arena-bridge 0.6.10 → 0.7.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ All notable changes to this project are documented here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/) and the project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.7.1
8
+
9
+ ### Fixed
10
+ - The Telegram listener no longer gives up on a long poll when the network is slow: the request
11
+ ceiling is now generous enough for the 20-second long poll to answer.
12
+
13
+ ## 0.7.0
14
+
15
+ ### Added
16
+ - **Telegram link.** The bridge can now talk to the user outside the agent chat:
17
+ - `notify_user` — send a message (progress, a question, a result); with
18
+ `{"wait_for_reply": true}` the call waits for the answer in Telegram.
19
+ - `telegram_inbox` — read the messages the user sent to the bot.
20
+ - `telegram_status` — the bot, the allowed chats, the number of unread messages.
21
+ - `arena-bridge telegram setup|test|status|inbox|poll` — the same link from the terminal.
22
+ - **Approvals from Telegram.** A pending request is announced in the chat right away, and
23
+ the user can settle it with `/approve <id>` or `/deny <id>` (the terminal still works:
24
+ `arena-bridge approve <id>`).
25
+ - The bridge listens for incoming bot messages on its own (the launchd service does it
26
+ automatically); the inbox lives in `~/.arena-bridge/telegram/`.
27
+
28
+ ### Security
29
+ - The bot token stays in `~/.arena-bridge/config.json` (0600), which the agent cannot read.
30
+ - The bridge may only write to the chats listed in `--chat-id`; 20 messages per minute,
31
+ 4000 characters per message.
32
+
7
33
  ## 0.6.10
8
34
 
9
35
  ### Fixed
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
  <img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square" alt="license">
13
13
  <img src="https://img.shields.io/badge/node-%3E%3D18-green.svg?style=flat-square" alt="node">
14
14
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen.svg?style=flat-square" alt="dependencies">
15
- <img src="https://img.shields.io/badge/tests-124%20passed-brightgreen.svg?style=flat-square" alt="tests">
15
+ <img src="https://img.shields.io/badge/tests-147%20passed-brightgreen.svg?style=flat-square" alt="tests">
16
16
  </p>
17
17
 
18
18
  <p align="center">
@@ -100,6 +100,7 @@ arena-bridge setup --root ~/projects
100
100
  | Процессы | `start_process` `read_process_output` `write_process` `kill_process` | фоновые дев-серверы |
101
101
  | Chrome | `chrome_launch` `chrome_profiles` `chrome_attach` `chrome_open` `chrome_navigate` `chrome_snapshot` `chrome_click` `chrome_type` `chrome_press` `chrome_screenshot` `chrome_evaluate` `chrome_tabs` `chrome_stop` | браузер через CDP |
102
102
  | Десктоп | `mac_see` `mac_element` `mac_click` `mac_set` `mac_type` `mac_key` `mac_scroll` `mac_move` `mac_drag` `mac_menu` `mac_menu_click` `mac_wait` `mac_spaces` `mac_space_switch` `mac_apps` `mac_windows` `mac_app_launch` `mac_app_focus` `mac_app_quit` `mac_permissions` | macOS через Peekaboo |
103
+ | Telegram | `notify_user` `telegram_inbox` `telegram_status` | сообщения вам и ваши ответы |
103
104
 
104
105
  Полный каталог с JSON-схемами: `GET /tools` или `arena-bridge tools`.
105
106
 
@@ -118,6 +119,7 @@ arena-bridge setup --root ~/projects
118
119
  | кликнуть в нативном приложении | `mac_click` (id элемента, видимый текст или координаты) |
119
120
  | напечатать | `mac_type`, затем `mac_key` для Enter/Tab/cmd+s |
120
121
  | посмотреть файлы | `list_dir` (только верхний уровень; глубже — `{"recursive":true}`) |
122
+ | написать вам или спросить | `notify_user` (Telegram), ответ читать через `telegram_inbox` |
121
123
 
122
124
  ---
123
125
 
@@ -219,6 +221,43 @@ arena-bridge audit dir # где всё это лежит
219
221
 
220
222
  ---
221
223
 
224
+ ## Связь с Telegram
225
+
226
+ Агент может писать вам в Telegram и читать ваши ответы — даже если вы сейчас не в чате с ним.
227
+
228
+ ```bash
229
+ # один раз: привязать бота (токен от @BotFather и ваш chat id)
230
+ arena-bridge telegram setup --token 123456:AA... --chat-id 190771108
231
+
232
+ # проверить связь
233
+ arena-bridge telegram test
234
+
235
+ # посмотреть, что вам писали
236
+ arena-bridge telegram inbox
237
+ ```
238
+
239
+ Что это даёт:
240
+ - `notify_user` — агент пишет вам: «сделал», «нужно ваше решение», «вот результат».
241
+ С `{"wait_for_reply": true}` вызов ждёт вашего ответа прямо в Telegram.
242
+ - `telegram_inbox` — агент читает то, что вы написали боту: ответы, правки, поручения.
243
+ - `telegram_status` — бот, список чатов, число непрочитанных сообщений.
244
+ - **Подтверждения из Telegram.** Когда агенту нужно ваше «да», мост сам пишет вам:
245
+ `/approve <id>` — разрешить, `/deny <id>` — отказать. Из терминала тоже можно:
246
+ `arena-bridge approve <id>`.
247
+
248
+ Как это устроено и что защищено:
249
+ - токен бота лежит только в `~/.arena-bridge/config.json` (права `0600`); агент этот файл
250
+ прочитать не может — он в списке защищённых путей;
251
+ - писать можно только в те чаты, что перечислены в `--chat-id` (по умолчанию — ваш);
252
+ - не больше 20 сообщений в минуту, одно сообщение — до 4000 символов;
253
+ - входящие сообщения мост слушает сам (служба launchd — автоматически), входящие лежат
254
+ в `~/.arena-bridge/telegram/`.
255
+
256
+ Одно уточнение: для бота `api_id`/`api_hash` не нужны — они пригодятся только для режима
257
+ «личного аккаунта» (MTProto), который пока не включён.
258
+
259
+ ---
260
+
222
261
  ## Доверенное окно: работать без подтверждений
223
262
 
224
263
  ```bash
@@ -319,6 +358,7 @@ arena-bridge audit -n 50 # что происходило
319
358
  | `service` | macOS-фоновый сервис: `install` / `uninstall` / `status` |
320
359
  | `browser` | профили браузера агента: `list` / `open <name>` / `close <name>` |
321
360
  | `rotate` | сменить агентский или девайсный токен |
361
+ | `telegram` | связь с Telegram: `setup` / `test` / `status` / `inbox` / `poll` |
322
362
 
323
363
  ---
324
364
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arena-bridge",
3
- "version": "0.6.10",
3
+ "version": "0.7.1",
4
4
  "description": "Give a cloud AI agent (Arena.ai Agent Mode, Claude, Cursor, ...) controlled access to your own machine over HTTP/MCP: files, shell, Chrome and the macOS desktop, with an approval gate, trust windows and an audit log.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/approvals.mjs CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { APPROVALS_DIR, ensureDirs } from './config.mjs';
5
+ import { sendTelegram } from './telegram.mjs';
5
6
 
6
7
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
7
8
 
@@ -74,6 +75,17 @@ export async function requestApproval(tool, args, cfg) {
74
75
  };
75
76
  fs.writeFileSync(fileOf(id), JSON.stringify(rec, null, 2), { mode: 0o600 });
76
77
 
78
+ // tell the user about it right away, so a pending request is not a dead end
79
+ if (cfg.telegram?.enabled && cfg.telegram?.botToken) {
80
+ sendTelegram(cfg, {
81
+ title: 'Approval needed',
82
+ text:
83
+ `Tool: ${tool}\n` +
84
+ `id: ${id}\n` +
85
+ `Reply /approve ${id} or /deny ${id} in Telegram, or run: arena-bridge approve ${id}`,
86
+ }).catch(() => {});
87
+ }
88
+
77
89
  const waitSec = Math.min(cfg.approvalWaitSec ?? 90, cfg.approvalTtlSec ?? 900);
78
90
  const deadline = now + waitSec * 1000;
79
91
  while (Date.now() < deadline) {
package/src/cli.mjs CHANGED
@@ -17,6 +17,13 @@ import { findPeekaboo } from './mac.mjs';
17
17
  import { findChrome, listProfiles, resolveProfile, sanitizeProfile } from './browser.mjs';
18
18
  import { packageInfo, latestVersion, compareVersions, selfUpdate } from './selfupdate.mjs';
19
19
  import { serviceStatus, serviceInstall, serviceUninstall } from './service.mjs';
20
+ import {
21
+ sendTelegram,
22
+ telegramSettings,
23
+ readInbox,
24
+ inboxState,
25
+ startPoller,
26
+ } from './telegram.mjs';
20
27
 
21
28
  const REPEATABLE_FLAGS = new Set(['root']);
22
29
 
@@ -74,6 +81,12 @@ Commands:
74
81
  --paths p1,p2 only these paths (bash is not covered)
75
82
  --off | --status
76
83
  audit [-n N] Show the audit log
84
+ telegram Talk to the user in Telegram
85
+ setup --token <bot token> --chat-id <id>
86
+ test send a test message
87
+ status show the bot, the chats, the unread count
88
+ inbox [-n N] show the messages the user sent
89
+ poll listen for messages in the foreground
77
90
  doctor Check the installation and configuration
78
91
  url Print the public URL the agent should use
79
92
  version Show the installed version and the latest one on npm
@@ -583,6 +596,85 @@ function cmdRotate(args) {
583
596
  );
584
597
  }
585
598
 
599
+ async function cmdTelegram(args) {
600
+ const cfg = loadConfig();
601
+ const sub = args._[1] || 'status';
602
+
603
+ if (sub === 'setup') {
604
+ const t = { ...(cfg.telegram || {}) };
605
+ if (args.flags.token) t.botToken = String(args.flags.token);
606
+ if (args.flags['chat-id']) {
607
+ t.chatIds = String(args.flags['chat-id'])
608
+ .split(',')
609
+ .map((v) => v.trim())
610
+ .filter(Boolean);
611
+ }
612
+ if (args.flags['api-id']) t.apiId = String(args.flags['api-id']);
613
+ if (args.flags['api-hash']) t.apiHash = String(args.flags['api-hash']);
614
+ if (args.flags['no-poll']) t.poll = false;
615
+ if (args.flags.poll) t.poll = true;
616
+ t.enabled = !!(t.botToken && (t.chatIds || []).length);
617
+ cfg.telegram = t;
618
+ saveConfig(cfg);
619
+ console.log(t.enabled ? '✓ Telegram configured' : '✗ both --token and --chat-id are required');
620
+ if (t.enabled) {
621
+ const res = await sendTelegram(cfg, {
622
+ title: 'arena-bridge',
623
+ text: 'Telegram is connected. The agent can now message you and read your answers.',
624
+ });
625
+ console.log(res.ok ? '✓ test message sent' : `✗ ${res.error}: ${res.reason || ''}`);
626
+ }
627
+ return;
628
+ }
629
+
630
+ if (sub === 'test') {
631
+ const res = await sendTelegram(cfg, {
632
+ title: 'arena-bridge',
633
+ text: 'Test message from the bridge. If you can read this, the link works.',
634
+ });
635
+ console.log(res.ok ? '✓ message sent' : `✗ ${res.error}: ${res.reason || ''}`);
636
+ if (!res.ok) process.exitCode = 1;
637
+ return;
638
+ }
639
+
640
+ if (sub === 'inbox') {
641
+ const limit = Math.min(100, Number(args.flags.n) || 20);
642
+ const items = readInbox({ limit });
643
+ if (!items.length) console.log('(no messages yet)');
644
+ for (const i of items) {
645
+ console.log(`#${i.id} ${i.from} ${i.read ? '' : '(new) '}${i.text}`);
646
+ }
647
+ return;
648
+ }
649
+
650
+ if (sub === 'poll') {
651
+ const s = telegramSettings(cfg);
652
+ if (!s.enabled) {
653
+ console.log('✗ Telegram is not configured. Run: arena-bridge telegram setup --token <bot token> --chat-id <id>');
654
+ process.exitCode = 1;
655
+ return;
656
+ }
657
+ console.log('listening for Telegram messages (Ctrl+C to stop)…');
658
+ const poller = startPoller(cfg, {
659
+ onMessage: async (item) => console.log(`#${item.id} ${item.from}: ${item.text}`),
660
+ });
661
+ await new Promise(() => {});
662
+ return poller;
663
+ }
664
+
665
+ const s = telegramSettings(cfg);
666
+ const st = inboxState();
667
+ console.log(`enabled : ${s.enabled}`);
668
+ console.log(`bot : ${st.bot?.username ? '@' + st.bot.username : '(unknown)'}`);
669
+ console.log(`chats : ${s.chatIds.join(', ') || '(none)'}`);
670
+ console.log(`polling : ${s.poll}`);
671
+ console.log(`messages : ${st.total} (${st.unread} unread)`);
672
+ if (st.lastError) console.log(`last error: ${st.lastError}`);
673
+ if (!s.enabled) {
674
+ console.log('\nRun: arena-bridge telegram setup --token <bot token> --chat-id <your id>');
675
+ }
676
+ }
677
+
586
678
  function cmdDoctor() {
587
679
  const cfg = loadConfig();
588
680
  const checks = [];
@@ -638,6 +730,8 @@ export async function main(argv = process.argv.slice(2)) {
638
730
  return cmdApprove(args);
639
731
  case 'audit':
640
732
  return cmdAudit(args);
733
+ case 'telegram':
734
+ return cmdTelegram(args);
641
735
  case 'rotate':
642
736
  return cmdRotate(args);
643
737
  case 'trust':
package/src/config.mjs CHANGED
@@ -48,6 +48,14 @@ export const DEFAULTS = {
48
48
  maxUploadBytes: 16 * 1024 * 1024,
49
49
  commandTimeoutSec: 120,
50
50
  audit: true,
51
+ telegram: {
52
+ enabled: false, // notify the user in Telegram
53
+ botToken: null, // bot token from @BotFather
54
+ chatIds: [], // chats the bridge may write to
55
+ poll: true, // listen for incoming messages and approval commands
56
+ apiId: null, // optional MTProto app id (not used by the bot)
57
+ apiHash: null, // optional MTProto app hash (not used by the bot)
58
+ },
51
59
  };
52
60
 
53
61
  export function expandHome(p) {
package/src/direct.mjs CHANGED
@@ -7,6 +7,7 @@ import { listApprovals, decide } from './approvals.mjs';
7
7
  import { checkPath } from './policy.mjs';
8
8
  import { loadConfig } from './config.mjs';
9
9
  import { tailAudit, readArtifact, auditDir } from './audit.mjs';
10
+ import { startPoller, parseCommand, sendTelegram } from './telegram.mjs';
10
11
 
11
12
  export function createDirectServer(cfg, engine) {
12
13
  const mcpCtx = {
@@ -15,6 +16,27 @@ export function createDirectServer(cfg, engine) {
15
16
  callTool: (name, args) => engine.call(name, args),
16
17
  };
17
18
 
19
+ const handleTelegramMessage = async (item) => {
20
+ const cmd = parseCommand(item.text);
21
+ if (!cmd) return;
22
+ const current = loadConfig();
23
+ const say = (text) => sendTelegram(current, { text, chatId: item.chat_id });
24
+ if ((cmd.cmd === 'approve' || cmd.cmd === 'deny') && cmd.args) {
25
+ const res = decide(cmd.args, cmd.cmd === 'approve' ? 'approve' : 'deny', 'telegram');
26
+ await say(res.ok ? `${cmd.cmd === 'approve' ? 'Approved' : 'Denied'} ${cmd.args}` : `Cannot ${cmd.cmd}: ${res.error}`);
27
+ return;
28
+ }
29
+ if (cmd.cmd === 'status') {
30
+ const pending = listApprovals().filter((a) => a.status === 'pending').length;
31
+ await say(`arena-bridge on ${cfg.device}\nPending approvals: ${pending}`);
32
+ return;
33
+ }
34
+ if (cmd.cmd === 'help') {
35
+ await say('Commands: /approve <id>, /deny <id>, /status');
36
+ return;
37
+ }
38
+ };
39
+
18
40
  const server = http.createServer(async (req, res) => {
19
41
  let url;
20
42
  try {
@@ -30,7 +52,7 @@ export function createDirectServer(cfg, engine) {
30
52
  ok: true,
31
53
  mode: 'direct',
32
54
  device: cfg.device,
33
- version: '0.6.10',
55
+ version: '0.7.1',
34
56
  uptime_sec: Math.round(process.uptime()),
35
57
  });
36
58
  }
@@ -60,6 +82,10 @@ export function createDirectServer(cfg, engine) {
60
82
  allowShell: cfg.allowShell,
61
83
  approvalWaitSec: cfg.approvalWaitSec,
62
84
  maxOutputBytes: cfg.maxOutputBytes,
85
+ telegram: {
86
+ enabled: !!cfg.telegram?.enabled,
87
+ chat_ids: cfg.telegram?.chatIds || [],
88
+ },
63
89
  });
64
90
  }
65
91
 
@@ -152,5 +178,8 @@ export function createDirectServer(cfg, engine) {
152
178
  }
153
179
  });
154
180
 
181
+ const poller = startPoller(cfg, { onMessage: handleTelegramMessage });
182
+ server.on('close', () => poller?.stop());
183
+
155
184
  return server;
156
185
  }
package/src/engine.mjs CHANGED
@@ -10,6 +10,7 @@ import { loadConfig, CONFIG_PATH } from './config.mjs';
10
10
  import { BrowserManager } from './browser.mjs';
11
11
  import { macTools } from './mac.mjs';
12
12
  import { trustState, trustAllows } from './trust.mjs';
13
+ import { telegramTools } from './telegram.mjs';
13
14
 
14
15
  /* ------------------------------------------------------------------ *
15
16
  * helpers
@@ -630,7 +631,7 @@ export class Engine {
630
631
  }
631
632
 
632
633
  catalog() {
633
- return [...toolCatalog(), ...this.browser.tools(), ...macTools(this.cfg)];
634
+ return [...toolCatalog(), ...this.browser.tools(), ...macTools(this.cfg), ...telegramTools(this.cfg)];
634
635
  }
635
636
 
636
637
  async call(name, args = {}, { requireApproval } = {}) {
package/src/hub.mjs CHANGED
@@ -90,7 +90,7 @@ export function createHubServer(cfg) {
90
90
  return sendJson(res, 200, {
91
91
  ok: true,
92
92
  mode: 'hub',
93
- version: '0.6.10',
93
+ version: '0.7.1',
94
94
  devices: [...devices.keys()],
95
95
  });
96
96
  }
package/src/mcp.mjs CHANGED
@@ -48,7 +48,7 @@ export async function handleMcp(req, res, ctx) {
48
48
  mcpResponse(id, {
49
49
  protocolVersion: PROTOCOL_VERSION,
50
50
  capabilities: { tools: { listChanged: false } },
51
- serverInfo: { name: 'arena-bridge', version: '0.6.10' },
51
+ serverInfo: { name: 'arena-bridge', version: '0.7.1' },
52
52
  instructions:
53
53
  'Tools operate the user\'s own machine. Dangerous operations may return ' +
54
54
  'PROTECTED and require a local human approval.',
package/src/prompt.mjs CHANGED
@@ -65,6 +65,7 @@ ${tools.join('\n')}
65
65
  | wait for a control to appear or vanish | \`mac_wait\` |
66
66
  | work on a separate virtual desktop | \`mac_spaces\`, \`mac_space_switch\` |
67
67
  | look at files | \`list_dir\` (top level only; add \`{"recursive":true}\` to go deeper), \`read_file\`, \`search_files\` |
68
+ | tell the user something or ask a question | \`notify_user\` (Telegram), \`telegram_inbox\` to read the answer |
68
69
 
69
70
  ## Browser control (Chrome over CDP)
70
71
  1. \`chrome_launch\` starts a browser that belongs to you — own profile, no personal data.
@@ -104,6 +105,15 @@ the accessibility trees that were read are stored next to the log.
104
105
  - \`audit_artifact\` — fetch a saved artifact: a screenshot (base64 PNG) or an accessibility tree (JSON).
105
106
  Use them when you need to look back at what was on the screen or what the interface contained.
106
107
 
108
+ ## Talking to the user (Telegram)
109
+ When Telegram is configured for this bridge, you can reach the user outside this chat:
110
+ - \`notify_user\` — send a message (progress, a question, a finished result).
111
+ Add \`{"wait_for_reply": true}\` to keep the call open until the user answers in Telegram.
112
+ - \`telegram_inbox\` — read the messages the user sent in Telegram (answers, requests, corrections).
113
+ - \`telegram_status\` — the bot, the allowed chats, the number of unread messages.
114
+ - The user can settle a pending approval from Telegram: \`/approve <approval_id>\` or \`/deny <approval_id>\`.
115
+ Use it when a step takes long or when you need a decision: one message is enough, no need to repeat it.
116
+
107
117
  ## Approvals
108
118
  ${trustLine}
109
119
  - When a call answers \`APPROVAL_PENDING\` it carries an \`approval_id\`: tell the user to run
@@ -0,0 +1,446 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CONFIG_DIR, ensureDirs, loadConfig } from './config.mjs';
4
+
5
+ const DEFAULT_BASE = 'https://api.telegram.org';
6
+ const MAX_TEXT = 4000;
7
+ const RATE_LIMIT = 20;
8
+ const RATE_WINDOW_MS = 60_000;
9
+
10
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11
+
12
+ /* ------------------------------------------------------------------ *
13
+ * settings
14
+ * ------------------------------------------------------------------ */
15
+
16
+ export function telegramSettings(cfg = {}) {
17
+ const t = cfg.telegram || {};
18
+ const baseUrl = String(t.baseUrl || process.env.TELEGRAM_API_BASE || DEFAULT_BASE).replace(/\/+$/, '');
19
+ return {
20
+ enabled: !!t.enabled && !!t.botToken,
21
+ botToken: t.botToken || null,
22
+ chatIds: (t.chatIds || []).map((v) => String(v)),
23
+ baseUrl,
24
+ poll: t.poll !== false,
25
+ apiId: t.apiId || null,
26
+ apiHash: t.apiHash || null,
27
+ };
28
+ }
29
+
30
+ export function telegramDir() {
31
+ return path.join(CONFIG_DIR, 'telegram');
32
+ }
33
+
34
+ export function inboxPath() {
35
+ return path.join(telegramDir(), 'inbox.jsonl');
36
+ }
37
+
38
+ export function statePath() {
39
+ return path.join(telegramDir(), 'state.json');
40
+ }
41
+
42
+ function ensureTelegramDir() {
43
+ ensureDirs();
44
+ fs.mkdirSync(telegramDir(), { recursive: true, mode: 0o700 });
45
+ }
46
+
47
+ function readState() {
48
+ try {
49
+ return JSON.parse(fs.readFileSync(statePath(), 'utf8'));
50
+ } catch {
51
+ return {};
52
+ }
53
+ }
54
+
55
+ function writeState(state) {
56
+ ensureTelegramDir();
57
+ fs.writeFileSync(statePath(), JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
58
+ }
59
+
60
+ /* ------------------------------------------------------------------ *
61
+ * bot api
62
+ * ------------------------------------------------------------------ */
63
+
64
+ export async function apiCall(cfg, method, payload = {}) {
65
+ const s = telegramSettings(cfg);
66
+ if (!s.botToken) {
67
+ return {
68
+ ok: false,
69
+ error: 'telegram_not_configured',
70
+ reason: 'no bot token in the configuration',
71
+ hint: 'Run: arena-bridge telegram setup --token <bot token> --chat-id <your id>',
72
+ };
73
+ }
74
+ let res;
75
+ let data = null;
76
+ try {
77
+ res = await fetch(`${s.baseUrl}/bot${s.botToken}/${method}`, {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify(payload),
81
+ signal: AbortSignal.timeout(90_000),
82
+ });
83
+ data = await res.json().catch(() => null);
84
+ } catch (e) {
85
+ return { ok: false, error: 'telegram_unreachable', reason: e.message };
86
+ }
87
+ if (!res.ok || !data || data.ok !== true) {
88
+ return {
89
+ ok: false,
90
+ error: 'telegram_api_error',
91
+ reason: data?.description || res.statusText || 'unknown error',
92
+ code: data?.error_code,
93
+ };
94
+ }
95
+ return { ok: true, result: data.result };
96
+ }
97
+
98
+ export function allowedChat(cfg, chatId) {
99
+ const s = telegramSettings(cfg);
100
+ return s.chatIds.includes(String(chatId));
101
+ }
102
+
103
+ const sentAt = [];
104
+
105
+ export function rateOk(limit = RATE_LIMIT, windowMs = RATE_WINDOW_MS) {
106
+ const now = Date.now();
107
+ while (sentAt.length && now - sentAt[0] > windowMs) sentAt.shift();
108
+ if (sentAt.length >= limit) return false;
109
+ sentAt.push(now);
110
+ return true;
111
+ }
112
+
113
+ /**
114
+ * Send a text message to one of the allowed chats.
115
+ */
116
+ export async function sendTelegram(cfg, { text, chatId, title, silent } = {}) {
117
+ const s = telegramSettings(cfg);
118
+ if (!s.enabled) {
119
+ return {
120
+ ok: false,
121
+ error: 'telegram_not_configured',
122
+ reason: 'Telegram is not configured for this bridge',
123
+ hint: 'Run: arena-bridge telegram setup --token <bot token> --chat-id <your id>',
124
+ };
125
+ }
126
+ const target = String(chatId ?? s.chatIds[0] ?? '');
127
+ if (!target) {
128
+ return { ok: false, error: 'telegram_no_chat', reason: 'no chat id configured' };
129
+ }
130
+ if (!allowedChat(cfg, target)) {
131
+ return {
132
+ ok: false,
133
+ error: 'telegram_chat_not_allowed',
134
+ reason: `chat ${target} is not on the allow list`,
135
+ };
136
+ }
137
+ const body = String(text ?? '').trim();
138
+ if (!body) return { ok: false, error: 'telegram_empty_message', reason: 'the message is empty' };
139
+ if (body.length > MAX_TEXT) {
140
+ return {
141
+ ok: false,
142
+ error: 'telegram_message_too_long',
143
+ reason: `the message is ${body.length} characters, the limit is ${MAX_TEXT}`,
144
+ };
145
+ }
146
+ if (!rateOk()) {
147
+ return {
148
+ ok: false,
149
+ error: 'telegram_rate_limited',
150
+ reason: `more than ${RATE_LIMIT} messages per minute`,
151
+ };
152
+ }
153
+ const text_ = title ? `${title}\n${body}` : body;
154
+ let res = await apiCall(cfg, 'sendMessage', {
155
+ chat_id: target,
156
+ text: text_,
157
+ disable_notification: !!silent,
158
+ });
159
+ if (!res.ok && /parse entities|Markdown/i.test(res.reason || '')) {
160
+ res = await apiCall(cfg, 'sendMessage', {
161
+ chat_id: target,
162
+ text: text_,
163
+ disable_notification: !!silent,
164
+ });
165
+ }
166
+ if (!res.ok) return res;
167
+ return {
168
+ ok: true,
169
+ chat_id: res.result?.chat?.id ?? target,
170
+ message_id: res.result?.message_id ?? null,
171
+ sent_at: new Date().toISOString(),
172
+ };
173
+ }
174
+
175
+ /* ------------------------------------------------------------------ *
176
+ * inbox
177
+ * ------------------------------------------------------------------ */
178
+
179
+ export function appendInbox(item) {
180
+ ensureTelegramDir();
181
+ fs.appendFileSync(inboxPath(), JSON.stringify(item) + '\n', { mode: 0o600 });
182
+ }
183
+
184
+ export function readInbox({ limit = 20 } = {}) {
185
+ let raw = '';
186
+ try {
187
+ raw = fs.readFileSync(inboxPath(), 'utf8');
188
+ } catch {
189
+ return [];
190
+ }
191
+ const read = new Set(readState().read || []);
192
+ const items = raw
193
+ .split('\n')
194
+ .filter(Boolean)
195
+ .map((line) => {
196
+ try {
197
+ return JSON.parse(line);
198
+ } catch {
199
+ return null;
200
+ }
201
+ })
202
+ .filter(Boolean);
203
+ return items.slice(-Math.max(1, limit)).map((i) => ({ ...i, read: read.has(String(i.id)) }));
204
+ }
205
+
206
+ export function markInboxRead(ids) {
207
+ const state = readState();
208
+ const read = new Set(state.read || []);
209
+ for (const id of ids || []) read.add(String(id));
210
+ state.read = [...read].slice(-500);
211
+ writeState(state);
212
+ return state.read.length;
213
+ }
214
+
215
+ export function inboxState() {
216
+ const state = readState();
217
+ let total = 0;
218
+ try {
219
+ total = fs.readFileSync(inboxPath(), 'utf8').split('\n').filter(Boolean).length;
220
+ } catch {
221
+ total = 0;
222
+ }
223
+ const read = new Set(state.read || []);
224
+ return {
225
+ total,
226
+ unread: Math.max(0, total - read.size),
227
+ bot: state.bot || null,
228
+ offset: state.offset || 0,
229
+ lastError: state.lastError || null,
230
+ };
231
+ }
232
+
233
+ export function saveBotInfo(info) {
234
+ const state = readState();
235
+ state.bot = info;
236
+ writeState(state);
237
+ }
238
+
239
+ /* ------------------------------------------------------------------ *
240
+ * commands
241
+ * ------------------------------------------------------------------ */
242
+
243
+ /**
244
+ * Parse a chat command like "/approve 1234" into { cmd, args }.
245
+ */
246
+ export function parseCommand(text) {
247
+ const raw = String(text ?? '').trim();
248
+ if (!raw.startsWith('/')) return null;
249
+ const [cmd, ...rest] = raw.slice(1).split(/\s+/);
250
+ return { cmd: cmd.toLowerCase(), args: rest.join(' ').trim() };
251
+ }
252
+
253
+ /* ------------------------------------------------------------------ *
254
+ * poller
255
+ * ------------------------------------------------------------------ */
256
+
257
+ /**
258
+ * Poll the bot for incoming messages and hand them to the callback.
259
+ * Returns a handle with stop().
260
+ */
261
+ export function startPoller(cfg, { onMessage } = {}) {
262
+ const s = telegramSettings(cfg);
263
+ if (!s.enabled || !s.poll) return null;
264
+ let stopped = false;
265
+ let offset = readState().offset || 0;
266
+
267
+ const fresh = () => {
268
+ try {
269
+ return loadConfig();
270
+ } catch {
271
+ return cfg;
272
+ }
273
+ };
274
+
275
+ const loop = async () => {
276
+ if (!readState().bot) {
277
+ const me = await apiCall(fresh(), 'getMe');
278
+ if (me.ok) saveBotInfo(me.result);
279
+ }
280
+ while (!stopped) {
281
+ try {
282
+ const res = await apiCall(fresh(), 'getUpdates', {
283
+ offset,
284
+ timeout: 20,
285
+ allowed_updates: ['message'],
286
+ });
287
+ if (!res.ok) {
288
+ const state = readState();
289
+ state.lastError = res.reason || res.error;
290
+ writeState(state);
291
+ await sleep(res.code === 409 ? 15000 : 5000);
292
+ continue;
293
+ }
294
+ const updates = res.result || [];
295
+ if (!updates.length) {
296
+ await sleep(400);
297
+ continue;
298
+ }
299
+ for (const upd of updates) {
300
+ offset = upd.update_id + 1;
301
+ const state = readState();
302
+ state.offset = offset;
303
+ writeState(state);
304
+ const msg = upd.message;
305
+ if (!msg) continue;
306
+ const item = {
307
+ id: upd.update_id,
308
+ chat_id: msg.chat?.id ?? null,
309
+ from: msg.from?.username || msg.from?.first_name || 'unknown',
310
+ text: msg.text || '',
311
+ date: msg.date || null,
312
+ read: false,
313
+ };
314
+ appendInbox(item);
315
+ if (onMessage) {
316
+ try {
317
+ await onMessage(item);
318
+ } catch {
319
+ /* the callback must not kill the loop */
320
+ }
321
+ }
322
+ }
323
+ } catch {
324
+ await sleep(5000);
325
+ }
326
+ }
327
+ };
328
+
329
+ loop();
330
+ return {
331
+ stop() {
332
+ stopped = true;
333
+ },
334
+ };
335
+ }
336
+
337
+ /* ------------------------------------------------------------------ *
338
+ * tools
339
+ * ------------------------------------------------------------------ */
340
+
341
+ const S = (o) => ({ type: 'object', properties: o, required: [], additionalProperties: false });
342
+
343
+ async function waitForReply(cfg, sinceId, timeoutSec) {
344
+ const deadline = Date.now() + Math.max(1, timeoutSec) * 1000;
345
+ while (Date.now() < deadline) {
346
+ const items = readInbox({ limit: 20 });
347
+ const fresh = items.find((i) => Number(i.id) > sinceId);
348
+ if (fresh) return fresh;
349
+ await sleep(500);
350
+ }
351
+ return null;
352
+ }
353
+
354
+ export function telegramTools(cfg = {}) {
355
+ return [
356
+ {
357
+ name: 'notify_user',
358
+ description:
359
+ 'Send a message to the user in Telegram. Use it to report progress, ask a question, or hand over ' +
360
+ 'a result without waiting for the chat. Set wait_for_reply to keep the call open until the user ' +
361
+ 'answers in Telegram.',
362
+ approval: false,
363
+ schema: S({
364
+ text: { type: 'string', description: 'the message text (up to 4000 characters)' },
365
+ title: { type: 'string', description: 'short first line, e.g. "Готово" or "Нужен ответ"' },
366
+ chat_id: { type: 'string', description: 'target chat id (default: the configured one)' },
367
+ silent: { type: 'boolean', description: 'send without a sound' },
368
+ wait_for_reply: { type: 'boolean', description: 'wait for the user to answer in Telegram' },
369
+ timeout_sec: { type: 'number', description: 'how long to wait for a reply (default 120, max 600)' },
370
+ }),
371
+ required: ['text'],
372
+ handler: async (args, ctx) => {
373
+ const c = ctx?.cfg || cfg;
374
+ const sent = await sendTelegram(c, {
375
+ text: args.text,
376
+ chatId: args.chat_id,
377
+ title: args.title,
378
+ silent: args.silent,
379
+ });
380
+ if (!sent.ok) return sent;
381
+ if (!args.wait_for_reply) return sent;
382
+ const since = Math.max(0, ...readInbox({ limit: 50 }).map((i) => Number(i.id) || 0));
383
+ const reply = await waitForReply(c, since, Math.min(600, Number(args.timeout_sec) || 120));
384
+ if (!reply) {
385
+ return {
386
+ ...sent,
387
+ reply: null,
388
+ timed_out: true,
389
+ hint: 'The user did not answer in Telegram. Continue without the answer or ask again later.',
390
+ };
391
+ }
392
+ markInboxRead([reply.id]);
393
+ return { ...sent, reply: { from: reply.from, text: reply.text, at: reply.date } };
394
+ },
395
+ },
396
+ {
397
+ name: 'telegram_inbox',
398
+ description:
399
+ 'Read the messages the user sent in Telegram. Use it to pick up an answer, a file name or a ' +
400
+ 'command given outside the agent chat.',
401
+ approval: false,
402
+ schema: S({
403
+ limit: { type: 'number', description: 'how many messages to return (default 20)' },
404
+ mark_read: { type: 'boolean', description: 'mark the returned messages as read (default true)' },
405
+ }),
406
+ required: [],
407
+ handler: async (args, ctx) => {
408
+ const c = ctx?.cfg || cfg;
409
+ const limit = Math.min(100, Number(args.limit) || 20);
410
+ const items = readInbox({ limit });
411
+ if (args.mark_read !== false && items.length) markInboxRead(items.map((i) => i.id));
412
+ return { ok: true, count: items.length, messages: items };
413
+ },
414
+ },
415
+ {
416
+ name: 'telegram_status',
417
+ description: 'Show the Telegram link: the bot, the allowed chats and how many messages are unread.',
418
+ approval: false,
419
+ schema: S({}),
420
+ required: [],
421
+ handler: async (args, ctx) => {
422
+ const c = ctx?.cfg || cfg;
423
+ const s = telegramSettings(c);
424
+ if (!s.enabled) {
425
+ return {
426
+ ok: false,
427
+ error: 'telegram_not_configured',
428
+ reason: 'Telegram is not configured for this bridge',
429
+ hint: 'Run: arena-bridge telegram setup --token <bot token> --chat-id <your id>',
430
+ };
431
+ }
432
+ const st = inboxState();
433
+ return {
434
+ ok: true,
435
+ bot: st.bot?.username ? `@${st.bot.username}` : null,
436
+ bot_name: st.bot?.first_name || null,
437
+ chat_ids: s.chatIds,
438
+ total_messages: st.total,
439
+ unread: st.unread,
440
+ polling: s.poll,
441
+ last_error: st.lastError,
442
+ };
443
+ },
444
+ },
445
+ ];
446
+ }