@sunnoy/wecom 1.2.0 → 1.4.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.
@@ -0,0 +1,90 @@
1
+ import {
2
+ DEFAULT_COMMAND_ALLOWLIST,
3
+ DEFAULT_COMMAND_BLOCK_MESSAGE,
4
+ HIGH_PRIORITY_COMMANDS,
5
+ } from "./constants.js";
6
+
7
+ /**
8
+ * Read command allowlist settings from config.
9
+ *
10
+ * Accepts either the full openclaw config or a per-account wecom config block.
11
+ */
12
+ export function getCommandConfig(config) {
13
+ const wecom = config?.channels?.wecom ?? config ?? {};
14
+ const commands = wecom.commands || {};
15
+ return {
16
+ allowlist: commands.allowlist || DEFAULT_COMMAND_ALLOWLIST,
17
+ blockMessage: commands.blockMessage || DEFAULT_COMMAND_BLOCK_MESSAGE,
18
+ enabled: commands.enabled !== false,
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Check whether a slash command is allowed.
24
+ * @param {string} message - User message
25
+ * @param {Object} config - OpenClaw config
26
+ * @returns {{ isCommand: boolean, allowed: boolean, command: string | null }}
27
+ */
28
+ export function checkCommandAllowlist(message, config) {
29
+ const trimmed = String(message || "").trim();
30
+
31
+ // Not a slash command.
32
+ if (!trimmed.startsWith("/")) {
33
+ return { isCommand: false, allowed: true, command: null };
34
+ }
35
+
36
+ // Use the first token as the command.
37
+ const command = trimmed.split(/\s+/)[0].toLowerCase();
38
+
39
+ const cmdConfig = getCommandConfig(config);
40
+
41
+ // Allow all commands when command gating is disabled.
42
+ if (!cmdConfig.enabled) {
43
+ return { isCommand: true, allowed: true, command };
44
+ }
45
+
46
+ // Require explicit allowlist match.
47
+ const allowed = cmdConfig.allowlist.some((cmd) => cmd.toLowerCase() === command);
48
+
49
+ return { isCommand: true, allowed, command };
50
+ }
51
+
52
+ /**
53
+ * Read admin user list from wecom config.
54
+ * Admins bypass the command allowlist, but still keep dynamic agent routing.
55
+ *
56
+ * Accepts either the full openclaw config or a per-account wecom config block.
57
+ */
58
+ export function getWecomAdminUsers(config) {
59
+ const wecom = config?.channels?.wecom ?? config ?? {};
60
+ const raw = wecom.adminUsers;
61
+ if (!Array.isArray(raw)) {
62
+ return [];
63
+ }
64
+ return raw
65
+ .map((u) => String(u ?? "").trim().toLowerCase())
66
+ .filter(Boolean);
67
+ }
68
+
69
+ export function isWecomAdmin(userId, config) {
70
+ if (!userId) {
71
+ return false;
72
+ }
73
+ const admins = getWecomAdminUsers(config);
74
+ return admins.length > 0 && admins.includes(String(userId).trim().toLowerCase());
75
+ }
76
+
77
+ export function extractLeadingSlashCommand(content) {
78
+ const trimmed = String(content || "").trim();
79
+ if (!trimmed.startsWith("/")) {
80
+ return null;
81
+ }
82
+ return trimmed.split(/\s+/)[0].toLowerCase();
83
+ }
84
+
85
+ export function isHighPriorityCommand(command) {
86
+ if (!command) {
87
+ return false;
88
+ }
89
+ return HIGH_PRIORITY_COMMANDS.has(command.toLowerCase());
90
+ }
@@ -0,0 +1,58 @@
1
+ import { join } from "node:path";
2
+
3
+ export const DEFAULT_ACCOUNT_ID = "default";
4
+
5
+ // Placeholder shown while the LLM is processing or the message is queued.
6
+ export const THINKING_PLACEHOLDER = "思考中...";
7
+
8
+ // Image cache directory.
9
+ export const MEDIA_CACHE_DIR = join(process.env.HOME || "/tmp", ".openclaw", "media", "wecom");
10
+
11
+ // Slash commands that are allowed by default.
12
+ export const DEFAULT_COMMAND_ALLOWLIST = ["/new", "/compact", "/help", "/status"];
13
+ export const HIGH_PRIORITY_COMMANDS = new Set(["/stop", "/new"]);
14
+
15
+ // Default message shown when a command is blocked.
16
+ export const DEFAULT_COMMAND_BLOCK_MESSAGE = `⚠️ 该命令不可用。
17
+
18
+ 支持的命令:
19
+ • **/new** - 新建会话
20
+ • **/compact** - 压缩会话(保留上下文摘要)
21
+ • **/help** - 查看帮助
22
+ • **/status** - 查看状态`;
23
+
24
+ // Files recognised by openclaw core as bootstrap files.
25
+ export const BOOTSTRAP_FILENAMES = new Set([
26
+ "AGENTS.md",
27
+ "SOUL.md",
28
+ "TOOLS.md",
29
+ "IDENTITY.md",
30
+ "USER.md",
31
+ "HEARTBEAT.md",
32
+ "BOOTSTRAP.md",
33
+ ]);
34
+
35
+ // Per-user message debounce buffer.
36
+ // Collects messages arriving within DEBOUNCE_MS into a single dispatch.
37
+ export const DEBOUNCE_MS = 2000;
38
+
39
+ export const MAIN_RESPONSE_IDLE_CLOSE_MS = 30 * 1000;
40
+ export const SAFETY_NET_IDLE_CLOSE_MS = 90 * 1000;
41
+ export const RESPONSE_URL_ERROR_BODY_PREVIEW_MAX = 300;
42
+
43
+ // Agent API endpoints (self-built application mode).
44
+ export const AGENT_API_ENDPOINTS = {
45
+ GET_TOKEN: "https://qyapi.weixin.qq.com/cgi-bin/gettoken",
46
+ SEND_MESSAGE: "https://qyapi.weixin.qq.com/cgi-bin/message/send",
47
+ SEND_APPCHAT: "https://qyapi.weixin.qq.com/cgi-bin/appchat/send",
48
+ UPLOAD_MEDIA: "https://qyapi.weixin.qq.com/cgi-bin/media/upload",
49
+ DOWNLOAD_MEDIA: "https://qyapi.weixin.qq.com/cgi-bin/media/get",
50
+ };
51
+
52
+ export const TOKEN_REFRESH_BUFFER_MS = 60 * 1000;
53
+ export const AGENT_API_REQUEST_TIMEOUT_MS = 15 * 1000;
54
+ export const MAX_REQUEST_BODY_SIZE = 1024 * 1024; // 1 MB
55
+
56
+ // Webhook Bot endpoints (group robot notifications).
57
+ export const WEBHOOK_BOT_SEND_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send";
58
+ export const WEBHOOK_BOT_UPLOAD_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media";
@@ -0,0 +1,315 @@
1
+ import * as crypto from "node:crypto";
2
+ import { logger } from "../logger.js";
3
+ import { streamManager } from "../stream-manager.js";
4
+ import { WecomWebhook } from "../webhook.js";
5
+ import { handleAgentInbound } from "./agent-inbound.js";
6
+ import { extractLeadingSlashCommand, isHighPriorityCommand } from "./commands.js";
7
+ import { DEBOUNCE_MS, MAIN_RESPONSE_IDLE_CLOSE_MS, THINKING_PLACEHOLDER } from "./constants.js";
8
+ import { flushMessageBuffer, processInboundMessage } from "./inbound-processor.js";
9
+ import { messageBuffers, streamMeta, webhookTargets } from "./state.js";
10
+ import {
11
+ clearBufferedMessagesForStream,
12
+ getMessageStreamKey,
13
+ handleStreamError,
14
+ } from "./stream-utils.js";
15
+ import { normalizeWebhookPath } from "./webhook-targets.js";
16
+
17
+ export async function wecomHttpHandler(req, res) {
18
+ const url = new URL(req.url || "", "http://localhost");
19
+ const path = normalizeWebhookPath(url.pathname);
20
+ const targets = webhookTargets.get(path);
21
+
22
+ if (!targets || targets.length === 0) {
23
+ return false; // Not handled by this plugin
24
+ }
25
+
26
+ const query = Object.fromEntries(url.searchParams);
27
+ logger.debug("WeCom HTTP request", { method: req.method, path });
28
+
29
+ // ── Agent inbound: route to dedicated handler when target has agentInbound config ──
30
+ const agentTarget = targets.find((t) => t.account?.agentInbound);
31
+ if (agentTarget) {
32
+ return handleAgentInbound({
33
+ req,
34
+ res,
35
+ agentAccount: agentTarget.account.agentInbound,
36
+ config: agentTarget.config,
37
+ });
38
+ }
39
+
40
+ // ── Bot mode: JSON-based stream handling ──
41
+
42
+ // GET: URL Verification
43
+ if (req.method === "GET") {
44
+ const target = targets[0]; // Use first target for verification
45
+ if (!target) {
46
+ res.writeHead(503, { "Content-Type": "text/plain" });
47
+ res.end("No webhook target configured");
48
+ return true;
49
+ }
50
+
51
+ const webhook = new WecomWebhook({
52
+ token: target.account.token,
53
+ encodingAesKey: target.account.encodingAesKey,
54
+ });
55
+
56
+ const echo = webhook.handleVerify(query);
57
+ if (echo) {
58
+ res.writeHead(200, { "Content-Type": "text/plain" });
59
+ res.end(echo);
60
+ logger.info("WeCom URL verification successful");
61
+ return true;
62
+ }
63
+
64
+ res.writeHead(403, { "Content-Type": "text/plain" });
65
+ res.end("Verification failed");
66
+ logger.warn("WeCom URL verification failed");
67
+ return true;
68
+ }
69
+
70
+ // POST: Message handling
71
+ if (req.method === "POST") {
72
+ const target = targets[0];
73
+ if (!target) {
74
+ res.writeHead(503, { "Content-Type": "text/plain" });
75
+ res.end("No webhook target configured");
76
+ return true;
77
+ }
78
+
79
+ // Read request body
80
+ const chunks = [];
81
+ for await (const chunk of req) {
82
+ chunks.push(chunk);
83
+ }
84
+ const body = Buffer.concat(chunks).toString("utf-8");
85
+ logger.debug("WeCom message received", { bodyLength: body.length });
86
+
87
+ const webhook = new WecomWebhook({
88
+ token: target.account.token,
89
+ encodingAesKey: target.account.encodingAesKey,
90
+ });
91
+
92
+ const result = await webhook.handleMessage(query, body);
93
+ if (result === WecomWebhook.DUPLICATE) {
94
+ // Duplicate message — ACK 200 to prevent platform retry storm.
95
+ res.writeHead(200, { "Content-Type": "text/plain" });
96
+ res.end("success");
97
+ return true;
98
+ }
99
+ if (!result) {
100
+ res.writeHead(400, { "Content-Type": "text/plain" });
101
+ res.end("Bad Request");
102
+ return true;
103
+ }
104
+
105
+ // Handle text message
106
+ if (result.message) {
107
+ const msg = result.message;
108
+ const { timestamp, nonce } = result.query;
109
+ const content = (msg.content || "").trim();
110
+
111
+ // Use stream responses for every inbound message, including commands.
112
+ // WeCom AI Bot response_url is single-use, so streaming is mandatory.
113
+ const streamId = `stream_${crypto.randomUUID()}`;
114
+ streamManager.createStream(streamId);
115
+ streamManager.appendStream(streamId, THINKING_PLACEHOLDER);
116
+
117
+ // Passive reply: return stream id immediately in the sync response.
118
+ // Include the placeholder so the client displays it right away.
119
+ const streamResponse = webhook.buildStreamResponse(streamId, THINKING_PLACEHOLDER, false, timestamp, nonce);
120
+
121
+ res.writeHead(200, { "Content-Type": "application/json" });
122
+ res.end(streamResponse);
123
+
124
+ logger.info("Stream initiated", {
125
+ streamId,
126
+ from: msg.fromUser,
127
+ isCommand: content.startsWith("/"),
128
+ });
129
+
130
+ const streamKey = getMessageStreamKey(msg);
131
+ const isCommand = content.startsWith("/");
132
+ const leadingCommand = extractLeadingSlashCommand(content);
133
+ const highPriorityCommand = isHighPriorityCommand(leadingCommand);
134
+
135
+ if (highPriorityCommand) {
136
+ const drained = clearBufferedMessagesForStream(streamKey, `消息已被 ${leadingCommand} 中断。`);
137
+ if (drained > 0) {
138
+ logger.info("WeCom: drained buffered messages before high-priority command", {
139
+ streamKey,
140
+ command: leadingCommand,
141
+ drained,
142
+ });
143
+ }
144
+ }
145
+
146
+ // Commands bypass debounce — process immediately.
147
+ if (isCommand) {
148
+ processInboundMessage({
149
+ message: msg,
150
+ streamId,
151
+ timestamp,
152
+ nonce,
153
+ account: target.account,
154
+ config: target.config,
155
+ }).catch(async (err) => {
156
+ logger.error("WeCom message processing failed", { error: err.message });
157
+ await handleStreamError(streamId, streamKey, "处理消息时出错,请稍后再试。");
158
+ });
159
+ return true;
160
+ }
161
+
162
+ // Debounce: buffer non-command messages per user/group.
163
+ // If multiple messages arrive within DEBOUNCE_MS, merge into one dispatch.
164
+ const existing = messageBuffers.get(streamKey);
165
+ if (existing) {
166
+ // A previous message is still buffered — merge this one in.
167
+ existing.messages.push(msg);
168
+ existing.streamIds.push(streamId);
169
+ clearTimeout(existing.timer);
170
+ existing.timer = setTimeout(() => flushMessageBuffer(streamKey, target), DEBOUNCE_MS);
171
+ logger.info("WeCom: message buffered for merge", {
172
+ streamKey,
173
+ streamId,
174
+ buffered: existing.messages.length,
175
+ });
176
+ } else {
177
+ // First message — start a new buffer with a debounce timer.
178
+ const buffer = {
179
+ messages: [msg],
180
+ streamIds: [streamId],
181
+ target,
182
+ timestamp,
183
+ nonce,
184
+ timer: setTimeout(() => flushMessageBuffer(streamKey, target), DEBOUNCE_MS),
185
+ };
186
+ messageBuffers.set(streamKey, buffer);
187
+ logger.info("WeCom: message buffered (first)", { streamKey, streamId });
188
+ }
189
+
190
+ return true;
191
+ }
192
+
193
+ // Handle stream refresh - return current stream state
194
+ if (result.stream) {
195
+ const { timestamp, nonce } = result.query;
196
+ const streamId = result.stream.id;
197
+
198
+ // Return latest stream state.
199
+ const stream = streamManager.getStream(streamId);
200
+
201
+ if (!stream) {
202
+ // Stream already expired or missing.
203
+ logger.warn("Stream not found for refresh", { streamId });
204
+ const streamResponse = webhook.buildStreamResponse(
205
+ streamId,
206
+ "会话已过期",
207
+ true,
208
+ timestamp,
209
+ nonce,
210
+ );
211
+ res.writeHead(200, { "Content-Type": "application/json" });
212
+ res.end(streamResponse);
213
+ return true;
214
+ }
215
+
216
+ // Check if stream should be closed (main response done + idle timeout).
217
+ // This is driven by WeCom client polling, so it's more reliable than setTimeout.
218
+ const meta = streamMeta.get(streamId);
219
+ if (meta?.mainResponseDone && !stream.finished) {
220
+ const idleMs = Date.now() - stream.updatedAt;
221
+ // Keep stream alive a bit longer for delayed subagent/tool follow-up messages.
222
+ if (idleMs > MAIN_RESPONSE_IDLE_CLOSE_MS) {
223
+ logger.info("WeCom: closing stream due to idle timeout", { streamId, idleMs });
224
+ try {
225
+ await streamManager.finishStream(streamId);
226
+ } catch (err) {
227
+ logger.error("WeCom: failed to finish stream", { streamId, error: err.message });
228
+ }
229
+ }
230
+ }
231
+
232
+ // Return current stream payload.
233
+ const streamResponse = webhook.buildStreamResponse(
234
+ streamId,
235
+ stream.content,
236
+ stream.finished,
237
+ timestamp,
238
+ nonce,
239
+ // Pass msgItem when stream is finished and has images
240
+ stream.finished && stream.msgItem.length > 0 ? { msgItem: stream.msgItem } : {},
241
+ );
242
+
243
+ res.writeHead(200, { "Content-Type": "application/json" });
244
+ res.end(streamResponse);
245
+
246
+ logger.debug("Stream refresh response sent", {
247
+ streamId,
248
+ contentLength: stream.content.length,
249
+ finished: stream.finished,
250
+ });
251
+
252
+ // Clean up completed streams after a short delay.
253
+ if (stream.finished) {
254
+ setTimeout(() => {
255
+ streamManager.deleteStream(streamId);
256
+ streamMeta.delete(streamId);
257
+ }, 30 * 1000);
258
+ }
259
+
260
+ return true;
261
+ }
262
+
263
+ // Handle event
264
+ if (result.event) {
265
+ logger.info("WeCom event received", { event: result.event });
266
+
267
+ // Handle enter_chat with an immediate welcome stream.
268
+ if (result.event?.event_type === "enter_chat") {
269
+ const { timestamp, nonce } = result.query;
270
+ const fromUser = result.event?.from?.userid || "";
271
+
272
+ // Welcome message body.
273
+ const welcomeMessage = `你好!👋 我是 AI 助手。
274
+
275
+ 你可以使用下面的指令管理会话:
276
+ • **/new** - 新建会话(清空上下文)
277
+ • **/compact** - 压缩会话(保留上下文摘要)
278
+ • **/help** - 查看更多命令
279
+
280
+ 有什么我可以帮你的吗?`;
281
+
282
+ // Build and finish stream in a single pass.
283
+ const streamId = `welcome_${crypto.randomUUID()}`;
284
+ streamManager.createStream(streamId);
285
+ streamManager.appendStream(streamId, welcomeMessage);
286
+ await streamManager.finishStream(streamId);
287
+
288
+ const streamResponse = webhook.buildStreamResponse(
289
+ streamId,
290
+ welcomeMessage,
291
+ true,
292
+ timestamp,
293
+ nonce,
294
+ );
295
+
296
+ logger.info("Sending welcome message", { fromUser, streamId });
297
+ res.writeHead(200, { "Content-Type": "application/json" });
298
+ res.end(streamResponse);
299
+ return true;
300
+ }
301
+
302
+ res.writeHead(200, { "Content-Type": "text/plain" });
303
+ res.end("success");
304
+ return true;
305
+ }
306
+
307
+ res.writeHead(200, { "Content-Type": "text/plain" });
308
+ res.end("success");
309
+ return true;
310
+ }
311
+
312
+ res.writeHead(405, { "Content-Type": "text/plain" });
313
+ res.end("Method Not Allowed");
314
+ return true;
315
+ }