@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.ts CHANGED
@@ -3,6 +3,7 @@ import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-sch
3
3
 
4
4
  import { workchatPlugin } from "./src/channel.js";
5
5
  import { assertHostCompatibility } from "./src/compat.js";
6
+ import { migrateLegacyDefaultWorkChatAccount } from "./src/auth/accounts.js";
6
7
  import { WorkChatConfigSchema } from "./src/config/config-schema.js";
7
8
 
8
9
  export default {
@@ -14,6 +15,12 @@ export default {
14
15
  // Fail-fast: reject incompatible host versions before any side-effects.
15
16
  assertHostCompatibility(api.runtime?.version);
16
17
 
18
+ // Rename legacy `default` accounts to the agent-scoped key before the
19
+ // channel starts enumerating accounts (one-way, idempotent).
20
+ void migrateLegacyDefaultWorkChatAccount().catch(err => {
21
+ console.error("[openclaw-workchat] legacy default account migration failed:", err);
22
+ });
23
+
17
24
  api.registerChannel({ plugin: workchatPlugin });
18
25
  },
19
26
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workclaw/openclaw-workchat",
3
- "version": "1.0.4",
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. */
@@ -21,6 +21,10 @@ function resolveAccountIndexPath(): string {
21
21
 
22
22
  /** Returns all accountIds registered via binding QR. */
23
23
  export function listIndexedWorkChatAccountIds(): string[] {
24
+ return readAccountIndex();
25
+ }
26
+
27
+ function readAccountIndex(): string[] {
24
28
  const filePath = resolveAccountIndexPath();
25
29
  try {
26
30
  if (!fs.existsSync(filePath)) return [];
@@ -32,22 +36,43 @@ export function listIndexedWorkChatAccountIds(): string[] {
32
36
  }
33
37
  }
34
38
 
39
+ /**
40
+ * Serialize index mutations inside the plugin process. All writers (binding
41
+ * registration, rebind pruning, legacy migration) run in this one process;
42
+ * the shared promise chain guarantees read-modify-write never interleaves.
43
+ */
44
+ let indexUpdateChain: Promise<void> = Promise.resolve();
45
+
46
+ /**
47
+ * Read-modify-write the account index. Mutations are serialized in-process
48
+ * (see indexUpdateChain); the framework file lock is not used because the
49
+ * index is only ever written by this plugin process, and the lock sidecar
50
+ * behaves inconsistently under test-runner module isolation.
51
+ */
52
+ async function updateAccountIndex(
53
+ mutator: (ids: string[]) => string[],
54
+ ): Promise<void> {
55
+ const run = indexUpdateChain.then(() => {
56
+ const filePath = resolveAccountIndexPath();
57
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
58
+ const updated = mutator(readAccountIndex());
59
+ fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), "utf-8");
60
+ });
61
+ // Keep the chain alive even when a mutation fails.
62
+ indexUpdateChain = run.then(() => undefined, () => undefined);
63
+ await run;
64
+ }
65
+
35
66
  /** Add accountId to the persistent index (no-op if already present). */
36
- export function registerWorkChatAccountId(accountId: string): void {
37
- const dir = resolveWorkChatStateDir();
38
- fs.mkdirSync(dir, { recursive: true });
39
- const existing = listIndexedWorkChatAccountIds();
40
- if (existing.includes(accountId)) return;
41
- fs.writeFileSync(resolveAccountIndexPath(), JSON.stringify([...existing, accountId], null, 2), "utf-8");
67
+ export async function registerWorkChatAccountId(accountId: string): Promise<void> {
68
+ await updateAccountIndex(ids =>
69
+ ids.includes(accountId) ? ids : [...ids, accountId],
70
+ );
42
71
  }
43
72
 
44
73
  /** Remove accountId from the persistent index. */
45
- export function unregisterWorkChatAccountId(accountId: string): void {
46
- const existing = listIndexedWorkChatAccountIds();
47
- const updated = existing.filter(id => id !== accountId);
48
- if (updated.length !== existing.length) {
49
- fs.writeFileSync(resolveAccountIndexPath(), JSON.stringify(updated, null, 2), "utf-8");
50
- }
74
+ export async function unregisterWorkChatAccountId(accountId: string): Promise<void> {
75
+ await updateAccountIndex(ids => ids.filter(id => id !== accountId));
51
76
  }
52
77
 
53
78
  // ---------------------------------------------------------------------------
@@ -115,14 +140,14 @@ export function saveWorkChatAccount(
115
140
  }
116
141
 
117
142
  /** Remove all files associated with an account. */
118
- export function clearWorkChatAccount(accountId: string): void {
143
+ export async function clearWorkChatAccount(accountId: string): Promise<void> {
119
144
  const filePath = resolveAccountPath(accountId);
120
145
  try {
121
146
  if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
122
147
  } catch (err) {
123
148
  logger.warn(`clearWorkChatAccount: failed to remove ${filePath}: ${String(err)}`);
124
149
  }
125
- unregisterWorkChatAccountId(accountId);
150
+ await unregisterWorkChatAccountId(accountId);
126
151
  }
127
152
 
128
153
  /**
@@ -152,23 +177,22 @@ export async function removeWorkChatAccountBinding(accountId: string): Promise<v
152
177
  }
153
178
 
154
179
  /**
155
- * Remove every registered account whose stored agentId differs from
156
- * `keepAgentId` (rebind cleanup). Returns the removed accountIds.
157
- * `default` is normalized: if it holds a foreign agentId it is removed too;
158
- * the surviving binding keeps whatever account key it was registered under.
180
+ * Remove every registered account other than `keepAccountId` (rebind cleanup).
181
+ * One active agent per host: a completed binding owns the agent-scoped key
182
+ * `agent-${agentId}`, and every other account — a previous agent, or a legacy
183
+ * `default` registered by older plugin versions even for the SAME agent — is
184
+ * removed so a re-scan can never leave duplicate accounts behind.
159
185
  */
160
- export async function pruneStaleWorkChatAccounts(keepAgentId: string): Promise<string[]> {
186
+ export async function pruneOtherWorkChatAccounts(keepAccountId: string): Promise<string[]> {
187
+ const keep = keepAccountId.trim();
161
188
  const removed: string[] = [];
162
189
  for (const accountId of listIndexedWorkChatAccountIds()) {
163
- const data = loadWorkChatAccount(accountId);
164
- const storedAgentId = data?.agentId?.trim();
165
- if (storedAgentId && storedAgentId !== keepAgentId) {
166
- await removeWorkChatAccountBinding(accountId);
167
- removed.push(accountId);
168
- logger.info(
169
- `pruneStaleWorkChatAccounts: removed account=${accountId} agent=${storedAgentId} (superseded by agent=${keepAgentId})`,
170
- );
171
- }
190
+ if (accountId === keep) continue;
191
+ await removeWorkChatAccountBinding(accountId);
192
+ removed.push(accountId);
193
+ logger.info(
194
+ `pruneOtherWorkChatAccounts: removed account=${accountId} (superseded by ${keep})`,
195
+ );
172
196
  }
173
197
  return removed;
174
198
  }
@@ -211,6 +235,62 @@ type WorkChatSectionConfig = WorkChatAccountConfig & {
211
235
  channelConfigUpdatedAt?: string;
212
236
  };
213
237
 
238
+ /**
239
+ * One-time migration for accounts registered by older plugin versions under
240
+ * the `default` key: rename them to the agent-scoped key `agent-${agentId}`.
241
+ * Moves the account data file and the framework allowFrom credentials so no
242
+ * orphaned files remain (the gateway startup migration treats orphaned
243
+ * allowFrom files as a fatal unresolved state). Idempotent — a no-op once no
244
+ * `default` account exists. Runs once at plugin registration (see index.ts),
245
+ * before any binding listener can start, so it cannot interleave with
246
+ * concurrent binding writes (register/unregister hold the index file lock).
247
+ */
248
+ export async function migrateLegacyDefaultWorkChatAccount(): Promise<boolean> {
249
+ const ids = listIndexedWorkChatAccountIds();
250
+ if (!ids.includes("default")) return false;
251
+ const data = loadWorkChatAccount("default");
252
+ const agentId = data?.agentId?.trim();
253
+ if (!agentId) return false;
254
+ const targetId = `agent-${agentId}`;
255
+
256
+ // 1. Persist the agent-scoped copy (unless it already exists — the
257
+ // agent-scoped file is the newer binding when both are present).
258
+ if (!ids.includes(targetId)) {
259
+ saveWorkChatAccount(targetId, { ...data });
260
+ }
261
+ // 2. Move the framework allowFrom credentials to the new key.
262
+ try {
263
+ const { readFrameworkAllowFromList, registerUserInFrameworkStore } = await import("./pairing.js");
264
+ for (const userId of readFrameworkAllowFromList("default")) {
265
+ await registerUserInFrameworkStore({ accountId: targetId, userId });
266
+ }
267
+ } catch (err) {
268
+ logger.warn(`migrateLegacyDefaultWorkChatAccount: allowFrom move failed: ${String(err)}`);
269
+ }
270
+ // 3. Register the new key and drop the legacy one, both under the index
271
+ // file lock so a concurrent binding cannot be lost.
272
+ await updateAccountIndex(existing => {
273
+ const next = existing.filter(id => id !== "default" && id !== targetId);
274
+ next.push(targetId);
275
+ return next;
276
+ });
277
+ try {
278
+ fs.rmSync(path.join(resolveWorkChatStateDir(), "accounts", "default.json"), { force: true });
279
+ } catch {
280
+ // Best-effort data file cleanup; the index above is authoritative.
281
+ }
282
+ try {
283
+ const { clearFrameworkAllowFromList } = await import("./pairing.js");
284
+ clearFrameworkAllowFromList("default");
285
+ } catch (err) {
286
+ logger.warn(`migrateLegacyDefaultWorkChatAccount: allowFrom cleanup failed: ${String(err)}`);
287
+ }
288
+ logger.info(
289
+ `migrateLegacyDefaultWorkChatAccount: renamed default -> ${targetId} (agent=${agentId})`,
290
+ );
291
+ return true;
292
+ }
293
+
214
294
  /** List accountIds from the index file (written at binding). */
215
295
  export function listWorkChatAccountIds(_cfg: OpenClawConfig): string[] {
216
296
  return listIndexedWorkChatAccountIds();
@@ -293,7 +293,6 @@ import { buildPongFrame } from "../messaging/inbound.js";
293
293
  import {
294
294
  saveWorkChatAccount,
295
295
  registerWorkChatAccountId,
296
- listIndexedWorkChatAccountIds,
297
296
  } from "./accounts.js";
298
297
 
299
298
  const bindingListeners = new Map<string, { ws: WebSocket; done: boolean }>();
@@ -388,45 +387,39 @@ async function handleListenerFrame(sessionKey: string, data: string, ws: WebSock
388
387
 
389
388
  const eventData = result.eventData;
390
389
  if (!eventData) return;
391
- const agentId = String(eventData.id ?? eventData.agentId ?? "");
390
+ const agentId = String(eventData.id ?? eventData.agentId ?? "").trim();
392
391
  if (!agentId) return;
393
- const userId = String(eventData.futureId ?? eventData.userId ?? "");
392
+ const userId = String(eventData.futureId ?? eventData.userId ?? "").trim();
394
393
 
395
394
  const entry = bindingListeners.get(sessionKey);
396
395
  if (entry) entry.done = true;
397
396
 
398
- // Rebind detection must happen BEFORE pruning empties the index: if any
399
- // account existed, this scan replaces it. The QR login flow force-stops the
400
- // channel (marked manually-stopped in the gateway), and a reused account id
401
- // would stay stopped after reload — so a rebind always registers under a
402
- // FRESH agent-scoped key instead of reusing `default`.
403
- const isRebind = listIndexedWorkChatAccountIds().length > 0;
397
+ // One active agent per host (mirrors handleAgentLifecycleEvent): every
398
+ // binding registers under the agent-scoped key `agent-${agentId}`. The QR
399
+ // login flow force-stops the channel (marked manually-stopped in the
400
+ // gateway), and a reused account id would stay stopped after reload — the
401
+ // fresh agent-scoped key plus the channel reload restarts it.
402
+ const accountId = `agent-${agentId}`;
404
403
 
405
- // Rebind semantics (mirrors handleAgentLifecycleEvent): a re-scan replaces
406
- // the previously bound agent, so drop accounts bound to other agentIds
407
- // before registering this one.
404
+ // A re-scan replaces the previously bound agent: drop every OTHER
405
+ // registered account (including a same-agent legacy `default`) before
406
+ // registering this one.
408
407
  try {
409
- const { pruneStaleWorkChatAccounts } = await import("./accounts.js");
410
- const removed = await pruneStaleWorkChatAccounts(agentId);
408
+ const { pruneOtherWorkChatAccounts } = await import("./accounts.js");
409
+ const removed = await pruneOtherWorkChatAccounts(accountId);
411
410
  if (removed.length > 0) {
412
- logger.info(`binding listener: pruned stale account(s): ${removed.join(", ")}`);
411
+ logger.info(`binding listener: pruned previous account(s): ${removed.join(", ")}`);
413
412
  }
414
413
  } catch (err) {
415
414
  logger.error(`binding listener: rebind cleanup failed: ${String(err)}`);
416
415
  }
417
416
 
418
- // First bound agent becomes the `default` account; a rebind gets a fresh
419
- // agent-scoped key (see isRebind note above).
420
- const accountId = isRebind
421
- ? `agent-${agentId}`
422
- : "default";
423
-
424
417
  try {
425
418
  saveWorkChatAccount(accountId, {
426
419
  agentId,
427
420
  ...(userId ? { userId } : {}),
428
421
  });
429
- registerWorkChatAccountId(accountId);
422
+ await registerWorkChatAccountId(accountId);
430
423
  // Complete the binding: account data is already in the private state
431
424
  // dir; this call registers the allowFrom user and triggers the channel
432
425
  // hot reload.
package/src/channel.ts CHANGED
@@ -5,12 +5,19 @@ import {
5
5
  listWorkChatAccountIds,
6
6
  resolveWorkChatAccount,
7
7
  } from "./auth/accounts.js";
8
+ import { DEFAULT_BASE_URL } from "./api/types.js";
8
9
  import {
9
10
  startWorkChatLoginWithQr,
10
11
  waitForWorkChatLogin,
11
12
  } from "./auth/login-qr.js";
12
13
  import type { WorkChatQrStartResult, WorkChatQrWaitResult } from "./auth/login-qr.js";
13
14
  import { pushWorkChatMessage, replyWorkChatMessage } from "./messaging/send.js";
15
+ import {
16
+ downloadRemoteMediaToTemp,
17
+ isLocalMediaSource,
18
+ resolveLocalMediaPath,
19
+ sendWorkChatMediaFile,
20
+ } from "./messaging/send-media.js";
14
21
  import { logger } from "./util/logger.js";
15
22
 
16
23
  /**
@@ -70,7 +77,8 @@ export const workchatPlugin: ChannelPlugin<ResolvedWorkChatAccount> = {
70
77
  messageToolHints: () => [
71
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.",
72
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>' }.",
73
- "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.",
74
82
  ],
75
83
  },
76
84
  reload: { configPrefixes: ["channels.openclaw-workchat"] },
@@ -106,16 +114,73 @@ export const workchatPlugin: ChannelPlugin<ResolvedWorkChatAccount> = {
106
114
  return { channel: "openclaw-workchat", messageId: result.messageId };
107
115
  },
108
116
  sendMedia: async (ctx) => {
109
- // Media sends degrade to a text message carrying the media URL until the
110
- // platform media-upload API is confirmed.
111
117
  const account = resolveOutboundAccount(ctx.cfg, ctx.accountId);
112
- const text = ctx.text?.trim() ? ctx.text : ctx.mediaUrl ?? "";
113
- const result = await pushWorkChatMessage({
114
- 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({
115
127
  to: ctx.to,
116
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 };
117
134
  });
118
- 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
+ }
119
184
  },
120
185
  },
121
186
  status: {
@@ -205,13 +270,24 @@ export const workchatPlugin: ChannelPlugin<ResolvedWorkChatAccount> = {
205
270
  // The gateway adapter has no cfg in scope here — read the live config
206
271
  // from disk (the gateway's own config, same file triggerWorkChatChannelReload
207
272
  // writes to) so the ${WORKCHAT_API_KEY} placeholder is resolved by the host.
273
+ // Account ids are always agent-scoped (`agent-${agentId}`); an explicit
274
+ // accountId targets that account, otherwise fall back to the first
275
+ // registered one. A first-time bind (empty index) still resolves an
276
+ // account-like view of the shared channel credentials — the QR flow
277
+ // only consumes baseUrl/appKey/appSecret.
208
278
  const { loadConfig } = await import("openclaw/plugin-sdk/config-runtime");
209
279
  const cfg = loadConfig() as OpenClawConfig;
210
- const account = resolveWorkChatAccount(cfg, accountId ?? "default");
280
+ const requestedAccountId = accountId?.trim() || listWorkChatAccountIds(cfg)[0];
281
+ const existing = requestedAccountId
282
+ ? resolveWorkChatAccount(cfg, requestedAccountId)
283
+ : undefined;
284
+ const section = cfg.channels?.["openclaw-workchat"] as
285
+ | { baseUrl?: string; appKey?: string; appSecret?: string }
286
+ | undefined;
211
287
  const result: WorkChatQrStartResult = await startWorkChatLoginWithQr({
212
- baseUrl: account.baseUrl,
213
- appKey: account.appKey,
214
- appSecret: account.appSecret,
288
+ baseUrl: existing?.baseUrl || section?.baseUrl || DEFAULT_BASE_URL,
289
+ appKey: existing?.appKey || section?.appKey?.trim() || undefined,
290
+ appSecret: existing?.appSecret || section?.appSecret?.trim() || undefined,
215
291
  });
216
292
  return {
217
293
  qrDataUrl: result.qrcodeUrl,