@inceptionstack/roundhouse 0.3.16 → 0.3.18

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.
package/src/gateway.ts CHANGED
@@ -20,6 +20,7 @@ import { BOT_COMMANDS } from "./commands";
20
20
  import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact, determineMemoryMode } from "./memory/lifecycle";
21
21
  import { maxPressure } from "./memory/policy";
22
22
  import type { PressureLevel } from "./memory/types";
23
+ import { readPendingPairing, completePendingPairing, isStartForNonce } from "./pairing";
23
24
 
24
25
  /** Match a Telegram command, handling optional @botname suffix */
25
26
  /** Bot username for command suffix validation (set during gateway init) */
@@ -242,6 +243,7 @@ export class Gateway {
242
243
  private chat!: Chat;
243
244
  private router: AgentRouter;
244
245
  private config: GatewayConfig;
246
+ private pairingComplete = false;
245
247
  private sttService: SttService | null = null;
246
248
  private cronScheduler: CronSchedulerService | null = null;
247
249
 
@@ -251,6 +253,82 @@ export class Gateway {
251
253
  _botUsername = config.chat.botUsername || "";
252
254
  }
253
255
 
256
+ /** Handle pending Telegram pairing from headless setup. Returns true if handled. */
257
+ private async handlePendingPairing(
258
+ text: string,
259
+ message: any,
260
+ thread: any,
261
+ authorName: string,
262
+ ): Promise<boolean> {
263
+ try {
264
+ const pending = await readPendingPairing();
265
+ if (!pending || pending.status !== "pending" || !isStartForNonce(text, pending.nonce)) {
266
+ return false;
267
+ }
268
+
269
+ const fromUser = authorName.toLowerCase();
270
+ const allowed = pending.allowedUsers.map(u => u.toLowerCase());
271
+ if (!fromUser || !allowed.includes(fromUser)) {
272
+ console.log(`[roundhouse] Pairing nonce from unauthorized user @${authorName}`);
273
+ return false;
274
+ }
275
+
276
+ // Extract IDs from the chat adapter message
277
+ const chatId = typeof message.chatId === "number"
278
+ ? message.chatId
279
+ : typeof thread.id === "string" && thread.id.startsWith("telegram:")
280
+ ? parseInt(thread.id.split(":")[1], 10)
281
+ : undefined;
282
+ const userId = typeof message.author?.id === "string"
283
+ ? parseInt(message.author.id, 10)
284
+ : undefined;
285
+
286
+ if (chatId == null || Number.isNaN(chatId) || userId == null || Number.isNaN(userId)) {
287
+ console.error(`[roundhouse] Pairing nonce matched but could not extract IDs: chatId=${chatId} userId=${userId}. Pairing left pending.`);
288
+ await thread.post("⚠️ Pairing nonce accepted but could not capture your Telegram IDs. Try sending /start again, or run: roundhouse pair");
289
+ return true;
290
+ }
291
+
292
+ await completePendingPairing({ chatId, userId, username: authorName });
293
+
294
+ // Update in-memory config
295
+ if (!this.config.chat.allowedUserIds) this.config.chat.allowedUserIds = [];
296
+ if (!this.config.chat.allowedUserIds.includes(userId)) {
297
+ this.config.chat.allowedUserIds.push(userId);
298
+ }
299
+ if (!this.config.chat.notifyChatIds) this.config.chat.notifyChatIds = [];
300
+ if (!this.config.chat.notifyChatIds.includes(chatId)) {
301
+ this.config.chat.notifyChatIds.push(chatId);
302
+ }
303
+
304
+ // Atomic config file update
305
+ try {
306
+ const { readFile: rf, rename: mvf, writeFile: wf, unlink: ulf } = await import("node:fs/promises");
307
+ const { randomBytes: rb } = await import("node:crypto");
308
+ const cfgPath = join(ROUNDHOUSE_DIR, "gateway.config.json");
309
+ const configRaw = JSON.parse(await rf(cfgPath, "utf8"));
310
+ if (!configRaw.chat) configRaw.chat = {};
311
+ if (!configRaw.chat.allowedUserIds) configRaw.chat.allowedUserIds = [];
312
+ if (!configRaw.chat.allowedUserIds.includes(userId)) configRaw.chat.allowedUserIds.push(userId);
313
+ if (!configRaw.chat.notifyChatIds) configRaw.chat.notifyChatIds = [];
314
+ if (!configRaw.chat.notifyChatIds.includes(chatId)) configRaw.chat.notifyChatIds.push(chatId);
315
+ const tmp = `${cfgPath}.tmp.${rb(4).toString("hex")}`;
316
+ await wf(tmp, JSON.stringify(configRaw, null, 2) + "\n");
317
+ await mvf(tmp, cfgPath).catch(async (e) => { try { await ulf(tmp); } catch {} throw e; });
318
+ } catch (cfgErr) {
319
+ console.error("[roundhouse] failed to update config after pairing:", cfgErr);
320
+ }
321
+
322
+ console.log(`[roundhouse] Telegram pairing complete: @${authorName} chatId=${chatId} userId=${userId}`);
323
+ this.pairingComplete = true;
324
+ await thread.post("✅ Roundhouse paired successfully!\n\nSend /status to verify everything is working.");
325
+ return true;
326
+ } catch (err) {
327
+ console.error("[roundhouse] error checking pending pairing:", err);
328
+ return false;
329
+ }
330
+ }
331
+
254
332
  async start() {
255
333
  const chatAdapters = await buildChatAdapters(this.config.chat.adapters);
256
334
 
@@ -289,7 +367,10 @@ export class Gateway {
289
367
  const allowedUsers = (this.config.chat.allowedUsers ?? []).map((u) =>
290
368
  u.toLowerCase()
291
369
  );
292
- const allowedUserIds = this.config.chat.allowedUserIds ?? [];
370
+ // Ensure arrays exist on config so pairing hook mutations are visible to isAllowed
371
+ if (!this.config.chat.allowedUserIds) this.config.chat.allowedUserIds = [];
372
+ if (!this.config.chat.notifyChatIds) this.config.chat.notifyChatIds = [];
373
+ const allowedUserIds = this.config.chat.allowedUserIds;
293
374
 
294
375
  // Per-thread verbose toggle (shows tool_start messages)
295
376
  const verboseThreads = new Set<string>();
@@ -310,6 +391,12 @@ export class Gateway {
310
391
  `[roundhouse] ${thread.id} @${authorName}: "${userText.slice(0, 120)}"${rawAttachments.length ? ` +${rawAttachments.length} attachment(s)` : ""}`
311
392
  );
312
393
 
394
+ // Check for pending Telegram pairing (headless setup)
395
+ if (userText.trim().startsWith("/start ") && !this.pairingComplete) {
396
+ const handled = await this.handlePendingPairing(userText.trim(), message, thread, authorName ?? "");
397
+ if (handled) return;
398
+ }
399
+
313
400
  if (!isAllowed(message, allowedUsers, allowedUserIds)) {
314
401
  console.log(`[roundhouse] blocked @${authorName} (not in allowlist)`);
315
402
  return;
package/src/pairing.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * pairing.ts — Persistent pending-pairing state for Telegram.
3
+ *
4
+ * Used by:
5
+ * - setup --telegram --headless: writes pending pairing before starting gateway
6
+ * - gateway.ts: detects pending pairing and completes on /start <nonce>
7
+ */
8
+ import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
9
+ import { dirname, resolve } from "node:path";
10
+ import { randomBytes } from "node:crypto";
11
+ import { ROUNDHOUSE_DIR } from "./config";
12
+
13
+ export interface PendingPairing {
14
+ version: 1;
15
+ nonce: string;
16
+ botUsername: string;
17
+ allowedUsers: string[];
18
+ createdAt: string;
19
+ status: "pending" | "paired";
20
+ pairedAt?: string;
21
+ chatId?: number;
22
+ userId?: number;
23
+ username?: string;
24
+ }
25
+
26
+ export const PAIRING_PATH = resolve(ROUNDHOUSE_DIR, "telegram-pairing.json");
27
+
28
+ /**
29
+ * Generate a pairing nonce: "rh-" + 8 random hex bytes.
30
+ */
31
+ export function createPairingNonce(): string {
32
+ return `rh-${randomBytes(8).toString("hex")}`;
33
+ }
34
+
35
+ /**
36
+ * Build the Telegram deep link for pairing.
37
+ */
38
+ export function createPairingLink(botUsername: string, nonce: string): string {
39
+ return `https://t.me/${botUsername}?start=${nonce}`;
40
+ }
41
+
42
+ /**
43
+ * Check if a message text matches /start <nonce>.
44
+ */
45
+ export function isStartForNonce(text: string, nonce: string): boolean {
46
+ const trimmed = text.trim();
47
+ return trimmed === `/start ${nonce}` || trimmed === nonce;
48
+ }
49
+
50
+ /**
51
+ * Read the pending pairing file. Returns null if not found or invalid.
52
+ */
53
+ export async function readPendingPairing(): Promise<PendingPairing | null> {
54
+ try {
55
+ const raw = await readFile(PAIRING_PATH, "utf8");
56
+ const data = JSON.parse(raw);
57
+ if (data?.version === 1 && data?.nonce && data?.status) {
58
+ return data as PendingPairing;
59
+ }
60
+ return null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Write pending pairing state (atomic, mode 0600).
68
+ */
69
+ export async function writePendingPairing(state: PendingPairing): Promise<void> {
70
+ await mkdir(dirname(PAIRING_PATH), { recursive: true });
71
+ const tmp = `${PAIRING_PATH}.tmp.${randomBytes(4).toString("hex")}`;
72
+ try {
73
+ await writeFile(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
74
+ await rename(tmp, PAIRING_PATH);
75
+ } catch (err) {
76
+ try { await unlink(tmp); } catch {}
77
+ throw err;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Mark pairing as complete — merges result into existing pending state.
83
+ */
84
+ export async function completePendingPairing(result: {
85
+ chatId: number;
86
+ userId: number;
87
+ username: string;
88
+ }): Promise<PendingPairing | null> {
89
+ const existing = await readPendingPairing();
90
+ if (!existing || existing.status !== "pending") return null;
91
+
92
+ const completed: PendingPairing = {
93
+ ...existing,
94
+ status: "paired",
95
+ pairedAt: new Date().toISOString(),
96
+ chatId: result.chatId,
97
+ userId: result.userId,
98
+ username: result.username,
99
+ };
100
+
101
+ await writePendingPairing(completed);
102
+ return completed;
103
+ }
104
+
105
+ /**
106
+ * Clear the pairing file.
107
+ */
108
+ export async function clearPendingPairing(): Promise<void> {
109
+ try {
110
+ await unlink(PAIRING_PATH);
111
+ } catch {}
112
+ }