@yanhaidao/wecom 2.2.7 → 2.3.2
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/.github/workflows/release.yml +56 -0
- package/CLAUDE.md +1 -1
- package/GOVERNANCE.md +26 -0
- package/LICENSE +7 -0
- package/README.md +275 -91
- package/assets/01.bot-add.png +0 -0
- package/assets/01.bot-setp2.png +0 -0
- package/assets/02.agent.add.png +0 -0
- package/assets/02.agent.api-set.png +0 -0
- package/assets/register.png +0 -0
- package/changelog/v2.2.28.md +70 -0
- package/changelog/v2.3.2.md +70 -0
- package/compat-single-account.md +118 -0
- package/package.json +10 -2
- package/src/accounts.ts +17 -55
- package/src/agent/api-client.ts +84 -37
- package/src/agent/api-client.upload.test.ts +110 -0
- package/src/agent/handler.event-filter.test.ts +50 -0
- package/src/agent/handler.ts +147 -145
- package/src/channel.config.test.ts +147 -0
- package/src/channel.lifecycle.test.ts +234 -0
- package/src/channel.ts +90 -140
- package/src/config/accounts.resolve.test.ts +38 -0
- package/src/config/accounts.ts +257 -22
- package/src/config/index.ts +6 -0
- package/src/config/network.ts +9 -5
- package/src/config/routing.test.ts +88 -0
- package/src/config/routing.ts +26 -0
- package/src/config/schema.ts +35 -4
- package/src/config-schema.ts +5 -41
- package/src/dynamic-agent.account-scope.test.ts +17 -0
- package/src/dynamic-agent.ts +13 -13
- package/src/gateway-monitor.ts +200 -0
- package/src/http.ts +16 -2
- package/src/media.test.ts +28 -1
- package/src/media.ts +59 -1
- package/src/monitor/state.queue.test.ts +1 -1
- package/src/monitor/state.ts +1 -1
- package/src/monitor/types.ts +1 -1
- package/src/monitor.active.test.ts +13 -7
- package/src/monitor.inbound-filter.test.ts +63 -0
- package/src/monitor.ts +948 -128
- package/src/monitor.webhook.test.ts +288 -3
- package/src/outbound.test.ts +130 -0
- package/src/outbound.ts +44 -9
- package/src/shared/command-auth.ts +4 -2
- package/src/shared/xml-parser.test.ts +21 -1
- package/src/shared/xml-parser.ts +18 -0
- package/src/types/account.ts +43 -14
- package/src/types/config.ts +37 -2
- package/src/types/index.ts +3 -0
- package/src/types.ts +29 -147
- package/GEMINI.md +0 -76
- package//345/212/250/346/200/201Agent/350/267/257/347/224/261.md +0 -360
package/src/dynamic-agent.ts
CHANGED
|
@@ -29,23 +29,23 @@ export function getDynamicAgentConfig(config: OpenClawConfig): DynamicAgentConfi
|
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function sanitizeDynamicIdPart(value: string): string {
|
|
33
|
+
return String(value)
|
|
34
|
+
.trim()
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9_-]/g, "_");
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
/**
|
|
33
40
|
* **generateAgentId (生成动态 Agent ID)**
|
|
34
41
|
*
|
|
35
|
-
*
|
|
36
|
-
* 格式: wecom-{type}-{sanitizedPeerId}
|
|
37
|
-
* - type: dm | group
|
|
38
|
-
* - sanitizedPeerId: 小写,非字母数字下划线横线替换为下划线
|
|
39
|
-
*
|
|
40
|
-
* @example
|
|
41
|
-
* generateAgentId("dm", "ZhangSan") // "wecom-dm-zhangsan"
|
|
42
|
-
* generateAgentId("group", "wr123456") // "wecom-group-wr123456"
|
|
42
|
+
* 根据账号 + 聊天类型 + 对端 ID 生成确定性的 Agent ID,避免多账号串会话。
|
|
43
|
+
* 格式: wecom-{accountId}-{type}-{sanitizedPeerId}
|
|
43
44
|
*/
|
|
44
|
-
export function generateAgentId(chatType: "dm" | "group", peerId: string): string {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return `wecom-${chatType}-${sanitized}`;
|
|
45
|
+
export function generateAgentId(chatType: "dm" | "group", peerId: string, accountId?: string): string {
|
|
46
|
+
const sanitizedPeer = sanitizeDynamicIdPart(peerId) || "unknown";
|
|
47
|
+
const sanitizedAccountId = sanitizeDynamicIdPart(accountId ?? "default") || "default";
|
|
48
|
+
return `wecom-${sanitizedAccountId}-${chatType}-${sanitizedPeer}`;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelGatewayContext,
|
|
3
|
+
OpenClawConfig,
|
|
4
|
+
PluginRuntime,
|
|
5
|
+
} from "openclaw/plugin-sdk";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
detectMode,
|
|
9
|
+
listWecomAccountIds,
|
|
10
|
+
resolveWecomAccount,
|
|
11
|
+
resolveWecomAccountConflict,
|
|
12
|
+
} from "./config/index.js";
|
|
13
|
+
import { registerAgentWebhookTarget, registerWecomWebhookTarget } from "./monitor.js";
|
|
14
|
+
import type { ResolvedWecomAccount, WecomConfig } from "./types/index.js";
|
|
15
|
+
|
|
16
|
+
type AccountRouteRegistryItem = {
|
|
17
|
+
botPaths: string[];
|
|
18
|
+
agentPath?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const accountRouteRegistry = new Map<string, AccountRouteRegistryItem>();
|
|
22
|
+
|
|
23
|
+
function logRegisteredRouteSummary(
|
|
24
|
+
ctx: ChannelGatewayContext<ResolvedWecomAccount>,
|
|
25
|
+
preferredOrder: string[],
|
|
26
|
+
): void {
|
|
27
|
+
const seen = new Set<string>();
|
|
28
|
+
const orderedAccountIds = [
|
|
29
|
+
...preferredOrder.filter((accountId) => accountRouteRegistry.has(accountId)),
|
|
30
|
+
...Array.from(accountRouteRegistry.keys())
|
|
31
|
+
.filter((accountId) => !seen.has(accountId))
|
|
32
|
+
.sort((a, b) => a.localeCompare(b)),
|
|
33
|
+
].filter((accountId) => {
|
|
34
|
+
if (seen.has(accountId)) return false;
|
|
35
|
+
seen.add(accountId);
|
|
36
|
+
return true;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const entries = orderedAccountIds
|
|
40
|
+
.map((accountId) => {
|
|
41
|
+
const routes = accountRouteRegistry.get(accountId);
|
|
42
|
+
if (!routes) return undefined;
|
|
43
|
+
const botText = routes.botPaths.length > 0 ? routes.botPaths.join(", ") : "未启用";
|
|
44
|
+
const agentText = routes.agentPath ?? "未启用";
|
|
45
|
+
return `accountId=${accountId}(Bot: ${botText};Agent: ${agentText})`;
|
|
46
|
+
})
|
|
47
|
+
.filter((entry): entry is string => Boolean(entry));
|
|
48
|
+
const summary = entries.length > 0 ? entries.join("; ") : "无";
|
|
49
|
+
ctx.log?.info(`[${ctx.account.accountId}] 已注册账号路由汇总:${summary}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveExpectedRouteSummaryAccountIds(cfg: OpenClawConfig): string[] {
|
|
53
|
+
return listWecomAccountIds(cfg)
|
|
54
|
+
.filter((accountId) => {
|
|
55
|
+
const conflict = resolveWecomAccountConflict({ cfg, accountId });
|
|
56
|
+
if (conflict) return false;
|
|
57
|
+
const account = resolveWecomAccount({ cfg, accountId });
|
|
58
|
+
if (!account.enabled || !account.configured) return false;
|
|
59
|
+
return Boolean(account.bot?.configured || account.agent?.configured);
|
|
60
|
+
})
|
|
61
|
+
.sort((a, b) => a.localeCompare(b));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function waitForAbortSignal(abortSignal: AbortSignal): Promise<void> {
|
|
65
|
+
if (abortSignal.aborted) {
|
|
66
|
+
return Promise.resolve();
|
|
67
|
+
}
|
|
68
|
+
return new Promise<void>((resolve) => {
|
|
69
|
+
const onAbort = () => {
|
|
70
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
71
|
+
resolve();
|
|
72
|
+
};
|
|
73
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Keeps WeCom webhook targets registered for the account lifecycle.
|
|
79
|
+
* The promise only settles after gateway abort/reload signals shutdown.
|
|
80
|
+
*/
|
|
81
|
+
export async function monitorWecomProvider(
|
|
82
|
+
ctx: ChannelGatewayContext<ResolvedWecomAccount>,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
const account = ctx.account;
|
|
85
|
+
const cfg = ctx.cfg as OpenClawConfig;
|
|
86
|
+
const expectedRouteSummaryAccountIds = resolveExpectedRouteSummaryAccountIds(cfg);
|
|
87
|
+
const conflict = resolveWecomAccountConflict({
|
|
88
|
+
cfg,
|
|
89
|
+
accountId: account.accountId,
|
|
90
|
+
});
|
|
91
|
+
if (conflict) {
|
|
92
|
+
ctx.setStatus({
|
|
93
|
+
accountId: account.accountId,
|
|
94
|
+
running: false,
|
|
95
|
+
configured: false,
|
|
96
|
+
lastError: conflict.message,
|
|
97
|
+
});
|
|
98
|
+
throw new Error(conflict.message);
|
|
99
|
+
}
|
|
100
|
+
const mode = detectMode(cfg.channels?.wecom as WecomConfig | undefined);
|
|
101
|
+
const matrixMode = mode === "matrix";
|
|
102
|
+
const bot = account.bot;
|
|
103
|
+
const agent = account.agent;
|
|
104
|
+
const botConfigured = Boolean(bot?.configured);
|
|
105
|
+
const agentConfigured = Boolean(agent?.configured);
|
|
106
|
+
|
|
107
|
+
if (mode === "legacy" && (botConfigured || agentConfigured)) {
|
|
108
|
+
if (agentConfigured && !botConfigured) {
|
|
109
|
+
ctx.log?.warn(
|
|
110
|
+
`[${account.accountId}] 检测到仍在使用单 Agent 兼容模式。建议尽快升级为多账号模式:` +
|
|
111
|
+
`将 channels.wecom.agent 迁移到 channels.wecom.accounts.<accountId>.agent,` +
|
|
112
|
+
`并设置 channels.wecom.defaultAccount。`,
|
|
113
|
+
);
|
|
114
|
+
} else {
|
|
115
|
+
ctx.log?.warn(
|
|
116
|
+
`[${account.accountId}] 检测到仍在使用单账号兼容模式。建议尽快升级为多账号模式:` +
|
|
117
|
+
`将 channels.wecom.bot/agent 迁移到 channels.wecom.accounts.<accountId>.bot/agent,` +
|
|
118
|
+
`并设置 channels.wecom.defaultAccount。`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!botConfigured && !agentConfigured) {
|
|
124
|
+
ctx.log?.warn(`[${account.accountId}] wecom not configured; channel is idle`);
|
|
125
|
+
ctx.setStatus({ accountId: account.accountId, running: false, configured: false });
|
|
126
|
+
await waitForAbortSignal(ctx.abortSignal);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const unregisters: Array<() => void> = [];
|
|
131
|
+
const botPaths: string[] = [];
|
|
132
|
+
let agentPath: string | undefined;
|
|
133
|
+
try {
|
|
134
|
+
if (bot && botConfigured) {
|
|
135
|
+
const paths = matrixMode
|
|
136
|
+
? [`/wecom/bot/${account.accountId}`]
|
|
137
|
+
: ["/wecom", "/wecom/bot"];
|
|
138
|
+
for (const path of paths) {
|
|
139
|
+
unregisters.push(
|
|
140
|
+
registerWecomWebhookTarget({
|
|
141
|
+
account: bot,
|
|
142
|
+
config: cfg,
|
|
143
|
+
runtime: ctx.runtime,
|
|
144
|
+
// The HTTP handler resolves the active PluginRuntime via getWecomRuntime().
|
|
145
|
+
// The stored target only needs to be decrypt/verify-capable.
|
|
146
|
+
core: {} as PluginRuntime,
|
|
147
|
+
path,
|
|
148
|
+
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
botPaths.push(...paths);
|
|
153
|
+
ctx.log?.info(`[${account.accountId}] wecom bot webhook registered at ${paths.join(", ")}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (agent && agentConfigured) {
|
|
157
|
+
const path = matrixMode ? `/wecom/agent/${account.accountId}` : "/wecom/agent";
|
|
158
|
+
unregisters.push(
|
|
159
|
+
registerAgentWebhookTarget({
|
|
160
|
+
agent,
|
|
161
|
+
config: cfg,
|
|
162
|
+
runtime: ctx.runtime,
|
|
163
|
+
path,
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
agentPath = path;
|
|
167
|
+
ctx.log?.info(`[${account.accountId}] wecom agent webhook registered at ${path}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
accountRouteRegistry.set(account.accountId, { botPaths, agentPath });
|
|
171
|
+
const shouldLogSummary =
|
|
172
|
+
expectedRouteSummaryAccountIds.length <= 1 ||
|
|
173
|
+
expectedRouteSummaryAccountIds.every((accountId) => accountRouteRegistry.has(accountId));
|
|
174
|
+
if (shouldLogSummary) {
|
|
175
|
+
logRegisteredRouteSummary(ctx, expectedRouteSummaryAccountIds);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
ctx.setStatus({
|
|
179
|
+
accountId: account.accountId,
|
|
180
|
+
running: true,
|
|
181
|
+
configured: true,
|
|
182
|
+
webhookPath: botConfigured
|
|
183
|
+
? (matrixMode ? `/wecom/bot/${account.accountId}` : "/wecom/bot")
|
|
184
|
+
: (matrixMode ? `/wecom/agent/${account.accountId}` : "/wecom/agent"),
|
|
185
|
+
lastStartAt: Date.now(),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await waitForAbortSignal(ctx.abortSignal);
|
|
189
|
+
} finally {
|
|
190
|
+
for (const unregister of unregisters) {
|
|
191
|
+
unregister();
|
|
192
|
+
}
|
|
193
|
+
accountRouteRegistry.delete(account.accountId);
|
|
194
|
+
ctx.setStatus({
|
|
195
|
+
accountId: account.accountId,
|
|
196
|
+
running: false,
|
|
197
|
+
lastStopAt: Date.now(),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
package/src/http.ts
CHANGED
|
@@ -57,13 +57,28 @@ export async function wecomFetch(input: string | URL, init?: RequestInit, opts?:
|
|
|
57
57
|
|
|
58
58
|
const initSignal = init?.signal ?? undefined;
|
|
59
59
|
const signal = mergeAbortSignal({ signal: opts?.signal ?? initSignal, timeoutMs: opts?.timeoutMs });
|
|
60
|
+
|
|
61
|
+
const headers = new Headers(init?.headers ?? {});
|
|
62
|
+
if (!headers.has("User-Agent")) {
|
|
63
|
+
headers.set("User-Agent", "OpenClaw/2.0 (WeCom-Agent)");
|
|
64
|
+
}
|
|
65
|
+
|
|
60
66
|
const nextInit: RequestInit & { dispatcher?: Dispatcher } = {
|
|
61
67
|
...(init ?? {}),
|
|
62
68
|
...(signal ? { signal } : {}),
|
|
63
69
|
...(dispatcher ? { dispatcher } : {}),
|
|
70
|
+
headers,
|
|
64
71
|
};
|
|
65
72
|
|
|
66
|
-
|
|
73
|
+
try {
|
|
74
|
+
return await undiciFetch(input, nextInit as Parameters<typeof undiciFetch>[1]) as unknown as Response;
|
|
75
|
+
} catch (err: unknown) {
|
|
76
|
+
if (err instanceof Error && err.name === "TypeError" && err.message === "fetch failed") {
|
|
77
|
+
const cause = (err as any).cause;
|
|
78
|
+
console.error(`[wecom-http] fetch failed: ${input} (proxy: ${proxyUrl || "none"})${cause ? ` - cause: ${String(cause)}` : ""}`);
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
/**
|
|
@@ -99,4 +114,3 @@ export async function readResponseBodyAsBuffer(res: Response, maxBytes?: number)
|
|
|
99
114
|
|
|
100
115
|
return Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
|
101
116
|
}
|
|
102
|
-
|
package/src/media.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { decryptWecomMedia } from "./media.js";
|
|
2
|
+
import { decryptWecomMedia, decryptWecomMediaWithMeta } from "./media.js";
|
|
3
3
|
import { WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
5
|
|
|
@@ -52,4 +52,31 @@ describe("decryptWecomMedia", () => {
|
|
|
52
52
|
it("should fail if key is invalid", async () => {
|
|
53
53
|
await expect(decryptWecomMedia("http://url", "invalid-key")).rejects.toThrow();
|
|
54
54
|
});
|
|
55
|
+
|
|
56
|
+
it("should return source metadata when using decryptWecomMediaWithMeta", async () => {
|
|
57
|
+
const aesKeyBase64 = "jWmYm7qr5nMoCAstdRmNjt3p7vsH8HkK+qiJqQ0aaaa=";
|
|
58
|
+
const aesKey = Buffer.from(aesKeyBase64 + "=", "base64");
|
|
59
|
+
const iv = aesKey.subarray(0, 16);
|
|
60
|
+
const originalData = Buffer.from("meta test", "utf8");
|
|
61
|
+
const padded = pkcs7Pad(originalData, WECOM_PKCS7_BLOCK_SIZE);
|
|
62
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
|
|
63
|
+
cipher.setAutoPadding(false);
|
|
64
|
+
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
|
|
65
|
+
|
|
66
|
+
undiciFetch.mockResolvedValue(
|
|
67
|
+
new Response(encrypted, {
|
|
68
|
+
status: 200,
|
|
69
|
+
headers: {
|
|
70
|
+
"content-type": "application/octet-stream; charset=binary",
|
|
71
|
+
"content-disposition": "attachment; filename*=UTF-8''report%20v1.docx",
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const decrypted = await decryptWecomMediaWithMeta("http://mock.url/media?id=1", aesKeyBase64);
|
|
77
|
+
expect(decrypted.buffer.toString("utf8")).toBe("meta test");
|
|
78
|
+
expect(decrypted.sourceContentType).toBe("application/octet-stream");
|
|
79
|
+
expect(decrypted.sourceFilename).toBe("report v1.docx");
|
|
80
|
+
expect(decrypted.sourceUrl).toBe("http://mock.url/media?id=1");
|
|
81
|
+
});
|
|
55
82
|
});
|
package/src/media.ts
CHANGED
|
@@ -2,6 +2,41 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import { decodeEncodingAESKey, pkcs7Unpad, WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
|
|
3
3
|
import { readResponseBodyAsBuffer, wecomFetch, type WecomHttpOptions } from "./http.js";
|
|
4
4
|
|
|
5
|
+
export type DecryptedWecomMedia = {
|
|
6
|
+
buffer: Buffer;
|
|
7
|
+
sourceContentType?: string;
|
|
8
|
+
sourceFilename?: string;
|
|
9
|
+
sourceUrl?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function normalizeMime(contentType?: string | null): string | undefined {
|
|
13
|
+
const raw = String(contentType ?? "").trim();
|
|
14
|
+
if (!raw) return undefined;
|
|
15
|
+
return raw.split(";")[0]?.trim().toLowerCase() || undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function extractFilenameFromContentDisposition(disposition?: string | null): string | undefined {
|
|
19
|
+
const raw = String(disposition ?? "").trim();
|
|
20
|
+
if (!raw) return undefined;
|
|
21
|
+
|
|
22
|
+
const star = raw.match(/filename\*\s*=\s*([^;]+)/i);
|
|
23
|
+
if (star?.[1]) {
|
|
24
|
+
const v = star[1].trim().replace(/^UTF-8''/i, "").replace(/^"(.*)"$/, "$1");
|
|
25
|
+
try {
|
|
26
|
+
const decoded = decodeURIComponent(v);
|
|
27
|
+
if (decoded.trim()) return decoded.trim();
|
|
28
|
+
} catch { /* ignore */ }
|
|
29
|
+
if (v.trim()) return v.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const plain = raw.match(/filename\s*=\s*([^;]+)/i);
|
|
33
|
+
if (plain?.[1]) {
|
|
34
|
+
const v = plain[1].trim().replace(/^"(.*)"$/, "$1").trim();
|
|
35
|
+
if (v) return v;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
5
40
|
/**
|
|
6
41
|
* **decryptWecomMedia (解密企业微信媒体文件)**
|
|
7
42
|
*
|
|
@@ -28,11 +63,29 @@ export async function decryptWecomMediaWithHttp(
|
|
|
28
63
|
encodingAESKey: string,
|
|
29
64
|
params?: { maxBytes?: number; http?: WecomHttpOptions },
|
|
30
65
|
): Promise<Buffer> {
|
|
66
|
+
const decrypted = await decryptWecomMediaWithMeta(url, encodingAESKey, params);
|
|
67
|
+
return decrypted.buffer;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* **decryptWecomMediaWithMeta (解密企业微信媒体并返回源信息)**
|
|
72
|
+
*
|
|
73
|
+
* 在返回解密结果的同时,保留下载响应中的元信息(content-type / filename / final url),
|
|
74
|
+
* 供上层更准确地推断文件后缀和 MIME。
|
|
75
|
+
*/
|
|
76
|
+
export async function decryptWecomMediaWithMeta(
|
|
77
|
+
url: string,
|
|
78
|
+
encodingAESKey: string,
|
|
79
|
+
params?: { maxBytes?: number; http?: WecomHttpOptions },
|
|
80
|
+
): Promise<DecryptedWecomMedia> {
|
|
31
81
|
// 1. Download encrypted content
|
|
32
82
|
const res = await wecomFetch(url, undefined, { ...params?.http, timeoutMs: params?.http?.timeoutMs ?? 15_000 });
|
|
33
83
|
if (!res.ok) {
|
|
34
84
|
throw new Error(`failed to download media: ${res.status}`);
|
|
35
85
|
}
|
|
86
|
+
const sourceContentType = normalizeMime(res.headers.get("content-type"));
|
|
87
|
+
const sourceFilename = extractFilenameFromContentDisposition(res.headers.get("content-disposition"));
|
|
88
|
+
const sourceUrl = res.url || url;
|
|
36
89
|
const encryptedData = await readResponseBodyAsBuffer(res, params?.maxBytes);
|
|
37
90
|
|
|
38
91
|
// 2. Prepare Key and IV
|
|
@@ -51,5 +104,10 @@ export async function decryptWecomMediaWithHttp(
|
|
|
51
104
|
// Note: Unlike msg bodies, usually removing PKCS#7 padding is enough for media files.
|
|
52
105
|
// The Python SDK logic: pad_len = decrypted_data[-1]; decrypted_data = decrypted_data[:-pad_len]
|
|
53
106
|
// Our pkcs7Unpad function does exactly this + validation.
|
|
54
|
-
return
|
|
107
|
+
return {
|
|
108
|
+
buffer: pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE),
|
|
109
|
+
sourceContentType,
|
|
110
|
+
sourceFilename,
|
|
111
|
+
sourceUrl,
|
|
112
|
+
};
|
|
55
113
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
import type { WecomInboundMessage } from "../types.js";
|
|
3
|
+
import type { WecomBotInboundMessage as WecomInboundMessage } from "../types/index.js";
|
|
4
4
|
import type { WecomWebhookTarget } from "./types.js";
|
|
5
5
|
import { StreamStore } from "./state.js";
|
|
6
6
|
|
package/src/monitor/state.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import type { StreamState, PendingInbound, ActiveReplyState, WecomWebhookTarget } from "./types.js";
|
|
3
|
-
import type { WecomInboundMessage } from "../types.js";
|
|
3
|
+
import type { WecomBotInboundMessage as WecomInboundMessage } from "../types/index.js";
|
|
4
4
|
|
|
5
5
|
// Constants
|
|
6
6
|
export const LIMITS = {
|
package/src/monitor/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
3
3
|
import type { ResolvedBotAccount } from "../types/index.js";
|
|
4
|
-
import type { WecomInboundMessage } from "../types.js";
|
|
4
|
+
import type { WecomBotInboundMessage as WecomInboundMessage } from "../types/index.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* **WecomRuntimeEnv (运行时环境)**
|
|
@@ -45,6 +45,7 @@ function createMockResponse(): ServerResponse {
|
|
|
45
45
|
|
|
46
46
|
describe("Monitor Active Features", () => {
|
|
47
47
|
let capturedDeliver: ((payload: { text: string }) => Promise<void>) | undefined;
|
|
48
|
+
let unregisterTarget: (() => void) | undefined;
|
|
48
49
|
let mockCore: any;
|
|
49
50
|
let msgSeq = 0;
|
|
50
51
|
let senderUserId = "";
|
|
@@ -109,7 +110,7 @@ describe("Monitor Active Features", () => {
|
|
|
109
110
|
return;
|
|
110
111
|
}
|
|
111
112
|
},
|
|
112
|
-
routing: { resolveAgentRoute: () => ({ agentId: "1", sessionKey: "1", accountId: "
|
|
113
|
+
routing: { resolveAgentRoute: () => ({ agentId: "1", sessionKey: "1", accountId: "default" }) },
|
|
113
114
|
session: {
|
|
114
115
|
resolveStorePath: () => "",
|
|
115
116
|
readSessionUpdatedAt: () => 0,
|
|
@@ -121,8 +122,8 @@ describe("Monitor Active Features", () => {
|
|
|
121
122
|
|
|
122
123
|
vi.spyOn(runtime, "getWecomRuntime").mockReturnValue(mockCore);
|
|
123
124
|
|
|
124
|
-
registerWecomWebhookTarget({
|
|
125
|
-
account: { accountId: "
|
|
125
|
+
unregisterTarget = registerWecomWebhookTarget({
|
|
126
|
+
account: { accountId: "default", enabled: true, configured: true, token: "T", encodingAESKey: validKey, receiveId: "R", config: {} as any },
|
|
126
127
|
config: {
|
|
127
128
|
channels: {
|
|
128
129
|
wecom: {
|
|
@@ -144,6 +145,8 @@ describe("Monitor Active Features", () => {
|
|
|
144
145
|
});
|
|
145
146
|
|
|
146
147
|
afterEach(() => {
|
|
148
|
+
unregisterTarget?.();
|
|
149
|
+
unregisterTarget = undefined;
|
|
147
150
|
vi.useRealTimers();
|
|
148
151
|
});
|
|
149
152
|
|
|
@@ -187,14 +190,17 @@ describe("Monitor Active Features", () => {
|
|
|
187
190
|
undiciFetch.mockResolvedValue(new Response("ok", { status: 200 }));
|
|
188
191
|
await sendActiveMessage(streamId, "Active Hello");
|
|
189
192
|
|
|
190
|
-
expect(undiciFetch).
|
|
191
|
-
|
|
193
|
+
expect(undiciFetch).toHaveBeenCalled();
|
|
194
|
+
const [url, init] = undiciFetch.mock.calls.at(-1)! as [string, RequestInit];
|
|
195
|
+
expect(url).toBe("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key");
|
|
196
|
+
expect(init).toEqual(
|
|
192
197
|
expect.objectContaining({
|
|
193
198
|
method: "POST",
|
|
194
|
-
headers: expect.objectContaining({ "Content-Type": "application/json" }),
|
|
195
199
|
body: JSON.stringify({ msgtype: "text", text: { content: "Active Hello" } }),
|
|
196
200
|
}),
|
|
197
201
|
);
|
|
202
|
+
const headers = new Headers(init.headers);
|
|
203
|
+
expect(headers.get("content-type")).toBe("application/json");
|
|
198
204
|
});
|
|
199
205
|
|
|
200
206
|
it("should fallback non-image media to agent DM (and push a Chinese prompt)", async () => {
|
|
@@ -234,6 +240,6 @@ describe("Monitor Active Features", () => {
|
|
|
234
240
|
expect(undiciFetch).toHaveBeenCalled();
|
|
235
241
|
});
|
|
236
242
|
|
|
237
|
-
// 注:本机路径(/Users
|
|
243
|
+
// 注:本机路径(/Users/...、/tmp/...、/root/...、/home/...)短路发图逻辑属于运行态特性,
|
|
238
244
|
// 单测在 fake timers + module singleton 状态下容易引入脆弱性;这里优先覆盖更关键的兜底链路与去重逻辑。
|
|
239
245
|
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { shouldProcessBotInboundMessage } from "./monitor.js";
|
|
4
|
+
|
|
5
|
+
describe("shouldProcessBotInboundMessage", () => {
|
|
6
|
+
it("skips payloads without sender id", () => {
|
|
7
|
+
const result = shouldProcessBotInboundMessage({
|
|
8
|
+
msgtype: "text",
|
|
9
|
+
from: {},
|
|
10
|
+
text: { content: "hello" },
|
|
11
|
+
});
|
|
12
|
+
expect(result.shouldProcess).toBe(false);
|
|
13
|
+
expect(result.reason).toBe("missing_sender");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("skips system sender payloads", () => {
|
|
17
|
+
const result = shouldProcessBotInboundMessage({
|
|
18
|
+
msgtype: "text",
|
|
19
|
+
from: { userid: "sys" },
|
|
20
|
+
text: { content: "hello" },
|
|
21
|
+
});
|
|
22
|
+
expect(result.shouldProcess).toBe(false);
|
|
23
|
+
expect(result.reason).toBe("system_sender");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("skips group payloads without chatid", () => {
|
|
27
|
+
const result = shouldProcessBotInboundMessage({
|
|
28
|
+
msgtype: "text",
|
|
29
|
+
chattype: "group",
|
|
30
|
+
from: { userid: "zhangsan" },
|
|
31
|
+
text: { content: "hello" },
|
|
32
|
+
});
|
|
33
|
+
expect(result.shouldProcess).toBe(false);
|
|
34
|
+
expect(result.reason).toBe("missing_chatid");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("accepts normal direct-user messages", () => {
|
|
38
|
+
const result = shouldProcessBotInboundMessage({
|
|
39
|
+
msgtype: "text",
|
|
40
|
+
chattype: "single",
|
|
41
|
+
from: { userid: "zhangsan" },
|
|
42
|
+
text: { content: "hello" },
|
|
43
|
+
});
|
|
44
|
+
expect(result.shouldProcess).toBe(true);
|
|
45
|
+
expect(result.reason).toBe("user_message");
|
|
46
|
+
expect(result.senderUserId).toBe("zhangsan");
|
|
47
|
+
expect(result.chatId).toBe("zhangsan");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("accepts normal group messages with chatid", () => {
|
|
51
|
+
const result = shouldProcessBotInboundMessage({
|
|
52
|
+
msgtype: "text",
|
|
53
|
+
chattype: "group",
|
|
54
|
+
chatid: "wr123",
|
|
55
|
+
from: { userid: "zhangsan" },
|
|
56
|
+
text: { content: "hello" },
|
|
57
|
+
});
|
|
58
|
+
expect(result.shouldProcess).toBe(true);
|
|
59
|
+
expect(result.reason).toBe("user_message");
|
|
60
|
+
expect(result.senderUserId).toBe("zhangsan");
|
|
61
|
+
expect(result.chatId).toBe("wr123");
|
|
62
|
+
});
|
|
63
|
+
});
|