add ask to run prompt for tool calls (demo) (#5261)

* add ask to run prompt for tools

* border-none on buttons

* translations

* linting

* i18n (#5263)

* extend approve/deny requests to telegram

* break up handler
This commit is contained in:
Timothy Carambat
2026-03-24 15:18:17 -07:00
committed by GitHub
parent 8937b8a98b
commit 7e9737dd86
44 changed files with 1154 additions and 8 deletions

View File

@@ -0,0 +1,46 @@
const { AgentSkillWhitelist } = require("../models/agentSkillWhitelist");
const { reqBody, userFromSession } = require("../utils/http");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
const {
flexUserRoleValid,
ROLES,
} = require("../utils/middleware/multiUserProtected");
function agentSkillWhitelistEndpoints(app) {
if (!app) return;
app.post(
"/agent-skills/whitelist/add",
[validatedRequest, flexUserRoleValid(ROLES.all)],
async (request, response) => {
try {
const { skillName } = reqBody(request);
if (!skillName) {
response
.status(400)
.json({ success: false, error: "Missing skillName" });
return;
}
const user = await userFromSession(request, response);
if (!user && response.locals?.multiUserMode) {
return response
.status(401)
.json({ success: false, error: "Unauthorized" });
}
const userId = user?.id || null;
const { success, error } = await AgentSkillWhitelist.add(
skillName,
userId
);
return response.status(success ? 200 : 400).json({ success, error });
} catch (e) {
console.error(e);
return response.status(500).json({ success: false, error: e.message });
}
}
);
}
module.exports = { agentSkillWhitelistEndpoints };

View File

@@ -11,6 +11,7 @@ const { safeJsonParse } = require("../utils/http");
// Setup listener for incoming messages to relay to socket so it can be handled by agent plugin.
function relayToSocket(message) {
if (this.handleFeedback) return this?.handleFeedback?.(message);
if (this.handleToolApproval) return this?.handleToolApproval?.(message);
this.checkBailCommand(message);
}

View File

@@ -60,6 +60,7 @@ const {
const { TemporaryAuthToken } = require("../models/temporaryAuthToken");
const { SystemPromptVariables } = require("../models/systemPromptVariables");
const { VALID_COMMANDS } = require("../utils/chats");
const { AgentSkillWhitelist } = require("../models/agentSkillWhitelist");
function systemEndpoints(app) {
if (!app) return;
@@ -619,7 +620,7 @@ function systemEndpoints(app) {
multi_user_mode: true,
});
await BrowserExtensionApiKey.migrateApiKeysToMultiUser(user.id);
await AgentSkillWhitelist.clearSingleUserWhitelist();
await updateENV(
{
JWTSecret: process.env.JWT_SECRET || v4(),

View File

@@ -23,6 +23,9 @@ const { bootHTTP, bootSSL } = require("./utils/boot");
const { workspaceThreadEndpoints } = require("./endpoints/workspaceThreads");
const { documentEndpoints } = require("./endpoints/document");
const { agentWebsocket } = require("./endpoints/agentWebsocket");
const {
agentSkillWhitelistEndpoints,
} = require("./endpoints/agentSkillWhitelist");
const { experimentalEndpoints } = require("./endpoints/experimental");
const { browserExtensionEndpoints } = require("./endpoints/browserExtension");
const { communityHubEndpoints } = require("./endpoints/communityHub");
@@ -75,6 +78,7 @@ embedManagementEndpoints(apiRouter);
utilEndpoints(apiRouter);
documentEndpoints(apiRouter);
agentWebsocket(apiRouter);
agentSkillWhitelistEndpoints(apiRouter);
experimentalEndpoints(apiRouter);
developerEndpoints(app, apiRouter);
communityHubEndpoints(apiRouter);

View File

@@ -8,6 +8,9 @@ const { WorkspaceThread } = require("../models/workspaceThread");
const { streamResponse } = require("../utils/telegramBot/chat/stream");
process.on("message", async (payload) => {
// Ignore tool approval responses - these are handled by http-socket plugin
if (payload?.type === "toolApprovalResponse") return;
const {
botToken,
chatId,

View File

@@ -0,0 +1,100 @@
const prisma = require("../utils/prisma");
const { safeJsonParse } = require("../utils/http");
const AgentSkillWhitelist = {
SINGLE_USER_LABEL: "whitelisted_agent_skills",
/**
* Get the label for storing whitelist in system_settings
* @param {number|null} userId - User ID in multi-user mode, null for single-user
* @returns {string}
*/
_getLabel: function (userId = null) {
if (userId) return `user_${userId}_whitelisted_agent_skills`;
return this.SINGLE_USER_LABEL;
},
/**
* Get the whitelisted skills for a user or the system
* @param {number|null} userId - User ID in multi-user mode, null for single-user
* @returns {Promise<string[]>} Array of whitelisted skill names
*/
get: async function (userId = null) {
try {
const label = this._getLabel(userId);
const setting = await prisma.system_settings.findFirst({
where: { label },
});
return safeJsonParse(setting?.value, []);
} catch (error) {
console.error("AgentSkillWhitelist.get error:", error.message);
return [];
}
},
/**
* Add a skill to the whitelist
* @param {string} skillName - The skill name to whitelist
* @param {number|null} userId - User ID in multi-user mode, null for single-user
* @returns {Promise<{success: boolean, error: string|null}>}
*/
add: async function (skillName, userId = null) {
try {
if (!skillName || typeof skillName !== "string") {
return { success: false, error: "Invalid skill name" };
}
const label = this._getLabel(userId);
const currentList = await this.get(userId);
if (currentList.includes(skillName)) {
return { success: true, error: null };
}
const newList = [...currentList, skillName];
await prisma.system_settings.upsert({
where: { label },
update: { value: JSON.stringify(newList) },
create: { label, value: JSON.stringify(newList) },
});
return { success: true, error: null };
} catch (error) {
console.error("AgentSkillWhitelist.add error:", error.message);
return { success: false, error: error.message };
}
},
/**
* Check if a skill is whitelisted
* @param {string} skillName - The skill name to check
* @param {number|null} userId - User ID in multi-user mode, null for single-user
* @returns {Promise<boolean>}
*/
isWhitelisted: async function (skillName, userId = null) {
const whitelist = await this.get(userId);
return whitelist.includes(skillName);
},
/**
* Clear the single-user whitelist (used when switching to multi-user mode)
* @returns {Promise<{success: boolean, error: string|null}>}
*/
clearSingleUserWhitelist: async function () {
try {
await prisma.system_settings.deleteMany({
where: { label: this.SINGLE_USER_LABEL },
});
return { success: true, error: null };
} catch (error) {
console.error(
"AgentSkillWhitelist.clearSingleUserWhitelist error:",
error.message
);
return { success: false, error: error.message };
}
},
};
module.exports = { AgentSkillWhitelist };

View File

@@ -1,10 +1,48 @@
const chalk = require("chalk");
const { Telemetry } = require("../../../../models/telemetry");
const { v4: uuidv4 } = require("uuid");
const TOOL_APPROVAL_TIMEOUT_MS = 120 * 1_000; // 2 mins for tool approval
/**
* Get the IPC channel for worker communication.
* Bree workers use worker_threads internally but polyfill process.on("message") for receiving.
* Workers send via parentPort.postMessage(), receive via process.on("message").
* @returns {{ send: Function, on: Function, removeListener: Function } | null}
*/
function getWorkerIPC() {
try {
const { parentPort } = require("node:worker_threads");
if (parentPort) {
// Bree worker context: send via parentPort, receive via process (Bree polyfill)
return {
send: (msg) => parentPort.postMessage(msg),
on: (event, handler) => process.on(event, handler),
removeListener: (event, handler) =>
process.removeListener(event, handler),
};
}
} catch {}
// Fallback for child_process workers
if (typeof process.send === "function") {
return {
send: (msg) => process.send(msg),
on: (event, handler) => process.on(event, handler),
removeListener: (event, handler) =>
process.removeListener(event, handler),
};
}
return null;
}
/**
* HTTP Interface plugin for Aibitat to emulate a websocket interface in the agent
* framework so we dont have to modify the interface for passing messages and responses
* in REST or WSS.
*
* When telegramChatId is provided, enables tool approval via Telegram inline keyboards
* using IPC messages to communicate with the parent TelegramBotService process.
*/
const httpSocket = {
name: "httpSocket",
@@ -21,12 +59,17 @@ const httpSocket = {
required: false,
default: true,
},
telegramChatId: {
required: false,
default: null,
},
},
},
plugin: function ({
handler,
muteUserReply = true, // Do not post messages to "USER" back to frontend.
introspection = false, // when enabled will attach socket to Aibitat object with .introspect method which reports status updates to frontend.
telegramChatId = null, // When set, enables tool approval via Telegram IPC
}) {
return {
name: this.name,
@@ -59,6 +102,125 @@ const httpSocket = {
},
};
/**
* Request user approval before executing a tool/skill.
* Only available when running in Telegram context (telegramChatId is set).
* Sends IPC message to parent process which shows Telegram inline keyboard.
*
* @param {Object} options - The approval request options
* @param {string} options.skillName - The name of the skill/tool requesting approval
* @param {Object} [options.payload={}] - Optional payload data to display to the user
* @param {string} [options.description] - Optional description of what the skill will do
* @returns {Promise<{approved: boolean, message: string}>} - The approval result
*/
aibitat.requestToolApproval = async function ({
skillName,
payload = {},
description = null,
}) {
// Check whitelist first
const {
AgentSkillWhitelist,
} = require("../../../../models/agentSkillWhitelist");
const isWhitelisted = await AgentSkillWhitelist.isWhitelisted(
skillName,
null
);
if (isWhitelisted) {
console.log(
chalk.green(`Skill ${skillName} is whitelisted - auto-approved.`)
);
return {
approved: true,
message: "Skill is whitelisted - auto-approved.",
};
}
// Tool approval only available in Telegram worker context
const ipc = getWorkerIPC();
if (!telegramChatId || !ipc) {
console.log(
chalk.yellow(
`Tool approval requested for ${skillName} but no Telegram context available. Auto-denying for safety.`
)
);
return {
approved: false,
message:
"Tool approval is not available in this context. Operation denied.",
};
}
const requestId = uuidv4();
console.log(
chalk.blue(
`Requesting tool approval for ${skillName} (${requestId})`
)
);
// Send introspection message before the approval UI appears
aibitat.introspect(
`Requesting approval to execute: ${skillName}${description ? ` - ${description}` : ""}`
);
return new Promise((resolve) => {
let timeoutId = null;
const messageHandler = (msg) => {
if (msg?.type !== "toolApprovalResponse") return;
if (msg?.requestId !== requestId) return;
ipc.removeListener("message", messageHandler);
clearTimeout(timeoutId);
if (msg.approved) {
console.log(
chalk.green(`Tool ${skillName} approved by user via Telegram`)
);
return resolve({
approved: true,
message: "User approved the tool execution.",
});
}
console.log(
chalk.yellow(`Tool ${skillName} denied by user via Telegram`)
);
return resolve({
approved: false,
message: "Tool call was rejected by the user.",
});
};
ipc.on("message", messageHandler);
// Send approval request to parent TelegramBotService process
ipc.send({
type: "toolApprovalRequest",
requestId,
chatId: telegramChatId,
skillName,
payload,
description,
timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
});
timeoutId = setTimeout(() => {
ipc.removeListener("message", messageHandler);
console.log(
chalk.yellow(
`Tool approval request timed out after ${TOOL_APPROVAL_TIMEOUT_MS}ms`
)
);
resolve({
approved: false,
message:
"Tool approval request timed out. User did not respond in time.",
});
}, TOOL_APPROVAL_TIMEOUT_MS);
});
};
// We can only receive one message response with HTTP
// so we end on first response.
aibitat.onMessage((message) => {

View File

@@ -1,6 +1,9 @@
const chalk = require("chalk");
const { Telemetry } = require("../../../../models/telemetry");
const { v4: uuidv4 } = require("uuid");
const { safeJsonParse } = require("../../../http");
const SOCKET_TIMEOUT_MS = 300 * 1_000; // 5 mins
const TOOL_APPROVAL_TIMEOUT_MS = 120 * 1_000; // 2 mins for tool approval
/**
* Websocket Interface plugin. It prints the messages on the console and asks for feedback
@@ -11,6 +14,7 @@ const SOCKET_TIMEOUT_MS = 300 * 1_000; // 5 mins
// askForFeedback?: any
// awaitResponse?: any
// handleFeedback?: (message: string) => void;
// handleToolApproval?: (message: string) => void;
// }
const WEBSOCKET_BAIL_COMMANDS = [
@@ -43,6 +47,7 @@ const websocket = {
socket, // @type AIbitatWebSocket
muteUserReply = true, // Do not post messages to "USER" back to frontend.
introspection = false, // when enabled will attach socket to Aibitat object with .introspect method which reports status updates to frontend.
userId = null, // User ID for multi-user mode whitelist lookups
}) {
return {
name: this.name,
@@ -79,6 +84,102 @@ const websocket = {
},
};
/**
* Request user approval before executing a tool/skill.
* This sends a request to the frontend and blocks until the user responds.
* If the skill is whitelisted, approval is granted automatically.
*
* @param {Object} options - The approval request options
* @param {string} options.skillName - The name of the skill/tool requesting approval
* @param {Object} [options.payload={}] - Optional payload data to display to the user
* @param {string} [options.description] - Optional description of what the skill will do
* @returns {Promise<{approved: boolean, message: string}>} - The approval result
*/
aibitat.requestToolApproval = async function ({
skillName,
payload = {},
description = null,
}) {
const {
AgentSkillWhitelist,
} = require("../../../../models/agentSkillWhitelist");
const isWhitelisted = await AgentSkillWhitelist.isWhitelisted(
skillName,
userId
);
if (isWhitelisted) {
console.log(
chalk.green(
userId
? `User ${userId} - `
: "" + `Skill ${skillName} is whitelisted - auto-approved.`
)
);
return {
approved: true,
message: "Skill is whitelisted - auto-approved.",
};
}
const requestId = uuidv4();
return new Promise((resolve) => {
let timeoutId = null;
socket.handleToolApproval = (message) => {
try {
const data = safeJsonParse(message, {});
if (
data?.type !== "toolApprovalResponse" ||
data?.requestId !== requestId
)
return;
delete socket.handleToolApproval;
clearTimeout(timeoutId);
if (data.approved) {
return resolve({
approved: true,
message: "User approved the tool execution.",
});
}
return resolve({
approved: false,
message: "Tool call was rejected by the user.",
});
} catch (e) {
console.error("Error handling tool approval response:", e);
}
};
socket.send(
JSON.stringify({
type: "toolApprovalRequest",
requestId,
skillName,
payload,
description,
timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
})
);
timeoutId = setTimeout(() => {
delete socket.handleToolApproval;
console.log(
chalk.yellow(
`Tool approval request timed out after ${TOOL_APPROVAL_TIMEOUT_MS}ms`
)
);
resolve({
approved: false,
message:
"Tool approval request timed out. User did not respond in time.",
});
}, TOOL_APPROVAL_TIMEOUT_MS);
});
};
// aibitat.onStart(() => {
// console.log("🚀 starting chat ...");
// });

View File

@@ -436,6 +436,7 @@ class EphemeralAgentHandler extends AgentHandler {
async createAIbitat(
args = {
handler: null,
telegramChatId: null,
}
) {
this.aibitat = new AIbitat({
@@ -456,12 +457,14 @@ class EphemeralAgentHandler extends AgentHandler {
this.aibitat.fetchParsedFileContext = () => this.#fetchParsedFileContext();
// Attach HTTP response object if defined for chunk streaming.
// When telegramChatId is provided, tool approval via Telegram is enabled.
this.log(`Attached ${httpSocket.name} plugin to Agent cluster`);
this.aibitat.use(
httpSocket.plugin({
handler: args.handler,
muteUserReply: true,
introspection: true,
telegramChatId: args.telegramChatId,
})
);

View File

@@ -726,6 +726,7 @@ class AgentHandler {
socket: args.socket,
muteUserReply: true,
introspection: true,
userId: this.invocation.user_id || null,
})
);

View File

@@ -232,7 +232,7 @@ async function handleAgentResponse(
threadId: thread?.id || null,
attachments,
}).init();
await agentHandler.createAIbitat({ handler });
await agentHandler.createAIbitat({ handler, telegramChatId: chatId });
// httpSocket terminates after the first agent message, but cap rounds
// as a safety net so the agent can't loop indefinitely.

View File

@@ -38,6 +38,8 @@ class TelegramBotService {
#pendingPairings = new Map();
// Active workers per chat: chatId -> { worker, jobId }
#activeWorkers = new Map();
// Pending tool approval requests: requestId -> { worker, chatId, messageId }
#pendingToolApprovals = new Map();
constructor() {
if (TelegramBotService._instance) return TelegramBotService._instance;
@@ -153,6 +155,7 @@ class TelegramBotService {
this.#chatState.clear();
this.#pendingPairings.clear();
this.#activeWorkers.clear();
this.#pendingToolApprovals.clear();
this.#log("Stopped");
}
@@ -349,9 +352,12 @@ class TelegramBotService {
guard(msg, () => handler(ctx, msg.chat.id, msg.text));
});
// Register callback queries, used for workspace/thread selection interactive menus
// Register callback queries, used for workspace/thread selection, tool approval, etc.
this.#bot.on("callback_query", (query) =>
handleKeyboardQueryCallback(ctx, query)
handleKeyboardQueryCallback(ctx, query, {
pendingToolApprovals: this.#pendingToolApprovals,
log: this.#log.bind(this),
})
);
this.#bot.on("message", (msg) => {
@@ -396,6 +402,9 @@ class TelegramBotService {
if (worker) {
worker.on("message", (msg) => {
if (msg?.type === "closeInvocation") invocationUuid = msg.uuid;
if (msg?.type === "toolApprovalRequest") {
this.#handleToolApprovalRequest(worker, msg);
}
});
this.#activeWorkers.set(chatId, { worker, jobId, bgService });
}
@@ -467,6 +476,91 @@ class TelegramBotService {
return true;
}
/**
* Handle a tool approval request from a worker process.
* Sends a Telegram message with Approve/Deny inline keyboard buttons.
* @param {Worker} worker - The worker process requesting approval
* @param {Object} msg - The tool approval request message
*/
async #handleToolApprovalRequest(worker, msg) {
const { requestId, chatId, skillName, payload, description, timeoutMs } =
msg;
this.#log(
`Tool approval request received: ${skillName} (requestId: ${requestId})`
);
try {
const payloadText =
payload && Object.keys(payload).length > 0
? `\n\n<b>Parameters:</b>\n<code>${JSON.stringify(payload, null, 2)}</code>`
: "";
const descText = description ? `\n${description}` : "";
const messageText =
`🔧 <b>Tool Approval Required</b>\n\n` +
`The agent wants to execute: <b>${skillName}</b>${descText}${payloadText}\n\n` +
`Do you want to allow this action?`;
const keyboard = {
inline_keyboard: [
[
{
text: "✅ Approve",
callback_data: `tool:approve:${requestId}`,
},
{ text: "❌ Deny", callback_data: `tool:deny:${requestId}` },
],
],
};
const sent = await this.#bot.sendMessage(chatId, messageText, {
parse_mode: "HTML",
reply_markup: keyboard,
});
this.#pendingToolApprovals.set(requestId, {
worker,
chatId,
messageId: sent.message_id,
skillName,
});
// Auto-cleanup if timeout expires (worker will also timeout)
setTimeout(() => {
if (this.#pendingToolApprovals.has(requestId)) {
this.#pendingToolApprovals.delete(requestId);
this.#bot
.editMessageText(
`⏱️ Tool approval for <b>${skillName}</b> timed out.`,
{
chat_id: chatId,
message_id: sent.message_id,
parse_mode: "HTML",
}
)
.catch(() => {});
}
}, timeoutMs + 1000);
} catch (error) {
this.#log("Failed to send tool approval request:", error.message);
// Send denial back to worker if we can't show the UI
try {
const response = {
type: "toolApprovalResponse",
requestId,
approved: false,
};
if (worker && typeof worker.send === "function") {
worker.send(response);
} else if (worker && typeof worker.postMessage === "function") {
worker.postMessage(response);
}
} catch {}
}
}
#shouldVoiceRespond(isVoiceMessage) {
if (!this.#config) return false;
const mode = this.#config.voice_response_mode || "text_only";

View File

@@ -0,0 +1,104 @@
/**
* Handle tool approval callback from an inline keyboard button.
* This handler requires access to the pending tool approvals map from TelegramBotService.
*
* @param {object} params
* @param {import("../../commands/index").BotContext} params.ctx
* @param {number} params.chatId
* @param {{id: string}} params.query
* @param {string} params.data
* @param {Map} params.pendingToolApprovals - Map of pending tool approval requests
* @param {Function} [params.log] - Logging function (falls back to ctx.log)
*/
async function handleToolApproval({
ctx,
chatId,
query,
data,
pendingToolApprovals,
log,
} = {}) {
const _log = log || ctx.log;
_log(`Tool approval callback received: ${data}`);
try {
const parts = data.split(":");
if (parts.length !== 3) {
_log(`Invalid callback data format: ${data}`);
await ctx.bot.answerCallbackQuery(query.id, {
text: "Invalid request format.",
});
return;
}
const action = parts[1]; // "approve" or "deny"
const requestId = parts[2];
const pending = pendingToolApprovals.get(requestId);
if (!pending) {
_log(`No pending approval found for requestId: ${requestId}`);
await ctx.bot.answerCallbackQuery(query.id, {
text: "This approval request has expired.",
});
return;
}
const { worker, messageId, skillName } = pending;
const approved = action === "approve";
_log(`Processing ${approved ? "approval" : "denial"} for ${skillName}`);
// Send response back to worker (Bree workers use send(), raw worker_threads use postMessage())
try {
const response = {
type: "toolApprovalResponse",
requestId,
approved,
};
if (worker && typeof worker.send === "function") {
worker.send(response);
_log(
`Sent tool approval response to worker via send(): ${approved ? "approved" : "denied"}`
);
} else if (worker && typeof worker.postMessage === "function") {
worker.postMessage(response);
_log(
`Sent tool approval response to worker via postMessage(): ${approved ? "approved" : "denied"}`
);
} else {
_log(
`Worker not available to send approval response (send: ${typeof worker?.send}, postMessage: ${typeof worker?.postMessage})`
);
}
} catch (err) {
_log(`Failed to send approval response: ${err.message}`);
}
pendingToolApprovals.delete(requestId);
// Update the message to show the result
const resultText = approved
? `✅ <b>${skillName}</b> was approved.`
: `❌ <b>${skillName}</b> was denied.`;
await ctx.bot
.editMessageText(resultText, {
chat_id: chatId,
message_id: messageId,
parse_mode: "HTML",
})
.catch((err) => _log(`Failed to edit message: ${err.message}`));
await ctx.bot.answerCallbackQuery(query.id, {
text: approved ? "Approved!" : "Denied.",
});
} catch (error) {
_log(`Error handling tool approval callback: ${error.message}`);
await ctx.bot.answerCallbackQuery(query.id, {
text: "Something went wrong.",
});
}
}
module.exports = { handleToolApproval };

View File

@@ -10,6 +10,7 @@ const { handleModelSelect } = require("./handleModelSelect");
const { handleSourceSelect } = require("./handleSourceSelect");
const { handleSourcePagination } = require("./handleSourcePagination");
const { handleBackSources } = require("./handleBackSources");
const { handleToolApproval } = require("./handleToolApproval");
const ExactCallbackHandlers = {
"ws-create": handleWorkspaceCreate,
@@ -20,6 +21,7 @@ const ExactCallbackHandlers = {
};
const PrefixCallbackHandlers = [
{ prefix: "tool:", handler: handleToolApproval },
{ prefix: "wspg:", handler: handleWorkspacePagination },
{ prefix: "ws:", handler: handleWorkspaceSelect },
{ prefix: "thpg:", handler: handleThreadPagination },

View File

@@ -2,11 +2,12 @@ const { isVerified } = require("../verification");
const { resolveCallbackHandler } = require("./callbacks");
/**
* Handle inline keyboard callback queries (workspace/thread selection).
* Handle inline keyboard callback queries (workspace/thread selection, tool approval, etc).
* @param {BotContext} ctx
* @param {object} query - Telegram callback query object
* @param {object} [options={}] - Optional dependencies that specific handlers may need
*/
async function handleKeyboardQueryCallback(ctx, query) {
async function handleKeyboardQueryCallback(ctx, query, options = {}) {
const chatId = query.message.chat.id;
const messageId = query.message.message_id;
const data = query.data;
@@ -21,7 +22,7 @@ async function handleKeyboardQueryCallback(ctx, query) {
try {
const handler = resolveCallbackHandler(data);
if (!handler) throw new Error(`Callback handler not found: ${data}`);
await handler({ ctx, chatId, query, messageId, data });
await handler({ ctx, chatId, query, messageId, data, ...options });
} catch (error) {
ctx.log("Callback error:", error.message);
await ctx.bot.answerCallbackQuery(query.id, {