@marshulll/openclaw-wecom 0.1.17 → 0.1.19

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.
@@ -43,7 +43,8 @@
43
43
  "corpSecret": "CORP_SECRET",
44
44
  "agentId": 1000001,
45
45
  "callbackToken": "CALLBACK_TOKEN",
46
- "callbackAesKey": "CALLBACK_AES"
46
+ "callbackAesKey": "CALLBACK_AES",
47
+ "pushToken": "PUSH_TOKEN"
47
48
  }
48
49
  }
49
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marshulll/openclaw-wecom",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom channel plugin (intelligent bot + internal app)",
6
6
  "author": "OpenClaw",
@@ -88,6 +88,9 @@ export function resolveWecomAccount(params: {
88
88
  const callbackAesKey = merged.callbackAesKey?.trim()
89
89
  || resolveAccountEnv(params.cfg, accountId, "CALLBACK_AES_KEY")
90
90
  || undefined;
91
+ const pushToken = merged.pushToken?.trim()
92
+ || resolveAccountEnv(params.cfg, accountId, "PUSH_TOKEN")
93
+ || undefined;
91
94
  const webhookPath = merged.webhookPath?.trim()
92
95
  || resolveAccountEnv(params.cfg, accountId, "WEBHOOK_PATH")
93
96
  || undefined;
@@ -109,6 +112,7 @@ export function resolveWecomAccount(params: {
109
112
  agentId,
110
113
  callbackToken,
111
114
  callbackAesKey,
115
+ pushToken,
112
116
  };
113
117
 
114
118
  return {
@@ -183,6 +183,7 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
183
183
  return { stop: () => {} };
184
184
  }
185
185
  const path = (account.config.webhookPath ?? "/wecom").trim();
186
+ const pushPath = path.endsWith("/") ? `${path}push` : `${path}/push`;
186
187
  const unregister = registerWecomWebhookTarget({
187
188
  account,
188
189
  config: ctx.cfg as ClawdbotConfig,
@@ -191,7 +192,16 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
191
192
  path,
192
193
  statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
193
194
  });
195
+ const unregisterPush = registerWecomWebhookTarget({
196
+ account,
197
+ config: ctx.cfg as ClawdbotConfig,
198
+ runtime: ctx.runtime,
199
+ core: ({} as unknown) as any,
200
+ path: pushPath,
201
+ statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
202
+ });
194
203
  ctx.log?.info(`[${account.accountId}] wecom webhook registered at ${path}`);
204
+ ctx.log?.info(`[${account.accountId}] wecom push endpoint registered at ${pushPath}`);
195
205
  ctx.setStatus({
196
206
  accountId: account.accountId,
197
207
  running: true,
@@ -202,6 +212,7 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
202
212
  return {
203
213
  stop: () => {
204
214
  unregister();
215
+ unregisterPush();
205
216
  ctx.setStatus({
206
217
  accountId: account.accountId,
207
218
  running: false,
@@ -1,8 +1,10 @@
1
1
  import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { basename } from "node:path";
2
4
 
3
5
  import { getWecomRuntime } from "./runtime.js";
4
6
  import { listWecomAccountIds } from "./accounts.js";
5
- import { sendWecomText } from "./wecom-api.js";
7
+ import { sendWecomFile, sendWecomText, uploadWecomMedia } from "./wecom-api.js";
6
8
  import type { ResolvedWecomAccount } from "./types.js";
7
9
 
8
10
  export type CommandContext = {
@@ -21,6 +23,65 @@ async function sendAndRecord(ctx: CommandContext, text: string): Promise<void> {
21
23
  ctx.log?.(`[wecom] command reply sent to ${ctx.fromUser}`);
22
24
  }
23
25
 
26
+ function parseQuotedArgs(raw: string): string[] {
27
+ const args: string[] = [];
28
+ const normalized = raw.replace(/,/g, " ");
29
+ const regex = /"([^"]+)"|'([^']+)'|(\S+)/g;
30
+ let match: RegExpExecArray | null;
31
+ while ((match = regex.exec(normalized))) {
32
+ const value = match[1] || match[2] || match[3];
33
+ if (value) args.push(value.trim());
34
+ }
35
+ return args;
36
+ }
37
+
38
+ async function sendFiles(ctx: CommandContext, paths: string[]): Promise<{ sent: number; skipped: number }> {
39
+ let sent = 0;
40
+ let skipped = 0;
41
+ const maxBytes = ctx.account.config.media?.maxBytes;
42
+ for (const rawPath of paths) {
43
+ const path = rawPath.startsWith("file://") ? rawPath.replace(/^file:\/\//, "") : rawPath;
44
+ if (!path.startsWith("/")) {
45
+ skipped += 1;
46
+ await sendAndRecord(ctx, `⚠️ 路径需为绝对路径:${rawPath}`);
47
+ continue;
48
+ }
49
+ try {
50
+ const info = await stat(path);
51
+ if (!info.isFile()) {
52
+ skipped += 1;
53
+ await sendAndRecord(ctx, `⚠️ 不是文件:${path}`);
54
+ continue;
55
+ }
56
+ if (typeof maxBytes === "number" && maxBytes > 0 && info.size > maxBytes) {
57
+ skipped += 1;
58
+ await sendAndRecord(ctx, `⚠️ 文件过大(${info.size} bytes):${path}`);
59
+ continue;
60
+ }
61
+ const buffer = await readFile(path);
62
+ const filename = basename(path) || "file.bin";
63
+ const mediaId = await uploadWecomMedia({
64
+ account: ctx.account,
65
+ type: "file",
66
+ buffer,
67
+ filename,
68
+ });
69
+ await sendWecomFile({
70
+ account: ctx.account,
71
+ toUser: ctx.fromUser,
72
+ chatId: ctx.isGroup ? ctx.chatId : undefined,
73
+ mediaId,
74
+ });
75
+ sent += 1;
76
+ } catch (err) {
77
+ skipped += 1;
78
+ await sendAndRecord(ctx, `⚠️ 发送失败:${path} (${String(err)})`);
79
+ }
80
+ }
81
+ ctx.statusSink?.({ lastOutboundAt: Date.now() });
82
+ return { sent, skipped };
83
+ }
84
+
24
85
  async function handleHelp(ctx: CommandContext): Promise<void> {
25
86
  const helpText = `🤖 WeCom 助手使用帮助
26
87
 
@@ -28,6 +89,7 @@ async function handleHelp(ctx: CommandContext): Promise<void> {
28
89
  /help - 显示此帮助信息
29
90
  /clear - 清除会话历史,开始新对话
30
91
  /status - 查看系统状态
92
+ /sendfile <path...> - 发送服务器文件(支持多个路径,可用引号)
31
93
 
32
94
  直接发送消息即可与 AI 对话。`;
33
95
  await sendAndRecord(ctx, helpText);
@@ -79,6 +141,16 @@ async function handleClear(ctx: CommandContext): Promise<void> {
79
141
  await sendAndRecord(ctx, "✅ 会话已重置,请开始新的对话。");
80
142
  }
81
143
 
144
+ async function handleSendFile(cmd: string, ctx: CommandContext): Promise<void> {
145
+ const args = parseQuotedArgs(cmd.replace(/^\/sendfile(s)?\s*/i, ""));
146
+ if (args.length === 0) {
147
+ await sendAndRecord(ctx, "用法:/sendfile /absolute/path/to/file1 /absolute/path/to/file2\n支持引号:/sendfile \"/path/with space/a.txt\"");
148
+ return;
149
+ }
150
+ const { sent, skipped } = await sendFiles(ctx, args);
151
+ await sendAndRecord(ctx, `✅ 已发送 ${sent} 个文件${skipped ? `,跳过 ${skipped} 个` : ""}。`);
152
+ }
153
+
82
154
  const COMMANDS: Record<string, (ctx: CommandContext) => Promise<void>> = {
83
155
  "/help": handleHelp,
84
156
  "/status": handleStatus,
@@ -88,6 +160,11 @@ const COMMANDS: Record<string, (ctx: CommandContext) => Promise<void>> = {
88
160
  export async function handleCommand(cmd: string, ctx: CommandContext): Promise<boolean> {
89
161
  const key = cmd.trim().split(/\s+/)[0]?.toLowerCase();
90
162
  if (!key) return false;
163
+ if (key === "/sendfile" || key === "/sendfiles") {
164
+ ctx.log?.(`[wecom] handling command ${key}`);
165
+ await handleSendFile(cmd, ctx);
166
+ return true;
167
+ }
91
168
  const handler = COMMANDS[key];
92
169
  if (!handler) return false;
93
170
  ctx.log?.(`[wecom] handling command ${key}`);
@@ -41,6 +41,7 @@ const accountSchema = z.object({
41
41
  agentId: z.union([z.string(), z.number()]).optional(),
42
42
  callbackToken: z.string().optional(),
43
43
  callbackAesKey: z.string().optional(),
44
+ pushToken: z.string().optional(),
44
45
 
45
46
  media: z.object({
46
47
  tempDir: z.string().optional(),
@@ -85,6 +86,7 @@ export const WecomConfigSchema = ensureJsonSchema(z.object({
85
86
  agentId: z.union([z.string(), z.number()]).optional(),
86
87
  callbackToken: z.string().optional(),
87
88
  callbackAesKey: z.string().optional(),
89
+ pushToken: z.string().optional(),
88
90
 
89
91
  media: z.object({
90
92
  tempDir: z.string().optional(),
@@ -3,7 +3,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
3
3
  import type { ClawdbotConfig, PluginRuntime } from "openclaw/plugin-sdk";
4
4
 
5
5
  import type { ResolvedWecomAccount } from "./types.js";
6
- import { handleWecomAppWebhook } from "./wecom-app.js";
6
+ import { handleWecomAppWebhook, handleWecomPushRequest } from "./wecom-app.js";
7
7
  import { handleWecomBotWebhook } from "./wecom-bot.js";
8
8
 
9
9
  export type WecomRuntimeEnv = {
@@ -55,6 +55,9 @@ export async function handleWecomWebhookRequest(
55
55
  const path = resolvePath(req);
56
56
  const targets = webhookTargets.get(path);
57
57
  if (!targets || targets.length === 0) return false;
58
+ if (path.endsWith("/push")) {
59
+ return await handleWecomPushRequest({ req, res, targets });
60
+ }
58
61
  const firstTarget = targets[0];
59
62
  const ua = req.headers["user-agent"] ?? "";
60
63
  const fwd = req.headers["x-forwarded-for"] ?? "";
@@ -40,6 +40,7 @@ export type WecomAccountConfig = {
40
40
  agentId?: string | number;
41
41
  callbackToken?: string;
42
42
  callbackAesKey?: string;
43
+ pushToken?: string;
43
44
 
44
45
  // Media handling
45
46
  media?: {
@@ -101,6 +101,27 @@ async function readRequestBody(req: IncomingMessage, maxSize = MAX_REQUEST_BODY_
101
101
  });
102
102
  }
103
103
 
104
+ function resolveHeaderToken(req: IncomingMessage): string {
105
+ const auth = req.headers.authorization ?? "";
106
+ if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
107
+ return auth.slice(7).trim();
108
+ }
109
+ const token = req.headers["x-openclaw-token"];
110
+ if (typeof token === "string") return token.trim();
111
+ return "";
112
+ }
113
+
114
+ function pickFirstString(...values: unknown[]): string {
115
+ for (const value of values) {
116
+ if (typeof value === "string" && value.trim()) return value.trim();
117
+ }
118
+ return "";
119
+ }
120
+
121
+ function sleep(ms: number): Promise<void> {
122
+ return new Promise((resolve) => setTimeout(resolve, ms));
123
+ }
124
+
104
125
  function logVerbose(target: WecomWebhookTarget, message: string): void {
105
126
  target.runtime.log?.(`[wecom] ${message}`);
106
127
  }
@@ -809,6 +830,163 @@ async function processAppMessage(params: {
809
830
  }
810
831
  }
811
832
 
833
+ type PushMessage = {
834
+ text?: string;
835
+ mediaUrl?: string;
836
+ mediaPath?: string;
837
+ mediaBase64?: string;
838
+ mediaType?: string;
839
+ filename?: string;
840
+ title?: string;
841
+ description?: string;
842
+ delayMs?: number;
843
+ };
844
+
845
+ type PushPayload = PushMessage & {
846
+ accountId?: string;
847
+ toUser?: string;
848
+ chatId?: string;
849
+ token?: string;
850
+ intervalMs?: number;
851
+ messages?: PushMessage[];
852
+ };
853
+
854
+ function resolvePushToken(target: WecomWebhookTarget): string {
855
+ return target.account.config.pushToken?.trim() || "";
856
+ }
857
+
858
+ function selectPushTarget(targets: WecomWebhookTarget[], accountId?: string): WecomWebhookTarget | undefined {
859
+ const appTargets = targets.filter((candidate) => shouldHandleApp(candidate));
860
+ if (!accountId) return appTargets[0];
861
+ return appTargets.find((candidate) => candidate.account.accountId === accountId);
862
+ }
863
+
864
+ export async function handleWecomPushRequest(params: {
865
+ req: IncomingMessage;
866
+ res: ServerResponse;
867
+ targets: WecomWebhookTarget[];
868
+ }): Promise<boolean> {
869
+ const { req, res, targets } = params;
870
+ if ((req.method ?? "").toUpperCase() !== "POST") {
871
+ res.statusCode = 405;
872
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
873
+ res.end("Method Not Allowed");
874
+ return true;
875
+ }
876
+
877
+ let payload: PushPayload | null = null;
878
+ try {
879
+ const raw = await readRequestBody(req, MAX_REQUEST_BODY_SIZE);
880
+ payload = raw ? (JSON.parse(raw) as PushPayload) : {};
881
+ } catch (err) {
882
+ res.statusCode = 400;
883
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
884
+ res.end(JSON.stringify({ ok: false, error: `Invalid JSON: ${String(err)}` }));
885
+ return true;
886
+ }
887
+
888
+ const url = new URL(req.url ?? "/", "http://localhost");
889
+ const accountId = pickFirstString(payload?.accountId, url.searchParams.get("accountId"));
890
+ const target = selectPushTarget(targets, accountId);
891
+ if (!target) {
892
+ res.statusCode = 404;
893
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
894
+ res.end(JSON.stringify({ ok: false, error: "No matching WeCom app account" }));
895
+ return true;
896
+ }
897
+
898
+ const expectedToken = resolvePushToken(target);
899
+ const requestToken = pickFirstString(
900
+ payload?.token,
901
+ url.searchParams.get("token"),
902
+ resolveHeaderToken(req),
903
+ );
904
+ if (expectedToken && expectedToken !== requestToken) {
905
+ res.statusCode = 403;
906
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
907
+ res.end(JSON.stringify({ ok: false, error: "Invalid push token" }));
908
+ return true;
909
+ }
910
+
911
+ const toUser = pickFirstString(payload?.toUser, url.searchParams.get("toUser"));
912
+ const chatId = pickFirstString(payload?.chatId, url.searchParams.get("chatId"));
913
+ if (!toUser && !chatId) {
914
+ res.statusCode = 400;
915
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
916
+ res.end(JSON.stringify({ ok: false, error: "Missing toUser or chatId" }));
917
+ return true;
918
+ }
919
+
920
+ if (!target.account.corpId || !target.account.corpSecret || !target.account.agentId) {
921
+ res.statusCode = 500;
922
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
923
+ res.end(JSON.stringify({ ok: false, error: "WeCom app not configured" }));
924
+ return true;
925
+ }
926
+
927
+ const messages = Array.isArray(payload?.messages) && payload?.messages.length > 0
928
+ ? payload.messages
929
+ : [payload ?? {}];
930
+ const intervalMs = typeof payload?.intervalMs === "number" && payload.intervalMs > 0 ? payload.intervalMs : 0;
931
+ let sent = 0;
932
+
933
+ for (const message of messages) {
934
+ if (message.delayMs && message.delayMs > 0) {
935
+ await sleep(message.delayMs);
936
+ }
937
+ try {
938
+ const outbound = await loadOutboundMedia({
939
+ payload: message,
940
+ account: target.account,
941
+ maxBytes: resolveMediaMaxBytes(target),
942
+ });
943
+ if (outbound) {
944
+ const mediaId = await uploadWecomMedia({
945
+ account: target.account,
946
+ type: outbound.type,
947
+ buffer: outbound.buffer,
948
+ filename: outbound.filename,
949
+ });
950
+ if (outbound.type === "image") {
951
+ await sendWecomImage({ account: target.account, toUser, chatId: chatId || undefined, mediaId });
952
+ } else if (outbound.type === "voice") {
953
+ await sendWecomVoice({ account: target.account, toUser, chatId: chatId || undefined, mediaId });
954
+ } else if (outbound.type === "video") {
955
+ await sendWecomVideo({
956
+ account: target.account,
957
+ toUser,
958
+ chatId: chatId || undefined,
959
+ mediaId,
960
+ title: message.title,
961
+ description: message.description,
962
+ });
963
+ } else {
964
+ await sendWecomFile({ account: target.account, toUser, chatId: chatId || undefined, mediaId });
965
+ }
966
+ sent += 1;
967
+ }
968
+
969
+ const text = markdownToWecomText(message.text ?? "");
970
+ if (text) {
971
+ await sendWecomText({ account: target.account, toUser, chatId: chatId || undefined, text });
972
+ sent += 1;
973
+ }
974
+ } catch (err) {
975
+ target.runtime.error?.(`wecom push failed: ${String(err)}`);
976
+ }
977
+
978
+ if (intervalMs) {
979
+ await sleep(intervalMs);
980
+ }
981
+ }
982
+
983
+ target.statusSink?.({ lastOutboundAt: Date.now() });
984
+ res.statusCode = 200;
985
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
986
+ res.end(JSON.stringify({ ok: true, sent }));
987
+ return true;
988
+ }
989
+
812
990
  export async function handleWecomAppWebhook(params: {
813
991
  req: IncomingMessage;
814
992
  res: ServerResponse;