@workclaw/openclaw-workchat 1.0.4 → 1.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.
@@ -23,6 +23,10 @@ export type ParseWorkChatFrameResult = {
23
23
  openConversationId: string;
24
24
  messageId: string;
25
25
  agentId?: string;
26
+ /** "image"/"audio" for media messages (backend message.type / chatType). */
27
+ mediaType?: string;
28
+ /** Media URL from message.content JSON or the DTO fileUrl list. */
29
+ mediaUrl?: string;
26
30
  };
27
31
  pongData?: unknown;
28
32
  eventData?: AgentEventPayload;
@@ -83,11 +87,28 @@ export function parseWorkChatFrame(data: string): ParseWorkChatFrameResult | nul
83
87
  if (type === "CALLBACK" && String(envelope?.metadata?.topic || "").trim().toUpperCase() === "AGENT_MESSAGE") {
84
88
  const payload = (parseMaybeJson(envelope?.data) ?? {}) as AgentMessagePayload;
85
89
  const message = payload?.message;
86
- const text = typeof message === "string"
90
+ const messageType = typeof message === "object" && message !== null
91
+ ? String((message as { type?: unknown }).type ?? "").trim().toLowerCase()
92
+ : "";
93
+ const rawContent = typeof message === "string"
87
94
  ? message
88
95
  : typeof message?.content === "string"
89
96
  ? message.content
90
97
  : "";
98
+
99
+ // Media messages: content is JSON {"url", ...} (image) or {"url","duration"} (audio);
100
+ // the backend DTO also carries a fileUrl list. Surface the URL for the AI
101
+ // pipeline while keeping a descriptive text body.
102
+ let mediaUrl: string | undefined;
103
+ let text = rawContent;
104
+ if (messageType === "image" || messageType === "audio") {
105
+ mediaUrl = extractMediaUrl(rawContent) ?? firstFileUrl(payload);
106
+ text = messageType === "image" ? "[用户发送了一张图片]" : "[用户发送了一条语音]";
107
+ } else if (!text.trim()) {
108
+ mediaUrl = firstFileUrl(payload);
109
+ if (mediaUrl) text = "[用户发送了一个文件]";
110
+ }
111
+
91
112
  return {
92
113
  type: "agent_message",
93
114
  message: {
@@ -96,6 +117,7 @@ export function parseWorkChatFrame(data: string): ParseWorkChatFrameResult | nul
96
117
  openConversationId: String(payload?.conversationId ?? ""),
97
118
  messageId: String(payload?.msgId ?? ""),
98
119
  ...(payload?.agentId !== undefined ? { agentId: String(payload.agentId) } : {}),
120
+ ...(mediaUrl ? { mediaUrl, mediaType: messageType || "file" } : {}),
99
121
  },
100
122
  };
101
123
  }
@@ -103,6 +125,23 @@ export function parseWorkChatFrame(data: string): ParseWorkChatFrameResult | nul
103
125
  return { type: "ignored", ignoredKind: `${type}/${topicLower || "(no topic)"}` };
104
126
  }
105
127
 
128
+ /** Extract `url` from a media message content JSON string, if parseable. */
129
+ function extractMediaUrl(content: string): string | undefined {
130
+ if (!content.trim()) return undefined;
131
+ try {
132
+ const parsed = JSON.parse(content) as { url?: unknown };
133
+ return typeof parsed?.url === "string" && parsed.url.trim() ? parsed.url.trim() : undefined;
134
+ } catch {
135
+ return undefined;
136
+ }
137
+ }
138
+
139
+ /** First entry of the backend DTO fileUrl list, when present. */
140
+ function firstFileUrl(payload: AgentMessagePayload): string | undefined {
141
+ const url = Array.isArray(payload?.fileUrl) ? payload.fileUrl[0] : undefined;
142
+ return typeof url === "string" && url.trim() ? url.trim() : undefined;
143
+ }
144
+
106
145
  /** Build the pong response frame for a SYSTEM ping. */
107
146
  export function buildPongFrame(pongData: unknown): string {
108
147
  return JSON.stringify({
@@ -139,6 +178,12 @@ export type WorkChatMsgContext = {
139
178
  ConversationId?: string;
140
179
  /** Cloud agent id this message was addressed to. */
141
180
  AgentId?: string;
181
+ /** Media URL for inbound image/audio/file messages (WorkChat-hosted). */
182
+ MediaUrl?: string;
183
+ /** Media kind for inbound media messages ("image" | "audio" | "file"). */
184
+ MediaType?: string;
185
+ /** Local path after the media is persisted into the framework media store. */
186
+ MediaPath?: string;
142
187
  };
143
188
 
144
189
  function generateMessageSid(): string {
@@ -151,8 +196,13 @@ export function workChatMessageToMsgContext(
151
196
  accountId: string,
152
197
  ): WorkChatMsgContext {
153
198
  const fromUserId = message.userId ?? "";
199
+ // Media messages carry a placeholder body; append the URL so the model can
200
+ // see and describe the actual asset.
201
+ const body = message.mediaUrl
202
+ ? `${message.text ?? ""}\n[media] ${message.mediaUrl}`
203
+ : message.text ?? "";
154
204
  const ctx: WorkChatMsgContext = {
155
- Body: message.text ?? "",
205
+ Body: body,
156
206
  From: fromUserId,
157
207
  To: fromUserId,
158
208
  AccountId: accountId,
@@ -170,5 +220,9 @@ export function workChatMessageToMsgContext(
170
220
  if (message.agentId) {
171
221
  ctx.AgentId = message.agentId;
172
222
  }
223
+ if (message.mediaUrl) {
224
+ ctx.MediaUrl = message.mediaUrl;
225
+ ctx.MediaType = message.mediaType ?? "file";
226
+ }
173
227
  return ctx;
174
228
  }
@@ -12,7 +12,7 @@ import {
12
12
  registerWorkChatAccountId,
13
13
  saveWorkChatAccount,
14
14
  triggerWorkChatChannelReload,
15
- pruneStaleWorkChatAccounts,
15
+ pruneOtherWorkChatAccounts,
16
16
  removeWorkChatAccountBinding,
17
17
  } from "../auth/accounts.js";
18
18
  import type { ResolvedWorkChatAccount } from "../auth/accounts.js";
@@ -33,10 +33,44 @@ function loadWorkChatOutboundHooks() {
33
33
  return workchatOutboundHooksPromise;
34
34
  }
35
35
 
36
+ /** Max inbound media size persisted to the framework media store. */
37
+ const WORKCHAT_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
38
+
39
+ /**
40
+ * Download an inbound media URL. Prefers the framework's fetchRemoteMedia
41
+ * (SSRF guard + size limit); falls back to plain fetch.
42
+ */
43
+ async function downloadInboundMedia(
44
+ url: string,
45
+ deps: ProcessFrameDeps,
46
+ ): Promise<{ buffer: Buffer; contentType?: string } | null> {
47
+ try {
48
+ if (deps.channelRuntime?.media?.fetchRemoteMedia) {
49
+ const fetched = await deps.channelRuntime.media.fetchRemoteMedia({
50
+ url,
51
+ maxBytes: WORKCHAT_MEDIA_MAX_BYTES,
52
+ });
53
+ return { buffer: fetched.buffer, contentType: fetched.contentType };
54
+ }
55
+ } catch (err) {
56
+ logger.warn(`fetchRemoteMedia failed, trying plain fetch: ${String(err)}`);
57
+ }
58
+ const res = await globalThis.fetch(url);
59
+ if (!res.ok) {
60
+ throw new Error(`inbound media download failed: HTTP ${res.status}`);
61
+ }
62
+ const buffer = Buffer.from(await res.arrayBuffer());
63
+ if (buffer.length > WORKCHAT_MEDIA_MAX_BYTES) {
64
+ throw new Error(`inbound media exceeds limit (${buffer.length} bytes)`);
65
+ }
66
+ return { buffer, contentType: res.headers.get("content-type") ?? undefined };
67
+ }
68
+
36
69
  async function applyWorkChatMessageSendingHook(params: {
37
70
  to: string;
38
71
  text: string;
39
72
  accountId?: string;
73
+ mediaUrl?: string;
40
74
  }): Promise<{ cancelled: boolean; text: string }> {
41
75
  try {
42
76
  const hooks = await loadWorkChatOutboundHooks();
@@ -79,48 +113,41 @@ async function handleAgentLifecycleEvent(
79
113
  const eventData = result.eventData;
80
114
  if (!eventData) return;
81
115
 
82
- const agentId = String(eventData.id ?? eventData.agentId ?? "");
116
+ const agentId = String(eventData.id ?? eventData.agentId ?? "").trim();
83
117
  if (!agentId) {
84
118
  logger.warn(`${frameType}: missing agent id, ignoring`);
85
119
  return;
86
120
  }
87
- const userId = String(eventData.futureId ?? eventData.userId ?? "");
121
+ const userId = String(eventData.futureId ?? eventData.userId ?? "").trim();
88
122
 
89
123
  logger.info(`${frameType}: agentId=${agentId} userId=${userId ? "present" : "missing"}`);
90
124
  deps.log(`${frameType}: agentId=${agentId}`);
91
125
 
92
- // Rebind detection must happen BEFORE pruning empties the index (see the
93
- // matching note in the binding listener): a rebind always registers under a
94
- // FRESH agent-scoped key so the gateway restarts it — the QR login flow
95
- // force-stops the channel with a manual-stop mark, and a reused account id
96
- // would stay stopped after the config reload.
97
- const isRebind = listWorkChatAccountIdsCount() > 0;
126
+ // One active agent per host: every binding (first scan included) registers
127
+ // under the agent-scoped key `agent-${agentId}`, and every OTHER registered
128
+ // account is removed before persisting. A reused account id would also stay
129
+ // stopped after the QR flow's manual-stop mark, so rebinds always land on
130
+ // the fresh key and the gateway restarts it via the channel reload below.
131
+ const { completeWorkChatBinding } = await import("../auth/login-qr.js");
132
+ const accountId = frameType === "init_agent"
133
+ ? `agent-${agentId}`
134
+ : deps.accountId;
98
135
 
99
- // Rebind semantics: one active agent per host. Before persisting the new
100
- // binding, remove every registered account bound to a different agent
101
- // (a re-scan creates a new platform agent; the old one is deleted there).
102
136
  try {
103
- const removed = await pruneStaleWorkChatAccounts(agentId);
137
+ const removed = await pruneOtherWorkChatAccounts(accountId);
104
138
  if (removed.length > 0) {
105
- deps.log(`rebind: removed stale account(s): ${removed.join(", ")}`);
139
+ deps.log(`rebind: removed previous account(s): ${removed.join(", ")}`);
106
140
  }
107
141
  } catch (err) {
108
142
  logger.error(`rebind cleanup failed: ${String(err)}`);
109
143
  }
110
144
 
111
- // The bound agent becomes the account: `default` for the primary install,
112
- // or a fresh agent-scoped key on rebind.
113
- const { completeWorkChatBinding } = await import("../auth/login-qr.js");
114
- const accountId = frameType === "init_agent"
115
- ? (isRebind ? `agent-${agentId}` : "default")
116
- : deps.accountId;
117
-
118
145
  try {
119
146
  saveWorkChatAccount(accountId, {
120
147
  agentId,
121
148
  ...(userId ? { userId } : {}),
122
149
  });
123
- registerWorkChatAccountId(accountId);
150
+ await registerWorkChatAccountId(accountId);
124
151
  // Bind the account to the incoming agent for message routing. agentId
125
152
  // comes from the event; userId (futureId) is the WorkChat user.
126
153
  const { bindAgentToAccount } = await import("../auth/bind-agent.js");
@@ -149,10 +176,6 @@ async function handleAgentLifecycleEvent(
149
176
  }
150
177
  }
151
178
 
152
- function listWorkChatAccountIdsCount(): number {
153
- return listIndexedWorkChatAccountIds().length;
154
- }
155
-
156
179
  // Typing-indicator helper versions: the host renamed the subpath between
157
180
  // releases (2026.3.x exports openclaw/plugin-sdk/channel-runtime, 2026.8.x
158
181
  // exports openclaw/plugin-sdk/channel-message). Load whichever resolves —
@@ -261,6 +284,17 @@ export async function processOneFrame(
261
284
  return;
262
285
  }
263
286
 
287
+ // --- Dedup: platform redelivery / monitor restart can replay frames; a
288
+ // known msgId is dropped before any dispatch side-effect.
289
+ if (message.messageId) {
290
+ const { isDuplicateMessage, recordMessageProcessed } = await import("../storage/seen-msgids.js");
291
+ if (isDuplicateMessage(deps.accountId, message.messageId)) {
292
+ logger.info(`inbound: duplicate msgId=${message.messageId}, dropping`);
293
+ return;
294
+ }
295
+ recordMessageProcessed(deps.accountId, message.messageId);
296
+ }
297
+
264
298
  const receivedAt = Date.now();
265
299
  const debug = isDebugMode(deps.accountId);
266
300
  const debugTrace: string[] = [];
@@ -268,7 +302,7 @@ export async function processOneFrame(
268
302
 
269
303
  const ctx = workChatMessageToMsgContext(message, deps.accountId);
270
304
  logger.info(
271
- `inbound: from=${ctx.From} to=${ctx.To} bodyLen=${ctx.Body.length} conversationId=${ctx.ConversationId ?? "(none)"}`,
305
+ `inbound: from=${ctx.From} to=${ctx.To} bodyLen=${ctx.Body.length} conversationId=${ctx.ConversationId ?? "(none)"} hasMedia=${Boolean(ctx.MediaUrl)}`,
272
306
  );
273
307
  logger.debug(`inbound raw frame: ${rawFrame.slice(0, 200)}`);
274
308
 
@@ -286,6 +320,30 @@ export async function processOneFrame(
286
320
  }
287
321
  }
288
322
 
323
+ // --- Inbound media persistence: download the WorkChat-hosted file into the
324
+ // framework media store so the AI sees a local MediaPath (vision-capable
325
+ // models can actually read the image) instead of just a URL in the body.
326
+ // Runs after the slash gate so commands never trigger media downloads.
327
+ if (ctx.MediaUrl && deps.channelRuntime?.media?.saveMediaBuffer) {
328
+ try {
329
+ const media = await downloadInboundMedia(ctx.MediaUrl, deps);
330
+ if (media) {
331
+ const saved = await deps.channelRuntime.media.saveMediaBuffer(
332
+ media.buffer,
333
+ media.contentType,
334
+ "inbound",
335
+ WORKCHAT_MEDIA_MAX_BYTES,
336
+ );
337
+ ctx.MediaPath = saved.path;
338
+ ctx.MediaType = media.contentType ?? ctx.MediaType;
339
+ logger.info(`inbound media saved: path=${saved.path} type=${media.contentType}`);
340
+ }
341
+ } catch (err) {
342
+ // Non-fatal: the URL is already in Body; the AI can still reference it.
343
+ deps.errLog(`inbound media download failed url=${redactBody(ctx.MediaUrl)}: ${String(err)}`);
344
+ }
345
+ }
346
+
289
347
  // --- Framework command authorization ---
290
348
  const rawBody = ctx.Body?.trim() ?? "";
291
349
  ctx.CommandBody = rawBody;
@@ -406,8 +464,11 @@ export async function processOneFrame(
406
464
  const rawText = String(payload.text ?? "");
407
465
  const f = new StreamingMarkdownFilter();
408
466
  let text = f.feed(rawText) + f.flush();
467
+ const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
409
468
 
410
- logger.info(`outbound: to=${ctx.To} textLen=${text.length}`);
469
+ logger.info(
470
+ `outbound: to=${ctx.To} textLen=${text.length} mediaUrl=${mediaUrl ? "present" : "none"}`,
471
+ );
411
472
 
412
473
  if (debug) {
413
474
  debugDeliveries.push({
@@ -421,6 +482,7 @@ export async function processOneFrame(
421
482
  to: ctx.To,
422
483
  text,
423
484
  accountId: deps.accountId,
485
+ mediaUrl,
424
486
  });
425
487
  if (sendingResult.cancelled) {
426
488
  logger.info(`outbound: cancelled by message_sending hook to=${ctx.To}`);
@@ -429,6 +491,43 @@ export async function processOneFrame(
429
491
  text = sendingResult.text;
430
492
 
431
493
  try {
494
+ // Media payloads ride the upload-then-card pipeline (same as the
495
+ // channel outbound sendMedia path); text-only payloads reply directly.
496
+ if (mediaUrl) {
497
+ const { isLocalMediaSource, resolveLocalMediaPath, downloadRemoteMediaToTemp, sendWorkChatMediaFile } =
498
+ await import("./send-media.js");
499
+ let filePath: string;
500
+ if (isLocalMediaSource(mediaUrl)) {
501
+ filePath = resolveLocalMediaPath(mediaUrl);
502
+ logger.debug(`outbound: local media resolved filePath=${filePath}`);
503
+ } else if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
504
+ logger.debug(`outbound: downloading remote mediaUrl=${mediaUrl.slice(0, 80)}...`);
505
+ filePath = await downloadRemoteMediaToTemp(mediaUrl);
506
+ } else {
507
+ logger.warn(
508
+ `outbound: unrecognized mediaUrl scheme, sending text only mediaUrl=${mediaUrl.slice(0, 80)}`,
509
+ );
510
+ filePath = "";
511
+ }
512
+ if (filePath) {
513
+ await sendWorkChatMediaFile({
514
+ account: {
515
+ ...deps.account,
516
+ agentId: ctx.AgentId ?? deps.account.agentId,
517
+ userId: ctx.From || deps.account.userId,
518
+ },
519
+ to: ctx.To,
520
+ filePath,
521
+ text,
522
+ replyToMessageId: ctx.MessageSid,
523
+ conversationId: ctx.ConversationId,
524
+ agentId: ctx.AgentId,
525
+ });
526
+ emitWorkChatMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId });
527
+ logger.info(`outbound: media reply sent OK to=${ctx.To}`);
528
+ return;
529
+ }
530
+ }
432
531
  const result = await replyWorkChatMessage({
433
532
  account: {
434
533
  ...deps.account,
@@ -453,7 +552,14 @@ export async function processOneFrame(
453
552
  onError: (err, info) => {
454
553
  deps.errLog(`workchat reply ${info.kind}: ${String(err)}`);
455
554
  const errMsg = err instanceof Error ? err.message : String(err);
456
- const notice = `⚠️ 消息发送失败:${errMsg}`;
555
+ let notice: string;
556
+ if (errMsg.includes("remote media download failed")) {
557
+ notice = `⚠️ 媒体文件下载失败,请检查链接是否可访问。`;
558
+ } else if (errMsg.includes("file upload response") || errMsg.includes("upload failed")) {
559
+ notice = `⚠️ 媒体文件上传失败,请稍后重试。`;
560
+ } else {
561
+ notice = `⚠️ 消息发送失败:${errMsg}`;
562
+ }
457
563
  void sendWorkChatErrorNotice({
458
564
  account: deps.account,
459
565
  to: ctx.To,
@@ -0,0 +1,201 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import type { WorkChatApiOptions } from "../api/api.js";
6
+ import { sendWorkChatMessage, sendWorkChatReplyMessage, uploadWorkChatFile } from "../api/api.js";
7
+ import { assertSessionActive, pauseOnPermanentError } from "../api/session-guard.js";
8
+ import { WorkChatMsgType } from "../api/types.js";
9
+ import type { ResolvedWorkChatAccount } from "../auth/accounts.js";
10
+ import { logger } from "../util/logger.js";
11
+ import { generateId } from "../util/random.js";
12
+
13
+ import { buildApiOptions, pushWorkChatMessage } from "./send.js";
14
+
15
+ /** Backend FileType whitelist (tuzai FileServiceImpl rejects unknown suffixes). */
16
+ const AUDIO_EXTENSIONS = new Set(["wav", "m4a", "mp3"]);
17
+
18
+ /** True when mediaUrl refers to a local filesystem path (not a remote URL). */
19
+ export function isLocalMediaSource(mediaUrl: string): boolean {
20
+ const trimmed = mediaUrl.trim();
21
+ if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return false;
22
+ return trimmed.startsWith("/")
23
+ || trimmed.startsWith("./")
24
+ || trimmed.startsWith("../")
25
+ || trimmed.startsWith("~")
26
+ || trimmed.startsWith("file://")
27
+ || path.isAbsolute(trimmed);
28
+ }
29
+
30
+ export function resolveLocalMediaPath(mediaUrl: string): string {
31
+ if (mediaUrl.startsWith("file://")) {
32
+ const parsed = new URL(mediaUrl);
33
+ return process.platform === "win32" ? parsed.pathname.replace(/^\/([A-Za-z]:)/, "$1") : parsed.pathname;
34
+ }
35
+ if (mediaUrl.startsWith("~")) {
36
+ return path.join(os.homedir(), mediaUrl.slice(1));
37
+ }
38
+ return path.resolve(mediaUrl);
39
+ }
40
+
41
+ /**
42
+ * Map a file path to the message type the WorkChat client renders as a card.
43
+ * The backend ChatType enum has Video/Voice/Link codes, but the open send API
44
+ * documents only text/image/audio — audio extensions ride "audio", everything
45
+ * else (images, and files outside the documented set) rides "image".
46
+ */
47
+ function resolveMediaMsgType(filePath: string): "image" | "audio" {
48
+ const ext = path.extname(filePath).replace(/^\./, "").toLowerCase();
49
+ return AUDIO_EXTENSIONS.has(ext) ? "audio" : "image";
50
+ }
51
+
52
+ interface MediaSendParams {
53
+ account: ResolvedWorkChatAccount;
54
+ to: string;
55
+ text?: string;
56
+ replyToMessageId?: string;
57
+ conversationId?: string;
58
+ agentId?: string;
59
+ }
60
+
61
+ function requireAgentId(account: ResolvedWorkChatAccount, agentId?: string): string {
62
+ const resolved = agentId ?? account.agentId;
63
+ if (!resolved) {
64
+ throw new Error("workchat: agentId is required — complete the binding QR flow first");
65
+ }
66
+ return resolved;
67
+ }
68
+
69
+ async function sendMediaRequest(params: {
70
+ account: ResolvedWorkChatAccount;
71
+ to: string;
72
+ msgType: string;
73
+ content: string;
74
+ replyToMessageId?: string;
75
+ conversationId?: string;
76
+ agentId?: string;
77
+ }): Promise<{ messageId: string }> {
78
+ const agentId = requireAgentId(params.account, params.agentId);
79
+ assertSessionActive(params.account.accountId);
80
+ const opts = buildApiOptions(params.account);
81
+ const body: {
82
+ agentId: string;
83
+ receiveId: string;
84
+ msgType: string;
85
+ content: string;
86
+ conversationId?: string;
87
+ is_last?: boolean;
88
+ } = {
89
+ agentId,
90
+ receiveId: params.account.userId ?? params.to,
91
+ msgType: params.msgType,
92
+ content: params.content,
93
+ ...(params.conversationId ? { conversationId: params.conversationId } : {}),
94
+ };
95
+ // is_last closes the streamed reply placeholder; media replies are single
96
+ // messages, so the media card itself is always the final frame.
97
+ if (params.replyToMessageId) body.is_last = true;
98
+ try {
99
+ if (params.replyToMessageId) {
100
+ const result = await sendWorkChatReplyMessage({ opts, messageId: params.replyToMessageId, body });
101
+ logger.info(`reply media sent to=${params.to} messageId=${result.msgId}`);
102
+ return { messageId: result.msgId };
103
+ }
104
+ const result = await sendWorkChatMessage({ opts, body });
105
+ logger.info(`push media sent to=${params.to} messageId=${result.msgId}`);
106
+ return { messageId: result.msgId };
107
+ } catch (err) {
108
+ pauseOnPermanentError(params.account.accountId, err instanceof Error ? err.message : String(err));
109
+ throw err;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Upload a local file and send it as a WorkChat image/audio message:
115
+ * audio (wav/m4a/mp3) → msgType "audio", content {"url"}
116
+ * everything else → msgType "image", content {"url"}
117
+ * The media content JSON cannot carry a caption, so a text caption (when
118
+ * present) is sent as its own message right before the media card.
119
+ */
120
+ export async function sendWorkChatMediaFile(params: MediaSendParams & {
121
+ filePath: string;
122
+ }): Promise<{ messageId: string }> {
123
+ const { account, to, filePath } = params;
124
+ const opts: WorkChatApiOptions = buildApiOptions(account);
125
+
126
+ logger.info(`sendMedia: uploading filePath=${filePath} to=${to}`);
127
+ const uploaded = await uploadWorkChatFile({ opts, filePath, userId: account.userId ?? to });
128
+ logger.info(`sendMedia: upload done urlLen=${uploaded.url.length} suffix=${uploaded.suffix ?? "(none)"}`);
129
+
130
+ const caption = params.text?.trim() ?? "";
131
+ if (caption) {
132
+ await pushWorkChatMessage({
133
+ account,
134
+ to,
135
+ text: caption,
136
+ conversationId: params.conversationId,
137
+ agentId: params.agentId,
138
+ });
139
+ }
140
+
141
+ const msgType = resolveMediaMsgType(filePath) === "audio" ? WorkChatMsgType.Audio : WorkChatMsgType.Image;
142
+ return sendMediaRequest({
143
+ account,
144
+ to,
145
+ msgType,
146
+ content: JSON.stringify({ url: uploaded.url }),
147
+ replyToMessageId: params.replyToMessageId,
148
+ conversationId: params.conversationId,
149
+ agentId: params.agentId,
150
+ });
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Remote URL relay
155
+ // ---------------------------------------------------------------------------
156
+
157
+ const REMOTE_MEDIA_DIR = path.join(os.tmpdir(), "openclaw", "workchat", "media");
158
+ const REMOTE_MEDIA_DOWNLOAD_TIMEOUT_MS = 60_000;
159
+
160
+ function tempMediaPath(ext: string): string {
161
+ return path.join(REMOTE_MEDIA_DIR, `${generateId("remote").replace(/:/g, "-")}${ext}`);
162
+ }
163
+
164
+ function extensionFromUrlOrContentType(contentType: string | null, url: string): string {
165
+ const fromUrl = path.extname(new URL(url).pathname).toLowerCase();
166
+ if (fromUrl) return fromUrl;
167
+ if (contentType?.startsWith("audio/mpeg")) return ".mp3";
168
+ if (contentType?.startsWith("audio/wav")) return ".wav";
169
+ if (contentType?.startsWith("audio/mp4")) return ".m4a";
170
+ if (contentType?.startsWith("image/")) return ".png";
171
+ return ".bin";
172
+ }
173
+
174
+ /**
175
+ * Download a remote media URL to a local temp file so the send pipeline can
176
+ * re-upload it to WorkChat file storage — clients render only WorkChat-hosted
177
+ * image/audio cards, never bare links. Bounded by a 60s abort so a hung
178
+ * remote never stalls the reply pipeline.
179
+ */
180
+ export async function downloadRemoteMediaToTemp(url: string): Promise<string> {
181
+ const controller = new AbortController();
182
+ const timeoutId = setTimeout(() => controller.abort(), REMOTE_MEDIA_DOWNLOAD_TIMEOUT_MS);
183
+ let response: Response;
184
+ try {
185
+ response = (await globalThis.fetch(url, { signal: controller.signal })) as Response;
186
+ } finally {
187
+ clearTimeout(timeoutId);
188
+ }
189
+ if (!response.ok) {
190
+ throw new Error(
191
+ `remote media download failed: ${response.status} ${response.statusText} url=${url.slice(0, 80)}`,
192
+ );
193
+ }
194
+ const buf = Buffer.from(await response.arrayBuffer());
195
+ const ext = extensionFromUrlOrContentType(response.headers.get("content-type"), url);
196
+ await mkdir(REMOTE_MEDIA_DIR, { recursive: true });
197
+ const filePath = tempMediaPath(ext);
198
+ await writeFile(filePath, buf);
199
+ logger.debug(`downloadRemoteMediaToTemp: saved ${buf.length} bytes to ${filePath}`);
200
+ return filePath;
201
+ }
@@ -6,6 +6,7 @@ import {
6
6
  sendWorkChatReplyMessage,
7
7
  } from "../api/api.js";
8
8
  import type { WorkChatApiOptions } from "../api/api.js";
9
+ import { pauseOnPermanentError } from "../api/session-guard.js";
9
10
  import type { ResolvedWorkChatAccount } from "../auth/accounts.js";
10
11
  import { logger } from "../util/logger.js";
11
12
 
@@ -38,6 +39,8 @@ function buildApiOptions(account: ResolvedWorkChatAccount): WorkChatApiOptions {
38
39
  };
39
40
  }
40
41
 
42
+ export { buildApiOptions };
43
+
41
44
  /**
42
45
  * Send a reply to a specific inbound message.
43
46
  * POST /open-apis/im/v1/messages/{messageId}/reply
@@ -74,6 +77,7 @@ export async function replyWorkChatMessage(params: {
74
77
  logger.info(`reply sent to=${to} messageId=${result.msgId}`);
75
78
  return { messageId: result.msgId };
76
79
  } catch (err) {
80
+ pauseOnPermanentError(account.accountId, err instanceof Error ? err.message : String(err));
77
81
  logger.error(`replyWorkChatMessage: failed to=${to} err=${String(err)}`);
78
82
  throw err;
79
83
  }
@@ -113,6 +117,7 @@ export async function pushWorkChatMessage(params: {
113
117
  logger.info(`push sent to=${to} messageId=${result.msgId}`);
114
118
  return { messageId: result.msgId };
115
119
  } catch (err) {
120
+ pauseOnPermanentError(account.accountId, err instanceof Error ? err.message : String(err));
116
121
  logger.error(`pushWorkChatMessage: failed to=${to} err=${String(err)}`);
117
122
  throw err;
118
123
  }
@@ -0,0 +1,79 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { resolveStateDir } from "./state-dir.js";
5
+ import { logger } from "../util/logger.js";
6
+
7
+ /**
8
+ * Persistent inbound message-id buffer (weixin sync-buf analogue): remembers
9
+ * the most recently processed AGENT_MESSAGE msgIds per account so a monitor
10
+ * restart / platform redelivery never double-dispatches a user message.
11
+ */
12
+
13
+ const SEEN_LIMIT = 500;
14
+
15
+ function resolveSeenFilePath(accountId: string): string {
16
+ const safe = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
17
+ return path.join(resolveStateDir(), "openclaw-workchat", "accounts", `${safe}.seen-msgids.json`);
18
+ }
19
+
20
+ type SeenCache = { ids: string[]; index: Set<string> };
21
+ const cache = new Map<string, SeenCache>();
22
+
23
+ function loadSeen(accountId: string): SeenCache {
24
+ const cached = cache.get(accountId);
25
+ if (cached) return cached;
26
+ let ids: string[] = [];
27
+ try {
28
+ const raw = fs.readFileSync(resolveSeenFilePath(accountId), "utf-8");
29
+ const parsed = JSON.parse(raw) as { msgIds?: unknown };
30
+ if (Array.isArray(parsed.msgIds)) {
31
+ ids = parsed.msgIds.filter((x): x is string => typeof x === "string" && x !== "");
32
+ }
33
+ } catch {
34
+ // missing or corrupt file — start empty
35
+ }
36
+ const entry: SeenCache = { ids, index: new Set(ids) };
37
+ cache.set(accountId, entry);
38
+ return entry;
39
+ }
40
+
41
+ function persistSeen(accountId: string, seen: SeenCache): void {
42
+ try {
43
+ const filePath = resolveSeenFilePath(accountId);
44
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
45
+ fs.writeFileSync(filePath, JSON.stringify({ msgIds: seen.ids }), "utf-8");
46
+ } catch (err) {
47
+ logger.warn(`seen-msgids persist failed accountId=${accountId}: ${String(err)}`);
48
+ }
49
+ }
50
+
51
+ /** Returns true when msgId was already recorded (duplicate delivery). */
52
+ export function isDuplicateMessage(accountId: string, msgId: string): boolean {
53
+ if (!msgId) return false;
54
+ const seen = loadSeen(accountId);
55
+ return seen.index.has(msgId);
56
+ }
57
+
58
+ /**
59
+ * Record msgId as processed. Returns true the first time (not a duplicate);
60
+ * false when it was already known.
61
+ */
62
+ export function recordMessageProcessed(accountId: string, msgId: string): boolean {
63
+ if (!msgId) return true;
64
+ const seen = loadSeen(accountId);
65
+ if (seen.index.has(msgId)) return false;
66
+ seen.index.add(msgId);
67
+ seen.ids.push(msgId);
68
+ if (seen.ids.length > SEEN_LIMIT) {
69
+ const drop = seen.ids.length - SEEN_LIMIT;
70
+ for (const old of seen.ids.splice(0, drop)) seen.index.delete(old);
71
+ }
72
+ persistSeen(accountId, seen);
73
+ return true;
74
+ }
75
+
76
+ /** Clear state — only for tests. @internal */
77
+ export function _resetSeenMsgIdsForTest(): void {
78
+ cache.clear();
79
+ }