@yanhaidao/wecom 2.3.270 → 2.4.120
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/MENU_EVENT_CONF.md +500 -0
- package/MENU_EVENT_PLAN.md +440 -0
- package/README.md +80 -3
- package/UPSTREAM_CONFIG.md +170 -0
- package/UPSTREAM_PLAN.md +175 -0
- package/changelog/v2.4.12.md +37 -0
- package/package.json +1 -1
- package/scripts/wecom/README.md +123 -0
- package/scripts/wecom/menu-click-help.js +59 -0
- package/scripts/wecom/menu-click-help.py +55 -0
- package/src/agent/event-router.test.ts +421 -0
- package/src/agent/event-router.ts +272 -0
- package/src/agent/handler.event-filter.test.ts +65 -1
- package/src/agent/handler.ts +375 -21
- package/src/agent/script-runner.ts +186 -0
- package/src/agent/test-fixtures/invalid-json-script.mjs +1 -0
- package/src/agent/test-fixtures/reply-event-script.mjs +29 -0
- package/src/agent/test-fixtures/reply-event-script.py +17 -0
- package/src/app/account-runtime.ts +1 -1
- package/src/capability/agent/upstream-delivery-service.ts +96 -0
- package/src/capability/bot/sandbox-media.test.ts +221 -0
- package/src/capability/bot/sandbox-media.ts +176 -0
- package/src/capability/bot/stream-orchestrator.ts +19 -0
- package/src/channel.config.test.ts +33 -0
- package/src/channel.meta.test.ts +10 -0
- package/src/channel.ts +4 -1
- package/src/config/accounts.ts +16 -0
- package/src/config/schema.ts +58 -0
- package/src/context-store.ts +41 -8
- package/src/outbound.test.ts +211 -2
- package/src/outbound.ts +323 -70
- package/src/runtime/session-manager.test.ts +39 -0
- package/src/runtime/session-manager.ts +17 -0
- package/src/runtime/source-registry.ts +5 -0
- package/src/shared/media-asset.ts +78 -0
- package/src/shared/media-service.test.ts +111 -0
- package/src/shared/media-service.ts +42 -14
- package/src/target.ts +40 -0
- package/src/transport/agent-api/client.ts +233 -0
- package/src/transport/agent-api/core.ts +101 -5
- package/src/transport/agent-api/upstream-delivery.ts +45 -0
- package/src/transport/agent-api/upstream-media-upload.ts +70 -0
- package/src/transport/agent-api/upstream-reply.ts +43 -0
- package/src/types/account.ts +2 -0
- package/src/types/config.ts +74 -0
- package/src/types/message.ts +2 -0
- package/src/upstream/index.ts +150 -0
- package/src/upstream.test.ts +84 -0
- package/vitest.config.ts +15 -4
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
WecomAgentEventRouteHandlerConfig,
|
|
6
|
+
WecomAgentScriptRuntimeConfig,
|
|
7
|
+
} from "../types/index.js";
|
|
8
|
+
|
|
9
|
+
export type AgentEventScriptEnvelope = {
|
|
10
|
+
version: "1.0";
|
|
11
|
+
channel: "wecom";
|
|
12
|
+
accountId: string;
|
|
13
|
+
receivedAt: number;
|
|
14
|
+
message: {
|
|
15
|
+
msgType: string;
|
|
16
|
+
eventType: string;
|
|
17
|
+
eventKey: string | null;
|
|
18
|
+
changeType: string | null;
|
|
19
|
+
fromUser: string;
|
|
20
|
+
toUser: string | null;
|
|
21
|
+
chatId: string | null;
|
|
22
|
+
agentId: number | null;
|
|
23
|
+
createTime: number | null;
|
|
24
|
+
msgId: string | null;
|
|
25
|
+
raw: Record<string, unknown>;
|
|
26
|
+
};
|
|
27
|
+
route: {
|
|
28
|
+
matchedRuleId: string;
|
|
29
|
+
handlerType: "node_script" | "python_script";
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type AgentEventScriptResponse = {
|
|
34
|
+
ok?: boolean;
|
|
35
|
+
action?: "none" | "reply_text";
|
|
36
|
+
reply?: {
|
|
37
|
+
text?: string;
|
|
38
|
+
};
|
|
39
|
+
chainToAgent?: boolean;
|
|
40
|
+
audit?: {
|
|
41
|
+
tags?: string[];
|
|
42
|
+
};
|
|
43
|
+
error?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type AgentEventScriptExecutionMeta = {
|
|
47
|
+
command: string;
|
|
48
|
+
entryPath: string;
|
|
49
|
+
durationMs: number;
|
|
50
|
+
exitCode: number | null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function resolveAllowedRoots(runtime: WecomAgentScriptRuntimeConfig): string[] {
|
|
54
|
+
return (runtime.allowPaths ?? []).map((entry) => path.resolve(entry));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveScriptEntry(entry: string): string {
|
|
58
|
+
return path.resolve(entry);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ensureScriptAllowed(entryPath: string, runtime: WecomAgentScriptRuntimeConfig): void {
|
|
62
|
+
// 安全兜底:脚本执行必须显式开启
|
|
63
|
+
if (runtime.enabled !== true) {
|
|
64
|
+
throw new Error("script runtime is disabled");
|
|
65
|
+
}
|
|
66
|
+
// 安全兜底:必须配置允许目录,拒绝任意路径执行
|
|
67
|
+
const roots = resolveAllowedRoots(runtime);
|
|
68
|
+
if (roots.length === 0) {
|
|
69
|
+
throw new Error("script runtime allowPaths is empty");
|
|
70
|
+
}
|
|
71
|
+
const allowed = roots.some((root) => entryPath === root || entryPath.startsWith(`${root}${path.sep}`));
|
|
72
|
+
if (!allowed) {
|
|
73
|
+
throw new Error(`script path is not allowed: ${entryPath}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function runAgentEventScript(params: {
|
|
78
|
+
runtime: WecomAgentScriptRuntimeConfig | undefined;
|
|
79
|
+
handler: Extract<WecomAgentEventRouteHandlerConfig, { type: "node_script" | "python_script" }>;
|
|
80
|
+
envelope: AgentEventScriptEnvelope;
|
|
81
|
+
}): Promise<{ response: AgentEventScriptResponse; meta: AgentEventScriptExecutionMeta }> {
|
|
82
|
+
const runtime = params.runtime ?? {};
|
|
83
|
+
const entryPath = resolveScriptEntry(params.handler.entry);
|
|
84
|
+
ensureScriptAllowed(entryPath, runtime);
|
|
85
|
+
|
|
86
|
+
// 优先使用 route 覆盖值,其次使用全局 runtime 默认值
|
|
87
|
+
const timeoutMs = params.handler.timeoutMs ?? runtime.defaultTimeoutMs ?? 5000;
|
|
88
|
+
const maxStdoutBytes = runtime.maxStdoutBytes ?? 262144;
|
|
89
|
+
const maxStderrBytes = runtime.maxStderrBytes ?? 131072;
|
|
90
|
+
const command = params.handler.type === "python_script"
|
|
91
|
+
? runtime.pythonCommand ?? "python3"
|
|
92
|
+
: runtime.nodeCommand ?? "node";
|
|
93
|
+
const startedAt = Date.now();
|
|
94
|
+
|
|
95
|
+
return await new Promise<{ response: AgentEventScriptResponse; meta: AgentEventScriptExecutionMeta }>((resolve, reject) => {
|
|
96
|
+
const child = spawn(command, [entryPath], {
|
|
97
|
+
cwd: process.cwd(),
|
|
98
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
99
|
+
env: {
|
|
100
|
+
PATH: process.env.PATH ?? "",
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
let stdout = "";
|
|
105
|
+
let stderr = "";
|
|
106
|
+
let stdoutExceeded = false;
|
|
107
|
+
let stderrExceeded = false;
|
|
108
|
+
let settled = false;
|
|
109
|
+
let exitCode: number | null = null;
|
|
110
|
+
|
|
111
|
+
const finish = (fn: () => void): void => {
|
|
112
|
+
if (settled) return;
|
|
113
|
+
settled = true;
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
fn();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
// 超时强制终止,防止脚本阻塞事件处理链
|
|
120
|
+
child.kill("SIGKILL");
|
|
121
|
+
finish(() => reject(new Error(`script execution timed out after ${timeoutMs}ms`)));
|
|
122
|
+
}, timeoutMs);
|
|
123
|
+
|
|
124
|
+
child.stdout.on("data", (chunk: Buffer | string) => {
|
|
125
|
+
const value = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
126
|
+
// 限制输出体积,避免异常脚本刷爆内存/日志
|
|
127
|
+
if (Buffer.byteLength(stdout, "utf8") + Buffer.byteLength(value, "utf8") > maxStdoutBytes) {
|
|
128
|
+
stdoutExceeded = true;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
stdout += value;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
135
|
+
const value = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
136
|
+
if (Buffer.byteLength(stderr, "utf8") + Buffer.byteLength(value, "utf8") > maxStderrBytes) {
|
|
137
|
+
stderrExceeded = true;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
stderr += value;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
child.on("error", (err) => {
|
|
144
|
+
finish(() => reject(err));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
child.on("close", (code) => {
|
|
148
|
+
exitCode = code;
|
|
149
|
+
const meta: AgentEventScriptExecutionMeta = {
|
|
150
|
+
command,
|
|
151
|
+
entryPath,
|
|
152
|
+
durationMs: Date.now() - startedAt,
|
|
153
|
+
exitCode,
|
|
154
|
+
};
|
|
155
|
+
if (stdoutExceeded) {
|
|
156
|
+
finish(() => reject(new Error("script stdout exceeded limit")));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (stderrExceeded) {
|
|
160
|
+
finish(() => reject(new Error("script stderr exceeded limit")));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (code !== 0) {
|
|
164
|
+
finish(() => reject(new Error(`script exited with code ${code}: ${stderr.trim() || stdout.trim()}`)));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const trimmed = stdout.trim();
|
|
168
|
+
if (!trimmed) {
|
|
169
|
+
// 空输出按无动作处理,减少脚本端样板代码
|
|
170
|
+
finish(() => resolve({ response: { ok: true, action: "none" }, meta }));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
// 脚本协议要求 stdout 必须是 JSON
|
|
175
|
+
const parsed = JSON.parse(trimmed) as AgentEventScriptResponse;
|
|
176
|
+
finish(() => resolve({ response: parsed, meta }));
|
|
177
|
+
} catch (err) {
|
|
178
|
+
finish(() => reject(new Error(`script output is not valid JSON: ${String(err)}`)));
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// 将完整 envelope 传给脚本,再关闭 stdin
|
|
183
|
+
child.stdin.write(JSON.stringify(params.envelope));
|
|
184
|
+
child.stdin.end();
|
|
185
|
+
});
|
|
186
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
process.stdout.write("this is not json");
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
let raw = "";
|
|
2
|
+
process.stdin.setEncoding("utf8");
|
|
3
|
+
process.stdin.on("data", (chunk) => {
|
|
4
|
+
raw += chunk;
|
|
5
|
+
});
|
|
6
|
+
process.stdin.on("end", () => {
|
|
7
|
+
const payload = JSON.parse(raw || "{}");
|
|
8
|
+
const eventType = payload?.message?.eventType ?? "unknown";
|
|
9
|
+
const eventKey = payload?.message?.eventKey ?? "";
|
|
10
|
+
const changeType = payload?.message?.changeType ?? "";
|
|
11
|
+
|
|
12
|
+
if (eventKey === "PASS_TO_DEFAULT") {
|
|
13
|
+
process.stdout.write(JSON.stringify({
|
|
14
|
+
ok: true,
|
|
15
|
+
action: "none",
|
|
16
|
+
chainToAgent: true,
|
|
17
|
+
}));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
process.stdout.write(JSON.stringify({
|
|
22
|
+
ok: true,
|
|
23
|
+
action: "reply_text",
|
|
24
|
+
reply: {
|
|
25
|
+
text: `script:${eventType}:${eventKey}:${changeType}`,
|
|
26
|
+
},
|
|
27
|
+
chainToAgent: false,
|
|
28
|
+
}));
|
|
29
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
payload = json.load(sys.stdin)
|
|
5
|
+
message = payload.get("message", {})
|
|
6
|
+
event_type = message.get("eventType", "unknown")
|
|
7
|
+
event_key = message.get("eventKey") or ""
|
|
8
|
+
change_type = message.get("changeType") or ""
|
|
9
|
+
|
|
10
|
+
json.dump({
|
|
11
|
+
"ok": True,
|
|
12
|
+
"action": "reply_text",
|
|
13
|
+
"reply": {
|
|
14
|
+
"text": f"python:{event_type}:{event_key}:{change_type}"
|
|
15
|
+
},
|
|
16
|
+
"chainToAgent": False
|
|
17
|
+
}, sys.stdout)
|
|
@@ -37,7 +37,7 @@ export class WecomAccountRuntime {
|
|
|
37
37
|
} = {},
|
|
38
38
|
private readonly statusSink?: (snapshot: Record<string, unknown>) => void,
|
|
39
39
|
) {
|
|
40
|
-
this.mediaService = new WecomMediaService(core);
|
|
40
|
+
this.mediaService = new WecomMediaService(core, cfg);
|
|
41
41
|
this.runtimeStatus = {
|
|
42
42
|
accountId: resolved.account.accountId,
|
|
43
43
|
health: "idle",
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ResolvedAgentAccount } from "../../types/index.js";
|
|
2
|
+
import { resolveScopedWecomTarget } from "../../target.js";
|
|
3
|
+
import { deliverUpstreamAgentApiMedia, deliverUpstreamAgentApiText } from "../../transport/agent-api/upstream-delivery.js";
|
|
4
|
+
import { canUseAgentApiDelivery } from "./fallback-policy.js";
|
|
5
|
+
import { getWecomRuntime } from "../../runtime.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 上下游企业消息发送服务
|
|
9
|
+
*
|
|
10
|
+
* 使用下游企业的 access_token 和 agentId 发送消息
|
|
11
|
+
*/
|
|
12
|
+
export class WecomUpstreamAgentDeliveryService {
|
|
13
|
+
constructor(
|
|
14
|
+
private readonly upstreamAgent: ResolvedAgentAccount,
|
|
15
|
+
private readonly primaryAgent: ResolvedAgentAccount,
|
|
16
|
+
) { }
|
|
17
|
+
|
|
18
|
+
assertAvailable(): void {
|
|
19
|
+
if (!canUseAgentApiDelivery(this.upstreamAgent)) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`WeCom upstream outbound requires channels.wecom.accounts.<accountId>.agent.agentId for upstream corp=${this.upstreamAgent.corpId}.`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
resolveTargetOrThrow(to: string | undefined) {
|
|
27
|
+
const scoped = resolveScopedWecomTarget(to, this.upstreamAgent.accountId);
|
|
28
|
+
if (!scoped) {
|
|
29
|
+
console.error(`[wecom-upstream-delivery] missing target account=${this.upstreamAgent.accountId}`);
|
|
30
|
+
throw new Error("WeCom upstream outbound requires a target (userid, partyid, tagid or chatid).");
|
|
31
|
+
}
|
|
32
|
+
if (scoped.accountId && scoped.accountId !== this.upstreamAgent.accountId) {
|
|
33
|
+
console.error(
|
|
34
|
+
`[wecom-upstream-delivery] account mismatch current=${this.upstreamAgent.accountId} targetAccount=${scoped.accountId} raw=${String(to ?? "")}`,
|
|
35
|
+
);
|
|
36
|
+
throw new Error(
|
|
37
|
+
`WeCom upstream outbound account mismatch: target belongs to account=${scoped.accountId}, current account=${this.upstreamAgent.accountId}.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const target = scoped.target;
|
|
41
|
+
if (target.chatid) {
|
|
42
|
+
console.warn(
|
|
43
|
+
`[wecom-upstream-delivery] blocked chat target account=${this.upstreamAgent.accountId} chatId=${target.chatid}`,
|
|
44
|
+
);
|
|
45
|
+
throw new Error(
|
|
46
|
+
`企业微信(WeCom)上下游 Agent 主动发送不支持向群 chatId 发送(chatId=${target.chatid})。` +
|
|
47
|
+
`请改为发送给用户(userid / user:xxx)。`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return target;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async sendText(params: { to: string | undefined; text: string }): Promise<void> {
|
|
54
|
+
this.assertAvailable();
|
|
55
|
+
const target = this.resolveTargetOrThrow(params.to);
|
|
56
|
+
console.log(
|
|
57
|
+
`[wecom-upstream-delivery] sendText account=${this.upstreamAgent.accountId} corpId=${this.upstreamAgent.corpId} to=${String(params.to ?? "")} len=${params.text.length}`,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const runtime = getWecomRuntime();
|
|
61
|
+
const chunks = runtime.channel.text.chunkText(params.text, 2048);
|
|
62
|
+
|
|
63
|
+
for (const chunk of chunks) {
|
|
64
|
+
if (!chunk.trim()) continue;
|
|
65
|
+
await deliverUpstreamAgentApiText({
|
|
66
|
+
upstreamAgent: this.upstreamAgent,
|
|
67
|
+
primaryAgent: this.primaryAgent,
|
|
68
|
+
target,
|
|
69
|
+
text: chunk,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async sendMedia(params: {
|
|
75
|
+
to: string | undefined;
|
|
76
|
+
text?: string;
|
|
77
|
+
buffer: Buffer;
|
|
78
|
+
filename: string;
|
|
79
|
+
contentType: string;
|
|
80
|
+
}): Promise<void> {
|
|
81
|
+
this.assertAvailable();
|
|
82
|
+
const target = this.resolveTargetOrThrow(params.to);
|
|
83
|
+
console.log(
|
|
84
|
+
`[wecom-upstream-delivery] sendMedia account=${this.upstreamAgent.accountId} corpId=${this.upstreamAgent.corpId} to=${String(params.to ?? "")} filename=${params.filename} contentType=${params.contentType}`,
|
|
85
|
+
);
|
|
86
|
+
await deliverUpstreamAgentApiMedia({
|
|
87
|
+
upstreamAgent: this.upstreamAgent,
|
|
88
|
+
primaryAgent: this.primaryAgent,
|
|
89
|
+
target,
|
|
90
|
+
buffer: params.buffer,
|
|
91
|
+
filename: params.filename,
|
|
92
|
+
contentType: params.contentType,
|
|
93
|
+
text: params.text,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { stageWecomInboundMediaForSession } from "./sandbox-media.js";
|
|
7
|
+
|
|
8
|
+
describe("stageWecomInboundMediaForSession", () => {
|
|
9
|
+
const root = path.join("/tmp", `wecom-sandbox-stage-${process.pid}`);
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
await mkdir(root, { recursive: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
vi.unstubAllEnvs();
|
|
17
|
+
await rm(root, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("stages inbound media into the session sandbox workspace using channels.wecom.mediaMaxMb", async () => {
|
|
21
|
+
const mediaPath = path.join(root, "openclaw-media", "inbound", "big.bin");
|
|
22
|
+
const agentWorkspace = path.join(root, "agent-workspace");
|
|
23
|
+
const sandboxRoot = path.join(root, "sandboxes");
|
|
24
|
+
|
|
25
|
+
await mkdir(path.dirname(mediaPath), { recursive: true });
|
|
26
|
+
await mkdir(agentWorkspace, { recursive: true });
|
|
27
|
+
await writeFile(mediaPath, Buffer.alloc(6 * 1024 * 1024, 7));
|
|
28
|
+
|
|
29
|
+
const staged = await stageWecomInboundMediaForSession({
|
|
30
|
+
cfg: {
|
|
31
|
+
channels: {
|
|
32
|
+
wecom: {
|
|
33
|
+
mediaMaxMb: 8,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
agents: {
|
|
37
|
+
list: [
|
|
38
|
+
{
|
|
39
|
+
id: "ops",
|
|
40
|
+
workspace: agentWorkspace,
|
|
41
|
+
sandbox: {
|
|
42
|
+
mode: "non-main",
|
|
43
|
+
scope: "session",
|
|
44
|
+
workspaceRoot: sandboxRoot,
|
|
45
|
+
workspaceAccess: "ro",
|
|
46
|
+
docker: {
|
|
47
|
+
workdir: "/workspace",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
} as any,
|
|
54
|
+
accountId: "default",
|
|
55
|
+
agentId: "ops",
|
|
56
|
+
sessionKey: "agent:ops:wecom:default:dm:zhangsan",
|
|
57
|
+
mediaPath,
|
|
58
|
+
filename: "big.bin",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(staged).toMatch(/^media\/inbound\/big\.bin$/);
|
|
62
|
+
const sandboxEntries = await readdir(sandboxRoot);
|
|
63
|
+
const stagedBuffer = await readFile(
|
|
64
|
+
path.join(sandboxRoot, sandboxEntries[0]!, "media", "inbound", "big.bin"),
|
|
65
|
+
);
|
|
66
|
+
expect(stagedBuffer.byteLength).toBe(6 * 1024 * 1024);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("uses the default sandbox workspace root when workspaceRoot is omitted", async () => {
|
|
70
|
+
vi.stubEnv("OPENCLAW_STATE_DIR", root);
|
|
71
|
+
const mediaPath = path.join(root, "openclaw-media", "inbound", "default-root.bin");
|
|
72
|
+
const agentWorkspace = path.join(root, "agent-workspace");
|
|
73
|
+
|
|
74
|
+
await mkdir(path.dirname(mediaPath), { recursive: true });
|
|
75
|
+
await mkdir(agentWorkspace, { recursive: true });
|
|
76
|
+
await writeFile(mediaPath, Buffer.alloc(2 * 1024 * 1024, 5));
|
|
77
|
+
|
|
78
|
+
const staged = await stageWecomInboundMediaForSession({
|
|
79
|
+
cfg: {
|
|
80
|
+
channels: {
|
|
81
|
+
wecom: {
|
|
82
|
+
mediaMaxMb: 8,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
agents: {
|
|
86
|
+
list: [
|
|
87
|
+
{
|
|
88
|
+
id: "ops",
|
|
89
|
+
workspace: agentWorkspace,
|
|
90
|
+
sandbox: {
|
|
91
|
+
mode: "non-main",
|
|
92
|
+
scope: "session",
|
|
93
|
+
workspaceAccess: "ro",
|
|
94
|
+
docker: {
|
|
95
|
+
workdir: "/workspace",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
} as any,
|
|
102
|
+
accountId: "default",
|
|
103
|
+
agentId: "ops",
|
|
104
|
+
sessionKey: "agent:ops:wecom:default:dm:lisi",
|
|
105
|
+
mediaPath,
|
|
106
|
+
filename: "default-root.bin",
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(staged).toMatch(/^media\/inbound\/default-root\.bin$/);
|
|
110
|
+
const sandboxEntries = await readdir(path.join(root, "sandboxes"));
|
|
111
|
+
const stagedBuffer = await readFile(
|
|
112
|
+
path.join(root, "sandboxes", sandboxEntries[0]!, "media", "inbound", "default-root.bin"),
|
|
113
|
+
);
|
|
114
|
+
expect(stagedBuffer.byteLength).toBe(2 * 1024 * 1024);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("stages inbound media into the agent workspace for non-sandbox sessions", async () => {
|
|
118
|
+
const mediaPath = path.join(root, "openclaw-media", "inbound", "small.bin");
|
|
119
|
+
const agentWorkspace = path.join(root, "agent-workspace");
|
|
120
|
+
|
|
121
|
+
await mkdir(path.dirname(mediaPath), { recursive: true });
|
|
122
|
+
await mkdir(agentWorkspace, { recursive: true });
|
|
123
|
+
await writeFile(mediaPath, Buffer.alloc(1024, 1));
|
|
124
|
+
|
|
125
|
+
const staged = await stageWecomInboundMediaForSession({
|
|
126
|
+
cfg: {
|
|
127
|
+
channels: {
|
|
128
|
+
wecom: {
|
|
129
|
+
mediaMaxMb: 8,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
agents: {
|
|
133
|
+
list: [
|
|
134
|
+
{
|
|
135
|
+
id: "ops",
|
|
136
|
+
workspace: agentWorkspace,
|
|
137
|
+
sandbox: {
|
|
138
|
+
mode: "off",
|
|
139
|
+
scope: "session",
|
|
140
|
+
workspaceRoot: path.join(root, "sandboxes"),
|
|
141
|
+
workspaceAccess: "ro",
|
|
142
|
+
docker: {
|
|
143
|
+
workdir: "/workspace",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
} as any,
|
|
150
|
+
accountId: "default",
|
|
151
|
+
agentId: "ops",
|
|
152
|
+
sessionKey: "agent:ops:wecom:default:dm:zhangsan",
|
|
153
|
+
mediaPath,
|
|
154
|
+
filename: "small.bin",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(staged).toBe(path.join(agentWorkspace, "media", "inbound", "small.bin"));
|
|
158
|
+
const stagedBuffer = await readFile(staged);
|
|
159
|
+
expect(stagedBuffer.byteLength).toBe(1024);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("allocates distinct staged filenames for concurrent same-name uploads", async () => {
|
|
163
|
+
const mediaPathA = path.join(root, "openclaw-media", "inbound", "dup-a.bin");
|
|
164
|
+
const mediaPathB = path.join(root, "openclaw-media", "inbound", "dup-b.bin");
|
|
165
|
+
const agentWorkspace = path.join(root, "agent-workspace");
|
|
166
|
+
|
|
167
|
+
await mkdir(path.dirname(mediaPathA), { recursive: true });
|
|
168
|
+
await mkdir(agentWorkspace, { recursive: true });
|
|
169
|
+
await writeFile(mediaPathA, Buffer.from("first"));
|
|
170
|
+
await writeFile(mediaPathB, Buffer.from("second"));
|
|
171
|
+
|
|
172
|
+
const cfg = {
|
|
173
|
+
channels: {
|
|
174
|
+
wecom: {
|
|
175
|
+
mediaMaxMb: 8,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
agents: {
|
|
179
|
+
list: [
|
|
180
|
+
{
|
|
181
|
+
id: "ops",
|
|
182
|
+
workspace: agentWorkspace,
|
|
183
|
+
sandbox: {
|
|
184
|
+
mode: "off",
|
|
185
|
+
scope: "session",
|
|
186
|
+
workspaceAccess: "ro",
|
|
187
|
+
docker: {
|
|
188
|
+
workdir: "/workspace",
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
} as any;
|
|
195
|
+
|
|
196
|
+
const [stagedA, stagedB] = await Promise.all([
|
|
197
|
+
stageWecomInboundMediaForSession({
|
|
198
|
+
cfg,
|
|
199
|
+
accountId: "default",
|
|
200
|
+
agentId: "ops",
|
|
201
|
+
sessionKey: "agent:ops:wecom:default:dm:zhangsan",
|
|
202
|
+
mediaPath: mediaPathA,
|
|
203
|
+
filename: "dup.bin",
|
|
204
|
+
}),
|
|
205
|
+
stageWecomInboundMediaForSession({
|
|
206
|
+
cfg,
|
|
207
|
+
accountId: "default",
|
|
208
|
+
agentId: "ops",
|
|
209
|
+
sessionKey: "agent:ops:wecom:default:dm:lisi",
|
|
210
|
+
mediaPath: mediaPathB,
|
|
211
|
+
filename: "dup.bin",
|
|
212
|
+
}),
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
expect(stagedA).not.toBe(stagedB);
|
|
216
|
+
expect([path.basename(stagedA), path.basename(stagedB)].sort()).toEqual(["dup-1.bin", "dup.bin"]);
|
|
217
|
+
expect((await readFile(stagedA)).toString()).toMatch(/first|second/);
|
|
218
|
+
expect((await readFile(stagedB)).toString()).toMatch(/first|second/);
|
|
219
|
+
expect((await readFile(stagedA)).toString()).not.toBe((await readFile(stagedB)).toString());
|
|
220
|
+
});
|
|
221
|
+
});
|