@grinev/opencode-telegram-bot 0.21.2 → 0.22.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.
Files changed (37) hide show
  1. package/.env.example +0 -15
  2. package/README.md +15 -8
  3. package/dist/app/formatters/summary-formatter.js +16 -0
  4. package/dist/app/managers/summary-aggregation-manager.js +174 -10
  5. package/dist/app/services/file-download-service.js +51 -2
  6. package/dist/app/services/scheduled-task-executor-service.js +22 -7
  7. package/dist/app/stores/settings-store.js +46 -4
  8. package/dist/bot/callbacks/callback-router.js +4 -1
  9. package/dist/bot/callbacks/scheduled-task-callback-handler.js +20 -1
  10. package/dist/bot/callbacks/settings-callback-handler.js +105 -0
  11. package/dist/bot/commands/definitions.js +1 -1
  12. package/dist/bot/commands/settings-command.js +10 -0
  13. package/dist/bot/commands/status-command.js +7 -2
  14. package/dist/bot/handlers/document-handler.js +1 -1
  15. package/dist/bot/handlers/media-group-handler.js +1 -1
  16. package/dist/bot/handlers/prompt.js +3 -3
  17. package/dist/bot/handlers/voice-handler.js +5 -2
  18. package/dist/bot/menus/inline-menu.js +3 -1
  19. package/dist/bot/menus/settings-menu.js +52 -0
  20. package/dist/bot/messages/thinking-message.js +1 -4
  21. package/dist/bot/messages/thinking-rendering.js +66 -0
  22. package/dist/bot/routers/command-router.js +2 -2
  23. package/dist/bot/services/event-subscription-service.js +304 -30
  24. package/dist/bot/streaming/compact-progress-streamer.js +168 -0
  25. package/dist/bot/streaming/finalize-assistant-response.js +9 -3
  26. package/dist/bot/streaming/response-streamer.js +16 -1
  27. package/dist/config.js +0 -15
  28. package/dist/i18n/ar.js +31 -6
  29. package/dist/i18n/de.js +32 -7
  30. package/dist/i18n/en.js +32 -7
  31. package/dist/i18n/es.js +32 -7
  32. package/dist/i18n/fr.js +32 -7
  33. package/dist/i18n/ru.js +32 -7
  34. package/dist/i18n/zh.js +32 -7
  35. package/dist/opencode/process.js +40 -3
  36. package/package.json +1 -1
  37. package/dist/bot/commands/tts-command.js +0 -13
@@ -0,0 +1,105 @@
1
+ import { isTtsConfigured } from "../../app/services/tts-service.js";
2
+ import { getCompactOutputMode, getResponseStreamingMode, getSendDiffFileAttachments, getShowAssistantRunFooter, getShowThinkingContent, getTtsMode, setCompactOutputMode, setResponseStreamingMode, setSendDiffFileAttachments, setShowAssistantRunFooter, setShowThinkingContent, setTtsMode, } from "../../app/stores/settings-store.js";
3
+ import { t } from "../../i18n/index.js";
4
+ import { logger } from "../../utils/logger.js";
5
+ import { appendInlineMenuCancelButton, ensureActiveInlineMenu } from "../menus/inline-menu.js";
6
+ import { buildSettingsMenuView, SETTINGS_ASSISTANT_FOOTER_CALLBACK, SETTINGS_CALLBACK_PREFIX, SETTINGS_COMPACT_OUTPUT_CALLBACK, SETTINGS_DIFF_FILES_CALLBACK, SETTINGS_RESPONSE_STREAMING_CALLBACK, SETTINGS_THINKING_CONTENT_CALLBACK, SETTINGS_TTS_CALLBACK, } from "../menus/settings-menu.js";
7
+ function getTtsSavedMessageKey(mode) {
8
+ if (mode === "all") {
9
+ return "tts.all";
10
+ }
11
+ if (mode === "auto") {
12
+ return "tts.auto";
13
+ }
14
+ return "tts.off";
15
+ }
16
+ function getNextTtsMode(mode) {
17
+ if (mode === "off") {
18
+ return "all";
19
+ }
20
+ if (mode === "all") {
21
+ return "auto";
22
+ }
23
+ return "off";
24
+ }
25
+ function getNextResponseStreamingMode(mode) {
26
+ return mode === "edit" ? "draft" : "edit";
27
+ }
28
+ export async function handleSettingsCallback(ctx) {
29
+ const callbackData = ctx.callbackQuery?.data;
30
+ if (!callbackData?.startsWith(SETTINGS_CALLBACK_PREFIX)) {
31
+ return false;
32
+ }
33
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "settings");
34
+ if (!isActiveMenu) {
35
+ return true;
36
+ }
37
+ try {
38
+ if (callbackData === SETTINGS_COMPACT_OUTPUT_CALLBACK) {
39
+ setCompactOutputMode(!getCompactOutputMode());
40
+ const { text, keyboard } = buildSettingsMenuView();
41
+ await ctx.answerCallbackQuery({ text: t("settings.saved") });
42
+ await ctx.editMessageText(text, {
43
+ reply_markup: appendInlineMenuCancelButton(keyboard, "settings"),
44
+ });
45
+ return true;
46
+ }
47
+ if (callbackData === SETTINGS_THINKING_CONTENT_CALLBACK) {
48
+ setShowThinkingContent(!getShowThinkingContent());
49
+ const { text, keyboard } = buildSettingsMenuView();
50
+ await ctx.answerCallbackQuery({ text: t("settings.saved") });
51
+ await ctx.editMessageText(text, {
52
+ reply_markup: appendInlineMenuCancelButton(keyboard, "settings"),
53
+ });
54
+ return true;
55
+ }
56
+ if (callbackData === SETTINGS_RESPONSE_STREAMING_CALLBACK) {
57
+ setResponseStreamingMode(getNextResponseStreamingMode(getResponseStreamingMode()));
58
+ const { text, keyboard } = buildSettingsMenuView();
59
+ await ctx.answerCallbackQuery({ text: t("settings.saved") });
60
+ await ctx.editMessageText(text, {
61
+ reply_markup: appendInlineMenuCancelButton(keyboard, "settings"),
62
+ });
63
+ return true;
64
+ }
65
+ if (callbackData === SETTINGS_DIFF_FILES_CALLBACK) {
66
+ setSendDiffFileAttachments(!getSendDiffFileAttachments());
67
+ const { text, keyboard } = buildSettingsMenuView();
68
+ await ctx.answerCallbackQuery({ text: t("settings.saved") });
69
+ await ctx.editMessageText(text, {
70
+ reply_markup: appendInlineMenuCancelButton(keyboard, "settings"),
71
+ });
72
+ return true;
73
+ }
74
+ if (callbackData === SETTINGS_ASSISTANT_FOOTER_CALLBACK) {
75
+ setShowAssistantRunFooter(!getShowAssistantRunFooter());
76
+ const { text, keyboard } = buildSettingsMenuView();
77
+ await ctx.answerCallbackQuery({ text: t("settings.saved") });
78
+ await ctx.editMessageText(text, {
79
+ reply_markup: appendInlineMenuCancelButton(keyboard, "settings"),
80
+ });
81
+ return true;
82
+ }
83
+ if (callbackData === SETTINGS_TTS_CALLBACK) {
84
+ const nextMode = getNextTtsMode(getTtsMode());
85
+ if (nextMode !== "off" && !isTtsConfigured()) {
86
+ await ctx.answerCallbackQuery({ text: t("tts.not_configured"), show_alert: true });
87
+ return true;
88
+ }
89
+ setTtsMode(nextMode);
90
+ const { text, keyboard } = buildSettingsMenuView();
91
+ await ctx.answerCallbackQuery({ text: t(getTtsSavedMessageKey(nextMode)) });
92
+ await ctx.editMessageText(text, {
93
+ reply_markup: appendInlineMenuCancelButton(keyboard, "settings"),
94
+ });
95
+ return true;
96
+ }
97
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
98
+ return true;
99
+ }
100
+ catch (error) {
101
+ logger.error("[Settings] Error handling settings callback:", error);
102
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
103
+ return true;
104
+ }
105
+ }
@@ -10,7 +10,7 @@ const COMMAND_DEFINITIONS = [
10
10
  { command: "detach", descriptionKey: "cmd.description.detach" },
11
11
  { command: "sessions", descriptionKey: "cmd.description.sessions" },
12
12
  { command: "messages", descriptionKey: "cmd.description.messages" },
13
- { command: "tts", descriptionKey: "cmd.description.tts" },
13
+ { command: "settings", descriptionKey: "cmd.description.settings" },
14
14
  { command: "projects", descriptionKey: "cmd.description.projects" },
15
15
  { command: "worktree", descriptionKey: "cmd.description.worktree" },
16
16
  { command: "task", descriptionKey: "cmd.description.task" },
@@ -0,0 +1,10 @@
1
+ import { buildSettingsMenuView } from "../menus/settings-menu.js";
2
+ import { replyWithInlineMenu } from "../menus/inline-menu.js";
3
+ export async function settingsCommand(ctx) {
4
+ const { text, keyboard } = buildSettingsMenuView();
5
+ await replyWithInlineMenu(ctx, {
6
+ menuKind: "settings",
7
+ text,
8
+ keyboard,
9
+ });
10
+ }
@@ -1,7 +1,7 @@
1
1
  import { opencodeClient } from "../../opencode/client.js";
2
2
  import { getGitWorktreeContext } from "../../app/services/worktree-service.js";
3
3
  import { getCurrentSession } from "../../app/services/session-service.js";
4
- import { getCurrentProject, isTtsEnabled } from "../../app/stores/settings-store.js";
4
+ import { getCurrentProject, getTtsMode } from "../../app/stores/settings-store.js";
5
5
  import { fetchCurrentAgent } from "../../app/services/agent-selection-service.js";
6
6
  import { fetchCurrentModel } from "../../app/services/model-selection-service.js";
7
7
  import { getAgentDisplayName } from "../../app/types/agent.js";
@@ -22,8 +22,13 @@ export async function statusCommand(ctx) {
22
22
  if (data.version) {
23
23
  message += `${t("status.line.version", { version: data.version })}\n`;
24
24
  }
25
+ const ttsMode = getTtsMode();
25
26
  message += `${t("status.line.tts", {
26
- tts: isTtsEnabled() ? t("status.tts.on") : t("status.tts.off"),
27
+ tts: ttsMode === "off"
28
+ ? t("status.tts.off")
29
+ : ttsMode === "all"
30
+ ? t("status.tts.all")
31
+ : t("status.tts.auto"),
27
32
  })}\n`;
28
33
  // Add agent information
29
34
  const currentAgent = await fetchCurrentAgent();
@@ -18,7 +18,7 @@ export async function handleDocumentMessage(ctx, deps) {
18
18
  const mimeType = doc.mime_type || "";
19
19
  const filename = doc.file_name || "document";
20
20
  try {
21
- if (isTextMimeType(mimeType)) {
21
+ if (isTextMimeType(mimeType, filename)) {
22
22
  if (!isFileSizeAllowed(doc.file_size, config.files.maxFileSizeKb)) {
23
23
  logger.warn(`[Document] Text file too large: ${filename} (${doc.file_size} bytes > ${config.files.maxFileSizeKb}KB)`);
24
24
  await ctx.reply(t("bot.text_file_too_large", { maxSizeKb: String(config.files.maxFileSizeKb) }));
@@ -137,7 +137,7 @@ export class MediaGroupAttachmentHandler {
137
137
  const document = item.document;
138
138
  const mimeType = document.mime_type || "";
139
139
  const filename = document.file_name || "document";
140
- if (isTextMimeType(mimeType)) {
140
+ if (isTextMimeType(mimeType, filename)) {
141
141
  if (!isFileSizeAllowed(document.file_size, config.files.maxFileSizeKb)) {
142
142
  return { reason: "text_file_too_large" };
143
143
  }
@@ -1,7 +1,7 @@
1
1
  import { opencodeClient } from "../../opencode/client.js";
2
- import { clearSession, getCurrentSession, setCurrentSession } from "../../app/services/session-service.js";
2
+ import { clearSession, getCurrentSession, setCurrentSession, } from "../../app/services/session-service.js";
3
3
  import { ingestSessionInfoForCache } from "../../app/services/session-cache-service.js";
4
- import { getCurrentProject, isTtsEnabled } from "../../app/stores/settings-store.js";
4
+ import { getCurrentProject, getTtsMode } from "../../app/stores/settings-store.js";
5
5
  import { getStoredAgent, resolveProjectAgent } from "../../app/services/agent-selection-service.js";
6
6
  import { getStoredModel } from "../../app/services/model-selection-service.js";
7
7
  import { formatVariantForButton } from "../../app/services/variant-selection-service.js";
@@ -91,7 +91,7 @@ async function resetMismatchedSessionContext() {
91
91
  */
92
92
  export async function processUserPrompt(ctx, text, deps, fileParts = [], options = {}) {
93
93
  const { bot, ensureEventSubscription } = deps;
94
- const responseMode = options.responseMode ?? (isTtsEnabled() ? "text_and_tts" : "text_only");
94
+ const responseMode = options.responseMode ?? (getTtsMode() === "all" ? "text_and_tts" : "text_only");
95
95
  const currentProject = getCurrentProject();
96
96
  if (!currentProject) {
97
97
  await ctx.reply(t("bot.project_not_selected"));
@@ -4,7 +4,8 @@ import { URL } from "node:url";
4
4
  import { HttpsProxyAgent } from "https-proxy-agent";
5
5
  import { SocksProxyAgent } from "socks-proxy-agent";
6
6
  import { config } from "../../config.js";
7
- import { isSttConfigured, transcribeAudio } from "../../app/services/stt-service.js";
7
+ import { getTtsMode } from "../../app/stores/settings-store.js";
8
+ import { isSttConfigured, transcribeAudio, } from "../../app/services/stt-service.js";
8
9
  import { processUserPrompt } from "./prompt.js";
9
10
  import { logger } from "../../utils/logger.js";
10
11
  import { t } from "../../i18n/index.js";
@@ -169,7 +170,9 @@ export async function handleVoiceMessage(ctx, deps) {
169
170
  textForLLM = `${llmNote}\n${recognizedText}`;
170
171
  }
171
172
  // Process the recognized text as a prompt
172
- await processPrompt(ctx, textForLLM, deps);
173
+ const currentTtsMode = getTtsMode();
174
+ const responseMode = currentTtsMode === "all" || currentTtsMode === "auto" ? "text_and_tts" : "text_only";
175
+ await processPrompt(ctx, textForLLM, deps, [], { responseMode });
173
176
  }
174
177
  catch (err) {
175
178
  const errorMessage = err instanceof Error ? err.message : "unknown error";
@@ -13,6 +13,7 @@ const INLINE_MENU_KINDS = [
13
13
  "open",
14
14
  "ls",
15
15
  "worktree",
16
+ "settings",
16
17
  ];
17
18
  export function isInlineMenuKind(value) {
18
19
  return INLINE_MENU_KINDS.includes(value);
@@ -53,7 +54,8 @@ export function appendInlineMenuCancelButton(keyboard, menuKind) {
53
54
  if (keyboard.inline_keyboard.length > 0) {
54
55
  keyboard.row();
55
56
  }
56
- keyboard.text(t("inline.button.cancel"), getInlineCancelCallbackData(menuKind));
57
+ const buttonText = menuKind === "settings" ? t("inline.button.close") : t("inline.button.cancel");
58
+ keyboard.text(buttonText, getInlineCancelCallbackData(menuKind));
57
59
  return keyboard;
58
60
  }
59
61
  export async function replyWithInlineMenu(ctx, options) {
@@ -0,0 +1,52 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { getCompactOutputMode, getResponseStreamingMode, getSendDiffFileAttachments, getShowAssistantRunFooter, getShowThinkingContent, getTtsMode, } from "../../app/stores/settings-store.js";
3
+ import { t } from "../../i18n/index.js";
4
+ export const SETTINGS_CALLBACK_PREFIX = "settings:";
5
+ export const SETTINGS_COMPACT_OUTPUT_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}compact_output`;
6
+ export const SETTINGS_THINKING_CONTENT_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}thinking_content`;
7
+ export const SETTINGS_RESPONSE_STREAMING_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}response_streaming`;
8
+ export const SETTINGS_DIFF_FILES_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}diff_files`;
9
+ export const SETTINGS_ASSISTANT_FOOTER_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}assistant_footer`;
10
+ export const SETTINGS_TTS_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}tts`;
11
+ export function formatBooleanSettingValue(enabled) {
12
+ return enabled ? t("settings.value.on") : t("settings.value.off");
13
+ }
14
+ export function formatTtsModeValue(mode) {
15
+ if (mode === "all") {
16
+ return t("status.tts.all");
17
+ }
18
+ if (mode === "auto") {
19
+ return t("status.tts.auto");
20
+ }
21
+ return t("status.tts.off");
22
+ }
23
+ export function formatResponseStreamingModeValue(mode) {
24
+ return mode === "draft"
25
+ ? t("settings.response_streaming.draft")
26
+ : t("settings.response_streaming.edit");
27
+ }
28
+ export function buildSettingsMenuView() {
29
+ const compactOutputMode = getCompactOutputMode();
30
+ const showThinkingContent = getShowThinkingContent();
31
+ const responseStreamingMode = getResponseStreamingMode();
32
+ const sendDiffFileAttachments = getSendDiffFileAttachments();
33
+ const showAssistantRunFooter = getShowAssistantRunFooter();
34
+ const ttsMode = getTtsMode();
35
+ const keyboard = new InlineKeyboard()
36
+ .text(`${t("settings.compact_output.label")}: ${formatBooleanSettingValue(compactOutputMode)}`, SETTINGS_COMPACT_OUTPUT_CALLBACK);
37
+ if (!compactOutputMode) {
38
+ keyboard.row().text(`${t("settings.thinking_content.label")}: ${formatBooleanSettingValue(showThinkingContent)}`, SETTINGS_THINKING_CONTENT_CALLBACK);
39
+ keyboard.row().text(`${t("settings.diff_files.label")}: ${formatBooleanSettingValue(sendDiffFileAttachments)}`, SETTINGS_DIFF_FILES_CALLBACK);
40
+ }
41
+ keyboard
42
+ .row()
43
+ .text(`${t("settings.response_streaming.label")}: ${formatResponseStreamingModeValue(responseStreamingMode)}`, SETTINGS_RESPONSE_STREAMING_CALLBACK)
44
+ .row()
45
+ .text(`${t("settings.assistant_footer.label")}: ${formatBooleanSettingValue(showAssistantRunFooter)}`, SETTINGS_ASSISTANT_FOOTER_CALLBACK)
46
+ .row()
47
+ .text(`${t("settings.tts.label")}: ${formatTtsModeValue(ttsMode)}`, SETTINGS_TTS_CALLBACK);
48
+ return {
49
+ text: t("settings.menu.title"),
50
+ keyboard,
51
+ };
52
+ }
@@ -1,8 +1,5 @@
1
1
  import { t } from "../../i18n/index.js";
2
- export function deliverThinkingMessage(sessionId, batcher, options) {
3
- if (options.hideThinkingMessages) {
4
- return;
5
- }
2
+ export function deliverThinkingMessage(sessionId, batcher) {
6
3
  const message = t("bot.thinking");
7
4
  batcher.sendTextNow(sessionId, message, "thinking_started");
8
5
  }
@@ -0,0 +1,66 @@
1
+ import { t } from "../../i18n/index.js";
2
+ function formatHeader(title) {
3
+ const fallback = t("bot.thinking");
4
+ const normalizedTitle = title?.trim();
5
+ return normalizedTitle ? `${fallback} — ${normalizedTitle}` : fallback;
6
+ }
7
+ function quoteFallbackText(text) {
8
+ return text
9
+ .split("\n")
10
+ .map((line) => `> ${line}`)
11
+ .join("\n");
12
+ }
13
+ function splitText(text, maxLength) {
14
+ if (text.length <= maxLength) {
15
+ return [text];
16
+ }
17
+ const chunks = [];
18
+ for (let offset = 0; offset < text.length; offset += maxLength) {
19
+ chunks.push(text.slice(offset, offset + maxLength));
20
+ }
21
+ return chunks;
22
+ }
23
+ function createThinkingPart(header, text, expandable) {
24
+ if (!text) {
25
+ return {
26
+ text: header,
27
+ fallbackText: header,
28
+ source: "entities",
29
+ };
30
+ }
31
+ const renderedText = `${header}\n${text}`;
32
+ const entity = {
33
+ type: expandable ? "expandable_blockquote" : "blockquote",
34
+ offset: header.length + 1,
35
+ length: text.length,
36
+ };
37
+ return {
38
+ text: renderedText,
39
+ entities: [entity],
40
+ fallbackText: `${header}\n${quoteFallbackText(text)}`,
41
+ source: "entities",
42
+ };
43
+ }
44
+ export function prepareThinkingStreamingPayload(sections, maxPartLength, options = {}) {
45
+ const parts = [];
46
+ const expandable = options.expandable ?? true;
47
+ for (const section of sections) {
48
+ const header = formatHeader(section.title);
49
+ const text = section.text.replace(/\r\n/g, "\n").trimEnd();
50
+ const textLimit = Math.max(1, maxPartLength - header.length - 1);
51
+ const chunks = text ? splitText(text, textLimit) : [""];
52
+ for (const chunk of chunks) {
53
+ parts.push(createThinkingPart(header, chunk, expandable));
54
+ }
55
+ }
56
+ return parts.length > 0 ? { parts } : null;
57
+ }
58
+ export function makeThinkingPayloadExpandable(payload) {
59
+ return {
60
+ ...payload,
61
+ parts: payload.parts.map((part) => ({
62
+ ...part,
63
+ entities: part.entities?.map((entity) => entity.type === "blockquote" ? { ...entity, type: "expandable_blockquote" } : entity),
64
+ })),
65
+ };
66
+ }
@@ -1,5 +1,5 @@
1
1
  import { config } from "../../config.js";
2
- import { ttsCommand } from "../commands/tts-command.js";
2
+ import { settingsCommand } from "../commands/settings-command.js";
3
3
  import { opencodeStartCommand } from "../commands/opencode-start-command.js";
4
4
  import { opencodeStopCommand } from "../commands/opencode-stop-command.js";
5
5
  import { projectsCommand } from "../commands/projects-command.js";
@@ -52,7 +52,7 @@ export function registerCommandRouter(bot, deps) {
52
52
  bot.command("start", startCommand);
53
53
  bot.command("help", helpCommand);
54
54
  bot.command("status", statusCommand);
55
- bot.command("tts", ttsCommand);
55
+ bot.command("settings", settingsCommand);
56
56
  bot.command("opencode_start", opencodeStartCommand);
57
57
  bot.command("opencode_stop", opencodeStopCommand);
58
58
  bot.command("projects", projectsCommand);