@max1874/openclaw-wecom 0.1.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/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@max1874/openclaw-wecom",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "OpenClaw WeChat/WeCom channel plugin via Stride",
6
+ "license": "MIT",
7
+ "author": "max1874",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/max1874/openclaw-wecom.git"
11
+ },
12
+ "homepage": "https://github.com/max1874/openclaw-wecom#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/max1874/openclaw-wecom/issues"
15
+ },
16
+ "main": "index.ts",
17
+ "exports": {
18
+ ".": "./index.ts",
19
+ "./plugin-sdk": "./index.ts"
20
+ },
21
+ "files": [
22
+ "index.ts",
23
+ "src",
24
+ "docs",
25
+ "openclaw.plugin.json"
26
+ ],
27
+ "keywords": [
28
+ "openclaw",
29
+ "wechat",
30
+ "wecom",
31
+ "微信",
32
+ "企业微信",
33
+ "stride",
34
+ "chatbot",
35
+ "ai",
36
+ "claude"
37
+ ],
38
+ "openclaw": {
39
+ "extensions": [
40
+ "./index.ts"
41
+ ],
42
+ "channel": {
43
+ "id": "wecom",
44
+ "label": "WeChat",
45
+ "selectionLabel": "WeChat/WeCom (微信/企业微信)",
46
+ "docsPath": "/channels/wecom",
47
+ "docsLabel": "wecom",
48
+ "blurb": "WeChat/WeCom messaging via Stride.",
49
+ "aliases": [
50
+ "wechat",
51
+ "stride"
52
+ ],
53
+ "order": 80
54
+ },
55
+ "install": {
56
+ "npmSpec": "@max1874/openclaw-wecom",
57
+ "localPath": ".",
58
+ "defaultChoice": "npm"
59
+ }
60
+ },
61
+ "dependencies": {
62
+ "zod": "^4.3.6"
63
+ },
64
+ "devDependencies": {
65
+ "@types/node": "^25.0.10",
66
+ "openclaw": "2026.1.29",
67
+ "tsx": "^4.21.0",
68
+ "typescript": "^5.7.0"
69
+ },
70
+ "peerDependencies": {
71
+ "openclaw": ">=2026.1.29"
72
+ }
73
+ }
@@ -0,0 +1,42 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
3
+ import type { WecomConfig, ResolvedWecomAccount } from "./types.js";
4
+
5
+ /**
6
+ * Resolve WeChat credentials from config or environment variables.
7
+ */
8
+ export function resolveWecomCredentials(
9
+ wecomCfg?: WecomConfig,
10
+ ): { token: string; chatId?: string } | null {
11
+ const token = wecomCfg?.token ?? process.env.WECOM_TOKEN ?? process.env.STRIDE_TOKEN;
12
+ if (!token) return null;
13
+
14
+ const chatId = wecomCfg?.chatId ?? process.env.WECOM_CHAT_ID ?? process.env.STRIDE_CHAT_ID;
15
+
16
+ return { token, chatId };
17
+ }
18
+
19
+ /**
20
+ * Check if WeChat channel is configured.
21
+ */
22
+ export function isWecomConfigured(wecomCfg?: WecomConfig): boolean {
23
+ return Boolean(resolveWecomCredentials(wecomCfg));
24
+ }
25
+
26
+ /**
27
+ * Resolve account information from config.
28
+ */
29
+ export function resolveWecomAccount(params: {
30
+ cfg: ClawdbotConfig;
31
+ }): ResolvedWecomAccount {
32
+ const wecomCfg = params.cfg.channels?.wecom as WecomConfig | undefined;
33
+ const creds = resolveWecomCredentials(wecomCfg);
34
+
35
+ return {
36
+ accountId: DEFAULT_ACCOUNT_ID,
37
+ enabled: wecomCfg?.enabled ?? true,
38
+ configured: Boolean(creds),
39
+ token: creds?.token,
40
+ chatId: creds?.chatId,
41
+ };
42
+ }
package/src/bot.ts ADDED
@@ -0,0 +1,377 @@
1
+ import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
2
+ import {
3
+ buildPendingHistoryContextFromMap,
4
+ recordPendingHistoryEntryIfEnabled,
5
+ clearHistoryEntriesIfEnabled,
6
+ DEFAULT_GROUP_HISTORY_LIMIT,
7
+ } from "openclaw/plugin-sdk";
8
+ import type {
9
+ WecomConfig,
10
+ WecomMessageContext,
11
+ WecomWebhookEvent,
12
+ TextPayload,
13
+ VoicePayload,
14
+ ImagePayload,
15
+ FilePayload,
16
+ LinkPayload,
17
+ ChatHistoryPayload,
18
+ } from "./types.js";
19
+ import { getWecomRuntime } from "./runtime.js";
20
+ import {
21
+ resolveWecomGroupConfig,
22
+ resolveWecomReplyPolicy,
23
+ resolveWecomAllowlistMatch,
24
+ isWecomGroupAllowed,
25
+ } from "./policy.js";
26
+ import { createWecomReplyDispatcher } from "./reply-dispatcher.js";
27
+ import { resolveWecomMediaList, buildWecomMediaPayload } from "./media.js";
28
+
29
+ /**
30
+ * Parse message content from webhook event payload.
31
+ */
32
+ function parseMessageContent(event: WecomWebhookEvent): {
33
+ content: string;
34
+ contentType: string;
35
+ mediaUrl?: string;
36
+ } {
37
+ const { type, payload } = event;
38
+
39
+ switch (type) {
40
+ case 7: {
41
+ // Text message
42
+ const textPayload = payload as TextPayload;
43
+ // Use pureText if available (removes @mentions), otherwise use text
44
+ return {
45
+ content: textPayload.pureText?.trim() || textPayload.text || "",
46
+ contentType: "text",
47
+ };
48
+ }
49
+
50
+ case 2: {
51
+ // Voice message - use transcribed text if available
52
+ const voicePayload = payload as VoicePayload;
53
+ const voiceContent = voicePayload.text
54
+ ? `[Voice] ${voicePayload.text}`
55
+ : "[Voice message]";
56
+ return {
57
+ content: voiceContent,
58
+ contentType: "voice",
59
+ mediaUrl: voicePayload.voiceUrl,
60
+ };
61
+ }
62
+
63
+ case 6: {
64
+ // Image message
65
+ const imagePayload = payload as ImagePayload;
66
+ return {
67
+ content: "[Image]",
68
+ contentType: "image",
69
+ mediaUrl: imagePayload.imageUrl,
70
+ };
71
+ }
72
+
73
+ case 1: {
74
+ // File message
75
+ const filePayload = payload as FilePayload;
76
+ return {
77
+ content: `[File] ${filePayload.name || "file"}`,
78
+ contentType: "file",
79
+ mediaUrl: filePayload.fileUrl,
80
+ };
81
+ }
82
+
83
+ case 12: {
84
+ // Link message
85
+ const linkPayload = payload as LinkPayload;
86
+ return {
87
+ content: `[Link] ${linkPayload.title || ""}\n${linkPayload.url || ""}`,
88
+ contentType: "link",
89
+ };
90
+ }
91
+
92
+ case 4: {
93
+ // Chat history (merged forward)
94
+ const historyPayload = payload as ChatHistoryPayload;
95
+ const historyContent = historyPayload.chatHistoryList
96
+ ?.map((item) => {
97
+ const sender = item.senderName || "Unknown";
98
+ const msg = item.message.content || item.message.imageUrl || "[media]";
99
+ return `${sender}: ${msg}`;
100
+ })
101
+ .join("\n");
102
+ return {
103
+ content: `[Chat History]\n${historyContent || historyPayload.content || ""}`,
104
+ contentType: "chatHistory",
105
+ };
106
+ }
107
+
108
+ case 10000:
109
+ case 10001: {
110
+ // System messages - ignore for now
111
+ return {
112
+ content: "",
113
+ contentType: "system",
114
+ };
115
+ }
116
+
117
+ default:
118
+ return {
119
+ content: "[Unknown message type]",
120
+ contentType: "unknown",
121
+ };
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Parse a webhook event into a message context.
127
+ */
128
+ export function parseWecomMessageEvent(event: WecomWebhookEvent): WecomMessageContext {
129
+ const { content, contentType, mediaUrl } = parseMessageContent(event);
130
+
131
+ // Determine if this is a group or DM
132
+ // If roomId exists, it's a group chat
133
+ const isGroup = Boolean(event.roomId);
134
+
135
+ return {
136
+ chatId: event.chatId,
137
+ contactId: event.contactId,
138
+ contactName: event.contactName,
139
+ roomId: event.roomId,
140
+ roomTopic: event.roomTopic,
141
+ chatType: isGroup ? "group" : "dm",
142
+ mentionedBot: event.mentionSelf,
143
+ content,
144
+ contentType,
145
+ mediaUrl,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Handle an incoming WeChat message webhook event.
151
+ */
152
+ export async function handleWecomMessage(params: {
153
+ cfg: ClawdbotConfig;
154
+ event: WecomWebhookEvent;
155
+ runtime?: RuntimeEnv;
156
+ chatHistories?: Map<string, HistoryEntry[]>;
157
+ }): Promise<void> {
158
+ const { cfg, event, runtime, chatHistories } = params;
159
+ const wecomCfg = cfg.channels?.wecom as WecomConfig | undefined;
160
+ const log = runtime?.log ?? console.log;
161
+ const error = runtime?.error ?? console.error;
162
+
163
+ // Skip self messages
164
+ if (event.isSelf) {
165
+ log(`wecom: skipping self message`);
166
+ return;
167
+ }
168
+
169
+ // Skip system messages
170
+ if (event.type === 10000 || event.type === 10001) {
171
+ log(`wecom: skipping system message type ${event.type}`);
172
+ return;
173
+ }
174
+
175
+ const ctx = parseWecomMessageEvent(event);
176
+ const isGroup = ctx.chatType === "group";
177
+
178
+ // Skip empty content
179
+ if (!ctx.content.trim()) {
180
+ log(`wecom: skipping empty message`);
181
+ return;
182
+ }
183
+
184
+ log(`wecom: received ${ctx.contentType} from ${ctx.contactName} (${ctx.contactId}) in ${ctx.chatId} (${ctx.chatType})`);
185
+
186
+ const historyLimit = Math.max(
187
+ 0,
188
+ wecomCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
189
+ );
190
+
191
+ // Check policies
192
+ if (isGroup) {
193
+ const groupPolicy = wecomCfg?.groupPolicy ?? "allowlist";
194
+ const groupAllowFrom = wecomCfg?.groupAllowFrom ?? [];
195
+ const groupConfig = resolveWecomGroupConfig({ cfg: wecomCfg, groupId: ctx.roomId ?? ctx.chatId });
196
+
197
+ const senderAllowFrom = groupConfig?.allowFrom ?? groupAllowFrom;
198
+ const allowed = isWecomGroupAllowed({
199
+ groupPolicy,
200
+ allowFrom: senderAllowFrom,
201
+ senderId: ctx.contactId,
202
+ senderName: ctx.contactName,
203
+ });
204
+
205
+ if (!allowed) {
206
+ log(`wecom: sender ${ctx.contactId} not in group allowlist`);
207
+ return;
208
+ }
209
+
210
+ const { requireMention } = resolveWecomReplyPolicy({
211
+ isDirectMessage: false,
212
+ globalConfig: wecomCfg,
213
+ groupConfig,
214
+ });
215
+
216
+ if (requireMention && !ctx.mentionedBot) {
217
+ log(`wecom: message in group ${ctx.chatId} did not mention bot, recording to history`);
218
+ if (chatHistories) {
219
+ recordPendingHistoryEntryIfEnabled({
220
+ historyMap: chatHistories,
221
+ historyKey: ctx.chatId,
222
+ limit: historyLimit,
223
+ entry: {
224
+ sender: ctx.contactId,
225
+ body: `${ctx.contactName}: ${ctx.content}`,
226
+ timestamp: Date.now(),
227
+ messageId: `${ctx.chatId}:${Date.now()}`,
228
+ },
229
+ });
230
+ }
231
+ return;
232
+ }
233
+ } else {
234
+ // DM policy check
235
+ const dmPolicy = wecomCfg?.dmPolicy ?? "allowlist";
236
+ const allowFrom = wecomCfg?.allowFrom ?? [];
237
+
238
+ if (dmPolicy === "allowlist") {
239
+ const match = resolveWecomAllowlistMatch({
240
+ allowFrom,
241
+ senderId: ctx.contactId,
242
+ senderName: ctx.contactName,
243
+ });
244
+ if (!match.allowed) {
245
+ log(`wecom: sender ${ctx.contactId} not in DM allowlist`);
246
+ return;
247
+ }
248
+ }
249
+ }
250
+
251
+ try {
252
+ const core = getWecomRuntime();
253
+
254
+ // Build From/To identifiers
255
+ const wecomFrom = `wecom:${ctx.contactId}`;
256
+ const wecomTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.contactId}`;
257
+
258
+ const route = core.channel.routing.resolveAgentRoute({
259
+ cfg,
260
+ channel: "wecom",
261
+ peer: {
262
+ kind: isGroup ? "group" : "dm",
263
+ id: isGroup ? (ctx.roomId ?? ctx.chatId) : ctx.contactId,
264
+ },
265
+ });
266
+
267
+ const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
268
+ const inboundLabel = isGroup
269
+ ? `WeChat message in group ${ctx.roomTopic || ctx.chatId}`
270
+ : `WeChat DM from ${ctx.contactName}`;
271
+
272
+ core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
273
+ sessionKey: route.sessionKey,
274
+ contextKey: `wecom:message:${ctx.chatId}:${Date.now()}`,
275
+ });
276
+
277
+ // Resolve media from message
278
+ const mediaMaxBytes = (wecomCfg?.mediaMaxMb ?? 30) * 1024 * 1024;
279
+ const mediaList = await resolveWecomMediaList({
280
+ cfg,
281
+ type: event.type,
282
+ payload: event.payload as Record<string, unknown>,
283
+ maxBytes: mediaMaxBytes,
284
+ log,
285
+ });
286
+ const mediaPayload = buildWecomMediaPayload(mediaList);
287
+
288
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
289
+
290
+ // Build message body with speaker label
291
+ const speaker = ctx.contactName || ctx.contactId;
292
+ let messageBody = `${speaker}: ${ctx.content}`;
293
+
294
+ const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.contactId}` : ctx.contactId;
295
+
296
+ const body = core.channel.reply.formatAgentEnvelope({
297
+ channel: "WeChat",
298
+ from: envelopeFrom,
299
+ timestamp: new Date(),
300
+ envelope: envelopeOptions,
301
+ body: messageBody,
302
+ });
303
+
304
+ let combinedBody = body;
305
+ const historyKey = isGroup ? ctx.chatId : undefined;
306
+
307
+ if (isGroup && historyKey && chatHistories) {
308
+ combinedBody = buildPendingHistoryContextFromMap({
309
+ historyMap: chatHistories,
310
+ historyKey,
311
+ limit: historyLimit,
312
+ currentMessage: combinedBody,
313
+ formatEntry: (entry) =>
314
+ core.channel.reply.formatAgentEnvelope({
315
+ channel: "WeChat",
316
+ from: `${ctx.chatId}:${entry.sender}`,
317
+ timestamp: entry.timestamp,
318
+ body: entry.body,
319
+ envelope: envelopeOptions,
320
+ }),
321
+ });
322
+ }
323
+
324
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
325
+ Body: combinedBody,
326
+ RawBody: ctx.content,
327
+ CommandBody: ctx.content,
328
+ From: wecomFrom,
329
+ To: wecomTo,
330
+ SessionKey: route.sessionKey,
331
+ AccountId: route.accountId,
332
+ ChatType: isGroup ? "group" : "direct",
333
+ GroupSubject: isGroup ? (ctx.roomTopic ?? ctx.chatId) : undefined,
334
+ SenderName: ctx.contactName || ctx.contactId,
335
+ SenderId: ctx.contactId,
336
+ Provider: "wecom" as const,
337
+ Surface: "wecom" as const,
338
+ MessageSid: `${ctx.chatId}:${Date.now()}`,
339
+ Timestamp: Date.now(),
340
+ WasMentioned: ctx.mentionedBot,
341
+ CommandAuthorized: true,
342
+ OriginatingChannel: "wecom" as const,
343
+ OriginatingTo: wecomTo,
344
+ ...mediaPayload,
345
+ });
346
+
347
+ const { dispatcher, replyOptions, markDispatchIdle } = createWecomReplyDispatcher({
348
+ cfg,
349
+ agentId: route.agentId,
350
+ runtime: runtime as RuntimeEnv,
351
+ chatId: ctx.chatId,
352
+ });
353
+
354
+ log(`wecom: dispatching to agent (session=${route.sessionKey})`);
355
+
356
+ const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
357
+ ctx: ctxPayload,
358
+ cfg,
359
+ dispatcher,
360
+ replyOptions,
361
+ });
362
+
363
+ markDispatchIdle();
364
+
365
+ if (isGroup && historyKey && chatHistories) {
366
+ clearHistoryEntriesIfEnabled({
367
+ historyMap: chatHistories,
368
+ historyKey,
369
+ limit: historyLimit,
370
+ });
371
+ }
372
+
373
+ log(`wecom: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`);
374
+ } catch (err) {
375
+ error(`wecom: failed to dispatch message: ${String(err)}`);
376
+ }
377
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,208 @@
1
+ import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
3
+ import type { ResolvedWecomAccount, WecomConfig } from "./types.js";
4
+ import { resolveWecomAccount, resolveWecomCredentials } from "./accounts.js";
5
+ import { wecomOutbound } from "./outbound.js";
6
+ import { probeWecom } from "./probe.js";
7
+ import { resolveWecomGroupToolPolicy } from "./policy.js";
8
+ import { normalizeWecomTarget, looksLikeWecomId, formatWecomTarget } from "./targets.js";
9
+ import { sendMessageWecom } from "./send.js";
10
+
11
+ const meta = {
12
+ id: "wecom",
13
+ label: "WeChat",
14
+ selectionLabel: "WeChat/WeCom (微信/企业微信)",
15
+ docsPath: "/channels/wecom",
16
+ docsLabel: "wecom",
17
+ blurb: "WeChat/WeCom messaging via Stride.",
18
+ aliases: ["wechat", "stride"],
19
+ order: 80,
20
+ } as const;
21
+
22
+ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
23
+ id: "wecom",
24
+ meta: {
25
+ ...meta,
26
+ },
27
+ pairing: {
28
+ idLabel: "wecomContactId",
29
+ normalizeAllowEntry: (entry) => entry.replace(/^(wecom|user|contact):/i, ""),
30
+ notifyApproval: async ({ cfg, id }) => {
31
+ await sendMessageWecom({
32
+ cfg,
33
+ to: id,
34
+ text: "Your access has been approved. You can now start chatting!",
35
+ });
36
+ },
37
+ },
38
+ capabilities: {
39
+ chatTypes: ["direct", "channel"],
40
+ polls: false,
41
+ threads: false, // WeChat doesn't support threads
42
+ media: true,
43
+ reactions: false, // Not supported via Stride
44
+ edit: false, // Not supported via Stride
45
+ reply: false, // Not supported via Stride
46
+ },
47
+ agentPrompt: {
48
+ messageToolHints: () => [
49
+ "- WeChat targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:contactId` or `chat:chatId`.",
50
+ "- WeChat does not support interactive cards or markdown. Use plain text.",
51
+ ],
52
+ },
53
+ groups: {
54
+ resolveToolPolicy: resolveWecomGroupToolPolicy,
55
+ },
56
+ reload: { configPrefixes: ["channels.wecom"] },
57
+ configSchema: {
58
+ schema: {
59
+ type: "object",
60
+ additionalProperties: false,
61
+ properties: {
62
+ enabled: { type: "boolean" },
63
+ token: { type: "string" },
64
+ chatId: { type: "string" },
65
+ webhookPath: { type: "string" },
66
+ webhookPort: { type: "integer", minimum: 1 },
67
+ dmPolicy: { type: "string", enum: ["open", "allowlist"] },
68
+ allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
69
+ groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
70
+ groupAllowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
71
+ requireMention: { type: "boolean" },
72
+ historyLimit: { type: "integer", minimum: 0 },
73
+ dmHistoryLimit: { type: "integer", minimum: 0 },
74
+ textChunkLimit: { type: "integer", minimum: 1 },
75
+ chunkMode: { type: "string", enum: ["length", "newline"] },
76
+ mediaMaxMb: { type: "number", minimum: 0 },
77
+ renderMode: { type: "string", enum: ["auto", "raw"] },
78
+ },
79
+ },
80
+ },
81
+ config: {
82
+ listAccountIds: () => [DEFAULT_ACCOUNT_ID],
83
+ resolveAccount: (cfg) => resolveWecomAccount({ cfg }),
84
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
85
+ setAccountEnabled: ({ cfg, enabled }) => ({
86
+ ...cfg,
87
+ channels: {
88
+ ...cfg.channels,
89
+ wecom: {
90
+ ...cfg.channels?.wecom,
91
+ enabled,
92
+ },
93
+ },
94
+ }),
95
+ deleteAccount: ({ cfg }) => {
96
+ const next = { ...cfg } as ClawdbotConfig;
97
+ const nextChannels = { ...cfg.channels };
98
+ delete (nextChannels as Record<string, unknown>).wecom;
99
+ if (Object.keys(nextChannels).length > 0) {
100
+ next.channels = nextChannels;
101
+ } else {
102
+ delete next.channels;
103
+ }
104
+ return next;
105
+ },
106
+ isConfigured: (_account, cfg) =>
107
+ Boolean(resolveWecomCredentials(cfg.channels?.wecom as WecomConfig | undefined)),
108
+ describeAccount: (account) => ({
109
+ accountId: account.accountId,
110
+ enabled: account.enabled,
111
+ configured: account.configured,
112
+ }),
113
+ resolveAllowFrom: ({ cfg }) =>
114
+ (cfg.channels?.wecom as WecomConfig | undefined)?.allowFrom ?? [],
115
+ formatAllowFrom: ({ allowFrom }) =>
116
+ allowFrom
117
+ .map((entry) => String(entry).trim())
118
+ .filter(Boolean)
119
+ .map((entry) => entry.toLowerCase()),
120
+ },
121
+ security: {
122
+ collectWarnings: ({ cfg }) => {
123
+ const wecomCfg = cfg.channels?.wecom as WecomConfig | undefined;
124
+ const defaultGroupPolicy = (cfg.channels as Record<string, { groupPolicy?: string }> | undefined)?.defaults?.groupPolicy;
125
+ const groupPolicy = wecomCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
126
+ if (groupPolicy !== "open") return [];
127
+ return [
128
+ `- WeChat groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.wecom.groupPolicy="allowlist" + channels.wecom.groupAllowFrom to restrict senders.`,
129
+ ];
130
+ },
131
+ },
132
+ setup: {
133
+ resolveAccountId: () => DEFAULT_ACCOUNT_ID,
134
+ applyAccountConfig: ({ cfg }) => ({
135
+ ...cfg,
136
+ channels: {
137
+ ...cfg.channels,
138
+ wecom: {
139
+ ...cfg.channels?.wecom,
140
+ enabled: true,
141
+ },
142
+ },
143
+ }),
144
+ },
145
+ messaging: {
146
+ normalizeTarget: normalizeWecomTarget,
147
+ targetResolver: {
148
+ looksLikeId: looksLikeWecomId,
149
+ hint: "<chatId|user:contactId|chat:chatId>",
150
+ },
151
+ },
152
+ directory: {
153
+ self: async () => null,
154
+ listPeers: async () => [],
155
+ listGroups: async () => [],
156
+ listPeersLive: async () => [],
157
+ listGroupsLive: async () => [],
158
+ },
159
+ outbound: wecomOutbound,
160
+ status: {
161
+ defaultRuntime: {
162
+ accountId: DEFAULT_ACCOUNT_ID,
163
+ running: false,
164
+ lastStartAt: null,
165
+ lastStopAt: null,
166
+ lastError: null,
167
+ port: null,
168
+ },
169
+ buildChannelSummary: ({ snapshot }) => ({
170
+ configured: snapshot.configured ?? false,
171
+ running: snapshot.running ?? false,
172
+ lastStartAt: snapshot.lastStartAt ?? null,
173
+ lastStopAt: snapshot.lastStopAt ?? null,
174
+ lastError: snapshot.lastError ?? null,
175
+ port: snapshot.port ?? null,
176
+ probe: snapshot.probe,
177
+ lastProbeAt: snapshot.lastProbeAt ?? null,
178
+ }),
179
+ probeAccount: async ({ cfg }) =>
180
+ await probeWecom(cfg.channels?.wecom as WecomConfig | undefined),
181
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
182
+ accountId: account.accountId,
183
+ enabled: account.enabled,
184
+ configured: account.configured,
185
+ running: runtime?.running ?? false,
186
+ lastStartAt: runtime?.lastStartAt ?? null,
187
+ lastStopAt: runtime?.lastStopAt ?? null,
188
+ lastError: runtime?.lastError ?? null,
189
+ port: runtime?.port ?? null,
190
+ probe,
191
+ }),
192
+ },
193
+ gateway: {
194
+ startAccount: async (ctx) => {
195
+ const { monitorWecomProvider } = await import("./monitor.js");
196
+ const wecomCfg = ctx.cfg.channels?.wecom as WecomConfig | undefined;
197
+ const port = wecomCfg?.webhookPort ?? 3000;
198
+ ctx.setStatus({ accountId: ctx.accountId, port });
199
+ ctx.log?.info(`starting wecom provider (webhook mode)`);
200
+ return monitorWecomProvider({
201
+ config: ctx.cfg,
202
+ runtime: ctx.runtime,
203
+ abortSignal: ctx.abortSignal,
204
+ accountId: ctx.accountId,
205
+ });
206
+ },
207
+ },
208
+ };