@tencent-connect/openclaw-qqbot 1.7.1 → 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 +206 -4
- package/README.zh.md +207 -4
- 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 -18
- 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 +400 -73
- package/scripts/upgrade-via-source.sh +126 -89
- 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 +59 -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 +1 -1
- package/src/types-augment.d.ts +54 -0
- package/src/types.ts +71 -8
- package/src/typings/openclaw-webhook-ingress.d.ts +66 -0
- 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 -17
- package/dist/index.js +0 -26
- package/dist/src/admin-resolver.d.ts +0 -33
- package/dist/src/admin-resolver.js +0 -157
- package/dist/src/api.d.ts +0 -307
- package/dist/src/api.js +0 -910
- 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 -56
- package/dist/src/config.js +0 -278
- 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 -2063
- 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 -2117
- 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 -234
- package/dist/src/tools/remind.d.ts +0 -2
- package/dist/src/tools/remind.js +0 -256
- package/dist/src/types.d.ts +0 -443
- 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 -755
- 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 -126
- package/skills/qqbot-media/SKILL.md +0 -60
- package/src/admin-resolver.ts +0 -181
- package/src/api.ts +0 -1319
- package/src/credential-backup.ts +0 -72
- package/src/deliver-debounce.ts +0 -229
- package/src/gateway.ts +0 -2304
- 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 -274
- 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 -2297
- 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 -281
- package/src/typing-keepalive.ts +0 -59
- package/src/utils/audio-convert.ts +0 -859
- 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,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron 定时消息
|
|
3
|
+
*
|
|
4
|
+
* 当 AI 输出 `QQBOT_PAYLOAD: { "type": "cron_reminder", "cron": "...", "text": "..." }`
|
|
5
|
+
* 时,注册一个定时任务在指定时间向用户发送提醒消息。
|
|
6
|
+
*
|
|
7
|
+
* 使用 setTimeout 实现(适合单次/短期提醒),不依赖外部调度库。
|
|
8
|
+
* 复杂 cron 表达式场景需要用户自行接入 node-cron 等库。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface CronReminderPayload {
|
|
12
|
+
/** 提醒文本 */
|
|
13
|
+
text: string;
|
|
14
|
+
/** 触发时间:ISO 8601 字符串 或 Unix ms 或 "in Xm/Xh/Xd" 相对格式 */
|
|
15
|
+
at?: string | number;
|
|
16
|
+
/** cron 表达式(暂不支持,仅做记录) */
|
|
17
|
+
cron?: string;
|
|
18
|
+
/** 发送目标 */
|
|
19
|
+
target?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ScheduledReminder {
|
|
23
|
+
id: string;
|
|
24
|
+
text: string;
|
|
25
|
+
target: string;
|
|
26
|
+
triggerAt: number;
|
|
27
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type SendFn = (target: string, text: string) => Promise<void>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 定时消息调度器
|
|
34
|
+
*/
|
|
35
|
+
export class CronScheduler {
|
|
36
|
+
private reminders = new Map<string, ScheduledReminder>();
|
|
37
|
+
private nextId = 0;
|
|
38
|
+
|
|
39
|
+
constructor(private readonly send: SendFn) {}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 注册一个定时提醒
|
|
43
|
+
*/
|
|
44
|
+
schedule(payload: CronReminderPayload, defaultTarget: string): { id: string; triggerAt: number; confirmText: string } {
|
|
45
|
+
const id = `reminder_${++this.nextId}`;
|
|
46
|
+
const target = payload.target ?? defaultTarget;
|
|
47
|
+
const triggerAt = resolveTriggerTime(payload.at);
|
|
48
|
+
const delayMs = Math.max(0, triggerAt - Date.now());
|
|
49
|
+
const text = payload.text?.trim() ?? '定时提醒';
|
|
50
|
+
|
|
51
|
+
const reminder: ScheduledReminder = {
|
|
52
|
+
id,
|
|
53
|
+
text,
|
|
54
|
+
target,
|
|
55
|
+
triggerAt,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// 如果延迟小于 24 小时,直接 setTimeout;否则只记录不调度
|
|
59
|
+
const MAX_TIMEOUT = 24 * 60 * 60 * 1000; // 24h
|
|
60
|
+
if (delayMs <= MAX_TIMEOUT) {
|
|
61
|
+
reminder.timer = setTimeout(async () => {
|
|
62
|
+
this.reminders.delete(id);
|
|
63
|
+
try {
|
|
64
|
+
await this.send(target, `⏰ ${text}`);
|
|
65
|
+
} catch { /* swallow */ }
|
|
66
|
+
}, delayMs);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.reminders.set(id, reminder);
|
|
70
|
+
|
|
71
|
+
const when = new Date(triggerAt);
|
|
72
|
+
const confirmText = `✅ Reminder scheduled for ${when.toLocaleString()}: "${text}"`;
|
|
73
|
+
return { id, triggerAt, confirmText };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 取消一个提醒
|
|
78
|
+
*/
|
|
79
|
+
cancel(id: string): boolean {
|
|
80
|
+
const reminder = this.reminders.get(id);
|
|
81
|
+
if (!reminder) return false;
|
|
82
|
+
if (reminder.timer) clearTimeout(reminder.timer);
|
|
83
|
+
this.reminders.delete(id);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 清除所有提醒
|
|
89
|
+
*/
|
|
90
|
+
clear(): void {
|
|
91
|
+
for (const [, r] of this.reminders) {
|
|
92
|
+
if (r.timer) clearTimeout(r.timer);
|
|
93
|
+
}
|
|
94
|
+
this.reminders.clear();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 获取所有待触发的提醒
|
|
99
|
+
*/
|
|
100
|
+
listPending(): ScheduledReminder[] {
|
|
101
|
+
return [...this.reminders.values()];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── 时间解析 ──
|
|
106
|
+
|
|
107
|
+
function resolveTriggerTime(at: string | number | undefined): number {
|
|
108
|
+
if (!at) return Date.now() + 60_000; // 默认 1 分钟后
|
|
109
|
+
|
|
110
|
+
if (typeof at === 'number') return at;
|
|
111
|
+
|
|
112
|
+
// ISO 8601
|
|
113
|
+
const dateMs = new Date(at).getTime();
|
|
114
|
+
if (!Number.isNaN(dateMs)) return dateMs;
|
|
115
|
+
|
|
116
|
+
// 相对格式: "in 5m", "in 2h", "in 1d"
|
|
117
|
+
const relMatch = at.match(/^(?:in\s+)?(\d+)\s*(m|min|h|hour|d|day|s|sec)/i);
|
|
118
|
+
if (relMatch) {
|
|
119
|
+
const val = Number(relMatch[1]);
|
|
120
|
+
const unit = relMatch[2].toLowerCase();
|
|
121
|
+
const multiplier =
|
|
122
|
+
unit.startsWith('s') ? 1000 :
|
|
123
|
+
unit.startsWith('m') ? 60_000 :
|
|
124
|
+
unit.startsWith('h') ? 3600_000 :
|
|
125
|
+
unit.startsWith('d') ? 86400_000 : 60_000;
|
|
126
|
+
return Date.now() + val * multiplier;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return Date.now() + 60_000;
|
|
130
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 出站消息合并防抖
|
|
3
|
+
*
|
|
4
|
+
* 当短时间内收到多次 deliver 时,将文本合并为一条消息发送,避免消息轰炸。
|
|
5
|
+
*
|
|
6
|
+
* 注:框架 ReplyDispatcher 通过 sendChain 保证 deliver 严格串行——
|
|
7
|
+
* enqueue 返回的 Promise 在文本实际发出前不会 resolve,因此上游
|
|
8
|
+
* 不会并发调用 deliver,无需 flushBeforeMedia。
|
|
9
|
+
*/
|
|
10
|
+
import type { DeliverDebounceConfig } from '../types.js';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_WINDOW_MS = 1500;
|
|
13
|
+
const DEFAULT_MAX_WAIT_MS = 8000;
|
|
14
|
+
const DEFAULT_SEPARATOR = '\n\n---\n\n';
|
|
15
|
+
|
|
16
|
+
interface PendingDeliver {
|
|
17
|
+
texts: string[];
|
|
18
|
+
firstAt: number;
|
|
19
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
20
|
+
resolve: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class DeliverDebouncer {
|
|
24
|
+
private pending = new Map<string, PendingDeliver>();
|
|
25
|
+
private readonly windowMs: number;
|
|
26
|
+
private readonly maxWaitMs: number;
|
|
27
|
+
private readonly separator: string;
|
|
28
|
+
private readonly flush: (targetId: string, mergedText: string) => Promise<void>;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
config: DeliverDebounceConfig | undefined,
|
|
32
|
+
flush: (targetId: string, mergedText: string) => Promise<void>,
|
|
33
|
+
) {
|
|
34
|
+
this.windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS;
|
|
35
|
+
this.maxWaitMs = config?.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
36
|
+
this.separator = config?.separator ?? DEFAULT_SEPARATOR;
|
|
37
|
+
this.flush = flush;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get enabled(): boolean {
|
|
41
|
+
return this.windowMs > 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 入队一条纯文本待发送消息
|
|
46
|
+
* @returns Promise,在文本实际发送时 resolve
|
|
47
|
+
*/
|
|
48
|
+
async enqueue(targetId: string, text: string): Promise<void> {
|
|
49
|
+
if (!this.enabled) {
|
|
50
|
+
await this.flush(targetId, text);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const existing = this.pending.get(targetId);
|
|
55
|
+
if (existing) {
|
|
56
|
+
existing.texts.push(text);
|
|
57
|
+
// 重置窗口定时器
|
|
58
|
+
if (existing.timer) clearTimeout(existing.timer);
|
|
59
|
+
// 检查是否超过最大等待
|
|
60
|
+
if (Date.now() - existing.firstAt >= this.maxWaitMs) {
|
|
61
|
+
await this.doFlush(targetId);
|
|
62
|
+
} else {
|
|
63
|
+
existing.timer = setTimeout(() => this.doFlush(targetId), this.windowMs);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 新建 pending
|
|
69
|
+
return new Promise<void>((resolve) => {
|
|
70
|
+
const pending: PendingDeliver = {
|
|
71
|
+
texts: [text],
|
|
72
|
+
firstAt: Date.now(),
|
|
73
|
+
timer: setTimeout(() => this.doFlush(targetId), this.windowMs),
|
|
74
|
+
resolve,
|
|
75
|
+
};
|
|
76
|
+
this.pending.set(targetId, pending);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async doFlush(targetId: string): Promise<void> {
|
|
81
|
+
const pending = this.pending.get(targetId);
|
|
82
|
+
if (!pending) return;
|
|
83
|
+
this.pending.delete(targetId);
|
|
84
|
+
|
|
85
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
86
|
+
const merged = pending.texts.join(this.separator);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
await this.flush(targetId, merged);
|
|
90
|
+
} finally {
|
|
91
|
+
pending.resolve();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 强制刷新所有 pending
|
|
97
|
+
*/
|
|
98
|
+
async flushAll(): Promise<void> {
|
|
99
|
+
const keys = [...this.pending.keys()];
|
|
100
|
+
await Promise.all(keys.map((k) => this.doFlush(k)));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 出站 Deliver Pipeline — 处理 AI 回复的富媒体分发
|
|
3
|
+
*
|
|
4
|
+
* 框架通过 ReplyPayload 结构化字段投递回复:
|
|
5
|
+
* - text: 纯文本
|
|
6
|
+
* - mediaUrl: 单个媒体 URL
|
|
7
|
+
* - mediaUrls: 多个媒体 URL
|
|
8
|
+
* - audioAsVoice: 语音气泡(TTS)
|
|
9
|
+
*
|
|
10
|
+
* Pipeline 按顺序处理:
|
|
11
|
+
* 1. 语音意图 — audioAsVoice (TTS → SILK → 发送)
|
|
12
|
+
* 2. 媒体发送 — mediaUrl / mediaUrls(图片/文件/视频)
|
|
13
|
+
* 3. 纯文本回复 — 走 debounce 合并
|
|
14
|
+
*/
|
|
15
|
+
import { sendMedia } from './media-send.js';
|
|
16
|
+
import type { MediaKind, SendResult } from './outbound-service.js';
|
|
17
|
+
import type { DeliverDebouncer } from './debounce.js';
|
|
18
|
+
import type { PluginLogger } from '../utils/plugin-logger.js';
|
|
19
|
+
import { isPathInAllowedRoots } from './local-file-router.js';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
import * as os from 'node:os';
|
|
22
|
+
|
|
23
|
+
// ── 类型 ──
|
|
24
|
+
|
|
25
|
+
export interface DeliverPayload {
|
|
26
|
+
text?: string;
|
|
27
|
+
mediaUrl?: string;
|
|
28
|
+
mediaUrls?: string[];
|
|
29
|
+
audioAsVoice?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DeliverInfo {
|
|
33
|
+
kind: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DeliverContext {
|
|
37
|
+
qualifiedTarget: string;
|
|
38
|
+
accountId: string;
|
|
39
|
+
replyToId: string;
|
|
40
|
+
/** 会话类型(用于判断语音是否可用) */
|
|
41
|
+
chatScope?: 'direct' | 'group';
|
|
42
|
+
/** 发送文本 */
|
|
43
|
+
sendText: (to: string, text: string) => Promise<SendResult>;
|
|
44
|
+
/** 发送媒体 */
|
|
45
|
+
sendMedia: (to: string, source: string, opts?: { text?: string; mediaKind?: MediaKind }) => Promise<SendResult>;
|
|
46
|
+
/** TTS 文字转语音(返回本地音频路径,失败返回 null) */
|
|
47
|
+
textToSpeech?: (params: { text: string; cfg?: unknown; channel?: string; accountId?: string }) => Promise<{ audioPath: string | null; error?: string }>;
|
|
48
|
+
/** 音频文件转 SILK Base64(QQ 语音格式) */
|
|
49
|
+
audioFileToSilkBase64?: (audioPath: string) => Promise<string | null>;
|
|
50
|
+
/** 出站文本合并防抖(可选,启用时纯文本走 debounce) */
|
|
51
|
+
debouncer?: DeliverDebouncer;
|
|
52
|
+
/** 运行时配置 */
|
|
53
|
+
cfg?: unknown;
|
|
54
|
+
/** 日志 */
|
|
55
|
+
log?: PluginLogger;
|
|
56
|
+
/** Agent ID(用于解析相对文件路径的工作区) */
|
|
57
|
+
agentId?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 解析 ReplyPayload 中的媒体 URL 列表(优先 mediaUrls,fallback 单个 mediaUrl)
|
|
62
|
+
*/
|
|
63
|
+
function resolveMediaUrls(payload: DeliverPayload): string[] {
|
|
64
|
+
if (payload.mediaUrls?.length) {
|
|
65
|
+
return payload.mediaUrls.filter(Boolean);
|
|
66
|
+
}
|
|
67
|
+
if (payload.mediaUrl) {
|
|
68
|
+
return [payload.mediaUrl];
|
|
69
|
+
}
|
|
70
|
+
// attachments[].path → 本地文件路径(findLocalPath() 展开可能相对路径)
|
|
71
|
+
return resolveAttachmentPaths(payload);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveAttachmentPaths(payload: DeliverPayload): string[] {
|
|
75
|
+
const attachments = (payload as any).attachments as Array<{ path?: string; url?: string }> | undefined;
|
|
76
|
+
if (!attachments?.length) return [];
|
|
77
|
+
return attachments
|
|
78
|
+
.map((a) => a.url ?? a.path)
|
|
79
|
+
.filter((p): p is string => typeof p === 'string' && p.length > 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 出站 Deliver Pipeline
|
|
84
|
+
*
|
|
85
|
+
* 按顺序处理:
|
|
86
|
+
* 1. 语音意图 → TTS
|
|
87
|
+
* 2. 媒体发送 → mediaUrl / mediaUrls
|
|
88
|
+
* 3. 纯文本回复 → debounce 合并
|
|
89
|
+
*/
|
|
90
|
+
export async function deliverReply(
|
|
91
|
+
payload: DeliverPayload,
|
|
92
|
+
_info: DeliverInfo | undefined,
|
|
93
|
+
ctx: DeliverContext,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const text = payload.text?.trim() ?? '';
|
|
96
|
+
const mediaUrls = resolveMediaUrls(payload);
|
|
97
|
+
const hasMedia = mediaUrls.length > 0;
|
|
98
|
+
|
|
99
|
+
ctx.log?.debug(`[text] textLen=${text.length} mediaCount=${mediaUrls.length}`);
|
|
100
|
+
|
|
101
|
+
// ── Layer 1: 语音意图 ──
|
|
102
|
+
if (payload.audioAsVoice) {
|
|
103
|
+
if (ctx.textToSpeech && text) {
|
|
104
|
+
const handled = await handleVoiceIntent(text, ctx);
|
|
105
|
+
if (handled) {
|
|
106
|
+
return; // 语音已发送,不再重复发送媒体或文本
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// TTS 不可用或合成失败 → 降级为纯文本 + 媒体
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Layer 2: 媒体发送 ──
|
|
113
|
+
if (hasMedia) {
|
|
114
|
+
await sendMediaUrls(ctx, mediaUrls);
|
|
115
|
+
// 附带文本一并发送
|
|
116
|
+
if (text) {
|
|
117
|
+
const result = await ctx.sendText(ctx.qualifiedTarget, text);
|
|
118
|
+
if (result.error) {
|
|
119
|
+
ctx.log?.error(`[media] sendText failed: ${result.error}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Layer 3: 纯文本回复 ──
|
|
126
|
+
if (!text) return;
|
|
127
|
+
|
|
128
|
+
if (ctx.debouncer) {
|
|
129
|
+
await ctx.debouncer.enqueue(ctx.qualifiedTarget, text);
|
|
130
|
+
} else {
|
|
131
|
+
const result = await ctx.sendText(ctx.qualifiedTarget, text);
|
|
132
|
+
if (result.error) {
|
|
133
|
+
ctx.log?.error(`[text] sendText failed: ${result.error}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── 媒体批量发送 ──
|
|
139
|
+
|
|
140
|
+
async function sendMediaUrls(ctx: DeliverContext, urls: string[]): Promise<void> {
|
|
141
|
+
const failedUrls: string[] = [];
|
|
142
|
+
for (const url of urls) {
|
|
143
|
+
const result = await sendMedia({
|
|
144
|
+
to: ctx.qualifiedTarget,
|
|
145
|
+
source: url,
|
|
146
|
+
replyToId: ctx.replyToId,
|
|
147
|
+
accountId: ctx.accountId,
|
|
148
|
+
log: ctx.log,
|
|
149
|
+
agentId: ctx.agentId,
|
|
150
|
+
});
|
|
151
|
+
if (result.error) {
|
|
152
|
+
ctx.log?.error(`[media] ${result.error}`);
|
|
153
|
+
failedUrls.push(url);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 媒体发送失败时通知用户
|
|
158
|
+
if (failedUrls.length > 0) {
|
|
159
|
+
const count = failedUrls.length;
|
|
160
|
+
const total = urls.length;
|
|
161
|
+
const failMsg = count === total
|
|
162
|
+
? `⚠️ 媒体发送失败(${count} 个),请重试`
|
|
163
|
+
: `⚠️ ${count}/${total} 个媒体发送失败`;
|
|
164
|
+
const textResult = await ctx.sendText(ctx.qualifiedTarget, failMsg);
|
|
165
|
+
if (textResult.error) {
|
|
166
|
+
ctx.log?.error(`[media] failed to send failure notification: ${textResult.error}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── 语音意图处理 ──
|
|
172
|
+
|
|
173
|
+
async function handleVoiceIntent(text: string, ctx: DeliverContext): Promise<boolean> {
|
|
174
|
+
if (!text || !ctx.textToSpeech) return false;
|
|
175
|
+
|
|
176
|
+
// 仅 c2c/group 支持语音消息;其他场景 fallback 发文本
|
|
177
|
+
if (ctx.chatScope && ctx.chatScope !== 'direct' && ctx.chatScope !== 'group') {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const ttsResult = await ctx.textToSpeech({
|
|
183
|
+
text,
|
|
184
|
+
cfg: ctx.cfg,
|
|
185
|
+
channel: 'qqbot',
|
|
186
|
+
accountId: ctx.accountId,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (!ttsResult.audioPath) {
|
|
190
|
+
ctx.log?.error(`[tts] TTS failed: ${ttsResult.error ?? 'no audio path returned'}`);
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// TTS 路径安全校验:确保音频文件在允许的临时目录内
|
|
195
|
+
if (!isTtsPathSafe(ttsResult.audioPath)) {
|
|
196
|
+
ctx.log?.error(`[tts] TTS audio path blocked: ${ttsResult.audioPath}`);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 优先走 SILK base64
|
|
201
|
+
if (ctx.audioFileToSilkBase64) {
|
|
202
|
+
const silkBase64 = await ctx.audioFileToSilkBase64(ttsResult.audioPath);
|
|
203
|
+
if (silkBase64) {
|
|
204
|
+
const result = await ctx.sendMedia(ctx.qualifiedTarget, silkBase64, { mediaKind: 'voice' });
|
|
205
|
+
if (result.error) ctx.log?.error(`[tts] sendVoice(base64) failed: ${result.error}`);
|
|
206
|
+
return !result.error;
|
|
207
|
+
}
|
|
208
|
+
ctx.log?.error(`[tts] SILK conversion failed, trying localPath fallback`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Fallback: 直接发本地路径(SDK 内部处理格式转换)
|
|
212
|
+
const result = await ctx.sendMedia(ctx.qualifiedTarget, ttsResult.audioPath, { mediaKind: 'voice' });
|
|
213
|
+
if (result.error) {
|
|
214
|
+
ctx.log?.error(`[tts] sendVoice(localPath) failed: ${result.error}`);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
} catch (err) {
|
|
219
|
+
ctx.log?.error(`[tts] TTS/voice send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── TTS 路径安全 ──
|
|
225
|
+
|
|
226
|
+
const TTS_ALLOWED_ROOTS = [
|
|
227
|
+
path.join(os.homedir(), '.openclaw'),
|
|
228
|
+
os.tmpdir(),
|
|
229
|
+
'/tmp',
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
function isTtsPathSafe(audioPath: string): boolean {
|
|
233
|
+
if (!path.isAbsolute(audioPath)) return false;
|
|
234
|
+
return isPathInAllowedRoots(audioPath, TTS_ALLOWED_ROOTS);
|
|
235
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown 图片尺寸探测
|
|
3
|
+
*
|
|
4
|
+
* 发送前探测图片宽高,格式化为 `` 格式。
|
|
5
|
+
* QQ 客户端 Markdown 渲染时需要这个尺寸提示来优化图片显示比例。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { validateRemoteUrl } from '../utils/ssrf-guard.js';
|
|
9
|
+
|
|
10
|
+
/** 图片尺寸结果 */
|
|
11
|
+
export interface ImageSize {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 通过 HTTP Range 请求前 N 字节解析图片尺寸
|
|
18
|
+
*
|
|
19
|
+
* 支持 PNG / JPEG / GIF / WebP 格式的快速头部探测(不下载完整图片)。
|
|
20
|
+
* 内置 SSRF 防护:fetch 前先校验 URL 安全性。
|
|
21
|
+
*/
|
|
22
|
+
export async function getImageSize(url: string, timeoutMs = 5000): Promise<ImageSize | null> {
|
|
23
|
+
try {
|
|
24
|
+
// SSRF 防护:禁止访问内网 / 云元数据端点
|
|
25
|
+
await validateRemoteUrl(url);
|
|
26
|
+
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
29
|
+
|
|
30
|
+
const resp = await fetch(url, {
|
|
31
|
+
signal: controller.signal,
|
|
32
|
+
headers: { Range: 'bytes=0-32767' }, // 请求前 32KB 足够解析头
|
|
33
|
+
});
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
|
|
36
|
+
if (!resp.ok && resp.status !== 206) return null;
|
|
37
|
+
|
|
38
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
39
|
+
return parseImageDimensions(buffer);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 从 buffer 头部解析图片尺寸
|
|
47
|
+
*/
|
|
48
|
+
function parseImageDimensions(buffer: Buffer): ImageSize | null {
|
|
49
|
+
if (buffer.length < 8) return null;
|
|
50
|
+
|
|
51
|
+
// PNG: 前 8 字节签名 + IHDR chunk (offset 16: width BE uint32, offset 20: height BE uint32)
|
|
52
|
+
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
|
53
|
+
if (buffer.length >= 24) {
|
|
54
|
+
return {
|
|
55
|
+
width: buffer.readUInt32BE(16),
|
|
56
|
+
height: buffer.readUInt32BE(20),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// JPEG: 搜索 SOFn marker (0xFF 0xC0-0xCF, 除 0xC4/0xC8/0xCC)
|
|
62
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
|
|
63
|
+
let offset = 2;
|
|
64
|
+
while (offset < buffer.length - 9) {
|
|
65
|
+
if (buffer[offset] !== 0xff) { offset++; continue; }
|
|
66
|
+
const marker = buffer[offset + 1];
|
|
67
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
68
|
+
return {
|
|
69
|
+
height: buffer.readUInt16BE(offset + 5),
|
|
70
|
+
width: buffer.readUInt16BE(offset + 7),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const segLen = buffer.readUInt16BE(offset + 2);
|
|
74
|
+
offset += 2 + segLen;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// GIF: offset 6: width LE uint16, offset 8: height LE uint16
|
|
79
|
+
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
|
|
80
|
+
if (buffer.length >= 10) {
|
|
81
|
+
return {
|
|
82
|
+
width: buffer.readUInt16LE(6),
|
|
83
|
+
height: buffer.readUInt16LE(8),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// WebP: RIFF header + "WEBP" + VP8 chunks
|
|
89
|
+
if (buffer.length >= 30 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') {
|
|
90
|
+
const chunk = buffer.toString('ascii', 12, 16);
|
|
91
|
+
if (chunk === 'VP8 ' && buffer.length >= 30) {
|
|
92
|
+
// Lossy VP8: offset 26-27 width (LE) & offset 28-29 height (LE), masked lower 14 bits
|
|
93
|
+
return {
|
|
94
|
+
width: buffer.readUInt16LE(26) & 0x3fff,
|
|
95
|
+
height: buffer.readUInt16LE(28) & 0x3fff,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (chunk === 'VP8L' && buffer.length >= 25) {
|
|
99
|
+
// Lossless VP8L: 5 bytes at offset 21, first 14 bits = width-1, next 14 bits = height-1
|
|
100
|
+
const bits = buffer.readUInt32LE(21);
|
|
101
|
+
return {
|
|
102
|
+
width: (bits & 0x3fff) + 1,
|
|
103
|
+
height: ((bits >> 14) & 0x3fff) + 1,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 格式化 QQ Markdown 图片标签(带尺寸提示)
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* formatQQMarkdownImage('https://x/img.png', { width: 800, height: 600 })
|
|
116
|
+
* // → ''
|
|
117
|
+
*/
|
|
118
|
+
export function formatQQMarkdownImage(url: string, size?: ImageSize | null): string {
|
|
119
|
+
if (size) {
|
|
120
|
+
return ``;
|
|
121
|
+
}
|
|
122
|
+
return ``;
|
|
123
|
+
}
|