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:
Byungchul Kim 2023-04-03 12:03:41 +09:00 committed by GitHub
parent 7121097ed2
commit e8075ee7d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 458 additions and 39 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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')

View File

@ -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)}))`)]

View File

@ -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 } })
})

View File

@ -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)

View File

@ -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 } })
})

View File

@ -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='국내 봇을 한 곳에서.' />

View File

@ -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 }

View File

@ -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
View 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';

View File

@ -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[]

View File

@ -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({

View File

@ -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
View 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
}

View File

@ -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
}