@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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export { z };
|
|
3
|
+
|
|
4
|
+
const DmPolicySchema = z.enum(["open", "allowlist"]);
|
|
5
|
+
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
|
|
6
|
+
|
|
7
|
+
const ToolPolicySchema = z
|
|
8
|
+
.object({
|
|
9
|
+
allow: z.array(z.string()).optional(),
|
|
10
|
+
deny: z.array(z.string()).optional(),
|
|
11
|
+
})
|
|
12
|
+
.strict()
|
|
13
|
+
.optional();
|
|
14
|
+
|
|
15
|
+
const DmConfigSchema = z
|
|
16
|
+
.object({
|
|
17
|
+
enabled: z.boolean().optional(),
|
|
18
|
+
systemPrompt: z.string().optional(),
|
|
19
|
+
})
|
|
20
|
+
.strict()
|
|
21
|
+
.optional();
|
|
22
|
+
|
|
23
|
+
export const WecomGroupSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
requireMention: z.boolean().optional(),
|
|
26
|
+
tools: ToolPolicySchema,
|
|
27
|
+
skills: z.array(z.string()).optional(),
|
|
28
|
+
enabled: z.boolean().optional(),
|
|
29
|
+
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
30
|
+
systemPrompt: z.string().optional(),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
|
|
34
|
+
export const WecomConfigSchema = z
|
|
35
|
+
.object({
|
|
36
|
+
enabled: z.boolean().optional(),
|
|
37
|
+
// Stride API credentials
|
|
38
|
+
token: z.string().optional(),
|
|
39
|
+
chatId: z.string().optional(), // Default chatId for outbound
|
|
40
|
+
// Webhook configuration
|
|
41
|
+
webhookPath: z.string().optional().default("/webhooks/wechat"),
|
|
42
|
+
webhookPort: z.number().int().positive().optional(),
|
|
43
|
+
// Policy
|
|
44
|
+
dmPolicy: DmPolicySchema.optional().default("allowlist"),
|
|
45
|
+
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
46
|
+
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
|
47
|
+
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
48
|
+
requireMention: z.boolean().optional().default(true),
|
|
49
|
+
// Per-group configuration
|
|
50
|
+
groups: z.record(z.string(), WecomGroupSchema.optional()).optional(),
|
|
51
|
+
// Per-DM configuration
|
|
52
|
+
dms: z.record(z.string(), DmConfigSchema).optional(),
|
|
53
|
+
// History limits
|
|
54
|
+
historyLimit: z.number().int().min(0).optional(),
|
|
55
|
+
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
56
|
+
// Message chunking
|
|
57
|
+
textChunkLimit: z.number().int().positive().optional(),
|
|
58
|
+
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
59
|
+
// Media limits
|
|
60
|
+
mediaMaxMb: z.number().positive().optional(),
|
|
61
|
+
// Render mode: WeChat doesn't support cards, so only raw/auto matter for table conversion
|
|
62
|
+
renderMode: z.enum(["auto", "raw"]).optional(),
|
|
63
|
+
})
|
|
64
|
+
.strict()
|
|
65
|
+
.superRefine((value, ctx) => {
|
|
66
|
+
if (value.dmPolicy === "open") {
|
|
67
|
+
const allowFrom = value.allowFrom ?? [];
|
|
68
|
+
const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*");
|
|
69
|
+
if (!hasWildcard) {
|
|
70
|
+
ctx.addIssue({
|
|
71
|
+
code: z.ZodIssueCode.custom,
|
|
72
|
+
path: ["allowFrom"],
|
|
73
|
+
message: 'channels.wecom.dmPolicy="open" requires channels.wecom.allowFrom to include "*"',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { WecomMediaInfo } from "./types.js";
|
|
3
|
+
import { getWecomRuntime } from "./runtime.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Infer placeholder text based on content type.
|
|
7
|
+
*/
|
|
8
|
+
function inferPlaceholder(contentType: string | undefined): string {
|
|
9
|
+
if (!contentType) return "<media:document>";
|
|
10
|
+
if (contentType.startsWith("image/")) return "<media:image>";
|
|
11
|
+
if (contentType.startsWith("audio/")) return "<media:audio>";
|
|
12
|
+
if (contentType.startsWith("video/")) return "<media:video>";
|
|
13
|
+
return "<media:document>";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Download media from a URL and save it locally.
|
|
18
|
+
* Stride provides direct URLs for all media, so this is simpler than Feishu.
|
|
19
|
+
*/
|
|
20
|
+
export async function downloadWecomMedia(params: {
|
|
21
|
+
url: string;
|
|
22
|
+
maxBytes: number;
|
|
23
|
+
log?: (msg: string) => void;
|
|
24
|
+
}): Promise<{ path: string; contentType?: string } | null> {
|
|
25
|
+
const { url, maxBytes, log } = params;
|
|
26
|
+
|
|
27
|
+
if (!url) return null;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(url);
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
log?.(`wecom: failed to download media: ${response.status} ${response.statusText}`);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const contentType = response.headers.get("content-type") || undefined;
|
|
37
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
38
|
+
|
|
39
|
+
const core = getWecomRuntime();
|
|
40
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
41
|
+
buffer,
|
|
42
|
+
contentType,
|
|
43
|
+
"inbound",
|
|
44
|
+
maxBytes,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
log?.(`wecom: downloaded media from ${url}, saved to ${saved.path}`);
|
|
48
|
+
return {
|
|
49
|
+
path: saved.path,
|
|
50
|
+
contentType: saved.contentType,
|
|
51
|
+
};
|
|
52
|
+
} catch (err) {
|
|
53
|
+
log?.(`wecom: failed to download media: ${String(err)}`);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve media from a webhook event.
|
|
60
|
+
* Returns media info list for attaching to inbound context.
|
|
61
|
+
*/
|
|
62
|
+
export async function resolveWecomMediaList(params: {
|
|
63
|
+
cfg: ClawdbotConfig;
|
|
64
|
+
type: number;
|
|
65
|
+
payload: Record<string, unknown>;
|
|
66
|
+
maxBytes: number;
|
|
67
|
+
log?: (msg: string) => void;
|
|
68
|
+
}): Promise<WecomMediaInfo[]> {
|
|
69
|
+
const { type, payload, maxBytes, log } = params;
|
|
70
|
+
|
|
71
|
+
const out: WecomMediaInfo[] = [];
|
|
72
|
+
|
|
73
|
+
// Type 1: File
|
|
74
|
+
if (type === 1 && payload.fileUrl) {
|
|
75
|
+
const result = await downloadWecomMedia({
|
|
76
|
+
url: payload.fileUrl as string,
|
|
77
|
+
maxBytes,
|
|
78
|
+
log,
|
|
79
|
+
});
|
|
80
|
+
if (result) {
|
|
81
|
+
out.push({
|
|
82
|
+
url: result.path,
|
|
83
|
+
contentType: result.contentType,
|
|
84
|
+
placeholder: "<media:document>",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Type 2: Voice (download audio, but text is already transcribed)
|
|
90
|
+
if (type === 2 && payload.voiceUrl) {
|
|
91
|
+
const result = await downloadWecomMedia({
|
|
92
|
+
url: payload.voiceUrl as string,
|
|
93
|
+
maxBytes,
|
|
94
|
+
log,
|
|
95
|
+
});
|
|
96
|
+
if (result) {
|
|
97
|
+
out.push({
|
|
98
|
+
url: result.path,
|
|
99
|
+
contentType: result.contentType ?? "audio/mp3",
|
|
100
|
+
placeholder: "<media:audio>",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Type 6: Image
|
|
106
|
+
if (type === 6 && payload.imageUrl) {
|
|
107
|
+
const result = await downloadWecomMedia({
|
|
108
|
+
url: payload.imageUrl as string,
|
|
109
|
+
maxBytes,
|
|
110
|
+
log,
|
|
111
|
+
});
|
|
112
|
+
if (result) {
|
|
113
|
+
out.push({
|
|
114
|
+
url: result.path,
|
|
115
|
+
contentType: result.contentType,
|
|
116
|
+
placeholder: "<media:image>",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build media payload for inbound context.
|
|
126
|
+
*/
|
|
127
|
+
export function buildWecomMediaPayload(
|
|
128
|
+
mediaList: WecomMediaInfo[],
|
|
129
|
+
): {
|
|
130
|
+
MediaPath?: string;
|
|
131
|
+
MediaType?: string;
|
|
132
|
+
MediaUrl?: string;
|
|
133
|
+
MediaPaths?: string[];
|
|
134
|
+
MediaUrls?: string[];
|
|
135
|
+
MediaTypes?: string[];
|
|
136
|
+
} {
|
|
137
|
+
const first = mediaList[0];
|
|
138
|
+
const mediaPaths = mediaList.map((media) => media.url);
|
|
139
|
+
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
MediaPath: first?.url,
|
|
143
|
+
MediaType: first?.contentType,
|
|
144
|
+
MediaUrl: first?.url,
|
|
145
|
+
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
146
|
+
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
147
|
+
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
|
148
|
+
};
|
|
149
|
+
}
|
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Server } from "http";
|
|
2
|
+
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { WecomConfig } from "./types.js";
|
|
4
|
+
import { resolveWecomCredentials } from "./accounts.js";
|
|
5
|
+
import { startWebhookServer, stopWebhookServer } from "./webhook.js";
|
|
6
|
+
|
|
7
|
+
export type MonitorWecomOpts = {
|
|
8
|
+
config?: ClawdbotConfig;
|
|
9
|
+
runtime?: RuntimeEnv;
|
|
10
|
+
abortSignal?: AbortSignal;
|
|
11
|
+
accountId?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
let currentServer: Server | null = null;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Start the WeChat webhook monitor.
|
|
18
|
+
*/
|
|
19
|
+
export async function monitorWecomProvider(opts: MonitorWecomOpts = {}): Promise<void> {
|
|
20
|
+
const cfg = opts.config;
|
|
21
|
+
if (!cfg) {
|
|
22
|
+
throw new Error("Config is required for WeChat monitor");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const wecomCfg = cfg.channels?.wecom as WecomConfig | undefined;
|
|
26
|
+
const creds = resolveWecomCredentials(wecomCfg);
|
|
27
|
+
if (!creds) {
|
|
28
|
+
throw new Error("WeChat credentials not configured (token required)");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const log = opts.runtime?.log ?? console.log;
|
|
32
|
+
const error = opts.runtime?.error ?? console.error;
|
|
33
|
+
|
|
34
|
+
log(`wecom: starting webhook server...`);
|
|
35
|
+
|
|
36
|
+
const chatHistories = new Map<string, HistoryEntry[]>();
|
|
37
|
+
|
|
38
|
+
const server = await startWebhookServer({
|
|
39
|
+
cfg,
|
|
40
|
+
runtime: opts.runtime,
|
|
41
|
+
abortSignal: opts.abortSignal,
|
|
42
|
+
chatHistories,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
currentServer = server;
|
|
46
|
+
|
|
47
|
+
// Return a promise that resolves when the server is closed
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
server.on("close", () => {
|
|
50
|
+
log(`wecom: webhook server closed`);
|
|
51
|
+
if (currentServer === server) {
|
|
52
|
+
currentServer = null;
|
|
53
|
+
}
|
|
54
|
+
resolve();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Handle abort signal
|
|
58
|
+
if (opts.abortSignal) {
|
|
59
|
+
opts.abortSignal.addEventListener("abort", () => {
|
|
60
|
+
log(`wecom: abort signal received, stopping server`);
|
|
61
|
+
stopWecomMonitor();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Stop the WeChat monitor.
|
|
69
|
+
*/
|
|
70
|
+
export function stopWecomMonitor(): void {
|
|
71
|
+
if (currentServer) {
|
|
72
|
+
currentServer.close();
|
|
73
|
+
currentServer = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/outbound.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getWecomRuntime } from "./runtime.js";
|
|
3
|
+
import { sendMessageWecom, sendImageWecom, sendFileWecom } from "./send.js";
|
|
4
|
+
|
|
5
|
+
export const wecomOutbound: ChannelOutboundAdapter = {
|
|
6
|
+
deliveryMode: "direct",
|
|
7
|
+
chunker: (text, limit) => getWecomRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
8
|
+
chunkerMode: "markdown",
|
|
9
|
+
textChunkLimit: 4000,
|
|
10
|
+
|
|
11
|
+
sendText: async ({ cfg, to, text }) => {
|
|
12
|
+
const result = await sendMessageWecom({ cfg, to, text });
|
|
13
|
+
return {
|
|
14
|
+
channel: "wecom",
|
|
15
|
+
chatId: result.chatId,
|
|
16
|
+
messageId: result.chatId, // Stride doesn't return message ID
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
sendMedia: async ({ cfg, to, text, mediaUrl }) => {
|
|
21
|
+
// Send text first if provided
|
|
22
|
+
if (text?.trim()) {
|
|
23
|
+
await sendMessageWecom({ cfg, to, text });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Send media if URL provided
|
|
27
|
+
if (mediaUrl) {
|
|
28
|
+
try {
|
|
29
|
+
// Detect media type from URL
|
|
30
|
+
const isImage = /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(mediaUrl);
|
|
31
|
+
|
|
32
|
+
if (isImage) {
|
|
33
|
+
const result = await sendImageWecom({ cfg, to, imageUrl: mediaUrl });
|
|
34
|
+
return {
|
|
35
|
+
channel: "wecom",
|
|
36
|
+
chatId: result.chatId,
|
|
37
|
+
messageId: result.chatId,
|
|
38
|
+
};
|
|
39
|
+
} else {
|
|
40
|
+
// Send as file
|
|
41
|
+
const fileName = mediaUrl.split("/").pop() || "file";
|
|
42
|
+
const result = await sendFileWecom({
|
|
43
|
+
cfg,
|
|
44
|
+
to,
|
|
45
|
+
fileUrl: mediaUrl,
|
|
46
|
+
fileName,
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
channel: "wecom",
|
|
50
|
+
chatId: result.chatId,
|
|
51
|
+
messageId: result.chatId,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error(`[wecom] sendMedia failed:`, err);
|
|
56
|
+
// Fallback to URL link if upload fails
|
|
57
|
+
const fallbackText = `[File] ${mediaUrl}`;
|
|
58
|
+
const result = await sendMessageWecom({ cfg, to, text: fallbackText });
|
|
59
|
+
return {
|
|
60
|
+
channel: "wecom",
|
|
61
|
+
chatId: result.chatId,
|
|
62
|
+
messageId: result.chatId,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// No media URL, just return text result
|
|
68
|
+
const result = await sendMessageWecom({ cfg, to, text: text ?? "" });
|
|
69
|
+
return {
|
|
70
|
+
channel: "wecom",
|
|
71
|
+
chatId: result.chatId,
|
|
72
|
+
messageId: result.chatId,
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { WecomConfig, WecomGroupConfig } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export type WecomAllowlistMatch = {
|
|
5
|
+
allowed: boolean;
|
|
6
|
+
matchKey?: string;
|
|
7
|
+
matchSource?: "wildcard" | "id" | "name";
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if a sender is in the allowlist.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveWecomAllowlistMatch(params: {
|
|
14
|
+
allowFrom: Array<string | number>;
|
|
15
|
+
senderId: string;
|
|
16
|
+
senderName?: string | null;
|
|
17
|
+
}): WecomAllowlistMatch {
|
|
18
|
+
const allowFrom = params.allowFrom
|
|
19
|
+
.map((entry) => String(entry).trim().toLowerCase())
|
|
20
|
+
.filter(Boolean);
|
|
21
|
+
|
|
22
|
+
if (allowFrom.length === 0) return { allowed: false };
|
|
23
|
+
if (allowFrom.includes("*")) {
|
|
24
|
+
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const senderId = params.senderId.toLowerCase();
|
|
28
|
+
if (allowFrom.includes(senderId)) {
|
|
29
|
+
return { allowed: true, matchKey: senderId, matchSource: "id" };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const senderName = params.senderName?.toLowerCase();
|
|
33
|
+
if (senderName && allowFrom.includes(senderName)) {
|
|
34
|
+
return { allowed: true, matchKey: senderName, matchSource: "name" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { allowed: false };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get group-specific configuration by group ID (chatId or roomId).
|
|
42
|
+
*/
|
|
43
|
+
export function resolveWecomGroupConfig(params: {
|
|
44
|
+
cfg?: WecomConfig;
|
|
45
|
+
groupId?: string | null;
|
|
46
|
+
}): WecomGroupConfig | undefined {
|
|
47
|
+
const groups = params.cfg?.groups ?? {};
|
|
48
|
+
const groupId = params.groupId?.trim();
|
|
49
|
+
if (!groupId) return undefined;
|
|
50
|
+
|
|
51
|
+
// Try exact match first
|
|
52
|
+
const direct = groups[groupId] as WecomGroupConfig | undefined;
|
|
53
|
+
if (direct) return direct;
|
|
54
|
+
|
|
55
|
+
// Try case-insensitive match
|
|
56
|
+
const lowered = groupId.toLowerCase();
|
|
57
|
+
const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered);
|
|
58
|
+
return matchKey ? (groups[matchKey] as WecomGroupConfig | undefined) : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve tool policy for a specific group.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveWecomGroupToolPolicy(
|
|
65
|
+
params: ChannelGroupContext,
|
|
66
|
+
): GroupToolPolicyConfig | undefined {
|
|
67
|
+
const cfg = params.cfg.channels?.wecom as WecomConfig | undefined;
|
|
68
|
+
if (!cfg) return undefined;
|
|
69
|
+
|
|
70
|
+
const groupConfig = resolveWecomGroupConfig({
|
|
71
|
+
cfg,
|
|
72
|
+
groupId: params.groupId,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return groupConfig?.tools;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if a message from a group is allowed based on policy.
|
|
80
|
+
*/
|
|
81
|
+
export function isWecomGroupAllowed(params: {
|
|
82
|
+
groupPolicy: "open" | "allowlist" | "disabled";
|
|
83
|
+
allowFrom: Array<string | number>;
|
|
84
|
+
senderId: string;
|
|
85
|
+
senderName?: string | null;
|
|
86
|
+
}): boolean {
|
|
87
|
+
const { groupPolicy } = params;
|
|
88
|
+
if (groupPolicy === "disabled") return false;
|
|
89
|
+
if (groupPolicy === "open") return true;
|
|
90
|
+
return resolveWecomAllowlistMatch(params).allowed;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve reply policy (whether @mention is required).
|
|
95
|
+
*/
|
|
96
|
+
export function resolveWecomReplyPolicy(params: {
|
|
97
|
+
isDirectMessage: boolean;
|
|
98
|
+
globalConfig?: WecomConfig;
|
|
99
|
+
groupConfig?: WecomGroupConfig;
|
|
100
|
+
}): { requireMention: boolean } {
|
|
101
|
+
// DMs never require mention
|
|
102
|
+
if (params.isDirectMessage) {
|
|
103
|
+
return { requireMention: false };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Group: check group-specific config, then global
|
|
107
|
+
const requireMention =
|
|
108
|
+
params.groupConfig?.requireMention ?? params.globalConfig?.requireMention ?? true;
|
|
109
|
+
|
|
110
|
+
return { requireMention };
|
|
111
|
+
}
|
package/src/probe.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { WecomConfig, WecomProbeResult } from "./types.js";
|
|
2
|
+
import { resolveWecomCredentials } from "./accounts.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Test WeChat/Stride connection by verifying credentials.
|
|
6
|
+
* Since Stride doesn't have a dedicated "test" endpoint, we just verify the token is set.
|
|
7
|
+
* A real probe could attempt to send a message to validate the token.
|
|
8
|
+
*/
|
|
9
|
+
export async function probeWecom(wecomCfg?: WecomConfig): Promise<WecomProbeResult> {
|
|
10
|
+
try {
|
|
11
|
+
const creds = resolveWecomCredentials(wecomCfg);
|
|
12
|
+
|
|
13
|
+
if (!creds) {
|
|
14
|
+
return {
|
|
15
|
+
ok: false,
|
|
16
|
+
error: "WeChat credentials not configured (token required)",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// We could try sending a test message here, but that might be intrusive.
|
|
21
|
+
// For now, just verify credentials are present.
|
|
22
|
+
return {
|
|
23
|
+
ok: true,
|
|
24
|
+
chatId: creds.chatId,
|
|
25
|
+
};
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error: String(err),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createReplyPrefixContext,
|
|
3
|
+
type ClawdbotConfig,
|
|
4
|
+
type RuntimeEnv,
|
|
5
|
+
type ReplyPayload,
|
|
6
|
+
} from "openclaw/plugin-sdk";
|
|
7
|
+
import { getWecomRuntime } from "./runtime.js";
|
|
8
|
+
import { sendMessageWecom } from "./send.js";
|
|
9
|
+
import type { WecomConfig } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export type CreateWecomReplyDispatcherParams = {
|
|
12
|
+
cfg: ClawdbotConfig;
|
|
13
|
+
agentId: string;
|
|
14
|
+
runtime: RuntimeEnv;
|
|
15
|
+
chatId: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function createWecomReplyDispatcher(params: CreateWecomReplyDispatcherParams) {
|
|
19
|
+
const core = getWecomRuntime();
|
|
20
|
+
const { cfg, agentId, chatId } = params;
|
|
21
|
+
|
|
22
|
+
const prefixContext = createReplyPrefixContext({
|
|
23
|
+
cfg,
|
|
24
|
+
agentId,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const textChunkLimit = core.channel.text.resolveTextChunkLimit({
|
|
28
|
+
cfg,
|
|
29
|
+
channel: "wecom",
|
|
30
|
+
defaultLimit: 4000,
|
|
31
|
+
});
|
|
32
|
+
const chunkMode = core.channel.text.resolveChunkMode(cfg, "wecom");
|
|
33
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
34
|
+
cfg,
|
|
35
|
+
channel: "wecom",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// WeChat doesn't have a native typing indicator API
|
|
39
|
+
// We could potentially add one using reactions or status, but skip for now
|
|
40
|
+
|
|
41
|
+
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
42
|
+
core.channel.reply.createReplyDispatcherWithTyping({
|
|
43
|
+
responsePrefix: prefixContext.responsePrefix,
|
|
44
|
+
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
|
45
|
+
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
|
46
|
+
onReplyStart: async () => {
|
|
47
|
+
// No typing indicator for WeChat
|
|
48
|
+
},
|
|
49
|
+
deliver: async (payload: ReplyPayload) => {
|
|
50
|
+
params.runtime.log?.(`wecom deliver called: text=${payload.text?.slice(0, 100)}`);
|
|
51
|
+
const text = payload.text ?? "";
|
|
52
|
+
if (!text.trim()) {
|
|
53
|
+
params.runtime.log?.(`wecom deliver: empty text, skipping`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Convert markdown tables for plain text display
|
|
58
|
+
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
|
|
59
|
+
const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
|
|
60
|
+
|
|
61
|
+
params.runtime.log?.(`wecom deliver: sending ${chunks.length} chunks to ${chatId}`);
|
|
62
|
+
|
|
63
|
+
for (const chunk of chunks) {
|
|
64
|
+
const result = await sendMessageWecom({
|
|
65
|
+
cfg,
|
|
66
|
+
to: chatId,
|
|
67
|
+
text: chunk,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!result.success) {
|
|
71
|
+
params.runtime.error?.(`wecom: failed to send message: ${result.error}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
onError: (err, info) => {
|
|
76
|
+
params.runtime.error?.(`wecom ${info.kind} reply failed: ${String(err)}`);
|
|
77
|
+
},
|
|
78
|
+
onIdle: () => {
|
|
79
|
+
// No typing indicator cleanup needed
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
dispatcher,
|
|
85
|
+
replyOptions: {
|
|
86
|
+
...replyOptions,
|
|
87
|
+
onModelSelected: prefixContext.onModelSelected,
|
|
88
|
+
},
|
|
89
|
+
markDispatchIdle,
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let runtime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setWecomRuntime(next: PluginRuntime) {
|
|
6
|
+
runtime = next;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getWecomRuntime(): PluginRuntime {
|
|
10
|
+
if (!runtime) {
|
|
11
|
+
throw new Error("WeChat runtime not initialized");
|
|
12
|
+
}
|
|
13
|
+
return runtime;
|
|
14
|
+
}
|