From 3eb23f40926d9e4f3666b886886466b61e7a1c5a Mon Sep 17 00:00:00 2001 From: _LittleC_ Date: Sat, 1 Apr 2023 15:27:06 +0800 Subject: [PATCH] feat(discord): support webhook message (#75) --- adapters/discord/src/bot.ts | 31 +++++- adapters/discord/src/message.ts | 147 +++++++++++++++++++++++--- adapters/discord/src/types/channel.ts | 6 +- adapters/discord/src/utils.ts | 13 +++ 4 files changed, 177 insertions(+), 20 deletions(-) diff --git a/adapters/discord/src/bot.ts b/adapters/discord/src/bot.ts index 5a810a7f..b21eb308 100644 --- a/adapters/discord/src/bot.ts +++ b/adapters/discord/src/bot.ts @@ -1,7 +1,7 @@ import { Bot, Context, Fragment, h, Quester, Schema, SendOptions } from '@satorijs/satori' import { adaptChannel, adaptGuild, adaptMessage, adaptUser } from './utils' import { DiscordMessenger } from './message' -import { Internal } from './types' +import { Internal, Webhook } from './types' import { WsClient } from './ws' // @ts-ignore @@ -10,6 +10,8 @@ import { version } from '../package.json' export class DiscordBot extends Bot { public http: Quester public internal: Internal + public webhooks: Record = {} + public webhookLock: Record> = {} constructor(ctx: Context, config: DiscordBot.Config) { super(ctx, config) @@ -25,6 +27,33 @@ export class DiscordBot extends Bot { ctx.plugin(WsClient, this) } + private async _ensureWebhook(channelId: string) { + let webhook: Webhook + const webhooks = await this.internal.getChannelWebhooks(channelId) + const selfId = this.selfId + if (!webhooks.find(v => v.name === 'Koishi' && v.user.id === selfId)) { + webhook = await this.internal.createWebhook(channelId, { + name: 'Koishi', + }) + // webhook may be `AxiosError: Request failed with status code 429` error + } else { + webhook = webhooks.find(v => v.name === 'Koishi' && v.user.id === this.selfId) + } + return this.webhooks[channelId] = webhook + } + + async ensureWebhook(channelId: string) { + if (this.webhooks[channelId] === null) { + delete this.webhooks[channelId] + delete this.webhookLock[channelId] + } + if (this.webhooks[channelId]) { + delete this.webhookLock[channelId] + return this.webhooks[channelId] + } + return this.webhookLock[channelId] ||= this._ensureWebhook(channelId) + } + async getSelf() { const data = await this.internal.getCurrentUser() return adaptUser(data) diff --git a/adapters/discord/src/message.ts b/adapters/discord/src/message.ts index 8ff9af2f..ab7df291 100644 --- a/adapters/discord/src/message.ts +++ b/adapters/discord/src/message.ts @@ -1,11 +1,25 @@ -import { Dict, h, Messenger, Schema } from '@satorijs/satori' +import { Dict, h, Logger, Messenger, Quester, Schema, segment, Session, Universal } from '@satorijs/satori' import FormData from 'form-data' import { DiscordBot } from './bot' -import { adaptMessage } from './utils' +import { Channel, Message } from './types' +import { adaptMessage, sanitize } from './utils' type RenderMode = 'default' | 'figure' +const logger = new Logger('discord') + +class State { + author: Partial = {} + quote: Partial = {} + channel: Partial = {} + fakeMessageMap: Record = {} // [userInput] = discord messages + threadCreated = false // forward: send the first message and create a thread + + constructor(public type: 'message' | 'forward') { } +} + export class DiscordMessenger extends Messenger { + private stack: State[] = [new State('message')] private buffer: string = '' private addition: Dict = {} private figure: h = null @@ -13,12 +27,43 @@ export class DiscordMessenger extends Messenger { async post(data?: any, headers?: any) { try { - const result = await this.bot.http.post(`/channels/${this.channelId}/messages`, data, { headers }) + let url = `/channels/${this.channelId}/messages` + if (this.stack[0].author.nickname || this.stack[0].author.avatar || (this.stack[0].type === 'forward' && !this.stack[0].threadCreated)) { + const webhook = await this.ensureWebhook() + url = `/webhooks/${webhook.id}/${webhook.token}?wait=true` + } + if (this.stack[0].type === 'forward' && this.stack[0].channel?.id) { + // 发送到子区 + if (this.stack[1].author.nickname || this.stack[1].author.avatar) { + const webhook = await this.ensureWebhook() + url = `/webhooks/${webhook.id}/${webhook.token}?wait=true&thread_id=${this.stack[0].channel?.id}` + } else { + url = `/channels/${this.stack[0].channel.id}/messages` + } + } + const result = await this.bot.http.post(url, data, { headers }) const session = this.bot.session() - await adaptMessage(this.bot, result, session) + const message = await adaptMessage(this.bot, result, session) session.app.emit(session, 'send', session) this.results.push(session) + + if (this.stack[0].type === 'forward' && !this.stack[0].threadCreated) { + this.stack[0].threadCreated = true + const thread = await this.bot.internal.startThreadFromMessage(this.channelId, result.id, { + name: 'Forward', + auto_archive_duration: 60, + }) + this.stack[0].channel = thread + } + + return message } catch (e) { + if (Quester.isAxiosError(e) && e.response?.data.code === 10015) { + logger.debug('webhook has been deleted, recreating..., %o', e.response.data) + if (!this.bot.webhookLock[this.channelId]) this.bot.webhooks[this.channelId] = null + await this.ensureWebhook() + return this.post(data, headers) + } this.errors.push(e) } } @@ -35,10 +80,6 @@ export class DiscordMessenger extends Messenger { return this.post(form, form.getHeaders()) } - async sendContent(content: string, addition: Dict) { - return this.post({ ...addition, content }) - } - async sendAsset(type: string, attrs: Dict, addition: Dict) { const { handleMixedContent, handleExternalAsset } = this.bot.config as DiscordMessenger.Config @@ -79,6 +120,10 @@ export class DiscordMessenger extends Messenger { }, sendDownload) } + async ensureWebhook() { + return this.bot.ensureWebhook(this.channelId) + } + async flush() { const content = this.buffer.trim() if (!content) return @@ -90,7 +135,7 @@ export class DiscordMessenger extends Messenger { async visit(element: h) { const { type, attrs, children } = element if (type === 'text') { - this.buffer += attrs.content.replace(/[\\*_`~|()]/g, '\\$&') + this.buffer += sanitize(attrs.content) } else if (type === 'b' || type === 'strong') { this.buffer += '**' await this.render(children) @@ -157,7 +202,7 @@ export class DiscordMessenger extends Messenger { ...this.addition, embeds: [{ ...attrs }], }) - } else if (type === 'record') { + } else if (type === 'audio') { await this.sendAsset('file', attrs, { ...this.addition, content: this.buffer.trim(), @@ -173,20 +218,90 @@ export class DiscordMessenger extends Messenger { }) this.buffer = '' this.mode = 'default' - } else if (type === 'quote') { - await this.flush() - this.addition.message_reference = { - message_id: attrs.id, - } - } else if (type === 'message') { + } else if (type === 'message' && !attrs.forward) { if (this.mode === 'figure') { await this.render(children) this.buffer += '\n' } else { + const resultLength = +this.results.length await this.flush() + + // author + const [author] = segment.select(children, 'author') + if (author) { + const { avatar, nickname } = author.attrs + if (avatar) this.addition.avatar_url = avatar + if (nickname) this.addition.username = nickname + if (this.stack[0].type === 'message') { + this.stack[0].author = author.attrs + } + if (this.stack[0].type === 'forward') { + this.stack[1].author = author.attrs + } + } + + // quote + const [quote] = segment.select(children, 'quote') + if (quote) { + const parse = (val: string) => val.replace(/\\([\\*_`~|()\[\]])/g, '$1') + + const message = this.stack[this.stack[0].type === 'forward' ? 1 : 0] + if (!message.author.avatar && !message.author.nickname && this.stack[0].type !== 'forward') { + // no quote and author, send by bot + await this.flush() + this.addition.message_reference = { + message_id: quote.attrs.id, + } + } else { + // quote + let replyId = quote.attrs.id, channelId = this.channelId + if (this.stack[0].type === 'forward' && this.stack[0].fakeMessageMap[quote.attrs.id]?.length >= 1) { + // quote to fake message, eg. 1st message has id (in channel or thread), later message quote to it + replyId = this.stack[0].fakeMessageMap[quote.attrs.id][0].messageId + channelId = this.stack[0].fakeMessageMap[quote.attrs.id][0].channelId + } + const quoted = await this.bot.getMessage(channelId, replyId) + this.addition.embeds = [{ + description: `${sanitize(parse(quoted.elements.filter(v => v.type === 'text').join('')).slice(0, 30))}\n\n [[ ↑ ]](https://discord.com/channels/${this.guildId}/${channelId}/${replyId})`, + author: { + name: quoted.author.nickname || quoted.author.username, + icon_url: quoted.author.avatar, + }, + }] + + // this.addition.embeds = [{ + // description: `${sanity(quoted.author.nickname || quoted.author.username)} [[ ↑ ]](https://discord.com/channels/${this.guildId}/${channelId}/${replyId})`, + // footer: { + // text: parse(quoted.elements.filter(v => v.type === 'text').join('')).slice(0, 30) || " ", + // icon_url: quoted.author.avatar + // } + // }] + } + } + await this.render(children) await this.flush() + const newLength = +this.results.length + const sentMessages = this.results.slice(resultLength, newLength) + if (this.stack[0].type === 'forward' && attrs.id) { + this.stack[0].fakeMessageMap[attrs.id] = sentMessages + } + if (this.stack[0].type === 'message') { + this.stack[0].author = {} + } + if (this.stack[0].type === 'forward') { + this.stack[1].author = {} + } } + } else if (type === 'message' && attrs.forward) { + this.stack.unshift(new State('forward')) + await this.render(children) + await this.flush() + await this.bot.internal.modifyChannel(this.stack[0].channel.id, { + archived: true, + locked: true, + }) + this.stack.shift() } else { await this.render(children) } diff --git a/adapters/discord/src/types/channel.ts b/adapters/discord/src/types/channel.ts index 9f79a3ec..cb93c981 100644 --- a/adapters/discord/src/types/channel.ts +++ b/adapters/discord/src/types/channel.ts @@ -167,9 +167,9 @@ export namespace Channel { } export type Modify = - | Modify.GroupDM - | Modify.GuildChannel - | Modify.Thread + | Partial + | Partial + | Partial export namespace Modify { /** https://discord.com/developers/docs/resources/channel#modify-channel-json-params-group-dm */ diff --git a/adapters/discord/src/utils.ts b/adapters/discord/src/utils.ts index b7c785fa..251c7928 100644 --- a/adapters/discord/src/utils.ts +++ b/adapters/discord/src/utils.ts @@ -2,6 +2,12 @@ import { h, Session, Universal } from '@satorijs/satori' import { DiscordBot } from './bot' import * as Discord from './types' +export const sanitize = (val: string) => + val + .replace(/[\\*_`~|()\[\]]/g, '\\$&') + .replace(/@everyone/g, () => '\\@everyone') + .replace(/@here/g, () => '\\@here') + export const adaptUser = (user: Discord.User): Universal.User => ({ userId: user.id, avatar: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`, @@ -138,6 +144,13 @@ function prepareReactionSession(session: Partial, data: any) { export async function adaptSession(bot: DiscordBot, input: Discord.GatewayPayload) { const session = bot.session() if (input.t === 'MESSAGE_CREATE') { + if (input.d.webhook_id) { + const webhook = await bot.ensureWebhook(input.d.channel_id) + if (webhook.id === input.d.webhook_id) { + // koishi's webhook + return + } + } session.type = 'message' await adaptMessage(bot, input.d, session) // dc 情况特殊 可能有 embeds 但是没有消息主体