@mocrane/wecom 2026.2.5

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/clawdbot.plugin.json +10 -0
  4. package/index.ts +28 -0
  5. package/openclaw.plugin.json +10 -0
  6. package/package.json +81 -0
  7. package/src/accounts.ts +72 -0
  8. package/src/agent/api-client.ts +336 -0
  9. package/src/agent/handler.ts +566 -0
  10. package/src/agent/index.ts +12 -0
  11. package/src/channel.ts +259 -0
  12. package/src/config/accounts.ts +99 -0
  13. package/src/config/index.ts +12 -0
  14. package/src/config/media.ts +14 -0
  15. package/src/config/network.ts +16 -0
  16. package/src/config/schema.ts +104 -0
  17. package/src/config-schema.ts +41 -0
  18. package/src/crypto/aes.ts +108 -0
  19. package/src/crypto/index.ts +24 -0
  20. package/src/crypto/signature.ts +43 -0
  21. package/src/crypto/xml.ts +49 -0
  22. package/src/crypto.test.ts +32 -0
  23. package/src/crypto.ts +176 -0
  24. package/src/http.ts +102 -0
  25. package/src/media.test.ts +55 -0
  26. package/src/media.ts +55 -0
  27. package/src/monitor/state.queue.test.ts +185 -0
  28. package/src/monitor/state.ts +514 -0
  29. package/src/monitor/types.ts +136 -0
  30. package/src/monitor.active.test.ts +239 -0
  31. package/src/monitor.integration.test.ts +207 -0
  32. package/src/monitor.ts +1802 -0
  33. package/src/monitor.webhook.test.ts +311 -0
  34. package/src/onboarding.ts +472 -0
  35. package/src/outbound.test.ts +143 -0
  36. package/src/outbound.ts +200 -0
  37. package/src/runtime.ts +14 -0
  38. package/src/shared/command-auth.ts +101 -0
  39. package/src/shared/index.ts +5 -0
  40. package/src/shared/xml-parser.test.ts +30 -0
  41. package/src/shared/xml-parser.ts +183 -0
  42. package/src/target.ts +80 -0
  43. package/src/types/account.ts +76 -0
  44. package/src/types/config.ts +88 -0
  45. package/src/types/constants.ts +42 -0
  46. package/src/types/global.d.ts +9 -0
  47. package/src/types/index.ts +38 -0
  48. package/src/types/message.ts +185 -0
  49. package/src/types.ts +159 -0
@@ -0,0 +1,200 @@
1
+ import type { ChannelOutboundAdapter, ChannelOutboundContext } from "openclaw/plugin-sdk";
2
+
3
+ import { sendText as sendAgentText, sendMedia as sendAgentMedia, uploadMedia } from "./agent/api-client.js";
4
+ import { resolveWecomAccounts } from "./config/index.js";
5
+ import { getWecomRuntime } from "./runtime.js";
6
+
7
+ import { resolveWecomTarget } from "./target.js";
8
+
9
+ function resolveAgentConfigOrThrow(cfg: ChannelOutboundContext["cfg"]) {
10
+ const account = resolveWecomAccounts(cfg).agent;
11
+ if (!account?.configured) {
12
+ throw new Error(
13
+ "WeCom outbound requires Agent mode. Configure channels.wecom.agent (corpId/corpSecret/agentId/token/encodingAESKey).",
14
+ );
15
+ }
16
+ // 注意:不要在日志里输出 corpSecret 等敏感信息
17
+ console.log(`[wecom-outbound] Using agent config: corpId=${account.corpId}, agentId=${account.agentId}`);
18
+ return account;
19
+ }
20
+
21
+ export const wecomOutbound: ChannelOutboundAdapter = {
22
+ deliveryMode: "direct",
23
+ chunkerMode: "text",
24
+ textChunkLimit: 20480,
25
+ chunker: (text, limit) => {
26
+ try {
27
+ return getWecomRuntime().channel.text.chunkText(text, limit);
28
+ } catch {
29
+ return [text];
30
+ }
31
+ },
32
+ sendText: async ({ cfg, to, text }: ChannelOutboundContext) => {
33
+ // signal removed - not supported in current SDK
34
+
35
+ const agent = resolveAgentConfigOrThrow(cfg);
36
+ const target = resolveWecomTarget(to);
37
+ if (!target) {
38
+ throw new Error("WeCom outbound requires a target (userid, partyid, tagid or chatid).");
39
+ }
40
+
41
+ // 体验优化:/new /reset 的“New session started”回执在 OpenClaw 核心里是英文固定文案,
42
+ // 且通过 routeReply 走 wecom outbound(Agent 主动发送)。
43
+ // 在 WeCom“双模式”场景下,这会造成:
44
+ // - 用户在 Bot 会话发 /new,但却收到一条 Agent 私信回执(双重回复/错会话)。
45
+ // 因此:
46
+ // - Bot 会话目标:抑制该回执(Bot 会话里由 wecom 插件补中文回执)。
47
+ // - Agent 会话目标(wecom-agent:):允许发送,但改写成中文。
48
+ let outgoingText = text;
49
+ const trimmed = String(outgoingText ?? "").trim();
50
+ const rawTo = typeof to === "string" ? to.trim().toLowerCase() : "";
51
+ const isAgentSessionTarget = rawTo.startsWith("wecom-agent:");
52
+ const looksLikeNewSessionAck =
53
+ /new session started/i.test(trimmed) && /model:/i.test(trimmed);
54
+
55
+ if (looksLikeNewSessionAck) {
56
+ if (!isAgentSessionTarget) {
57
+ console.log(`[wecom-outbound] Suppressed command ack to avoid Bot/Agent double-reply (len=${trimmed.length})`);
58
+ return { channel: "wecom", messageId: `suppressed-${Date.now()}`, timestamp: Date.now() };
59
+ }
60
+
61
+ const modelLabel = (() => {
62
+ const m = trimmed.match(/model:\s*([^\n()]+)\s*/i);
63
+ return m?.[1]?.trim();
64
+ })();
65
+ const rewritten = modelLabel ? `✅ 已开启新会话(模型:${modelLabel})` : "✅ 已开启新会话。";
66
+ console.log(`[wecom-outbound] Rewrote command ack for agent session (len=${rewritten.length})`);
67
+ outgoingText = rewritten;
68
+ }
69
+
70
+ const { touser, toparty, totag, chatid } = target;
71
+ if (chatid) {
72
+ throw new Error(
73
+ `企业微信(WeCom)Agent 主动发送不支持向群 chatId 发送(chatId=${chatid})。` +
74
+ `该路径在实际环境中经常失败(例如 86008:无权限访问该会话/会话由其他应用创建)。` +
75
+ `请改为发送给用户(userid / user:xxx),或由 Bot 模式在群内交付。`,
76
+ );
77
+ }
78
+ console.log(`[wecom-outbound] Sending text to target=${JSON.stringify(target)} (len=${outgoingText.length})`);
79
+
80
+ try {
81
+ await sendAgentText({
82
+ agent,
83
+ toUser: touser,
84
+ toParty: toparty,
85
+ toTag: totag,
86
+ chatId: chatid,
87
+ text: outgoingText,
88
+ });
89
+ console.log(`[wecom-outbound] Successfully sent text to ${JSON.stringify(target)}`);
90
+ } catch (err) {
91
+ console.error(`[wecom-outbound] Failed to send text to ${JSON.stringify(target)}:`, err);
92
+ throw err;
93
+ }
94
+
95
+ return {
96
+ channel: "wecom",
97
+ messageId: `agent-${Date.now()}`,
98
+ timestamp: Date.now(),
99
+ };
100
+ },
101
+ sendMedia: async ({ cfg, to, text, mediaUrl }: ChannelOutboundContext) => {
102
+ // signal removed - not supported in current SDK
103
+
104
+ const agent = resolveAgentConfigOrThrow(cfg);
105
+ const target = resolveWecomTarget(to);
106
+ if (!target) {
107
+ throw new Error("WeCom outbound requires a target (userid, partyid, tagid or chatid).");
108
+ }
109
+ if (target.chatid) {
110
+ throw new Error(
111
+ `企业微信(WeCom)Agent 主动发送不支持向群 chatId 发送(chatId=${target.chatid})。` +
112
+ `该路径在实际环境中经常失败(例如 86008:无权限访问该会话/会话由其他应用创建)。` +
113
+ `请改为发送给用户(userid / user:xxx),或由 Bot 模式在群内交付。`,
114
+ );
115
+ }
116
+ if (!mediaUrl) {
117
+ throw new Error("WeCom outbound requires mediaUrl.");
118
+ }
119
+
120
+ let buffer: Buffer;
121
+ let contentType: string;
122
+ let filename: string;
123
+
124
+ // 判断是 URL 还是本地文件路径
125
+ const isRemoteUrl = /^https?:\/\//i.test(mediaUrl);
126
+
127
+ if (isRemoteUrl) {
128
+ const res = await fetch(mediaUrl, { signal: AbortSignal.timeout(30000) });
129
+ if (!res.ok) {
130
+ throw new Error(`Failed to download media: ${res.status}`);
131
+ }
132
+ buffer = Buffer.from(await res.arrayBuffer());
133
+ contentType = res.headers.get("content-type") || "application/octet-stream";
134
+ const urlPath = new URL(mediaUrl).pathname;
135
+ filename = urlPath.split("/").pop() || "media";
136
+ } else {
137
+ // 本地文件路径
138
+ const fs = await import("node:fs/promises");
139
+ const path = await import("node:path");
140
+
141
+ buffer = await fs.readFile(mediaUrl);
142
+ filename = path.basename(mediaUrl);
143
+
144
+ // 根据扩展名推断 content-type
145
+ const ext = path.extname(mediaUrl).slice(1).toLowerCase();
146
+ const mimeTypes: Record<string, string> = {
147
+ jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
148
+ webp: "image/webp", bmp: "image/bmp", mp3: "audio/mpeg", wav: "audio/wav",
149
+ amr: "audio/amr", mp4: "video/mp4", pdf: "application/pdf", doc: "application/msword",
150
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
151
+ xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
152
+ };
153
+ contentType = mimeTypes[ext] || "application/octet-stream";
154
+ console.log(`[wecom-outbound] Reading local file: ${mediaUrl}, ext=${ext}, contentType=${contentType}`);
155
+ }
156
+
157
+ let mediaType: "image" | "voice" | "video" | "file" = "file";
158
+ if (contentType.startsWith("image/")) mediaType = "image";
159
+ else if (contentType.startsWith("audio/")) mediaType = "voice";
160
+ else if (contentType.startsWith("video/")) mediaType = "video";
161
+
162
+ const mediaId = await uploadMedia({
163
+ agent,
164
+ type: mediaType,
165
+ buffer,
166
+ filename,
167
+ });
168
+
169
+ const { touser, toparty, totag, chatid } = target;
170
+ console.log(`[wecom-outbound] Sending media (${mediaType}) to ${JSON.stringify(target)} (mediaId=${mediaId})`);
171
+
172
+ try {
173
+ await sendAgentMedia({
174
+ agent,
175
+ toUser: touser,
176
+ toParty: toparty,
177
+ toTag: totag,
178
+ chatId: chatid,
179
+ mediaId,
180
+ mediaType,
181
+ ...(mediaType === "video" && text?.trim()
182
+ ? {
183
+ title: text.trim().slice(0, 64),
184
+ description: text.trim().slice(0, 512),
185
+ }
186
+ : {}),
187
+ });
188
+ console.log(`[wecom-outbound] Successfully sent media to ${JSON.stringify(target)}`);
189
+ } catch (err) {
190
+ console.error(`[wecom-outbound] Failed to send media to ${JSON.stringify(target)}:`, err);
191
+ throw err;
192
+ }
193
+
194
+ return {
195
+ channel: "wecom",
196
+ messageId: `agent-media-${Date.now()}`,
197
+ timestamp: Date.now(),
198
+ };
199
+ },
200
+ };
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setWecomRuntime(next: PluginRuntime): void {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getWecomRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("WeCom runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }
@@ -0,0 +1,101 @@
1
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ import type { WecomAccountConfig } from "../types.js";
4
+
5
+ function normalizeWecomAllowFromEntry(raw: string): string {
6
+ return raw
7
+ .trim()
8
+ .toLowerCase()
9
+ .replace(/^wecom:/, "")
10
+ .replace(/^user:/, "")
11
+ .replace(/^userid:/, "");
12
+ }
13
+
14
+ function isWecomSenderAllowed(senderUserId: string, allowFrom: string[]): boolean {
15
+ const list = allowFrom.map((entry) => normalizeWecomAllowFromEntry(entry)).filter(Boolean);
16
+ if (list.includes("*")) return true;
17
+ const normalizedSender = normalizeWecomAllowFromEntry(senderUserId);
18
+ if (!normalizedSender) return false;
19
+ return list.includes(normalizedSender);
20
+ }
21
+
22
+ export async function resolveWecomCommandAuthorization(params: {
23
+ core: PluginRuntime;
24
+ cfg: OpenClawConfig;
25
+ accountConfig: WecomAccountConfig;
26
+ rawBody: string;
27
+ senderUserId: string;
28
+ }): Promise<{
29
+ shouldComputeAuth: boolean;
30
+ dmPolicy: "pairing" | "allowlist" | "open" | "disabled";
31
+ senderAllowed: boolean;
32
+ authorizerConfigured: boolean;
33
+ commandAuthorized: boolean | undefined;
34
+ effectiveAllowFrom: string[];
35
+ }> {
36
+ const { core, cfg, accountConfig, rawBody, senderUserId } = params;
37
+
38
+ const dmPolicy = (accountConfig.dm?.policy ?? "pairing") as "pairing" | "allowlist" | "open" | "disabled";
39
+ const configAllowFrom = (accountConfig.dm?.allowFrom ?? []).map((v) => String(v));
40
+
41
+ const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, cfg);
42
+ // WeCom channel currently does NOT support the `openclaw pairing` CLI workflow
43
+ // ("Channel wecom does not support pairing"). So we must not rely on pairing
44
+ // store approvals for command authorization here.
45
+ //
46
+ // Policy semantics:
47
+ // - open: commands are allowed for everyone by default (unless higher-level access-groups deny).
48
+ // - allowlist: commands require allowFrom entries.
49
+ // - pairing: treated the same as allowlist for WeCom (since pairing CLI is unsupported).
50
+ const effectiveAllowFrom = dmPolicy === "open" ? ["*"] : configAllowFrom;
51
+
52
+ const senderAllowed = isWecomSenderAllowed(senderUserId, effectiveAllowFrom);
53
+ const allowAllConfigured = effectiveAllowFrom.some((entry) => normalizeWecomAllowFromEntry(entry) === "*");
54
+ const authorizerConfigured = allowAllConfigured || effectiveAllowFrom.length > 0;
55
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
56
+
57
+ const commandAuthorized = shouldComputeAuth
58
+ ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
59
+ useAccessGroups,
60
+ authorizers: [{ configured: authorizerConfigured, allowed: senderAllowed }],
61
+ })
62
+ : undefined;
63
+
64
+ return {
65
+ shouldComputeAuth,
66
+ dmPolicy,
67
+ senderAllowed,
68
+ authorizerConfigured,
69
+ commandAuthorized,
70
+ effectiveAllowFrom,
71
+ };
72
+ }
73
+
74
+ export function buildWecomUnauthorizedCommandPrompt(params: {
75
+ senderUserId: string;
76
+ dmPolicy: "pairing" | "allowlist" | "open" | "disabled";
77
+ scope: "bot" | "agent";
78
+ }): string {
79
+ const user = params.senderUserId || "unknown";
80
+ const policy = params.dmPolicy;
81
+ const scopeLabel = params.scope === "bot" ? "Bot(智能机器人)" : "Agent(自建应用)";
82
+ const dmPrefix = params.scope === "bot" ? "channels.wecom.bot.dm" : "channels.wecom.agent.dm";
83
+ const allowCmd = (value: string) => `openclaw config set ${dmPrefix}.allowFrom '${value}'`;
84
+ const policyCmd = (value: string) => `openclaw config set ${dmPrefix}.policy "${value}"`;
85
+
86
+ if (policy === "disabled") {
87
+ return [
88
+ `无权限执行命令(${scopeLabel} 已禁用:dm.policy=disabled)`,
89
+ `触发者:${user}`,
90
+ `管理员:${policyCmd("open")}(全放开)或 ${policyCmd("allowlist")}(白名单)`,
91
+ ].join("\n");
92
+ }
93
+ // WeCom 不支持 pairing CLI,因此这里统一给出“open / allowlist”两种明确的配置指令
94
+ return [
95
+ `无权限执行命令(入口:${scopeLabel},userid:${user})`,
96
+ `管理员全放开:${policyCmd("open")}`,
97
+ `管理员放行该用户:${policyCmd("allowlist")}`,
98
+ `然后设置白名单:${allowCmd(JSON.stringify([user]))}`,
99
+ `如果仍被拦截:检查 commands.useAccessGroups/访问组`,
100
+ ].join("\n");
101
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * WeCom Shared 模块导出
3
+ */
4
+
5
+ export { parseXml, extractMsgType, extractFromUser, extractContent, extractChatId, extractToUser } from "./xml-parser.js";
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import { extractContent, extractMediaId, extractMsgId } from "./xml-parser.js";
4
+
5
+ describe("wecom xml-parser", () => {
6
+ test("extractContent is robust to non-string Content", () => {
7
+ const msg: any = { MsgType: "text", Content: { "#text": "hello", "@_foo": "bar" } };
8
+ expect(extractContent(msg)).toBe("hello");
9
+ });
10
+
11
+ test("extractContent handles array content", () => {
12
+ const msg: any = { MsgType: "text", Content: ["a", "b"] };
13
+ expect(extractContent(msg)).toBe("a\nb");
14
+ });
15
+
16
+ test("extractContent handles file messages", () => {
17
+ const msg: any = { MsgType: "file", MediaId: "MEDIA123" };
18
+ expect(extractContent(msg)).toBe("[文件消息]");
19
+ });
20
+
21
+ test("extractMediaId handles object MediaId", () => {
22
+ const msg: any = { MediaId: { "#text": "MEDIA123", "@_foo": "bar" } };
23
+ expect(extractMediaId(msg)).toBe("MEDIA123");
24
+ });
25
+
26
+ test("extractMsgId handles number MsgId", () => {
27
+ const msg: any = { MsgId: 123456789 };
28
+ expect(extractMsgId(msg)).toBe("123456789");
29
+ });
30
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * WeCom XML 解析器
3
+ * 用于 Agent 模式解析 XML 格式消息
4
+ */
5
+
6
+ import { XMLParser } from "fast-xml-parser";
7
+ import type { WecomAgentInboundMessage } from "../types/index.js";
8
+
9
+ const xmlParser = new XMLParser({
10
+ ignoreAttributes: false,
11
+ trimValues: true,
12
+ processEntities: false,
13
+ });
14
+
15
+ /**
16
+ * 解析 XML 字符串为消息对象
17
+ */
18
+ export function parseXml(xml: string): WecomAgentInboundMessage {
19
+ const obj = xmlParser.parse(xml);
20
+ const root = obj?.xml ?? obj;
21
+ return root ?? {};
22
+ }
23
+
24
+ /**
25
+ * 从 XML 中提取消息类型
26
+ */
27
+ export function extractMsgType(msg: WecomAgentInboundMessage): string {
28
+ return String(msg.MsgType ?? "").toLowerCase();
29
+ }
30
+
31
+ /**
32
+ * 从 XML 中提取发送者 ID
33
+ */
34
+ export function extractFromUser(msg: WecomAgentInboundMessage): string {
35
+ return String(msg.FromUserName ?? "");
36
+ }
37
+
38
+ /**
39
+ * 从 XML 中提取文件名(主要用于 file 消息)
40
+ */
41
+ export function extractFileName(msg: WecomAgentInboundMessage): string | undefined {
42
+ const raw = (msg as any).FileName ?? (msg as any).Filename ?? (msg as any).fileName ?? (msg as any).filename;
43
+ if (raw == null) return undefined;
44
+ if (typeof raw === "string") return raw.trim() || undefined;
45
+ if (typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint") return String(raw);
46
+ if (Array.isArray(raw)) {
47
+ const merged = raw.map((v) => (v == null ? "" : String(v))).join("\n").trim();
48
+ return merged || undefined;
49
+ }
50
+ if (typeof raw === "object") {
51
+ const obj = raw as Record<string, unknown>;
52
+ const text = (typeof obj["#text"] === "string" ? obj["#text"] :
53
+ typeof obj["_text"] === "string" ? obj["_text"] :
54
+ typeof obj["text"] === "string" ? obj["text"] : undefined);
55
+ if (text && text.trim()) return text.trim();
56
+ }
57
+ const s = String(raw);
58
+ return s.trim() || undefined;
59
+ }
60
+
61
+ /**
62
+ * 从 XML 中提取接收者 ID (CorpID)
63
+ */
64
+ export function extractToUser(msg: WecomAgentInboundMessage): string {
65
+ return String(msg.ToUserName ?? "");
66
+ }
67
+
68
+ /**
69
+ * 从 XML 中提取群聊 ID
70
+ */
71
+ export function extractChatId(msg: WecomAgentInboundMessage): string | undefined {
72
+ return msg.ChatId ? String(msg.ChatId) : undefined;
73
+ }
74
+
75
+ /**
76
+ * 从 XML 中提取消息内容
77
+ */
78
+ export function extractContent(msg: WecomAgentInboundMessage): string {
79
+ const msgType = extractMsgType(msg);
80
+
81
+ const asText = (value: unknown): string => {
82
+ if (value == null) return "";
83
+ if (typeof value === "string") return value;
84
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
85
+ if (Array.isArray(value)) return value.map(asText).filter(Boolean).join("\n");
86
+ if (typeof value === "object") {
87
+ const obj = value as Record<string, unknown>;
88
+ // fast-xml-parser 在某些情况下(例如带属性)会把文本放在 "#text"
89
+ if (typeof obj["#text"] === "string") return obj["#text"];
90
+ if (typeof obj["_text"] === "string") return obj["_text"];
91
+ if (typeof obj["text"] === "string") return obj["text"];
92
+ try {
93
+ return JSON.stringify(obj);
94
+ } catch {
95
+ return String(value);
96
+ }
97
+ }
98
+ return String(value);
99
+ };
100
+
101
+ switch (msgType) {
102
+ case "text":
103
+ return asText(msg.Content);
104
+ case "voice":
105
+ // 语音识别结果
106
+ return asText(msg.Recognition) || "[语音消息]";
107
+ case "image":
108
+ return `[图片] ${asText(msg.PicUrl)}`;
109
+ case "file":
110
+ return "[文件消息]";
111
+ case "video":
112
+ return "[视频消息]";
113
+ case "location":
114
+ return `[位置] ${asText(msg.Label)} (${asText(msg.Location_X)}, ${asText(msg.Location_Y)})`;
115
+ case "link":
116
+ return `[链接] ${asText(msg.Title)}\n${asText(msg.Description)}\n${asText(msg.Url)}`;
117
+ case "event":
118
+ return `[事件] ${asText(msg.Event)} - ${asText(msg.EventKey)}`;
119
+ default:
120
+ return `[${msgType || "未知消息类型"}]`;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * 从 XML 中提取媒体 ID (Image, Voice, Video)
126
+ * 根据官方文档,MediaId 在 Agent 回调中直接位于根节点
127
+ */
128
+ export function extractMediaId(msg: WecomAgentInboundMessage): string | undefined {
129
+ const raw = (msg as any).MediaId ?? (msg as any).MediaID ?? (msg as any).mediaid ?? (msg as any).mediaId;
130
+ if (raw == null) return undefined;
131
+ if (typeof raw === "string") return raw.trim() || undefined;
132
+ if (typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint") return String(raw);
133
+ if (Array.isArray(raw)) {
134
+ const merged = raw.map((v) => (v == null ? "" : String(v))).join("\n").trim();
135
+ return merged || undefined;
136
+ }
137
+ if (typeof raw === "object") {
138
+ const obj = raw as Record<string, unknown>;
139
+ const text = (typeof obj["#text"] === "string" ? obj["#text"] :
140
+ typeof obj["_text"] === "string" ? obj["_text"] :
141
+ typeof obj["text"] === "string" ? obj["text"] : undefined);
142
+ if (text && text.trim()) return text.trim();
143
+ try {
144
+ const s = JSON.stringify(obj);
145
+ return s.trim() || undefined;
146
+ } catch {
147
+ const s = String(raw);
148
+ return s.trim() || undefined;
149
+ }
150
+ }
151
+ const s = String(raw);
152
+ return s.trim() || undefined;
153
+ }
154
+
155
+ /**
156
+ * 从 XML 中提取 MsgId(用于去重)
157
+ */
158
+ export function extractMsgId(msg: WecomAgentInboundMessage): string | undefined {
159
+ const raw = (msg as any).MsgId ?? (msg as any).MsgID ?? (msg as any).msgid ?? (msg as any).msgId;
160
+ if (raw == null) return undefined;
161
+ if (typeof raw === "string") return raw.trim() || undefined;
162
+ if (typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint") return String(raw);
163
+ if (Array.isArray(raw)) {
164
+ const merged = raw.map((v) => (v == null ? "" : String(v))).join("\n").trim();
165
+ return merged || undefined;
166
+ }
167
+ if (typeof raw === "object") {
168
+ const obj = raw as Record<string, unknown>;
169
+ const text = (typeof obj["#text"] === "string" ? obj["#text"] :
170
+ typeof obj["_text"] === "string" ? obj["_text"] :
171
+ typeof obj["text"] === "string" ? obj["text"] : undefined);
172
+ if (text && text.trim()) return text.trim();
173
+ try {
174
+ const s = JSON.stringify(obj);
175
+ return s.trim() || undefined;
176
+ } catch {
177
+ const s = String(raw);
178
+ return s.trim() || undefined;
179
+ }
180
+ }
181
+ const s = String(raw);
182
+ return s.trim() || undefined;
183
+ }
package/src/target.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * WeCom Target Resolver (企业微信目标解析器)
3
+ *
4
+ * 解析 OpenClaw 的 `to` 字段(原始目标字符串),将其转换为企业微信支持的具体接收对象。
5
+ * 支持显式前缀 (party:, tag: 等) 和基于规则的启发式推断。
6
+ *
7
+ * **关于“目标发送”与“消息记录”的对应关系 (Target vs Inbound):**
8
+ * - **发送 (Outbound)**: 支持一对多广播 (Party/Tag)。
9
+ * 例如发送给 `party:1`,消息会触达该部门下所有成员。
10
+ * - **接收 (Inbound)**: 总是来自具体的 **用户 (User)** 或 **群聊 (Chat)**。
11
+ * 当成员回复部门广播消息时,可以视为一个新的单聊会话或在该成员的现有单聊中回复。
12
+ * 因此,Outbound Target (如 Party) 与 Inbound Source (User) 不需要也不可能 1:1 强匹配。
13
+ * 广播是“发后即忘” (Fire-and-Forget) 的通知模式,而回复是具体的会话模式。
14
+ */
15
+
16
+ export interface WecomTarget {
17
+ touser?: string;
18
+ toparty?: string;
19
+ totag?: string;
20
+ chatid?: string;
21
+ }
22
+
23
+ /**
24
+ * Parses a raw target string into a WeComTarget object.
25
+ * 解析原始目标字符串为 WeComTarget 对象。
26
+ *
27
+ * 逻辑:
28
+ * 1. 移除标准命名空间前缀 (wecom:, qywx: 等)。
29
+ * 2. 检查显式类型前缀 (party:, tag:, group:, user:)。
30
+ * 3. 启发式回退 (无前缀时):
31
+ * - 以 "wr" 或 "wc" 开头 -> Chat ID (群聊)
32
+ * - 纯数字 -> Party ID (部门)
33
+ * - 其他 -> User ID (用户)
34
+ *
35
+ * @param raw - The raw target string (e.g. "party:1", "zhangsan", "wecom:wr123")
36
+ */
37
+ export function resolveWecomTarget(raw: string | undefined): WecomTarget | undefined {
38
+ if (!raw?.trim()) return undefined;
39
+
40
+ // 1. Remove standard namespace prefixes (移除标准命名空间前缀)
41
+ let clean = raw.trim().replace(/^(wecom-agent|wecom|wechatwork|wework|qywx):/i, "");
42
+
43
+ // 2. Explicit Type Prefixes (显式类型前缀)
44
+ if (/^party:/i.test(clean)) {
45
+ return { toparty: clean.replace(/^party:/i, "").trim() };
46
+ }
47
+ if (/^dept:/i.test(clean)) {
48
+ return { toparty: clean.replace(/^dept:/i, "").trim() };
49
+ }
50
+ if (/^tag:/i.test(clean)) {
51
+ return { totag: clean.replace(/^tag:/i, "").trim() };
52
+ }
53
+ if (/^group:/i.test(clean)) {
54
+ return { chatid: clean.replace(/^group:/i, "").trim() };
55
+ }
56
+ if (/^chat:/i.test(clean)) {
57
+ return { chatid: clean.replace(/^chat:/i, "").trim() };
58
+ }
59
+ if (/^user:/i.test(clean)) {
60
+ return { touser: clean.replace(/^user:/i, "").trim() };
61
+ }
62
+
63
+ // 3. Heuristics (启发式规则)
64
+
65
+ // Chat ID typically starts with 'wr' or 'wc'
66
+ // 群聊 ID 通常以 'wr' (外部群) 或 'wc' 开头
67
+ if (/^(wr|wc)/i.test(clean)) {
68
+ return { chatid: clean };
69
+ }
70
+
71
+ // Pure digits are likely Department IDs (Parties)
72
+ // 纯数字优先被视为部门 ID (Parties),方便运维配置 (如 "1" 代表根部门)
73
+ // 如果必须要发送给纯数字 ID 的用户,请使用显式前缀 "user:1001"
74
+ if (/^\d+$/.test(clean)) {
75
+ return { toparty: clean };
76
+ }
77
+
78
+ // Default to User (默认为用户)
79
+ return { touser: clean };
80
+ }