@pixelbyte-software/pixcode 1.36.3 → 1.37.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.
Files changed (64) hide show
  1. package/dist/assets/index-CfHK8y_H.css +32 -0
  2. package/dist/assets/{index-Bp8mXdQd.js → index-D8uNxHf1.js} +165 -159
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/daemon-manager.js +18 -12
  5. package/dist-server/server/daemon-manager.js.map +1 -1
  6. package/dist-server/server/database/db.js +53 -2
  7. package/dist-server/server/database/db.js.map +1 -1
  8. package/dist-server/server/index.js +11 -4
  9. package/dist-server/server/index.js.map +1 -1
  10. package/dist-server/server/modules/orchestration/tasks/orchestration-task.routes.js +10 -1
  11. package/dist-server/server/modules/orchestration/tasks/orchestration-task.routes.js.map +1 -1
  12. package/dist-server/server/modules/orchestration/tasks/orchestration-task.service.js +7 -0
  13. package/dist-server/server/modules/orchestration/tasks/orchestration-task.service.js.map +1 -1
  14. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js +143 -26
  15. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js.map +1 -1
  16. package/dist-server/server/routes/taskmaster.js +194 -0
  17. package/dist-server/server/routes/taskmaster.js.map +1 -1
  18. package/dist-server/server/routes/telegram.js +16 -2
  19. package/dist-server/server/routes/telegram.js.map +1 -1
  20. package/dist-server/server/services/install-jobs.js +1 -0
  21. package/dist-server/server/services/install-jobs.js.map +1 -1
  22. package/dist-server/server/services/notification-orchestrator.js +66 -9
  23. package/dist-server/server/services/notification-orchestrator.js.map +1 -1
  24. package/dist-server/server/services/telegram/bot.js +48 -6
  25. package/dist-server/server/services/telegram/bot.js.map +1 -1
  26. package/dist-server/server/services/telegram/control-center.js +903 -0
  27. package/dist-server/server/services/telegram/control-center.js.map +1 -0
  28. package/dist-server/server/services/telegram/telegram-http-client.js +26 -4
  29. package/dist-server/server/services/telegram/telegram-http-client.js.map +1 -1
  30. package/dist-server/server/services/telegram/translations.js +150 -2
  31. package/dist-server/server/services/telegram/translations.js.map +1 -1
  32. package/package.json +3 -1
  33. package/scripts/smoke/chat-realtime-hydration.mjs +44 -0
  34. package/scripts/smoke/daemon-entrypoint.mjs +20 -0
  35. package/scripts/smoke/multi-worker-slots.mjs +42 -0
  36. package/scripts/smoke/notification-center.mjs +63 -0
  37. package/scripts/smoke/orchestration-execution-dashboard.mjs +33 -0
  38. package/scripts/smoke/orchestration-user-facing-output.mjs +25 -0
  39. package/scripts/smoke/shell-manual-disconnect.mjs +30 -0
  40. package/scripts/smoke/side-panel-editor-layout.mjs +34 -0
  41. package/scripts/smoke/static-root-routing.mjs +21 -0
  42. package/scripts/smoke/strict-handoff-compact.mjs +60 -0
  43. package/scripts/smoke/taskmaster-execution-telegram.mjs +52 -0
  44. package/scripts/smoke/taskmaster-onboarding.mjs +52 -0
  45. package/scripts/smoke/telegram-control.mjs +242 -0
  46. package/scripts/smoke/update-issue-progress.mjs +69 -0
  47. package/scripts/smoke/version-modal-autoshow.mjs +29 -0
  48. package/server/daemon-manager.js +17 -12
  49. package/server/database/db.js +56 -2
  50. package/server/index.js +12 -5
  51. package/server/modules/orchestration/tasks/orchestration-task.routes.ts +10 -1
  52. package/server/modules/orchestration/tasks/orchestration-task.service.ts +7 -0
  53. package/server/modules/orchestration/tasks/orchestration-task.types.ts +3 -0
  54. package/server/modules/orchestration/workflows/workflow-runner.ts +149 -26
  55. package/server/modules/orchestration/workflows/workflow.types.ts +2 -0
  56. package/server/routes/taskmaster.js +201 -0
  57. package/server/routes/telegram.js +17 -2
  58. package/server/services/install-jobs.js +1 -0
  59. package/server/services/notification-orchestrator.js +76 -8
  60. package/server/services/telegram/bot.js +58 -6
  61. package/server/services/telegram/control-center.js +965 -0
  62. package/server/services/telegram/telegram-http-client.js +25 -4
  63. package/server/services/telegram/translations.js +150 -2
  64. package/dist/assets/index-Dx7QyTSN.css +0 -32
@@ -17,6 +17,12 @@ import express from 'express';
17
17
  import { orchestrationTaskService } from '@/modules/orchestration/tasks/orchestration-task.service.js';
18
18
 
19
19
  import { extractProjectDirectory } from '../projects.js';
20
+ import {
21
+ cancelInstallJob,
22
+ createInstallJob,
23
+ getInstallJob,
24
+ snapshotDonePayload
25
+ } from '../services/install-jobs.js';
20
26
  import { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.js';
21
27
  import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';
22
28
 
@@ -144,6 +150,17 @@ async function readTaskMasterTasks(projectName) {
144
150
  return { projectPath, transformedTasks, currentTag };
145
151
  }
146
152
 
153
+ function taskMasterExecutionDescription(task) {
154
+ return [
155
+ task.description ? `Description:\n${task.description}` : '',
156
+ task.details ? `Details:\n${task.details}` : '',
157
+ task.testStrategy ? `Test strategy:\n${task.testStrategy}` : '',
158
+ Array.isArray(task.dependencies) && task.dependencies.length
159
+ ? `Dependencies: ${task.dependencies.join(', ')}`
160
+ : '',
161
+ ].filter(Boolean).join('\n\n');
162
+ }
163
+
147
164
  // API Routes
148
165
 
149
166
  /**
@@ -181,6 +198,121 @@ router.get('/installation-status', async (req, res) => {
181
198
  }
182
199
  });
183
200
 
201
+ /**
202
+ * POST /api/taskmaster/install
203
+ * Install TaskMaster CLI into Pixcode's sandboxed CLI bin.
204
+ */
205
+ router.post('/install', async (req, res) => {
206
+ try {
207
+ const job = createInstallJob({
208
+ provider: 'taskmaster',
209
+ installCmd: 'npm install -g task-master',
210
+ packageName: 'task-master'
211
+ });
212
+
213
+ res.json({
214
+ success: true,
215
+ jobId: job.id,
216
+ provider: 'taskmaster',
217
+ packageName: 'task-master',
218
+ startedAt: job.startedAt
219
+ });
220
+ } catch (error) {
221
+ console.error('TaskMaster install start error:', error);
222
+ res.status(500).json({
223
+ success: false,
224
+ error: 'Failed to start TaskMaster install',
225
+ message: error.message
226
+ });
227
+ }
228
+ });
229
+
230
+ /**
231
+ * GET /api/taskmaster/install/:jobId/stream
232
+ * Replay and stream TaskMaster install output.
233
+ */
234
+ router.get('/install/:jobId/stream', async (req, res) => {
235
+ const job = getInstallJob(req.params.jobId);
236
+ if (!job || job.provider !== 'taskmaster') {
237
+ return res.status(404).json({
238
+ success: false,
239
+ error: 'Install job not found or already expired'
240
+ });
241
+ }
242
+
243
+ res.setHeader('Content-Type', 'text/event-stream');
244
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
245
+ res.setHeader('Connection', 'keep-alive');
246
+ res.setHeader('X-Accel-Buffering', 'no');
247
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
248
+
249
+ let closed = false;
250
+ const write = (event, payload) => {
251
+ if (closed) return;
252
+ try {
253
+ res.write(`event: ${event}\n`);
254
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
255
+ } catch {
256
+ // Socket is gone.
257
+ }
258
+ };
259
+
260
+ try { res.write(': start\n\n'); } catch { /* noop */ }
261
+ const heartbeat = setInterval(() => {
262
+ if (!closed) {
263
+ try { res.write(': ping\n\n'); } catch { /* noop */ }
264
+ }
265
+ }, 5000);
266
+
267
+ for (const entry of job.logs) {
268
+ write('log', { stream: entry.stream, chunk: entry.chunk });
269
+ }
270
+
271
+ const cleanup = () => {
272
+ if (closed) return;
273
+ closed = true;
274
+ clearInterval(heartbeat);
275
+ job.emitter.off('log', onLog);
276
+ job.emitter.off('done', onDone);
277
+ };
278
+ const onLog = (entry) => {
279
+ write('log', { stream: entry.stream, chunk: entry.chunk });
280
+ };
281
+ const onDone = (payload) => {
282
+ write('done', payload);
283
+ cleanup();
284
+ try { res.end(); } catch { /* noop */ }
285
+ };
286
+
287
+ if (job.status !== 'running') {
288
+ write('done', snapshotDonePayload(job));
289
+ cleanup();
290
+ try { res.end(); } catch { /* noop */ }
291
+ return;
292
+ }
293
+
294
+ job.emitter.on('log', onLog);
295
+ job.emitter.once('done', onDone);
296
+
297
+ req.on('close', cleanup);
298
+ });
299
+
300
+ /**
301
+ * DELETE /api/taskmaster/install/:jobId
302
+ * Cancel a running TaskMaster install job.
303
+ */
304
+ router.delete('/install/:jobId', async (req, res) => {
305
+ const job = getInstallJob(req.params.jobId);
306
+ if (!job || job.provider !== 'taskmaster') {
307
+ return res.status(404).json({
308
+ success: false,
309
+ error: 'Install job not found'
310
+ });
311
+ }
312
+
313
+ res.json({ success: true, cancelled: cancelInstallJob(req.params.jobId) });
314
+ });
315
+
184
316
  /**
185
317
  * GET /api/taskmaster/tasks/:projectName
186
318
  * Load actual tasks from .taskmaster/tasks/tasks.json
@@ -230,6 +362,75 @@ router.get('/tasks/:projectName', async (req, res) => {
230
362
  }
231
363
  });
232
364
 
365
+ /**
366
+ * POST /api/taskmaster/execute/:projectName/:taskId
367
+ * Import a TaskMaster task into orchestration and dispatch it to a CLI agent.
368
+ */
369
+ router.post('/execute/:projectName/:taskId', async (req, res) => {
370
+ try {
371
+ const { projectName, taskId } = req.params;
372
+ const adapterId = typeof req.body?.adapterId === 'string'
373
+ ? req.body.adapterId
374
+ : typeof req.body?.provider === 'string'
375
+ ? req.body.provider
376
+ : '';
377
+ const model = typeof req.body?.model === 'string' ? req.body.model : undefined;
378
+ const permissionMode = typeof req.body?.permissionMode === 'string' ? req.body.permissionMode : undefined;
379
+ const isolation = ['host', 'worktree', 'docker'].includes(req.body?.isolation)
380
+ ? req.body.isolation
381
+ : 'worktree';
382
+ const projectId = typeof req.body?.projectId === 'string' ? req.body.projectId : projectName;
383
+
384
+ if (!adapterId) {
385
+ return res.status(400).json({
386
+ success: false,
387
+ error: 'Missing adapterId',
388
+ message: 'adapterId or provider is required'
389
+ });
390
+ }
391
+
392
+ const { projectPath, transformedTasks } = await readTaskMasterTasks(projectName);
393
+ const task = transformedTasks.find((candidate) => String(candidate.id) === String(taskId));
394
+ if (!task) {
395
+ return res.status(404).json({
396
+ success: false,
397
+ error: 'TaskMaster task not found',
398
+ message: `Task "${taskId}" was not found in project "${projectName}"`
399
+ });
400
+ }
401
+
402
+ const orchestrationTask = orchestrationTaskService.upsertFromTaskMaster({
403
+ projectId,
404
+ taskmasterId: String(task.id),
405
+ title: `TaskMaster #${task.id}: ${task.title}`,
406
+ description: taskMasterExecutionDescription(task)
407
+ });
408
+
409
+ const dispatchedTask = await orchestrationTaskService.dispatch(orchestrationTask.id, {
410
+ adapterId,
411
+ isolation,
412
+ projectPath,
413
+ model,
414
+ permissionMode
415
+ });
416
+
417
+ res.json({
418
+ success: true,
419
+ projectName,
420
+ projectPath,
421
+ taskmasterTask: task,
422
+ task: dispatchedTask
423
+ });
424
+ } catch (error) {
425
+ console.error('TaskMaster execute error:', error);
426
+ res.status(500).json({
427
+ success: false,
428
+ error: 'Failed to execute TaskMaster task',
429
+ message: error.message
430
+ });
431
+ }
432
+ });
433
+
233
434
  /**
234
435
  * POST /api/taskmaster/sync-orchestration/:projectName
235
436
  * One-way sync: TaskMaster -> Orchestration tasks
@@ -33,6 +33,7 @@ router.get('/status', (req, res) => {
33
33
  language: link.language,
34
34
  notificationsEnabled: Boolean(link.notifications_enabled),
35
35
  bridgeEnabled: Boolean(link.bridge_enabled),
36
+ control: telegramLinksDb.getControlState(req.user.id),
36
37
  pairingCode: link.pairing_code,
37
38
  pairingExpiresAt: link.pairing_code_expires_at,
38
39
  verifiedAt: link.verified_at,
@@ -98,13 +99,27 @@ router.post('/pairing-code', (req, res) => {
98
99
  // PATCH /api/telegram/link — update language / toggles on the user's link
99
100
  router.patch('/link', (req, res) => {
100
101
  try {
101
- const { language, notificationsEnabled, bridgeEnabled } = req.body || {};
102
+ const { language, notificationsEnabled, bridgeEnabled, controlEnabled, progressMode } = req.body || {};
102
103
  const payload = {};
103
104
  if (language !== undefined) payload.language = sanitizeLanguage(language);
104
105
  if (notificationsEnabled !== undefined) payload.notificationsEnabled = Boolean(notificationsEnabled);
105
106
  if (bridgeEnabled !== undefined) payload.bridgeEnabled = Boolean(bridgeEnabled);
106
107
  telegramLinksDb.updatePreferences(req.user.id, payload);
107
- res.json({ success: true, link: telegramLinksDb.getByUserId(req.user.id) });
108
+
109
+ const controlPatch = {};
110
+ if (controlEnabled !== undefined) controlPatch.remoteControlEnabled = Boolean(controlEnabled);
111
+ if (progressMode !== undefined && ['final', 'steps', 'all'].includes(progressMode)) {
112
+ controlPatch.progressMode = progressMode;
113
+ }
114
+ if (Object.keys(controlPatch).length > 0) {
115
+ telegramLinksDb.updateControlState(req.user.id, controlPatch);
116
+ }
117
+
118
+ res.json({
119
+ success: true,
120
+ link: telegramLinksDb.getByUserId(req.user.id),
121
+ control: telegramLinksDb.getControlState(req.user.id),
122
+ });
108
123
  } catch (error) {
109
124
  console.error('telegram/link patch failed:', error);
110
125
  res.status(500).json({ error: 'Failed to update link' });
@@ -63,6 +63,7 @@ const PACKAGE_BINARIES = {
63
63
  '@google/gemini-cli': 'gemini',
64
64
  '@qwen-code/qwen-code': 'qwen',
65
65
  'opencode-ai': 'opencode',
66
+ 'task-master': 'task-master',
66
67
  };
67
68
 
68
69
  /**
@@ -23,6 +23,11 @@ const PROVIDER_LABELS = {
23
23
 
24
24
  const recentEventKeys = new Map();
25
25
  const DEDUPE_WINDOW_MS = 20000;
26
+ let notificationWebSocketServer = null;
27
+
28
+ function setNotificationWebSocketServer(wss) {
29
+ notificationWebSocketServer = wss;
30
+ }
26
31
 
27
32
  const cleanupOldEventKeys = () => {
28
33
  const now = Date.now();
@@ -41,6 +46,22 @@ function shouldSendPush(preferences, event) {
41
46
  return webPushEnabled && eventEnabled;
42
47
  }
43
48
 
49
+ function shouldSendInApp(preferences, event) {
50
+ const inAppEnabled = preferences?.channels?.inApp !== false;
51
+ const prefEventKey = KIND_TO_PREF_KEY[event.kind];
52
+ const eventEnabled = prefEventKey ? preferences?.events?.[prefEventKey] !== false : true;
53
+
54
+ return inAppEnabled && eventEnabled;
55
+ }
56
+
57
+ function shouldSendTelegram(preferences, event) {
58
+ const telegramEnabled = preferences?.channels?.telegram !== false;
59
+ const prefEventKey = KIND_TO_PREF_KEY[event.kind];
60
+ const eventEnabled = prefEventKey ? preferences?.events?.[prefEventKey] !== false : true;
61
+
62
+ return telegramEnabled && eventEnabled;
63
+ }
64
+
44
65
  function isDuplicate(event) {
45
66
  cleanupOldEventKeys();
46
67
  const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
@@ -149,6 +170,41 @@ function buildPushBody(event) {
149
170
  };
150
171
  }
151
172
 
173
+ function buildNotificationPayload(event) {
174
+ const pushBody = buildPushBody(event);
175
+ return {
176
+ id: event.dedupeKey || `${event.provider || 'system'}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}:${event.createdAt}`,
177
+ title: pushBody.title,
178
+ body: pushBody.body,
179
+ kind: event.kind || 'info',
180
+ code: event.code || 'generic.info',
181
+ severity: event.severity || 'info',
182
+ provider: event.provider || null,
183
+ sessionId: event.sessionId || null,
184
+ createdAt: event.createdAt,
185
+ requiresUserAction: Boolean(event.requiresUserAction),
186
+ data: pushBody.data
187
+ };
188
+ }
189
+
190
+ function broadcastInAppNotification(userId, event) {
191
+ if (!notificationWebSocketServer || !userId || !event) {
192
+ return;
193
+ }
194
+
195
+ const message = JSON.stringify({
196
+ type: 'notification:event',
197
+ notification: buildNotificationPayload(event)
198
+ });
199
+ const normalizedUserId = String(userId);
200
+
201
+ notificationWebSocketServer.clients.forEach((client) => {
202
+ if (client.readyState === 1 && String(client.userId || '') === normalizedUserId) {
203
+ client.send(message);
204
+ }
205
+ });
206
+ }
207
+
152
208
  async function sendWebPush(userId, event) {
153
209
  const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
154
210
  if (!subscriptions.length) return;
@@ -191,6 +247,14 @@ function notifyUserIfEnabled({ userId, event }) {
191
247
  return;
192
248
  }
193
249
 
250
+ if (shouldSendInApp(preferences, event)) {
251
+ try {
252
+ broadcastInAppNotification(userId, event);
253
+ } catch (err) {
254
+ console.error('In-app notification send error:', err);
255
+ }
256
+ }
257
+
194
258
  if (shouldSendPush(preferences, event)) {
195
259
  sendWebPush(userId, event).catch((err) => {
196
260
  console.error('Web push send error:', err);
@@ -203,14 +267,16 @@ function notifyUserIfEnabled({ userId, event }) {
203
267
  const providerLabel = PROVIDER_LABELS[event.provider] || event.provider || 'Session';
204
268
  const sessionTitle = event.meta?.sessionName || providerLabel;
205
269
  const errorText = event.meta?.error || '';
206
- notifyTelegramUser({
207
- userId,
208
- kind: event.kind,
209
- title: sessionTitle,
210
- error: errorText,
211
- }).catch((err) => {
212
- console.warn('[telegram] notify failed:', err?.message || err);
213
- });
270
+ if (shouldSendTelegram(preferences, event)) {
271
+ notifyTelegramUser({
272
+ userId,
273
+ kind: event.kind,
274
+ title: sessionTitle,
275
+ error: errorText,
276
+ }).catch((err) => {
277
+ console.warn('[telegram] notify failed:', err?.message || err);
278
+ });
279
+ }
214
280
  }
215
281
 
216
282
  function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) {
@@ -247,6 +313,8 @@ function notifyRunFailed({ userId, provider, sessionId = null, error, sessionNam
247
313
 
248
314
  export {
249
315
  createNotificationEvent,
316
+ setNotificationWebSocketServer,
317
+ broadcastInAppNotification,
250
318
  notifyUserIfEnabled,
251
319
  notifyRunStopped,
252
320
  notifyRunFailed
@@ -2,6 +2,13 @@ import { EventEmitter } from 'node:events';
2
2
 
3
3
  import { telegramConfigDb, telegramLinksDb } from '../../database/db.js';
4
4
 
5
+ import {
6
+ getTelegramControlCommand,
7
+ handleTelegramControlCallback,
8
+ handleTelegramControlMessage,
9
+ isTelegramControlCommand,
10
+ showMainMenu,
11
+ } from './control-center.js';
5
12
  import { t } from './translations.js';
6
13
  // Swapped in v1.32: previously `node-telegram-bot-api` which carried the
7
14
  // deprecated `request`/`har-validator`/`uuid@3` chain. TelegramHttpBot is
@@ -21,6 +28,10 @@ let bot = null;
21
28
  let botInfo = null; // { id, username, first_name }
22
29
  let lastError = null;
23
30
 
31
+ export const setTelegramBotForTesting = (nextBot) => {
32
+ bot = nextBot;
33
+ };
34
+
24
35
  // Subscribers (notification-orchestrator, future session bridge) use this to
25
36
  // react to events without importing the bot module directly.
26
37
  export const telegramEvents = new EventEmitter();
@@ -64,14 +75,14 @@ const parseMaybeCode = (text) => {
64
75
  return /^\d{6}$/.test(trimmed) ? trimmed : null;
65
76
  };
66
77
 
67
- const safeSend = async (chatId, text) => {
78
+ const safeSend = async (chatId, text, extra = {}) => {
68
79
  if (!bot) return;
69
80
  try {
70
- await bot.sendMessage(chatId, text, { parse_mode: 'Markdown' });
81
+ await bot.sendMessage(chatId, text, { parse_mode: 'Markdown', ...extra });
71
82
  } catch (err) {
72
83
  // Markdown parse errors from user input are common; retry plaintext.
73
84
  try {
74
- await bot.sendMessage(chatId, text);
85
+ await bot.sendMessage(chatId, text, extra);
75
86
  } catch (fallbackErr) {
76
87
  console.warn('[telegram] sendMessage failed:', fallbackErr?.message || fallbackErr);
77
88
  }
@@ -98,19 +109,21 @@ const handlePairing = async (msg, code) => {
98
109
  telegramEvents.emit('paired', { userId: link.user_id, chatId: String(msg.chat.id), username: telegramUsername });
99
110
 
100
111
  await safeSend(msg.chat.id, t(language, 'pairing.success'));
112
+ await safeSend(msg.chat.id, t(language, 'control.onboarding'));
113
+ await showMainMenu({ bot, chatId: msg.chat.id, link: telegramLinksDb.getByUserId(link.user_id) });
101
114
  };
102
115
 
103
116
  const handleBridgeMessage = async (msg, existing) => {
104
117
  const language = existing.language || 'en';
105
118
 
119
+ const handled = await handleTelegramControlMessage({ bot, msg, link: existing, safeSend });
120
+ if (handled) return;
121
+
106
122
  if (!existing.bridge_enabled) {
107
123
  await safeSend(msg.chat.id, t(language, 'bridge.disabled'));
108
124
  return;
109
125
  }
110
126
 
111
- // Fan out to subscribers (future: session-prompt bridge). We don't do the
112
- // actual agent dispatch here to keep the bot service narrowly focused on
113
- // Telegram I/O and let the rest of the server opt in.
114
127
  telegramEvents.emit('prompt', {
115
128
  userId: existing.user_id,
116
129
  chatId: String(msg.chat.id),
@@ -122,11 +135,43 @@ const handleBridgeMessage = async (msg, existing) => {
122
135
  await safeSend(msg.chat.id, t(language, 'bridge.queued'));
123
136
  };
124
137
 
138
+ const handleCallbackQuery = async (query) => {
139
+ const chatId = query?.message?.chat?.id;
140
+ if (!chatId) return;
141
+ const existing = telegramLinksDb.getByChatId(String(chatId));
142
+ if (!existing) {
143
+ await bot?.answerCallbackQuery(query.id, { text: 'Pair Pixcode first.' }).catch(() => {});
144
+ return;
145
+ }
146
+ await handleTelegramControlCallback({ bot, query, link: existing, safeSend });
147
+ };
148
+
125
149
  const handleMessage = async (msg) => {
126
150
  if (!msg?.chat?.id || !msg?.text) return;
127
151
 
128
152
  const existing = telegramLinksDb.getByChatId(String(msg.chat.id));
129
153
  if (existing) {
154
+ const language = existing.language || 'en';
155
+ const command = getTelegramControlCommand(msg.text);
156
+ if (command === '/start') {
157
+ await safeSend(msg.chat.id, t(language, 'control.onboarding'));
158
+ await showMainMenu({ bot, chatId: msg.chat.id, link: existing });
159
+ return;
160
+ }
161
+ if (command === '/help') {
162
+ await safeSend(msg.chat.id, t(language, 'control.help'));
163
+ return;
164
+ }
165
+ if (command === '/menu' || command === 'menu') {
166
+ await showMainMenu({ bot, chatId: msg.chat.id, link: existing });
167
+ return;
168
+ }
169
+
170
+ if (isTelegramControlCommand(msg.text)) {
171
+ await handleTelegramControlMessage({ bot, msg, link: existing });
172
+ return;
173
+ }
174
+
130
175
  // Paired user path: a 6-digit-only message is treated as noise (we keep
131
176
  // the already-paired binding); anything else is bridge traffic.
132
177
  const maybeCode = parseMaybeCode(msg.text);
@@ -157,6 +202,8 @@ const handleMessage = async (msg) => {
157
202
  await safeSend(msg.chat.id, t('en', 'pairing.stillNeeded'));
158
203
  };
159
204
 
205
+ export const handleIncomingTelegramMessage = handleMessage;
206
+
160
207
  const wirePollingErrors = () => {
161
208
  if (!bot) return;
162
209
  bot.on('polling_error', (err) => {
@@ -213,6 +260,11 @@ export const startBot = async ({ token, persist = true } = {}) => {
213
260
  console.error('[telegram] handleMessage crashed:', err);
214
261
  });
215
262
  });
263
+ bot.on('callback_query', (query) => {
264
+ handleCallbackQuery(query).catch((err) => {
265
+ console.error('[telegram] handleCallbackQuery crashed:', err);
266
+ });
267
+ });
216
268
  wirePollingErrors();
217
269
 
218
270
  console.log(`[telegram] bot started as @${me.username}`);