@grinev/opencode-telegram-bot 0.6.1 → 0.8.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.
@@ -0,0 +1,62 @@
1
+ import { opencodeClient } from "../opencode/client.js";
2
+ import { logger } from "../utils/logger.js";
3
+ const capabilitiesCache = {};
4
+ /**
5
+ * Get model capabilities from OpenCode API
6
+ * Results are cached in memory per model
7
+ */
8
+ export async function getModelCapabilities(providerID, modelID) {
9
+ const cacheKey = `${providerID}/${modelID}`;
10
+ if (capabilitiesCache[cacheKey] !== undefined) {
11
+ logger.debug(`[ModelCapabilities] Cache hit for ${cacheKey}`);
12
+ return capabilitiesCache[cacheKey];
13
+ }
14
+ try {
15
+ logger.debug(`[ModelCapabilities] Fetching capabilities for ${cacheKey}`);
16
+ const response = await opencodeClient.config.providers();
17
+ if (response.error || !response.data) {
18
+ logger.error("[ModelCapabilities] API returned error:", response.error);
19
+ capabilitiesCache[cacheKey] = null;
20
+ return null;
21
+ }
22
+ const providers = response.data.providers;
23
+ const provider = providers.find((p) => p.id === providerID);
24
+ if (!provider) {
25
+ logger.warn(`[ModelCapabilities] Provider ${providerID} not found`);
26
+ capabilitiesCache[cacheKey] = null;
27
+ return null;
28
+ }
29
+ const model = provider.models[modelID];
30
+ if (!model) {
31
+ logger.warn(`[ModelCapabilities] Model ${cacheKey} not found in provider`);
32
+ capabilitiesCache[cacheKey] = null;
33
+ return null;
34
+ }
35
+ logger.debug(`[ModelCapabilities] Found capabilities for ${cacheKey}`);
36
+ capabilitiesCache[cacheKey] = model.capabilities;
37
+ return model.capabilities;
38
+ }
39
+ catch (error) {
40
+ logger.error("[ModelCapabilities] Failed to fetch providers:", error);
41
+ capabilitiesCache[cacheKey] = null;
42
+ return null;
43
+ }
44
+ }
45
+ /**
46
+ * Check if model supports a specific input type
47
+ */
48
+ export function supportsInput(capabilities, inputType) {
49
+ if (!capabilities) {
50
+ return false;
51
+ }
52
+ return capabilities.input[inputType] === true;
53
+ }
54
+ /**
55
+ * Check if model supports attachments in general
56
+ */
57
+ export function supportsAttachment(capabilities) {
58
+ if (!capabilities) {
59
+ return false;
60
+ }
61
+ return capabilities.attachment === true;
62
+ }
@@ -0,0 +1,64 @@
1
+ import { config } from "../config.js";
2
+ import { logger } from "../utils/logger.js";
3
+ const STT_REQUEST_TIMEOUT_MS = 60_000;
4
+ /**
5
+ * Returns true if STT is configured (API URL and API key are set).
6
+ */
7
+ export function isSttConfigured() {
8
+ return Boolean(config.stt.apiUrl && config.stt.apiKey);
9
+ }
10
+ /**
11
+ * Transcribes an audio buffer using a Whisper-compatible API (OpenAI / Groq / etc.).
12
+ *
13
+ * Sends a multipart/form-data POST to `{STT_API_URL}/audio/transcriptions`.
14
+ *
15
+ * @param audioBuffer - Raw audio file bytes (ogg, mp3, wav, m4a, webm, etc.)
16
+ * @param filename - Original filename with extension (used by the API to detect format)
17
+ * @returns Transcribed text
18
+ * @throws Error if STT is not configured, the request fails, or the response is invalid
19
+ */
20
+ export async function transcribeAudio(audioBuffer, filename) {
21
+ if (!isSttConfigured()) {
22
+ throw new Error("STT is not configured: STT_API_URL and STT_API_KEY are required");
23
+ }
24
+ const url = `${config.stt.apiUrl}/audio/transcriptions`;
25
+ const formData = new FormData();
26
+ formData.append("file", new Blob([new Uint8Array(audioBuffer)]), filename);
27
+ formData.append("model", config.stt.model);
28
+ formData.append("response_format", "json");
29
+ if (config.stt.language) {
30
+ formData.append("language", config.stt.language);
31
+ }
32
+ logger.debug(`[STT] Sending transcription request: url=${url}, model=${config.stt.model}, filename=${filename}, size=${audioBuffer.length} bytes`);
33
+ const controller = new AbortController();
34
+ const timeout = setTimeout(() => controller.abort(), STT_REQUEST_TIMEOUT_MS);
35
+ try {
36
+ const response = await fetch(url, {
37
+ method: "POST",
38
+ headers: {
39
+ Authorization: `Bearer ${config.stt.apiKey}`,
40
+ },
41
+ body: formData,
42
+ signal: controller.signal,
43
+ });
44
+ if (!response.ok) {
45
+ const errorBody = await response.text().catch(() => "");
46
+ throw new Error(`STT API returned HTTP ${response.status}: ${errorBody || response.statusText}`);
47
+ }
48
+ const data = (await response.json());
49
+ if (typeof data.text !== "string") {
50
+ throw new Error("STT API response does not contain a text field");
51
+ }
52
+ logger.debug(`[STT] Transcription result: ${data.text.length} chars`);
53
+ return { text: data.text };
54
+ }
55
+ catch (err) {
56
+ if (err instanceof DOMException && err.name === "AbortError") {
57
+ throw new Error(`STT request timed out after ${STT_REQUEST_TIMEOUT_MS}ms`);
58
+ }
59
+ throw err;
60
+ }
61
+ finally {
62
+ clearTimeout(timeout);
63
+ }
64
+ }
@@ -39,11 +39,13 @@ class SummaryAggregator {
39
39
  onThinkingCallback = null;
40
40
  onTokensCallback = null;
41
41
  onSessionCompactedCallback = null;
42
+ onSessionErrorCallback = null;
42
43
  onPermissionCallback = null;
43
44
  onSessionDiffCallback = null;
44
45
  onFileChangeCallback = null;
45
46
  onClearedCallback = null;
46
47
  processedToolStates = new Set();
48
+ thinkingFiredForMessages = new Set();
47
49
  bot = null;
48
50
  chatId = null;
49
51
  typingTimer = null;
@@ -76,6 +78,9 @@ class SummaryAggregator {
76
78
  setOnSessionCompacted(callback) {
77
79
  this.onSessionCompactedCallback = callback;
78
80
  }
81
+ setOnSessionError(callback) {
82
+ this.onSessionErrorCallback = callback;
83
+ }
79
84
  setOnPermission(callback) {
80
85
  this.onPermissionCallback = callback;
81
86
  }
@@ -133,6 +138,9 @@ class SummaryAggregator {
133
138
  case "session.compacted":
134
139
  this.handleSessionCompacted(event);
135
140
  break;
141
+ case "session.error":
142
+ this.handleSessionError(event);
143
+ break;
136
144
  case "question.asked":
137
145
  this.handleQuestionAsked(event);
138
146
  break;
@@ -170,6 +178,7 @@ class SummaryAggregator {
170
178
  this.messages.clear();
171
179
  this.partHashes.clear();
172
180
  this.processedToolStates.clear();
181
+ this.thinkingFiredForMessages.clear();
173
182
  this.messageCount = 0;
174
183
  this.lastUpdated = 0;
175
184
  if (this.onClearedCallback) {
@@ -193,16 +202,6 @@ class SummaryAggregator {
193
202
  this.currentMessageParts.set(messageID, []);
194
203
  this.messageCount++;
195
204
  this.startTypingIndicator();
196
- const isSummaryMessage = info.summary === true;
197
- // Notify that agent started thinking
198
- if (!isSummaryMessage && this.onThinkingCallback) {
199
- const callback = this.onThinkingCallback;
200
- setImmediate(() => {
201
- if (typeof callback === "function") {
202
- callback(info.sessionID);
203
- }
204
- });
205
- }
206
205
  }
207
206
  const pending = this.pendingParts.get(messageID) || [];
208
207
  const current = this.currentMessageParts.get(messageID) || [];
@@ -250,7 +249,21 @@ class SummaryAggregator {
250
249
  }
251
250
  const messageID = part.messageID;
252
251
  const messageInfo = this.messages.get(messageID);
253
- if (part.type === "text" && "text" in part && part.text) {
252
+ if (part.type === "reasoning") {
253
+ // Fire the thinking callback once per message on the first reasoning part.
254
+ // This is the signal that the model is actually doing extended thinking.
255
+ if (!this.thinkingFiredForMessages.has(messageID) && this.onThinkingCallback) {
256
+ this.thinkingFiredForMessages.add(messageID);
257
+ const callback = this.onThinkingCallback;
258
+ const sessionID = part.sessionID;
259
+ setImmediate(() => {
260
+ if (typeof callback === "function") {
261
+ callback(sessionID);
262
+ }
263
+ });
264
+ }
265
+ }
266
+ else if (part.type === "text" && "text" in part && part.text) {
254
267
  const partHash = this.hashString(part.text);
255
268
  if (!this.partHashes.has(messageID)) {
256
269
  this.partHashes.set(messageID, new Set());
@@ -452,6 +465,21 @@ class SummaryAggregator {
452
465
  });
453
466
  }
454
467
  }
468
+ handleSessionError(event) {
469
+ const { sessionID, error } = event.properties;
470
+ if (sessionID !== this.currentSessionId) {
471
+ return;
472
+ }
473
+ const message = error?.data?.message || error?.message || error?.name || "Unknown session error";
474
+ logger.warn(`[Aggregator] Session error: ${sessionID}: ${message}`);
475
+ this.stopTypingIndicator();
476
+ if (this.onSessionErrorCallback) {
477
+ const callback = this.onSessionErrorCallback;
478
+ setImmediate(() => {
479
+ callback(sessionID, message);
480
+ });
481
+ }
482
+ }
455
483
  handleQuestionAsked(event) {
456
484
  const { id, sessionID, questions } = event.properties;
457
485
  if (sessionID !== this.currentSessionId) {
@@ -1,4 +1,5 @@
1
1
  import * as path from "path";
2
+ import { convert } from "telegram-markdown-v2";
2
3
  import { config } from "../config.js";
3
4
  import { logger } from "../utils/logger.js";
4
5
  import { t } from "../i18n/index.js";
@@ -22,6 +23,87 @@ function splitText(text, maxLength) {
22
23
  }
23
24
  return parts;
24
25
  }
26
+ function isCodeFenceLine(line) {
27
+ return line.trimStart().startsWith("```");
28
+ }
29
+ function isHorizontalRuleLine(line) {
30
+ const normalized = line.trim();
31
+ if (!normalized) {
32
+ return false;
33
+ }
34
+ return /^([-*_])(?:\s*\1){2,}$/.test(normalized);
35
+ }
36
+ function isHeadingLine(line) {
37
+ return /^\s{0,3}#{1,6}\s+\S/.test(line);
38
+ }
39
+ function normalizeHeadingLine(line) {
40
+ const match = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*$/);
41
+ if (!match) {
42
+ return line;
43
+ }
44
+ return `**${match[1]}**`;
45
+ }
46
+ function normalizeChecklistLine(line) {
47
+ const match = line.match(/^(\s*)(?:[-+*]|\d+\.)\s+\[( |x|X)\]\s+(.*)$/);
48
+ if (!match) {
49
+ return null;
50
+ }
51
+ const marker = match[2].toLowerCase() === "x" ? "✅" : "🔲";
52
+ return `${match[1]}${marker} ${match[3]}`;
53
+ }
54
+ function preprocessMarkdownForTelegram(text) {
55
+ const lines = text.split("\n");
56
+ const output = [];
57
+ let inCodeFence = false;
58
+ let inQuote = false;
59
+ for (let index = 0; index < lines.length; index++) {
60
+ const line = lines[index];
61
+ if (isCodeFenceLine(line)) {
62
+ inCodeFence = !inCodeFence;
63
+ inQuote = false;
64
+ output.push(line);
65
+ continue;
66
+ }
67
+ if (inCodeFence) {
68
+ output.push(line);
69
+ continue;
70
+ }
71
+ if (!line.trim()) {
72
+ inQuote = false;
73
+ output.push(line);
74
+ continue;
75
+ }
76
+ if (isHeadingLine(line)) {
77
+ output.push(normalizeHeadingLine(line));
78
+ inQuote = false;
79
+ continue;
80
+ }
81
+ if (isHorizontalRuleLine(line)) {
82
+ output.push("──────────");
83
+ inQuote = false;
84
+ continue;
85
+ }
86
+ const trimmedLeft = line.trimStart();
87
+ if (trimmedLeft.startsWith(">")) {
88
+ inQuote = true;
89
+ const quoteContent = trimmedLeft.replace(/^>\s?/, "");
90
+ const normalizedChecklistInQuote = normalizeChecklistLine(quoteContent);
91
+ output.push(normalizedChecklistInQuote ? `> ${normalizedChecklistInQuote.trimStart()}` : trimmedLeft);
92
+ continue;
93
+ }
94
+ const normalizedChecklist = normalizeChecklistLine(line);
95
+ if (normalizedChecklist) {
96
+ output.push(inQuote ? `> ${normalizedChecklist.trimStart()}` : normalizedChecklist);
97
+ continue;
98
+ }
99
+ if (inQuote) {
100
+ output.push(`> ${trimmedLeft}`);
101
+ continue;
102
+ }
103
+ output.push(line);
104
+ }
105
+ return output.join("\n");
106
+ }
25
107
  export function normalizePathForDisplay(filePath) {
26
108
  const normalizedPath = filePath.replace(/\\/g, "/");
27
109
  const project = getCurrentProject();
@@ -44,6 +126,25 @@ export function normalizePathForDisplay(filePath) {
44
126
  return normalizedPath;
45
127
  }
46
128
  export function formatSummary(text) {
129
+ return formatSummaryWithMode(text, config.bot.messageFormatMode);
130
+ }
131
+ export function getAssistantParseMode() {
132
+ if (config.bot.messageFormatMode === "markdown") {
133
+ return "MarkdownV2";
134
+ }
135
+ return undefined;
136
+ }
137
+ function formatMarkdownForTelegram(text) {
138
+ try {
139
+ const preprocessed = preprocessMarkdownForTelegram(text);
140
+ return convert(preprocessed, "keep");
141
+ }
142
+ catch (error) {
143
+ logger.warn("[Formatter] Failed to convert markdown summary, falling back to raw text", error);
144
+ return text;
145
+ }
146
+ }
147
+ export function formatSummaryWithMode(text, mode) {
47
148
  if (!text || text.trim().length === 0) {
48
149
  return [];
49
150
  }
@@ -54,6 +155,17 @@ export function formatSummary(text) {
54
155
  if (!trimmed) {
55
156
  continue;
56
157
  }
158
+ if (mode === "markdown") {
159
+ const converted = formatMarkdownForTelegram(trimmed);
160
+ const convertedParts = splitText(converted, TELEGRAM_MESSAGE_LIMIT);
161
+ for (const convertedPart of convertedParts) {
162
+ const normalizedPart = convertedPart.trim();
163
+ if (normalizedPart) {
164
+ formattedParts.push(normalizedPart);
165
+ }
166
+ }
167
+ continue;
168
+ }
57
169
  if (parts.length > 1) {
58
170
  formattedParts.push(`\`\`\`\n${trimmed}\n\`\`\``);
59
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -57,7 +57,8 @@
57
57
  "dotenv": "^17.2.3",
58
58
  "grammy": "^1.39.2",
59
59
  "https-proxy-agent": "^7.0.6",
60
- "socks-proxy-agent": "^8.0.5"
60
+ "socks-proxy-agent": "^8.0.5",
61
+ "telegram-markdown-v2": "^0.0.4"
61
62
  },
62
63
  "devDependencies": {
63
64
  "@types/better-sqlite3": "^7.6.13",