@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.
@@ -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
- export async function synthesizeSpeech(text) {
8
- if (!isTtsConfigured()) {
9
- throw new Error("TTS is not configured: set TTS API credentials");
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
- const input = text.trim();
12
- if (!input) {
13
- throw new Error("TTS input text is empty");
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 arrayBuffer = await response.arrayBuffer();
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
- buffer,
46
- filename: "assistant-reply.mp3",
47
- mimeType: "audio/mpeg",
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
  }
@@ -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 = path.join(runtimePaths.logsDirPath, getLogFileName(mode));
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.18.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": {