@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.
- package/.env.example +18 -0
- package/README.md +50 -26
- package/dist/bot/commands/projects.js +2 -2
- package/dist/bot/handlers/model.js +19 -3
- package/dist/bot/handlers/prompt.js +232 -0
- package/dist/bot/handlers/voice.js +170 -0
- package/dist/bot/index.js +104 -197
- package/dist/bot/utils/file-download.js +75 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +58 -0
- package/dist/config.js +19 -0
- package/dist/i18n/en.js +12 -0
- package/dist/i18n/ru.js +12 -0
- package/dist/interaction/guard.js +8 -1
- package/dist/model/capabilities.js +62 -0
- package/dist/stt/client.js +64 -0
- package/dist/summary/aggregator.js +39 -11
- package/dist/summary/formatter.js +112 -0
- package/package.json +3 -2
|
@@ -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 === "
|
|
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.
|
|
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",
|