Files
openclaw/extensions/telegram/src/bot-handlers.callback.runtime.ts
Peter Steinberger da44d52ac6 feat: ask_user — structured questions from the agent with web card, channel buttons, and text answers (#109922)
* feat(gateway): add transient question runtime (question.* methods + broadcasts)

* feat(agents): add blocking ask_user question tool with chat prompt delivery and text-reply claim

* feat(ui): interactive in-thread question cards for ask_user

* feat(channels): native tap-to-answer buttons for ask_user on Telegram, Discord, and Slack

* feat(ui): unify codex and gateway question cards with interactive gateway answering

* refactor(agents): collapse ask_user pending state to one registry; docs for ask_user

* fix(agents): include ask_user in normal gateway runs; add question-flow control-ui e2e

* test(ui): avoid credential-shaped fixture in question card test

* refactor(ui): reorder stream-group context keys

* fix(gateway,ui): validate question answers at resolve; reject secret/duplicate-label questions; UI retry and reconnect hardening

* fix(gateway,agents): canonicalize accepted option answers; bound ask_user option labels to 64 chars

* chore(ci): prune unused question exports, allowlist mobile question events, fix discord lint

* chore(ci): regenerate protocol/i18n/docs/tool-display artifacts for question surface

* fix(protocol): flatten QuestionRecord for native codegen; drop TS-only alias from schema registry

* chore(android): regenerate ask-user localization resources

* docs: regenerate docs map after rebase

* fix(ci): avoid stale read-only dependency disks

* test: remove stale reef lint suppression ratchet

* fix(ci): keep source locale drift advisory in release gates

* fix(ci): scope locale advisory handling to parity check
2026-07-17 22:24:17 +01:00

313 lines
11 KiB
TypeScript

// Telegram callback-query routing across approvals, plugin actions, selects, commands, and models.
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import {
hasTelegramApprovalCallbackPrefix,
parseTelegramApprovalCallbackData,
} from "./approval-callback-data.js";
import type {
TelegramEventAuthorizationMode,
TelegramHandlerAuthorizationRuntime,
} from "./bot-handlers.authorization.runtime.js";
import { createTelegramCallbackMessageActions } from "./bot-handlers.callback-actions.runtime.js";
import { createTelegramCallbackApprovalRuntime } from "./bot-handlers.callback-approvals.runtime.js";
import {
isPermanentTelegramCallbackEditError,
TelegramRetryableCallbackError,
} from "./bot-handlers.callback-errors.runtime.js";
import { handleTelegramInteractiveCallback } from "./bot-handlers.callback-interactions.runtime.js";
import { handleTelegramModelCallback } from "./bot-handlers.callback-model.runtime.js";
import { handleTelegramQuestionCallback } from "./bot-handlers.callback-questions.runtime.js";
import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js";
import { parseTelegramNativeCommandCallbackData } from "./bot-native-commands.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import {
isTelegramSpooledReplayUpdate,
recordTelegramMessageProcessingResult,
} from "./bot-processing-outcome.js";
import { resolveTelegramForumFlag, withResolvedTelegramForumFlag } from "./bot/helpers.js";
import type { TelegramGetChat } from "./bot/types.js";
import { getTelegramCallbackQueryAnswerPromise } from "./callback-query-answer-state.js";
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
import { parseTelegramOpaqueCallbackData } from "./native-command-callback-data.js";
import {
hasTelegramQuestionCallbackPrefix,
parseTelegramQuestionCallbackData,
} from "./question-callback-data.js";
export function registerTelegramCallbackQueryHandler(
{ accountId, bot, runtime, telegramDeps, shouldSkipUpdate }: RegisterTelegramHandlerParams,
messageRuntime: TelegramHandlerMessageRuntime,
authorizationRuntime: TelegramHandlerAuthorizationRuntime,
) {
const { buildSyntheticTextMessage, buildSyntheticContext, processMessageWithReplyChain } =
messageRuntime;
const {
resolveTelegramEventAuthorizationContext,
authorizeTelegramEventSender,
isTelegramModelCallbackAuthorized,
} = authorizationRuntime;
const getChat: TelegramGetChat = bot.api.getChat.bind(bot.api);
bot.on("callback_query", async (ctx) => {
const callback = ctx.callbackQuery;
if (!callback) {
return;
}
let callbackAnswered = false;
const answerCallbackQuery = async (text?: string) => {
// Callback answers prevent Telegram retries while the routed action runs.
await withTelegramApiErrorLogging({
operation: "answerCallbackQuery",
runtime,
fn: () =>
text
? bot.api.answerCallbackQuery(callback.id, { text })
: bot.api.answerCallbackQuery(callback.id),
}).catch(() => {});
callbackAnswered = true;
};
if (shouldSkipUpdate(ctx)) {
const earlyAnswerPromise = getTelegramCallbackQueryAnswerPromise(ctx);
if (earlyAnswerPromise) {
await earlyAnswerPromise.catch(async () => await answerCallbackQuery());
} else {
await answerCallbackQuery();
}
return;
}
const data = (callback.data ?? "").trim();
const typedQuestionCallback = parseTelegramQuestionCallbackData(data);
const earlyAnswerPromise = getTelegramCallbackQueryAnswerPromise(ctx);
if (earlyAnswerPromise) {
try {
await earlyAnswerPromise;
callbackAnswered = true;
} catch {
await answerCallbackQuery();
}
} else {
await answerCallbackQuery();
}
try {
const callbackMessage = callback.message;
if (!data || !callbackMessage) {
return;
}
const chatId = callbackMessage.chat.id;
const isGroup =
callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup";
const nativeCallbackCommand = parseTelegramNativeCommandCallbackData(data);
const opaqueCallbackData = parseTelegramOpaqueCallbackData(data);
const genericCallbackText = data.startsWith("/") ? data : `callback_data: ${data}`;
const callbackCommandText =
nativeCallbackCommand ?? (opaqueCallbackData ? "" : genericCallbackText);
const hasReservedApprovalPrefix = hasTelegramApprovalCallbackPrefix(data);
const hasReservedQuestionPrefix = hasTelegramQuestionCallbackPrefix(data);
const typedApprovalCallback = parseTelegramApprovalCallbackData(data);
const legacyApprovalCallback = parseExecApprovalCommandText(
nativeCallbackCommand ?? (opaqueCallbackData ? "" : data),
);
const isApprovalCallback = hasReservedApprovalPrefix || legacyApprovalCallback !== null;
const isRuntimeControlCallback = isApprovalCallback || hasReservedQuestionPrefix;
const authorizationCfg = telegramDeps.getRuntimeConfig();
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg: authorizationCfg,
accountId,
});
// Runtime controls retain their authorization after inline-button capability changes.
if (!isRuntimeControlCallback) {
if (
inlineButtonsScope === "off" ||
(inlineButtonsScope === "dm" && isGroup) ||
(inlineButtonsScope === "group" && !isGroup)
) {
return;
}
}
const messageThreadId = callbackMessage.message_thread_id;
const isForum = await resolveTelegramForumFlag({
chatId,
chatType: callbackMessage.chat.type,
isGroup,
isForum: callbackMessage.chat.is_forum,
isTopicMessage: callbackMessage.is_topic_message,
getChat,
});
const senderId = callback.from?.id ? String(callback.from.id) : "";
const senderUsername = callback.from?.username ?? "";
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
cfg: authorizationCfg,
chatId,
isGroup,
isForum,
senderId,
messageThreadId,
});
const { resolvedThreadId, dmThreadId, storeAllowFrom, groupConfig } = eventAuthContext;
const requireTopic = (groupConfig as { requireTopic?: boolean } | undefined)?.requireTopic;
if (!isGroup && requireTopic === true && dmThreadId == null) {
logVerbose(
`Blocked telegram callback in DM ${chatId}: requireTopic=true but no topic present`,
);
return;
}
const authorizationMode: TelegramEventAuthorizationMode = hasReservedQuestionPrefix
? "callback-runtime-allowlist"
: !isGroup || (!isRuntimeControlCallback && inlineButtonsScope === "allowlist")
? "callback-allowlist"
: "callback-scope";
const senderAuthorization = await authorizeTelegramEventSender({
chatId,
chatTitle: callbackMessage.chat.title,
isGroup,
senderId,
senderUsername,
mode: authorizationMode,
context: eventAuthContext,
});
if (!senderAuthorization) {
return;
}
const callbackThreadId = resolvedThreadId ?? dmThreadId;
const callbackConversationId =
callbackThreadId != null ? `${chatId}:topic:${callbackThreadId}` : String(chatId);
const runtimeCfg = telegramDeps.getRuntimeConfig();
const actions = createTelegramCallbackMessageActions({
bot,
callbackMessage,
isGroup,
isForum,
});
const approvalRuntime = createTelegramCallbackApprovalRuntime({
accountId,
telegramDeps,
runtimeCfg,
senderId,
actions,
});
const authorizeCallback = async () =>
await isTelegramModelCallbackAuthorized({
chatId,
isGroup,
senderId,
senderUsername,
context: eventAuthContext,
});
if (typedApprovalCallback) {
await approvalRuntime.handleCanonical(typedApprovalCallback);
return;
}
if (typedQuestionCallback) {
await handleTelegramQuestionCallback({
callback: typedQuestionCallback,
cfg: runtimeCfg,
senderId,
feedback: async (text, terminal) => {
if (terminal) {
await actions.clearCallbackButtons().catch(() => {});
}
await actions.replyToCallbackChat(text);
},
});
return;
}
if (hasReservedQuestionPrefix) {
return;
}
if (hasReservedApprovalPrefix) {
await approvalRuntime.handleMalformedReserved();
return;
}
if (
await handleTelegramInteractiveCallback({
accountId,
callback,
ctx,
callbackMessage,
data,
pluginCallbackData: opaqueCallbackData ?? data,
callbackConversationId,
callbackThreadId,
senderId,
senderUsername,
isGroup,
isForum,
storeAllowFrom,
actions,
messageRuntime,
authorizeCallback,
})
) {
return;
}
if (legacyApprovalCallback) {
await approvalRuntime.handleLegacy(legacyApprovalCallback);
return;
}
if (opaqueCallbackData) {
return;
}
if (
await handleTelegramModelCallback({
data,
ctx,
chatId,
isGroup,
isForum,
messageThreadId,
resolvedThreadId,
senderId,
runtimeCfg,
telegramDeps,
actions,
messageRuntime,
authorizeCallback,
})
) {
return;
}
const syntheticMessage = buildSyntheticTextMessage({
base: withResolvedTelegramForumFlag(callbackMessage, isForum),
from: callback.from,
text: callbackCommandText,
});
const syntheticCtx = buildSyntheticContext(ctx, syntheticMessage);
await processMessageWithReplyChain({
ctx: syntheticCtx,
msg: syntheticMessage,
allMedia: [],
storeAllowFrom,
options: {
...(nativeCallbackCommand ? { commandSource: "native" as const } : {}),
forceWasMentioned: true,
messageIdOverride: callback.id,
},
});
} catch (err) {
if (err instanceof TelegramRetryableCallbackError) {
if (isPermanentTelegramCallbackEditError(err.cause)) {
logVerbose(`telegram: swallowing permanent callback edit error: ${String(err.cause)}`);
return;
}
runtime.error?.(danger(`callback handler failed: ${String(err)}`));
throw err.cause;
}
runtime.error?.(danger(`callback handler failed: ${String(err)}`));
if (isTelegramSpooledReplayUpdate(ctx.update)) {
recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: err });
}
} finally {
if (typedQuestionCallback && !callbackAnswered) {
await answerCallbackQuery();
}
}
});
}