@botcord/daemon 0.2.36 → 0.2.37

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.
Files changed (65) hide show
  1. package/dist/config.d.ts +29 -0
  2. package/dist/config.js +27 -0
  3. package/dist/daemon-config-map.d.ts +3 -0
  4. package/dist/daemon-config-map.js +30 -0
  5. package/dist/daemon.d.ts +15 -1
  6. package/dist/daemon.js +56 -11
  7. package/dist/gateway/channels/botcord.js +44 -0
  8. package/dist/gateway/channels/http-types.d.ts +19 -0
  9. package/dist/gateway/channels/http-types.js +1 -0
  10. package/dist/gateway/channels/index.d.ts +5 -0
  11. package/dist/gateway/channels/index.js +5 -0
  12. package/dist/gateway/channels/login-session.d.ts +83 -0
  13. package/dist/gateway/channels/login-session.js +99 -0
  14. package/dist/gateway/channels/secret-store.d.ts +21 -0
  15. package/dist/gateway/channels/secret-store.js +75 -0
  16. package/dist/gateway/channels/state-store.d.ts +60 -0
  17. package/dist/gateway/channels/state-store.js +173 -0
  18. package/dist/gateway/channels/telegram.d.ts +31 -0
  19. package/dist/gateway/channels/telegram.js +371 -0
  20. package/dist/gateway/channels/text-split.d.ts +13 -0
  21. package/dist/gateway/channels/text-split.js +33 -0
  22. package/dist/gateway/channels/url-guard.d.ts +18 -0
  23. package/dist/gateway/channels/url-guard.js +53 -0
  24. package/dist/gateway/channels/wechat-http.d.ts +18 -0
  25. package/dist/gateway/channels/wechat-http.js +28 -0
  26. package/dist/gateway/channels/wechat-login.d.ts +36 -0
  27. package/dist/gateway/channels/wechat-login.js +62 -0
  28. package/dist/gateway/channels/wechat.d.ts +40 -0
  29. package/dist/gateway/channels/wechat.js +472 -0
  30. package/dist/gateway/runtimes/openclaw-acp.js +211 -6
  31. package/dist/gateway/types.d.ts +10 -0
  32. package/dist/gateway-control.d.ts +53 -0
  33. package/dist/gateway-control.js +638 -0
  34. package/dist/provision.d.ts +7 -0
  35. package/dist/provision.js +255 -5
  36. package/package.json +1 -1
  37. package/src/__tests__/gateway-control.test.ts +499 -0
  38. package/src/__tests__/openclaw-acp.test.ts +63 -0
  39. package/src/__tests__/provision.test.ts +179 -0
  40. package/src/__tests__/secret-store.test.ts +70 -0
  41. package/src/__tests__/state-store.test.ts +119 -0
  42. package/src/__tests__/third-party-gateway.test.ts +126 -0
  43. package/src/__tests__/url-guard.test.ts +85 -0
  44. package/src/__tests__/wechat-channel.test.ts +1134 -0
  45. package/src/config.ts +71 -0
  46. package/src/daemon-config-map.ts +24 -0
  47. package/src/daemon.ts +70 -11
  48. package/src/gateway/__tests__/botcord-channel.test.ts +1 -1
  49. package/src/gateway/__tests__/telegram-channel.test.ts +555 -0
  50. package/src/gateway/channels/botcord.ts +39 -0
  51. package/src/gateway/channels/http-types.ts +22 -0
  52. package/src/gateway/channels/index.ts +22 -0
  53. package/src/gateway/channels/login-session.ts +135 -0
  54. package/src/gateway/channels/secret-store.ts +100 -0
  55. package/src/gateway/channels/state-store.ts +213 -0
  56. package/src/gateway/channels/telegram.ts +469 -0
  57. package/src/gateway/channels/text-split.ts +29 -0
  58. package/src/gateway/channels/url-guard.ts +55 -0
  59. package/src/gateway/channels/wechat-http.ts +35 -0
  60. package/src/gateway/channels/wechat-login.ts +90 -0
  61. package/src/gateway/channels/wechat.ts +572 -0
  62. package/src/gateway/runtimes/openclaw-acp.ts +211 -7
  63. package/src/gateway/types.ts +10 -0
  64. package/src/gateway-control.ts +709 -0
  65. package/src/provision.ts +336 -5
@@ -0,0 +1,638 @@
1
+ /**
2
+ * Daemon-side handlers for the third-party gateway control frames defined
3
+ * in `packages/protocol-core/src/control-frame.ts`. Kept separate from
4
+ * `provision.ts` so the BotCord-agent provisioning logic and the third-
5
+ * party adapter management stay independently testable.
6
+ *
7
+ * All handlers take a {@link GatewayControlContext} so callers can swap the
8
+ * gateway, login-session store, fetch impl, and config I/O — `provision.ts`
9
+ * wires the production defaults.
10
+ */
11
+ import { loadConfig, saveConfig, resolveConfiguredAgentIds, } from "./config.js";
12
+ import { deleteGatewaySecret, loadGatewaySecret, saveGatewaySecret, } from "./gateway/channels/secret-store.js";
13
+ import { LoginSessionStore, maskTokenPreview, mintLoginId, } from "./gateway/channels/login-session.js";
14
+ import { DEFAULT_WECHAT_BASE_URL, getBotQrcode, getQrcodeStatus, } from "./gateway/channels/wechat-login.js";
15
+ import { assertSafeBaseUrl, UnsafeBaseUrlError } from "./gateway/channels/url-guard.js";
16
+ import { log as daemonLog } from "./log.js";
17
+ /**
18
+ * Build a closure carrying the production / test defaults. Returned object
19
+ * exposes one `handle*` method per frame type so `provision.ts` can route by
20
+ * `frame.type` without re-resolving dependencies on every dispatch.
21
+ */
22
+ export function createGatewayControl(ctx) {
23
+ const cfgIO = ctx.configIO ?? { load: loadConfig, save: saveConfig };
24
+ const sessions = ctx.loginSessions ?? new LoginSessionStore();
25
+ const wechatLogin = ctx.wechatLoginClient ?? { getBotQrcode, getQrcodeStatus };
26
+ // W7: validate fetch availability at construction so a missing global is
27
+ // diagnosed at startup, not during the first control frame. Tests inject
28
+ // `ctx.fetchImpl` explicitly and bypass the global lookup entirely.
29
+ let fetchImpl;
30
+ if (ctx.fetchImpl) {
31
+ fetchImpl = ctx.fetchImpl;
32
+ }
33
+ else {
34
+ const globalFetch = globalThis.fetch;
35
+ if (typeof globalFetch !== "function") {
36
+ throw new Error("createGatewayControl: globalThis.fetch is not available (Node ≥18 required) and no ctx.fetchImpl was supplied");
37
+ }
38
+ const bound = globalFetch.bind(globalThis);
39
+ fetchImpl = ((input, init) => bound(input, init));
40
+ }
41
+ // --- list_gateways ------------------------------------------------------
42
+ function handleList() {
43
+ const cfg = cfgIO.load();
44
+ const channelStatus = ctx.gateway.snapshot().channels;
45
+ const profiles = cfg.thirdPartyGateways ?? [];
46
+ const gateways = profiles.map((p) => annotateProfile(p, channelStatus[p.id]));
47
+ const result = { gateways };
48
+ return { ok: true, result };
49
+ }
50
+ // --- upsert_gateway -----------------------------------------------------
51
+ async function handleUpsert(params) {
52
+ const err = validateUpsertParams(params);
53
+ if (err)
54
+ return badParams(err);
55
+ // W1: defense-in-depth — Hub already screens baseUrl; reject again here
56
+ // so a compromised control plane cannot pivot the daemon to internal IPs.
57
+ try {
58
+ assertSafeBaseUrl(params.settings?.baseUrl);
59
+ }
60
+ catch (urlErr) {
61
+ if (urlErr instanceof UnsafeBaseUrlError)
62
+ return badParams(urlErr.message);
63
+ throw urlErr;
64
+ }
65
+ const cfg = cfgIO.load();
66
+ // accountId must belong to a daemon-bound agent. An empty agent set
67
+ // (no agents provisioned yet) is itself a hard reject — otherwise we
68
+ // would silently accept upserts against a daemon that has nowhere to
69
+ // route their inbound messages.
70
+ const agentIds = new Set(resolveConfiguredAgentIds(cfg) ?? []);
71
+ if (!agentIds.has(params.accountId)) {
72
+ return {
73
+ ok: false,
74
+ error: {
75
+ code: "unknown_account",
76
+ message: `accountId "${params.accountId}" is not bound to this daemon`,
77
+ },
78
+ };
79
+ }
80
+ // Provider-specific secret resolution.
81
+ let botToken;
82
+ if (params.type === "telegram") {
83
+ botToken = params.secret?.botToken;
84
+ if (!botToken) {
85
+ // Allow updates that only flip enabled/whitelist — only require a
86
+ // token when none is on disk yet.
87
+ const existing = loadGatewaySecret(params.id);
88
+ if (!existing?.botToken) {
89
+ return badParams("upsert_gateway: telegram requires secret.botToken on first install");
90
+ }
91
+ botToken = existing.botToken;
92
+ }
93
+ }
94
+ else if (params.type === "wechat") {
95
+ const loginId = params.loginId;
96
+ if (!loginId) {
97
+ return badParams("upsert_gateway: wechat requires loginId");
98
+ }
99
+ const session = sessions.get(loginId);
100
+ if (!session) {
101
+ return {
102
+ ok: false,
103
+ error: { code: "login_expired", message: `wechat login session "${loginId}" not found or expired` },
104
+ };
105
+ }
106
+ if (session.provider !== "wechat") {
107
+ return badParams(`upsert_gateway: login session provider "${session.provider}" != "wechat"`);
108
+ }
109
+ if (session.accountId !== params.accountId) {
110
+ return {
111
+ ok: false,
112
+ error: {
113
+ code: "login_account_mismatch",
114
+ message: "wechat login session accountId does not match upsert request",
115
+ },
116
+ };
117
+ }
118
+ if (!session.botToken) {
119
+ return {
120
+ ok: false,
121
+ error: { code: "login_unconfirmed", message: "wechat login session has no bot token yet" },
122
+ };
123
+ }
124
+ botToken = session.botToken;
125
+ // Bind the session to its eventual gateway id for forensic logging.
126
+ sessions.update(loginId, { gatewayId: params.id });
127
+ }
128
+ else {
129
+ return badParams(`upsert_gateway: unknown provider "${params.type}"`);
130
+ }
131
+ // W3/W6: remember whether a profile already exists for this id BEFORE we
132
+ // write the secret/config. For UPDATE path, capture previous profile +
133
+ // previous secret so addChannel failure can restore prior state.
134
+ const existingProfiles = cfg.thirdPartyGateways ?? [];
135
+ const hadExistingProfile = existingProfiles.some((g) => g.id === params.id);
136
+ const prevProfile = existingProfiles.find((g) => g.id === params.id);
137
+ // W6: load the previous secret for UPDATE rollback BEFORE overwriting.
138
+ const prevSecret = hadExistingProfile
139
+ ? loadGatewaySecret(params.id)
140
+ : null;
141
+ // Persist secret first (so a config write that succeeds is never
142
+ // followed by a missing-secret crash). Atomic rename inside saveSecret.
143
+ const secretFile = saveGatewaySecret(params.id, { botToken });
144
+ // Update or insert the third-party gateway profile in config.
145
+ const enabled = params.enabled !== false;
146
+ const next = upsertProfileInConfig(cfg, {
147
+ id: params.id,
148
+ type: params.type,
149
+ accountId: params.accountId,
150
+ label: params.label,
151
+ enabled,
152
+ baseUrl: params.settings?.baseUrl,
153
+ allowedSenderIds: params.settings?.allowedSenderIds,
154
+ allowedChatIds: params.settings?.allowedChatIds,
155
+ splitAt: params.settings?.splitAt,
156
+ });
157
+ cfgIO.save(next);
158
+ // Hot-plug. removeChannel is a no-op when the id isn't registered, so
159
+ // calling it unconditionally lets us swap secrets/settings in place.
160
+ if (enabled) {
161
+ try {
162
+ await ctx.gateway.removeChannel(params.id, "upsert_gateway");
163
+ }
164
+ catch {
165
+ // best-effort
166
+ }
167
+ try {
168
+ await ctx.gateway.addChannel(buildChannelConfig(params, secretFile));
169
+ }
170
+ catch (addErr) {
171
+ const message = addErr instanceof Error ? addErr.message : String(addErr);
172
+ daemonLog.warn("upsert_gateway.addChannel failed", { id: params.id, error: message });
173
+ if (!hadExistingProfile) {
174
+ // W3: fresh install — delete the orphan secret so nothing references it.
175
+ try {
176
+ deleteGatewaySecret(params.id);
177
+ }
178
+ catch {
179
+ // best-effort
180
+ }
181
+ }
182
+ else {
183
+ // W6: UPDATE path — restore previous secret + profile + try to re-add
184
+ // the channel with the old config.
185
+ try {
186
+ if (prevSecret)
187
+ saveGatewaySecret(params.id, prevSecret);
188
+ }
189
+ catch {
190
+ // best-effort
191
+ }
192
+ try {
193
+ if (prevProfile) {
194
+ cfgIO.save(upsertProfileInConfig(cfgIO.load(), prevProfile));
195
+ }
196
+ }
197
+ catch {
198
+ // best-effort
199
+ }
200
+ try {
201
+ if (prevProfile && prevSecret?.botToken) {
202
+ await ctx.gateway.addChannel(buildChannelConfig({
203
+ ...params,
204
+ type: prevProfile.type,
205
+ enabled: prevProfile.enabled !== false,
206
+ secret: { botToken: prevSecret.botToken },
207
+ settings: {
208
+ baseUrl: prevProfile.baseUrl,
209
+ allowedSenderIds: prevProfile.allowedSenderIds,
210
+ allowedChatIds: prevProfile.allowedChatIds,
211
+ splitAt: prevProfile.splitAt,
212
+ },
213
+ }, secretFile));
214
+ }
215
+ }
216
+ catch {
217
+ // Restore also failed — surface structured error.
218
+ return {
219
+ ok: false,
220
+ error: {
221
+ code: "addChannel_failed",
222
+ message: "channel down, manual recovery needed",
223
+ },
224
+ };
225
+ }
226
+ }
227
+ return {
228
+ ok: false,
229
+ error: { code: "addChannel_failed", message },
230
+ };
231
+ }
232
+ }
233
+ else {
234
+ // enabled=false: stop the channel if it was running but keep the secret.
235
+ try {
236
+ await ctx.gateway.removeChannel(params.id, "upsert_gateway disabled");
237
+ }
238
+ catch {
239
+ // best-effort
240
+ }
241
+ }
242
+ const liveStatus = ctx.gateway.snapshot().channels[params.id];
243
+ const result = {
244
+ id: params.id,
245
+ type: params.type,
246
+ accountId: params.accountId,
247
+ enabled,
248
+ tokenPreview: maskTokenPreview(botToken),
249
+ ...(liveStatus ? { status: pickStatus(liveStatus) } : {}),
250
+ };
251
+ daemonLog.info("upsert_gateway applied", {
252
+ id: params.id,
253
+ type: params.type,
254
+ enabled,
255
+ });
256
+ return { ok: true, result };
257
+ }
258
+ // --- remove_gateway -----------------------------------------------------
259
+ async function handleRemove(params) {
260
+ if (!params.id || typeof params.id !== "string") {
261
+ return badParams("remove_gateway: id is required");
262
+ }
263
+ // W6: stop the channel BEFORE deleting the secret. An orphaned secret on
264
+ // disk is recoverable; a running poll loop holding a live token after the
265
+ // operator clicked "remove" is not. Re-throw on stop failure so the Hub
266
+ // surfaces the error and the operator can retry.
267
+ try {
268
+ await ctx.gateway.removeChannel(params.id, "remove_gateway");
269
+ }
270
+ catch (err) {
271
+ const message = err instanceof Error ? err.message : String(err);
272
+ daemonLog.warn("remove_gateway.removeChannel failed — keeping secret", {
273
+ id: params.id,
274
+ error: message,
275
+ });
276
+ return {
277
+ ok: false,
278
+ error: { code: "removeChannel_failed", message },
279
+ };
280
+ }
281
+ const cfg = cfgIO.load();
282
+ const before = cfg.thirdPartyGateways ?? [];
283
+ const after = before.filter((g) => g.id !== params.id);
284
+ if (after.length !== before.length) {
285
+ cfgIO.save({ ...cfg, thirdPartyGateways: after });
286
+ }
287
+ let secretDeleted = false;
288
+ if (params.deleteSecret !== false) {
289
+ try {
290
+ deleteGatewaySecret(params.id);
291
+ secretDeleted = true;
292
+ }
293
+ catch (err) {
294
+ daemonLog.warn("remove_gateway.deleteSecret failed", {
295
+ id: params.id,
296
+ error: err instanceof Error ? err.message : String(err),
297
+ });
298
+ }
299
+ }
300
+ const result = {
301
+ id: params.id,
302
+ removed: after.length !== before.length,
303
+ secretDeleted,
304
+ };
305
+ daemonLog.info("remove_gateway applied", { id: params.id, secretDeleted });
306
+ return { ok: true, result };
307
+ }
308
+ // --- test_gateway -------------------------------------------------------
309
+ async function handleTest(params) {
310
+ if (!params.id || typeof params.id !== "string") {
311
+ return badParams("test_gateway: id is required");
312
+ }
313
+ const cfg = cfgIO.load();
314
+ const profile = (cfg.thirdPartyGateways ?? []).find((g) => g.id === params.id);
315
+ if (!profile) {
316
+ return {
317
+ ok: false,
318
+ error: { code: "unknown_gateway", message: `no gateway with id "${params.id}"` },
319
+ };
320
+ }
321
+ if (profile.type === "telegram") {
322
+ const secret = loadGatewaySecret(profile.id, profile.secretFile);
323
+ const token = secret?.botToken;
324
+ if (!token) {
325
+ const result = { id: profile.id, ok: false, error: "missing bot token" };
326
+ return { ok: true, result };
327
+ }
328
+ const baseUrl = (profile.baseUrl ?? "https://api.telegram.org").replace(/\/+$/, "");
329
+ try {
330
+ const res = await fetchImpl(`${baseUrl}/bot${token}/getMe`, { method: "GET" });
331
+ const body = JSON.parse(await res.text());
332
+ if (!body.ok) {
333
+ const result = {
334
+ id: profile.id,
335
+ ok: false,
336
+ error: body.description ?? `telegram getMe returned ok=false`,
337
+ };
338
+ return { ok: true, result };
339
+ }
340
+ const result = { id: profile.id, ok: true, info: body.result ?? {} };
341
+ return { ok: true, result };
342
+ }
343
+ catch (err) {
344
+ const raw = err instanceof Error ? err.message : String(err);
345
+ const redacted = raw.split(token).join("***");
346
+ const result = {
347
+ id: profile.id,
348
+ ok: false,
349
+ error: redacted,
350
+ };
351
+ return { ok: true, result };
352
+ }
353
+ }
354
+ // WeChat: iLink has no no-side-effect probe today. Fall back to the
355
+ // adapter's last poll snapshot. `authorized === true` means the secret
356
+ // is loaded and at least one poll succeeded.
357
+ const snap = ctx.gateway.snapshot().channels[profile.id];
358
+ const result = snap
359
+ ? {
360
+ id: profile.id,
361
+ ok: snap.running === true && snap.authorized !== false && !snap.lastError,
362
+ info: {
363
+ lastPollAt: snap.lastPollAt ?? null,
364
+ lastInboundAt: snap.lastInboundAt ?? null,
365
+ authorized: snap.authorized ?? null,
366
+ },
367
+ ...(snap.lastError ? { error: snap.lastError } : {}),
368
+ }
369
+ : { id: profile.id, ok: false, error: "wechat channel not running" };
370
+ return { ok: true, result };
371
+ }
372
+ // --- gateway_login_start ------------------------------------------------
373
+ async function handleLoginStart(params) {
374
+ if (!isProvider(params.provider)) {
375
+ return badParams(`gateway_login_start: unknown provider "${String(params.provider)}"`);
376
+ }
377
+ if (!params.accountId || typeof params.accountId !== "string") {
378
+ return badParams("gateway_login_start: accountId is required");
379
+ }
380
+ if (params.provider !== "wechat") {
381
+ // Telegram has no qrcode flow; surface a clear error so the dashboard
382
+ // can fall through to the token form.
383
+ return badParams(`gateway_login_start: provider "${params.provider}" does not require login`);
384
+ }
385
+ // W1: SSRF guard — `baseUrl` flows directly into an authenticated fetch.
386
+ try {
387
+ assertSafeBaseUrl(params.baseUrl);
388
+ }
389
+ catch (urlErr) {
390
+ if (urlErr instanceof UnsafeBaseUrlError)
391
+ return badParams(urlErr.message);
392
+ throw urlErr;
393
+ }
394
+ const baseUrl = params.baseUrl ?? DEFAULT_WECHAT_BASE_URL;
395
+ let qrcode;
396
+ let qrcodeUrl;
397
+ try {
398
+ const r = await wechatLogin.getBotQrcode({ baseUrl });
399
+ qrcode = r.qrcode;
400
+ qrcodeUrl = r.qrcodeUrl;
401
+ }
402
+ catch (err) {
403
+ const message = err instanceof Error ? err.message : String(err);
404
+ daemonLog.warn("gateway_login_start.getBotQrcode failed", { error: message });
405
+ return {
406
+ ok: false,
407
+ error: { code: "provider_unreachable", message },
408
+ };
409
+ }
410
+ const loginId = mintLoginId("wechat");
411
+ const session = sessions.create({
412
+ loginId,
413
+ accountId: params.accountId,
414
+ ...(params.gatewayId ? { gatewayId: params.gatewayId } : {}),
415
+ provider: "wechat",
416
+ qrcode,
417
+ ...(qrcodeUrl ? { qrcodeUrl } : {}),
418
+ baseUrl,
419
+ });
420
+ const result = {
421
+ loginId,
422
+ qrcode,
423
+ ...(qrcodeUrl ? { qrcodeUrl } : {}),
424
+ expiresAt: session.expiresAt,
425
+ };
426
+ daemonLog.info("gateway_login_start", { provider: "wechat", loginId, accountId: params.accountId });
427
+ return { ok: true, result };
428
+ }
429
+ // --- gateway_login_status -----------------------------------------------
430
+ async function handleLoginStatus(params) {
431
+ if (!isProvider(params.provider)) {
432
+ return badParams(`gateway_login_status: unknown provider "${String(params.provider)}"`);
433
+ }
434
+ if (!params.loginId) {
435
+ return badParams("gateway_login_status: loginId is required");
436
+ }
437
+ if (!params.accountId || typeof params.accountId !== "string") {
438
+ return badParams("gateway_login_status: accountId is required");
439
+ }
440
+ const session = sessions.get(params.loginId);
441
+ if (!session) {
442
+ const result = { status: "expired" };
443
+ return { ok: true, result };
444
+ }
445
+ if (session.provider !== params.provider) {
446
+ return badParams("gateway_login_status: provider does not match login session");
447
+ }
448
+ // W4: accountId ownership check — prevent one user from polling another's login session.
449
+ if (session.accountId !== params.accountId) {
450
+ return {
451
+ ok: false,
452
+ error: {
453
+ code: "forbidden",
454
+ message: "gateway_login_status: accountId does not match login session",
455
+ },
456
+ };
457
+ }
458
+ // If we already saw `confirmed`, return cached result so re-polling
459
+ // doesn't keep hitting iLink.
460
+ if (session.botToken) {
461
+ const result = {
462
+ status: "confirmed",
463
+ ...(session.baseUrl ? { baseUrl: session.baseUrl } : {}),
464
+ tokenPreview: session.tokenPreview ?? maskTokenPreview(session.botToken),
465
+ };
466
+ return { ok: true, result };
467
+ }
468
+ if (params.provider !== "wechat") {
469
+ // Future provider hook — today only WeChat poll path exists.
470
+ return badParams(`gateway_login_status: provider "${params.provider}" not supported`);
471
+ }
472
+ if (!session.qrcode) {
473
+ return {
474
+ ok: false,
475
+ error: { code: "no_qrcode", message: "login session has no qrcode to poll" },
476
+ };
477
+ }
478
+ let probe;
479
+ try {
480
+ probe = await wechatLogin.getQrcodeStatus(session.qrcode, { baseUrl: session.baseUrl });
481
+ }
482
+ catch (err) {
483
+ const message = err instanceof Error ? err.message : String(err);
484
+ daemonLog.warn("gateway_login_status.getQrcodeStatus failed", { error: message });
485
+ return {
486
+ ok: false,
487
+ error: { code: "provider_unreachable", message },
488
+ };
489
+ }
490
+ const status = mapWechatStatus(probe.status);
491
+ if (status === "confirmed" && probe.botToken) {
492
+ const baseUrl = probe.baseUrl ?? session.baseUrl;
493
+ const tokenPreview = maskTokenPreview(probe.botToken);
494
+ sessions.update(params.loginId, {
495
+ botToken: probe.botToken,
496
+ ...(baseUrl ? { baseUrl } : {}),
497
+ tokenPreview,
498
+ });
499
+ const result = {
500
+ status: "confirmed",
501
+ ...(baseUrl ? { baseUrl } : {}),
502
+ tokenPreview,
503
+ };
504
+ return { ok: true, result };
505
+ }
506
+ const result = { status };
507
+ return { ok: true, result };
508
+ }
509
+ return {
510
+ handleList,
511
+ handleUpsert,
512
+ handleRemove,
513
+ handleTest,
514
+ handleLoginStart,
515
+ handleLoginStatus,
516
+ /** Exposed for tests — direct access to the in-memory session map. */
517
+ _sessions: sessions,
518
+ };
519
+ }
520
+ // ---------------------------------------------------------------------------
521
+ // Helpers
522
+ // ---------------------------------------------------------------------------
523
+ function badParams(message) {
524
+ return { ok: false, error: { code: "bad_params", message } };
525
+ }
526
+ function isProvider(p) {
527
+ return p === "telegram" || p === "wechat";
528
+ }
529
+ function validateUpsertParams(p) {
530
+ if (!p.id || typeof p.id !== "string")
531
+ return "upsert_gateway: id is required";
532
+ if (!isProvider(p.type))
533
+ return `upsert_gateway: unknown provider "${String(p.type)}"`;
534
+ if (!p.accountId || typeof p.accountId !== "string")
535
+ return "upsert_gateway: accountId is required";
536
+ return null;
537
+ }
538
+ function annotateProfile(p, status) {
539
+ return {
540
+ id: p.id,
541
+ type: p.type,
542
+ accountId: p.accountId,
543
+ ...(p.label !== undefined ? { label: p.label } : {}),
544
+ enabled: p.enabled !== false,
545
+ ...(p.baseUrl !== undefined ? { baseUrl: p.baseUrl } : {}),
546
+ ...(p.allowedSenderIds !== undefined ? { allowedSenderIds: p.allowedSenderIds } : {}),
547
+ ...(p.allowedChatIds !== undefined ? { allowedChatIds: p.allowedChatIds } : {}),
548
+ ...(p.splitAt !== undefined ? { splitAt: p.splitAt } : {}),
549
+ ...(status ? { status: pickStatus(status) } : {}),
550
+ };
551
+ }
552
+ function pickStatus(s) {
553
+ return {
554
+ running: s.running === true,
555
+ ...(typeof s.connected === "boolean" ? { connected: s.connected } : {}),
556
+ ...(typeof s.authorized === "boolean" ? { authorized: s.authorized } : {}),
557
+ ...(typeof s.lastPollAt === "number" ? { lastPollAt: s.lastPollAt } : {}),
558
+ ...(typeof s.lastInboundAt === "number" ? { lastInboundAt: s.lastInboundAt } : {}),
559
+ ...(typeof s.lastSendAt === "number" ? { lastSendAt: s.lastSendAt } : {}),
560
+ ...(s.lastError !== undefined ? { lastError: s.lastError } : {}),
561
+ };
562
+ }
563
+ function upsertProfileInConfig(cfg, patch) {
564
+ const list = (cfg.thirdPartyGateways ?? []).slice();
565
+ const idx = list.findIndex((g) => g.id === patch.id);
566
+ // Drop undefined fields so a partial patch doesn't blow away existing
567
+ // values (e.g. label not changing on an enabled-only flip).
568
+ const compact = compactProfile(patch);
569
+ if (idx >= 0) {
570
+ list[idx] = { ...list[idx], ...compact };
571
+ }
572
+ else {
573
+ list.push(compact);
574
+ }
575
+ return { ...cfg, thirdPartyGateways: list };
576
+ }
577
+ function compactProfile(p) {
578
+ const out = {
579
+ id: p.id,
580
+ type: p.type,
581
+ accountId: p.accountId,
582
+ };
583
+ if (p.label !== undefined)
584
+ out.label = p.label;
585
+ if (p.enabled !== undefined)
586
+ out.enabled = p.enabled;
587
+ if (p.baseUrl !== undefined)
588
+ out.baseUrl = p.baseUrl;
589
+ if (p.allowedSenderIds !== undefined)
590
+ out.allowedSenderIds = p.allowedSenderIds;
591
+ if (p.allowedChatIds !== undefined)
592
+ out.allowedChatIds = p.allowedChatIds;
593
+ if (p.splitAt !== undefined)
594
+ out.splitAt = p.splitAt;
595
+ if (p.secretFile !== undefined)
596
+ out.secretFile = p.secretFile;
597
+ if (p.stateFile !== undefined)
598
+ out.stateFile = p.stateFile;
599
+ return out;
600
+ }
601
+ function buildChannelConfig(params, secretFile) {
602
+ const ch = {
603
+ id: params.id,
604
+ type: params.type,
605
+ accountId: params.accountId,
606
+ secretFile,
607
+ };
608
+ if (params.label !== undefined)
609
+ ch.label = params.label;
610
+ const s = params.settings ?? {};
611
+ if (s.baseUrl !== undefined)
612
+ ch.baseUrl = s.baseUrl;
613
+ if (s.allowedSenderIds !== undefined)
614
+ ch.allowedSenderIds = s.allowedSenderIds;
615
+ if (s.allowedChatIds !== undefined)
616
+ ch.allowedChatIds = s.allowedChatIds;
617
+ if (s.splitAt !== undefined)
618
+ ch.splitAt = s.splitAt;
619
+ return ch;
620
+ }
621
+ /**
622
+ * Translate iLink's qrcode status string into our public enum. iLink uses
623
+ * lowercase variants (`pending`, `scaned`/`scanned`, `confirmed`, `expired`),
624
+ * and we collapse anything unrecognized to `failed` so callers can surface a
625
+ * concrete state.
626
+ */
627
+ function mapWechatStatus(raw) {
628
+ const v = raw.toLowerCase();
629
+ if (v === "confirmed" || v === "ok" || v === "success")
630
+ return "confirmed";
631
+ if (v === "scanned" || v === "scaned")
632
+ return "scanned";
633
+ if (v === "pending" || v === "waiting")
634
+ return "pending";
635
+ if (v === "expired" || v === "timeout")
636
+ return "expired";
637
+ return "failed";
638
+ }
@@ -2,6 +2,7 @@ import { BotCordClient, type AgentIdentitySnapshot, type ControlAck, type Contro
2
2
  import type { Gateway } from "./gateway/index.js";
3
3
  import type { PolicyResolverLike } from "./gateway/policy-resolver.js";
4
4
  import { type DaemonConfig } from "./config.js";
5
+ import type { LoginSessionStore } from "./gateway/channels/login-session.js";
5
6
  /**
6
7
  * Information passed to {@link OnAgentInstalledHook} after a successful
7
8
  * provision. Mirrors the credential fields the daemon's per-agent caches
@@ -50,6 +51,12 @@ export interface ProvisionerOptions {
50
51
  * the next restart.
51
52
  */
52
53
  onAgentInstalled?: OnAgentInstalledHook;
54
+ /**
55
+ * Optional shared login-session store for the third-party gateway login
56
+ * frames. Tests inject a stub clock; production lets `createGatewayControl`
57
+ * spin up a default in-memory store.
58
+ */
59
+ loginSessions?: LoginSessionStore;
53
60
  }
54
61
  /** The value a frame handler returns (minus the `id` which the channel fills in). */
55
62
  type AckBody = Omit<ControlAck, "id">;