opencode-telegram-group-topics-bot 0.11.2

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 (101) hide show
  1. package/.env.example +74 -0
  2. package/LICENSE +21 -0
  3. package/README.md +305 -0
  4. package/dist/agent/manager.js +60 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +47 -0
  7. package/dist/bot/commands/abort.js +116 -0
  8. package/dist/bot/commands/commands.js +389 -0
  9. package/dist/bot/commands/constants.js +20 -0
  10. package/dist/bot/commands/definitions.js +25 -0
  11. package/dist/bot/commands/help.js +27 -0
  12. package/dist/bot/commands/models.js +38 -0
  13. package/dist/bot/commands/new.js +247 -0
  14. package/dist/bot/commands/opencode-start.js +85 -0
  15. package/dist/bot/commands/opencode-stop.js +44 -0
  16. package/dist/bot/commands/projects.js +304 -0
  17. package/dist/bot/commands/rename.js +173 -0
  18. package/dist/bot/commands/sessions.js +491 -0
  19. package/dist/bot/commands/start.js +67 -0
  20. package/dist/bot/commands/status.js +138 -0
  21. package/dist/bot/constants.js +49 -0
  22. package/dist/bot/handlers/agent.js +127 -0
  23. package/dist/bot/handlers/context.js +125 -0
  24. package/dist/bot/handlers/document.js +65 -0
  25. package/dist/bot/handlers/inline-menu.js +124 -0
  26. package/dist/bot/handlers/model.js +152 -0
  27. package/dist/bot/handlers/permission.js +281 -0
  28. package/dist/bot/handlers/prompt.js +263 -0
  29. package/dist/bot/handlers/question.js +285 -0
  30. package/dist/bot/handlers/variant.js +147 -0
  31. package/dist/bot/handlers/voice.js +173 -0
  32. package/dist/bot/index.js +945 -0
  33. package/dist/bot/message-patterns.js +4 -0
  34. package/dist/bot/middleware/auth.js +30 -0
  35. package/dist/bot/middleware/interaction-guard.js +80 -0
  36. package/dist/bot/middleware/unknown-command.js +22 -0
  37. package/dist/bot/scope.js +222 -0
  38. package/dist/bot/telegram-constants.js +3 -0
  39. package/dist/bot/telegram-rate-limiter.js +263 -0
  40. package/dist/bot/utils/commands.js +21 -0
  41. package/dist/bot/utils/file-download.js +91 -0
  42. package/dist/bot/utils/keyboard.js +85 -0
  43. package/dist/bot/utils/send-with-markdown-fallback.js +57 -0
  44. package/dist/bot/utils/session-error-filter.js +34 -0
  45. package/dist/bot/utils/topic-link.js +29 -0
  46. package/dist/cli/args.js +98 -0
  47. package/dist/cli.js +80 -0
  48. package/dist/config.js +103 -0
  49. package/dist/i18n/de.js +330 -0
  50. package/dist/i18n/en.js +330 -0
  51. package/dist/i18n/es.js +330 -0
  52. package/dist/i18n/index.js +102 -0
  53. package/dist/i18n/ru.js +330 -0
  54. package/dist/i18n/zh.js +330 -0
  55. package/dist/index.js +28 -0
  56. package/dist/interaction/cleanup.js +24 -0
  57. package/dist/interaction/constants.js +25 -0
  58. package/dist/interaction/guard.js +100 -0
  59. package/dist/interaction/manager.js +113 -0
  60. package/dist/interaction/types.js +1 -0
  61. package/dist/keyboard/manager.js +115 -0
  62. package/dist/keyboard/types.js +1 -0
  63. package/dist/model/capabilities.js +62 -0
  64. package/dist/model/manager.js +257 -0
  65. package/dist/model/types.js +24 -0
  66. package/dist/opencode/client.js +13 -0
  67. package/dist/opencode/events.js +159 -0
  68. package/dist/opencode/prompt-submit-error.js +101 -0
  69. package/dist/permission/manager.js +92 -0
  70. package/dist/permission/types.js +1 -0
  71. package/dist/pinned/manager.js +405 -0
  72. package/dist/pinned/types.js +1 -0
  73. package/dist/process/manager.js +273 -0
  74. package/dist/process/types.js +1 -0
  75. package/dist/project/manager.js +88 -0
  76. package/dist/question/manager.js +186 -0
  77. package/dist/question/types.js +1 -0
  78. package/dist/rename/manager.js +64 -0
  79. package/dist/runtime/bootstrap.js +350 -0
  80. package/dist/runtime/mode.js +74 -0
  81. package/dist/runtime/paths.js +37 -0
  82. package/dist/runtime/process-error-handlers.js +24 -0
  83. package/dist/session/cache-manager.js +455 -0
  84. package/dist/session/manager.js +87 -0
  85. package/dist/settings/manager.js +283 -0
  86. package/dist/stt/client.js +64 -0
  87. package/dist/summary/aggregator.js +625 -0
  88. package/dist/summary/formatter.js +417 -0
  89. package/dist/summary/tool-message-batcher.js +277 -0
  90. package/dist/topic/colors.js +8 -0
  91. package/dist/topic/constants.js +10 -0
  92. package/dist/topic/manager.js +161 -0
  93. package/dist/topic/title-constants.js +2 -0
  94. package/dist/topic/title-format.js +10 -0
  95. package/dist/topic/title-sync.js +17 -0
  96. package/dist/utils/error-format.js +29 -0
  97. package/dist/utils/logger.js +175 -0
  98. package/dist/utils/safe-background-task.js +33 -0
  99. package/dist/variant/manager.js +103 -0
  100. package/dist/variant/types.js +1 -0
  101. package/package.json +76 -0
@@ -0,0 +1,147 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { getAvailableVariants, getCurrentVariant, setCurrentVariant, formatVariantForDisplay, formatVariantForButton, } from "../../variant/manager.js";
3
+ import { getStoredModel } from "../../model/manager.js";
4
+ import { getStoredAgent } from "../../agent/manager.js";
5
+ import { logger } from "../../utils/logger.js";
6
+ import { keyboardManager } from "../../keyboard/manager.js";
7
+ import { pinnedMessageManager } from "../../pinned/manager.js";
8
+ import { createMainKeyboard } from "../utils/keyboard.js";
9
+ import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
10
+ import { t } from "../../i18n/index.js";
11
+ import { SCOPE_CONTEXT, getScopeFromKey, getScopeKeyFromContext } from "../scope.js";
12
+ /**
13
+ * Handle variant selection callback
14
+ * @param ctx grammY context
15
+ * @returns true if handled, false otherwise
16
+ */
17
+ export async function handleVariantSelect(ctx) {
18
+ const callbackQuery = ctx.callbackQuery;
19
+ if (!callbackQuery?.data || !callbackQuery.data.startsWith("variant:")) {
20
+ return false;
21
+ }
22
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "variant");
23
+ if (!isActiveMenu) {
24
+ return true;
25
+ }
26
+ logger.debug(`[VariantHandler] Received callback: ${callbackQuery.data}`);
27
+ try {
28
+ const scopeKey = getScopeKeyFromContext(ctx);
29
+ if (ctx.chat) {
30
+ keyboardManager.initialize(ctx.api, ctx.chat.id, scopeKey);
31
+ }
32
+ if (pinnedMessageManager.getContextLimit(scopeKey) === 0) {
33
+ await pinnedMessageManager.refreshContextLimit(scopeKey);
34
+ }
35
+ // Parse callback data: "variant:variantId"
36
+ const variantId = callbackQuery.data.replace("variant:", "");
37
+ // Get current model
38
+ const currentModel = getStoredModel(scopeKey);
39
+ if (!currentModel.providerID || !currentModel.modelID) {
40
+ logger.error("[VariantHandler] No model selected");
41
+ await ctx.answerCallbackQuery({ text: t("variant.model_not_selected_callback") });
42
+ return false;
43
+ }
44
+ // Set variant
45
+ setCurrentVariant(variantId, scopeKey);
46
+ // Re-read model after variant update
47
+ const updatedModel = getStoredModel(scopeKey);
48
+ // Update keyboard manager state
49
+ keyboardManager.updateModel(updatedModel, scopeKey);
50
+ keyboardManager.updateVariant(variantId, scopeKey);
51
+ // Build keyboard with correct context info
52
+ const currentAgent = getStoredAgent(scopeKey);
53
+ const contextInfo = pinnedMessageManager.getContextInfo(scopeKey) ??
54
+ (pinnedMessageManager.getContextLimit(scopeKey) > 0
55
+ ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit(scopeKey) }
56
+ : keyboardManager.getContextInfo(scopeKey));
57
+ if (contextInfo) {
58
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit, scopeKey);
59
+ }
60
+ const variantName = formatVariantForButton(variantId);
61
+ const scope = getScopeFromKey(scopeKey);
62
+ const keyboard = createMainKeyboard(currentAgent, updatedModel, contextInfo ?? undefined, variantName, scope?.context === SCOPE_CONTEXT.GROUP_GENERAL
63
+ ? {
64
+ contextFirst: true,
65
+ contextLabel: t("keyboard.general_defaults"),
66
+ }
67
+ : undefined);
68
+ // Send confirmation message with updated keyboard
69
+ const displayName = formatVariantForDisplay(variantId);
70
+ clearActiveInlineMenu("variant_selected", scopeKey);
71
+ await ctx.answerCallbackQuery({ text: t("variant.changed_callback", { name: displayName }) });
72
+ await ctx.reply(t("variant.changed_message", { name: displayName }), {
73
+ reply_markup: keyboard,
74
+ });
75
+ // Delete the inline menu message
76
+ await ctx.deleteMessage().catch(() => { });
77
+ return true;
78
+ }
79
+ catch (err) {
80
+ clearActiveInlineMenu("variant_select_error", getScopeKeyFromContext(ctx));
81
+ logger.error("[VariantHandler] Error handling variant select:", err);
82
+ await ctx.answerCallbackQuery({ text: t("variant.change_error_callback") }).catch(() => { });
83
+ return false;
84
+ }
85
+ }
86
+ /**
87
+ * Build inline keyboard with available variants
88
+ * @param currentVariant Current variant for highlighting
89
+ * @param providerID Provider ID
90
+ * @param modelID Model ID
91
+ * @returns InlineKeyboard with variant selection buttons
92
+ */
93
+ export async function buildVariantSelectionMenu(currentVariant, providerID, modelID) {
94
+ const keyboard = new InlineKeyboard();
95
+ const variants = await getAvailableVariants(providerID, modelID);
96
+ if (variants.length === 0) {
97
+ logger.warn("[VariantHandler] No variants found");
98
+ return keyboard;
99
+ }
100
+ // Filter only active variants (not disabled)
101
+ const activeVariants = variants.filter((v) => !v.disabled);
102
+ if (activeVariants.length === 0) {
103
+ logger.warn("[VariantHandler] No active variants found");
104
+ // If no active variants, show default at least
105
+ keyboard.text(`✅ ${formatVariantForDisplay("default")}`, "variant:default").row();
106
+ return keyboard;
107
+ }
108
+ // Add button for each variant (one per row)
109
+ activeVariants.forEach((variant) => {
110
+ const isActive = variant.id === currentVariant;
111
+ const label = formatVariantForDisplay(variant.id);
112
+ const labelWithCheck = isActive ? `✅ ${label}` : label;
113
+ keyboard.text(labelWithCheck, `variant:${variant.id}`).row();
114
+ });
115
+ return keyboard;
116
+ }
117
+ /**
118
+ * Show variant selection menu
119
+ * @param ctx grammY context
120
+ */
121
+ export async function showVariantSelectionMenu(ctx) {
122
+ try {
123
+ const scopeKey = getScopeKeyFromContext(ctx);
124
+ const currentModel = getStoredModel(scopeKey);
125
+ if (!currentModel.providerID || !currentModel.modelID) {
126
+ await ctx.reply(t("variant.select_model_first"));
127
+ return;
128
+ }
129
+ const currentVariant = getCurrentVariant(scopeKey);
130
+ const keyboard = await buildVariantSelectionMenu(currentVariant, currentModel.providerID, currentModel.modelID);
131
+ if (keyboard.inline_keyboard.length === 0) {
132
+ await ctx.reply(t("variant.menu.empty"));
133
+ return;
134
+ }
135
+ const displayName = formatVariantForDisplay(currentVariant);
136
+ const text = t("variant.menu.current", { name: displayName });
137
+ await replyWithInlineMenu(ctx, {
138
+ menuKind: "variant",
139
+ text,
140
+ keyboard,
141
+ });
142
+ }
143
+ catch (err) {
144
+ logger.error("[VariantHandler] Error showing variant menu:", err);
145
+ await ctx.reply(t("variant.menu.error"));
146
+ }
147
+ }
@@ -0,0 +1,173 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ import { URL } from "node:url";
4
+ import { HttpsProxyAgent } from "https-proxy-agent";
5
+ import { SocksProxyAgent } from "socks-proxy-agent";
6
+ import { config } from "../../config.js";
7
+ import { isSttConfigured, transcribeAudio } from "../../stt/client.js";
8
+ import { processUserPrompt } from "./prompt.js";
9
+ import { logger } from "../../utils/logger.js";
10
+ import { t } from "../../i18n/index.js";
11
+ const TELEGRAM_DOWNLOAD_TIMEOUT_MS = 30_000;
12
+ const TELEGRAM_DOWNLOAD_MAX_REDIRECTS = 3;
13
+ let telegramDownloadAgent;
14
+ function getTelegramDownloadAgent() {
15
+ if (telegramDownloadAgent !== undefined) {
16
+ return telegramDownloadAgent || undefined;
17
+ }
18
+ const proxyUrl = config.telegram.proxyUrl.trim();
19
+ if (!proxyUrl) {
20
+ telegramDownloadAgent = null;
21
+ return undefined;
22
+ }
23
+ telegramDownloadAgent = proxyUrl.startsWith("socks")
24
+ ? new SocksProxyAgent(proxyUrl)
25
+ : new HttpsProxyAgent(proxyUrl);
26
+ logger.info(`[Voice] Using Telegram download proxy: ${proxyUrl.replace(/\/\/.*@/, "//***@")}`);
27
+ return telegramDownloadAgent;
28
+ }
29
+ async function downloadTelegramFileByUrl(url, redirectDepth = 0) {
30
+ return new Promise((resolve, reject) => {
31
+ const targetUrl = new URL(url);
32
+ const requestModule = targetUrl.protocol === "http:" ? http : https;
33
+ const request = requestModule.get(targetUrl, { agent: getTelegramDownloadAgent() }, (response) => {
34
+ const statusCode = response.statusCode ?? 0;
35
+ if (statusCode >= 300 && statusCode < 400 && response.headers.location) {
36
+ response.resume();
37
+ if (redirectDepth >= TELEGRAM_DOWNLOAD_MAX_REDIRECTS) {
38
+ reject(new Error("Too many redirects while downloading Telegram file"));
39
+ return;
40
+ }
41
+ const redirectUrl = new URL(response.headers.location, targetUrl).toString();
42
+ void downloadTelegramFileByUrl(redirectUrl, redirectDepth + 1)
43
+ .then(resolve)
44
+ .catch(reject);
45
+ return;
46
+ }
47
+ if (statusCode < 200 || statusCode >= 300) {
48
+ response.resume();
49
+ reject(new Error(`Telegram file download failed with HTTP ${statusCode}`));
50
+ return;
51
+ }
52
+ const chunks = [];
53
+ response.on("data", (chunk) => {
54
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
55
+ });
56
+ response.on("end", () => {
57
+ resolve(Buffer.concat(chunks));
58
+ });
59
+ response.on("error", reject);
60
+ });
61
+ request.on("error", reject);
62
+ request.setTimeout(TELEGRAM_DOWNLOAD_TIMEOUT_MS, () => {
63
+ request.destroy(new Error(`Telegram file download timed out after ${TELEGRAM_DOWNLOAD_TIMEOUT_MS}ms`));
64
+ });
65
+ });
66
+ }
67
+ /**
68
+ * Downloads the audio file from Telegram servers.
69
+ *
70
+ * @returns Buffer with file content, or null on failure
71
+ */
72
+ async function downloadTelegramFile(ctx, fileId) {
73
+ try {
74
+ const file = await ctx.api.getFile(fileId);
75
+ if (!file.file_path) {
76
+ logger.error("[Voice] Telegram getFile returned no file_path");
77
+ return null;
78
+ }
79
+ const fileUrl = `https://api.telegram.org/file/bot${ctx.api.token}/${file.file_path}`;
80
+ logger.debug(`[Voice] Downloading file: ${file.file_path} (${file.file_size ?? "?"} bytes)`);
81
+ const buffer = await downloadTelegramFileByUrl(fileUrl);
82
+ // Extract filename from file_path (e.g., "voice/file_123.oga" -> "file_123.oga")
83
+ let filename = file.file_path.split("/").pop() || "audio.ogg";
84
+ if (filename.endsWith(".oga")) {
85
+ filename = filename.slice(0, -4) + ".ogg";
86
+ }
87
+ logger.debug(`[Voice] Downloaded file: ${filename} (${buffer.length} bytes)`);
88
+ return { buffer, filename };
89
+ }
90
+ catch (err) {
91
+ logger.error("[Voice] Error downloading file from Telegram:", err);
92
+ return null;
93
+ }
94
+ }
95
+ /**
96
+ * Creates the voice message handler function.
97
+ *
98
+ * The factory pattern is used so that `bot` and `ensureEventSubscription` dependencies
99
+ * can be injected from createBot() without circular imports.
100
+ */
101
+ export function createVoiceHandler(deps) {
102
+ return async (ctx) => {
103
+ await handleVoiceMessage(ctx, deps);
104
+ };
105
+ }
106
+ /**
107
+ * Handles incoming voice and audio messages:
108
+ * 1. Checks if STT is configured
109
+ * 2. Downloads the audio file from Telegram
110
+ * 3. Sends "recognizing..." status message
111
+ * 4. Calls STT API
112
+ * 5. Shows recognized text
113
+ * 6. Passes text to processUserPrompt
114
+ */
115
+ export async function handleVoiceMessage(ctx, deps) {
116
+ const sttConfigured = deps.isSttConfigured ?? isSttConfigured;
117
+ const downloadFile = deps.downloadTelegramFile ?? downloadTelegramFile;
118
+ const transcribe = deps.transcribeAudio ?? transcribeAudio;
119
+ const processPrompt = deps.processPrompt ?? processUserPrompt;
120
+ // Determine file_id from voice or audio message
121
+ const voice = ctx.message?.voice;
122
+ const audio = ctx.message?.audio;
123
+ const fileId = voice?.file_id ?? audio?.file_id;
124
+ if (!fileId) {
125
+ logger.warn("[Voice] Received voice/audio message with no file_id");
126
+ return;
127
+ }
128
+ // Check if STT is configured
129
+ if (!sttConfigured()) {
130
+ await ctx.reply(t("stt.not_configured"));
131
+ return;
132
+ }
133
+ // Send "recognizing..." status message (will be edited later)
134
+ const statusMessage = await ctx.reply(t("stt.recognizing"));
135
+ try {
136
+ // Download the audio file from Telegram
137
+ const fileData = await downloadFile(ctx, fileId);
138
+ if (!fileData) {
139
+ await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("stt.error", { error: "download failed" }));
140
+ return;
141
+ }
142
+ // Transcribe the audio
143
+ const result = await transcribe(fileData.buffer, fileData.filename);
144
+ const recognizedText = result.text.trim();
145
+ if (!recognizedText) {
146
+ await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("stt.empty_result"));
147
+ return;
148
+ }
149
+ // Show the recognized text by editing the status message.
150
+ // IMPORTANT: even if this edit fails (e.g. Telegram message length limits),
151
+ // we still send the recognized text to OpenCode as a prompt.
152
+ try {
153
+ await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("stt.recognized", { text: recognizedText }));
154
+ }
155
+ catch (editError) {
156
+ logger.warn("[Voice] Failed to edit status message with recognized text:", editError);
157
+ }
158
+ logger.info(`[Voice] Transcribed audio: ${recognizedText.length} chars`);
159
+ // Process the recognized text as a prompt
160
+ await processPrompt(ctx, recognizedText, deps);
161
+ }
162
+ catch (err) {
163
+ const errorMessage = err instanceof Error ? err.message : "unknown error";
164
+ logger.error("[Voice] Error processing voice message:", err);
165
+ try {
166
+ await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("stt.error", { error: errorMessage }));
167
+ }
168
+ catch {
169
+ // If we can't edit the status message, try sending a new one
170
+ await ctx.reply(t("stt.error", { error: errorMessage })).catch(() => { });
171
+ }
172
+ }
173
+ }