Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(discord): support webhook message #75

Merged
merged 15 commits into from
Apr 1, 2023
17 changes: 16 additions & 1 deletion adapters/discord/src/bot.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Bot, Context, Fragment, Quester, Schema, SendOptions, segment } 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'

export class DiscordBot extends Bot<DiscordBot.Config> {
public http: Quester
public internal: Internal
public webhooks: Record<string, Webhook> = {}
public webhookLock: Record<string, Promise<Webhook>> = {}

constructor(ctx: Context, config: DiscordBot.Config) {
super(ctx, config)
Expand All @@ -21,6 +23,19 @@ export class DiscordBot extends Bot<DiscordBot.Config> {
ctx.plugin(WsClient, this)
}

async _ensureWebhook(channelId: string) {
let webhook: Webhook;
let webhooks = await this.internal.getChannelWebhooks(channelId)
if (!webhooks.find(v => v.name === "Koishi" && v.user.id === this.selfId)) {
webhook = await this.internal.createWebhook(channelId, {
name: "Koishi"
})
} else {
webhook = webhooks.find(v => v.name === "Koishi" && v.user.id === this.selfId)
}
return webhook
}

async getSelf() {
const data = await this.internal.getCurrentUser()
return adaptUser(data)
Expand Down
174 changes: 157 additions & 17 deletions adapters/discord/src/message.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,77 @@
import { Dict, Messenger, Schema, segment } from '@satorijs/satori'
import { Dict, Messenger, Schema, segment, Universal, Session } from '@satorijs/satori'
import { fromBuffer } from 'file-type'
import FormData from 'form-data'
import { DiscordBot } from './bot'
import { Channel, Message } from './types'
import { adaptMessage } from './utils'

type RenderMode = 'default' | 'figure'

class State {
author: Partial<Universal.Author> = {}
quote: Partial<Universal.Message> = {}
channel: Partial<Channel> = {}
fakeMessageMap: Record<string, Session[]> = {} // [userInput] = discord messages
threadCreated = false // forward: send the first message and create a thread

constructor(public type: 'message' | 'forward') { }
}

export class DiscordMessenger extends Messenger<DiscordBot> {
private stack: State[] = [new State('message')]
private buffer: string = ''
private addition: Dict = {}
private figure: segment = null
private mode: RenderMode = 'default'

get webhook() {
return this.bot.webhooks[this.channelId]
}
set webhook(val) {
if (!val) {
delete this.bot.webhookLock[this.channelId]
}
shigma marked this conversation as resolved.
Show resolved Hide resolved
this.bot.webhooks[this.channelId] = val
}

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)) {
await this.ensureWebhook()
url = `/webhooks/${this.webhook.id}/${this.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) {
await this.ensureWebhook()
url = `/webhooks/${this.webhook.id}/${this.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<Message>(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
let 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 (e?.response?.status === 404 || e?.response?.data?.code === 10015) {
shigma marked this conversation as resolved.
Show resolved Hide resolved
this.webhook = null
await this.ensureWebhook()
return this.post(data, headers)
}
this.errors.push(e)
}
}
Expand All @@ -32,10 +84,6 @@ export class DiscordMessenger extends Messenger<DiscordBot> {
return this.post(fd, fd.getHeaders())
}

async sendContent(content: string, addition: Dict) {
return this.post({ ...addition, content })
}

async sendAsset(type: string, data: Dict<string>, addition: Dict) {
const { handleMixedContent, handleExternalAsset } = this.bot.config as DiscordMessenger.Config

Expand Down Expand Up @@ -65,7 +113,7 @@ export class DiscordMessenger extends Messenger<DiscordBot> {
}

const mode = data.mode as DiscordMessenger.HandleExternalAsset || handleExternalAsset
if (mode === 'download' || handleMixedContent === 'attach' && addition.content || type === 'file') {
if (mode === 'download' || handleMixedContent === 'attach' && addition.content || type === 'file' || this.stack[0].quote || this.stack[1]?.quote) {
shigma marked this conversation as resolved.
Show resolved Hide resolved
return sendDownload()
} else if (mode === 'direct') {
return sendDirect()
Expand All @@ -83,6 +131,22 @@ export class DiscordMessenger extends Messenger<DiscordBot> {
}, sendDownload)
}

private async _ensureWebhook() {
if (this.webhook === null) {
delete this.bot.webhookLock[this.channelId]
}
if (this.webhook) {
delete this.bot.webhookLock[this.channelId]
return this.webhook;
}
this.bot.webhookLock[this.channelId] = this.bot._ensureWebhook(this.channelId)
return this.bot.webhookLock[this.channelId]
}

async ensureWebhook() {
return this.webhook = await (this.bot.webhookLock[this.channelId] ||= this._ensureWebhook())
shigma marked this conversation as resolved.
Show resolved Hide resolved
}

async flush() {
const content = this.buffer.trim()
if (!content) return
Expand All @@ -92,9 +156,14 @@ export class DiscordMessenger extends Messenger<DiscordBot> {
}

async visit(element: segment) {
const sanity = (val: string) =>
shigma marked this conversation as resolved.
Show resolved Hide resolved
val
.replace(/[\\*_`~|()\[\]]/g, "\\$&")
.replace(/@everyone/g, () => "\\@everyone")
.replace(/@here/g, () => "\\@here");
const { type, attrs, children } = element
if (type === 'text') {
this.buffer += attrs.content.replace(/[\\*_`~|()]/g, '\\$&')
this.buffer += sanity(attrs.content);
} else if (type === 'b' || type === 'strong') {
this.buffer += '**'
await this.render(children)
Expand Down Expand Up @@ -161,7 +230,7 @@ export class DiscordMessenger extends Messenger<DiscordBot> {
...this.addition,
embeds: [{ ...attrs }],
})
} else if (type === 'record') {
} else if (type === 'audio') {
await this.sendAsset('file', attrs, {
...this.addition,
content: this.buffer.trim(),
Expand All @@ -177,21 +246,92 @@ export class DiscordMessenger extends Messenger<DiscordBot> {
})
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 {
let 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")

let 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
}
let quoted = await this.bot.getMessage(channelId, replyId)
this.addition.embeds = [{
description: `${sanity(parse(quoted.elements.filter(v => v.type === 'text').join('')).slice(0, 30))}\n\n <t:${Math.ceil(quoted.timestamp / 1000)}:R> [[ ↑ ]](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)} <t:${Math.ceil(quoted.timestamp / 1000)}:R> [[ ↑ ]](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()
let 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 {
} 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)
}
}
Expand Down
6 changes: 3 additions & 3 deletions adapters/discord/src/types/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,9 @@ export namespace Channel {
}

export type Modify =
| Modify.GroupDM
| Modify.GuildChannel
| Modify.Thread
| Partial<Modify.GroupDM>
| Partial<Modify.GuildChannel>
| Partial<Modify.Thread>

export namespace Modify {
/** https://discord.com/developers/docs/resources/channel#modify-channel-json-params-group-dm */
Expand Down
15 changes: 15 additions & 0 deletions adapters/discord/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,28 @@ function prepareReactionSession(session: Partial<Session>, 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) {
if ((await bot._ensureWebhook(input.d.channel_id)).id === input.d.webhook_id) {
// koishi's webhook
return
}
}
if (input.d.author.id === bot.selfId) {
shigma marked this conversation as resolved.
Show resolved Hide resolved
return
}
session.type = 'message'
await adaptMessage(bot, input.d, session)
// dc 情况特殊 可能有 embeds 但是没有消息主体
// if (!session.content) return
} else if (input.t === 'MESSAGE_UPDATE') {
session.type = 'message-updated'
const msg = await bot.internal.getChannelMessage(input.d.channel_id, input.d.id)
if (msg.application_id === bot.selfId) {
return
}
if (msg.author.id === bot.selfId) {
return
}
// Unlike creates, message updates may contain only a subset of the full message object payload
// https://discord.com/developers/docs/topics/gateway-events#message-update
await adaptMessage(bot, msg, session)
Expand Down