@workclaw/openclaw-workchat 1.0.0 → 1.0.4
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/README.md +210 -210
- package/index.js +49568 -0
- package/index.ts +19 -19
- package/openclaw.plugin.json +25 -25
- package/package.json +64 -63
- package/src/api/api.ts +260 -260
- package/src/api/types.ts +56 -56
- package/src/auth/accounts.ts +279 -290
- package/src/auth/bind-agent.ts +52 -70
- package/src/auth/login-qr-terminal.ts +16 -16
- package/src/auth/login-qr.ts +477 -475
- package/src/auth/pairing.ts +133 -133
- package/src/channel.ts +282 -282
- package/src/compat.ts +75 -75
- package/src/config/config-schema.ts +29 -29
- package/src/messaging/debug-mode.ts +57 -57
- package/src/messaging/error-notice.ts +30 -30
- package/src/messaging/inbound.ts +174 -174
- package/src/messaging/markdown-filter.ts +361 -361
- package/src/messaging/outbound-hooks.ts +84 -84
- package/src/messaging/process-message.ts +530 -530
- package/src/messaging/send.ts +119 -119
- package/src/messaging/slash-commands.ts +105 -105
- package/src/monitor/monitor.ts +261 -261
- package/src/storage/state-dir.ts +11 -11
- package/src/util/logger.ts +147 -147
- package/src/util/random.ts +9 -9
- package/src/util/redact.ts +39 -39
- package/src/vendor.d.ts +10 -10
package/src/auth/login-qr.ts
CHANGED
|
@@ -1,475 +1,477 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
|
|
4
|
-
import type { WorkChatApiOptions } from "../api/api.js";
|
|
5
|
-
import { logger } from "../util/logger.js";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* bind-hardware QR payload — the plugin builds it locally (no platform call):
|
|
9
|
-
* the user scans it with the WorkChat app, which registers this device and
|
|
10
|
-
* pushes an INIT_AGENT event down the WebSocket to bind the agent.
|
|
11
|
-
*
|
|
12
|
-
* Fixed fields: cmd / type / name. Everything else is read live from the host.
|
|
13
|
-
*/
|
|
14
|
-
export type BindHardwarePayload = {
|
|
15
|
-
cmd: "bind-hardware";
|
|
16
|
-
data: {
|
|
17
|
-
type: string;
|
|
18
|
-
name: string;
|
|
19
|
-
serial_number: string;
|
|
20
|
-
product_model: string;
|
|
21
|
-
main_processor: string;
|
|
22
|
-
memory: string;
|
|
23
|
-
/** Storage capacity. The field name is protocol-fixed (eMMC on embedded
|
|
24
|
-
* devices); on desktops it carries the system disk size. */
|
|
25
|
-
emmc: string;
|
|
26
|
-
system: string;
|
|
27
|
-
app_key: string;
|
|
28
|
-
};
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
/** Fixed values for this product line. */
|
|
32
|
-
const BIND_CMD = "bind-hardware" as const;
|
|
33
|
-
const BIND_TYPE = "99";
|
|
34
|
-
const BIND_NAME = "WorkClaw 桌面端";
|
|
35
|
-
|
|
36
|
-
function readCpuModel(): string {
|
|
37
|
-
try {
|
|
38
|
-
const cpus = os.cpus();
|
|
39
|
-
if (cpus.length > 0 && cpus[0]?.model?.trim()) {
|
|
40
|
-
return cpus[0].model.trim();
|
|
41
|
-
}
|
|
42
|
-
} catch {
|
|
43
|
-
// ignore
|
|
44
|
-
}
|
|
45
|
-
return "unknown";
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function readTotalMemory(): string {
|
|
49
|
-
try {
|
|
50
|
-
const gb = os.totalmem() / 1024 / 1024 / 1024;
|
|
51
|
-
return `${Math.max(1, Math.round(gb))}GB`;
|
|
52
|
-
} catch {
|
|
53
|
-
return "unknown";
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Read the storage capacity of the volume backing the current working
|
|
59
|
-
* directory (the system disk on typical installs) via fs.statfs.
|
|
60
|
-
* Returns the total capacity, not free space.
|
|
61
|
-
*/
|
|
62
|
-
function readStorageSize(): string {
|
|
63
|
-
try {
|
|
64
|
-
const st = fs.statfsSync(process.cwd());
|
|
65
|
-
const totalBytes = Number(st.blocks) * Number(st.bsize);
|
|
66
|
-
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return "unknown";
|
|
67
|
-
const gb = totalBytes / 1024 / 1024 / 1024;
|
|
68
|
-
return `${Math.max(1, Math.round(gb))}GB`;
|
|
69
|
-
} catch {
|
|
70
|
-
return "unknown";
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function readPlatform(): string {
|
|
75
|
-
return `${os.type()} ${os.release()} ${os.arch()}`.trim() || "unknown";
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Build the bind-hardware QR content from live host info.
|
|
80
|
-
* `appKey` comes from the channel config (already injected via
|
|
81
|
-
* ${WORKCHAT_API_KEY}); the renderer encodes this JSON string into the QR.
|
|
82
|
-
*/
|
|
83
|
-
export function buildBindHardwarePayload(params: {
|
|
84
|
-
appKey: string;
|
|
85
|
-
serialNumber: string;
|
|
86
|
-
productModel?: string;
|
|
87
|
-
storageSize?: string;
|
|
88
|
-
}): BindHardwarePayload {
|
|
89
|
-
return {
|
|
90
|
-
cmd: BIND_CMD,
|
|
91
|
-
data: {
|
|
92
|
-
type: BIND_TYPE,
|
|
93
|
-
name: BIND_NAME,
|
|
94
|
-
serial_number: params.serialNumber,
|
|
95
|
-
product_model: params.productModel || "WorkClaw Desktop",
|
|
96
|
-
main_processor: readCpuModel(),
|
|
97
|
-
memory: readTotalMemory(),
|
|
98
|
-
// 协议字段名固定为 emmc(协议来自嵌入式设备的 eMMC 存储),
|
|
99
|
-
// 桌面端语义上是系统磁盘总容量;调用方可覆盖,默认自动探测。
|
|
100
|
-
emmc: params.storageSize || readStorageSize(),
|
|
101
|
-
system: readPlatform(),
|
|
102
|
-
app_key: params.appKey,
|
|
103
|
-
},
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Serialize the payload for QR encoding.
|
|
109
|
-
* Per the platform contract the JSON is base64-encoded (UTF-8) before it is
|
|
110
|
-
* painted into the QR — the WorkChat app decodes base64, then parses the JSON.
|
|
111
|
-
*/
|
|
112
|
-
export function buildBindHardwareQrContent(params: {
|
|
113
|
-
appKey: string;
|
|
114
|
-
serialNumber: string;
|
|
115
|
-
productModel?: string;
|
|
116
|
-
storageSize?: string;
|
|
117
|
-
}): string {
|
|
118
|
-
const json = JSON.stringify(buildBindHardwarePayload(params));
|
|
119
|
-
return Buffer.from(json, "utf8").toString("base64");
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
type ActiveBinding = {
|
|
123
|
-
sessionKey: string;
|
|
124
|
-
qrContent: string;
|
|
125
|
-
opts: WorkChatApiOptions;
|
|
126
|
-
startedAt: number;
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
const ACTIVE_BINDING_TTL_MS = 10 * 60_000;
|
|
130
|
-
|
|
131
|
-
const activeBindings = new Map<string, ActiveBinding>();
|
|
132
|
-
|
|
133
|
-
function isBindingFresh(binding: ActiveBinding): boolean {
|
|
134
|
-
return Date.now() - binding.startedAt < ACTIVE_BINDING_TTL_MS;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Remove expired entries from activeBindings to prevent memory leaks. */
|
|
138
|
-
function purgeExpiredBindings(): void {
|
|
139
|
-
for (const [id, binding] of activeBindings) {
|
|
140
|
-
if (!isBindingFresh(binding)) {
|
|
141
|
-
activeBindings.delete(id);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export type WorkChatQrStartResult = {
|
|
147
|
-
qrcodeUrl?: string;
|
|
148
|
-
sessionKey?: string;
|
|
149
|
-
message: string;
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
export type WorkChatQrWaitResult = {
|
|
153
|
-
connected: boolean;
|
|
154
|
-
message: string;
|
|
155
|
-
accountId?: string;
|
|
156
|
-
agentId?: string;
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Build the binding QR locally.
|
|
161
|
-
*
|
|
162
|
-
* Unlike Weixin, no platform call is needed to fetch a QR: the plugin
|
|
163
|
-
* assembles the bind-hardware JSON (device info + this user's app_key), the
|
|
164
|
-
* renderer paints it as a QR, and the WorkChat app scanning it pushes an
|
|
165
|
-
* INIT_AGENT event down the WebSocket which completes the binding.
|
|
166
|
-
*/
|
|
167
|
-
export async function startWorkChatLoginWithQr(opts: WorkChatApiOptions): Promise<WorkChatQrStartResult> {
|
|
168
|
-
purgeExpiredBindings();
|
|
169
|
-
|
|
170
|
-
if (!opts.appKey) {
|
|
171
|
-
return { message: "workchat: missing appKey (WORKCHAT_API_KEY 未注入,请先在桌面端登录)" };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
try {
|
|
175
|
-
const qrContent = buildBindHardwareQrContent({
|
|
176
|
-
appKey: opts.appKey,
|
|
177
|
-
// Machine identity: hostname + MAC makes a stable per-device serial.
|
|
178
|
-
serialNumber: buildDeviceSerial(),
|
|
179
|
-
});
|
|
180
|
-
const sessionKey = `bind-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
181
|
-
activeBindings.set(sessionKey, { sessionKey, qrContent, opts, startedAt: Date.now() });
|
|
182
|
-
logger.info(
|
|
183
|
-
`binding QR built locally appKey=${opts.appKey.slice(0, 8)}… serialLen=${buildDeviceSerial().length}`,
|
|
184
|
-
);
|
|
185
|
-
return {
|
|
186
|
-
qrcodeUrl: qrContent,
|
|
187
|
-
sessionKey,
|
|
188
|
-
message: "请使用 WorkChat 扫描二维码完成智能体绑定",
|
|
189
|
-
};
|
|
190
|
-
} catch (err) {
|
|
191
|
-
logger.error(`startWorkChatLoginWithQr failed: ${String(err)}`);
|
|
192
|
-
return { message: String(err) };
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function buildDeviceSerial(): string {
|
|
197
|
-
const nets = os.networkInterfaces();
|
|
198
|
-
let mac = "";
|
|
199
|
-
for (const list of Object.values(nets)) {
|
|
200
|
-
const hit = list?.find(n => n && !n.internal && n.mac && n.mac !== "00:00:00:00:00:00");
|
|
201
|
-
if (hit?.mac) {
|
|
202
|
-
mac = hit.mac;
|
|
203
|
-
break;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
const raw = `${os.hostname()}|${mac}`;
|
|
207
|
-
// Simple non-crypto hash to a fixed-width hex serial (stable per device,
|
|
208
|
-
// avoids leaking the hostname/MAC into the QR payload).
|
|
209
|
-
let h1 = 0x811c9dc5;
|
|
210
|
-
let h2 = 0x01000193;
|
|
211
|
-
for (let i = 0; i < raw.length; i++) {
|
|
212
|
-
h1 = (h1 ^ raw.charCodeAt(i)) * 0x01000193 >>> 0;
|
|
213
|
-
h2 = (h2 + raw.charCodeAt(i) * (i + 1)) >>> 0;
|
|
214
|
-
}
|
|
215
|
-
return `WC-${h1.toString(16).padStart(8, "0")}${h2.toString(16).padStart(8, "0")}`.toUpperCase();
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// ---------------------------------------------------------------------------
|
|
219
|
-
// Binding completion — signaled by the WebSocket INIT_AGENT event
|
|
220
|
-
// ---------------------------------------------------------------------------
|
|
221
|
-
|
|
222
|
-
export type BindingCompletion = {
|
|
223
|
-
accountId: string;
|
|
224
|
-
agentId: string;
|
|
225
|
-
userId?: string;
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
const pendingWaiters = new Map<string, (completion: BindingCompletion) => void>();
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Wait for the binding scan completion.
|
|
232
|
-
*
|
|
233
|
-
* There is no HTTP polling: after the user scans, the WorkChat platform
|
|
234
|
-
* pushes an INIT_AGENT event down a WebSocket. The gateway's web.login flow
|
|
235
|
-
* stops the channel before login starts (force), so the plugin's regular
|
|
236
|
-
* monitor cannot be the listener — instead this wait opens its own
|
|
237
|
-
* short-lived binding-listener WebSocket (same credentials), handles the
|
|
238
|
-
* INIT_AGENT frame (persist account + bind agent), resolves, and closes.
|
|
239
|
-
* The monitor's own INIT_AGENT handling remains as a second path.
|
|
240
|
-
*/
|
|
241
|
-
export async function waitForWorkChatLogin(params: {
|
|
242
|
-
sessionKey?: string;
|
|
243
|
-
opts?: WorkChatApiOptions;
|
|
244
|
-
timeoutMs?: number;
|
|
245
|
-
}): Promise<WorkChatQrWaitResult> {
|
|
246
|
-
const sessionKey = params.sessionKey?.trim();
|
|
247
|
-
if (!sessionKey) {
|
|
248
|
-
return { connected: false, message: "workchat: no pending binding session" };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const binding = activeBindings.get(sessionKey);
|
|
252
|
-
if (!binding || !isBindingFresh(binding)) {
|
|
253
|
-
activeBindings.delete(sessionKey);
|
|
254
|
-
return { connected: false, message: "绑定二维码已过期,请重新获取" };
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const opts = params.opts ?? binding.opts;
|
|
258
|
-
const timeoutMs = params.timeoutMs && params.timeoutMs > 0 ? params.timeoutMs : 8 * 60_000;
|
|
259
|
-
|
|
260
|
-
// Own the completion: register the waiter and run the binding listener.
|
|
261
|
-
const completionPromise = waitForCompletion(sessionKey);
|
|
262
|
-
startBindingListener(sessionKey, opts, timeoutMs);
|
|
263
|
-
const timeoutPromise = new Promise<BindingCompletion | null>(resolve =>
|
|
264
|
-
setTimeout(() => resolve(null), timeoutMs).unref?.());
|
|
265
|
-
|
|
266
|
-
const completion = await Promise.race([completionPromise, timeoutPromise]);
|
|
267
|
-
|
|
268
|
-
stopBindingListener(sessionKey);
|
|
269
|
-
pendingWaiters.delete(sessionKey);
|
|
270
|
-
|
|
271
|
-
if (!completion) {
|
|
272
|
-
activeBindings.delete(sessionKey);
|
|
273
|
-
return { connected: false, message: "绑定二维码已过期,请重新获取" };
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
activeBindings.delete(sessionKey);
|
|
277
|
-
return {
|
|
278
|
-
connected: true,
|
|
279
|
-
message: "WorkChat 绑定成功",
|
|
280
|
-
accountId: completion.accountId,
|
|
281
|
-
agentId: completion.agentId,
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// ---------------------------------------------------------------------------
|
|
286
|
-
// Binding listener — a dedicated WebSocket for the wait window
|
|
287
|
-
// ---------------------------------------------------------------------------
|
|
288
|
-
|
|
289
|
-
import { Agent, WebSocket } from "undici";
|
|
290
|
-
import { openConnection, clearWorkChatTokenCache } from "../api/api.js";
|
|
291
|
-
import { parseWorkChatFrame } from "../messaging/inbound.js";
|
|
292
|
-
import { buildPongFrame } from "../messaging/inbound.js";
|
|
293
|
-
import {
|
|
294
|
-
saveWorkChatAccount,
|
|
295
|
-
registerWorkChatAccountId,
|
|
296
|
-
listIndexedWorkChatAccountIds,
|
|
297
|
-
} from "./accounts.js";
|
|
298
|
-
|
|
299
|
-
const bindingListeners = new Map<string, { ws: WebSocket; done: boolean }>();
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Open a dedicated WebSocket for the binding wait window. Reconnects until
|
|
303
|
-
* the wait resolves or times out; the INIT_AGENT frame completes the binding
|
|
304
|
-
* (account persisted + agent bound into openclaw.json) and wakes the waiter.
|
|
305
|
-
*/
|
|
306
|
-
function startBindingListener(
|
|
307
|
-
sessionKey: string,
|
|
308
|
-
opts: WorkChatApiOptions,
|
|
309
|
-
timeoutMs: number,
|
|
310
|
-
): void {
|
|
311
|
-
const deadline = Date.now() + timeoutMs;
|
|
312
|
-
let backoffMs = 2_000;
|
|
313
|
-
|
|
314
|
-
const attempt = (): void => {
|
|
315
|
-
if (Date.now() > deadline) return;
|
|
316
|
-
const entry = bindingListeners.get(sessionKey);
|
|
317
|
-
if (!entry || entry.done) return;
|
|
318
|
-
|
|
319
|
-
void (async () => {
|
|
320
|
-
let ws: WebSocket | null = null;
|
|
321
|
-
try {
|
|
322
|
-
clearWorkChatTokenCache(opts.appKey || "default");
|
|
323
|
-
const { endpoint, ticket } = await openConnection(opts);
|
|
324
|
-
const url = `${endpoint}?ticket=${encodeURIComponent(ticket)}`;
|
|
325
|
-
const dispatcher = opts.allowInsecureTls
|
|
326
|
-
? new Agent({ connect: { rejectUnauthorized: false } })
|
|
327
|
-
: undefined;
|
|
328
|
-
ws = new WebSocket(url, dispatcher ? { dispatcher } : undefined);
|
|
329
|
-
const entryNow = bindingListeners.get(sessionKey);
|
|
330
|
-
if (!entryNow || entryNow.done) {
|
|
331
|
-
try { ws.close(); } catch { /* ignore */ }
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
entryNow.ws = ws;
|
|
335
|
-
|
|
336
|
-
await new Promise<void>((resolve, reject) => {
|
|
337
|
-
const timer = setTimeout(() => reject(new Error("binding listener ws timeout")), 30_000);
|
|
338
|
-
ws!.onopen = () => { clearTimeout(timer); resolve(); };
|
|
339
|
-
ws!.onerror = (e: unknown) => { clearTimeout(timer); reject(new Error(String((e as any)?.message ?? e))); };
|
|
340
|
-
ws!.onclose = (e: unknown) => { clearTimeout(timer); reject(new Error(`binding listener closed code=${String((e as any)?.code ?? "")}`)); };
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
logger.info("binding listener WebSocket connected");
|
|
344
|
-
backoffMs = 2_000;
|
|
345
|
-
|
|
346
|
-
ws.onmessage = (event: unknown) => {
|
|
347
|
-
const data = typeof (event as any).data === "string"
|
|
348
|
-
? (event as any).data
|
|
349
|
-
: String((event as any).data ?? "");
|
|
350
|
-
void handleListenerFrame(sessionKey, data, ws);
|
|
351
|
-
};
|
|
352
|
-
ws.onclose = () => {
|
|
353
|
-
// Reconnect while the wait window is open.
|
|
354
|
-
const e = bindingListeners.get(sessionKey);
|
|
355
|
-
if (e && !e.done) setTimeout(attempt, backoffMs);
|
|
356
|
-
};
|
|
357
|
-
ws.onerror = (e: unknown) => {
|
|
358
|
-
logger.warn(`binding listener ws error: ${String((e as any)?.message ?? e)}`);
|
|
359
|
-
};
|
|
360
|
-
} catch (err) {
|
|
361
|
-
const errorStr = String(err);
|
|
362
|
-
logger.warn(`binding listener connect failed: ${errorStr}`);
|
|
363
|
-
const e = bindingListeners.get(sessionKey);
|
|
364
|
-
if (!e || e.done) return;
|
|
365
|
-
if (errorStr.includes("鉴权失败") || errorStr.includes("missing appKey/appSecret")) {
|
|
366
|
-
// Credentials wrong — retrying cannot help.
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
backoffMs = Math.min(backoffMs * 2, 60_000);
|
|
370
|
-
setTimeout(attempt, backoffMs);
|
|
371
|
-
}
|
|
372
|
-
})();
|
|
373
|
-
};
|
|
374
|
-
|
|
375
|
-
bindingListeners.set(sessionKey, { ws: null as unknown as WebSocket, done: false });
|
|
376
|
-
attempt();
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
async function handleListenerFrame(sessionKey: string, data: string, ws: WebSocket | null): Promise<void> {
|
|
380
|
-
const result = parseWorkChatFrame(data);
|
|
381
|
-
if (!result) return;
|
|
382
|
-
|
|
383
|
-
if (result.type === "ping") {
|
|
384
|
-
try { ws?.send(buildPongFrame(result.pongData)); } catch { /* ignore */ }
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
if (result.type !== "init_agent") return;
|
|
388
|
-
|
|
389
|
-
const eventData = result.eventData;
|
|
390
|
-
if (!eventData) return;
|
|
391
|
-
const agentId = String(eventData.id ?? eventData.agentId ?? "");
|
|
392
|
-
if (!agentId) return;
|
|
393
|
-
const userId = String(eventData.futureId ?? eventData.userId ?? "");
|
|
394
|
-
|
|
395
|
-
const entry = bindingListeners.get(sessionKey);
|
|
396
|
-
if (entry) entry.done = true;
|
|
397
|
-
|
|
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;
|
|
404
|
-
|
|
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.
|
|
408
|
-
try {
|
|
409
|
-
const { pruneStaleWorkChatAccounts } = await import("./accounts.js");
|
|
410
|
-
const removed = await pruneStaleWorkChatAccounts(agentId);
|
|
411
|
-
if (removed.length > 0) {
|
|
412
|
-
logger.info(`binding listener: pruned stale account(s): ${removed.join(", ")}`);
|
|
413
|
-
}
|
|
414
|
-
} catch (err) {
|
|
415
|
-
logger.error(`binding listener: rebind cleanup failed: ${String(err)}`);
|
|
416
|
-
}
|
|
417
|
-
|
|
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
|
-
try {
|
|
425
|
-
saveWorkChatAccount(accountId, {
|
|
426
|
-
agentId,
|
|
427
|
-
...(userId ? { userId } : {}),
|
|
428
|
-
});
|
|
429
|
-
registerWorkChatAccountId(accountId);
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
logger.
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
entry
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
resolve
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
|
|
4
|
+
import type { WorkChatApiOptions } from "../api/api.js";
|
|
5
|
+
import { logger } from "../util/logger.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* bind-hardware QR payload — the plugin builds it locally (no platform call):
|
|
9
|
+
* the user scans it with the WorkChat app, which registers this device and
|
|
10
|
+
* pushes an INIT_AGENT event down the WebSocket to bind the agent.
|
|
11
|
+
*
|
|
12
|
+
* Fixed fields: cmd / type / name. Everything else is read live from the host.
|
|
13
|
+
*/
|
|
14
|
+
export type BindHardwarePayload = {
|
|
15
|
+
cmd: "bind-hardware";
|
|
16
|
+
data: {
|
|
17
|
+
type: string;
|
|
18
|
+
name: string;
|
|
19
|
+
serial_number: string;
|
|
20
|
+
product_model: string;
|
|
21
|
+
main_processor: string;
|
|
22
|
+
memory: string;
|
|
23
|
+
/** Storage capacity. The field name is protocol-fixed (eMMC on embedded
|
|
24
|
+
* devices); on desktops it carries the system disk size. */
|
|
25
|
+
emmc: string;
|
|
26
|
+
system: string;
|
|
27
|
+
app_key: string;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Fixed values for this product line. */
|
|
32
|
+
const BIND_CMD = "bind-hardware" as const;
|
|
33
|
+
const BIND_TYPE = "99";
|
|
34
|
+
const BIND_NAME = "WorkClaw 桌面端";
|
|
35
|
+
|
|
36
|
+
function readCpuModel(): string {
|
|
37
|
+
try {
|
|
38
|
+
const cpus = os.cpus();
|
|
39
|
+
if (cpus.length > 0 && cpus[0]?.model?.trim()) {
|
|
40
|
+
return cpus[0].model.trim();
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// ignore
|
|
44
|
+
}
|
|
45
|
+
return "unknown";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readTotalMemory(): string {
|
|
49
|
+
try {
|
|
50
|
+
const gb = os.totalmem() / 1024 / 1024 / 1024;
|
|
51
|
+
return `${Math.max(1, Math.round(gb))}GB`;
|
|
52
|
+
} catch {
|
|
53
|
+
return "unknown";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read the storage capacity of the volume backing the current working
|
|
59
|
+
* directory (the system disk on typical installs) via fs.statfs.
|
|
60
|
+
* Returns the total capacity, not free space.
|
|
61
|
+
*/
|
|
62
|
+
function readStorageSize(): string {
|
|
63
|
+
try {
|
|
64
|
+
const st = fs.statfsSync(process.cwd());
|
|
65
|
+
const totalBytes = Number(st.blocks) * Number(st.bsize);
|
|
66
|
+
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return "unknown";
|
|
67
|
+
const gb = totalBytes / 1024 / 1024 / 1024;
|
|
68
|
+
return `${Math.max(1, Math.round(gb))}GB`;
|
|
69
|
+
} catch {
|
|
70
|
+
return "unknown";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readPlatform(): string {
|
|
75
|
+
return `${os.type()} ${os.release()} ${os.arch()}`.trim() || "unknown";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build the bind-hardware QR content from live host info.
|
|
80
|
+
* `appKey` comes from the channel config (already injected via
|
|
81
|
+
* ${WORKCHAT_API_KEY}); the renderer encodes this JSON string into the QR.
|
|
82
|
+
*/
|
|
83
|
+
export function buildBindHardwarePayload(params: {
|
|
84
|
+
appKey: string;
|
|
85
|
+
serialNumber: string;
|
|
86
|
+
productModel?: string;
|
|
87
|
+
storageSize?: string;
|
|
88
|
+
}): BindHardwarePayload {
|
|
89
|
+
return {
|
|
90
|
+
cmd: BIND_CMD,
|
|
91
|
+
data: {
|
|
92
|
+
type: BIND_TYPE,
|
|
93
|
+
name: BIND_NAME,
|
|
94
|
+
serial_number: params.serialNumber,
|
|
95
|
+
product_model: params.productModel || "WorkClaw Desktop",
|
|
96
|
+
main_processor: readCpuModel(),
|
|
97
|
+
memory: readTotalMemory(),
|
|
98
|
+
// 协议字段名固定为 emmc(协议来自嵌入式设备的 eMMC 存储),
|
|
99
|
+
// 桌面端语义上是系统磁盘总容量;调用方可覆盖,默认自动探测。
|
|
100
|
+
emmc: params.storageSize || readStorageSize(),
|
|
101
|
+
system: readPlatform(),
|
|
102
|
+
app_key: params.appKey,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Serialize the payload for QR encoding.
|
|
109
|
+
* Per the platform contract the JSON is base64-encoded (UTF-8) before it is
|
|
110
|
+
* painted into the QR — the WorkChat app decodes base64, then parses the JSON.
|
|
111
|
+
*/
|
|
112
|
+
export function buildBindHardwareQrContent(params: {
|
|
113
|
+
appKey: string;
|
|
114
|
+
serialNumber: string;
|
|
115
|
+
productModel?: string;
|
|
116
|
+
storageSize?: string;
|
|
117
|
+
}): string {
|
|
118
|
+
const json = JSON.stringify(buildBindHardwarePayload(params));
|
|
119
|
+
return Buffer.from(json, "utf8").toString("base64");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type ActiveBinding = {
|
|
123
|
+
sessionKey: string;
|
|
124
|
+
qrContent: string;
|
|
125
|
+
opts: WorkChatApiOptions;
|
|
126
|
+
startedAt: number;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const ACTIVE_BINDING_TTL_MS = 10 * 60_000;
|
|
130
|
+
|
|
131
|
+
const activeBindings = new Map<string, ActiveBinding>();
|
|
132
|
+
|
|
133
|
+
function isBindingFresh(binding: ActiveBinding): boolean {
|
|
134
|
+
return Date.now() - binding.startedAt < ACTIVE_BINDING_TTL_MS;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Remove expired entries from activeBindings to prevent memory leaks. */
|
|
138
|
+
function purgeExpiredBindings(): void {
|
|
139
|
+
for (const [id, binding] of activeBindings) {
|
|
140
|
+
if (!isBindingFresh(binding)) {
|
|
141
|
+
activeBindings.delete(id);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type WorkChatQrStartResult = {
|
|
147
|
+
qrcodeUrl?: string;
|
|
148
|
+
sessionKey?: string;
|
|
149
|
+
message: string;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export type WorkChatQrWaitResult = {
|
|
153
|
+
connected: boolean;
|
|
154
|
+
message: string;
|
|
155
|
+
accountId?: string;
|
|
156
|
+
agentId?: string;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build the binding QR locally.
|
|
161
|
+
*
|
|
162
|
+
* Unlike Weixin, no platform call is needed to fetch a QR: the plugin
|
|
163
|
+
* assembles the bind-hardware JSON (device info + this user's app_key), the
|
|
164
|
+
* renderer paints it as a QR, and the WorkChat app scanning it pushes an
|
|
165
|
+
* INIT_AGENT event down the WebSocket which completes the binding.
|
|
166
|
+
*/
|
|
167
|
+
export async function startWorkChatLoginWithQr(opts: WorkChatApiOptions): Promise<WorkChatQrStartResult> {
|
|
168
|
+
purgeExpiredBindings();
|
|
169
|
+
|
|
170
|
+
if (!opts.appKey) {
|
|
171
|
+
return { message: "workchat: missing appKey (WORKCHAT_API_KEY 未注入,请先在桌面端登录)" };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const qrContent = buildBindHardwareQrContent({
|
|
176
|
+
appKey: opts.appKey,
|
|
177
|
+
// Machine identity: hostname + MAC makes a stable per-device serial.
|
|
178
|
+
serialNumber: buildDeviceSerial(),
|
|
179
|
+
});
|
|
180
|
+
const sessionKey = `bind-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
181
|
+
activeBindings.set(sessionKey, { sessionKey, qrContent, opts, startedAt: Date.now() });
|
|
182
|
+
logger.info(
|
|
183
|
+
`binding QR built locally appKey=${opts.appKey.slice(0, 8)}… serialLen=${buildDeviceSerial().length}`,
|
|
184
|
+
);
|
|
185
|
+
return {
|
|
186
|
+
qrcodeUrl: qrContent,
|
|
187
|
+
sessionKey,
|
|
188
|
+
message: "请使用 WorkChat 扫描二维码完成智能体绑定",
|
|
189
|
+
};
|
|
190
|
+
} catch (err) {
|
|
191
|
+
logger.error(`startWorkChatLoginWithQr failed: ${String(err)}`);
|
|
192
|
+
return { message: String(err) };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function buildDeviceSerial(): string {
|
|
197
|
+
const nets = os.networkInterfaces();
|
|
198
|
+
let mac = "";
|
|
199
|
+
for (const list of Object.values(nets)) {
|
|
200
|
+
const hit = list?.find(n => n && !n.internal && n.mac && n.mac !== "00:00:00:00:00:00");
|
|
201
|
+
if (hit?.mac) {
|
|
202
|
+
mac = hit.mac;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const raw = `${os.hostname()}|${mac}`;
|
|
207
|
+
// Simple non-crypto hash to a fixed-width hex serial (stable per device,
|
|
208
|
+
// avoids leaking the hostname/MAC into the QR payload).
|
|
209
|
+
let h1 = 0x811c9dc5;
|
|
210
|
+
let h2 = 0x01000193;
|
|
211
|
+
for (let i = 0; i < raw.length; i++) {
|
|
212
|
+
h1 = (h1 ^ raw.charCodeAt(i)) * 0x01000193 >>> 0;
|
|
213
|
+
h2 = (h2 + raw.charCodeAt(i) * (i + 1)) >>> 0;
|
|
214
|
+
}
|
|
215
|
+
return `WC-${h1.toString(16).padStart(8, "0")}${h2.toString(16).padStart(8, "0")}`.toUpperCase();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Binding completion — signaled by the WebSocket INIT_AGENT event
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
export type BindingCompletion = {
|
|
223
|
+
accountId: string;
|
|
224
|
+
agentId: string;
|
|
225
|
+
userId?: string;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const pendingWaiters = new Map<string, (completion: BindingCompletion) => void>();
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Wait for the binding scan completion.
|
|
232
|
+
*
|
|
233
|
+
* There is no HTTP polling: after the user scans, the WorkChat platform
|
|
234
|
+
* pushes an INIT_AGENT event down a WebSocket. The gateway's web.login flow
|
|
235
|
+
* stops the channel before login starts (force), so the plugin's regular
|
|
236
|
+
* monitor cannot be the listener — instead this wait opens its own
|
|
237
|
+
* short-lived binding-listener WebSocket (same credentials), handles the
|
|
238
|
+
* INIT_AGENT frame (persist account + bind agent), resolves, and closes.
|
|
239
|
+
* The monitor's own INIT_AGENT handling remains as a second path.
|
|
240
|
+
*/
|
|
241
|
+
export async function waitForWorkChatLogin(params: {
|
|
242
|
+
sessionKey?: string;
|
|
243
|
+
opts?: WorkChatApiOptions;
|
|
244
|
+
timeoutMs?: number;
|
|
245
|
+
}): Promise<WorkChatQrWaitResult> {
|
|
246
|
+
const sessionKey = params.sessionKey?.trim();
|
|
247
|
+
if (!sessionKey) {
|
|
248
|
+
return { connected: false, message: "workchat: no pending binding session" };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const binding = activeBindings.get(sessionKey);
|
|
252
|
+
if (!binding || !isBindingFresh(binding)) {
|
|
253
|
+
activeBindings.delete(sessionKey);
|
|
254
|
+
return { connected: false, message: "绑定二维码已过期,请重新获取" };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const opts = params.opts ?? binding.opts;
|
|
258
|
+
const timeoutMs = params.timeoutMs && params.timeoutMs > 0 ? params.timeoutMs : 8 * 60_000;
|
|
259
|
+
|
|
260
|
+
// Own the completion: register the waiter and run the binding listener.
|
|
261
|
+
const completionPromise = waitForCompletion(sessionKey);
|
|
262
|
+
startBindingListener(sessionKey, opts, timeoutMs);
|
|
263
|
+
const timeoutPromise = new Promise<BindingCompletion | null>(resolve =>
|
|
264
|
+
setTimeout(() => resolve(null), timeoutMs).unref?.());
|
|
265
|
+
|
|
266
|
+
const completion = await Promise.race([completionPromise, timeoutPromise]);
|
|
267
|
+
|
|
268
|
+
stopBindingListener(sessionKey);
|
|
269
|
+
pendingWaiters.delete(sessionKey);
|
|
270
|
+
|
|
271
|
+
if (!completion) {
|
|
272
|
+
activeBindings.delete(sessionKey);
|
|
273
|
+
return { connected: false, message: "绑定二维码已过期,请重新获取" };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
activeBindings.delete(sessionKey);
|
|
277
|
+
return {
|
|
278
|
+
connected: true,
|
|
279
|
+
message: "WorkChat 绑定成功",
|
|
280
|
+
accountId: completion.accountId,
|
|
281
|
+
agentId: completion.agentId,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Binding listener — a dedicated WebSocket for the wait window
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
import { Agent, WebSocket } from "undici";
|
|
290
|
+
import { openConnection, clearWorkChatTokenCache } from "../api/api.js";
|
|
291
|
+
import { parseWorkChatFrame } from "../messaging/inbound.js";
|
|
292
|
+
import { buildPongFrame } from "../messaging/inbound.js";
|
|
293
|
+
import {
|
|
294
|
+
saveWorkChatAccount,
|
|
295
|
+
registerWorkChatAccountId,
|
|
296
|
+
listIndexedWorkChatAccountIds,
|
|
297
|
+
} from "./accounts.js";
|
|
298
|
+
|
|
299
|
+
const bindingListeners = new Map<string, { ws: WebSocket; done: boolean }>();
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Open a dedicated WebSocket for the binding wait window. Reconnects until
|
|
303
|
+
* the wait resolves or times out; the INIT_AGENT frame completes the binding
|
|
304
|
+
* (account persisted + agent bound into openclaw.json) and wakes the waiter.
|
|
305
|
+
*/
|
|
306
|
+
function startBindingListener(
|
|
307
|
+
sessionKey: string,
|
|
308
|
+
opts: WorkChatApiOptions,
|
|
309
|
+
timeoutMs: number,
|
|
310
|
+
): void {
|
|
311
|
+
const deadline = Date.now() + timeoutMs;
|
|
312
|
+
let backoffMs = 2_000;
|
|
313
|
+
|
|
314
|
+
const attempt = (): void => {
|
|
315
|
+
if (Date.now() > deadline) return;
|
|
316
|
+
const entry = bindingListeners.get(sessionKey);
|
|
317
|
+
if (!entry || entry.done) return;
|
|
318
|
+
|
|
319
|
+
void (async () => {
|
|
320
|
+
let ws: WebSocket | null = null;
|
|
321
|
+
try {
|
|
322
|
+
clearWorkChatTokenCache(opts.appKey || "default");
|
|
323
|
+
const { endpoint, ticket } = await openConnection(opts);
|
|
324
|
+
const url = `${endpoint}?ticket=${encodeURIComponent(ticket)}`;
|
|
325
|
+
const dispatcher = opts.allowInsecureTls
|
|
326
|
+
? new Agent({ connect: { rejectUnauthorized: false } })
|
|
327
|
+
: undefined;
|
|
328
|
+
ws = new WebSocket(url, dispatcher ? { dispatcher } : undefined);
|
|
329
|
+
const entryNow = bindingListeners.get(sessionKey);
|
|
330
|
+
if (!entryNow || entryNow.done) {
|
|
331
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
entryNow.ws = ws;
|
|
335
|
+
|
|
336
|
+
await new Promise<void>((resolve, reject) => {
|
|
337
|
+
const timer = setTimeout(() => reject(new Error("binding listener ws timeout")), 30_000);
|
|
338
|
+
ws!.onopen = () => { clearTimeout(timer); resolve(); };
|
|
339
|
+
ws!.onerror = (e: unknown) => { clearTimeout(timer); reject(new Error(String((e as any)?.message ?? e))); };
|
|
340
|
+
ws!.onclose = (e: unknown) => { clearTimeout(timer); reject(new Error(`binding listener closed code=${String((e as any)?.code ?? "")}`)); };
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
logger.info("binding listener WebSocket connected");
|
|
344
|
+
backoffMs = 2_000;
|
|
345
|
+
|
|
346
|
+
ws.onmessage = (event: unknown) => {
|
|
347
|
+
const data = typeof (event as any).data === "string"
|
|
348
|
+
? (event as any).data
|
|
349
|
+
: String((event as any).data ?? "");
|
|
350
|
+
void handleListenerFrame(sessionKey, data, ws);
|
|
351
|
+
};
|
|
352
|
+
ws.onclose = () => {
|
|
353
|
+
// Reconnect while the wait window is open.
|
|
354
|
+
const e = bindingListeners.get(sessionKey);
|
|
355
|
+
if (e && !e.done) setTimeout(attempt, backoffMs);
|
|
356
|
+
};
|
|
357
|
+
ws.onerror = (e: unknown) => {
|
|
358
|
+
logger.warn(`binding listener ws error: ${String((e as any)?.message ?? e)}`);
|
|
359
|
+
};
|
|
360
|
+
} catch (err) {
|
|
361
|
+
const errorStr = String(err);
|
|
362
|
+
logger.warn(`binding listener connect failed: ${errorStr}`);
|
|
363
|
+
const e = bindingListeners.get(sessionKey);
|
|
364
|
+
if (!e || e.done) return;
|
|
365
|
+
if (errorStr.includes("鉴权失败") || errorStr.includes("missing appKey/appSecret")) {
|
|
366
|
+
// Credentials wrong — retrying cannot help.
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
backoffMs = Math.min(backoffMs * 2, 60_000);
|
|
370
|
+
setTimeout(attempt, backoffMs);
|
|
371
|
+
}
|
|
372
|
+
})();
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
bindingListeners.set(sessionKey, { ws: null as unknown as WebSocket, done: false });
|
|
376
|
+
attempt();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function handleListenerFrame(sessionKey: string, data: string, ws: WebSocket | null): Promise<void> {
|
|
380
|
+
const result = parseWorkChatFrame(data);
|
|
381
|
+
if (!result) return;
|
|
382
|
+
|
|
383
|
+
if (result.type === "ping") {
|
|
384
|
+
try { ws?.send(buildPongFrame(result.pongData)); } catch { /* ignore */ }
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (result.type !== "init_agent") return;
|
|
388
|
+
|
|
389
|
+
const eventData = result.eventData;
|
|
390
|
+
if (!eventData) return;
|
|
391
|
+
const agentId = String(eventData.id ?? eventData.agentId ?? "");
|
|
392
|
+
if (!agentId) return;
|
|
393
|
+
const userId = String(eventData.futureId ?? eventData.userId ?? "");
|
|
394
|
+
|
|
395
|
+
const entry = bindingListeners.get(sessionKey);
|
|
396
|
+
if (entry) entry.done = true;
|
|
397
|
+
|
|
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;
|
|
404
|
+
|
|
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.
|
|
408
|
+
try {
|
|
409
|
+
const { pruneStaleWorkChatAccounts } = await import("./accounts.js");
|
|
410
|
+
const removed = await pruneStaleWorkChatAccounts(agentId);
|
|
411
|
+
if (removed.length > 0) {
|
|
412
|
+
logger.info(`binding listener: pruned stale account(s): ${removed.join(", ")}`);
|
|
413
|
+
}
|
|
414
|
+
} catch (err) {
|
|
415
|
+
logger.error(`binding listener: rebind cleanup failed: ${String(err)}`);
|
|
416
|
+
}
|
|
417
|
+
|
|
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
|
+
try {
|
|
425
|
+
saveWorkChatAccount(accountId, {
|
|
426
|
+
agentId,
|
|
427
|
+
...(userId ? { userId } : {}),
|
|
428
|
+
});
|
|
429
|
+
registerWorkChatAccountId(accountId);
|
|
430
|
+
// Complete the binding: account data is already in the private state
|
|
431
|
+
// dir; this call registers the allowFrom user and triggers the channel
|
|
432
|
+
// hot reload.
|
|
433
|
+
const { bindAgentToAccountFromBinding } = await import("./bind-agent.js");
|
|
434
|
+
await bindAgentToAccountFromBinding(accountId, agentId, userId);
|
|
435
|
+
logger.info(`binding listener: account=${accountId} agent=${agentId} persisted`);
|
|
436
|
+
} catch (err) {
|
|
437
|
+
logger.error(`binding listener: failed to persist binding err=${String(err)}`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
completeWorkChatBinding({
|
|
441
|
+
accountId,
|
|
442
|
+
agentId,
|
|
443
|
+
...(userId ? { userId } : {}),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function stopBindingListener(sessionKey: string): void {
|
|
448
|
+
const entry = bindingListeners.get(sessionKey);
|
|
449
|
+
if (!entry) return;
|
|
450
|
+
entry.done = true;
|
|
451
|
+
try { entry.ws?.close(); } catch { /* ignore */ }
|
|
452
|
+
bindingListeners.delete(sessionKey);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function waitForCompletion(sessionKey: string): Promise<BindingCompletion> {
|
|
456
|
+
return new Promise(resolve => {
|
|
457
|
+
pendingWaiters.set(sessionKey, resolve);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Called by the binding listener / monitor loop when the WebSocket INIT_AGENT
|
|
463
|
+
* frame arrives for a pending binding: resolves the waiting web.login.wait.
|
|
464
|
+
*/
|
|
465
|
+
export function completeWorkChatBinding(completion: BindingCompletion): boolean {
|
|
466
|
+
const sessionKey = pendingWaiters.size > 0
|
|
467
|
+
? [...pendingWaiters.keys()][0]
|
|
468
|
+
: undefined;
|
|
469
|
+
if (!sessionKey) return false;
|
|
470
|
+
const resolve = pendingWaiters.get(sessionKey);
|
|
471
|
+
pendingWaiters.delete(sessionKey);
|
|
472
|
+
resolve?.(completion);
|
|
473
|
+
logger.info(
|
|
474
|
+
`binding completed via INIT_AGENT sessionKey=${sessionKey.slice(0, 12)}… agentId=${completion.agentId}`,
|
|
475
|
+
);
|
|
476
|
+
return true;
|
|
477
|
+
}
|