fix: add Telegram native progress placeholder opt-in for plugin commands (#59300)

Merged via squash.

Prepared head SHA: 4f5bc22a89
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Josh Lehman
2026-04-02 15:55:46 -07:00
committed by GitHub
parent 5f4077cc7d
commit ed8d5b3797
11 changed files with 489 additions and 11 deletions

View File

@@ -1,4 +1,4 @@
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { deliverReplies } from "./bot/delivery.js";
import { deliverReplies, emitTelegramMessageSentHooks } from "./bot/delivery.js";
export { createChannelReplyPipeline, deliverReplies };
export { createChannelReplyPipeline, deliverReplies, emitTelegramMessageSentHooks };

View File

@@ -18,6 +18,7 @@ type CreateCommandBotResult = {
bot: RegisterTelegramNativeCommandsParams["bot"];
commandHandlers: Map<string, (ctx: unknown) => Promise<void>>;
sendMessage: ReturnType<typeof vi.fn>;
deleteMessage: ReturnType<typeof vi.fn>;
setMyCommands: ReturnType<typeof vi.fn>;
};
@@ -29,10 +30,14 @@ const skillCommandMocks = vi.hoisted(() => ({
const deliveryMocks = vi.hoisted(() => ({
deliverReplies: vi.fn(async () => ({ delivered: true })),
editMessageTelegram: vi.fn(async () => ({ ok: true as const, messageId: "999", chatId: "100" })),
emitTelegramMessageSentHooks: vi.fn(),
}));
export const listSkillCommandsForAgents = skillCommandMocks.listSkillCommandsForAgents;
export const deliverReplies = deliveryMocks.deliverReplies;
export const editMessageTelegram = deliveryMocks.editMessageTelegram;
export const emitTelegramMessageSentHooks = deliveryMocks.emitTelegramMessageSentHooks;
vi.mock("openclaw/plugin-sdk/command-auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/command-auth")>();
@@ -44,6 +49,7 @@ vi.mock("openclaw/plugin-sdk/command-auth", async (importOriginal) => {
vi.mock("./bot/delivery.js", () => ({
deliverReplies,
emitTelegramMessageSentHooks,
}));
vi.mock("./bot/delivery.replies.js", () => ({
@@ -64,22 +70,27 @@ export function resetNativeCommandMenuMocks() {
listSkillCommandsForAgents.mockReturnValue([]);
deliverReplies.mockClear();
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockClear();
editMessageTelegram.mockResolvedValue({ ok: true as const, messageId: "999", chatId: "100" });
emitTelegramMessageSentHooks.mockClear();
}
export function createCommandBot(): CreateCommandBotResult {
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
const sendMessage = vi.fn().mockResolvedValue(undefined);
const sendMessage = vi.fn().mockResolvedValue({ message_id: 999 });
const deleteMessage = vi.fn().mockResolvedValue(true);
const setMyCommands = vi.fn().mockResolvedValue(undefined);
const bot = {
api: {
setMyCommands,
sendMessage,
deleteMessage,
},
command: vi.fn((name: string, cb: (ctx: unknown) => Promise<void>) => {
commandHandlers.set(name, cb);
}),
} as unknown as RegisterTelegramNativeCommandsParams["bot"];
return { bot, commandHandlers, sendMessage, setMyCommands };
return { bot, commandHandlers, sendMessage, deleteMessage, setMyCommands };
}
export function createNativeCommandTestParams(
@@ -122,6 +133,7 @@ export function createNativeCommandTestParams(
return bot.api.setMyCommands(commandsToRegister);
}) as TelegramBotDeps["syncTelegramMenuCommands"],
wasSentByBot: vi.fn(() => false) as TelegramBotDeps["wasSentByBot"],
editMessageTelegram,
};
return createBaseNativeCommandTestParams({
cfg,

View File

@@ -8,16 +8,21 @@ let createCommandBot: typeof import("./bot-native-commands.menu-test-support.js"
let createNativeCommandTestParams: typeof import("./bot-native-commands.menu-test-support.js").createNativeCommandTestParams;
let createPrivateCommandContext: typeof import("./bot-native-commands.menu-test-support.js").createPrivateCommandContext;
let deliverReplies: typeof import("./bot-native-commands.menu-test-support.js").deliverReplies;
let editMessageTelegram: typeof import("./bot-native-commands.menu-test-support.js").editMessageTelegram;
let resetNativeCommandMenuMocks: typeof import("./bot-native-commands.menu-test-support.js").resetNativeCommandMenuMocks;
let waitForRegisteredCommands: typeof import("./bot-native-commands.menu-test-support.js").waitForRegisteredCommands;
function registerPairPluginCommand(params?: {
nativeNames?: { telegram?: string; discord?: string };
telegramNativeProgressMessage?: string;
}) {
expect(
registerPluginCommand("demo-plugin", {
name: "pair",
...(params?.nativeNames ? { nativeNames: params.nativeNames } : {}),
...(params?.telegramNativeProgressMessage
? { telegramNativeProgressMessage: params.telegramNativeProgressMessage }
: {}),
description: "Pair device",
acceptsArgs: true,
requireAuth: false,
@@ -30,9 +35,13 @@ async function registerPairMenu(params: {
bot: ReturnType<typeof createCommandBot>["bot"];
setMyCommands: ReturnType<typeof createCommandBot>["setMyCommands"];
nativeNames?: { telegram?: string; discord?: string };
telegramNativeProgressMessage?: string;
}) {
registerPairPluginCommand({
...(params.nativeNames ? { nativeNames: params.nativeNames } : {}),
...(params.telegramNativeProgressMessage
? { telegramNativeProgressMessage: params.telegramNativeProgressMessage }
: {}),
});
registerTelegramNativeCommands({
@@ -54,6 +63,7 @@ describe("registerTelegramNativeCommands real plugin registry", () => {
createNativeCommandTestParams,
createPrivateCommandContext,
deliverReplies,
editMessageTelegram,
resetNativeCommandMenuMocks,
waitForRegisteredCommands,
} = await import("./bot-native-commands.menu-test-support.js"));
@@ -89,6 +99,35 @@ describe("registerTelegramNativeCommands real plugin registry", () => {
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
});
it("uses plugin command metadata to send and edit a Telegram progress placeholder", async () => {
const { bot, commandHandlers, setMyCommands, sendMessage } = createCommandBot();
await registerPairMenu({
bot,
setMyCommands,
telegramNativeProgressMessage:
"Running pair now...\n\nI'll edit this message with the final result when it's ready.",
});
const handler = commandHandlers.get("pair");
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext({ match: "now" }));
expect(sendMessage).toHaveBeenCalledWith(
100,
expect.stringContaining("Running pair now"),
undefined,
);
expect(editMessageTelegram).toHaveBeenCalledWith(
100,
999,
"paired:now",
expect.objectContaining({ accountId: "default" }),
);
expect(deliverReplies).not.toHaveBeenCalled();
});
it("round-trips Telegram native aliases through the real plugin registry", async () => {
const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot();

View File

@@ -13,6 +13,8 @@ import {
createNativeCommandTestParams,
createPrivateCommandContext,
deliverReplies,
editMessageTelegram,
emitTelegramMessageSentHooks,
listSkillCommandsForAgents,
resetNativeCommandMenuMocks,
waitForRegisteredCommands,
@@ -242,6 +244,251 @@ describe("registerTelegramNativeCommands", () => {
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
});
it("uses plugin command metadata to send and edit a Telegram progress placeholder", async () => {
const { bot, commandHandlers, sendMessage, deleteMessage } = createCommandBot();
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
command: {
key: "plug",
requireAuth: false,
telegramNativeProgressMessage:
"Running this command now...\n\nI'll edit this message with the final result when it's ready.",
},
args: "now",
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({
text: "Command completed successfully",
} as never);
registerTelegramNativeCommands({
...createNativeCommandTestParams({}, { bot }),
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.(
createPrivateCommandContext({
match: "now",
}),
);
expect(sendMessage).toHaveBeenCalledWith(
100,
expect.stringContaining("Running this command now"),
undefined,
);
expect(editMessageTelegram).toHaveBeenCalledWith(
100,
999,
expect.stringContaining("Command completed successfully"),
expect.objectContaining({
accountId: "default",
}),
);
expect(deleteMessage).not.toHaveBeenCalled();
expect(deliverReplies).not.toHaveBeenCalled();
expect(emitTelegramMessageSentHooks).toHaveBeenCalledWith(
expect.objectContaining({
chatId: "100",
content: "Command completed successfully",
messageId: 999,
success: true,
}),
);
});
it("preserves Telegram buttons when editing a metadata-driven progress placeholder", async () => {
const { bot, commandHandlers, sendMessage, deleteMessage } = createCommandBot();
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
command: {
key: "plug",
requireAuth: false,
telegramNativeProgressMessage: "Working on it...",
},
args: "now",
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({
text: "Choose an option",
channelData: {
telegram: {
buttons: [[{ text: "Approve", callback_data: "approve" }]],
},
},
} as never);
registerTelegramNativeCommands({
...createNativeCommandTestParams({}, { bot }),
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext({ match: "now" }));
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(editMessageTelegram).toHaveBeenCalledWith(
100,
999,
"Choose an option",
expect.objectContaining({
buttons: [[{ text: "Approve", callback_data: "approve" }]],
}),
);
expect(deleteMessage).not.toHaveBeenCalled();
expect(deliverReplies).not.toHaveBeenCalled();
});
it("falls back to a normal reply when a metadata-driven progress result is not editable", async () => {
const { bot, commandHandlers, sendMessage, deleteMessage } = createCommandBot();
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
command: {
key: "plug",
requireAuth: false,
telegramNativeProgressMessage: "Working on it...",
},
args: "now",
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({
text: "rich output",
mediaUrl: "/tmp/render.png",
} as never);
registerTelegramNativeCommands({
...createNativeCommandTestParams({}, { bot }),
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.(
createPrivateCommandContext({
match: "now",
}),
);
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(deleteMessage).toHaveBeenCalledWith(100, 999);
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ mediaUrl: "/tmp/render.png" })],
}),
);
});
it("cleans up the progress placeholder before falling back after an edit failure", async () => {
const { bot, commandHandlers, sendMessage, deleteMessage } = createCommandBot();
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
command: {
key: "plug",
requireAuth: false,
telegramNativeProgressMessage: "Working on it...",
},
args: "now",
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({
text: "Command completed successfully",
} as never);
editMessageTelegram.mockRejectedValueOnce(new Error("message to edit not found"));
registerTelegramNativeCommands({
...createNativeCommandTestParams({}, { bot }),
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext({ match: "now" }));
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(editMessageTelegram).toHaveBeenCalledTimes(1);
expect(deleteMessage).toHaveBeenCalledWith(100, 999);
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "Command completed successfully" })],
}),
);
});
it("cleans up the progress placeholder when Telegram suppresses a local exec approval reply", async () => {
const { bot, commandHandlers, sendMessage, deleteMessage } = createCommandBot();
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
command: {
key: "plug",
requireAuth: false,
telegramNativeProgressMessage: "Working on it...",
},
args: "now",
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({
text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```",
channelData: {
execApproval: {
approvalId: "7f423fdc-1111-2222-3333-444444444444",
approvalSlug: "7f423fdc",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
} as never);
registerTelegramNativeCommands({
...createNativeCommandTestParams(
{
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["12345"],
target: "dm",
},
},
},
},
{ bot },
),
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext({ match: "now" }));
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(deleteMessage).toHaveBeenCalledWith(100, 999);
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(deliverReplies).not.toHaveBeenCalled();
});
it("sends plugin command error replies silently when silentErrorReplies is enabled", async () => {
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
const cfg: OpenClawConfig = {

View File

@@ -58,6 +58,7 @@ import {
resolveTelegramThreadSpec,
} from "./bot/helpers.js";
import type { TelegramContext, TelegramGetChat } from "./bot/types.js";
import type { TelegramInlineButtons } from "./button-types.js";
import {
resolveTelegramConversationBaseSessionKey,
resolveTelegramConversationRoute,
@@ -70,6 +71,7 @@ import {
} from "./group-access.js";
import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
import { buildInlineKeyboard } from "./send.js";
import { recordSentMessage } from "./sent-message-cache.js";
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
const TELEGRAM_NATIVE_COMMAND_CALLBACK_PREFIX = "tgcmd:";
@@ -78,6 +80,11 @@ type TelegramNativeCommandContext = Context & { match?: string };
type TelegramChunkMode = ReturnType<
typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").resolveChunkMode
>;
type TelegramNativeReplyPayload = import("openclaw/plugin-sdk/reply-dispatch-runtime").ReplyPayload;
type TelegramNativeReplyChannelData = {
buttons?: TelegramInlineButtons;
pin?: boolean;
};
type TelegramCommandAuthResult = {
chatId: number;
@@ -110,6 +117,53 @@ async function loadTelegramNativeCommandRuntime() {
return await telegramNativeCommandRuntimePromise;
}
function resolveTelegramProgressPlaceholder(command: {
telegramNativeProgressMessage?: string;
}): string | null {
const text = command.telegramNativeProgressMessage?.trim();
return text ? text : null;
}
function resolveTelegramNativeReplyChannelData(
result: TelegramNativeReplyPayload,
): TelegramNativeReplyChannelData | undefined {
return result.channelData?.telegram as TelegramNativeReplyChannelData | undefined;
}
function isEditableTelegramProgressResult(result: TelegramNativeReplyPayload): boolean {
const telegramData = resolveTelegramNativeReplyChannelData(result);
return Boolean(
typeof result.text === "string" &&
result.text.trim() &&
!result.mediaUrl &&
(!result.mediaUrls || result.mediaUrls.length === 0) &&
!result.interactive &&
!result.btw &&
telegramData?.pin !== true,
);
}
async function cleanupTelegramProgressPlaceholder(params: {
bot: Bot;
chatId: number;
progressMessageId?: number;
runtime: RuntimeEnv;
}): Promise<void> {
const progressMessageId = params.progressMessageId;
if (progressMessageId == null) {
return;
}
try {
await withTelegramApiErrorLogging({
operation: "deleteMessage",
runtime: params.runtime,
fn: () => params.bot.api.deleteMessage(params.chatId, progressMessageId),
});
} catch {
// Best-effort cleanup before fallback or suppression exits.
}
}
export type RegisterTelegramHandlerParams = {
cfg: OpenClawConfig;
accountId: string;
@@ -956,7 +1010,31 @@ export const registerTelegramNativeCommands = ({
});
const from = isGroup ? buildTelegramGroupFrom(chatId, threadSpec.id) : `telegram:${chatId}`;
const to = `telegram:${chatId}`;
const { deliverReplies } = await loadTelegramNativeCommandDeliveryRuntime();
const { deliverReplies, emitTelegramMessageSentHooks } =
await loadTelegramNativeCommandDeliveryRuntime();
let progressMessageId: number | undefined;
const progressPlaceholder = resolveTelegramProgressPlaceholder(match.command);
if (progressPlaceholder) {
try {
const sent = await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime,
fn: () =>
bot.api.sendMessage(
chatId,
progressPlaceholder,
buildTelegramThreadParams(threadSpec),
),
});
const maybeMessageId = (sent as { message_id?: unknown } | undefined)?.message_id;
if (typeof maybeMessageId === "number") {
progressMessageId = maybeMessageId;
}
} catch {
// Fall back to the normal final reply path if the placeholder send fails.
}
}
const result = await nativeCommandRuntime.executePluginCommand({
command: match.command,
@@ -974,18 +1052,65 @@ export const registerTelegramNativeCommands = ({
});
if (
!shouldSuppressLocalTelegramExecApprovalPrompt({
shouldSuppressLocalTelegramExecApprovalPrompt({
cfg: runtimeCfg,
accountId: route.accountId,
payload: result,
})
) {
await deliverReplies({
replies: [result],
...deliveryBaseOptions,
silent: runtimeTelegramCfg.silentErrorReplies === true && result.isError === true,
await cleanupTelegramProgressPlaceholder({
bot,
chatId,
progressMessageId,
runtime,
});
return;
}
const progressResultText =
typeof result.text === "string" && result.text.trim().length > 0 ? result.text : null;
const telegramResultData = resolveTelegramNativeReplyChannelData(result);
if (
progressMessageId != null &&
telegramDeps.editMessageTelegram &&
progressResultText &&
isEditableTelegramProgressResult(result)
) {
try {
await telegramDeps.editMessageTelegram(chatId, progressMessageId, progressResultText, {
cfg: runtimeCfg,
accountId: route.accountId,
textMode: "markdown",
linkPreview: runtimeTelegramCfg.linkPreview,
buttons: telegramResultData?.buttons,
});
recordSentMessage(chatId, progressMessageId);
emitTelegramMessageSentHooks({
sessionKeyForInternalHooks: route.sessionKey,
chatId: String(chatId),
accountId: route.accountId,
content: progressResultText,
success: true,
messageId: progressMessageId,
isGroup,
groupId: isGroup ? String(chatId) : undefined,
});
return;
} catch {
// Fall through to cleanup + normal delivered reply if editing fails.
}
}
await cleanupTelegramProgressPlaceholder({
bot,
chatId,
progressMessageId,
runtime,
});
await deliverReplies({
replies: [result],
...deliveryBaseOptions,
silent: runtimeTelegramCfg.silentErrorReplies === true && result.isError === true,
});
});
}
} else if (nativeDisabledExplicit) {

View File

@@ -570,6 +570,15 @@ function emitMessageSentHooks(
emitInternalMessageSentHook(params);
}
export function emitTelegramMessageSentHooks(params: EmitMessageSentHookParams): void {
const hookRunner = getGlobalHookRunner();
emitMessageSentHooks({
...params,
hookRunner,
enabled: hookRunner?.hasHooks("message_sent") ?? false,
});
}
export async function deliverReplies(params: {
replies: ReplyPayload[];
chatId: string;

View File

@@ -1,2 +1,6 @@
export { deliverReplies, emitInternalMessageSentHook } from "./delivery.replies.js";
export {
deliverReplies,
emitInternalMessageSentHook,
emitTelegramMessageSentHooks,
} from "./delivery.replies.js";
export { resolveMedia } from "./delivery.resolve-media.js";