mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 02:11:41 +00:00
* 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
313 lines
11 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
});
|
|
}
|