arena-bridge 0.7.3 → 0.7.6

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,39 @@ 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.6
8
+
9
+ ### Added
10
+ - Release artifacts: every tag publishes the npm archive, the python sdist and wheel, and the
11
+ macOS app for arm64 and x86_64 as a GitHub Release.
12
+ - `install.sh`: one command installs the command line tool and the macOS app.
13
+ - `feature-tree.md`: the complete feature tree of the product.
14
+ - The ax-tree artifact opens in the app as a readable list of elements, with a switch to the raw
15
+ answer of the tool.
16
+ - `mac_see` asks for the accessibility tree on its own when the first answer has no elements.
17
+
18
+ ### Changed
19
+ - The README says in one line how to get past Gatekeeper.
20
+
21
+ ## 0.7.5
22
+
23
+ ### Added
24
+ - Approvals in Telegram carry native buttons: Approve once, Approve for 3h, Deny. "Approve for 3h"
25
+ also opens a trust window for the same tool.
26
+ - `arena-bridge trust --status --json` prints the trust window as JSON.
27
+ - The native macOS app: a trust window status with the time left, an "Approve for 3 h" button,
28
+ local notifications when a request needs a decision, the command arguments in the work log,
29
+ a click-through to screenshots and accessibility trees, and a button that copies the agent prompt.
30
+ - The app icon: `macos/ArenaBridge/Resources/AppIcon.png`, turned into an `.icns` by `build.sh`.
31
+
32
+ ## 0.7.4
33
+
34
+ ### Fixed
35
+ - Approvals over HTTP work again: `POST /approvals/<id>` now answers with the decision instead
36
+ of an internal error, the local token is accepted by the API, and the agent token is refused —
37
+ an agent can no longer approve its own dangerous action.
38
+ - The same rule now applies to the hub mode.
39
+
7
40
  ## 0.7.3
8
41
 
9
42
  ### 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-149%20passed-brightgreen.svg?style=flat-square" alt="tests">
15
+ <img src="https://img.shields.io/badge/tests-154%20passed-brightgreen.svg?style=flat-square" alt="tests">
16
16
  </p>
17
17
 
18
18
  <p align="center">
@@ -23,6 +23,8 @@
23
23
 
24
24
  ## Что это
25
25
 
26
+ Полное дерево функциональности — в [feature-tree.md](feature-tree.md).
27
+
26
28
  Облачный агент (Arena.ai Agent Mode, Claude, Cursor, любой MCP-клиент) работает в песочнице
27
29
  с выходом в интернет, но не имеет доступа к вашему компьютеру. `arena-bridge` — это маленький
28
30
  HTTP-сервис на вашей машине: он слушает только localhost, выставляется наружу туннелем
@@ -41,16 +43,31 @@ HTTP-сервис на вашей машине: он слушает только
41
43
 
42
44
  ## Быстрый старт
43
45
 
46
+ Одна команда ставит и командную строку, и нативное приложение для macOS:
47
+
48
+ ```bash
49
+ curl -fsSL https://raw.githubusercontent.com/vernikr/arena-bridge/main/install.sh | sh
50
+ ```
51
+
52
+ Или по отдельности:
53
+
44
54
  ```bash
45
- # 1. установить
46
- npm i -g arena-bridge
55
+ npm i -g arena-bridge # только командная строка
56
+ pip install arena-bridge # то же самое на Python
57
+ ```
47
58
 
48
- # 2. один раз: создать конфиг, проверить машину, поднять API + туннель
59
+ ```bash
60
+ # 1. один раз: создать конфиг, проверить машину, поднять API + туннель
49
61
  arena-bridge setup --root ~/projects --root ~/Documents
50
62
 
51
- # 3. скопировать блок «Paste this into the agent chat» и вставить его в чат с агентом
63
+ # 2. скопировать блок «Paste this into the agent chat» и вставить его в чат с агентом
52
64
  ```
53
65
 
66
+ > **macOS и Gatekeeper.** Приложение подписано локально, поэтому при первом запуске система может
67
+ > сказать «нельзя открыть, так как разработчик не проверен». Обойти это можно без терминала:
68
+ > правый клик по **Arena Bridge** в «Программах» → **Открыть** → **Открыть**. Через терминал:
69
+ > `xattr -dr com.apple.quarantine "/Applications/Arena Bridge.app"`.
70
+
54
71
  `setup` печатает готовый промпт и публичный URL. Дальше — просто говорите агенту задачу.
55
72
 
56
73
  Туннель под присмотром: если `cloudflared`/`ngrok` упадёт, мост поднимет его заново и сам
@@ -258,6 +275,59 @@ arena-bridge telegram inbox
258
275
 
259
276
  ---
260
277
 
278
+ ## Нативное приложение для macOS
279
+
280
+ Окно, в котором всё видно и всё управляется: статус, пуск/стоп, подтверждения, журнал.
281
+
282
+ ```bash
283
+ git clone https://github.com/vernikr/arena-bridge.git
284
+ cd arena-bridge/macos/ArenaBridge
285
+ ./build.sh # → dist/Arena Bridge.app
286
+ cp -R "dist/Arena Bridge.app" /Applications/
287
+ ```
288
+
289
+ Нужен только Swift из Xcode Command Line Tools (`xcode-select --install`), Xcode целиком не
290
+ требуется. Собирается под текущую архитектуру: `ARCH=x86_64` для Intel, `ARCH=arm64` для
291
+ Apple Silicon.
292
+
293
+ | Блок в окне | Что делает |
294
+ | --- | --- |
295
+ | Статус | запущен/остановлен, версия, режим, число инструментов, публичный адрес, уведомления, Telegram |
296
+ | Управление | «Запустить» / «Остановить» (служба `ai.arena.bridge`), «Настроить…», «Скопировать промпт агенту» |
297
+ | Доверенное окно | «Открыть на 3 ч» / «Закрыть» и статус «действует ещё 2:59» |
298
+ | Подтверждения | ожидающие запросы агента с кнопками «Разрешить» / «Разрешить на 3 ч» / «Отклонить» |
299
+ | Журнал | команды с аргументами, клик по скриншоту или ax-tree открывает его целиком, «Сохранить…» в файл (с ax-tree) |
300
+
301
+ Когда агенту нужно решение, приложение показывает системное уведомление macOS
302
+ «Approval needed» — в строке меню и в центре уведомлений. Внутри окна запрос тоже виден
303
+ сразу, в карточке «Подтверждения».
304
+
305
+ «Настроить…» собирает конфиг, службу и запуск одной кнопкой: туннель (ngrok/cloudflared),
306
+ зарезервированный домен, authtoken, папки для агента, Telegram (токен бота и chat id).
307
+
308
+ Иконка приложения лежит в `macos/ArenaBridge/Resources/AppIcon.png`; `build.sh` сам собирает
309
+ из неё `.icns` и кладёт в бандл.
310
+
311
+ ---
312
+
313
+ ## Подтверждения в Telegram: кнопки
314
+
315
+ Под сообщением «Approval needed» появляются нативные кнопки:
316
+
317
+ ```
318
+ Approve once Approve for 3h Deny
319
+ ```
320
+
321
+ * **Approve once** — разрешить этот запрос и всё;
322
+ * **Approve for 3h** — разрешить и открыть доверенное окно на 3 часа для этого же инструмента
323
+ (команды больше не спрашивают);
324
+ * **Deny** — отклонить.
325
+
326
+ Кнопки работают и в приложении на Mac, и в Telegram; сообщение в Telegram после нажатия
327
+ переписывается на итог. Прежние команды `/approve <id>` и `/deny <id>` тоже работают.
328
+
329
+ ---
330
+
261
331
  ## Доверенное окно: работать без подтверждений
262
332
 
263
333
  ```bash
@@ -265,6 +335,7 @@ arena-bridge trust --for 2h # всё, на 2
265
335
  arena-bridge trust --for 30m --tools bash # только команды
266
336
  arena-bridge trust --for 1h --paths ~/projects/my-app # только эта папка
267
337
  arena-bridge trust --status
338
+ arena-bridge trust --status --json # для приложений и скриптов
268
339
  arena-bridge trust --off
269
340
  ```
270
341
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arena-bridge",
3
- "version": "0.7.3",
3
+ "version": "0.7.6",
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,7 +2,8 @@ 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
+ import { sendTelegram, approvalKeyboard } from './telegram.mjs';
6
+ import { saveTrust } from './trust.mjs';
6
7
 
7
8
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
8
9
 
@@ -43,6 +44,24 @@ export function decide(id, decision, by = 'cli') {
43
44
  return { ok: true, approval: rec };
44
45
  }
45
46
 
47
+ /**
48
+ * Settle a request and, when asked, open a trust window for the same tool.
49
+ */
50
+ export function settle(id, decision, by = 'cli', { trustSec = 0 } = {}) {
51
+ const res = decide(id, decision, by);
52
+ if (res.ok && trustSec > 0) {
53
+ const tool = res.approval?.tool;
54
+ saveTrust({
55
+ enabled: true,
56
+ until: Date.now() + trustSec * 1000,
57
+ tools: tool ? [tool] : [],
58
+ paths: [],
59
+ createdAt: Date.now(),
60
+ });
61
+ }
62
+ return res;
63
+ }
64
+
46
65
  function redact(args) {
47
66
  const clone = { ...args };
48
67
  for (const k of Object.keys(clone)) {
@@ -82,7 +101,8 @@ export async function requestApproval(tool, args, cfg) {
82
101
  text:
83
102
  `Tool: ${tool}\n` +
84
103
  `id: ${id}\n` +
85
- `Reply /approve ${id} or /deny ${id} in Telegram, or run: arena-bridge approve ${id}`,
104
+ `Use the buttons below, reply /approve ${id} or /deny ${id}, or run: arena-bridge approve ${id}`,
105
+ buttons: approvalKeyboard(id),
86
106
  }).catch(() => {});
87
107
  }
88
108
 
package/src/cli.mjs CHANGED
@@ -359,6 +359,19 @@ function cmdTrust(args) {
359
359
  return;
360
360
  }
361
361
  if (args.flags.status || (!args.flags.for && !args.flags.tools && !args.flags.paths)) {
362
+ if (args.flags.json) {
363
+ const remaining = state && state.active ? Math.max(0, Math.round((state.until - Date.now()) / 1000)) : 0;
364
+ console.log(
365
+ JSON.stringify({
366
+ enabled: !!(state && state.active),
367
+ until: state && state.active ? state.until : null,
368
+ remaining_sec: remaining,
369
+ tools: state ? state.tools : [],
370
+ paths: state ? state.paths : [],
371
+ }),
372
+ );
373
+ return;
374
+ }
362
375
  if (!state || !state.active) {
363
376
  console.log('no active trust window (every side-effecting action needs approval)');
364
377
  } else {
package/src/direct.mjs CHANGED
@@ -3,11 +3,11 @@ import fsp from 'node:fs/promises';
3
3
  import { sendJson, sendText, sendRaw, readJson, readBody, isAuthorized, bearer, safeEqual } from './http.mjs';
4
4
  import { handleMcp } from './mcp.mjs';
5
5
  import { buildPrompt } from './prompt.mjs';
6
- import { listApprovals, decide } from './approvals.mjs';
6
+ import { listApprovals, decide, settle } 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
+ import { startPoller, parseCommand, sendTelegram, answerCallback, editMessageText } from './telegram.mjs';
11
11
 
12
12
  export function createDirectServer(cfg, engine) {
13
13
  const mcpCtx = {
@@ -37,6 +37,27 @@ export function createDirectServer(cfg, engine) {
37
37
  }
38
38
  };
39
39
 
40
+ const handleTelegramCallback = async (cb) => {
41
+ const current = loadConfig();
42
+ const [action, id] = String(cb.data || '').split(':');
43
+ if (!id || !['approve', 'approve3h', 'deny'].includes(action)) {
44
+ await answerCallback(current, cb.callback_id, 'Unknown action');
45
+ return;
46
+ }
47
+ const trustSec = action === 'approve3h' ? 3 * 3600 : 0;
48
+ const res = settle(id, action === 'deny' ? 'deny' : 'approve', 'telegram', { trustSec });
49
+ const tool = res.approval?.tool || 'the tool';
50
+ const text = !res.ok
51
+ ? `Cannot ${action}: ${res.error || 'unknown error'}`
52
+ : action === 'deny'
53
+ ? `Denied ${id}`
54
+ : trustSec
55
+ ? `Approved ${id} — ${tool} is trusted for the next 3 hours`
56
+ : `Approved ${id}`;
57
+ await answerCallback(current, cb.callback_id, res.ok ? 'Done' : 'Error');
58
+ await editMessageText(current, { chatId: cb.chat_id, messageId: cb.message_id, text });
59
+ };
60
+
40
61
  const server = http.createServer(async (req, res) => {
41
62
  let url;
42
63
  try {
@@ -52,7 +73,7 @@ export function createDirectServer(cfg, engine) {
52
73
  ok: true,
53
74
  mode: 'direct',
54
75
  device: cfg.device,
55
- version: '0.7.3',
76
+ version: '0.7.6',
56
77
  uptime_sec: Math.round(process.uptime()),
57
78
  });
58
79
  }
@@ -118,10 +139,20 @@ export function createDirectServer(cfg, engine) {
118
139
 
119
140
  const approveMatch = /^\/approvals\/([\w-]+)$/.exec(p);
120
141
  if (req.method === 'POST' && approveMatch) {
142
+ // settling a request is a local-only privilege: the agent token must not self-approve
143
+ const expected = loadConfig().localToken;
144
+ const presented = bearer(req);
145
+ if (!expected || !presented || !safeEqual(presented, expected)) {
146
+ return sendJson(res, 403, {
147
+ ok: false,
148
+ error: 'forbidden',
149
+ reason: 'approvals require the local token — run "arena-bridge approve <id>" on the machine',
150
+ });
151
+ }
121
152
  const body = await readJson(req, 64 * 1024);
122
153
  const decision = body.decision === 'deny' ? 'deny' : 'approve';
123
154
  const res2 = decide(approveMatch[1], decision, 'api');
124
- return sendJson(res2.ok ? 200 : 400, res2);
155
+ return sendJson(res, res2.ok ? 200 : 400, res2);
125
156
  }
126
157
 
127
158
  if (req.method === 'POST' && p === '/exec') {
@@ -178,7 +209,7 @@ export function createDirectServer(cfg, engine) {
178
209
  }
179
210
  });
180
211
 
181
- const poller = startPoller(cfg, { onMessage: handleTelegramMessage });
212
+ const poller = startPoller(cfg, { onMessage: handleTelegramMessage, onCallback: handleTelegramCallback });
182
213
  server.on('close', () => poller?.stop());
183
214
 
184
215
  return server;
package/src/http.mjs CHANGED
@@ -72,6 +72,8 @@ export function bearer(req) {
72
72
 
73
73
  export function isAuthorized(req, cfg) {
74
74
  const token = bearer(req);
75
- if (!token || !cfg.token) return false;
76
- return safeEqual(token, cfg.token);
75
+ if (!token) return false;
76
+ if (cfg.token && safeEqual(token, cfg.token)) return true;
77
+ // the local token is for the machine owner: approvals and trust
78
+ return !!(cfg.localToken && safeEqual(token, cfg.localToken));
77
79
  }
package/src/hub.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import http from 'node:http';
2
2
  import crypto from 'node:crypto';
3
- import { sendJson, sendText, readJson, readBody, isAuthorized, safeEqual } from './http.mjs';
3
+ import { sendJson, sendText, readJson, readBody, isAuthorized, bearer, safeEqual } from './http.mjs';
4
4
  import { handleMcp } from './mcp.mjs';
5
5
  import { buildPrompt } from './prompt.mjs';
6
6
  import { toolCatalog } from './engine.mjs';
@@ -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.7.3',
93
+ version: '0.7.6',
94
94
  devices: [...devices.keys()],
95
95
  });
96
96
  }
@@ -158,10 +158,20 @@ export function createHubServer(cfg) {
158
158
 
159
159
  const approveMatch = /^\/approvals\/([\w-]+)$/.exec(p);
160
160
  if (req.method === 'POST' && approveMatch) {
161
+ // settling a request is a local-only privilege: the agent token must not self-approve
162
+ const expected = loadConfig().localToken;
163
+ const presented = bearer(req);
164
+ if (!expected || !presented || !safeEqual(presented, expected)) {
165
+ return sendJson(res, 403, {
166
+ ok: false,
167
+ error: 'forbidden',
168
+ reason: 'approvals require the local token — run "arena-bridge approve <id>" on the machine',
169
+ });
170
+ }
161
171
  const body = await readJson(req, 64 * 1024);
162
172
  const decision = body.decision === 'deny' ? 'deny' : 'approve';
163
173
  const r = decide(approveMatch[1], decision, 'api');
164
- return sendJson(r.ok ? 200 : 400, r);
174
+ return sendJson(res, r.ok ? 200 : 400, r);
165
175
  }
166
176
 
167
177
  if (req.method === 'POST' && p === '/exec') {
package/src/mac.mjs CHANGED
@@ -218,6 +218,18 @@ export function macTools(cfg = {}) {
218
218
  const denied = isPermissionError(json, res);
219
219
  if (res.exit_code !== 0 && !denied && !UNKNOWN.test(res.stderr || '')) break;
220
220
  }
221
+ // no elements yet: ask for the accessibility tree on its own
222
+ if (!best || !best.elements.length) {
223
+ for (const variant of [
224
+ ['see', '--tree', '--no-screenshot', '--json', ...targetFlags(args)],
225
+ ['see', '--tree', '--no-screenshot', '--no-remote', '--json', ...targetFlags(args)],
226
+ ]) {
227
+ const res = await run(peek(), variant, { timeout: 60 });
228
+ const json = parseJson(res.stdout);
229
+ const elements = collectElements(json);
230
+ if (elements.length) return { res, json, elements, variant };
231
+ }
232
+ }
221
233
  return best;
222
234
  };
223
235
 
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.7.3' },
51
+ serverInfo: { name: 'arena-bridge', version: '0.7.6' },
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/telegram.mjs CHANGED
@@ -110,10 +110,23 @@ export function rateOk(limit = RATE_LIMIT, windowMs = RATE_WINDOW_MS) {
110
110
  return true;
111
111
  }
112
112
 
113
+ /**
114
+ * The inline keyboard under an approval message: approve once, approve for 3 hours, deny.
115
+ */
116
+ export function approvalKeyboard(id) {
117
+ return [
118
+ [
119
+ { text: 'Approve once', callback_data: `approve:${id}` },
120
+ { text: 'Approve for 3h', callback_data: `approve3h:${id}` },
121
+ ],
122
+ [{ text: 'Deny', callback_data: `deny:${id}` }],
123
+ ];
124
+ }
125
+
113
126
  /**
114
127
  * Send a text message to one of the allowed chats.
115
128
  */
116
- export async function sendTelegram(cfg, { text, chatId, title, silent } = {}) {
129
+ export async function sendTelegram(cfg, { text, chatId, title, silent, buttons } = {}) {
117
130
  const s = telegramSettings(cfg);
118
131
  if (!s.enabled) {
119
132
  return {
@@ -151,17 +164,16 @@ export async function sendTelegram(cfg, { text, chatId, title, silent } = {}) {
151
164
  };
152
165
  }
153
166
  const text_ = title ? `${title}\n${body}` : body;
154
- let res = await apiCall(cfg, 'sendMessage', {
167
+ const markup = buttons && buttons.length ? { inline_keyboard: buttons } : undefined;
168
+ const payload = {
155
169
  chat_id: target,
156
170
  text: text_,
157
171
  disable_notification: !!silent,
158
- });
172
+ ...(markup ? { reply_markup: markup } : {}),
173
+ };
174
+ let res = await apiCall(cfg, 'sendMessage', payload);
159
175
  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
- });
176
+ res = await apiCall(cfg, 'sendMessage', payload);
165
177
  }
166
178
  if (!res.ok) return res;
167
179
  return {
@@ -172,6 +184,29 @@ export async function sendTelegram(cfg, { text, chatId, title, silent } = {}) {
172
184
  };
173
185
  }
174
186
 
187
+ /**
188
+ * Acknowledge a button press so Telegram stops showing the spinner.
189
+ */
190
+ export async function answerCallback(cfg, callbackId, text = '') {
191
+ if (!callbackId) return { ok: false, error: 'telegram_no_callback' };
192
+ const payload = { callback_query_id: callbackId };
193
+ if (text) payload.text = String(text).slice(0, 200);
194
+ return apiCall(cfg, 'answerCallbackQuery', payload);
195
+ }
196
+
197
+ /**
198
+ * Replace the text of a message and drop its keyboard.
199
+ */
200
+ export async function editMessageText(cfg, { chatId, messageId, text } = {}) {
201
+ if (!chatId || !messageId) return { ok: false, error: 'telegram_no_message' };
202
+ return apiCall(cfg, 'editMessageText', {
203
+ chat_id: chatId,
204
+ message_id: messageId,
205
+ text: String(text ?? ''),
206
+ reply_markup: { inline_keyboard: [] },
207
+ });
208
+ }
209
+
175
210
  /* ------------------------------------------------------------------ *
176
211
  * inbox
177
212
  * ------------------------------------------------------------------ */
@@ -258,7 +293,7 @@ export function parseCommand(text) {
258
293
  * Poll the bot for incoming messages and hand them to the callback.
259
294
  * Returns a handle with stop().
260
295
  */
261
- export function startPoller(cfg, { onMessage } = {}) {
296
+ export function startPoller(cfg, { onMessage, onCallback } = {}) {
262
297
  const s = telegramSettings(cfg);
263
298
  if (!s.enabled || !s.poll) return null;
264
299
  let stopped = false;
@@ -282,7 +317,7 @@ export function startPoller(cfg, { onMessage } = {}) {
282
317
  const res = await apiCall(fresh(), 'getUpdates', {
283
318
  offset,
284
319
  timeout: 20,
285
- allowed_updates: ['message'],
320
+ allowed_updates: ['message', 'callback_query'],
286
321
  });
287
322
  if (!res.ok) {
288
323
  const state = readState();
@@ -301,6 +336,26 @@ export function startPoller(cfg, { onMessage } = {}) {
301
336
  const state = readState();
302
337
  state.offset = offset;
303
338
  writeState(state);
339
+ const cb = upd.callback_query;
340
+ if (cb) {
341
+ const item = {
342
+ id: upd.update_id,
343
+ callback_id: cb.id,
344
+ data: cb.data || '',
345
+ chat_id: cb.message?.chat?.id ?? null,
346
+ message_id: cb.message?.message_id ?? null,
347
+ from: cb.from?.username || cb.from?.first_name || 'unknown',
348
+ date: cb.message?.date || null,
349
+ };
350
+ if (onCallback) {
351
+ try {
352
+ await onCallback(item);
353
+ } catch {
354
+ /* the callback must not kill the loop */
355
+ }
356
+ }
357
+ continue;
358
+ }
304
359
  const msg = upd.message;
305
360
  if (!msg) continue;
306
361
  const item = {