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.
- package/.env.example +116 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/agent/manager.js +88 -0
- package/dist/agent/types.js +26 -0
- package/dist/app/start-bot-app.js +49 -0
- package/dist/bot/commands/abort.js +121 -0
- package/dist/bot/commands/commands.js +480 -0
- package/dist/bot/commands/definitions.js +27 -0
- package/dist/bot/commands/help.js +10 -0
- package/dist/bot/commands/models.js +38 -0
- package/dist/bot/commands/new.js +70 -0
- package/dist/bot/commands/opencode-start.js +101 -0
- package/dist/bot/commands/opencode-stop.js +44 -0
- package/dist/bot/commands/projects.js +223 -0
- package/dist/bot/commands/rename.js +139 -0
- package/dist/bot/commands/sessions.js +351 -0
- package/dist/bot/commands/start.js +43 -0
- package/dist/bot/commands/status.js +95 -0
- package/dist/bot/commands/task.js +399 -0
- package/dist/bot/commands/tasklist.js +220 -0
- package/dist/bot/commands/voice.js +145 -0
- package/dist/bot/handlers/agent.js +118 -0
- package/dist/bot/handlers/context.js +100 -0
- package/dist/bot/handlers/document.js +65 -0
- package/dist/bot/handlers/inline-menu.js +119 -0
- package/dist/bot/handlers/model.js +143 -0
- package/dist/bot/handlers/permission.js +235 -0
- package/dist/bot/handlers/prompt.js +240 -0
- package/dist/bot/handlers/question.js +390 -0
- package/dist/bot/handlers/tts.js +89 -0
- package/dist/bot/handlers/variant.js +138 -0
- package/dist/bot/handlers/voice.js +173 -0
- package/dist/bot/index.js +977 -0
- package/dist/bot/message-patterns.js +4 -0
- package/dist/bot/middleware/auth.js +30 -0
- package/dist/bot/middleware/interaction-guard.js +95 -0
- package/dist/bot/middleware/unknown-command.js +22 -0
- package/dist/bot/streaming/response-streamer.js +286 -0
- package/dist/bot/streaming/tool-call-streamer.js +285 -0
- package/dist/bot/utils/busy-guard.js +15 -0
- package/dist/bot/utils/commands.js +21 -0
- package/dist/bot/utils/file-download.js +91 -0
- package/dist/bot/utils/finalize-assistant-response.js +52 -0
- package/dist/bot/utils/keyboard.js +69 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +165 -0
- package/dist/bot/utils/telegram-text.js +28 -0
- package/dist/bot/utils/thinking-message.js +8 -0
- package/dist/cli/args.js +98 -0
- package/dist/cli.js +80 -0
- package/dist/config.js +97 -0
- package/dist/i18n/de.js +357 -0
- package/dist/i18n/en.js +357 -0
- package/dist/i18n/es.js +357 -0
- package/dist/i18n/fr.js +357 -0
- package/dist/i18n/index.js +109 -0
- package/dist/i18n/ru.js +357 -0
- package/dist/i18n/zh.js +357 -0
- package/dist/index.js +26 -0
- package/dist/interaction/busy.js +8 -0
- package/dist/interaction/cleanup.js +32 -0
- package/dist/interaction/guard.js +140 -0
- package/dist/interaction/manager.js +106 -0
- package/dist/interaction/types.js +1 -0
- package/dist/keyboard/manager.js +172 -0
- package/dist/keyboard/types.js +1 -0
- package/dist/model/capabilities.js +62 -0
- package/dist/model/context-limit.js +57 -0
- package/dist/model/manager.js +259 -0
- package/dist/model/types.js +24 -0
- package/dist/opencode/client.js +13 -0
- package/dist/opencode/events.js +140 -0
- package/dist/permission/manager.js +100 -0
- package/dist/permission/types.js +1 -0
- package/dist/pinned/format.js +29 -0
- package/dist/pinned/manager.js +682 -0
- package/dist/pinned/types.js +1 -0
- package/dist/process/manager.js +273 -0
- package/dist/process/types.js +1 -0
- package/dist/project/manager.js +88 -0
- package/dist/question/manager.js +176 -0
- package/dist/question/types.js +1 -0
- package/dist/rename/manager.js +53 -0
- package/dist/runtime/bootstrap.js +350 -0
- package/dist/runtime/mode.js +74 -0
- package/dist/runtime/paths.js +37 -0
- package/dist/scheduled-task/creation-manager.js +113 -0
- package/dist/scheduled-task/display.js +239 -0
- package/dist/scheduled-task/executor.js +87 -0
- package/dist/scheduled-task/foreground-state.js +32 -0
- package/dist/scheduled-task/next-run.js +207 -0
- package/dist/scheduled-task/runtime.js +368 -0
- package/dist/scheduled-task/schedule-parser.js +169 -0
- package/dist/scheduled-task/store.js +65 -0
- package/dist/scheduled-task/types.js +19 -0
- package/dist/session/cache-manager.js +455 -0
- package/dist/session/manager.js +10 -0
- package/dist/settings/manager.js +158 -0
- package/dist/stt/client.js +97 -0
- package/dist/summary/aggregator.js +1136 -0
- package/dist/summary/formatter.js +491 -0
- package/dist/summary/subagent-formatter.js +63 -0
- package/dist/summary/tool-message-batcher.js +90 -0
- package/dist/tts/client.js +130 -0
- package/dist/utils/error-format.js +29 -0
- package/dist/utils/logger.js +127 -0
- package/dist/utils/safe-background-task.js +33 -0
- package/dist/utils/telegram-rate-limit-retry.js +93 -0
- package/dist/variant/manager.js +103 -0
- package/dist/variant/types.js +1 -0
- 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
|
+
}
|