@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,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地文件路由 — 检测路径并按类型自动选择发送方式
|
|
3
|
+
*
|
|
4
|
+
* 本地文件路径检测 + 文件类型推断。
|
|
5
|
+
*/
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as os from 'node:os';
|
|
9
|
+
import type { MediaKind } from './outbound-service.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 规范化本地路径
|
|
13
|
+
* - 剥离 file:// 前缀
|
|
14
|
+
* - 展开 ~ 为 home 目录
|
|
15
|
+
* - URL decode
|
|
16
|
+
*/
|
|
17
|
+
export function normalizePath(p: string): string {
|
|
18
|
+
let result = p;
|
|
19
|
+
if (result.startsWith('file://')) {
|
|
20
|
+
result = result.slice('file://'.length);
|
|
21
|
+
// Windows: file:///C:/path → C:/path
|
|
22
|
+
if (/^\/[a-zA-Z]:[\\/]/.test(result)) {
|
|
23
|
+
result = result.slice(1);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// URL decode
|
|
27
|
+
try {
|
|
28
|
+
result = decodeURIComponent(result);
|
|
29
|
+
} catch {
|
|
30
|
+
// keep raw if decode fails
|
|
31
|
+
}
|
|
32
|
+
// ~ 展开
|
|
33
|
+
if (result === '~' || result.startsWith('~/') || result.startsWith('~\\')) {
|
|
34
|
+
result = result.replace(/^~/, os.homedir());
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 判断一个 source 是否为本地文件路径
|
|
41
|
+
*/
|
|
42
|
+
export function isLocalFilePath(source: string): boolean {
|
|
43
|
+
if (!source) return false;
|
|
44
|
+
if (source.startsWith('http://') || source.startsWith('https://')) return false;
|
|
45
|
+
if (source.startsWith('data:')) return false;
|
|
46
|
+
if (source.startsWith('file://')) return true;
|
|
47
|
+
if (source === '~' || source.startsWith('~/') || source.startsWith('~\\')) return true;
|
|
48
|
+
if (source.startsWith('/')) return true;
|
|
49
|
+
if (/^[a-zA-Z]:[\\/]/.test(source)) return true;
|
|
50
|
+
if (source.startsWith('\\\\')) return true;
|
|
51
|
+
if (source.startsWith('./') || source.startsWith('../')) return true;
|
|
52
|
+
if (source.startsWith('.\\') || source.startsWith('..\\')) return true;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 判断一个 source 是否为 Base64 data URL
|
|
58
|
+
*/
|
|
59
|
+
export function isDataUrl(source: string): boolean {
|
|
60
|
+
return source.startsWith('data:');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 验证本地文件存在且可读
|
|
65
|
+
*/
|
|
66
|
+
export function validateLocalFile(filePath: string): boolean {
|
|
67
|
+
try {
|
|
68
|
+
fs.accessSync(filePath, fs.constants.R_OK);
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 获取本地文件大小(字节),不存在返回 -1
|
|
77
|
+
*/
|
|
78
|
+
export function getFileSize(filePath: string): number {
|
|
79
|
+
try {
|
|
80
|
+
return fs.statSync(filePath).size;
|
|
81
|
+
} catch {
|
|
82
|
+
return -1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── 扩展名 → MediaKind 映射 ──
|
|
87
|
+
|
|
88
|
+
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.ico']);
|
|
89
|
+
const VOICE_EXTS = new Set(['.wav', '.mp3', '.silk', '.amr', '.ogg', '.flac', '.aac', '.m4a']);
|
|
90
|
+
const VIDEO_EXTS = new Set(['.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', '.wmv']);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 根据文件扩展名推断 MediaKind
|
|
94
|
+
*/
|
|
95
|
+
export function inferMediaKind(filePath: string): MediaKind {
|
|
96
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
97
|
+
if (IMAGE_EXTS.has(ext)) return 'image';
|
|
98
|
+
if (VOICE_EXTS.has(ext)) return 'voice';
|
|
99
|
+
if (VIDEO_EXTS.has(ext)) return 'video';
|
|
100
|
+
return 'file';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 根据 MIME type 推断 MediaKind
|
|
105
|
+
*/
|
|
106
|
+
export function inferMediaKindFromMime(mime: string): MediaKind {
|
|
107
|
+
const lower = mime.toLowerCase();
|
|
108
|
+
if (lower.startsWith('image/')) return 'image';
|
|
109
|
+
if (lower.startsWith('audio/') || lower === 'voice') return 'voice';
|
|
110
|
+
if (lower.startsWith('video/')) return 'video';
|
|
111
|
+
return 'file';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 从 data URL 提取 MIME type
|
|
116
|
+
*/
|
|
117
|
+
export function extractMimeFromDataUrl(dataUrl: string): string | undefined {
|
|
118
|
+
const match = dataUrl.match(/^data:([^;,]+)/);
|
|
119
|
+
return match?.[1];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── 路径安全校验 ──
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 校验本地文件真实路径是否在允许的目录列表中。
|
|
126
|
+
* 同时解析符号链接和规范化路径,防止路径穿越。
|
|
127
|
+
*
|
|
128
|
+
* @returns true 表示路径在允许的目录内
|
|
129
|
+
*/
|
|
130
|
+
export function isPathInAllowedRoots(absPath: string, allowedRoots: string[]): boolean {
|
|
131
|
+
if (!allowedRoots.length) return false;
|
|
132
|
+
try {
|
|
133
|
+
const real = fs.realpathSync(absPath);
|
|
134
|
+
return allowedRoots.some((root) => {
|
|
135
|
+
try {
|
|
136
|
+
const rootReal = fs.existsSync(root) ? fs.realpathSync(root) : root;
|
|
137
|
+
return real.startsWith(rootReal + path.sep) || real === rootReal;
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一富媒体发送入口
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* 1. 路径安全校验(只允许白名单目录下的本地文件)
|
|
6
|
+
* 2. 类型推断(扩展名 / MIME / 显式指定)
|
|
7
|
+
* 3. 路由分发(image / voice / video / file)
|
|
8
|
+
* 4. 语音发送失败 fallback 到文件
|
|
9
|
+
*
|
|
10
|
+
* 所有出站媒体发送(channel.outbound.sendMedia / deliver pipeline / Message 工具)
|
|
11
|
+
* 统一经过此入口。
|
|
12
|
+
*/
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as os from 'node:os';
|
|
16
|
+
import { resolveAgentWorkspace } from '../adapter/workspace.js';
|
|
17
|
+
import type { ReplyTarget } from '@tencent-connect/qqbot-nodejs';
|
|
18
|
+
import type { QQBotGateway } from '../gateway/index.js';
|
|
19
|
+
import { tryGetQQBotRuntime } from '../runtime.js';
|
|
20
|
+
import { getAdapters } from '../adapter/resolve.js';
|
|
21
|
+
import type { PluginLogger } from '../utils/plugin-logger.js';
|
|
22
|
+
import { validateRemoteUrl } from '../utils/ssrf-guard.js';
|
|
23
|
+
import { getGateway } from './outbound-service.js';
|
|
24
|
+
import { parseTarget } from './target.js';
|
|
25
|
+
import {
|
|
26
|
+
isLocalFilePath,
|
|
27
|
+
isDataUrl,
|
|
28
|
+
normalizePath,
|
|
29
|
+
inferMediaKind,
|
|
30
|
+
inferMediaKindFromMime,
|
|
31
|
+
isPathInAllowedRoots,
|
|
32
|
+
} from './local-file-router.js';
|
|
33
|
+
import type { MediaKind } from './outbound-service.js';
|
|
34
|
+
|
|
35
|
+
// ── 类型 ──
|
|
36
|
+
|
|
37
|
+
export interface SendMediaParams {
|
|
38
|
+
/** 目标 (qqbot:c2c:xxx / qqbot:group:xxx) */
|
|
39
|
+
to: string;
|
|
40
|
+
/** 媒体源(URL / 本地路径 / data URL) */
|
|
41
|
+
source: string;
|
|
42
|
+
/** 附带文本 */
|
|
43
|
+
text?: string;
|
|
44
|
+
/** 显式指定类型(优先级最高) */
|
|
45
|
+
mediaKind?: MediaKind;
|
|
46
|
+
/** MIME type 提示(优先级次于 mediaKind) */
|
|
47
|
+
mimeType?: string;
|
|
48
|
+
/** 被动回复 ID */
|
|
49
|
+
replyToId?: string;
|
|
50
|
+
/** 账户 ID */
|
|
51
|
+
accountId: string;
|
|
52
|
+
/** 日志 */
|
|
53
|
+
log?: PluginLogger;
|
|
54
|
+
/** Agent ID(用于解析相对路径的工作区) */
|
|
55
|
+
agentId?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SendMediaResult {
|
|
59
|
+
messageId?: string;
|
|
60
|
+
error?: string;
|
|
61
|
+
/** 是否走了 fallback 路径 */
|
|
62
|
+
fallback?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── 安全常量 ──
|
|
66
|
+
|
|
67
|
+
/** 收集临时目录根路径:os.tmpdir + Unix /tmp(处理 macOS /tmp→/private/tmp 符号链接) */
|
|
68
|
+
function resolveTempRoots(): string[] {
|
|
69
|
+
const roots = new Set<string>();
|
|
70
|
+
try {
|
|
71
|
+
const tmp = os.tmpdir();
|
|
72
|
+
roots.add(fs.existsSync(tmp) ? fs.realpathSync(tmp) : tmp);
|
|
73
|
+
} catch { /* skip */ }
|
|
74
|
+
// Unix: 解析 /tmp 真实路径,覆盖 macOS 符号链接场景
|
|
75
|
+
if (process.platform !== 'win32') {
|
|
76
|
+
try { roots.add(fs.realpathSync('/tmp')); } catch { /* skip */ }
|
|
77
|
+
}
|
|
78
|
+
return [...roots];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 构建动态白名单目录列表。
|
|
83
|
+
*
|
|
84
|
+
* 从 openclaw 核心已解析的 workspaceDir 反向推导配置目录,
|
|
85
|
+
* 同时保留已知配置目录名以向后兼容。
|
|
86
|
+
*/
|
|
87
|
+
function buildDynamicAllowedRoots(workspaceDir?: string): string[] {
|
|
88
|
+
const home = os.homedir();
|
|
89
|
+
const derivedBase = workspaceDir ? path.dirname(workspaceDir) : path.join(home, '.openclaw');
|
|
90
|
+
const roots: string[] = [];
|
|
91
|
+
const added = new Set<string>();
|
|
92
|
+
|
|
93
|
+
const addRoot = (p: string) => {
|
|
94
|
+
try {
|
|
95
|
+
const real = fs.existsSync(p) ? fs.realpathSync(p) : p;
|
|
96
|
+
if (!added.has(real)) {
|
|
97
|
+
added.add(real);
|
|
98
|
+
roots.push(real);
|
|
99
|
+
}
|
|
100
|
+
} catch { /* skip */ }
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// 已知配置目录名(包括默认和开发模式)
|
|
104
|
+
const knownBases = [path.join(home, '.openclaw'), path.join(home, '.openclaw-dev')];
|
|
105
|
+
|
|
106
|
+
// 当前配置目录优先(从 workspaceDir 反推)
|
|
107
|
+
if (workspaceDir) {
|
|
108
|
+
addRoot(path.join(derivedBase, 'media'));
|
|
109
|
+
addRoot(path.join(derivedBase, 'workspace'));
|
|
110
|
+
addRoot(path.join(derivedBase, 'outbound'));
|
|
111
|
+
addRoot(workspaceDir);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 所有已知配置目录(去重,确保向后兼容)
|
|
115
|
+
for (const base of knownBases) {
|
|
116
|
+
addRoot(path.join(base, 'media'));
|
|
117
|
+
addRoot(path.join(base, 'workspace'));
|
|
118
|
+
addRoot(path.join(base, 'outbound'));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 临时目录
|
|
122
|
+
for (const t of resolveTempRoots()) addRoot(t);
|
|
123
|
+
|
|
124
|
+
return roots;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 入站 Base64 / Data URL 最大字节数(10MB) */
|
|
128
|
+
const MAX_DATA_URL_BYTES = 10 * 1024 * 1024;
|
|
129
|
+
|
|
130
|
+
// ── 统一入口 ──
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 统一富媒体发送入口
|
|
134
|
+
*/
|
|
135
|
+
export async function sendMedia(params: SendMediaParams): Promise<SendMediaResult> {
|
|
136
|
+
const { source, accountId, log } = params;
|
|
137
|
+
const mlog = log?.child('media');
|
|
138
|
+
|
|
139
|
+
if (!source) {
|
|
140
|
+
mlog?.error('source is empty');
|
|
141
|
+
return { error: 'sendMedia: source is required' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 1. 安全校验 + 路径规范化
|
|
145
|
+
const wsDir = resolveWorkspaceFromAgent(params.agentId);
|
|
146
|
+
mlog?.debug(`resolveMediaPath source=${source} agentId=${params.agentId ?? 'none'} workspaceDir=${wsDir ?? 'none'}`);
|
|
147
|
+
const resolved = await resolveMediaPath(source, mlog, wsDir);
|
|
148
|
+
if (!resolved.ok) {
|
|
149
|
+
mlog?.error(`resolveMediaPath failed: ${resolved.error}`);
|
|
150
|
+
return { error: resolved.error };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2. 推断类型
|
|
154
|
+
const kind = params.mediaKind
|
|
155
|
+
?? (params.mimeType ? inferMediaKindFromMime(params.mimeType) : undefined)
|
|
156
|
+
?? inferMediaKind(resolved.path);
|
|
157
|
+
|
|
158
|
+
// 3. 获取 gateway
|
|
159
|
+
const gw = getGateway(accountId);
|
|
160
|
+
if (!gw) {
|
|
161
|
+
return { error: `Bot "${accountId}" not running` };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const target = parseTarget(params.to);
|
|
165
|
+
|
|
166
|
+
// 4. 路由分发
|
|
167
|
+
switch (kind) {
|
|
168
|
+
case 'voice':
|
|
169
|
+
return sendVoiceMedia(gw, target, resolved.path, params);
|
|
170
|
+
case 'video':
|
|
171
|
+
return sendVideoMedia(gw, target, resolved.path, params);
|
|
172
|
+
case 'file':
|
|
173
|
+
return sendFileMedia(gw, target, resolved.path, params);
|
|
174
|
+
case 'image':
|
|
175
|
+
default:
|
|
176
|
+
return sendImageMedia(gw, target, resolved.path, params);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── 路径安全校验 ──
|
|
181
|
+
|
|
182
|
+
interface ResolveResult {
|
|
183
|
+
ok: true;
|
|
184
|
+
path: string;
|
|
185
|
+
isLocal: boolean;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface ResolveError {
|
|
189
|
+
ok: false;
|
|
190
|
+
error: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function resolveMediaPath(source: string, log?: SendMediaParams['log'], workspaceDir?: string): Promise<ResolveResult | ResolveError> {
|
|
194
|
+
const normalized = normalizePath(source);
|
|
195
|
+
|
|
196
|
+
// Data URL → 大小限制检查
|
|
197
|
+
if (isDataUrl(normalized)) {
|
|
198
|
+
if (normalized.length > MAX_DATA_URL_BYTES) {
|
|
199
|
+
const sizeMB = (normalized.length / (1024 * 1024)).toFixed(1);
|
|
200
|
+
return { ok: false, error: `Data URL 过大(${sizeMB}MB,最大 10MB)` };
|
|
201
|
+
}
|
|
202
|
+
return { ok: true, path: normalized, isLocal: false };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 远程 URL → SSRF 安全检查
|
|
206
|
+
if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
|
|
207
|
+
try {
|
|
208
|
+
await validateRemoteUrl(normalized);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
log?.warn(`SSRF blocked for media URL: ${normalized}`);
|
|
211
|
+
return { ok: false, error: `媒体 URL 被 SSRF 防护拦截: ${err instanceof Error ? err.message : String(err)}` };
|
|
212
|
+
}
|
|
213
|
+
return { ok: true, path: normalized, isLocal: false };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 纯文件名→工作区兜底查找
|
|
217
|
+
if (!isLocalFilePath(normalized)) {
|
|
218
|
+
const resolved = resolveWorkingFile(normalized, workspaceDir);
|
|
219
|
+
if (resolved) {
|
|
220
|
+
return { ok: true, path: resolved, isLocal: true };
|
|
221
|
+
}
|
|
222
|
+
return { ok: true, path: normalized, isLocal: false };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 本地路径 → 安全校验
|
|
226
|
+
const resolved = path.resolve(normalized);
|
|
227
|
+
if (!fs.existsSync(resolved)) {
|
|
228
|
+
return { ok: false, error: `File not found: ${resolved}` };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let real: string;
|
|
232
|
+
try {
|
|
233
|
+
real = fs.realpathSync(resolved);
|
|
234
|
+
} catch {
|
|
235
|
+
return { ok: false, error: `Cannot resolve path: ${resolved}` };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 动态白名单:静态根目录 + 当前 agent 工作区
|
|
239
|
+
const dynamicRoots = buildDynamicAllowedRoots(workspaceDir);
|
|
240
|
+
const allowed = isPathInAllowedRoots(real, dynamicRoots);
|
|
241
|
+
|
|
242
|
+
if (!allowed) {
|
|
243
|
+
log?.warn(`path blocked — not in allowed directory: ${real}`);
|
|
244
|
+
return { ok: false, error: `文件路径不在允许的目录中` };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { ok: true, path: real, isLocal: true };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Agent ID → workspaceDir(动态加载 plugin-sdk/health) */
|
|
251
|
+
function resolveWorkspaceFromAgent(agentId?: string): string | undefined {
|
|
252
|
+
const cfg = resolveConfigViaAdapter();
|
|
253
|
+
if (!cfg) return undefined;
|
|
254
|
+
return resolveAgentWorkspace(cfg, agentId);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 通过 adapter 获取配置 */
|
|
258
|
+
function resolveConfigViaAdapter(): Record<string, unknown> | undefined {
|
|
259
|
+
try {
|
|
260
|
+
const rt = tryGetQQBotRuntime();
|
|
261
|
+
if (!rt) return undefined;
|
|
262
|
+
return getAdapters(rt).getConfig?.();
|
|
263
|
+
} catch { return undefined; }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 纯文件名在 cwd + 工作区兜底查找 */
|
|
267
|
+
function resolveWorkingFile(name: string, workspaceDir?: string): string | null {
|
|
268
|
+
for (const p of [path.resolve(name), workspaceDir ? path.join(workspaceDir, name) : null]) {
|
|
269
|
+
if (p && fs.existsSync(p)) return p;
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── 各类型 sender ──
|
|
275
|
+
|
|
276
|
+
async function sendImageMedia(
|
|
277
|
+
gw: QQBotGateway,
|
|
278
|
+
target: ReplyTarget,
|
|
279
|
+
source: string,
|
|
280
|
+
params: SendMediaParams,
|
|
281
|
+
): Promise<SendMediaResult> {
|
|
282
|
+
try {
|
|
283
|
+
const result = await gw.sendMedia(target, source, {
|
|
284
|
+
text: params.text,
|
|
285
|
+
msgId: params.replyToId,
|
|
286
|
+
});
|
|
287
|
+
return { messageId: result.id };
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return { error: formatErr(err) };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function sendVoiceMedia(
|
|
294
|
+
gw: QQBotGateway,
|
|
295
|
+
target: ReplyTarget,
|
|
296
|
+
source: string,
|
|
297
|
+
params: SendMediaParams,
|
|
298
|
+
): Promise<SendMediaResult> {
|
|
299
|
+
// 语音源路由:本地 → { localPath },URL → { url },其他 → { base64 }
|
|
300
|
+
const voiceSource = resolveVoiceSource(source);
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const result = await gw.sendVoice(target, voiceSource, {
|
|
304
|
+
msgId: params.replyToId,
|
|
305
|
+
});
|
|
306
|
+
return { messageId: result.id };
|
|
307
|
+
} catch (err) {
|
|
308
|
+
// 语音失败 → fallback 到文件发送
|
|
309
|
+
params.log?.child('media')?.warn(`sendVoice failed (${formatErr(err)}), falling back to sendFile`);
|
|
310
|
+
try {
|
|
311
|
+
const fileName = path.basename(source);
|
|
312
|
+
const fallback = await gw.sendFile(target, source, {
|
|
313
|
+
text: params.text,
|
|
314
|
+
msgId: params.replyToId,
|
|
315
|
+
fileName,
|
|
316
|
+
});
|
|
317
|
+
return { messageId: fallback.id, fallback: true };
|
|
318
|
+
} catch (fallbackErr) {
|
|
319
|
+
return { error: `voice: ${formatErr(err)} | fallback file: ${formatErr(fallbackErr)}` };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function sendVideoMedia(
|
|
325
|
+
gw: QQBotGateway,
|
|
326
|
+
target: ReplyTarget,
|
|
327
|
+
source: string,
|
|
328
|
+
params: SendMediaParams,
|
|
329
|
+
): Promise<SendMediaResult> {
|
|
330
|
+
try {
|
|
331
|
+
const result = await gw.sendVideo(target, source, {
|
|
332
|
+
text: params.text,
|
|
333
|
+
msgId: params.replyToId,
|
|
334
|
+
});
|
|
335
|
+
return { messageId: result.id };
|
|
336
|
+
} catch (err) {
|
|
337
|
+
return { error: formatErr(err) };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function sendFileMedia(
|
|
342
|
+
gw: QQBotGateway,
|
|
343
|
+
target: ReplyTarget,
|
|
344
|
+
source: string,
|
|
345
|
+
params: SendMediaParams,
|
|
346
|
+
): Promise<SendMediaResult> {
|
|
347
|
+
try {
|
|
348
|
+
const fileName = path.basename(source);
|
|
349
|
+
const result = await gw.sendFile(target, source, {
|
|
350
|
+
text: params.text,
|
|
351
|
+
msgId: params.replyToId,
|
|
352
|
+
fileName,
|
|
353
|
+
});
|
|
354
|
+
return { messageId: result.id };
|
|
355
|
+
} catch (err) {
|
|
356
|
+
return { error: formatErr(err) };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── 辅助 ──
|
|
361
|
+
|
|
362
|
+
function resolveVoiceSource(source: string): { url?: string; base64?: string; localPath?: string } {
|
|
363
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
364
|
+
return { url: source };
|
|
365
|
+
}
|
|
366
|
+
if (source.startsWith('data:') || (!source.startsWith('/') && !source.startsWith('./') && !source.startsWith('../') && !source.startsWith('~'))) {
|
|
367
|
+
// data URL 或纯 base64 字符串
|
|
368
|
+
const commaIdx = source.indexOf(',');
|
|
369
|
+
return { base64: commaIdx > 0 ? source.slice(commaIdx + 1) : source };
|
|
370
|
+
}
|
|
371
|
+
return { localPath: source };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function formatErr(err: unknown): string {
|
|
375
|
+
if (err instanceof Error) return err.message;
|
|
376
|
+
return String(err);
|
|
377
|
+
}
|