@pixelbyte-software/pixcode 1.53.3 → 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.
@@ -0,0 +1,250 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ import { telegramLinksDb } from '../../database/db.js';
4
+
5
+ export const TELEGRAM_CONTROL_SCOPES = [
6
+ 'admin',
7
+ 'telegram:read',
8
+ 'telegram:write',
9
+ 'providers:read',
10
+ 'providers:write',
11
+ 'orchestration:read',
12
+ 'orchestration:write',
13
+ 'projects:read',
14
+ 'sessions:read',
15
+ 'webhooks:read',
16
+ ];
17
+
18
+ export const TELEGRAM_PROVIDERS = ['claude', 'cursor', 'codex', 'gemini', 'qwen', 'opencode'];
19
+
20
+ export const TELEGRAM_PROGRESS_MODES = ['final', 'steps', 'all', 'errors'];
21
+
22
+ const MAX_TELEGRAM_TEXT = 3600;
23
+ const CONFIRMATION_TTL_MS = 5 * 60 * 1000;
24
+ const DEFAULT_ACTION_TIMEOUT_MS = 10 * 60 * 1000;
25
+ const chatQueues = new Map();
26
+
27
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
28
+
29
+ export function splitTelegramText(text, max = MAX_TELEGRAM_TEXT) {
30
+ const value = String(text || '').trim();
31
+ if (!value) return [''];
32
+ if (value.length <= max) return [value];
33
+
34
+ const chunks = [];
35
+ let remaining = value;
36
+ while (remaining.length > max) {
37
+ let cut = remaining.lastIndexOf('\n', max);
38
+ if (cut < Math.floor(max * 0.55)) cut = remaining.lastIndexOf(' ', max);
39
+ if (cut < Math.floor(max * 0.55)) cut = max;
40
+ chunks.push(remaining.slice(0, cut).trim());
41
+ remaining = remaining.slice(cut).trim();
42
+ }
43
+ if (remaining) chunks.push(remaining);
44
+ return chunks;
45
+ }
46
+
47
+ export function normalizeTelegramToolResult(result = {}) {
48
+ if (result?.ok === false) {
49
+ return {
50
+ ok: false,
51
+ message: String(result.message || result.error || 'Action failed.'),
52
+ data: result.data ?? null,
53
+ requiresConfirmation: Boolean(result.requiresConfirmation),
54
+ };
55
+ }
56
+ return {
57
+ ok: true,
58
+ message: String(result.message || 'OK'),
59
+ data: result.data ?? result,
60
+ requiresConfirmation: Boolean(result.requiresConfirmation),
61
+ };
62
+ }
63
+
64
+ export function withTimeout(promiseFactory, timeoutMs = DEFAULT_ACTION_TIMEOUT_MS) {
65
+ let timeoutId;
66
+ const timeout = new Promise((_, reject) => {
67
+ timeoutId = setTimeout(() => {
68
+ const error = new Error(`Telegram action timed out after ${timeoutMs}ms`);
69
+ error.code = 'TELEGRAM_ACTION_TIMEOUT';
70
+ reject(error);
71
+ }, timeoutMs);
72
+ });
73
+ return Promise.race([Promise.resolve().then(promiseFactory), timeout])
74
+ .finally(() => clearTimeout(timeoutId));
75
+ }
76
+
77
+ function isRetryableError(error) {
78
+ const status = error?.status || error?.statusCode || error?.response?.status || error?.response?.statusCode;
79
+ if ([408, 409, 425, 429, 500, 502, 503, 504].includes(Number(status))) return true;
80
+ const message = String(error?.message || '');
81
+ return /ECONNRESET|ETIMEDOUT|EAI_AGAIN|fetch failed|network/i.test(message);
82
+ }
83
+
84
+ export async function retryWithBackoff(action, {
85
+ retries = 2,
86
+ baseDelayMs = 500,
87
+ maxDelayMs = 4000,
88
+ shouldRetry = isRetryableError,
89
+ } = {}) {
90
+ let lastError;
91
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
92
+ try {
93
+ return await action(attempt);
94
+ } catch (error) {
95
+ lastError = error;
96
+ if (attempt >= retries || !shouldRetry(error)) break;
97
+ const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
98
+ await sleep(delay);
99
+ }
100
+ }
101
+ throw lastError;
102
+ }
103
+
104
+ export function enqueueTelegramJob(chatId, job, { timeoutMs = DEFAULT_ACTION_TIMEOUT_MS } = {}) {
105
+ const key = String(chatId);
106
+ const previous = chatQueues.get(key) || Promise.resolve();
107
+ const current = previous
108
+ .catch(() => {})
109
+ .then(() => withTimeout(job, timeoutMs));
110
+ let queued;
111
+ queued = current.finally(() => {
112
+ if (chatQueues.get(key) === queued) chatQueues.delete(key);
113
+ }).catch(() => {});
114
+ chatQueues.set(key, queued);
115
+ return current;
116
+ }
117
+
118
+ export async function runTelegramTool({
119
+ userId,
120
+ action,
121
+ execute,
122
+ remoteControlRequired = true,
123
+ timeoutMs = DEFAULT_ACTION_TIMEOUT_MS,
124
+ retries = 1,
125
+ }) {
126
+ const state = telegramLinksDb.getControlState(userId);
127
+ if (remoteControlRequired && state.remoteControlEnabled === false) {
128
+ return normalizeTelegramToolResult({
129
+ ok: false,
130
+ message: 'REMOTE_CONTROL_DISABLED',
131
+ data: { action },
132
+ });
133
+ }
134
+
135
+ const data = await retryWithBackoff(
136
+ () => withTimeout(execute, timeoutMs),
137
+ { retries },
138
+ );
139
+ return normalizeTelegramToolResult({ ok: true, message: 'OK', data });
140
+ }
141
+
142
+ export function createTelegramConfirmation(userId, action, payload = {}) {
143
+ const pending = {
144
+ id: crypto.randomBytes(8).toString('hex'),
145
+ action,
146
+ payload,
147
+ expiresAt: new Date(Date.now() + CONFIRMATION_TTL_MS).toISOString(),
148
+ };
149
+ telegramLinksDb.updateControlState(userId, { pendingConfirmation: pending });
150
+ return pending;
151
+ }
152
+
153
+ export function consumeTelegramConfirmation(userId, id) {
154
+ const state = telegramLinksDb.getControlState(userId);
155
+ const pending = state.pendingConfirmation;
156
+ if (!pending || pending.id !== id) return { ok: false, reason: 'missing' };
157
+ if (Date.parse(pending.expiresAt) <= Date.now()) {
158
+ telegramLinksDb.updateControlState(userId, { pendingConfirmation: null });
159
+ return { ok: false, reason: 'expired' };
160
+ }
161
+ telegramLinksDb.updateControlState(userId, { pendingConfirmation: null });
162
+ return { ok: true, confirmation: pending };
163
+ }
164
+
165
+ export function clearTelegramConfirmation(userId, id = null) {
166
+ const state = telegramLinksDb.getControlState(userId);
167
+ if (id && state.pendingConfirmation?.id !== id) return false;
168
+ telegramLinksDb.updateControlState(userId, { pendingConfirmation: null });
169
+ return true;
170
+ }
171
+
172
+ function normalizedText(text) {
173
+ return String(text || '')
174
+ .trim()
175
+ .toLocaleLowerCase('tr')
176
+ .replace(/\s+/g, ' ');
177
+ }
178
+
179
+ export function classifyTelegramIntent(text, state = {}) {
180
+ if (state.routerEnabled === false || state.routerMode !== 'hybrid') return null;
181
+
182
+ const value = normalizedText(text);
183
+ if (!value) return null;
184
+
185
+ for (const provider of TELEGRAM_PROVIDERS) {
186
+ const providerRe = new RegExp(`(^|\\s)${provider}(\\s|$)`);
187
+ if (
188
+ providerRe.test(value)
189
+ && /(kullan|seç|sec|switch|use|select|provider|sağlayıcı|saglayici)/i.test(value)
190
+ ) {
191
+ return { action: 'provider_select', provider };
192
+ }
193
+ }
194
+
195
+ const projectPick = value.match(/^(?:proje(?:yi)?\s+seç|proje(?:yi)?\s+sec|select project|pick project)\s+(.+)$/i);
196
+ if (projectPick?.[1]) return { action: 'project_select_query', query: projectPick[1].trim() };
197
+
198
+ if (/^(?:projeler|projeleri göster|projeleri goster|proje seç|proje sec|projects?|show projects|select project|pick project)$/.test(value)) {
199
+ return { action: 'projects' };
200
+ }
201
+ if (/^(?:sağlayıcı|saglayici|provider|providers|sağlayıcı seç|saglayici sec|change provider|select provider)$/.test(value)) {
202
+ return { action: 'provider_menu' };
203
+ }
204
+ if (/^(?:model|models|model seç|model sec|change model|select model)$/.test(value)) {
205
+ return { action: 'model_menu' };
206
+ }
207
+ if (/(?:son|last|recent).*(?:run|çalışma|calisma|durum|status)|^(?:runs?|çalışmalar|calismalar)$/.test(value)) {
208
+ return { action: 'runs' };
209
+ }
210
+ if (/^(?:approvals?|onaylar|bekleyen onaylar|izinler)$/.test(value)) {
211
+ return { action: 'approvals' };
212
+ }
213
+ if (/^(?:workflow|workflows|iş akışı|is akisi|orkestrasyon|orchestration|workflow seç|workflow sec)$/.test(value)) {
214
+ return { action: 'workflows' };
215
+ }
216
+ if (/^(?:new chat|yeni sohbet|yeni chat)$/.test(value)) {
217
+ return { action: 'new_chat' };
218
+ }
219
+
220
+ return null;
221
+ }
222
+
223
+ export function resolveTelegramProvider(state = {}) {
224
+ const provider = TELEGRAM_PROVIDERS.includes(state.routerProvider)
225
+ ? state.routerProvider
226
+ : state.selectedProvider;
227
+ return TELEGRAM_PROVIDERS.includes(provider) ? provider : 'opencode';
228
+ }
229
+
230
+ export function resolveTelegramModel(state = {}) {
231
+ return typeof state.routerModel === 'string' && state.routerModel.trim()
232
+ ? state.routerModel.trim()
233
+ : state.selectedModel;
234
+ }
235
+
236
+ export function buildTelegramAgentPrompt(prompt, state = {}) {
237
+ const provider = resolveTelegramProvider(state);
238
+ const model = resolveTelegramModel(state);
239
+ const project = state.selectedProjectName || state.selectedProjectPath || 'not selected';
240
+ return [
241
+ 'Source: Telegram.',
242
+ `Selected project: ${project}.`,
243
+ `Selected provider: ${provider}${model ? ` / ${model}` : ''}.`,
244
+ 'Return a Telegram-friendly final summary with status, relevant IDs, concise output, and any diff/test summary.',
245
+ 'Keep the final answer brief unless the user explicitly asks for detail.',
246
+ '',
247
+ 'User request:',
248
+ String(prompt || '').trim(),
249
+ ].join('\n');
250
+ }
@@ -53,15 +53,19 @@ async function callApi(token, method, params, { signal } = {}) {
53
53
  }
54
54
 
55
55
  export class TelegramHttpBot extends EventEmitter {
56
- constructor(token, { polling = true, pollTimeoutSec = 30 } = {}) {
56
+ constructor(token, { polling = true, pollTimeoutSec = 30, pollLimit = 25, dropPendingUpdates = true } = {}) {
57
57
  super();
58
58
  if (!token) throw new Error('TelegramHttpBot: token is required');
59
59
  this._token = token;
60
60
  this._pollTimeoutSec = pollTimeoutSec;
61
+ this._pollLimit = pollLimit;
62
+ this._dropPendingUpdates = dropPendingUpdates;
61
63
  this._offset = 0;
62
64
  this._polling = false;
63
65
  this._abortController = null;
64
- if (polling) this._startPolling();
66
+ this._pollingLoop = null;
67
+ this._pollingErrorCount = 0;
68
+ if (polling) this.startPolling();
65
69
  }
66
70
 
67
71
  // ---------- Public API (mirrors node-telegram-bot-api surface) ----------
@@ -96,56 +100,102 @@ export class TelegramHttpBot extends EventEmitter {
96
100
  this._polling = false;
97
101
  try { this._abortController?.abort(); } catch { /* ignore */ }
98
102
  this._abortController = null;
103
+ if (this._pollingLoop) {
104
+ await this._pollingLoop.catch(() => {});
105
+ this._pollingLoop = null;
106
+ }
99
107
  }
100
108
 
101
109
  // ---------- Polling loop ----------
102
110
 
103
- async _startPolling() {
111
+ async startPolling({ dropPendingUpdates = this._dropPendingUpdates } = {}) {
112
+ if (this._polling) return;
104
113
  this._polling = true;
105
- // Kick off a non-awaited loop. Each iteration long-polls getUpdates for
106
- // up to pollTimeoutSec, then loops immediately. We deliberately serialize
107
- // (no concurrent long-polls) because Telegram rejects that with 409.
108
- (async () => {
109
- while (this._polling) {
110
- this._abortController = new AbortController();
111
- try {
112
- const updates = await callApi(
113
- this._token,
114
- 'getUpdates',
115
- {
116
- offset: this._offset,
117
- timeout: this._pollTimeoutSec,
118
- allowed_updates: ['message', 'callback_query'],
119
- },
120
- { signal: this._abortController.signal },
121
- );
122
- for (const update of updates) {
123
- if (typeof update.update_id === 'number') {
124
- this._offset = Math.max(this._offset, update.update_id + 1);
125
- }
126
- if (update.message) {
127
- try { this.emit('message', update.message); } catch (err) {
128
- // Don't let a listener exception break the poll loop.
129
- this.emit('polling_error', err);
130
- }
131
- }
132
- if (update.callback_query) {
133
- try { this.emit('callback_query', update.callback_query); } catch (err) {
134
- this.emit('polling_error', err);
135
- }
136
- }
114
+ this._pollingLoop = this._runPollingLoop({ dropPendingUpdates });
115
+ await Promise.resolve();
116
+ }
117
+
118
+ async _dropPendingUpdatesBeforePolling() {
119
+ try {
120
+ await callApi(this._token, 'deleteWebhook', { drop_pending_updates: true });
121
+ return;
122
+ } catch (err) {
123
+ this.emit('polling_error', err);
124
+ }
125
+
126
+ // Fallback for environments where deleteWebhook is rejected: a negative
127
+ // offset asks Telegram to forget older queued updates.
128
+ try {
129
+ const updates = await callApi(this._token, 'getUpdates', {
130
+ offset: -1,
131
+ limit: 1,
132
+ timeout: 0,
133
+ allowed_updates: ['message', 'callback_query'],
134
+ });
135
+ const lastUpdate = Array.isArray(updates) ? updates.at(-1) : null;
136
+ if (typeof lastUpdate?.update_id === 'number') {
137
+ this._offset = lastUpdate.update_id + 1;
138
+ }
139
+ } catch (err) {
140
+ this.emit('polling_error', err);
141
+ }
142
+ }
143
+
144
+ async _emitSerial(eventName, payload) {
145
+ const listeners = this.listeners(eventName);
146
+ for (const listener of listeners) {
147
+ try {
148
+ await listener(payload);
149
+ } catch (err) {
150
+ this.emit('polling_error', err);
151
+ }
152
+ }
153
+ }
154
+
155
+ async _runPollingLoop({ dropPendingUpdates }) {
156
+ if (dropPendingUpdates) {
157
+ await this._dropPendingUpdatesBeforePolling();
158
+ }
159
+
160
+ // Each iteration long-polls getUpdates for up to pollTimeoutSec, then
161
+ // loops immediately. We deliberately serialize update handling because a
162
+ // stale Telegram backlog can otherwise fan out into many expensive scans.
163
+ while (this._polling) {
164
+ this._abortController = new AbortController();
165
+ try {
166
+ const updates = await callApi(
167
+ this._token,
168
+ 'getUpdates',
169
+ {
170
+ offset: this._offset,
171
+ limit: this._pollLimit,
172
+ timeout: this._pollTimeoutSec,
173
+ allowed_updates: ['message', 'callback_query'],
174
+ },
175
+ { signal: this._abortController.signal },
176
+ );
177
+ for (const update of updates) {
178
+ if (typeof update.update_id === 'number') {
179
+ this._offset = Math.max(this._offset, update.update_id + 1);
180
+ }
181
+ if (update.message) {
182
+ await this._emitSerial('message', update.message);
183
+ }
184
+ if (update.callback_query) {
185
+ await this._emitSerial('callback_query', update.callback_query);
137
186
  }
138
- } catch (err) {
139
- // AbortError is the expected path when stopPolling() is called.
140
- if (err?.name === 'AbortError' || !this._polling) break;
141
- this.emit('polling_error', err);
142
- // Back off before retrying — rapid retries on 401/409 would
143
- // otherwise spin at 100% CPU. Upstream consumer's polling_error
144
- // handler may also call stopBot() on 401/409 which flips _polling
145
- // off and breaks the loop on the next tick.
146
- await new Promise((r) => setTimeout(r, 2000));
147
187
  }
188
+ this._pollingErrorCount = 0;
189
+ } catch (err) {
190
+ // AbortError is the expected path when stopPolling() is called.
191
+ if (err?.name === 'AbortError' || !this._polling) break;
192
+ this.emit('polling_error', err);
193
+ const status = err?.response?.statusCode || err?.code;
194
+ const baseDelay = status === 409 ? 2000 : 1000;
195
+ const delay = Math.min(60_000, baseDelay * 2 ** this._pollingErrorCount);
196
+ this._pollingErrorCount = Math.min(this._pollingErrorCount + 1, 6);
197
+ await new Promise((r) => setTimeout(r, delay));
148
198
  }
149
- })();
199
+ }
150
200
  }
151
201
  }
@@ -55,8 +55,11 @@ const EN = {
55
55
  'control.button.refresh': 'Refresh',
56
56
  'control.button.approve': 'Approve',
57
57
  'control.button.deny': 'Deny',
58
+ 'control.button.confirm': 'Confirm',
59
+ 'control.button.cancel': 'Cancel',
58
60
  'control.noProjects': 'No Pixcode projects were found yet. Add/open a project in Pixcode first.',
59
61
  'control.pickProject': 'Pick the project Telegram should control:',
62
+ 'control.projectNotFound': 'No project matched "{{query}}". Pick one from the list.',
60
63
  'control.pickProvider': 'Pick the CLI provider Telegram should use:',
61
64
  'control.modelsFor': 'Models for {{provider}}:',
62
65
  'control.pickWorkflow': 'Pick an orchestration workflow:',
@@ -83,6 +86,12 @@ const EN = {
83
86
  'control.authMenu': 'Pick a provider for OAuth/login instructions:',
84
87
  'control.manualInstall': 'Manual install for {{provider}}:\n{{manual}}',
85
88
  'control.installStarted': 'Install started for {{provider}}.\nJob: {{jobId}}\nCommand: {{command}}',
89
+ 'control.confirmationRequired': 'This action needs confirmation before it can run.',
90
+ 'control.confirmInstall': 'Confirm CLI install/repair for {{provider}}?',
91
+ 'control.confirmCancelRun': 'Confirm cancel for workflow run {{runId}}?',
92
+ 'control.confirmationExpired': 'That confirmation expired. Start the action again.',
93
+ 'control.confirmationMissing': 'No matching confirmation is pending. Start the action again.',
94
+ 'control.confirmationCanceled': 'Action canceled.',
86
95
  'control.settingsTitle': 'Telegram settings',
87
96
  'control.pickLanguage': 'Pick Telegram language:',
88
97
  'control.menuExpired': 'This menu expired. Send /menu.',
@@ -157,8 +166,11 @@ const TR = {
157
166
  'control.button.refresh': 'Yenile',
158
167
  'control.button.approve': 'Onayla',
159
168
  'control.button.deny': 'Reddet',
169
+ 'control.button.confirm': 'Onayla',
170
+ 'control.button.cancel': 'Vazgeç',
160
171
  'control.noProjects': 'Henüz Pixcode projesi bulunamadı. Önce Pixcode içinde proje ekle/aç.',
161
172
  'control.pickProject': 'Telegramın kontrol edeceği projeyi seç:',
173
+ 'control.projectNotFound': '"{{query}}" ile eşleşen proje bulunamadı. Listeden birini seç.',
162
174
  'control.pickProvider': 'Telegramın kullanacağı CLI sağlayıcısını seç:',
163
175
  'control.modelsFor': '{{provider}} modelleri:',
164
176
  'control.pickWorkflow': 'Bir orkestrasyon iş akışı seç:',
@@ -185,6 +197,12 @@ const TR = {
185
197
  'control.authMenu': 'OAuth/giriş yönergeleri için sağlayıcı seç:',
186
198
  'control.manualInstall': '{{provider}} manuel kurulum:\n{{manual}}',
187
199
  'control.installStarted': '{{provider}} kurulumu başladı.\nİş: {{jobId}}\nKomut: {{command}}',
200
+ 'control.confirmationRequired': 'Bu işlem çalışmadan önce onay gerektiriyor.',
201
+ 'control.confirmInstall': '{{provider}} için CLI kurulum/onarım işlemini onaylıyor musun?',
202
+ 'control.confirmCancelRun': '{{runId}} workflow çalışmasını iptal etmeyi onaylıyor musun?',
203
+ 'control.confirmationExpired': 'Bu onayın süresi doldu. İşlemi yeniden başlat.',
204
+ 'control.confirmationMissing': 'Bekleyen eşleşen bir onay yok. İşlemi yeniden başlat.',
205
+ 'control.confirmationCanceled': 'İşlem iptal edildi.',
188
206
  'control.settingsTitle': 'Telegram ayarları',
189
207
  'control.pickLanguage': 'Telegram dilini seç:',
190
208
  'control.menuExpired': 'Bu menünün süresi doldu. /menu gönder.',