Files
openclaw/extensions/telegram/src/sequential-key.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

210 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Telegram plugin module implements sequential key behavior.
import type { Message, UserFromGetMe } from "grammy/types";
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
import {
listChatCommands,
maybeResolveTextAlias,
normalizeCommandBody,
} from "openclaw/plugin-sdk/command-auth-native";
import {
isAbortRequestText,
isBtwRequestText,
} from "openclaw/plugin-sdk/command-primitives-runtime";
import {
resolveTelegramForumThreadId,
resolveTelegramMessageForumFlagHint,
} from "./bot/helpers.js";
import { parseTelegramQuestionCallbackData } from "./question-callback-data.js";
const TELEGRAM_READ_ONLY_STATUS_COMMAND_KEYS = new Set([
"commands",
"context",
"help",
"status",
"tasks",
"tools",
"whoami",
]);
const TELEGRAM_ACTIVE_RUN_CONTROL_COMMAND_KEYS = new Set(["queue", "steer"]);
type TelegramSequentialKeyContext = {
chat?: { id?: number };
me?: UserFromGetMe;
message?: Message;
channelPost?: Message;
editedMessage?: Message;
editedChannelPost?: Message;
update?: {
message?: Message;
edited_message?: Message;
channel_post?: Message;
edited_channel_post?: Message;
callback_query?: { message?: Message; data?: string };
message_reaction?: { chat?: { id?: number } };
};
};
export function isTelegramReadOnlyControlLaneText(params: {
rawText?: string;
botUsername?: string;
}): boolean {
// Only read-only status commands should bypass the per-topic lane.
// Diagnostics and export commands materialize state and should not interleave with an active turn.
const normalizedBody = normalizeCommandBody(
params.rawText?.trim() ?? "",
params.botUsername ? { botUsername: params.botUsername } : undefined,
);
const alias = maybeResolveTextAlias(normalizedBody);
if (!alias) {
return false;
}
const command = listChatCommands().find((entry) =>
entry.textAliases.some((candidate) => candidate.trim().toLowerCase() === alias),
);
return command?.category === "status" && TELEGRAM_READ_ONLY_STATUS_COMMAND_KEYS.has(command.key);
}
function isTelegramTargetedStopCommand(rawText?: string, botUsername?: string): boolean {
const trimmed = rawText?.trim();
if (!trimmed) {
return false;
}
// Isolated ingress may not have getMe() metadata yet. A targeted Telegram
// /stop@bot command still needs the control lane so it can cancel a busy turn.
const match = trimmed.match(/^\/stop@([A-Za-z0-9_]+)(?:$|\s|[.!?,;:'")\]}])/iu);
if (!match) {
return false;
}
const normalizedBotUsername = botUsername?.trim().toLowerCase();
if (!normalizedBotUsername) {
return true;
}
return match[1]?.toLowerCase() === normalizedBotUsername;
}
function resolveTelegramCommandAliasForControlLane(
rawText?: string,
botUsername?: string,
): string | undefined {
const trimmed = rawText?.trim();
if (!trimmed?.startsWith("/")) {
return undefined;
}
const targetedMatch = trimmed.match(
/^\/([A-Za-z0-9_-]+)(?:@([A-Za-z0-9_]+))?(?:$|\s|[.!?,;:'")\]}])/iu,
);
const targetBotUsername = targetedMatch?.[2]?.trim().toLowerCase();
const normalizedBotUsername = botUsername?.trim().toLowerCase();
if (targetBotUsername && normalizedBotUsername && targetBotUsername !== normalizedBotUsername) {
return undefined;
}
if (targetBotUsername && !normalizedBotUsername) {
const commandAlias = `/${targetedMatch?.[1]?.toLowerCase() ?? ""}`;
return commandAlias === "/" ? undefined : commandAlias;
}
return (
maybeResolveTextAlias(
normalizeCommandBody(trimmed, botUsername ? { botUsername } : undefined),
) ?? undefined
);
}
function isTelegramActiveRunControlLaneText(params: {
rawText?: string;
botUsername?: string;
}): boolean {
const alias = resolveTelegramCommandAliasForControlLane(params.rawText, params.botUsername);
if (!alias) {
return false;
}
const command = listChatCommands().find((entry) =>
entry.textAliases.some((candidate) => candidate.trim().toLowerCase() === alias),
);
return command ? TELEGRAM_ACTIVE_RUN_CONTROL_COMMAND_KEYS.has(command.key) : false;
}
function isTelegramControlLaneText(params: { rawText?: string; botUsername?: string }): boolean {
if (
isAbortRequestText(
params.rawText,
params.botUsername ? { botUsername: params.botUsername } : undefined,
)
) {
return true;
}
if (isTelegramTargetedStopCommand(params.rawText, params.botUsername)) {
return true;
}
if (isTelegramActiveRunControlLaneText(params)) {
return true;
}
return isTelegramReadOnlyControlLaneText(params);
}
export function getTelegramSequentialKey(ctx: TelegramSequentialKeyContext): string {
const reaction = ctx.update?.message_reaction;
if (reaction?.chat?.id) {
return `telegram:${reaction.chat.id}`;
}
const msg =
ctx.message ??
ctx.channelPost ??
ctx.editedMessage ??
ctx.editedChannelPost ??
ctx.update?.message ??
ctx.update?.edited_message ??
ctx.update?.channel_post ??
ctx.update?.edited_channel_post ??
ctx.update?.callback_query?.message;
const chatId = msg?.chat?.id ?? ctx.chat?.id;
const rawText = msg?.text ?? msg?.caption;
const botUsername = ctx.me?.username;
if (isTelegramControlLaneText({ rawText, botUsername })) {
if (typeof chatId === "number") {
return `telegram:${chatId}:control`;
}
return "telegram:control";
}
if (isBtwRequestText(rawText, botUsername ? { botUsername } : undefined)) {
const messageId = msg?.message_id;
if (typeof chatId === "number" && typeof messageId === "number") {
return `telegram:${chatId}:btw:${messageId}`;
}
if (typeof chatId === "number") {
return `telegram:${chatId}:btw`;
}
return "telegram:btw";
}
const callbackData = ctx.update?.callback_query?.data;
if (parseTelegramQuestionCallbackData(callbackData)) {
if (typeof chatId === "number") {
return `telegram:${chatId}:question`;
}
return "telegram:question";
}
if (callbackData && parseExecApprovalCommandText(callbackData) !== null) {
if (typeof chatId === "number") {
return `telegram:${chatId}:approval`;
}
return "telegram:approval";
}
const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup";
const messageThreadId = msg?.message_thread_id;
const isForum = resolveTelegramMessageForumFlagHint({
chatType: msg?.chat?.type,
isForum: msg?.chat?.is_forum,
isTopicMessage: msg?.is_topic_message,
});
const threadId = isGroup
? resolveTelegramForumThreadId({ isForum, messageThreadId })
: messageThreadId;
if (typeof chatId === "number") {
return threadId != null ? `telegram:${chatId}:topic:${threadId}` : `telegram:${chatId}`;
}
return "telegram:unknown";
}