@botcord/daemon 0.2.36 → 0.2.38

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
package/dist/config.d.ts CHANGED
@@ -84,6 +84,29 @@ export interface OpenclawDiscoveryConfig {
84
84
  /** Defaults to false. When false, discovery only persists gateways. */
85
85
  autoProvision?: boolean;
86
86
  }
87
+ /** Third-party messaging provider supported by the daemon's channel factory. */
88
+ export type ThirdPartyGatewayType = "telegram" | "wechat";
89
+ /**
90
+ * One third-party gateway profile bound to a BotCord agent. `id` is the
91
+ * channel id (typically `gw_...` minted by the Hub); `accountId` is the
92
+ * BotCord agent the inbound traffic should be attributed to. Secrets and
93
+ * provider cursors live outside this struct — see `secretFile` and
94
+ * `stateFile`. When omitted, the daemon derives them as
95
+ * `~/.botcord/daemon/gateways/{id}.json` and `{id}.state.json`.
96
+ */
97
+ export interface ThirdPartyGatewayProfile {
98
+ id: string;
99
+ type: ThirdPartyGatewayType;
100
+ accountId: string;
101
+ label?: string;
102
+ enabled?: boolean;
103
+ secretFile?: string;
104
+ stateFile?: string;
105
+ allowedSenderIds?: string[];
106
+ allowedChatIds?: string[];
107
+ splitAt?: number;
108
+ baseUrl?: string;
109
+ }
87
110
  export interface DaemonConfig {
88
111
  /**
89
112
  * @deprecated Kept for backward compatibility with pre-multi-agent configs.
@@ -128,6 +151,12 @@ export interface DaemonConfig {
128
151
  * search paths/ports and automatic adoption of discovered agents.
129
152
  */
130
153
  openclawDiscovery?: OpenclawDiscoveryConfig;
154
+ /**
155
+ * Third-party messaging gateways (Telegram, WeChat, …) bound to BotCord
156
+ * agents on this daemon. Each entry becomes one channel in the gateway
157
+ * runtime; `enabled === false` entries are filtered out at boot.
158
+ */
159
+ thirdPartyGateways?: ThirdPartyGatewayProfile[];
131
160
  }
132
161
  /**
133
162
  * Persistent transcript settings (design §6). Default-off — `botcord-daemon
package/dist/config.js CHANGED
@@ -202,6 +202,33 @@ export function loadConfig() {
202
202
  }
203
203
  out.openclawDiscovery = copy;
204
204
  }
205
+ const tpg = parsed.thirdPartyGateways;
206
+ if (tpg !== undefined) {
207
+ if (!Array.isArray(tpg)) {
208
+ throw new Error(`daemon config "thirdPartyGateways" must be an array (${CONFIG_PATH})`);
209
+ }
210
+ const seen = new Set();
211
+ for (const [i, g] of tpg.entries()) {
212
+ if (!g || typeof g !== "object") {
213
+ throw new Error(`daemon config thirdPartyGateways[${i}] is not an object (${CONFIG_PATH})`);
214
+ }
215
+ const gg = g;
216
+ if (typeof gg.id !== "string" || gg.id.length === 0) {
217
+ throw new Error(`daemon config thirdPartyGateways[${i}].id must be a non-empty string (${CONFIG_PATH})`);
218
+ }
219
+ if (gg.type !== "telegram" && gg.type !== "wechat") {
220
+ throw new Error(`daemon config thirdPartyGateways[${i}].type must be "telegram" or "wechat" (${CONFIG_PATH})`);
221
+ }
222
+ if (typeof gg.accountId !== "string" || gg.accountId.length === 0) {
223
+ throw new Error(`daemon config thirdPartyGateways[${i}].accountId must be a non-empty string (${CONFIG_PATH})`);
224
+ }
225
+ if (seen.has(gg.id)) {
226
+ throw new Error(`daemon config thirdPartyGateways[${i}].id "${gg.id}" duplicated (${CONFIG_PATH})`);
227
+ }
228
+ seen.add(gg.id);
229
+ }
230
+ out.thirdPartyGateways = tpg.map((g) => ({ ...g }));
231
+ }
205
232
  return out;
206
233
  }
207
234
  function validateAdapter(id, field) {
@@ -52,6 +52,9 @@ export interface ToGatewayConfigOptions {
52
52
  export declare const DEFAULT_BOTCORD_CHANNEL_ID = "botcord-main";
53
53
  /** Channel `type` tag used by `createBotCordChannel`. */
54
54
  export declare const BOTCORD_CHANNEL_TYPE = "botcord";
55
+ /** Channel `type` tags for built-in third-party providers. */
56
+ export declare const TELEGRAM_CHANNEL_TYPE = "telegram";
57
+ export declare const WECHAT_CHANNEL_TYPE = "wechat";
55
58
  /**
56
59
  * Convert the daemon's on-disk config into a gateway runtime config. Only
57
60
  * used in-process at daemon boot; the daemon config file itself is the
@@ -74,6 +74,9 @@ function resolveGateway(profiles, gatewayName, agentOverride, where) {
74
74
  export const DEFAULT_BOTCORD_CHANNEL_ID = "botcord-main";
75
75
  /** Channel `type` tag used by `createBotCordChannel`. */
76
76
  export const BOTCORD_CHANNEL_TYPE = "botcord";
77
+ /** Channel `type` tags for built-in third-party providers. */
78
+ export const TELEGRAM_CHANNEL_TYPE = "telegram";
79
+ export const WECHAT_CHANNEL_TYPE = "wechat";
77
80
  /**
78
81
  * Map daemon's historical narrower TrustLevel ("owner" | "untrusted") onto
79
82
  * gateway's ("owner" | "trusted" | "public"). Matches the adapter-level
@@ -165,6 +168,33 @@ export function toGatewayConfig(cfg, opts = {}) {
165
168
  accountId: agentId,
166
169
  agentId,
167
170
  }));
171
+ // Append one channel per enabled third-party gateway. Disabled entries are
172
+ // dropped here so the gateway runtime never sees them; re-enabling requires
173
+ // an `upsert_gateway` (Phase B) or a config reload.
174
+ for (const g of cfg.thirdPartyGateways ?? []) {
175
+ if (g.enabled === false)
176
+ continue;
177
+ const ch = {
178
+ id: g.id,
179
+ type: g.type,
180
+ accountId: g.accountId,
181
+ };
182
+ if (g.label !== undefined)
183
+ ch.label = g.label;
184
+ if (g.secretFile !== undefined)
185
+ ch.secretFile = g.secretFile;
186
+ if (g.stateFile !== undefined)
187
+ ch.stateFile = g.stateFile;
188
+ if (g.allowedSenderIds !== undefined)
189
+ ch.allowedSenderIds = g.allowedSenderIds;
190
+ if (g.allowedChatIds !== undefined)
191
+ ch.allowedChatIds = g.allowedChatIds;
192
+ if (g.splitAt !== undefined)
193
+ ch.splitAt = g.splitAt;
194
+ if (g.baseUrl !== undefined)
195
+ ch.baseUrl = g.baseUrl;
196
+ channels.push(ch);
197
+ }
168
198
  // DaemonConfig's typed surface doesn't carry `trustLevel`, but we read it
169
199
  // defensively so future config extensions can propagate without a shape bump.
170
200
  const profiles = prepareGatewayProfiles(cfg.openclawGateways);
package/dist/daemon.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type GatewayInboundMessage, type GatewayLogger, type GatewayRuntimeSnapshot } from "./gateway/index.js";
1
+ import { type ChannelAdapter, type GatewayChannelConfig, type GatewayInboundMessage, type GatewayLogger, type GatewayRuntimeSnapshot } from "./gateway/index.js";
2
2
  import type { DaemonConfig } from "./config.js";
3
3
  import { type BootAgentsResult } from "./agent-discovery.js";
4
4
  import { ensureAgentWorkspace } from "./agent-workspace.js";
@@ -31,6 +31,20 @@ export declare function createActivityRecorder(opts: {
31
31
  activityTracker: ActivityRecorderTarget;
32
32
  fallbackAgentId?: string;
33
33
  }): (msg: GatewayInboundMessage) => void;
34
+ /** Per-call dependencies for {@link createDaemonChannel}. */
35
+ export interface CreateDaemonChannelDeps {
36
+ credentialPathByAgentId: Map<string, string>;
37
+ defaultCredentialsPath?: string;
38
+ hubBaseUrl?: string;
39
+ }
40
+ /**
41
+ * Dispatch a `GatewayChannelConfig` to the right adapter constructor based on
42
+ * `chCfg.type`. Phase A wires up the BotCord adapter and stub constructors
43
+ * for telegram/wechat (which throw "not implemented"); Phase B will fill the
44
+ * latter in. Unknown types throw so misconfigured channels fail loudly at
45
+ * boot rather than silently dropping inbound traffic.
46
+ */
47
+ export declare function createDaemonChannel(chCfg: GatewayChannelConfig, deps: CreateDaemonChannelDeps): ChannelAdapter;
34
48
  /**
35
49
  * Minimal send-capable surface used by {@link pushRuntimeSnapshot}.
36
50
  * Exists so the helper is trivially mockable from unit tests without needing
package/dist/daemon.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CONTROL_FRAME_TYPES, shouldWake, } from "@botcord/protocol-core";
2
- import { Gateway, createBotCordChannel, resolveTranscriptEnabled, sanitizeUntrustedContent, } from "./gateway/index.js";
2
+ import { Gateway, createBotCordChannel, createTelegramChannel, createWechatChannel, resolveTranscriptEnabled, sanitizeUntrustedContent, } from "./gateway/index.js";
3
3
  import { ActivityTracker } from "./activity-tracker.js";
4
4
  import { SESSIONS_PATH, SNAPSHOT_PATH } from "./config.js";
5
5
  import { resolveBootAgents } from "./agent-discovery.js";
@@ -70,6 +70,56 @@ export function createActivityRecorder(opts) {
70
70
  });
71
71
  };
72
72
  }
73
+ /**
74
+ * Dispatch a `GatewayChannelConfig` to the right adapter constructor based on
75
+ * `chCfg.type`. Phase A wires up the BotCord adapter and stub constructors
76
+ * for telegram/wechat (which throw "not implemented"); Phase B will fill the
77
+ * latter in. Unknown types throw so misconfigured channels fail loudly at
78
+ * boot rather than silently dropping inbound traffic.
79
+ */
80
+ export function createDaemonChannel(chCfg, deps) {
81
+ switch (chCfg.type) {
82
+ case "botcord": {
83
+ const agentId = typeof chCfg.agentId === "string" ? chCfg.agentId : chCfg.accountId;
84
+ return createBotCordChannel({
85
+ id: chCfg.id,
86
+ accountId: chCfg.accountId,
87
+ agentId,
88
+ credentialsPath: deps.credentialPathByAgentId.get(agentId) ?? deps.defaultCredentialsPath,
89
+ hubBaseUrl: deps.hubBaseUrl,
90
+ });
91
+ }
92
+ case "telegram":
93
+ return createTelegramChannel({
94
+ id: chCfg.id,
95
+ accountId: chCfg.accountId,
96
+ ...(typeof chCfg.baseUrl === "string" ? { baseUrl: chCfg.baseUrl } : {}),
97
+ ...(Array.isArray(chCfg.allowedSenderIds)
98
+ ? { allowedSenderIds: chCfg.allowedSenderIds }
99
+ : {}),
100
+ ...(Array.isArray(chCfg.allowedChatIds)
101
+ ? { allowedChatIds: chCfg.allowedChatIds }
102
+ : {}),
103
+ ...(typeof chCfg.splitAt === "number" ? { splitAt: chCfg.splitAt } : {}),
104
+ ...(typeof chCfg.secretFile === "string" ? { secretFile: chCfg.secretFile } : {}),
105
+ ...(typeof chCfg.stateFile === "string" ? { stateFile: chCfg.stateFile } : {}),
106
+ });
107
+ case "wechat":
108
+ return createWechatChannel({
109
+ id: chCfg.id,
110
+ accountId: chCfg.accountId,
111
+ ...(typeof chCfg.baseUrl === "string" ? { baseUrl: chCfg.baseUrl } : {}),
112
+ ...(Array.isArray(chCfg.allowedSenderIds)
113
+ ? { allowedSenderIds: chCfg.allowedSenderIds }
114
+ : {}),
115
+ ...(typeof chCfg.splitAt === "number" ? { splitAt: chCfg.splitAt } : {}),
116
+ ...(typeof chCfg.secretFile === "string" ? { secretFile: chCfg.secretFile } : {}),
117
+ ...(typeof chCfg.stateFile === "string" ? { stateFile: chCfg.stateFile } : {}),
118
+ });
119
+ default:
120
+ throw new Error(`unknown channel type "${chCfg.type}"`);
121
+ }
122
+ }
73
123
  /**
74
124
  * Emit one `runtime_snapshot` event frame on the control channel. Plan §8.5
75
125
  * P0: first-connect push only — reconnect-push and diffing are P1. A send
@@ -282,16 +332,11 @@ export async function startDaemon(opts) {
282
332
  const gateway = new Gateway({
283
333
  config: gwConfig,
284
334
  sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
285
- createChannel: (chCfg) => {
286
- const agentId = typeof chCfg.agentId === "string" ? chCfg.agentId : chCfg.accountId;
287
- return createBotCordChannel({
288
- id: chCfg.id,
289
- accountId: chCfg.accountId,
290
- agentId,
291
- credentialsPath: credentialPathByAgentId.get(agentId) ?? opts.credentialsPath,
292
- hubBaseUrl: opts.hubBaseUrl,
293
- });
294
- },
335
+ createChannel: (chCfg) => createDaemonChannel(chCfg, {
336
+ credentialPathByAgentId,
337
+ defaultCredentialsPath: opts.credentialsPath,
338
+ hubBaseUrl: opts.hubBaseUrl,
339
+ }),
295
340
  log: logger,
296
341
  turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
297
342
  buildSystemContext,
@@ -823,6 +823,7 @@ function normalizeBlockForHub(block, seq) {
823
823
  payload.session_id = raw.session_id;
824
824
  if (typeof raw?.model === "string")
825
825
  payload.model = raw.model;
826
+ payload.details = formatBlockDetails(raw);
826
827
  return { kind: "system", seq, payload };
827
828
  }
828
829
  if (kind === "thinking") {
@@ -836,6 +837,7 @@ function normalizeBlockForHub(block, seq) {
836
837
  payload.label = raw.label;
837
838
  if (typeof raw?.source === "string")
838
839
  payload.source = raw.source;
840
+ payload.details = formatBlockDetails(raw);
839
841
  return { kind: "thinking", seq, payload };
840
842
  }
841
843
  // "other" — e.g. Claude Code `type:"result"` end-of-turn summary.
@@ -849,3 +851,45 @@ function normalizeBlockForHub(block, seq) {
849
851
  }
850
852
  return { kind: "other", seq, payload };
851
853
  }
854
+ function formatBlockDetails(raw) {
855
+ if (!raw || typeof raw !== "object")
856
+ return "";
857
+ const r = raw;
858
+ const direct = typeof r.text === "string" ? r.text
859
+ : typeof r.message === "string" ? r.message
860
+ : typeof r.summary === "string" ? r.summary
861
+ : typeof r.label === "string" ? r.label
862
+ : "";
863
+ if (direct)
864
+ return direct;
865
+ const contentText = extractContentText(r.content ?? r.message?.content ?? r.params?.update?.content);
866
+ if (contentText)
867
+ return contentText;
868
+ try {
869
+ return JSON.stringify(raw, null, 2);
870
+ }
871
+ catch {
872
+ return String(raw);
873
+ }
874
+ }
875
+ function extractContentText(content) {
876
+ if (!content)
877
+ return "";
878
+ if (typeof content === "string")
879
+ return content;
880
+ if (Array.isArray(content)) {
881
+ return content.map(extractContentText).filter(Boolean).join("\n");
882
+ }
883
+ if (typeof content === "object") {
884
+ const c = content;
885
+ if (typeof c.text === "string")
886
+ return c.text;
887
+ if (typeof c.thinking === "string")
888
+ return c.thinking;
889
+ if (typeof c.content === "string")
890
+ return c.content;
891
+ if (Array.isArray(c.content))
892
+ return extractContentText(c.content);
893
+ }
894
+ return "";
895
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Canonical `fetch`-compatible signature shared by gateway-control.ts and
3
+ * the WeChat HTTP helpers. Lets tests inject a stub without depending on
4
+ * undici's full type surface.
5
+ *
6
+ * Kept structurally compatible with both `globalThis.fetch` and the
7
+ * narrower wechat-http test stubs — `body` is optional so callers that
8
+ * only issue GETs (e.g. Telegram `getMe` test probe) can omit it.
9
+ */
10
+ export type FetchLike = (input: string, init?: {
11
+ method?: string;
12
+ headers?: Record<string, string>;
13
+ body?: string;
14
+ signal?: AbortSignal;
15
+ }) => Promise<{
16
+ status?: number;
17
+ ok?: boolean;
18
+ text(): Promise<string>;
19
+ }>;
@@ -0,0 +1 @@
1
+ export {};
@@ -1,2 +1,7 @@
1
1
  export { createBotCordChannel } from "./botcord.js";
2
2
  export type { BotCordChannelClient, BotCordChannelOptions, BotCordClientFactory, } from "./botcord.js";
3
+ export { createTelegramChannel, type TelegramChannelOptions } from "./telegram.js";
4
+ export { createWechatChannel, type WechatChannelOptions } from "./wechat.js";
5
+ export { getBotQrcode, getQrcodeStatus, DEFAULT_WECHAT_BASE_URL, type WechatQrcode, type WechatQrcodeStatus, type WechatLoginOptions, } from "./wechat-login.js";
6
+ export { defaultGatewaySecretPath, loadGatewaySecret, saveGatewaySecret, deleteGatewaySecret, } from "./secret-store.js";
7
+ export { GatewayStateStore, defaultGatewayStatePath, type GatewayStateStoreOptions, type ThirdPartyGatewayState, } from "./state-store.js";
@@ -1 +1,6 @@
1
1
  export { createBotCordChannel } from "./botcord.js";
2
+ export { createTelegramChannel } from "./telegram.js";
3
+ export { createWechatChannel } from "./wechat.js";
4
+ export { getBotQrcode, getQrcodeStatus, DEFAULT_WECHAT_BASE_URL, } from "./wechat-login.js";
5
+ export { defaultGatewaySecretPath, loadGatewaySecret, saveGatewaySecret, deleteGatewaySecret, } from "./secret-store.js";
6
+ export { GatewayStateStore, defaultGatewayStatePath, } from "./state-store.js";
@@ -0,0 +1,83 @@
1
+ /**
2
+ * In-memory login-session store used by the daemon's third-party gateway
3
+ * control frames. Today only WeChat consumes it (qrcode → bot token), but
4
+ * the shape is provider-generic so future LINE/Discord OAuth callbacks can
5
+ * reuse the same store without a control-frame churn.
6
+ *
7
+ * The store is intentionally NOT persisted — bot tokens never live anywhere
8
+ * outside the daemon process or the per-gateway secret file. A daemon
9
+ * restart drops in-flight logins; the user just rescans.
10
+ */
11
+ export type LoginProvider = "wechat" | "telegram";
12
+ export interface LoginSession {
13
+ loginId: string;
14
+ accountId: string;
15
+ gatewayId?: string;
16
+ provider: LoginProvider;
17
+ /** WeChat: opaque qrcode string returned by `get_bot_qrcode`. */
18
+ qrcode?: string;
19
+ /** Optional renderable URL for the qrcode. */
20
+ qrcodeUrl?: string;
21
+ /** WeChat iLink base URL the bot token will be used against. */
22
+ baseUrl?: string;
23
+ /** Stored only after the user confirms the qrcode. Never returned to Hub. */
24
+ botToken?: string;
25
+ /** Masked preview safe for Hub/dashboard display. */
26
+ tokenPreview?: string;
27
+ /** Unix millis. */
28
+ expiresAt: number;
29
+ }
30
+ /** Default session TTL: 5 minutes per the design doc. */
31
+ export declare const LOGIN_SESSION_TTL_MS: number;
32
+ export interface LoginSessionStoreOptions {
33
+ /** Override the wall clock — used by tests. */
34
+ now?: () => number;
35
+ /** Override the TTL applied at `create` time. */
36
+ ttlMs?: number;
37
+ }
38
+ /**
39
+ * Lazy-evicting login session map. Eviction runs inline on every read/write
40
+ * so no background timer is required and tests can scrub state by advancing
41
+ * a fake clock.
42
+ */
43
+ export declare class LoginSessionStore {
44
+ private readonly sessions;
45
+ private readonly now;
46
+ private readonly ttlMs;
47
+ constructor(opts?: LoginSessionStoreOptions);
48
+ /**
49
+ * Insert a fresh session. `expiresAt` is computed as `now() + ttlMs`
50
+ * unless the caller pre-populated it. Returns the persisted record.
51
+ */
52
+ create(input: Omit<LoginSession, "expiresAt"> & {
53
+ expiresAt?: number;
54
+ }): LoginSession;
55
+ /** Get a non-expired session by id, or `null` when missing/expired. */
56
+ get(loginId: string): LoginSession | null;
57
+ /**
58
+ * Apply a partial patch to the session in place. No-op when the session
59
+ * is missing or expired. Returns the updated record (or `null`).
60
+ */
61
+ update(loginId: string, patch: Partial<LoginSession>): LoginSession | null;
62
+ delete(loginId: string): boolean;
63
+ /** Drop every entry whose `expiresAt` is in the past. */
64
+ sweep(): void;
65
+ /** Test helper: number of live sessions after sweep. */
66
+ size(): number;
67
+ }
68
+ /**
69
+ * Build a masked preview suitable for dashboard display. Returns the raw
70
+ * value untouched when shorter than 8 chars (no point masking) or `""` when
71
+ * empty. Default format: `"abcd...wxyz"` with a single ellipsis, never
72
+ * leaking the middle of the secret.
73
+ */
74
+ export declare function maskTokenPreview(token: string | undefined | null): string;
75
+ /**
76
+ * Allocate a new login id. Format `wxl_<base36ts>_<rand>` so it sorts by
77
+ * creation time and is trivially distinguishable from BotCord agent ids.
78
+ *
79
+ * W8: the random tail uses `crypto.randomBytes` (cryptographically secure)
80
+ * instead of `Math.random()` so an attacker cannot predict in-flight login
81
+ * ids and racefully claim someone else's session.
82
+ */
83
+ export declare function mintLoginId(provider: LoginProvider): string;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * In-memory login-session store used by the daemon's third-party gateway
3
+ * control frames. Today only WeChat consumes it (qrcode → bot token), but
4
+ * the shape is provider-generic so future LINE/Discord OAuth callbacks can
5
+ * reuse the same store without a control-frame churn.
6
+ *
7
+ * The store is intentionally NOT persisted — bot tokens never live anywhere
8
+ * outside the daemon process or the per-gateway secret file. A daemon
9
+ * restart drops in-flight logins; the user just rescans.
10
+ */
11
+ import { randomBytes } from "node:crypto";
12
+ /** Default session TTL: 5 minutes per the design doc. */
13
+ export const LOGIN_SESSION_TTL_MS = 5 * 60 * 1000;
14
+ /**
15
+ * Lazy-evicting login session map. Eviction runs inline on every read/write
16
+ * so no background timer is required and tests can scrub state by advancing
17
+ * a fake clock.
18
+ */
19
+ export class LoginSessionStore {
20
+ sessions = new Map();
21
+ now;
22
+ ttlMs;
23
+ constructor(opts = {}) {
24
+ this.now = opts.now ?? (() => Date.now());
25
+ this.ttlMs = opts.ttlMs ?? LOGIN_SESSION_TTL_MS;
26
+ }
27
+ /**
28
+ * Insert a fresh session. `expiresAt` is computed as `now() + ttlMs`
29
+ * unless the caller pre-populated it. Returns the persisted record.
30
+ */
31
+ create(input) {
32
+ this.sweep();
33
+ const expiresAt = typeof input.expiresAt === "number" ? input.expiresAt : this.now() + this.ttlMs;
34
+ const session = { ...input, expiresAt };
35
+ this.sessions.set(session.loginId, session);
36
+ return session;
37
+ }
38
+ /** Get a non-expired session by id, or `null` when missing/expired. */
39
+ get(loginId) {
40
+ this.sweep();
41
+ return this.sessions.get(loginId) ?? null;
42
+ }
43
+ /**
44
+ * Apply a partial patch to the session in place. No-op when the session
45
+ * is missing or expired. Returns the updated record (or `null`).
46
+ */
47
+ update(loginId, patch) {
48
+ const cur = this.get(loginId);
49
+ if (!cur)
50
+ return null;
51
+ const next = { ...cur, ...patch };
52
+ this.sessions.set(loginId, next);
53
+ return next;
54
+ }
55
+ delete(loginId) {
56
+ return this.sessions.delete(loginId);
57
+ }
58
+ /** Drop every entry whose `expiresAt` is in the past. */
59
+ sweep() {
60
+ const t = this.now();
61
+ for (const [id, s] of this.sessions) {
62
+ if (s.expiresAt <= t)
63
+ this.sessions.delete(id);
64
+ }
65
+ }
66
+ /** Test helper: number of live sessions after sweep. */
67
+ size() {
68
+ this.sweep();
69
+ return this.sessions.size;
70
+ }
71
+ }
72
+ /**
73
+ * Build a masked preview suitable for dashboard display. Returns the raw
74
+ * value untouched when shorter than 8 chars (no point masking) or `""` when
75
+ * empty. Default format: `"abcd...wxyz"` with a single ellipsis, never
76
+ * leaking the middle of the secret.
77
+ */
78
+ export function maskTokenPreview(token) {
79
+ if (!token)
80
+ return "";
81
+ if (token.length <= 8)
82
+ return token;
83
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
84
+ }
85
+ /**
86
+ * Allocate a new login id. Format `wxl_<base36ts>_<rand>` so it sorts by
87
+ * creation time and is trivially distinguishable from BotCord agent ids.
88
+ *
89
+ * W8: the random tail uses `crypto.randomBytes` (cryptographically secure)
90
+ * instead of `Math.random()` so an attacker cannot predict in-flight login
91
+ * ids and racefully claim someone else's session.
92
+ */
93
+ export function mintLoginId(provider) {
94
+ const prefix = provider === "wechat" ? "wxl" : "tgl";
95
+ const ts = Date.now().toString(36);
96
+ // 32 hex chars = 128 bits of entropy — W5 regression fix from round 2.
97
+ const rand = randomBytes(16).toString("hex");
98
+ return `${prefix}_${ts}_${rand}`;
99
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Resolve the on-disk secret-file path for a third-party gateway. Honors an
3
+ * explicit override when provided; otherwise falls back to
4
+ * `~/.botcord/daemon/gateways/{id}.json` (mode 0600 inside a 0700 dir).
5
+ */
6
+ export declare function defaultGatewaySecretPath(gatewayId: string, override?: string): string;
7
+ /**
8
+ * Load a previously-written secret blob. Returns `null` when the file is
9
+ * absent — callers treat that as "not yet authorized" rather than an error.
10
+ */
11
+ export declare function loadGatewaySecret<T = Record<string, unknown>>(gatewayId: string, override?: string): T | null;
12
+ /**
13
+ * Persist a secret blob with mode `0600`, ensuring the parent directory
14
+ * exists with mode `0700`. Writes go through a `.tmp` rename for atomicity.
15
+ *
16
+ * The parent directory mode is re-applied on every write so a permission
17
+ * drift (e.g. operator chmod) is corrected the next time the daemon writes.
18
+ */
19
+ export declare function saveGatewaySecret(gatewayId: string, secret: Record<string, unknown>, override?: string): string;
20
+ /** Remove a previously-saved secret. No-op when the file is missing. */
21
+ export declare function deleteGatewaySecret(gatewayId: string, override?: string): void;
@@ -0,0 +1,75 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ // W3: logger for corrupt-file warnings. Using console so no circular dep on log.ts.
5
+ const _warn = (msg) => console.warn(`[secret-store] ${msg}`);
6
+ const DEFAULT_GATEWAYS_DIR = path.join(homedir(), ".botcord", "daemon", "gateways");
7
+ /**
8
+ * Resolve the on-disk secret-file path for a third-party gateway. Honors an
9
+ * explicit override when provided; otherwise falls back to
10
+ * `~/.botcord/daemon/gateways/{id}.json` (mode 0600 inside a 0700 dir).
11
+ */
12
+ export function defaultGatewaySecretPath(gatewayId, override) {
13
+ if (override && override.length > 0)
14
+ return override;
15
+ return path.join(DEFAULT_GATEWAYS_DIR, `${gatewayId}.json`);
16
+ }
17
+ /**
18
+ * Load a previously-written secret blob. Returns `null` when the file is
19
+ * absent — callers treat that as "not yet authorized" rather than an error.
20
+ */
21
+ export function loadGatewaySecret(gatewayId, override) {
22
+ const file = defaultGatewaySecretPath(gatewayId, override);
23
+ if (!existsSync(file))
24
+ return null;
25
+ const raw = readFileSync(file, "utf8");
26
+ // W3: guard against corrupt files — JSON.parse throws on malformed input.
27
+ try {
28
+ return JSON.parse(raw);
29
+ }
30
+ catch {
31
+ _warn(`corrupt secret file at ${file} — ignoring`);
32
+ return null;
33
+ }
34
+ }
35
+ /**
36
+ * Persist a secret blob with mode `0600`, ensuring the parent directory
37
+ * exists with mode `0700`. Writes go through a `.tmp` rename for atomicity.
38
+ *
39
+ * The parent directory mode is re-applied on every write so a permission
40
+ * drift (e.g. operator chmod) is corrected the next time the daemon writes.
41
+ */
42
+ export function saveGatewaySecret(gatewayId, secret, override) {
43
+ const file = defaultGatewaySecretPath(gatewayId, override);
44
+ const dir = path.dirname(file);
45
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
46
+ try {
47
+ chmodSync(dir, 0o700);
48
+ }
49
+ catch {
50
+ // best-effort
51
+ }
52
+ const tmp = `${file}.tmp`;
53
+ writeFileSync(tmp, JSON.stringify(secret, null, 2), { mode: 0o600 });
54
+ try {
55
+ chmodSync(tmp, 0o600);
56
+ }
57
+ catch {
58
+ // best-effort
59
+ }
60
+ renameSync(tmp, file);
61
+ try {
62
+ chmodSync(file, 0o600);
63
+ }
64
+ catch {
65
+ // best-effort
66
+ }
67
+ return file;
68
+ }
69
+ /** Remove a previously-saved secret. No-op when the file is missing. */
70
+ export function deleteGatewaySecret(gatewayId, override) {
71
+ const file = defaultGatewaySecretPath(gatewayId, override);
72
+ if (!existsSync(file))
73
+ return;
74
+ unlinkSync(file);
75
+ }