@pixelbyte-software/pixcode 1.53.4 → 1.53.5

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.
@@ -4,9 +4,24 @@ import { apiKeysDb, telegramLinksDb } from '../../database/db.js';
4
4
  import { getProjects } from '../../projects.js';
5
5
  import { getStaticProviderModels } from '../model-registry.js';
6
6
 
7
+ import {
8
+ TELEGRAM_CONTROL_SCOPES,
9
+ TELEGRAM_PROVIDERS,
10
+ buildTelegramAgentPrompt,
11
+ classifyTelegramIntent,
12
+ clearTelegramConfirmation,
13
+ consumeTelegramConfirmation,
14
+ createTelegramConfirmation,
15
+ enqueueTelegramJob,
16
+ resolveTelegramModel,
17
+ resolveTelegramProvider,
18
+ retryWithBackoff,
19
+ runTelegramTool,
20
+ splitTelegramText,
21
+ } from './telegram-gateway.js';
7
22
  import { SUPPORTED_LANGUAGES, t } from './translations.js';
8
23
 
9
- const PROVIDERS = ['claude', 'cursor', 'codex', 'gemini', 'qwen', 'opencode'];
24
+ const PROVIDERS = TELEGRAM_PROVIDERS;
10
25
  const TERMINAL_RUN_STATES = new Set(['completed', 'failed', 'canceled']);
11
26
  const CALLBACK_TTL_MS = 10 * 60 * 1000;
12
27
  const MAX_CALLBACK_ACTIONS = 1000;
@@ -130,7 +145,11 @@ function readAction(data) {
130
145
  callbackActions.delete(id);
131
146
  return null;
132
147
  }
133
- return entry;
148
+ return { id, ...entry };
149
+ }
150
+
151
+ function forgetAction(id) {
152
+ if (id) callbackActions.delete(id);
134
153
  }
135
154
 
136
155
  function button(text, action, payload = {}) {
@@ -150,27 +169,44 @@ async function send(bot, chatId, text, options = {}) {
150
169
  disable_web_page_preview: true,
151
170
  ...telegramOptions,
152
171
  };
153
- const messageText = truncate(text);
172
+ const chunks = splitTelegramText(text, MAX_TELEGRAM_TEXT);
173
+ const [firstChunk = ''] = chunks;
174
+ let startIndex = 0;
154
175
  if (editMessageId && typeof bot.editMessageText === 'function') {
155
176
  try {
156
- return await bot.editMessageText(messageText, {
177
+ const edited = await bot.editMessageText(firstChunk, {
157
178
  chat_id: chatId,
158
179
  message_id: editMessageId,
159
180
  ...extra,
160
181
  });
182
+ startIndex = 1;
183
+ for (const chunk of chunks.slice(startIndex)) {
184
+ await send(bot, chatId, chunk, telegramOptions);
185
+ }
186
+ return edited;
161
187
  } catch (err) {
162
188
  const description = err?.response?.body?.description || err?.message || '';
163
- if (/message is not modified/i.test(description)) return null;
189
+ if (/message is not modified/i.test(description)) {
190
+ startIndex = 1;
191
+ for (const chunk of chunks.slice(startIndex)) {
192
+ await send(bot, chatId, chunk, telegramOptions);
193
+ }
194
+ return null;
195
+ }
164
196
  console.warn('[telegram-control] editMessageText failed:', description || err);
165
197
  }
166
198
  }
167
- try {
168
- return await bot.sendMessage(chatId, messageText, extra);
169
- } catch {
170
- const fallback = { ...extra };
171
- delete fallback.parse_mode;
172
- return bot.sendMessage(chatId, messageText, fallback);
199
+ let result = null;
200
+ for (const chunk of chunks.slice(startIndex)) {
201
+ try {
202
+ result = await bot.sendMessage(chatId, chunk, extra);
203
+ } catch {
204
+ const fallback = { ...extra };
205
+ delete fallback.parse_mode;
206
+ result = await bot.sendMessage(chatId, chunk, fallback);
207
+ }
173
208
  }
209
+ return result;
174
210
  }
175
211
 
176
212
  function localApiBase() {
@@ -182,19 +218,35 @@ function getOrCreateTelegramApiKey(userId) {
182
218
  const existing = apiKeysDb
183
219
  .getApiKeys(userId)
184
220
  .find((key) => key.key_name === 'Telegram Control' && Boolean(key.is_active));
185
- if (existing?.api_key) return existing.api_key;
186
- return apiKeysDb.createApiKey(userId, 'Telegram Control').apiKey;
221
+ if (existing?.api_key) {
222
+ const existingScopes = apiKeysDb.normalizeScopes(existing.scopes || []);
223
+ const nextScopes = apiKeysDb.normalizeScopes([...existingScopes, ...TELEGRAM_CONTROL_SCOPES]);
224
+ const hasAllScopes = TELEGRAM_CONTROL_SCOPES.every((scope) => existingScopes.includes(scope));
225
+ if (!hasAllScopes || nextScopes.length !== existingScopes.length) {
226
+ apiKeysDb.updateApiKeyScopes(userId, existing.id, nextScopes);
227
+ }
228
+ return existing.api_key;
229
+ }
230
+ return apiKeysDb.createApiKey(userId, 'Telegram Control', TELEGRAM_CONTROL_SCOPES).apiKey;
187
231
  }
188
232
 
189
233
  async function localApi(userId, path, { method = 'GET', body } = {}) {
190
234
  const apiKey = getOrCreateTelegramApiKey(userId);
191
- const response = await fetch(`${localApiBase()}${path}`, {
192
- method,
193
- headers: {
194
- 'Content-Type': 'application/json',
195
- 'X-API-Key': apiKey,
196
- },
197
- body: body === undefined ? undefined : JSON.stringify(body),
235
+ const response = await retryWithBackoff(async () => {
236
+ const res = await fetch(`${localApiBase()}${path}`, {
237
+ method,
238
+ headers: {
239
+ 'Content-Type': 'application/json',
240
+ 'X-API-Key': apiKey,
241
+ },
242
+ body: body === undefined ? undefined : JSON.stringify(body),
243
+ });
244
+ if ([408, 429, 500, 502, 503, 504].includes(res.status)) {
245
+ const error = new Error(`HTTP ${res.status}`);
246
+ error.status = res.status;
247
+ throw error;
248
+ }
249
+ return res;
198
250
  });
199
251
  const data = await response.json().catch(() => ({}));
200
252
  if (!response.ok) {
@@ -487,6 +539,39 @@ function extractAssistantText(response) {
487
539
  return chunks.join('\n\n').trim();
488
540
  }
489
541
 
542
+ function confirmationLabel(lang, action, payload = {}) {
543
+ if (action === 'install_provider') {
544
+ return t(lang, 'control.confirmInstall', { provider: payload.provider || '' });
545
+ }
546
+ if (action === 'run_cancel') {
547
+ return t(lang, 'control.confirmCancelRun', { runId: payload.runId || '' });
548
+ }
549
+ return t(lang, 'control.confirmationRequired');
550
+ }
551
+
552
+ async function requestConfirmation({ bot, chatId, link, action, payload = {}, editMessageId }) {
553
+ const lang = languageFor(link);
554
+ const pending = createTelegramConfirmation(link.user_id, action, payload);
555
+ await send(bot, chatId, confirmationLabel(lang, action, payload), {
556
+ editMessageId,
557
+ reply_markup: {
558
+ inline_keyboard: [[
559
+ button(t(lang, 'control.button.confirm'), 'confirm_action', { id: pending.id }),
560
+ button(t(lang, 'control.button.cancel'), 'cancel_confirmation', { id: pending.id }),
561
+ ]],
562
+ },
563
+ });
564
+ return { ok: false, requiresConfirmation: true };
565
+ }
566
+
567
+ async function sendToolFailure({ bot, chatId, link, result, editMessageId }) {
568
+ const lang = languageFor(link);
569
+ const message = result?.message === 'REMOTE_CONTROL_DISABLED'
570
+ ? t(lang, 'control.disabled')
571
+ : (result?.message || t(lang, 'error.generic'));
572
+ await send(bot, chatId, message, { editMessageId });
573
+ }
574
+
490
575
  async function runAgent({ bot, chatId, link, prompt }) {
491
576
  const lang = languageFor(link);
492
577
  const state = getState(link.user_id);
@@ -500,28 +585,41 @@ async function runAgent({ bot, chatId, link, prompt }) {
500
585
  return;
501
586
  }
502
587
 
588
+ const provider = resolveTelegramProvider(state);
589
+ const model = resolveTelegramModel(state);
503
590
  if (state.progressMode !== 'final') {
504
591
  await send(bot, chatId, t(lang, 'control.agentStarted', {
505
- provider: state.selectedProvider,
592
+ provider,
506
593
  project: state.selectedProjectName || state.selectedProjectPath,
507
594
  }));
508
595
  }
509
596
 
510
- const response = await localApi(link.user_id, '/api/agent', {
511
- method: 'POST',
512
- body: {
513
- projectPath: state.selectedProjectPath,
514
- provider: state.selectedProvider,
515
- model: state.selectedModel || undefined,
516
- message: prompt,
517
- cleanup: false,
518
- stream: false,
519
- },
597
+ const result = await runTelegramTool({
598
+ userId: link.user_id,
599
+ action: 'run_agent',
600
+ retries: 0,
601
+ execute: () => localApi(link.user_id, '/api/agent', {
602
+ method: 'POST',
603
+ body: {
604
+ projectPath: state.selectedProjectPath,
605
+ provider,
606
+ model: model || undefined,
607
+ message: buildTelegramAgentPrompt(prompt, state),
608
+ cleanup: false,
609
+ stream: false,
610
+ },
611
+ }),
520
612
  });
613
+ if (!result.ok) {
614
+ await sendToolFailure({ bot, chatId, link, result });
615
+ return;
616
+ }
617
+
618
+ const response = result.data;
521
619
  const assistantText = extractAssistantText(response);
522
620
  const statusLine = response.success === false
523
621
  ? t(lang, 'control.agentFailed', { error: response.error || 'provider returned no answer' })
524
- : t(lang, 'control.agentDone', { provider: state.selectedProvider, session: response.sessionId ? ` (${response.sessionId})` : '' });
622
+ : t(lang, 'control.agentDone', { provider, session: response.sessionId ? ` (${response.sessionId})` : '' });
525
623
  await send(bot, chatId, `${statusLine}\n\n${assistantText || response.error || t(lang, 'control.noAssistantText')}`);
526
624
  }
527
625
 
@@ -529,6 +627,10 @@ export async function runWorkflow({ bot, chatId, link, input }) {
529
627
  const lang = languageFor(link);
530
628
  const state = getState(link.user_id);
531
629
  const workflowId = state.selectedWorkflowId;
630
+ if (!state.remoteControlEnabled) {
631
+ await send(bot, chatId, t(lang, 'control.disabled'));
632
+ return;
633
+ }
532
634
  if (!workflowId) {
533
635
  await send(bot, chatId, t(lang, 'control.selectWorkflowFirst'));
534
636
  await showWorkflowMenu({ bot, chatId, link });
@@ -540,27 +642,178 @@ export async function runWorkflow({ bot, chatId, link, input }) {
540
642
  return;
541
643
  }
542
644
 
543
- const run = await localApi(link.user_id, `/api/orchestration/workflows/${workflowId}/runs`, {
544
- method: 'POST',
545
- body: {
546
- input,
547
- metadata: {
548
- projectId: state.selectedProjectName,
549
- projectName: state.selectedProjectName,
550
- projectPath: state.selectedProjectPath,
551
- workspaceTarget: 'selected_project',
552
- telegram: true,
553
- preferredProvider: state.selectedProvider,
554
- preferredModel: state.selectedModel,
645
+ const provider = resolveTelegramProvider(state);
646
+ const model = resolveTelegramModel(state);
647
+ const result = await runTelegramTool({
648
+ userId: link.user_id,
649
+ action: 'run_workflow',
650
+ execute: () => localApi(link.user_id, `/api/orchestration/workflows/${workflowId}/runs`, {
651
+ method: 'POST',
652
+ body: {
653
+ input,
654
+ metadata: {
655
+ projectId: state.selectedProjectName,
656
+ projectName: state.selectedProjectName,
657
+ projectPath: state.selectedProjectPath,
658
+ workspaceTarget: 'selected_project',
659
+ telegram: true,
660
+ preferredProvider: provider,
661
+ preferredModel: model,
662
+ },
555
663
  },
556
- },
664
+ }),
557
665
  });
666
+ if (!result.ok) {
667
+ await sendToolFailure({ bot, chatId, link, result });
668
+ return;
669
+ }
670
+ const run = result.data;
558
671
  await send(bot, chatId, t(lang, 'control.workflowStarted', { runId: run.id, workflowId }));
559
672
  monitorWorkflowRun({ bot, chatId, link, runId: run.id }).catch((error) => {
560
673
  console.warn('[telegram-control] workflow monitor failed:', error?.message || error);
561
674
  });
562
675
  }
563
676
 
677
+ async function cancelRun({ bot, chatId, link, runId, editMessageId, confirmed = false }) {
678
+ const lang = languageFor(link);
679
+ if (!runId) {
680
+ await send(bot, chatId, t(lang, 'control.cancelUsage'), { editMessageId });
681
+ return;
682
+ }
683
+ const state = getState(link.user_id);
684
+ if (state.remoteControlEnabled === false) {
685
+ await send(bot, chatId, t(lang, 'control.disabled'), { editMessageId });
686
+ return;
687
+ }
688
+ if (state.confirmationPolicy === 'strict' && !confirmed) {
689
+ await requestConfirmation({
690
+ bot,
691
+ chatId,
692
+ link,
693
+ action: 'run_cancel',
694
+ payload: { runId },
695
+ editMessageId,
696
+ });
697
+ return;
698
+ }
699
+
700
+ const result = await runTelegramTool({
701
+ userId: link.user_id,
702
+ action: 'run_cancel',
703
+ execute: () => localApi(link.user_id, `/api/orchestration/workflows/runs/${encodeURIComponent(runId)}/cancel`, {
704
+ method: 'POST',
705
+ }),
706
+ });
707
+ if (!result.ok) {
708
+ await sendToolFailure({ bot, chatId, link, result, editMessageId });
709
+ return;
710
+ }
711
+ const run = result.data;
712
+ await send(bot, chatId, t(lang, 'control.runStatus', { runId: run.id, status: run.status }), { editMessageId });
713
+ }
714
+
715
+ async function executeConfirmedAction({ bot, chatId, link, confirmation, editMessageId }) {
716
+ if (confirmation.action === 'install_provider') {
717
+ await startCliInstall({
718
+ bot,
719
+ chatId,
720
+ link,
721
+ provider: confirmation.payload?.provider,
722
+ editMessageId,
723
+ confirmed: true,
724
+ });
725
+ return true;
726
+ }
727
+ if (confirmation.action === 'run_cancel') {
728
+ await cancelRun({
729
+ bot,
730
+ chatId,
731
+ link,
732
+ runId: confirmation.payload?.runId,
733
+ editMessageId,
734
+ confirmed: true,
735
+ });
736
+ return true;
737
+ }
738
+ return false;
739
+ }
740
+
741
+ async function findProjectByQuery(query) {
742
+ const projects = await listProjects();
743
+ const needle = String(query || '').trim().toLocaleLowerCase('tr');
744
+ if (!needle) return null;
745
+ return projects.find((project) => {
746
+ const candidates = [project.name, project.displayName, project.fullPath, project.path]
747
+ .filter(Boolean)
748
+ .map((value) => String(value).toLocaleLowerCase('tr'));
749
+ return candidates.some((candidate) => candidate === needle || candidate.includes(needle));
750
+ }) || null;
751
+ }
752
+
753
+ async function handleRoutedIntent({ bot, chatId, link, text }) {
754
+ const state = getState(link.user_id);
755
+ const intent = classifyTelegramIntent(text, state);
756
+ if (!intent) return false;
757
+
758
+ if (intent.action === 'projects') {
759
+ await showProjectMenu({ bot, chatId, link });
760
+ return true;
761
+ }
762
+ if (intent.action === 'project_select_query') {
763
+ const project = await findProjectByQuery(intent.query);
764
+ const lang = languageFor(link);
765
+ if (!project) {
766
+ await send(bot, chatId, t(lang, 'control.projectNotFound', { query: intent.query }));
767
+ await showProjectMenu({ bot, chatId, link });
768
+ return true;
769
+ }
770
+ updateTelegramControlState(link.user_id, {
771
+ selectedProjectName: project.name,
772
+ selectedProjectPath: project.fullPath || project.path,
773
+ });
774
+ await showMainMenu({
775
+ bot,
776
+ chatId,
777
+ link,
778
+ notice: t(lang, 'control.projectSelected', {
779
+ project: project.displayName || project.name,
780
+ path: project.fullPath || project.path,
781
+ }),
782
+ });
783
+ return true;
784
+ }
785
+ if (intent.action === 'provider_menu') {
786
+ await showProviderMenu({ bot, chatId, link });
787
+ return true;
788
+ }
789
+ if (intent.action === 'provider_select') {
790
+ updateTelegramControlState(link.user_id, { selectedProvider: intent.provider, selectedModel: null });
791
+ await showModelMenu({ bot, chatId, link });
792
+ return true;
793
+ }
794
+ if (intent.action === 'model_menu') {
795
+ await showModelMenu({ bot, chatId, link });
796
+ return true;
797
+ }
798
+ if (intent.action === 'runs') {
799
+ await showRuns({ bot, chatId, link });
800
+ return true;
801
+ }
802
+ if (intent.action === 'approvals') {
803
+ await showApprovalQueue({ bot, chatId, link });
804
+ return true;
805
+ }
806
+ if (intent.action === 'workflows') {
807
+ await showWorkflowMenu({ bot, chatId, link });
808
+ return true;
809
+ }
810
+ if (intent.action === 'new_chat') {
811
+ await startNewChat({ bot, chatId, link });
812
+ return true;
813
+ }
814
+ return false;
815
+ }
816
+
564
817
  async function fetchRun(userId, runId) {
565
818
  return localApi(userId, `/api/orchestration/workflows/runs/${runId}`);
566
819
  }
@@ -625,18 +878,49 @@ async function monitorWorkflowRun({ bot, chatId, link, runId }) {
625
878
  }
626
879
  }
627
880
 
628
- export async function startCliInstall({ bot, chatId, link, provider }) {
881
+ export async function startCliInstall({ bot, chatId, link, provider, editMessageId, confirmed = false }) {
629
882
  const lang = languageFor(link);
630
- const data = await localApi(link.user_id, `/api/providers/${provider}/install`, { method: 'POST' });
883
+ if (!PROVIDERS.includes(provider)) {
884
+ await send(bot, chatId, t(lang, 'control.providerAuthFallback'), { editMessageId });
885
+ return;
886
+ }
887
+ const state = getState(link.user_id);
888
+ if (state.remoteControlEnabled === false) {
889
+ await send(bot, chatId, t(lang, 'control.disabled'), { editMessageId });
890
+ return;
891
+ }
892
+ if (state.confirmationPolicy === 'strict' && !confirmed) {
893
+ await requestConfirmation({
894
+ bot,
895
+ chatId,
896
+ link,
897
+ action: 'install_provider',
898
+ payload: { provider },
899
+ editMessageId,
900
+ });
901
+ return;
902
+ }
903
+
904
+ const result = await runTelegramTool({
905
+ userId: link.user_id,
906
+ action: 'install_provider',
907
+ execute: () => localApi(link.user_id, `/api/providers/${provider}/install`, { method: 'POST' }),
908
+ });
909
+ if (!result.ok) {
910
+ await sendToolFailure({ bot, chatId, link, result, editMessageId });
911
+ return;
912
+ }
913
+
914
+ const data = result.data;
631
915
  if (data?.manual) {
632
- await send(bot, chatId, t(lang, 'control.manualInstall', { provider, manual: data.manual }));
916
+ await send(bot, chatId, t(lang, 'control.manualInstall', { provider, manual: data.manual }), { editMessageId });
633
917
  return;
634
918
  }
635
919
  await send(bot, chatId, t(lang, 'control.installStarted', {
636
920
  provider,
637
921
  jobId: data.jobId,
638
922
  command: data.installCmd || 'internal installer',
639
- }));
923
+ }), { editMessageId });
640
924
  }
641
925
 
642
926
  async function showInstallMenu({ bot, chatId, link, editMessageId }) {
@@ -818,16 +1102,13 @@ async function handleCommand({ bot, chatId, link, text }) {
818
1102
  await send(bot, chatId, t(lang, 'control.cancelUsage'));
819
1103
  return true;
820
1104
  }
821
- const run = await localApi(link.user_id, `/api/orchestration/workflows/runs/${encodeURIComponent(argText)}/cancel`, {
822
- method: 'POST',
823
- });
824
- await send(bot, chatId, t(lang, 'control.runStatus', { runId: run.id, status: run.status }));
1105
+ await cancelRun({ bot, chatId, link, runId: argText });
825
1106
  return true;
826
1107
  }
827
1108
  return false;
828
1109
  }
829
1110
 
830
- export async function handleTelegramControlMessage({ bot, msg, link }) {
1111
+ async function handleTelegramControlMessageInternal({ bot, msg, link }) {
831
1112
  const chatId = msg.chat.id;
832
1113
  const text = String(msg.text || '').trim();
833
1114
  if (!text) return false;
@@ -843,8 +1124,10 @@ export async function handleTelegramControlMessage({ bot, msg, link }) {
843
1124
 
844
1125
  if (await handleAwaitingInput({ bot, chatId, link, text })) return true;
845
1126
  if (await handleCommand({ bot, chatId, link, text })) return true;
1127
+ if (await handleRoutedIntent({ bot, chatId, link, text })) return true;
846
1128
 
847
1129
  const state = getState(link.user_id);
1130
+ if (state.routerEnabled === false) return false;
848
1131
  if (!state.remoteControlEnabled) {
849
1132
  await send(bot, chatId, t(languageFor(link), 'control.disabled'));
850
1133
  return true;
@@ -854,6 +1137,20 @@ export async function handleTelegramControlMessage({ bot, msg, link }) {
854
1137
  return true;
855
1138
  }
856
1139
 
1140
+ export async function handleTelegramControlMessage(args) {
1141
+ const chatId = args?.msg?.chat?.id;
1142
+ if (!chatId) return false;
1143
+ return enqueueTelegramJob(chatId, async () => {
1144
+ try {
1145
+ return await handleTelegramControlMessageInternal(args);
1146
+ } catch (error) {
1147
+ console.error('[telegram-control] message handler failed:', error);
1148
+ await send(args.bot, chatId, t(languageFor(args.link), 'error.generic')).catch(() => {});
1149
+ return true;
1150
+ }
1151
+ });
1152
+ }
1153
+
857
1154
  async function showRunDetail({ bot, chatId, link, runId, editMessageId }) {
858
1155
  const lang = languageFor(link);
859
1156
  const run = await fetchRun(link.user_id, runId);
@@ -868,7 +1165,7 @@ async function showRunDetail({ bot, chatId, link, runId, editMessageId }) {
868
1165
  });
869
1166
  }
870
1167
 
871
- export async function handleTelegramControlCallback({ bot, query, link }) {
1168
+ async function handleTelegramControlCallbackInternal({ bot, query, link }) {
872
1169
  const chatId = query.message?.chat?.id;
873
1170
  if (!chatId) return;
874
1171
  const editMessageId = query.message?.message_id;
@@ -882,6 +1179,32 @@ export async function handleTelegramControlCallback({ bot, query, link }) {
882
1179
  await bot.answerCallbackQuery(query.id).catch(() => {});
883
1180
 
884
1181
  const { action, payload } = entry;
1182
+ if (action === 'confirm_action') {
1183
+ forgetAction(entry.id);
1184
+ const lang = languageFor(link);
1185
+ const result = consumeTelegramConfirmation(link.user_id, payload.id);
1186
+ if (!result.ok) {
1187
+ await send(bot, chatId, t(lang, result.reason === 'expired'
1188
+ ? 'control.confirmationExpired'
1189
+ : 'control.confirmationMissing'), { editMessageId });
1190
+ return;
1191
+ }
1192
+ if (await executeConfirmedAction({
1193
+ bot,
1194
+ chatId,
1195
+ link,
1196
+ confirmation: result.confirmation,
1197
+ editMessageId,
1198
+ })) return;
1199
+ await send(bot, chatId, t(lang, 'error.generic'), { editMessageId });
1200
+ return;
1201
+ }
1202
+ if (action === 'cancel_confirmation') {
1203
+ forgetAction(entry.id);
1204
+ clearTelegramConfirmation(link.user_id, payload.id);
1205
+ await send(bot, chatId, t(languageFor(link), 'control.confirmationCanceled'), { editMessageId });
1206
+ return;
1207
+ }
885
1208
  if (action === 'menu') return showMainMenu({ bot, chatId, link, editMessageId });
886
1209
  if (action === 'projects') return showProjectMenu({ bot, chatId, link, editMessageId });
887
1210
  if (action === 'providers') return showProviderMenu({ bot, chatId, link, editMessageId });
@@ -947,20 +1270,26 @@ export async function handleTelegramControlCallback({ bot, query, link }) {
947
1270
  }
948
1271
  if (action === 'run_detail') return showRunDetail({ bot, chatId, link, runId: payload.runId, editMessageId });
949
1272
  if (action === 'run_cancel') {
950
- const run = await localApi(link.user_id, `/api/orchestration/workflows/runs/${encodeURIComponent(payload.runId)}/cancel`, {
951
- method: 'POST',
952
- });
953
- await send(bot, chatId, t(languageFor(link), 'control.runStatus', { runId: run.id, status: run.status }), { editMessageId });
1273
+ await cancelRun({ bot, chatId, link, runId: payload.runId, editMessageId });
954
1274
  return;
955
1275
  }
956
1276
  if (action === 'approval_decide') {
957
- const result = await localApi(link.user_id, `/api/orchestration/workflows/approvals/${encodeURIComponent(payload.approvalId)}`, {
958
- method: 'POST',
959
- body: {
960
- allow: payload.allow === true,
961
- source: 'telegram',
962
- },
1277
+ const toolResult = await runTelegramTool({
1278
+ userId: link.user_id,
1279
+ action: 'approval_decide',
1280
+ execute: () => localApi(link.user_id, `/api/orchestration/workflows/approvals/${encodeURIComponent(payload.approvalId)}`, {
1281
+ method: 'POST',
1282
+ body: {
1283
+ allow: payload.allow === true,
1284
+ source: 'telegram',
1285
+ },
1286
+ }),
963
1287
  });
1288
+ if (!toolResult.ok) {
1289
+ await sendToolFailure({ bot, chatId, link, result: toolResult, editMessageId });
1290
+ return;
1291
+ }
1292
+ const result = toolResult.data;
964
1293
  const lang = languageFor(link);
965
1294
  await send(bot, chatId, t(lang, 'control.approvalDecided', {
966
1295
  approvalId: payload.approvalId,
@@ -969,7 +1298,7 @@ export async function handleTelegramControlCallback({ bot, query, link }) {
969
1298
  }), { editMessageId });
970
1299
  return showApprovalQueue({ bot, chatId, link });
971
1300
  }
972
- if (action === 'install_provider') return startCliInstall({ bot, chatId, link, provider: payload.provider });
1301
+ if (action === 'install_provider') return startCliInstall({ bot, chatId, link, provider: payload.provider, editMessageId });
973
1302
  if (action === 'auth_provider') {
974
1303
  await send(bot, chatId, `${payload.provider} login:\n${AUTH_HELP[payload.provider] || t(languageFor(link), 'control.providerAuthFallback')}`, { editMessageId });
975
1304
  return;
@@ -994,3 +1323,18 @@ export async function handleTelegramControlCallback({ bot, query, link }) {
994
1323
  });
995
1324
  }
996
1325
  }
1326
+
1327
+ export async function handleTelegramControlCallback(args) {
1328
+ const chatId = args?.query?.message?.chat?.id;
1329
+ if (!chatId) return;
1330
+ return enqueueTelegramJob(chatId, async () => {
1331
+ try {
1332
+ await handleTelegramControlCallbackInternal(args);
1333
+ } catch (error) {
1334
+ console.error('[telegram-control] callback failed:', error);
1335
+ const lang = languageFor(args.link);
1336
+ await args.bot?.answerCallbackQuery(args.query?.id, { text: t(lang, 'error.generic') }).catch(() => {});
1337
+ await send(args.bot, chatId, t(lang, 'error.generic')).catch(() => {});
1338
+ }
1339
+ });
1340
+ }