@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
@@ -0,0 +1,135 @@
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
+
12
+ import { randomBytes } from "node:crypto";
13
+
14
+ export type LoginProvider = "wechat" | "telegram";
15
+
16
+ export interface LoginSession {
17
+ loginId: string;
18
+ accountId: string;
19
+ gatewayId?: string;
20
+ provider: LoginProvider;
21
+ /** WeChat: opaque qrcode string returned by `get_bot_qrcode`. */
22
+ qrcode?: string;
23
+ /** Optional renderable URL for the qrcode. */
24
+ qrcodeUrl?: string;
25
+ /** WeChat iLink base URL the bot token will be used against. */
26
+ baseUrl?: string;
27
+ /** Stored only after the user confirms the qrcode. Never returned to Hub. */
28
+ botToken?: string;
29
+ /** Masked preview safe for Hub/dashboard display. */
30
+ tokenPreview?: string;
31
+ /** Unix millis. */
32
+ expiresAt: number;
33
+ }
34
+
35
+ /** Default session TTL: 5 minutes per the design doc. */
36
+ export const LOGIN_SESSION_TTL_MS = 5 * 60 * 1000;
37
+
38
+ export interface LoginSessionStoreOptions {
39
+ /** Override the wall clock — used by tests. */
40
+ now?: () => number;
41
+ /** Override the TTL applied at `create` time. */
42
+ ttlMs?: number;
43
+ }
44
+
45
+ /**
46
+ * Lazy-evicting login session map. Eviction runs inline on every read/write
47
+ * so no background timer is required and tests can scrub state by advancing
48
+ * a fake clock.
49
+ */
50
+ export class LoginSessionStore {
51
+ private readonly sessions = new Map<string, LoginSession>();
52
+ private readonly now: () => number;
53
+ private readonly ttlMs: number;
54
+
55
+ constructor(opts: LoginSessionStoreOptions = {}) {
56
+ this.now = opts.now ?? (() => Date.now());
57
+ this.ttlMs = opts.ttlMs ?? LOGIN_SESSION_TTL_MS;
58
+ }
59
+
60
+ /**
61
+ * Insert a fresh session. `expiresAt` is computed as `now() + ttlMs`
62
+ * unless the caller pre-populated it. Returns the persisted record.
63
+ */
64
+ create(input: Omit<LoginSession, "expiresAt"> & { expiresAt?: number }): LoginSession {
65
+ this.sweep();
66
+ const expiresAt = typeof input.expiresAt === "number" ? input.expiresAt : this.now() + this.ttlMs;
67
+ const session: LoginSession = { ...input, expiresAt };
68
+ this.sessions.set(session.loginId, session);
69
+ return session;
70
+ }
71
+
72
+ /** Get a non-expired session by id, or `null` when missing/expired. */
73
+ get(loginId: string): LoginSession | null {
74
+ this.sweep();
75
+ return this.sessions.get(loginId) ?? null;
76
+ }
77
+
78
+ /**
79
+ * Apply a partial patch to the session in place. No-op when the session
80
+ * is missing or expired. Returns the updated record (or `null`).
81
+ */
82
+ update(loginId: string, patch: Partial<LoginSession>): LoginSession | null {
83
+ const cur = this.get(loginId);
84
+ if (!cur) return null;
85
+ const next = { ...cur, ...patch };
86
+ this.sessions.set(loginId, next);
87
+ return next;
88
+ }
89
+
90
+ delete(loginId: string): boolean {
91
+ return this.sessions.delete(loginId);
92
+ }
93
+
94
+ /** Drop every entry whose `expiresAt` is in the past. */
95
+ sweep(): void {
96
+ const t = this.now();
97
+ for (const [id, s] of this.sessions) {
98
+ if (s.expiresAt <= t) this.sessions.delete(id);
99
+ }
100
+ }
101
+
102
+ /** Test helper: number of live sessions after sweep. */
103
+ size(): number {
104
+ this.sweep();
105
+ return this.sessions.size;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Build a masked preview suitable for dashboard display. Returns the raw
111
+ * value untouched when shorter than 8 chars (no point masking) or `""` when
112
+ * empty. Default format: `"abcd...wxyz"` with a single ellipsis, never
113
+ * leaking the middle of the secret.
114
+ */
115
+ export function maskTokenPreview(token: string | undefined | null): string {
116
+ if (!token) return "";
117
+ if (token.length <= 8) return token;
118
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
119
+ }
120
+
121
+ /**
122
+ * Allocate a new login id. Format `wxl_<base36ts>_<rand>` so it sorts by
123
+ * creation time and is trivially distinguishable from BotCord agent ids.
124
+ *
125
+ * W8: the random tail uses `crypto.randomBytes` (cryptographically secure)
126
+ * instead of `Math.random()` so an attacker cannot predict in-flight login
127
+ * ids and racefully claim someone else's session.
128
+ */
129
+ export function mintLoginId(provider: LoginProvider): string {
130
+ const prefix = provider === "wechat" ? "wxl" : "tgl";
131
+ const ts = Date.now().toString(36);
132
+ // 32 hex chars = 128 bits of entropy — W5 regression fix from round 2.
133
+ const rand = randomBytes(16).toString("hex");
134
+ return `${prefix}_${ts}_${rand}`;
135
+ }
@@ -0,0 +1,100 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import path from "node:path";
12
+
13
+ // W3: logger for corrupt-file warnings. Using console so no circular dep on log.ts.
14
+ const _warn = (msg: string) => console.warn(`[secret-store] ${msg}`);
15
+
16
+ const DEFAULT_GATEWAYS_DIR = path.join(
17
+ homedir(),
18
+ ".botcord",
19
+ "daemon",
20
+ "gateways",
21
+ );
22
+
23
+ /**
24
+ * Resolve the on-disk secret-file path for a third-party gateway. Honors an
25
+ * explicit override when provided; otherwise falls back to
26
+ * `~/.botcord/daemon/gateways/{id}.json` (mode 0600 inside a 0700 dir).
27
+ */
28
+ export function defaultGatewaySecretPath(
29
+ gatewayId: string,
30
+ override?: string,
31
+ ): string {
32
+ if (override && override.length > 0) return override;
33
+ return path.join(DEFAULT_GATEWAYS_DIR, `${gatewayId}.json`);
34
+ }
35
+
36
+ /**
37
+ * Load a previously-written secret blob. Returns `null` when the file is
38
+ * absent — callers treat that as "not yet authorized" rather than an error.
39
+ */
40
+ export function loadGatewaySecret<T = Record<string, unknown>>(
41
+ gatewayId: string,
42
+ override?: string,
43
+ ): T | null {
44
+ const file = defaultGatewaySecretPath(gatewayId, override);
45
+ if (!existsSync(file)) return null;
46
+ const raw = readFileSync(file, "utf8");
47
+ // W3: guard against corrupt files — JSON.parse throws on malformed input.
48
+ try {
49
+ return JSON.parse(raw) as T;
50
+ } catch {
51
+ _warn(`corrupt secret file at ${file} — ignoring`);
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Persist a secret blob with mode `0600`, ensuring the parent directory
58
+ * exists with mode `0700`. Writes go through a `.tmp` rename for atomicity.
59
+ *
60
+ * The parent directory mode is re-applied on every write so a permission
61
+ * drift (e.g. operator chmod) is corrected the next time the daemon writes.
62
+ */
63
+ export function saveGatewaySecret(
64
+ gatewayId: string,
65
+ secret: Record<string, unknown>,
66
+ override?: string,
67
+ ): string {
68
+ const file = defaultGatewaySecretPath(gatewayId, override);
69
+ const dir = path.dirname(file);
70
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
71
+ try {
72
+ chmodSync(dir, 0o700);
73
+ } catch {
74
+ // best-effort
75
+ }
76
+ const tmp = `${file}.tmp`;
77
+ writeFileSync(tmp, JSON.stringify(secret, null, 2), { mode: 0o600 });
78
+ try {
79
+ chmodSync(tmp, 0o600);
80
+ } catch {
81
+ // best-effort
82
+ }
83
+ renameSync(tmp, file);
84
+ try {
85
+ chmodSync(file, 0o600);
86
+ } catch {
87
+ // best-effort
88
+ }
89
+ return file;
90
+ }
91
+
92
+ /** Remove a previously-saved secret. No-op when the file is missing. */
93
+ export function deleteGatewaySecret(
94
+ gatewayId: string,
95
+ override?: string,
96
+ ): void {
97
+ const file = defaultGatewaySecretPath(gatewayId, override);
98
+ if (!existsSync(file)) return;
99
+ unlinkSync(file);
100
+ }
@@ -0,0 +1,213 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import path from "node:path";
10
+
11
+ const DEFAULT_GATEWAYS_DIR = path.join(
12
+ homedir(),
13
+ ".botcord",
14
+ "daemon",
15
+ "gateways",
16
+ );
17
+
18
+ const DEFAULT_DEBOUNCE_MS = 1000;
19
+
20
+ /**
21
+ * On-disk cursor + provider-state for one third-party gateway. Kept separate
22
+ * from `secret-store` so high-frequency cursor advancements don't churn the
23
+ * secret file (and so cursor doesn't end up in any secret backup).
24
+ */
25
+ export interface ThirdPartyGatewayState {
26
+ cursor?: string;
27
+ providerState?: Record<string, unknown>;
28
+ updatedAt: string;
29
+ }
30
+
31
+ export function defaultGatewayStatePath(
32
+ gatewayId: string,
33
+ override?: string,
34
+ ): string {
35
+ if (override && override.length > 0) return override;
36
+ return path.join(DEFAULT_GATEWAYS_DIR, `${gatewayId}.state.json`);
37
+ }
38
+
39
+ function readState(file: string): ThirdPartyGatewayState {
40
+ if (!existsSync(file)) return { updatedAt: new Date(0).toISOString() };
41
+ try {
42
+ return JSON.parse(readFileSync(file, "utf8")) as ThirdPartyGatewayState;
43
+ } catch {
44
+ return { updatedAt: new Date(0).toISOString() };
45
+ }
46
+ }
47
+
48
+ function writeStateSync(file: string, state: ThirdPartyGatewayState): void {
49
+ const dir = path.dirname(file);
50
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
51
+ const tmp = `${file}.tmp`;
52
+ writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
53
+ renameSync(tmp, file);
54
+ }
55
+
56
+ export interface GatewayStateStoreOptions {
57
+ /** Override the on-disk path; defaults to `~/.botcord/daemon/gateways/{id}.state.json`. */
58
+ override?: string;
59
+ /** Debounce window for batching writes; defaults to 1000ms. Use `0` for sync writes (tests). */
60
+ debounceMs?: number;
61
+ }
62
+
63
+ /**
64
+ * Per-gateway state store with debounced writes. Reads are always synchronous
65
+ * against the in-memory snapshot, so the polling loop sees its own writes
66
+ * even before they are flushed to disk. `flush()` is exposed for shutdown
67
+ * paths and tests; `close()` flushes and clears the timer.
68
+ */
69
+ const MAX_FLUSH_RETRIES = 10;
70
+
71
+ export class GatewayStateStore {
72
+ private readonly file: string;
73
+ private readonly debounceMs: number;
74
+ private state: ThirdPartyGatewayState;
75
+ private timer: NodeJS.Timeout | null = null;
76
+ private dirty = false;
77
+ private flushRetryCount = 0;
78
+ /** W9: most recent write error surfaced for diagnostics. Cleared on success. */
79
+ lastError: Error | null = null;
80
+
81
+ constructor(gatewayId: string, opts: GatewayStateStoreOptions = {}) {
82
+ this.file = defaultGatewayStatePath(gatewayId, opts.override);
83
+ this.debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
84
+ this.state = readState(this.file);
85
+ }
86
+
87
+ /** Read the current cursor (in-memory; reflects pending writes). */
88
+ getCursor(): string | undefined {
89
+ return this.state.cursor;
90
+ }
91
+
92
+ /** Read the current provider-state bag. Mutating the returned object does not persist. */
93
+ getProviderState(): Record<string, unknown> | undefined {
94
+ return this.state.providerState;
95
+ }
96
+
97
+ getSnapshot(): ThirdPartyGatewayState {
98
+ return { ...this.state };
99
+ }
100
+
101
+ /** Update cursor + optional provider state and schedule a debounced flush. */
102
+ update(patch: {
103
+ cursor?: string;
104
+ providerState?: Record<string, unknown>;
105
+ }): void {
106
+ if (patch.cursor !== undefined) this.state.cursor = patch.cursor;
107
+ if (patch.providerState !== undefined) {
108
+ this.state.providerState = patch.providerState;
109
+ }
110
+ this.state.updatedAt = new Date().toISOString();
111
+ this.scheduleFlush();
112
+ }
113
+
114
+ /**
115
+ * Force a synchronous write of the pending state, if any.
116
+ *
117
+ * W9: on write failure, leave `dirty=true` and re-arm the debounce timer
118
+ * so a subsequent `update()` (or background timer fire) retries instead of
119
+ * silently dropping the pending state. The error is also re-thrown so
120
+ * callers that explicitly invoke `flush()` (shutdown paths, tests) see it.
121
+ */
122
+ flush(): void {
123
+ if (this.timer) {
124
+ clearTimeout(this.timer);
125
+ this.timer = null;
126
+ }
127
+ if (!this.dirty) return;
128
+ try {
129
+ writeStateSync(this.file, this.state);
130
+ this.dirty = false;
131
+ this.lastError = null;
132
+ this.flushRetryCount = 0;
133
+ } catch (err) {
134
+ this.lastError = err instanceof Error ? err : new Error(String(err));
135
+ // Keep dirty=true so the next update() re-arms a flush. We also
136
+ // schedule a retry now in case the caller has nothing else queued.
137
+ this.scheduleFlushRetry();
138
+ throw this.lastError;
139
+ }
140
+ }
141
+
142
+ /** Flush and stop accepting future debounced writes. */
143
+ close(): void {
144
+ this.flush();
145
+ }
146
+
147
+ /** On-disk path; exposed for tests and diagnostics. */
148
+ get filePath(): string {
149
+ return this.file;
150
+ }
151
+
152
+ private scheduleFlush(): void {
153
+ this.dirty = true;
154
+ if (this.debounceMs <= 0) {
155
+ // W9: synchronous mode — re-throw on failure so the caller sees the
156
+ // problem instead of having the data silently disappear.
157
+ this.flush();
158
+ return;
159
+ }
160
+ if (this.timer) return;
161
+ this.timer = setTimeout(() => {
162
+ this.timer = null;
163
+ if (!this.dirty) return;
164
+ try {
165
+ writeStateSync(this.file, this.state);
166
+ this.dirty = false;
167
+ this.lastError = null;
168
+ this.flushRetryCount = 0;
169
+ } catch (err) {
170
+ // W9: keep dirty=true and re-arm so the failed write retries.
171
+ this.lastError = err instanceof Error ? err : new Error(String(err));
172
+ this.scheduleFlushRetry();
173
+ }
174
+ }, this.debounceMs);
175
+ if (typeof this.timer.unref === "function") this.timer.unref();
176
+ }
177
+
178
+ /**
179
+ * Re-arm the debounce timer after a write failure. Bounded delay (capped
180
+ * at 5s) so transient failures do not become a hot-spin retry loop.
181
+ * After MAX_FLUSH_RETRIES consecutive failures, log and stop retrying so
182
+ * a permanently broken write path cannot loop indefinitely. `lastError` is
183
+ * left set so callers can detect persistent failure.
184
+ */
185
+ private scheduleFlushRetry(): void {
186
+ if (this.debounceMs <= 0) return; // sync mode: caller decides
187
+ if (this.timer) return;
188
+ this.flushRetryCount += 1;
189
+ if (this.flushRetryCount > MAX_FLUSH_RETRIES) {
190
+ // Persistent failure — give up. lastError remains set for diagnostics.
191
+ console.error(
192
+ `[state-store] flush failed ${MAX_FLUSH_RETRIES} times; giving up on ${this.file}`,
193
+ this.lastError,
194
+ );
195
+ return;
196
+ }
197
+ const retryMs = Math.min(Math.max(this.debounceMs, 250), 5000);
198
+ this.timer = setTimeout(() => {
199
+ this.timer = null;
200
+ if (!this.dirty) return;
201
+ try {
202
+ writeStateSync(this.file, this.state);
203
+ this.dirty = false;
204
+ this.lastError = null;
205
+ this.flushRetryCount = 0;
206
+ } catch (err) {
207
+ this.lastError = err instanceof Error ? err : new Error(String(err));
208
+ this.scheduleFlushRetry();
209
+ }
210
+ }, retryMs);
211
+ if (typeof this.timer.unref === "function") this.timer.unref();
212
+ }
213
+ }