diff --git a/adapters/qq/src/message.ts b/adapters/qq/src/message.ts index 0a062f0f..39e128ae 100644 --- a/adapters/qq/src/message.ts +++ b/adapters/qq/src/message.ts @@ -5,6 +5,10 @@ import FormData from 'form-data' import { escape } from '@satorijs/element' import { QQGuildBot } from './bot/guild' +export const escapeMarkdown = (val: string) => + val + .replace(/([\\`*_[\*_~`\]\-(#!>])/g, '\\$&') + export class QQGuildMessageEncoder extends MessageEncoder { private content: string = '' private file: Buffer @@ -24,34 +28,39 @@ export class QQGuildMessageEncoder extends MessageEncoder { const useFormData = Boolean(this.file) let r: QQ.Message this.bot.ctx.logger('qq').debug('use form data %s', useFormData) - if (useFormData) { - const form = new FormData() - form.append('content', this.content) - if (this.options?.session) { - form.append('msg_id', this.options?.session?.messageId) - } - if (this.file) { - form.append('file_image', this.file, this.filename) - } - // if (this.fileUrl) { - // form.append('image', this.fileUrl) - // } - r = await this.bot.http.post(endpoint, form, { - headers: form.getHeaders(), - }) - } else { - r = await this.bot.http.post(endpoint, { - ...{ - content: this.content, - msg_id: this.options?.session?.messageId ?? this.options?.session?.id, - image: this.fileUrl, - }, - ...(this.reference ? { - messageReference: { - message_id: this.reference, + try { + if (useFormData) { + const form = new FormData() + form.append('content', this.content) + if (this.options?.session) { + form.append('msg_id', this.options?.session?.messageId) + } + if (this.file) { + form.append('file_image', this.file, this.filename) + } + // if (this.fileUrl) { + // form.append('image', this.fileUrl) + // } + r = await this.bot.http.post(endpoint, form, { + headers: form.getHeaders(), + }) + } else { + r = await this.bot.http.post(endpoint, { + ...{ + content: this.content, + msg_id: this.options?.session?.messageId ?? this.options?.session?.id, + image: this.fileUrl, }, - } : {}), - }) + ...(this.reference ? { + messageReference: { + message_id: this.reference, + }, + } : {}), + }) + } + } catch (e) { + this.bot.ctx.logger('qq').error(e) + this.bot.ctx.logger('qq').error('[response] %o', e.response?.data) } this.bot.ctx.logger('qq').debug(require('util').inspect(r, false, null, true)) @@ -121,33 +130,58 @@ export class QQGuildMessageEncoder extends MessageEncoder { export class QQMessageEncoder extends MessageEncoder { private content: string = '' + private useMarkdown = false + private rows: QQ.InlineKeyboardRow[] = [] + private buttonGroupState = false async flush() { - if (!this.content.trim()) return + if (!this.content.trim() && !this.rows.map(v => v.buttons).flat().length) return const data: QQ.SendMessageParams = { content: this.content, msg_type: 0, timestamp: Math.floor(Date.now() / 1000), msg_id: this.options?.session?.messageId, } + + if (this.useMarkdown) { + data.msg_type = 2 + delete data.content + data.markdown = { + content: escapeMarkdown(this.content) || ' ', + } + if (this.rows.length) { + data.keyboard = { + content: { + rows: this.rows, + }, + } + } + } const session = this.bot.session() session.type = 'send' - if (this.session.isDirect) { - const { sendResult: { msg_id } } = await this.bot.internal.sendPrivateMessage(this.session.channelId, data) - session.messageId = msg_id - } else { - // FIXME: missing message id - await this.bot.internal.sendMessage(this.guildId, data) + try { + if (this.session.isDirect) { + const { sendResult: { msg_id } } = await this.bot.internal.sendPrivateMessage(this.session.channelId, data) + session.messageId = msg_id + } else { + // FIXME: missing message id + await this.bot.internal.sendMessage(this.guildId, data) + } + } catch (e) { + this.bot.ctx.logger('qq').error(e) + this.bot.ctx.logger('qq').error('[response] %o', e.response?.data) } // this.results.push(session.event.message) // session.app.emit(session, 'send', session) this.content = '' + this.rows = [] } async sendFile(type: string, attrs: Dict) { if (!attrs.url.startsWith('http')) { return this.bot.ctx.logger('qq').warn('unsupported file url') } + await this.flush() let file_type = 0 if (type === 'image') file_type = 1 else if (type === 'video') file_type = 2 @@ -164,6 +198,26 @@ export class QQMessageEncoder extends MessageEncoder { } } + decodeButton(attrs: Dict, label: string) { + const result: QQ.Button = { + id: attrs.id, + render_data: { + label, + visited_label: label, + style: 0, + }, + action: { + type: attrs.type === 'completion' ? 2 + : (attrs.type === 'link' ? 0 : 1), + permission: { + type: 2, + }, + data: attrs.data, + }, + } + return result + } + async visit(element: h) { const { type, attrs, children } = element if (type === 'text') { @@ -172,6 +226,24 @@ export class QQMessageEncoder extends MessageEncoder { await this.sendFile(type, attrs) } else if (type === 'video' && attrs.url) { await this.sendFile(type, attrs) + } else if (type === 'button-group') { + this.useMarkdown = true + this.buttonGroupState = true + this.rows.push({ buttons: [] }) + await this.render(children) + this.buttonGroupState = false + } else if (type === 'button') { + this.useMarkdown = true + if (this.buttonGroupState) { + const last = this.rows[this.rows.length - 1] + last.buttons.push(this.decodeButton(attrs, children.join(''))) + } else { + this.rows.push({ + buttons: [ + this.decodeButton(attrs, children.join('')), + ], + }) + } } else if (type === 'message') { await this.flush() await this.render(children) diff --git a/adapters/qq/src/types.ts b/adapters/qq/src/types.ts index ab9ba8f4..2726d27e 100644 --- a/adapters/qq/src/types.ts +++ b/adapters/qq/src/types.ts @@ -1173,8 +1173,10 @@ export interface SendMessageParams { * 当发送 md,ark,embed 的时候 centent 字段需要填入随意内容,否则发送失败 */ msg_type: MessageType - markdown?: object - keyboard?: object + markdown?: { + content: string + } + keyboard?: Partial ark?: object image?: unknown message_reference?: object @@ -1209,6 +1211,12 @@ export interface UserMessage { attachments?: Attachment[] // not listed in document? } +export enum ChatType { + GROUP = 1, + DIRECT = 2, + CHANNEL = 3 +} + export interface Interaction { id: string type: 11 @@ -1217,7 +1225,7 @@ export interface Interaction { guild_id: string channel_id: string group_open_id: string - chat_type: number // @TODO enum + chat_type: ChatType data: { resolved: { button_data: string @@ -1238,3 +1246,36 @@ export interface UserEvent { timestamp: number openid: string } + +export interface MessageKeyboard { + id: string + content: InlineKeyboard +} + +export interface InlineKeyboard { + rows: InlineKeyboardRow[] +} + +export interface InlineKeyboardRow { + buttons: Button[] +} + +export interface Button { + id?: string + render_data: { + label: string + visited_label: string + style: number + } + action: { + type: number + permission: { + type: number + specify_role_ids?: string[] + specify_user_ids?: string + } + click_limit?: number + data: string + at_bot_show_channel_list?: string + } +} diff --git a/adapters/qq/src/utils.ts b/adapters/qq/src/utils.ts index 9ccc4f99..c4bc5bd2 100644 --- a/adapters/qq/src/utils.ts +++ b/adapters/qq/src/utils.ts @@ -110,7 +110,7 @@ export async function adaptSession(bot: QQBot, input: QQ.DispatchPayload) { let session = bot.session() if (!['GROUP_AT_MESSAGE_CREATE', 'C2C_MESSAGE_CREATE', 'FRIEND_ADD', 'FRIEND_DEL', - 'GROUP_ADD_ROBOT', 'GROUP_DEL_ROBOT'].includes(input.t)) { + 'GROUP_ADD_ROBOT', 'GROUP_DEL_ROBOT', 'INTERACTION_CREATE'].includes(input.t)) { session = bot.guildBot.session() } @@ -187,6 +187,28 @@ export async function adaptSession(bot: QQBot, input: QQ.DispatchPayload) { session.timestamp = input.d.timestamp session.guildId = input.d.group_openid session.operatorId = input.d.op_member_openid + } else if (input.t === 'INTERACTION_CREATE') { + session.type = 'interaction/button' + session.userId = input.d.data.resolved.user_id + if (input.d.chat_type === QQ.ChatType.GROUP) { + session.guildId = input.d.group_open_id + session.channelId = session.guildId + session.isDirect = false + } else if (input.d.chat_type === QQ.ChatType.CHANNEL) { + session.channelId = session.userId + session.isDirect = true + } + session.event.button = { + id: input.d.data.resolved.button_id, + } + // session.messageId = input.d.id // event_id is not supported for sending message + + // {message: 'get header appid failed', code: 630006} + try { + await bot.internal.acknowledgeInteraction(input.d.id, 0) + } catch (e) { + bot.ctx.logger('qq').warn(e) + } } else { return } diff --git a/packages/core/src/bot.ts b/packages/core/src/bot.ts index 71921cdb..96e4879c 100644 --- a/packages/core/src/bot.ts +++ b/packages/core/src/bot.ts @@ -1,4 +1,4 @@ -import { pick, remove } from 'cosmokit' +import { Dict, pick, remove } from 'cosmokit' import { Context, Fragment } from '.' import { Adapter } from './adapter' import { MessageEncoder } from './message' @@ -26,6 +26,7 @@ export abstract class Bot implements Login { public platform: string public adapter?: Adapter public error?: Error + public callbacks: Dict = {} protected context: Context protected _status: Status = Status.OFFLINE @@ -42,6 +43,11 @@ export abstract class Bot implements Login { }) ctx.on('dispose', () => this.dispose()) + + ctx.on('interaction/button', (session: Session) => { + const cb = this.callbacks[session.event.button.id] + if (cb) cb(session) + }) } update(login: Login) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b74f3283..2b540892 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -53,6 +53,7 @@ type EventCallback = (this: Session, session: Se export interface Events extends cordis.Events { 'internal/session'(session: Session): void 'interaction/command'(session: Session): void + 'interaction/button'(session: Session): void 'message'(session: Session): void 'message-created'(session: Session): void 'message-deleted'(session: Session): void diff --git a/packages/core/src/message.ts b/packages/core/src/message.ts index 4ae4cd34..75cdcb75 100644 --- a/packages/core/src/message.ts +++ b/packages/core/src/message.ts @@ -14,9 +14,9 @@ export abstract class MessageEncoder { public results: Message[] = [] public session: Session - constructor(public bot: B, public channelId: string, public guildId?: string, public options: SendOptions = {}) {} + constructor(public bot: B, public channelId: string, public guildId?: string, public options: SendOptions = {}) { } - async prepare() {} + async prepare() { } abstract flush(): Promise abstract visit(element: h): Promise @@ -40,6 +40,12 @@ export abstract class MessageEncoder { }) await this.prepare() this.session.elements = h.normalize(content) + const btns = h.select(this.session.elements, 'button').filter(v => v.attrs.type !== 'link' && !v.attrs.id) + for (const btn of btns) { + const r = (Math.random() + 1).toString(36).substring(7) + btn.attrs.id ||= r + if (typeof btn.attrs.action === 'function') this.bot.callbacks[btn.attrs.id] = btn.attrs.action + } if (await this.session.app.serial(this.session, 'before-send', this.session, this.options)) return const session = this.options.session ?? this.session await this.render(await session.transform(this.session.elements)) diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 77b6f0ca..bd0d8742 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -222,6 +222,10 @@ export interface Message { updatedAt?: number } +export interface Button { + id: string +} + export interface Command { name: string aliases: string[] @@ -289,6 +293,7 @@ export interface Event { operator?: User role?: GuildRole user?: User + button?: Button _type?: string _data?: any /** @deprecated */