@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.
@@ -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
+ }
@@ -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
+ }