@tencent-connect/openclaw-qqbot 1.7.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -8
- package/README.zh.md +24 -8
- package/bin/qqbot-cli.js +5 -2
- package/dist/index.cjs +15549 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +890 -0
- package/index.ts +56 -19
- package/openclaw.plugin.json +19 -3
- package/package.json +14 -23
- package/preload.cjs +17 -14
- package/scripts/link-sdk-core.cjs +133 -50
- package/scripts/upgrade-via-npm.sh +42 -11
- package/scripts/upgrade-via-source.sh +4 -4
- package/skills/qqbot-channel/SKILL.md +34 -12
- package/src/adapter/contract.ts +69 -0
- package/src/adapter/gateway.ts +61 -0
- package/src/adapter/index.ts +15 -0
- package/src/adapter/lint.ts +144 -0
- package/src/adapter/media.ts +112 -0
- package/src/adapter/pairing.ts +99 -0
- package/src/adapter/resolve.ts +333 -0
- package/src/adapter/setup.ts +55 -0
- package/src/adapter/webhook.ts +248 -0
- package/src/adapter/workspace.ts +29 -0
- package/src/bot-instance.ts +60 -0
- package/src/channel.ts +202 -413
- package/src/commands/bot-approve.ts +143 -0
- package/src/commands/bot-clear-storage.ts +114 -0
- package/src/commands/bot-group-always.ts +62 -0
- package/src/commands/bot-help.ts +40 -0
- package/src/commands/bot-logs.ts +248 -0
- package/src/commands/bot-me.ts +18 -0
- package/src/commands/bot-pairing.ts +57 -0
- package/src/commands/bot-ping.ts +33 -0
- package/src/commands/bot-streaming.ts +55 -0
- package/src/commands/bot-upgrade.ts +56 -0
- package/src/commands/bot-version.ts +41 -0
- package/src/commands/config-util.ts +96 -0
- package/src/commands/index.ts +49 -0
- package/src/config.ts +46 -19
- package/src/dispatch/body-assembler.ts +356 -0
- package/src/dispatch/ctx-builder.ts +110 -0
- package/src/dispatch/dispatch.ts +352 -0
- package/src/dispatch/envelope-builder.ts +112 -0
- package/src/dispatch/index.ts +2 -0
- package/src/{approval-handler.ts → features/approval-handler.ts} +40 -94
- package/src/features/approval-utils.ts +41 -0
- package/src/features/credential-backup.ts +82 -0
- package/src/features/history-store.ts +19 -0
- package/src/features/msgid-cache.ts +55 -0
- package/src/features/onboarding.ts +38 -0
- package/src/{proactive.ts → features/proactive.ts} +68 -49
- package/src/features/ref-index-store.ts +282 -0
- package/src/{update-checker.ts → features/update-checker.ts} +15 -35
- package/src/gateway/event-handlers.ts +241 -0
- package/src/gateway/index.ts +3 -0
- package/src/gateway/lifecycle.ts +221 -0
- package/src/gateway/middleware-setup.ts +139 -0
- package/src/gateway/qqbot-gateway.ts +342 -0
- package/src/middleware/access-control.ts +130 -0
- package/src/middleware/attachment.ts +314 -0
- package/src/middleware/policy-injector.ts +66 -0
- package/src/openclaw-plugin-sdk.d.ts +81 -10
- package/src/outbound/cron-scheduler.ts +130 -0
- package/src/outbound/debounce.ts +102 -0
- package/src/outbound/deliver-pipeline.ts +235 -0
- package/src/outbound/image-size.ts +123 -0
- package/src/outbound/index.ts +3 -0
- package/src/outbound/local-file-router.ts +145 -0
- package/src/outbound/media-send.ts +377 -0
- package/src/outbound/outbound-service.ts +222 -0
- package/src/outbound/reply-limiter.ts +128 -0
- package/src/outbound/sanitize.ts +32 -0
- package/src/outbound/streaming-controller.ts +228 -0
- package/src/outbound/target.ts +63 -0
- package/src/outbound/tts-provider.ts +84 -0
- package/src/request-context.ts +9 -8
- package/src/runtime.ts +31 -6
- package/src/setup/account-key.ts +41 -0
- package/src/setup/finalize.ts +110 -0
- package/src/setup/login.ts +197 -0
- package/src/setup/surface.ts +40 -0
- package/src/tools/platform.ts +149 -0
- package/src/tools/remind.ts +2 -0
- package/src/types-augment.d.ts +54 -0
- package/src/types.ts +51 -9
- package/src/utils/mention.ts +52 -0
- package/src/utils/pkg-version.ts +84 -47
- package/src/utils/platform.ts +31 -7
- package/src/utils/plugin-logger.ts +104 -0
- package/src/utils/ssrf-guard.ts +32 -2
- package/src/utils/stt.ts +137 -0
- package/src/utils/voice-text.ts +61 -0
- package/tsconfig.json +2 -1
- package/tsup.config.ts +57 -0
- package/dist/index.d.ts +0 -18
- package/dist/index.js +0 -27
- package/dist/src/admin-resolver.d.ts +0 -33
- package/dist/src/admin-resolver.js +0 -157
- package/dist/src/api.d.ts +0 -309
- package/dist/src/api.js +0 -923
- package/dist/src/approval-handler.d.ts +0 -47
- package/dist/src/approval-handler.js +0 -372
- package/dist/src/channel.d.ts +0 -29
- package/dist/src/channel.js +0 -528
- package/dist/src/config.d.ts +0 -60
- package/dist/src/config.js +0 -288
- package/dist/src/credential-backup.d.ts +0 -31
- package/dist/src/credential-backup.js +0 -66
- package/dist/src/deliver-debounce.d.ts +0 -74
- package/dist/src/deliver-debounce.js +0 -174
- package/dist/src/gateway.d.ts +0 -18
- package/dist/src/gateway.js +0 -2025
- package/dist/src/group-history.d.ts +0 -136
- package/dist/src/group-history.js +0 -226
- package/dist/src/image-server.d.ts +0 -87
- package/dist/src/image-server.js +0 -570
- package/dist/src/inbound-attachments.d.ts +0 -60
- package/dist/src/inbound-attachments.js +0 -248
- package/dist/src/known-users.d.ts +0 -100
- package/dist/src/known-users.js +0 -263
- package/dist/src/message-gating.d.ts +0 -53
- package/dist/src/message-gating.js +0 -107
- package/dist/src/message-queue.d.ts +0 -91
- package/dist/src/message-queue.js +0 -257
- package/dist/src/onboarding.d.ts +0 -10
- package/dist/src/onboarding.js +0 -203
- package/dist/src/outbound-deliver.d.ts +0 -48
- package/dist/src/outbound-deliver.js +0 -392
- package/dist/src/outbound.d.ts +0 -220
- package/dist/src/outbound.js +0 -975
- package/dist/src/proactive.d.ts +0 -170
- package/dist/src/proactive.js +0 -399
- package/dist/src/ref-index-store.d.ts +0 -101
- package/dist/src/ref-index-store.js +0 -298
- package/dist/src/reply-dispatcher.d.ts +0 -35
- package/dist/src/reply-dispatcher.js +0 -311
- package/dist/src/request-context.d.ts +0 -25
- package/dist/src/request-context.js +0 -37
- package/dist/src/runtime.d.ts +0 -3
- package/dist/src/runtime.js +0 -13
- package/dist/src/session-store.d.ts +0 -52
- package/dist/src/session-store.js +0 -254
- package/dist/src/slash-commands.d.ts +0 -82
- package/dist/src/slash-commands.js +0 -2233
- package/dist/src/startup-greeting.d.ts +0 -30
- package/dist/src/startup-greeting.js +0 -97
- package/dist/src/streaming.d.ts +0 -247
- package/dist/src/streaming.js +0 -899
- package/dist/src/stt.d.ts +0 -21
- package/dist/src/stt.js +0 -70
- package/dist/src/tools/channel.d.ts +0 -16
- package/dist/src/tools/channel.js +0 -231
- package/dist/src/tools/remind.d.ts +0 -2
- package/dist/src/tools/remind.js +0 -255
- package/dist/src/transport/index.d.ts +0 -10
- package/dist/src/transport/index.js +0 -9
- package/dist/src/transport/webhook-transport.d.ts +0 -67
- package/dist/src/transport/webhook-transport.js +0 -245
- package/dist/src/transport/webhook-verify.d.ts +0 -48
- package/dist/src/transport/webhook-verify.js +0 -98
- package/dist/src/types.d.ts +0 -462
- package/dist/src/types.js +0 -22
- package/dist/src/typing-keepalive.d.ts +0 -27
- package/dist/src/typing-keepalive.js +0 -64
- package/dist/src/update-checker.d.ts +0 -36
- package/dist/src/update-checker.js +0 -171
- package/dist/src/utils/audio-convert.d.ts +0 -98
- package/dist/src/utils/audio-convert.js +0 -783
- package/dist/src/utils/chunked-upload.d.ts +0 -68
- package/dist/src/utils/chunked-upload.js +0 -341
- package/dist/src/utils/file-utils.d.ts +0 -61
- package/dist/src/utils/file-utils.js +0 -172
- package/dist/src/utils/image-size.d.ts +0 -51
- package/dist/src/utils/image-size.js +0 -234
- package/dist/src/utils/media-send.d.ts +0 -158
- package/dist/src/utils/media-send.js +0 -502
- package/dist/src/utils/media-tags.d.ts +0 -14
- package/dist/src/utils/media-tags.js +0 -165
- package/dist/src/utils/payload.d.ts +0 -112
- package/dist/src/utils/payload.js +0 -186
- package/dist/src/utils/pkg-version.d.ts +0 -5
- package/dist/src/utils/pkg-version.js +0 -61
- package/dist/src/utils/platform.d.ts +0 -137
- package/dist/src/utils/platform.js +0 -390
- package/dist/src/utils/ssrf-guard.d.ts +0 -25
- package/dist/src/utils/ssrf-guard.js +0 -91
- package/dist/src/utils/text-parsing.d.ts +0 -31
- package/dist/src/utils/text-parsing.js +0 -74
- package/dist/src/utils/upload-cache.d.ts +0 -34
- package/dist/src/utils/upload-cache.js +0 -93
- package/node_modules/@eshaz/web-worker/LICENSE +0 -201
- package/node_modules/@eshaz/web-worker/README.md +0 -134
- package/node_modules/@eshaz/web-worker/browser.js +0 -17
- package/node_modules/@eshaz/web-worker/cjs/browser.js +0 -16
- package/node_modules/@eshaz/web-worker/cjs/node.js +0 -219
- package/node_modules/@eshaz/web-worker/index.d.ts +0 -4
- package/node_modules/@eshaz/web-worker/node.js +0 -223
- package/node_modules/@eshaz/web-worker/package.json +0 -54
- package/node_modules/@wasm-audio-decoders/common/index.js +0 -5
- package/node_modules/@wasm-audio-decoders/common/package.json +0 -36
- package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +0 -231
- package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +0 -129
- package/node_modules/@wasm-audio-decoders/common/src/puff/README +0 -67
- package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +0 -31
- package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +0 -863
- package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +0 -35
- package/node_modules/@wasm-audio-decoders/common/src/utilities.js +0 -3
- package/node_modules/@wasm-audio-decoders/common/types.d.ts +0 -7
- package/node_modules/mpg123-decoder/README.md +0 -265
- package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +0 -185
- package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +0 -1
- package/node_modules/mpg123-decoder/index.js +0 -8
- package/node_modules/mpg123-decoder/package.json +0 -58
- package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +0 -464
- package/node_modules/mpg123-decoder/src/MPEGDecoder.js +0 -200
- package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +0 -21
- package/node_modules/mpg123-decoder/types.d.ts +0 -30
- package/node_modules/silk-wasm/LICENSE +0 -21
- package/node_modules/silk-wasm/README.md +0 -85
- package/node_modules/silk-wasm/lib/index.cjs +0 -16
- package/node_modules/silk-wasm/lib/index.d.ts +0 -70
- package/node_modules/silk-wasm/lib/index.mjs +0 -16
- package/node_modules/silk-wasm/lib/silk.wasm +0 -0
- package/node_modules/silk-wasm/lib/utils.d.ts +0 -4
- package/node_modules/silk-wasm/package.json +0 -39
- package/node_modules/simple-yenc/.github/FUNDING.yml +0 -1
- package/node_modules/simple-yenc/.prettierignore +0 -1
- package/node_modules/simple-yenc/LICENSE +0 -7
- package/node_modules/simple-yenc/README.md +0 -163
- package/node_modules/simple-yenc/dist/esm.js +0 -1
- package/node_modules/simple-yenc/dist/index.js +0 -1
- package/node_modules/simple-yenc/package.json +0 -50
- package/node_modules/simple-yenc/rollup.config.js +0 -27
- package/node_modules/simple-yenc/src/simple-yenc.js +0 -302
- package/node_modules/ws/LICENSE +0 -20
- package/node_modules/ws/README.md +0 -548
- package/node_modules/ws/browser.js +0 -8
- package/node_modules/ws/index.js +0 -13
- package/node_modules/ws/lib/buffer-util.js +0 -131
- package/node_modules/ws/lib/constants.js +0 -19
- package/node_modules/ws/lib/event-target.js +0 -292
- package/node_modules/ws/lib/extension.js +0 -203
- package/node_modules/ws/lib/limiter.js +0 -55
- package/node_modules/ws/lib/permessage-deflate.js +0 -528
- package/node_modules/ws/lib/receiver.js +0 -706
- package/node_modules/ws/lib/sender.js +0 -602
- package/node_modules/ws/lib/stream.js +0 -161
- package/node_modules/ws/lib/subprotocol.js +0 -62
- package/node_modules/ws/lib/validation.js +0 -152
- package/node_modules/ws/lib/websocket-server.js +0 -554
- package/node_modules/ws/lib/websocket.js +0 -1393
- package/node_modules/ws/package.json +0 -69
- package/node_modules/ws/wrapper.mjs +0 -8
- package/scripts/postinstall-link-sdk.js +0 -170
- package/skills/qqbot-media/SKILL.md +0 -60
- package/src/admin-resolver.ts +0 -181
- package/src/api.ts +0 -1333
- package/src/credential-backup.ts +0 -72
- package/src/deliver-debounce.ts +0 -229
- package/src/gateway.ts +0 -2272
- package/src/group-history.ts +0 -328
- package/src/image-server.ts +0 -675
- package/src/inbound-attachments.ts +0 -321
- package/src/known-users.ts +0 -353
- package/src/message-gating.ts +0 -190
- package/src/message-queue.ts +0 -354
- package/src/onboarding.ts +0 -282
- package/src/outbound-deliver.ts +0 -486
- package/src/outbound.ts +0 -1179
- package/src/ref-index-store.ts +0 -412
- package/src/reply-dispatcher.ts +0 -334
- package/src/session-store.ts +0 -303
- package/src/slash-commands.ts +0 -2431
- package/src/startup-greeting.ts +0 -120
- package/src/streaming.ts +0 -1077
- package/src/stt.ts +0 -86
- package/src/tools/channel.ts +0 -275
- package/src/transport/index.ts +0 -11
- package/src/transport/webhook-transport.ts +0 -332
- package/src/transport/webhook-verify.ts +0 -119
- package/src/typing-keepalive.ts +0 -59
- package/src/utils/audio-convert.ts +0 -887
- package/src/utils/chunked-upload.ts +0 -483
- package/src/utils/file-utils.ts +0 -193
- package/src/utils/image-size.ts +0 -266
- package/src/utils/media-send.ts +0 -636
- package/src/utils/media-tags.ts +0 -183
- package/src/utils/payload.ts +0 -265
- package/src/utils/text-parsing.ts +0 -85
- package/src/utils/upload-cache.ts +0 -128
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway 生命周期管理
|
|
3
|
+
*
|
|
4
|
+
* 封装 startAccount / logoutAccount 的业务逻辑:
|
|
5
|
+
* - 凭证恢复
|
|
6
|
+
* - QQBotGateway 实例创建与注册
|
|
7
|
+
* - Features 初始化(update-checker、approval-handler)
|
|
8
|
+
* - 登出时凭证清除
|
|
9
|
+
*/
|
|
10
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
|
|
11
|
+
import type { ResolvedQQBotAccount } from '../types.js';
|
|
12
|
+
import { DEFAULT_ACCOUNT_ID, resolveQQBotAccount, applyQQBotAccountConfig } from '../config.js';
|
|
13
|
+
import { getQQBotRuntime } from '../runtime.js';
|
|
14
|
+
import { getAdapters } from '../adapter/resolve.js';
|
|
15
|
+
import { QQBotGateway } from './qqbot-gateway.js';
|
|
16
|
+
import { createPluginLogger } from '../utils/plugin-logger.js';
|
|
17
|
+
import type { PluginLogger } from '../utils/plugin-logger.js';
|
|
18
|
+
import { registerGateway, unregisterGateway, getGateway } from '../outbound/outbound-service.js';
|
|
19
|
+
import { saveCredentialBackup, loadCredentialBackup } from '../features/credential-backup.js';
|
|
20
|
+
import { triggerUpdateCheck } from '../features/update-checker.js';
|
|
21
|
+
import { QQBotApprovalHandler, registerApprovalHandler, unregisterApprovalHandler, getApprovalHandler } from '../features/approval-handler.js';
|
|
22
|
+
|
|
23
|
+
export interface StartAccountContext {
|
|
24
|
+
account: ResolvedQQBotAccount;
|
|
25
|
+
abortSignal?: AbortSignal;
|
|
26
|
+
cfg: any;
|
|
27
|
+
/**
|
|
28
|
+
* 框架传入的基础 logger(无 child 方法)。内部会自动包装为 PluginLogger。
|
|
29
|
+
* 写日志的优先级:info > warn > error > debug,方法均为必选。
|
|
30
|
+
*/
|
|
31
|
+
log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void; debug: (msg: string) => void };
|
|
32
|
+
getStatus: () => Record<string, unknown>;
|
|
33
|
+
setStatus: (s: Record<string, unknown>) => void;
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 启动账户(含凭证恢复 + features 初始化)
|
|
39
|
+
*/
|
|
40
|
+
export async function startAccountWithCredentialRecovery(ctx: StartAccountContext): Promise<void> {
|
|
41
|
+
let { account } = ctx;
|
|
42
|
+
const { abortSignal, cfg } = ctx;
|
|
43
|
+
const log: PluginLogger = createPluginLogger({
|
|
44
|
+
prefix: `[${account.accountId}]`,
|
|
45
|
+
...(ctx.log?.info ? { output: ctx.log as PluginLogger } : {}),
|
|
46
|
+
});
|
|
47
|
+
const runtime = getQQBotRuntime();
|
|
48
|
+
|
|
49
|
+
// 凭证恢复:配置中 appId/secret 为空时尝试从暂存文件恢复
|
|
50
|
+
if (!account.appId || !account.clientSecret) {
|
|
51
|
+
const backup = loadCredentialBackup(account.accountId);
|
|
52
|
+
if (backup) {
|
|
53
|
+
log?.info(`[qqbot:${account.accountId}] 从暂存文件恢复凭证 (appId=${backup.appId})`);
|
|
54
|
+
try {
|
|
55
|
+
const restoredCfg = applyQQBotAccountConfig(cfg, account.accountId, {
|
|
56
|
+
appId: backup.appId,
|
|
57
|
+
clientSecret: backup.clientSecret,
|
|
58
|
+
});
|
|
59
|
+
const adapters = getAdapters(runtime);
|
|
60
|
+
if (adapters.persistConfig) {
|
|
61
|
+
await adapters.persistConfig(() => restoredCfg);
|
|
62
|
+
}
|
|
63
|
+
account = resolveQQBotAccount(restoredCfg, account.accountId);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
log?.error(`[qqbot:${account.accountId}] 凭证恢复失败: ${e}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 创建 gateway 实例并注册
|
|
71
|
+
const gw = new QQBotGateway(account, runtime, log);
|
|
72
|
+
registerGateway(account.accountId, gw);
|
|
73
|
+
|
|
74
|
+
await gw.start(
|
|
75
|
+
{
|
|
76
|
+
onReady: () => {
|
|
77
|
+
saveCredentialBackup(account.accountId, account.appId, account.clientSecret);
|
|
78
|
+
ctx.setStatus({
|
|
79
|
+
...ctx.getStatus(),
|
|
80
|
+
running: true,
|
|
81
|
+
connected: true,
|
|
82
|
+
lastConnectedAt: Date.now(),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── Features 初始化(gateway ready 后触发)──
|
|
86
|
+
initFeatures(account, cfg, log).catch((e) => {
|
|
87
|
+
log?.error(`[qqbot:${account.accountId}] initFeatures error: ${e}`);
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
onError: (error) => {
|
|
91
|
+
log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`);
|
|
92
|
+
ctx.setStatus({ ...ctx.getStatus(), lastError: error.message });
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
abortSignal,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Gateway ready 后初始化各 feature 模块
|
|
101
|
+
*/
|
|
102
|
+
async function initFeatures(account: ResolvedQQBotAccount, cfg: any, log: PluginLogger): Promise<void> {
|
|
103
|
+
// 1. 版本更新检测(后台预热,fire-and-forget)
|
|
104
|
+
triggerUpdateCheck(log);
|
|
105
|
+
|
|
106
|
+
const existing = getApprovalHandler(account.accountId);
|
|
107
|
+
if (existing) {
|
|
108
|
+
await existing.stop();
|
|
109
|
+
unregisterApprovalHandler(account.accountId);
|
|
110
|
+
}
|
|
111
|
+
const approvalLog = log.child('approval');
|
|
112
|
+
try {
|
|
113
|
+
const handler = new QQBotApprovalHandler({
|
|
114
|
+
accountId: account.accountId,
|
|
115
|
+
appId: account.appId,
|
|
116
|
+
clientSecret: account.clientSecret,
|
|
117
|
+
cfg,
|
|
118
|
+
log: approvalLog,
|
|
119
|
+
});
|
|
120
|
+
registerApprovalHandler(account.accountId, handler);
|
|
121
|
+
await handler.start();
|
|
122
|
+
approvalLog.info('registered');
|
|
123
|
+
} catch (e) {
|
|
124
|
+
approvalLog.debug(`not available: ${e}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 停止账户 — 主动调用,与 abort 信号双保险。
|
|
130
|
+
*
|
|
131
|
+
* 框架先 abort.abort() 让 startAccount promise 自然 resolve,
|
|
132
|
+
* 再调用 stopAccount 给插件机会做主动清理(关 WebSocket、注销处理器、刷新持久化)。
|
|
133
|
+
*
|
|
134
|
+
* 实现策略:
|
|
135
|
+
* 1. 主动调用 bot.stop() — 比 abort 信号更立刻、不依赖事件循环时机
|
|
136
|
+
* 2. 注销 approval handler / gateway
|
|
137
|
+
* 3. 立即返回;剩余资源(typing keepalive 定时器等)由 abort 信号触发收尾
|
|
138
|
+
*/
|
|
139
|
+
export async function stopAccountGracefully(params: {
|
|
140
|
+
accountId: string;
|
|
141
|
+
log?: PluginLogger;
|
|
142
|
+
}): Promise<void> {
|
|
143
|
+
const { accountId, log } = params;
|
|
144
|
+
const gw = getGateway(accountId);
|
|
145
|
+
|
|
146
|
+
// 1. 主动停止 bot
|
|
147
|
+
if (gw) {
|
|
148
|
+
try {
|
|
149
|
+
await gw.stop();
|
|
150
|
+
log?.info(`[qqbot:${accountId}] gateway stopped`);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
log?.error(`[qqbot:${accountId}] gateway stop error: ${err instanceof Error ? err.message : String(err)}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
unregisterGateway(accountId);
|
|
157
|
+
try {
|
|
158
|
+
const h = getApprovalHandler(accountId);
|
|
159
|
+
if (h) await h.stop();
|
|
160
|
+
} catch {
|
|
161
|
+
}
|
|
162
|
+
unregisterApprovalHandler(accountId);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 登出账户(清除凭证)
|
|
167
|
+
*/
|
|
168
|
+
export async function logoutAndClearCredentials(params: {
|
|
169
|
+
accountId: string;
|
|
170
|
+
cfg: any;
|
|
171
|
+
}): Promise<{ ok: boolean; cleared: boolean; envToken: boolean; loggedOut: boolean }> {
|
|
172
|
+
const { accountId, cfg } = params;
|
|
173
|
+
unregisterGateway(accountId);
|
|
174
|
+
try {
|
|
175
|
+
const h = getApprovalHandler(accountId);
|
|
176
|
+
if (h) await h.stop();
|
|
177
|
+
} catch {
|
|
178
|
+
}
|
|
179
|
+
unregisterApprovalHandler(accountId);
|
|
180
|
+
|
|
181
|
+
const nextCfg = { ...cfg } as OpenClawConfig;
|
|
182
|
+
const nextQQBot = cfg.channels?.qqbot ? { ...cfg.channels.qqbot } : undefined;
|
|
183
|
+
let cleared = false;
|
|
184
|
+
let changed = false;
|
|
185
|
+
|
|
186
|
+
if (nextQQBot) {
|
|
187
|
+
const qqbot = nextQQBot as Record<string, unknown>;
|
|
188
|
+
if (accountId === DEFAULT_ACCOUNT_ID && qqbot.clientSecret) {
|
|
189
|
+
delete qqbot.clientSecret;
|
|
190
|
+
cleared = true;
|
|
191
|
+
changed = true;
|
|
192
|
+
}
|
|
193
|
+
const accounts = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
|
|
194
|
+
if (accounts && accountId in accounts) {
|
|
195
|
+
const entry = accounts[accountId];
|
|
196
|
+
if (entry && 'clientSecret' in entry) {
|
|
197
|
+
delete entry.clientSecret;
|
|
198
|
+
cleared = true;
|
|
199
|
+
changed = true;
|
|
200
|
+
}
|
|
201
|
+
if (entry && Object.keys(entry).length === 0) {
|
|
202
|
+
delete accounts[accountId];
|
|
203
|
+
changed = true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (changed && nextQQBot) {
|
|
209
|
+
nextCfg.channels = { ...nextCfg.channels, qqbot: nextQQBot };
|
|
210
|
+
const runtime = getQQBotRuntime();
|
|
211
|
+
const adapters = getAdapters(runtime);
|
|
212
|
+
if (adapters.persistConfig) {
|
|
213
|
+
await adapters.persistConfig(() => nextCfg);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId);
|
|
218
|
+
const loggedOut = resolved.secretSource === 'none';
|
|
219
|
+
const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET);
|
|
220
|
+
return { ok: true, cleared, envToken, loggedOut };
|
|
221
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK 中间件编排
|
|
3
|
+
*
|
|
4
|
+
* 根据账户配置组装 SDK 内置中间件链。
|
|
5
|
+
* 中间件负责过滤和上下文富化;concurrencyGuard 负责串行+合并,
|
|
6
|
+
* 合并后的消息继续走完剩余中间件链,最终统一由 bot.on("message") 处理转发。
|
|
7
|
+
*/
|
|
8
|
+
import type { QQBot, MiddlewareContext } from '@tencent-connect/qqbot-nodejs';
|
|
9
|
+
import {
|
|
10
|
+
messageFilter,
|
|
11
|
+
contentSanitizer,
|
|
12
|
+
rateLimiter,
|
|
13
|
+
concurrencyGuard,
|
|
14
|
+
mentionGate,
|
|
15
|
+
quoteRef,
|
|
16
|
+
historyBuffer,
|
|
17
|
+
envelopeFormatter,
|
|
18
|
+
typingIndicator,
|
|
19
|
+
slashCommand,
|
|
20
|
+
errorHandler,
|
|
21
|
+
} from '@tencent-connect/qqbot-nodejs';
|
|
22
|
+
import type { ResolvedQQBotAccount } from '../types.js';
|
|
23
|
+
import { buildCommandList } from '../commands/index.js';
|
|
24
|
+
import { attachmentProcessor } from '../middleware/attachment.js';
|
|
25
|
+
import { assembleBody } from '../dispatch/body-assembler.js';
|
|
26
|
+
import { getPersistedRefIndexStore } from '../features/ref-index-store.js';
|
|
27
|
+
import { createPolicyInjector } from '../middleware/policy-injector.js';
|
|
28
|
+
import { getHistoryStore, historyGroupKey } from '../features/history-store.js';
|
|
29
|
+
import { dynamicAccessControl } from '../middleware/access-control.js';
|
|
30
|
+
import { stripMentionText } from '../utils/mention.js';
|
|
31
|
+
|
|
32
|
+
export interface MiddlewareSetupOptions {
|
|
33
|
+
/** 获取 runtime */
|
|
34
|
+
getRuntime: () => any;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 为 QQBot 实例编排完整的中间件链
|
|
39
|
+
*/
|
|
40
|
+
export function setupMiddlewares(bot: QQBot, account: ResolvedQQBotAccount, opts: MiddlewareSetupOptions): void {
|
|
41
|
+
// 1. 错误兜底(最外层洋葱皮)
|
|
42
|
+
bot.use(errorHandler());
|
|
43
|
+
|
|
44
|
+
// 2. 消息过滤:bot 回声 + 消息去重
|
|
45
|
+
bot.use(messageFilter({ skipSelfEcho: false }));
|
|
46
|
+
|
|
47
|
+
// 3. 动态策略注入 — 每条消息注入 ctx.state.policy
|
|
48
|
+
// 后续 dynamicAccessControl / mentionGate / historyBuffer 自动读取
|
|
49
|
+
bot.use(createPolicyInjector(account));
|
|
50
|
+
|
|
51
|
+
// 4. 群历史缓冲 — 放在门控之前,确保所有消息(含未 @bot)都计入上下文
|
|
52
|
+
// limit 从 ctx.state.policy.group.historyLimit 读取,key 带 accountId 前缀隔离多账号
|
|
53
|
+
bot.use(historyBuffer({
|
|
54
|
+
store: getHistoryStore(),
|
|
55
|
+
groupKey: (ctx) => {
|
|
56
|
+
const gid = ctx.message.groupOpenid;
|
|
57
|
+
if (ctx.message.kind !== 'group' || !gid) return undefined;
|
|
58
|
+
return historyGroupKey(account.accountId, gid);
|
|
59
|
+
},
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// 5. 动态访问控制 — 从 ctx.state.policy 动态读取,支持 pairing
|
|
63
|
+
bot.use(dynamicAccessControl({
|
|
64
|
+
accountId: account.accountId,
|
|
65
|
+
getRuntime: opts.getRuntime,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
// 6. 群聊 @bot 门控(从 ctx.state.policy.group 读取动态配置)
|
|
69
|
+
bot.use(mentionGate());
|
|
70
|
+
// 7. 内容清洗(去 @marker、表情标签、多余空白)
|
|
71
|
+
// SDK 用 appId 匹配 @标记,但 QQ openid 不等于 appId,追加 stripMentionText 正确剥离
|
|
72
|
+
bot.use(contentSanitizer({
|
|
73
|
+
parseFaceTags: true,
|
|
74
|
+
transform: (content, ctx) => stripMentionText(content, (ctx.message as any).mentions),
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
// 8. 三层限流(sender / group / global)
|
|
78
|
+
bot.use(rateLimiter());
|
|
79
|
+
|
|
80
|
+
// 9. 斜杠命令(在并发锁之前,命令匹配后直接 reply + stop,不排队)
|
|
81
|
+
// 依赖:ctx.state.policy(policyInjector, #3)、ctx.message.*(原始消息)
|
|
82
|
+
// 注意:/stop 是框架级命令,不在 qqbot 命令列表中,仍由 urgentPredicate 处理
|
|
83
|
+
const slash = slashCommand({ commands: buildCommandList(account, { getRuntime: opts.getRuntime }) });
|
|
84
|
+
bot.use(slash.middleware);
|
|
85
|
+
|
|
86
|
+
// 10. 并发串行+合并(在副作用中间件之前)
|
|
87
|
+
// - 同 peer 串行处理,避免平台 session conflict
|
|
88
|
+
// - 处理中消息暂存 buffer;完成后合并为一条,继续走完剩余中间件链
|
|
89
|
+
// (typingIndicator/quoteRef/attachmentProcessor/... 直到 bot.on("message"))
|
|
90
|
+
// - 合并时清除 assembledBody 让 dispatch.ts 用合并后 content 重建
|
|
91
|
+
// - 超时后 abort 处理链(取消 LLM 调用),释放锁并排空缓冲
|
|
92
|
+
bot.use(concurrencyGuard({
|
|
93
|
+
strategy: 'merge',
|
|
94
|
+
maxQueue: 50,
|
|
95
|
+
maxProcessingMs: account.processingTimeoutMs,
|
|
96
|
+
/** 紧急指令(/stop)跳过排队,立即处理 */
|
|
97
|
+
urgentPredicate: (ctx: MiddlewareContext) => {
|
|
98
|
+
return (ctx.message.content as string ?? '').trim() === '/stop';
|
|
99
|
+
},
|
|
100
|
+
onMerge: (buffered) => {
|
|
101
|
+
const last = buffered[buffered.length - 1];
|
|
102
|
+
if (buffered.length === 1) return last;
|
|
103
|
+
|
|
104
|
+
// 透传原始消息列表,格式拼接下沉给下游 envelopeFormatter / assembleBody
|
|
105
|
+
(last.state as Record<string, unknown>).mergedMessages = buffered;
|
|
106
|
+
|
|
107
|
+
// 合并附件(所有 buffer 中的附件汇总到 survivor)
|
|
108
|
+
const attachments = buffered.flatMap((c) => c.message.attachments ?? []);
|
|
109
|
+
if (attachments.length > 0) {
|
|
110
|
+
last.message.attachments = attachments;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 清除 assembledBody,让 dispatch.ts 用合并后的 ctx 重新构建
|
|
114
|
+
delete (last.state as Record<string, unknown>).assembledBody;
|
|
115
|
+
|
|
116
|
+
return last;
|
|
117
|
+
},
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
// 11. C2C 输入状态指示器
|
|
121
|
+
bot.use(typingIndicator());
|
|
122
|
+
|
|
123
|
+
// 12. 引用消息解析(默认优先 msg_elements 获取文件名等丰富信息)
|
|
124
|
+
bot.use(quoteRef({
|
|
125
|
+
store: getPersistedRefIndexStore(account.accountId),
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
// 13. 附件处理(语音 STT 转录 + 图片/文件下载)
|
|
129
|
+
bot.use(attachmentProcessor({ getRuntime: opts.getRuntime }));
|
|
130
|
+
|
|
131
|
+
// 14. 上下文组装(构建框架规约的 body)
|
|
132
|
+
bot.use(envelopeFormatter({
|
|
133
|
+
format: (ctx) => {
|
|
134
|
+
const assembled = assembleBody(ctx, ctx.message as never, account, opts.getRuntime);
|
|
135
|
+
(ctx.state as Record<string, unknown>).assembledBody = assembled;
|
|
136
|
+
return assembled.agentBody;
|
|
137
|
+
},
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQBotGateway — 封装单个 Bot 实例的完整生命周期
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
QQBot,
|
|
6
|
+
FileKVStore,
|
|
7
|
+
kvSessionPersistence,
|
|
8
|
+
MediaFileType,
|
|
9
|
+
type ReplyTarget,
|
|
10
|
+
type QQBotInboundMessage,
|
|
11
|
+
type MiddlewareContext,
|
|
12
|
+
type InteractionEvent,
|
|
13
|
+
type MessageResponse,
|
|
14
|
+
type StreamSession,
|
|
15
|
+
} from '@tencent-connect/qqbot-nodejs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import type { PluginRuntime } from 'openclaw/plugin-sdk';
|
|
18
|
+
import type { ResolvedQQBotAccount } from '../types.js';
|
|
19
|
+
import type { PluginLogger } from '../utils/plugin-logger.js';
|
|
20
|
+
import { createPluginLogger } from '../utils/plugin-logger.js';
|
|
21
|
+
import { setupMiddlewares } from './middleware-setup.js';
|
|
22
|
+
import { handleMessage, handleInteraction } from './event-handlers.js';
|
|
23
|
+
import { getQQBotDataDir } from '../utils/platform.js';
|
|
24
|
+
import { buildUserAgent } from '../bot-instance.js';
|
|
25
|
+
import { createPluginWebhookAdapter } from '../adapter/webhook.js';
|
|
26
|
+
import { getPersistedRefIndexStore } from '../features/ref-index-store.js';
|
|
27
|
+
import { getCachedMsgId } from '../features/msgid-cache.js';
|
|
28
|
+
|
|
29
|
+
export interface GatewayCallbacks {
|
|
30
|
+
onReady?: () => void;
|
|
31
|
+
onError?: (error: Error) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SendOptions {
|
|
35
|
+
msgId?: string;
|
|
36
|
+
text?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── 超时常量 ──
|
|
40
|
+
|
|
41
|
+
const TEXT_TIMEOUT_MS = 30_000;
|
|
42
|
+
const MEDIA_TIMEOUT_MS = 300_000;
|
|
43
|
+
|
|
44
|
+
function resolveMs(envKey: string, defaultMs: number): number {
|
|
45
|
+
const env = process.env[envKey];
|
|
46
|
+
if (env) {
|
|
47
|
+
const v = Number(env);
|
|
48
|
+
if (!Number.isNaN(v) && v > 0) return v;
|
|
49
|
+
}
|
|
50
|
+
return defaultMs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
54
|
+
if (ms <= 0) return promise;
|
|
55
|
+
return Promise.race([
|
|
56
|
+
promise,
|
|
57
|
+
new Promise<T>((_, reject) =>
|
|
58
|
+
setTimeout(() => reject(new Error(`出站超时: ${label} (${ms}ms)`)), ms),
|
|
59
|
+
),
|
|
60
|
+
]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class QQBotGateway {
|
|
64
|
+
readonly bot: QQBot;
|
|
65
|
+
private readonly account: ResolvedQQBotAccount;
|
|
66
|
+
private readonly runtime: PluginRuntime;
|
|
67
|
+
readonly log: PluginLogger;
|
|
68
|
+
private readonly textTimeout: number;
|
|
69
|
+
private readonly mediaTimeout: number;
|
|
70
|
+
|
|
71
|
+
constructor(account: ResolvedQQBotAccount, runtime: PluginRuntime, log?: PluginLogger) {
|
|
72
|
+
this.textTimeout = resolveMs('OPENCLAW_OUTBOUND_TIMEOUT_MS', TEXT_TIMEOUT_MS);
|
|
73
|
+
this.mediaTimeout = resolveMs('OPENCLAW_OUTBOUND_MEDIA_TIMEOUT_MS', MEDIA_TIMEOUT_MS);
|
|
74
|
+
this.account = account;
|
|
75
|
+
this.runtime = runtime;
|
|
76
|
+
this.log = log ?? createPluginLogger({ prefix: `[qqbot:${account.accountId}]` });
|
|
77
|
+
|
|
78
|
+
const dataDir = getQQBotDataDir(account.accountId);
|
|
79
|
+
|
|
80
|
+
const isWebhook = account.config.transport === 'webhook';
|
|
81
|
+
|
|
82
|
+
this.bot = new QQBot({
|
|
83
|
+
appId: account.appId,
|
|
84
|
+
appSecret: account.clientSecret,
|
|
85
|
+
accountId: account.accountId,
|
|
86
|
+
markdownSupport: account.markdownSupport,
|
|
87
|
+
userAgent: buildUserAgent(account.userAgentSuffix),
|
|
88
|
+
baseUrl: process.env.QQBOT_BASE_URL?.replace(/\/+$/, '') || 'https://api.sgroup.qq.com',
|
|
89
|
+
tokenBaseUrl: process.env.QQBOT_TOKEN_BASE_URL?.replace(/\/+$/, '') || 'https://bots.qq.com',
|
|
90
|
+
transport: account.config.transport,
|
|
91
|
+
webhook: isWebhook ? { path: account.config.webhook?.path, server: createPluginWebhookAdapter({ account, log: this.log }) } : undefined,
|
|
92
|
+
sessionPersistence: kvSessionPersistence({
|
|
93
|
+
store: new FileKVStore({ dir: dataDir, fileName: 'session.json' }),
|
|
94
|
+
accountId: account.accountId,
|
|
95
|
+
}),
|
|
96
|
+
tokenPrefetch: 'sync',
|
|
97
|
+
logger: this.log,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// 包装 sendText/sendMedia,回复后自动写入 ref-index store
|
|
101
|
+
this.wrapBotSendForRefIndex();
|
|
102
|
+
|
|
103
|
+
// concurrencyGuard 的 merge 策略合并后会继续走完剩余中间件链,
|
|
104
|
+
// 最终与单条消息一样统一由下方 bot.on('message') 处理转发,
|
|
105
|
+
// 因此这里不再需要单独的 onMergeDispatch 回调。
|
|
106
|
+
setupMiddlewares(this.bot, account, {
|
|
107
|
+
getRuntime: () => runtime,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async start(callbacks?: GatewayCallbacks, signal?: AbortSignal): Promise<void> {
|
|
112
|
+
const handleReady = () => {
|
|
113
|
+
this.log.info(`Gateway ready`);
|
|
114
|
+
callbacks?.onReady?.();
|
|
115
|
+
};
|
|
116
|
+
this.bot.on(`ready`, handleReady);
|
|
117
|
+
this.bot.on(`resumed`, handleReady);
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
this.bot.on('error', (err: Error) => {
|
|
121
|
+
this.log.error(`Gateway error: ${err.message}`);
|
|
122
|
+
callbacks?.onError?.(err);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const gatewayLog = this.log.child('gateway');
|
|
126
|
+
|
|
127
|
+
this.bot.on('message', async (ctx: MiddlewareContext, msg: QQBotInboundMessage) => {
|
|
128
|
+
gatewayLog.debug(`message msgId=${msg.messageId}`);
|
|
129
|
+
try {
|
|
130
|
+
await handleMessage(ctx, msg, this.account, this.runtime, this.log);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
gatewayLog.error(`Dispatch error: ${err instanceof Error ? err.message : String(err)}`);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
this.bot.on('interaction', (_ctx, event: InteractionEvent) => {
|
|
137
|
+
handleInteraction(event, this.account, this.runtime, this.log, (id, code, data) =>
|
|
138
|
+
this.bot.acknowledgeInteraction(id, code, data),
|
|
139
|
+
).catch((err) => {
|
|
140
|
+
this.log.error(`Interaction error: ${err}`);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await this.bot.start(signal);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async stop(): Promise<void> {
|
|
148
|
+
await this.bot.stop();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async sendText(target: ReplyTarget, text: string, opts?: SendOptions): Promise<MessageResponse> {
|
|
152
|
+
return withTimeout(
|
|
153
|
+
this.bot.sendText(attachMsgId(target, opts), text),
|
|
154
|
+
this.textTimeout, 'sendText',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async sendMedia(
|
|
159
|
+
target: ReplyTarget,
|
|
160
|
+
source: string,
|
|
161
|
+
opts?: SendOptions & { fileType?: MediaFileType },
|
|
162
|
+
): Promise<MessageResponse> {
|
|
163
|
+
const resolvedTarget = attachMsgId(target, opts);
|
|
164
|
+
const fileType = opts?.fileType ?? MediaFileType.IMAGE;
|
|
165
|
+
const sourceOpts = resolveMediaSource(source);
|
|
166
|
+
const result = await withTimeout(
|
|
167
|
+
this.bot.sendMedia({ target: resolvedTarget, fileType, ...sourceOpts, content: opts?.text }),
|
|
168
|
+
this.mediaTimeout, 'sendMedia',
|
|
169
|
+
);
|
|
170
|
+
return result.message ?? { id: '', timestamp: Date.now() };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async sendVoice(
|
|
174
|
+
target: ReplyTarget,
|
|
175
|
+
source: { url?: string; base64?: string; localPath?: string },
|
|
176
|
+
opts?: SendOptions,
|
|
177
|
+
): Promise<MessageResponse> {
|
|
178
|
+
const resolvedTarget = attachMsgId(target, opts);
|
|
179
|
+
|
|
180
|
+
if (source.base64) {
|
|
181
|
+
const result = await withTimeout(
|
|
182
|
+
this.bot.sendMedia({ target: resolvedTarget, fileType: MediaFileType.VOICE, fileData: source.base64, content: opts?.text }),
|
|
183
|
+
this.mediaTimeout, 'sendVoice(base64)',
|
|
184
|
+
);
|
|
185
|
+
return result.message ?? { id: '', timestamp: Date.now() };
|
|
186
|
+
}
|
|
187
|
+
if (source.localPath) {
|
|
188
|
+
const result = await withTimeout(
|
|
189
|
+
this.bot.sendMedia({ target: resolvedTarget, fileType: MediaFileType.VOICE, localPath: source.localPath, content: opts?.text }),
|
|
190
|
+
this.mediaTimeout, 'sendVoice(path)',
|
|
191
|
+
);
|
|
192
|
+
return result.message ?? { id: '', timestamp: Date.now() };
|
|
193
|
+
}
|
|
194
|
+
const result = await withTimeout(
|
|
195
|
+
this.bot.sendMedia({ target: resolvedTarget, fileType: MediaFileType.VOICE, url: source.url!, content: opts?.text }),
|
|
196
|
+
this.mediaTimeout, 'sendVoice(url)',
|
|
197
|
+
);
|
|
198
|
+
return result.message ?? { id: '', timestamp: Date.now() };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async sendVideo(
|
|
202
|
+
target: ReplyTarget,
|
|
203
|
+
source: string,
|
|
204
|
+
opts?: SendOptions,
|
|
205
|
+
): Promise<MessageResponse> {
|
|
206
|
+
const resolvedTarget = attachMsgId(target, opts);
|
|
207
|
+
const sourceOpts = resolveMediaSource(source);
|
|
208
|
+
const result = await withTimeout(
|
|
209
|
+
this.bot.sendMedia({ target: resolvedTarget, fileType: MediaFileType.VIDEO, ...sourceOpts, content: opts?.text }),
|
|
210
|
+
this.mediaTimeout, 'sendVideo',
|
|
211
|
+
);
|
|
212
|
+
return result.message ?? { id: '', timestamp: Date.now() };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async sendFile(
|
|
216
|
+
target: ReplyTarget,
|
|
217
|
+
source: string,
|
|
218
|
+
opts?: SendOptions & { fileName?: string },
|
|
219
|
+
): Promise<MessageResponse> {
|
|
220
|
+
const resolvedTarget = attachMsgId(target, opts);
|
|
221
|
+
const sourceOpts = resolveMediaSource(source);
|
|
222
|
+
const result = await withTimeout(
|
|
223
|
+
this.bot.sendMedia({ target: resolvedTarget, fileType: MediaFileType.FILE, ...sourceOpts, fileName: opts?.fileName, content: opts?.text }),
|
|
224
|
+
this.mediaTimeout, 'sendFile',
|
|
225
|
+
);
|
|
226
|
+
return result.message ?? { id: '', timestamp: Date.now() };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
openStream(target: ReplyTarget, msgId: string): StreamSession {
|
|
230
|
+
return this.bot.openStream({
|
|
231
|
+
target: { ...target, msgId },
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async sendTyping(target: ReplyTarget): Promise<void> {
|
|
236
|
+
await this.bot.sendTyping(target);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private wrapBotSendForRefIndex(): void {
|
|
240
|
+
const { accountId, appId } = this.account;
|
|
241
|
+
const senderName = this.account.config.name ?? appId;
|
|
242
|
+
|
|
243
|
+
const storeEntry = (msg: MessageResponse, content: string, scope: string, mediaKind?: string): void => {
|
|
244
|
+
const refIdx = msg.ext_info?.ref_idx;
|
|
245
|
+
if (!refIdx) return;
|
|
246
|
+
// 空内容降级为媒体类型标签
|
|
247
|
+
const finalContent = content || mediaKind
|
|
248
|
+
? mediaKind === 'voice' ? '[语音]'
|
|
249
|
+
: mediaKind === 'image' ? '[图片]'
|
|
250
|
+
: mediaKind ? `[${mediaKind}]`
|
|
251
|
+
: content
|
|
252
|
+
: '';
|
|
253
|
+
const entry = {
|
|
254
|
+
messageId: msg.id, content: finalContent, senderId: appId, senderName,
|
|
255
|
+
timestamp: typeof msg.timestamp === 'number' ? new Date(msg.timestamp).toISOString() : msg.timestamp,
|
|
256
|
+
isBot: true, scope,
|
|
257
|
+
};
|
|
258
|
+
getPersistedRefIndexStore(accountId).set(refIdx, entry as any);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// sendText → 直接捕获 text content
|
|
262
|
+
const origSendText = this.bot.sendText.bind(this.bot);
|
|
263
|
+
this.bot.sendText = async (target, text, ...rest) => {
|
|
264
|
+
const result = await origSendText(target, text, ...rest);
|
|
265
|
+
storeEntry(result, text, target.scope);
|
|
266
|
+
return result;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// sendMedia → ext_info 在 result.message 里
|
|
270
|
+
const origSendMedia = this.bot.sendMedia.bind(this.bot);
|
|
271
|
+
this.bot.sendMedia = async (params: any) => {
|
|
272
|
+
const result = await origSendMedia(params);
|
|
273
|
+
const msg = (result as any).message as MessageResponse | undefined;
|
|
274
|
+
if (msg) storeEntry(msg, '', params.target?.scope ?? '', params.mediaKind);
|
|
275
|
+
return result;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// openStream → 流式消息 complete() 时才返回 ext_info.ref_idx
|
|
279
|
+
const origOpenStream = this.bot.openStream.bind(this.bot);
|
|
280
|
+
this.bot.openStream = (opts) => {
|
|
281
|
+
const session = origOpenStream(opts);
|
|
282
|
+
let lastContent = '';
|
|
283
|
+
const origUpdate = session.update.bind(session);
|
|
284
|
+
session.update = async (content: string) => {
|
|
285
|
+
lastContent = content;
|
|
286
|
+
return origUpdate(content);
|
|
287
|
+
};
|
|
288
|
+
const origComplete = session.complete.bind(session);
|
|
289
|
+
session.complete = async (): Promise<any> => {
|
|
290
|
+
const result = await origComplete();
|
|
291
|
+
if (result?.ext_info?.ref_idx) {
|
|
292
|
+
getPersistedRefIndexStore(accountId).set(result.ext_info.ref_idx, {
|
|
293
|
+
messageId: result.id, content: lastContent, senderId: appId, senderName,
|
|
294
|
+
timestamp: typeof result.timestamp === 'number' ? new Date(result.timestamp).toISOString() : result.timestamp,
|
|
295
|
+
isBot: true, scope: opts.target?.scope ?? '',
|
|
296
|
+
} as any);
|
|
297
|
+
}
|
|
298
|
+
return result;
|
|
299
|
+
};
|
|
300
|
+
return session;
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function attachMsgId(target: ReplyTarget, opts?: SendOptions): ReplyTarget {
|
|
306
|
+
if (opts?.msgId) return { ...target, msgId: opts.msgId };
|
|
307
|
+
// 无显式 msgId 时尝试从缓存获取
|
|
308
|
+
const cached = getCachedMsgId(target.scope, target.targetId);
|
|
309
|
+
return cached ? { ...target, msgId: cached } : target;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function resolveMediaSource(source: string): { url?: string; localPath?: string; fileData?: string } {
|
|
313
|
+
if (source.startsWith('data:')) {
|
|
314
|
+
const commaIdx = source.indexOf(',');
|
|
315
|
+
if (commaIdx > 0) {
|
|
316
|
+
return { fileData: source.slice(commaIdx + 1) };
|
|
317
|
+
}
|
|
318
|
+
return { fileData: source };
|
|
319
|
+
}
|
|
320
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
321
|
+
return { url: source };
|
|
322
|
+
}
|
|
323
|
+
if (source.startsWith('file://')) {
|
|
324
|
+
let p = source.slice('file://'.length);
|
|
325
|
+
if (/^\/[a-zA-Z]:[\\/]/.test(p)) p = p.slice(1);
|
|
326
|
+
try { p = decodeURIComponent(p); } catch {}
|
|
327
|
+
return { localPath: p };
|
|
328
|
+
}
|
|
329
|
+
if (source === '~' || source.startsWith('~/') || source.startsWith('~\\')) {
|
|
330
|
+
return { localPath: source.replace(/^~/, os.homedir()) };
|
|
331
|
+
}
|
|
332
|
+
if (
|
|
333
|
+
source.startsWith('/') ||
|
|
334
|
+
source.startsWith('./') || source.startsWith('../') ||
|
|
335
|
+
source.startsWith('.\\') || source.startsWith('..\\') ||
|
|
336
|
+
/^[a-zA-Z]:[\\/]/.test(source) ||
|
|
337
|
+
source.startsWith('\\\\')
|
|
338
|
+
) {
|
|
339
|
+
return { localPath: source };
|
|
340
|
+
}
|
|
341
|
+
return { url: source };
|
|
342
|
+
}
|