feat(telegram): add editForumTopic action for renaming forum topics
Add a new Telegram tool action 'editForumTopic' that allows the agent to rename forum topics (and change their icon) using the Telegram Bot API. Changes: - Add editForumTopicTelegram() function in send.ts with support for both regular topics and the General topic (thread_id=1, uses separate API) - Add 'editForumTopic' action handler in telegram-actions.ts with proper parameter validation and action gating - Add editForumTopic field to TelegramActionConfig type - Add 4 tests covering happy path, gating, validation, and required params The action accepts: - chatId (required): target chat - messageThreadId (required): topic thread ID to edit - name (optional): new topic name - iconCustomEmojiId (optional): new custom emoji icon At least one of name or iconCustomEmojiId must be provided. Closes #3582
This commit is contained in:
parent
a7534dc223
commit
f2664ca852
1
.gitignore
vendored
1
.gitignore
vendored
@ -71,3 +71,4 @@ USER.md
|
||||
|
||||
# local tooling
|
||||
.serena/
|
||||
package-lock.json
|
||||
|
||||
@ -13,6 +13,7 @@ const sendStickerTelegram = vi.fn(async () => ({
|
||||
chatId: "123",
|
||||
}));
|
||||
const deleteMessageTelegram = vi.fn(async () => ({ ok: true }));
|
||||
const editForumTopicTelegram = vi.fn(async () => ({ ok: true }));
|
||||
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
|
||||
vi.mock("../../telegram/send.js", () => ({
|
||||
@ -20,6 +21,7 @@ vi.mock("../../telegram/send.js", () => ({
|
||||
sendMessageTelegram: (...args: unknown[]) => sendMessageTelegram(...args),
|
||||
sendStickerTelegram: (...args: unknown[]) => sendStickerTelegram(...args),
|
||||
deleteMessageTelegram: (...args: unknown[]) => deleteMessageTelegram(...args),
|
||||
editForumTopicTelegram: (...args: unknown[]) => editForumTopicTelegram(...args),
|
||||
}));
|
||||
|
||||
describe("handleTelegramAction", () => {
|
||||
@ -28,6 +30,7 @@ describe("handleTelegramAction", () => {
|
||||
sendMessageTelegram.mockClear();
|
||||
sendStickerTelegram.mockClear();
|
||||
deleteMessageTelegram.mockClear();
|
||||
editForumTopicTelegram.mockClear();
|
||||
process.env.TELEGRAM_BOT_TOKEN = "tok";
|
||||
});
|
||||
|
||||
@ -506,6 +509,80 @@ describe("handleTelegramAction", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("edits a forum topic name", async () => {
|
||||
const cfg = {
|
||||
channels: { telegram: { botToken: "tok" } },
|
||||
} as MoltbotConfig;
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "editForumTopic",
|
||||
chatId: "-1001234567890",
|
||||
messageThreadId: 42,
|
||||
name: "New Topic Name",
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(editForumTopicTelegram).toHaveBeenCalledWith(
|
||||
"-1001234567890",
|
||||
42,
|
||||
expect.objectContaining({
|
||||
token: "tok",
|
||||
name: "New Topic Name",
|
||||
iconCustomEmojiId: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("respects editForumTopic gating", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: { botToken: "tok", actions: { editForumTopic: false } },
|
||||
},
|
||||
} as MoltbotConfig;
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
{
|
||||
action: "editForumTopic",
|
||||
chatId: "-1001234567890",
|
||||
messageThreadId: 42,
|
||||
name: "New Name",
|
||||
},
|
||||
cfg,
|
||||
),
|
||||
).rejects.toThrow(/Telegram editForumTopic is disabled/);
|
||||
});
|
||||
|
||||
it("requires at least name or iconCustomEmojiId", async () => {
|
||||
const cfg = {
|
||||
channels: { telegram: { botToken: "tok" } },
|
||||
} as MoltbotConfig;
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
{
|
||||
action: "editForumTopic",
|
||||
chatId: "-1001234567890",
|
||||
messageThreadId: 42,
|
||||
},
|
||||
cfg,
|
||||
),
|
||||
).rejects.toThrow(/At least one of name or iconCustomEmojiId is required/);
|
||||
});
|
||||
|
||||
it("requires chatId and messageThreadId", async () => {
|
||||
const cfg = {
|
||||
channels: { telegram: { botToken: "tok" } },
|
||||
} as MoltbotConfig;
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
{
|
||||
action: "editForumTopic",
|
||||
name: "New Name",
|
||||
},
|
||||
cfg,
|
||||
),
|
||||
).rejects.toThrow(/chatId required/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readTelegramButtons", () => {
|
||||
|
||||
@ -3,6 +3,7 @@ import type { MoltbotConfig } from "../../config/config.js";
|
||||
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
|
||||
import {
|
||||
deleteMessageTelegram,
|
||||
editForumTopicTelegram,
|
||||
editMessageTelegram,
|
||||
reactMessageTelegram,
|
||||
sendMessageTelegram,
|
||||
@ -318,5 +319,36 @@ export async function handleTelegramAction(
|
||||
return jsonResult({ ok: true, ...stats });
|
||||
}
|
||||
|
||||
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", {
|
||||
required: true,
|
||||
integer: true,
|
||||
});
|
||||
const name = readStringParam(params, "name");
|
||||
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
|
||||
if (!name && !iconCustomEmojiId) {
|
||||
throw new Error("At least one of name or iconCustomEmojiId is required.");
|
||||
}
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
await editForumTopicTelegram(chatId ?? "", messageThreadId ?? 0, {
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
name: name ?? undefined,
|
||||
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Telegram action: ${action}`);
|
||||
}
|
||||
|
||||
@ -16,6 +16,8 @@ export type TelegramActionConfig = {
|
||||
sendMessage?: boolean;
|
||||
deleteMessage?: boolean;
|
||||
editMessage?: boolean;
|
||||
/** Enable editing forum topic name/icon. */
|
||||
editForumTopic?: boolean;
|
||||
/** Enable sticker actions (send and search). */
|
||||
sticker?: boolean;
|
||||
};
|
||||
|
||||
@ -630,6 +630,14 @@ function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
|
||||
}
|
||||
}
|
||||
|
||||
type TelegramEditForumTopicOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
api?: Bot["api"];
|
||||
retry?: RetryConfig;
|
||||
};
|
||||
|
||||
type TelegramStickerOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
@ -642,6 +650,62 @@ type TelegramStickerOpts = {
|
||||
messageThreadId?: number;
|
||||
};
|
||||
|
||||
export async function editForumTopicTelegram(
|
||||
chatIdInput: string | number,
|
||||
messageThreadId: number,
|
||||
opts: TelegramEditForumTopicOpts & {
|
||||
name?: string;
|
||||
iconCustomEmojiId?: string;
|
||||
} = {},
|
||||
): Promise<{ ok: true }> {
|
||||
const cfg = loadConfig();
|
||||
const account = resolveTelegramAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveToken(opts.token, account);
|
||||
const chatId = normalizeChatId(String(chatIdInput));
|
||||
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,
|
||||
shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
|
||||
});
|
||||
const logHttpError = createTelegramHttpLogger(cfg);
|
||||
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
||||
withTelegramApiErrorLogging({
|
||||
operation: label ?? "request",
|
||||
fn: () => request(fn, label),
|
||||
}).catch((err) => {
|
||||
logHttpError(label ?? "request", err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
// The General topic (thread_id = 1) uses a different API method
|
||||
if (messageThreadId === 1) {
|
||||
if (!opts.name) {
|
||||
throw new Error("name is required for editing the General forum topic");
|
||||
}
|
||||
await requestWithDiag(
|
||||
() => api.editGeneralForumTopic(chatId, opts.name),
|
||||
"editGeneralForumTopic",
|
||||
);
|
||||
} else {
|
||||
const editParams: { name?: string; icon_custom_emoji_id?: string } = {};
|
||||
if (opts.name) editParams.name = opts.name;
|
||||
if (opts.iconCustomEmojiId) editParams.icon_custom_emoji_id = opts.iconCustomEmojiId;
|
||||
await requestWithDiag(
|
||||
() => api.editForumTopic(chatId, messageThreadId, editParams),
|
||||
"editForumTopic",
|
||||
);
|
||||
}
|
||||
|
||||
logVerbose(`[telegram] Edited forum topic ${messageThreadId} in chat ${chatId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sticker to a Telegram chat by file_id.
|
||||
* @param to - Chat ID or username (e.g., "123456789" or "@username")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user