fix: handle telegram select callbacks safely

This commit is contained in:
Moeed Ahmed
2026-05-09 14:23:29 +01:00
committed by Ayaan Zaidi
parent f1d935d39f
commit 243618e804
4 changed files with 329 additions and 1 deletions

View File

@@ -238,6 +238,110 @@ export const registerTelegramHandlers = ({
: async () => ({});
return { message, me: ctx.me, getFile };
};
const MULTI_SELECT_PREFIX = "OC_MULTI|";
const SELECT_PREFIX = "OC_SELECT|";
const SELECTED_PREFIX = "✅ ";
type TelegramCallbackButton = {
text: string;
callback_data: string;
style?: "danger" | "success" | "primary";
};
const cloneInlineKeyboardButtons = (message: Message): TelegramCallbackButton[][] => {
const rows = (message as { reply_markup?: { inline_keyboard?: unknown } }).reply_markup
?.inline_keyboard;
if (!Array.isArray(rows)) {
return [];
}
return rows
.map((row) =>
Array.isArray(row)
? row
.map((button): TelegramCallbackButton | null => {
const candidate = button as {
text?: unknown;
callback_data?: unknown;
style?: unknown;
};
if (
typeof candidate.text !== "string" ||
typeof candidate.callback_data !== "string"
) {
return null;
}
const style =
candidate.style === "danger" ||
candidate.style === "success" ||
candidate.style === "primary"
? candidate.style
: undefined;
return {
text: candidate.text,
callback_data: candidate.callback_data,
...(style ? { style } : {}),
};
})
.filter((button): button is TelegramCallbackButton => button !== null)
: [],
)
.filter((row) => row.length > 0);
};
const stripMultiSelectPrefix = (text: string): string => text.replace(/^✅\s*/, "");
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|`);
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)];
}),
);
const updateMultiSelectKeyboard = (
message: Message,
action: "toggle" | "clear",
value = "",
): TelegramCallbackButton[][] =>
cloneInlineKeyboardButtons(message).map((row) =>
row.map((button) => {
if (!isMultiToggleButton(button)) {
return button;
}
const buttonValue = button.callback_data!.slice(`${MULTI_SELECT_PREFIX}toggle|`.length);
const baseText = stripMultiSelectPrefix(button.text);
const selected =
action === "clear"
? false
: buttonValue === value
? !isSelectedMultiButton(button)
: isSelectedMultiButton(button);
return {
...button,
text: selected ? `${SELECTED_PREFIX}${baseText}` : baseText,
};
}),
);
const buildCallbackSyntheticTextContext = (params: {
ctx: Pick<TelegramContext, "me"> & { getFile?: unknown };
callbackMessage: Message;
callback: { from?: Message["from"] };
text: string;
isForum: boolean;
}): { ctx: TelegramContext; message: Message } => {
const message = buildSyntheticTextMessage({
base: withResolvedTelegramForumFlag(params.callbackMessage, params.isForum),
from: params.callback.from,
text: params.text,
});
return { ctx: buildSyntheticContext(params.ctx, message), message };
};
const inboundDebouncer = createInboundDebouncer<TelegramDebounceEntry>({
debounceMs,
resolveDebounceMs: (entry) =>
@@ -1590,6 +1694,65 @@ 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);
if (buttons.length > 0) {
try {
await editCallbackButtons(buttons);
} catch (editErr) {
if (!String(editErr).includes("message is not modified")) {
throw new TelegramRetryableCallbackError(editErr);
}
}
}
return;
}
if (action === "submit") {
const selected = resolveMultiSelectedValues(cloneInlineKeyboardButtons(callbackMessage));
const synthetic = buildCallbackSyntheticTextContext({
ctx,
callbackMessage,
callback,
text: `Multi-select submitted: ${selected.length > 0 ? selected.join(", ") : "none"}`,
isForum,
});
await processMessageWithReplyChain(synthetic.ctx, synthetic.message, [], storeAllowFrom, {
forceWasMentioned: true,
messageIdOverride: callback.id,
});
return;
}
}
if (data.startsWith(SELECT_PREFIX)) {
const value = data.slice(SELECT_PREFIX.length);
try {
await clearCallbackButtons();
} catch (editErr) {
const errStr = String(editErr);
if (
!errStr.includes("message is not modified") &&
!errStr.includes("there is no text in the message to edit")
) {
throw new TelegramRetryableCallbackError(editErr);
}
}
const synthetic = buildCallbackSyntheticTextContext({
ctx,
callbackMessage,
callback,
text: `Single-select submitted: ${value}`,
isForum,
});
await processMessageWithReplyChain(synthetic.ctx, synthetic.message, [], storeAllowFrom, {
forceWasMentioned: true,
messageIdOverride: callback.id,
});
return;
}
if (approvalCallback) {
const isPluginApproval = approvalCallback.approvalId.startsWith("plugin:");
const pluginApprovalAuthorizedSender = isTelegramExecApprovalApprover({

View File

@@ -751,6 +751,111 @@ describe("createTelegramBot", () => {
expect(payload.Body).toContain("cmd:option_a");
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1");
});
it("toggles OC_MULTI buttons without routing through the generic callback message path", async () => {
createTelegramBot({ token: "tok" });
const callbackHandler = requireValue(
onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as
| ((ctx: Record<string, unknown>) => Promise<void>)
| undefined,
"callback_query handler",
);
await callbackHandler({
callbackQuery: {
id: "cbq-multi-toggle-1",
data: "OC_MULTI|toggle|red",
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" }]],
},
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(1234, 10, {
reply_markup: {
inline_keyboard: [[{ text: "✅ Red", callback_data: "OC_MULTI|toggle|red" }]],
},
});
expect(replySpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-multi-toggle-1");
});
it("submits OC_MULTI selections as a synthetic inbound message", async () => {
createTelegramBot({ token: "tok" });
const callbackHandler = requireValue(
onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as
| ((ctx: Record<string, unknown>) => Promise<void>)
| undefined,
"callback_query handler",
);
await callbackHandler({
callbackQuery: {
id: "cbq-multi-submit-1",
data: "OC_MULTI|submit",
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" }],
[{ text: "Blue", callback_data: "OC_MULTI|toggle|blue" }],
],
},
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
expect(replySpy.mock.calls[0][0].Body).toContain("Multi-select submitted: red");
});
it("submits OC_SELECT values as a synthetic inbound message and clears buttons", async () => {
createTelegramBot({ token: "tok" });
const callbackHandler = requireValue(
onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as
| ((ctx: Record<string, unknown>) => Promise<void>)
| undefined,
"callback_query handler",
);
await callbackHandler({
callbackQuery: {
id: "cbq-select-1",
data: "OC_SELECT|alpha",
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" }]],
},
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(1234, 10, {
reply_markup: { inline_keyboard: [] },
});
expect(replySpy).toHaveBeenCalledTimes(1);
expect(replySpy.mock.calls[0][0].Body).toContain("Single-select submitted: alpha");
});
it("preserves native command source for prefixed callback_query payloads", async () => {
loadConfig.mockReturnValue({
commands: { text: false, native: true },

View File

@@ -1211,6 +1211,62 @@ 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",
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: {

View File

@@ -56,7 +56,11 @@ 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"]);
const AGENT_MEDIATED_COMPLETION_TOOLS = new Set([
"music_generate",
"video_generate",
"subagent_announce",
]);
type SubagentAnnounceDeliveryDeps = {
callGateway: typeof callGateway;