appback-remoteagent 0.20.0 → 0.21.0

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/README.md CHANGED
@@ -325,7 +325,7 @@ remoteagent bot add
325
325
  remoteagent-start
326
326
  ```
327
327
 
328
- The command validates the BotFather token, stores the bot configuration, and configures the numeric Telegram owner user ID. Tokens are entered through a hidden prompt by default.
328
+ The command validates the BotFather token and shows a one-time Telegram `/start` link. Open that link from the account that will own the installation; RemoteAgent detects the sender's numeric user ID and stores it as the owner automatically. The token is entered through a hidden prompt, and the owner ID does not need to be looked up or typed manually.
329
329
 
330
330
  To move installation-wide `/secret` values to another PC, export and import a password-encrypted bundle:
331
331
 
package/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import fs from "node:fs/promises";
2
+ import { randomBytes } from "node:crypto";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  import process from "node:process";
5
- import readline from "node:readline/promises";
6
- import { registerTelegramBot, readConfiguredOwnerId } from "./services/cli-config-service.js";
6
+ import { fetchTelegramBotIdentity, registerTelegramBot, readConfiguredOwnerId, waitForTelegramOwner, } from "./services/cli-config-service.js";
7
7
  import { exportSecrets, importSecrets } from "./services/secret-transfer-service.js";
8
8
  async function main() {
9
9
  const args = process.argv.slice(2);
@@ -46,9 +46,27 @@ async function addBot(dataDir, args) {
46
46
  if (args.length > 0) {
47
47
  throw new Error(`Unexpected bot argument: ${args[0]}`);
48
48
  }
49
+ const identity = await fetchTelegramBotIdentity(token);
49
50
  const configuredOwner = await readConfiguredOwnerId(dataDir);
50
- const ownerId = ownerOption || configuredOwner || await promptVisible("Telegram owner user ID: ");
51
- const result = await registerTelegramBot({ dataDir, token, ownerId });
51
+ let ownerId = ownerOption || configuredOwner;
52
+ if (!ownerId) {
53
+ const startPayload = `ra_${randomBytes(8).toString("hex")}`;
54
+ console.log([
55
+ `Validated @${identity.username} (${identity.id}).`,
56
+ "",
57
+ "Open this Telegram link within 3 minutes to confirm the owner:",
58
+ ` https://t.me/${identity.username}?start=${startPayload}`,
59
+ "",
60
+ "Or send this exact command to the bot:",
61
+ ` /start ${startPayload}`,
62
+ "",
63
+ "Waiting for owner confirmation...",
64
+ ].join("\n"));
65
+ const owner = await waitForTelegramOwner(token, startPayload);
66
+ ownerId = owner.id;
67
+ console.log(`Detected Telegram owner: ${owner.displayName}${owner.username ? ` (@${owner.username})` : ""} (${owner.id})`);
68
+ }
69
+ const result = await registerTelegramBot({ dataDir, token, ownerId, identity });
52
70
  console.log([
53
71
  `${result.added ? "Registered" : "Updated"} @${result.identity.username} (${result.identity.id}).`,
54
72
  `Configured bots: ${result.botCount}`,
@@ -119,18 +137,6 @@ function takeFlag(args, name) {
119
137
  args.splice(index, 1);
120
138
  return true;
121
139
  }
122
- async function promptVisible(question) {
123
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
124
- throw new Error(`${question.trim()} is required as a command option in non-interactive mode.`);
125
- }
126
- const terminal = readline.createInterface({ input: process.stdin, output: process.stdout });
127
- try {
128
- return (await terminal.question(question)).trim();
129
- }
130
- finally {
131
- terminal.close();
132
- }
133
- }
134
140
  async function promptHidden(question) {
135
141
  if (!process.stdin.isTTY || !process.stdout.isTTY || !process.stdin.setRawMode) {
136
142
  throw new Error("Interactive hidden input requires a TTY. Use the corresponding --*-file option instead.");
@@ -1,5 +1,8 @@
1
+ import { execFile } from "node:child_process";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ const execFileAsync = promisify(execFile);
3
6
  export async function registerTelegramBot(options) {
4
7
  const token = options.token.trim();
5
8
  const ownerId = options.ownerId.trim();
@@ -63,30 +66,121 @@ export async function readConfiguredOwnerId(dataDir) {
63
66
  }
64
67
  export async function fetchTelegramBotIdentity(token) {
65
68
  assertBotToken(token);
66
- let response;
69
+ let stdout;
67
70
  try {
68
- response = await fetch(`https://api.telegram.org/bot${token}/getMe`, {
69
- signal: AbortSignal.timeout(20_000),
70
- });
71
+ const result = await execFileAsync("curl", [
72
+ "-4",
73
+ "-sS",
74
+ "--connect-timeout",
75
+ "10",
76
+ "--max-time",
77
+ "20",
78
+ `https://api.telegram.org/bot${token}/getMe`,
79
+ ]);
80
+ stdout = result.stdout;
81
+ if (result.stderr?.trim()) {
82
+ console.error(`curl stderr for Telegram getMe: ${result.stderr.trim()}`);
83
+ }
71
84
  }
72
- catch {
73
- throw new Error("Telegram getMe request failed. Check this machine's network and DNS, then retry.");
85
+ catch (error) {
86
+ const detail = error instanceof Error ? error.message.replace(token, "[redacted]") : String(error);
87
+ throw new Error(`Telegram getMe request failed over IPv4: ${detail}`);
74
88
  }
75
89
  let payload;
76
90
  try {
77
- payload = await response.json();
91
+ payload = JSON.parse(stdout);
78
92
  }
79
93
  catch {
80
- throw new Error(`Telegram getMe returned an invalid response (HTTP ${response.status}).`);
94
+ throw new Error("Telegram getMe returned an invalid response.");
81
95
  }
82
- if (!response.ok || !payload.ok || !payload.result?.id || !payload.result.username) {
83
- throw new Error(payload.description || `Telegram rejected the supplied bot token (HTTP ${response.status}).`);
96
+ if (!payload.ok || !payload.result?.id || !payload.result.username) {
97
+ throw new Error(payload.description || "Telegram rejected the supplied bot token.");
84
98
  }
85
99
  return {
86
100
  id: payload.result.id,
87
101
  username: payload.result.username,
88
102
  };
89
103
  }
104
+ export async function waitForTelegramOwner(token, startPayload, timeoutMs = 180_000) {
105
+ assertBotToken(token);
106
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(startPayload)) {
107
+ throw new Error("Telegram start payload must use 1-64 URL-safe characters.");
108
+ }
109
+ const startedAt = Date.now();
110
+ let offset = await nextTelegramUpdateOffset(token);
111
+ while (Date.now() - startedAt < timeoutMs) {
112
+ const remainingMs = timeoutMs - (Date.now() - startedAt);
113
+ const pollSeconds = Math.max(1, Math.min(15, Math.floor(remainingMs / 1000)));
114
+ const updates = await getTelegramUpdates(token, offset, pollSeconds);
115
+ for (const update of updates) {
116
+ if (typeof update.update_id === "number") {
117
+ offset = Math.max(offset, update.update_id + 1);
118
+ }
119
+ const message = update.message;
120
+ const sender = message?.from;
121
+ if (message?.chat?.type !== "private"
122
+ || sender?.is_bot
123
+ || typeof sender?.id !== "number"
124
+ || message.text?.trim() !== `/start ${startPayload}`) {
125
+ continue;
126
+ }
127
+ return {
128
+ id: String(sender.id),
129
+ username: sender.username,
130
+ displayName: [sender.first_name, sender.last_name].filter(Boolean).join(" ") || sender.username || String(sender.id),
131
+ };
132
+ }
133
+ }
134
+ throw new Error("Timed out waiting for the Telegram owner confirmation. Run the command again and use the new /start link.");
135
+ }
136
+ async function nextTelegramUpdateOffset(token) {
137
+ const updates = await getTelegramUpdates(token, undefined, 0);
138
+ return updates.reduce((next, update) => typeof update.update_id === "number" ? Math.max(next, update.update_id + 1) : next, 0);
139
+ }
140
+ async function getTelegramUpdates(token, offset, timeoutSeconds) {
141
+ const args = [
142
+ "-4",
143
+ "-sS",
144
+ "--get",
145
+ "--connect-timeout",
146
+ "10",
147
+ "--max-time",
148
+ String(Math.max(20, timeoutSeconds + 10)),
149
+ "--data-urlencode",
150
+ `timeout=${timeoutSeconds}`,
151
+ "--data-urlencode",
152
+ "limit=100",
153
+ "--data-urlencode",
154
+ 'allowed_updates=["message"]',
155
+ ];
156
+ if (offset !== undefined) {
157
+ args.push("--data-urlencode", `offset=${offset}`);
158
+ }
159
+ args.push(`https://api.telegram.org/bot${token}/getUpdates`);
160
+ let stdout;
161
+ try {
162
+ const result = await execFileAsync("curl", args);
163
+ stdout = result.stdout;
164
+ if (result.stderr?.trim()) {
165
+ console.error(`curl stderr for Telegram getUpdates: ${result.stderr.trim()}`);
166
+ }
167
+ }
168
+ catch (error) {
169
+ const detail = error instanceof Error ? error.message.replace(token, "[redacted]") : String(error);
170
+ throw new Error(`Telegram getUpdates request failed over IPv4: ${detail}`);
171
+ }
172
+ let payload;
173
+ try {
174
+ payload = JSON.parse(stdout);
175
+ }
176
+ catch {
177
+ throw new Error("Telegram getUpdates returned an invalid response.");
178
+ }
179
+ if (!payload.ok || !Array.isArray(payload.result)) {
180
+ throw new Error(payload.description || "Telegram rejected the getUpdates request.");
181
+ }
182
+ return payload.result;
183
+ }
90
184
  function assertBotToken(token) {
91
185
  if (!isBotToken(token)) {
92
186
  throw new Error("Invalid Telegram bot token format.");
@@ -11,9 +11,11 @@ remoteagent bot add
11
11
  remoteagent-start
12
12
  ```
13
13
 
14
- `remoteagent bot add` asks for the BotFather token without echoing it and then asks for the numeric Telegram owner user ID. It validates the token with Telegram `getMe` before writing `~/.remoteagent/.env`.
14
+ `remoteagent bot add` asks for the BotFather token without echoing it and validates the token with Telegram `getMe`. On the first registration it then prints a one-time Telegram `/start` link and waits up to three minutes. Open that link from the Telegram account that will own the installation; RemoteAgent detects and stores that account's numeric user ID automatically. No manual owner ID lookup or entry is required.
15
15
 
16
- For automation, keep sensitive values out of shell history by using a permission-restricted token file:
16
+ The confirmation update is consumed by the setup command. After `remoteagent-start`, send `/start` to the bot normally to begin using RemoteAgent.
17
+
18
+ For unattended automation, keep sensitive values out of shell history by using a permission-restricted token file and pass the already verified owner ID explicitly:
17
19
 
18
20
  ```bash
19
21
  chmod 600 /secure/path/telegram-token
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appback-remoteagent",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Personal installable session server for continuing local AI work across PC and Telegram",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,7 +4,11 @@ import assert from "node:assert/strict";
4
4
  import fs from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
- import { registerTelegramBot } from "../dist/services/cli-config-service.js";
7
+ import {
8
+ fetchTelegramBotIdentity,
9
+ registerTelegramBot,
10
+ waitForTelegramOwner,
11
+ } from "../dist/services/cli-config-service.js";
8
12
  import { exportSecrets, importSecrets } from "../dist/services/secret-transfer-service.js";
9
13
 
10
14
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "remoteagent-cli-selftest-"));
@@ -15,6 +19,50 @@ const selectedBundlePath = path.join(root, "selected-transfer.ra-secrets");
15
19
  const passphrase = "correct-horse-battery-staple";
16
20
 
17
21
  try {
22
+ const binDir = path.join(root, "bin");
23
+ const curlArgsPath = path.join(root, "curl-args.txt");
24
+ const curlUpdateCallsPath = path.join(root, "curl-update-calls.txt");
25
+ await fs.mkdir(binDir, { recursive: true });
26
+ await fs.writeFile(path.join(binDir, "curl"), `#!/usr/bin/env bash
27
+ printf '%s\\n' "$@" > ${JSON.stringify(curlArgsPath)}
28
+ if printf '%s\\n' "$@" | grep -q '/getUpdates'; then
29
+ count=0
30
+ if [ -f ${JSON.stringify(curlUpdateCallsPath)} ]; then
31
+ count="$(cat ${JSON.stringify(curlUpdateCallsPath)})"
32
+ fi
33
+ count=$((count + 1))
34
+ printf '%s' "$count" > ${JSON.stringify(curlUpdateCallsPath)}
35
+ if [ "$count" -eq 1 ]; then
36
+ printf '{"ok":true,"result":[{"update_id":41,"message":{"text":"/start stale_payload","chat":{"id":777,"type":"private"},"from":{"id":777,"is_bot":false,"username":"stale"}}}]}'
37
+ else
38
+ printf '{"ok":true,"result":[{"update_id":42,"message":{"text":"/start ra_selftest","chat":{"id":8202993989,"type":"private"},"from":{"id":8202993989,"is_bot":false,"username":"roy","first_name":"Roy"}}}]}'
39
+ fi
40
+ else
41
+ printf '{"ok":true,"result":{"id":100000,"username":"bootstrap_test_bot"}}'
42
+ fi
43
+ `, { mode: 0o755 });
44
+ const originalPath = process.env.PATH;
45
+ process.env.PATH = `${binDir}:${originalPath ?? ""}`;
46
+ const fetchedIdentity = await fetchTelegramBotIdentity("100000:abcdefghijklmnopqrstuvwxyz_123456");
47
+ assert.deepEqual(fetchedIdentity, { id: 100000, username: "bootstrap_test_bot" });
48
+ const curlArgs = await fs.readFile(curlArgsPath, "utf8");
49
+ assert.match(curlArgs, /^-4$/m);
50
+ assert.match(curlArgs, /\/getMe$/m);
51
+ const detectedOwner = await waitForTelegramOwner(
52
+ "100000:abcdefghijklmnopqrstuvwxyz_123456",
53
+ "ra_selftest",
54
+ 2_000,
55
+ );
56
+ assert.deepEqual(detectedOwner, {
57
+ id: "8202993989",
58
+ username: "roy",
59
+ displayName: "Roy",
60
+ });
61
+ const ownerCurlArgs = await fs.readFile(curlArgsPath, "utf8");
62
+ assert.match(ownerCurlArgs, /^-4$/m);
63
+ assert.match(ownerCurlArgs, /^offset=42$/m);
64
+ process.env.PATH = originalPath;
65
+
18
66
  await fs.mkdir(sourceDataDir, { recursive: true });
19
67
  await fs.writeFile(path.join(sourceDataDir, ".env"), [
20
68
  "TELEGRAM_BOT_TOKEN=your-telegram-bot-token",