mirror of
https://github.com/koreanbots/core.git
synced 2025-12-13 05:10:24 +00:00
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
This commit is contained in:
parent
e99e661b8d
commit
160fe4ecb3
5
.gitignore
vendored
5
.gitignore
vendored
@ -43,4 +43,7 @@ yarn-error.log*
|
||||
package-lock.json
|
||||
|
||||
# sub module
|
||||
api-docs/
|
||||
api-docs/
|
||||
|
||||
# Firebase
|
||||
service-account.json
|
||||
165
components/FCM.tsx
Normal file
165
components/FCM.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import Fetch from '@utils/Fetch'
|
||||
import { initializeApp } from 'firebase/app'
|
||||
import { getMessaging, getToken as getFirebaseToken } from 'firebase/messaging'
|
||||
import { useState } from 'react'
|
||||
import Button from './Button'
|
||||
|
||||
export async function getFCMToken() {
|
||||
try {
|
||||
const app = initializeApp({
|
||||
apiKey: 'AIzaSyDWnwXCBaP1C627gfIBQxyZbmNnAU_b_1Q',
|
||||
authDomain: 'koreanlist-e95d9.firebaseapp.com',
|
||||
projectId: 'koreanlist-e95d9',
|
||||
storageBucket: 'koreanlist-e95d9.firebasestorage.app',
|
||||
messagingSenderId: '716438516411',
|
||||
appId: '1:716438516411:web:cddd6c7cc3b0571fa4af9e',
|
||||
})
|
||||
|
||||
const worker = await navigator.serviceWorker.register('/vote-notification-sw.js')
|
||||
|
||||
const messaging = getMessaging(app)
|
||||
const token = await getFirebaseToken(messaging, {
|
||||
vapidKey: process.env.NEXT_PUBLIC_FCM_VAPID_KEY,
|
||||
serviceWorkerRegistration: worker,
|
||||
})
|
||||
return token
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function SetNotification({ id, notificationSet }: { id: string; notificationSet: boolean }) {
|
||||
const [state, setState] = useState(notificationSet ? 1 : 0)
|
||||
const [hold, setHold] = useState(false)
|
||||
|
||||
const getToken = async () => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
setState(4)
|
||||
return 'NO_SERVICE_WORKER'
|
||||
}
|
||||
|
||||
if (!('Notification' in window)) {
|
||||
setState(4)
|
||||
return 'NO_NOTIFICATION'
|
||||
}
|
||||
|
||||
const p = await Notification.requestPermission()
|
||||
if (p !== 'granted') {
|
||||
setState(5)
|
||||
return 'PERMISSION_DENIED'
|
||||
}
|
||||
|
||||
const token = await getFCMToken()
|
||||
|
||||
if (!token) {
|
||||
setState(4)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await Fetch('/users/notification', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
targetId: id,
|
||||
}),
|
||||
})
|
||||
|
||||
if (result.code === 200) {
|
||||
setState(2)
|
||||
} else {
|
||||
setState(4)
|
||||
}
|
||||
}
|
||||
const components = {
|
||||
0: (
|
||||
<>
|
||||
<p className='whitespace-pre-line text-lg font-normal'>
|
||||
12시간 후에 이 기기로 알림을 받으려면 아래 버튼을 눌러주세요.
|
||||
</p>
|
||||
<Button
|
||||
disabled={hold}
|
||||
onClick={() => {
|
||||
setHold(true)
|
||||
getToken()
|
||||
.then(() => {
|
||||
setHold(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setState(4)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<i className='far fa-bell' /> {hold ? '설정 중...' : '알림 설정'}
|
||||
</>
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
1: (
|
||||
<>
|
||||
<p className='whitespace-pre-line text-lg font-normal'>
|
||||
이 기기로 알림을 수신하고 있습니다. 알림을 해제하려면 아래 버튼을 눌러주세요.
|
||||
</p>
|
||||
<Button
|
||||
disabled={hold}
|
||||
onClick={() => {
|
||||
setHold(true)
|
||||
getFCMToken()
|
||||
.then(async (token) => {
|
||||
await Fetch('/users/notification', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
targetId: id,
|
||||
}),
|
||||
})
|
||||
setHold(false)
|
||||
setState(3)
|
||||
})
|
||||
.catch(() => {
|
||||
setState(4)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<i className='far fa-bell-slash mr-1' /> {hold ? '설정 중...' : '알림 해제'}
|
||||
</>
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
2: (
|
||||
<>
|
||||
<p className='whitespace-pre-line text-lg font-normal'>알림이 설정되었습니다.</p>
|
||||
</>
|
||||
),
|
||||
3: (
|
||||
<>
|
||||
<p className='whitespace-pre-line text-lg font-normal'>알림이 해제되었습니다.</p>
|
||||
</>
|
||||
),
|
||||
4: (
|
||||
<>
|
||||
<p className='whitespace-pre-line text-lg font-normal'>
|
||||
알림을 설정할 수 없습니다. 사용하는 브라우저를 점검해주세요. {'\n'}
|
||||
iOS 사용자는 Safari 브라우저에서 한국 디스코드 리스트를 홈 화면에 추가해야 합니다.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
5: (
|
||||
<>
|
||||
<p className='whitespace-pre-line text-lg font-normal'>
|
||||
알림이 허용되지 않았습니다. 브라우저 설정에서 알림을 허용해주세요.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
}
|
||||
return (
|
||||
components[state] ?? (
|
||||
<p className='whitespace-pre-line text-lg font-normal'>
|
||||
알림을 설정할 수 없습니다. 사용하는 브라우저를 점검해주세요.
|
||||
</p>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default SetNotification
|
||||
@ -34,6 +34,8 @@
|
||||
"emoji-mart": "3.0.1",
|
||||
"erlpack": "0.1.4",
|
||||
"express-rate-limit": "^5.3.0",
|
||||
"firebase": "^11.2.0",
|
||||
"firebase-admin": "^13.0.2",
|
||||
"formik": "2.4.2",
|
||||
"generate-license-file": "1.1.0",
|
||||
"josa": "3.0.1",
|
||||
|
||||
@ -41,6 +41,15 @@ const BotVote = RequestHandler()
|
||||
if (!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
|
||||
|
||||
const vote = await put.voteBot(user, bot.id)
|
||||
|
||||
const token = req.body.firebaseToken
|
||||
let notificationSet = false
|
||||
|
||||
if (token) {
|
||||
const noti = await get.notifications.token(token, bot.id)
|
||||
notificationSet = !!noti
|
||||
}
|
||||
|
||||
if (vote === null) return ResponseWrapper(res, { code: 401 })
|
||||
else if (vote === true) {
|
||||
get.bot.clear(req.query.id)
|
||||
@ -55,8 +64,8 @@ const BotVote = RequestHandler()
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
return ResponseWrapper(res, { code: 200 })
|
||||
} else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote } })
|
||||
return ResponseWrapper(res, { code: 200, data: { notificationSet } })
|
||||
} else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote, notificationSet } })
|
||||
})
|
||||
|
||||
interface ApiRequest extends NextApiRequest {
|
||||
@ -75,6 +84,7 @@ interface PostApiRequest extends ApiRequest {
|
||||
body: {
|
||||
_captcha: string
|
||||
_csrf: string
|
||||
firebaseToken?: string | null
|
||||
}
|
||||
}
|
||||
export default BotVote
|
||||
|
||||
@ -41,6 +41,15 @@ const ServerVote = RequestHandler()
|
||||
if (!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
|
||||
|
||||
const vote = await put.voteServer(user, server.id)
|
||||
|
||||
const token = req.body.firebaseToken
|
||||
let notificationSet = false
|
||||
|
||||
if (token) {
|
||||
const result = await get.notifications.token(token, server.id)
|
||||
notificationSet = !!result
|
||||
}
|
||||
|
||||
if (vote === null) return ResponseWrapper(res, { code: 401 })
|
||||
else if (vote === true) {
|
||||
get.server.clear(req.query.id)
|
||||
@ -55,8 +64,8 @@ const ServerVote = RequestHandler()
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
return ResponseWrapper(res, { code: 200 })
|
||||
} else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote } })
|
||||
return ResponseWrapper(res, { code: 200, data: { notificationSet } })
|
||||
} else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote, notificationSet } })
|
||||
})
|
||||
|
||||
interface ApiRequest extends NextApiRequest {
|
||||
@ -75,6 +84,7 @@ interface PostApiRequest extends ApiRequest {
|
||||
body: {
|
||||
_captcha: string
|
||||
_csrf: string
|
||||
firebaseToken?: string | null
|
||||
}
|
||||
}
|
||||
export default ServerVote
|
||||
|
||||
49
pages/api/v2/users/notification.ts
Normal file
49
pages/api/v2/users/notification.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { addNotification, get } from '@utils/Query'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
|
||||
const Notification = RequestHandler()
|
||||
.get(async (req, res) => {
|
||||
const token = req.query.token as string
|
||||
const target = req.query.target as string
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if (!user) return res.status(401).json({ code: 401 })
|
||||
|
||||
const result = token
|
||||
? await get.notifications.token(token, target)
|
||||
: await get.notifications.user(user)
|
||||
|
||||
if (!result) return res.status(400).json({ code: 400 })
|
||||
|
||||
return res.status(200).json({ code: 200, data: result })
|
||||
})
|
||||
.post(async (req, res) => {
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if (!user) return res.status(401).json({ code: 401 })
|
||||
|
||||
const { token, targetId } = req.body
|
||||
|
||||
if (!token || !targetId)
|
||||
return res.status(400).json({ code: 400, message: 'Either token or targetId is missing' })
|
||||
|
||||
const result = await addNotification({ token, targetId, userId: user })
|
||||
if (typeof result === 'string') return res.status(400).json({ code: 400, message: result })
|
||||
|
||||
return res.status(200).json({ code: 200 })
|
||||
})
|
||||
.delete(async (req, res) => {
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
|
||||
if (!user) return res.status(401).json({ code: 401 })
|
||||
|
||||
const { token, targetId } = req.body
|
||||
|
||||
if (!token) return res.status(400).json({ code: 400 })
|
||||
|
||||
const result = global.notification.removeNotification({ userId: user, targetId, token })
|
||||
|
||||
if (!result) return res.status(400).json({ code: 400 })
|
||||
|
||||
return res.status(200).json({ code: 200 })
|
||||
})
|
||||
|
||||
export default Notification
|
||||
@ -12,12 +12,13 @@ import { ParsedUrlQuery } from 'querystring'
|
||||
import NotFound from 'pages/404'
|
||||
import { getToken } from '@utils/Csrf'
|
||||
import Captcha from '@components/Captcha'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Fetch from '@utils/Fetch'
|
||||
import Day from '@utils/Day'
|
||||
import { getJosaPicker } from 'josa'
|
||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import SetNotification, { getFCMToken } from '@components/FCM'
|
||||
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
|
||||
@ -30,7 +31,18 @@ const Message = dynamic(() => import('@components/Message'))
|
||||
|
||||
const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
|
||||
const [votingStatus, setVotingStatus] = useState(0)
|
||||
const [result, setResult] = useState<ResponseProps<{ retryAfter?: number }>>(null)
|
||||
const [result, setResult] =
|
||||
useState<ResponseProps<{ retryAfter?: number; notificationSet: boolean }>>(null)
|
||||
const fcmTokenRef = useRef<string | null>('')
|
||||
|
||||
useEffect(() => {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
getFCMToken().then((token) => {
|
||||
fcmTokenRef.current = token
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const router = useRouter()
|
||||
if (!data?.id) return <NotFound />
|
||||
if (!user)
|
||||
@ -116,11 +128,15 @@ const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
|
||||
<Captcha
|
||||
dark={theme === 'dark'}
|
||||
onVerify={async (key) => {
|
||||
const res = await Fetch<{ retryAfter: number } | unknown>(
|
||||
const res = await Fetch<{ retryAfter: number; notificationSet: boolean }>(
|
||||
`/bots/${data.id}/vote`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ _csrf: csrfToken, _captcha: key }),
|
||||
body: JSON.stringify({
|
||||
_csrf: csrfToken,
|
||||
_captcha: key,
|
||||
firebaseToken: fcmTokenRef.current,
|
||||
}),
|
||||
}
|
||||
)
|
||||
setResult(res)
|
||||
@ -128,7 +144,10 @@ const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
|
||||
}}
|
||||
/>
|
||||
) : result.code === 200 ? (
|
||||
<h2 className='text-2xl font-bold'>해당 봇에 투표했습니다!</h2>
|
||||
<>
|
||||
<h2 className='text-2xl font-bold'>해당 봇에 투표했습니다!</h2>
|
||||
<SetNotification id={data.id} notificationSet={result.data.notificationSet} />
|
||||
</>
|
||||
) : result.code === 429 ? (
|
||||
<>
|
||||
<h2 className='text-2xl font-bold'>이미 해당 봇에 투표하였습니다.</h2>
|
||||
@ -136,6 +155,7 @@ const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
|
||||
{Day(+new Date() + result.data?.retryAfter).fromNow()} 다시 투표하실 수
|
||||
있습니다.
|
||||
</h4>
|
||||
<SetNotification id={data.id} notificationSet={result.data.notificationSet} />
|
||||
</>
|
||||
) : (
|
||||
<p>{result.message}</p>
|
||||
|
||||
@ -12,12 +12,13 @@ import { ParsedUrlQuery } from 'querystring'
|
||||
import NotFound from 'pages/404'
|
||||
import { getToken } from '@utils/Csrf'
|
||||
import Captcha from '@components/Captcha'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Fetch from '@utils/Fetch'
|
||||
import Day from '@utils/Day'
|
||||
import { getJosaPicker } from 'josa'
|
||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import SetNotification, { getFCMToken } from '@components/FCM'
|
||||
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
|
||||
@ -30,7 +31,19 @@ const Message = dynamic(() => import('@components/Message'))
|
||||
|
||||
const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken }) => {
|
||||
const [votingStatus, setVotingStatus] = useState(0)
|
||||
const [result, setResult] = useState<ResponseProps<{ retryAfter?: number }>>(null)
|
||||
const [result, setResult] =
|
||||
useState<ResponseProps<{ retryAfter?: number; notificationSet: boolean }>>(null)
|
||||
|
||||
const fcmTokenRef = useRef<string | null>('')
|
||||
|
||||
useEffect(() => {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
getFCMToken().then((token) => {
|
||||
fcmTokenRef.current = token
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const router = useRouter()
|
||||
if (!data?.id) return <NotFound />
|
||||
if (!user)
|
||||
@ -90,7 +103,11 @@ const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken })
|
||||
</Link>
|
||||
<Segment className='mb-16 py-8'>
|
||||
<div className='text-center'>
|
||||
<ServerIcon id={data.id} className='mx-auto mb-4 h-52 w-52 rounded-full bg-white' hash={data.icon} />
|
||||
<ServerIcon
|
||||
id={data.id}
|
||||
className='mx-auto mb-4 h-52 w-52 rounded-full bg-white'
|
||||
hash={data.icon}
|
||||
/>
|
||||
<Tag
|
||||
text={
|
||||
<span>
|
||||
@ -112,11 +129,15 @@ const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken })
|
||||
<Captcha
|
||||
dark={theme === 'dark'}
|
||||
onVerify={async (key) => {
|
||||
const res = await Fetch<{ retryAfter: number } | unknown>(
|
||||
const res = await Fetch<{ retryAfter: number; notificationSet: boolean }>(
|
||||
`/servers/${data.id}/vote`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ _csrf: csrfToken, _captcha: key }),
|
||||
body: JSON.stringify({
|
||||
_csrf: csrfToken,
|
||||
_captcha: key,
|
||||
firebaseToken: fcmTokenRef.current,
|
||||
}),
|
||||
}
|
||||
)
|
||||
setResult(res)
|
||||
@ -124,7 +145,10 @@ const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken })
|
||||
}}
|
||||
/>
|
||||
) : result.code === 200 ? (
|
||||
<h2 className='text-2xl font-bold'>해당 서버에 투표했습니다!</h2>
|
||||
<>
|
||||
<h2 className='text-2xl font-bold'>해당 서버에 투표했습니다!</h2>
|
||||
<SetNotification id={data.id} notificationSet={result.data.notificationSet} />
|
||||
</>
|
||||
) : result.code === 429 ? (
|
||||
<>
|
||||
<h2 className='text-2xl font-bold'>이미 해당 서버에 투표하였습니다.</h2>
|
||||
@ -132,6 +156,7 @@ const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken })
|
||||
{Day(+new Date() + result.data?.retryAfter).fromNow()} 다시 투표하실 수
|
||||
있습니다.
|
||||
</h4>
|
||||
<SetNotification id={data.id} notificationSet={result.data.notificationSet} />
|
||||
</>
|
||||
) : (
|
||||
<p>{result.message}</p>
|
||||
|
||||
41
public/vote-notification-sw.js
Normal file
41
public/vote-notification-sw.js
Normal file
@ -0,0 +1,41 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
importScripts('https://www.gstatic.com/firebasejs/8.10.0/firebase-app.js')
|
||||
importScripts('https://www.gstatic.com/firebasejs/8.10.0/firebase-messaging.js')
|
||||
|
||||
firebase.initializeApp({
|
||||
apiKey: 'AIzaSyDWnwXCBaP1C627gfIBQxyZbmNnAU_b_1Q',
|
||||
authDomain: 'koreanlist-e95d9.firebaseapp.com',
|
||||
projectId: 'koreanlist-e95d9',
|
||||
storageBucket: 'koreanlist-e95d9.firebasestorage.app',
|
||||
messagingSenderId: '716438516411',
|
||||
appId: '1:716438516411:web:cddd6c7cc3b0571fa4af9e',
|
||||
})
|
||||
|
||||
const messaging = firebase.messaging()
|
||||
|
||||
/*
|
||||
{
|
||||
type: 'vote-available',
|
||||
name: target.name,
|
||||
imageUrl: image ?? undefined,
|
||||
url: `/${isBot ? 'bots' : 'servers'}/${noti.target_id}`,
|
||||
}
|
||||
*/
|
||||
|
||||
messaging.onBackgroundMessage(function (payload) {
|
||||
if (payload.data.type !== 'vote-available') return
|
||||
const notificationTitle = '투표 알림'
|
||||
const notificationOptions = {
|
||||
body: `${payload.data.name}의 투표가 가능합니다.`,
|
||||
icon: payload.data.imageUrl,
|
||||
}
|
||||
notificationOptions.data = payload.data
|
||||
|
||||
self.addEventListener('notificationclick', function (event) {
|
||||
event.notification.close()
|
||||
clients.openWindow(event.notification.data.url)
|
||||
})
|
||||
|
||||
self.registration.showNotification(notificationTitle, notificationOptions)
|
||||
})
|
||||
8
types/global.d.ts
vendored
8
types/global.d.ts
vendored
@ -1,4 +1,7 @@
|
||||
/* eslint-disable no-var */
|
||||
import * as Yup from 'yup'
|
||||
import { Client } from 'discord.js'
|
||||
import NotificationManager from '@utils/NotificationManager'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -8,6 +11,9 @@ declare global {
|
||||
highlightBlock(e: Element): void
|
||||
}
|
||||
}
|
||||
var kodl: Client
|
||||
var serverlist: Client
|
||||
var notification: NotificationManager
|
||||
interface Navigator {
|
||||
standalone?: boolean
|
||||
}
|
||||
@ -21,3 +27,5 @@ declare module 'yup' {
|
||||
declare module 'difflib' {
|
||||
export function unifiedDiff(before: string, after: string): string[]
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
@ -1,13 +1,33 @@
|
||||
import * as Discord from 'discord.js'
|
||||
import NotificationManager from './NotificationManager'
|
||||
|
||||
export const DiscordBot = new Discord.Client({
|
||||
intents: Number(process.env.DISCORD_CLIENT_INTENTS ?? 32767),
|
||||
})
|
||||
if (!global.kodl) {
|
||||
global.kodl = new Discord.Client({
|
||||
intents: Number(process.env.DISCORD_CLIENT_INTENTS ?? 32767),
|
||||
})
|
||||
global.serverlist = new Discord.Client({
|
||||
intents: [],
|
||||
})
|
||||
|
||||
export const ServerListDiscordBot = new Discord.Client({
|
||||
intents: [],
|
||||
})
|
||||
console.log('Discord Client is initializing')
|
||||
|
||||
global.kodl.on('ready', async () => {
|
||||
console.log('Discord Client is ready')
|
||||
await getMainGuild().members.fetch()
|
||||
console.log(`Fetched ${getMainGuild().members.cache.size} Members`)
|
||||
})
|
||||
|
||||
global.kodl.login(process.env.DISCORD_TOKEN ?? '')
|
||||
global.serverlist.login(process.env.DISCORD_SERVERLIST_TOKEN)
|
||||
}
|
||||
|
||||
if (!global.notification) {
|
||||
global.notification = new NotificationManager()
|
||||
}
|
||||
|
||||
export const DiscordBot = global.kodl as Discord.Client
|
||||
|
||||
export const ServerListDiscordBot = global.serverlist as Discord.Client
|
||||
const dummyURL =
|
||||
'https://discord.com/api/webhooks/123123123123123123/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdf'
|
||||
|
||||
@ -40,15 +60,6 @@ export const webhookClients = {
|
||||
},
|
||||
}
|
||||
|
||||
DiscordBot.on('ready', async () => {
|
||||
console.log('Discord Client is ready')
|
||||
await getMainGuild().members.fetch()
|
||||
console.log(`Fetched ${getMainGuild().members.cache.size} Members`)
|
||||
})
|
||||
|
||||
DiscordBot.login(process.env.DISCORD_TOKEN ?? '')
|
||||
ServerListDiscordBot.login(process.env.DISCORD_SERVERLIST_TOKEN)
|
||||
|
||||
export const getMainGuild = () => DiscordBot.guilds.cache.get(process.env.GUILD_ID ?? '')
|
||||
|
||||
export const discordLog = async (
|
||||
|
||||
114
utils/NotificationManager.ts
Normal file
114
utils/NotificationManager.ts
Normal file
@ -0,0 +1,114 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
182
utils/Query.ts
182
utils/Query.ts
@ -39,6 +39,7 @@ import { sign, verify } from './Jwt'
|
||||
import { areArraysEqual, camoUrl, formData, getYYMMDD, serialize } from './Tools'
|
||||
import { AddBotSubmit, AddServerSubmit, ManageBot, ManageServer } from './Yup'
|
||||
import { markdownImage } from './Regex'
|
||||
import { Notification } from './NotificationManager'
|
||||
|
||||
export const imageRateLimit = new TLRU<unknown, number>({ maxAgeMs: 60000 })
|
||||
|
||||
@ -76,7 +77,7 @@ async function getBot(id: string, topLevel = true): Promise<Bot> {
|
||||
|
||||
if (res) {
|
||||
const discordBot = await get.discord.user.load(res.id)
|
||||
if(!discordBot) {
|
||||
if (!discordBot) {
|
||||
return null
|
||||
}
|
||||
if (Number(discordBot.discriminator) === 0) {
|
||||
@ -528,18 +529,32 @@ async function getWebhook(id: string, type: 'bots' | 'servers'): Promise<Webhook
|
||||
}
|
||||
|
||||
async function voteBot(userID: string, botID: string): Promise<number | boolean> {
|
||||
const user = await knex('users').select(['votes']).where({ id: userID })
|
||||
const key = `bot:${botID}`
|
||||
if (user.length === 0) return null
|
||||
const date = +new Date()
|
||||
const data = JSON.parse(user[0].votes)
|
||||
const lastDate = data[key] || 0
|
||||
if (date - lastDate < VOTE_COOLDOWN) return VOTE_COOLDOWN - (date - lastDate)
|
||||
data[key] = date
|
||||
const [vote] = await knex('votes').select('*').where({ user_id: userID, target: botID })
|
||||
const date = new Date()
|
||||
if (vote) {
|
||||
const lastDate = vote.last_voted.getTime() || 0
|
||||
if (date.getTime() - lastDate < VOTE_COOLDOWN)
|
||||
return VOTE_COOLDOWN - (date.getTime() - lastDate)
|
||||
}
|
||||
|
||||
await knex('bots').where({ id: botID }).increment('votes', 1)
|
||||
await knex('users')
|
||||
.where({ id: userID })
|
||||
.update({ votes: JSON.stringify(data) })
|
||||
|
||||
const votes = await knex('votes')
|
||||
.select('id')
|
||||
.where({ user_id: userID, target: botID, type: ObjectType.Bot })
|
||||
if (votes.length === 0) {
|
||||
await knex('votes').insert({
|
||||
user_id: userID,
|
||||
target: botID,
|
||||
type: ObjectType.Bot,
|
||||
last_voted: date,
|
||||
})
|
||||
} else {
|
||||
await knex('votes').where({ id: votes[0].id }).update({ last_voted: date })
|
||||
}
|
||||
|
||||
global.notification.refresh(userID, botID)
|
||||
|
||||
const record = await Bots.updateOne(
|
||||
{ _id: botID, 'voteMetrix.day': getYYMMDD() },
|
||||
{ $inc: { 'voteMetrix.$.increasement': 1, 'voteMetrix.$.count': 1 } }
|
||||
@ -554,18 +569,22 @@ async function voteBot(userID: string, botID: string): Promise<number | boolean>
|
||||
}
|
||||
|
||||
async function voteServer(userID: string, serverID: string): Promise<number | boolean> {
|
||||
const user = await knex('users').select(['votes']).where({ id: userID })
|
||||
const key = `server:${serverID}`
|
||||
if (user.length === 0) return null
|
||||
const date = +new Date()
|
||||
const data = JSON.parse(user[0].votes)
|
||||
const lastDate = data[key] || 0
|
||||
if (date - lastDate < VOTE_COOLDOWN) return VOTE_COOLDOWN - (date - lastDate)
|
||||
data[key] = date
|
||||
const [vote] = await knex('votes').select('*').where({ user_id: userID, target: serverID })
|
||||
const date = new Date()
|
||||
if (vote) {
|
||||
const lastDate = vote.last_voted.getTime() || 0
|
||||
if (date.getTime() - lastDate < VOTE_COOLDOWN)
|
||||
return VOTE_COOLDOWN - (date.getTime() - lastDate)
|
||||
}
|
||||
|
||||
await knex('servers').where({ id: serverID }).increment('votes', 1)
|
||||
await knex('users')
|
||||
.where({ id: userID })
|
||||
.update({ votes: JSON.stringify(data) })
|
||||
await knex('votes')
|
||||
.insert({ user_id: userID, target: serverID, type: ObjectType.Server, last_voted: date })
|
||||
.onConflict(['user_id', 'target', 'type'])
|
||||
.merge({ last_voted: date })
|
||||
|
||||
global.notification.refresh(userID, serverID)
|
||||
|
||||
// const record = await Servers.updateOne({ _id: serverID, 'voteMetrix.day': getYYMMDD() }, { $inc: { 'voteMetrix.$.increasement': 1, 'voteMetrix.$.count': 1 } })
|
||||
// if(record.n === 0) await Servers.findByIdAndUpdate(serverID, { $push: { voteMetrix: { count: (await knex('servers').where({ id: serverID }))[0].votes } } }, { upsert: true })
|
||||
return true
|
||||
@ -947,6 +966,119 @@ export async function CaptchaVerify(response: string): Promise<boolean> {
|
||||
return res.success
|
||||
}
|
||||
|
||||
// FCM
|
||||
|
||||
export async function addNotification({
|
||||
token,
|
||||
userId,
|
||||
targetId,
|
||||
}: {
|
||||
token: string
|
||||
userId: string
|
||||
targetId: string
|
||||
}) {
|
||||
const [vote] = await knex('votes').select('id').where({ user_id: userId, target: targetId })
|
||||
|
||||
if (!vote) {
|
||||
return 'Vote does not exist'
|
||||
}
|
||||
|
||||
const voteId = vote.id
|
||||
|
||||
const { length } = await knex('notifications').select('vote_id').where({ token, vote_id: voteId })
|
||||
|
||||
if (length === 0) {
|
||||
await knex('notifications').insert({ token, vote_id: voteId })
|
||||
}
|
||||
|
||||
console.log('Notification added to database', token, userId, targetId)
|
||||
|
||||
global.notification.setNotification(token, userId, targetId)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export async function removeNotification({
|
||||
token,
|
||||
targetId,
|
||||
}: {
|
||||
token: string
|
||||
targetId: string | null
|
||||
}) {
|
||||
if (targetId === null) {
|
||||
await knex('notifications').delete().where({ token })
|
||||
return true
|
||||
}
|
||||
|
||||
await knex('notifications')
|
||||
.delete()
|
||||
.leftJoin('votes', 'votes.id', 'notifications.vote_id')
|
||||
.where({
|
||||
'notifications.token': token,
|
||||
'votes.target': targetId,
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* We consider that multiple devices could listen to the same topic.
|
||||
* @param userId
|
||||
* @param targetId
|
||||
*/
|
||||
export async function getNotifications(userId?: string, targetId?: string) {
|
||||
const q = knex('notifications')
|
||||
.select([
|
||||
'notifications.*',
|
||||
'votes.user_id as user_id',
|
||||
'votes.target as target_id',
|
||||
'votes.type as type',
|
||||
'votes.last_voted as last_voted',
|
||||
])
|
||||
.leftJoin('votes', 'votes.id', 'notifications.vote_id')
|
||||
|
||||
if (userId) q.where('votes.user_id', userId)
|
||||
if (targetId) q.where('votes.target', targetId)
|
||||
|
||||
const res = await q
|
||||
|
||||
return res as Notification[]
|
||||
}
|
||||
|
||||
export async function getNotificationsByToken(token: string, targetId?: string) {
|
||||
const q = knex('notifications')
|
||||
.select([
|
||||
'notifications.*',
|
||||
'votes.user_id as user_id',
|
||||
'votes.target as target_id',
|
||||
'votes.type as type',
|
||||
'votes.last_voted as last_voted',
|
||||
])
|
||||
.leftJoin('votes', 'votes.id', 'notifications.vote_id')
|
||||
.where('notifications.token', token)
|
||||
|
||||
if (targetId) q.where('votes.target', targetId)
|
||||
|
||||
const [res] = await q
|
||||
|
||||
return res as Notification
|
||||
}
|
||||
|
||||
export async function getNotificationsByUserId(userId: string) {
|
||||
const res = await knex('notifications')
|
||||
.select([
|
||||
'notifications.*',
|
||||
'votes.user_id as user_id',
|
||||
'votes.target as target_id',
|
||||
'votes.type as type',
|
||||
'votes.last_voted as last_voted',
|
||||
])
|
||||
.leftJoin('votes', 'votes.id', 'notifications.vote_id')
|
||||
.where('votes.user_id', userId)
|
||||
|
||||
return res as Notification[]
|
||||
}
|
||||
|
||||
// Private APIs
|
||||
|
||||
async function getBotSubmitList() {
|
||||
@ -1257,6 +1389,10 @@ export const get = {
|
||||
botSubmitHistory: getBotSubmitHistory,
|
||||
botSubmitStrikes: getBotSubmitStrikes,
|
||||
serverOwners: fetchServerOwners,
|
||||
notifications: {
|
||||
user: getNotificationsByUserId,
|
||||
token: getNotificationsByToken,
|
||||
},
|
||||
}
|
||||
|
||||
export const update = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user