core/utils/NotificationManager.ts
SKINMAKER 160fe4ecb3
feat: 투표 알림 기능 (#669)
* feat: ensure djs clients are singleton instances

* feat: add votes table

* feat: vote notification

* chore: reduce vote cooldown to 15 min

* feat: add SetNotification to server

* chore: add debug logs

* fix: do not add notification when token and voteid already exists

* feat: add loading indicator

* feat: refresh notification when voted

* feat: add opt-out

* feat: add debug log

* fix: initialize firebase app

* fix: remove app on messaging

* feat: show notifications only with service worker

* fix: state improperly used

* fix: schedule notification if notification is newly added

* chore:  remove duplicated notification

* chore: add spacing

* chore: get token if notification is granted

* chore: change vote cooldown to 12 hours

* chore: remove logging
2025-02-17 07:34:12 +09:00

115 lines
2.9 KiB
TypeScript

import { initializeApp } from 'firebase-admin/app'
import { KoreanbotsEndPoints, VOTE_COOLDOWN } from './Constants'
import { get, getNotifications, removeNotification as removeNotificationData } from './Query'
import { ObjectType } from '@types'
import { messaging } from 'firebase-admin'
export type Notification = {
vote_id: number
token: string
user_id: string
target_id: string
type: ObjectType
last_voted: Date
}
export default class NotificationManager {
private timeouts: Record<`${string}:${string}:${string}`, NodeJS.Timeout> = {}
private firebaseApp: ReturnType<typeof initializeApp>
public constructor() {
this.initVotes()
this.firebaseApp = initializeApp()
console.log('NotificationManager initialized')
}
public async setNotification(token: string, userId: string, targetId: string) {
const noti = await get.notifications.token(token, targetId)
if (!noti) return false
await this.scheduleNotification(noti)
}
/**
* This is a service. This removes the timeout and the notification data.
*/
public async removeNotification({
userId,
targetId,
token,
}: {
userId: string
targetId: string
token: string
}): ReturnType<typeof removeNotificationData> {
clearTimeout(this.timeouts[`${userId}:${targetId}:${token}`])
return await removeNotificationData({ targetId, token })
}
public async scheduleNotification(noti: Notification) {
const time = noti.last_voted.getTime() + VOTE_COOLDOWN + 1000 * 60 - Date.now()
if (time <= 0) {
return
}
const key = `${noti.user_id}:${noti.target_id}:${noti.token}`
this.timeouts[key] = setTimeout(() => {
this.pushNotification(noti)
clearTimeout(this.timeouts[key])
}, time)
}
public async refresh(userId: string, targetId: string) {
const notifications = await getNotifications(userId, targetId)
for (const noti of notifications) {
clearTimeout(this.timeouts[`${userId}:${targetId}:${noti.token}`])
this.scheduleNotification(noti)
}
}
public async initVotes() {
const res = await getNotifications()
for (const noti of res) {
this.scheduleNotification(noti)
}
}
private async pushNotification(noti: Notification) {
const isBot = noti.type === ObjectType.Bot
const target = isBot
? await get.bot.load(noti.target_id)
: await get.server.load(noti.target_id)
const image =
process.env.KOREANBOTS_URL +
('avatar' in target
? KoreanbotsEndPoints.CDN.avatar(target.id, { size: 256 })
: KoreanbotsEndPoints.CDN.icon(target.id, { size: 256 }))
await messaging()
.send({
token: noti.token,
data: {
type: 'vote-available',
name: target.name,
imageUrl: image ?? undefined,
url: `/${isBot ? 'bots' : 'servers'}/${noti.target_id}`,
},
})
.catch((e) => {
if ('code' in e) {
if (e.code === 'messaging/registration-token-not-registered') {
this.removeNotification({
userId: noti.user_id,
token: noti.token,
targetId: null,
})
}
}
})
}
}