@inceptionstack/roundhouse 0.3.17 → 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/package.json +2 -1
- package/src/agents/registry.ts +109 -16
- package/src/cli/qr.ts +24 -0
- package/src/cli/setup-logger.ts +142 -0
- package/src/cli/setup-prompts.ts +78 -0
- package/src/cli/setup-telegram.ts +12 -5
- package/src/cli/setup.ts +446 -71
- package/src/gateway.ts +88 -1
- package/src/pairing.ts +112 -0
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
|
-
|
|
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
|
+
}
|