@jerryliang122/openclaw-qqbot 1.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/LICENSE +22 -0
- package/README.md +967 -0
- package/README.zh.md +790 -0
- package/dist/index.cjs +18072 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1222 -0
- package/index.ts +92 -0
- package/openclaw.plugin.json +38 -0
- package/package.json +69 -0
- package/preload.cjs +19 -0
- package/scripts/link-sdk-core.cjs +268 -0
- package/scripts/proactive-api-server.ts +369 -0
- package/scripts/send-proactive.ts +293 -0
- package/scripts/test-sendmedia.ts +116 -0
- package/skills/qqbot-channel/SKILL.md +285 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-remind/SKILL.md +159 -0
- package/skills/qqbot-upgrade/SKILL.md +56 -0
- package/src/adapter/contract.ts +63 -0
- package/src/adapter/lint.ts +144 -0
- package/src/adapter/media.ts +40 -0
- package/src/adapter/pairing.ts +95 -0
- package/src/adapter/resolve.ts +255 -0
- package/src/adapter/setup.ts +13 -0
- package/src/adapter/webhook.ts +248 -0
- package/src/adapter/workspace.ts +21 -0
- package/src/agent-prompt-adapter.ts +26 -0
- package/src/bot-instance.ts +60 -0
- package/src/channel.ts +230 -0
- 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-group-info.ts +48 -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 +50 -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 +51 -0
- package/src/config.ts +403 -0
- package/src/constants.ts +6 -0
- package/src/dispatch/body-assembler.ts +308 -0
- package/src/dispatch/ctx-builder.ts +127 -0
- package/src/dispatch/dispatch.ts +667 -0
- package/src/dispatch/envelope-builder.ts +112 -0
- package/src/dispatch/index.ts +2 -0
- package/src/features/approval-capability.ts +302 -0
- package/src/features/approval-helpers.ts +271 -0
- package/src/features/approval-utils.ts +21 -0
- package/src/features/command-panel.ts +301 -0
- package/src/features/credential-backup.ts +74 -0
- package/src/features/group-mode-store.ts +79 -0
- package/src/features/history-store.ts +75 -0
- package/src/features/msgid-cache.ts +55 -0
- package/src/features/outbound-echo-store.ts +46 -0
- package/src/features/proactive-budget.ts +57 -0
- package/src/features/proactive.ts +549 -0
- package/src/features/question-helpers.ts +771 -0
- package/src/features/quota-manager.ts +173 -0
- package/src/features/ref-index-store.ts +289 -0
- package/src/features/secret-input-store.ts +118 -0
- package/src/features/secret-store-cli.ts +324 -0
- package/src/features/typing-refresh.ts +51 -0
- package/src/features/update-checker.ts +166 -0
- package/src/gateway/event-handlers.ts +456 -0
- package/src/gateway/index.ts +3 -0
- package/src/gateway/lifecycle.ts +236 -0
- package/src/gateway/middleware-setup.ts +173 -0
- package/src/gateway/qqbot-gateway.ts +458 -0
- package/src/gateway-adapter.ts +44 -0
- package/src/heartbeat-adapter.ts +57 -0
- package/src/message-adapter.ts +40 -0
- package/src/messaging-adapter.ts +78 -0
- package/src/middleware/access-control.ts +125 -0
- package/src/middleware/attachment.ts +373 -0
- package/src/middleware/inbound-guard.ts +102 -0
- package/src/middleware/policy-injector.ts +71 -0
- package/src/middleware/secret-capture.ts +161 -0
- package/src/middleware/typing.ts +110 -0
- package/src/openclaw-plugin-sdk.d.ts +543 -0
- package/src/outbound/chunker.ts +80 -0
- package/src/outbound/debounce.ts +102 -0
- package/src/outbound/deliver-pipeline.ts +235 -0
- package/src/outbound/index.ts +3 -0
- package/src/outbound/local-file-router.ts +145 -0
- package/src/outbound/media-send.ts +408 -0
- package/src/outbound/outbound-service.ts +298 -0
- package/src/outbound/reply-limiter.ts +139 -0
- package/src/outbound/sanitize.ts +32 -0
- package/src/outbound/streaming-controller.ts +332 -0
- package/src/outbound/target.ts +109 -0
- package/src/outbound-adapter.ts +323 -0
- package/src/plugin-base.ts +42 -0
- package/src/request-context.ts +50 -0
- package/src/runtime.ts +42 -0
- 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/status-adapter.ts +56 -0
- package/src/tools/platform.ts +149 -0
- package/src/tools/remind.ts +308 -0
- package/src/tools/secret-input.ts +185 -0
- package/src/types-augment.d.ts +54 -0
- package/src/types-plugin.ts +82 -0
- package/src/types.ts +620 -0
- package/src/typing-lifecycle.ts +182 -0
- package/src/utils/mention.ts +52 -0
- package/src/utils/pkg-version.ts +23 -0
- package/src/utils/platform.ts +459 -0
- package/src/utils/plugin-logger.ts +104 -0
- package/src/utils/ssrf-guard.ts +132 -0
- package/src/utils/stt.ts +150 -0
- package/src/utils/voice-text.ts +61 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +64 -0
|
@@ -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,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
|
+
}
|