Skip to content

Commit

Permalink
feat(qq): support webhook (#327)
Browse files Browse the repository at this point in the history
Co-authored-by: Shigma <[email protected]>
  • Loading branch information
idranme and shigma authored Dec 23, 2024
1 parent 1ddb604 commit 2c9aa14
Show file tree
Hide file tree
Showing 5 changed files with 146 additions and 14 deletions.
1 change: 1 addition & 0 deletions adapters/qq/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"chat"
],
"devDependencies": {
"@noble/ed25519": "^2.1.0",
"@satorijs/core": "^4.3.3",
"cordis": "^3.18.1"
},
Expand Down
34 changes: 24 additions & 10 deletions adapters/qq/src/bot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -12,7 +13,10 @@ interface GetAppAccessTokenResult {

export class QQBot<C extends Context = Context> extends Bot<C, QQBot.Config> {
static MessageEncoder = QQMessageEncoder
static inject = ['http']
static inject = {
required: ['http'],
optional: ['server'],
}

public guildBot: QQGuildBot<C>

Expand Down Expand Up @@ -41,16 +45,18 @@ export class QQBot<C extends Context = Context> extends Bot<C, QQBot.Config> {
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() {
Expand Down Expand Up @@ -114,12 +120,16 @@ export class QQBot<C extends Context = Context> extends Bot<C, QQBot.Config> {
}

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<Config> = Schema.intersect([
Schema.object({
id: Schema.string().description('机器人 id。').required(),
Expand All @@ -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('高级设置'),
Expand Down
101 changes: 101 additions & 0 deletions adapters/qq/src/http.ts
Original file line number Diff line number Diff line change
@@ -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<C extends Context = Context> extends Adapter<C, QQBot<C>> {
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<Options> = Schema.object({
protocol: Schema.const('webhook').required(),
path: Schema.string().role('url').description('服务器监听的路径。').default('/qq'),
})
}
11 changes: 9 additions & 2 deletions adapters/qq/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = T & { op_user_id: string }
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -869,7 +877,6 @@ export interface Options {
/** 是否开启沙箱模式 */
sandbox?: boolean
endpoint?: string
/** 目前还不支持 bearer 验证方式。 */
authType?: 'bot' | 'bearer'
/** 重连次数 */
retryTimes?: number
Expand Down
13 changes: 11 additions & 2 deletions adapters/qq/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ export class WsClient<C extends Context = Context> extends Adapter.WsClient<C, Q
this._sessionId = parsed.d.session_id
this.bot.user = decodeUser(parsed.d.user)
this.bot.guildBot.user = this.bot.user
await this.bot.initialize()
try {
await this.bot.initialize()
} catch (e) {
this.bot.logger.warn(e)
}
return this.bot.online()
}
if (parsed.t === 'RESUMED') {
Expand All @@ -99,9 +103,14 @@ export class WsClient<C extends Context = Context> extends Adapter.WsClient<C, Q
}

export namespace WsClient {
export interface Options extends Adapter.WsClientConfig { }
export interface Options extends Adapter.WsClientConfig {
protocol: 'websocket'
}

export const Options: Schema<Options> = Schema.intersect([
Schema.object({
protocol: Schema.const('websocket').required(),
}),
Adapter.WsClientConfig,
])
}

0 comments on commit 2c9aa14

Please sign in to comment.