mirror of
https://github.com/koreanbots/core.git
synced 2025-12-15 14:10:22 +00:00
361 lines
8.5 KiB
TypeScript
361 lines
8.5 KiB
TypeScript
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
|
||
}
|