opencode-telegram-group-topics-bot 0.11.2

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 (101) hide show
  1. package/.env.example +74 -0
  2. package/LICENSE +21 -0
  3. package/README.md +305 -0
  4. package/dist/agent/manager.js +60 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +47 -0
  7. package/dist/bot/commands/abort.js +116 -0
  8. package/dist/bot/commands/commands.js +389 -0
  9. package/dist/bot/commands/constants.js +20 -0
  10. package/dist/bot/commands/definitions.js +25 -0
  11. package/dist/bot/commands/help.js +27 -0
  12. package/dist/bot/commands/models.js +38 -0
  13. package/dist/bot/commands/new.js +247 -0
  14. package/dist/bot/commands/opencode-start.js +85 -0
  15. package/dist/bot/commands/opencode-stop.js +44 -0
  16. package/dist/bot/commands/projects.js +304 -0
  17. package/dist/bot/commands/rename.js +173 -0
  18. package/dist/bot/commands/sessions.js +491 -0
  19. package/dist/bot/commands/start.js +67 -0
  20. package/dist/bot/commands/status.js +138 -0
  21. package/dist/bot/constants.js +49 -0
  22. package/dist/bot/handlers/agent.js +127 -0
  23. package/dist/bot/handlers/context.js +125 -0
  24. package/dist/bot/handlers/document.js +65 -0
  25. package/dist/bot/handlers/inline-menu.js +124 -0
  26. package/dist/bot/handlers/model.js +152 -0
  27. package/dist/bot/handlers/permission.js +281 -0
  28. package/dist/bot/handlers/prompt.js +263 -0
  29. package/dist/bot/handlers/question.js +285 -0
  30. package/dist/bot/handlers/variant.js +147 -0
  31. package/dist/bot/handlers/voice.js +173 -0
  32. package/dist/bot/index.js +945 -0
  33. package/dist/bot/message-patterns.js +4 -0
  34. package/dist/bot/middleware/auth.js +30 -0
  35. package/dist/bot/middleware/interaction-guard.js +80 -0
  36. package/dist/bot/middleware/unknown-command.js +22 -0
  37. package/dist/bot/scope.js +222 -0
  38. package/dist/bot/telegram-constants.js +3 -0
  39. package/dist/bot/telegram-rate-limiter.js +263 -0
  40. package/dist/bot/utils/commands.js +21 -0
  41. package/dist/bot/utils/file-download.js +91 -0
  42. package/dist/bot/utils/keyboard.js +85 -0
  43. package/dist/bot/utils/send-with-markdown-fallback.js +57 -0
  44. package/dist/bot/utils/session-error-filter.js +34 -0
  45. package/dist/bot/utils/topic-link.js +29 -0
  46. package/dist/cli/args.js +98 -0
  47. package/dist/cli.js +80 -0
  48. package/dist/config.js +103 -0
  49. package/dist/i18n/de.js +330 -0
  50. package/dist/i18n/en.js +330 -0
  51. package/dist/i18n/es.js +330 -0
  52. package/dist/i18n/index.js +102 -0
  53. package/dist/i18n/ru.js +330 -0
  54. package/dist/i18n/zh.js +330 -0
  55. package/dist/index.js +28 -0
  56. package/dist/interaction/cleanup.js +24 -0
  57. package/dist/interaction/constants.js +25 -0
  58. package/dist/interaction/guard.js +100 -0
  59. package/dist/interaction/manager.js +113 -0
  60. package/dist/interaction/types.js +1 -0
  61. package/dist/keyboard/manager.js +115 -0
  62. package/dist/keyboard/types.js +1 -0
  63. package/dist/model/capabilities.js +62 -0
  64. package/dist/model/manager.js +257 -0
  65. package/dist/model/types.js +24 -0
  66. package/dist/opencode/client.js +13 -0
  67. package/dist/opencode/events.js +159 -0
  68. package/dist/opencode/prompt-submit-error.js +101 -0
  69. package/dist/permission/manager.js +92 -0
  70. package/dist/permission/types.js +1 -0
  71. package/dist/pinned/manager.js +405 -0
  72. package/dist/pinned/types.js +1 -0
  73. package/dist/process/manager.js +273 -0
  74. package/dist/process/types.js +1 -0
  75. package/dist/project/manager.js +88 -0
  76. package/dist/question/manager.js +186 -0
  77. package/dist/question/types.js +1 -0
  78. package/dist/rename/manager.js +64 -0
  79. package/dist/runtime/bootstrap.js +350 -0
  80. package/dist/runtime/mode.js +74 -0
  81. package/dist/runtime/paths.js +37 -0
  82. package/dist/runtime/process-error-handlers.js +24 -0
  83. package/dist/session/cache-manager.js +455 -0
  84. package/dist/session/manager.js +87 -0
  85. package/dist/settings/manager.js +283 -0
  86. package/dist/stt/client.js +64 -0
  87. package/dist/summary/aggregator.js +625 -0
  88. package/dist/summary/formatter.js +417 -0
  89. package/dist/summary/tool-message-batcher.js +277 -0
  90. package/dist/topic/colors.js +8 -0
  91. package/dist/topic/constants.js +10 -0
  92. package/dist/topic/manager.js +161 -0
  93. package/dist/topic/title-constants.js +2 -0
  94. package/dist/topic/title-format.js +10 -0
  95. package/dist/topic/title-sync.js +17 -0
  96. package/dist/utils/error-format.js +29 -0
  97. package/dist/utils/logger.js +175 -0
  98. package/dist/utils/safe-background-task.js +33 -0
  99. package/dist/variant/manager.js +103 -0
  100. package/dist/variant/types.js +1 -0
  101. package/package.json +76 -0
@@ -0,0 +1,4 @@
1
+ export const AGENT_MODE_BUTTON_TEXT_PATTERN = /^(📋|🛠️|💬|🔍|📝|📄|📦|🤖)\s.+\sMode$/;
2
+ export const MODEL_BUTTON_TEXT_PATTERN = /^🤖\s(?!.*\sMode$)[\s\S]+$/;
3
+ // Keep support for both legacy "💭" and current "💡" prefix.
4
+ export const VARIANT_BUTTON_TEXT_PATTERN = /^(💡|💭)\s.+$/;
@@ -0,0 +1,30 @@
1
+ import { config } from "../../config.js";
2
+ import { logger } from "../../utils/logger.js";
3
+ export async function authMiddleware(ctx, next) {
4
+ const userId = ctx.from?.id;
5
+ logger.debug(`[Auth] Checking access: userId=${userId}, allowedUserId=${config.telegram.allowedUserId}, hasCallbackQuery=${!!ctx.callbackQuery}, hasMessage=${!!ctx.message}`);
6
+ if (userId && userId === config.telegram.allowedUserId) {
7
+ logger.debug(`[Auth] Access granted for userId=${userId}`);
8
+ await next();
9
+ }
10
+ else {
11
+ // Silently ignore unauthorized users
12
+ logger.warn(`Unauthorized access attempt from user ID: ${userId}`);
13
+ // Actively hide commands for unauthorized users by setting empty command list
14
+ // Only do this if the chat is NOT the authorized user's chat
15
+ // (to avoid resetting commands when forwarded messages are received)
16
+ if (ctx.chat?.id && ctx.chat.id !== config.telegram.allowedUserId) {
17
+ try {
18
+ // Set empty commands for this specific chat (more reliable than deleteMyCommands)
19
+ await ctx.api.setMyCommands([], {
20
+ scope: { type: "chat", chat_id: ctx.chat.id },
21
+ });
22
+ logger.debug(`[Auth] Set empty commands for unauthorized chat_id=${ctx.chat.id}`);
23
+ }
24
+ catch (err) {
25
+ // Ignore errors
26
+ logger.debug(`[Auth] Could not set empty commands: ${err}`);
27
+ }
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,80 @@
1
+ import { resolveInteractionGuardDecision } from "../../interaction/guard.js";
2
+ import { logger } from "../../utils/logger.js";
3
+ import { t } from "../../i18n/index.js";
4
+ function getInteractionBlockedMessage(reason, interactionKind) {
5
+ if (interactionKind === "permission") {
6
+ switch (reason) {
7
+ case "command_not_allowed":
8
+ return t("permission.blocked.command_not_allowed");
9
+ case "expected_callback":
10
+ case "expected_command":
11
+ case "expected_text":
12
+ default:
13
+ return t("permission.blocked.expected_reply");
14
+ }
15
+ }
16
+ if (interactionKind === "inline") {
17
+ switch (reason) {
18
+ case "command_not_allowed":
19
+ return t("inline.blocked.command_not_allowed");
20
+ case "expected_callback":
21
+ case "expected_command":
22
+ case "expected_text":
23
+ default:
24
+ return t("inline.blocked.expected_choice");
25
+ }
26
+ }
27
+ if (interactionKind === "question") {
28
+ switch (reason) {
29
+ case "command_not_allowed":
30
+ return t("question.blocked.command_not_allowed");
31
+ case "expected_callback":
32
+ case "expected_command":
33
+ case "expected_text":
34
+ default:
35
+ return t("question.blocked.expected_answer");
36
+ }
37
+ }
38
+ if (interactionKind === "rename") {
39
+ switch (reason) {
40
+ case "command_not_allowed":
41
+ return t("rename.blocked.command_not_allowed");
42
+ case "expected_callback":
43
+ case "expected_command":
44
+ case "expected_text":
45
+ default:
46
+ return t("rename.blocked.expected_name");
47
+ }
48
+ }
49
+ switch (reason) {
50
+ case "expired":
51
+ return t("interaction.blocked.expired");
52
+ case "expected_callback":
53
+ return t("interaction.blocked.expected_callback");
54
+ case "expected_command":
55
+ return t("interaction.blocked.expected_command");
56
+ case "command_not_allowed":
57
+ return t("interaction.blocked.command_not_allowed");
58
+ case "expected_text":
59
+ default:
60
+ return t("interaction.blocked.expected_text");
61
+ }
62
+ }
63
+ export async function interactionGuardMiddleware(ctx, next) {
64
+ const decision = resolveInteractionGuardDecision(ctx);
65
+ if (decision.allow) {
66
+ await next();
67
+ return;
68
+ }
69
+ const message = getInteractionBlockedMessage(decision.reason, decision.state?.kind);
70
+ logger.debug(`[InteractionGuard] Blocked input: interactionKind=${decision.state?.kind || "none"}, inputType=${decision.inputType}, reason=${decision.reason || "unknown"}, command=${decision.command || "-"}`);
71
+ if (ctx.callbackQuery) {
72
+ await ctx.answerCallbackQuery({ text: message }).catch(() => { });
73
+ return;
74
+ }
75
+ if (ctx.chat) {
76
+ await ctx.reply(message).catch((err) => {
77
+ logger.error("[InteractionGuard] Failed to send blocked input message:", err);
78
+ });
79
+ }
80
+ }
@@ -0,0 +1,22 @@
1
+ import { extractCommandName, isKnownCommand } from "../utils/commands.js";
2
+ import { logger } from "../../utils/logger.js";
3
+ import { t } from "../../i18n/index.js";
4
+ export async function unknownCommandMiddleware(ctx, next) {
5
+ const text = ctx.message?.text;
6
+ if (!text) {
7
+ await next();
8
+ return;
9
+ }
10
+ const commandName = extractCommandName(text);
11
+ if (!commandName) {
12
+ await next();
13
+ return;
14
+ }
15
+ if (isKnownCommand(commandName)) {
16
+ await next();
17
+ return;
18
+ }
19
+ const commandToken = text.trim().split(/\s+/)[0];
20
+ logger.debug(`[Bot] Unknown slash command received: ${commandToken}`);
21
+ await ctx.reply(t("bot.unknown_command", { command: commandToken }));
22
+ }
@@ -0,0 +1,222 @@
1
+ import { CHAT_TYPE } from "./constants.js";
2
+ export const GLOBAL_SCOPE_KEY = "global";
3
+ export const SCOPE_CONTEXT = {
4
+ DM: "dm",
5
+ GROUP_GENERAL: "group-general",
6
+ GROUP_TOPIC: "group-topic",
7
+ };
8
+ const DM_SCOPE_PREFIX = "dm:";
9
+ const GROUP_SCOPE_PREFIX = "chat:";
10
+ export const GENERAL_TOPIC_THREAD_ID = 1;
11
+ function isKnownChatType(type) {
12
+ return (type === CHAT_TYPE.PRIVATE ||
13
+ type === CHAT_TYPE.GROUP ||
14
+ type === CHAT_TYPE.SUPERGROUP ||
15
+ type === CHAT_TYPE.CHANNEL);
16
+ }
17
+ function isObject(value) {
18
+ return typeof value === "object" && value !== null;
19
+ }
20
+ function toNullableThreadId(value) {
21
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
22
+ return null;
23
+ }
24
+ return value;
25
+ }
26
+ function normalizeContextForLegacyInput(chatId, threadId, chatType) {
27
+ if (chatType === CHAT_TYPE.PRIVATE) {
28
+ return {
29
+ chatId,
30
+ context: SCOPE_CONTEXT.DM,
31
+ };
32
+ }
33
+ if (threadId === null) {
34
+ return {
35
+ chatId,
36
+ context: SCOPE_CONTEXT.GROUP_GENERAL,
37
+ };
38
+ }
39
+ return {
40
+ chatId,
41
+ threadId,
42
+ context: threadId === GENERAL_TOPIC_THREAD_ID
43
+ ? SCOPE_CONTEXT.GROUP_GENERAL
44
+ : SCOPE_CONTEXT.GROUP_TOPIC,
45
+ };
46
+ }
47
+ function buildScopeKey(params) {
48
+ if (params.context === SCOPE_CONTEXT.DM) {
49
+ return `${DM_SCOPE_PREFIX}${params.chatId}`;
50
+ }
51
+ if (typeof params.threadId === "number") {
52
+ return `${params.chatId}:${params.threadId}`;
53
+ }
54
+ return `${GROUP_SCOPE_PREFIX}${params.chatId}`;
55
+ }
56
+ export function parseScopeKey(scopeKey) {
57
+ if (!scopeKey || scopeKey === GLOBAL_SCOPE_KEY) {
58
+ return null;
59
+ }
60
+ const directTopicMatch = /^(-?\d+):(\d+)$/.exec(scopeKey);
61
+ if (directTopicMatch) {
62
+ const chatId = Number.parseInt(directTopicMatch[1], 10);
63
+ const threadId = Number.parseInt(directTopicMatch[2], 10);
64
+ return {
65
+ chatId,
66
+ threadId,
67
+ context: threadId === GENERAL_TOPIC_THREAD_ID
68
+ ? SCOPE_CONTEXT.GROUP_GENERAL
69
+ : SCOPE_CONTEXT.GROUP_TOPIC,
70
+ };
71
+ }
72
+ const legacyTopicMatch = /^chat:(-?\d+):(\d+)$/.exec(scopeKey);
73
+ if (legacyTopicMatch) {
74
+ const chatId = Number.parseInt(legacyTopicMatch[1], 10);
75
+ const threadId = Number.parseInt(legacyTopicMatch[2], 10);
76
+ return {
77
+ chatId,
78
+ threadId,
79
+ context: threadId === GENERAL_TOPIC_THREAD_ID
80
+ ? SCOPE_CONTEXT.GROUP_GENERAL
81
+ : SCOPE_CONTEXT.GROUP_TOPIC,
82
+ };
83
+ }
84
+ const dmMatch = /^dm:(\d+)$/.exec(scopeKey);
85
+ if (dmMatch) {
86
+ return {
87
+ chatId: Number.parseInt(dmMatch[1], 10),
88
+ context: SCOPE_CONTEXT.DM,
89
+ };
90
+ }
91
+ const groupGeneralMatch = /^chat:(-?\d+)$/.exec(scopeKey);
92
+ if (groupGeneralMatch) {
93
+ return {
94
+ chatId: Number.parseInt(groupGeneralMatch[1], 10),
95
+ context: SCOPE_CONTEXT.GROUP_GENERAL,
96
+ };
97
+ }
98
+ return null;
99
+ }
100
+ export function createScopeKey(chatId, threadId, chatType) {
101
+ if (!chatType && threadId === null) {
102
+ return GLOBAL_SCOPE_KEY;
103
+ }
104
+ return buildScopeKey(normalizeContextForLegacyInput(chatId, threadId, chatType));
105
+ }
106
+ export function createScopeKeyFromParams(params) {
107
+ return buildScopeKey(params);
108
+ }
109
+ function extractThreadIdFromMessage(message) {
110
+ const parsed = toNullableThreadId(message.message_thread_id);
111
+ if (parsed !== null) {
112
+ return parsed;
113
+ }
114
+ if (message.is_topic_message) {
115
+ return GENERAL_TOPIC_THREAD_ID;
116
+ }
117
+ return null;
118
+ }
119
+ function getContextPayloadThreadId(ctx) {
120
+ if (ctx.message) {
121
+ return extractThreadIdFromMessage(ctx.message);
122
+ }
123
+ if (ctx.callbackQuery && "message" in ctx.callbackQuery) {
124
+ return extractThreadIdFromMessage(ctx.callbackQuery.message);
125
+ }
126
+ const callbackMessage = isObject(ctx.callbackQuery)
127
+ ? Reflect.get(ctx.callbackQuery, "message")
128
+ : undefined;
129
+ if (isObject(callbackMessage)) {
130
+ return toNullableThreadId(Reflect.get(callbackMessage, "message_thread_id"));
131
+ }
132
+ return null;
133
+ }
134
+ function resolveScopeContext(chatType, threadId) {
135
+ if (chatType === CHAT_TYPE.PRIVATE) {
136
+ return SCOPE_CONTEXT.DM;
137
+ }
138
+ if (threadId === null || threadId === GENERAL_TOPIC_THREAD_ID) {
139
+ return SCOPE_CONTEXT.GROUP_GENERAL;
140
+ }
141
+ return SCOPE_CONTEXT.GROUP_TOPIC;
142
+ }
143
+ export function getScopeFromContext(ctx) {
144
+ if (!ctx.chat) {
145
+ return null;
146
+ }
147
+ const threadId = getContextPayloadThreadId(ctx);
148
+ const chatType = isKnownChatType(ctx.chat.type) ? ctx.chat.type : undefined;
149
+ if (!chatType) {
150
+ if (threadId === null) {
151
+ return null;
152
+ }
153
+ const fallbackContext = threadId === GENERAL_TOPIC_THREAD_ID
154
+ ? SCOPE_CONTEXT.GROUP_GENERAL
155
+ : SCOPE_CONTEXT.GROUP_TOPIC;
156
+ return {
157
+ key: createScopeKeyFromParams({
158
+ chatId: ctx.chat.id,
159
+ threadId,
160
+ context: fallbackContext,
161
+ }),
162
+ chatId: ctx.chat.id,
163
+ threadId,
164
+ context: fallbackContext,
165
+ };
166
+ }
167
+ const context = resolveScopeContext(chatType, threadId);
168
+ const key = createScopeKeyFromParams({
169
+ chatId: ctx.chat.id,
170
+ threadId: threadId ?? undefined,
171
+ context,
172
+ });
173
+ return {
174
+ key,
175
+ chatId: ctx.chat.id,
176
+ threadId,
177
+ context,
178
+ };
179
+ }
180
+ export function getScopeKeyFromContext(ctx) {
181
+ return getScopeFromContext(ctx)?.key ?? GLOBAL_SCOPE_KEY;
182
+ }
183
+ export function getScopeFromKey(scopeKey) {
184
+ const parsed = parseScopeKey(scopeKey);
185
+ if (!parsed) {
186
+ return null;
187
+ }
188
+ return {
189
+ key: buildScopeKey(parsed),
190
+ chatId: parsed.chatId,
191
+ threadId: typeof parsed.threadId === "number" ? parsed.threadId : null,
192
+ context: parsed.context,
193
+ };
194
+ }
195
+ export function getThreadIdFromScopeKey(scopeKey) {
196
+ return getScopeFromKey(scopeKey)?.threadId ?? null;
197
+ }
198
+ export function getMessageThreadId(threadId) {
199
+ if (threadId === null || threadId === GENERAL_TOPIC_THREAD_ID) {
200
+ return null;
201
+ }
202
+ return threadId;
203
+ }
204
+ export function getThreadSendOptions(threadId) {
205
+ const messageThreadId = getMessageThreadId(threadId);
206
+ if (messageThreadId === null) {
207
+ return {};
208
+ }
209
+ return { message_thread_id: messageThreadId };
210
+ }
211
+ export function getChatActionThreadOptions(threadId) {
212
+ if (threadId === null) {
213
+ return {};
214
+ }
215
+ return { message_thread_id: threadId };
216
+ }
217
+ export function isGeneralScope(scope) {
218
+ return scope?.context === SCOPE_CONTEXT.GROUP_GENERAL;
219
+ }
220
+ export function isTopicScope(scope) {
221
+ return scope?.context === SCOPE_CONTEXT.GROUP_TOPIC;
222
+ }
@@ -0,0 +1,3 @@
1
+ export const TELEGRAM_CHAT_ACTION = {
2
+ TYPING: "typing",
3
+ };
@@ -0,0 +1,263 @@
1
+ import { logger } from "../utils/logger.js";
2
+ import { createScopeKeyFromParams } from "./scope.js";
3
+ const GLOBAL_MIN_INTERVAL_MS = 40;
4
+ const PER_CHAT_MIN_INTERVAL_MS = 1100;
5
+ const GROUP_WINDOW_MS = 60_000;
6
+ const GROUP_LIMIT_PER_WINDOW = 20;
7
+ const RATE_LIMITED_METHODS = new Set([
8
+ "sendMessage",
9
+ "editMessageText",
10
+ "sendDocument",
11
+ "sendPhoto",
12
+ "sendAudio",
13
+ "sendVoice",
14
+ "sendVideo",
15
+ "sendAnimation",
16
+ "sendMediaGroup",
17
+ ]);
18
+ function parseChatId(payload) {
19
+ if (!payload || typeof payload !== "object") {
20
+ return null;
21
+ }
22
+ const value = Reflect.get(payload, "chat_id");
23
+ if (typeof value === "number") {
24
+ return value;
25
+ }
26
+ if (typeof value === "string") {
27
+ const parsed = Number(value);
28
+ return Number.isFinite(parsed) ? parsed : null;
29
+ }
30
+ return null;
31
+ }
32
+ function parseThreadId(payload) {
33
+ if (!payload || typeof payload !== "object") {
34
+ return null;
35
+ }
36
+ const value = Reflect.get(payload, "message_thread_id");
37
+ return typeof value === "number" ? value : null;
38
+ }
39
+ function scopeKeyFromPayload(payload) {
40
+ const chatId = parseChatId(payload);
41
+ if (chatId === null) {
42
+ return null;
43
+ }
44
+ if (chatId > 0) {
45
+ return createScopeKeyFromParams({
46
+ chatId,
47
+ context: "dm",
48
+ });
49
+ }
50
+ const threadId = parseThreadId(payload);
51
+ if (threadId !== null) {
52
+ return createScopeKeyFromParams({
53
+ chatId,
54
+ threadId,
55
+ context: threadId === 1 ? "group-general" : "group-topic",
56
+ });
57
+ }
58
+ return createScopeKeyFromParams({
59
+ chatId,
60
+ context: "group-general",
61
+ });
62
+ }
63
+ function isGroupLikeChat(chatId) {
64
+ return chatId !== null && chatId < 0;
65
+ }
66
+ function getRetryAfterMs(error) {
67
+ if (!error || typeof error !== "object") {
68
+ return null;
69
+ }
70
+ const params = Reflect.get(error, "parameters");
71
+ if (!params || typeof params !== "object") {
72
+ return null;
73
+ }
74
+ const retryAfter = Reflect.get(params, "retry_after");
75
+ if (typeof retryAfter !== "number" || retryAfter <= 0) {
76
+ return null;
77
+ }
78
+ return retryAfter * 1000;
79
+ }
80
+ function sleep(ms) {
81
+ if (ms <= 0) {
82
+ return Promise.resolve();
83
+ }
84
+ return new Promise((resolve) => setTimeout(resolve, ms));
85
+ }
86
+ export class TelegramRateLimiter {
87
+ queue = [];
88
+ processing = false;
89
+ lastGlobalSentAt = 0;
90
+ lastSentAtByChat = new Map();
91
+ groupWindowByChat = new Map();
92
+ activeScopeKey = null;
93
+ setActiveScopeKey(scopeKey) {
94
+ this.activeScopeKey = scopeKey;
95
+ }
96
+ enqueue(method, payload, run) {
97
+ if (!RATE_LIMITED_METHODS.has(method)) {
98
+ return run();
99
+ }
100
+ const chatId = parseChatId(payload);
101
+ const job = {
102
+ method,
103
+ scopeKey: scopeKeyFromPayload(payload),
104
+ chatId,
105
+ isGroupLike: isGroupLikeChat(chatId),
106
+ notBefore: 0,
107
+ run,
108
+ resolve: () => undefined,
109
+ reject: () => undefined,
110
+ };
111
+ const promise = new Promise((resolve, reject) => {
112
+ job.resolve = resolve;
113
+ job.reject = reject;
114
+ });
115
+ this.queue.push(job);
116
+ if (this.queue.length > 25) {
117
+ logger.debug(`[RateLimiter] Outbound queue depth=${this.queue.length}`);
118
+ }
119
+ this.ensureProcessing();
120
+ return promise;
121
+ }
122
+ ensureProcessing() {
123
+ if (this.processing) {
124
+ return;
125
+ }
126
+ this.processing = true;
127
+ void this.processLoop();
128
+ }
129
+ findNextIndex(now) {
130
+ let prioritizedReadyIndex = -1;
131
+ let firstReadyIndex = -1;
132
+ let minWaitMs = Number.POSITIVE_INFINITY;
133
+ let minWaitIndex = 0;
134
+ const seenScopeKeys = new Set();
135
+ for (let index = 0; index < this.queue.length; index++) {
136
+ const job = this.queue[index];
137
+ // Keep strict in-order delivery per scope.
138
+ // If a scope already has an earlier queued job, skip later jobs for the same scope
139
+ // until the head-of-line job is processed.
140
+ if (job.scopeKey) {
141
+ if (seenScopeKeys.has(job.scopeKey)) {
142
+ continue;
143
+ }
144
+ seenScopeKeys.add(job.scopeKey);
145
+ }
146
+ const waitMs = this.getWaitMs(job, now);
147
+ if (waitMs <= 0) {
148
+ if (this.activeScopeKey &&
149
+ prioritizedReadyIndex < 0 &&
150
+ job.scopeKey === this.activeScopeKey) {
151
+ prioritizedReadyIndex = index;
152
+ }
153
+ if (firstReadyIndex < 0) {
154
+ firstReadyIndex = index;
155
+ }
156
+ continue;
157
+ }
158
+ if (waitMs < minWaitMs) {
159
+ minWaitMs = waitMs;
160
+ minWaitIndex = index;
161
+ }
162
+ }
163
+ if (prioritizedReadyIndex >= 0) {
164
+ return { index: prioritizedReadyIndex, waitMs: 0 };
165
+ }
166
+ if (firstReadyIndex >= 0) {
167
+ return { index: firstReadyIndex, waitMs: 0 };
168
+ }
169
+ return {
170
+ index: minWaitIndex,
171
+ waitMs: Number.isFinite(minWaitMs) ? Math.max(1, minWaitMs) : 1,
172
+ };
173
+ }
174
+ pruneGroupWindow(chatId, now) {
175
+ const current = this.groupWindowByChat.get(chatId) ?? [];
176
+ const pruned = current.filter((ts) => now - ts < GROUP_WINDOW_MS);
177
+ this.groupWindowByChat.set(chatId, pruned);
178
+ return pruned;
179
+ }
180
+ getWaitMs(job, now = Date.now()) {
181
+ const waits = [];
182
+ waits.push(job.notBefore - now);
183
+ waits.push(this.lastGlobalSentAt + GLOBAL_MIN_INTERVAL_MS - now);
184
+ if (job.chatId !== null) {
185
+ const lastPerChat = this.lastSentAtByChat.get(job.chatId) ?? 0;
186
+ waits.push(lastPerChat + PER_CHAT_MIN_INTERVAL_MS - now);
187
+ }
188
+ if (job.isGroupLike && job.chatId !== null) {
189
+ const timestamps = this.pruneGroupWindow(job.chatId, now);
190
+ if (timestamps.length >= GROUP_LIMIT_PER_WINDOW) {
191
+ waits.push(timestamps[0] + GROUP_WINDOW_MS - now);
192
+ }
193
+ }
194
+ return Math.max(0, ...waits);
195
+ }
196
+ markSent(job) {
197
+ const now = Date.now();
198
+ this.lastGlobalSentAt = now;
199
+ if (job.chatId !== null) {
200
+ this.lastSentAtByChat.set(job.chatId, now);
201
+ }
202
+ if (job.isGroupLike && job.chatId !== null) {
203
+ const timestamps = this.pruneGroupWindow(job.chatId, now);
204
+ timestamps.push(now);
205
+ this.groupWindowByChat.set(job.chatId, timestamps);
206
+ }
207
+ }
208
+ async executeJob(job) {
209
+ try {
210
+ const result = await job.run();
211
+ this.markSent(job);
212
+ job.resolve(result);
213
+ return "done";
214
+ }
215
+ catch (error) {
216
+ const retryAfterMs = getRetryAfterMs(error);
217
+ if (!retryAfterMs) {
218
+ job.reject(error);
219
+ return "done";
220
+ }
221
+ const cappedDelay = Math.min(retryAfterMs + 100, 10_000);
222
+ job.notBefore = Date.now() + cappedDelay;
223
+ logger.warn(`[RateLimiter] Telegram 429 for ${job.method}; requeue in ${cappedDelay}ms (queue=${this.queue.length + 1})`);
224
+ return "retry";
225
+ }
226
+ }
227
+ requeueRetryJob(job) {
228
+ if (!job.scopeKey) {
229
+ this.queue.push(job);
230
+ return;
231
+ }
232
+ const sameScopeIndex = this.queue.findIndex((queued) => queued.scopeKey === job.scopeKey);
233
+ if (sameScopeIndex < 0) {
234
+ this.queue.push(job);
235
+ return;
236
+ }
237
+ this.queue.splice(sameScopeIndex, 0, job);
238
+ }
239
+ async processLoop() {
240
+ try {
241
+ while (this.queue.length > 0) {
242
+ const now = Date.now();
243
+ const selection = this.findNextIndex(now);
244
+ if (selection.waitMs > 0) {
245
+ await sleep(selection.waitMs);
246
+ continue;
247
+ }
248
+ const index = selection.index;
249
+ const [job] = this.queue.splice(index, 1);
250
+ const outcome = await this.executeJob(job);
251
+ if (outcome === "retry") {
252
+ this.requeueRetryJob(job);
253
+ }
254
+ }
255
+ }
256
+ finally {
257
+ this.processing = false;
258
+ if (this.queue.length > 0) {
259
+ this.ensureProcessing();
260
+ }
261
+ }
262
+ }
263
+ }
@@ -0,0 +1,21 @@
1
+ import { BOT_COMMANDS } from "../commands/definitions.js";
2
+ const KNOWN_COMMANDS = new Set(["start", ...BOT_COMMANDS.map((item) => item.command)]);
3
+ export function extractCommandName(text) {
4
+ const trimmed = text.trim();
5
+ if (!trimmed.startsWith("/")) {
6
+ return null;
7
+ }
8
+ const token = trimmed.split(/\s+/)[0];
9
+ const withoutSlash = token.slice(1);
10
+ if (!withoutSlash) {
11
+ return null;
12
+ }
13
+ const withoutMention = withoutSlash.split("@")[0].toLowerCase();
14
+ if (!withoutMention) {
15
+ return null;
16
+ }
17
+ return withoutMention;
18
+ }
19
+ export function isKnownCommand(commandName) {
20
+ return KNOWN_COMMANDS.has(commandName);
21
+ }