mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 09:01:22 +00:00
feat(telegram): add edit message action (#2394) (thanks @marcelomar21)
This commit is contained in:
91
src/telegram/send.edit-message.test.ts
Normal file
91
src/telegram/send.edit-message.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { botApi, botCtorSpy } = vi.hoisted(() => ({
|
||||
botApi: {
|
||||
editMessageText: vi.fn(),
|
||||
},
|
||||
botCtorSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("grammy", () => ({
|
||||
Bot: class {
|
||||
api = botApi;
|
||||
constructor(public token: string) {
|
||||
botCtorSpy(token);
|
||||
}
|
||||
},
|
||||
InputFile: class {},
|
||||
}));
|
||||
|
||||
import { editMessageTelegram } from "./send.js";
|
||||
|
||||
describe("editMessageTelegram", () => {
|
||||
beforeEach(() => {
|
||||
botApi.editMessageText.mockReset();
|
||||
botCtorSpy.mockReset();
|
||||
});
|
||||
|
||||
it("keeps existing buttons when buttons is undefined (no reply_markup)", async () => {
|
||||
botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
|
||||
|
||||
await editMessageTelegram("123", 1, "hi", {
|
||||
token: "tok",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(botCtorSpy).toHaveBeenCalledWith("tok");
|
||||
expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
|
||||
const call = botApi.editMessageText.mock.calls[0] ?? [];
|
||||
const params = call[3] as Record<string, unknown>;
|
||||
expect(params).toEqual(expect.objectContaining({ parse_mode: "HTML" }));
|
||||
expect(params).not.toHaveProperty("reply_markup");
|
||||
});
|
||||
|
||||
it("removes buttons when buttons is empty (reply_markup.inline_keyboard = [])", async () => {
|
||||
botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
|
||||
|
||||
await editMessageTelegram("123", 1, "hi", {
|
||||
token: "tok",
|
||||
cfg: {},
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
|
||||
const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record<string, unknown>;
|
||||
expect(params).toEqual(
|
||||
expect.objectContaining({
|
||||
parse_mode: "HTML",
|
||||
reply_markup: { inline_keyboard: [] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to plain text when Telegram HTML parse fails (and preserves reply_markup)", async () => {
|
||||
botApi.editMessageText
|
||||
.mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities"))
|
||||
.mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } });
|
||||
|
||||
await editMessageTelegram("123", 1, "<bad> html", {
|
||||
token: "tok",
|
||||
cfg: {},
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(botApi.editMessageText).toHaveBeenCalledTimes(2);
|
||||
|
||||
const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record<string, unknown>;
|
||||
expect(firstParams).toEqual(
|
||||
expect.objectContaining({
|
||||
parse_mode: "HTML",
|
||||
reply_markup: { inline_keyboard: [] },
|
||||
}),
|
||||
);
|
||||
|
||||
const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record<string, unknown>;
|
||||
expect(secondParams).toEqual(
|
||||
expect.objectContaining({
|
||||
reply_markup: { inline_keyboard: [] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -495,6 +495,99 @@ export async function deleteMessageTelegram(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
type TelegramEditOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
api?: Bot["api"];
|
||||
retry?: RetryConfig;
|
||||
textMode?: "markdown" | "html";
|
||||
/** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */
|
||||
buttons?: Array<Array<{ text: string; callback_data: string }>>;
|
||||
/** Optional config injection to avoid global loadConfig() (improves testability). */
|
||||
cfg?: ReturnType<typeof loadConfig>;
|
||||
};
|
||||
|
||||
export async function editMessageTelegram(
|
||||
chatIdInput: string | number,
|
||||
messageIdInput: string | number,
|
||||
text: string,
|
||||
opts: TelegramEditOpts = {},
|
||||
): Promise<{ ok: true; messageId: string; chatId: string }> {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const account = resolveTelegramAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveToken(opts.token, account);
|
||||
const chatId = normalizeChatId(String(chatIdInput));
|
||||
const messageId = normalizeMessageId(messageIdInput);
|
||||
const client = resolveTelegramClientOptions(account);
|
||||
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||
const request = createTelegramRetryRunner({
|
||||
retry: opts.retry,
|
||||
configRetry: account.config.retry,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
const logHttpError = createTelegramHttpLogger(cfg);
|
||||
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
||||
request(fn, label).catch((err) => {
|
||||
logHttpError(label ?? "request", err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
const textMode = opts.textMode ?? "markdown";
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const htmlText = renderTelegramHtmlText(text, { textMode, tableMode });
|
||||
|
||||
// Reply markup semantics:
|
||||
// - buttons === undefined → don't send reply_markup (keep existing)
|
||||
// - buttons is [] (or filters to empty) → send { inline_keyboard: [] } (remove)
|
||||
// - otherwise → send built inline keyboard
|
||||
const shouldTouchButtons = opts.buttons !== undefined;
|
||||
const builtKeyboard = shouldTouchButtons ? buildInlineKeyboard(opts.buttons) : undefined;
|
||||
const replyMarkup = shouldTouchButtons ? (builtKeyboard ?? { inline_keyboard: [] }) : undefined;
|
||||
|
||||
const editParams: Record<string, unknown> = {
|
||||
parse_mode: "HTML",
|
||||
};
|
||||
if (replyMarkup !== undefined) {
|
||||
editParams.reply_markup = replyMarkup;
|
||||
}
|
||||
|
||||
await requestWithDiag(
|
||||
() => api.editMessageText(chatId, messageId, htmlText, editParams),
|
||||
"editMessage",
|
||||
).catch(async (err) => {
|
||||
// Telegram rejects malformed HTML. Fall back to plain text.
|
||||
const errText = formatErrorMessage(err);
|
||||
if (PARSE_ERR_RE.test(errText)) {
|
||||
if (opts.verbose) {
|
||||
console.warn(`telegram HTML parse failed, retrying as plain text: ${errText}`);
|
||||
}
|
||||
const plainParams: Record<string, unknown> = {};
|
||||
if (replyMarkup !== undefined) {
|
||||
plainParams.reply_markup = replyMarkup;
|
||||
}
|
||||
return await requestWithDiag(
|
||||
() =>
|
||||
Object.keys(plainParams).length > 0
|
||||
? api.editMessageText(chatId, messageId, text, plainParams)
|
||||
: api.editMessageText(chatId, messageId, text),
|
||||
"editMessage-plain",
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
logVerbose(`[telegram] Edited message ${messageId} in chat ${chatId}`);
|
||||
return { ok: true, messageId: String(messageId), chatId };
|
||||
}
|
||||
|
||||
function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
|
||||
switch (kind) {
|
||||
case "image":
|
||||
|
||||
Reference in New Issue
Block a user