@grinev/opencode-telegram-bot 0.14.0 → 0.14.1
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/.env.example +4 -0
- package/README.md +25 -24
- package/dist/bot/index.js +14 -5
- package/dist/bot/utils/finalize-assistant-response.js +6 -3
- package/dist/bot/utils/send-with-markdown-fallback.js +82 -4
- package/dist/bot/utils/telegram-text.js +4 -2
- package/dist/config.js +1 -0
- package/dist/pinned/manager.js +98 -27
- package/dist/utils/telegram-rate-limit-retry.js +93 -0
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -46,6 +46,10 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
46
46
|
# Maximum number of scheduled tasks allowed at once (default: 10)
|
|
47
47
|
# TASK_LIMIT=10
|
|
48
48
|
|
|
49
|
+
# Stream update throttle in milliseconds for assistant/tool message edits (default: 500)
|
|
50
|
+
# Higher value = fewer Telegram edit requests, lower value = more real-time updates
|
|
51
|
+
# RESPONSE_STREAM_THROTTLE_MS=500
|
|
52
|
+
|
|
49
53
|
# Bot locale: supported locale code (default: en)
|
|
50
54
|
# Supported locales: en, de, es, fr, ru, zh
|
|
51
55
|
# BOT_LOCALE=en
|
package/README.md
CHANGED
|
@@ -147,30 +147,31 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
147
147
|
- **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
|
|
148
148
|
- **Linux:** `~/.config/opencode-telegram-bot/.env`
|
|
149
149
|
|
|
150
|
-
| Variable
|
|
151
|
-
|
|
|
152
|
-
| `TELEGRAM_BOT_TOKEN`
|
|
153
|
-
| `TELEGRAM_ALLOWED_USER_ID`
|
|
154
|
-
| `TELEGRAM_PROXY_URL`
|
|
155
|
-
| `OPENCODE_API_URL`
|
|
156
|
-
| `OPENCODE_SERVER_USERNAME`
|
|
157
|
-
| `OPENCODE_SERVER_PASSWORD`
|
|
158
|
-
| `OPENCODE_MODEL_PROVIDER`
|
|
159
|
-
| `OPENCODE_MODEL_ID`
|
|
160
|
-
| `BOT_LOCALE`
|
|
161
|
-
| `SESSIONS_LIST_LIMIT`
|
|
162
|
-
| `PROJECTS_LIST_LIMIT`
|
|
163
|
-
| `COMMANDS_LIST_LIMIT`
|
|
164
|
-
| `TASK_LIMIT`
|
|
165
|
-
| `
|
|
166
|
-
| `
|
|
167
|
-
| `
|
|
168
|
-
| `
|
|
169
|
-
| `
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
150
|
+
| Variable | Description | Required | Default |
|
|
151
|
+
| ----------------------------- | -------------------------------------------------------------------------------- | :------: | ------------------------ |
|
|
152
|
+
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
|
|
153
|
+
| `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
|
|
154
|
+
| `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
|
|
155
|
+
| `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
|
|
156
|
+
| `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
|
|
157
|
+
| `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
|
|
158
|
+
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
|
|
159
|
+
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
|
|
160
|
+
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
|
|
161
|
+
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
|
|
162
|
+
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
|
|
163
|
+
| `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
|
|
164
|
+
| `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
|
|
165
|
+
| `RESPONSE_STREAM_THROTTLE_MS` | Stream edit throttle (ms) for assistant and tool updates | No | `500` |
|
|
166
|
+
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
|
|
167
|
+
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
|
|
168
|
+
| `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
|
|
169
|
+
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
|
|
170
|
+
| `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
|
|
171
|
+
| `STT_API_KEY` | API key for your STT provider | No | — |
|
|
172
|
+
| `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
|
|
173
|
+
| `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
|
|
174
|
+
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
|
|
174
175
|
|
|
175
176
|
> **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
|
|
176
177
|
|
package/dist/bot/index.js
CHANGED
|
@@ -43,6 +43,7 @@ import { getCurrentSession } from "../session/manager.js";
|
|
|
43
43
|
import { ingestSessionInfoForCache } from "../session/cache-manager.js";
|
|
44
44
|
import { logger } from "../utils/logger.js";
|
|
45
45
|
import { safeBackgroundTask } from "../utils/safe-background-task.js";
|
|
46
|
+
import { withTelegramRateLimitRetry } from "../utils/telegram-rate-limit-retry.js";
|
|
46
47
|
import { pinnedMessageManager } from "../pinned/manager.js";
|
|
47
48
|
import { t } from "../i18n/index.js";
|
|
48
49
|
import { processUserPrompt } from "./handlers/prompt.js";
|
|
@@ -63,7 +64,7 @@ let botInstance = null;
|
|
|
63
64
|
let chatIdInstance = null;
|
|
64
65
|
let commandsInitialized = false;
|
|
65
66
|
const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
|
|
66
|
-
const RESPONSE_STREAM_THROTTLE_MS =
|
|
67
|
+
const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
|
|
67
68
|
const RESPONSE_STREAM_TEXT_LIMIT = 3800;
|
|
68
69
|
const SESSION_RETRY_PREFIX = "🔁";
|
|
69
70
|
const SUBAGENT_STREAM_PREFIX = "🧩";
|
|
@@ -93,7 +94,7 @@ function prepareStreamingPayload(messageText) {
|
|
|
93
94
|
}
|
|
94
95
|
return {
|
|
95
96
|
parts,
|
|
96
|
-
format:
|
|
97
|
+
format: "raw",
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
100
|
const toolMessageBatcher = new ToolMessageBatcher({
|
|
@@ -323,13 +324,15 @@ async function ensureEventSubscription(directory) {
|
|
|
323
324
|
]).then(() => undefined),
|
|
324
325
|
prepareStreamingPayload,
|
|
325
326
|
formatSummary,
|
|
327
|
+
formatRawSummary: (text) => formatSummaryWithMode(text, "raw"),
|
|
326
328
|
resolveFormat: () => (getAssistantParseMode() === "MarkdownV2" ? "markdown_v2" : "raw"),
|
|
327
329
|
getReplyKeyboard: getCurrentReplyKeyboard,
|
|
328
|
-
sendText: async (text, options, format) => {
|
|
330
|
+
sendText: async (text, rawFallbackText, options, format) => {
|
|
329
331
|
await sendBotText({
|
|
330
332
|
api: botApi,
|
|
331
333
|
chatId,
|
|
332
334
|
text,
|
|
335
|
+
rawFallbackText,
|
|
333
336
|
options: options,
|
|
334
337
|
format,
|
|
335
338
|
});
|
|
@@ -675,11 +678,17 @@ export function createBot() {
|
|
|
675
678
|
const timeSinceLast = now - lastGetUpdatesTime;
|
|
676
679
|
logger.debug(`[Bot API] getUpdates called (${timeSinceLast}ms since last)`);
|
|
677
680
|
lastGetUpdatesTime = now;
|
|
681
|
+
return prev(method, payload, signal);
|
|
678
682
|
}
|
|
679
|
-
|
|
683
|
+
if (method === "sendMessage") {
|
|
680
684
|
logger.debug(`[Bot API] sendMessage to chat ${payload.chat_id}`);
|
|
681
685
|
}
|
|
682
|
-
return prev(method, payload, signal)
|
|
686
|
+
return withTelegramRateLimitRetry(() => prev(method, payload, signal), {
|
|
687
|
+
maxRetries: 5,
|
|
688
|
+
onRetry: ({ attempt, retryAfterMs, error }) => {
|
|
689
|
+
logger.warn(`[Bot API] Telegram rate limit on ${method}, retrying in ${retryAfterMs}ms (attempt=${attempt})`, error);
|
|
690
|
+
},
|
|
691
|
+
});
|
|
683
692
|
});
|
|
684
693
|
bot.use((ctx, next) => {
|
|
685
694
|
const hasCallbackQuery = !!ctx.callbackQuery;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { logger } from "../../utils/logger.js";
|
|
2
|
-
export async function finalizeAssistantResponse({ sessionId, messageId, messageText, responseStreamer, flushPendingServiceMessages, prepareStreamingPayload, formatSummary, resolveFormat, getReplyKeyboard, sendText, deleteMessages, }) {
|
|
2
|
+
export async function finalizeAssistantResponse({ sessionId, messageId, messageText, responseStreamer, flushPendingServiceMessages, prepareStreamingPayload, formatSummary, formatRawSummary, resolveFormat, getReplyKeyboard, sendText, deleteMessages, }) {
|
|
3
3
|
let streamedMessageIds = [];
|
|
4
4
|
const preparedStreamPayload = prepareStreamingPayload(messageText);
|
|
5
5
|
if (preparedStreamPayload) {
|
|
@@ -22,11 +22,14 @@ export async function finalizeAssistantResponse({ sessionId, messageId, messageT
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
const parts = formatSummary(messageText);
|
|
25
|
+
const rawParts = formatRawSummary(messageText);
|
|
25
26
|
const format = resolveFormat();
|
|
26
|
-
for (
|
|
27
|
+
for (let partIndex = 0; partIndex < parts.length; partIndex++) {
|
|
28
|
+
const part = parts[partIndex];
|
|
29
|
+
const rawFallbackText = rawParts[partIndex];
|
|
27
30
|
const keyboard = getReplyKeyboard();
|
|
28
31
|
const options = keyboard ? { reply_markup: keyboard } : undefined;
|
|
29
|
-
await sendText(part, options, format);
|
|
32
|
+
await sendText(part, rawFallbackText, options, format);
|
|
30
33
|
}
|
|
31
34
|
return false;
|
|
32
35
|
}
|
|
@@ -6,6 +6,50 @@ const MARKDOWN_PARSE_ERROR_MARKERS = [
|
|
|
6
6
|
"entity beginning",
|
|
7
7
|
"bad request: can't parse",
|
|
8
8
|
];
|
|
9
|
+
const MARKDOWN_V2_RESERVED_CHARS = new Set([
|
|
10
|
+
"_",
|
|
11
|
+
"*",
|
|
12
|
+
"[",
|
|
13
|
+
"]",
|
|
14
|
+
"(",
|
|
15
|
+
")",
|
|
16
|
+
"~",
|
|
17
|
+
"`",
|
|
18
|
+
">",
|
|
19
|
+
"#",
|
|
20
|
+
"+",
|
|
21
|
+
"-",
|
|
22
|
+
"=",
|
|
23
|
+
"|",
|
|
24
|
+
"{",
|
|
25
|
+
"}",
|
|
26
|
+
".",
|
|
27
|
+
"!",
|
|
28
|
+
"\\",
|
|
29
|
+
]);
|
|
30
|
+
const MARKDOWN_V2_ESCAPED_CHAR = /\\([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
|
|
31
|
+
function escapeTelegramMarkdownV2(text) {
|
|
32
|
+
let result = "";
|
|
33
|
+
let trailingBackslashes = 0;
|
|
34
|
+
for (const char of text) {
|
|
35
|
+
if (char === "\\") {
|
|
36
|
+
result += char;
|
|
37
|
+
trailingBackslashes += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const isEscaped = trailingBackslashes % 2 === 1;
|
|
41
|
+
trailingBackslashes = 0;
|
|
42
|
+
if (MARKDOWN_V2_RESERVED_CHARS.has(char) && !isEscaped) {
|
|
43
|
+
result += `\\${char}`;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
result += char;
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
function unescapeTelegramMarkdownV2(text) {
|
|
51
|
+
return text.replace(MARKDOWN_V2_ESCAPED_CHAR, "$1");
|
|
52
|
+
}
|
|
9
53
|
function stripMarkdownFormattingOptions(options) {
|
|
10
54
|
if (!options) {
|
|
11
55
|
return options;
|
|
@@ -47,7 +91,7 @@ export function isTelegramMarkdownParseError(error) {
|
|
|
47
91
|
}
|
|
48
92
|
return MARKDOWN_PARSE_ERROR_MARKERS.some((marker) => errorText.includes(marker));
|
|
49
93
|
}
|
|
50
|
-
export async function sendMessageWithMarkdownFallback({ api, chatId, text, options, parseMode, }) {
|
|
94
|
+
export async function sendMessageWithMarkdownFallback({ api, chatId, text, rawFallbackText, options, parseMode, }) {
|
|
51
95
|
if (!parseMode) {
|
|
52
96
|
return api.sendMessage(chatId, text, options);
|
|
53
97
|
}
|
|
@@ -55,6 +99,7 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, optio
|
|
|
55
99
|
...(options || {}),
|
|
56
100
|
parse_mode: parseMode,
|
|
57
101
|
};
|
|
102
|
+
const fallbackText = rawFallbackText ?? (parseMode === "MarkdownV2" ? unescapeTelegramMarkdownV2(text) : text);
|
|
58
103
|
try {
|
|
59
104
|
return await api.sendMessage(chatId, text, markdownOptions);
|
|
60
105
|
}
|
|
@@ -62,11 +107,27 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, optio
|
|
|
62
107
|
if (!isTelegramMarkdownParseError(error)) {
|
|
63
108
|
throw error;
|
|
64
109
|
}
|
|
110
|
+
if (parseMode === "MarkdownV2") {
|
|
111
|
+
const escapedText = escapeTelegramMarkdownV2(text);
|
|
112
|
+
if (escapedText !== text) {
|
|
113
|
+
logger.warn("[Bot] Markdown parse failed, retrying assistant message with escaped MarkdownV2", error);
|
|
114
|
+
try {
|
|
115
|
+
return await api.sendMessage(chatId, escapedText, markdownOptions);
|
|
116
|
+
}
|
|
117
|
+
catch (escapedError) {
|
|
118
|
+
if (!isTelegramMarkdownParseError(escapedError)) {
|
|
119
|
+
throw escapedError;
|
|
120
|
+
}
|
|
121
|
+
logger.warn("[Bot] Escaped Markdown parse failed, retrying assistant message in raw mode", escapedError);
|
|
122
|
+
return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
65
126
|
logger.warn("[Bot] Markdown parse failed, retrying assistant message in raw mode", error);
|
|
66
|
-
return api.sendMessage(chatId,
|
|
127
|
+
return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
|
|
67
128
|
}
|
|
68
129
|
}
|
|
69
|
-
export async function editMessageWithMarkdownFallback({ api, chatId, messageId, text, options, parseMode, }) {
|
|
130
|
+
export async function editMessageWithMarkdownFallback({ api, chatId, messageId, text, rawFallbackText, options, parseMode, }) {
|
|
70
131
|
if (!parseMode) {
|
|
71
132
|
return api.editMessageText(chatId, messageId, text, options);
|
|
72
133
|
}
|
|
@@ -74,6 +135,7 @@ export async function editMessageWithMarkdownFallback({ api, chatId, messageId,
|
|
|
74
135
|
...(options || {}),
|
|
75
136
|
parse_mode: parseMode,
|
|
76
137
|
};
|
|
138
|
+
const fallbackText = rawFallbackText ?? (parseMode === "MarkdownV2" ? unescapeTelegramMarkdownV2(text) : text);
|
|
77
139
|
try {
|
|
78
140
|
return await api.editMessageText(chatId, messageId, text, markdownOptions);
|
|
79
141
|
}
|
|
@@ -81,7 +143,23 @@ export async function editMessageWithMarkdownFallback({ api, chatId, messageId,
|
|
|
81
143
|
if (!isTelegramMarkdownParseError(error)) {
|
|
82
144
|
throw error;
|
|
83
145
|
}
|
|
146
|
+
if (parseMode === "MarkdownV2") {
|
|
147
|
+
const escapedText = escapeTelegramMarkdownV2(text);
|
|
148
|
+
if (escapedText !== text) {
|
|
149
|
+
logger.warn("[Bot] Markdown parse failed, retrying edited message with escaped MarkdownV2", error);
|
|
150
|
+
try {
|
|
151
|
+
return await api.editMessageText(chatId, messageId, escapedText, markdownOptions);
|
|
152
|
+
}
|
|
153
|
+
catch (escapedError) {
|
|
154
|
+
if (!isTelegramMarkdownParseError(escapedError)) {
|
|
155
|
+
throw escapedError;
|
|
156
|
+
}
|
|
157
|
+
logger.warn("[Bot] Escaped Markdown parse failed, retrying edited message in raw mode", escapedError);
|
|
158
|
+
return api.editMessageText(chatId, messageId, fallbackText, stripMarkdownFormattingOptions(options));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
84
162
|
logger.warn("[Bot] Markdown parse failed, retrying edited message in raw mode", error);
|
|
85
|
-
return api.editMessageText(chatId, messageId,
|
|
163
|
+
return api.editMessageText(chatId, messageId, fallbackText, stripMarkdownFormattingOptions(options));
|
|
86
164
|
}
|
|
87
165
|
}
|
|
@@ -5,21 +5,23 @@ function resolveParseMode(format) {
|
|
|
5
5
|
}
|
|
6
6
|
return undefined;
|
|
7
7
|
}
|
|
8
|
-
export async function sendBotText({ api, chatId, text, options, format = "raw", }) {
|
|
8
|
+
export async function sendBotText({ api, chatId, text, rawFallbackText, options, format = "raw", }) {
|
|
9
9
|
await sendMessageWithMarkdownFallback({
|
|
10
10
|
api,
|
|
11
11
|
chatId,
|
|
12
12
|
text,
|
|
13
|
+
rawFallbackText,
|
|
13
14
|
options,
|
|
14
15
|
parseMode: resolveParseMode(format),
|
|
15
16
|
});
|
|
16
17
|
}
|
|
17
|
-
export async function editBotText({ api, chatId, messageId, text, options, format = "raw", }) {
|
|
18
|
+
export async function editBotText({ api, chatId, messageId, text, rawFallbackText, options, format = "raw", }) {
|
|
18
19
|
await editMessageWithMarkdownFallback({
|
|
19
20
|
api,
|
|
20
21
|
chatId,
|
|
21
22
|
messageId,
|
|
22
23
|
text,
|
|
24
|
+
rawFallbackText,
|
|
23
25
|
options,
|
|
24
26
|
parseMode: resolveParseMode(format),
|
|
25
27
|
});
|
package/dist/config.js
CHANGED
|
@@ -73,6 +73,7 @@ export const config = {
|
|
|
73
73
|
projectsListLimit: getOptionalPositiveIntEnvVar("PROJECTS_LIST_LIMIT", 10),
|
|
74
74
|
commandsListLimit: getOptionalPositiveIntEnvVar("COMMANDS_LIST_LIMIT", 10),
|
|
75
75
|
taskLimit: getOptionalPositiveIntEnvVar("TASK_LIMIT", 10),
|
|
76
|
+
responseStreamThrottleMs: getOptionalPositiveIntEnvVar("RESPONSE_STREAM_THROTTLE_MS", 500),
|
|
76
77
|
locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
|
|
77
78
|
hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
|
|
78
79
|
hideToolCallMessages: getOptionalBooleanEnvVar("HIDE_TOOL_CALL_MESSAGES", false),
|
package/dist/pinned/manager.js
CHANGED
|
@@ -23,6 +23,11 @@ class PinnedMessageManager {
|
|
|
23
23
|
};
|
|
24
24
|
contextLimit = null;
|
|
25
25
|
onKeyboardUpdateCallback;
|
|
26
|
+
updateDebounceTimer = null;
|
|
27
|
+
updateTask = null;
|
|
28
|
+
pendingUpdate = false;
|
|
29
|
+
pendingForceUpdate = false;
|
|
30
|
+
lastRenderedMessageText = null;
|
|
26
31
|
/**
|
|
27
32
|
* Initialize manager with bot API and chat ID
|
|
28
33
|
*/
|
|
@@ -58,6 +63,9 @@ class PinnedMessageManager {
|
|
|
58
63
|
}
|
|
59
64
|
// Reset changed files for new session
|
|
60
65
|
this.state.changedFiles = [];
|
|
66
|
+
this.lastRenderedMessageText = null;
|
|
67
|
+
this.pendingUpdate = false;
|
|
68
|
+
this.pendingForceUpdate = false;
|
|
61
69
|
// Unpin old message and create new one
|
|
62
70
|
await this.unpinOldMessage();
|
|
63
71
|
await this.createPinnedMessage();
|
|
@@ -163,12 +171,16 @@ class PinnedMessageManager {
|
|
|
163
171
|
* Used at thinking time to push accumulated silent updates to Telegram.
|
|
164
172
|
*/
|
|
165
173
|
async refresh() {
|
|
166
|
-
await this.updatePinnedMessage();
|
|
174
|
+
await this.updatePinnedMessage(true);
|
|
167
175
|
}
|
|
168
176
|
/**
|
|
169
177
|
* Called when cost info is received from SSE events
|
|
170
178
|
*/
|
|
171
179
|
async onCostUpdate(cost) {
|
|
180
|
+
if (!Number.isFinite(cost) || cost === 0) {
|
|
181
|
+
logger.debug("[PinnedManager] Ignoring non-impacting cost update");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
172
184
|
const currentCost = this.state.cost || 0;
|
|
173
185
|
this.state.cost = currentCost + cost;
|
|
174
186
|
logger.debug(`[PinnedManager] Cost added: $${cost.toFixed(2)}, total session: $${(this.state.cost || 0).toFixed(2)}`);
|
|
@@ -223,6 +235,10 @@ class PinnedMessageManager {
|
|
|
223
235
|
logger.debug("[PinnedManager] Ignoring empty session.diff, keeping tool-collected data");
|
|
224
236
|
return;
|
|
225
237
|
}
|
|
238
|
+
if (this.areFileDiffsEqual(this.state.changedFiles, diffs)) {
|
|
239
|
+
logger.debug("[PinnedManager] Ignoring unchanged session.diff");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
226
242
|
this.state.changedFiles = diffs;
|
|
227
243
|
logger.debug(`[PinnedManager] Session diff updated: ${diffs.length} files`);
|
|
228
244
|
await this.updatePinnedMessage();
|
|
@@ -243,15 +259,14 @@ class PinnedMessageManager {
|
|
|
243
259
|
// Schedule debounced update (avoid spamming Telegram API on rapid tool events)
|
|
244
260
|
this.scheduleDebouncedUpdate();
|
|
245
261
|
}
|
|
246
|
-
updateDebounceTimer = null;
|
|
247
262
|
scheduleDebouncedUpdate() {
|
|
248
263
|
if (this.updateDebounceTimer) {
|
|
249
264
|
clearTimeout(this.updateDebounceTimer);
|
|
250
265
|
}
|
|
251
266
|
this.updateDebounceTimer = setTimeout(() => {
|
|
252
267
|
this.updateDebounceTimer = null;
|
|
253
|
-
this.updatePinnedMessage();
|
|
254
|
-
},
|
|
268
|
+
void this.updatePinnedMessage();
|
|
269
|
+
}, 1000);
|
|
255
270
|
}
|
|
256
271
|
/**
|
|
257
272
|
* Load file diffs from API for current session.
|
|
@@ -430,6 +445,21 @@ class PinnedMessageManager {
|
|
|
430
445
|
return normalized;
|
|
431
446
|
return ".../" + segments.slice(-3).join("/");
|
|
432
447
|
}
|
|
448
|
+
areFileDiffsEqual(current, next) {
|
|
449
|
+
if (current.length !== next.length) {
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
for (let index = 0; index < current.length; index++) {
|
|
453
|
+
const left = current[index];
|
|
454
|
+
const right = next[index];
|
|
455
|
+
if (left.file !== right.file ||
|
|
456
|
+
left.additions !== right.additions ||
|
|
457
|
+
left.deletions !== right.deletions) {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
433
463
|
/**
|
|
434
464
|
* Fetch context limit from current model configuration
|
|
435
465
|
*/
|
|
@@ -498,6 +528,7 @@ class PinnedMessageManager {
|
|
|
498
528
|
this.state.messageId = sentMessage.message_id;
|
|
499
529
|
this.state.chatId = this.chatId;
|
|
500
530
|
this.state.lastUpdated = Date.now();
|
|
531
|
+
this.lastRenderedMessageText = text;
|
|
501
532
|
// Save to settings for persistence
|
|
502
533
|
setPinnedMessageId(sentMessage.message_id);
|
|
503
534
|
// Pin the message (silently)
|
|
@@ -513,36 +544,67 @@ class PinnedMessageManager {
|
|
|
513
544
|
/**
|
|
514
545
|
* Update existing pinned message text
|
|
515
546
|
*/
|
|
516
|
-
async updatePinnedMessage() {
|
|
547
|
+
async updatePinnedMessage(forceUpdate = false) {
|
|
517
548
|
if (!this.api || !this.chatId || !this.state.messageId) {
|
|
518
549
|
return;
|
|
519
550
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
this.state.lastUpdated = Date.now();
|
|
524
|
-
logger.debug(`[PinnedManager] Updated pinned message: ${this.state.messageId}`);
|
|
525
|
-
// Trigger keyboard update callback
|
|
526
|
-
if (this.onKeyboardUpdateCallback && this.state.tokensLimit > 0) {
|
|
527
|
-
setImmediate(() => {
|
|
528
|
-
this.onKeyboardUpdateCallback(this.state.tokensUsed, this.state.tokensLimit);
|
|
529
|
-
});
|
|
530
|
-
}
|
|
551
|
+
this.pendingUpdate = true;
|
|
552
|
+
if (forceUpdate) {
|
|
553
|
+
this.pendingForceUpdate = true;
|
|
531
554
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
555
|
+
if (this.updateTask) {
|
|
556
|
+
await this.updateTask;
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
this.updateTask = this.flushPendingPinnedUpdates().finally(() => {
|
|
560
|
+
this.updateTask = null;
|
|
561
|
+
});
|
|
562
|
+
await this.updateTask;
|
|
563
|
+
}
|
|
564
|
+
async flushPendingPinnedUpdates() {
|
|
565
|
+
while (this.pendingUpdate) {
|
|
566
|
+
this.pendingUpdate = false;
|
|
567
|
+
const shouldForceUpdate = this.pendingForceUpdate;
|
|
568
|
+
this.pendingForceUpdate = false;
|
|
569
|
+
if (!this.api || !this.chatId || !this.state.messageId) {
|
|
535
570
|
return;
|
|
536
571
|
}
|
|
537
|
-
|
|
538
|
-
if (
|
|
539
|
-
logger.
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
572
|
+
const text = this.formatMessage();
|
|
573
|
+
if (!shouldForceUpdate && text === this.lastRenderedMessageText) {
|
|
574
|
+
logger.debug("[PinnedManager] Skipping pinned update: message content unchanged");
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
try {
|
|
578
|
+
await this.api.editMessageText(this.chatId, this.state.messageId, text);
|
|
579
|
+
this.state.lastUpdated = Date.now();
|
|
580
|
+
this.lastRenderedMessageText = text;
|
|
581
|
+
logger.debug(`[PinnedManager] Updated pinned message: ${this.state.messageId}`);
|
|
582
|
+
// Trigger keyboard update callback
|
|
583
|
+
if (this.onKeyboardUpdateCallback && this.state.tokensLimit > 0) {
|
|
584
|
+
setImmediate(() => {
|
|
585
|
+
this.onKeyboardUpdateCallback(this.state.tokensUsed, this.state.tokensLimit);
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
const errorMessage = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
|
591
|
+
// Handle "message is not modified" error silently
|
|
592
|
+
if (errorMessage.includes("message is not modified")) {
|
|
593
|
+
this.lastRenderedMessageText = text;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
// Handle "message to edit not found" - recreate
|
|
597
|
+
if (errorMessage.includes("message to edit not found")) {
|
|
598
|
+
logger.warn("[PinnedManager] Pinned message was deleted, recreating...");
|
|
599
|
+
this.state.messageId = null;
|
|
600
|
+
this.lastRenderedMessageText = null;
|
|
601
|
+
this.pendingForceUpdate = false;
|
|
602
|
+
clearPinnedMessageId();
|
|
603
|
+
await this.createPinnedMessage();
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
logger.error("[PinnedManager] Error updating pinned message:", err);
|
|
544
607
|
}
|
|
545
|
-
logger.error("[PinnedManager] Error updating pinned message:", err);
|
|
546
608
|
}
|
|
547
609
|
}
|
|
548
610
|
/**
|
|
@@ -556,6 +618,9 @@ class PinnedMessageManager {
|
|
|
556
618
|
// Unpin all messages (ensures clean state)
|
|
557
619
|
await this.api.unpinAllChatMessages(this.chatId).catch(() => { });
|
|
558
620
|
this.state.messageId = null;
|
|
621
|
+
this.lastRenderedMessageText = null;
|
|
622
|
+
this.pendingUpdate = false;
|
|
623
|
+
this.pendingForceUpdate = false;
|
|
559
624
|
clearPinnedMessageId();
|
|
560
625
|
logger.debug("[PinnedManager] Unpinned old messages");
|
|
561
626
|
}
|
|
@@ -586,6 +651,9 @@ class PinnedMessageManager {
|
|
|
586
651
|
this.state.tokensUsed = 0;
|
|
587
652
|
this.state.tokensLimit = 0;
|
|
588
653
|
this.state.changedFiles = [];
|
|
654
|
+
this.lastRenderedMessageText = null;
|
|
655
|
+
this.pendingUpdate = false;
|
|
656
|
+
this.pendingForceUpdate = false;
|
|
589
657
|
clearPinnedMessageId();
|
|
590
658
|
return;
|
|
591
659
|
}
|
|
@@ -600,6 +668,9 @@ class PinnedMessageManager {
|
|
|
600
668
|
this.state.tokensUsed = 0;
|
|
601
669
|
this.state.tokensLimit = 0;
|
|
602
670
|
this.state.changedFiles = [];
|
|
671
|
+
this.lastRenderedMessageText = null;
|
|
672
|
+
this.pendingUpdate = false;
|
|
673
|
+
this.pendingForceUpdate = false;
|
|
603
674
|
clearPinnedMessageId();
|
|
604
675
|
logger.info("[PinnedManager] Cleared pinned message state");
|
|
605
676
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
function getErrorMessage(error) {
|
|
2
|
+
const parts = [];
|
|
3
|
+
if (error instanceof Error) {
|
|
4
|
+
parts.push(error.message);
|
|
5
|
+
}
|
|
6
|
+
if (typeof error === "object" && error !== null) {
|
|
7
|
+
const description = Reflect.get(error, "description");
|
|
8
|
+
if (typeof description === "string") {
|
|
9
|
+
parts.push(description);
|
|
10
|
+
}
|
|
11
|
+
const message = Reflect.get(error, "message");
|
|
12
|
+
if (typeof message === "string") {
|
|
13
|
+
parts.push(message);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (typeof error === "string") {
|
|
17
|
+
parts.push(error);
|
|
18
|
+
}
|
|
19
|
+
return parts.join("\n");
|
|
20
|
+
}
|
|
21
|
+
function getRetryAfterSecondsFromError(error) {
|
|
22
|
+
if (typeof error === "object" && error !== null) {
|
|
23
|
+
const parameters = Reflect.get(error, "parameters");
|
|
24
|
+
if (typeof parameters === "object" && parameters !== null) {
|
|
25
|
+
const retryAfter = Reflect.get(parameters, "retry_after");
|
|
26
|
+
if (typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter > 0) {
|
|
27
|
+
return retryAfter;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const message = getErrorMessage(error);
|
|
32
|
+
const retryMatch = message.match(/retry after\s+(\d+)/i);
|
|
33
|
+
if (!retryMatch) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const parsedSeconds = Number.parseInt(retryMatch[1], 10);
|
|
37
|
+
if (!Number.isFinite(parsedSeconds) || parsedSeconds <= 0) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return parsedSeconds;
|
|
41
|
+
}
|
|
42
|
+
function isTelegramRateLimitError(error) {
|
|
43
|
+
if (typeof error === "object" && error !== null) {
|
|
44
|
+
const status = Reflect.get(error, "status");
|
|
45
|
+
if (typeof status === "number" && status === 429) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
const errorCode = Reflect.get(error, "error_code");
|
|
49
|
+
if (typeof errorCode === "number" && errorCode === 429) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const message = getErrorMessage(error).toLowerCase();
|
|
54
|
+
return /\b429\b/.test(message) || message.includes("too many requests");
|
|
55
|
+
}
|
|
56
|
+
function wait(ms) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
setTimeout(resolve, ms);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export function getTelegramRetryAfterMs(error, fallbackDelayMs = 1000) {
|
|
62
|
+
if (!isTelegramRateLimitError(error)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const retryAfterSeconds = getRetryAfterSecondsFromError(error);
|
|
66
|
+
if (retryAfterSeconds !== null) {
|
|
67
|
+
return retryAfterSeconds * 1000;
|
|
68
|
+
}
|
|
69
|
+
return Math.max(1, Math.floor(fallbackDelayMs));
|
|
70
|
+
}
|
|
71
|
+
export async function withTelegramRateLimitRetry(operation, options) {
|
|
72
|
+
const maxRetries = Math.max(0, Math.floor(options?.maxRetries ?? 3));
|
|
73
|
+
const fallbackDelayMs = options?.fallbackDelayMs ?? 1000;
|
|
74
|
+
let attempt = 0;
|
|
75
|
+
while (true) {
|
|
76
|
+
try {
|
|
77
|
+
return await operation();
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const retryAfterMs = getTelegramRetryAfterMs(error, fallbackDelayMs);
|
|
81
|
+
if (retryAfterMs === null || attempt >= maxRetries) {
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
attempt += 1;
|
|
85
|
+
options?.onRetry?.({
|
|
86
|
+
attempt,
|
|
87
|
+
retryAfterMs,
|
|
88
|
+
error,
|
|
89
|
+
});
|
|
90
|
+
await wait(retryAfterMs);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|