zardbot-telegram 1.0.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 (111) hide show
  1. package/.env.example +116 -0
  2. package/LICENSE +21 -0
  3. package/README.md +250 -0
  4. package/dist/agent/manager.js +88 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +49 -0
  7. package/dist/bot/commands/abort.js +121 -0
  8. package/dist/bot/commands/commands.js +480 -0
  9. package/dist/bot/commands/definitions.js +27 -0
  10. package/dist/bot/commands/help.js +10 -0
  11. package/dist/bot/commands/models.js +38 -0
  12. package/dist/bot/commands/new.js +70 -0
  13. package/dist/bot/commands/opencode-start.js +101 -0
  14. package/dist/bot/commands/opencode-stop.js +44 -0
  15. package/dist/bot/commands/projects.js +223 -0
  16. package/dist/bot/commands/rename.js +139 -0
  17. package/dist/bot/commands/sessions.js +351 -0
  18. package/dist/bot/commands/start.js +43 -0
  19. package/dist/bot/commands/status.js +95 -0
  20. package/dist/bot/commands/task.js +399 -0
  21. package/dist/bot/commands/tasklist.js +220 -0
  22. package/dist/bot/commands/voice.js +145 -0
  23. package/dist/bot/handlers/agent.js +118 -0
  24. package/dist/bot/handlers/context.js +100 -0
  25. package/dist/bot/handlers/document.js +65 -0
  26. package/dist/bot/handlers/inline-menu.js +119 -0
  27. package/dist/bot/handlers/model.js +143 -0
  28. package/dist/bot/handlers/permission.js +235 -0
  29. package/dist/bot/handlers/prompt.js +240 -0
  30. package/dist/bot/handlers/question.js +390 -0
  31. package/dist/bot/handlers/tts.js +89 -0
  32. package/dist/bot/handlers/variant.js +138 -0
  33. package/dist/bot/handlers/voice.js +173 -0
  34. package/dist/bot/index.js +977 -0
  35. package/dist/bot/message-patterns.js +4 -0
  36. package/dist/bot/middleware/auth.js +30 -0
  37. package/dist/bot/middleware/interaction-guard.js +95 -0
  38. package/dist/bot/middleware/unknown-command.js +22 -0
  39. package/dist/bot/streaming/response-streamer.js +286 -0
  40. package/dist/bot/streaming/tool-call-streamer.js +285 -0
  41. package/dist/bot/utils/busy-guard.js +15 -0
  42. package/dist/bot/utils/commands.js +21 -0
  43. package/dist/bot/utils/file-download.js +91 -0
  44. package/dist/bot/utils/finalize-assistant-response.js +52 -0
  45. package/dist/bot/utils/keyboard.js +69 -0
  46. package/dist/bot/utils/send-with-markdown-fallback.js +165 -0
  47. package/dist/bot/utils/telegram-text.js +28 -0
  48. package/dist/bot/utils/thinking-message.js +8 -0
  49. package/dist/cli/args.js +98 -0
  50. package/dist/cli.js +80 -0
  51. package/dist/config.js +97 -0
  52. package/dist/i18n/de.js +357 -0
  53. package/dist/i18n/en.js +357 -0
  54. package/dist/i18n/es.js +357 -0
  55. package/dist/i18n/fr.js +357 -0
  56. package/dist/i18n/index.js +109 -0
  57. package/dist/i18n/ru.js +357 -0
  58. package/dist/i18n/zh.js +357 -0
  59. package/dist/index.js +26 -0
  60. package/dist/interaction/busy.js +8 -0
  61. package/dist/interaction/cleanup.js +32 -0
  62. package/dist/interaction/guard.js +140 -0
  63. package/dist/interaction/manager.js +106 -0
  64. package/dist/interaction/types.js +1 -0
  65. package/dist/keyboard/manager.js +172 -0
  66. package/dist/keyboard/types.js +1 -0
  67. package/dist/model/capabilities.js +62 -0
  68. package/dist/model/context-limit.js +57 -0
  69. package/dist/model/manager.js +259 -0
  70. package/dist/model/types.js +24 -0
  71. package/dist/opencode/client.js +13 -0
  72. package/dist/opencode/events.js +140 -0
  73. package/dist/permission/manager.js +100 -0
  74. package/dist/permission/types.js +1 -0
  75. package/dist/pinned/format.js +29 -0
  76. package/dist/pinned/manager.js +682 -0
  77. package/dist/pinned/types.js +1 -0
  78. package/dist/process/manager.js +273 -0
  79. package/dist/process/types.js +1 -0
  80. package/dist/project/manager.js +88 -0
  81. package/dist/question/manager.js +176 -0
  82. package/dist/question/types.js +1 -0
  83. package/dist/rename/manager.js +53 -0
  84. package/dist/runtime/bootstrap.js +350 -0
  85. package/dist/runtime/mode.js +74 -0
  86. package/dist/runtime/paths.js +37 -0
  87. package/dist/scheduled-task/creation-manager.js +113 -0
  88. package/dist/scheduled-task/display.js +239 -0
  89. package/dist/scheduled-task/executor.js +87 -0
  90. package/dist/scheduled-task/foreground-state.js +32 -0
  91. package/dist/scheduled-task/next-run.js +207 -0
  92. package/dist/scheduled-task/runtime.js +368 -0
  93. package/dist/scheduled-task/schedule-parser.js +169 -0
  94. package/dist/scheduled-task/store.js +65 -0
  95. package/dist/scheduled-task/types.js +19 -0
  96. package/dist/session/cache-manager.js +455 -0
  97. package/dist/session/manager.js +10 -0
  98. package/dist/settings/manager.js +158 -0
  99. package/dist/stt/client.js +97 -0
  100. package/dist/summary/aggregator.js +1136 -0
  101. package/dist/summary/formatter.js +491 -0
  102. package/dist/summary/subagent-formatter.js +63 -0
  103. package/dist/summary/tool-message-batcher.js +90 -0
  104. package/dist/tts/client.js +130 -0
  105. package/dist/utils/error-format.js +29 -0
  106. package/dist/utils/logger.js +127 -0
  107. package/dist/utils/safe-background-task.js +33 -0
  108. package/dist/utils/telegram-rate-limit-retry.js +93 -0
  109. package/dist/variant/manager.js +103 -0
  110. package/dist/variant/types.js +1 -0
  111. package/package.json +79 -0
@@ -0,0 +1,143 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { selectModel, fetchCurrentModel, getModelSelectionLists } from "../../model/manager.js";
3
+ import { formatModelForDisplay } from "../../model/types.js";
4
+ import { formatVariantForButton } from "../../variant/manager.js";
5
+ import { logger } from "../../utils/logger.js";
6
+ import { createMainKeyboard } from "../utils/keyboard.js";
7
+ import { getStoredAgent } from "../../agent/manager.js";
8
+ import { pinnedMessageManager } from "../../pinned/manager.js";
9
+ import { keyboardManager } from "../../keyboard/manager.js";
10
+ import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
11
+ import { t } from "../../i18n/index.js";
12
+ function buildModelSelectionMenuText(modelLists) {
13
+ const lines = [t("model.menu.select"), t("model.menu.favorites_title")];
14
+ if (modelLists.favorites.length === 0) {
15
+ lines.push(t("model.menu.favorites_empty"));
16
+ }
17
+ lines.push(t("model.menu.recent_title"));
18
+ if (modelLists.recent.length === 0) {
19
+ lines.push(t("model.menu.recent_empty"));
20
+ }
21
+ return lines.join("\n");
22
+ }
23
+ /**
24
+ * Handle model selection callback
25
+ * @param ctx grammY context
26
+ * @returns true if handled, false otherwise
27
+ */
28
+ export async function handleModelSelect(ctx) {
29
+ const callbackQuery = ctx.callbackQuery;
30
+ if (!callbackQuery?.data || !callbackQuery.data.startsWith("model:")) {
31
+ return false;
32
+ }
33
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "model");
34
+ if (!isActiveMenu) {
35
+ return true;
36
+ }
37
+ logger.debug(`[ModelHandler] Received callback: ${callbackQuery.data}`);
38
+ try {
39
+ if (ctx.chat) {
40
+ keyboardManager.initialize(ctx.api, ctx.chat.id);
41
+ }
42
+ // Parse callback data: "model:providerID:modelID"
43
+ const parts = callbackQuery.data.split(":");
44
+ if (parts.length < 3) {
45
+ logger.error(`[ModelHandler] Invalid callback data format: ${callbackQuery.data}`);
46
+ clearActiveInlineMenu("model_select_invalid_callback");
47
+ await ctx.answerCallbackQuery({ text: t("model.change_error_callback") }).catch(() => { });
48
+ return true;
49
+ }
50
+ const providerID = parts[1];
51
+ const modelID = parts.slice(2).join(":"); // Handle model IDs that may contain ":"
52
+ const modelInfo = {
53
+ providerID,
54
+ modelID,
55
+ variant: "default", // Reset to default when switching models
56
+ };
57
+ // Select model and persist
58
+ selectModel(modelInfo);
59
+ // Update keyboard manager state (may not be initialized if no session selected)
60
+ keyboardManager.updateModel(modelInfo);
61
+ // Refresh context limit for new model
62
+ await pinnedMessageManager.refreshContextLimit();
63
+ // Update Reply Keyboard with new model and context
64
+ const currentAgent = getStoredAgent();
65
+ const contextInfo = pinnedMessageManager.getContextInfo() ??
66
+ (pinnedMessageManager.getContextLimit() > 0
67
+ ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
68
+ : null);
69
+ if (contextInfo) {
70
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
71
+ }
72
+ const variantName = formatVariantForButton(modelInfo.variant || "default");
73
+ const keyboard = createMainKeyboard(currentAgent, modelInfo, contextInfo ?? undefined, variantName);
74
+ const displayName = formatModelForDisplay(modelInfo.providerID, modelInfo.modelID);
75
+ clearActiveInlineMenu("model_selected");
76
+ // Send confirmation message with updated keyboard
77
+ await ctx.answerCallbackQuery({ text: t("model.changed_callback", { name: displayName }) });
78
+ await ctx.reply(t("model.changed_message", { name: displayName }), {
79
+ reply_markup: keyboard,
80
+ });
81
+ // Delete the inline menu message
82
+ await ctx.deleteMessage().catch(() => { });
83
+ return true;
84
+ }
85
+ catch (err) {
86
+ clearActiveInlineMenu("model_select_error");
87
+ logger.error("[ModelHandler] Error handling model select:", err);
88
+ await ctx.answerCallbackQuery({ text: t("model.change_error_callback") }).catch(() => { });
89
+ return false;
90
+ }
91
+ }
92
+ /**
93
+ * Build inline keyboard with favorite and recent models
94
+ * @param currentModel Current model for highlighting
95
+ * @returns InlineKeyboard with model selection buttons
96
+ */
97
+ export async function buildModelSelectionMenu(currentModel, modelLists) {
98
+ const keyboard = new InlineKeyboard();
99
+ const lists = modelLists ?? (await getModelSelectionLists());
100
+ const favorites = lists.favorites;
101
+ const recent = lists.recent;
102
+ if (favorites.length === 0 && recent.length === 0) {
103
+ logger.warn("[ModelHandler] No model choices found in favorites/recent");
104
+ return keyboard;
105
+ }
106
+ const addButton = (model, prefix) => {
107
+ const isActive = currentModel &&
108
+ model.providerID === currentModel.providerID &&
109
+ model.modelID === currentModel.modelID;
110
+ // Inline buttons use full model ID without truncation
111
+ const label = `${prefix} ${model.providerID}/${model.modelID}`;
112
+ const labelWithCheck = isActive ? `✅ ${label}` : label;
113
+ keyboard.text(labelWithCheck, `model:${model.providerID}:${model.modelID}`).row();
114
+ };
115
+ favorites.forEach((model) => addButton(model, "⭐"));
116
+ recent.forEach((model) => addButton(model, "🕘"));
117
+ return keyboard;
118
+ }
119
+ /**
120
+ * Show model selection menu
121
+ * @param ctx grammY context
122
+ */
123
+ export async function showModelSelectionMenu(ctx) {
124
+ try {
125
+ const currentModel = fetchCurrentModel();
126
+ const modelLists = await getModelSelectionLists();
127
+ const keyboard = await buildModelSelectionMenu(currentModel, modelLists);
128
+ if (keyboard.inline_keyboard.length === 0) {
129
+ await ctx.reply(t("model.menu.empty"));
130
+ return;
131
+ }
132
+ const text = buildModelSelectionMenuText(modelLists);
133
+ await replyWithInlineMenu(ctx, {
134
+ menuKind: "model",
135
+ text,
136
+ keyboard,
137
+ });
138
+ }
139
+ catch (err) {
140
+ logger.error("[ModelHandler] Error showing model menu:", err);
141
+ await ctx.reply(t("model.menu.error"));
142
+ }
143
+ }
@@ -0,0 +1,235 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { permissionManager } from "../../permission/manager.js";
3
+ import { opencodeClient } from "../../opencode/client.js";
4
+ import { getCurrentProject } from "../../settings/manager.js";
5
+ import { getCurrentSession } from "../../session/manager.js";
6
+ import { summaryAggregator } from "../../summary/aggregator.js";
7
+ import { interactionManager } from "../../interaction/manager.js";
8
+ import { logger } from "../../utils/logger.js";
9
+ import { safeBackgroundTask } from "../../utils/safe-background-task.js";
10
+ import { t } from "../../i18n/index.js";
11
+ // Permission type display names
12
+ const PERMISSION_NAME_KEYS = {
13
+ bash: "permission.name.bash",
14
+ edit: "permission.name.edit",
15
+ write: "permission.name.write",
16
+ read: "permission.name.read",
17
+ webfetch: "permission.name.webfetch",
18
+ websearch: "permission.name.websearch",
19
+ glob: "permission.name.glob",
20
+ grep: "permission.name.grep",
21
+ list: "permission.name.list",
22
+ task: "permission.name.task",
23
+ lsp: "permission.name.lsp",
24
+ external_directory: "permission.name.external_directory",
25
+ };
26
+ // Permission type emojis
27
+ const PERMISSION_EMOJIS = {
28
+ bash: "⚡",
29
+ edit: "✏️",
30
+ write: "📝",
31
+ read: "📖",
32
+ webfetch: "🌐",
33
+ websearch: "🔍",
34
+ glob: "📁",
35
+ grep: "🔎",
36
+ list: "📂",
37
+ task: "⚙️",
38
+ lsp: "🔧",
39
+ external_directory: "📁",
40
+ };
41
+ function getCallbackMessageId(ctx) {
42
+ const message = ctx.callbackQuery?.message;
43
+ if (!message || !("message_id" in message)) {
44
+ return null;
45
+ }
46
+ const messageId = message.message_id;
47
+ return typeof messageId === "number" ? messageId : null;
48
+ }
49
+ function clearPermissionInteraction(reason) {
50
+ const state = interactionManager.getSnapshot();
51
+ if (state?.kind === "permission") {
52
+ interactionManager.clear(reason);
53
+ }
54
+ }
55
+ function syncPermissionInteractionState(metadata = {}) {
56
+ const pendingCount = permissionManager.getPendingCount();
57
+ if (pendingCount === 0) {
58
+ clearPermissionInteraction("permission_no_pending_requests");
59
+ return;
60
+ }
61
+ const nextMetadata = {
62
+ pendingCount,
63
+ ...metadata,
64
+ };
65
+ const state = interactionManager.getSnapshot();
66
+ if (state?.kind === "permission") {
67
+ interactionManager.transition({
68
+ expectedInput: "callback",
69
+ metadata: nextMetadata,
70
+ });
71
+ return;
72
+ }
73
+ interactionManager.start({
74
+ kind: "permission",
75
+ expectedInput: "callback",
76
+ metadata: nextMetadata,
77
+ });
78
+ }
79
+ function isPermissionReply(value) {
80
+ return value === "once" || value === "always" || value === "reject";
81
+ }
82
+ /**
83
+ * Handle permission callback from inline buttons
84
+ */
85
+ export async function handlePermissionCallback(ctx) {
86
+ const data = ctx.callbackQuery?.data;
87
+ if (!data)
88
+ return false;
89
+ if (!data.startsWith("permission:")) {
90
+ return false;
91
+ }
92
+ logger.debug(`[PermissionHandler] Received callback: ${data}`);
93
+ if (!permissionManager.isActive()) {
94
+ clearPermissionInteraction("permission_inactive_callback");
95
+ await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
96
+ return true;
97
+ }
98
+ const callbackMessageId = getCallbackMessageId(ctx);
99
+ if (!permissionManager.isActiveMessage(callbackMessageId)) {
100
+ await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
101
+ return true;
102
+ }
103
+ const requestID = permissionManager.getRequestID(callbackMessageId);
104
+ if (!requestID) {
105
+ await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
106
+ return true;
107
+ }
108
+ const parts = data.split(":");
109
+ const action = parts[1];
110
+ if (!isPermissionReply(action)) {
111
+ await ctx.answerCallbackQuery({
112
+ text: t("permission.processing_error_callback"),
113
+ show_alert: true,
114
+ });
115
+ return true;
116
+ }
117
+ try {
118
+ await handlePermissionReply(ctx, action, requestID, callbackMessageId);
119
+ }
120
+ catch (err) {
121
+ logger.error("[PermissionHandler] Error handling callback:", err);
122
+ await ctx.answerCallbackQuery({
123
+ text: t("permission.processing_error_callback"),
124
+ show_alert: true,
125
+ });
126
+ }
127
+ return true;
128
+ }
129
+ /**
130
+ * Handle permission reply (once/always/reject)
131
+ */
132
+ async function handlePermissionReply(ctx, reply, requestID, callbackMessageId) {
133
+ const currentProject = getCurrentProject();
134
+ const currentSession = getCurrentSession();
135
+ const chatId = ctx.chat?.id;
136
+ const directory = currentSession?.directory ?? currentProject?.worktree;
137
+ if (!directory || !chatId) {
138
+ permissionManager.clear();
139
+ clearPermissionInteraction("permission_invalid_runtime_context");
140
+ await ctx.answerCallbackQuery({
141
+ text: t("permission.no_active_request_callback"),
142
+ show_alert: true,
143
+ });
144
+ return;
145
+ }
146
+ // Reply labels for user feedback
147
+ const replyLabels = {
148
+ once: t("permission.reply.once"),
149
+ always: t("permission.reply.always"),
150
+ reject: t("permission.reply.reject"),
151
+ };
152
+ await ctx.answerCallbackQuery({ text: replyLabels[reply] });
153
+ // Delete the permission message
154
+ await ctx.deleteMessage().catch(() => { });
155
+ // Stop typing indicator since we're responding
156
+ summaryAggregator.stopTypingIndicator();
157
+ logger.info(`[PermissionHandler] Sending permission reply: ${reply}, requestID=${requestID}`);
158
+ // CRITICAL: Fire-and-forget! Do not block the handler
159
+ safeBackgroundTask({
160
+ taskName: "permission.reply",
161
+ task: () => opencodeClient.permission.reply({
162
+ requestID,
163
+ directory,
164
+ reply,
165
+ }),
166
+ onSuccess: ({ error }) => {
167
+ if (error) {
168
+ logger.error("[PermissionHandler] Failed to send permission reply:", error);
169
+ if (ctx.api && chatId) {
170
+ void ctx.api.sendMessage(chatId, t("permission.send_reply_error")).catch(() => { });
171
+ }
172
+ return;
173
+ }
174
+ logger.info("[PermissionHandler] Permission reply sent successfully");
175
+ },
176
+ });
177
+ permissionManager.removeByMessageId(callbackMessageId);
178
+ if (!permissionManager.isActive()) {
179
+ clearPermissionInteraction("permission_replied");
180
+ return;
181
+ }
182
+ syncPermissionInteractionState({
183
+ lastRepliedRequestID: requestID,
184
+ });
185
+ }
186
+ /**
187
+ * Show permission request message with inline buttons
188
+ */
189
+ export async function showPermissionRequest(bot, chatId, request) {
190
+ logger.debug(`[PermissionHandler] Showing permission request: ${request.permission}`);
191
+ const text = formatPermissionText(request);
192
+ const keyboard = buildPermissionKeyboard();
193
+ try {
194
+ const message = await bot.sendMessage(chatId, text, {
195
+ reply_markup: keyboard,
196
+ });
197
+ logger.debug(`[PermissionHandler] Message sent, messageId=${message.message_id}`);
198
+ permissionManager.startPermission(request, message.message_id);
199
+ syncPermissionInteractionState({
200
+ requestID: request.id,
201
+ messageId: message.message_id,
202
+ });
203
+ summaryAggregator.stopTypingIndicator();
204
+ }
205
+ catch (err) {
206
+ logger.error("[PermissionHandler] Failed to send permission message:", err);
207
+ throw err;
208
+ }
209
+ }
210
+ /**
211
+ * Format permission request text
212
+ */
213
+ function formatPermissionText(request) {
214
+ const emoji = PERMISSION_EMOJIS[request.permission] || "🔐";
215
+ const nameKey = PERMISSION_NAME_KEYS[request.permission];
216
+ const name = nameKey ? t(nameKey) : request.permission;
217
+ let text = t("permission.header", { emoji, name });
218
+ // Show patterns (commands/files)
219
+ if (request.patterns.length > 0) {
220
+ request.patterns.forEach((pattern) => {
221
+ text += `• ${pattern}\n`;
222
+ });
223
+ }
224
+ return text;
225
+ }
226
+ /**
227
+ * Build inline keyboard with permission buttons
228
+ */
229
+ function buildPermissionKeyboard() {
230
+ const keyboard = new InlineKeyboard();
231
+ keyboard.text(t("permission.button.allow"), "permission:once").row();
232
+ keyboard.text(t("permission.button.always"), "permission:always").row();
233
+ keyboard.text(t("permission.button.reject"), "permission:reject");
234
+ return keyboard;
235
+ }
@@ -0,0 +1,240 @@
1
+ import { opencodeClient } from "../../opencode/client.js";
2
+ import { clearSession, getCurrentSession, setCurrentSession } from "../../session/manager.js";
3
+ import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
4
+ import { getCurrentProject } from "../../settings/manager.js";
5
+ import { getStoredAgent } from "../../agent/manager.js";
6
+ import { getStoredModel } from "../../model/manager.js";
7
+ import { formatVariantForButton } from "../../variant/manager.js";
8
+ import { createMainKeyboard } from "../utils/keyboard.js";
9
+ import { keyboardManager } from "../../keyboard/manager.js";
10
+ import { pinnedMessageManager } from "../../pinned/manager.js";
11
+ import { summaryAggregator } from "../../summary/aggregator.js";
12
+ import { stopEventListening } from "../../opencode/events.js";
13
+ import { interactionManager } from "../../interaction/manager.js";
14
+ import { clearAllInteractionState } from "../../interaction/cleanup.js";
15
+ import { safeBackgroundTask } from "../../utils/safe-background-task.js";
16
+ import { formatErrorDetails } from "../../utils/error-format.js";
17
+ import { logger } from "../../utils/logger.js";
18
+ import { t } from "../../i18n/index.js";
19
+ import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
20
+ /** Module-level references for async callbacks that don't have ctx. */
21
+ let botInstance = null;
22
+ let chatIdInstance = null;
23
+ export function getPromptBotInstance() {
24
+ return botInstance;
25
+ }
26
+ export function getPromptChatId() {
27
+ return chatIdInstance;
28
+ }
29
+ async function isSessionBusy(sessionId, directory) {
30
+ try {
31
+ const { data, error } = await opencodeClient.session.status({ directory });
32
+ if (error || !data) {
33
+ logger.warn("[Bot] Failed to check session status before prompt:", error);
34
+ return false;
35
+ }
36
+ const sessionStatus = data[sessionId];
37
+ if (!sessionStatus) {
38
+ return false;
39
+ }
40
+ logger.debug(`[Bot] Current session status before prompt: ${sessionStatus.type || "unknown"}`);
41
+ return sessionStatus.type === "busy";
42
+ }
43
+ catch (err) {
44
+ logger.warn("[Bot] Error checking session status before prompt:", err);
45
+ return false;
46
+ }
47
+ }
48
+ async function resetMismatchedSessionContext() {
49
+ stopEventListening();
50
+ summaryAggregator.clear();
51
+ foregroundSessionState.clearAll("session_mismatch_reset");
52
+ clearAllInteractionState("session_mismatch_reset");
53
+ clearSession();
54
+ keyboardManager.clearContext();
55
+ if (!pinnedMessageManager.isInitialized()) {
56
+ return;
57
+ }
58
+ try {
59
+ await pinnedMessageManager.clear();
60
+ }
61
+ catch (err) {
62
+ logger.error("[Bot] Failed to clear pinned message during session reset:", err);
63
+ }
64
+ }
65
+ /**
66
+ * Processes a user prompt: ensures project/session, subscribes to events, and sends
67
+ * the prompt to OpenCode. Used by text, voice, and photo message handlers.
68
+ *
69
+ * @param ctx - Grammy context
70
+ * @param text - Text content of the prompt
71
+ * @param deps - Dependencies (bot and event subscription)
72
+ * @param fileParts - Optional file parts (for photo/document attachments)
73
+ * @returns true if the prompt was dispatched, false if it was blocked/failed early.
74
+ */
75
+ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
76
+ const { bot, ensureEventSubscription } = deps;
77
+ const currentProject = getCurrentProject();
78
+ if (!currentProject) {
79
+ await ctx.reply(t("bot.project_not_selected"));
80
+ return false;
81
+ }
82
+ botInstance = bot;
83
+ chatIdInstance = ctx.chat.id;
84
+ // Initialize pinned message manager if not already
85
+ if (!pinnedMessageManager.isInitialized()) {
86
+ pinnedMessageManager.initialize(bot.api, ctx.chat.id);
87
+ }
88
+ // Initialize keyboard manager if not already
89
+ keyboardManager.initialize(bot.api, ctx.chat.id);
90
+ let currentSession = getCurrentSession();
91
+ if (currentSession && currentSession.directory !== currentProject.worktree) {
92
+ logger.warn(`[Bot] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}. Resetting session context.`);
93
+ await resetMismatchedSessionContext();
94
+ await ctx.reply(t("bot.session_reset_project_mismatch"));
95
+ return false;
96
+ }
97
+ if (!currentSession) {
98
+ await ctx.reply(t("bot.creating_session"));
99
+ const { data: session, error } = await opencodeClient.session.create({
100
+ directory: currentProject.worktree,
101
+ });
102
+ if (error || !session) {
103
+ await ctx.reply(t("bot.create_session_error"));
104
+ return false;
105
+ }
106
+ logger.info(`[Bot] Created new session: id=${session.id}, title="${session.title}", project=${currentProject.worktree}`);
107
+ currentSession = {
108
+ id: session.id,
109
+ title: session.title,
110
+ directory: currentProject.worktree,
111
+ };
112
+ setCurrentSession(currentSession);
113
+ await ingestSessionInfoForCache(session);
114
+ // Create pinned message for new session
115
+ try {
116
+ await pinnedMessageManager.onSessionChange(session.id, session.title);
117
+ }
118
+ catch (err) {
119
+ logger.error("[Bot] Error creating pinned message for new session:", err);
120
+ }
121
+ const currentAgent = getStoredAgent();
122
+ const currentModel = getStoredModel();
123
+ const contextInfo = pinnedMessageManager.getContextInfo();
124
+ const variantName = formatVariantForButton(currentModel.variant || "default");
125
+ const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
126
+ await ctx.reply(t("bot.session_created", { title: session.title }), {
127
+ reply_markup: keyboard,
128
+ });
129
+ }
130
+ else {
131
+ logger.info(`[Bot] Using existing session: id=${currentSession.id}, title="${currentSession.title}"`);
132
+ // Ensure pinned message exists for existing session
133
+ if (!pinnedMessageManager.getState().messageId) {
134
+ try {
135
+ await pinnedMessageManager.onSessionChange(currentSession.id, currentSession.title);
136
+ }
137
+ catch (err) {
138
+ logger.error("[Bot] Error creating pinned message for existing session:", err);
139
+ }
140
+ }
141
+ }
142
+ await ensureEventSubscription(currentSession.directory);
143
+ summaryAggregator.setSession(currentSession.id);
144
+ summaryAggregator.setBotAndChatId(bot, ctx.chat.id);
145
+ const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory);
146
+ if (sessionIsBusy) {
147
+ logger.info(`[Bot] Ignoring new prompt: session ${currentSession.id} is busy`);
148
+ await ctx.reply(t("bot.session_busy"));
149
+ return false;
150
+ }
151
+ try {
152
+ const currentAgent = getStoredAgent();
153
+ const storedModel = getStoredModel();
154
+ // Build parts array with text and files
155
+ const parts = [];
156
+ // Add text part if present
157
+ if (text.trim().length > 0) {
158
+ parts.push({ type: "text", text });
159
+ }
160
+ // Add file parts
161
+ parts.push(...fileParts);
162
+ // If no text and files exist, use a placeholder
163
+ if (parts.length === 0 || (parts.length > 0 && parts.every((p) => p.type === "file"))) {
164
+ if (fileParts.length > 0) {
165
+ // Files without text - add a minimal system prompt
166
+ parts.unshift({ type: "text", text: "See attached file" });
167
+ }
168
+ }
169
+ const promptOptions = {
170
+ sessionID: currentSession.id,
171
+ directory: currentSession.directory,
172
+ parts,
173
+ agent: currentAgent,
174
+ };
175
+ // Use stored model (from settings or config)
176
+ if (storedModel.providerID && storedModel.modelID) {
177
+ promptOptions.model = {
178
+ providerID: storedModel.providerID,
179
+ modelID: storedModel.modelID,
180
+ };
181
+ // Add variant if specified
182
+ if (storedModel.variant) {
183
+ promptOptions.variant = storedModel.variant;
184
+ }
185
+ }
186
+ const promptErrorLogContext = {
187
+ sessionId: currentSession.id,
188
+ directory: currentSession.directory,
189
+ agent: currentAgent || "default",
190
+ modelProvider: storedModel.providerID || "default",
191
+ modelId: storedModel.modelID || "default",
192
+ variant: storedModel.variant || "default",
193
+ promptLength: text.length,
194
+ fileCount: fileParts.length,
195
+ };
196
+ logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}, fileCount=${fileParts.length}...`);
197
+ foregroundSessionState.markBusy(currentSession.id);
198
+ // CRITICAL: DO NOT wait for session.prompt to complete.
199
+ // If we wait, the handler will not finish and grammY will not call getUpdates,
200
+ // which blocks receiving button callback_query updates.
201
+ // The processing result will arrive via SSE events.
202
+ safeBackgroundTask({
203
+ taskName: "session.prompt",
204
+ task: () => opencodeClient.session.prompt(promptOptions),
205
+ onSuccess: ({ error }) => {
206
+ if (error) {
207
+ foregroundSessionState.markIdle(currentSession.id);
208
+ const details = formatErrorDetails(error, 6000);
209
+ logger.error("[Bot] OpenCode API returned an error for session.prompt", promptErrorLogContext);
210
+ logger.error("[Bot] session.prompt error details:", details);
211
+ logger.error("[Bot] session.prompt raw API error object:", error);
212
+ // Send user-friendly error via API directly because ctx is no longer available
213
+ void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
214
+ return;
215
+ }
216
+ logger.info("[Bot] session.prompt completed");
217
+ },
218
+ onError: (error) => {
219
+ foregroundSessionState.markIdle(currentSession.id);
220
+ const details = formatErrorDetails(error, 6000);
221
+ logger.error("[Bot] session.prompt background task failed", promptErrorLogContext);
222
+ logger.error("[Bot] session.prompt background failure details:", details);
223
+ logger.error("[Bot] session.prompt raw background error object:", error);
224
+ void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
225
+ },
226
+ });
227
+ return true;
228
+ }
229
+ catch (err) {
230
+ if (currentSession) {
231
+ foregroundSessionState.markIdle(currentSession.id);
232
+ }
233
+ logger.error("Error in prompt handler:", err);
234
+ if (interactionManager.getSnapshot()) {
235
+ clearAllInteractionState("message_handler_error");
236
+ }
237
+ await ctx.reply(t("error.generic"));
238
+ return false;
239
+ }
240
+ }