@yanhaidao/wecom 2.2.3 → 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.
- package/README.md +171 -45
- package/assets/03.agent.page.png +0 -0
- package/assets/03.bot.page.png +0 -0
- package/assets/link-me.jpg +0 -0
- package/index.ts +8 -0
- package/package.json +6 -2
- package/scripts/test-proxy.ts +70 -0
- package/src/agent/api-client.ts +140 -29
- package/src/agent/handler.ts +280 -21
- package/src/channel.ts +18 -1
- package/src/config/accounts.ts +5 -3
- package/src/config/index.ts +2 -0
- package/src/config/media.ts +14 -0
- package/src/config/network.ts +16 -0
- package/src/config/schema.ts +50 -5
- package/src/config-schema.ts +2 -0
- package/src/crypto.ts +43 -0
- package/src/http.ts +102 -0
- package/src/media.test.ts +15 -9
- package/src/media.ts +28 -12
- package/src/monitor/state.queue.test.ts +185 -0
- package/src/monitor/state.ts +514 -0
- package/src/monitor/types.ts +136 -0
- package/src/monitor.active.test.ts +90 -7
- package/src/monitor.integration.test.ts +15 -5
- package/src/monitor.ts +1038 -200
- package/src/monitor.webhook.test.ts +83 -1
- package/src/onboarding.ts +3 -3
- package/src/outbound.test.ts +82 -17
- package/src/outbound.ts +97 -42
- package/src/shared/command-auth.ts +101 -0
- package/src/shared/xml-parser.test.ts +30 -0
- package/src/shared/xml-parser.ts +112 -6
- package/src/target.ts +80 -0
- package/src/types/account.ts +5 -1
- package/src/types/config.ts +7 -0
- package/src/types/global.d.ts +9 -0
- package/src/types/message.ts +43 -0
package/src/shared/xml-parser.ts
CHANGED
|
@@ -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,23 +78,106 @@ 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
|
|
114
|
+
return `[位置] ${asText(msg.Label)} (${asText(msg.Location_X)}, ${asText(msg.Location_Y)})`;
|
|
70
115
|
case "link":
|
|
71
|
-
return `[链接] ${msg.Title
|
|
116
|
+
return `[链接] ${asText(msg.Title)}\n${asText(msg.Description)}\n${asText(msg.Url)}`;
|
|
72
117
|
case "event":
|
|
73
|
-
return `[事件] ${msg.Event
|
|
118
|
+
return `[事件] ${asText(msg.Event)} - ${asText(msg.EventKey)}`;
|
|
74
119
|
default:
|
|
75
120
|
return `[${msgType || "未知消息类型"}]`;
|
|
76
121
|
}
|
|
77
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
|
+
}
|
package/src/types/account.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* WeCom 账号类型定义
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { WecomBotConfig, WecomAgentConfig, WecomDmConfig } from "./config.js";
|
|
5
|
+
import type { WecomBotConfig, WecomAgentConfig, WecomDmConfig, WecomNetworkConfig } from "./config.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* 解析后的 Bot 账号
|
|
@@ -22,6 +22,8 @@ export type ResolvedBotAccount = {
|
|
|
22
22
|
receiveId: string;
|
|
23
23
|
/** 原始配置 */
|
|
24
24
|
config: WecomBotConfig;
|
|
25
|
+
/** 网络配置(来自 channels.wecom.network) */
|
|
26
|
+
network?: WecomNetworkConfig;
|
|
25
27
|
};
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -46,6 +48,8 @@ export type ResolvedAgentAccount = {
|
|
|
46
48
|
encodingAESKey: string;
|
|
47
49
|
/** 原始配置 */
|
|
48
50
|
config: WecomAgentConfig;
|
|
51
|
+
/** 网络配置(来自 channels.wecom.network) */
|
|
52
|
+
network?: WecomNetworkConfig;
|
|
49
53
|
};
|
|
50
54
|
|
|
51
55
|
/**
|
package/src/types/config.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
/** DM 策略配置 - 与其他渠道保持一致,仅用 allowFrom */
|
|
6
6
|
export type WecomDmConfig = {
|
|
7
|
+
/** DM 策略: 'open' 允许所有人, 'pairing' 需要配对, 'allowlist' 仅允许列表, 'disabled' 禁用 */
|
|
8
|
+
policy?: 'open' | 'pairing' | 'allowlist' | 'disabled';
|
|
7
9
|
/** 允许的用户列表,为空表示允许所有人 */
|
|
8
10
|
allowFrom?: Array<string | number>;
|
|
9
11
|
};
|
|
@@ -21,6 +23,11 @@ export type WecomNetworkConfig = {
|
|
|
21
23
|
timeoutMs?: number;
|
|
22
24
|
retries?: number;
|
|
23
25
|
retryDelayMs?: number;
|
|
26
|
+
/**
|
|
27
|
+
* 出口代理(用于企业可信 IP 固定出口场景)。
|
|
28
|
+
* 示例: "http://proxy.company.local:3128"
|
|
29
|
+
*/
|
|
30
|
+
egressProxyUrl?: string;
|
|
24
31
|
};
|
|
25
32
|
|
|
26
33
|
/**
|
package/src/types/message.ts
CHANGED
|
@@ -6,6 +6,17 @@
|
|
|
6
6
|
/**
|
|
7
7
|
* Bot 模式入站消息基础结构 (JSON)
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* **WecomBotInboundBase (Bot 入站消息基类)**
|
|
11
|
+
*
|
|
12
|
+
* Bot 模式下 JSON 格式回调的基础字段。
|
|
13
|
+
* @property msgid 消息 ID
|
|
14
|
+
* @property aibotid 机器人 ID
|
|
15
|
+
* @property chattype 会话类型: "single" | "group"
|
|
16
|
+
* @property chatid 群聊 ID (仅群组时存在)
|
|
17
|
+
* @property response_url 下行回复 URL (用于被动响应转主动推送)
|
|
18
|
+
* @property from 发送者信息
|
|
19
|
+
*/
|
|
9
20
|
export type WecomBotInboundBase = {
|
|
10
21
|
msgid?: string;
|
|
11
22
|
aibotid?: string;
|
|
@@ -14,6 +25,8 @@ export type WecomBotInboundBase = {
|
|
|
14
25
|
response_url?: string;
|
|
15
26
|
from?: { userid?: string; corpid?: string };
|
|
16
27
|
msgtype?: string;
|
|
28
|
+
/** 附件数量 (部分消息存在) */
|
|
29
|
+
attachment_count?: number;
|
|
17
30
|
};
|
|
18
31
|
|
|
19
32
|
export type WecomBotInboundText = WecomBotInboundBase & {
|
|
@@ -42,10 +55,19 @@ export type WecomBotInboundEvent = WecomBotInboundBase & {
|
|
|
42
55
|
};
|
|
43
56
|
};
|
|
44
57
|
|
|
58
|
+
/**
|
|
59
|
+
* **WecomInboundQuote (引用消息)**
|
|
60
|
+
*
|
|
61
|
+
* 消息中引用的原始内容(如回复某条消息)。
|
|
62
|
+
* 支持引用文本、图片、混合类型、语音、文件等。
|
|
63
|
+
*/
|
|
45
64
|
export type WecomInboundQuote = {
|
|
46
65
|
msgtype?: "text" | "image" | "mixed" | "voice" | "file";
|
|
66
|
+
/** 引用文本内容 */
|
|
47
67
|
text?: { content?: string };
|
|
68
|
+
/** 引用图片 URL */
|
|
48
69
|
image?: { url?: string };
|
|
70
|
+
/** 引用混合消息 (图文) */
|
|
49
71
|
mixed?: {
|
|
50
72
|
msg_item?: Array<{
|
|
51
73
|
msgtype: "text" | "image";
|
|
@@ -53,7 +75,9 @@ export type WecomInboundQuote = {
|
|
|
53
75
|
image?: { url?: string };
|
|
54
76
|
}>;
|
|
55
77
|
};
|
|
78
|
+
/** 引用语音 */
|
|
56
79
|
voice?: { content?: string };
|
|
80
|
+
/** 引用文件 */
|
|
57
81
|
file?: { url?: string };
|
|
58
82
|
};
|
|
59
83
|
|
|
@@ -67,6 +91,12 @@ export type WecomBotInboundMessage =
|
|
|
67
91
|
/**
|
|
68
92
|
* Agent 模式入站消息结构 (解析自 XML)
|
|
69
93
|
*/
|
|
94
|
+
/**
|
|
95
|
+
* **WecomAgentInboundMessage (Agent 入站消息)**
|
|
96
|
+
*
|
|
97
|
+
* Agent 模式下解析自 XML 的扁平化消息结构。
|
|
98
|
+
* 键名保持 PascalCase (如 `ToUserName`)。
|
|
99
|
+
*/
|
|
70
100
|
export type WecomAgentInboundMessage = {
|
|
71
101
|
ToUserName?: string;
|
|
72
102
|
FromUserName?: string;
|
|
@@ -79,6 +109,8 @@ export type WecomAgentInboundMessage = {
|
|
|
79
109
|
// 图片消息
|
|
80
110
|
PicUrl?: string;
|
|
81
111
|
MediaId?: string;
|
|
112
|
+
// 文件消息
|
|
113
|
+
FileName?: string;
|
|
82
114
|
// 语音消息
|
|
83
115
|
Format?: string;
|
|
84
116
|
Recognition?: string;
|
|
@@ -103,6 +135,17 @@ export type WecomAgentInboundMessage = {
|
|
|
103
135
|
/**
|
|
104
136
|
* 模板卡片类型
|
|
105
137
|
*/
|
|
138
|
+
/**
|
|
139
|
+
* **WecomTemplateCard (模板卡片)**
|
|
140
|
+
*
|
|
141
|
+
* 复杂的交互式卡片结构。
|
|
142
|
+
* @property card_type 卡片类型: "text_notice" | "news_notice" | "button_interaction" ...
|
|
143
|
+
* @property source 来源信息
|
|
144
|
+
* @property main_title 主标题
|
|
145
|
+
* @property sub_title_text 副标题
|
|
146
|
+
* @property horizontal_content_list 水平排列的键值列表
|
|
147
|
+
* @property button_list 按钮列表
|
|
148
|
+
*/
|
|
106
149
|
export type WecomTemplateCard = {
|
|
107
150
|
card_type: "text_notice" | "news_notice" | "button_interaction" | "vote_interaction" | "multiple_interaction";
|
|
108
151
|
source?: { icon_url?: string; desc?: string; desc_color?: number };
|