fix(telegram): tighten select callback handling

This commit is contained in:
Ayaan Zaidi
2026-05-10 08:20:38 +05:30
parent 3c5e68e80f
commit 175c42eacd
6 changed files with 52 additions and 118 deletions
+42 -13
View File
@@ -240,15 +240,40 @@ export const registerTelegramHandlers = ({
};
const MULTI_SELECT_PREFIX = "OC_MULTI|";
const MULTI_SELECT_TOGGLE_PREFIX = `${MULTI_SELECT_PREFIX}toggle|`;
const SELECT_PREFIX = "OC_SELECT|";
const SELECTED_PREFIX = "✅ ";
type TelegramManagedSelectCallback =
| { type: "multi-toggle"; value: string }
| { type: "multi-clear" }
| { type: "multi-submit" }
| { type: "select"; value: string };
type TelegramCallbackButton = {
text: string;
callback_data: string;
style?: "danger" | "success" | "primary";
};
const parseTelegramManagedSelectCallback = (
data: string,
): TelegramManagedSelectCallback | undefined => {
if (data.startsWith(MULTI_SELECT_TOGGLE_PREFIX)) {
return { type: "multi-toggle", value: data.slice(MULTI_SELECT_TOGGLE_PREFIX.length) };
}
if (data === `${MULTI_SELECT_PREFIX}clear`) {
return { type: "multi-clear" };
}
if (data === `${MULTI_SELECT_PREFIX}submit`) {
return { type: "multi-submit" };
}
if (data.startsWith(SELECT_PREFIX)) {
return { type: "select", value: data.slice(SELECT_PREFIX.length) };
}
return undefined;
};
const cloneInlineKeyboardButtons = (message: Message): TelegramCallbackButton[][] => {
const rows = (message as { reply_markup?: { inline_keyboard?: unknown } }).reply_markup
?.inline_keyboard;
@@ -292,15 +317,14 @@ export const registerTelegramHandlers = ({
const isSelectedMultiButton = (button: TelegramCallbackButton): boolean =>
/^✅\s*/.test(button.text);
const isMultiToggleButton = (button: TelegramCallbackButton): boolean =>
typeof button.callback_data === "string" &&
button.callback_data.startsWith(`${MULTI_SELECT_PREFIX}toggle|`);
button.callback_data.startsWith(MULTI_SELECT_TOGGLE_PREFIX);
const resolveMultiSelectedValues = (buttons: TelegramCallbackButton[][]): string[] =>
buttons.flatMap((row) =>
row.flatMap((button) => {
if (!isMultiToggleButton(button) || !isSelectedMultiButton(button)) {
return [];
}
return [button.callback_data.slice(`${MULTI_SELECT_PREFIX}toggle|`.length)];
return [button.callback_data.slice(MULTI_SELECT_TOGGLE_PREFIX.length)];
}),
);
const updateMultiSelectKeyboard = (
@@ -313,7 +337,7 @@ export const registerTelegramHandlers = ({
if (!isMultiToggleButton(button)) {
return button;
}
const buttonValue = button.callback_data.slice(`${MULTI_SELECT_PREFIX}toggle|`.length);
const buttonValue = button.callback_data.slice(MULTI_SELECT_TOGGLE_PREFIX.length);
const baseText = stripMultiSelectPrefix(button.text);
const selected =
action === "clear"
@@ -1694,10 +1718,17 @@ export const registerTelegramHandlers = ({
return;
}
if (data.startsWith(MULTI_SELECT_PREFIX)) {
const [, action, value = ""] = data.split("|");
if (action === "toggle" || action === "clear") {
const buttons = updateMultiSelectKeyboard(callbackMessage, action, value);
const managedSelectCallback = parseTelegramManagedSelectCallback(data);
if (managedSelectCallback) {
if (
managedSelectCallback.type === "multi-toggle" ||
managedSelectCallback.type === "multi-clear"
) {
const buttons = updateMultiSelectKeyboard(
callbackMessage,
managedSelectCallback.type === "multi-clear" ? "clear" : "toggle",
managedSelectCallback.type === "multi-toggle" ? managedSelectCallback.value : "",
);
if (buttons.length > 0) {
try {
await editCallbackButtons(buttons);
@@ -1709,7 +1740,8 @@ export const registerTelegramHandlers = ({
}
return;
}
if (action === "submit") {
if (managedSelectCallback.type === "multi-submit") {
const selected = resolveMultiSelectedValues(cloneInlineKeyboardButtons(callbackMessage));
const synthetic = buildCallbackSyntheticTextContext({
ctx,
@@ -1724,10 +1756,7 @@ export const registerTelegramHandlers = ({
});
return;
}
}
if (data.startsWith(SELECT_PREFIX)) {
const value = data.slice(SELECT_PREFIX.length);
try {
await clearCallbackButtons();
} catch (editErr) {
@@ -1743,7 +1772,7 @@ export const registerTelegramHandlers = ({
ctx,
callbackMessage,
callback,
text: `Single-select submitted: ${value}`,
text: `Single-select submitted: ${managedSelectCallback.value}`,
isForum,
});
await processMessageWithReplyChain(synthetic.ctx, synthetic.message, [], storeAllowFrom, {
@@ -764,14 +764,14 @@ describe("createTelegramBot", () => {
await callbackHandler({
callbackQuery: {
id: "cbq-multi-toggle-1",
data: "OC_MULTI|toggle|red",
data: "OC_MULTI|toggle|env|prod",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 10,
reply_markup: {
inline_keyboard: [[{ text: "Red", callback_data: "OC_MULTI|toggle|red" }]],
inline_keyboard: [[{ text: "Prod", callback_data: "OC_MULTI|toggle|env|prod" }]],
},
},
},
@@ -781,7 +781,7 @@ describe("createTelegramBot", () => {
expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(1234, 10, {
reply_markup: {
inline_keyboard: [[{ text: "✅ Red", callback_data: "OC_MULTI|toggle|red" }]],
inline_keyboard: [[{ text: "✅ Prod", callback_data: "OC_MULTI|toggle|env|prod" }]],
},
});
expect(replySpy).not.toHaveBeenCalled();
@@ -808,7 +808,7 @@ describe("createTelegramBot", () => {
message_id: 10,
reply_markup: {
inline_keyboard: [
[{ text: "✅ Red", callback_data: "OC_MULTI|toggle|red" }],
[{ text: "✅ Prod", callback_data: "OC_MULTI|toggle|env|prod" }],
[{ text: "Blue", callback_data: "OC_MULTI|toggle|blue" }],
],
},
@@ -819,7 +819,7 @@ describe("createTelegramBot", () => {
});
expect(replySpy).toHaveBeenCalledTimes(1);
expect(replySpy.mock.calls[0][0].Body).toContain("Multi-select submitted: red");
expect(replySpy.mock.calls[0][0].Body).toContain("Multi-select submitted: env|prod");
});
it("submits OC_SELECT values as a synthetic inbound message and clears buttons", async () => {
@@ -834,14 +834,14 @@ describe("createTelegramBot", () => {
await callbackHandler({
callbackQuery: {
id: "cbq-select-1",
data: "OC_SELECT|alpha",
data: "OC_SELECT|env|canary",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 10,
reply_markup: {
inline_keyboard: [[{ text: "Alpha", callback_data: "OC_SELECT|alpha" }]],
inline_keyboard: [[{ text: "Canary", callback_data: "OC_SELECT|env|canary" }]],
},
},
},
@@ -853,7 +853,7 @@ describe("createTelegramBot", () => {
reply_markup: { inline_keyboard: [] },
});
expect(replySpy).toHaveBeenCalledTimes(1);
expect(replySpy.mock.calls[0][0].Body).toContain("Single-select submitted: alpha");
expect(replySpy.mock.calls[0][0].Body).toContain("Single-select submitted: env|canary");
});
it("preserves native command source for prefixed callback_query payloads", async () => {
+1 -15
View File
@@ -25,8 +25,6 @@ type AgentTaskCompletionInternalEvent = {
replyInstruction: string;
};
const MAX_CHILD_RESULT_PROMPT_CHARS = 4_000;
export type AgentInternalEvent = AgentTaskCompletionInternalEvent;
export { INTERNAL_RUNTIME_CONTEXT_BEGIN, INTERNAL_RUNTIME_CONTEXT_END };
@@ -43,23 +41,11 @@ function sanitizeMultilineField(value: string, fallback: string): string {
return sanitized || fallback;
}
function truncateChildResultForPrompt(value: string): string {
if (value.length <= MAX_CHILD_RESULT_PROMPT_CHARS) {
return value;
}
return [
value.slice(0, MAX_CHILD_RESULT_PROMPT_CHARS).trimEnd(),
"",
`[child result truncated: ${value.length - MAX_CHILD_RESULT_PROMPT_CHARS} additional characters omitted]`,
].join("\n");
}
function formatChildResultDataBlock(value: string): string {
const safeValue = truncateChildResultForPrompt(value);
return (
wrapPromptDataBlock({
label: "Child result",
text: safeValue,
text: value,
}) || "Child result: (no output)"
);
}
@@ -362,26 +362,6 @@ describe("sanitizeUserFacingText", () => {
);
});
it("bounds large child completion results before injecting internal context", () => {
const internal = formatAgentInternalEventsForPrompt([
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:main:subagent:test",
childSessionId: "sess_1",
announceType: "subagent task",
taskLabel: "Investigate issue",
status: "ok",
statusLabel: "completed successfully",
result: "x".repeat(6_000),
replyInstruction: "Reply to the user in your own words.",
},
]);
expect(internal).toContain("[child result truncated: 2000 additional characters omitted]");
expect(internal.length).toBeLessThan(5_000);
});
it("does not strip inline delimiter mentions that are not standalone marker lines", () => {
const input = `Note: ${INTERNAL_RUNTIME_CONTEXT_BEGIN} appears inline and should stay.`;
expect(sanitizeUserFacingText(input)).toBe(input);
@@ -1211,63 +1211,6 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
expect(sendMessage).not.toHaveBeenCalled();
});
it("reports subagent group completions that miss required message-tool delivery", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [
{
text: "Child result that must not be raw-sent.",
},
],
},
});
const sendMessage = createSendMessageMock();
const result = await deliverSlackChannelAnnouncement({
callGateway,
sendMessage,
sessionId: "requester-session-channel",
isActive: false,
expectsCompletionMessage: true,
directIdempotencyKey: "announce-channel-subagent-message-tool",
sourceTool: "subagent_announce",
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:openclaw:subagent:child-123",
childSessionId: "child-123",
announceType: "subagent task",
taskLabel: "channel completion smoke",
status: "ok",
statusLabel: "completed successfully",
result: "Raw child result that should stay internal.",
replyInstruction: "Let the requester/orchestrator deliver the final response.",
},
],
});
expect(result).toEqual(
expect.objectContaining({
delivered: false,
path: "direct",
error: "completion agent did not deliver through the message tool",
}),
);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "agent",
params: expect.objectContaining({
deliver: false,
channel: "slack",
accountId: "acct-1",
to: "channel:C123",
threadId: undefined,
}),
}),
);
expect(sendMessage).not.toHaveBeenCalled();
});
it("does not fallback for generated media group completions when message tool evidence exists", async () => {
const callGateway = createGatewayMock({
result: {
+1 -5
View File
@@ -56,11 +56,7 @@ import type { SpawnSubagentMode } from "./subagent-spawn.types.js";
const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 120_000;
const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000;
const AGENT_MEDIATED_COMPLETION_TOOLS = new Set([
"music_generate",
"video_generate",
"subagent_announce",
]);
const AGENT_MEDIATED_COMPLETION_TOOLS = new Set(["music_generate", "video_generate"]);
type SubagentAnnounceDeliveryDeps = {
callGateway: typeof callGateway;