@grinev/opencode-telegram-bot 0.14.1 → 0.16.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 +25 -0
- package/README.md +50 -26
- package/dist/agent/manager.js +34 -6
- package/dist/agent/types.js +7 -1
- package/dist/app/start-bot-app.js +6 -1
- package/dist/bot/assistant-run-state.js +60 -0
- package/dist/bot/commands/abort.js +2 -0
- package/dist/bot/commands/commands.js +12 -2
- package/dist/bot/commands/definitions.js +2 -0
- package/dist/bot/commands/new.js +3 -2
- package/dist/bot/commands/open.js +314 -0
- package/dist/bot/commands/projects.js +5 -37
- package/dist/bot/commands/sessions.js +3 -0
- package/dist/bot/commands/start.js +2 -0
- package/dist/bot/commands/status.js +5 -2
- package/dist/bot/commands/tts.js +13 -0
- package/dist/bot/handlers/agent.js +3 -4
- package/dist/bot/handlers/inline-menu.js +9 -1
- package/dist/bot/handlers/model.js +3 -2
- package/dist/bot/handlers/prompt.js +33 -5
- package/dist/bot/handlers/variant.js +3 -2
- package/dist/bot/index.js +179 -94
- package/dist/bot/message-patterns.js +2 -2
- package/dist/bot/streaming/response-streamer.js +26 -17
- package/dist/bot/streaming/tool-call-streamer.js +31 -24
- package/dist/bot/utils/assistant-rendering.js +117 -0
- package/dist/bot/utils/assistant-run-footer.js +9 -0
- package/dist/bot/utils/browser-roots.js +140 -0
- package/dist/bot/utils/file-tree.js +92 -0
- package/dist/bot/utils/finalize-assistant-response.js +18 -24
- package/dist/bot/utils/keyboard.js +6 -6
- package/dist/bot/utils/send-tts-response.js +37 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +3 -3
- package/dist/bot/utils/switch-project.js +48 -0
- package/dist/bot/utils/telegram-text.js +95 -1
- package/dist/cli.js +4 -0
- package/dist/config.js +10 -0
- package/dist/i18n/de.js +32 -9
- package/dist/i18n/en.js +32 -9
- package/dist/i18n/es.js +32 -9
- package/dist/i18n/fr.js +32 -9
- package/dist/i18n/ru.js +32 -9
- package/dist/i18n/zh.js +32 -9
- package/dist/index.js +2 -0
- package/dist/pinned/manager.js +66 -4
- package/dist/project/manager.js +2 -1
- package/dist/settings/manager.js +7 -0
- package/dist/summary/aggregator.js +17 -1
- package/dist/summary/formatter.js +12 -89
- package/dist/telegram/render/block-fallback.js +28 -0
- package/dist/telegram/render/block-parser.js +295 -0
- package/dist/telegram/render/block-renderer.js +457 -0
- package/dist/telegram/render/chunker.js +281 -0
- package/dist/telegram/render/inline-renderer.js +128 -0
- package/dist/telegram/render/markdown-normalizer.js +94 -0
- package/dist/telegram/render/pipeline.js +9 -0
- package/dist/telegram/render/types.js +1 -0
- package/dist/telegram/render/validator.js +160 -0
- package/dist/tts/client.js +59 -0
- package/dist/utils/logger.js +200 -73
- package/package.json +6 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { logger } from "../utils/logger.js";
|
|
3
|
+
const TTS_REQUEST_TIMEOUT_MS = 60_000;
|
|
4
|
+
export function isTtsConfigured() {
|
|
5
|
+
return Boolean(config.tts.apiUrl && config.tts.apiKey);
|
|
6
|
+
}
|
|
7
|
+
export async function synthesizeSpeech(text) {
|
|
8
|
+
if (!isTtsConfigured()) {
|
|
9
|
+
throw new Error("TTS is not configured: set TTS API credentials");
|
|
10
|
+
}
|
|
11
|
+
const input = text.trim();
|
|
12
|
+
if (!input) {
|
|
13
|
+
throw new Error("TTS input text is empty");
|
|
14
|
+
}
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const timeout = setTimeout(() => controller.abort(), TTS_REQUEST_TIMEOUT_MS);
|
|
17
|
+
try {
|
|
18
|
+
const url = `${config.tts.apiUrl}/audio/speech`;
|
|
19
|
+
logger.debug(`[TTS] Sending speech synthesis request: url=${url}, model=${config.tts.model}, voice=${config.tts.voice}, chars=${input.length}`);
|
|
20
|
+
const response = await fetch(url, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: {
|
|
23
|
+
Authorization: `Bearer ${config.tts.apiKey}`,
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify({
|
|
27
|
+
model: config.tts.model,
|
|
28
|
+
voice: config.tts.voice,
|
|
29
|
+
input,
|
|
30
|
+
response_format: "mp3",
|
|
31
|
+
}),
|
|
32
|
+
signal: controller.signal,
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
const errorBody = await response.text().catch(() => "");
|
|
36
|
+
throw new Error(`TTS API returned HTTP ${response.status}: ${errorBody || response.statusText}`);
|
|
37
|
+
}
|
|
38
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
39
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
40
|
+
if (buffer.length === 0) {
|
|
41
|
+
throw new Error("TTS API returned an empty audio response");
|
|
42
|
+
}
|
|
43
|
+
logger.debug(`[TTS] Generated speech audio: ${buffer.length} bytes`);
|
|
44
|
+
return {
|
|
45
|
+
buffer,
|
|
46
|
+
filename: "assistant-reply.mp3",
|
|
47
|
+
mimeType: "audio/mpeg",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
52
|
+
throw new Error(`TTS request timed out after ${TTS_REQUEST_TIMEOUT_MS}ms`);
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
clearTimeout(timeout);
|
|
58
|
+
}
|
|
59
|
+
}
|
package/dist/utils/logger.js
CHANGED
|
@@ -1,57 +1,65 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsPromises from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { inspect } from "node:util";
|
|
5
|
+
import { getRuntimeMode } from "../runtime/mode.js";
|
|
6
|
+
import { getRuntimePaths } from "../runtime/paths.js";
|
|
7
|
+
const DEFAULT_LOG_LEVEL = "info";
|
|
8
|
+
const DEFAULT_LOG_RETENTION = 10;
|
|
9
|
+
const LOGGER_ERROR_PREFIX = "[LOGGER]";
|
|
6
10
|
const LOG_LEVELS = {
|
|
7
11
|
debug: 0,
|
|
8
12
|
info: 1,
|
|
9
13
|
warn: 2,
|
|
10
14
|
error: 3,
|
|
11
15
|
};
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* @param value - The log level string to normalize
|
|
17
|
-
* @returns A valid LogLevel
|
|
18
|
-
*/
|
|
16
|
+
let logStream = null;
|
|
17
|
+
let logFilePath = null;
|
|
18
|
+
let initializePromise = null;
|
|
19
|
+
let streamErrorReported = false;
|
|
19
20
|
function normalizeLogLevel(value) {
|
|
20
21
|
if (value in LOG_LEVELS) {
|
|
21
22
|
return value;
|
|
22
23
|
}
|
|
23
|
-
return
|
|
24
|
+
return DEFAULT_LOG_LEVEL;
|
|
25
|
+
}
|
|
26
|
+
function parsePositiveInteger(value, defaultValue) {
|
|
27
|
+
if (!value) {
|
|
28
|
+
return defaultValue;
|
|
29
|
+
}
|
|
30
|
+
const parsed = Number.parseInt(value, 10);
|
|
31
|
+
if (Number.isNaN(parsed) || parsed <= 0) {
|
|
32
|
+
return defaultValue;
|
|
33
|
+
}
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
function getConfiguredLogLevel() {
|
|
37
|
+
return normalizeLogLevel(process.env.LOG_LEVEL ?? DEFAULT_LOG_LEVEL);
|
|
38
|
+
}
|
|
39
|
+
function getConfiguredLogRetention() {
|
|
40
|
+
return parsePositiveInteger(process.env.LOG_RETENTION, DEFAULT_LOG_RETENTION);
|
|
24
41
|
}
|
|
25
|
-
/**
|
|
26
|
-
* Formats the log message prefix with timestamp and level
|
|
27
|
-
*
|
|
28
|
-
* @param level - The log level for the message
|
|
29
|
-
* @returns Formatted prefix string
|
|
30
|
-
*/
|
|
31
42
|
function formatPrefix(level) {
|
|
32
43
|
return `[${new Date().toISOString()}] [${level.toUpperCase()}]`;
|
|
33
44
|
}
|
|
34
|
-
/**
|
|
35
|
-
* Formats individual arguments for logging
|
|
36
|
-
* Special handling for Error objects to extract stack trace
|
|
37
|
-
*
|
|
38
|
-
* @param arg - The argument to format
|
|
39
|
-
* @returns Formatted argument
|
|
40
|
-
*/
|
|
41
45
|
function formatArg(arg) {
|
|
42
46
|
if (arg instanceof Error) {
|
|
43
47
|
return arg.stack ?? `${arg.name}: ${arg.message}`;
|
|
44
48
|
}
|
|
45
49
|
return arg;
|
|
46
50
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
function formatArgForFile(arg) {
|
|
52
|
+
const formatted = formatArg(arg);
|
|
53
|
+
if (typeof formatted === "string") {
|
|
54
|
+
return formatted;
|
|
55
|
+
}
|
|
56
|
+
return inspect(formatted, {
|
|
57
|
+
colors: false,
|
|
58
|
+
compact: true,
|
|
59
|
+
depth: 8,
|
|
60
|
+
breakLength: Infinity,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
55
63
|
function withPrefix(level, args) {
|
|
56
64
|
const formattedArgs = args.map((arg) => formatArg(arg));
|
|
57
65
|
const prefix = formatPrefix(level);
|
|
@@ -63,65 +71,184 @@ function withPrefix(level, args) {
|
|
|
63
71
|
}
|
|
64
72
|
return [prefix, ...formattedArgs];
|
|
65
73
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
function formatLine(level, args) {
|
|
75
|
+
const prefix = formatPrefix(level);
|
|
76
|
+
const formattedArgs = args.map((arg) => formatArgForFile(arg));
|
|
77
|
+
if (formattedArgs.length === 0) {
|
|
78
|
+
return prefix;
|
|
79
|
+
}
|
|
80
|
+
return `${prefix} ${formattedArgs.join(" ")}`;
|
|
81
|
+
}
|
|
73
82
|
function shouldLog(level) {
|
|
74
|
-
const
|
|
75
|
-
return LOG_LEVELS[level] >= LOG_LEVELS[
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
const configuredLevel = getConfiguredLogLevel();
|
|
84
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[configuredLevel];
|
|
85
|
+
}
|
|
86
|
+
function sanitizeTimestampForFile(timestamp) {
|
|
87
|
+
return timestamp.replace(/:/g, "-").replace("T", "_");
|
|
88
|
+
}
|
|
89
|
+
function getSourcesLogFileName() {
|
|
90
|
+
const timestamp = sanitizeTimestampForFile(new Date().toISOString().slice(0, 19));
|
|
91
|
+
return `bot-${timestamp}_${process.pid}.log`;
|
|
92
|
+
}
|
|
93
|
+
function getInstalledLogFileName() {
|
|
94
|
+
return `bot-${new Date().toISOString().slice(0, 10)}.log`;
|
|
95
|
+
}
|
|
96
|
+
function getLogFileName(mode) {
|
|
97
|
+
return mode === "installed" ? getInstalledLogFileName() : getSourcesLogFileName();
|
|
98
|
+
}
|
|
99
|
+
function getLogFilePattern(mode) {
|
|
100
|
+
if (mode === "installed") {
|
|
101
|
+
return /^bot-\d{4}-\d{2}-\d{2}\.log$/;
|
|
102
|
+
}
|
|
103
|
+
return /^bot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_\d+\.log$/;
|
|
104
|
+
}
|
|
105
|
+
function reportLoggerInternalError(message, error) {
|
|
106
|
+
const details = error instanceof Error
|
|
107
|
+
? (error.stack ?? `${error.name}: ${error.message}`)
|
|
108
|
+
: String(error ?? "");
|
|
109
|
+
const suffix = details && details !== "undefined" ? ` ${details}` : "";
|
|
110
|
+
process.stderr.write(`${formatPrefix("error")} ${LOGGER_ERROR_PREFIX} ${message}${suffix}\n`);
|
|
111
|
+
}
|
|
112
|
+
function handleLogStreamError(error) {
|
|
113
|
+
if (!streamErrorReported) {
|
|
114
|
+
streamErrorReported = true;
|
|
115
|
+
reportLoggerInternalError("Failed to write to log file.", error);
|
|
116
|
+
}
|
|
117
|
+
if (logStream) {
|
|
118
|
+
logStream.destroy();
|
|
119
|
+
logStream = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function closeLogStream() {
|
|
123
|
+
if (!logStream) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
logStream.removeAllListeners("error");
|
|
127
|
+
logStream.end();
|
|
128
|
+
logStream = null;
|
|
129
|
+
}
|
|
130
|
+
function ensureLogStream(filePath) {
|
|
131
|
+
if (logStream && logFilePath === filePath) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
closeLogStream();
|
|
135
|
+
streamErrorReported = false;
|
|
136
|
+
const stream = fs.createWriteStream(filePath, { flags: "a" });
|
|
137
|
+
stream.on("error", handleLogStreamError);
|
|
138
|
+
logStream = stream;
|
|
139
|
+
logFilePath = filePath;
|
|
140
|
+
}
|
|
141
|
+
async function cleanupOldLogs(logsDirPath, mode) {
|
|
142
|
+
const retention = getConfiguredLogRetention();
|
|
143
|
+
const filePattern = getLogFilePattern(mode);
|
|
144
|
+
let fileNames;
|
|
145
|
+
try {
|
|
146
|
+
fileNames = await fsPromises.readdir(logsDirPath);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
reportLoggerInternalError(`Failed to read log directory ${logsDirPath}.`, error);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const matchingFiles = fileNames.filter((fileName) => filePattern.test(fileName)).sort();
|
|
153
|
+
const filesToDelete = matchingFiles.slice(0, Math.max(0, matchingFiles.length - retention));
|
|
154
|
+
await Promise.all(filesToDelete.map(async (fileName) => {
|
|
155
|
+
try {
|
|
156
|
+
await fsPromises.unlink(path.join(logsDirPath, fileName));
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
reportLoggerInternalError(`Failed to delete old log file ${fileName}.`, error);
|
|
160
|
+
}
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
function writeToFile(line) {
|
|
164
|
+
if (!logStream) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
logStream.write(`${line}\n`);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
handleLogStreamError(error);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function initializeLoggerInternal() {
|
|
175
|
+
if (logStream && logFilePath) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const runtimePaths = getRuntimePaths();
|
|
179
|
+
const mode = getRuntimeMode();
|
|
180
|
+
try {
|
|
181
|
+
await fsPromises.mkdir(runtimePaths.logsDirPath, { recursive: true });
|
|
182
|
+
const nextLogFilePath = path.join(runtimePaths.logsDirPath, getLogFileName(mode));
|
|
183
|
+
await fsPromises.appendFile(nextLogFilePath, "");
|
|
184
|
+
ensureLogStream(nextLogFilePath);
|
|
185
|
+
await cleanupOldLogs(runtimePaths.logsDirPath, mode);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
reportLoggerInternalError(`Failed to initialize file logging in ${runtimePaths.logsDirPath}.`, error);
|
|
189
|
+
closeLogStream();
|
|
190
|
+
logFilePath = null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export async function initializeLogger() {
|
|
194
|
+
if (initializePromise) {
|
|
195
|
+
await initializePromise;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
initializePromise = initializeLoggerInternal();
|
|
199
|
+
try {
|
|
200
|
+
await initializePromise;
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
initializePromise = null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
export function getLogFilePath() {
|
|
207
|
+
return logFilePath;
|
|
208
|
+
}
|
|
209
|
+
export async function __flushLoggerForTests() {
|
|
210
|
+
if (!logStream) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
await new Promise((resolve, reject) => {
|
|
214
|
+
logStream?.write("", (error) => {
|
|
215
|
+
if (error) {
|
|
216
|
+
reject(error);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
resolve();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
export function __resetLoggerForTests() {
|
|
224
|
+
initializePromise = null;
|
|
225
|
+
logFilePath = null;
|
|
226
|
+
streamErrorReported = false;
|
|
227
|
+
closeLogStream();
|
|
228
|
+
}
|
|
82
229
|
export const logger = {
|
|
83
|
-
/**
|
|
84
|
-
* Logs debug-level messages (most verbose)
|
|
85
|
-
* Used for detailed diagnostics and internal operations
|
|
86
|
-
*
|
|
87
|
-
* @param args - Arguments to log
|
|
88
|
-
*/
|
|
89
230
|
debug: (...args) => {
|
|
90
231
|
if (shouldLog("debug")) {
|
|
91
232
|
console.log(...withPrefix("debug", args));
|
|
233
|
+
writeToFile(formatLine("debug", args));
|
|
92
234
|
}
|
|
93
235
|
},
|
|
94
|
-
/**
|
|
95
|
-
* Logs info-level messages
|
|
96
|
-
* Used for important events and general information
|
|
97
|
-
*
|
|
98
|
-
* @param args - Arguments to log
|
|
99
|
-
*/
|
|
100
236
|
info: (...args) => {
|
|
101
237
|
if (shouldLog("info")) {
|
|
102
238
|
console.log(...withPrefix("info", args));
|
|
239
|
+
writeToFile(formatLine("info", args));
|
|
103
240
|
}
|
|
104
241
|
},
|
|
105
|
-
/**
|
|
106
|
-
* Logs warning-level messages
|
|
107
|
-
* Used for recoverable errors and potential issues
|
|
108
|
-
*
|
|
109
|
-
* @param args - Arguments to log
|
|
110
|
-
*/
|
|
111
242
|
warn: (...args) => {
|
|
112
243
|
if (shouldLog("warn")) {
|
|
113
244
|
console.warn(...withPrefix("warn", args));
|
|
245
|
+
writeToFile(formatLine("warn", args));
|
|
114
246
|
}
|
|
115
247
|
},
|
|
116
|
-
/**
|
|
117
|
-
* Logs error-level messages
|
|
118
|
-
* Used for critical failures and exceptions
|
|
119
|
-
*
|
|
120
|
-
* @param args - Arguments to log
|
|
121
|
-
*/
|
|
122
248
|
error: (...args) => {
|
|
123
249
|
if (shouldLog("error")) {
|
|
124
250
|
console.error(...withPrefix("error", args));
|
|
251
|
+
writeToFile(formatLine("error", args));
|
|
125
252
|
}
|
|
126
253
|
},
|
|
127
254
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grinev/opencode-telegram-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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,8 +57,12 @@
|
|
|
57
57
|
"dotenv": "^17.2.3",
|
|
58
58
|
"grammy": "^1.39.2",
|
|
59
59
|
"https-proxy-agent": "^7.0.6",
|
|
60
|
+
"mdast-util-to-string": "^4.0.0",
|
|
61
|
+
"remark-gfm": "^4.0.1",
|
|
62
|
+
"remark-parse": "^11.0.0",
|
|
60
63
|
"socks-proxy-agent": "^8.0.5",
|
|
61
|
-
"telegram-markdown-v2": "^0.0.4"
|
|
64
|
+
"telegram-markdown-v2": "^0.0.4",
|
|
65
|
+
"unified": "^11.0.5"
|
|
62
66
|
},
|
|
63
67
|
"devDependencies": {
|
|
64
68
|
"@types/better-sqlite3": "^7.6.13",
|