@sunnoy/wecom 1.0.0
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/CONTRIBUTING.md +25 -0
- package/LICENSE +7 -0
- package/README.md +284 -0
- package/README_ZH.md +284 -0
- package/client.js +127 -0
- package/crypto.js +108 -0
- package/dynamic-agent.js +120 -0
- package/image-processor.js +179 -0
- package/index.js +889 -0
- package/logger.js +64 -0
- package/openclaw.plugin.json +13 -0
- package/package.json +60 -0
- package/stream-manager.js +307 -0
- package/utils.js +251 -0
- package/webhook.js +273 -0
package/index.js
ADDED
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
import { WecomWebhook } from "./webhook.js";
|
|
2
|
+
import { logger } from "./logger.js";
|
|
3
|
+
import { streamManager } from "./stream-manager.js";
|
|
4
|
+
import {
|
|
5
|
+
generateAgentId,
|
|
6
|
+
getDynamicAgentConfig,
|
|
7
|
+
shouldTriggerGroupResponse,
|
|
8
|
+
extractGroupMessageContent,
|
|
9
|
+
} from "./dynamic-agent.js";
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// 命令白名单配置
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
// 默认允许的斜杠命令(用户操作安全的命令)
|
|
19
|
+
const DEFAULT_COMMAND_ALLOWLIST = [
|
|
20
|
+
"/new", // 新建会话
|
|
21
|
+
"/compact", // 压缩会话
|
|
22
|
+
"/help", // 帮助
|
|
23
|
+
"/status", // 状态
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// 默认拦截消息
|
|
27
|
+
const DEFAULT_COMMAND_BLOCK_MESSAGE = `⚠️ 该命令不可用。
|
|
28
|
+
|
|
29
|
+
支持的命令:
|
|
30
|
+
• **/new** - 新建会话
|
|
31
|
+
• **/compact** - 压缩会话(保留上下文摘要)
|
|
32
|
+
• **/help** - 查看帮助
|
|
33
|
+
• **/status** - 查看状态`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 获取命令白名单配置
|
|
37
|
+
*/
|
|
38
|
+
function getCommandConfig(config) {
|
|
39
|
+
const wecom = config?.channels?.wecom || {};
|
|
40
|
+
const commands = wecom.commands || {};
|
|
41
|
+
return {
|
|
42
|
+
allowlist: commands.allowlist || DEFAULT_COMMAND_ALLOWLIST,
|
|
43
|
+
blockMessage: commands.blockMessage || DEFAULT_COMMAND_BLOCK_MESSAGE,
|
|
44
|
+
enabled: commands.enabled !== false, // 默认启用白名单
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 检查命令是否在白名单中
|
|
50
|
+
* @param {string} message - 用户消息
|
|
51
|
+
* @param {Object} config - 配置
|
|
52
|
+
* @returns {{ isCommand: boolean, allowed: boolean, command: string | null }}
|
|
53
|
+
*/
|
|
54
|
+
function checkCommandAllowlist(message, config) {
|
|
55
|
+
const trimmed = message.trim();
|
|
56
|
+
|
|
57
|
+
// 不是斜杠命令
|
|
58
|
+
if (!trimmed.startsWith("/")) {
|
|
59
|
+
return { isCommand: false, allowed: true, command: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 提取命令(取第一个空格之前的部分)
|
|
63
|
+
const command = trimmed.split(/\s+/)[0].toLowerCase();
|
|
64
|
+
|
|
65
|
+
const cmdConfig = getCommandConfig(config);
|
|
66
|
+
|
|
67
|
+
// 如果白名单功能禁用,允许所有命令
|
|
68
|
+
if (!cmdConfig.enabled) {
|
|
69
|
+
return { isCommand: true, allowed: true, command };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 检查是否在白名单中
|
|
73
|
+
const allowed = cmdConfig.allowlist.some(cmd =>
|
|
74
|
+
cmd.toLowerCase() === command
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return { isCommand: true, allowed, command };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Runtime state (module-level singleton)
|
|
81
|
+
let _runtime = null;
|
|
82
|
+
let _openclawConfig = null;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Set the plugin runtime (called during plugin registration)
|
|
86
|
+
*/
|
|
87
|
+
function setRuntime(runtime) {
|
|
88
|
+
_runtime = runtime;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getRuntime() {
|
|
92
|
+
if (!_runtime) {
|
|
93
|
+
throw new Error("[wecom] Runtime not initialized");
|
|
94
|
+
}
|
|
95
|
+
return _runtime;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Webhook targets registry (similar to Google Chat)
|
|
99
|
+
const webhookTargets = new Map();
|
|
100
|
+
|
|
101
|
+
// Track active stream for each user, so outbound messages (like reset confirmation)
|
|
102
|
+
// can be added to the correct stream instead of using response_url
|
|
103
|
+
const activeStreams = new Map();
|
|
104
|
+
|
|
105
|
+
function normalizeWecomAllowFromEntry(raw) {
|
|
106
|
+
const trimmed = String(raw ?? "").trim();
|
|
107
|
+
if (!trimmed) return null;
|
|
108
|
+
if (trimmed === "*") return "*";
|
|
109
|
+
return trimmed.replace(/^(wecom|wework):/i, "").replace(/^user:/i, "").toLowerCase();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveWecomAllowFrom(cfg, accountId) {
|
|
113
|
+
const wecom = cfg?.channels?.wecom;
|
|
114
|
+
if (!wecom) return [];
|
|
115
|
+
|
|
116
|
+
const normalizedAccountId = String(accountId || DEFAULT_ACCOUNT_ID).trim().toLowerCase();
|
|
117
|
+
const accounts = wecom.accounts;
|
|
118
|
+
const account =
|
|
119
|
+
accounts && typeof accounts === "object"
|
|
120
|
+
? accounts[accountId] ??
|
|
121
|
+
accounts[
|
|
122
|
+
Object.keys(accounts).find((key) => key.toLowerCase() === normalizedAccountId) ?? ""
|
|
123
|
+
]
|
|
124
|
+
: undefined;
|
|
125
|
+
|
|
126
|
+
const allowFromRaw =
|
|
127
|
+
account?.dm?.allowFrom ?? account?.allowFrom ?? wecom.dm?.allowFrom ?? wecom.allowFrom ?? [];
|
|
128
|
+
|
|
129
|
+
if (!Array.isArray(allowFromRaw)) return [];
|
|
130
|
+
|
|
131
|
+
return allowFromRaw
|
|
132
|
+
.map(normalizeWecomAllowFromEntry)
|
|
133
|
+
.filter((entry) => Boolean(entry));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveWecomCommandAuthorized({ cfg, accountId, senderId }) {
|
|
137
|
+
const sender = String(senderId ?? "").trim().toLowerCase();
|
|
138
|
+
if (!sender) return false;
|
|
139
|
+
|
|
140
|
+
const allowFrom = resolveWecomAllowFrom(cfg, accountId);
|
|
141
|
+
if (allowFrom.includes("*") || allowFrom.length === 0) return true;
|
|
142
|
+
return allowFrom.includes(sender);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeWebhookPath(raw) {
|
|
146
|
+
const trimmed = (raw || "").trim();
|
|
147
|
+
if (!trimmed) return "/";
|
|
148
|
+
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
149
|
+
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
|
150
|
+
return withSlash.slice(0, -1);
|
|
151
|
+
}
|
|
152
|
+
return withSlash;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function registerWebhookTarget(target) {
|
|
156
|
+
const key = normalizeWebhookPath(target.path);
|
|
157
|
+
const existing = webhookTargets.get(key) ?? [];
|
|
158
|
+
webhookTargets.set(key, [...existing, { ...target, path: key }]);
|
|
159
|
+
return () => {
|
|
160
|
+
const updated = (webhookTargets.get(key) ?? []).filter((e) => e !== target);
|
|
161
|
+
if (updated.length > 0) {
|
|
162
|
+
webhookTargets.set(key, updated);
|
|
163
|
+
} else {
|
|
164
|
+
webhookTargets.delete(key);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// =============================================================================
|
|
170
|
+
// Channel Plugin Definition
|
|
171
|
+
// =============================================================================
|
|
172
|
+
|
|
173
|
+
const wecomChannelPlugin = {
|
|
174
|
+
id: "wecom",
|
|
175
|
+
meta: {
|
|
176
|
+
id: "wecom",
|
|
177
|
+
label: "Enterprise WeChat",
|
|
178
|
+
selectionLabel: "Enterprise WeChat (AI Bot)",
|
|
179
|
+
docsPath: "/channels/wecom",
|
|
180
|
+
blurb: "Enterprise WeChat AI Bot channel plugin.",
|
|
181
|
+
aliases: ["wecom", "wework"],
|
|
182
|
+
},
|
|
183
|
+
capabilities: {
|
|
184
|
+
chatTypes: ["direct", "group"], // 支持私聊和群聊
|
|
185
|
+
reactions: false,
|
|
186
|
+
threads: false,
|
|
187
|
+
media: true, // Supports image sending via base64 encoding
|
|
188
|
+
nativeCommands: false,
|
|
189
|
+
blockStreaming: true, // WeCom AI Bot uses stream response format
|
|
190
|
+
},
|
|
191
|
+
reload: { configPrefixes: ["channels.wecom"] },
|
|
192
|
+
config: {
|
|
193
|
+
listAccountIds: (cfg) => {
|
|
194
|
+
const wecom = cfg?.channels?.wecom;
|
|
195
|
+
if (!wecom || !wecom.enabled) return [];
|
|
196
|
+
return [DEFAULT_ACCOUNT_ID];
|
|
197
|
+
},
|
|
198
|
+
resolveAccount: (cfg, accountId) => {
|
|
199
|
+
const wecom = cfg?.channels?.wecom;
|
|
200
|
+
if (!wecom) return null;
|
|
201
|
+
return {
|
|
202
|
+
id: accountId || DEFAULT_ACCOUNT_ID,
|
|
203
|
+
accountId: accountId || DEFAULT_ACCOUNT_ID,
|
|
204
|
+
enabled: wecom.enabled !== false,
|
|
205
|
+
token: wecom.token || "",
|
|
206
|
+
encodingAesKey: wecom.encodingAesKey || "",
|
|
207
|
+
webhookPath: wecom.webhookPath || "/webhooks/wecom",
|
|
208
|
+
config: wecom,
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
defaultAccountId: (cfg) => {
|
|
212
|
+
const wecom = cfg?.channels?.wecom;
|
|
213
|
+
if (!wecom || !wecom.enabled) return null;
|
|
214
|
+
return DEFAULT_ACCOUNT_ID;
|
|
215
|
+
},
|
|
216
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
|
217
|
+
if (!cfg.channels) cfg.channels = {};
|
|
218
|
+
if (!cfg.channels.wecom) cfg.channels.wecom = {};
|
|
219
|
+
cfg.channels.wecom.enabled = enabled;
|
|
220
|
+
return cfg;
|
|
221
|
+
},
|
|
222
|
+
deleteAccount: ({ cfg, accountId }) => {
|
|
223
|
+
if (cfg.channels?.wecom) delete cfg.channels.wecom;
|
|
224
|
+
return cfg;
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
directory: {
|
|
228
|
+
self: async () => null,
|
|
229
|
+
listPeers: async () => [],
|
|
230
|
+
listGroups: async () => [],
|
|
231
|
+
},
|
|
232
|
+
// Outbound adapter: Send messages via stream (all messages go through stream now)
|
|
233
|
+
outbound: {
|
|
234
|
+
sendText: async ({ cfg, to, text, accountId }) => {
|
|
235
|
+
// to格式: \"wecom:userid\" 或 \"userid\"
|
|
236
|
+
const userId = to.replace(/^wecom:/, "");
|
|
237
|
+
|
|
238
|
+
// 获取该用户当前活跃的 streamId
|
|
239
|
+
const streamId = activeStreams.get(userId);
|
|
240
|
+
|
|
241
|
+
if (streamId && streamManager.hasStream(streamId)) {
|
|
242
|
+
logger.debug("Appending outbound text to stream", { userId, streamId, text: text.substring(0, 30) });
|
|
243
|
+
// 使用 appendStream 追加内容,保留之前的内容
|
|
244
|
+
const stream = streamManager.getStream(streamId);
|
|
245
|
+
const separator = stream && stream.content.length > 0 ? "\n\n" : "";
|
|
246
|
+
streamManager.appendStream(streamId, separator + text);
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
channel: "wecom",
|
|
250
|
+
messageId: `msg_stream_${Date.now()}`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 如果没有活跃的流,记录警告
|
|
255
|
+
logger.warn("WeCom outbound: no active stream for user", { userId });
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
channel: "wecom",
|
|
259
|
+
messageId: `fake_${Date.now()}`,
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
|
|
263
|
+
const userId = to.replace(/^wecom:/, "");
|
|
264
|
+
const streamId = activeStreams.get(userId);
|
|
265
|
+
|
|
266
|
+
if (streamId && streamManager.hasStream(streamId)) {
|
|
267
|
+
// Check if mediaUrl is a local path (sandbox: prefix or absolute path)
|
|
268
|
+
const isLocalPath = mediaUrl.startsWith("sandbox:") || mediaUrl.startsWith("/");
|
|
269
|
+
|
|
270
|
+
if (isLocalPath) {
|
|
271
|
+
// Convert sandbox: URLs to absolute paths
|
|
272
|
+
// Support both sandbox:/ and sandbox:// formats
|
|
273
|
+
const absolutePath = mediaUrl
|
|
274
|
+
.replace(/^sandbox:\/\//, "")
|
|
275
|
+
.replace(/^sandbox:\//, "");
|
|
276
|
+
|
|
277
|
+
logger.debug("Queueing local image for stream", {
|
|
278
|
+
userId,
|
|
279
|
+
streamId,
|
|
280
|
+
mediaUrl,
|
|
281
|
+
absolutePath
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Queue the image for processing when stream finishes
|
|
285
|
+
const queued = streamManager.queueImage(streamId, absolutePath);
|
|
286
|
+
|
|
287
|
+
if (queued) {
|
|
288
|
+
// Append text content to stream (without markdown image)
|
|
289
|
+
if (text) {
|
|
290
|
+
const stream = streamManager.getStream(streamId);
|
|
291
|
+
const separator = stream && stream.content.length > 0 ? "\n\n" : "";
|
|
292
|
+
streamManager.appendStream(streamId, separator + text);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Append placeholder indicating image will follow
|
|
296
|
+
const imagePlaceholder = "\n\n[图片]";
|
|
297
|
+
streamManager.appendStream(streamId, imagePlaceholder);
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
channel: "wecom",
|
|
301
|
+
messageId: `msg_stream_img_${Date.now()}`,
|
|
302
|
+
};
|
|
303
|
+
} else {
|
|
304
|
+
logger.warn("Failed to queue image, falling back to markdown", {
|
|
305
|
+
userId,
|
|
306
|
+
streamId,
|
|
307
|
+
mediaUrl
|
|
308
|
+
});
|
|
309
|
+
// Fallback to old behavior
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// OLD BEHAVIOR: For external URLs or if queueing failed, use markdown
|
|
314
|
+
const content = text ? `${text}\n\n` : ``;
|
|
315
|
+
logger.debug("Appending outbound media to stream (markdown)", {
|
|
316
|
+
userId,
|
|
317
|
+
streamId,
|
|
318
|
+
mediaUrl
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// 使用 appendStream 追加内容
|
|
322
|
+
const stream = streamManager.getStream(streamId);
|
|
323
|
+
const separator = stream && stream.content.length > 0 ? "\n\n" : "";
|
|
324
|
+
streamManager.appendStream(streamId, separator + content);
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
channel: "wecom",
|
|
328
|
+
messageId: `msg_stream_${Date.now()}`,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
logger.warn("WeCom outbound sendMedia: no active stream", { userId });
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
channel: "wecom",
|
|
336
|
+
messageId: `fake_${Date.now()}`,
|
|
337
|
+
};
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
gateway: {
|
|
341
|
+
startAccount: async (ctx) => {
|
|
342
|
+
const account = ctx.account;
|
|
343
|
+
logger.info("WeCom gateway starting", { accountId: account.accountId, webhookPath: account.webhookPath });
|
|
344
|
+
|
|
345
|
+
const unregister = registerWebhookTarget({
|
|
346
|
+
path: account.webhookPath || "/webhooks/wecom",
|
|
347
|
+
account,
|
|
348
|
+
config: ctx.cfg,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
shutdown: async () => {
|
|
353
|
+
logger.info("WeCom gateway shutting down");
|
|
354
|
+
unregister();
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// =============================================================================
|
|
362
|
+
// HTTP Webhook Handler
|
|
363
|
+
// =============================================================================
|
|
364
|
+
|
|
365
|
+
async function wecomHttpHandler(req, res) {
|
|
366
|
+
const url = new URL(req.url || "", "http://localhost");
|
|
367
|
+
const path = normalizeWebhookPath(url.pathname);
|
|
368
|
+
const targets = webhookTargets.get(path);
|
|
369
|
+
|
|
370
|
+
if (!targets || targets.length === 0) {
|
|
371
|
+
return false; // Not handled by this plugin
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const query = Object.fromEntries(url.searchParams);
|
|
375
|
+
logger.debug("WeCom HTTP request", { method: req.method, path });
|
|
376
|
+
|
|
377
|
+
// GET: URL Verification
|
|
378
|
+
if (req.method === "GET") {
|
|
379
|
+
const target = targets[0]; // Use first target for verification
|
|
380
|
+
if (!target) {
|
|
381
|
+
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
382
|
+
res.end("No webhook target configured");
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const webhook = new WecomWebhook({
|
|
387
|
+
token: target.account.token,
|
|
388
|
+
encodingAesKey: target.account.encodingAesKey,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const echo = webhook.handleVerify(query);
|
|
392
|
+
if (echo) {
|
|
393
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
394
|
+
res.end(echo);
|
|
395
|
+
logger.info("WeCom URL verification successful");
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
400
|
+
res.end("Verification failed");
|
|
401
|
+
logger.warn("WeCom URL verification failed");
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// POST: Message handling
|
|
406
|
+
if (req.method === "POST") {
|
|
407
|
+
const target = targets[0];
|
|
408
|
+
if (!target) {
|
|
409
|
+
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
410
|
+
res.end("No webhook target configured");
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Read request body
|
|
415
|
+
const chunks = [];
|
|
416
|
+
for await (const chunk of req) {
|
|
417
|
+
chunks.push(chunk);
|
|
418
|
+
}
|
|
419
|
+
const body = Buffer.concat(chunks).toString("utf-8");
|
|
420
|
+
logger.debug("WeCom message received", { bodyLength: body.length });
|
|
421
|
+
|
|
422
|
+
const webhook = new WecomWebhook({
|
|
423
|
+
token: target.account.token,
|
|
424
|
+
encodingAesKey: target.account.encodingAesKey,
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const result = await webhook.handleMessage(query, body);
|
|
428
|
+
if (!result) {
|
|
429
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
430
|
+
res.end("Bad Request");
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Handle text message
|
|
435
|
+
if (result.message) {
|
|
436
|
+
const msg = result.message;
|
|
437
|
+
const { timestamp, nonce } = result.query;
|
|
438
|
+
const content = (msg.content || "").trim();
|
|
439
|
+
|
|
440
|
+
// 统一使用流式回复处理所有消息(包括命令)
|
|
441
|
+
// 企业微信 AI Bot 的 response_url 只能使用一次,
|
|
442
|
+
// 所以必须通过流式来发送所有回复内容
|
|
443
|
+
const streamId = `stream_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
444
|
+
streamManager.createStream(streamId);
|
|
445
|
+
|
|
446
|
+
// 被动回复:返回流式消息ID (同步响应)
|
|
447
|
+
const streamResponse = webhook.buildStreamResponse(
|
|
448
|
+
streamId,
|
|
449
|
+
"", // 初始内容为空
|
|
450
|
+
false, // 未完成
|
|
451
|
+
timestamp,
|
|
452
|
+
nonce
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
456
|
+
res.end(streamResponse);
|
|
457
|
+
|
|
458
|
+
logger.info("Stream initiated", { streamId, from: msg.fromUser, isCommand: content.startsWith("/") });
|
|
459
|
+
// 异步处理消息 - 调用AI并更新流内容
|
|
460
|
+
processInboundMessage({
|
|
461
|
+
message: msg,
|
|
462
|
+
streamId,
|
|
463
|
+
timestamp,
|
|
464
|
+
nonce,
|
|
465
|
+
account: target.account,
|
|
466
|
+
config: target.config,
|
|
467
|
+
}).catch(async (err) => {
|
|
468
|
+
logger.error("WeCom message processing failed", { error: err.message });
|
|
469
|
+
// 即使失败也要标记流为完成
|
|
470
|
+
await streamManager.finishStream(streamId);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Handle stream refresh - return current stream state
|
|
477
|
+
if (result.stream) {
|
|
478
|
+
const { timestamp, nonce } = result.query;
|
|
479
|
+
const streamId = result.stream.id;
|
|
480
|
+
|
|
481
|
+
// 获取流的当前状态
|
|
482
|
+
const stream = streamManager.getStream(streamId);
|
|
483
|
+
|
|
484
|
+
if (!stream) {
|
|
485
|
+
// 流不存在或已过期,返回空的完成响应
|
|
486
|
+
logger.warn("Stream not found for refresh", { streamId });
|
|
487
|
+
const streamResponse = webhook.buildStreamResponse(
|
|
488
|
+
streamId,
|
|
489
|
+
"会话已过期",
|
|
490
|
+
true,
|
|
491
|
+
timestamp,
|
|
492
|
+
nonce
|
|
493
|
+
);
|
|
494
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
495
|
+
res.end(streamResponse);
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// 返回当前流的内容
|
|
500
|
+
const streamResponse = webhook.buildStreamResponse(
|
|
501
|
+
streamId,
|
|
502
|
+
stream.content,
|
|
503
|
+
stream.finished,
|
|
504
|
+
timestamp,
|
|
505
|
+
nonce,
|
|
506
|
+
// Pass msgItem when stream is finished and has images
|
|
507
|
+
stream.finished && stream.msgItem.length > 0
|
|
508
|
+
? { msgItem: stream.msgItem }
|
|
509
|
+
: {}
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
513
|
+
res.end(streamResponse);
|
|
514
|
+
|
|
515
|
+
logger.debug("Stream refresh response sent", {
|
|
516
|
+
streamId,
|
|
517
|
+
contentLength: stream.content.length,
|
|
518
|
+
finished: stream.finished
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// 如果流已完成,在一段时间后清理
|
|
522
|
+
if (stream.finished) {
|
|
523
|
+
setTimeout(() => {
|
|
524
|
+
streamManager.deleteStream(streamId);
|
|
525
|
+
}, 30 * 1000); // 30秒后清理
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Handle event
|
|
532
|
+
if (result.event) {
|
|
533
|
+
logger.info("WeCom event received", { event: result.event });
|
|
534
|
+
|
|
535
|
+
// 处理进入会话事件 - 发送欢迎语
|
|
536
|
+
if (result.event?.event_type === "enter_chat") {
|
|
537
|
+
const { timestamp, nonce } = result.query;
|
|
538
|
+
const fromUser = result.event?.from?.userid || "";
|
|
539
|
+
|
|
540
|
+
// 欢迎语内容
|
|
541
|
+
const welcomeMessage = `你好!👋 我是 AI 助手。
|
|
542
|
+
|
|
543
|
+
你可以使用下面的指令管理会话:
|
|
544
|
+
• **/new** - 新建会话(清空上下文)
|
|
545
|
+
• **/compact** - 压缩会话(保留上下文摘要)
|
|
546
|
+
• **/help** - 查看更多命令
|
|
547
|
+
|
|
548
|
+
有什么我可以帮你的吗?`;
|
|
549
|
+
|
|
550
|
+
// 创建流并返回欢迎语
|
|
551
|
+
const streamId = `welcome_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
552
|
+
streamManager.createStream(streamId);
|
|
553
|
+
streamManager.appendStream(streamId, welcomeMessage);
|
|
554
|
+
await streamManager.finishStream(streamId);
|
|
555
|
+
|
|
556
|
+
const streamResponse = webhook.buildStreamResponse(
|
|
557
|
+
streamId,
|
|
558
|
+
welcomeMessage,
|
|
559
|
+
true, // 直接完成
|
|
560
|
+
timestamp,
|
|
561
|
+
nonce
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
logger.info("Sending welcome message", { fromUser, streamId });
|
|
565
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
566
|
+
res.end(streamResponse);
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
571
|
+
res.end("success");
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
576
|
+
res.end("success");
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
res.writeHead(405, { "Content-Type": "text/plain" });
|
|
581
|
+
res.end("Method Not Allowed");
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// =============================================================================
|
|
586
|
+
// Inbound Message Processing (triggers AI response)
|
|
587
|
+
// =============================================================================
|
|
588
|
+
|
|
589
|
+
async function processInboundMessage({ message, streamId, timestamp, nonce, account, config }) {
|
|
590
|
+
const runtime = getRuntime();
|
|
591
|
+
const core = runtime.channel;
|
|
592
|
+
|
|
593
|
+
const senderId = message.fromUser;
|
|
594
|
+
const rawContent = message.content || "";
|
|
595
|
+
const responseUrl = message.responseUrl;
|
|
596
|
+
const chatType = message.chatType || "single"; // "single" 或 "group"
|
|
597
|
+
const chatId = message.chatId || ""; // 群聊 ID
|
|
598
|
+
const isGroupChat = chatType === "group" && chatId;
|
|
599
|
+
|
|
600
|
+
// 确定 peerId:群聊用 chatId,私聊用 senderId
|
|
601
|
+
const peerId = isGroupChat ? chatId : senderId;
|
|
602
|
+
const peerKind = isGroupChat ? "group" : "dm";
|
|
603
|
+
const conversationId = isGroupChat ? `wecom:group:${chatId}` : `wecom:${senderId}`;
|
|
604
|
+
|
|
605
|
+
// 设置用户当前活跃的 streamId,供 outbound.sendText 使用
|
|
606
|
+
// 群聊时用 chatId 作为 key
|
|
607
|
+
const streamKey = isGroupChat ? chatId : senderId;
|
|
608
|
+
if (streamId) {
|
|
609
|
+
activeStreams.set(streamKey, streamId);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// 群聊消息检查:是否满足触发条件(@提及)
|
|
613
|
+
let rawBody = rawContent;
|
|
614
|
+
if (isGroupChat) {
|
|
615
|
+
if (!shouldTriggerGroupResponse(rawContent, config)) {
|
|
616
|
+
logger.debug("WeCom: group message ignored (no mention)", { chatId, senderId });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
// 提取实际内容(移除 @提及)
|
|
620
|
+
rawBody = extractGroupMessageContent(rawContent, config);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const commandAuthorized = resolveWecomCommandAuthorized({
|
|
624
|
+
cfg: config,
|
|
625
|
+
accountId: account.accountId,
|
|
626
|
+
senderId,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
if (!rawBody.trim()) {
|
|
630
|
+
logger.debug("WeCom: empty message, skipping");
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ========================================================================
|
|
635
|
+
// 命令白名单检查
|
|
636
|
+
// ========================================================================
|
|
637
|
+
const commandCheck = checkCommandAllowlist(rawBody, config);
|
|
638
|
+
|
|
639
|
+
if (commandCheck.isCommand && !commandCheck.allowed) {
|
|
640
|
+
// 命令不在白名单中,返回拒绝消息
|
|
641
|
+
const cmdConfig = getCommandConfig(config);
|
|
642
|
+
logger.warn("WeCom: blocked command", {
|
|
643
|
+
command: commandCheck.command,
|
|
644
|
+
from: senderId,
|
|
645
|
+
chatType: peerKind
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// 通过流式响应返回拦截消息
|
|
649
|
+
if (streamId) {
|
|
650
|
+
streamManager.appendStream(streamId, cmdConfig.blockMessage);
|
|
651
|
+
await streamManager.finishStream(streamId);
|
|
652
|
+
activeStreams.delete(streamKey);
|
|
653
|
+
}
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
logger.info("WeCom processing message", {
|
|
658
|
+
from: senderId,
|
|
659
|
+
chatType: peerKind,
|
|
660
|
+
peerId,
|
|
661
|
+
content: rawBody.substring(0, 50),
|
|
662
|
+
streamId,
|
|
663
|
+
isCommand: commandCheck.isCommand,
|
|
664
|
+
command: commandCheck.command
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// ========================================================================
|
|
668
|
+
// 动态 Agent 逻辑(极简版)
|
|
669
|
+
// 只需要生成 agentId 和构造 SessionKey,OpenClaw 会自动创建 workspace
|
|
670
|
+
// ========================================================================
|
|
671
|
+
const dynamicConfig = getDynamicAgentConfig(config);
|
|
672
|
+
|
|
673
|
+
// 生成目标 AgentId
|
|
674
|
+
const targetAgentId = dynamicConfig.enabled ? generateAgentId(peerKind, peerId) : null;
|
|
675
|
+
|
|
676
|
+
if (targetAgentId) {
|
|
677
|
+
logger.debug("Using dynamic agent", { agentId: targetAgentId, chatType: peerKind, peerId });
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ========================================================================
|
|
681
|
+
// 路由到目标 Agent
|
|
682
|
+
// ========================================================================
|
|
683
|
+
const route = core.routing.resolveAgentRoute({
|
|
684
|
+
cfg: config,
|
|
685
|
+
channel: "wecom",
|
|
686
|
+
accountId: account.accountId,
|
|
687
|
+
peer: {
|
|
688
|
+
kind: peerKind,
|
|
689
|
+
id: peerId,
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// 使用动态 Agent,覆盖默认路由
|
|
694
|
+
if (targetAgentId) {
|
|
695
|
+
route.agentId = targetAgentId;
|
|
696
|
+
route.sessionKey = `agent:${targetAgentId}:${peerKind}:${peerId}`;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
// Build inbound context
|
|
701
|
+
const storePath = core.session.resolveStorePath(config.session?.store, {
|
|
702
|
+
agentId: route.agentId,
|
|
703
|
+
});
|
|
704
|
+
const envelopeOptions = core.reply.resolveEnvelopeFormatOptions(config);
|
|
705
|
+
const previousTimestamp = core.session.readSessionUpdatedAt({
|
|
706
|
+
storePath,
|
|
707
|
+
sessionKey: route.sessionKey,
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// 构建消息头,群聊时显示发送者
|
|
711
|
+
const senderLabel = isGroupChat ? `[${senderId}]` : senderId;
|
|
712
|
+
const body = core.reply.formatAgentEnvelope({
|
|
713
|
+
channel: isGroupChat ? "Enterprise WeChat Group" : "Enterprise WeChat",
|
|
714
|
+
from: senderLabel,
|
|
715
|
+
timestamp: Date.now(),
|
|
716
|
+
previousTimestamp,
|
|
717
|
+
envelope: envelopeOptions,
|
|
718
|
+
body: rawBody,
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
const ctxPayload = core.reply.finalizeInboundContext({
|
|
722
|
+
Body: body,
|
|
723
|
+
RawBody: rawBody,
|
|
724
|
+
CommandBody: rawBody,
|
|
725
|
+
From: `wecom:${senderId}`,
|
|
726
|
+
To: conversationId,
|
|
727
|
+
SessionKey: route.sessionKey,
|
|
728
|
+
AccountId: route.accountId,
|
|
729
|
+
ChatType: isGroupChat ? "group" : "direct",
|
|
730
|
+
ConversationLabel: isGroupChat ? `群聊 ${chatId}` : senderId,
|
|
731
|
+
SenderName: senderId,
|
|
732
|
+
SenderId: senderId,
|
|
733
|
+
GroupId: isGroupChat ? chatId : undefined,
|
|
734
|
+
Provider: "wecom",
|
|
735
|
+
Surface: "wecom",
|
|
736
|
+
OriginatingChannel: "wecom",
|
|
737
|
+
OriginatingTo: conversationId,
|
|
738
|
+
CommandAuthorized: commandAuthorized,
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
// Record session meta
|
|
742
|
+
void core.session.recordSessionMetaFromInbound({
|
|
743
|
+
storePath,
|
|
744
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
745
|
+
ctx: ctxPayload,
|
|
746
|
+
}).catch((err) => {
|
|
747
|
+
logger.error("WeCom: failed updating session meta", { error: err.message });
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// Dispatch reply with AI processing
|
|
751
|
+
await core.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
752
|
+
ctx: ctxPayload,
|
|
753
|
+
cfg: config,
|
|
754
|
+
dispatcherOptions: {
|
|
755
|
+
deliver: async (payload, info) => {
|
|
756
|
+
logger.info("Dispatcher deliver called", {
|
|
757
|
+
kind: info.kind,
|
|
758
|
+
hasText: !!(payload.text && payload.text.trim()),
|
|
759
|
+
textPreview: (payload.text || "").substring(0, 50),
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
await deliverWecomReply({
|
|
763
|
+
payload,
|
|
764
|
+
account,
|
|
765
|
+
responseUrl,
|
|
766
|
+
senderId: streamKey, // 使用 streamKey(群聊时是 chatId)
|
|
767
|
+
streamId,
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// 如果是最终回复,标记流为完成
|
|
771
|
+
if (streamId && info.kind === "final") {
|
|
772
|
+
await streamManager.finishStream(streamId);
|
|
773
|
+
logger.info("WeCom stream finished", { streamId });
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
onError: async (err, info) => {
|
|
777
|
+
logger.error("WeCom reply failed", { error: err.message, kind: info.kind });
|
|
778
|
+
// 发生错误时也标记流为完成
|
|
779
|
+
if (streamId) {
|
|
780
|
+
await streamManager.finishStream(streamId);
|
|
781
|
+
}
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
// 确保在dispatch完成后标记流为完成(兜底机制)
|
|
787
|
+
if (streamId) {
|
|
788
|
+
await streamManager.finishStream(streamId);
|
|
789
|
+
activeStreams.delete(streamKey); // 清理活跃流映射
|
|
790
|
+
logger.info("WeCom stream finished (dispatch complete)", { streamId });
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// =============================================================================
|
|
795
|
+
// Outbound Reply Delivery (Stream-only mode)
|
|
796
|
+
// =============================================================================
|
|
797
|
+
|
|
798
|
+
async function deliverWecomReply({ payload, account, responseUrl, senderId, streamId }) {
|
|
799
|
+
const text = payload.text || "";
|
|
800
|
+
|
|
801
|
+
logger.debug("deliverWecomReply called", {
|
|
802
|
+
hasText: !!text.trim(),
|
|
803
|
+
textPreview: text.substring(0, 50),
|
|
804
|
+
streamId,
|
|
805
|
+
senderId,
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// 所有消息都通过流式发送
|
|
809
|
+
if (!text.trim()) {
|
|
810
|
+
logger.debug("WeCom: empty block, skipping stream update");
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// 辅助函数:追加内容到流(带去重)
|
|
815
|
+
const appendToStream = (targetStreamId, content) => {
|
|
816
|
+
const stream = streamManager.getStream(targetStreamId);
|
|
817
|
+
if (!stream) return false;
|
|
818
|
+
|
|
819
|
+
// 去重:检查流内容是否已包含此消息(避免 block + final 重复)
|
|
820
|
+
if (stream.content.includes(content.trim())) {
|
|
821
|
+
logger.debug("WeCom: duplicate content, skipping", {
|
|
822
|
+
streamId: targetStreamId,
|
|
823
|
+
contentPreview: content.substring(0, 30)
|
|
824
|
+
});
|
|
825
|
+
return true; // 返回 true 表示不需要再发送
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const separator = stream.content.length > 0 ? "\n\n" : "";
|
|
829
|
+
streamManager.appendStream(targetStreamId, separator + content);
|
|
830
|
+
return true;
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
if (!streamId) {
|
|
834
|
+
// 尝试从 activeStreams 获取
|
|
835
|
+
const activeStreamId = activeStreams.get(senderId);
|
|
836
|
+
if (activeStreamId && streamManager.hasStream(activeStreamId)) {
|
|
837
|
+
appendToStream(activeStreamId, text);
|
|
838
|
+
logger.debug("WeCom stream appended (via activeStreams)", {
|
|
839
|
+
streamId: activeStreamId,
|
|
840
|
+
contentLength: text.length,
|
|
841
|
+
});
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
logger.warn("WeCom: no active stream for this message", { senderId });
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (!streamManager.hasStream(streamId)) {
|
|
849
|
+
logger.warn("WeCom: stream not found, cannot update", { streamId });
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
appendToStream(streamId, text);
|
|
854
|
+
logger.debug("WeCom stream appended", {
|
|
855
|
+
streamId,
|
|
856
|
+
contentLength: text.length,
|
|
857
|
+
to: senderId
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// =============================================================================
|
|
862
|
+
// Plugin Registration
|
|
863
|
+
// =============================================================================
|
|
864
|
+
|
|
865
|
+
const plugin = {
|
|
866
|
+
// Plugin id should match `openclaw.plugin.json` id (and config.plugins.entries key).
|
|
867
|
+
id: "wecom",
|
|
868
|
+
name: "Enterprise WeChat",
|
|
869
|
+
description: "Enterprise WeChat AI Bot channel plugin for OpenClaw",
|
|
870
|
+
configSchema: { type: "object", additionalProperties: false, properties: {} },
|
|
871
|
+
register(api) {
|
|
872
|
+
logger.info("WeCom plugin registering...");
|
|
873
|
+
|
|
874
|
+
// Save runtime for message processing
|
|
875
|
+
setRuntime(api.runtime);
|
|
876
|
+
_openclawConfig = api.config;
|
|
877
|
+
|
|
878
|
+
// Register channel
|
|
879
|
+
api.registerChannel({ plugin: wecomChannelPlugin });
|
|
880
|
+
logger.info("WeCom channel registered");
|
|
881
|
+
|
|
882
|
+
// Register HTTP handler for webhooks
|
|
883
|
+
api.registerHttpHandler(wecomHttpHandler);
|
|
884
|
+
logger.info("WeCom HTTP handler registered");
|
|
885
|
+
},
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
export default plugin;
|
|
889
|
+
export const register = (api) => plugin.register(api);
|