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.
- package/.env.example +74 -0
- package/LICENSE +21 -0
- package/README.md +305 -0
- package/dist/agent/manager.js +60 -0
- package/dist/agent/types.js +26 -0
- package/dist/app/start-bot-app.js +47 -0
- package/dist/bot/commands/abort.js +116 -0
- package/dist/bot/commands/commands.js +389 -0
- package/dist/bot/commands/constants.js +20 -0
- package/dist/bot/commands/definitions.js +25 -0
- package/dist/bot/commands/help.js +27 -0
- package/dist/bot/commands/models.js +38 -0
- package/dist/bot/commands/new.js +247 -0
- package/dist/bot/commands/opencode-start.js +85 -0
- package/dist/bot/commands/opencode-stop.js +44 -0
- package/dist/bot/commands/projects.js +304 -0
- package/dist/bot/commands/rename.js +173 -0
- package/dist/bot/commands/sessions.js +491 -0
- package/dist/bot/commands/start.js +67 -0
- package/dist/bot/commands/status.js +138 -0
- package/dist/bot/constants.js +49 -0
- package/dist/bot/handlers/agent.js +127 -0
- package/dist/bot/handlers/context.js +125 -0
- package/dist/bot/handlers/document.js +65 -0
- package/dist/bot/handlers/inline-menu.js +124 -0
- package/dist/bot/handlers/model.js +152 -0
- package/dist/bot/handlers/permission.js +281 -0
- package/dist/bot/handlers/prompt.js +263 -0
- package/dist/bot/handlers/question.js +285 -0
- package/dist/bot/handlers/variant.js +147 -0
- package/dist/bot/handlers/voice.js +173 -0
- package/dist/bot/index.js +945 -0
- package/dist/bot/message-patterns.js +4 -0
- package/dist/bot/middleware/auth.js +30 -0
- package/dist/bot/middleware/interaction-guard.js +80 -0
- package/dist/bot/middleware/unknown-command.js +22 -0
- package/dist/bot/scope.js +222 -0
- package/dist/bot/telegram-constants.js +3 -0
- package/dist/bot/telegram-rate-limiter.js +263 -0
- package/dist/bot/utils/commands.js +21 -0
- package/dist/bot/utils/file-download.js +91 -0
- package/dist/bot/utils/keyboard.js +85 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +57 -0
- package/dist/bot/utils/session-error-filter.js +34 -0
- package/dist/bot/utils/topic-link.js +29 -0
- package/dist/cli/args.js +98 -0
- package/dist/cli.js +80 -0
- package/dist/config.js +103 -0
- package/dist/i18n/de.js +330 -0
- package/dist/i18n/en.js +330 -0
- package/dist/i18n/es.js +330 -0
- package/dist/i18n/index.js +102 -0
- package/dist/i18n/ru.js +330 -0
- package/dist/i18n/zh.js +330 -0
- package/dist/index.js +28 -0
- package/dist/interaction/cleanup.js +24 -0
- package/dist/interaction/constants.js +25 -0
- package/dist/interaction/guard.js +100 -0
- package/dist/interaction/manager.js +113 -0
- package/dist/interaction/types.js +1 -0
- package/dist/keyboard/manager.js +115 -0
- package/dist/keyboard/types.js +1 -0
- package/dist/model/capabilities.js +62 -0
- package/dist/model/manager.js +257 -0
- package/dist/model/types.js +24 -0
- package/dist/opencode/client.js +13 -0
- package/dist/opencode/events.js +159 -0
- package/dist/opencode/prompt-submit-error.js +101 -0
- package/dist/permission/manager.js +92 -0
- package/dist/permission/types.js +1 -0
- package/dist/pinned/manager.js +405 -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 +186 -0
- package/dist/question/types.js +1 -0
- package/dist/rename/manager.js +64 -0
- package/dist/runtime/bootstrap.js +350 -0
- package/dist/runtime/mode.js +74 -0
- package/dist/runtime/paths.js +37 -0
- package/dist/runtime/process-error-handlers.js +24 -0
- package/dist/session/cache-manager.js +455 -0
- package/dist/session/manager.js +87 -0
- package/dist/settings/manager.js +283 -0
- package/dist/stt/client.js +64 -0
- package/dist/summary/aggregator.js +625 -0
- package/dist/summary/formatter.js +417 -0
- package/dist/summary/tool-message-batcher.js +277 -0
- package/dist/topic/colors.js +8 -0
- package/dist/topic/constants.js +10 -0
- package/dist/topic/manager.js +161 -0
- package/dist/topic/title-constants.js +2 -0
- package/dist/topic/title-format.js +10 -0
- package/dist/topic/title-sync.js +17 -0
- package/dist/utils/error-format.js +29 -0
- package/dist/utils/logger.js +175 -0
- package/dist/utils/safe-background-task.js +33 -0
- package/dist/variant/manager.js +103 -0
- package/dist/variant/types.js +1 -0
- package/package.json +76 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
2
|
+
import { logger } from "../../utils/logger.js";
|
|
3
|
+
import { t } from "../../i18n/index.js";
|
|
4
|
+
import { getScopeFromContext, getScopeKeyFromContext, getThreadSendOptions } from "../scope.js";
|
|
5
|
+
const INLINE_MENU_CANCEL_PREFIX = "inline:cancel:";
|
|
6
|
+
const LEGACY_CONTEXT_CANCEL_CALLBACK = "compact:cancel";
|
|
7
|
+
const INLINE_MENU_KINDS = ["project", "session", "model", "agent", "variant", "context"];
|
|
8
|
+
function isInlineMenuKind(value) {
|
|
9
|
+
return INLINE_MENU_KINDS.includes(value);
|
|
10
|
+
}
|
|
11
|
+
function getCallbackMessageId(ctx) {
|
|
12
|
+
const message = ctx.callbackQuery?.message;
|
|
13
|
+
if (!message || !("message_id" in message)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const messageId = message.message_id;
|
|
17
|
+
return typeof messageId === "number" ? messageId : null;
|
|
18
|
+
}
|
|
19
|
+
function getActiveInlineMenuMetadata(state) {
|
|
20
|
+
if (!state || state.kind !== "inline") {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const menuKind = state.metadata.menuKind;
|
|
24
|
+
const messageId = state.metadata.messageId;
|
|
25
|
+
if (typeof menuKind !== "string" || !isInlineMenuKind(menuKind)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (typeof messageId !== "number") {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
menuKind,
|
|
33
|
+
messageId,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function getInlineCancelCallbackData(menuKind) {
|
|
37
|
+
return `${INLINE_MENU_CANCEL_PREFIX}${menuKind}`;
|
|
38
|
+
}
|
|
39
|
+
export function appendInlineMenuCancelButton(keyboard, menuKind) {
|
|
40
|
+
while (keyboard.inline_keyboard.length > 0 &&
|
|
41
|
+
keyboard.inline_keyboard[keyboard.inline_keyboard.length - 1].length === 0) {
|
|
42
|
+
keyboard.inline_keyboard.pop();
|
|
43
|
+
}
|
|
44
|
+
if (keyboard.inline_keyboard.length > 0) {
|
|
45
|
+
keyboard.row();
|
|
46
|
+
}
|
|
47
|
+
keyboard.text(t("inline.button.cancel"), getInlineCancelCallbackData(menuKind));
|
|
48
|
+
return keyboard;
|
|
49
|
+
}
|
|
50
|
+
export async function replyWithInlineMenu(ctx, options) {
|
|
51
|
+
const scope = getScopeFromContext(ctx);
|
|
52
|
+
const scopeKey = scope?.key ?? getScopeKeyFromContext(ctx);
|
|
53
|
+
const keyboard = appendInlineMenuCancelButton(options.keyboard, options.menuKind);
|
|
54
|
+
const replyOptions = {
|
|
55
|
+
reply_markup: keyboard,
|
|
56
|
+
...getThreadSendOptions(scope?.threadId ?? null),
|
|
57
|
+
};
|
|
58
|
+
if (options.parseMode) {
|
|
59
|
+
replyOptions.parse_mode = options.parseMode;
|
|
60
|
+
}
|
|
61
|
+
const message = await ctx.reply(options.text, replyOptions);
|
|
62
|
+
interactionManager.start({
|
|
63
|
+
kind: "inline",
|
|
64
|
+
expectedInput: "callback",
|
|
65
|
+
metadata: {
|
|
66
|
+
menuKind: options.menuKind,
|
|
67
|
+
messageId: message.message_id,
|
|
68
|
+
},
|
|
69
|
+
}, scopeKey);
|
|
70
|
+
logger.debug(`[InlineMenu] Opened menu: kind=${options.menuKind}, messageId=${message.message_id}`);
|
|
71
|
+
return message.message_id;
|
|
72
|
+
}
|
|
73
|
+
export async function ensureActiveInlineMenu(ctx, menuKind) {
|
|
74
|
+
const scopeKey = getScopeKeyFromContext(ctx);
|
|
75
|
+
const activeMetadata = getActiveInlineMenuMetadata(interactionManager.getSnapshot(scopeKey));
|
|
76
|
+
const callbackMessageId = getCallbackMessageId(ctx);
|
|
77
|
+
const isActive = !!activeMetadata &&
|
|
78
|
+
callbackMessageId !== null &&
|
|
79
|
+
activeMetadata.menuKind === menuKind &&
|
|
80
|
+
activeMetadata.messageId === callbackMessageId;
|
|
81
|
+
if (isActive) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
logger.debug(`[InlineMenu] Stale callback ignored: expectedKind=${menuKind}, activeKind=${activeMetadata?.menuKind || "none"}, callbackMessageId=${callbackMessageId || "none"}, activeMessageId=${activeMetadata?.messageId || "none"}`);
|
|
85
|
+
await ctx
|
|
86
|
+
.answerCallbackQuery({ text: t("inline.inactive_callback"), show_alert: true })
|
|
87
|
+
.catch(() => { });
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
export function clearActiveInlineMenu(reason, scopeKey = "global") {
|
|
91
|
+
const state = interactionManager.getSnapshot(scopeKey);
|
|
92
|
+
if (state?.kind === "inline") {
|
|
93
|
+
interactionManager.clear(reason, scopeKey);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function handleInlineMenuCancel(ctx) {
|
|
97
|
+
const data = ctx.callbackQuery?.data;
|
|
98
|
+
if (!data) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
let menuKind = null;
|
|
102
|
+
if (data === LEGACY_CONTEXT_CANCEL_CALLBACK) {
|
|
103
|
+
menuKind = "context";
|
|
104
|
+
}
|
|
105
|
+
else if (data.startsWith(INLINE_MENU_CANCEL_PREFIX)) {
|
|
106
|
+
const rawKind = data.slice(INLINE_MENU_CANCEL_PREFIX.length);
|
|
107
|
+
if (!isInlineMenuKind(rawKind)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
menuKind = rawKind;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const isActive = await ensureActiveInlineMenu(ctx, menuKind);
|
|
116
|
+
if (!isActive) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
clearActiveInlineMenu(`inline_menu_cancel:${menuKind}`, getScopeKeyFromContext(ctx));
|
|
120
|
+
await ctx.answerCallbackQuery({ text: t("inline.cancelled_callback") }).catch(() => { });
|
|
121
|
+
await ctx.deleteMessage().catch(() => { });
|
|
122
|
+
logger.debug(`[InlineMenu] Menu cancelled: kind=${menuKind}`);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
import { SCOPE_CONTEXT, getScopeFromKey, getScopeKeyFromContext } from "../scope.js";
|
|
13
|
+
function buildModelSelectionMenuText(modelLists) {
|
|
14
|
+
const lines = [t("model.menu.select"), t("model.menu.favorites_title")];
|
|
15
|
+
if (modelLists.favorites.length === 0) {
|
|
16
|
+
lines.push(t("model.menu.favorites_empty"));
|
|
17
|
+
}
|
|
18
|
+
lines.push(t("model.menu.recent_title"));
|
|
19
|
+
if (modelLists.recent.length === 0) {
|
|
20
|
+
lines.push(t("model.menu.recent_empty"));
|
|
21
|
+
}
|
|
22
|
+
return lines.join("\n");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Handle model selection callback
|
|
26
|
+
* @param ctx grammY context
|
|
27
|
+
* @returns true if handled, false otherwise
|
|
28
|
+
*/
|
|
29
|
+
export async function handleModelSelect(ctx) {
|
|
30
|
+
const callbackQuery = ctx.callbackQuery;
|
|
31
|
+
if (!callbackQuery?.data || !callbackQuery.data.startsWith("model:")) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const isActiveMenu = await ensureActiveInlineMenu(ctx, "model");
|
|
35
|
+
if (!isActiveMenu) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
logger.debug(`[ModelHandler] Received callback: ${callbackQuery.data}`);
|
|
39
|
+
try {
|
|
40
|
+
const scopeKey = getScopeKeyFromContext(ctx);
|
|
41
|
+
if (ctx.chat) {
|
|
42
|
+
keyboardManager.initialize(ctx.api, ctx.chat.id, scopeKey);
|
|
43
|
+
}
|
|
44
|
+
// Parse callback data: "model:providerID:modelID"
|
|
45
|
+
const parts = callbackQuery.data.split(":");
|
|
46
|
+
if (parts.length < 3) {
|
|
47
|
+
logger.error(`[ModelHandler] Invalid callback data format: ${callbackQuery.data}`);
|
|
48
|
+
clearActiveInlineMenu("model_select_invalid_callback", scopeKey);
|
|
49
|
+
await ctx.answerCallbackQuery({ text: t("model.change_error_callback") }).catch(() => { });
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
const providerID = parts[1];
|
|
53
|
+
const modelID = parts.slice(2).join(":"); // Handle model IDs that may contain ":"
|
|
54
|
+
const modelInfo = {
|
|
55
|
+
providerID,
|
|
56
|
+
modelID,
|
|
57
|
+
variant: "default", // Reset to default when switching models
|
|
58
|
+
};
|
|
59
|
+
// Select model and persist
|
|
60
|
+
selectModel(modelInfo, scopeKey);
|
|
61
|
+
// Update keyboard manager state (may not be initialized if no session selected)
|
|
62
|
+
keyboardManager.updateModel(modelInfo, scopeKey);
|
|
63
|
+
// Refresh context limit for new model
|
|
64
|
+
await pinnedMessageManager.refreshContextLimit(scopeKey);
|
|
65
|
+
// Update Reply Keyboard with new model and context
|
|
66
|
+
const currentAgent = getStoredAgent(scopeKey);
|
|
67
|
+
const contextInfo = pinnedMessageManager.getContextInfo(scopeKey) ??
|
|
68
|
+
(pinnedMessageManager.getContextLimit(scopeKey) > 0
|
|
69
|
+
? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit(scopeKey) }
|
|
70
|
+
: keyboardManager.getContextInfo(scopeKey));
|
|
71
|
+
if (contextInfo) {
|
|
72
|
+
keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit, scopeKey);
|
|
73
|
+
}
|
|
74
|
+
const variantName = formatVariantForButton(modelInfo.variant || "default");
|
|
75
|
+
const scope = getScopeFromKey(scopeKey);
|
|
76
|
+
const keyboard = createMainKeyboard(currentAgent, modelInfo, contextInfo ?? undefined, variantName, scope?.context === SCOPE_CONTEXT.GROUP_GENERAL
|
|
77
|
+
? {
|
|
78
|
+
contextFirst: true,
|
|
79
|
+
contextLabel: t("keyboard.general_defaults"),
|
|
80
|
+
}
|
|
81
|
+
: undefined);
|
|
82
|
+
const displayName = formatModelForDisplay(modelInfo.providerID, modelInfo.modelID);
|
|
83
|
+
clearActiveInlineMenu("model_selected", scopeKey);
|
|
84
|
+
// Send confirmation message with updated keyboard
|
|
85
|
+
await ctx.answerCallbackQuery({ text: t("model.changed_callback", { name: displayName }) });
|
|
86
|
+
await ctx.reply(t("model.changed_message", { name: displayName }), {
|
|
87
|
+
reply_markup: keyboard,
|
|
88
|
+
});
|
|
89
|
+
// Delete the inline menu message
|
|
90
|
+
await ctx.deleteMessage().catch(() => { });
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
clearActiveInlineMenu("model_select_error", getScopeKeyFromContext(ctx));
|
|
95
|
+
logger.error("[ModelHandler] Error handling model select:", err);
|
|
96
|
+
await ctx.answerCallbackQuery({ text: t("model.change_error_callback") }).catch(() => { });
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Build inline keyboard with favorite and recent models
|
|
102
|
+
* @param currentModel Current model for highlighting
|
|
103
|
+
* @returns InlineKeyboard with model selection buttons
|
|
104
|
+
*/
|
|
105
|
+
export async function buildModelSelectionMenu(currentModel, modelLists) {
|
|
106
|
+
const keyboard = new InlineKeyboard();
|
|
107
|
+
const lists = modelLists ?? (await getModelSelectionLists());
|
|
108
|
+
const favorites = lists.favorites;
|
|
109
|
+
const recent = lists.recent;
|
|
110
|
+
if (favorites.length === 0 && recent.length === 0) {
|
|
111
|
+
logger.warn("[ModelHandler] No model choices found in favorites/recent");
|
|
112
|
+
return keyboard;
|
|
113
|
+
}
|
|
114
|
+
const addButton = (model, prefix) => {
|
|
115
|
+
const isActive = currentModel &&
|
|
116
|
+
model.providerID === currentModel.providerID &&
|
|
117
|
+
model.modelID === currentModel.modelID;
|
|
118
|
+
// Inline buttons use full model ID without truncation
|
|
119
|
+
const label = `${prefix} ${model.providerID}/${model.modelID}`;
|
|
120
|
+
const labelWithCheck = isActive ? `✅ ${label}` : label;
|
|
121
|
+
keyboard.text(labelWithCheck, `model:${model.providerID}:${model.modelID}`).row();
|
|
122
|
+
};
|
|
123
|
+
favorites.forEach((model) => addButton(model, "⭐"));
|
|
124
|
+
recent.forEach((model) => addButton(model, "🕘"));
|
|
125
|
+
return keyboard;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Show model selection menu
|
|
129
|
+
* @param ctx grammY context
|
|
130
|
+
*/
|
|
131
|
+
export async function showModelSelectionMenu(ctx) {
|
|
132
|
+
try {
|
|
133
|
+
const scopeKey = getScopeKeyFromContext(ctx);
|
|
134
|
+
const currentModel = fetchCurrentModel(scopeKey);
|
|
135
|
+
const modelLists = await getModelSelectionLists();
|
|
136
|
+
const keyboard = await buildModelSelectionMenu(currentModel, modelLists);
|
|
137
|
+
if (keyboard.inline_keyboard.length === 0) {
|
|
138
|
+
await ctx.reply(t("model.menu.empty"));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const text = buildModelSelectionMenuText(modelLists);
|
|
142
|
+
await replyWithInlineMenu(ctx, {
|
|
143
|
+
menuKind: "model",
|
|
144
|
+
text,
|
|
145
|
+
keyboard,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
logger.error("[ModelHandler] Error showing model menu:", err);
|
|
150
|
+
await ctx.reply(t("model.menu.error"));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
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, getSessionById } from "../../session/manager.js";
|
|
6
|
+
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
7
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
8
|
+
import { INTERACTION_CLEAR_REASON } from "../../interaction/constants.js";
|
|
9
|
+
import { logger } from "../../utils/logger.js";
|
|
10
|
+
import { t } from "../../i18n/index.js";
|
|
11
|
+
import { sendMessageWithMarkdownFallback } from "../utils/send-with-markdown-fallback.js";
|
|
12
|
+
import { getScopeFromContext, getScopeKeyFromContext, getThreadSendOptions } from "../scope.js";
|
|
13
|
+
const PERMISSION_CALLBACK = {
|
|
14
|
+
PREFIX: "permission:",
|
|
15
|
+
SEPARATOR: ":",
|
|
16
|
+
ACTION_INDEX: 1,
|
|
17
|
+
REQUEST_ID_INDEX: 2,
|
|
18
|
+
};
|
|
19
|
+
// Permission type display names
|
|
20
|
+
const PERMISSION_NAME_KEYS = {
|
|
21
|
+
bash: "permission.name.bash",
|
|
22
|
+
edit: "permission.name.edit",
|
|
23
|
+
write: "permission.name.write",
|
|
24
|
+
read: "permission.name.read",
|
|
25
|
+
webfetch: "permission.name.webfetch",
|
|
26
|
+
websearch: "permission.name.websearch",
|
|
27
|
+
glob: "permission.name.glob",
|
|
28
|
+
grep: "permission.name.grep",
|
|
29
|
+
list: "permission.name.list",
|
|
30
|
+
task: "permission.name.task",
|
|
31
|
+
lsp: "permission.name.lsp",
|
|
32
|
+
};
|
|
33
|
+
// Permission type emojis
|
|
34
|
+
const PERMISSION_EMOJIS = {
|
|
35
|
+
bash: "⚡",
|
|
36
|
+
edit: "✏️",
|
|
37
|
+
write: "📝",
|
|
38
|
+
read: "📖",
|
|
39
|
+
webfetch: "🌐",
|
|
40
|
+
websearch: "🔍",
|
|
41
|
+
glob: "📁",
|
|
42
|
+
grep: "🔎",
|
|
43
|
+
list: "📂",
|
|
44
|
+
task: "⚙️",
|
|
45
|
+
lsp: "🔧",
|
|
46
|
+
};
|
|
47
|
+
function getCallbackMessageId(ctx) {
|
|
48
|
+
const message = ctx.callbackQuery?.message;
|
|
49
|
+
if (!message || !("message_id" in message)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const messageId = message.message_id;
|
|
53
|
+
return typeof messageId === "number" ? messageId : null;
|
|
54
|
+
}
|
|
55
|
+
function clearPermissionInteraction(reason, scopeKey) {
|
|
56
|
+
const state = interactionManager.getSnapshot(scopeKey);
|
|
57
|
+
if (state?.kind === "permission") {
|
|
58
|
+
interactionManager.clear(reason, scopeKey);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function syncPermissionInteractionState(scopeKey, metadata = {}) {
|
|
62
|
+
const pendingCount = permissionManager.getPendingCount(scopeKey);
|
|
63
|
+
if (pendingCount === 0) {
|
|
64
|
+
clearPermissionInteraction(INTERACTION_CLEAR_REASON.PERMISSION_NO_PENDING_REQUESTS, scopeKey);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const nextMetadata = {
|
|
68
|
+
pendingCount,
|
|
69
|
+
...metadata,
|
|
70
|
+
};
|
|
71
|
+
const state = interactionManager.getSnapshot(scopeKey);
|
|
72
|
+
if (state?.kind === "permission") {
|
|
73
|
+
interactionManager.transition({
|
|
74
|
+
expectedInput: "callback",
|
|
75
|
+
metadata: nextMetadata,
|
|
76
|
+
}, scopeKey);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
interactionManager.start({
|
|
80
|
+
kind: "permission",
|
|
81
|
+
expectedInput: "callback",
|
|
82
|
+
metadata: nextMetadata,
|
|
83
|
+
}, scopeKey);
|
|
84
|
+
}
|
|
85
|
+
function isPermissionReply(value) {
|
|
86
|
+
return value === "once" || value === "always" || value === "reject";
|
|
87
|
+
}
|
|
88
|
+
function parsePermissionCallback(data) {
|
|
89
|
+
if (!data.startsWith(PERMISSION_CALLBACK.PREFIX)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const parts = data.split(PERMISSION_CALLBACK.SEPARATOR);
|
|
93
|
+
const action = parts[PERMISSION_CALLBACK.ACTION_INDEX] ?? "";
|
|
94
|
+
if (!isPermissionReply(action)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const payloadRequestID = parts[PERMISSION_CALLBACK.REQUEST_ID_INDEX] ?? "";
|
|
98
|
+
const requestIDFromPayload = payloadRequestID.length > 0 ? payloadRequestID : null;
|
|
99
|
+
return {
|
|
100
|
+
action,
|
|
101
|
+
requestIDFromPayload,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function resolvePermissionRequest(messageId, requestIDFromPayload, scopeKey) {
|
|
105
|
+
const requestByMessageId = permissionManager.getRequest(messageId, scopeKey);
|
|
106
|
+
if (requestByMessageId) {
|
|
107
|
+
return {
|
|
108
|
+
messageId,
|
|
109
|
+
request: requestByMessageId,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (!requestIDFromPayload) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const matchByID = permissionManager.getRequestByID(requestIDFromPayload, scopeKey);
|
|
116
|
+
if (!matchByID) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
messageId: matchByID.messageId,
|
|
121
|
+
request: matchByID.request,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Handle permission callback from inline buttons
|
|
126
|
+
*/
|
|
127
|
+
export async function handlePermissionCallback(ctx) {
|
|
128
|
+
const data = ctx.callbackQuery?.data;
|
|
129
|
+
if (!data)
|
|
130
|
+
return false;
|
|
131
|
+
const parsedCallback = parsePermissionCallback(data);
|
|
132
|
+
if (!parsedCallback) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
logger.debug(`[PermissionHandler] Received callback: ${data}`);
|
|
136
|
+
const scopeKey = getScopeKeyFromContext(ctx);
|
|
137
|
+
if (!permissionManager.isActive(scopeKey)) {
|
|
138
|
+
clearPermissionInteraction(INTERACTION_CLEAR_REASON.PERMISSION_INACTIVE_CALLBACK, scopeKey);
|
|
139
|
+
await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
const callbackMessageId = getCallbackMessageId(ctx);
|
|
143
|
+
const resolvedRequest = resolvePermissionRequest(callbackMessageId, parsedCallback.requestIDFromPayload, scopeKey);
|
|
144
|
+
if (!resolvedRequest) {
|
|
145
|
+
await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
await handlePermissionReply(ctx, parsedCallback.action, resolvedRequest, scopeKey);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
logger.error("[PermissionHandler] Error handling callback:", err);
|
|
153
|
+
await ctx.answerCallbackQuery({
|
|
154
|
+
text: t("permission.processing_error_callback"),
|
|
155
|
+
show_alert: true,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Handle permission reply (once/always/reject)
|
|
162
|
+
*/
|
|
163
|
+
async function handlePermissionReply(ctx, reply, resolvedRequest, scopeKey) {
|
|
164
|
+
const { request, messageId: callbackMessageId } = resolvedRequest;
|
|
165
|
+
const requestID = request.id;
|
|
166
|
+
const currentProject = getCurrentProject(scopeKey);
|
|
167
|
+
const currentSession = getCurrentSession(scopeKey);
|
|
168
|
+
const cachedSession = getSessionById(request.sessionID);
|
|
169
|
+
const chatId = ctx.chat?.id;
|
|
170
|
+
const threadId = getScopeFromContext(ctx)?.threadId ?? null;
|
|
171
|
+
const directory = (currentSession?.id === request.sessionID ? currentSession.directory : null) ??
|
|
172
|
+
cachedSession?.directory ??
|
|
173
|
+
currentProject?.worktree;
|
|
174
|
+
if (!directory || !chatId) {
|
|
175
|
+
permissionManager.clear(scopeKey);
|
|
176
|
+
clearPermissionInteraction(INTERACTION_CLEAR_REASON.PERMISSION_INVALID_RUNTIME_CONTEXT, scopeKey);
|
|
177
|
+
await ctx.answerCallbackQuery({
|
|
178
|
+
text: t("permission.no_active_request_callback"),
|
|
179
|
+
show_alert: true,
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// Reply labels for user feedback
|
|
184
|
+
const replyLabels = {
|
|
185
|
+
once: t("permission.reply.once"),
|
|
186
|
+
always: t("permission.reply.always"),
|
|
187
|
+
reject: t("permission.reply.reject"),
|
|
188
|
+
};
|
|
189
|
+
await ctx.answerCallbackQuery({ text: replyLabels[reply] });
|
|
190
|
+
// Stop typing indicator since we're responding
|
|
191
|
+
summaryAggregator.stopTypingIndicator(request.sessionID);
|
|
192
|
+
logger.info(`[PermissionHandler] Sending permission reply: ${reply}, requestID=${requestID}`);
|
|
193
|
+
const { error } = await opencodeClient.permission.reply({
|
|
194
|
+
requestID,
|
|
195
|
+
directory,
|
|
196
|
+
reply,
|
|
197
|
+
});
|
|
198
|
+
if (error) {
|
|
199
|
+
logger.error("[PermissionHandler] Failed to send permission reply:", error);
|
|
200
|
+
if (ctx.api) {
|
|
201
|
+
await ctx.api
|
|
202
|
+
.sendMessage(chatId, t("permission.send_reply_error"), getThreadSendOptions(threadId))
|
|
203
|
+
.catch(() => { });
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
logger.info("[PermissionHandler] Permission reply sent successfully");
|
|
208
|
+
// Delete the permission message only after successful reply
|
|
209
|
+
await ctx.deleteMessage().catch(() => { });
|
|
210
|
+
permissionManager.removeByMessageId(callbackMessageId, scopeKey);
|
|
211
|
+
if (!permissionManager.isActive(scopeKey)) {
|
|
212
|
+
clearPermissionInteraction(INTERACTION_CLEAR_REASON.PERMISSION_REPLIED, scopeKey);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
syncPermissionInteractionState(scopeKey, {
|
|
216
|
+
lastRepliedRequestID: requestID,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Show permission request message with inline buttons
|
|
221
|
+
*/
|
|
222
|
+
export async function showPermissionRequest(bot, chatId, request, scopeKey, threadId) {
|
|
223
|
+
logger.debug(`[PermissionHandler] Showing permission request: ${request.permission}`);
|
|
224
|
+
const text = formatPermissionText(request);
|
|
225
|
+
const keyboard = buildPermissionKeyboard(request.id);
|
|
226
|
+
try {
|
|
227
|
+
const message = await sendMessageWithMarkdownFallback({
|
|
228
|
+
api: bot,
|
|
229
|
+
chatId,
|
|
230
|
+
text,
|
|
231
|
+
options: {
|
|
232
|
+
reply_markup: keyboard,
|
|
233
|
+
...getThreadSendOptions(threadId),
|
|
234
|
+
},
|
|
235
|
+
parseMode: "Markdown",
|
|
236
|
+
});
|
|
237
|
+
logger.debug(`[PermissionHandler] Message sent, messageId=${message.message_id}`);
|
|
238
|
+
permissionManager.startPermission(request, message.message_id, scopeKey);
|
|
239
|
+
syncPermissionInteractionState(scopeKey, {
|
|
240
|
+
requestID: request.id,
|
|
241
|
+
messageId: message.message_id,
|
|
242
|
+
});
|
|
243
|
+
summaryAggregator.stopTypingIndicator(request.sessionID);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
logger.error("[PermissionHandler] Failed to send permission message:", err);
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Format permission request text
|
|
252
|
+
*/
|
|
253
|
+
function formatPermissionText(request) {
|
|
254
|
+
const emoji = PERMISSION_EMOJIS[request.permission] || "🔐";
|
|
255
|
+
const nameKey = PERMISSION_NAME_KEYS[request.permission];
|
|
256
|
+
const name = nameKey ? t(nameKey) : request.permission;
|
|
257
|
+
let text = t("permission.header", { emoji, name });
|
|
258
|
+
// Show patterns (commands/files)
|
|
259
|
+
if (request.patterns.length > 0) {
|
|
260
|
+
request.patterns.forEach((pattern) => {
|
|
261
|
+
// Escape backticks for Markdown code
|
|
262
|
+
const escapedPattern = pattern.replace(/`/g, "\\`");
|
|
263
|
+
text += `\`${escapedPattern}\`\n`;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return text;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Build inline keyboard with permission buttons
|
|
270
|
+
*/
|
|
271
|
+
function buildPermissionKeyboard(requestID) {
|
|
272
|
+
const keyboard = new InlineKeyboard();
|
|
273
|
+
keyboard
|
|
274
|
+
.text(t("permission.button.allow"), `${PERMISSION_CALLBACK.PREFIX}once${PERMISSION_CALLBACK.SEPARATOR}${requestID}`)
|
|
275
|
+
.row();
|
|
276
|
+
keyboard
|
|
277
|
+
.text(t("permission.button.always"), `${PERMISSION_CALLBACK.PREFIX}always${PERMISSION_CALLBACK.SEPARATOR}${requestID}`)
|
|
278
|
+
.row();
|
|
279
|
+
keyboard.text(t("permission.button.reject"), `${PERMISSION_CALLBACK.PREFIX}reject${PERMISSION_CALLBACK.SEPARATOR}${requestID}`);
|
|
280
|
+
return keyboard;
|
|
281
|
+
}
|