mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix(telegram): tighten select callback handling
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user