@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.
- package/index.js +819 -282
- package/index.ts +7 -0
- 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/auth/accounts.ts +108 -28
- package/src/auth/login-qr.ts +15 -22
- package/src/channel.ts +87 -11
- package/src/messaging/inbound.ts +56 -2
- package/src/messaging/process-message.ts +135 -29
- package/src/messaging/send-media.ts +201 -0
- package/src/messaging/send.ts +5 -0
- package/src/storage/seen-msgids.ts +79 -0
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
|
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
registerWorkChatAccountId,
|
|
13
13
|
saveWorkChatAccount,
|
|
14
14
|
triggerWorkChatChannelReload,
|
|
15
|
-
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
const
|
|
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
|
|
137
|
+
const removed = await pruneOtherWorkChatAccounts(accountId);
|
|
104
138
|
if (removed.length > 0) {
|
|
105
|
-
deps.log(`rebind: removed
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/messaging/send.ts
CHANGED
|
@@ -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
|
+
}
|