@grinev/opencode-telegram-bot 0.18.0 → 0.19.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 +22 -1
- package/README.md +20 -5
- package/dist/app/start-bot-app.js +4 -0
- package/dist/attach/service.js +6 -1
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/mcps.js +394 -0
- package/dist/bot/commands/opencode-start.js +2 -10
- package/dist/bot/commands/projects.js +5 -4
- package/dist/bot/commands/tasklist.js +3 -1
- package/dist/bot/handlers/prompt.js +15 -14
- package/dist/bot/index.js +6 -2
- package/dist/config.js +25 -6
- package/dist/i18n/de.js +21 -0
- package/dist/i18n/en.js +21 -0
- package/dist/i18n/es.js +21 -0
- package/dist/i18n/fr.js +21 -0
- package/dist/i18n/ru.js +21 -0
- package/dist/i18n/zh.js +21 -0
- package/dist/opencode/auto-restart.js +92 -0
- package/dist/opencode/process.js +18 -1
- package/dist/pinned/manager.js +21 -0
- package/dist/scheduled-task/executor.js +155 -8
- package/dist/scheduled-task/runtime.js +30 -5
- package/dist/session/cache-manager.js +19 -6
- package/dist/summary/aggregator.js +35 -0
- package/dist/summary/formatter.js +2 -2
- package/dist/summary/markdown-to-telegram-v2.js +99 -0
- package/dist/summary/subagent-formatter.js +23 -2
- package/dist/telegram/render/inline-renderer.js +18 -0
- package/dist/tts/client.js +94 -19
- package/dist/utils/logger.js +47 -1
- package/package.json +2 -2
package/dist/tts/client.js
CHANGED
|
@@ -1,22 +1,80 @@
|
|
|
1
1
|
import { config } from "../config.js";
|
|
2
2
|
import { logger } from "../utils/logger.js";
|
|
3
|
+
import textToSpeech from "@google-cloud/text-to-speech";
|
|
3
4
|
const TTS_REQUEST_TIMEOUT_MS = 60_000;
|
|
4
5
|
export function isTtsConfigured() {
|
|
6
|
+
if (config.tts.provider === "google") {
|
|
7
|
+
return Boolean(process.env.GOOGLE_APPLICATION_CREDENTIALS);
|
|
8
|
+
}
|
|
5
9
|
return Boolean(config.tts.apiUrl && config.tts.apiKey);
|
|
6
10
|
}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Removes markdown syntax that TTS engines would read aloud
|
|
13
|
+
* (asterisks, backticks, heading markers, etc.).
|
|
14
|
+
*/
|
|
15
|
+
export function stripMarkdownForSpeech(text) {
|
|
16
|
+
let clean = text;
|
|
17
|
+
// fenced code blocks → inline content
|
|
18
|
+
clean = clean.replace(/```[\s\S]*?```/g, (match) => {
|
|
19
|
+
const inner = match.replace(/^```\w*\n?/, "").replace(/\n?```$/, "");
|
|
20
|
+
return inner.replace(/\n/g, " ");
|
|
21
|
+
});
|
|
22
|
+
clean = clean.replace(/`([^`]+)`/g, "$1");
|
|
23
|
+
clean = clean.replace(/\*\*\*(.+?)\*\*\*/g, "$1");
|
|
24
|
+
clean = clean.replace(/\*\*(.+?)\*\*/g, "$1");
|
|
25
|
+
clean = clean.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "$1");
|
|
26
|
+
clean = clean.replace(/~~(.+?)~~/g, "$1");
|
|
27
|
+
clean = clean.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
|
28
|
+
clean = clean.replace(/^#{1,6}\s+/gm, "");
|
|
29
|
+
clean = clean.replace(/^>\s?/gm, "");
|
|
30
|
+
clean = clean.replace(/^[-*]\s+/gm, "");
|
|
31
|
+
clean = clean.replace(/^\d+\.\s+/gm, "");
|
|
32
|
+
clean = clean.replace(/^[-*_]{3,}\s*$/gm, "");
|
|
33
|
+
clean = clean.replace(/<\/?[A-Za-z][^>]*>/g, "");
|
|
34
|
+
clean = clean.replace(/[ \t]+/g, " ");
|
|
35
|
+
clean = clean.replace(/\n{3,}/g, "\n\n");
|
|
36
|
+
return clean.trim();
|
|
37
|
+
}
|
|
38
|
+
/** Extracts "ll-CC" from Google voice names like "de-DE-Neural2-B". */
|
|
39
|
+
export function extractLanguageCode(voiceName) {
|
|
40
|
+
const match = voiceName.match(/^([a-z]{2,3}-[A-Z]{2})/);
|
|
41
|
+
return match ? match[1] : "en-US";
|
|
42
|
+
}
|
|
43
|
+
// --- Provider implementations ---
|
|
44
|
+
let googleClient = null;
|
|
45
|
+
function getGoogleClient() {
|
|
46
|
+
if (!googleClient) {
|
|
47
|
+
googleClient = new textToSpeech.TextToSpeechClient();
|
|
10
48
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
49
|
+
return googleClient;
|
|
50
|
+
}
|
|
51
|
+
/** @internal Reset Google client singleton (for tests only). */
|
|
52
|
+
export function _resetGoogleClient() {
|
|
53
|
+
googleClient = null;
|
|
54
|
+
}
|
|
55
|
+
async function synthesizeWithGoogle(text) {
|
|
56
|
+
const client = getGoogleClient();
|
|
57
|
+
const voiceName = config.tts.voice || "en-US-Studio-O";
|
|
58
|
+
const languageCode = extractLanguageCode(voiceName);
|
|
59
|
+
logger.debug(`[TTS] Google Cloud TTS: voice=${voiceName}, languageCode=${languageCode}, chars=${text.length}`);
|
|
60
|
+
const [response] = await client.synthesizeSpeech({
|
|
61
|
+
input: { text },
|
|
62
|
+
voice: { languageCode, name: voiceName },
|
|
63
|
+
audioConfig: { audioEncoding: "MP3" },
|
|
64
|
+
}, { timeout: TTS_REQUEST_TIMEOUT_MS });
|
|
65
|
+
const raw = response.audioContent;
|
|
66
|
+
const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
67
|
+
if (buffer.length === 0) {
|
|
68
|
+
throw new Error("Google TTS API returned an empty audio response");
|
|
14
69
|
}
|
|
70
|
+
return { buffer, filename: "assistant-reply.mp3", mimeType: "audio/mpeg" };
|
|
71
|
+
}
|
|
72
|
+
async function synthesizeWithOpenAi(text) {
|
|
73
|
+
const url = `${config.tts.apiUrl}/audio/speech`;
|
|
74
|
+
logger.debug(`[TTS] OpenAI-compatible: url=${url}, model=${config.tts.model}, voice=${config.tts.voice}, chars=${text.length}`);
|
|
15
75
|
const controller = new AbortController();
|
|
16
76
|
const timeout = setTimeout(() => controller.abort(), TTS_REQUEST_TIMEOUT_MS);
|
|
17
77
|
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
78
|
const response = await fetch(url, {
|
|
21
79
|
method: "POST",
|
|
22
80
|
headers: {
|
|
@@ -26,7 +84,7 @@ export async function synthesizeSpeech(text) {
|
|
|
26
84
|
body: JSON.stringify({
|
|
27
85
|
model: config.tts.model,
|
|
28
86
|
voice: config.tts.voice,
|
|
29
|
-
input,
|
|
87
|
+
input: text,
|
|
30
88
|
response_format: "mp3",
|
|
31
89
|
}),
|
|
32
90
|
signal: controller.signal,
|
|
@@ -35,17 +93,37 @@ export async function synthesizeSpeech(text) {
|
|
|
35
93
|
const errorBody = await response.text().catch(() => "");
|
|
36
94
|
throw new Error(`TTS API returned HTTP ${response.status}: ${errorBody || response.statusText}`);
|
|
37
95
|
}
|
|
38
|
-
const
|
|
39
|
-
const buffer = Buffer.from(arrayBuffer);
|
|
96
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
40
97
|
if (buffer.length === 0) {
|
|
41
98
|
throw new Error("TTS API returned an empty audio response");
|
|
42
99
|
}
|
|
43
100
|
logger.debug(`[TTS] Generated speech audio: ${buffer.length} bytes`);
|
|
44
|
-
return {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
101
|
+
return { buffer, filename: "assistant-reply.mp3", mimeType: "audio/mpeg" };
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
clearTimeout(timeout);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// --- Public API ---
|
|
108
|
+
function getNotConfiguredMessage() {
|
|
109
|
+
return config.tts.provider === "google"
|
|
110
|
+
? "TTS is not configured: set GOOGLE_APPLICATION_CREDENTIALS for Google Cloud TTS"
|
|
111
|
+
: "TTS is not configured: set TTS_API_URL and TTS_API_KEY";
|
|
112
|
+
}
|
|
113
|
+
export async function synthesizeSpeech(text) {
|
|
114
|
+
if (!isTtsConfigured()) {
|
|
115
|
+
throw new Error(getNotConfiguredMessage());
|
|
116
|
+
}
|
|
117
|
+
const raw = text.trim();
|
|
118
|
+
if (!raw) {
|
|
119
|
+
throw new Error("TTS input text is empty");
|
|
120
|
+
}
|
|
121
|
+
const input = stripMarkdownForSpeech(raw);
|
|
122
|
+
try {
|
|
123
|
+
if (config.tts.provider === "google") {
|
|
124
|
+
return await synthesizeWithGoogle(input);
|
|
125
|
+
}
|
|
126
|
+
return await synthesizeWithOpenAi(input);
|
|
49
127
|
}
|
|
50
128
|
catch (err) {
|
|
51
129
|
if (err instanceof DOMException && err.name === "AbortError") {
|
|
@@ -53,7 +131,4 @@ export async function synthesizeSpeech(text) {
|
|
|
53
131
|
}
|
|
54
132
|
throw err;
|
|
55
133
|
}
|
|
56
|
-
finally {
|
|
57
|
-
clearTimeout(timeout);
|
|
58
|
-
}
|
|
59
134
|
}
|
package/dist/utils/logger.js
CHANGED
|
@@ -16,6 +16,7 @@ const LOG_LEVELS = {
|
|
|
16
16
|
let logStream = null;
|
|
17
17
|
let logFilePath = null;
|
|
18
18
|
let initializePromise = null;
|
|
19
|
+
let cleanupPromise = null;
|
|
19
20
|
let streamErrorReported = false;
|
|
20
21
|
function normalizeLogLevel(value) {
|
|
21
22
|
if (value in LOG_LEVELS) {
|
|
@@ -96,6 +97,9 @@ function getInstalledLogFileName() {
|
|
|
96
97
|
function getLogFileName(mode) {
|
|
97
98
|
return mode === "installed" ? getInstalledLogFileName() : getSourcesLogFileName();
|
|
98
99
|
}
|
|
100
|
+
function getLogFilePathForMode(logsDirPath, mode) {
|
|
101
|
+
return path.join(logsDirPath, getLogFileName(mode));
|
|
102
|
+
}
|
|
99
103
|
function getLogFilePattern(mode) {
|
|
100
104
|
if (mode === "installed") {
|
|
101
105
|
return /^bot-\d{4}-\d{2}-\d{2}\.log$/;
|
|
@@ -160,11 +164,49 @@ async function cleanupOldLogs(logsDirPath, mode) {
|
|
|
160
164
|
}
|
|
161
165
|
}));
|
|
162
166
|
}
|
|
167
|
+
function cleanupOldLogsInBackground(logsDirPath, mode) {
|
|
168
|
+
if (cleanupPromise) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
cleanupPromise = cleanupOldLogs(logsDirPath, mode)
|
|
172
|
+
.catch((error) => {
|
|
173
|
+
reportLoggerInternalError(`Failed to clean up old logs in ${logsDirPath}.`, error);
|
|
174
|
+
})
|
|
175
|
+
.finally(() => {
|
|
176
|
+
cleanupPromise = null;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
function rotateInstalledLogIfNeeded() {
|
|
180
|
+
const mode = getRuntimeMode();
|
|
181
|
+
if (mode !== "installed" || !logFilePath) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const runtimePaths = getRuntimePaths();
|
|
185
|
+
const nextLogFilePath = getLogFilePathForMode(runtimePaths.logsDirPath, mode);
|
|
186
|
+
if (logFilePath === nextLogFilePath) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
fs.mkdirSync(runtimePaths.logsDirPath, { recursive: true });
|
|
191
|
+
fs.appendFileSync(nextLogFilePath, "");
|
|
192
|
+
ensureLogStream(nextLogFilePath);
|
|
193
|
+
cleanupOldLogsInBackground(runtimePaths.logsDirPath, mode);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
reportLoggerInternalError(`Failed to rotate file logging to ${nextLogFilePath}.`, error);
|
|
197
|
+
closeLogStream();
|
|
198
|
+
logFilePath = null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
163
201
|
function writeToFile(line) {
|
|
164
202
|
if (!logStream) {
|
|
165
203
|
return;
|
|
166
204
|
}
|
|
167
205
|
try {
|
|
206
|
+
rotateInstalledLogIfNeeded();
|
|
207
|
+
if (!logStream) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
168
210
|
logStream.write(`${line}\n`);
|
|
169
211
|
}
|
|
170
212
|
catch (error) {
|
|
@@ -179,7 +221,7 @@ async function initializeLoggerInternal() {
|
|
|
179
221
|
const mode = getRuntimeMode();
|
|
180
222
|
try {
|
|
181
223
|
await fsPromises.mkdir(runtimePaths.logsDirPath, { recursive: true });
|
|
182
|
-
const nextLogFilePath =
|
|
224
|
+
const nextLogFilePath = getLogFilePathForMode(runtimePaths.logsDirPath, mode);
|
|
183
225
|
await fsPromises.appendFile(nextLogFilePath, "");
|
|
184
226
|
ensureLogStream(nextLogFilePath);
|
|
185
227
|
await cleanupOldLogs(runtimePaths.logsDirPath, mode);
|
|
@@ -207,6 +249,9 @@ export function getLogFilePath() {
|
|
|
207
249
|
return logFilePath;
|
|
208
250
|
}
|
|
209
251
|
export async function __flushLoggerForTests() {
|
|
252
|
+
if (cleanupPromise) {
|
|
253
|
+
await cleanupPromise;
|
|
254
|
+
}
|
|
210
255
|
if (!logStream) {
|
|
211
256
|
return;
|
|
212
257
|
}
|
|
@@ -222,6 +267,7 @@ export async function __flushLoggerForTests() {
|
|
|
222
267
|
}
|
|
223
268
|
export function __resetLoggerForTests() {
|
|
224
269
|
initializePromise = null;
|
|
270
|
+
cleanupPromise = null;
|
|
225
271
|
logFilePath = null;
|
|
226
272
|
streamErrorReported = false;
|
|
227
273
|
closeLogStream();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grinev/opencode-telegram-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
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",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"homepage": "https://github.com/grinev/opencode-telegram-bot#readme",
|
|
53
53
|
"dependencies": {
|
|
54
|
+
"@google-cloud/text-to-speech": "^6.4.0",
|
|
54
55
|
"@grammyjs/menu": "^1.3.1",
|
|
55
56
|
"@opencode-ai/sdk": "^1.1.21",
|
|
56
57
|
"better-sqlite3": "^12.6.2",
|
|
@@ -61,7 +62,6 @@
|
|
|
61
62
|
"remark-gfm": "^4.0.1",
|
|
62
63
|
"remark-parse": "^11.0.0",
|
|
63
64
|
"socks-proxy-agent": "^8.0.5",
|
|
64
|
-
"telegram-markdown-v2": "^0.0.4",
|
|
65
65
|
"unified": "^11.0.5"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|