@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,469 @@
1
+ import type {
2
+ ChannelAdapter,
3
+ ChannelSendContext,
4
+ ChannelSendResult,
5
+ ChannelStartContext,
6
+ ChannelStatusSnapshot,
7
+ ChannelStopContext,
8
+ ChannelTypingContext,
9
+ GatewayInboundEnvelope,
10
+ GatewayInboundMessage,
11
+ } from "../types.js";
12
+ import { sanitizeUntrustedContent } from "./sanitize.js";
13
+ import { GatewayStateStore } from "./state-store.js";
14
+ import { loadGatewaySecret } from "./secret-store.js";
15
+ import { splitText } from "./text-split.js";
16
+
17
+ const DEFAULT_BASE_URL = "https://api.telegram.org";
18
+ const DEFAULT_SPLIT_AT = 4000; // Telegram hard limit is 4096; leave slack.
19
+ const POLL_TIMEOUT_S = 25;
20
+ const POLL_BACKOFF_MS = 3000;
21
+ const TRANSIENT_BACKOFF_MS = 1000;
22
+ const TELEGRAM_PROVIDER = "telegram" as const;
23
+
24
+ /** Options accepted by {@link createTelegramChannel}. */
25
+ export interface TelegramChannelOptions {
26
+ id: string;
27
+ accountId: string;
28
+ /** Bot token. When omitted, the adapter loads it from the secret-store on start. */
29
+ botToken?: string;
30
+ baseUrl?: string;
31
+ /** Empty / missing list = default-deny. */
32
+ allowedSenderIds?: string[];
33
+ /** Empty / missing list = default-deny. */
34
+ allowedChatIds?: string[];
35
+ splitAt?: number;
36
+ secretFile?: string;
37
+ stateFile?: string;
38
+ /** Test hook: override `globalThis.fetch`. */
39
+ fetchImpl?: typeof fetch;
40
+ /** Test hook: synchronous state writes (`debounceMs: 0`). */
41
+ stateDebounceMs?: number;
42
+ }
43
+
44
+ interface TelegramSecret {
45
+ botToken?: string;
46
+ [key: string]: unknown;
47
+ }
48
+
49
+ interface TelegramUser {
50
+ id: number;
51
+ is_bot?: boolean;
52
+ username?: string;
53
+ first_name?: string;
54
+ }
55
+
56
+ interface TelegramChat {
57
+ id: number;
58
+ type: "private" | "group" | "supergroup" | "channel";
59
+ title?: string;
60
+ }
61
+
62
+ interface TelegramMessage {
63
+ message_id: number;
64
+ from?: TelegramUser;
65
+ chat: TelegramChat;
66
+ text?: string;
67
+ date?: number;
68
+ }
69
+
70
+ interface TelegramUpdate {
71
+ update_id: number;
72
+ message?: TelegramMessage;
73
+ }
74
+
75
+ interface TelegramApiResult<T> {
76
+ ok: boolean;
77
+ result?: T;
78
+ description?: string;
79
+ }
80
+
81
+ /**
82
+ * Telegram channel adapter — long-polls `getUpdates`, normalizes text messages
83
+ * to `GatewayInboundEnvelope`, and writes replies via `sendMessage`. Cursor
84
+ * (`update_id + 1`) is persisted to the state-store so a daemon restart never
85
+ * replays the last batch.
86
+ *
87
+ * Allowlists are default-deny: an empty (or missing) `allowedChatIds` /
88
+ * `allowedSenderIds` rejects every inbound message. This matches the security
89
+ * default in the third-party-gateway design doc.
90
+ */
91
+ export function createTelegramChannel(opts: TelegramChannelOptions): ChannelAdapter {
92
+ const channelType = TELEGRAM_PROVIDER;
93
+ const baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
94
+ const splitAt = opts.splitAt && opts.splitAt > 0 ? opts.splitAt : DEFAULT_SPLIT_AT;
95
+ const allowedSenderIds = new Set((opts.allowedSenderIds ?? []).map((s) => String(s)));
96
+ const allowedChatIds = new Set((opts.allowedChatIds ?? []).map((s) => String(s)));
97
+ const fetchImpl: typeof fetch = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
98
+
99
+ let botToken: string | undefined = opts.botToken;
100
+ let started = false;
101
+
102
+ /**
103
+ * C3: Redact the bot token from any log/error string. Telegram's Bot API
104
+ * embeds the token in the URL path, so fetch errors and JSON.parse failures
105
+ * routinely include it. Replace before any log.* call.
106
+ */
107
+ function redactToken(input: string): string {
108
+ if (!botToken || !input) return input;
109
+ return input.split(botToken).join("***");
110
+ }
111
+ let stateStore: GatewayStateStore | null = null;
112
+ let stopCallback: (() => void) | null = null;
113
+ // W11: captured during start() so send() can push lastSendAt to the
114
+ // gateway-tracked snapshot, not just the local statusSnapshot.
115
+ let liveSetStatus:
116
+ | ((patch: Partial<ChannelStatusSnapshot>) => void)
117
+ | null = null;
118
+
119
+ let statusSnapshot: ChannelStatusSnapshot = {
120
+ channel: opts.id,
121
+ accountId: opts.accountId,
122
+ running: false,
123
+ connected: false,
124
+ reconnectAttempts: 0,
125
+ lastError: null,
126
+ provider: TELEGRAM_PROVIDER,
127
+ authorized: false,
128
+ };
129
+
130
+ function ensureState(): GatewayStateStore {
131
+ if (!stateStore) {
132
+ stateStore = new GatewayStateStore(opts.id, {
133
+ ...(opts.stateFile ? { override: opts.stateFile } : {}),
134
+ ...(opts.stateDebounceMs !== undefined
135
+ ? { debounceMs: opts.stateDebounceMs }
136
+ : {}),
137
+ });
138
+ }
139
+ return stateStore;
140
+ }
141
+
142
+ function loadTokenFromSecretIfNeeded(): string | undefined {
143
+ if (botToken) return botToken;
144
+ const secret = loadGatewaySecret<TelegramSecret>(opts.id, opts.secretFile);
145
+ if (secret && typeof secret.botToken === "string" && secret.botToken.length > 0) {
146
+ botToken = secret.botToken;
147
+ }
148
+ return botToken;
149
+ }
150
+
151
+ async function callApi<T>(
152
+ method: string,
153
+ params: Record<string, unknown>,
154
+ timeoutMs: number,
155
+ ): Promise<TelegramApiResult<T>> {
156
+ if (!botToken) throw new Error("telegram bot token not loaded");
157
+ const url = `${baseUrl}/bot${botToken}/${method}`;
158
+ let resp: Response;
159
+ try {
160
+ resp = await fetchImpl(url, {
161
+ method: "POST",
162
+ headers: { "Content-Type": "application/json" },
163
+ body: JSON.stringify(params),
164
+ signal: AbortSignal.timeout(timeoutMs),
165
+ });
166
+ } catch (err) {
167
+ // C3: fetch errors often stringify the URL (which embeds the token).
168
+ // Re-raise with the token replaced.
169
+ const e = err as Error;
170
+ const redacted = redactToken(e.message ?? String(err));
171
+ const next = new Error(redacted);
172
+ next.name = e.name ?? "Error";
173
+ throw next;
174
+ }
175
+ const json = (await resp.json()) as TelegramApiResult<T>;
176
+ return json;
177
+ }
178
+
179
+ function chatIdFromConversation(conversationId: string): string | null {
180
+ if (conversationId.startsWith("telegram:user:")) {
181
+ return conversationId.slice("telegram:user:".length);
182
+ }
183
+ if (conversationId.startsWith("telegram:group:")) {
184
+ return conversationId.slice("telegram:group:".length);
185
+ }
186
+ return null;
187
+ }
188
+
189
+ function normalizeUpdate(update: TelegramUpdate): GatewayInboundMessage | null {
190
+ const msg = update.message;
191
+ if (!msg) return null;
192
+ const text = typeof msg.text === "string" ? msg.text : null;
193
+ if (text === null) return null;
194
+ const from = msg.from;
195
+ if (!from) return null;
196
+ const chat = msg.chat;
197
+ if (!chat) return null;
198
+
199
+ const fromUserId = String(from.id);
200
+ const chatId = String(chat.id);
201
+
202
+ // W5: default-deny is the INTERSECTION — both chatId AND senderId must
203
+ // appear in their respective allowlists. An empty list rejects everyone.
204
+ // TODO: surface this rule in the dashboard help text (frontend).
205
+ if (!allowedChatIds.has(chatId)) return null;
206
+ if (!allowedSenderIds.has(fromUserId)) return null;
207
+
208
+ const isPrivate = chat.type === "private";
209
+ const conversationId = isPrivate
210
+ ? `telegram:user:${chatId}`
211
+ : `telegram:group:${chatId}`;
212
+ const conversationKind: "direct" | "group" = isPrivate ? "direct" : "group";
213
+
214
+ const senderName =
215
+ from.username ??
216
+ [from.first_name].filter((s): s is string => typeof s === "string" && s.length > 0)[0];
217
+
218
+ const sanitized = sanitizeUntrustedContent(text);
219
+ const messageId = `telegram:${chatId}:${msg.message_id}`;
220
+
221
+ return {
222
+ id: messageId,
223
+ channel: opts.id,
224
+ accountId: opts.accountId,
225
+ conversation: {
226
+ id: conversationId,
227
+ kind: conversationKind,
228
+ ...(chat.title ? { title: chat.title } : {}),
229
+ },
230
+ sender: {
231
+ id: `telegram:user:${fromUserId}`,
232
+ ...(senderName ? { name: senderName } : {}),
233
+ kind: "user",
234
+ },
235
+ text: sanitized,
236
+ raw: update,
237
+ replyTo: null,
238
+ mentioned: false,
239
+ receivedAt: Date.now(),
240
+ trace: { id: messageId, streamable: false },
241
+ };
242
+ }
243
+
244
+ async function pollLoop(ctx: ChannelStartContext): Promise<void> {
245
+ const { abortSignal, log, emit, setStatus } = ctx;
246
+ liveSetStatus = setStatus;
247
+ const state = ensureState();
248
+
249
+ function markStatus(patch: Partial<ChannelStatusSnapshot>) {
250
+ statusSnapshot = { ...statusSnapshot, ...patch };
251
+ setStatus(patch);
252
+ }
253
+
254
+ if (!loadTokenFromSecretIfNeeded()) {
255
+ markStatus({
256
+ running: false,
257
+ connected: false,
258
+ authorized: false,
259
+ lastError: "missing_secret",
260
+ });
261
+ log.error("telegram missing bot token", { gatewayId: opts.id });
262
+ return;
263
+ }
264
+
265
+ let offset = 0;
266
+ const cursor = state.getCursor();
267
+ if (cursor) {
268
+ const parsed = Number(cursor);
269
+ if (Number.isFinite(parsed)) offset = parsed;
270
+ }
271
+
272
+ markStatus({
273
+ running: true,
274
+ connected: true,
275
+ authorized: true,
276
+ reconnectAttempts: 0,
277
+ lastError: null,
278
+ lastStartAt: Date.now(),
279
+ });
280
+ log.info("telegram poll loop starting", { gatewayId: opts.id, offset });
281
+
282
+ let stopped = false;
283
+ const onAbort = () => {
284
+ stopped = true;
285
+ };
286
+ abortSignal.addEventListener("abort", onAbort, { once: true });
287
+ stopCallback = () => {
288
+ stopped = true;
289
+ };
290
+
291
+ while (!stopped && !abortSignal.aborted) {
292
+ try {
293
+ const resp = await callApi<TelegramUpdate[]>(
294
+ "getUpdates",
295
+ {
296
+ offset,
297
+ timeout: POLL_TIMEOUT_S,
298
+ allowed_updates: ["message"],
299
+ },
300
+ (POLL_TIMEOUT_S + 15) * 1000,
301
+ );
302
+ markStatus({ lastPollAt: Date.now() });
303
+ if (!resp.ok) {
304
+ log.warn("telegram getUpdates non-ok", {
305
+ description: redactToken(resp.description ?? ""),
306
+ });
307
+ markStatus({ lastError: redactToken(resp.description ?? "getUpdates failed") });
308
+ await sleep(POLL_BACKOFF_MS, abortSignal);
309
+ continue;
310
+ }
311
+ const updates = resp.result ?? [];
312
+ if (updates.length === 0) continue;
313
+
314
+ // W1: persist cursor only AFTER all emits return cleanly. If emit
315
+ // throws, leave the cursor untouched so the same batch retries on
316
+ // the next poll instead of being silently dropped.
317
+ let maxId = offset - 1;
318
+ for (const u of updates) {
319
+ if (u.update_id > maxId) maxId = u.update_id;
320
+ }
321
+
322
+ let emitFailed = false;
323
+ for (const update of updates) {
324
+ const normalized = normalizeUpdate(update);
325
+ if (!normalized) continue;
326
+ markStatus({ lastInboundAt: Date.now() });
327
+ const envelope: GatewayInboundEnvelope = { message: normalized };
328
+ try {
329
+ await emit(envelope);
330
+ } catch (err) {
331
+ emitFailed = true;
332
+ log.error("telegram emit threw — leaving cursor unchanged", {
333
+ err: redactToken(String(err)),
334
+ });
335
+ break;
336
+ }
337
+ }
338
+ if (!emitFailed) {
339
+ offset = maxId + 1;
340
+ state.update({ cursor: String(offset) });
341
+ }
342
+ } catch (err) {
343
+ if (stopped || abortSignal.aborted) break;
344
+ const name = (err as Error)?.name ?? "";
345
+ if (name === "AbortError" || name === "TimeoutError") {
346
+ log.warn("telegram poll transient", { name });
347
+ await sleep(TRANSIENT_BACKOFF_MS, abortSignal);
348
+ continue;
349
+ }
350
+ log.error("telegram poll failed", { err: redactToken(String(err)) });
351
+ markStatus({ lastError: redactToken(String(err)) });
352
+ await sleep(POLL_BACKOFF_MS, abortSignal);
353
+ }
354
+ }
355
+
356
+ markStatus({
357
+ running: false,
358
+ connected: false,
359
+ lastStopAt: Date.now(),
360
+ });
361
+ try {
362
+ state.flush();
363
+ } catch (e) {
364
+ log.warn("state-flush-on-stop failed", { error: String(e) });
365
+ }
366
+ }
367
+
368
+ const adapter: ChannelAdapter = {
369
+ id: opts.id,
370
+ type: channelType,
371
+
372
+ async start(ctx: ChannelStartContext): Promise<void> {
373
+ if (started) throw new Error("already started");
374
+ started = true;
375
+ await pollLoop(ctx);
376
+ },
377
+
378
+ async stop(_ctx: ChannelStopContext): Promise<void> {
379
+ if (stopCallback) {
380
+ stopCallback();
381
+ stopCallback = null;
382
+ }
383
+ try {
384
+ stateStore?.flush();
385
+ } catch (e) {
386
+ // W7: log flush failures at stop — previously swallowed silently.
387
+ console.warn("[telegram] state-flush-on-stop failed", String(e));
388
+ }
389
+ },
390
+
391
+ async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
392
+ const { message, log } = ctx;
393
+ if (!loadTokenFromSecretIfNeeded()) {
394
+ throw new Error("telegram bot token not loaded");
395
+ }
396
+ const chatId = chatIdFromConversation(message.conversationId);
397
+ if (!chatId) {
398
+ throw new Error(
399
+ `telegram send: unrecognized conversationId "${message.conversationId}"`,
400
+ );
401
+ }
402
+ const chunks = splitText(message.text, splitAt);
403
+ let lastMessageId: string | null = null;
404
+ for (const chunk of chunks) {
405
+ const resp = await callApi<TelegramMessage>(
406
+ "sendMessage",
407
+ {
408
+ chat_id: chatId,
409
+ text: chunk,
410
+ disable_web_page_preview: true,
411
+ },
412
+ 15_000,
413
+ );
414
+ if (!resp.ok) {
415
+ log.warn("telegram sendMessage non-ok", {
416
+ description: redactToken(resp.description ?? ""),
417
+ });
418
+ throw new Error(
419
+ `telegram sendMessage failed: ${redactToken(resp.description ?? "unknown")}`,
420
+ );
421
+ }
422
+ if (resp.result?.message_id !== undefined) {
423
+ lastMessageId = `telegram:${chatId}:${resp.result.message_id}`;
424
+ }
425
+ }
426
+ const sendAt = Date.now();
427
+ statusSnapshot = { ...statusSnapshot, lastSendAt: sendAt };
428
+ // W11: push to the gateway snapshot too — the dashboard reads this.
429
+ if (liveSetStatus) liveSetStatus({ lastSendAt: sendAt });
430
+ return { providerMessageId: lastMessageId };
431
+ },
432
+
433
+ async typing(ctx: ChannelTypingContext): Promise<void> {
434
+ if (!loadTokenFromSecretIfNeeded()) return;
435
+ const chatId = chatIdFromConversation(ctx.conversationId);
436
+ if (!chatId) return;
437
+ try {
438
+ await callApi("sendChatAction", { chat_id: chatId, action: "typing" }, 10_000);
439
+ } catch (err) {
440
+ ctx.log.warn("telegram typing failed", { err: redactToken(String(err)) });
441
+ }
442
+ },
443
+
444
+ status(): ChannelStatusSnapshot {
445
+ return { ...statusSnapshot };
446
+ },
447
+ };
448
+
449
+ return adapter;
450
+ }
451
+
452
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
453
+ return new Promise((resolve) => {
454
+ if (signal?.aborted) {
455
+ resolve();
456
+ return;
457
+ }
458
+ const timer = setTimeout(() => {
459
+ signal?.removeEventListener("abort", onAbort);
460
+ resolve();
461
+ }, ms);
462
+ const onAbort = () => {
463
+ clearTimeout(timer);
464
+ resolve();
465
+ };
466
+ signal?.addEventListener("abort", onAbort, { once: true });
467
+ });
468
+ }
469
+
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Split a long message into chunks <= `limit` characters each. Prefers to cut
3
+ * on newline boundaries so multi-paragraph replies don't fragment mid-line.
4
+ *
5
+ * Shared by third-party channel adapters (Telegram, WeChat) which both have a
6
+ * per-message size cap from upstream and no native streaming. WeChat caller
7
+ * passes a smaller `limit` (~1800), Telegram a larger one (~4000, since the
8
+ * raw Telegram limit is 4096).
9
+ *
10
+ * Empty input returns `[""]` so callers can iterate uniformly without a length
11
+ * check.
12
+ */
13
+ export function splitText(text: string, limit: number): string[] {
14
+ if (limit <= 0) return [text];
15
+ if (text.length === 0) return [""];
16
+ if (text.length <= limit) return [text];
17
+
18
+ const out: string[] = [];
19
+ let remaining = text;
20
+ while (remaining.length > limit) {
21
+ let cut = remaining.lastIndexOf("\n", limit);
22
+ if (cut <= 0) cut = limit;
23
+ out.push(remaining.slice(0, cut));
24
+ // Drop the leading newline so the next chunk doesn't start with a blank line.
25
+ remaining = remaining.slice(cut).replace(/^\n/, "");
26
+ }
27
+ if (remaining.length > 0) out.push(remaining);
28
+ return out;
29
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * W9: SSRF guard for user-supplied `baseUrl` values that flow into
3
+ * authenticated daemon-side fetches (Telegram getMe, WeChat iLink).
4
+ *
5
+ * Policy: scheme MUST be `https`; the hostname MUST match one of the
6
+ * explicitly-allowed well-known API hosts (case-insensitive exact match).
7
+ * Switching from blocklist to allowlist closes the GCP/AWS metadata hostname
8
+ * pivot vector — blocklists miss names like `metadata.google.internal`,
9
+ * `*.svc.cluster.local`, etc.
10
+ *
11
+ * The test host `botcord-test.local` is added only when
12
+ * NODE_ENV === "test" to keep unit tests working without relaxing production
13
+ * policy.
14
+ */
15
+
16
+ export class UnsafeBaseUrlError extends Error {
17
+ constructor(reason: string) {
18
+ super(`unsafe_base_url: ${reason}`);
19
+ this.name = "UnsafeBaseUrlError";
20
+ }
21
+ }
22
+
23
+ function allowedHosts(): Set<string> {
24
+ const hosts = new Set(["api.telegram.org", "ilinkai.weixin.qq.com"]);
25
+ if (process.env.NODE_ENV === "test") {
26
+ hosts.add("botcord-test.local");
27
+ }
28
+ return hosts;
29
+ }
30
+
31
+ export function assertSafeBaseUrl(value: string | undefined | null): void {
32
+ if (value === undefined || value === null || value === "") {
33
+ // Caller handles the "no baseUrl supplied → use default" path.
34
+ return;
35
+ }
36
+ if (typeof value !== "string") {
37
+ throw new UnsafeBaseUrlError("not a string");
38
+ }
39
+ let parsed: URL;
40
+ try {
41
+ parsed = new URL(value);
42
+ } catch {
43
+ throw new UnsafeBaseUrlError("not a valid URL");
44
+ }
45
+ if (parsed.protocol !== "https:") {
46
+ throw new UnsafeBaseUrlError(`scheme "${parsed.protocol}" is not https`);
47
+ }
48
+ const host = parsed.hostname.toLowerCase();
49
+ if (!host) {
50
+ throw new UnsafeBaseUrlError("empty host");
51
+ }
52
+ if (!allowedHosts().has(host)) {
53
+ throw new UnsafeBaseUrlError(`host "${host}" is not in the allowlist`);
54
+ }
55
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared HTTP plumbing for the iLink WeChat adapter and its login helper.
3
+ *
4
+ * Centralises the four mandatory headers (`AuthorizationType`,
5
+ * `Authorization`, `X-WECHAT-UIN`, `Content-Type`) so the adapter and the
6
+ * login flow can't drift on header shape — the iLink server rejects requests
7
+ * that omit any of them.
8
+ */
9
+
10
+ import { randomBytes } from "node:crypto";
11
+
12
+ // W7: canonical FetchLike now lives in http-types.ts; re-export for callers
13
+ // that historically imported it from this module.
14
+ export type { FetchLike } from "./http-types.js";
15
+
16
+ /** `X-WECHAT-UIN: base64(str(random uint32))` — fresh per request, anti-replay. */
17
+ export function wechatUinHeader(): string {
18
+ const n = randomBytes(4).readUInt32BE(0);
19
+ return Buffer.from(String(n), "utf8").toString("base64");
20
+ }
21
+
22
+ /** Build the canonical iLink request headers. Token is optional for login calls. */
23
+ export function wechatHeaders(botToken?: string): Record<string, string> {
24
+ const h: Record<string, string> = {
25
+ "Content-Type": "application/json",
26
+ AuthorizationType: "ilink_bot_token",
27
+ "X-WECHAT-UIN": wechatUinHeader(),
28
+ };
29
+ if (botToken) h["Authorization"] = `Bearer ${botToken}`;
30
+ return h;
31
+ }
32
+
33
+ /** iLink `base_info` block required on every authenticated POST body. */
34
+ export const WECHAT_CHANNEL_VERSION = "1.0.2";
35
+ export const WECHAT_BASE_INFO = { channel_version: WECHAT_CHANNEL_VERSION } as const;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * iLink WeChat QR-code login helpers.
3
+ *
4
+ * Co-located with the channel adapter so the small set of unauthenticated
5
+ * iLink endpoints (`get_bot_qrcode`, `get_qrcode_status`) used during the
6
+ * scan-confirm flow stay alongside the authenticated calls in `wechat.ts`.
7
+ *
8
+ * This module deliberately exports ONLY the two HTTP calls. Login session
9
+ * persistence (mapping `loginId` → `{accountId, gatewayId, botToken, ...}`)
10
+ * is owned by the control-plane layer and is out of scope here.
11
+ */
12
+
13
+ import { wechatHeaders, type FetchLike } from "./wechat-http.js";
14
+ import { assertSafeBaseUrl } from "./url-guard.js";
15
+
16
+ export const DEFAULT_WECHAT_BASE_URL = "https://ilinkai.weixin.qq.com";
17
+
18
+ export interface WechatQrcode {
19
+ qrcode: string;
20
+ qrcodeUrl?: string;
21
+ raw: Record<string, unknown>;
22
+ }
23
+
24
+ export interface WechatQrcodeStatus {
25
+ status: string;
26
+ botToken?: string;
27
+ baseUrl?: string;
28
+ raw: Record<string, unknown>;
29
+ }
30
+
31
+ export interface WechatLoginOptions {
32
+ baseUrl?: string;
33
+ fetchImpl?: FetchLike;
34
+ }
35
+
36
+ /** `GET /ilink/bot/get_bot_qrcode?bot_type=3` — fetch a fresh login QR. */
37
+ export async function getBotQrcode(opts: WechatLoginOptions = {}): Promise<WechatQrcode> {
38
+ // W1: defense-in-depth SSRF guard at the fetch boundary.
39
+ assertSafeBaseUrl(opts.baseUrl);
40
+ const base = (opts.baseUrl ?? DEFAULT_WECHAT_BASE_URL).replace(/\/+$/, "");
41
+ const fetcher = opts.fetchImpl ?? (globalThis.fetch as FetchLike);
42
+ const res = await fetcher(`${base}/ilink/bot/get_bot_qrcode?bot_type=3`, {
43
+ method: "GET",
44
+ headers: wechatHeaders(),
45
+ });
46
+ const data = (await safeJson(res)) ?? {};
47
+ const qrcode = typeof data.qrcode === "string" ? data.qrcode : "";
48
+ if (!qrcode) {
49
+ throw new Error(`wechat get_bot_qrcode: missing qrcode in response`);
50
+ }
51
+ const qrcodeUrl =
52
+ (typeof data.qrcode_url === "string" && data.qrcode_url) ||
53
+ (typeof data.qrcode_img_content === "string" && data.qrcode_img_content) ||
54
+ undefined;
55
+ return { qrcode, qrcodeUrl, raw: data };
56
+ }
57
+
58
+ /**
59
+ * `GET /ilink/bot/get_qrcode_status?qrcode=...` — poll for scan/confirm.
60
+ * Caller is responsible for backoff and TTL; this just returns the parsed
61
+ * server response.
62
+ */
63
+ export async function getQrcodeStatus(
64
+ qrcode: string,
65
+ opts: WechatLoginOptions = {},
66
+ ): Promise<WechatQrcodeStatus> {
67
+ // W1: defense-in-depth SSRF guard at the fetch boundary.
68
+ assertSafeBaseUrl(opts.baseUrl);
69
+ const base = (opts.baseUrl ?? DEFAULT_WECHAT_BASE_URL).replace(/\/+$/, "");
70
+ const fetcher = opts.fetchImpl ?? (globalThis.fetch as FetchLike);
71
+ const res = await fetcher(
72
+ `${base}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`,
73
+ { method: "GET", headers: wechatHeaders() },
74
+ );
75
+ const data = (await safeJson(res)) ?? {};
76
+ const status = typeof data.status === "string" ? data.status : "unknown";
77
+ const botToken = typeof data.bot_token === "string" ? data.bot_token : undefined;
78
+ const baseUrl = typeof data.baseurl === "string" ? data.baseurl : undefined;
79
+ return { status, botToken, baseUrl, raw: data };
80
+ }
81
+
82
+ async function safeJson(res: { text(): Promise<string> }): Promise<Record<string, unknown> | null> {
83
+ try {
84
+ const raw = await res.text();
85
+ if (!raw) return null;
86
+ return JSON.parse(raw) as Record<string, unknown>;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }