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(qq): support webhook #327

Merged
merged 12 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
])
}
Loading