@sunnoy/wecom 1.9.0 → 2.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/README.md +391 -845
- package/image-processor.js +30 -27
- package/index.js +10 -43
- package/package.json +5 -5
- package/think-parser.js +51 -11
- package/wecom/accounts.js +323 -189
- package/wecom/channel-plugin.js +543 -750
- package/wecom/constants.js +57 -47
- package/wecom/dm-policy.js +91 -0
- package/wecom/group-policy.js +85 -0
- package/wecom/onboarding.js +117 -0
- package/wecom/runtime-telemetry.js +330 -0
- package/wecom/sandbox.js +60 -0
- package/wecom/state.js +33 -35
- package/wecom/workspace-template.js +62 -5
- package/wecom/ws-monitor.js +1487 -0
- package/wecom/ws-state.js +160 -0
- package/crypto.js +0 -135
- package/stream-manager.js +0 -358
- package/webhook.js +0 -469
- package/wecom/agent-inbound.js +0 -541
- package/wecom/http-handler-state.js +0 -23
- package/wecom/http-handler.js +0 -395
- package/wecom/inbound-processor.js +0 -562
- package/wecom/media.js +0 -192
- package/wecom/outbound-delivery.js +0 -435
- package/wecom/response-url.js +0 -33
- package/wecom/stream-utils.js +0 -163
- package/wecom/webhook-targets.js +0 -28
- package/wecom/xml-parser.js +0 -126
package/wecom/http-handler.js
DELETED
|
@@ -1,395 +0,0 @@
|
|
|
1
|
-
import * as crypto from "node:crypto";
|
|
2
|
-
import { logger } from "../logger.js";
|
|
3
|
-
import { streamManager } from "../stream-manager.js";
|
|
4
|
-
import { parseThinkingContent } from "../think-parser.js";
|
|
5
|
-
import { WecomWebhook } from "../webhook.js";
|
|
6
|
-
import { handleAgentInbound } from "./agent-inbound.js";
|
|
7
|
-
import { extractLeadingSlashCommand, isHighPriorityCommand } from "./commands.js";
|
|
8
|
-
import { DEBOUNCE_MS, MAIN_RESPONSE_IDLE_CLOSE_MS, THINKING_PLACEHOLDER } from "./constants.js";
|
|
9
|
-
import { flushMessageBuffer, processInboundMessage } from "./inbound-processor.js";
|
|
10
|
-
import { messageBuffers, streamMeta, webhookTargets } from "./state.js";
|
|
11
|
-
import {
|
|
12
|
-
clearBufferedMessagesForStream,
|
|
13
|
-
getMessageStreamKey,
|
|
14
|
-
handleStreamError,
|
|
15
|
-
} from "./stream-utils.js";
|
|
16
|
-
import { normalizeWebhookPath } from "./webhook-targets.js";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Create a per-route HTTP handler for the new `registerPluginHttpRoute` API.
|
|
20
|
-
*
|
|
21
|
-
* The route framework already matches by path, so this handler resolves
|
|
22
|
-
* targets at call time from the pre-registered in-memory map.
|
|
23
|
-
*
|
|
24
|
-
* @param {string} routePath - Normalized webhook path (e.g. "/webhooks/wecom")
|
|
25
|
-
* @returns {(req: IncomingMessage, res: ServerResponse) => Promise<void>}
|
|
26
|
-
*/
|
|
27
|
-
export function createWecomRouteHandler(routePath) {
|
|
28
|
-
return async (req, res) => {
|
|
29
|
-
const targets = webhookTargets.get(routePath);
|
|
30
|
-
if (!targets || targets.length === 0) {
|
|
31
|
-
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
32
|
-
res.end("No webhook target configured");
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
const url = new URL(req.url || "", "http://localhost");
|
|
36
|
-
const query = Object.fromEntries(url.searchParams);
|
|
37
|
-
await handleWecomRequest(req, res, targets, query, routePath);
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Legacy wildcard HTTP handler for older OpenClaw versions that still support
|
|
43
|
-
* `api.registerHttpHandler()`. Returns `false` when the path is not handled.
|
|
44
|
-
*/
|
|
45
|
-
export async function wecomHttpHandler(req, res) {
|
|
46
|
-
const url = new URL(req.url || "", "http://localhost");
|
|
47
|
-
const path = normalizeWebhookPath(url.pathname);
|
|
48
|
-
const targets = webhookTargets.get(path);
|
|
49
|
-
|
|
50
|
-
if (!targets || targets.length === 0) {
|
|
51
|
-
// Return a proper HTTP response instead of `false`. Returning false tells
|
|
52
|
-
// OpenClaw 3.x "not handled", which causes the SPA catch-all to serve the
|
|
53
|
-
// chat UI on webhook paths (issue #81).
|
|
54
|
-
logger.debug("WeCom: no webhook target registered for path", { path });
|
|
55
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
56
|
-
res.end(`No WeCom webhook target configured for ${path}`);
|
|
57
|
-
return true;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const query = Object.fromEntries(url.searchParams);
|
|
61
|
-
await handleWecomRequest(req, res, targets, query, path);
|
|
62
|
-
return true;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Shared request handling logic used by both the legacy wildcard handler and
|
|
67
|
-
* the new per-route handler.
|
|
68
|
-
*/
|
|
69
|
-
async function handleWecomRequest(req, res, targets, query, path) {
|
|
70
|
-
logger.debug("WeCom HTTP request", { method: req.method, path });
|
|
71
|
-
|
|
72
|
-
// ── Agent inbound: route to dedicated handler when target has agentInbound config ──
|
|
73
|
-
const agentTarget = targets.find((t) => t.account?.agentInbound);
|
|
74
|
-
if (agentTarget) {
|
|
75
|
-
await handleAgentInbound({
|
|
76
|
-
req,
|
|
77
|
-
res,
|
|
78
|
-
agentAccount: agentTarget.account.agentInbound,
|
|
79
|
-
config: agentTarget.config,
|
|
80
|
-
});
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ── Bot mode: JSON-based stream handling ──
|
|
85
|
-
|
|
86
|
-
// GET: URL Verification
|
|
87
|
-
if (req.method === "GET") {
|
|
88
|
-
const target = targets[0]; // Use first target for verification
|
|
89
|
-
if (!target) {
|
|
90
|
-
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
91
|
-
res.end("No webhook target configured");
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const webhook = new WecomWebhook({
|
|
96
|
-
token: target.account.token,
|
|
97
|
-
encodingAesKey: target.account.encodingAesKey,
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
const echo = webhook.handleVerify(query);
|
|
101
|
-
if (echo) {
|
|
102
|
-
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
103
|
-
res.end(echo);
|
|
104
|
-
logger.info("WeCom URL verification successful");
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
109
|
-
res.end("Verification failed");
|
|
110
|
-
logger.warn("WeCom URL verification failed");
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// POST: Message handling
|
|
115
|
-
if (req.method === "POST") {
|
|
116
|
-
const target = targets[0];
|
|
117
|
-
if (!target) {
|
|
118
|
-
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
119
|
-
res.end("No webhook target configured");
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Read request body
|
|
124
|
-
const chunks = [];
|
|
125
|
-
for await (const chunk of req) {
|
|
126
|
-
chunks.push(chunk);
|
|
127
|
-
}
|
|
128
|
-
const body = Buffer.concat(chunks).toString("utf-8");
|
|
129
|
-
logger.debug("WeCom message received", { bodyLength: body.length });
|
|
130
|
-
|
|
131
|
-
if (body.trimStart().startsWith("<")) {
|
|
132
|
-
logger.warn("WeCom: XML body received on Bot webhook (expected JSON). Agent callbacks should use /webhooks/app endpoint.", {
|
|
133
|
-
path,
|
|
134
|
-
bodyPreview: body.substring(0, 120),
|
|
135
|
-
});
|
|
136
|
-
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
137
|
-
res.end(
|
|
138
|
-
"Invalid format: Bot webhook expects JSON.\n" +
|
|
139
|
-
"If you are configuring a WeCom self-built application (自建应用), " +
|
|
140
|
-
"use the Agent callback URL: /webhooks/app/{accountId}",
|
|
141
|
-
);
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const webhook = new WecomWebhook({
|
|
146
|
-
token: target.account.token,
|
|
147
|
-
encodingAesKey: target.account.encodingAesKey,
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
const result = await webhook.handleMessage(query, body);
|
|
151
|
-
if (result === WecomWebhook.DUPLICATE) {
|
|
152
|
-
// Duplicate message — ACK 200 to prevent platform retry storm.
|
|
153
|
-
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
154
|
-
res.end("success");
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
if (!result) {
|
|
158
|
-
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
159
|
-
res.end("Bad Request");
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Handle text message
|
|
164
|
-
if (result.message) {
|
|
165
|
-
const msg = result.message;
|
|
166
|
-
const { timestamp, nonce } = result.query;
|
|
167
|
-
const content = (msg.content || "").trim();
|
|
168
|
-
|
|
169
|
-
// Use stream responses for every inbound message, including commands.
|
|
170
|
-
// WeCom AI Bot response_url is single-use, so streaming is mandatory.
|
|
171
|
-
const streamId = `stream_${crypto.randomUUID()}`;
|
|
172
|
-
streamManager.createStream(streamId);
|
|
173
|
-
streamManager.appendStream(streamId, THINKING_PLACEHOLDER);
|
|
174
|
-
|
|
175
|
-
// Passive reply: return stream id with thinking_content so the WeCom
|
|
176
|
-
// client shows the collapsible "thinking" UI while the LLM processes.
|
|
177
|
-
const streamResponse = webhook.buildStreamResponse(
|
|
178
|
-
streamId,
|
|
179
|
-
"",
|
|
180
|
-
false,
|
|
181
|
-
timestamp,
|
|
182
|
-
nonce,
|
|
183
|
-
{ thinkingContent: THINKING_PLACEHOLDER },
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
187
|
-
res.end(streamResponse);
|
|
188
|
-
|
|
189
|
-
logger.info("Stream initiated", {
|
|
190
|
-
streamId,
|
|
191
|
-
from: msg.fromUser,
|
|
192
|
-
isCommand: content.startsWith("/"),
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
const streamKey = getMessageStreamKey(msg);
|
|
196
|
-
const isCommand = content.startsWith("/");
|
|
197
|
-
const leadingCommand = extractLeadingSlashCommand(content);
|
|
198
|
-
const highPriorityCommand = isHighPriorityCommand(leadingCommand);
|
|
199
|
-
|
|
200
|
-
if (highPriorityCommand) {
|
|
201
|
-
const drained = clearBufferedMessagesForStream(streamKey, `消息已被 ${leadingCommand} 中断。`);
|
|
202
|
-
if (drained > 0) {
|
|
203
|
-
logger.info("WeCom: drained buffered messages before high-priority command", {
|
|
204
|
-
streamKey,
|
|
205
|
-
command: leadingCommand,
|
|
206
|
-
drained,
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Commands bypass debounce — process immediately.
|
|
212
|
-
if (isCommand) {
|
|
213
|
-
processInboundMessage({
|
|
214
|
-
message: msg,
|
|
215
|
-
streamId,
|
|
216
|
-
timestamp,
|
|
217
|
-
nonce,
|
|
218
|
-
account: target.account,
|
|
219
|
-
config: target.config,
|
|
220
|
-
}).catch(async (err) => {
|
|
221
|
-
logger.error("WeCom message processing failed", { error: err.message });
|
|
222
|
-
await handleStreamError(streamId, streamKey, "处理消息时出错,请稍后再试。");
|
|
223
|
-
});
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Debounce: buffer non-command messages per user/group.
|
|
228
|
-
// If multiple messages arrive within DEBOUNCE_MS, merge into one dispatch.
|
|
229
|
-
const existing = messageBuffers.get(streamKey);
|
|
230
|
-
if (existing) {
|
|
231
|
-
// A previous message is still buffered — merge this one in.
|
|
232
|
-
existing.messages.push(msg);
|
|
233
|
-
existing.streamIds.push(streamId);
|
|
234
|
-
clearTimeout(existing.timer);
|
|
235
|
-
existing.timer = setTimeout(() => flushMessageBuffer(streamKey, target), DEBOUNCE_MS);
|
|
236
|
-
logger.info("WeCom: message buffered for merge", {
|
|
237
|
-
streamKey,
|
|
238
|
-
streamId,
|
|
239
|
-
buffered: existing.messages.length,
|
|
240
|
-
});
|
|
241
|
-
} else {
|
|
242
|
-
// First message — start a new buffer with a debounce timer.
|
|
243
|
-
const buffer = {
|
|
244
|
-
messages: [msg],
|
|
245
|
-
streamIds: [streamId],
|
|
246
|
-
target,
|
|
247
|
-
timestamp,
|
|
248
|
-
nonce,
|
|
249
|
-
timer: setTimeout(() => flushMessageBuffer(streamKey, target), DEBOUNCE_MS),
|
|
250
|
-
};
|
|
251
|
-
messageBuffers.set(streamKey, buffer);
|
|
252
|
-
logger.info("WeCom: message buffered (first)", { streamKey, streamId });
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Handle stream refresh - return current stream state
|
|
259
|
-
if (result.stream) {
|
|
260
|
-
const { timestamp, nonce } = result.query;
|
|
261
|
-
const streamId = result.stream.id;
|
|
262
|
-
|
|
263
|
-
// Return latest stream state.
|
|
264
|
-
const stream = streamManager.getStream(streamId);
|
|
265
|
-
|
|
266
|
-
if (!stream) {
|
|
267
|
-
// Stream already expired or missing.
|
|
268
|
-
logger.warn("Stream not found for refresh", { streamId });
|
|
269
|
-
const streamResponse = webhook.buildStreamResponse(
|
|
270
|
-
streamId,
|
|
271
|
-
"会话已过期",
|
|
272
|
-
true,
|
|
273
|
-
timestamp,
|
|
274
|
-
nonce,
|
|
275
|
-
);
|
|
276
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
277
|
-
res.end(streamResponse);
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Check if stream should be closed (main response done + idle timeout).
|
|
282
|
-
// This is driven by WeCom client polling, so it's more reliable than setTimeout.
|
|
283
|
-
const meta = streamMeta.get(streamId);
|
|
284
|
-
if (meta?.mainResponseDone && !stream.finished) {
|
|
285
|
-
const idleMs = Date.now() - stream.updatedAt;
|
|
286
|
-
// Keep stream alive a bit longer for delayed subagent/tool follow-up messages.
|
|
287
|
-
if (idleMs > MAIN_RESPONSE_IDLE_CLOSE_MS) {
|
|
288
|
-
logger.info("WeCom: closing stream due to idle timeout", { streamId, idleMs });
|
|
289
|
-
try {
|
|
290
|
-
await streamManager.finishStream(streamId);
|
|
291
|
-
} catch (err) {
|
|
292
|
-
logger.error("WeCom: failed to finish stream", { streamId, error: err.message });
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Parse thinking tags from accumulated content so WeCom can display
|
|
298
|
-
// the model's reasoning in a collapsible section.
|
|
299
|
-
const { visibleContent, thinkingContent, isThinking } =
|
|
300
|
-
parseThinkingContent(stream.content);
|
|
301
|
-
|
|
302
|
-
// While the model is still thinking (unclosed <think> tag) and there is
|
|
303
|
-
// no visible content yet, keep showing the placeholder in thinking_content.
|
|
304
|
-
const effectiveThinking =
|
|
305
|
-
thinkingContent || (isThinking ? THINKING_PLACEHOLDER : "");
|
|
306
|
-
|
|
307
|
-
// Return current stream payload.
|
|
308
|
-
const streamResponse = webhook.buildStreamResponse(
|
|
309
|
-
streamId,
|
|
310
|
-
visibleContent,
|
|
311
|
-
stream.finished,
|
|
312
|
-
timestamp,
|
|
313
|
-
nonce,
|
|
314
|
-
{
|
|
315
|
-
// Pass msgItem only when stream is finished and has images.
|
|
316
|
-
...(stream.finished && stream.msgItem.length > 0
|
|
317
|
-
? { msgItem: stream.msgItem }
|
|
318
|
-
: {}),
|
|
319
|
-
// Include thinking content when available.
|
|
320
|
-
...(effectiveThinking ? { thinkingContent: effectiveThinking } : {}),
|
|
321
|
-
},
|
|
322
|
-
);
|
|
323
|
-
|
|
324
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
325
|
-
res.end(streamResponse);
|
|
326
|
-
|
|
327
|
-
logger.debug("Stream refresh response sent", {
|
|
328
|
-
streamId,
|
|
329
|
-
contentLength: stream.content.length,
|
|
330
|
-
finished: stream.finished,
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
// Clean up completed streams after a short delay.
|
|
334
|
-
if (stream.finished) {
|
|
335
|
-
setTimeout(() => {
|
|
336
|
-
streamManager.deleteStream(streamId);
|
|
337
|
-
streamMeta.delete(streamId);
|
|
338
|
-
}, 30 * 1000);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Handle event
|
|
345
|
-
if (result.event) {
|
|
346
|
-
logger.info("WeCom event received", { event: result.event });
|
|
347
|
-
|
|
348
|
-
// Handle enter_chat with an immediate welcome stream.
|
|
349
|
-
if (result.event?.event_type === "enter_chat") {
|
|
350
|
-
const { timestamp, nonce } = result.query;
|
|
351
|
-
const fromUser = result.event?.from?.userid || "";
|
|
352
|
-
|
|
353
|
-
// Welcome message body.
|
|
354
|
-
const welcomeMessage = `你好!👋 我是 AI 助手。
|
|
355
|
-
|
|
356
|
-
你可以使用下面的指令管理会话:
|
|
357
|
-
• **/new** - 新建会话(清空上下文)
|
|
358
|
-
• **/compact** - 压缩会话(保留上下文摘要)
|
|
359
|
-
• **/help** - 查看更多命令
|
|
360
|
-
|
|
361
|
-
有什么我可以帮你的吗?`;
|
|
362
|
-
|
|
363
|
-
// Build and finish stream in a single pass.
|
|
364
|
-
const streamId = `welcome_${crypto.randomUUID()}`;
|
|
365
|
-
streamManager.createStream(streamId);
|
|
366
|
-
streamManager.appendStream(streamId, welcomeMessage);
|
|
367
|
-
await streamManager.finishStream(streamId);
|
|
368
|
-
|
|
369
|
-
const streamResponse = webhook.buildStreamResponse(
|
|
370
|
-
streamId,
|
|
371
|
-
welcomeMessage,
|
|
372
|
-
true,
|
|
373
|
-
timestamp,
|
|
374
|
-
nonce,
|
|
375
|
-
);
|
|
376
|
-
|
|
377
|
-
logger.info("Sending welcome message", { fromUser, streamId });
|
|
378
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
379
|
-
res.end(streamResponse);
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
384
|
-
res.end("success");
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
389
|
-
res.end("success");
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
res.writeHead(405, { "Content-Type": "text/plain" });
|
|
394
|
-
res.end("Method Not Allowed");
|
|
395
|
-
}
|