mirror of
https://github.com/koreanbots/core.git
synced 2025-12-15 06:10:22 +00:00
feat: support webhook (#514)
* feat: support webhook * feat: bot webhook * types: add enums for webhook * feat: update webhook status by param * feat: send webhook of server count change * feat: send webhook of vote * chore: add desc of faulty webhook * chore: set initial value * feat: add collection of clients * chore: simplify * feat: set webhook status dynamically * feat: webhook for discord * refactor: rename WebhookStatus.Paused * refactor: make webhookClients to one object * feat: webhook with fetch * feat: add warning prop to input component * feat: display red when warning * feat: check server count properly * feat: handle status codes * refactor: remove double fetch * chore: typo * feat: send failed message * fix: missing id on query * feat: limit response body * feat: use severlist bot to dm * feat: webhook for servers * feat: use env for ids * refactor: remove variables * fix: send discord log * fix: message * feat: include koreanbots in footer * fix: typo * refactor: export function as non default * feat: add verification * feat: add columns * feat: verify bot webhook * feat: verify server webhook * chore: rename key to secret * fix: stringify * chore: remove webhook related columns * refactor: use separate object for webhook * type: add webhook prop to bot / server * fix: implement webhook status * refactor: rename webhook to webhookURL * feat: select webhook props * feat: remove bot's private props * feat: remove server private fields * chore: use makeURLs * type: fix faildSince is type as string * refactor: rename to updateWebhook * chore: make props optional * feat: failedSince * feat: remove failedSince when success * fix: missing import * fix: typo * fix: convert missing prop * fix: typo * chore: remove unnecessary select * fix: missing systax * feat: sort docs * feat: use relay * fix: check status properly * chore: handle relay server error * remove awaits * fix: add base url * fix: typo * chore: remove red highlights * chore: change emoji --------- Co-authored-by: SKINMAKER <skinmaker@SKINMAKERs-iMac.local> Co-authored-by: skinmaker1345 <me@skinmaker.dev>
This commit is contained in:
parent
7121097ed2
commit
e8075ee7d5
@ -15,6 +15,14 @@ DISCORD_SCOPE=SCOPE
|
||||
DISCORD_TOKEN=BOT_TOKEN
|
||||
DISCORD_CLIENT_INTENTS=32767
|
||||
|
||||
GUILD_ID=653083797763522580
|
||||
REVIEW_GUILD_ID=906537041326637086
|
||||
REPORT_CHANNEL_ID=813255797823766568
|
||||
LOGGING_CHANNEL_ID=844006379823955978
|
||||
STATS_LOGGING_CHANNEL_ID=653227346962153472
|
||||
REVIEW_LOG_CHANNEL_ID=906551334063439902
|
||||
OPEN_REVIEW_LOG_CHANNEL_ID=1008376563731013643
|
||||
|
||||
GITHUB_CLIENT_ID=GH_CLIENT_ID
|
||||
GITHUB_CLIENT_SECRET=GH_CLIENT_SECRET
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ const Input: React.FC<InputProps> = ({ name, placeholder, ...props }) => {
|
||||
return <Field
|
||||
{...props}
|
||||
name={name}
|
||||
className='border-grey-light relative px-3 w-full h-10 text-black dark:text-white dark:bg-very-black border dark:border-transparent rounded outline-none'
|
||||
className={'border-grey-light relative px-3 w-full h-10 \'text-black dark:text-white\' dark:bg-very-black border dark:border-transparent rounded outline-none'}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
}
|
||||
@ -12,6 +12,7 @@ const Input: React.FC<InputProps> = ({ name, placeholder, ...props }) => {
|
||||
interface InputProps {
|
||||
name: string
|
||||
placeholder?: string
|
||||
warning?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import Tooltip from '@components/Tooltip'
|
||||
|
||||
const Label: React.FC<LabelProps> = ({
|
||||
For,
|
||||
children,
|
||||
@ -7,6 +9,8 @@ const Label: React.FC<LabelProps> = ({
|
||||
grid = true,
|
||||
short = false,
|
||||
required = false,
|
||||
warning = false,
|
||||
warningText
|
||||
}) => {
|
||||
return <label
|
||||
className={grid ? 'grid grid-cols-1 xl:grid-cols-4 gap-2 my-4' : 'inline-flex items-center'}
|
||||
@ -19,6 +23,11 @@ const Label: React.FC<LabelProps> = ({
|
||||
{required && (
|
||||
<span className='align-text-top text-red-500 text-base font-semibold'> *</span>
|
||||
)}
|
||||
{warning && (
|
||||
<Tooltip direction='left' text={warningText}>
|
||||
<span className='text-red-500 text-base font-semibold pl-1' role='img' aria-label='warning'>⚠️</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</h3>
|
||||
{labelDesc}
|
||||
</div>
|
||||
@ -39,6 +48,8 @@ interface LabelProps {
|
||||
grid?: boolean
|
||||
short?: boolean
|
||||
required?: boolean
|
||||
warning?: boolean
|
||||
warningText?: string | null
|
||||
}
|
||||
|
||||
export default Label
|
||||
|
||||
@ -12,6 +12,7 @@ import { User } from '@types'
|
||||
import { checkUserFlag, diff, inspect, makeDiscordCodeblock, objectDiff, serialize } from '@utils/Tools'
|
||||
import { discordLog, getBotReviewLogChannel, getMainGuild } from '@utils/DiscordBot'
|
||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||
import { verifyWebhook } from '@utils/Webhook'
|
||||
|
||||
const patchLimiter = rateLimit({
|
||||
windowMs: 2 * 60 * 1000,
|
||||
@ -27,7 +28,11 @@ const Bots = RequestHandler()
|
||||
.get(async (req: GetApiRequest, res) => {
|
||||
const bot = await get.bot.load(req.query.id)
|
||||
if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
|
||||
else return ResponseWrapper(res, { code: 200, data: bot })
|
||||
else {
|
||||
delete bot.webhookURL
|
||||
delete bot.webhookStatus
|
||||
return ResponseWrapper(res, { code: 200, data: bot })
|
||||
}
|
||||
})
|
||||
.post(async (req: PostApiRequest, res) => {
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
@ -153,14 +158,18 @@ const Bots = RequestHandler()
|
||||
})
|
||||
|
||||
if (!validated) return
|
||||
const result = await update.bot(req.query.id, validated)
|
||||
const key = await verifyWebhook(validated.webhookURL)
|
||||
if(key === false) {
|
||||
return ResponseWrapper(res, { code: 400, message: '웹후크 주소를 검증할 수 없습니다.', errors: ['웹후크 주소가 올바른지 확인해주세요.\n웹후크 주소 검증에 대한 자세한 내용은 API 문서를 참고해주세요.'] })
|
||||
}
|
||||
const result = await update.bot(req.query.id, validated, key)
|
||||
if(result === 0) return ResponseWrapper(res, { code: 400 })
|
||||
else {
|
||||
get.bot.clear(req.query.id)
|
||||
const embed = new EmbedBuilder().setDescription(`${bot.name} - <@${bot.id}> ([${bot.id}](${KoreanbotsEndPoints.URL.bot(bot.id)}))`)
|
||||
const diffData = objectDiff(
|
||||
{ prefix: bot.prefix, library: bot.lib, web: bot.web, git: bot.git, url: bot.url, discord: bot.discord, intro: bot.intro, category: JSON.stringify(bot.category) },
|
||||
{ prefix: validated.prefix, library: validated.library, web: validated.website, git: validated.git, url: validated.url, discord: validated.discord, intro: validated.intro, category: JSON.stringify(validated.category) }
|
||||
{ prefix: bot.prefix, library: bot.lib, web: bot.web, git: bot.git, url: bot.url, discord: bot.discord, webhook: bot.webhookURL, intro: bot.intro, category: JSON.stringify(bot.category) },
|
||||
{ prefix: validated.prefix, library: validated.library, web: validated.website, git: validated.git, url: validated.url, discord: validated.discord, webhook: validated.webhookURL, intro: validated.intro, category: JSON.stringify(validated.category) }
|
||||
)
|
||||
diffData.forEach(d => {
|
||||
embed.addFields({name: d[0], value: makeDiscordCodeblock(diff(d[1][0] || '', d[1][1] || ''), 'diff')
|
||||
|
||||
@ -9,7 +9,8 @@ import { BotStatUpdate, BotStatUpdateSchema } from '@utils/Yup'
|
||||
import { getStatsLoggingChannel } from '@utils/DiscordBot'
|
||||
import { checkUserFlag, makeDiscordCodeblock } from '@utils/Tools'
|
||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||
import type { User } from '@types'
|
||||
import { User, WebhookType } from '@types'
|
||||
import { sendWebhook } from '@utils/Webhook'
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 3 * 60 * 1000,
|
||||
@ -57,6 +58,17 @@ const BotStats = RequestHandler().post(limiter)
|
||||
const d = await update.updateServer(botInfo.id, validated.servers, validated.shards)
|
||||
if(d===1 || d===2) return ResponseWrapper(res, { code: 403, message: `서버 수를 ${[null, '1만', '100만'][d]} 이상으로 설정하실 수 없습니다. 문의해주세요.` })
|
||||
get.bot.clear(req.query.id)
|
||||
if (validated.servers && botInfo.servers !== validated.servers) {
|
||||
sendWebhook(botInfo, {
|
||||
type: 'bot',
|
||||
botId: botInfo.id,
|
||||
data: {
|
||||
type: WebhookType.ServerCountChange,
|
||||
before: botInfo.servers,
|
||||
after: validated.servers,
|
||||
}
|
||||
})
|
||||
}
|
||||
await getStatsLoggingChannel().send({
|
||||
content: `[BOT/STATS] <@${botInfo.id}> (${botInfo.id})\n${makeDiscordCodeblock(`${botInfo.servers > validated.servers ? '-' : '+'} ${botInfo.servers} -> ${validated.servers} (${botInfo.servers > validated.servers ? '▼' : '▲'}${Math.abs(validated.servers - botInfo.servers)})`, 'diff')}`,
|
||||
embeds: [new EmbedBuilder().setDescription(`${botInfo.name} - <@${botInfo.id}> ([${botInfo.id}](${KoreanbotsEndPoints.URL.bot(botInfo.id)}))`)]
|
||||
|
||||
@ -6,6 +6,8 @@ import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { checkToken } from '@utils/Csrf'
|
||||
import Yup from '@utils/Yup'
|
||||
import { VOTE_COOLDOWN } from '@utils/Constants'
|
||||
import { sendWebhook } from '@utils/Webhook'
|
||||
import { WebhookType } from '@types'
|
||||
|
||||
const BotVote = RequestHandler()
|
||||
.get(async (req: GetApiRequest, res) => {
|
||||
@ -32,7 +34,19 @@ const BotVote = RequestHandler()
|
||||
|
||||
const vote = await put.voteBot(user, bot.id)
|
||||
if(vote === null) return ResponseWrapper(res, { code: 401 })
|
||||
else if(vote === true) return ResponseWrapper(res, { code: 200 })
|
||||
else if(vote === true) {
|
||||
sendWebhook(bot, {
|
||||
type: 'bot',
|
||||
botId: bot.id,
|
||||
data: {
|
||||
type: WebhookType.HeartChange,
|
||||
before: bot.votes,
|
||||
after: bot.votes + 1,
|
||||
userId: user
|
||||
}
|
||||
})
|
||||
return ResponseWrapper(res, { code: 200 })
|
||||
}
|
||||
else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote } })
|
||||
})
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import RequestHandler from '@utils/RequestHandler'
|
||||
import { checkUserFlag, diff, inspect, makeDiscordCodeblock, objectDiff, serialize } from '@utils/Tools'
|
||||
import { DiscordBot, discordLog } from '@utils/DiscordBot'
|
||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||
import { verifyWebhook } from '@utils/Webhook'
|
||||
|
||||
const patchLimiter = rateLimit({
|
||||
windowMs: 2 * 60 * 1000,
|
||||
@ -25,7 +26,11 @@ const Servers = RequestHandler()
|
||||
.get(async (req: GetApiRequest, res) => {
|
||||
const server = await get.server.load(req.query.id)
|
||||
if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버 입니다.' })
|
||||
else return ResponseWrapper(res, { code: 200, data: server })
|
||||
else {
|
||||
delete server.webhookURL
|
||||
delete server.webhookStatus
|
||||
return ResponseWrapper(res, { code: 200, data: server })
|
||||
}
|
||||
})
|
||||
.post(async (req: PostApiRequest, res) => {
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
@ -132,7 +137,14 @@ const Servers = RequestHandler()
|
||||
if (!validated) return
|
||||
const invite = await DiscordBot.fetchInvite(validated.invite).catch(() => null)
|
||||
if(invite?.guild.id !== server.id || invite.expiresAt) return ResponseWrapper(res, { code: 400, message: '올바르지 않은 초대코드입니다.', errors: ['입력하신 초대코드가 올바르지 않습니다. 올바른 초대코드를 입력했는지 다시 한 번 확인해주세요.', '만료되지 않는 초대코드인지 확인해주세요.'] })
|
||||
const result = await update.server(req.query.id, validated)
|
||||
|
||||
const key = await verifyWebhook(validated.webhook)
|
||||
if(key === false) {
|
||||
return ResponseWrapper(res, { code: 400, message: '웹후크 주소를 검증할 수 없습니다.', errors: ['웹후크 주소가 올바른지 확인해주세요.\n웹후크 주소 검증에 대한 자세한 내용은 API 문서를 참고해주세요.'] })
|
||||
}
|
||||
|
||||
const result = await update.server(req.query.id, validated, key)
|
||||
|
||||
if(result === 0) return ResponseWrapper(res, { code: 400 })
|
||||
else {
|
||||
get.server.clear(req.query.id)
|
||||
|
||||
@ -6,6 +6,8 @@ import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { checkToken } from '@utils/Csrf'
|
||||
import Yup from '@utils/Yup'
|
||||
import { VOTE_COOLDOWN } from '@utils/Constants'
|
||||
import { sendWebhook } from '@utils/Webhook'
|
||||
import { WebhookType } from '@types'
|
||||
|
||||
const ServerVote = RequestHandler()
|
||||
.get(async (req: GetApiRequest, res) => {
|
||||
@ -24,7 +26,7 @@ const ServerVote = RequestHandler()
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if(!user) return ResponseWrapper(res, { code: 401 })
|
||||
const server = await get.server.load(req.query.id)
|
||||
if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
|
||||
if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' })
|
||||
const csrfValidated = checkToken(req, res, req.body._csrf)
|
||||
if (!csrfValidated) return
|
||||
const captcha = await CaptchaVerify(req.body._captcha)
|
||||
@ -32,7 +34,19 @@ const ServerVote = RequestHandler()
|
||||
|
||||
const vote = await put.voteServer(user, server.id)
|
||||
if(vote === null) return ResponseWrapper(res, { code: 401 })
|
||||
else if(vote === true) return ResponseWrapper(res, { code: 200 })
|
||||
else if(vote === true) {
|
||||
sendWebhook(server, {
|
||||
type: 'server',
|
||||
guildId: server.id,
|
||||
data: {
|
||||
type: WebhookType.HeartChange,
|
||||
before: server.votes,
|
||||
after: server.votes + 1,
|
||||
userId: user
|
||||
}
|
||||
})
|
||||
return ResponseWrapper(res, { code: 200 })
|
||||
}
|
||||
else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote } })
|
||||
})
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ import { get } from '@utils/Query'
|
||||
import { checkUserFlag, cleanObject, makeBotURL, parseCookie, redirectTo } from '@utils/Tools'
|
||||
import { ManageBot, ManageBotSchema } from '@utils/Yup'
|
||||
import { botCategories, library } from '@utils/Constants'
|
||||
import { Bot, Theme, User } from '@types'
|
||||
import { Bot, Theme, User, WebhookStatus } from '@types'
|
||||
import { getToken } from '@utils/Csrf'
|
||||
import Fetch from '@utils/Fetch'
|
||||
|
||||
@ -75,6 +75,7 @@ const ManageBotPage:NextPage<ManageBotProps> = ({ bot, user, csrfToken, theme })
|
||||
url: bot.url,
|
||||
git: bot.git,
|
||||
discord: bot.discord,
|
||||
webhookURL: bot.webhookURL,
|
||||
_csrf: csrfToken
|
||||
})}
|
||||
validationSchema={ManageBotSchema}
|
||||
@ -145,6 +146,9 @@ const ManageBotPage:NextPage<ManageBotProps> = ({ bot, user, csrfToken, theme })
|
||||
discord.gg/<Input name='discord' placeholder='JEh53MQ' />
|
||||
</div>
|
||||
</Label>
|
||||
<Label For='webhookURL' label='웹훅 링크' labelDesc='봇의 업데이트 알림을 받을 웹훅 링크를 입력해주세요. (웹훅 링크가 유효하지 않을 경우 웹훅이 중지되며, 다시 저장할 경우 작동합니다.)' error={errors.webhookURL && touched.webhookURL ? errors.webhookURL : null} warning={bot.webhookStatus === WebhookStatus.Disabled} warningText='웹훅 링크가 유효하지 않아 웹훅이 중지되었습니다.'>
|
||||
<Input name='webhookURL' placeholder='https://discord.com/api/webhooks/ID/TOKEN'/>
|
||||
</Label>
|
||||
<Divider />
|
||||
<Label For='intro' label='봇 소개' labelDesc='봇을 소개할 수 있는 간단한 설명을 적어주세요. (최대 60자)' error={errors.intro && touched.intro ? errors.intro : null} required>
|
||||
<Input name='intro' placeholder='국내 봇을 한 곳에서.' />
|
||||
|
||||
@ -45,7 +45,7 @@ export async function getStaticPaths () {
|
||||
}
|
||||
|
||||
export async function getStaticProps () {
|
||||
const docs = await Promise.all((await readdir(docsDir)).map(async el => {
|
||||
const docs = (await Promise.all((await readdir(docsDir)).map(async el => {
|
||||
const isDir = (await lstat(join(docsDir, el))).isDirectory()
|
||||
if(!isDir) {
|
||||
return {
|
||||
@ -64,8 +64,10 @@ export async function getStaticProps () {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
}))
|
||||
}))).sort((a, b) => {
|
||||
if('list' in b && 'text' in a) return -1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return {
|
||||
props: { docs }
|
||||
|
||||
@ -11,7 +11,7 @@ import { get } from '@utils/Query'
|
||||
import { checkUserFlag, cleanObject, getRandom, makeServerURL, parseCookie, redirectTo } from '@utils/Tools'
|
||||
import { ManageServer, ManageServerSchema } from '@utils/Yup'
|
||||
import { serverCategories, ServerIntroList } from '@utils/Constants'
|
||||
import { Server, Theme, User } from '@types'
|
||||
import { Server, Theme, User, WebhookStatus } from '@types'
|
||||
import { getToken } from '@utils/Csrf'
|
||||
import Fetch from '@utils/Fetch'
|
||||
|
||||
@ -57,6 +57,7 @@ const ManageServerPage:NextPage<ManageServerProps> = ({ server, user, owners, cs
|
||||
intro: server.intro,
|
||||
desc: server.desc,
|
||||
category: server.category,
|
||||
webhookURL: server.webhookURL,
|
||||
_csrf: csrfToken
|
||||
})}
|
||||
validationSchema={ManageServerSchema}
|
||||
@ -98,6 +99,9 @@ const ManageServerPage:NextPage<ManageServerProps> = ({ server, user, owners, cs
|
||||
discord.gg/<Input name='invite' placeholder='JEh53MQ' />
|
||||
</div>
|
||||
</Label>
|
||||
<Label For='webhookURL' label='웹훅 링크' labelDesc='봇의 업데이트 알림을 받을 웹훅 링크를 입력해주세요. (웹훅 링크가 유효하지 않을 경우 웹훅이 중지되며, 다시 저장할 경우 작동합니다.)' error={errors.webhookURL && touched.webhookURL ? errors.webhookURL : null} warning={server.webhookStatus === WebhookStatus.Disabled} warningText='웹훅 링크가 유효하지 않아 웹훅이 중지되었습니다.'>
|
||||
<Input name='webhookURL' placeholder='https://discord.com/api/webhooks/ID/TOKEN'/>
|
||||
</Label>
|
||||
<Divider />
|
||||
<Label For='intro' label='서버 소개' labelDesc='서버를 소개할 수 있는 간단한 설명을 적어주세요. (최대 60자)' error={errors.intro && touched.intro ? errors.intro : null} required>
|
||||
<Input name='intro' placeholder={getRandom(ServerIntroList)} />
|
||||
|
||||
9
sql/webhook.sql
Normal file
9
sql/webhook.sql
Normal file
@ -0,0 +1,9 @@
|
||||
-- Webhook 기능 추가를 위한 SQL 쿼리문입니다.
|
||||
use discordbots;
|
||||
|
||||
-- bots TABLE
|
||||
ALTER TABLE `bots` ADD COLUMN webhook_status INT NOT NULL DEFAULT '1';
|
||||
|
||||
-- servers TABLE
|
||||
ALTER TABLE `servers` ADD COLUMN webhook TEXT DEFAULT NULL;
|
||||
ALTER TABLE `servers` ADD COLUMN webhook_status INT NOT NULL DEFAULT '1';
|
||||
@ -24,6 +24,8 @@ export interface Bot {
|
||||
git: string | null
|
||||
url: string | null
|
||||
discord: string | null
|
||||
webhookURL: string | null
|
||||
webhookStatus: WebhookStatus
|
||||
vanity: string | null
|
||||
bg: string
|
||||
banner: string
|
||||
@ -53,6 +55,8 @@ export interface Server {
|
||||
desc: string
|
||||
category: ServerCategory[]
|
||||
invite: string
|
||||
webhookURL: string | null
|
||||
webhookStatus: WebhookStatus
|
||||
vanity: string | null
|
||||
bg: string | null
|
||||
banner: string | null
|
||||
@ -60,6 +64,13 @@ export interface Server {
|
||||
bots: Bot[] | string[]
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
url: string | null
|
||||
status: WebhookStatus
|
||||
failedSince: number | null
|
||||
secret: string | null
|
||||
}
|
||||
|
||||
export interface Emoji {
|
||||
id: string
|
||||
name: string
|
||||
@ -168,6 +179,18 @@ export enum DiscordUserFlags {
|
||||
VERIFIED_DEVELOPER = 1 << 17,
|
||||
}
|
||||
|
||||
export enum WebhookStatus {
|
||||
None = 0,
|
||||
Discord = 1,
|
||||
HTTP = 2,
|
||||
Disabled = 3
|
||||
}
|
||||
|
||||
export enum WebhookType {
|
||||
HeartChange = 0,
|
||||
ServerCountChange = 1
|
||||
}
|
||||
|
||||
export interface List<T> {
|
||||
type: ListType
|
||||
data: T[]
|
||||
|
||||
@ -4,15 +4,14 @@ export const DiscordBot = new Discord.Client({
|
||||
intents: Number(process.env.DISCORD_CLIENT_INTENTS ?? 32767)
|
||||
})
|
||||
|
||||
const guildID = '653083797763522580'
|
||||
export const ServerListDiscordBot = new Discord.Client({
|
||||
intents: []
|
||||
})
|
||||
|
||||
const reportChannelID = '813255797823766568'
|
||||
const loggingChannelID = '844006379823955978'
|
||||
const statsLoggingChannelID = '653227346962153472'
|
||||
|
||||
const reviewGuildID = '906537041326637086'
|
||||
const botReviewLogChannelID = '906551334063439902'
|
||||
const openBotReviewLogChannelID = '1008376563731013643'
|
||||
export const webhookClients = {
|
||||
bot: new Discord.Collection<string, Discord.WebhookClient>(),
|
||||
server: new Discord.Collection<string, Discord.WebhookClient>()
|
||||
}
|
||||
|
||||
DiscordBot.on('ready', async () => {
|
||||
console.log('I\'m Ready')
|
||||
@ -21,14 +20,15 @@ DiscordBot.on('ready', async () => {
|
||||
})
|
||||
|
||||
DiscordBot.login(process.env.DISCORD_TOKEN)
|
||||
ServerListDiscordBot.login(process.env.DISCORD_SERVERLIST_TOKEN)
|
||||
|
||||
export const getMainGuild = () => DiscordBot.guilds.cache.get(guildID)
|
||||
export const getReviewGuild = () => DiscordBot.guilds.cache.get(reviewGuildID)
|
||||
export const getReportChannel = (): Discord.TextChannel => getMainGuild().channels.cache.get(reportChannelID) as Discord.TextChannel
|
||||
export const getLoggingChannel = (): Discord.TextChannel => getMainGuild().channels.cache.get(loggingChannelID) as Discord.TextChannel
|
||||
export const getBotReviewLogChannel = (): Discord.TextChannel => getReviewGuild().channels.cache.get(botReviewLogChannelID) as Discord.TextChannel
|
||||
export const getStatsLoggingChannel = (): Discord.TextChannel => getMainGuild().channels.cache.get(statsLoggingChannelID) as Discord.TextChannel
|
||||
export const getOpenBotReviewLogChannel = (): Discord.TextChannel => getMainGuild().channels.cache.get(openBotReviewLogChannelID) as Discord.TextChannel
|
||||
export const getMainGuild = () => DiscordBot.guilds.cache.get(process.env.GUILD_ID)
|
||||
export const getReviewGuild = () => DiscordBot.guilds.cache.get(process.env.REVIEW_GUILD_ID)
|
||||
export const getReportChannel = (): Discord.TextChannel => getMainGuild().channels.cache.get(process.env.REPORT_CHANNEL_ID) as Discord.TextChannel
|
||||
export const getLoggingChannel = (): Discord.TextChannel => getMainGuild().channels.cache.get(process.env.LOGGING_CHANNEL_ID) as Discord.TextChannel
|
||||
export const getStatsLoggingChannel = (): Discord.TextChannel => getMainGuild().channels.cache.get(process.env.STATS_LOGGING_CHANNEL_ID) as Discord.TextChannel
|
||||
export const getBotReviewLogChannel = (): Discord.TextChannel => getReviewGuild().channels.cache.get(process.env.REVIEW_LOG_CHANNEL_ID) as Discord.TextChannel
|
||||
export const getOpenBotReviewLogChannel = (): Discord.TextChannel => getMainGuild().channels.cache.get(process.env.OPEN_REVIEW_LOG_CHANNEL_ID) as Discord.TextChannel
|
||||
|
||||
export const discordLog = async (type: string, issuerID: string, embed?: Discord.EmbedBuilder, attachment?: { content: string, format: string}, content?: string): Promise<void> => {
|
||||
getLoggingChannel().send({
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import fetch from 'node-fetch'
|
||||
import { TLRU } from 'tlru'
|
||||
import DataLoader from 'dataloader'
|
||||
import { ActivityType, GuildFeature, GuildMember, User as DiscordUser, UserFlags } from 'discord.js'
|
||||
import { ActivityType, GuildFeature, GuildMember, parseWebhookURL, User as DiscordUser, UserFlags } from 'discord.js'
|
||||
|
||||
import { Bot, Server, User, ListType, List, TokenRegister, BotFlags, DiscordUserFlags, SubmittedBot, DiscordTokenInfo, ServerData, ServerFlags, RawGuild, Nullable } from '@types'
|
||||
import { Bot, Server, User, ListType, List, TokenRegister, BotFlags, DiscordUserFlags, SubmittedBot, DiscordTokenInfo, ServerData, ServerFlags, RawGuild, Nullable, WebhookStatus, Webhook } from '@types'
|
||||
import { botCategories, DiscordEnpoints, imageSafeHost, serverCategories, SpecialEndPoints, VOTE_COOLDOWN } from './Constants'
|
||||
|
||||
import knex from './Knex'
|
||||
@ -37,6 +37,8 @@ async function getBot(id: string, topLevel=true):Promise<Bot> {
|
||||
'trusted',
|
||||
'partnered',
|
||||
'discord',
|
||||
'webhook_url',
|
||||
'webhook_status',
|
||||
'state',
|
||||
'vanity',
|
||||
'bg',
|
||||
@ -66,8 +68,12 @@ async function getBot(id: string, topLevel=true):Promise<Bot> {
|
||||
} else {
|
||||
res[0].status = null
|
||||
}
|
||||
res[0].webhookURL = res[0].webhook_url
|
||||
res[0].webhookStatus = res[0].webhook_status
|
||||
delete res[0].trusted
|
||||
delete res[0].partnered
|
||||
delete res[0].webhook_url
|
||||
delete res[0].webhook_status
|
||||
if (topLevel) {
|
||||
res[0].owners = await Promise.all(
|
||||
res[0].owners.map(async (u: string) => await get._rawUser.load(u))
|
||||
@ -95,6 +101,8 @@ async function getServer(id: string, topLevel=true): Promise<Server> {
|
||||
'category',
|
||||
'invite',
|
||||
'state',
|
||||
'webhook_url',
|
||||
'webhook_status',
|
||||
'vanity',
|
||||
'bg',
|
||||
'banner',
|
||||
@ -112,8 +120,11 @@ async function getServer(id: string, topLevel=true): Promise<Server> {
|
||||
await knex('servers').update({ name: data.name, owners: JSON.stringify([data.owner, ...data.admins]) })
|
||||
.where({ id: res[0].id })
|
||||
}
|
||||
res[0].webhookURL = res[0].webhook_url
|
||||
res[0].webhookStatus = res[0].webhook_status
|
||||
delete res[0].webhook_url
|
||||
delete res[0].webhook_status
|
||||
delete res[0].owners
|
||||
// console.log(data)
|
||||
res[0].icon = data?.icon || null
|
||||
res[0].members = data?.memberCount || null
|
||||
res[0].emojis = data?.emojis || []
|
||||
@ -364,6 +375,17 @@ async function getVote(userID: string, targetID: string, type: 'bot' | 'server')
|
||||
return data[`${type}:${targetID}`] || 0
|
||||
}
|
||||
|
||||
async function getWebhook(id: string, type: 'bots' | 'servers'): Promise<Webhook | null> {
|
||||
const res = (await knex(type).select(['webhook_url', 'webhook_status', 'webhook_failed_since', 'webhook_secret']).where({ id }))[0]
|
||||
if(!res) return null
|
||||
return {
|
||||
url: res.webhook_url,
|
||||
status: res.webhook_status,
|
||||
failedSince: res.webhook_failed_since,
|
||||
secret: res.webhook_secret
|
||||
}
|
||||
}
|
||||
|
||||
async function voteBot(userID: string, botID: string): Promise<number|boolean> {
|
||||
const user = await knex('users').select(['votes']).where({ id: userID })
|
||||
const key = `bot:${botID}`
|
||||
@ -466,7 +488,7 @@ async function submitServer(userID: string, id: string, data: AddServerSubmit):
|
||||
}
|
||||
|
||||
async function getBotSpec(id: string, userID: string) {
|
||||
const res = await knex('bots').select(['id', 'token', 'webhook']).where({ id }).andWhere('owners', 'like', `%${userID}%`)
|
||||
const res = await knex('bots').select(['id', 'token']).where({ id }).andWhere('owners', 'like', `%${userID}%`)
|
||||
if(!res[0]) return null
|
||||
return serialize(res[0])
|
||||
}
|
||||
@ -488,7 +510,7 @@ async function deleteServer(id: string): Promise<boolean> {
|
||||
return !!server
|
||||
}
|
||||
|
||||
async function updateBot(id: string, data: ManageBot): Promise<number> {
|
||||
async function updateBot(id: string, data: ManageBot, webhookSecret: string | null): Promise<number> {
|
||||
const res = await knex('bots').where({ id })
|
||||
if(res.length === 0) return 0
|
||||
await knex('bots').update({
|
||||
@ -498,6 +520,10 @@ async function updateBot(id: string, data: ManageBot): Promise<number> {
|
||||
git: data.git,
|
||||
url: data.url,
|
||||
discord: data.discord,
|
||||
webhook_url: data.webhookURL,
|
||||
webhook_status: parseWebhookURL(data.webhookURL) ? WebhookStatus.Discord : WebhookStatus.HTTP,
|
||||
webhook_failed_since: null,
|
||||
webhook_secret: webhookSecret,
|
||||
category: JSON.stringify(data.category),
|
||||
intro: data.intro,
|
||||
desc: data.desc
|
||||
@ -506,14 +532,18 @@ async function updateBot(id: string, data: ManageBot): Promise<number> {
|
||||
return 1
|
||||
}
|
||||
|
||||
async function updatedServer(id: string, data: ManageServer) {
|
||||
async function updatedServer(id: string, data: ManageServer, webhookSecret: string | null) {
|
||||
const res = await knex('servers').where({ id })
|
||||
if(res.length === 0) return 0
|
||||
await knex('servers').update({
|
||||
invite: data.invite,
|
||||
category: JSON.stringify(data.category),
|
||||
intro: data.intro,
|
||||
desc: data.desc
|
||||
desc: data.desc,
|
||||
webhook_url: data.webhookURL,
|
||||
webhook_status: parseWebhookURL(data.webhookURL) ? WebhookStatus.Discord : WebhookStatus.HTTP,
|
||||
webhook_failed_since: null,
|
||||
webhook_secret: webhookSecret,
|
||||
}).where({ id })
|
||||
|
||||
return 1
|
||||
@ -537,6 +567,17 @@ async function updateServer(id: string, servers: number, shards: number) {
|
||||
return
|
||||
}
|
||||
|
||||
async function updateWebhook(id: string, type: 'bots' | 'servers', value: Partial<Webhook>) {
|
||||
const res = await knex(type).update({
|
||||
webhook_url: value.url,
|
||||
webhook_status: value.status,
|
||||
webhook_failed_since: value.failedSince,
|
||||
webhook_secret: value.secret
|
||||
}).where({ id })
|
||||
if(res !== 1) return false
|
||||
return true
|
||||
}
|
||||
|
||||
async function updateBotApplication(id: string, value: { webhook: string }) {
|
||||
const bot = await knex('bots').update({ webhook: value.webhook }).where({ id })
|
||||
if(bot !== 1) return false
|
||||
@ -837,6 +878,7 @@ export const get = {
|
||||
, { cacheMap: new TLRU({ maxStoreSize: 500, maxAgeMs: 3600000 }) }),
|
||||
},
|
||||
serverData: getServerData,
|
||||
webhook: getWebhook,
|
||||
botVote: async (botID: string, targetID: string) => await getVote(botID, targetID, 'bot'),
|
||||
vote: getVote,
|
||||
Authorization,
|
||||
@ -858,6 +900,7 @@ export const update = {
|
||||
bot: updateBot,
|
||||
server: updatedServer,
|
||||
botOwners: updateOwner,
|
||||
webhook: updateWebhook,
|
||||
denyBotSubmission,
|
||||
approveBotSubmission,
|
||||
fetchUserDiscordToken
|
||||
|
||||
241
utils/Webhook.ts
Normal file
241
utils/Webhook.ts
Normal file
@ -0,0 +1,241 @@
|
||||
import { APIEmbed, ButtonStyle, Colors, ComponentType, DiscordAPIError, parseWebhookURL, Snowflake, WebhookClient } from 'discord.js'
|
||||
|
||||
import { get, update } from './Query'
|
||||
import { DiscordBot, ServerListDiscordBot, webhookClients } from './DiscordBot'
|
||||
import { DiscordEnpoints } from './Constants'
|
||||
import fetch, { Response } from 'node-fetch'
|
||||
import { Bot, Server, WebhookStatus, WebhookType } from '@types'
|
||||
import { makeBotURL, makeDiscordCodeblock, makeServerURL } from './Tools'
|
||||
import crypto from 'crypto'
|
||||
|
||||
type RelayOptions = {
|
||||
dest: string,
|
||||
method: 'GET' | 'POST',
|
||||
data?: string
|
||||
secret: string,
|
||||
}
|
||||
|
||||
function relayedFetch(options: RelayOptions): Promise<Response> {
|
||||
return fetch(process.env.WEBHOOK_RELAY_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(options),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': process.env.WEBHOOK_RELAY_SECRET,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const sendFailedMessage = async (target: Bot | Server): Promise<void> => {
|
||||
const isBot = 'owners' in target
|
||||
const users = isBot ? target.owners : [target.owner]
|
||||
|
||||
for(const user of users) {
|
||||
const r = await (isBot ? DiscordBot : ServerListDiscordBot).users.send(typeof user === 'string' ? user : user.id, {
|
||||
embeds: [
|
||||
{
|
||||
title: '웹후크 전송 실패',
|
||||
description: `\`\`${target.name}\`\`에 등록된 웹후크 주소가 올바르지 않거나, 제대로 동작하지 않아 비활성화되었습니다.\n` +
|
||||
'설정된 웹후크의 주소가 올바른지 확인해주세요.\n' +
|
||||
`[관리 패널](https://koreanbots.dev/${isBot ? 'bots' : 'servers'}/${target.id}/edit)에서 설정된 내용을 다시 저장하면 웹후크가 활성화됩니다.\n` +
|
||||
(isBot ? '문제가 지속될 경우 본 DM을 통해 문의해주세요.' : '문제가 지속될 경우 한디리 공식 디스코드 서버에서 문의해주세요.'),
|
||||
color: Colors.Red
|
||||
}
|
||||
],
|
||||
components: isBot ? [] : [
|
||||
{
|
||||
type: ComponentType.ActionRow,
|
||||
components: [
|
||||
{
|
||||
type: ComponentType.Button,
|
||||
label: '공식 디스코드 서버 참가하기',
|
||||
style: ButtonStyle.Link,
|
||||
url: 'https://discord.gg/koreanlist'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}).catch(() => null)
|
||||
if(r) return
|
||||
}
|
||||
}
|
||||
|
||||
export const verifyWebhook = async(webhookURL: string): Promise<string | false | null> => {
|
||||
if(parseWebhookURL(webhookURL)) return null
|
||||
const secret = crypto.randomUUID()
|
||||
const url = new URL(webhookURL)
|
||||
url.searchParams.set('secret', secret)
|
||||
const result = await relayedFetch({
|
||||
dest: url.toString(),
|
||||
method: 'GET',
|
||||
secret
|
||||
}).then(r => r.json()).catch(() => null)
|
||||
|
||||
if(result) {
|
||||
const data = result.data ? JSON.parse(result.data) : null
|
||||
if(data?.secret === secret) return secret
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const sendWebhook = async (target: Bot | Server, payload: WebhookPayload): Promise<boolean> => {
|
||||
const id = target.id
|
||||
const isBot = payload.type === 'bot'
|
||||
|
||||
const webhook = await get.webhook(id, isBot ? 'bots' : 'servers')
|
||||
if(!webhook) return
|
||||
if(webhook.status === 0) return
|
||||
|
||||
if(webhook.status === WebhookStatus.Discord) {
|
||||
if(!webhookClients[payload.type].has(id)) {
|
||||
webhookClients[payload.type].set(id, new WebhookClient({
|
||||
url: webhook.url
|
||||
}))
|
||||
}
|
||||
const client = webhookClients[payload.type].get(id)
|
||||
|
||||
const url = new URL(webhook.url)
|
||||
const result = await client.send({
|
||||
embeds: [buildEmbed({payload, target})],
|
||||
threadId: url.searchParams.get('thread_id') || undefined
|
||||
}).catch((r: DiscordAPIError | unknown)=> {
|
||||
if(r instanceof DiscordAPIError) {
|
||||
if(400 <= r.status && r.status < 500) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if(!result) {
|
||||
await update.webhook(id, isBot ? 'bots' : 'servers', { status: WebhookStatus.Disabled })
|
||||
sendFailedMessage(target)
|
||||
return false
|
||||
}
|
||||
} else if(webhook.status === WebhookStatus.HTTP) {
|
||||
const result = await relayedFetch({
|
||||
dest: webhook.url,
|
||||
method: 'POST',
|
||||
data: JSON.stringify(payload),
|
||||
secret: webhook.secret
|
||||
}).then(async r => {
|
||||
if(!r.ok) {
|
||||
return null
|
||||
}
|
||||
return r.json()
|
||||
}).catch(() => null)
|
||||
if(result === null) return
|
||||
|
||||
if(result.success) {
|
||||
const data = result.data
|
||||
if((200 <= result.status && result.status < 300) && data.length === 0) {
|
||||
await update.webhook(id, isBot ? 'bots' : 'servers', { failedSince: null })
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if(Date.now() - webhook.failedSince > 1000 * 60 * 60 * 24) {
|
||||
await update.webhook(id, isBot ? 'bots' : 'servers', {
|
||||
status: WebhookStatus.Disabled,
|
||||
failedSince: null,
|
||||
secret: null
|
||||
})
|
||||
sendFailedMessage(target)
|
||||
} else if(!webhook.failedSince) {
|
||||
await update.webhook(id, isBot ? 'bots' : 'servers', {
|
||||
failedSince: Date.now()
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function compare(before, after) {
|
||||
if(before < after) {
|
||||
return '🔺'
|
||||
} else if(before === after) {
|
||||
return '➖'
|
||||
} else {
|
||||
return '🔻'
|
||||
}
|
||||
}
|
||||
|
||||
function buildEmbed({payload, target}: {payload: WebhookPayload, target: Bot | Server}): APIEmbed {
|
||||
const author = 'avatar' in target ? {
|
||||
name: target.name,
|
||||
icon_url:
|
||||
DiscordEnpoints.CDN.user(target.id, target.avatar, {format: 'png'}),
|
||||
url: process.env.KOREANBOTS_URL + makeBotURL({id: target.id, vanity: target.vanity})
|
||||
} : {
|
||||
name: target.name,
|
||||
icon_url:
|
||||
DiscordEnpoints.CDN.guild(target.id, target.icon, {format: 'png'}),
|
||||
url: process.env.KOREANBOTS_URL + makeServerURL({id: target.id, vanity: target.vanity})
|
||||
}
|
||||
const footer = {
|
||||
text: '한국 디스코드 리스트',
|
||||
icon_url: DiscordBot.user.avatarURL(),
|
||||
}
|
||||
switch(payload.data.type) {
|
||||
case WebhookType.HeartChange:
|
||||
return {
|
||||
author,
|
||||
title: '❤️ 하트 수 변동',
|
||||
fields: [
|
||||
{
|
||||
name: '이전',
|
||||
value: makeDiscordCodeblock(payload.data.before.toString()),
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: '이후',
|
||||
value: makeDiscordCodeblock(payload.data.after.toString()),
|
||||
inline: true,
|
||||
}
|
||||
],
|
||||
color: 0xCD3E45,
|
||||
footer,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
case WebhookType.ServerCountChange:
|
||||
return {
|
||||
author,
|
||||
title: '서버 수 변동',
|
||||
description: `${payload.data.before} -> ${payload.data.after} (${compare(payload.data.before, payload.data.after)})`,
|
||||
color: Colors.Aqua,
|
||||
footer,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
type WebhookPayload = BotWebhookPayload | ServerWebhookPayload
|
||||
|
||||
type ServerWebhookData = HeartChange
|
||||
type BotWebhookData = HeartChange | ServerCountChange
|
||||
|
||||
type ServerWebhookPayload = {
|
||||
type: 'server',
|
||||
guildId: Snowflake,
|
||||
data: ServerWebhookData
|
||||
}
|
||||
|
||||
type BotWebhookPayload = {
|
||||
type: 'bot',
|
||||
botId: Snowflake,
|
||||
data: BotWebhookData
|
||||
}
|
||||
|
||||
type HeartChange = {
|
||||
type: WebhookType.HeartChange,
|
||||
before: number,
|
||||
after: number,
|
||||
userId: Snowflake
|
||||
}
|
||||
|
||||
type ServerCountChange = {
|
||||
type: WebhookType.ServerCountChange,
|
||||
before: number,
|
||||
after: number
|
||||
}
|
||||
14
utils/Yup.ts
14
utils/Yup.ts
@ -326,6 +326,11 @@ export const ManageBotSchema: Yup.SchemaOf<ManageBot> = Yup.object({
|
||||
.min(2, '지원 디스코드는 최소 2자여야합니다.')
|
||||
.max(32, '지원 디스코드는 최대 32자까지만 가능합니다.')
|
||||
.nullable(),
|
||||
webhookURL: Yup.string()
|
||||
.matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.')
|
||||
.matches(Url, '올바른 웹훅 URL을 입력해주세요.')
|
||||
.max(256, '웹훅 링크는 최대 256자까지만 가능합니다.')
|
||||
.nullable(),
|
||||
category: Yup.array(Yup.string().oneOf(botCategories))
|
||||
.min(1, '최소 한 개의 카테고리를 선택해주세요.')
|
||||
.unique('카테고리는 중복될 수 없습니다.')
|
||||
@ -348,13 +353,14 @@ export interface ManageBot {
|
||||
url: string
|
||||
git: string
|
||||
discord: string
|
||||
webhookURL: string
|
||||
category: string[]
|
||||
intro: string
|
||||
desc: string
|
||||
_csrf: string
|
||||
}
|
||||
|
||||
export const ManageServerSchema = Yup.object({
|
||||
export const ManageServerSchema: Yup.SchemaOf<ManageServer> = Yup.object({
|
||||
invite: Yup.string()
|
||||
.matches(Vanity, '디스코드 초대코드 형식을 지켜주세요.')
|
||||
.min(2, '초대코드는 최소 2자여야합니다.')
|
||||
@ -372,6 +378,11 @@ export const ManageServerSchema = Yup.object({
|
||||
.min(100, '서버 설명은 최소 100자여야합니다.')
|
||||
.max(1500, '서버 설명은 최대 1500자여야합니다.')
|
||||
.required('서버 설명은 필수 항목입니다.'),
|
||||
webhookURL: Yup.string()
|
||||
.matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.')
|
||||
.matches(Url, '올바른 웹훅 URL을 입력해주세요.')
|
||||
.max(256, '웹훅 링크는 최대 256자까지만 가능합니다.')
|
||||
.nullable(),
|
||||
_csrf: Yup.string().required(),
|
||||
})
|
||||
|
||||
@ -380,6 +391,7 @@ export interface ManageServer {
|
||||
category: string[]
|
||||
intro: string
|
||||
desc: string
|
||||
webhookURL: string
|
||||
_csrf: string
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user