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, Webhook, 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 } const timeouts = new Map>() const clearTimeouts = (id: Snowflake) => { if (timeouts.has(id)) { timeouts.get(id).forEach(clearTimeout) timeouts.delete(id) return true } else { return false } } async function sendRequest({ retryCount, webhook, target, payload, }: { retryCount: number webhook: Webhook target: Bot | Server payload: WebhookPayload }) { const id = target.id const isBot = payload.type === 'bot' timeouts.get(id)?.delete(payload.timestamp) 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 } else if ((400 <= result.status && result.status < 500) || data.length !== 0) { if (!clearTimeouts(id)) return await update.webhook(id, isBot ? 'bots' : 'servers', { status: WebhookStatus.Disabled, failedSince: null, secret: null, }) sendFailedMessage(target) return } } if (retryCount === 10) { if (!webhook.failedSince) { await update.webhook(id, isBot ? 'bots' : 'servers', { failedSince: Math.floor(Date.now() / 1000), }) } else if (Date.now() - webhook.failedSince * 1000 > 1000 * 60 * 60 * 24) { if (!clearTimeouts(id)) return await update.webhook(id, isBot ? 'bots' : 'servers', { status: WebhookStatus.Disabled, failedSince: null, secret: null, }) sendFailedMessage(target) } return } if (!timeouts.has(id)) { timeouts.set(id, new Map()) } timeouts.get(id).set( payload.timestamp, setTimeout( () => { sendRequest({ retryCount: retryCount + 1, webhook, target, payload, }) }, Math.pow(2, retryCount + 2) * 1000 ) ) } export function destroyWebhookClient(id: string, type: 'bot' | 'server') { const client = webhookClients[type].get(id) if (client) { client.destroy() webhookClients[type].delete(id) } } function relayedFetch(options: RelayOptions): Promise { 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 => { 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' + `[개발자 패널](${process.env.KOREANBOTS_URL}/developers/applications/${ isBot ? 'bots' : 'servers' }/${target.id})에서 설정된 내용을 다시 저장하면 웹후크가 활성화됩니다.\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 => { 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 => { 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 } } else if (webhook.status === WebhookStatus.HTTP) { sendRequest({ retryCount: 0, webhook, target, payload, }) return } } 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.before} -> ${payload.data.after} (${compare( payload.data.before, payload.data.after )})` : `+ ${payload.data.after}`, color: Colors.Aqua, footer, timestamp: new Date().toISOString(), } } } type WebhookPayload = (BotWebhookPayload | ServerWebhookPayload) & { timestamp: number } type ServerWebhookData = HeartChange & { guildId: Snowflake } type BotWebhookData = (HeartChange | ServerCountChange) & { botId: Snowflake } type ServerWebhookPayload = { type: 'server' data: ServerWebhookData } type BotWebhookPayload = { type: 'bot' data: BotWebhookData } type HeartChange = { type: WebhookType.HeartChange before: number after: number userId: Snowflake } type ServerCountChange = { type: WebhookType.ServerCountChange before: number | number after: number }