@yanhaidao/wecom 2.2.4 → 2.2.7

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/src/outbound.ts CHANGED
@@ -38,6 +38,35 @@ export const wecomOutbound: ChannelOutboundAdapter = {
38
38
  throw new Error("WeCom outbound requires a target (userid, partyid, tagid or chatid).");
39
39
  }
40
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
+
41
70
  const { touser, toparty, totag, chatid } = target;
42
71
  if (chatid) {
43
72
  throw new Error(
@@ -46,7 +75,7 @@ export const wecomOutbound: ChannelOutboundAdapter = {
46
75
  `请改为发送给用户(userid / user:xxx),或由 Bot 模式在群内交付。`,
47
76
  );
48
77
  }
49
- console.log(`[wecom-outbound] Sending text to target=${JSON.stringify(target)} (len=${text.length})`);
78
+ console.log(`[wecom-outbound] Sending text to target=${JSON.stringify(target)} (len=${outgoingText.length})`);
50
79
 
51
80
  try {
52
81
  await sendAgentText({
@@ -55,7 +84,7 @@ export const wecomOutbound: ChannelOutboundAdapter = {
55
84
  toParty: toparty,
56
85
  toTag: totag,
57
86
  chatId: chatid,
58
- text,
87
+ text: outgoingText,
59
88
  });
60
89
  console.log(`[wecom-outbound] Successfully sent text to ${JSON.stringify(target)}`);
61
90
  } catch (err) {
@@ -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,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
+ });
@@ -35,6 +35,29 @@ export function extractFromUser(msg: WecomAgentInboundMessage): string {
35
35
  return String(msg.FromUserName ?? "");
36
36
  }
37
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
+
38
61
  /**
39
62
  * 从 XML 中提取接收者 ID (CorpID)
40
63
  */
@@ -55,22 +78,44 @@ export function extractChatId(msg: WecomAgentInboundMessage): string | undefined
55
78
  export function extractContent(msg: WecomAgentInboundMessage): string {
56
79
  const msgType = extractMsgType(msg);
57
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
+
58
101
  switch (msgType) {
59
102
  case "text":
60
- return msg.Content ?? "";
103
+ return asText(msg.Content);
61
104
  case "voice":
62
105
  // 语音识别结果
63
- return msg.Recognition ?? "[语音消息]";
106
+ return asText(msg.Recognition) || "[语音消息]";
64
107
  case "image":
65
- return `[图片] ${msg.PicUrl ?? ""}`;
108
+ return `[图片] ${asText(msg.PicUrl)}`;
109
+ case "file":
110
+ return "[文件消息]";
66
111
  case "video":
67
112
  return "[视频消息]";
68
113
  case "location":
69
- return `[位置] ${msg.Label ?? ""} (${msg.Location_X}, ${msg.Location_Y})`;
114
+ return `[位置] ${asText(msg.Label)} (${asText(msg.Location_X)}, ${asText(msg.Location_Y)})`;
70
115
  case "link":
71
- return `[链接] ${msg.Title ?? ""}\n${msg.Description ?? ""}\n${msg.Url ?? ""}`;
116
+ return `[链接] ${asText(msg.Title)}\n${asText(msg.Description)}\n${asText(msg.Url)}`;
72
117
  case "event":
73
- return `[事件] ${msg.Event ?? ""} - ${msg.EventKey ?? ""}`;
118
+ return `[事件] ${asText(msg.Event)} - ${asText(msg.EventKey)}`;
74
119
  default:
75
120
  return `[${msgType || "未知消息类型"}]`;
76
121
  }
@@ -81,5 +126,58 @@ export function extractContent(msg: WecomAgentInboundMessage): string {
81
126
  * 根据官方文档,MediaId 在 Agent 回调中直接位于根节点
82
127
  */
83
128
  export function extractMediaId(msg: WecomAgentInboundMessage): string | undefined {
84
- return msg.MediaId ? String(msg.MediaId) : 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;
85
183
  }
package/src/target.ts CHANGED
@@ -38,7 +38,7 @@ export function resolveWecomTarget(raw: string | undefined): WecomTarget | undef
38
38
  if (!raw?.trim()) return undefined;
39
39
 
40
40
  // 1. Remove standard namespace prefixes (移除标准命名空间前缀)
41
- let clean = raw.trim().replace(/^(wecom|wechatwork|wework|qywx):/i, "");
41
+ let clean = raw.trim().replace(/^(wecom-agent|wecom|wechatwork|wework|qywx):/i, "");
42
42
 
43
43
  // 2. Explicit Type Prefixes (显式类型前缀)
44
44
  if (/^party:/i.test(clean)) {
@@ -70,6 +70,18 @@ export type WecomAgentConfig = {
70
70
  dm?: WecomDmConfig;
71
71
  };
72
72
 
73
+ /** 动态 Agent 配置 */
74
+ export type WecomDynamicAgentsConfig = {
75
+ /** 是否启用动态 Agent */
76
+ enabled?: boolean;
77
+ /** 私聊:是否为每个用户创建独立 Agent */
78
+ dmCreateAgent?: boolean;
79
+ /** 群聊:是否启用动态 Agent */
80
+ groupEnabled?: boolean;
81
+ /** 管理员列表(绕过动态路由,使用主 Agent) */
82
+ adminUsers?: string[];
83
+ };
84
+
73
85
  /**
74
86
  * 顶层 WeCom 配置
75
87
  * 通过 bot / agent 字段隐式指定模式
@@ -85,4 +97,6 @@ export type WecomConfig = {
85
97
  media?: WecomMediaConfig;
86
98
  /** 网络配置 */
87
99
  network?: WecomNetworkConfig;
100
+ /** 动态 Agent 配置 */
101
+ dynamicAgents?: WecomDynamicAgentsConfig;
88
102
  };
@@ -109,6 +109,8 @@ export type WecomAgentInboundMessage = {
109
109
  // 图片消息
110
110
  PicUrl?: string;
111
111
  MediaId?: string;
112
+ // 文件消息
113
+ FileName?: string;
112
114
  // 语音消息
113
115
  Format?: string;
114
116
  Recognition?: string;