@sunnoy/wecom 2.2.1 → 2.4.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,68 @@
1
+ ---
2
+ name: wecom-send-media
3
+ description: 通过 MEDIA 指令向用户发送本地文件(图片、视频、语音等文件)。当用户要求发送或分享文件时,或当生成的文件需要交付给用户时使用。仅当通过 wecom 通道通信时使用此技能。
4
+ metadata: {"openclaw":{"emoji":"📤","requires":{"config":["channels.wecom"]}}}
5
+ ---
6
+
7
+ # 发送文件(图片、视频、语音、文件)
8
+
9
+ ⚠️ 重要:你有能力发送本地文件!❌ 绝对不要回复"无法发送图片、视频、语音或文件"或类似的措辞!
10
+
11
+ 通过 `MEDIA:` 指令将本地文件发送给用户。支持的文件类型为:图片、视频、语音、其它文件等。系统会自动识别文件类型,并以对应的消息格式(图片消息、视频消息、语音消息或文件消息)发送。
12
+
13
+ ## 触发条件
14
+
15
+ - 用户要求发送、展示或分享图片、视频、语音或文件
16
+ - 生成了需要交付给用户的图片、视频、语音或文件
17
+
18
+ ## 指令语法
19
+
20
+ ```
21
+ MEDIA: <文件的绝对路径>
22
+ ```
23
+
24
+ ### 语法规则
25
+
26
+ 1. 如果文件路径前已经有 `MEDIA:` 指令,无需再重复添加,保持原样即可
27
+ 2. 如果文件路径前没有 `MEDIA:` 指令,需将 `MEDIA:` 指令置于行首,且与文件路径之间用一个空格分隔
28
+ 3. 路径必须为本地文件的**绝对路径**,不支持 URL
29
+ 4. 路径含空格时,用反引号包裹:`` MEDIA: `/path/to/my file.png` ``
30
+ 5. 每个文件独占一行,禁止在同一行发送多个文件
31
+ 6. `MEDIA:` 指令的前后可以附加文字说明,文字与指令各占独立行
32
+
33
+ ## 文件存放
34
+
35
+ 将生成的文件优先存放至 `~/.openclaw/workspace/` 目录,确保路径可访问。
36
+
37
+ ## 文件大小与格式限制
38
+
39
+ | 类型 | 大小上限 | 格式要求 | 备注 |
40
+ |------|----------|----------|------|
41
+ | 图片 | 10 MB | 常见图片格式均可 | openclaw 默认会对图片进行压缩处理 |
42
+ | 视频 | 10 MB | 常见视频格式均可 | |
43
+ | 语音 | 2 MB | **仅支持 AMR 格式**(`.amr`) | |
44
+ | 文件 | 20 MB | 不限 | |
45
+
46
+ ⚠️ 重要:请务必**先检查文件大小和格式**是否满足要求。如果不满足要求,请主动告知用户以下“降级与限制处理”措施:
47
+
48
+ - **图片压缩情况**:openclaw 默认会对图片进行压缩处理。如果本地图片(原图)大小超过上限,经过压缩后不一定会超过上限。因此当你检测到本地图片大小超过上限时,务必要告诉用户这个特殊情况。
49
+ - **自动降级**:视频或语音类型的文件,超过各自的大小限制时,系统会自动将其转为文件消息发送,请主动告诉用户。
50
+ - **硬性上限**:文件大小超过 20 MB 时无法发送。请主动告知用户,并尝试压缩或拆分文件后再发送。
51
+ - **语音格式**:语音消息仅支持 AMR 格式(`.amr`)。非 AMR 格式的音频文件将以文件消息的形式发送。
52
+ - **发送耗时**:文件发送涉及系统处理,可能耗时较长。发送含 `MEDIA:` 指令的消息 30 秒后,请主动检查文件是否已成功发送。如果没有请重试。
53
+
54
+ ## 示例
55
+
56
+ ```
57
+ 以下是生成的图表:
58
+ MEDIA: ~/.openclaw/workspace/output.png
59
+
60
+ 报告已生成,请查收:
61
+ MEDIA: ~/.openclaw/workspace/report.pdf
62
+ ```
63
+
64
+ ## 错误示例
65
+
66
+ - ❌ 错误:说"我无法发送本地图片"
67
+ - ❌ 错误:说"受限于技术限制,无法直接发送"
68
+ - ❌ 错误:说"由于某些问题,我无法直接发送文件"
package/wecom/accounts.js CHANGED
@@ -9,6 +9,7 @@ const RESERVED_KEYS = new Set([
9
9
  "websocketUrl",
10
10
  "sendThinkingMessage",
11
11
  "welcomeMessage",
12
+ "welcomeMessagesFile",
12
13
  "allowFrom",
13
14
  "dmPolicy",
14
15
  "groupPolicy",
@@ -32,6 +33,7 @@ const SHARED_MULTI_ACCOUNT_KEYS = new Set([
32
33
  "websocketUrl",
33
34
  "sendThinkingMessage",
34
35
  "welcomeMessage",
36
+ "welcomeMessagesFile",
35
37
  "allowFrom",
36
38
  "dmPolicy",
37
39
  "groupPolicy",
@@ -300,7 +300,7 @@ async function processCallbackMessage({ parsedMsg, account, config, runtime }) {
300
300
  agent: account.agentCredentials,
301
301
  mediaId,
302
302
  type: mediaType === "image" ? "image" : mediaType === "voice" ? "voice" : "file",
303
- runtime,
303
+ mediaRuntime: core.media ?? runtime?.media,
304
304
  config,
305
305
  });
306
306
  mediaList.push(downloaded);
@@ -321,10 +321,6 @@ async function processCallbackMessage({ parsedMsg, account, config, runtime }) {
321
321
  ? generateAgentId(peerKind, peerId, account.accountId)
322
322
  : null;
323
323
 
324
- if (dynamicAgentId) {
325
- await ensureDynamicAgentListed(dynamicAgentId, account.config.workspaceTemplate);
326
- }
327
-
328
324
  const route = core.routing.resolveAgentRoute({
329
325
  cfg: config,
330
326
  channel: CHANNEL_ID,
@@ -337,9 +333,17 @@ async function processCallbackMessage({ parsedMsg, account, config, runtime }) {
337
333
  config.bindings.some(
338
334
  (b) => b.match?.channel === CHANNEL_ID && b.match?.accountId === account.accountId,
339
335
  );
336
+
340
337
  if (dynamicAgentId && !hasExplicitBinding) {
338
+ const routeAgentId = route.agentId;
339
+ // Use the account's configured agentId as the base for property inheritance
340
+ // (model, subagents, tools). route.agentId may resolve to "main" when
341
+ // there is no explicit binding, but the account's agentId points to the
342
+ // actual parent agent whose properties the dynamic agent should inherit.
343
+ const baseAgentId = account.config.agentId || routeAgentId;
344
+ await ensureDynamicAgentListed(dynamicAgentId, account.config.workspaceTemplate, baseAgentId);
345
+ route.sessionKey = route.sessionKey.replace(`agent:${routeAgentId}:`, `agent:${dynamicAgentId}:`);
341
346
  route.agentId = dynamicAgentId;
342
- route.sessionKey = `agent:${dynamicAgentId}:${peerKind}:${peerId}`;
343
347
  }
344
348
 
345
349
  // Build a body object that mirrors the WS frame.body structure expected by
@@ -12,6 +12,12 @@ import { getAccessToken } from "./agent-api.js";
12
12
  import { wecomFetch } from "./http.js";
13
13
  import { AGENT_API_ENDPOINTS, CALLBACK_MEDIA_DOWNLOAD_TIMEOUT_MS } from "./constants.js";
14
14
 
15
+ function resolveManagedCallbackMediaDir() {
16
+ const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
17
+ const stateDir = override || path.join(process.env.HOME || "/tmp", ".openclaw");
18
+ return path.join(stateDir, "media", "wecom");
19
+ }
20
+
15
21
  /**
16
22
  * Download a WeCom media file (image / voice / file) by MediaId via the
17
23
  * self-built app access token and save it through the core media runtime.
@@ -20,11 +26,11 @@ import { AGENT_API_ENDPOINTS, CALLBACK_MEDIA_DOWNLOAD_TIMEOUT_MS } from "./const
20
26
  * @param {object} params.agent - { corpId, corpSecret, agentId }
21
27
  * @param {string} params.mediaId - WeCom MediaId
22
28
  * @param {"image"|"voice"|"file"} params.type - media type hint
23
- * @param {object} params.runtime - OpenClaw runtime (for saveMediaBuffer)
29
+ * @param {object} [params.mediaRuntime] - OpenClaw media runtime (for saveMediaBuffer)
24
30
  * @param {object} params.config - OpenClaw config (for mediaMaxMb)
25
31
  * @returns {Promise<{ path: string, contentType: string }>}
26
32
  */
27
- export async function downloadCallbackMedia({ agent, mediaId, type, runtime, config }) {
33
+ export async function downloadCallbackMedia({ agent, mediaId, type, mediaRuntime, config }) {
28
34
  const token = await getAccessToken(agent);
29
35
  const url = `${AGENT_API_ENDPOINTS.DOWNLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`;
30
36
 
@@ -57,20 +63,22 @@ export async function downloadCallbackMedia({ agent, mediaId, type, runtime, con
57
63
  (type === "image" ? `${mediaId}.jpg` : type === "voice" ? `${mediaId}.amr` : mediaId);
58
64
 
59
65
  // Save via core media runtime when available
60
- if (typeof runtime?.media?.saveMediaBuffer === "function") {
61
- const saved = await runtime.media.saveMediaBuffer(buffer, contentType, "inbound", maxBytes, filename);
66
+ if (typeof mediaRuntime?.saveMediaBuffer === "function") {
67
+ const saved = await mediaRuntime.saveMediaBuffer(buffer, contentType, "inbound", maxBytes, filename);
62
68
  return { path: saved.path, contentType: saved.contentType };
63
69
  }
64
70
 
65
- // Fallback: write to OS temp dir
66
- const { tmpdir } = await import("node:os");
67
- const { writeFile } = await import("node:fs/promises");
71
+ // Fallback: keep callback media under the managed OpenClaw media root so
72
+ // stageSandboxMedia can safely copy it into the agent sandbox later.
73
+ const { mkdir, writeFile } = await import("node:fs/promises");
68
74
  const ext = path.extname(filename) || (type === "image" ? ".jpg" : ".bin");
75
+ const mediaDir = resolveManagedCallbackMediaDir();
69
76
  const tempPath = path.join(
70
- tmpdir(),
77
+ mediaDir,
71
78
  `wecom-cb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`,
72
79
  );
80
+ await mkdir(mediaDir, { recursive: true, mode: 0o700 });
73
81
  await writeFile(tempPath, buffer);
74
- logger.debug(`[CB] Media saved to temp path: ${tempPath}`);
82
+ logger.debug(`[CB] Media saved to managed path: ${tempPath}`);
75
83
  return { path: tempPath, contentType };
76
84
  }
@@ -24,7 +24,7 @@ import { wecomOnboardingAdapter } from "./onboarding.js";
24
24
  import { getAccountTelemetry, recordOutboundActivity } from "./runtime-telemetry.js";
25
25
  import { getOpenclawConfig, getRuntime, setOpenclawConfig } from "./state.js";
26
26
  import { resolveWecomTarget } from "./target.js";
27
- import { webhookSendFile, webhookSendImage, webhookSendMarkdown, webhookUploadFile } from "./webhook-bot.js";
27
+ import { webhookSendFile, webhookSendImage, webhookSendMarkdown, webhookSendText, webhookUploadFile } from "./webhook-bot.js";
28
28
  import { loadOutboundMediaFromUrl as loadOutboundMediaFromUrlCompat } from "./openclaw-compat.js";
29
29
  import {
30
30
  CHANNEL_ID,
@@ -36,6 +36,7 @@ import {
36
36
  } from "./constants.js";
37
37
  import { uploadAndSendMedia } from "./media-uploader.js";
38
38
  import { getExtendedMediaLocalRoots } from "./openclaw-compat.js";
39
+ import { extractParentAgentId } from "./parent-resolver.js";
39
40
  import { sendWsMessage, startWsMonitor } from "./ws-monitor.js";
40
41
  import { getWsClient } from "./ws-state.js";
41
42
 
@@ -141,7 +142,7 @@ function applyNetworkConfig(cfg, accountId) {
141
142
  return account;
142
143
  }
143
144
 
144
- async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, preparedMedia }) {
145
+ async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, preparedMedia, replyFormat }) {
145
146
  const account = resolveAccount(cfg, accountId);
146
147
  const raw = account?.config?.webhooks?.[webhookName];
147
148
  const url = raw ? (String(raw).startsWith("http") ? String(raw) : `${getWebhookBotSendUrl()}?key=${raw}`) : null;
@@ -149,8 +150,13 @@ async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, pre
149
150
  throw new Error(`unknown webhook target: ${webhookName}`);
150
151
  }
151
152
 
153
+ const effectiveFormat = replyFormat || account?.agentReplyFormat || "markdown";
154
+ const sendWebhookText = effectiveFormat === "text"
155
+ ? (opts) => webhookSendText(opts)
156
+ : (opts) => webhookSendMarkdown(opts);
157
+
152
158
  if (!mediaUrl) {
153
- await webhookSendMarkdown({ url, content: text });
159
+ await sendWebhookText({ url, content: text });
154
160
  recordOutboundActivity({ accountId });
155
161
  return { channel: CHANNEL_ID, messageId: `wecom-webhook-${Date.now()}` };
156
162
  }
@@ -159,7 +165,7 @@ async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, pre
159
165
  preparedMedia ?? (await loadResolvedMedia(mediaUrl, { accountConfig: account?.config }));
160
166
 
161
167
  if (text) {
162
- await webhookSendMarkdown({ url, content: text });
168
+ await sendWebhookText({ url, content: text });
163
169
  }
164
170
 
165
171
  if (mediaType === "image") {
@@ -177,15 +183,17 @@ async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, pre
177
183
  return { channel: CHANNEL_ID, messageId: `wecom-webhook-${Date.now()}` };
178
184
  }
179
185
 
180
- async function sendViaAgent({ cfg, accountId, target, text, mediaUrl, preparedMedia }) {
181
- const agent = resolveAccount(cfg, accountId)?.agentCredentials;
186
+ async function sendViaAgent({ cfg, accountId, target, text, mediaUrl, preparedMedia, replyFormat }) {
187
+ const account = resolveAccount(cfg, accountId);
188
+ const agent = account?.agentCredentials;
182
189
  if (!agent) {
183
190
  throw new Error("Agent API is not configured for this account");
184
191
  }
185
192
 
193
+ const effectiveFormat = replyFormat || account?.agentReplyFormat || "markdown";
186
194
  if (text) {
187
195
  for (const chunk of splitTextByByteLimit(text)) {
188
- await agentSendText({ agent, ...target, text: chunk });
196
+ await agentSendText({ agent, ...target, text: chunk, format: effectiveFormat });
189
197
  }
190
198
  }
191
199
 
@@ -262,6 +270,7 @@ export const wecomChannelPlugin = {
262
270
  websocketUrl: { type: "string" },
263
271
  sendThinkingMessage: { type: "boolean" },
264
272
  welcomeMessage: { type: "string" },
273
+ welcomeMessagesFile: { type: "string" },
265
274
  dmPolicy: { enum: ["pairing", "allowlist", "open", "disabled"] },
266
275
  allowFrom: { type: "array", items: { type: "string" } },
267
276
  groupPolicy: { enum: ["open", "allowlist", "disabled"] },
@@ -291,6 +300,10 @@ export const wecomChannelPlugin = {
291
300
  secret: { label: "Secret", sensitive: true },
292
301
  websocketUrl: { label: "WebSocket URL", placeholder: DEFAULT_WS_URL },
293
302
  welcomeMessage: { label: "Welcome Message" },
303
+ welcomeMessagesFile: {
304
+ label: "Welcome Messages File",
305
+ placeholder: "welcome-messages.json",
306
+ },
294
307
  "agent.corpSecret": { sensitive: true, label: "Application Secret" },
295
308
  "agent.replyFormat": { label: "Reply Format", placeholder: "text" },
296
309
  "agent.callback.token": { label: "Callback Token" },
@@ -404,6 +417,7 @@ export const wecomChannelPlugin = {
404
417
  try {
405
418
  if (!target.toParty && !target.toTag) {
406
419
  const wsTarget = target.chatId || target.toUser || to;
420
+ logger.debug(`[wecom] sendText: trying WS accountId=${resolvedAccountId} target=${wsTarget}`);
407
421
  return await sendWsMessage({
408
422
  to: wsTarget,
409
423
  content: text,
@@ -411,9 +425,17 @@ export const wecomChannelPlugin = {
411
425
  });
412
426
  }
413
427
  } catch (error) {
414
- logger.warn(`[wecom] WS sendText failed, falling back to Agent API: ${error.message}`);
428
+ logger.warn(`[wecom] WS sendText failed (accountId=${resolvedAccountId}), falling back: ${error.message}`);
429
+ }
430
+
431
+ // Webhook fallback for accounts without Agent API (e.g. WS bot mode)
432
+ const account = resolveAccount(cfg, resolvedAccountId);
433
+ if (!account?.agentCredentials && account?.config?.webhooks?.default) {
434
+ logger.debug(`[wecom] sendText: Agent API unavailable, using webhook fallback accountId=${resolvedAccountId}`);
435
+ return sendViaWebhook({ cfg, accountId: resolvedAccountId, webhookName: "default", text });
415
436
  }
416
437
 
438
+ logger.debug(`[wecom] sendText: trying Agent API accountId=${resolvedAccountId}`);
417
439
  return sendViaAgent({
418
440
  cfg,
419
441
  accountId: resolvedAccountId,
@@ -631,6 +653,55 @@ export const wecomChannelPlugin = {
631
653
  };
632
654
  },
633
655
  },
656
+ hooks: {
657
+ /**
658
+ * Ensure announce delivery uses a valid WeCom channel accountId.
659
+ *
660
+ * When a dynamic agent (e.g. wecom-yoyo-dm-xxx) spawns a sub-agent,
661
+ * the announce delivery may reference the dynamic agent ID as accountId.
662
+ * This hook resolves it to the actual WeCom account (e.g. yoyo) so the
663
+ * outbound sendText can find valid WS/Agent API credentials.
664
+ */
665
+ subagent_delivery_target: async (event, ctx) => {
666
+ const origin = event.requesterOrigin;
667
+ if (!origin?.channel || origin.channel !== CHANNEL_ID) return;
668
+
669
+ const cfg = ctx?.cfg ?? getOpenclawConfig();
670
+
671
+ // Check whether current accountId already resolves to a valid account
672
+ const currentAccount = resolveAccount(cfg, origin.accountId);
673
+ if (currentAccount?.enabled) return;
674
+
675
+ // Try to extract the base account from a dynamic agent ID
676
+ const baseId = extractParentAgentId(origin.accountId);
677
+ if (baseId && baseId !== origin.accountId) {
678
+ const baseAccount = resolveAccount(cfg, baseId);
679
+ if (baseAccount?.enabled) {
680
+ logger.info(`[wecom] subagent_delivery_target: ${origin.accountId} → ${baseId}`);
681
+ return { origin: { ...origin, accountId: baseId } };
682
+ }
683
+ }
684
+
685
+ // Fallback to default account
686
+ const defaultId = resolveDefaultAccountId(cfg);
687
+ if (defaultId && defaultId !== origin.accountId) {
688
+ logger.info(`[wecom] subagent_delivery_target: fallback → ${defaultId}`);
689
+ return { origin: { ...origin, accountId: defaultId } };
690
+ }
691
+ },
692
+
693
+ subagent_spawned: async (event) => {
694
+ logger.info(
695
+ `[wecom] subagent spawned: child=${event.childSessionKey} requester=${event.requesterSessionKey}`,
696
+ );
697
+ },
698
+
699
+ subagent_ended: async (event) => {
700
+ logger.info(
701
+ `[wecom] subagent ended: target=${event.targetSessionKey} reason=${event.reason} outcome=${event.outcome}`,
702
+ );
703
+ },
704
+ },
634
705
  };
635
706
 
636
707
  export const wecomChannelPluginTesting = {};
@@ -47,7 +47,8 @@ export const DEFAULT_WELCOME_MESSAGES = [
47
47
  "/compact 压缩对话",
48
48
  "/help 帮助",
49
49
  "/status 查看状态",
50
- "/reasoning stream 打开思考动画",
50
+ "你可以让我生成和编辑图片了",
51
+ "你可以用语音跟我对话",
51
52
  ].join("\n"),
52
53
  [
53
54
  "终于唤醒我啦,我已经准备就绪!😄",
@@ -57,7 +58,8 @@ export const DEFAULT_WELCOME_MESSAGES = [
57
58
  "/compact 压缩对话",
58
59
  "/help 帮助",
59
60
  "/status 查看状态",
60
- "/reasoning stream 打开思考动画",
61
+ "你可以让我生成和编辑图片了",
62
+ "你可以用语音跟我对话",
61
63
  ].join("\n"),
62
64
  [
63
65
  "欢迎回来,准备开始今天的工作吧!✨",
@@ -67,7 +69,8 @@ export const DEFAULT_WELCOME_MESSAGES = [
67
69
  "/compact 压缩对话",
68
70
  "/help 帮助",
69
71
  "/status 查看状态",
70
- "/reasoning stream 打开思考动画",
72
+ "你可以让我生成和编辑图片了",
73
+ "你可以用语音跟我对话",
71
74
  ].join("\n"),
72
75
  [
73
76
  "嗨,我已经在线!🤖",
@@ -77,7 +80,8 @@ export const DEFAULT_WELCOME_MESSAGES = [
77
80
  "/compact 压缩对话",
78
81
  "/help 帮助",
79
82
  "/status 查看状态",
80
- "/reasoning stream 打开思考动画",
83
+ "你可以让我生成和编辑图片了",
84
+ "你可以用语音跟我对话",
81
85
  ].join("\n"),
82
86
  [
83
87
  "今天也一起高效开工吧!🚀",
@@ -87,7 +91,8 @@ export const DEFAULT_WELCOME_MESSAGES = [
87
91
  "/compact 压缩对话",
88
92
  "/help 帮助",
89
93
  "/status 查看状态",
90
- "/reasoning stream 打开思考动画",
94
+ "你可以让我生成和编辑图片了",
95
+ "你可以用语音跟我对话",
91
96
  ].join("\n"),
92
97
  [
93
98
  "叮咚,你的数字助手已就位!🎉",
@@ -97,7 +102,8 @@ export const DEFAULT_WELCOME_MESSAGES = [
97
102
  "/compact 压缩对话",
98
103
  "/help 帮助",
99
104
  "/status 查看状态",
100
- "/reasoning stream 打开思考动画",
105
+ "你可以让我生成和编辑图片了",
106
+ "你可以用语音跟我对话",
101
107
  ].join("\n"),
102
108
  [
103
109
  "灵感加载完成,随时可以开聊!💡",
@@ -107,7 +113,8 @@ export const DEFAULT_WELCOME_MESSAGES = [
107
113
  "/compact 压缩对话",
108
114
  "/help 帮助",
109
115
  "/status 查看状态",
110
- "/reasoning stream 打开思考动画",
116
+ "你可以让我生成和编辑图片了",
117
+ "你可以用语音跟我对话",
111
118
  ].join("\n"),
112
119
  ];
113
120
  export const DEFAULT_WELCOME_MESSAGE = DEFAULT_WELCOME_MESSAGES[0];