@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,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 出站消息服务
|
|
3
|
+
*
|
|
4
|
+
* 负责将 AI 回复通过 QQBotGateway 发送到 QQ。
|
|
5
|
+
* 超时保护由 QQBotGateway 内部统一处理,本层做 target 解析 + 被动回复限额管控。
|
|
6
|
+
*/
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import { MediaFileType } from '@tencent-connect/qqbot-nodejs';
|
|
9
|
+
import type { QQBotGateway } from '../gateway/index.js';
|
|
10
|
+
import type { ResolvedQQBotAccount } from '../types.js';
|
|
11
|
+
import { parseTarget } from './target.js';
|
|
12
|
+
import { ReplyLimiter } from './reply-limiter.js';
|
|
13
|
+
|
|
14
|
+
// ── Gateway + Limiter 注册表(生命周期由 channel.ts 管理)──
|
|
15
|
+
|
|
16
|
+
const gateways = new Map<string, QQBotGateway>();
|
|
17
|
+
const limiters = new Map<string, ReplyLimiter>();
|
|
18
|
+
|
|
19
|
+
function getLimiter(accountId: string): ReplyLimiter {
|
|
20
|
+
let l = limiters.get(accountId);
|
|
21
|
+
if (!l) {
|
|
22
|
+
l = new ReplyLimiter();
|
|
23
|
+
limiters.set(accountId, l);
|
|
24
|
+
}
|
|
25
|
+
return l;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 解析 replyToId:超出被动回复限额时自动降级为主动消息(不传 msgId)。
|
|
30
|
+
* @returns 实际使用的 msgId(超限时为 undefined)
|
|
31
|
+
*/
|
|
32
|
+
function resolveMsgId(replyToId: string | undefined, accountId: string): string | undefined {
|
|
33
|
+
if (!replyToId) return undefined;
|
|
34
|
+
const limiter = getLimiter(accountId);
|
|
35
|
+
const result = limiter.checkLimit(replyToId);
|
|
36
|
+
if (!result.allowed) return undefined;
|
|
37
|
+
limiter.record(replyToId);
|
|
38
|
+
return replyToId;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function registerGateway(accountId: string, gw: QQBotGateway): void {
|
|
42
|
+
gateways.set(accountId, gw);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function unregisterGateway(accountId: string): void {
|
|
46
|
+
gateways.delete(accountId);
|
|
47
|
+
limiters.delete(accountId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getGateway(accountId: string): QQBotGateway | undefined {
|
|
51
|
+
return gateways.get(accountId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── 媒体类型映射 ──
|
|
55
|
+
|
|
56
|
+
export type MediaKind = 'image' | 'voice' | 'video' | 'file';
|
|
57
|
+
|
|
58
|
+
const MEDIA_KIND_TO_FILE_TYPE: Record<MediaKind, MediaFileType> = {
|
|
59
|
+
image: MediaFileType.IMAGE,
|
|
60
|
+
voice: MediaFileType.VOICE,
|
|
61
|
+
video: MediaFileType.VIDEO,
|
|
62
|
+
file: MediaFileType.FILE,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export interface SendResult {
|
|
66
|
+
messageId?: string;
|
|
67
|
+
error?: string;
|
|
68
|
+
errorCode?: string;
|
|
69
|
+
qqBizCode?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── 公开 API(channel.ts / deliver-pipeline.ts 调用)──
|
|
73
|
+
|
|
74
|
+
export async function sendText(params: {
|
|
75
|
+
to: string;
|
|
76
|
+
text: string;
|
|
77
|
+
accountId?: string;
|
|
78
|
+
replyToId?: string;
|
|
79
|
+
account: ResolvedQQBotAccount;
|
|
80
|
+
}): Promise<SendResult> {
|
|
81
|
+
const accountId = params.account.accountId;
|
|
82
|
+
const gw = gateways.get(accountId);
|
|
83
|
+
if (!gw) return { error: `Bot "${accountId}" not running` };
|
|
84
|
+
try {
|
|
85
|
+
const target = parseTarget(params.to);
|
|
86
|
+
const msgId = resolveMsgId(params.replyToId, accountId);
|
|
87
|
+
const result = await gw.sendText(target, params.text, { msgId });
|
|
88
|
+
return { messageId: result.id };
|
|
89
|
+
} catch (err: unknown) { return formatError(err); }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function sendMedia(params: {
|
|
93
|
+
to: string;
|
|
94
|
+
text?: string;
|
|
95
|
+
mediaUrl: string;
|
|
96
|
+
mediaKind?: MediaKind;
|
|
97
|
+
accountId?: string;
|
|
98
|
+
replyToId?: string;
|
|
99
|
+
account: ResolvedQQBotAccount;
|
|
100
|
+
}): Promise<SendResult> {
|
|
101
|
+
const accountId = params.account.accountId;
|
|
102
|
+
const gw = gateways.get(accountId);
|
|
103
|
+
if (!gw) return { error: `Bot "${accountId}" not running` };
|
|
104
|
+
try {
|
|
105
|
+
const target = parseTarget(params.to);
|
|
106
|
+
const kind = params.mediaKind ?? 'image';
|
|
107
|
+
const msgId = resolveMsgId(params.replyToId, accountId);
|
|
108
|
+
if (kind === 'voice') {
|
|
109
|
+
const source = resolveVoiceSource(params.mediaUrl);
|
|
110
|
+
const result = await gw.sendVoice(target, source, { text: params.text, msgId });
|
|
111
|
+
return { messageId: result.id };
|
|
112
|
+
}
|
|
113
|
+
if (kind === 'video') {
|
|
114
|
+
const result = await gw.sendVideo(target, params.mediaUrl, { text: params.text, msgId });
|
|
115
|
+
return { messageId: result.id };
|
|
116
|
+
}
|
|
117
|
+
if (kind === 'file') {
|
|
118
|
+
const result = await gw.sendFile(target, params.mediaUrl, { text: params.text, msgId });
|
|
119
|
+
return { messageId: result.id };
|
|
120
|
+
}
|
|
121
|
+
const fileType = MEDIA_KIND_TO_FILE_TYPE[kind];
|
|
122
|
+
const result = await gw.sendMedia(target, params.mediaUrl, { text: params.text, msgId, fileType });
|
|
123
|
+
return { messageId: result.id };
|
|
124
|
+
} catch (err: unknown) { return formatError(err); }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function sendVoice(params: {
|
|
128
|
+
to: string;
|
|
129
|
+
source: { url?: string; base64?: string };
|
|
130
|
+
accountId?: string;
|
|
131
|
+
replyToId?: string;
|
|
132
|
+
account: ResolvedQQBotAccount;
|
|
133
|
+
}): Promise<SendResult> {
|
|
134
|
+
const accountId = params.account.accountId;
|
|
135
|
+
const gw = gateways.get(accountId);
|
|
136
|
+
if (!gw) return { error: `Bot "${accountId}" not running` };
|
|
137
|
+
try {
|
|
138
|
+
const target = parseTarget(params.to);
|
|
139
|
+
const msgId = resolveMsgId(params.replyToId, accountId);
|
|
140
|
+
const result = await gw.sendVoice(target, params.source, { msgId });
|
|
141
|
+
return { messageId: result.id };
|
|
142
|
+
} catch (err: unknown) { return formatError(err); }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function sendVideo(params: {
|
|
146
|
+
to: string;
|
|
147
|
+
videoUrl: string;
|
|
148
|
+
accountId?: string;
|
|
149
|
+
replyToId?: string;
|
|
150
|
+
account: ResolvedQQBotAccount;
|
|
151
|
+
}): Promise<SendResult> {
|
|
152
|
+
const accountId = params.account.accountId;
|
|
153
|
+
const gw = gateways.get(accountId);
|
|
154
|
+
if (!gw) return { error: `Bot "${accountId}" not running` };
|
|
155
|
+
try {
|
|
156
|
+
const target = parseTarget(params.to);
|
|
157
|
+
const msgId = resolveMsgId(params.replyToId, accountId);
|
|
158
|
+
const result = await gw.sendVideo(target, params.videoUrl, { msgId });
|
|
159
|
+
return { messageId: result.id };
|
|
160
|
+
} catch (err: unknown) { return formatError(err); }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── OutboundService(deliver-pipeline 专用)──
|
|
164
|
+
|
|
165
|
+
export class OutboundService {
|
|
166
|
+
constructor(private readonly gw: QQBotGateway, private readonly accountId: string) {}
|
|
167
|
+
|
|
168
|
+
async sendText(to: string, text: string, msgId?: string): Promise<SendResult> {
|
|
169
|
+
try {
|
|
170
|
+
const target = parseTarget(to);
|
|
171
|
+
const resolvedMsgId = resolveMsgId(msgId, this.accountId);
|
|
172
|
+
const result = await this.gw.sendText(target, text, { msgId: resolvedMsgId });
|
|
173
|
+
return { messageId: result.id };
|
|
174
|
+
} catch (err: unknown) { return formatError(err); }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async sendMedia(to: string, source: string, opts?: { text?: string; msgId?: string; mediaKind?: MediaKind }): Promise<SendResult> {
|
|
178
|
+
try {
|
|
179
|
+
const target = parseTarget(to);
|
|
180
|
+
const kind = opts?.mediaKind ?? 'image';
|
|
181
|
+
const resolvedMsgId = resolveMsgId(opts?.msgId, this.accountId);
|
|
182
|
+
if (kind === 'voice') {
|
|
183
|
+
const voiceSource = resolveVoiceSource(source);
|
|
184
|
+
const result = await this.gw.sendVoice(target, voiceSource, { text: opts?.text, msgId: resolvedMsgId });
|
|
185
|
+
return { messageId: result.id };
|
|
186
|
+
}
|
|
187
|
+
if (kind === 'video') {
|
|
188
|
+
const result = await this.gw.sendVideo(target, source, { text: opts?.text, msgId: resolvedMsgId });
|
|
189
|
+
return { messageId: result.id };
|
|
190
|
+
}
|
|
191
|
+
if (kind === 'file') {
|
|
192
|
+
const result = await this.gw.sendFile(target, source, { text: opts?.text, msgId: resolvedMsgId, fileName: path.basename(source) });
|
|
193
|
+
return { messageId: result.id };
|
|
194
|
+
}
|
|
195
|
+
const fileType = MEDIA_KIND_TO_FILE_TYPE[kind];
|
|
196
|
+
const result = await this.gw.sendMedia(target, source, { text: opts?.text, msgId: resolvedMsgId, fileType });
|
|
197
|
+
return { messageId: result.id };
|
|
198
|
+
} catch (err: unknown) { return formatError(err); }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── 辅助 ──
|
|
203
|
+
|
|
204
|
+
function resolveVoiceSource(source: string): { url?: string; base64?: string; localPath?: string } {
|
|
205
|
+
if (source.startsWith('http://') || source.startsWith('https://')) return { url: source };
|
|
206
|
+
if (source.startsWith('/') || source.startsWith('./') || source.startsWith('../')) return { localPath: source };
|
|
207
|
+
if (source.startsWith('data:')) {
|
|
208
|
+
const i = source.indexOf(',');
|
|
209
|
+
return { base64: i > 0 ? source.slice(i + 1) : source };
|
|
210
|
+
}
|
|
211
|
+
return { base64: source };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function formatError(err: unknown): SendResult {
|
|
215
|
+
if (err instanceof Error) {
|
|
216
|
+
const result: SendResult = { error: err.message };
|
|
217
|
+
if ('code' in err) result.errorCode = String((err as any).code);
|
|
218
|
+
if ('qqBizCode' in err) result.qqBizCode = (err as any).qqBizCode;
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
return { error: String(err) };
|
|
222
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 被动回复限额管理
|
|
3
|
+
*
|
|
4
|
+
* QQ Bot 被动回复有限额(同一条消息最多回复 N 次,超时后不能再被动回复)。
|
|
5
|
+
* 当限额耗尽或消息过期时,标记应降级为主动消息(proactive)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ReplyLimiterConfig {
|
|
9
|
+
/** 每条消息最大被动回复次数(默认 4) */
|
|
10
|
+
limit?: number;
|
|
11
|
+
/** 消息 ID 过期时间 ms(默认 1 小时) */
|
|
12
|
+
ttlMs?: number;
|
|
13
|
+
/** 最大跟踪消息数(LRU 驱逐,默认 10000) */
|
|
14
|
+
maxTrackedMessages?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ReplyLimitResult {
|
|
18
|
+
allowed: boolean;
|
|
19
|
+
remaining: number;
|
|
20
|
+
shouldFallbackToProactive: boolean;
|
|
21
|
+
fallbackReason?: 'expired' | 'limit_exceeded';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TrackedMessage {
|
|
25
|
+
count: number;
|
|
26
|
+
firstSeenAt: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 被动回复限额管理器
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* const limiter = new ReplyLimiter({ limit: 4, ttlMs: 3600_000 });
|
|
35
|
+
* const result = limiter.checkLimit(messageId);
|
|
36
|
+
* if (!result.allowed) {
|
|
37
|
+
* // 降级为主动消息
|
|
38
|
+
* } else {
|
|
39
|
+
* limiter.record(messageId);
|
|
40
|
+
* // 正常被动回复
|
|
41
|
+
* }
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export class ReplyLimiter {
|
|
45
|
+
private readonly limit: number;
|
|
46
|
+
private readonly ttlMs: number;
|
|
47
|
+
private readonly maxTracked: number;
|
|
48
|
+
private readonly messages = new Map<string, TrackedMessage>();
|
|
49
|
+
|
|
50
|
+
constructor(config?: ReplyLimiterConfig) {
|
|
51
|
+
this.limit = config?.limit ?? 4;
|
|
52
|
+
this.ttlMs = config?.ttlMs ?? 3600_000; // 1 小时
|
|
53
|
+
this.maxTracked = config?.maxTrackedMessages ?? 10_000;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 检查是否允许对指定消息继续被动回复
|
|
58
|
+
*/
|
|
59
|
+
checkLimit(messageId: string): ReplyLimitResult {
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
const tracked = this.messages.get(messageId);
|
|
62
|
+
|
|
63
|
+
if (!tracked) {
|
|
64
|
+
return { allowed: true, remaining: this.limit, shouldFallbackToProactive: false };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// TTL 过期
|
|
68
|
+
if (now - tracked.firstSeenAt > this.ttlMs) {
|
|
69
|
+
this.messages.delete(messageId);
|
|
70
|
+
return {
|
|
71
|
+
allowed: false,
|
|
72
|
+
remaining: 0,
|
|
73
|
+
shouldFallbackToProactive: true,
|
|
74
|
+
fallbackReason: 'expired',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 限额检查
|
|
79
|
+
const remaining = Math.max(0, this.limit - tracked.count);
|
|
80
|
+
if (remaining <= 0) {
|
|
81
|
+
return {
|
|
82
|
+
allowed: false,
|
|
83
|
+
remaining: 0,
|
|
84
|
+
shouldFallbackToProactive: true,
|
|
85
|
+
fallbackReason: 'limit_exceeded',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { allowed: true, remaining, shouldFallbackToProactive: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 记录一次被动回复
|
|
94
|
+
*/
|
|
95
|
+
record(messageId: string): void {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const tracked = this.messages.get(messageId);
|
|
98
|
+
|
|
99
|
+
if (tracked) {
|
|
100
|
+
tracked.count++;
|
|
101
|
+
} else {
|
|
102
|
+
// LRU 驱逐
|
|
103
|
+
if (this.messages.size >= this.maxTracked) {
|
|
104
|
+
const firstKey = this.messages.keys().next().value;
|
|
105
|
+
if (firstKey) this.messages.delete(firstKey);
|
|
106
|
+
}
|
|
107
|
+
this.messages.set(messageId, { count: 1, firstSeenAt: now });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 获取统计信息
|
|
113
|
+
*/
|
|
114
|
+
getStats(): { trackedMessages: number; totalReplies: number } {
|
|
115
|
+
let totalReplies = 0;
|
|
116
|
+
for (const [, v] of this.messages) {
|
|
117
|
+
totalReplies += v.count;
|
|
118
|
+
}
|
|
119
|
+
return { trackedMessages: this.messages.size, totalReplies };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 清除所有跟踪数据
|
|
124
|
+
*/
|
|
125
|
+
clear(): void {
|
|
126
|
+
this.messages.clear();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQBot 文本消毒
|
|
3
|
+
*
|
|
4
|
+
* 剥离框架内部脚手架标签(system-reminder、thinking、reasoning 等),
|
|
5
|
+
* 保留 Markdown/HTML 用于 QQ Bot Markdown 渲染。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const INTERNAL_TAGS = [
|
|
9
|
+
// 框架脚手架标签
|
|
10
|
+
/<system-reminder\b[^>]*>[\s\S]*?<\/system-reminder>/gi,
|
|
11
|
+
/<previous_response\b[^>]*>[\s\S]*?<\/previous_response>/gi,
|
|
12
|
+
/<\s*\/?\s*(?:system-reminder|previous_response)\b[^>]*\/?\s*>/gi,
|
|
13
|
+
// 模型推理/思考内容
|
|
14
|
+
// deepseek: `think`...`/think` — 匹配完整标签块,标签名 think 必须完整
|
|
15
|
+
// 格式说明:deepseek 用 `` ` `` (反引号) 替代 XML 的 `<` `>` 作为标签定界符
|
|
16
|
+
// `think` ≡ <think> 开标签
|
|
17
|
+
// `/think` ≡ </think> 闭标签
|
|
18
|
+
/`think`[\s\S]*?`\/think`/gi,
|
|
19
|
+
/<\s*\/?\s*think\b[^>]*\/?\s*>/gi,
|
|
20
|
+
// claude: <thinking>...</thinking>
|
|
21
|
+
/<thinking\b[^>]*>[\s\S]*?<\/thinking>/gi,
|
|
22
|
+
/<\s*\/?\s*thinking\b[^>]*\/?\s*>/gi,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** 剥离内部运行时脚手架块 + 模型推理内容 */
|
|
26
|
+
export function sanitizeQQBotText(text: string): string {
|
|
27
|
+
let result = text;
|
|
28
|
+
for (const re of INTERNAL_TAGS) {
|
|
29
|
+
result = result.replace(re, '');
|
|
30
|
+
}
|
|
31
|
+
return result.trim();
|
|
32
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 流式消息控制器
|
|
3
|
+
*
|
|
4
|
+
* 核心约束:QQ 流式 API 替换模式下,已下发文本的**前缀不可变更**。
|
|
5
|
+
*
|
|
6
|
+
* 状态机:
|
|
7
|
+
* IDLE → (first chunk) → STREAMING → (complete) → DONE
|
|
8
|
+
* → (prefix changed) → DONE
|
|
9
|
+
* → (new reply) → IDLE → …
|
|
10
|
+
*
|
|
11
|
+
* onPartialReply(text) 输入:模型全量文本,持续增长。
|
|
12
|
+
* finalize() 标记:框架通知回复结束,用 lastAccepted 收尾。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ReplyTarget, StreamSession } from '@tencent-connect/qqbot-nodejs';
|
|
16
|
+
import type { PluginLogger } from '../utils/plugin-logger.js';
|
|
17
|
+
import type { QQBotGateway } from '../gateway/qqbot-gateway.js';
|
|
18
|
+
|
|
19
|
+
// ── 类型 ──
|
|
20
|
+
|
|
21
|
+
export type StreamingPhase = 'idle' | 'streaming' | 'done' | 'failed';
|
|
22
|
+
|
|
23
|
+
export interface StreamingControllerDeps {
|
|
24
|
+
gateway: QQBotGateway;
|
|
25
|
+
target: ReplyTarget;
|
|
26
|
+
accountId: string;
|
|
27
|
+
replyToId: string;
|
|
28
|
+
log?: PluginLogger;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── 控制器 ──
|
|
32
|
+
|
|
33
|
+
export class StreamingController {
|
|
34
|
+
private phase: StreamingPhase = 'idle';
|
|
35
|
+
private session: StreamSession | null = null;
|
|
36
|
+
|
|
37
|
+
/** QQ 已接受的最新文本 — 单源真理 */
|
|
38
|
+
private lastAcceptedFull = '';
|
|
39
|
+
|
|
40
|
+
/** 已成功发送的分片数(降级:=0 则走静态消息兜底) */
|
|
41
|
+
private sentChunkCount = 0;
|
|
42
|
+
|
|
43
|
+
/** 同步标志:收到第一个 onPartialReply 即置 true(不等 async 完成) */
|
|
44
|
+
private _hasStarted = false;
|
|
45
|
+
|
|
46
|
+
/** 串行队列 */
|
|
47
|
+
private chain: Promise<unknown> = Promise.resolve();
|
|
48
|
+
|
|
49
|
+
constructor(private readonly deps: StreamingControllerDeps) {}
|
|
50
|
+
|
|
51
|
+
// ── 公共访问器 ──
|
|
52
|
+
|
|
53
|
+
get currentPhase(): StreamingPhase { return this.phase; }
|
|
54
|
+
|
|
55
|
+
/** 是否已成功发送至少一个流式分片 */
|
|
56
|
+
get hasSentChunks(): boolean { return this.sentChunkCount > 0; }
|
|
57
|
+
|
|
58
|
+
/** 同步标志:流式已启动(不等异步完成),用于 final 去重 */
|
|
59
|
+
get hasStarted(): boolean { return this._hasStarted; }
|
|
60
|
+
|
|
61
|
+
get isTerminal(): boolean {
|
|
62
|
+
return this.phase === 'done' || this.phase === 'failed';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get shouldFallbackToStatic(): boolean {
|
|
66
|
+
return this.isTerminal && this.sentChunkCount === 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── 入口 ──
|
|
70
|
+
|
|
71
|
+
onPartialReply(text: string): Promise<void> {
|
|
72
|
+
this._hasStarted = true; // 同步置位:不等异步发送,final 就能感知流式已启动
|
|
73
|
+
this.chain = this.chain.then(() => this.handleChunk(text)).catch((err) => {
|
|
74
|
+
this.deps.log?.error(`onPartialReply error: ${err instanceof Error ? err.message : String(err)}`);
|
|
75
|
+
this.transition('failed', 'chunk_error');
|
|
76
|
+
});
|
|
77
|
+
return this.chain as Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
finalize(): Promise<void> {
|
|
81
|
+
this.chain = this.chain.then(() => this.handleFinalize()).catch((err) => {
|
|
82
|
+
this.deps.log?.error(`finalize error: ${err instanceof Error ? err.message : String(err)}`);
|
|
83
|
+
this.transition('failed', 'finalize_error');
|
|
84
|
+
});
|
|
85
|
+
return this.chain as Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async abort(reason?: string): Promise<void> {
|
|
89
|
+
if (this.isTerminal) return;
|
|
90
|
+
this.deps.log?.warn(`aborting stream reason=${reason ?? 'manual'} sent=${this.sentChunkCount}`);
|
|
91
|
+
if (this.session) {
|
|
92
|
+
try { await this.session.complete(); } catch (e) {
|
|
93
|
+
this.deps.log?.error(`abort complete failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
94
|
+
}
|
|
95
|
+
this.session = null;
|
|
96
|
+
}
|
|
97
|
+
this.transition('failed', `abort:${reason ?? 'manual'}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── 核心逻辑 ──
|
|
101
|
+
|
|
102
|
+
private async handleChunk(text: string): Promise<void> {
|
|
103
|
+
if (this.isTerminal || !text) return;
|
|
104
|
+
|
|
105
|
+
// 正常续写:新文本前缀匹配(含空白归一化)
|
|
106
|
+
if (prefixMatches(this.lastAcceptedFull, text)) {
|
|
107
|
+
if (text.length !== this.lastAcceptedFull.length) {
|
|
108
|
+
await this.sendUpdate(text);
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 无已下发内容 → 首发
|
|
114
|
+
if (!this.lastAcceptedFull) {
|
|
115
|
+
await this.sendUpdate(text);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 前缀不匹配 — 长度回退 → 新回复(工具调用后)
|
|
120
|
+
if (text.length < this.lastAcceptedFull.length) {
|
|
121
|
+
this.deps.log?.info(`new reply: lastAccepted=${this.lastAcceptedFull.length}→chunk=${text.length}`);
|
|
122
|
+
await this.completeSession('new_reply');
|
|
123
|
+
this.lastAcceptedFull = '';
|
|
124
|
+
await this.sendUpdate(text);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 前缀不匹配但长度增长 → 模型重写尾部,同一条流追加
|
|
129
|
+
const commonLen = longestCommonPrefix(this.lastAcceptedFull, text);
|
|
130
|
+
const extra = text.slice(Math.max(commonLen, 0));
|
|
131
|
+
const merged = this.lastAcceptedFull + extra;
|
|
132
|
+
this.deps.log?.warn(
|
|
133
|
+
`prefix retry: lastAccepted=${this.lastAcceptedFull.length}→chunk=${text.length} common=${commonLen} extra=${extra.length}, appending`,
|
|
134
|
+
);
|
|
135
|
+
await this.sendUpdate(merged);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async handleFinalize(): Promise<void> {
|
|
139
|
+
if (this.isTerminal) return;
|
|
140
|
+
|
|
141
|
+
// 用已下发文本收尾 — 不用框架 deliver 的文本(框架可能追加 ⚠️ 标记)
|
|
142
|
+
if (this.session) {
|
|
143
|
+
await this.completeSession();
|
|
144
|
+
this.transition('done', 'finalize');
|
|
145
|
+
this.deps.log?.info(`stream done chunks=${this.sentChunkCount} chars=${this.lastAcceptedFull.length}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 无会话 — 视有无下发决定终态
|
|
150
|
+
if (this.sentChunkCount > 0) {
|
|
151
|
+
this.transition('done', 'finalize:no_session');
|
|
152
|
+
} else {
|
|
153
|
+
this.transition('failed', 'finalize:fallback');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── QQ 交互 ──
|
|
158
|
+
|
|
159
|
+
private async sendUpdate(text: string): Promise<void> {
|
|
160
|
+
if (!this.session) {
|
|
161
|
+
this.session = this.deps.gateway.openStream(this.deps.target, this.deps.replyToId);
|
|
162
|
+
this.transition('streaming', 'first_chunk');
|
|
163
|
+
this.deps.log?.info(`stream opened (firstChunk=${text.length})`);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
await this.session.update(text);
|
|
167
|
+
this.lastAcceptedFull = text;
|
|
168
|
+
this.sentChunkCount++;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
this.deps.log?.error(`update failed (len=${text.length}): ${err instanceof Error ? err.message : String(err)}`);
|
|
171
|
+
this.session = null;
|
|
172
|
+
this.transition('failed', 'update_error');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async completeSession(reason?: string): Promise<void> {
|
|
177
|
+
if (!this.session) return;
|
|
178
|
+
this.deps.log?.info(`completing stream (sent=${this.sentChunkCount} chars=${this.lastAcceptedFull.length} reason=${reason ?? 'done'})`);
|
|
179
|
+
try {
|
|
180
|
+
await this.session.complete();
|
|
181
|
+
} catch (err) {
|
|
182
|
+
this.deps.log?.error(`complete failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
183
|
+
}
|
|
184
|
+
this.session = null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── 状态机 ──
|
|
188
|
+
|
|
189
|
+
private transition(next: StreamingPhase, reason: string): void {
|
|
190
|
+
if (this.phase === next) return;
|
|
191
|
+
this.deps.log?.info(`phase: ${this.phase} → ${next} (${reason})`);
|
|
192
|
+
this.phase = next;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 归一化空白符:连续换行/空格合并为单个空格 */
|
|
197
|
+
function normalizeWs(s: string): string {
|
|
198
|
+
return s.replace(/\s+/g, ' ');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 忽略空白前缀比较 */
|
|
202
|
+
function prefixMatches(accepted: string, incoming: string): boolean {
|
|
203
|
+
if (incoming.startsWith(accepted)) return true;
|
|
204
|
+
return normalizeWs(incoming).startsWith(normalizeWs(accepted));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function longestCommonPrefix(a: string, b: string): number {
|
|
208
|
+
let i = 0;
|
|
209
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
210
|
+
return i;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── 入口判断 ──
|
|
214
|
+
|
|
215
|
+
import type { ResolvedQQBotAccount } from '../types.js';
|
|
216
|
+
|
|
217
|
+
export function shouldUseStreaming(
|
|
218
|
+
account: ResolvedQQBotAccount,
|
|
219
|
+
targetScope: 'c2c' | 'group' | 'channel',
|
|
220
|
+
): boolean {
|
|
221
|
+
if (targetScope !== 'c2c') return false;
|
|
222
|
+
const streaming = account.config?.streaming;
|
|
223
|
+
if (typeof streaming === 'boolean') return streaming;
|
|
224
|
+
if (streaming && typeof streaming === 'object') {
|
|
225
|
+
return (streaming as any).mode !== 'off';
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 出站目标地址解析
|
|
3
|
+
*
|
|
4
|
+
* 将 OpenClaw 规范的目标地址字符串(如 qqbot:c2c:xxx / qqbot:group:xxx)
|
|
5
|
+
* 转换为 SDK 的 ReplyTarget 结构。
|
|
6
|
+
*
|
|
7
|
+
* 也导出共享的正则常量供 channel.ts messaging 段复用。
|
|
8
|
+
*/
|
|
9
|
+
import type { ReplyTarget } from '@tencent-connect/qqbot-nodejs';
|
|
10
|
+
|
|
11
|
+
// ── 共享正则常量 ──
|
|
12
|
+
|
|
13
|
+
/** 32 位十六进制 OpenID(不带连字符) */
|
|
14
|
+
export const OPENID_HEX_RE = /^[0-9a-fA-F]{32}$/;
|
|
15
|
+
|
|
16
|
+
/** UUID 格式的 OpenID(带连字符) */
|
|
17
|
+
export const OPENID_UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
18
|
+
|
|
19
|
+
/** 带 qqbot: 前缀的目标格式 */
|
|
20
|
+
export const QQBOT_PREFIX_RE = /^qqbot:(c2c|group|channel):/i;
|
|
21
|
+
|
|
22
|
+
/** 不带前缀但有 scope 标识 */
|
|
23
|
+
export const SCOPE_PREFIX_RE = /^(c2c|group|channel):/i;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 判断 ID 是否看起来像 QQ Bot 目标格式
|
|
27
|
+
*/
|
|
28
|
+
export function isQQBotTarget(id: string): boolean {
|
|
29
|
+
if (QQBOT_PREFIX_RE.test(id)) return true;
|
|
30
|
+
if (SCOPE_PREFIX_RE.test(id)) return true;
|
|
31
|
+
if (OPENID_HEX_RE.test(id)) return true;
|
|
32
|
+
return OPENID_UUID_RE.test(id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 规范化目标地址字符串
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeTarget(target: string): string | undefined {
|
|
39
|
+
const id = target.replace(/^qqbot:/i, '');
|
|
40
|
+
if (id.startsWith('c2c:') || id.startsWith('group:') || id.startsWith('channel:')) {
|
|
41
|
+
return `qqbot:${id}`;
|
|
42
|
+
}
|
|
43
|
+
if (OPENID_HEX_RE.test(id)) return `qqbot:c2c:${id}`;
|
|
44
|
+
if (OPENID_UUID_RE.test(id)) return `qqbot:c2c:${id}`;
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 解析目标地址字符串为 SDK ReplyTarget
|
|
50
|
+
*/
|
|
51
|
+
export function parseTarget(to: string): ReplyTarget {
|
|
52
|
+
const id = to.replace(/^qqbot:/i, '');
|
|
53
|
+
|
|
54
|
+
if (id.startsWith('c2c:')) {
|
|
55
|
+
return { scope: 'c2c', targetId: id.slice(4) };
|
|
56
|
+
}
|
|
57
|
+
if (id.startsWith('group:')) {
|
|
58
|
+
return { scope: 'group', targetId: id.slice(6) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 默认当作 c2c(32 位十六进制 / UUID 格式的 openid)
|
|
62
|
+
return { scope: 'c2c', targetId: id };
|
|
63
|
+
}
|