feat(telegram): add topic-edit action

This commit is contained in:
Ayaan Zaidi 2026-03-16 07:49:13 +05:30
parent 0c9428a865
commit a516141bda
10 changed files with 204 additions and 10 deletions

View File

@ -115,6 +115,9 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
if (isEnabled("createForumTopic")) {
actions.add("topic-create");
}
if (isEnabled("editForumTopic")) {
actions.add("topic-edit");
}
return Array.from(actions);
},
supportsButtons: ({ cfg }) => {
@ -290,6 +293,30 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
);
}
if (action === "topic-edit") {
const chatId = readTelegramChatIdParam(params);
const messageThreadId =
readNumberParam(params, "messageThreadId", { integer: true }) ??
readNumberParam(params, "threadId", { integer: true });
if (typeof messageThreadId !== "number") {
throw new Error("messageThreadId or threadId is required.");
}
const name = readStringParam(params, "name");
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
return await handleTelegramAction(
{
action: "editForumTopic",
chatId,
messageThreadId,
name: name ?? undefined,
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
},
};

View File

@ -15,6 +15,7 @@ const { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegr
const {
buildInlineKeyboard,
createForumTopicTelegram,
editForumTopicTelegram,
editMessageTelegram,
pinMessageTelegram,
reactMessageTelegram,
@ -257,6 +258,42 @@ describe("sendMessageTelegram", () => {
});
});
it("edits a Telegram forum topic name and icon via the shared helper", async () => {
loadConfig.mockReturnValue({
channels: {
telegram: {
botToken: "tok",
},
},
});
botApi.editForumTopic.mockResolvedValue(true);
await editForumTopicTelegram("-1001234567890", 271, {
accountId: "default",
name: "Codex Thread",
iconCustomEmojiId: "emoji-123",
});
expect(botApi.editForumTopic).toHaveBeenCalledWith("-1001234567890", 271, {
name: "Codex Thread",
icon_custom_emoji_id: "emoji-123",
});
});
it("rejects empty topic edits", async () => {
await expect(
editForumTopicTelegram("-1001234567890", 271, {
accountId: "default",
}),
).rejects.toThrow("Telegram forum topic update requires a name or iconCustomEmojiId");
await expect(
editForumTopicTelegram("-1001234567890", 271, {
accountId: "default",
iconCustomEmojiId: " ",
}),
).rejects.toThrow("Telegram forum topic icon custom emoji ID is required");
});
it("applies timeoutSeconds config precedence", async () => {
const cases = [
{

View File

@ -1128,19 +1128,39 @@ export async function unpinMessageTelegram(
};
}
export async function renameForumTopicTelegram(
type TelegramEditForumTopicOpts = TelegramDeleteOpts & {
name?: string;
iconCustomEmojiId?: string;
};
export async function editForumTopicTelegram(
chatIdInput: string | number,
messageThreadIdInput: string | number,
name: string,
opts: TelegramDeleteOpts = {},
): Promise<{ ok: true; chatId: string; messageThreadId: number; name: string }> {
const trimmedName = name.trim();
if (!trimmedName) {
opts: TelegramEditForumTopicOpts = {},
): Promise<{
ok: true;
chatId: string;
messageThreadId: number;
name?: string;
iconCustomEmojiId?: string;
}> {
const nameProvided = opts.name !== undefined;
const trimmedName = opts.name?.trim();
if (nameProvided && !trimmedName) {
throw new Error("Telegram forum topic name is required");
}
if (trimmedName.length > 128) {
if (trimmedName && trimmedName.length > 128) {
throw new Error("Telegram forum topic name must be 128 characters or fewer");
}
const iconProvided = opts.iconCustomEmojiId !== undefined;
const trimmedIconCustomEmojiId = opts.iconCustomEmojiId?.trim();
if (iconProvided && !trimmedIconCustomEmojiId) {
throw new Error("Telegram forum topic icon custom emoji ID is required");
}
if (!trimmedName && !trimmedIconCustomEmojiId) {
throw new Error("Telegram forum topic update requires a name or iconCustomEmojiId");
}
const { cfg, account, api } = resolveTelegramApiContext(opts);
const rawTarget = String(chatIdInput);
const chatId = await resolveAndPersistChatId({
@ -1157,16 +1177,39 @@ export async function renameForumTopicTelegram(
retry: opts.retry,
verbose: opts.verbose,
});
const payload = {
...(trimmedName ? { name: trimmedName } : {}),
...(trimmedIconCustomEmojiId ? { icon_custom_emoji_id: trimmedIconCustomEmojiId } : {}),
};
await requestWithDiag(
() => api.editForumTopic(chatId, messageThreadId, { name: trimmedName }),
() => api.editForumTopic(chatId, messageThreadId, payload),
"editForumTopic",
);
logVerbose(`[telegram] Renamed forum topic ${messageThreadId} in chat ${chatId}`);
logVerbose(`[telegram] Edited forum topic ${messageThreadId} in chat ${chatId}`);
return {
ok: true,
chatId,
messageThreadId,
name: trimmedName,
...(trimmedName ? { name: trimmedName } : {}),
...(trimmedIconCustomEmojiId ? { iconCustomEmojiId: trimmedIconCustomEmojiId } : {}),
};
}
export async function renameForumTopicTelegram(
chatIdInput: string | number,
messageThreadIdInput: string | number,
name: string,
opts: TelegramDeleteOpts = {},
): Promise<{ ok: true; chatId: string; messageThreadId: number; name: string }> {
const result = await editForumTopicTelegram(chatIdInput, messageThreadIdInput, {
...opts,
name,
});
return {
ok: true,
chatId: result.chatId,
messageThreadId: result.messageThreadId,
name: result.name ?? name.trim(),
};
}

View File

@ -23,6 +23,12 @@ const editMessageTelegram = vi.fn(async () => ({
messageId: "456",
chatId: "123",
}));
const editForumTopicTelegram = vi.fn(async () => ({
ok: true,
chatId: "123",
messageThreadId: 42,
name: "Renamed",
}));
const createForumTopicTelegram = vi.fn(async () => ({
topicId: 99,
name: "Topic",
@ -42,6 +48,8 @@ vi.mock("../../../extensions/telegram/src/send.js", () => ({
deleteMessageTelegram(...args),
editMessageTelegram: (...args: Parameters<typeof editMessageTelegram>) =>
editMessageTelegram(...args),
editForumTopicTelegram: (...args: Parameters<typeof editForumTopicTelegram>) =>
editForumTopicTelegram(...args),
createForumTopicTelegram: (...args: Parameters<typeof createForumTopicTelegram>) =>
createForumTopicTelegram(...args),
}));
@ -105,6 +113,7 @@ describe("handleTelegramAction", () => {
sendStickerTelegram.mockClear();
deleteMessageTelegram.mockClear();
editMessageTelegram.mockClear();
editForumTopicTelegram.mockClear();
createForumTopicTelegram.mockClear();
process.env.TELEGRAM_BOT_TOKEN = "tok";
});
@ -457,6 +466,14 @@ describe("handleTelegramAction", () => {
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
) => readCallOpts(createForumTopicTelegram.mock.calls as unknown[][], 2),
},
{
name: "editForumTopic",
params: { action: "editForumTopic", chatId: "123", messageThreadId: 42, name: "New" },
cfg: telegramConfig({ actions: { editForumTopic: true } }),
assertCall: (
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
) => readCallOpts(editForumTopicTelegram.mock.calls as unknown[][], 2),
},
])("forwards resolved cfg for $name action", async ({ params, cfg, assertCall }) => {
const readCallOpts = (calls: unknown[][], argIndex: number): Record<string, unknown> => {
const args = calls[0];

View File

@ -15,6 +15,7 @@ import { resolveTelegramReactionLevel } from "../../../extensions/telegram/src/r
import {
createForumTopicTelegram,
deleteMessageTelegram,
editForumTopicTelegram,
editMessageTelegram,
reactMessageTelegram,
sendMessageTelegram,
@ -478,5 +479,36 @@ export async function handleTelegramAction(
});
}
if (action === "editForumTopic") {
if (!isActionEnabled("editForumTopic")) {
throw new Error("Telegram editForumTopic is disabled.");
}
const chatId = readStringOrNumberParam(params, "chatId", {
required: true,
});
const messageThreadId =
readNumberParam(params, "messageThreadId", { integer: true }) ??
readNumberParam(params, "threadId", { integer: true });
if (typeof messageThreadId !== "number") {
throw new Error("messageThreadId or threadId is required.");
}
const name = readStringParam(params, "name");
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await editForumTopicTelegram(chatId ?? "", messageThreadId, {
cfg,
token,
accountId: accountId ?? undefined,
name: name ?? undefined,
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
});
return jsonResult(result);
}
throw new Error(`Unsupported Telegram action: ${action}`);
}

View File

@ -540,6 +540,21 @@ describe("telegramMessageActions", () => {
expect(actions).toContain("poll");
});
it("lists topic-edit when telegram topic edits are enabled", () => {
const cfg = {
channels: {
telegram: {
botToken: "tok",
actions: { editForumTopic: true },
},
},
} as OpenClawConfig;
const actions = telegramMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).toContain("topic-edit");
});
it("omits poll when sendMessage is disabled", () => {
const cfg = {
channels: {
@ -793,6 +808,24 @@ describe("telegramMessageActions", () => {
accountId: undefined,
},
},
{
name: "topic-edit maps to editForumTopic",
action: "topic-edit" as const,
params: {
to: "telegram:group:-1001234567890:topic:271",
threadId: 271,
name: "Build Updates",
iconCustomEmojiId: "emoji-123",
},
expectedPayload: {
action: "editForumTopic",
chatId: "telegram:group:-1001234567890:topic:271",
messageThreadId: 271,
name: "Build Updates",
iconCustomEmojiId: "emoji-123",
accountId: undefined,
},
},
] as const;
for (const testCase of cases) {

View File

@ -44,6 +44,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
"category-edit",
"category-delete",
"topic-create",
"topic-edit",
"voice-status",
"event-list",
"event-create",

View File

@ -26,6 +26,8 @@ export type TelegramActionConfig = {
sticker?: boolean;
/** Enable forum topic creation. */
createForumTopic?: boolean;
/** Enable forum topic editing (rename / change icon). */
editForumTopic?: boolean;
};
export type TelegramNetworkConfig = {

View File

@ -258,6 +258,7 @@ export const TelegramAccountSchemaBase = z
editMessage: z.boolean().optional(),
sticker: z.boolean().optional(),
createForumTopic: z.boolean().optional(),
editForumTopic: z.boolean().optional(),
})
.strict()
.optional(),

View File

@ -49,6 +49,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
"category-edit": "none",
"category-delete": "none",
"topic-create": "to",
"topic-edit": "to",
"voice-status": "none",
"event-list": "none",
"event-create": "none",