@pixelbyte-software/pixcode 1.53.2 → 1.53.4

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.
@@ -234,7 +234,7 @@ export const startBot = async ({ token, persist = true } = {}) => {
234
234
  // 409 conflicts on every long-poll.
235
235
  if (bot) await stopBot();
236
236
 
237
- const instance = new TelegramHttpBot(token, { polling: true });
237
+ const instance = new TelegramHttpBot(token, { polling: false, dropPendingUpdates: true });
238
238
  // Validate the token first — if getMe fails we never want to persist a
239
239
  // broken token or leave the poller running.
240
240
  let me;
@@ -255,17 +255,18 @@ export const startBot = async ({ token, persist = true } = {}) => {
255
255
 
256
256
  if (persist) telegramConfigDb.set(token, me.username);
257
257
 
258
- bot.on('message', (msg) => {
259
- handleMessage(msg).catch((err) => {
258
+ bot.on('message', async (msg) => {
259
+ await handleMessage(msg).catch((err) => {
260
260
  console.error('[telegram] handleMessage crashed:', err);
261
261
  });
262
262
  });
263
- bot.on('callback_query', (query) => {
264
- handleCallbackQuery(query).catch((err) => {
263
+ bot.on('callback_query', async (query) => {
264
+ await handleCallbackQuery(query).catch((err) => {
265
265
  console.error('[telegram] handleCallbackQuery crashed:', err);
266
266
  });
267
267
  });
268
268
  wirePollingErrors();
269
+ await bot.startPolling({ dropPendingUpdates: true });
269
270
 
270
271
  console.log(`[telegram] bot started as @${me.username}`);
271
272
  telegramEvents.emit('started', { username: me.username });
@@ -9,6 +9,7 @@ import { SUPPORTED_LANGUAGES, t } from './translations.js';
9
9
  const PROVIDERS = ['claude', 'cursor', 'codex', 'gemini', 'qwen', 'opencode'];
10
10
  const TERMINAL_RUN_STATES = new Set(['completed', 'failed', 'canceled']);
11
11
  const CALLBACK_TTL_MS = 10 * 60 * 1000;
12
+ const MAX_CALLBACK_ACTIONS = 1000;
12
13
  const MAX_TELEGRAM_TEXT = 3600;
13
14
  const callbackActions = new Map();
14
15
  const runMonitors = new Map();
@@ -94,7 +95,23 @@ export function updateTelegramControlState(userId, patch) {
94
95
  return telegramLinksDb.updateControlState(userId, patch);
95
96
  }
96
97
 
98
+ function pruneCallbackActions() {
99
+ const now = Date.now();
100
+ for (const [id, entry] of callbackActions.entries()) {
101
+ if (entry.expiresAt < now) {
102
+ callbackActions.delete(id);
103
+ }
104
+ }
105
+
106
+ while (callbackActions.size > MAX_CALLBACK_ACTIONS) {
107
+ const oldestId = callbackActions.keys().next().value;
108
+ if (!oldestId) break;
109
+ callbackActions.delete(oldestId);
110
+ }
111
+ }
112
+
97
113
  function registerAction(action, payload = {}) {
114
+ pruneCallbackActions();
98
115
  const id = crypto.randomBytes(8).toString('hex');
99
116
  callbackActions.set(id, {
100
117
  action,
@@ -53,15 +53,18 @@ 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
+ if (polling) this.startPolling();
65
68
  }
66
69
 
67
70
  // ---------- Public API (mirrors node-telegram-bot-api surface) ----------
@@ -96,56 +99,101 @@ export class TelegramHttpBot extends EventEmitter {
96
99
  this._polling = false;
97
100
  try { this._abortController?.abort(); } catch { /* ignore */ }
98
101
  this._abortController = null;
102
+ if (this._pollingLoop) {
103
+ await this._pollingLoop.catch(() => {});
104
+ this._pollingLoop = null;
105
+ }
99
106
  }
100
107
 
101
108
  // ---------- Polling loop ----------
102
109
 
103
- async _startPolling() {
110
+ async startPolling({ dropPendingUpdates = this._dropPendingUpdates } = {}) {
111
+ if (this._polling) return;
104
112
  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
- }
113
+ this._pollingLoop = this._runPollingLoop({ dropPendingUpdates });
114
+ await Promise.resolve();
115
+ }
116
+
117
+ async _dropPendingUpdatesBeforePolling() {
118
+ try {
119
+ await callApi(this._token, 'deleteWebhook', { drop_pending_updates: true });
120
+ return;
121
+ } catch (err) {
122
+ this.emit('polling_error', err);
123
+ }
124
+
125
+ // Fallback for environments where deleteWebhook is rejected: a negative
126
+ // offset asks Telegram to forget older queued updates.
127
+ try {
128
+ const updates = await callApi(this._token, 'getUpdates', {
129
+ offset: -1,
130
+ limit: 1,
131
+ timeout: 0,
132
+ allowed_updates: ['message', 'callback_query'],
133
+ });
134
+ const lastUpdate = Array.isArray(updates) ? updates.at(-1) : null;
135
+ if (typeof lastUpdate?.update_id === 'number') {
136
+ this._offset = lastUpdate.update_id + 1;
137
+ }
138
+ } catch (err) {
139
+ this.emit('polling_error', err);
140
+ }
141
+ }
142
+
143
+ async _emitSerial(eventName, payload) {
144
+ const listeners = this.listeners(eventName);
145
+ for (const listener of listeners) {
146
+ try {
147
+ await listener(payload);
148
+ } catch (err) {
149
+ this.emit('polling_error', err);
150
+ }
151
+ }
152
+ }
153
+
154
+ async _runPollingLoop({ dropPendingUpdates }) {
155
+ if (dropPendingUpdates) {
156
+ await this._dropPendingUpdatesBeforePolling();
157
+ }
158
+
159
+ // Each iteration long-polls getUpdates for up to pollTimeoutSec, then
160
+ // loops immediately. We deliberately serialize update handling because a
161
+ // stale Telegram backlog can otherwise fan out into many expensive scans.
162
+ while (this._polling) {
163
+ this._abortController = new AbortController();
164
+ try {
165
+ const updates = await callApi(
166
+ this._token,
167
+ 'getUpdates',
168
+ {
169
+ offset: this._offset,
170
+ limit: this._pollLimit,
171
+ timeout: this._pollTimeoutSec,
172
+ allowed_updates: ['message', 'callback_query'],
173
+ },
174
+ { signal: this._abortController.signal },
175
+ );
176
+ for (const update of updates) {
177
+ if (typeof update.update_id === 'number') {
178
+ this._offset = Math.max(this._offset, update.update_id + 1);
179
+ }
180
+ if (update.message) {
181
+ await this._emitSerial('message', update.message);
182
+ }
183
+ if (update.callback_query) {
184
+ await this._emitSerial('callback_query', update.callback_query);
137
185
  }
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
186
  }
187
+ } catch (err) {
188
+ // AbortError is the expected path when stopPolling() is called.
189
+ if (err?.name === 'AbortError' || !this._polling) break;
190
+ this.emit('polling_error', err);
191
+ // Back off before retrying — rapid retries on 401/409 would
192
+ // otherwise spin at 100% CPU. Upstream consumer's polling_error
193
+ // handler may also call stopBot() on 401/409 which flips _polling
194
+ // off and breaks the loop on the next tick.
195
+ await new Promise((r) => setTimeout(r, 2000));
148
196
  }
149
- })();
197
+ }
150
198
  }
151
199
  }