mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 21:34:46 +00:00
fix: handle telegram select callbacks safely
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user