diff --git a/adapters/qqguild/package.json b/adapters/qqguild/package.json index dbbd83c4..801dece3 100644 --- a/adapters/qqguild/package.json +++ b/adapters/qqguild/package.json @@ -29,7 +29,6 @@ "@satorijs/satori": "^3.0.0-alpha.1" }, "dependencies": { - "@qq-guild-sdk/core": "^2.2.2", "qface": "^1.4.1" } } diff --git a/adapters/qqguild/src/bot.ts b/adapters/qqguild/src/bot.ts index 91ec2b02..de8630dd 100644 --- a/adapters/qqguild/src/bot.ts +++ b/adapters/qqguild/src/bot.ts @@ -1,67 +1,125 @@ -import * as QQGuild from '@qq-guild-sdk/core' -import { Bot, Context, h, Schema } from '@satorijs/satori' -import { adaptGuild, adaptUser } from './utils' +import { Bot, Context, defineProperty, Quester, Schema, Universal } from '@satorijs/satori' +import { adaptChannel, adaptGuild, adaptUser, decodeGuildMember, decodeMessage } from './utils' import { QQGuildMessageEncoder } from './message' import { WsClient } from './ws' +import { Internal } from './internal' +import * as QQGuild from './types' export class QQGuildBot extends Bot { static MessageEncoder = QQGuildMessageEncoder - internal: QQGuild.Bot + internal: Internal + http: Quester constructor(ctx: Context, config: QQGuildBot.Config) { super(ctx, config) this.platform = 'qqguild' - this.internal = new QQGuild.Bot(config as QQGuild.Bot.Options) + let endpoint = config.endpoint + if (config.sandbox) { + endpoint = endpoint.replace(/^(https?:\/\/)/, '$1sandbox.') + } + this.http = ctx.http.extend({ + endpoint, + headers: { + Authorization: `Bot ${this.config.app.id}.${this.config.app.token}`, + }, + }) + this.internal = new Internal(this.http) ctx.plugin(WsClient, this) } + session(payload?: any, input?: any) { + return defineProperty(super.session(payload), 'qqguild', Object.assign(Object.create(this.internal), input)) + } + async getLogin() { - this.user = adaptUser(await this.internal.me) - return { status: this.status, user: this.user } + this.user = adaptUser(await this.internal.getMe()) + return this.toJSON() } - async getGuildList() { - const guilds = await this.internal.guilds + async getUser(userId: string, guildId?: string): Promise { + const { user } = await this.getGuildMember(guildId, userId) + return user + } + + async getGuildList(next?: string) { + const guilds = await this.internal.getGuilds() return { data: guilds.map(adaptGuild) } } - adaptMessage(msg: QQGuild.Message) { - const { id: messageId, author, guildId, channelId, timestamp } = msg - const session = this.session() - session.type = 'message' - session.guildId = guildId - session.messageId = messageId - session.channelId = channelId - session.timestamp = +timestamp - session.data.user = adaptUser(author) - // TODO https://github.com/satorijs/satori/blob/fbcf4665c77381ff80c8718106d2282a931d5736/packages/core/src/message.ts#L23 - // satori core need set guildId is undefined when isPrivate - // this is a temporary solution - if (msg.isPrivate) { - session.guildId = undefined - session.channelId = msg.guildId + async getGuild(guildId: string) { + const guild = await this.internal.getGuild(guildId) + return adaptGuild(guild) + } + + async getChannelList(guildId: string, next?: string): Promise> { + const channels = await this.internal.getChannels(guildId) + return { data: channels.map(adaptChannel) } + } + + async getChannel(channelId: string): Promise { + const channel = await this.internal.getChannel(channelId) + return adaptChannel(channel) + } + + async getGuildMemberList(guildId: string, next?: string): Promise> { + const members = await this.internal.getGuildMembers(guildId, { + limit: 400, + after: next, + }) + return { data: members.map(decodeGuildMember), next: members[members.length - 1].user.id } + } + + async getGuildMember(guildId: string, userId: string): Promise { + const member = await this.internal.getGuildMember(guildId, userId) + return decodeGuildMember(member) + } + + async kickGuildMember(guildId: string, userId: string) { + await this.internal.deleteGuildMember(guildId, userId) + } + + async muteGuildMember(guildId: string, userId: string, duration: number) { + await this.internal.muteGuildMember(guildId, userId, duration) + } + + async getReactionList(channelId: string, messageId: string, emoji: string, next?: string): Promise> { + const [type, id] = emoji.split(':') + const { users, cookie } = await this.internal.getReactions(channelId, messageId, type, id, { + limit: 50, + cookie: next, + }) + return { next: cookie, data: users.map(adaptUser) } + } + + async createReaction(channelId: string, messageId: string, emoji: string) { + const [type, id] = emoji.split(':') + await this.internal.createReaction(channelId, messageId, type, id) + } + + async deleteReaction(channelId: string, messageId: string, emoji: string) { + const [type, id] = emoji.split(':') + await this.internal.deleteReaction(channelId, messageId, type, id) + } + + async getMessage(channelId: string, messageId: string): Promise { + const r = await this.internal.getMessage(channelId, messageId) + return decodeMessage(this, r) + } + + async deleteMessage(channelId: string, messageId: string) { + if (channelId.includes('_')) { + // direct message + const [guildId] = channelId.split('_') + await this.internal.deleteDM(guildId, messageId) } else { - session.guildId = msg.guildId - session.channelId = msg.channelId + await this.internal.deleteMessage(channelId, messageId) } - // it's useless, but I need it - session.subtype = msg.isPrivate ? 'private' : 'group' - session.isDirect = msg.isPrivate - session.content = (msg.content ?? '') - .replace(/<@!(.+)>/, (_, $1) => h.at($1).toString()) - .replace(/<#(.+)>/, (_, $1) => h.sharp($1).toString()) - const { attachments = [] } = msg as { attachments?: any[] } - session.content = attachments - .filter(({ contentType }) => contentType.startsWith('image')) - .reduce((content, attachment) => content + h.image(attachment.url), session.content) - session.elements = h.parse(session.content) - return session } } export namespace QQGuildBot { - type BotOptions = QQGuild.Bot.Options + type BotOptions = QQGuild.Options type CustomBotOptions = Omit & Partial> export interface Config extends Bot.Config, CustomBotOptions, WsClient.Config { intents?: number @@ -73,11 +131,12 @@ export namespace QQGuildBot { id: Schema.string().description('机器人 id。').required(), key: Schema.string().description('机器人 key。').role('secret').required(), token: Schema.string().description('机器人令牌。').role('secret').required(), + type: Schema.union(['public', 'private'] as const).description('机器人类型。').required(), }) as any, sandbox: Schema.boolean().description('是否开启沙箱模式。').default(true), endpoint: Schema.string().role('link').description('要连接的服务器地址。').default('https://api.sgroup.qq.com/'), authType: Schema.union(['bot', 'bearer'] as const).description('采用的验证方式。').default('bot'), - intents: Schema.bitset(QQGuild.Bot.Intents).description('需要订阅的机器人事件。').default(QQGuild.Bot.Intents.PUBLIC_GUILD_MESSAGES), + intents: Schema.bitset(QQGuild.Intents).description('需要订阅的机器人事件。').default(QQGuild.Intents.PUBLIC_GUILD_MESSAGES), }), WsClient.Config, ] as const) diff --git a/adapters/qqguild/src/index.ts b/adapters/qqguild/src/index.ts index 69a4daaa..2c735b4b 100644 --- a/adapters/qqguild/src/index.ts +++ b/adapters/qqguild/src/index.ts @@ -1,4 +1,4 @@ -import * as QQGuild from '@qq-guild-sdk/core' +import * as QQGuild from './types' import { QQGuildBot } from './bot' export { QQGuild } @@ -12,6 +12,6 @@ export default QQGuildBot declare module '@satorijs/core' { interface Session { - qqguild?: QQGuild.Bot + qqguild?: QQGuild.Payload } } diff --git a/adapters/qqguild/src/internal.ts b/adapters/qqguild/src/internal.ts new file mode 100644 index 00000000..654c81fe --- /dev/null +++ b/adapters/qqguild/src/internal.ts @@ -0,0 +1,101 @@ +import { Quester } from '@satorijs/satori' +import * as QQGuild from './types' + +export class Internal { + constructor(private http: Quester) { } + + async getMe() { + return this.http.get('/users/@me') + } + + /** https://bot.q.qq.com/wiki/develop/api/openapi/dms/post_dms.html */ + async createDMS(recipient_id: string, source_guild_id: string) { + return this.http.post('/users/@me/dms', { + recipient_id, source_guild_id, + }) + } + + async getMessage(channelId: string, messageId: string) { + const { message } = await this.http.get<{ + message: QQGuild.Message + }>(`/channels/${channelId}/messages/${messageId}`) + return message + } + + async getGuilds(params?: Partial<{ + before: string + after: string + limit: number + }>) { + return this.http.get('/users/@me/guilds', { + params, + }) + } + + async getGuild(guild_id: string) { + return this.http.get(`/guilds/${guild_id}`) + } + + async getChannels(guild_id: string) { + return this.http.get(`/guilds/${guild_id}/channels`) + } + + async getChannel(channel_id: string) { + return this.http.get(`/channels/${channel_id}`) + } + + async getGuildMembers(guild_id: string, params?: Partial<{ + after: string + limit: number + }>) { + return this.http.get(`/guilds/${guild_id}/members`, { params }) + } + + async getGuildMember(guild_id: string, user_id: string) { + return this.http.get(`/guilds/${guild_id}/members/${user_id}`) + } + + async deleteGuildMember(guild_id: string, user_id: string) { + return this.http.delete(`/guilds/${guild_id}/members/${user_id}`) + } + + async getGuildRoles(guild_id: string) { + return this.http.get(`/guilds/${guild_id}/roles`) + } + + async muteGuildMember(guild_id: string, user_id: string, duration: number) { + return this.http.patch(`/guilds/${guild_id}/members/${user_id}/mute`, { + mute_seconds: duration / 1000, + }) + } + + async getReactions(channel_id: string, message_id: string, type: string, id: string, params?: Partial<{ + cookie: string + limit: number + }>) { + return this.http.get<{ + cookie: string + is_end: boolean + users: Pick[] + }>(`/channels/${channel_id}/messages/${message_id}/reactions/${type}/${id}`, { + params, + }) + } + + async createReaction(channel_id: string, message_id: string, type: string, id: string) { + return this.http.put(`/channels/${channel_id}/messages/${message_id}/reactions/${type}/${id}`) + } + + async deleteReaction(channel_id: string, message_id: string, type: string, id: string) { + return this.http.delete(`/channels/${channel_id}/messages/${message_id}/reactions/${type}/${id}`) + } + + async deleteMessage(channel_id: string, message_id: string) { + return this.http.delete(`/channels/${channel_id}/messages/${message_id}`) + } + + async deleteDM(guild_id: string, message_id: string) { + // guild_id 是 createDMS 之后的 id + return this.http.delete(`/dms/${guild_id}/messages/${message_id}`) + } +} diff --git a/adapters/qqguild/src/message.ts b/adapters/qqguild/src/message.ts index 45c2f2c9..5197e6dd 100644 --- a/adapters/qqguild/src/message.ts +++ b/adapters/qqguild/src/message.ts @@ -1,135 +1,113 @@ -import { fileURLToPath } from 'url' -import * as QQGuild from '@qq-guild-sdk/core' -import { Dict, h, Logger, MessageEncoder, Quester } from '@satorijs/satori' +import * as QQGuild from './types' +import { Dict, h, MessageEncoder } from '@satorijs/satori' import { QQGuildBot } from './bot' +import FormData from 'form-data' +import { decodeMessage } from './utils' +import { escape } from '@satorijs/element' -const logger = new Logger('satori') - -type File = { - type: 'url' - data: string -} | { - type: 'filepath' - data: string -} | { - type: 'buffer' - data: Buffer -} - -function dataUrlToBuffer(dataUrl: string) { - const [, data] = dataUrl.split(',') - return Buffer.from(data, 'base64') -} - -function base64ToBuffer(base64: string) { - return Buffer.from(base64, 'base64') -} +export class QQGuildMessageEncoder extends MessageEncoder { + private content: string = '' + private file: Buffer + private filename: string + fileUrl: string + reference: string + dms: QQGuild.DMS -function urlToBuffer(url: string) { - if (url.startsWith('data:')) { - return dataUrlToBuffer(url) - } else if (url.startsWith('base64:')) { - return base64ToBuffer(url.slice(9)) - } else { - throw new Error(`Unsupported url: ${url}`) + async initDms() { + const dms = await this.bot.internal.createDMS(this.options.session.userId, this.session.guildId || this.options.session.guildId) + this.bot.ctx.logger('qqguild').debug(require('util').inspect(dms, false, null, true)) + this.dms = dms } -} - -function checkEmpty(req: QQGuild.Message.Request) { - return req.content.trim() === '' - && req.image === undefined - && req.fileImage === undefined - // && req.ark === undefined - // && req.markdown === undefined - // && req.embed === undefined -} -export class QQGuildMessageEncoder extends MessageEncoder { - private mode: 'figure' | 'default' = 'default' - private content: string = '' - private addition = { - reference: null as string | null, - file: null as File | null, + async prepare() { + if (this.session.isDirect && !this.options.session) { + // initiative send + await this.initDms() + } } + // 先文后图 async flush() { - const { reference, file } = this.addition - const req: QQGuild.Message.Request = { - content: this.content, + if (!this.content.trim().length && !this.file && !this.fileUrl) { + return } - req.msgId = this.options?.session?.messageId - if (file) { - if (file.type === 'url') { - req.image = file.data - } else if (['filepath', 'buffer'].includes(file.type)) { - req.fileImage = file.data - } else { - throw new Error(`Unsupported file type: ${file.type}`) - } - this.addition.file = null - } else if (reference) { - req.messageReference = reference - this.addition.reference = null + let endpoint = `/channels/${this.session.channelId}/messages` + let srcGuildId // directMsg + if (this.session.isDirect && !this.options?.session) { + // initiative send + endpoint = `/dms/${this.dms.guild_id}/messages` + srcGuildId = this.session.guildId + } else if (this.session.isDirect && this.options?.session) { + // @ts-ignore + const payload = this.options.session.qqguild.d as QQGuild.Message + endpoint = `/dms/${payload.guild_id}/messages` + srcGuildId = payload.src_guild_id } - const sender = this.bot.internal.send as QQGuild.Sender - let result: QQGuild.Message.Response - - try { - if (checkEmpty(req)) { - return + const useFormData = Boolean(this.file) + let r: QQGuild.Message + this.bot.ctx.logger('qqguild').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.session.isDirect) { - result = await sender.private(this.session.uid, req) - } else { - result = await sender.channel(this.session.channelId, req) + if (this.file) { + form.append('file_image', this.file, this.filename) } - const session = this.bot.adaptMessage(result) - this.results.push(session) - session.app.emit(session, 'message', session) - } catch (e) { - if (Quester.isAxiosError(e)) { - const res = e.response - logger.warn(`QQGuild: ${res.status} ${res.statusText} [${res.data.code}](${res.data.message})`) - logger.warn(res.data.data) - } else { - logger.warn(e) - } - } finally { - this.content = '' + // 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, + image: this.fileUrl, + }, + ...(this.reference ? { + messageReference: { + message_id: this.reference, + }, + } : {}), + }) + } + + this.bot.ctx.logger('qqguild').debug(require('util').inspect(r, false, null, true)) + const session = this.bot.session() + await decodeMessage(this.bot, r, session) + if (this.session.isDirect) { + session.guildId = this.session.guildId + session.channelId = this.session.channelId + session.isDirect = true } + + // https://bot.q.qq.com/wiki/develop/api/gateway/direct_message.html#%E6%B3%A8%E6%84%8F + this.results.push(session) + session.app.emit(session, 'send', session) + this.content = '' + this.file = null + this.filename = null + this.fileUrl = null } - resolveFile(attrs: Dict) { - const { url } = attrs as { url: string } - try { - if (!url) { - throw new Error('url is required') - } - let file: Partial - if (url.startsWith('file:')) { - file = { - type: 'filepath', - data: fileURLToPath(url), - } - } else if (['data:', 'base64:'].some((prefix) => url.startsWith(prefix))) { - file = { - type: 'buffer', - data: urlToBuffer(url), - } - } else { - throw new Error(`Unsupported url: ${url}`) - } - this.addition.file = file as File - } catch (e) { - logger.warn(e) + async resolveFile(attrs: Dict) { + if (attrs.url.startsWith('http')) { + return this.fileUrl = attrs.url } + const { data, filename } = await this.bot.ctx.http.file(attrs.url, attrs) + this.file = Buffer.from(data) + this.filename = filename } async visit(element: h) { const { type, attrs, children } = element if (type === 'text') { - this.content += attrs.content + this.content += escape(attrs.content) } else if (type === 'at') { switch (attrs.type) { case 'all': @@ -147,41 +125,18 @@ export class QQGuildMessageEncoder extends MessageEncoder { } else if (type === 'sharp') { this.content += `<#${attrs.id}>` } else if (type === 'quote') { + this.reference = attrs.id await this.flush() - this.addition.reference = attrs.id - } else if ((type === 'image' || type === 'video' || type === 'audio' || type === 'file') && attrs.url) { - this.resolveFile(attrs) + } else if (type === 'image' && attrs.url) { await this.flush() - } else if (type === 'figure') { + await this.resolveFile(attrs) + await this.flush() + } else if (type === 'message') { await this.flush() - this.mode = 'figure' await this.render(children) await this.flush() - this.mode = 'default' - } else if (type === 'message') { - if (this.mode === 'figure') { - await this.render(children) - this.content += '\n' - } else { - await this.flush() - await this.render(children) - await this.flush() - } } else { await this.render(children) } } - - async send(content: h.Fragment) { - try { - return await super.send(content) - } catch (e) { - // https://bot.q.qq.com/wiki/develop/api/openapi/error/error.html#错误码处理:~:text=304031,拉私信错误 - if ([304031, 304032, 304033].includes(e.code)) { - await this.bot.internal.createDMS(this.session.channelId, this.session.guildId || this.options.session.guildId) - return await super.send(content) - } - throw e - } - } } diff --git a/adapters/qqguild/src/types.ts b/adapters/qqguild/src/types.ts new file mode 100644 index 00000000..adcc47a3 --- /dev/null +++ b/adapters/qqguild/src/types.ts @@ -0,0 +1,703 @@ +export enum Intents { + /** + * 频道事件 + * - GUILD_CREATE 当机器人加入新guild时 + * - GUILD_UPDATE 当guild资料发生变更时 + * - GUILD_DELETE 当机器人退出guild时 + * - CHANNEL_CREATE 当channel被创建时 + * - CHANNEL_UPDATE 当channel被更新时 + * - CHANNEL_DELETE 当channel被删除时 + */ + GUILDS = 1 << 0, + /** + * 频道成员事件 + * - GUILD_MEMBER_ADD 当成员加入时 + * - GUILD_MEMBER_UPDATE 当成员资料变更时 + * - GUILD_MEMBER_REMOVE 当成员被移除时 + */ + GUILD_MEMBERS = 1 << 1, + /** + * 消息事件,仅 *私域* 机器人能够设置此 intents。 + * - MESSAGE_CREATE 发送消息事件,代表频道内的全部消息,而不只是 at 机器人的消息。内容与 AT_MESSAGE_CREATE 相同 + * - MESSAGE_DELETE 删除(撤回)消息事件 + */ + GUILD_MESSAGES = 1 << 9, + /** + * 频道表情表态事件 + * - MESSAGE_REACTION_ADD 为消息添加表情表态 + * - MESSAGE_REACTION_REMOVE 为消息删除表情表态 + */ + GUILD_MESSAGE_REACTIONS = 1 << 10, + /** + * 监听私聊消息事件 + * - DIRECT_MESSAGE_CREATE 当收到用户发给机器人的私信消息时 + * - DIRECT_MESSAGE_DELETE 删除(撤回)消息事件 + */ + DIRECT_MESSAGES = 1 << 12, + /** + * - INTERACTION_CREATE 互动事件创建时 + */ + INTERACTIONS = 1 << 26, + /** + * - MESSAGE_AUDIT_PASS 消息审核通过 + * - MESSAGE_AUDIT_REJECT 消息审核不通过 + */ + MESSAGE_AUDIT = 1 << 27, + /** + * 论坛事件,仅 *私域* 机器人能够设置此 intents。 + * - FORUM_THREAD_CREATE 当用户创建主题时 + * - FORUM_THREAD_UPDATE 当用户更新主题时 + * - FORUM_THREAD_DELETE 当用户删除主题时 + * - FORUM_POST_CREATE 当用户创建帖子时 + * - FORUM_POST_DELETE 当用户删除帖子时 + * - FORUM_REPLY_CREATE 当用户回复评论时 + * - FORUM_REPLY_DELETE 当用户回复评论时 + * - FORUM_PUBLISH_AUDIT_RESULT 当用户发表审核通过时 + */ + FORUM_EVENT = 1 << 28, + /** + * 音频相关事件 + * - AUDIO_START 音频开始播放时 + * - AUDIO_FINISH 音频播放结束时 + * - AUDIO_ON_MIC 上麦时 + * - AUDIO_OFF_MIC 下麦时 + */ + AUDIO_ACTION = 1 << 29, + /** + * 消息事件,此为公域的消息事件 + * - AT_MESSAGE_CREATE 当收到@机器人的消息时 + * - PUBLIC_MESSAGE_DELETE 当频道的消息被删除时 + */ + PUBLIC_GUILD_MESSAGES = 1 << 30 +} + +export enum Opcode { + /** 服务端进行消息推送 */ + DISPATCH = 0, + /** 客户端或服务端发送心跳 */ + HEARTBEAT = 1, + /** 客户端发送鉴权 */ + IDENTIFY = 2, + /** 客户端恢复连接 */ + RESUME = 6, + /** 服务端通知客户端重新连接 */ + RECONNECT = 7, + /** 当identify或resume的时候,如果参数有错,服务端会返回该消息 */ + INVALID_SESSION = 9, + /** 当客户端与网关建立ws连接之后,网关下发的第一条消息 */ + HELLO = 10, + /** 当发送心跳成功之后,就会收到该消息 */ + HEARTBEAT_ACK = 11 +} + +export type DispatchPayload = { + op: Opcode.DISPATCH + s: number + t: 'READY' + d: { + version: number + session_id: string + user: User + shard: [number, number] + } +} | { + op: Opcode.DISPATCH + s: number + t: 'RESUMED' + d: string +} | { + op: Opcode.DISPATCH + s: number + t: 'MESSAGE_CREATE' | 'AT_MESSAGE_CREATE' | 'DIRECT_MESSAGE_CREATE' + d: Message +} | { + op: Opcode.DISPATCH + s: number + t: 'MESSAGE_REACTION_ADD' | 'MESSAGE_REACTION_REMOVE' + d: MessageReaction +} | { + op: Opcode.DISPATCH + s: number + t: 'GUILD_CREATE' | 'GUILD_UPDATE' | 'GUILD_DELETE' + d: Guild +} | { + op: Opcode.DISPATCH + s: number + t: 'CHANNEL_CREATE' | 'CHANNEL_UPDATE' | 'CHANNEL_DELETE' + d: Channel +} | { + op: Opcode.DISPATCH + s: number + t: 'GUILD_MEMBER_ADD' | 'GUILD_MEMBER_UPDATE' | 'GUILD_MEMBER_DELETE' + d: MemberWithGuild +} | { + op: Opcode.DISPATCH + s: number + t: 'MESSAGE_DELETE' | 'PUBLIC_MESSAGE_DELETE' | 'DIRECT_MESSAGE_DELETE' + d: Message.DeletionPayload +} + +export type Payload = DispatchPayload | { + op: Opcode.HELLO + d: { + heartbeat_interval: number + } +} | { + op: Opcode.RECONNECT +} | { + op: Opcode.IDENTIFY + d: { + /** 是创建机器人的时候分配的,格式为Bot {appid}.{app_token} */ + token: string + /** 是此次连接所需要接收的事件,具体可参考 [Intents](https://bot.q.qq.com/wiki/develop/api/gateway/intents.html) */ + intents: Intents | number + /** + * 该参数是用来进行水平分片的。该参数是个拥有两个元素的数组。 + * 例如:[0, 4],代表分为四个片,当前链接是第 0 个片,业务稍后应该继续建立 shard 为[1, 4],[2, 4],[3, 4]的链接,才能完整接收事件。 + * 更多详细的内容可以参考 [Shard](https://bot.q.qq.com/wiki/develop/api/gateway/shard.html)。 + */ + shard?: [number, number] + /** 目前无实际作用 */ + properties?: { + } + } +} | { + op: Opcode.HEARTBEAT + /** 为客户端收到的最新的消息的 `s`,如果是第一次连接,传 `null`。 */ + d: number +} | { + op: Opcode.HEARTBEAT_ACK +} | { + op: Opcode.RESUME + d: { + token: string + session_id: string + seq: number + } +} | { + op: Opcode.INVALID_SESSION +} + +export interface Attachment { + content_type: string + filename: string + height: number + id: string + size: number + url: string + width: number +} + +export interface Message { + /** 消息 id */ + id: string + /** 消息创建者 */ + author: User + /** 消息内容 */ + content?: string + /** 频道 id */ + guild_id: string + /** 子频道 id */ + channel_id: string + /** 消息创建时间 */ + timestamp: string + /** 消息编辑时间 */ + edited_timestamp: string + /** 是否是@全员消息 */ + mention_everyone: boolean + /** 附件 */ + attachments: Attachment[] + /** embed */ + embeds: Message.Embed[] + /** 消息中@的人 */ + mentions?: User + /** 消息创建者的 member 信息 */ + member: Member + /** ark消息 */ + ark?: Message.Ark + /** 子频道消息 seq,用于消息间的排序,seq 在同一子频道中按从先到后的顺序递增,不同的子频道之间消息无法排序 */ + seq_in_channel?: string + /** 引用消息对象 */ + message_reference?: Message.Reference + /** 用于私信场景下识别真实的来源频道id */ + src_guild_id?: string + direct_message?: boolean +} + +export namespace Message { + export interface Ark { + /** ark 模板 id(需要先申请) */ + template_id: number + /** kv 值列表 */ + kv: ArkKv[] + } + export interface ArkKv { + key: string + value?: string + /** ark obj 类型的列表 */ + obj?: ArkObj[] + } + export interface ArkObj { + /** ark objkv 列表 */ + objKv: ArkObjKv[] + } + export interface ArkObjKv { + key: string + value: string + } + export interface EmbedField { + /** 字段名 */ + name: string + /** 字段值 */ + value: string + } + export interface Embed { + /** 标题 */ + title: string + /** 描述 */ + description: string + /** 消息弹窗内容 */ + prompt: string + /** 消息创建时间 */ + timestamp: Date + /** 对象数组 消息创建时间 */ + fields: EmbedField + } + export interface Markdown { + /** markdown 模板 id */ + template_id?: number + /** markdown 模板模板参数 */ + params?: MarkdownParams + /** 原生 markdown 内容,与 template_id 和 params 参数互斥,参数都传值将报错。 */ + content?: string + } + export interface MarkdownParams { + /** markdown 模版 key */ + key: string + /** markdown 模版 key 对应的 values ,列表长度大小为 1 代表单 value 值,长度大于1则为列表类型的参数 values 传参数 */ + values: string[] + } + export interface Reference { + /** 需要引用回复的消息 id */ + message_id: string + /** 是否忽略获取引用消息详情错误,默认否 */ + ignore_get_message_error?: boolean + } + export interface Request { + /** 选填,消息内容,文本内容,支持内嵌格式 */ + content?: string + /** 选填,embed 消息,一种特殊的 ark */ + embed?: Embed + /** 选填,ark 消息 */ + ark?: Ark + /** + * 选填,引用消息 + * + * 传入值为 string 类型时默认为 msgId + */ + messageReference?: string | Reference + /** + * 选填,图片 url 地址,平台会转存该图片,用于下发图片消息 + * + * 该 url 必须为 https 链接 + */ + image?: string + /** 图片文件。form-data 支持直接通过文件上传的方式发送图片。 */ + // @TODO fix type + // fileImage?: PathLike | ReadStream | Buffer + /** 选填,要回复的消息 id(Message.id), 在 AT_CREATE_MESSAGE 事件中获取。 */ + msgId?: string + /** 选填,要回复的事件 id, 在各事件对象中获取。 */ + eventId?: string + /** 选填,markdown 消息 */ + markdown?: string | Markdown + } + export interface Response extends Message { + tts: boolean + type: number + flags: number + pinned: boolean + embeds: Embed[] + mentionEveryone: boolean + } + + export interface DeletionPayload { + message: Partial + op_user: Pick + } +} + +export interface User { + id: string + username: string + avatar: string + bot: boolean +} + +export interface Role { + /** 身份组 ID , 默认值可参考 DefaultRoles */ + id: string + /** 名称 */ + name: string + /** ARGB 的 HEX 十六进制颜色值转换后的十进制数值 */ + color: number + /** 是否在成员列表中单独展示: 0-否, 1-是 */ + hoist: number + /** 人数 */ + number: number + /** 成员上限 */ + member_limit: number +} + +export enum DefaultRoles { + /** 全体成员 */ + ALL = 1, + /** 管理员 */ + ADMIN = 2, + /** 群主/创建者 */ + OWNER = 4, + /** 子频道管理员 */ + SUBCHANNEL_ADMIN = 5 +} + +export interface Member { + /** 用户基础信息,来自QQ资料,只有成员相关接口中会填充此信息 */ + user: User + /** 用户在频道内的昵称 */ + nick: string + /** 用户在频道内的身份组ID, 默认值可参考DefaultRoles */ + roles: string[] + /** 用户加入频道的时间 */ + joined_at: string +} + +export interface Guild { + id: string + name: string + icon: string + owner: boolean + owner_id?: string + member_count?: number + max_members?: number + description?: number + joined_at?: string +} + +export enum ChannelType { + /** 文字子频道 */ + TEXT = 0, + /** 语音子频道 */ + VOICE = 2, + /** 子频道分组 */ + GROUP = 4, + /** 直播子频道 */ + LIVE = 10005, + /** 应用子频道 */ + APPLICATION = 10006, + /** 论坛子频道 */ + FORUM = 10007 +} + +export enum ChannelSubType { + /** 闲聊 */ + IDLE = 0, + /** 公告 */ + ANNOUNCEMENT = 1, + /** 攻略 */ + STRATEGY = 2, + /** 开黑 */ + BLACK = 3 +} + +export interface ChannelPermissions { + /** 子频道 id */ + channel_id: string + /** 用户 id */ + user_id: string + /** 用户拥有的子频道权限 */ + permissions: string +} + +export enum ChannelPrivateType { + /** 公开频道 */ + PUBLIC = 0, + /** 群主管理员可见 */ + ADMIN_ONLY = 1, + /** 群主管理员+指定成员 */ + SELECTED_MEMBERS = 2 +} + +export enum ChannelSpeakPermission { + /** 无效类型 */ + INVALID = 0, + /** 所有人 */ + ALL = 1, + /** 群主管理员+指定成员 */ + SELECTED_MEMBERS = 2 +} + +export interface Channel { + /** 子频道 id */ + id: string + /** 频道 id */ + guild_id: string + /** 子频道名 */ + name: string + /** 子频道类型 */ + type: ChannelType + /** 子频道子类型 */ + sub_type: ChannelSubType + /** 排序,必填,而且不能够和其他子频道的值重复 */ + position: number + /** 分组 id */ + parent_id: string + /** 创建人 id */ + owner_id: string + /** 子频道私密类型 */ + private_type: ChannelPrivateType + /** 子频道发言权限 */ + speak_permission: ChannelSpeakPermission + /** 用于标识应用子频道应用类型,仅应用子频道时会使用该字段 */ + application_id?: string + /** 子频道私密类型 */ + permissions: string +} + +export interface MemberWithGuild { + /** 频道 id */ + guild_id: string + /** 用户基础信息 */ + user: User + /** 用户在频道内的昵称 */ + nick: string + /** 用户在频道内的身份 */ + roles: string[] + /** 用户加入频道的时间 */ + joined_at: string +} + +/** + * 公告对象 + */ +export interface Announce { + /** 频道 id */ + guild_id: string + /** 子频道 id */ + channel_id: string + /** 消息 id */ + message_id: string +} + +/** + * 表情表态对象 + */ +export interface MessageReaction { + /** 用户 ID */ + user_id: string + /** 频道 ID */ + guild_id: string + /** 子频道 ID */ + channel_id: string + /** 表态对象 */ + target: ReactionTarget + /** 表态所用表情 */ + emoji: Emoji +} + +/** + * 表态对象 + */ +export interface ReactionTarget { + /** 表态对象 ID */ + id: string + /** 表态对象类型 */ + type: ReactionTargetType +} + +/** + * 表态对象类型 + */ +export enum ReactionTargetType { + /** 消息 */ + MESSAGE = 'ReactionTargetType_MSG', + /** 帖子 */ + POST = 'ReactionTargetType_FEED', + /** 评论 */ + COMMENT = 'ReactionTargetType_COMMNENT', + /** 回复 */ + REPLY = 'ReactionTargetType_REPLY' +} + +/** + * 表情对象 + */ +export interface Emoji { + /** + * 表情 ID + * 系统表情使用数字为 ID + * emoji 使用 emoji 本身为 id + */ + id: string + /** 表情类型 */ + type: number +} + +/** + * 表情类型 + */ +export enum EmojiType { + /** 系统表情 */ + SYSTEM = 1, + /** emoji 表情 */ + DEFAULT = 2 +} + +/** + * 日程对象 + */ +export interface Schedule { + /** 日程 id */ + id: string + /** 日程名称 */ + name: string + /** 日程描述 */ + description: string + /** 日程开始时间戳(ms) */ + start_timestamp: string + /** 日程结束时间戳(ms) */ + endTimestamp: string + /** 创建者 */ + creator: Member + /** 日程开始时跳转到的子频道 id */ + jumpchannel_id: string + /** 日程提醒类型,取值参考 RemindType */ + remindType: RemindType +} + +/** + * 日程提醒类型 + */ +export enum RemindType { + /** 不提醒 */ + NEVER = '0', + /** 开始时提醒 */ + START = '1', + /** 开始前5分钟提醒 */ + BEFORE_5 = '2', + /** 开始前15分钟提醒 */ + BEFORE_15 = '3', + /** 开始前30分钟提醒 */ + BEFORE_30 = '4', + /** 开始前60分钟提醒 */ + BEFORE_60 = '5', +} + +export interface Mute { + /** 禁言到期时间戳,绝对时间戳,单位:秒(与 mute_seconds 字段同时赋值的话,以该字段为准) */ + mute_end_timestamp?: string + /** 禁言多少秒(两个字段二选一,默认以 mute_end_timestamp 为准) */ + mute_seconds?: number + /** 禁言成员的user_id列表,即 User 的id */ + user_ids?: string[] +} + +export enum DeleteHistoryMsgDays { + ALL = -1, + NONE = 0, + DAY_3 = 3, + DAY_7 = 7, + DAY_15 = 15, + DAY_30 = 30, +} + +export interface MessageSetting { + /** 是否允许创建私信 */ + disable_create_dm: string + /** 是否允许发主动消息 */ + disable_push_msg: string + /** 子频道 id 数组 */ + channel_ids: string + /** 每个子频道允许主动推送消息最大消息条数 */ + channel_push_max_num: string +} + +/** + * 创建的私信会话 + */ +export interface DMS { + /** 私信会话关联的频道 id */ + guild_id: string + /** 私信会话关联的子频道 id */ + channel_id: string + /** 创建私信会话时间戳 */ + create_time: string +} + +/** + * 精华消息对象 + */ +export interface PinsMessage { + /** 频道 id */ + guild_id: string + /** 子频道 id */ + channel_id: string + /** 子频道内精华消息 id 数组 */ + message_ids: string[] +} + +/** + * 接口权限对象 + */ +export interface APIPermission { + /** API 接口名,例如 /guilds/{guild_id}/members/{user_id} */ + path: string + /** 请求方法,例如 GET */ + method: string + /** API 接口名称,例如 获取频道信息 */ + desc: string + /** 授权状态,auth_stats 为 1 时已授权 */ + auth_status: number +} + +export interface APIPermissionDemandIdentify { + /** API 接口名,例如 /guilds/{guild_id}/members/{user_id} */ + path: string + /** 请求方法,例如 GET */ + method: string +} + +/** + * 接口权限需求对象 + */ +export interface APIPermissionDemand { + /** 申请接口权限的频道 id */ + guild_id: string + /** 接口权限需求授权链接发送的子频道 id */ + channel_id: string + /** 权限接口唯一标识 */ + api_identify: APIPermissionDemandIdentify + /** 接口权限链接中的接口权限描述信息 */ + title: string + /** 接口权限链接中的机器人可使用功能的描述信息 */ + desc: string +} + +export interface AppConfig { + id: string + key: string + token: string + type: 'public' | 'private' +} + +export interface Options { + app: AppConfig + /** 是否开启沙箱模式 */ + sandbox: boolean + endpoint?: string + /** 目前还不支持 bearer 验证方式。 */ + authType?: 'bot' | 'bearer' + /** 重连次数 */ + retryTimes?: number + /** 重连时间间隔,单位 ms */ + retryInterval?: number +} diff --git a/adapters/qqguild/src/utils.ts b/adapters/qqguild/src/utils.ts index 60f21529..554b4128 100644 --- a/adapters/qqguild/src/utils.ts +++ b/adapters/qqguild/src/utils.ts @@ -1,16 +1,129 @@ -import { Universal } from '@satorijs/satori' -import * as QQGuild from '@qq-guild-sdk/core' +import { h, Session, Universal } from '@satorijs/satori' +import * as QQGuild from './types' +import { QQGuildBot } from './bot' +import { unescape } from '@satorijs/element' export const adaptGuild = (guild: QQGuild.Guild): Universal.Guild => ({ id: guild.id, name: guild.name, }) +export const adaptChannel = (channel: QQGuild.Channel): Universal.Channel => ({ + id: channel.id, + name: channel.name, + // TODO support more channel types + type: Universal.Channel.Type.TEXT, +}) + export const adaptUser = (user: QQGuild.User): Universal.User => ({ id: user.id, name: user.username, isBot: user.bot, avatar: user.avatar, - userId: user.id, - username: user.username, }) + +export const decodeGuildMember = (member: QQGuild.Member): Universal.GuildMember => ({ + user: adaptUser(member.user), + name: member.nick, + roles: member.roles, +}) + +export async function decodeMessage(bot: QQGuildBot, msg: QQGuild.Message, session: Partial = {}): Promise { + const { id: messageId, author, guild_id, channel_id, timestamp } = msg + session.type = 'message' + session.guildId = guild_id + session.messageId = messageId + session.channelId = channel_id + session.timestamp = new Date(timestamp).valueOf() + + session.author = adaptUser(msg.author) + session.userId = author.id + if (msg.direct_message) { + // real guild id, dm's fake guild id + session.guildId = `${msg.src_guild_id}_${msg.guild_id}` + session.channelId = `${msg.guild_id}_${msg.channel_id}` + } else { + session.guildId = guild_id + session.channelId = channel_id + } + session.isDirect = !!msg.direct_message + session.content = (msg.content ?? '') + .replace(/<@!(\d+)>/g, (_, $1) => h.at($1).toString()) + // .replace(/<#(.+)>/, (_, $1) => h.sharp($1).toString()) // not used? + const { attachments = [] } = msg + session.content = attachments + .filter(({ content_type }) => content_type.startsWith('image')) + .reduce((content, attachment) => content + h.image('https://' + attachment.url), session.content) + session.elements = h.parse(session.content) + session.elements = h.transform(session.elements, { + text: (attrs) => unescape(attrs.content), + }) + + if (msg.message_reference) { + session.quote = await bot.getMessage(msg.channel_id, msg.message_reference.message_id) + } + + return session +} + +export function setupReaction(session: Partial, data: QQGuild.MessageReaction) { + session.userId = data.user_id + session.guildId = data.guild_id + session.channelId = data.channel_id + session.content = `${data.emoji.type}:${data.emoji.id}` + // https://bot.q.qq.com/wiki/develop/api/openapi/reaction/model.html#reactiontargettype + session.messageId = data.target.id + session.isDirect = false + // @TODO type + return session +} + +export async function adaptSession(bot: QQGuildBot, input: QQGuild.DispatchPayload) { + const session = bot.session({}, input) + if (input.t === 'MESSAGE_CREATE' || input.t === 'AT_MESSAGE_CREATE' || input.t === 'DIRECT_MESSAGE_CREATE') { + if (bot.config.app.type === 'private' && input.t === 'AT_MESSAGE_CREATE') return + await decodeMessage(bot, input.d, session) + } else if (input.t === 'MESSAGE_REACTION_ADD') { + if (input.d.target.type !== 'ReactionTargetType_MSG') return + setupReaction(session, input.d) + session.type = 'reaction-added' + } else if (input.t === 'MESSAGE_REACTION_REMOVE') { + if (input.d.target.type !== 'ReactionTargetType_MSG') return + setupReaction(session, input.d) + session.type = 'reaction-removed' + } else if (input.t === 'CHANNEL_CREATE' || input.t === 'CHANNEL_UPDATE' || input.t === 'CHANNEL_DELETE') { + session.type = { + CHANNEL_CREATE: 'channel-added', + CHANNEL_UPDATE: 'channel-updated', + CHANNEL_DELETE: 'channel-deleted', + }[input.t] + session.guildId = input.d.guild_id + session.channelId = input.d.id + session.channelName = input.d.name + } else if (input.t === 'GUILD_CREATE' || input.t === 'GUILD_UPDATE' || input.t === 'GUILD_DELETE') { + session.type = { + GUILD_CREATE: 'guild-added', + GUILD_UPDATE: 'guild-updated', + GUILD_DELETE: 'guild-deleted', + }[input.t] + session.guildId = input.d.id + session.guildName = input.d.name + } else if (input.t === 'DIRECT_MESSAGE_DELETE' || input.t === 'MESSAGE_DELETE' || input.t === 'PUBLIC_MESSAGE_DELETE') { + if (bot.config.app.type === 'private' && input.t === 'PUBLIC_MESSAGE_DELETE') return + session.type = 'message-deleted' + session.userId = input.d.message.author.id + session.operatorId = input.d.op_user.id + session.messageId = input.d.message.id + session.isDirect = input.d.message.direct_message + if (session.isDirect) { + session.guildId = `${input.d.message.src_guild_id}_${input.d.message.guild_id}` + session.channelId = `${input.d.message.guild_id}_${input.d.message.channel_id}` + } else { + session.guildId = input.d.message.guild_id + session.channelId = input.d.message.channel_id + } + } else { + return + } + return session +} diff --git a/adapters/qqguild/src/ws.ts b/adapters/qqguild/src/ws.ts index b4661c99..38cdbc2f 100644 --- a/adapters/qqguild/src/ws.ts +++ b/adapters/qqguild/src/ws.ts @@ -1,25 +1,87 @@ -import { Adapter, Schema } from '@satorijs/satori' +import { Adapter, Logger, Schema } from '@satorijs/satori' import { QQGuildBot } from './bot' +import { Opcode, Payload } from './types' +import { adaptSession } from './utils' -export class WsClient extends Adapter.Client { - async start(bot: QQGuildBot) { - await bot.getLogin() - await bot.internal.startClient(bot.config.intents) - bot.internal.on('ready', bot.online.bind(bot)) - bot.internal.on('message', msg => { - const session = bot.adaptMessage(msg) - if (session) bot.dispatch(session) +const logger = new Logger('qqguild') +export class WsClient extends Adapter.WsClient { + _sessionId = '' + _s: number = null + _ping: NodeJS.Timeout + + async prepare() { + const { url } = await this.bot.http.get(`/gateway`) + logger.debug('url: %s', url) + return this.bot.http.ws(url) + } + + heartbeat() { + this.bot.socket.send(JSON.stringify({ + op: Opcode.HEARTBEAT, + s: this._s, + })) + } + + async accept(bot: QQGuildBot) { + bot.socket.addEventListener('message', async ({ data }) => { + const parsed: Payload = JSON.parse(data.toString()) + logger.debug(require('util').inspect(parsed, false, null, true)) + if (parsed.op === Opcode.HELLO) { + if (this._sessionId) { + bot.socket.send(JSON.stringify({ + op: Opcode.RESUME, + d: { + token: `Bot ${bot.config.app.id}.${bot.config.app.token}`, + session_id: this._sessionId, + seq: this._s, + }, + })) + } else { + bot.socket.send(JSON.stringify({ + op: Opcode.IDENTIFY, + d: { + token: `Bot ${bot.config.app.id}.${bot.config.app.token}`, + intents: 0 | bot.config.intents, + }, + })) + } + this._ping = setInterval(() => this.heartbeat(), parsed.d.heartbeat_interval) + } else if (parsed.op === Opcode.INVALID_SESSION) { + this._sessionId = '' + this._s = null + logger.warn('offline: invalid session') + bot.socket?.close() + } else if (parsed.op === Opcode.RECONNECT) { + logger.warn('offline: server request reconnect') + this.bot.socket?.close() + } else if (parsed.op === Opcode.DISPATCH) { + this._s = parsed.s + if (parsed.t === 'READY') { + this._sessionId = parsed.d.session_id + return bot.online() + } + if (parsed.t === 'RESUMED') { + return bot.online() + } + const session = await adaptSession(bot, parsed) + if (session) bot.dispatch(session) + logger.debug(require('util').inspect(session, false, null, true)) + } + }) + + bot.socket.addEventListener('close', (e) => { + clearInterval(this._ping) }) } - async stop(bot: QQGuildBot) { - bot.internal.stopClient() + async stop(bot: QQGuildBot): Promise { bot.offline() + bot.socket?.close() } } export namespace WsClient { - export interface Config extends Adapter.WsClient.Config {} + export interface Config extends Adapter.WsClient.Config { } export const Config: Schema = Schema.intersect([ Adapter.WsClient.Config,