@workclaw/openclaw-workchat 1.0.5 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workclaw/openclaw-workchat",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WorkChat channel plugin: QR binding + WebSocket messaging with the WorkChat open platform",
6
6
  "license": "MIT",
package/src/api/api.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { Agent, fetch as undiciFetch } from "undici";
2
-
3
1
  import { DEFAULT_BASE_URL, DEFAULT_WS_URL, RequestDefaults } from "./types.js";
4
2
  import { logger } from "../util/logger.js";
5
3
  import { redactBody, redactUrl } from "../util/redact.js";
@@ -23,10 +21,13 @@ async function fetchJson<T>(params: {
23
21
  allowInsecureTls?: boolean;
24
22
  }): Promise<T> {
25
23
  const { url, init, timeoutMs, allowInsecureTls } = params;
26
- const dispatcher = allowInsecureTls
27
- ? new Agent({ connect: { rejectUnauthorized: false } })
28
- : undefined;
29
- const fetcher = allowInsecureTls ? undiciFetch : globalThis.fetch;
24
+ void allowInsecureTls; // TLS verification always on (see fetcher note below)
25
+ // Native fetch only: undici's fetch serializes FormData file parts without
26
+ // the Blob's Content-Type (backend rejects with 4006 "不支持的媒体类型"),
27
+ // while Node's built-in fetch preserves it. allowInsecureTls is not used by
28
+ // any WorkChat deployment (workbrain.cn is public TLS), so dropping the
29
+ // undici path here is safe and keeps multipart uploads working.
30
+ const fetcher = globalThis.fetch;
30
31
  const controller = new AbortController();
31
32
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
32
33
 
@@ -36,7 +37,6 @@ async function fetchJson<T>(params: {
36
37
  response = await fetcher(url, {
37
38
  ...init,
38
39
  signal: controller.signal,
39
- ...(dispatcher ? { dispatcher } : {}),
40
40
  } as any);
41
41
  text = await response.text().catch(() => "");
42
42
  } finally {
@@ -239,6 +239,73 @@ export async function sendWorkChatReplyMessage(params: {
239
239
  return { msgId: String(data.msgId) };
240
240
  }
241
241
 
242
+ // ---------------------------------------------------------------------------
243
+ // File upload (media send pipeline)
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /** Response of POST /open-apis/v1/files/upload (backend FileUploadResp). */
247
+ export interface WorkChatFileUploadResult {
248
+ url: string;
249
+ fileName?: string;
250
+ suffix?: string;
251
+ fileSize?: number;
252
+ }
253
+
254
+ /**
255
+ * POST {base}/open-apis/v1/files/upload — multipart single-file upload.
256
+ * Goes to the public bucket (SYSTEM_RESOURCE), so the returned URL is
257
+ * permanent and safe to embed in image/audio message content.
258
+ * Field name must be `file` (backend UploadFileReq).
259
+ */
260
+ export async function uploadWorkChatFile(params: {
261
+ opts: WorkChatApiOptions;
262
+ filePath: string;
263
+ /** WorkChat user id — required by the backend as the login user (form field + x-user-id header). */
264
+ userId?: string | number;
265
+ }): Promise<WorkChatFileUploadResult> {
266
+ const { opts, filePath, userId } = params;
267
+ const token = await getAccessToken(opts);
268
+ const { readFile } = await import("node:fs/promises");
269
+ const pathMod = await import("node:path");
270
+ const content = await readFile(filePath);
271
+ const fileName = pathMod.basename(filePath);
272
+ // The backend rejects non image/* or audio/* part content-types (4006), so
273
+ // the MIME must reflect the actual file — the send pipeline routes by
274
+ // extension already (audio: wav/m4a/mp3, else image).
275
+ const ext = pathMod.extname(fileName).replace(/^\./, "").toLowerCase();
276
+ const mime = ext === "mp3" || ext === "m4a" ? "audio/mpeg" : ext === "wav" ? "audio/wav" : `image/${ext === "jpg" ? "jpeg" : ext || "png"}`;
277
+ const form = new FormData();
278
+ form.set("file", new Blob([new Uint8Array(content)], { type: mime }), fileName);
279
+ // Backend UploadFileReq.userId: the file owner. The gateway normally injects
280
+ // x-user-id for user tokens; the plugin's app token does not carry it, so
281
+ // both the form field and the header are sent explicitly.
282
+ const ownerUserId = userId !== undefined && String(userId).trim() !== "" ? String(userId) : "0";
283
+ form.set("userId", ownerUserId);
284
+
285
+ const resp = await fetchJson<{ code?: number; message?: string; data?: { url?: string; fileName?: string; suffix?: string; fileSize?: number } }>({
286
+ url: `${normalizeBaseUrl(opts.baseUrl)}/open-apis/v1/files/upload`,
287
+ timeoutMs: opts.timeoutMs ?? RequestDefaults.TimeoutMs,
288
+ allowInsecureTls: opts.allowInsecureTls,
289
+ init: {
290
+ method: "POST",
291
+ headers: {
292
+ Authorization: `Bearer ${token}`,
293
+ "x-user-id": ownerUserId,
294
+ },
295
+ body: form,
296
+ },
297
+ });
298
+ const data = unwrapEnvelope(resp);
299
+ const url = typeof data?.url === "string" ? data.url.trim() : "";
300
+ if (!url) throw new Error("workchat: url missing in file upload response");
301
+ return {
302
+ url,
303
+ fileName: typeof data?.fileName === "string" ? data.fileName : undefined,
304
+ suffix: typeof data?.suffix === "string" ? data.suffix : undefined,
305
+ fileSize: typeof data?.fileSize === "number" ? data.fileSize : undefined,
306
+ };
307
+ }
308
+
242
309
  // ---------------------------------------------------------------------------
243
310
  // Agent lifecycle
244
311
  // ---------------------------------------------------------------------------
@@ -0,0 +1,84 @@
1
+ import { logger } from "../util/logger.js";
2
+
3
+ const SESSION_PAUSE_DURATION_MS = 60 * 60 * 1000;
4
+
5
+ /**
6
+ * Backend error strings that indicate a permanent binding failure (agent
7
+ * deleted / not owned by this appKey). Retrying within the session cannot
8
+ * fix these — only a re-scan (new binding QR) can.
9
+ */
10
+ const PERMANENT_ERROR_PATTERNS = [
11
+ "智能体不能为空",
12
+ "智能体不是接收人的智能体",
13
+ "设备链接异常",
14
+ "申请绑定的AppKey不存在",
15
+ ];
16
+
17
+ const pauseUntilMap = new Map<string, number>();
18
+
19
+ /** Pause all send attempts for `accountId` for one hour (permanent backend error). */
20
+ export function pauseSession(accountId: string): void {
21
+ const until = Date.now() + SESSION_PAUSE_DURATION_MS;
22
+ pauseUntilMap.set(accountId, until);
23
+ logger.info(
24
+ `session-guard: paused accountId=${accountId} until=${new Date(until).toISOString()} (${SESSION_PAUSE_DURATION_MS / 1000}s)`,
25
+ );
26
+ }
27
+
28
+ /** Returns `true` when the account is still within its one-hour cooldown window. */
29
+ export function isSessionPaused(accountId: string): boolean {
30
+ const until = pauseUntilMap.get(accountId);
31
+ if (until === undefined) return false;
32
+ if (Date.now() >= until) {
33
+ pauseUntilMap.delete(accountId);
34
+ return false;
35
+ }
36
+ return true;
37
+ }
38
+
39
+ /** Milliseconds remaining until the pause expires (0 when not paused). */
40
+ export function getRemainingPauseMs(accountId: string): number {
41
+ const until = pauseUntilMap.get(accountId);
42
+ if (until === undefined) return 0;
43
+ const remaining = until - Date.now();
44
+ if (remaining <= 0) {
45
+ pauseUntilMap.delete(accountId);
46
+ return 0;
47
+ }
48
+ return remaining;
49
+ }
50
+
51
+ /** Throw if the session is currently paused. Call before any send request. */
52
+ export function assertSessionActive(accountId: string): void {
53
+ if (isSessionPaused(accountId)) {
54
+ const remainingMin = Math.ceil(getRemainingPauseMs(accountId) / 60_000);
55
+ throw new Error(
56
+ `session paused for accountId=${accountId}, ${remainingMin} min remaining — the WorkChat binding is invalid, re-scan the QR code to fix`,
57
+ );
58
+ }
59
+ }
60
+
61
+ /** True when a backend error message indicates a permanent binding failure. */
62
+ export function isPermanentBindingError(errMsg: string): boolean {
63
+ return PERMANENT_ERROR_PATTERNS.some((p) => errMsg.includes(p));
64
+ }
65
+
66
+ /**
67
+ * Auto-pause the account when the error looks like a permanent binding
68
+ * failure. Returns true when the pause was armed.
69
+ */
70
+ export function pauseOnPermanentError(accountId: string, errMsg: string): boolean {
71
+ if (!isPermanentBindingError(errMsg)) return false;
72
+ if (!isSessionPaused(accountId)) {
73
+ pauseSession(accountId);
74
+ logger.warn(
75
+ `session-guard: permanent binding error, pausing accountId=${accountId} err="${errMsg.slice(0, 120)}" — re-scan required`,
76
+ );
77
+ }
78
+ return true;
79
+ }
80
+
81
+ /** Reset internal state — only for tests. @internal */
82
+ export function _resetSessionGuardForTest(): void {
83
+ pauseUntilMap.clear();
84
+ }
package/src/api/types.ts CHANGED
@@ -13,10 +13,19 @@ export const DEFAULT_BASE_URL = "https://workbrain.cn/backend-api";
13
13
  /** WebSocket endpoint used when the open-connection API returns none. */
14
14
  export const DEFAULT_WS_URL = "wss://workbrain.cn/backend-api/open-apis/connect";
15
15
 
16
- /** msgType codes accepted by /open-apis/im/v1/messages. */
16
+ /**
17
+ * msgType values accepted by /open-apis/im/v1/messages — the backend ChatType
18
+ * enum deserializes by NUMERIC CODE (verified live: name strings like "text"
19
+ * fail with 2001 请求体格式错误, while codes "1"/1/2/"2" pass through to the
20
+ * business layer). Codes from the backend ChatType enum: TEXT=1, IMAGE=2,
21
+ * Voice=110. Audio rides Voice(110) — the integration guide's "audio" name
22
+ * does not match the live deserializer.
23
+ * Media content is a JSON string: image {"url", width?, height?}, audio {"url", duration?}.
24
+ */
17
25
  export const WorkChatMsgType = {
18
26
  Text: "1",
19
- Rich: "26",
27
+ Image: "2",
28
+ Audio: "110",
20
29
  } as const;
21
30
 
22
31
  export const RequestDefaults = {
@@ -39,7 +48,13 @@ export interface AgentMessagePayload {
39
48
  agentId?: string | number;
40
49
  conversationId?: string;
41
50
  msgId?: string | number;
42
- message?: string | { content?: string };
51
+ message?: string | { content?: string; type?: string };
52
+ /** Backend ChatAgentMessageDTO: chatType mirrors message.type for media. */
53
+ chatType?: string | number;
54
+ /** Single file id (backend DTO fileId). */
55
+ fileId?: string | number;
56
+ /** File url list (backend DTO fileUrl) — populated for file/image/audio messages. */
57
+ fileUrl?: string[];
43
58
  }
44
59
 
45
60
  /** EVENT/AGENT_CREATED|INIT_AGENT payload. */
package/src/channel.ts CHANGED
@@ -12,6 +12,12 @@ import {
12
12
  } from "./auth/login-qr.js";
13
13
  import type { WorkChatQrStartResult, WorkChatQrWaitResult } from "./auth/login-qr.js";
14
14
  import { pushWorkChatMessage, replyWorkChatMessage } from "./messaging/send.js";
15
+ import {
16
+ downloadRemoteMediaToTemp,
17
+ isLocalMediaSource,
18
+ resolveLocalMediaPath,
19
+ sendWorkChatMediaFile,
20
+ } from "./messaging/send-media.js";
15
21
  import { logger } from "./util/logger.js";
16
22
 
17
23
  /**
@@ -71,7 +77,8 @@ export const workchatPlugin: ChannelPlugin<ResolvedWorkChatAccount> = {
71
77
  messageToolHints: () => [
72
78
  "To send a message to the current WorkChat user, use the message tool with action='send'. You do not need to specify 'to' — the current conversation recipient is used automatically.",
73
79
  "IMPORTANT: When creating a cron job (scheduled task) for the current WorkChat user, you MUST set delivery.to to the user's WorkChat ID (the numeric ID from the current conversation) AND set delivery.accountId to the current AccountId. Without an explicit 'to', the cron delivery will fail with 'requires target'. Without an explicit 'accountId', the message may be sent from the wrong bot account. Example: delivery: { mode: 'announce', channel: 'openclaw-workchat', to: '<current_user_id>', accountId: '<current_AccountId>' }.",
74
- "IMPORTANT: When sending media, set 'media' to a URL the WorkChat platform can fetch (an HTTPS URL). Local file paths are not supported by the WorkChat send API yet — include the link in the message text instead.",
80
+ "To send an image or audio file to the current user, use the message tool with action='send' and set 'media' to a local file path or a remote HTTPS URL. Supported audio formats: wav, m4a, mp3; supported image formats: bmp, gif, jpg, jpeg, png, webp. Remote URLs are downloaded and re-uploaded to WorkChat storage automatically — the client renders image/audio cards, never bare links.",
81
+ "IMPORTANT: When generating or saving a file to send, always use an absolute path (e.g. /tmp/photo.png or C:\\tmp\\photo.png on Windows), never a relative path like ./photo.png. Relative paths cannot be resolved and the file will not be delivered.",
75
82
  ],
76
83
  },
77
84
  reload: { configPrefixes: ["channels.openclaw-workchat"] },
@@ -107,16 +114,73 @@ export const workchatPlugin: ChannelPlugin<ResolvedWorkChatAccount> = {
107
114
  return { channel: "openclaw-workchat", messageId: result.messageId };
108
115
  },
109
116
  sendMedia: async (ctx) => {
110
- // Media sends degrade to a text message carrying the media URL until the
111
- // platform media-upload API is confirmed.
112
117
  const account = resolveOutboundAccount(ctx.cfg, ctx.accountId);
113
- const text = ctx.text?.trim() ? ctx.text : ctx.mediaUrl ?? "";
114
- const result = await pushWorkChatMessage({
115
- account,
118
+ const aLog = logger.withAccount(account.accountId);
119
+ const mediaUrl = ctx.mediaUrl?.trim();
120
+ let text = ctx.text ?? "";
121
+
122
+ // message_sending hook (mirrors the deliver path; weixin parity): lets
123
+ // hosts observe/cancel outbound media too.
124
+ const { applyWorkChatMessageSendingHook, emitWorkChatMessageSent } =
125
+ await import("./messaging/outbound-hooks.js");
126
+ const sendingResult = await applyWorkChatMessageSendingHook({
116
127
  to: ctx.to,
117
128
  text,
129
+ accountId: account.accountId,
130
+ mediaUrl,
131
+ }).catch((err: unknown) => {
132
+ aLog.warn(`message_sending hook error, proceeding: ${String(err)}`);
133
+ return { cancelled: false, text };
118
134
  });
119
- return { channel: "openclaw-workchat", messageId: result.messageId };
135
+ if (sendingResult.cancelled) {
136
+ aLog.info(`sendMedia: cancelled by message_sending hook to=${ctx.to}`);
137
+ return { channel: "openclaw-workchat", messageId: "" };
138
+ }
139
+ text = sendingResult.text;
140
+
141
+ if (!mediaUrl) {
142
+ // No media: degrade to a plain text send.
143
+ try {
144
+ const result = await pushWorkChatMessage({ account, to: ctx.to, text });
145
+ emitWorkChatMessageSent({ to: ctx.to, content: text, success: true, accountId: account.accountId });
146
+ return { channel: "openclaw-workchat", messageId: result.messageId };
147
+ } catch (err) {
148
+ emitWorkChatMessageSent({ to: ctx.to, content: text, success: false, error: String(err), accountId: account.accountId });
149
+ throw err;
150
+ }
151
+ }
152
+
153
+ // Local file → upload directly; remote URL → relay through local temp
154
+ // (the WorkChat client renders only WorkChat-hosted image/audio cards).
155
+ let filePath: string;
156
+ try {
157
+ if (isLocalMediaSource(mediaUrl)) {
158
+ filePath = resolveLocalMediaPath(mediaUrl);
159
+ aLog.debug(`sendMedia: uploading local file ${filePath}`);
160
+ } else {
161
+ aLog.debug(`sendMedia: downloading remote mediaUrl=${mediaUrl.slice(0, 80)}...`);
162
+ filePath = await downloadRemoteMediaToTemp(mediaUrl);
163
+ aLog.debug(`sendMedia: remote media downloaded to ${filePath}`);
164
+ }
165
+ } catch (err) {
166
+ emitWorkChatMessageSent({ to: ctx.to, content: text, success: false, error: String(err), accountId: account.accountId });
167
+ throw err;
168
+ }
169
+
170
+ try {
171
+ const result = await sendWorkChatMediaFile({
172
+ account,
173
+ to: ctx.to,
174
+ filePath,
175
+ text,
176
+ replyToMessageId: ctx.replyToId?.trim() || undefined,
177
+ });
178
+ emitWorkChatMessageSent({ to: ctx.to, content: text, success: true, accountId: account.accountId });
179
+ return { channel: "openclaw-workchat", messageId: result.messageId };
180
+ } catch (err) {
181
+ emitWorkChatMessageSent({ to: ctx.to, content: text, success: false, error: String(err), accountId: account.accountId });
182
+ throw err;
183
+ }
120
184
  },
121
185
  },
122
186
  status: {
@@ -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
  }
@@ -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();
@@ -250,6 +284,17 @@ export async function processOneFrame(
250
284
  return;
251
285
  }
252
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
+
253
298
  const receivedAt = Date.now();
254
299
  const debug = isDebugMode(deps.accountId);
255
300
  const debugTrace: string[] = [];
@@ -257,7 +302,7 @@ export async function processOneFrame(
257
302
 
258
303
  const ctx = workChatMessageToMsgContext(message, deps.accountId);
259
304
  logger.info(
260
- `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)}`,
261
306
  );
262
307
  logger.debug(`inbound raw frame: ${rawFrame.slice(0, 200)}`);
263
308
 
@@ -275,6 +320,30 @@ export async function processOneFrame(
275
320
  }
276
321
  }
277
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
+
278
347
  // --- Framework command authorization ---
279
348
  const rawBody = ctx.Body?.trim() ?? "";
280
349
  ctx.CommandBody = rawBody;
@@ -395,8 +464,11 @@ export async function processOneFrame(
395
464
  const rawText = String(payload.text ?? "");
396
465
  const f = new StreamingMarkdownFilter();
397
466
  let text = f.feed(rawText) + f.flush();
467
+ const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
398
468
 
399
- 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
+ );
400
472
 
401
473
  if (debug) {
402
474
  debugDeliveries.push({
@@ -410,6 +482,7 @@ export async function processOneFrame(
410
482
  to: ctx.To,
411
483
  text,
412
484
  accountId: deps.accountId,
485
+ mediaUrl,
413
486
  });
414
487
  if (sendingResult.cancelled) {
415
488
  logger.info(`outbound: cancelled by message_sending hook to=${ctx.To}`);
@@ -418,6 +491,43 @@ export async function processOneFrame(
418
491
  text = sendingResult.text;
419
492
 
420
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
+ }
421
531
  const result = await replyWorkChatMessage({
422
532
  account: {
423
533
  ...deps.account,
@@ -442,7 +552,14 @@ export async function processOneFrame(
442
552
  onError: (err, info) => {
443
553
  deps.errLog(`workchat reply ${info.kind}: ${String(err)}`);
444
554
  const errMsg = err instanceof Error ? err.message : String(err);
445
- 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
+ }
446
563
  void sendWorkChatErrorNotice({
447
564
  account: deps.account,
448
565
  to: ctx.To,