diff --git a/adapters/qq/package.json b/adapters/qq/package.json index 000e1d6c..609366fa 100644 --- a/adapters/qq/package.json +++ b/adapters/qq/package.json @@ -31,6 +31,7 @@ "chat" ], "devDependencies": { + "@noble/ed25519": "^2.1.0", "@satorijs/core": "^4.3.3", "cordis": "^3.18.1" }, diff --git a/adapters/qq/src/bot/index.ts b/adapters/qq/src/bot/index.ts index 7148f56a..f6d383cd 100644 --- a/adapters/qq/src/bot/index.ts +++ b/adapters/qq/src/bot/index.ts @@ -4,6 +4,7 @@ import * as QQ from '../types' import { QQGuildBot } from './guild' import { QQMessageEncoder } from '../message' import { GroupInternal } from '../internal' +import { HttpServer } from '../http' interface GetAppAccessTokenResult { access_token: string @@ -12,7 +13,10 @@ interface GetAppAccessTokenResult { export class QQBot extends Bot { static MessageEncoder = QQMessageEncoder - static inject = ['http'] + static inject = { + required: ['http'], + optional: ['server'], + } public guildBot: QQGuildBot @@ -41,16 +45,18 @@ export class QQBot extends Bot { parent: this, }) this.internal = new GroupInternal(this, () => this.http) - this.ctx.plugin(WsClient, this) + if (config.protocol === 'websocket') { + this.ctx.plugin(WsClient, this) + } else { + this.ctx.plugin(HttpServer, this) + } } async initialize() { - try { - const user = await this.guildBot.internal.getMe() - Object.assign(this.user, user) - } catch (e) { - this.logger.error(e) - } + const user = await this.guildBot.internal.getMe() + Object.assign(this.user, user) + this.user.name = user.username + this.user.isBot = true } async stop() { @@ -114,12 +120,16 @@ export class QQBot extends Bot { } export namespace QQBot { - export interface Config extends QQ.Options, WsClient.Options { + export interface BaseConfig extends QQ.Options { intents?: number retryWhen: number[] manualAcknowledge: boolean + protocol: 'websocket' | 'webhook' + path?: string } + export type Config = BaseConfig & (HttpServer.Options | WsClient.Options) + export const Config: Schema = Schema.intersect([ Schema.object({ id: Schema.string().description('机器人 id。').required(), @@ -131,8 +141,12 @@ export namespace QQBot { authType: Schema.union(['bot', 'bearer'] as const).description('采用的验证方式。').default('bearer'), intents: Schema.bitset(QQ.Intents).description('需要订阅的机器人事件。'), retryWhen: Schema.array(Number).description('发送消息遇到平台错误码时重试。').default([]), + protocol: Schema.union(['websocket', 'webhook']).description('选择要使用的协议。').default('websocket'), }), - WsClient.Options, + Schema.union([ + WsClient.Options, + HttpServer.Options, + ]), Schema.object({ manualAcknowledge: Schema.boolean().description('手动响应回调消息。').default(false), }).description('高级设置'), diff --git a/adapters/qq/src/http.ts b/adapters/qq/src/http.ts new file mode 100644 index 00000000..70250a53 --- /dev/null +++ b/adapters/qq/src/http.ts @@ -0,0 +1,101 @@ +import { Adapter, Binary, Context, Schema, Universal } from '@satorijs/core' +import { getPublicKeyAsync, signAsync, verifyAsync } from '@noble/ed25519' +import { QQBot } from './bot' +import { Opcode, Payload } from './types' +import { adaptSession } from './utils' +import { IncomingHttpHeaders } from 'node:http' +import { } from '@cordisjs/plugin-server' + +export class HttpServer extends Adapter> { + static inject = ['server'] + + async connect(bot: QQBot) { + if (bot.config.authType === 'bearer') { + await bot.getAccessToken() + } + await this.initialize(bot) + + bot.ctx.server.post(bot.config.path, async (ctx) => { + const bot = this.bots.find(bot => bot.config.id === ctx.get('X-Bot-Appid')) + if (!bot) return ctx.status = 403 + + ctx.status = 200 + const payload: Payload = ctx.request.body + if (payload.op === Opcode.ADDRESS_VERIFICATION) { + const key = this.getPrivateKey(bot.config.secret) + const data = payload.d.event_ts + payload.d.plain_token + const sig = await signAsync(new TextEncoder().encode(data), key) + return ctx.body = { + plain_token: payload.d.plain_token, + signature: Binary.toHex(sig), + } + } else if (payload.op === Opcode.DISPATCH) { + // https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/sign.html + const key = this.getPrivateKey(bot.config.secret) + const body = ctx.request.body[Symbol.for('unparsedBody')] + if (!(await this.verify(key, ctx.request.header, body))) { + return ctx.status = 403 + } + + if (bot.status !== Universal.Status.ONLINE) { + await this.initialize(bot) + } + bot.dispatch(bot.session({ + type: 'internal', + _type: 'qq/' + payload.t.toLowerCase().replace(/_/g, '-'), + _data: payload.d, + })) + const session = await adaptSession(bot, payload) + if (session) bot.dispatch(session) + } + + ctx.body = { + d: {}, + op: Opcode.HTTP_CALLBACK_ACK, + } + }) + } + + async initialize(bot: QQBot) { + try { + await bot.initialize() + bot.online() + } catch (e) { + if (bot.http.isError(e) && e.response) { + bot.logger.warn(`GET /users/@me response: %o`, e.response.data) + } else { + bot.logger.warn(e) + } + bot.offline() + } + } + + private getPrivateKey(secret: string) { + const seedSize = 32 + let seed = secret + if (seed.length < seedSize) { + seed = seed + seed.slice(0, seedSize - seed.length) + } + return new TextEncoder().encode(seed) + } + + private async verify(privateKey: Uint8Array, header: IncomingHttpHeaders, body: string) { + const sig = Binary.fromHex(header['x-signature-ed25519'] as string) + const timestamp = header['x-signature-timestamp'] as string + const msg = timestamp + body + const pubKey = await getPublicKeyAsync(privateKey) + return verifyAsync(new Uint8Array(sig), new TextEncoder().encode(msg), pubKey) + } +} + +export namespace HttpServer { + export interface Options { + protocol: 'webhook' + path: string + } + + export const Options: Schema = Schema.object({ + protocol: Schema.const('webhook').required(), + path: Schema.string().role('url').description('服务器监听的路径。').default('/qq'), + }) +} diff --git a/adapters/qq/src/types.ts b/adapters/qq/src/types.ts index 32357c71..9c58431f 100644 --- a/adapters/qq/src/types.ts +++ b/adapters/qq/src/types.ts @@ -111,7 +111,9 @@ export enum Opcode { /** 当发送心跳成功之后,就会收到该消息 */ HEARTBEAT_ACK = 11, /** 仅用于 http 回调模式的回包,代表机器人收到了平台推送的数据 */ - HTTP_CAKKBACK_ACK = 12 + HTTP_CALLBACK_ACK = 12, + /** 开放平台对机器人服务端进行验证 */ + ADDRESS_VERIFICATION = 13, } export type WithOpUser = T & { op_user_id: string } @@ -247,6 +249,12 @@ export type Payload = DispatchPayload | { } } | { op: Opcode.INVALID_SESSION +} | { + op: Opcode.ADDRESS_VERIFICATION + d: { + plain_token: string + event_ts: number + } } export interface Attachment { @@ -869,7 +877,6 @@ export interface Options { /** 是否开启沙箱模式 */ sandbox?: boolean endpoint?: string - /** 目前还不支持 bearer 验证方式。 */ authType?: 'bot' | 'bearer' /** 重连次数 */ retryTimes?: number diff --git a/adapters/qq/src/ws.ts b/adapters/qq/src/ws.ts index 3142ba5a..6e741661 100644 --- a/adapters/qq/src/ws.ts +++ b/adapters/qq/src/ws.ts @@ -75,7 +75,11 @@ export class WsClient extends Adapter.WsClient extends Adapter.WsClient = Schema.intersect([ + Schema.object({ + protocol: Schema.const('websocket').required(), + }), Adapter.WsClientConfig, ]) }