@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/index.js +720 -233
- package/package.json +1 -1
- package/src/api/api.ts +74 -7
- package/src/api/session-guard.ts +84 -0
- package/src/api/types.ts +18 -3
- package/src/channel.ts +71 -7
- package/src/messaging/inbound.ts +56 -2
- package/src/messaging/process-message.ts +120 -3
- package/src/messaging/send-media.ts +201 -0
- package/src/messaging/send.ts +5 -0
- package/src/storage/seen-msgids.ts +79 -0
package/package.json
CHANGED
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
114
|
-
const
|
|
115
|
-
|
|
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
|
-
|
|
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: {
|
package/src/messaging/inbound.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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,
|