@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/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
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/auth/accounts.ts
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
156
|
-
*
|
|
157
|
-
* `
|
|
158
|
-
*
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
removed
|
|
168
|
-
|
|
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();
|
package/src/auth/login-qr.ts
CHANGED
|
@@ -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
|
-
//
|
|
399
|
-
//
|
|
400
|
-
// channel (marked manually-stopped in the
|
|
401
|
-
// would stay stopped after reload —
|
|
402
|
-
//
|
|
403
|
-
const
|
|
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
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
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 {
|
|
410
|
-
const removed = await
|
|
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
|
|
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
|
-
"
|
|
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
|
|
113
|
-
const
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
213
|
-
appKey:
|
|
214
|
-
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,
|