@wecom/wecom-openclaw-plugin 2026.3.30 → 2026.3.31-beta.1
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 +251 -4
- package/dist/index.d.ts +2 -4
- package/dist/index.js +113 -0
- package/dist/src/accounts.d.ts +57 -0
- package/dist/src/accounts.js +222 -0
- package/dist/src/agent/api-client.d.ts +94 -0
- package/dist/src/agent/api-client.js +292 -0
- package/dist/src/agent/handler.d.ts +63 -0
- package/dist/src/agent/handler.js +562 -0
- package/dist/src/agent/index.d.ts +5 -0
- package/dist/src/agent/index.js +5 -0
- package/dist/src/agent/webhook.d.ts +23 -0
- package/dist/src/agent/webhook.js +226 -0
- package/dist/src/agent/xml.d.ts +21 -0
- package/dist/src/agent/xml.js +41 -0
- package/dist/src/channel.js +564 -0
- package/dist/src/chat-queue.d.ts +31 -0
- package/dist/src/chat-queue.js +45 -0
- package/dist/src/const.d.ts +46 -0
- package/dist/src/const.js +157 -0
- package/dist/src/dm-policy.js +84 -0
- package/dist/src/dynamic-agent.d.ts +37 -0
- package/dist/src/dynamic-agent.js +60 -0
- package/dist/src/dynamic-routing.d.ts +75 -0
- package/dist/src/dynamic-routing.js +124 -0
- package/dist/src/group-policy.js +126 -0
- package/dist/src/http.d.ts +26 -0
- package/dist/src/http.js +93 -0
- package/dist/src/interface.d.ts +0 -2
- package/dist/src/interface.js +4 -0
- package/dist/src/mcp/index.js +6 -0
- package/dist/src/mcp/interceptors/biz-error.js +64 -0
- package/dist/src/mcp/interceptors/index.js +66 -0
- package/dist/src/mcp/interceptors/msg-media.js +133 -0
- package/dist/src/mcp/interceptors/smartpage-create.js +132 -0
- package/dist/src/mcp/interceptors/smartpage-export.js +87 -0
- package/dist/src/mcp/interceptors/types.js +7 -0
- package/dist/src/mcp/schema.js +99 -0
- package/dist/src/mcp/tool.js +202 -0
- package/dist/src/mcp/transport.js +410 -0
- package/dist/src/media-handler.js +122 -0
- package/dist/src/media-uploader.js +333 -0
- package/dist/src/message-parser.js +129 -0
- package/dist/src/message-sender.d.ts +18 -0
- package/dist/src/message-sender.js +120 -0
- package/dist/src/monitor.js +875 -0
- package/dist/src/onboarding.js +175 -0
- package/dist/src/openclaw-compat.d.ts +47 -0
- package/dist/src/openclaw-compat.js +477 -0
- package/dist/src/reqid-store.js +83 -0
- package/dist/src/routing.d.ts +24 -0
- package/dist/src/routing.js +34 -0
- package/dist/src/runtime.js +10 -0
- package/dist/src/shared/command-auth.d.ts +23 -0
- package/dist/src/shared/command-auth.js +73 -0
- package/dist/src/shared/xml-parser.d.ts +46 -0
- package/dist/src/shared/xml-parser.js +210 -0
- package/dist/src/state-dir-resolve.d.ts +2 -0
- package/dist/src/state-dir-resolve.js +9 -0
- package/dist/src/state-manager.js +216 -0
- package/dist/src/target.d.ts +35 -0
- package/dist/src/target.js +67 -0
- package/dist/src/template-card-manager.d.ts +55 -0
- package/dist/src/template-card-manager.js +205 -0
- package/dist/src/template-card-parser.js +647 -0
- package/dist/src/timeout.js +36 -0
- package/dist/src/types/account.d.ts +29 -0
- package/dist/src/types/account.js +4 -0
- package/dist/src/types/config.d.ts +100 -0
- package/dist/src/types/config.js +7 -0
- package/dist/src/types/constants.d.ts +42 -0
- package/dist/src/types/constants.js +42 -0
- package/dist/src/types/index.d.ts +7 -0
- package/dist/src/types/index.js +5 -0
- package/dist/src/types/message.d.ts +238 -0
- package/dist/src/types/message.js +5 -0
- package/dist/src/utils.d.ts +39 -0
- package/dist/src/utils.js +70 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +24 -0
- package/dist/src/webhook/command-auth.d.ts +47 -0
- package/dist/src/webhook/command-auth.js +96 -0
- package/dist/src/webhook/gateway.d.ts +36 -0
- package/dist/src/webhook/gateway.js +227 -0
- package/dist/src/webhook/handler.d.ts +18 -0
- package/dist/src/webhook/handler.js +420 -0
- package/dist/src/webhook/helpers.d.ts +156 -0
- package/dist/src/webhook/helpers.js +833 -0
- package/dist/src/webhook/http.d.ts +26 -0
- package/dist/src/webhook/http.js +94 -0
- package/dist/src/webhook/index.d.ts +11 -0
- package/dist/src/webhook/index.js +14 -0
- package/dist/src/webhook/media.d.ts +29 -0
- package/dist/src/webhook/media.js +88 -0
- package/dist/src/webhook/monitor.d.ts +59 -0
- package/dist/src/webhook/monitor.js +1185 -0
- package/dist/src/webhook/state.d.ts +220 -0
- package/dist/src/webhook/state.js +491 -0
- package/dist/src/webhook/target.d.ts +41 -0
- package/dist/src/webhook/target.js +132 -0
- package/dist/src/webhook/types.d.ts +347 -0
- package/dist/src/webhook/types.js +33 -0
- package/package.json +15 -7
- package/dist/index.cjs.js +0 -4953
- package/dist/index.cjs.js.map +0 -1
- package/dist/index.esm.js +0 -4926
- package/dist/index.esm.js.map +0 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
function normalizeWecomAllowFromEntry(raw) {
|
|
2
|
+
return raw
|
|
3
|
+
.trim()
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.replace(/^wecom:/, "")
|
|
6
|
+
.replace(/^user:/, "")
|
|
7
|
+
.replace(/^userid:/, "");
|
|
8
|
+
}
|
|
9
|
+
function isWecomSenderAllowed(senderUserId, allowFrom) {
|
|
10
|
+
const list = allowFrom.map((entry) => normalizeWecomAllowFromEntry(entry)).filter(Boolean);
|
|
11
|
+
if (list.includes("*"))
|
|
12
|
+
return true;
|
|
13
|
+
const normalizedSender = normalizeWecomAllowFromEntry(senderUserId);
|
|
14
|
+
if (!normalizedSender)
|
|
15
|
+
return false;
|
|
16
|
+
return list.includes(normalizedSender);
|
|
17
|
+
}
|
|
18
|
+
export async function resolveWecomCommandAuthorization(params) {
|
|
19
|
+
const { core, cfg, accountConfig, rawBody, senderUserId } = params;
|
|
20
|
+
const dmPolicy = (accountConfig.dmPolicy ?? "pairing");
|
|
21
|
+
const configAllowFrom = (accountConfig.allowFrom ?? []).map((v) => String(v));
|
|
22
|
+
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, cfg);
|
|
23
|
+
// WeCom channel currently does NOT support the `openclaw pairing` CLI workflow
|
|
24
|
+
// ("Channel wecom does not support pairing"). So we must not rely on pairing
|
|
25
|
+
// store approvals for command authorization here.
|
|
26
|
+
//
|
|
27
|
+
// Policy semantics:
|
|
28
|
+
// - open: commands are allowed for everyone by default (unless higher-level access-groups deny).
|
|
29
|
+
// - allowlist: commands require allowFrom entries.
|
|
30
|
+
// - pairing: treated the same as allowlist for WeCom (since pairing CLI is unsupported).
|
|
31
|
+
const effectiveAllowFrom = dmPolicy === "open" ? ["*"] : configAllowFrom;
|
|
32
|
+
const senderAllowed = isWecomSenderAllowed(senderUserId, effectiveAllowFrom);
|
|
33
|
+
const allowAllConfigured = effectiveAllowFrom.some((entry) => normalizeWecomAllowFromEntry(entry) === "*");
|
|
34
|
+
const authorizerConfigured = allowAllConfigured || effectiveAllowFrom.length > 0;
|
|
35
|
+
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
|
36
|
+
const commandAuthorized = shouldComputeAuth
|
|
37
|
+
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
38
|
+
useAccessGroups,
|
|
39
|
+
authorizers: [{ configured: authorizerConfigured, allowed: senderAllowed }],
|
|
40
|
+
})
|
|
41
|
+
: undefined;
|
|
42
|
+
return {
|
|
43
|
+
shouldComputeAuth,
|
|
44
|
+
dmPolicy,
|
|
45
|
+
senderAllowed,
|
|
46
|
+
authorizerConfigured,
|
|
47
|
+
commandAuthorized,
|
|
48
|
+
effectiveAllowFrom,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function buildWecomUnauthorizedCommandPrompt(params) {
|
|
52
|
+
const user = params.senderUserId || "unknown";
|
|
53
|
+
const policy = params.dmPolicy;
|
|
54
|
+
const scopeLabel = params.scope === "bot" ? "Bot(智能机器人)" : "Agent(自建应用)";
|
|
55
|
+
const dmPrefix = params.scope === "bot" ? "channels.wecom.bot" : "channels.wecom.agent";
|
|
56
|
+
const allowCmd = (value) => `openclaw config set ${dmPrefix}.allowFrom '${value}'`;
|
|
57
|
+
const policyCmd = (value) => `openclaw config set ${dmPrefix}.dmPolicy "${value}"`;
|
|
58
|
+
if (policy === "disabled") {
|
|
59
|
+
return [
|
|
60
|
+
`无权限执行命令(${scopeLabel} 已禁用:dmPolicy=disabled)`,
|
|
61
|
+
`触发者:${user}`,
|
|
62
|
+
`管理员:${policyCmd("open")}(全放开)或 ${policyCmd("allowlist")}(白名单)`,
|
|
63
|
+
].join("\n");
|
|
64
|
+
}
|
|
65
|
+
// WeCom 不支持 pairing CLI,因此这里统一给出“open / allowlist”两种明确的配置指令
|
|
66
|
+
return [
|
|
67
|
+
`无权限执行命令(入口:${scopeLabel},userid:${user})`,
|
|
68
|
+
`管理员全放开:${policyCmd("open")}`,
|
|
69
|
+
`管理员放行该用户:${policyCmd("allowlist")}`,
|
|
70
|
+
`然后设置白名单:${allowCmd(JSON.stringify([user]))}`,
|
|
71
|
+
`如果仍被拦截:检查 commands.useAccessGroups/访问组`,
|
|
72
|
+
].join("\n");
|
|
73
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom XML 解析器
|
|
3
|
+
* 用于 Agent 模式解析 XML 格式消息
|
|
4
|
+
*/
|
|
5
|
+
import type { WecomAgentInboundMessage } from "../types/index.js";
|
|
6
|
+
/**
|
|
7
|
+
* 解析 XML 字符串为消息对象
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseXml(xml: string): WecomAgentInboundMessage;
|
|
10
|
+
/**
|
|
11
|
+
* 从 XML 中提取消息类型
|
|
12
|
+
*/
|
|
13
|
+
export declare function extractMsgType(msg: WecomAgentInboundMessage): string;
|
|
14
|
+
/**
|
|
15
|
+
* 从 XML 中提取发送者 ID
|
|
16
|
+
*/
|
|
17
|
+
export declare function extractFromUser(msg: WecomAgentInboundMessage): string;
|
|
18
|
+
/**
|
|
19
|
+
* 从 XML 中提取文件名(主要用于 file 消息)
|
|
20
|
+
*/
|
|
21
|
+
export declare function extractFileName(msg: WecomAgentInboundMessage): string | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* 从 XML 中提取接收者 ID (CorpID)
|
|
24
|
+
*/
|
|
25
|
+
export declare function extractToUser(msg: WecomAgentInboundMessage): string;
|
|
26
|
+
/**
|
|
27
|
+
* 从 XML 中提取群聊 ID
|
|
28
|
+
*/
|
|
29
|
+
export declare function extractChatId(msg: WecomAgentInboundMessage): string | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* 从 XML 中提取 AgentID(兼容 AgentID/agentid 等大小写)
|
|
32
|
+
*/
|
|
33
|
+
export declare function extractAgentId(msg: WecomAgentInboundMessage): string | number | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* 从 XML 中提取消息内容
|
|
36
|
+
*/
|
|
37
|
+
export declare function extractContent(msg: WecomAgentInboundMessage): string;
|
|
38
|
+
/**
|
|
39
|
+
* 从 XML 中提取媒体 ID (Image, Voice, Video)
|
|
40
|
+
* 根据官方文档,MediaId 在 Agent 回调中直接位于根节点
|
|
41
|
+
*/
|
|
42
|
+
export declare function extractMediaId(msg: WecomAgentInboundMessage): string | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* 从 XML 中提取 MsgId(用于去重)
|
|
45
|
+
*/
|
|
46
|
+
export declare function extractMsgId(msg: WecomAgentInboundMessage): string | undefined;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom XML 解析器
|
|
3
|
+
* 用于 Agent 模式解析 XML 格式消息
|
|
4
|
+
*/
|
|
5
|
+
import { XMLParser } from "fast-xml-parser";
|
|
6
|
+
const xmlParser = new XMLParser({
|
|
7
|
+
ignoreAttributes: false,
|
|
8
|
+
trimValues: true,
|
|
9
|
+
processEntities: false,
|
|
10
|
+
parseTagValue: false,
|
|
11
|
+
parseAttributeValue: false,
|
|
12
|
+
});
|
|
13
|
+
/**
|
|
14
|
+
* 解析 XML 字符串为消息对象
|
|
15
|
+
*/
|
|
16
|
+
export function parseXml(xml) {
|
|
17
|
+
const obj = xmlParser.parse(xml);
|
|
18
|
+
const root = obj?.xml ?? obj;
|
|
19
|
+
return root ?? {};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 从 XML 中提取消息类型
|
|
23
|
+
*/
|
|
24
|
+
export function extractMsgType(msg) {
|
|
25
|
+
return String(msg.MsgType ?? "").toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 从 XML 中提取发送者 ID
|
|
29
|
+
*/
|
|
30
|
+
export function extractFromUser(msg) {
|
|
31
|
+
return String(msg.FromUserName ?? "");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 从 XML 中提取文件名(主要用于 file 消息)
|
|
35
|
+
*/
|
|
36
|
+
export function extractFileName(msg) {
|
|
37
|
+
const raw = msg.FileName ?? msg.Filename ?? msg.fileName ?? msg.filename;
|
|
38
|
+
if (raw == null)
|
|
39
|
+
return undefined;
|
|
40
|
+
if (typeof raw === "string")
|
|
41
|
+
return raw.trim() || undefined;
|
|
42
|
+
if (typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint")
|
|
43
|
+
return String(raw);
|
|
44
|
+
if (Array.isArray(raw)) {
|
|
45
|
+
const merged = raw.map((v) => (v == null ? "" : String(v))).join("\n").trim();
|
|
46
|
+
return merged || undefined;
|
|
47
|
+
}
|
|
48
|
+
if (typeof raw === "object") {
|
|
49
|
+
const obj = raw;
|
|
50
|
+
const text = (typeof obj["#text"] === "string" ? obj["#text"] :
|
|
51
|
+
typeof obj["_text"] === "string" ? obj["_text"] :
|
|
52
|
+
typeof obj["text"] === "string" ? obj["text"] : undefined);
|
|
53
|
+
if (text && text.trim())
|
|
54
|
+
return text.trim();
|
|
55
|
+
}
|
|
56
|
+
const s = String(raw);
|
|
57
|
+
return s.trim() || undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 从 XML 中提取接收者 ID (CorpID)
|
|
61
|
+
*/
|
|
62
|
+
export function extractToUser(msg) {
|
|
63
|
+
return String(msg.ToUserName ?? "");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 从 XML 中提取群聊 ID
|
|
67
|
+
*/
|
|
68
|
+
export function extractChatId(msg) {
|
|
69
|
+
return msg.ChatId ? String(msg.ChatId) : undefined;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 从 XML 中提取 AgentID(兼容 AgentID/agentid 等大小写)
|
|
73
|
+
*/
|
|
74
|
+
export function extractAgentId(msg) {
|
|
75
|
+
const raw = msg.AgentID ??
|
|
76
|
+
msg.AgentId ??
|
|
77
|
+
msg.agentid ??
|
|
78
|
+
msg.agentId;
|
|
79
|
+
if (raw == null)
|
|
80
|
+
return undefined;
|
|
81
|
+
if (typeof raw === "string")
|
|
82
|
+
return raw.trim() || undefined;
|
|
83
|
+
if (typeof raw === "number")
|
|
84
|
+
return raw;
|
|
85
|
+
const asString = String(raw).trim();
|
|
86
|
+
return asString || undefined;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 从 XML 中提取消息内容
|
|
90
|
+
*/
|
|
91
|
+
export function extractContent(msg) {
|
|
92
|
+
const msgType = extractMsgType(msg);
|
|
93
|
+
const asText = (value) => {
|
|
94
|
+
if (value == null)
|
|
95
|
+
return "";
|
|
96
|
+
if (typeof value === "string")
|
|
97
|
+
return value;
|
|
98
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint")
|
|
99
|
+
return String(value);
|
|
100
|
+
if (Array.isArray(value))
|
|
101
|
+
return value.map(asText).filter(Boolean).join("\n");
|
|
102
|
+
if (typeof value === "object") {
|
|
103
|
+
const obj = value;
|
|
104
|
+
// fast-xml-parser 在某些情况下(例如带属性)会把文本放在 "#text"
|
|
105
|
+
if (typeof obj["#text"] === "string")
|
|
106
|
+
return obj["#text"];
|
|
107
|
+
if (typeof obj["_text"] === "string")
|
|
108
|
+
return obj["_text"];
|
|
109
|
+
if (typeof obj["text"] === "string")
|
|
110
|
+
return obj["text"];
|
|
111
|
+
try {
|
|
112
|
+
return JSON.stringify(obj);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return String(value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return String(value);
|
|
119
|
+
};
|
|
120
|
+
switch (msgType) {
|
|
121
|
+
case "text":
|
|
122
|
+
return asText(msg.Content);
|
|
123
|
+
case "voice":
|
|
124
|
+
// 语音识别结果
|
|
125
|
+
return asText(msg.Recognition) || "[语音消息]";
|
|
126
|
+
case "image":
|
|
127
|
+
return `[图片] ${asText(msg.PicUrl)}`;
|
|
128
|
+
case "file":
|
|
129
|
+
return "[文件消息]";
|
|
130
|
+
case "video":
|
|
131
|
+
return "[视频消息]";
|
|
132
|
+
case "location":
|
|
133
|
+
return `[位置] ${asText(msg.Label)} (${asText(msg.Location_X)}, ${asText(msg.Location_Y)})`;
|
|
134
|
+
case "link":
|
|
135
|
+
return `[链接] ${asText(msg.Title)}\n${asText(msg.Description)}\n${asText(msg.Url)}`;
|
|
136
|
+
case "event":
|
|
137
|
+
return `[事件] ${asText(msg.Event)} - ${asText(msg.EventKey)}`;
|
|
138
|
+
default:
|
|
139
|
+
return `[${msgType || "未知消息类型"}]`;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 从 XML 中提取媒体 ID (Image, Voice, Video)
|
|
144
|
+
* 根据官方文档,MediaId 在 Agent 回调中直接位于根节点
|
|
145
|
+
*/
|
|
146
|
+
export function extractMediaId(msg) {
|
|
147
|
+
const raw = msg.MediaId ?? msg.MediaID ?? msg.mediaid ?? msg.mediaId;
|
|
148
|
+
if (raw == null)
|
|
149
|
+
return undefined;
|
|
150
|
+
if (typeof raw === "string")
|
|
151
|
+
return raw.trim() || undefined;
|
|
152
|
+
if (typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint")
|
|
153
|
+
return String(raw);
|
|
154
|
+
if (Array.isArray(raw)) {
|
|
155
|
+
const merged = raw.map((v) => (v == null ? "" : String(v))).join("\n").trim();
|
|
156
|
+
return merged || undefined;
|
|
157
|
+
}
|
|
158
|
+
if (typeof raw === "object") {
|
|
159
|
+
const obj = raw;
|
|
160
|
+
const text = (typeof obj["#text"] === "string" ? obj["#text"] :
|
|
161
|
+
typeof obj["_text"] === "string" ? obj["_text"] :
|
|
162
|
+
typeof obj["text"] === "string" ? obj["text"] : undefined);
|
|
163
|
+
if (text && text.trim())
|
|
164
|
+
return text.trim();
|
|
165
|
+
try {
|
|
166
|
+
const s = JSON.stringify(obj);
|
|
167
|
+
return s.trim() || undefined;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
const s = String(raw);
|
|
171
|
+
return s.trim() || undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const s = String(raw);
|
|
175
|
+
return s.trim() || undefined;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 从 XML 中提取 MsgId(用于去重)
|
|
179
|
+
*/
|
|
180
|
+
export function extractMsgId(msg) {
|
|
181
|
+
const raw = msg.MsgId ?? msg.MsgID ?? msg.msgid ?? msg.msgId;
|
|
182
|
+
if (raw == null)
|
|
183
|
+
return undefined;
|
|
184
|
+
if (typeof raw === "string")
|
|
185
|
+
return raw.trim() || undefined;
|
|
186
|
+
if (typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint")
|
|
187
|
+
return String(raw);
|
|
188
|
+
if (Array.isArray(raw)) {
|
|
189
|
+
const merged = raw.map((v) => (v == null ? "" : String(v))).join("\n").trim();
|
|
190
|
+
return merged || undefined;
|
|
191
|
+
}
|
|
192
|
+
if (typeof raw === "object") {
|
|
193
|
+
const obj = raw;
|
|
194
|
+
const text = (typeof obj["#text"] === "string" ? obj["#text"] :
|
|
195
|
+
typeof obj["_text"] === "string" ? obj["_text"] :
|
|
196
|
+
typeof obj["text"] === "string" ? obj["text"] : undefined);
|
|
197
|
+
if (text && text.trim())
|
|
198
|
+
return text.trim();
|
|
199
|
+
try {
|
|
200
|
+
const s = JSON.stringify(obj);
|
|
201
|
+
return s.trim() || undefined;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
const s = String(raw);
|
|
205
|
+
return s.trim() || undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const s = String(raw);
|
|
209
|
+
return s.trim() || undefined;
|
|
210
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
/** 解析 openclaw 状态目录 */
|
|
4
|
+
export function resolveStateDir() {
|
|
5
|
+
const stateOverride = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
|
|
6
|
+
if (stateOverride)
|
|
7
|
+
return stateOverride;
|
|
8
|
+
return path.join(os.homedir(), ".openclaw");
|
|
9
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 企业微信全局状态管理模块
|
|
3
|
+
*
|
|
4
|
+
* 负责管理 WSClient 实例、消息状态(带 TTL 清理)、ReqId 存储
|
|
5
|
+
* 解决全局 Map 的内存泄漏问题
|
|
6
|
+
*/
|
|
7
|
+
import { createPersistentReqIdStore } from "./reqid-store.js";
|
|
8
|
+
import { MESSAGE_STATE_TTL_MS, MESSAGE_STATE_CLEANUP_INTERVAL_MS, MESSAGE_STATE_MAX_SIZE, } from "./const.js";
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// WSClient 实例管理
|
|
11
|
+
// ============================================================================
|
|
12
|
+
/** WSClient 实例管理 */
|
|
13
|
+
const wsClientInstances = new Map();
|
|
14
|
+
/**
|
|
15
|
+
* 获取指定账户的 WSClient 实例
|
|
16
|
+
*/
|
|
17
|
+
export function getWeComWebSocket(accountId) {
|
|
18
|
+
return wsClientInstances.get(accountId) ?? null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 设置指定账户的 WSClient 实例
|
|
22
|
+
*/
|
|
23
|
+
export function setWeComWebSocket(accountId, client) {
|
|
24
|
+
wsClientInstances.set(accountId, client);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 删除指定账户的 WSClient 实例
|
|
28
|
+
*/
|
|
29
|
+
export function deleteWeComWebSocket(accountId) {
|
|
30
|
+
wsClientInstances.delete(accountId);
|
|
31
|
+
}
|
|
32
|
+
/** 消息状态管理 */
|
|
33
|
+
const messageStates = new Map();
|
|
34
|
+
/** 定期清理定时器 */
|
|
35
|
+
let cleanupTimer = null;
|
|
36
|
+
/**
|
|
37
|
+
* 启动消息状态定期清理(自动 TTL 清理 + 容量限制)
|
|
38
|
+
*/
|
|
39
|
+
export function startMessageStateCleanup() {
|
|
40
|
+
if (cleanupTimer)
|
|
41
|
+
return;
|
|
42
|
+
cleanupTimer = setInterval(() => {
|
|
43
|
+
pruneMessageStates();
|
|
44
|
+
}, MESSAGE_STATE_CLEANUP_INTERVAL_MS);
|
|
45
|
+
// 允许进程退出时不阻塞
|
|
46
|
+
if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
|
|
47
|
+
cleanupTimer.unref();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 停止消息状态定期清理
|
|
52
|
+
*/
|
|
53
|
+
export function stopMessageStateCleanup() {
|
|
54
|
+
if (cleanupTimer) {
|
|
55
|
+
clearInterval(cleanupTimer);
|
|
56
|
+
cleanupTimer = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 清理过期和超量的消息状态条目
|
|
61
|
+
*/
|
|
62
|
+
function pruneMessageStates() {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
// 1. 清理过期条目
|
|
65
|
+
for (const [key, entry] of messageStates) {
|
|
66
|
+
if (now - entry.createdAt >= MESSAGE_STATE_TTL_MS) {
|
|
67
|
+
messageStates.delete(key);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// 2. 容量限制:如果仍超过最大条目数,按时间淘汰最旧的
|
|
71
|
+
if (messageStates.size > MESSAGE_STATE_MAX_SIZE) {
|
|
72
|
+
const sorted = [...messageStates.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
73
|
+
const toRemove = sorted.slice(0, messageStates.size - MESSAGE_STATE_MAX_SIZE);
|
|
74
|
+
for (const [key] of toRemove) {
|
|
75
|
+
messageStates.delete(key);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 设置消息状态
|
|
81
|
+
*/
|
|
82
|
+
export function setMessageState(messageId, state) {
|
|
83
|
+
messageStates.set(messageId, {
|
|
84
|
+
state,
|
|
85
|
+
createdAt: Date.now(),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 获取消息状态
|
|
90
|
+
*/
|
|
91
|
+
export function getMessageState(messageId) {
|
|
92
|
+
const entry = messageStates.get(messageId);
|
|
93
|
+
if (!entry)
|
|
94
|
+
return undefined;
|
|
95
|
+
// 检查 TTL
|
|
96
|
+
if (Date.now() - entry.createdAt >= MESSAGE_STATE_TTL_MS) {
|
|
97
|
+
messageStates.delete(messageId);
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
return entry.state;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 删除消息状态
|
|
104
|
+
*/
|
|
105
|
+
export function deleteMessageState(messageId) {
|
|
106
|
+
messageStates.delete(messageId);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 清空所有消息状态
|
|
110
|
+
*/
|
|
111
|
+
export function clearAllMessageStates() {
|
|
112
|
+
messageStates.clear();
|
|
113
|
+
}
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// ReqId 持久化存储管理(按 accountId 隔离)
|
|
116
|
+
// ============================================================================
|
|
117
|
+
/**
|
|
118
|
+
* ReqId 持久化存储管理
|
|
119
|
+
* 参考 createPersistentDedupe 模式:内存 + 磁盘双层、文件锁、原子写入、TTL 过期、防抖写入
|
|
120
|
+
* 重启后可从磁盘恢复,确保主动推送消息时能获取到 reqId
|
|
121
|
+
*/
|
|
122
|
+
const reqIdStores = new Map();
|
|
123
|
+
function getOrCreateReqIdStore(accountId) {
|
|
124
|
+
let store = reqIdStores.get(accountId);
|
|
125
|
+
if (!store) {
|
|
126
|
+
store = createPersistentReqIdStore(accountId);
|
|
127
|
+
reqIdStores.set(accountId, store);
|
|
128
|
+
}
|
|
129
|
+
return store;
|
|
130
|
+
}
|
|
131
|
+
// ============================================================================
|
|
132
|
+
// ReqId 操作函数
|
|
133
|
+
// ============================================================================
|
|
134
|
+
/**
|
|
135
|
+
* 设置 chatId 对应的 reqId(写入内存 + 防抖写磁盘)
|
|
136
|
+
*/
|
|
137
|
+
export function setReqIdForChat(chatId, reqId, accountId = "default") {
|
|
138
|
+
getOrCreateReqIdStore(accountId).set(chatId, reqId);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* 获取 chatId 对应的 reqId(异步:优先内存,miss 时查磁盘并回填内存)
|
|
142
|
+
*/
|
|
143
|
+
export async function getReqIdForChatAsync(chatId, accountId = "default") {
|
|
144
|
+
return getOrCreateReqIdStore(accountId).get(chatId);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* 获取 chatId 对应的 reqId(同步:仅内存,保留向后兼容)
|
|
148
|
+
*/
|
|
149
|
+
export function getReqIdForChat(chatId, accountId = "default") {
|
|
150
|
+
return getOrCreateReqIdStore(accountId).getSync(chatId);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* 删除 chatId 对应的 reqId
|
|
154
|
+
*/
|
|
155
|
+
export function deleteReqIdForChat(chatId, accountId = "default") {
|
|
156
|
+
getOrCreateReqIdStore(accountId).delete(chatId);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* 启动时预热 reqId 缓存(从磁盘加载到内存)
|
|
160
|
+
*
|
|
161
|
+
* 注意:由于移除了磁盘存储,此函数现在只返回 0(无预热条目)
|
|
162
|
+
*/
|
|
163
|
+
export async function warmupReqIdStore(accountId = "default", log) {
|
|
164
|
+
// 由于移除了磁盘存储,不再需要预热过程
|
|
165
|
+
log?.("[WeCom] reqid-store warmup: no-op (disk storage removed)");
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 立即将 reqId 数据刷写到磁盘(用于优雅退出)
|
|
170
|
+
*
|
|
171
|
+
* 注意:由于移除了磁盘存储,此函数现在是无操作
|
|
172
|
+
*/
|
|
173
|
+
export async function flushReqIdStore(accountId = "default") {
|
|
174
|
+
// 由于移除了磁盘存储,不再需要刷写操作
|
|
175
|
+
}
|
|
176
|
+
// ============================================================================
|
|
177
|
+
// 全局 cleanup(断开连接时释放所有资源)
|
|
178
|
+
// ============================================================================
|
|
179
|
+
/**
|
|
180
|
+
* 清理指定账户的所有资源
|
|
181
|
+
*/
|
|
182
|
+
export async function cleanupAccount(accountId) {
|
|
183
|
+
// 1. 断开 WSClient
|
|
184
|
+
const wsClient = wsClientInstances.get(accountId);
|
|
185
|
+
if (wsClient) {
|
|
186
|
+
try {
|
|
187
|
+
wsClient.disconnect();
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// 忽略断开连接时的错误
|
|
191
|
+
}
|
|
192
|
+
wsClientInstances.delete(accountId);
|
|
193
|
+
}
|
|
194
|
+
// 2. 由于移除了磁盘存储,不再需要 flush reqId 存储
|
|
195
|
+
// 注意:不删除 store,因为重连后可能还需要
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* 清理所有资源(用于进程退出)
|
|
199
|
+
*/
|
|
200
|
+
export async function cleanupAll() {
|
|
201
|
+
// 停止定期清理
|
|
202
|
+
stopMessageStateCleanup();
|
|
203
|
+
// 清理所有 WSClient
|
|
204
|
+
for (const [accountId, wsClient] of wsClientInstances) {
|
|
205
|
+
try {
|
|
206
|
+
wsClient.disconnect();
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// 忽略
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
wsClientInstances.clear();
|
|
213
|
+
// 由于移除了磁盘存储,不再需要 flush 所有 reqId 存储
|
|
214
|
+
// 清空消息状态
|
|
215
|
+
clearAllMessageStates();
|
|
216
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
export interface WecomTarget {
|
|
16
|
+
touser?: string;
|
|
17
|
+
toparty?: string;
|
|
18
|
+
totag?: string;
|
|
19
|
+
chatid?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Parses a raw target string into a WeComTarget object.
|
|
23
|
+
* 解析原始目标字符串为 WeComTarget 对象。
|
|
24
|
+
*
|
|
25
|
+
* 逻辑:
|
|
26
|
+
* 1. 移除标准命名空间前缀 (wecom:, qywx: 等)。
|
|
27
|
+
* 2. 检查显式类型前缀 (party:, tag:, group:, user:)。
|
|
28
|
+
* 3. 启发式回退 (无前缀时):
|
|
29
|
+
* - 以 "wr" 或 "wc" 开头 -> Chat ID (群聊)
|
|
30
|
+
* - 纯数字 -> Party ID (部门)
|
|
31
|
+
* - 其他 -> User ID (用户)
|
|
32
|
+
*
|
|
33
|
+
* @param raw - The raw target string (e.g. "party:1", "zhangsan", "wecom:wr123")
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveWecomTarget(raw: string | undefined): WecomTarget | undefined;
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
* Parses a raw target string into a WeComTarget object.
|
|
17
|
+
* 解析原始目标字符串为 WeComTarget 对象。
|
|
18
|
+
*
|
|
19
|
+
* 逻辑:
|
|
20
|
+
* 1. 移除标准命名空间前缀 (wecom:, qywx: 等)。
|
|
21
|
+
* 2. 检查显式类型前缀 (party:, tag:, group:, user:)。
|
|
22
|
+
* 3. 启发式回退 (无前缀时):
|
|
23
|
+
* - 以 "wr" 或 "wc" 开头 -> Chat ID (群聊)
|
|
24
|
+
* - 纯数字 -> Party ID (部门)
|
|
25
|
+
* - 其他 -> User ID (用户)
|
|
26
|
+
*
|
|
27
|
+
* @param raw - The raw target string (e.g. "party:1", "zhangsan", "wecom:wr123")
|
|
28
|
+
*/
|
|
29
|
+
export function resolveWecomTarget(raw) {
|
|
30
|
+
if (!raw?.trim())
|
|
31
|
+
return undefined;
|
|
32
|
+
// 1. Remove standard namespace prefixes (移除标准命名空间前缀)
|
|
33
|
+
let clean = raw.trim().replace(/^(wecom-agent|wecom|wechatwork|wework|qywx):/i, "");
|
|
34
|
+
// 2. Explicit Type Prefixes (显式类型前缀)
|
|
35
|
+
if (/^party:/i.test(clean)) {
|
|
36
|
+
return { toparty: clean.replace(/^party:/i, "").trim() };
|
|
37
|
+
}
|
|
38
|
+
if (/^dept:/i.test(clean)) {
|
|
39
|
+
return { toparty: clean.replace(/^dept:/i, "").trim() };
|
|
40
|
+
}
|
|
41
|
+
if (/^tag:/i.test(clean)) {
|
|
42
|
+
return { totag: clean.replace(/^tag:/i, "").trim() };
|
|
43
|
+
}
|
|
44
|
+
if (/^group:/i.test(clean)) {
|
|
45
|
+
return { chatid: clean.replace(/^group:/i, "").trim() };
|
|
46
|
+
}
|
|
47
|
+
if (/^chat:/i.test(clean)) {
|
|
48
|
+
return { chatid: clean.replace(/^chat:/i, "").trim() };
|
|
49
|
+
}
|
|
50
|
+
if (/^user:/i.test(clean)) {
|
|
51
|
+
return { touser: clean.replace(/^user:/i, "").trim() };
|
|
52
|
+
}
|
|
53
|
+
// 3. Heuristics (启发式规则)
|
|
54
|
+
// Chat ID typically starts with 'wr' or 'wc'
|
|
55
|
+
// 群聊 ID 通常以 'wr' (外部群) 或 'wc' 开头
|
|
56
|
+
if (/^(wr|wc)/i.test(clean)) {
|
|
57
|
+
return { chatid: clean };
|
|
58
|
+
}
|
|
59
|
+
// Pure digits are likely Department IDs (Parties)
|
|
60
|
+
// 纯数字优先被视为部门 ID (Parties),方便运维配置 (如 "1" 代表根部门)
|
|
61
|
+
// 如果必须要发送给纯数字 ID 的用户,请使用显式前缀 "user:1001"
|
|
62
|
+
if (/^\d+$/.test(clean)) {
|
|
63
|
+
return { toparty: clean };
|
|
64
|
+
}
|
|
65
|
+
// Default to User (默认为用户)
|
|
66
|
+
return { touser: clean };
|
|
67
|
+
}
|