@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/README.md +110 -0
- package/docs/CONFIG.md +362 -0
- package/docs/SETUP.md +162 -0
- package/index.ts +43 -0
- package/openclaw.plugin.json +17 -0
- package/package.json +73 -0
- package/src/accounts.ts +42 -0
- package/src/bot.ts +377 -0
- package/src/channel.ts +208 -0
- package/src/config-schema.ts +77 -0
- package/src/media.ts +149 -0
- package/src/monitor.ts +75 -0
- package/src/outbound.ts +75 -0
- package/src/policy.ts +111 -0
- package/src/probe.ts +32 -0
- package/src/reply-dispatcher.ts +91 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +250 -0
- package/src/targets.ts +66 -0
- package/src/types.ts +257 -0
- package/src/webhook.ts +171 -0
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
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -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
|
+
};
|