@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.
@@ -0,0 +1,201 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import type { WorkChatApiOptions } from "../api/api.js";
6
+ import { sendWorkChatMessage, sendWorkChatReplyMessage, uploadWorkChatFile } from "../api/api.js";
7
+ import { assertSessionActive, pauseOnPermanentError } from "../api/session-guard.js";
8
+ import { WorkChatMsgType } from "../api/types.js";
9
+ import type { ResolvedWorkChatAccount } from "../auth/accounts.js";
10
+ import { logger } from "../util/logger.js";
11
+ import { generateId } from "../util/random.js";
12
+
13
+ import { buildApiOptions, pushWorkChatMessage } from "./send.js";
14
+
15
+ /** Backend FileType whitelist (tuzai FileServiceImpl rejects unknown suffixes). */
16
+ const AUDIO_EXTENSIONS = new Set(["wav", "m4a", "mp3"]);
17
+
18
+ /** True when mediaUrl refers to a local filesystem path (not a remote URL). */
19
+ export function isLocalMediaSource(mediaUrl: string): boolean {
20
+ const trimmed = mediaUrl.trim();
21
+ if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return false;
22
+ return trimmed.startsWith("/")
23
+ || trimmed.startsWith("./")
24
+ || trimmed.startsWith("../")
25
+ || trimmed.startsWith("~")
26
+ || trimmed.startsWith("file://")
27
+ || path.isAbsolute(trimmed);
28
+ }
29
+
30
+ export function resolveLocalMediaPath(mediaUrl: string): string {
31
+ if (mediaUrl.startsWith("file://")) {
32
+ const parsed = new URL(mediaUrl);
33
+ return process.platform === "win32" ? parsed.pathname.replace(/^\/([A-Za-z]:)/, "$1") : parsed.pathname;
34
+ }
35
+ if (mediaUrl.startsWith("~")) {
36
+ return path.join(os.homedir(), mediaUrl.slice(1));
37
+ }
38
+ return path.resolve(mediaUrl);
39
+ }
40
+
41
+ /**
42
+ * Map a file path to the message type the WorkChat client renders as a card.
43
+ * The backend ChatType enum has Video/Voice/Link codes, but the open send API
44
+ * documents only text/image/audio — audio extensions ride "audio", everything
45
+ * else (images, and files outside the documented set) rides "image".
46
+ */
47
+ function resolveMediaMsgType(filePath: string): "image" | "audio" {
48
+ const ext = path.extname(filePath).replace(/^\./, "").toLowerCase();
49
+ return AUDIO_EXTENSIONS.has(ext) ? "audio" : "image";
50
+ }
51
+
52
+ interface MediaSendParams {
53
+ account: ResolvedWorkChatAccount;
54
+ to: string;
55
+ text?: string;
56
+ replyToMessageId?: string;
57
+ conversationId?: string;
58
+ agentId?: string;
59
+ }
60
+
61
+ function requireAgentId(account: ResolvedWorkChatAccount, agentId?: string): string {
62
+ const resolved = agentId ?? account.agentId;
63
+ if (!resolved) {
64
+ throw new Error("workchat: agentId is required — complete the binding QR flow first");
65
+ }
66
+ return resolved;
67
+ }
68
+
69
+ async function sendMediaRequest(params: {
70
+ account: ResolvedWorkChatAccount;
71
+ to: string;
72
+ msgType: string;
73
+ content: string;
74
+ replyToMessageId?: string;
75
+ conversationId?: string;
76
+ agentId?: string;
77
+ }): Promise<{ messageId: string }> {
78
+ const agentId = requireAgentId(params.account, params.agentId);
79
+ assertSessionActive(params.account.accountId);
80
+ const opts = buildApiOptions(params.account);
81
+ const body: {
82
+ agentId: string;
83
+ receiveId: string;
84
+ msgType: string;
85
+ content: string;
86
+ conversationId?: string;
87
+ is_last?: boolean;
88
+ } = {
89
+ agentId,
90
+ receiveId: params.account.userId ?? params.to,
91
+ msgType: params.msgType,
92
+ content: params.content,
93
+ ...(params.conversationId ? { conversationId: params.conversationId } : {}),
94
+ };
95
+ // is_last closes the streamed reply placeholder; media replies are single
96
+ // messages, so the media card itself is always the final frame.
97
+ if (params.replyToMessageId) body.is_last = true;
98
+ try {
99
+ if (params.replyToMessageId) {
100
+ const result = await sendWorkChatReplyMessage({ opts, messageId: params.replyToMessageId, body });
101
+ logger.info(`reply media sent to=${params.to} messageId=${result.msgId}`);
102
+ return { messageId: result.msgId };
103
+ }
104
+ const result = await sendWorkChatMessage({ opts, body });
105
+ logger.info(`push media sent to=${params.to} messageId=${result.msgId}`);
106
+ return { messageId: result.msgId };
107
+ } catch (err) {
108
+ pauseOnPermanentError(params.account.accountId, err instanceof Error ? err.message : String(err));
109
+ throw err;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Upload a local file and send it as a WorkChat image/audio message:
115
+ * audio (wav/m4a/mp3) → msgType "audio", content {"url"}
116
+ * everything else → msgType "image", content {"url"}
117
+ * The media content JSON cannot carry a caption, so a text caption (when
118
+ * present) is sent as its own message right before the media card.
119
+ */
120
+ export async function sendWorkChatMediaFile(params: MediaSendParams & {
121
+ filePath: string;
122
+ }): Promise<{ messageId: string }> {
123
+ const { account, to, filePath } = params;
124
+ const opts: WorkChatApiOptions = buildApiOptions(account);
125
+
126
+ logger.info(`sendMedia: uploading filePath=${filePath} to=${to}`);
127
+ const uploaded = await uploadWorkChatFile({ opts, filePath, userId: account.userId ?? to });
128
+ logger.info(`sendMedia: upload done urlLen=${uploaded.url.length} suffix=${uploaded.suffix ?? "(none)"}`);
129
+
130
+ const caption = params.text?.trim() ?? "";
131
+ if (caption) {
132
+ await pushWorkChatMessage({
133
+ account,
134
+ to,
135
+ text: caption,
136
+ conversationId: params.conversationId,
137
+ agentId: params.agentId,
138
+ });
139
+ }
140
+
141
+ const msgType = resolveMediaMsgType(filePath) === "audio" ? WorkChatMsgType.Audio : WorkChatMsgType.Image;
142
+ return sendMediaRequest({
143
+ account,
144
+ to,
145
+ msgType,
146
+ content: JSON.stringify({ url: uploaded.url }),
147
+ replyToMessageId: params.replyToMessageId,
148
+ conversationId: params.conversationId,
149
+ agentId: params.agentId,
150
+ });
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Remote URL relay
155
+ // ---------------------------------------------------------------------------
156
+
157
+ const REMOTE_MEDIA_DIR = path.join(os.tmpdir(), "openclaw", "workchat", "media");
158
+ const REMOTE_MEDIA_DOWNLOAD_TIMEOUT_MS = 60_000;
159
+
160
+ function tempMediaPath(ext: string): string {
161
+ return path.join(REMOTE_MEDIA_DIR, `${generateId("remote").replace(/:/g, "-")}${ext}`);
162
+ }
163
+
164
+ function extensionFromUrlOrContentType(contentType: string | null, url: string): string {
165
+ const fromUrl = path.extname(new URL(url).pathname).toLowerCase();
166
+ if (fromUrl) return fromUrl;
167
+ if (contentType?.startsWith("audio/mpeg")) return ".mp3";
168
+ if (contentType?.startsWith("audio/wav")) return ".wav";
169
+ if (contentType?.startsWith("audio/mp4")) return ".m4a";
170
+ if (contentType?.startsWith("image/")) return ".png";
171
+ return ".bin";
172
+ }
173
+
174
+ /**
175
+ * Download a remote media URL to a local temp file so the send pipeline can
176
+ * re-upload it to WorkChat file storage — clients render only WorkChat-hosted
177
+ * image/audio cards, never bare links. Bounded by a 60s abort so a hung
178
+ * remote never stalls the reply pipeline.
179
+ */
180
+ export async function downloadRemoteMediaToTemp(url: string): Promise<string> {
181
+ const controller = new AbortController();
182
+ const timeoutId = setTimeout(() => controller.abort(), REMOTE_MEDIA_DOWNLOAD_TIMEOUT_MS);
183
+ let response: Response;
184
+ try {
185
+ response = (await globalThis.fetch(url, { signal: controller.signal })) as Response;
186
+ } finally {
187
+ clearTimeout(timeoutId);
188
+ }
189
+ if (!response.ok) {
190
+ throw new Error(
191
+ `remote media download failed: ${response.status} ${response.statusText} url=${url.slice(0, 80)}`,
192
+ );
193
+ }
194
+ const buf = Buffer.from(await response.arrayBuffer());
195
+ const ext = extensionFromUrlOrContentType(response.headers.get("content-type"), url);
196
+ await mkdir(REMOTE_MEDIA_DIR, { recursive: true });
197
+ const filePath = tempMediaPath(ext);
198
+ await writeFile(filePath, buf);
199
+ logger.debug(`downloadRemoteMediaToTemp: saved ${buf.length} bytes to ${filePath}`);
200
+ return filePath;
201
+ }
@@ -6,6 +6,7 @@ import {
6
6
  sendWorkChatReplyMessage,
7
7
  } from "../api/api.js";
8
8
  import type { WorkChatApiOptions } from "../api/api.js";
9
+ import { pauseOnPermanentError } from "../api/session-guard.js";
9
10
  import type { ResolvedWorkChatAccount } from "../auth/accounts.js";
10
11
  import { logger } from "../util/logger.js";
11
12
 
@@ -38,6 +39,8 @@ function buildApiOptions(account: ResolvedWorkChatAccount): WorkChatApiOptions {
38
39
  };
39
40
  }
40
41
 
42
+ export { buildApiOptions };
43
+
41
44
  /**
42
45
  * Send a reply to a specific inbound message.
43
46
  * POST /open-apis/im/v1/messages/{messageId}/reply
@@ -74,6 +77,7 @@ export async function replyWorkChatMessage(params: {
74
77
  logger.info(`reply sent to=${to} messageId=${result.msgId}`);
75
78
  return { messageId: result.msgId };
76
79
  } catch (err) {
80
+ pauseOnPermanentError(account.accountId, err instanceof Error ? err.message : String(err));
77
81
  logger.error(`replyWorkChatMessage: failed to=${to} err=${String(err)}`);
78
82
  throw err;
79
83
  }
@@ -113,6 +117,7 @@ export async function pushWorkChatMessage(params: {
113
117
  logger.info(`push sent to=${to} messageId=${result.msgId}`);
114
118
  return { messageId: result.msgId };
115
119
  } catch (err) {
120
+ pauseOnPermanentError(account.accountId, err instanceof Error ? err.message : String(err));
116
121
  logger.error(`pushWorkChatMessage: failed to=${to} err=${String(err)}`);
117
122
  throw err;
118
123
  }
@@ -0,0 +1,79 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { resolveStateDir } from "./state-dir.js";
5
+ import { logger } from "../util/logger.js";
6
+
7
+ /**
8
+ * Persistent inbound message-id buffer (weixin sync-buf analogue): remembers
9
+ * the most recently processed AGENT_MESSAGE msgIds per account so a monitor
10
+ * restart / platform redelivery never double-dispatches a user message.
11
+ */
12
+
13
+ const SEEN_LIMIT = 500;
14
+
15
+ function resolveSeenFilePath(accountId: string): string {
16
+ const safe = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
17
+ return path.join(resolveStateDir(), "openclaw-workchat", "accounts", `${safe}.seen-msgids.json`);
18
+ }
19
+
20
+ type SeenCache = { ids: string[]; index: Set<string> };
21
+ const cache = new Map<string, SeenCache>();
22
+
23
+ function loadSeen(accountId: string): SeenCache {
24
+ const cached = cache.get(accountId);
25
+ if (cached) return cached;
26
+ let ids: string[] = [];
27
+ try {
28
+ const raw = fs.readFileSync(resolveSeenFilePath(accountId), "utf-8");
29
+ const parsed = JSON.parse(raw) as { msgIds?: unknown };
30
+ if (Array.isArray(parsed.msgIds)) {
31
+ ids = parsed.msgIds.filter((x): x is string => typeof x === "string" && x !== "");
32
+ }
33
+ } catch {
34
+ // missing or corrupt file — start empty
35
+ }
36
+ const entry: SeenCache = { ids, index: new Set(ids) };
37
+ cache.set(accountId, entry);
38
+ return entry;
39
+ }
40
+
41
+ function persistSeen(accountId: string, seen: SeenCache): void {
42
+ try {
43
+ const filePath = resolveSeenFilePath(accountId);
44
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
45
+ fs.writeFileSync(filePath, JSON.stringify({ msgIds: seen.ids }), "utf-8");
46
+ } catch (err) {
47
+ logger.warn(`seen-msgids persist failed accountId=${accountId}: ${String(err)}`);
48
+ }
49
+ }
50
+
51
+ /** Returns true when msgId was already recorded (duplicate delivery). */
52
+ export function isDuplicateMessage(accountId: string, msgId: string): boolean {
53
+ if (!msgId) return false;
54
+ const seen = loadSeen(accountId);
55
+ return seen.index.has(msgId);
56
+ }
57
+
58
+ /**
59
+ * Record msgId as processed. Returns true the first time (not a duplicate);
60
+ * false when it was already known.
61
+ */
62
+ export function recordMessageProcessed(accountId: string, msgId: string): boolean {
63
+ if (!msgId) return true;
64
+ const seen = loadSeen(accountId);
65
+ if (seen.index.has(msgId)) return false;
66
+ seen.index.add(msgId);
67
+ seen.ids.push(msgId);
68
+ if (seen.ids.length > SEEN_LIMIT) {
69
+ const drop = seen.ids.length - SEEN_LIMIT;
70
+ for (const old of seen.ids.splice(0, drop)) seen.index.delete(old);
71
+ }
72
+ persistSeen(accountId, seen);
73
+ return true;
74
+ }
75
+
76
+ /** Clear state — only for tests. @internal */
77
+ export function _resetSeenMsgIdsForTest(): void {
78
+ cache.clear();
79
+ }