@yanhaidao/wecom 2.2.4 → 2.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.
@@ -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)) {
@@ -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;
package/GEMINI.md DELETED
@@ -1,76 +0,0 @@
1
- # 企业微信 (WeCom) 插件上下文
2
-
3
- ## 平台架构:机器人 (Bot) vs 自建应用 (Agent)
4
-
5
- 本插件采用 **双模 (Dual-Mode)** 架构,结合了企业微信“智能机器人”和“自建应用”的优势。
6
-
7
- ### 1. 定义与边界
8
-
9
- | 特性 | **Bot (智能机器人)** | **Agent (自建应用)** |
10
- | :--- | :--- | :--- |
11
- | **身份** | 虚拟用户/助手。 | 工作台中的服务应用。 |
12
- | **位置** | 存在于 **会话** 中 (单聊或群聊)。 | 存在于 **工作台** 或应用列表。 |
13
- | **协议** | **JSON** (加密)。 | **XML** (加密)。 |
14
- | **交互** | 对话式 (回复用户)。 | 事务式 (通知用户/系统)。 |
15
- | **流式 (Stream)** | ✅ **支持** (打字机效果)。 | ❌ 不支持。 |
16
- | **文件能力** | ❌ **受限** (被动回复无法发文件)。 | ✅ **完整支持** (API 发送视频、文件、图片)。 |
17
- | **群聊支持** | ✅ 原生支持 (可被 @)。 | ⚠️ 受限 (仅能在自建群或通过 API 推送)。 |
18
-
19
- ### 2. 通信协议
20
-
21
- #### A. Bot (智能机器人)
22
- * **接收 (回调)**:
23
- * **格式**: JSON。
24
- * **单聊**: `chattype: "single"`. 无 `chatid`。`cid` = `from.userid`。
25
- * **群聊**: `chattype: "group"`. 有 `chatid`。`cid` = `chatid`。
26
- * **发送 (回复)**:
27
- * **交互回复**: 使用回调中的 `response_url`。支持 `markdown`, `template_card`。**不支持文件/视频**。
28
- * **主动推送**: 使用 Webhook Key。
29
-
30
- #### B. Agent (自建应用)
31
- * **接收 (回调)**:
32
- * **格式**: XML。
33
- * **结构**: `<ToUserName>`, `<FromUserName>`, `<MsgType>`, `<Content>`。
34
- * **发送 (推送)**:
35
- * **API**: `message/send`。
36
- * **能力**: 支持所有媒体类型 (文件, 视频, 语音, 文本, 卡片)。
37
- * **对象**: 用户 (`touser`), 部门 (`toparty`), 标签 (`totag`)。
38
-
39
- ---
40
-
41
- ## 核心策略:机器人优先,智能体兜底 (接近 6 分钟切换)
42
-
43
- ### 目标
44
- * **默认**: 用户在 Bot 会话触发,结果在 Bot 会话内完成回复(含流式)。
45
- * **兜底**: 当 Bot 因超时(接近 6 分钟限制)、异常或流式窗口结束无法完成交付时,若配置了 Agent,则由 Agent 私信触发用户发送最终结果。
46
-
47
- ### 关键约束
48
- * **6 分钟窗口**: 企业微信智能机器人流式链路最多维持 6 分钟。超过后连接断开。
49
-
50
- ### 流程详解
51
-
52
- #### 1. 启动阶段 (Bot)
53
- 1. **接收消息**: 解析 JSON 回调。
54
- 2. **生成 StreamID**: 确定性生成 `streamId = hash(accountId + aibotid + msgid)`,防止并发重试导致重复创建流。
55
- 3. **快速响应**: 立即返回“已接收,处理中”或流式首包,建立连接。
56
-
57
- #### 2. 执行阶段 (Worker)
58
- 4. **异步处理**: 任务入队执行。
59
- 5. **流式刷新**: 当收到企微的“流式消息刷新”回调时,根据 `streamId` 返回最新生成的内容。
60
-
61
- #### 3. 正常交付 (Bot)
62
- 6. **完成**: 任务在 6 分钟内完成,发送 `finish=true` 信号结束流式消息。
63
-
64
- #### 4. 异常切换 (Agent 兜底)
65
- 7. **触发条件**:
66
- * **超时临界**: `now >= createdAt + 6min - 安全阈值(30s)`。
67
- * **链路中断**: Bot 刷新回调长时间未到达 (如 > 60s)。
68
- * **发送异常**: Bot 接口报错。
69
- 8. **切换动作**:
70
- * **状态标记**: 将任务标记为 `agent_fallback`。
71
- * **用户提示**: (可选) Bot 在群内尝试发送“结果将私信送达”。
72
- * **私信推送**: Agent 调用 `message/send` 接口,将最终结果(文本/文件)推送给触发者 `userId`。
73
- * **注意**: 即使原会话是群聊,兜底结果通常通过 Agent **私信** 发送给触发者,因为 Agent 难以直接向普通群推送消息。
74
-
75
- ### 总结
76
- 该策略确保了用户体验(首选流式)和交付可靠性(超时/发文件走 Agent)的平衡。