@link-assistant/hive-mind 2.5.6 → 2.6.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c031b7b: Handle weekly-only Codex usage windows and add Telegram Bot API rolling rate-limit telemetry to `/limits`.
8
+
3
9
  ## 2.5.6
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.5.6",
3
+ "version": "2.6.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Classify raw Codex windows by duration instead of response position.
3
+ *
4
+ * Historically the primary window was five hours and the secondary window was
5
+ * weekly. Weekly-only accounts can put the weekly value in the primary slot.
6
+ */
7
+ export function classifyCodexRateLimitWindows(rateLimit) {
8
+ const primary = rateLimit?.primary_window;
9
+ const secondary = rateLimit?.secondary_window;
10
+ const windows = [primary, secondary].filter(Boolean);
11
+ const hasDurationMetadata = windows.some(window => Number.isFinite(Number(window?.limit_window_seconds)));
12
+
13
+ if (!hasDurationMetadata) {
14
+ return { sessionWindow: primary, weeklyWindow: secondary };
15
+ }
16
+
17
+ const sessionWindow = windows.find(window => {
18
+ const seconds = Number(window?.limit_window_seconds);
19
+ return Number.isFinite(seconds) && seconds > 0 && seconds < 24 * 60 * 60;
20
+ });
21
+ const weeklyWindow = windows.find(window => {
22
+ const seconds = Number(window?.limit_window_seconds);
23
+ return Number.isFinite(seconds) && seconds >= 24 * 60 * 60;
24
+ });
25
+
26
+ return { sessionWindow, weeklyWindow };
27
+ }
@@ -85,6 +85,12 @@ const ENGLISH_LIMITS = {
85
85
  subscription_detail_trial_ends: 'trial ends {{time}}',
86
86
  subscription_detail_trial_ends_in: 'trial ends in {{duration}}; {{time}}',
87
87
  subscription_status: 'Subscription: {{status}}',
88
+ telegram_api: 'Telegram Bot API (local rolling telemetry)',
89
+ telegram_global_window: '{{used}}/{{limit}} messages in 1s',
90
+ telegram_group_window: '{{used}}/{{limit}} messages in busiest group over 1m',
91
+ telegram_last_rate_limit: 'Last 429: {{method}}',
92
+ telegram_rate_limit_responses: '429 responses since startup: {{count}}',
93
+ telegram_retry_in: 'retry in {{seconds}}s',
88
94
  trial_ends: 'Trial ends {{time}}',
89
95
  trial_ends_in: 'Trial ends in {{duration}} ({{time}})',
90
96
  unavailable: 'unavailable',
@@ -12,9 +12,11 @@ import { promisify } from 'node:util';
12
12
  import dayjs from 'dayjs';
13
13
  import utc from 'dayjs/plugin/utc.js';
14
14
 
15
+ import { classifyCodexRateLimitWindows } from './codex-rate-limit-windows.lib.mjs';
15
16
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. execGhWithRetry adds transient-network retry (#1756).
16
17
  import { formatLimitResetsAt, formatLimitResetsIn, formatLocalizedCurrentTime, formatLocalizedRelativeTime, formatLocalizedResetTime, localizeCompactDuration, lt, resolveLimitLocale } from './limits-i18n.lib.mjs';
17
18
  import { formatSubscriptionHeading, formatSubscriptionLines, getCachedClaudeSubscription, getCachedCodexSubscription, getClaudeSubscriptionInfo, getCodexSubscriptionInfo } from './limits-subscription.lib.mjs';
19
+ import { getTelegramRateLimits } from './telegram-rate-limit.lib.mjs';
18
20
  export { getCachedClaudeSubscription, getCachedCodexSubscription, getClaudeSubscriptionInfo, getCodexSubscriptionInfo };
19
21
  // Initialize dayjs plugins
20
22
  dayjs.extend(utc);
@@ -75,6 +77,15 @@ function mapCodexWindow(window) {
75
77
  };
76
78
  }
77
79
 
80
+ export function mapCodexRateLimitWindows(rateLimit) {
81
+ const { sessionWindow, weeklyWindow } = classifyCodexRateLimitWindows(rateLimit);
82
+
83
+ return {
84
+ currentSession: mapCodexWindow(sessionWindow),
85
+ allModels: mapCodexWindow(weeklyWindow),
86
+ };
87
+ }
88
+
78
89
  export async function readCodexAuth(authPath = DEFAULT_CODEX_AUTH_PATH, verbose = false) {
79
90
  try {
80
91
  const content = await readFile(authPath, 'utf-8');
@@ -936,8 +947,7 @@ export async function getCodexUsageLimits(verbose = false, authPath = DEFAULT_CO
936
947
  }
937
948
 
938
949
  const usage = {
939
- currentSession: mapCodexWindow(data?.rate_limit?.primary_window),
940
- allModels: mapCodexWindow(data?.rate_limit?.secondary_window),
950
+ ...mapCodexRateLimitWindows(data?.rate_limit),
941
951
  sonnetOnly: {
942
952
  percentage: null,
943
953
  resetTime: null,
@@ -949,8 +959,7 @@ export async function getCodexUsageLimits(verbose = false, authPath = DEFAULT_CO
949
959
  ? data.additional_rate_limits.map(limit => ({
950
960
  limitId: limit?.metered_feature || null,
951
961
  limitName: limit?.limit_name || limit?.metered_feature || 'additional',
952
- currentSession: mapCodexWindow(limit?.rate_limit?.primary_window),
953
- allModels: mapCodexWindow(limit?.rate_limit?.secondary_window),
962
+ ...mapCodexRateLimitWindows(limit?.rate_limit),
954
963
  allowed: limit?.rate_limit?.allowed ?? null,
955
964
  limitReached: limit?.rate_limit?.limit_reached ?? null,
956
965
  }))
@@ -1116,6 +1125,24 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
1116
1125
  sections.push(section);
1117
1126
  }
1118
1127
 
1128
+ const telegramRateLimit = options?.telegramRateLimit || null;
1129
+ if (telegramRateLimit) {
1130
+ let section = `${lt('telegram_api', {}, { locale })}\n`;
1131
+ const global = telegramRateLimit.global;
1132
+ section += `${getProgressBar(global.usedPercentage)} ${global.usedPercentage}% ${lt('used', {}, { locale })}\n`;
1133
+ section += `${lt('telegram_global_window', { used: global.used, limit: global.limit }, { locale })}\n`;
1134
+ const group = telegramRateLimit.busiestGroup;
1135
+ section += `${getProgressBar(group.usedPercentage)} ${group.usedPercentage}% ${lt('used', {}, { locale })}\n`;
1136
+ section += `${lt('telegram_group_window', { used: group.used, limit: group.limit }, { locale })}\n`;
1137
+ section += `${lt('telegram_rate_limit_responses', { count: telegramRateLimit.rateLimitResponses }, { locale })}\n`;
1138
+ if (telegramRateLimit.lastRateLimit) {
1139
+ const retry = telegramRateLimit.lastRateLimit.retryRemainingSeconds;
1140
+ const retryText = retry === null ? '' : `, ${lt('telegram_retry_in', { seconds: retry }, { locale })}`;
1141
+ section += `${lt('telegram_last_rate_limit', { method: telegramRateLimit.lastRateLimit.method }, { locale })}${retryText}\n`;
1142
+ }
1143
+ sections.push(section);
1144
+ }
1145
+
1119
1146
  const claudeHeading = formatSubscriptionHeading('claude', subscription, { locale });
1120
1147
  const useShortClaudeLabels = Boolean(claudeHeading);
1121
1148
  const claudeSections = [];
@@ -1194,7 +1221,7 @@ export function formatCodexLimitsSection(codexLimits, codexError = null, options
1194
1221
  const sessionSection = formatLimitWindowSection(useTitledLayout ? lt('five_hour_limit_session', {}, { locale }) : lt('codex_5_hour_session', {}, { locale }), usage?.currentSession, 5, DISPLAY_THRESHOLDS.CODEX_5_HOUR_SESSION, { locale });
1195
1222
  const weeklySection = formatLimitWindowSection(useTitledLayout ? lt('current_week', {}, { locale }) : lt('current_week_all_models', {}, { locale }), usage?.allModels, 168, DISPLAY_THRESHOLDS.CODEX_WEEKLY, { locale });
1196
1223
 
1197
- section += `${sessionSection}\n${weeklySection}`;
1224
+ section += [sessionSection, weeklySection].filter((_, index) => (index === 0 ? hasLimitPercentage(usage?.currentSession) : hasLimitPercentage(usage?.allModels))).join('\n');
1198
1225
 
1199
1226
  const visibleAdditionalRateLimits = additionalRateLimits.filter(limit => hasPositivePercentage(limit.allModels?.percentage));
1200
1227
  if (visibleAdditionalRateLimits.length > 0) {
@@ -1202,9 +1229,10 @@ export function formatCodexLimitsSection(codexLimits, codexError = null, options
1202
1229
  for (const limit of visibleAdditionalRateLimits) {
1203
1230
  const sessionPct = limit.currentSession?.percentage;
1204
1231
  const weeklyPct = limit.allModels?.percentage;
1205
- const sessionText = sessionPct === null || sessionPct === undefined ? `${lt('session', {}, { locale })} ${lt('na', {}, { locale })}` : `${lt('session', {}, { locale })} ${Math.floor(sessionPct)}%`;
1206
- const weeklyText = weeklyPct === null || weeklyPct === undefined ? `${lt('week', {}, { locale })} ${lt('na', {}, { locale })}` : `${lt('week', {}, { locale })} ${Math.floor(weeklyPct)}%`;
1207
- section += `${limit.limitName}: ${sessionText}, ${weeklyText}\n`;
1232
+ const windowTexts = [];
1233
+ if (sessionPct !== null && sessionPct !== undefined) windowTexts.push(`${lt('session', {}, { locale })} ${Math.floor(sessionPct)}%`);
1234
+ if (weeklyPct !== null && weeklyPct !== undefined) windowTexts.push(`${lt('week', {}, { locale })} ${Math.floor(weeklyPct)}%`);
1235
+ section += `${limit.limitName}: ${windowTexts.join(', ')}\n`;
1208
1236
  }
1209
1237
  }
1210
1238
 
@@ -1409,8 +1437,8 @@ export async function getCachedDiskInfo(verbose = false) {
1409
1437
  }
1410
1438
 
1411
1439
  export async function getAllCachedLimits(verbose = false) {
1412
- const [claude, codex, github, memory, cpu, disk, claudeSubscription, codexSubscription] = await Promise.all([getCachedClaudeLimits(verbose), getCachedCodexLimits(verbose), getCachedGitHubLimits(verbose), getCachedMemoryInfo(verbose), getCachedCpuInfo(verbose), getCachedDiskInfo(verbose), getCachedClaudeSubscription(verbose), getCachedCodexSubscription(verbose)]);
1413
- return { claude, codex, github, memory, cpu, disk, claudeSubscription, codexSubscription };
1440
+ const [claude, codex, github, memory, cpu, disk, claudeSubscription, codexSubscription, telegram] = await Promise.all([getCachedClaudeLimits(verbose), getCachedCodexLimits(verbose), getCachedGitHubLimits(verbose), getCachedMemoryInfo(verbose), getCachedCpuInfo(verbose), getCachedDiskInfo(verbose), getCachedClaudeSubscription(verbose), getCachedCodexSubscription(verbose), getTelegramRateLimits(verbose)]);
1441
+ return { claude, codex, github, memory, cpu, disk, claudeSubscription, codexSubscription, telegram };
1414
1442
  }
1415
1443
 
1416
1444
  export default {
@@ -334,6 +334,20 @@ en
334
334
  label "Subscription ends {{time}}"
335
335
  in "Subscription ends in {{duration}} ({{time}})"
336
336
  status "Subscription: {{status}}"
337
+ telegram
338
+ api "Telegram Bot API (local rolling telemetry)"
339
+ global
340
+ window "{{used}}/{{limit}} messages in 1s"
341
+ group
342
+ window "{{used}}/{{limit}} messages in busiest group over 1m"
343
+ last
344
+ rate
345
+ limit "Last 429: {{method}}"
346
+ rate
347
+ limit
348
+ responses "429 responses since startup: {{count}}"
349
+ retry
350
+ in "retry in {{seconds}}s"
337
351
  trial
338
352
  ends
339
353
  label "Trial ends {{time}}"
@@ -334,6 +334,20 @@ hi
334
334
  label "सदस्यता समाप्त होगी {{time}}"
335
335
  in "सदस्यता {{duration}} में समाप्त होगी ({{time}})"
336
336
  status "सदस्यता: {{status}}"
337
+ telegram
338
+ api "Telegram Bot API (स्थानीय रोलिंग टेलीमेट्री)"
339
+ global
340
+ window "1 सेकंड में {{used}}/{{limit}} संदेश"
341
+ group
342
+ window "सबसे व्यस्त समूह में 1 मिनट में {{used}}/{{limit}} संदेश"
343
+ last
344
+ rate
345
+ limit "अंतिम 429: {{method}}"
346
+ rate
347
+ limit
348
+ responses "स्टार्टअप के बाद 429 प्रतिक्रियाएँ: {{count}}"
349
+ retry
350
+ in "{{seconds}} सेकंड में पुनः प्रयास"
337
351
  trial
338
352
  ends
339
353
  label "ट्रायल समाप्त होगा {{time}}"
@@ -334,6 +334,20 @@ ru
334
334
  label "Подписка заканчивается {{time}}"
335
335
  in "Подписка заканчивается через {{duration}} ({{time}})"
336
336
  status "Подписка: {{status}}"
337
+ telegram
338
+ api "Telegram Bot API (локальная скользящая статистика)"
339
+ global
340
+ window "{{used}}/{{limit}} сообщений за 1 с"
341
+ group
342
+ window "{{used}}/{{limit}} сообщений в самой активной группе за 1 мин"
343
+ last
344
+ rate
345
+ limit "Последний ответ 429: {{method}}"
346
+ rate
347
+ limit
348
+ responses "Ответов 429 с момента запуска: {{count}}"
349
+ retry
350
+ in "повтор через {{seconds}} с"
337
351
  trial
338
352
  ends
339
353
  label "Пробный период заканчивается {{time}}"
@@ -334,6 +334,20 @@ zh
334
334
  label "订阅结束于 {{time}}"
335
335
  in "订阅将在 {{duration}} 后结束 ({{time}})"
336
336
  status "订阅: {{status}}"
337
+ telegram
338
+ api "Telegram Bot API(本地滚动遥测)"
339
+ global
340
+ window "1 秒内 {{used}}/{{limit}} 条消息"
341
+ group
342
+ window "最繁忙群组 1 分钟内 {{used}}/{{limit}} 条消息"
343
+ last
344
+ rate
345
+ limit "最近一次 429:{{method}}"
346
+ rate
347
+ limit
348
+ responses "启动后的 429 响应:{{count}}"
349
+ retry
350
+ in "{{seconds}} 秒后重试"
337
351
  trial
338
352
  ends
339
353
  label "试用结束于 {{time}}"
@@ -329,6 +329,7 @@ const { executeStartScreen: executeStartScreenCommand, buildExecuteAndUpdateMess
329
329
  const { isChatStopped, getChatStopInfo, getStoppedChatRejectMessage, DEFAULT_STOP_REASON } = await import('./telegram-start-stop-command.lib.mjs');
330
330
  const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwarded: _isForwarded, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
331
331
  const { installTelegramFormattingFallback, isTelegramFormattingError, isTelegramMessageTooLongError, safeEditMessageText, safeReply, TELEGRAM_TEXT_LIMIT } = await import('./telegram-safe-reply.lib.mjs');
332
+ const { installTelegramRateLimitTracker } = await import('./telegram-rate-limit.lib.mjs');
332
333
  const { registerTerminalWatchCommand, startAutoTerminalWatchForSession } = await import('./telegram-terminal-watch-command.lib.mjs');
333
334
  const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
334
335
  const { trackSession, untrackSession, startSessionMonitoring, hasActiveSessionForUrlAsync, findStoppableSessionByUrl, setSessionStore, setSessionLogger, resumeTrackedSessions, getActiveSessionCount } = await import('./session-monitor.lib.mjs');
@@ -352,12 +353,11 @@ await preloadAllLocales();
352
353
 
353
354
  const telegrafModule = await use('telegraf');
354
355
  const { Telegraf } = telegrafModule;
355
-
356
356
  const bot = new Telegraf(BOT_TOKEN, {
357
357
  handlerTimeout: Infinity, // Remove default 90s timeout; command handlers like /solve spawn long-running processes
358
358
  });
359
359
  installTelegramFormattingFallback(bot.telegram, { verbose: VERBOSE });
360
-
360
+ installTelegramRateLimitTracker(bot.telegram, { verbose: VERBOSE });
361
361
  // Track bot startup time (Unix seconds to match Telegram's message.date format)
362
362
  const BOT_START_TIME = Math.floor(Date.now() / 1000);
363
363
 
@@ -635,7 +635,7 @@ bot.command('limits', async ctx => {
635
635
  const claudeSubscription = limits.claudeSubscription?.success ? limits.claudeSubscription.subscription : null;
636
636
  const codexSubscription = limits.codexSubscription?.success ? limits.codexSubscription.subscription : null;
637
637
  const codexSection = formatCodexLimitsSection(limits.codex.success ? limits.codex : null, codexError, { locale: userLocale, subscription: codexSubscription });
638
- const message = t('telegram.usage_limits_title', {}, { locale: userLocale }) + '\n\n' + formatUsageMessage(limits.claude.success ? limits.claude.usage : null, limits.disk.success ? limits.disk.diskSpace : null, limits.github.success ? limits.github.githubRateLimit : null, limits.cpu.success ? limits.cpu.cpuLoad : null, limits.memory.success ? limits.memory.memory : null, claudeError, [codexSection, queueStatus], { locale: userLocale, subscription: claudeSubscription });
638
+ const message = t('telegram.usage_limits_title', {}, { locale: userLocale }) + '\n\n' + formatUsageMessage(limits.claude.success ? limits.claude.usage : null, limits.disk.success ? limits.disk.diskSpace : null, limits.github.success ? limits.github.githubRateLimit : null, limits.cpu.success ? limits.cpu.cpuLoad : null, limits.memory.success ? limits.memory.memory : null, claudeError, [codexSection, queueStatus], { locale: userLocale, subscription: claudeSubscription, telegramRateLimit: limits.telegram.telegramRateLimit });
639
639
  await safeEditMessageText(ctx.telegram, fetchingMessage.chat.id, fetchingMessage.message_id, undefined, message, { parse_mode: 'Markdown', fallbackLocale: userLocale, verbose: VERBOSE });
640
640
  });
641
641
  bot.command('version', async ctx => {
@@ -0,0 +1,167 @@
1
+ /**
2
+ * In-process Telegram Bot API rate-limit telemetry.
3
+ *
4
+ * Telegram does not expose a quota endpoint. The Bot API documents approximate
5
+ * outbound-message limits and reports actual flood control as error 429 with a
6
+ * ResponseParameters.retry_after value. This tracker therefore combines local
7
+ * rolling counters with the last observed server-side throttle.
8
+ */
9
+
10
+ export const TELEGRAM_RATE_LIMITS = Object.freeze({
11
+ GLOBAL_MESSAGES_PER_SECOND: 30,
12
+ GROUP_MESSAGES_PER_MINUTE: 20,
13
+ });
14
+
15
+ const TRACKER_INSTALLED = Symbol.for('hiveMind.telegramRateLimitTrackerInstalled');
16
+
17
+ function isMessageMethod(method) {
18
+ const name = String(method || '');
19
+ if (/^sendChatAction$/i.test(name)) return false;
20
+ return /^(send|copyMessage$|forwardMessage$|editMessage)/i.test(name);
21
+ }
22
+
23
+ function toChatId(payload) {
24
+ const value = payload?.chat_id;
25
+ return value === null || value === undefined ? null : String(value);
26
+ }
27
+
28
+ function isGroupChatId(chatId) {
29
+ return typeof chatId === 'string' && chatId.startsWith('-');
30
+ }
31
+
32
+ function percentage(used, limit) {
33
+ return limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
34
+ }
35
+
36
+ function extractRateLimitError(error) {
37
+ const response = error?.response || error;
38
+ const code = response?.error_code ?? response?.status ?? error?.code;
39
+ const description = response?.description || error?.description || error?.message || '';
40
+ if (Number(code) !== 429 && !/\b429\b|too many requests/i.test(String(description))) return null;
41
+
42
+ const rawRetryAfter = response?.parameters?.retry_after ?? error?.parameters?.retry_after;
43
+ const retryAfterSeconds = Number(rawRetryAfter);
44
+ return {
45
+ retryAfterSeconds: Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0 ? retryAfterSeconds : null,
46
+ description: String(description),
47
+ };
48
+ }
49
+
50
+ export class TelegramRateLimitTracker {
51
+ constructor({ now = Date.now } = {}) {
52
+ this.now = now;
53
+ this.messageEvents = [];
54
+ this.totalApiRequests = 0;
55
+ this.rateLimitResponses = 0;
56
+ this.lastRateLimit = null;
57
+ }
58
+
59
+ prune(now = this.now()) {
60
+ const oldestRelevant = now - 60_000;
61
+ this.messageEvents = this.messageEvents.filter(event => event.at > oldestRelevant);
62
+ }
63
+
64
+ recordRequest(method, payload = {}) {
65
+ this.totalApiRequests++;
66
+ if (!isMessageMethod(method)) return;
67
+
68
+ const now = this.now();
69
+ const chatId = toChatId(payload);
70
+ this.messageEvents.push({ at: now, method: String(method), chatId, isGroup: isGroupChatId(chatId) });
71
+ this.prune(now);
72
+ }
73
+
74
+ recordError(error, method, payload = {}) {
75
+ const rateLimit = extractRateLimitError(error);
76
+ if (!rateLimit) return false;
77
+
78
+ const observedAt = this.now();
79
+ this.rateLimitResponses++;
80
+ this.lastRateLimit = {
81
+ ...rateLimit,
82
+ method: String(method || 'unknown'),
83
+ chatId: toChatId(payload),
84
+ observedAt,
85
+ retryUntil: rateLimit.retryAfterSeconds === null ? null : observedAt + rateLimit.retryAfterSeconds * 1_000,
86
+ };
87
+ return true;
88
+ }
89
+
90
+ getSnapshot() {
91
+ const now = this.now();
92
+ this.prune(now);
93
+
94
+ const globalUsed = this.messageEvents.filter(event => event.at > now - 1_000).length;
95
+ const groupCounts = new Map();
96
+ for (const event of this.messageEvents) {
97
+ if (!event.isGroup || event.chatId === null) continue;
98
+ groupCounts.set(event.chatId, (groupCounts.get(event.chatId) || 0) + 1);
99
+ }
100
+ let busiestGroupChatId = null;
101
+ let busiestGroupUsed = 0;
102
+ for (const [chatId, count] of groupCounts) {
103
+ if (count > busiestGroupUsed) {
104
+ busiestGroupChatId = chatId;
105
+ busiestGroupUsed = count;
106
+ }
107
+ }
108
+
109
+ let lastRateLimit = null;
110
+ if (this.lastRateLimit) {
111
+ const retryRemainingSeconds = this.lastRateLimit.retryUntil === null ? null : Math.max(0, Math.ceil((this.lastRateLimit.retryUntil - now) / 1_000));
112
+ lastRateLimit = { ...this.lastRateLimit, retryRemainingSeconds };
113
+ }
114
+
115
+ return {
116
+ global: {
117
+ used: globalUsed,
118
+ limit: TELEGRAM_RATE_LIMITS.GLOBAL_MESSAGES_PER_SECOND,
119
+ usedPercentage: percentage(globalUsed, TELEGRAM_RATE_LIMITS.GLOBAL_MESSAGES_PER_SECOND),
120
+ },
121
+ busiestGroup: {
122
+ used: busiestGroupUsed,
123
+ limit: TELEGRAM_RATE_LIMITS.GROUP_MESSAGES_PER_MINUTE,
124
+ usedPercentage: percentage(busiestGroupUsed, TELEGRAM_RATE_LIMITS.GROUP_MESSAGES_PER_MINUTE),
125
+ chatId: busiestGroupChatId,
126
+ },
127
+ totalApiRequests: this.totalApiRequests,
128
+ rateLimitResponses: this.rateLimitResponses,
129
+ lastRateLimit,
130
+ };
131
+ }
132
+ }
133
+
134
+ const defaultTracker = new TelegramRateLimitTracker();
135
+
136
+ export function getTelegramRateLimits(verbose = false) {
137
+ const telegramRateLimit = defaultTracker.getSnapshot();
138
+ if (verbose) console.log('[VERBOSE] /limits Telegram Bot API telemetry:', JSON.stringify(telegramRateLimit, null, 2));
139
+ return { success: true, telegramRateLimit };
140
+ }
141
+
142
+ export function installTelegramRateLimitTracker(telegram, { tracker = defaultTracker, verbose = false } = {}) {
143
+ if (!telegram || telegram[TRACKER_INSTALLED]) return telegram;
144
+ const originalCallApi = telegram.callApi;
145
+ if (typeof originalCallApi !== 'function') return telegram;
146
+
147
+ telegram.callApi = async function trackedCallApi(method, payload = {}, ...rest) {
148
+ tracker.recordRequest(method, payload);
149
+ if (verbose && isMessageMethod(method)) {
150
+ const snapshot = tracker.getSnapshot();
151
+ console.log(`[VERBOSE] Telegram Bot API ${method}: global ${snapshot.global.used}/${snapshot.global.limit} messages/1s; busiest group ${snapshot.busiestGroup.used}/${snapshot.busiestGroup.limit} messages/1m`);
152
+ }
153
+ try {
154
+ return await originalCallApi.call(this, method, payload, ...rest);
155
+ } catch (error) {
156
+ if (tracker.recordError(error, method, payload)) {
157
+ const observed = tracker.getSnapshot().lastRateLimit;
158
+ console.warn(`[telegram-bot] Telegram Bot API rate limit: method=${observed.method} chat=${observed.chatId ?? 'unknown'} retry_after=${observed.retryAfterSeconds ?? 'unknown'}s`);
159
+ if (verbose) console.error('[VERBOSE] Telegram Bot API 429 response:', JSON.stringify(error?.response || { message: error?.message }, null, 2));
160
+ }
161
+ throw error;
162
+ }
163
+ };
164
+
165
+ telegram[TRACKER_INSTALLED] = true;
166
+ return telegram;
167
+ }