Files
openclaw/extensions/telegram/src/sequential-key.ts
VACInc f526d96c98 Fix Telegram forum topic parallel flow (#83829)
Summary:
- The branch fixes Telegram forum-topic session routing, per-topic text/media buffering, media-group scoping, and outbound group send fairness, with focused Telegram regression tests and a changelog entry.
- Reproducibility: yes. source inspection of current main plus the PR body's before-proof give a high-confiden ... s_forum can collapse to the base group route, and global text/media buffer chains serialize sibling topics.

Automerge notes:
- PR branch already contained follow-up commit before automerge: Fix Telegram forum topic parallel flow

Validation:
- ClawSweeper review passed for head b0f78fa275.
- Required merge gates passed before the squash merge.

Prepared head SHA: b0f78fa275
Review: https://github.com/openclaw/openclaw/pull/83829#issuecomment-4483486851

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-19 01:48:56 +00:00

156 lines
4.8 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.
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";
const TELEGRAM_READ_ONLY_STATUS_COMMAND_KEYS = new Set([
"commands",
"context",
"help",
"status",
"tasks",
"tools",
"whoami",
]);
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;
}
export 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;
}
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 (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";
}