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