core/utils/Webhook.ts
SKINMAKER 90b98cbebe
fix: duplicated failed message (#559)
* fix: clear timeouts when disabled

* fix: check timeout maps

* fix: wrong link for panel
2023-04-29 12:27:31 +09:00

316 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Snowflake, Map<number, NodeJS.Timeout>>()
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<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' +
`[개발자 패널](${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<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<void> => {
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
}