@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.
- package/.env.example +22 -1
- package/README.md +35 -6
- package/dist/app/start-bot-app.js +4 -0
- package/dist/attach/manager.js +66 -0
- package/dist/attach/service.js +171 -0
- package/dist/bot/commands/abort.js +0 -4
- package/dist/bot/commands/commands.js +13 -3
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/mcps.js +394 -0
- package/dist/bot/commands/new.js +10 -18
- package/dist/bot/commands/opencode-start.js +2 -10
- package/dist/bot/commands/sessions.js +19 -20
- package/dist/bot/commands/start.js +2 -0
- package/dist/bot/handlers/prompt.js +26 -31
- package/dist/bot/index.js +82 -10
- package/dist/bot/middleware/interaction-guard.js +1 -1
- package/dist/bot/utils/busy-guard.js +3 -2
- package/dist/bot/utils/external-user-input.js +46 -0
- package/dist/bot/utils/switch-project.js +2 -0
- package/dist/config.js +25 -6
- package/dist/external-input/suppression.js +54 -0
- package/dist/i18n/de.js +34 -0
- package/dist/i18n/en.js +34 -0
- package/dist/i18n/es.js +34 -0
- package/dist/i18n/fr.js +34 -0
- package/dist/i18n/ru.js +34 -0
- package/dist/i18n/zh.js +34 -0
- package/dist/interaction/guard.js +2 -1
- package/dist/opencode/auto-restart.js +92 -0
- package/dist/opencode/events.js +13 -1
- package/dist/opencode/process.js +18 -1
- package/dist/pinned/manager.js +39 -0
- package/dist/summary/aggregator.js +89 -9
- 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/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.
|
|
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(
|
|
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:
|
|
37
|
+
title: toolTitle,
|
|
17
38
|
metadata: {},
|
|
18
39
|
time: { start: subagent.updatedAt },
|
|
19
40
|
},
|
|
20
41
|
input: subagent.currentToolInput,
|
|
21
|
-
title:
|
|
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,
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grinev/opencode-telegram-bot",
|
|
3
|
-
"version": "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": {
|