@grinev/opencode-telegram-bot 0.17.0 → 0.19.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.
Files changed (39) hide show
  1. package/.env.example +22 -1
  2. package/README.md +35 -6
  3. package/dist/app/start-bot-app.js +4 -0
  4. package/dist/attach/manager.js +66 -0
  5. package/dist/attach/service.js +171 -0
  6. package/dist/bot/commands/abort.js +0 -4
  7. package/dist/bot/commands/commands.js +13 -3
  8. package/dist/bot/commands/definitions.js +1 -0
  9. package/dist/bot/commands/mcps.js +394 -0
  10. package/dist/bot/commands/new.js +10 -18
  11. package/dist/bot/commands/opencode-start.js +2 -10
  12. package/dist/bot/commands/sessions.js +19 -20
  13. package/dist/bot/commands/start.js +2 -0
  14. package/dist/bot/handlers/prompt.js +26 -31
  15. package/dist/bot/index.js +82 -10
  16. package/dist/bot/middleware/interaction-guard.js +1 -1
  17. package/dist/bot/utils/busy-guard.js +3 -2
  18. package/dist/bot/utils/external-user-input.js +46 -0
  19. package/dist/bot/utils/switch-project.js +2 -0
  20. package/dist/config.js +25 -6
  21. package/dist/external-input/suppression.js +54 -0
  22. package/dist/i18n/de.js +34 -0
  23. package/dist/i18n/en.js +34 -0
  24. package/dist/i18n/es.js +34 -0
  25. package/dist/i18n/fr.js +34 -0
  26. package/dist/i18n/ru.js +34 -0
  27. package/dist/i18n/zh.js +34 -0
  28. package/dist/interaction/guard.js +2 -1
  29. package/dist/opencode/auto-restart.js +92 -0
  30. package/dist/opencode/events.js +13 -1
  31. package/dist/opencode/process.js +18 -1
  32. package/dist/pinned/manager.js +39 -0
  33. package/dist/summary/aggregator.js +89 -9
  34. package/dist/summary/formatter.js +2 -2
  35. package/dist/summary/markdown-to-telegram-v2.js +99 -0
  36. package/dist/summary/subagent-formatter.js +23 -2
  37. package/dist/telegram/render/inline-renderer.js +18 -0
  38. package/dist/tts/client.js +94 -19
  39. package/package.json +2 -2
@@ -24,6 +24,17 @@ function countDiffChangesFromText(text) {
24
24
  }
25
25
  return { additions, deletions };
26
26
  }
27
+ function normalizeSnapshotValue(value) {
28
+ if (Array.isArray(value)) {
29
+ return value.map((item) => normalizeSnapshotValue(item));
30
+ }
31
+ if (value && typeof value === "object") {
32
+ return Object.fromEntries(Object.entries(value)
33
+ .sort(([left], [right]) => left.localeCompare(right))
34
+ .map(([key, entryValue]) => [key, normalizeSnapshotValue(entryValue)]));
35
+ }
36
+ return value;
37
+ }
27
38
  class SummaryAggregator {
28
39
  currentSessionId = null;
29
40
  textMessageStates = new Map();
@@ -32,6 +43,7 @@ class SummaryAggregator {
32
43
  lastUpdated = 0;
33
44
  onCompleteCallback = null;
34
45
  onPartialCallback = null;
46
+ onExternalUserInputCallback = null;
35
47
  onToolCallback = null;
36
48
  onToolFileCallback = null;
37
49
  onQuestionCallback = null;
@@ -50,6 +62,7 @@ class SummaryAggregator {
50
62
  onClearedCallback = null;
51
63
  processedToolStates = new Set();
52
64
  thinkingFiredForMessages = new Set();
65
+ deliveredExternalUserMessageIds = new Set();
53
66
  knownTextPartIds = new Map();
54
67
  bot = null;
55
68
  chatId = null;
@@ -63,6 +76,7 @@ class SummaryAggregator {
63
76
  pendingSubagentCardIdsByParent = new Map();
64
77
  pendingChildSessionIdsByParent = new Map();
65
78
  fallbackSubagentCardIdsByParent = new Map();
79
+ lastSubagentSnapshot = "";
66
80
  setBotAndChatId(bot, chatId) {
67
81
  this.bot = bot;
68
82
  this.chatId = chatId;
@@ -73,6 +87,9 @@ class SummaryAggregator {
73
87
  setOnPartial(callback) {
74
88
  this.onPartialCallback = callback;
75
89
  }
90
+ setOnExternalUserInput(callback) {
91
+ this.onExternalUserInputCallback = callback;
92
+ }
76
93
  setOnTool(callback) {
77
94
  this.onToolCallback = callback;
78
95
  }
@@ -226,6 +243,7 @@ class SummaryAggregator {
226
243
  this.knownTextPartIds.clear();
227
244
  this.processedToolStates.clear();
228
245
  this.thinkingFiredForMessages.clear();
246
+ this.deliveredExternalUserMessageIds.clear();
229
247
  this.trackedSessionParents.clear();
230
248
  this.subagentStates.clear();
231
249
  this.subagentOrder = [];
@@ -233,6 +251,7 @@ class SummaryAggregator {
233
251
  this.pendingSubagentCardIdsByParent.clear();
234
252
  this.pendingChildSessionIdsByParent.clear();
235
253
  this.fallbackSubagentCardIdsByParent.clear();
254
+ this.lastSubagentSnapshot = "";
236
255
  this.messageCount = 0;
237
256
  this.lastUpdated = 0;
238
257
  if (this.onClearedCallback) {
@@ -306,6 +325,28 @@ class SummaryAggregator {
306
325
  terminalMessage: state.terminalMessage,
307
326
  updatedAt: state.updatedAt,
308
327
  }));
328
+ const snapshot = JSON.stringify(subagents.map((subagent) => ({
329
+ cardId: subagent.cardId,
330
+ sessionId: subagent.sessionId,
331
+ parentSessionId: subagent.parentSessionId,
332
+ agent: subagent.agent,
333
+ description: subagent.description,
334
+ prompt: subagent.prompt,
335
+ command: subagent.command,
336
+ status: subagent.status,
337
+ providerID: subagent.providerID,
338
+ modelID: subagent.modelID,
339
+ tokens: subagent.tokens,
340
+ cost: subagent.cost,
341
+ currentTool: subagent.currentTool,
342
+ currentToolInput: normalizeSnapshotValue(subagent.currentToolInput),
343
+ currentToolTitle: subagent.currentToolTitle,
344
+ terminalMessage: subagent.terminalMessage,
345
+ })));
346
+ if (snapshot === this.lastSubagentSnapshot) {
347
+ return;
348
+ }
349
+ this.lastSubagentSnapshot = snapshot;
309
350
  this.onSubagentCallback(this.currentSessionId, subagents);
310
351
  }
311
352
  createSubagentState(parentSessionId, sessionId, cardId = `subagent-${parentSessionId}-${Date.now()}-${this.subagentOrder.length}`) {
@@ -605,6 +646,10 @@ class SummaryAggregator {
605
646
  }
606
647
  const messageID = info.id;
607
648
  this.messages.set(messageID, { role: info.role });
649
+ if (info.role === "user") {
650
+ this.emitExternalUserInputIfReady(info.sessionID, messageID);
651
+ return;
652
+ }
608
653
  if (info.role === "assistant") {
609
654
  if (!this.textMessageStates.has(messageID)) {
610
655
  this.textMessageStates.set(messageID, {
@@ -655,15 +700,8 @@ class SummaryAggregator {
655
700
  completedAt: time?.completed,
656
701
  });
657
702
  }
658
- this.textMessageStates.delete(messageID);
659
- this.messages.delete(messageID);
660
- this.partHashes.delete(messageID);
661
- this.knownTextPartIds.delete(messageID);
703
+ this.cleanupCompletedMessage(messageID);
662
704
  logger.debug(`[Aggregator] Message completed cleanup: remaining messages=${this.textMessageStates.size}`);
663
- if (this.textMessageStates.size === 0) {
664
- logger.debug("[Aggregator] No more active messages, stopping typing indicator");
665
- this.stopTypingIndicator();
666
- }
667
705
  }
668
706
  this.lastUpdated = Date.now();
669
707
  }
@@ -741,6 +779,9 @@ class SummaryAggregator {
741
779
  this.startTypingIndicator();
742
780
  this.emitPartialText(part.sessionID, messageID, fullText);
743
781
  }
782
+ else if (messageInfo && messageInfo.role === "user") {
783
+ this.emitExternalUserInputIfReady(part.sessionID, messageID);
784
+ }
744
785
  else {
745
786
  const state = this.getOrCreateTextMessageState(messageID);
746
787
  state.optimisticUpdateCount++;
@@ -857,9 +898,48 @@ class SummaryAggregator {
857
898
  if (!combined.trim()) {
858
899
  return;
859
900
  }
901
+ const messageInfo = this.messages.get(messageID);
902
+ if (messageInfo?.role === "user") {
903
+ this.emitExternalUserInputIfReady(sessionID, messageID);
904
+ return;
905
+ }
860
906
  this.startTypingIndicator();
861
907
  this.emitPartialText(sessionID, messageID, combined);
862
908
  }
909
+ emitExternalUserInputIfReady(sessionId, messageId) {
910
+ if (sessionId !== this.currentSessionId || this.deliveredExternalUserMessageIds.has(messageId)) {
911
+ return;
912
+ }
913
+ const messageInfo = this.messages.get(messageId);
914
+ if (!messageInfo || messageInfo.role !== "user") {
915
+ return;
916
+ }
917
+ const messageText = this.getCombinedMessageText(messageId).trim();
918
+ if (!messageText) {
919
+ return;
920
+ }
921
+ this.deliveredExternalUserMessageIds.add(messageId);
922
+ this.cleanupCompletedMessage(messageId);
923
+ if (!this.onExternalUserInputCallback) {
924
+ return;
925
+ }
926
+ const callback = this.onExternalUserInputCallback;
927
+ setImmediate(() => {
928
+ Promise.resolve(callback(sessionId, messageId, messageText)).catch((err) => {
929
+ logger.error("[Aggregator] Error in external user input callback:", err);
930
+ });
931
+ });
932
+ }
933
+ cleanupCompletedMessage(messageId) {
934
+ this.textMessageStates.delete(messageId);
935
+ this.messages.delete(messageId);
936
+ this.partHashes.delete(messageId);
937
+ this.knownTextPartIds.delete(messageId);
938
+ if (this.textMessageStates.size === 0) {
939
+ logger.debug("[Aggregator] No more active messages, stopping typing indicator");
940
+ this.stopTypingIndicator();
941
+ }
942
+ }
863
943
  emitPartialText(sessionId, messageId, messageText) {
864
944
  if (!this.onPartialCallback || !messageText.trim()) {
865
945
  return;
@@ -1103,7 +1183,7 @@ class SummaryAggregator {
1103
1183
  const callback = this.onQuestionCallback;
1104
1184
  setImmediate(async () => {
1105
1185
  try {
1106
- await callback(questions, id);
1186
+ await callback(questions, id, sessionID);
1107
1187
  }
1108
1188
  catch (err) {
1109
1189
  logger.error("[Aggregator] Error in question callback:", err);
@@ -1,9 +1,9 @@
1
1
  import * as path from "path";
2
- import { convert } from "telegram-markdown-v2";
3
2
  import { config } from "../config.js";
4
3
  import { logger } from "../utils/logger.js";
5
4
  import { t } from "../i18n/index.js";
6
5
  import { getCurrentProject } from "../settings/manager.js";
6
+ import { convertToTelegramMarkdownV2 } from "./markdown-to-telegram-v2.js";
7
7
  import { normalizeMarkdownForTelegramRendering } from "../telegram/render/markdown-normalizer.js";
8
8
  const TELEGRAM_MESSAGE_LIMIT = 4096;
9
9
  const MARKDOWN_V2_RESERVED_CHARS = /([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
@@ -90,7 +90,7 @@ export function escapePlainTextForTelegramMarkdownV2(text) {
90
90
  function formatMarkdownForTelegram(text) {
91
91
  try {
92
92
  const preprocessed = normalizeMarkdownForTelegramRendering(text);
93
- return escapeMarkdownV2PipesOutsideCode(convert(preprocessed, "keep"));
93
+ return escapeMarkdownV2PipesOutsideCode(convertToTelegramMarkdownV2(preprocessed));
94
94
  }
95
95
  catch (error) {
96
96
  logger.warn("[Formatter] Failed to convert markdown summary, falling back to raw text", error);
@@ -0,0 +1,99 @@
1
+ import { parseTelegramBlocks } from "../telegram/render/block-parser.js";
2
+ import { normalizeMarkdownForTelegramRendering } from "../telegram/render/markdown-normalizer.js";
3
+ /**
4
+ * Escapes characters reserved in Telegram MarkdownV2.
5
+ * These must be escaped everywhere except when used as syntactic delimiters
6
+ * we intentionally emit (e.g. the surrounding * of bold).
7
+ */
8
+ function escapeMarkdownV2(text) {
9
+ return text.replace(/([_*[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
10
+ }
11
+ function escapeMarkdownV2Code(text) {
12
+ return text.replace(/([`\\])/g, "\\$1");
13
+ }
14
+ function escapeMarkdownV2LinkUrl(text) {
15
+ return text.replace(/([)\\])/g, "\\$1");
16
+ }
17
+ function renderInlineNodes(nodes) {
18
+ return nodes
19
+ .map((node) => {
20
+ switch (node.type) {
21
+ case "text":
22
+ return escapeMarkdownV2(node.text);
23
+ case "bold":
24
+ return `*${renderInlineNodes(node.children)}*`;
25
+ case "italic":
26
+ return `_${renderInlineNodes(node.children)}_`;
27
+ case "strike":
28
+ return `~${renderInlineNodes(node.children)}~`;
29
+ case "code":
30
+ return `\`${escapeMarkdownV2Code(node.text)}\``;
31
+ case "link":
32
+ return `[${renderInlineNodes(node.text)}](${escapeMarkdownV2LinkUrl(node.url)})`;
33
+ case "underline":
34
+ return `__${renderInlineNodes(node.children)}__`;
35
+ case "spoiler":
36
+ return `||${renderInlineNodes(node.children)}||`;
37
+ default:
38
+ return escapeMarkdownV2(String(node));
39
+ }
40
+ })
41
+ .join("");
42
+ }
43
+ function renderBlock(block) {
44
+ switch (block.type) {
45
+ case "paragraph":
46
+ return renderInlineNodes(block.inlines);
47
+ case "heading": {
48
+ const content = renderInlineNodes(block.inlines);
49
+ return `*${content}*`;
50
+ }
51
+ case "blockquote":
52
+ return block.lines
53
+ .map((line) => renderInlineNodes(line)
54
+ .split("\n")
55
+ .map((part) => `> ${part}`)
56
+ .join("\n"))
57
+ .join("\n");
58
+ case "list":
59
+ return block.items
60
+ .map((item, index) => {
61
+ const prefix = block.ordered ? `${index + 1}\\. ` : "\\- ";
62
+ const body = renderInlineNodes(item);
63
+ return `${prefix}${body}`;
64
+ })
65
+ .join("\n");
66
+ case "code":
67
+ return `\`\`\`${escapeMarkdownV2Code(block.language ?? "")}\n${escapeMarkdownV2Code(block.text)}\n\`\`\``;
68
+ case "table": {
69
+ if (block.rows.length === 0) {
70
+ return "";
71
+ }
72
+ const header = `\\| ${block.rows[0].map(escapeMarkdownV2).join(" \\| ")} \\|`;
73
+ const separator = `\\| ${block.rows[0].map(() => "\\-\\-\\-").join(" \\| ")} \\|`;
74
+ const body = block.rows
75
+ .slice(1)
76
+ .map((row) => `\\| ${row.map(escapeMarkdownV2).join(" \\| ")} \\|`);
77
+ return [header, separator, ...body].join("\n");
78
+ }
79
+ case "rule":
80
+ return "---";
81
+ case "plain":
82
+ return escapeMarkdownV2(block.text);
83
+ }
84
+ }
85
+ /**
86
+ * Converts standard Markdown into Telegram MarkdownV2.
87
+ *
88
+ * Uses the project's existing block parser (unified/remark) so that
89
+ * headings, lists, tables, code blocks, etc. are handled consistently
90
+ * with the entity-based Telegram renderer.
91
+ */
92
+ export function convertToTelegramMarkdownV2(markdown) {
93
+ const normalized = normalizeMarkdownForTelegramRendering(markdown);
94
+ const blocks = parseTelegramBlocks(normalized);
95
+ if (blocks.length === 0) {
96
+ return "";
97
+ }
98
+ return blocks.map(renderBlock).join("\n\n");
99
+ }
@@ -1,10 +1,31 @@
1
1
  import { formatModelDisplayName } from "../pinned/format.js";
2
2
  import { t } from "../i18n/index.js";
3
3
  import { formatCompactToolInfo } from "./formatter.js";
4
+ function shouldPreferInputDetails(tool, input) {
5
+ if (!input) {
6
+ return false;
7
+ }
8
+ switch (tool) {
9
+ case "read":
10
+ case "edit":
11
+ case "write":
12
+ case "apply_patch":
13
+ return typeof input.path === "string" || typeof input.filePath === "string";
14
+ case "bash":
15
+ return typeof input.command === "string";
16
+ case "grep":
17
+ case "glob":
18
+ return typeof input.pattern === "string";
19
+ }
20
+ return ["query", "url", "name", "prompt", "text"].some((field) => typeof input[field] === "string");
21
+ }
4
22
  function formatToolStep(subagent) {
5
23
  if (!subagent.currentTool) {
6
24
  return "";
7
25
  }
26
+ const toolTitle = shouldPreferInputDetails(subagent.currentTool, subagent.currentToolInput)
27
+ ? undefined
28
+ : subagent.currentToolTitle;
8
29
  const toolInfo = {
9
30
  sessionId: subagent.sessionId ?? subagent.parentSessionId,
10
31
  messageId: subagent.cardId,
@@ -13,12 +34,12 @@ function formatToolStep(subagent) {
13
34
  state: {
14
35
  status: "running",
15
36
  input: subagent.currentToolInput ?? {},
16
- title: subagent.currentToolTitle,
37
+ title: toolTitle,
17
38
  metadata: {},
18
39
  time: { start: subagent.updatedAt },
19
40
  },
20
41
  input: subagent.currentToolInput,
21
- title: subagent.currentToolTitle,
42
+ title: toolTitle,
22
43
  metadata: {},
23
44
  hasFileAttachment: false,
24
45
  };
@@ -32,6 +32,18 @@ function pushEntity(state, entity) {
32
32
  }
33
33
  state.entities.push(entity);
34
34
  }
35
+ function isTelegramTextLinkUrl(url) {
36
+ try {
37
+ const parsed = new URL(url);
38
+ return ["http:", "https:", "tg:", "mailto:"].includes(parsed.protocol);
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ function isLocalReferenceUrl(url) {
45
+ return url.startsWith("#") || url.startsWith("/") || url.startsWith("./") || url.startsWith("../");
46
+ }
35
47
  function renderIntoState(state, nodes) {
36
48
  for (const node of nodes) {
37
49
  switch (node.type) {
@@ -81,6 +93,12 @@ function renderIntoState(state, nodes) {
81
93
  case "link": {
82
94
  const offset = state.text.length;
83
95
  renderIntoState(state, node.text);
96
+ if (!isTelegramTextLinkUrl(node.url)) {
97
+ if (isLocalReferenceUrl(node.url)) {
98
+ appendText(state, ` (${node.url})`);
99
+ break;
100
+ }
101
+ }
84
102
  pushEntity(state, {
85
103
  type: "text_link",
86
104
  offset,
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.17.0",
3
+ "version": "0.19.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",
@@ -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": {