feat(telegram): add forum topic management actions

Add support for creating, editing, closing, reopening, and deleting
Telegram forum topics via the message tool and CLI.

New actions:
- thread-create: Create a new forum topic
- thread-edit: Edit topic name/icon
- thread-close: Close a topic
- thread-reopen: Reopen a closed topic
- thread-delete: Delete a topic and all messages

CLI commands:
- clawdbot message thread create --channel telegram --target <chat_id> --thread-name <name>
- clawdbot message thread edit --channel telegram --target <chat_id> --thread-id <id> --thread-name <name>
- clawdbot message thread close --channel telegram --target <chat_id> --thread-id <id>
- clawdbot message thread reopen --channel telegram --target <chat_id> --thread-id <id>
- clawdbot message thread delete --channel telegram --target <chat_id> --thread-id <id>

Configuration:
Enable with channels.telegram.actions.forumTopics: true

The bot must be an administrator with can_manage_topics permission.

Closes #XXXX
This commit is contained in:
Daan 2026-01-27 10:55:11 +01:00
parent 72fea5e305
commit 061886fd6d
11 changed files with 14075 additions and 1 deletions

13233
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,13 @@ import {
sendMessageTelegram,
sendStickerTelegram,
} from "../../telegram/send.js";
import {
closeForumTopicTelegram,
createForumTopicTelegram,
deleteForumTopicTelegram,
editForumTopicTelegram,
reopenForumTopicTelegram,
} from "../../telegram/forum.js";
import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import {
@ -316,5 +323,137 @@ export async function handleTelegramAction(
return jsonResult({ ok: true, ...stats });
}
// Forum topic management actions
if (action === "createForumTopic") {
if (!isActionEnabled("forumTopics", false)) {
throw new Error(
"Telegram forum topic actions are disabled. Set channels.telegram.actions.forumTopics to true.",
);
}
const chatId = readStringOrNumberParam(params, "chatId", { required: true });
const name = readStringParam(params, "name", { required: true });
const iconColor = readNumberParam(params, "iconColor", { integer: true });
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 createForumTopicTelegram(String(chatId), name, {
token,
accountId: accountId ?? undefined,
iconColor: iconColor ?? undefined,
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
});
return jsonResult({
ok: true,
messageThreadId: result.messageThreadId,
name: result.name,
iconColor: result.iconColor,
iconCustomEmojiId: result.iconCustomEmojiId,
});
}
if (action === "editForumTopic") {
if (!isActionEnabled("forumTopics", false)) {
throw new Error(
"Telegram forum topic actions are disabled. Set channels.telegram.actions.forumTopics to true.",
);
}
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");
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(String(chatId), messageThreadId ?? 0, {
token,
accountId: accountId ?? undefined,
name: name ?? undefined,
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
});
return jsonResult({ ok: true, edited: true });
}
if (action === "closeForumTopic") {
if (!isActionEnabled("forumTopics", false)) {
throw new Error(
"Telegram forum topic actions are disabled. Set channels.telegram.actions.forumTopics to true.",
);
}
const chatId = readStringOrNumberParam(params, "chatId", { required: true });
const messageThreadId = readNumberParam(params, "messageThreadId", {
required: true,
integer: true,
});
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
await closeForumTopicTelegram(String(chatId), messageThreadId ?? 0, {
token,
accountId: accountId ?? undefined,
});
return jsonResult({ ok: true, closed: true });
}
if (action === "reopenForumTopic") {
if (!isActionEnabled("forumTopics", false)) {
throw new Error(
"Telegram forum topic actions are disabled. Set channels.telegram.actions.forumTopics to true.",
);
}
const chatId = readStringOrNumberParam(params, "chatId", { required: true });
const messageThreadId = readNumberParam(params, "messageThreadId", {
required: true,
integer: true,
});
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
await reopenForumTopicTelegram(String(chatId), messageThreadId ?? 0, {
token,
accountId: accountId ?? undefined,
});
return jsonResult({ ok: true, reopened: true });
}
if (action === "deleteForumTopic") {
if (!isActionEnabled("forumTopics", false)) {
throw new Error(
"Telegram forum topic actions are disabled. Set channels.telegram.actions.forumTopics to true.",
);
}
const chatId = readStringOrNumberParam(params, "chatId", { required: true });
const messageThreadId = readNumberParam(params, "messageThreadId", {
required: true,
integer: true,
});
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
await deleteForumTopicTelegram(String(chatId), messageThreadId ?? 0, {
token,
accountId: accountId ?? undefined,
});
return jsonResult({ ok: true, deleted: true });
}
throw new Error(`Unsupported Telegram action: ${action}`);
}

View File

@ -1 +1 @@
2567ca5bbc065b922d96717a488d5db3120b5b033c5d0508682d1aa8fbba470a
403d6f15feb541de53fc5a239bc15aae7400d73b72082934ce2af142431ae94c

View File

@ -118,4 +118,83 @@ describe("telegramMessageActions", () => {
expect(handleTelegramAction).not.toHaveBeenCalled();
});
it("excludes forum topic actions when not enabled", () => {
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
const actions = telegramMessageActions.listActions({ cfg });
expect(actions).not.toContain("thread-create");
expect(actions).not.toContain("thread-edit");
expect(actions).not.toContain("thread-close");
expect(actions).not.toContain("thread-reopen");
expect(actions).not.toContain("thread-delete");
});
it("includes forum topic actions when forumTopics is enabled", () => {
const cfg = {
channels: { telegram: { botToken: "tok", actions: { forumTopics: true } } },
} as ClawdbotConfig;
const actions = telegramMessageActions.listActions({ cfg });
expect(actions).toContain("thread-create");
expect(actions).toContain("thread-edit");
expect(actions).toContain("thread-close");
expect(actions).toContain("thread-reopen");
expect(actions).toContain("thread-delete");
});
it("maps thread-create action to createForumTopic", async () => {
handleTelegramAction.mockClear();
const cfg = {
channels: { telegram: { botToken: "tok", actions: { forumTopics: true } } },
} as ClawdbotConfig;
await telegramMessageActions.handleAction({
action: "thread-create",
params: {
target: "-1001234567890",
threadName: "Test Topic",
iconColor: 7322096,
},
cfg,
accountId: undefined,
});
expect(handleTelegramAction).toHaveBeenCalledWith(
{
action: "createForumTopic",
chatId: "-1001234567890",
name: "Test Topic",
iconColor: 7322096,
iconCustomEmojiId: undefined,
accountId: undefined,
},
cfg,
);
});
it("maps thread-close action to closeForumTopic", async () => {
handleTelegramAction.mockClear();
const cfg = {
channels: { telegram: { botToken: "tok", actions: { forumTopics: true } } },
} as ClawdbotConfig;
await telegramMessageActions.handleAction({
action: "thread-close",
params: {
target: "-1001234567890",
threadId: 42,
},
cfg,
accountId: undefined,
});
expect(handleTelegramAction).toHaveBeenCalledWith(
{
action: "closeForumTopic",
chatId: "-1001234567890",
messageThreadId: 42,
accountId: undefined,
},
cfg,
);
});
});

View File

@ -50,6 +50,13 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
actions.add("sticker");
actions.add("sticker-search");
}
if (gate("forumTopics", false)) {
actions.add("thread-create");
actions.add("thread-edit");
actions.add("thread-close");
actions.add("thread-reopen");
actions.add("thread-delete");
}
return Array.from(actions);
},
supportsButtons: ({ cfg }) => {
@ -181,6 +188,112 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
);
}
// Forum topic management actions
if (action === "thread-create") {
const chatId =
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "target") ??
readStringParam(params, "to", { required: true });
const name = readStringParam(params, "threadName", { required: true });
const iconColor = readNumberParam(params, "iconColor", { integer: true });
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
return await handleTelegramAction(
{
action: "createForumTopic",
chatId,
name,
iconColor: iconColor ?? undefined,
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "thread-edit") {
const chatId =
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "target") ??
readStringParam(params, "to", { required: true });
const messageThreadId = readNumberParam(params, "threadId", {
required: true,
integer: true,
});
const name = readStringParam(params, "threadName");
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
return await handleTelegramAction(
{
action: "editForumTopic",
chatId,
messageThreadId,
name: name ?? undefined,
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "thread-close") {
const chatId =
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "target") ??
readStringParam(params, "to", { required: true });
const messageThreadId = readNumberParam(params, "threadId", {
required: true,
integer: true,
});
return await handleTelegramAction(
{
action: "closeForumTopic",
chatId,
messageThreadId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "thread-reopen") {
const chatId =
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "target") ??
readStringParam(params, "to", { required: true });
const messageThreadId = readNumberParam(params, "threadId", {
required: true,
integer: true,
});
return await handleTelegramAction(
{
action: "reopenForumTopic",
chatId,
messageThreadId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "thread-delete") {
const chatId =
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "target") ??
readStringParam(params, "to", { required: true });
const messageThreadId = readNumberParam(params, "threadId", {
required: true,
integer: true,
});
return await handleTelegramAction(
{
action: "deleteForumTopic",
chatId,
messageThreadId,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
},
};

View File

@ -21,6 +21,10 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
"list-pins",
"permissions",
"thread-create",
"thread-edit",
"thread-close",
"thread-reopen",
"thread-delete",
"thread-list",
"thread-reply",
"search",

View File

@ -15,6 +15,11 @@ export function registerMessageThreadCommands(message: Command, helpers: Message
)
.option("--message-id <id>", "Message id (optional)")
.option("--auto-archive-min <n>", "Thread auto-archive minutes")
.option(
"--icon-color <color>",
"Icon color (Telegram: 7322096=blue, 16766590=yellow, 13338331=violet, 9367192=green, 16749490=rose, 16478047=red)",
)
.option("--icon-custom-emoji-id <id>", "Custom emoji id for topic icon (Telegram Premium)")
.action(async (opts) => {
await helpers.runMessageAction("thread-create", opts);
});
@ -51,4 +56,59 @@ export function registerMessageThreadCommands(message: Command, helpers: Message
.action(async (opts) => {
await helpers.runMessageAction("thread-reply", opts);
});
// Telegram forum topic management commands
helpers
.withMessageBase(
helpers.withRequiredMessageTarget(
thread
.command("edit")
.description("Edit a thread/topic (Telegram forums)")
.requiredOption("--thread-id <id>", "Thread/topic id"),
),
)
.option("--thread-name <name>", "New thread name")
.option("--icon-custom-emoji-id <id>", "Custom emoji id for topic icon")
.action(async (opts) => {
await helpers.runMessageAction("thread-edit", opts);
});
helpers
.withMessageBase(
helpers.withRequiredMessageTarget(
thread
.command("close")
.description("Close a thread/topic (Telegram forums)")
.requiredOption("--thread-id <id>", "Thread/topic id"),
),
)
.action(async (opts) => {
await helpers.runMessageAction("thread-close", opts);
});
helpers
.withMessageBase(
helpers.withRequiredMessageTarget(
thread
.command("reopen")
.description("Reopen a closed thread/topic (Telegram forums)")
.requiredOption("--thread-id <id>", "Thread/topic id"),
),
)
.action(async (opts) => {
await helpers.runMessageAction("thread-reopen", opts);
});
helpers
.withMessageBase(
helpers.withRequiredMessageTarget(
thread
.command("delete")
.description("Delete a thread/topic and all its messages (Telegram forums)")
.requiredOption("--thread-id <id>", "Thread/topic id"),
),
)
.action(async (opts) => {
await helpers.runMessageAction("thread-delete", opts);
});
}

View File

@ -18,6 +18,8 @@ export type TelegramActionConfig = {
editMessage?: boolean;
/** Enable sticker actions (send and search). */
sticker?: boolean;
/** Enable forum topic management actions (create, edit, close, reopen, delete). */
forumTopics?: boolean;
};
export type TelegramNetworkConfig = {

View File

@ -129,6 +129,7 @@ export const TelegramAccountSchemaBase = z
sendMessage: z.boolean().optional(),
deleteMessage: z.boolean().optional(),
sticker: z.boolean().optional(),
forumTopics: z.boolean().optional(),
})
.strict()
.optional(),

View File

@ -26,6 +26,10 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
"list-pins": "to",
permissions: "to",
"thread-create": "to",
"thread-edit": "to",
"thread-close": "to",
"thread-reopen": "to",
"thread-delete": "to",
"thread-list": "none",
"thread-reply": "to",
search: "none",

439
src/telegram/forum.ts Normal file
View File

@ -0,0 +1,439 @@
/**
* Telegram Forum Topic Management
*
* Functions for creating, editing, closing, reopening, and deleting
* forum topics in Telegram supergroups.
*/
import type { ForumTopic } from "@grammyjs/types";
import { type ApiClientOptions, Bot } from "grammy";
import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { RetryConfig } from "../infra/retry.js";
import { createTelegramRetryRunner } from "../infra/retry-policy.js";
import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramFetch } from "./fetch.js";
import { makeProxyFetch } from "./proxy.js";
import { isRecoverableTelegramNetworkError } from "./network-errors.js";
import { stripTelegramInternalPrefixes } from "./targets.js";
type ForumTopicOpts = {
token?: string;
accountId?: string;
verbose?: boolean;
api?: Bot["api"];
retry?: RetryConfig;
};
/**
* Valid icon colors for Telegram forum topics.
* These are the only colors supported by the Telegram API.
*/
export const FORUM_TOPIC_ICON_COLORS = [
7322096, // Blue
16766590, // Yellow
13338331, // Violet
9367192, // Green
16749490, // Rose
16478047, // Red
] as const;
export type ForumTopicIconColor = (typeof FORUM_TOPIC_ICON_COLORS)[number];
type CreateForumTopicOpts = ForumTopicOpts & {
/** Color of the topic icon (one of Telegram's predefined colors: 7322096, 16766590, 13338331, 9367192, 16749490, 16478047) */
iconColor?: number;
/** Custom emoji identifier for the topic icon */
iconCustomEmojiId?: string;
};
type EditForumTopicOpts = ForumTopicOpts & {
/** New name for the topic (0-128 chars, can be empty for General topic) */
name?: string;
/** Custom emoji identifier for the topic icon; pass empty string to remove */
iconCustomEmojiId?: string;
};
export type ForumTopicResult = {
messageThreadId: number;
name: string;
iconColor?: number;
iconCustomEmojiId?: string;
};
/**
* Validates and casts a number to a valid forum topic icon color.
* Returns undefined if the color is not one of the allowed values.
*/
function validateIconColor(color: number | undefined): ForumTopicIconColor | undefined {
if (color == null) return undefined;
if ((FORUM_TOPIC_ICON_COLORS as readonly number[]).includes(color)) {
return color as ForumTopicIconColor;
}
return undefined;
}
function resolveTelegramClientOptions(
account: ResolvedTelegramAccount,
): ApiClientOptions | undefined {
const proxyUrl = account.config.proxy?.trim();
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
const fetchImpl = resolveTelegramFetch(proxyFetch, {
network: account.config.network,
});
const timeoutSeconds =
typeof account.config.timeoutSeconds === "number" &&
Number.isFinite(account.config.timeoutSeconds)
? Math.max(1, Math.floor(account.config.timeoutSeconds))
: undefined;
return fetchImpl || timeoutSeconds
? {
...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}),
...(timeoutSeconds ? { timeoutSeconds } : {}),
}
: undefined;
}
function resolveToken(explicit: string | undefined, params: { accountId: string; token: string }) {
if (explicit?.trim()) return explicit.trim();
if (!params.token) {
throw new Error(
`Telegram bot token missing for account "${params.accountId}" (set channels.telegram.accounts.${params.accountId}.botToken/tokenFile or TELEGRAM_BOT_TOKEN for default).`,
);
}
return params.token.trim();
}
function normalizeChatId(to: string): string {
const trimmed = to.trim();
if (!trimmed) throw new Error("Chat ID is required for forum topic operations");
let normalized = stripTelegramInternalPrefixes(trimmed);
// Accept t.me links for public chats
const m =
/^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ??
/^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized);
if (m?.[1]) normalized = `@${m[1]}`;
if (!normalized) throw new Error("Chat ID is required for forum topic operations");
if (normalized.startsWith("@")) return normalized;
if (/^-?\d+$/.test(normalized)) return normalized;
// If the user passed a username without `@`, assume they meant a public chat
if (/^[A-Za-z0-9_]{5,}$/i.test(normalized)) return `@${normalized}`;
return normalized;
}
function wrapForumError(err: unknown, chatId: string, operation: string): Error {
const errText = formatErrorMessage(err);
if (/chat not found/i.test(errText)) {
return new Error(
`Telegram ${operation} failed: chat not found (chat_id=${chatId}). ` +
"Ensure the bot is a member of the group with can_manage_topics permission.",
);
}
if (/CHAT_NOT_MODIFIED/i.test(errText)) {
return new Error(`Telegram ${operation} failed: topic not modified (no changes detected).`);
}
if (/TOPIC_NOT_MODIFIED/i.test(errText)) {
return new Error(`Telegram ${operation} failed: topic not modified (no changes detected).`);
}
if (/not enough rights/i.test(errText) || /CHAT_ADMIN_REQUIRED/i.test(errText)) {
return new Error(
`Telegram ${operation} failed: bot needs administrator rights with can_manage_topics permission.`,
);
}
if (/TOPIC_CLOSED/i.test(errText)) {
return new Error(`Telegram ${operation} failed: topic is closed. Reopen it first.`);
}
if (/TOPIC_NOT_CLOSED/i.test(errText)) {
return new Error(`Telegram ${operation} failed: topic is not closed.`);
}
return err instanceof Error ? err : new Error(String(err));
}
/**
* Create a new forum topic in a supergroup chat.
* The bot must be an administrator with can_manage_topics permission.
*/
export async function createForumTopicTelegram(
chatId: string,
name: string,
opts: CreateForumTopicOpts = {},
): Promise<ForumTopicResult> {
if (!name?.trim()) {
throw new Error("Topic name is required (1-128 characters)");
}
const trimmedName = name.trim();
if (trimmedName.length > 128) {
throw new Error("Topic name must be 128 characters or less");
}
const cfg = loadConfig();
const account = resolveTelegramAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.token, account);
const normalizedChatId = normalizeChatId(chatId);
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 requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
withTelegramApiErrorLogging({
operation: label ?? "request",
fn: () => request(fn, label),
});
const other: { icon_color?: ForumTopicIconColor; icon_custom_emoji_id?: string } = {};
if (opts.iconColor != null) {
const validatedColor = validateIconColor(opts.iconColor);
if (validatedColor != null) {
other.icon_color = validatedColor;
}
}
if (opts.iconCustomEmojiId != null) {
other.icon_custom_emoji_id = opts.iconCustomEmojiId;
}
const result: ForumTopic = await requestWithDiag(
() =>
api.createForumTopic(
normalizedChatId,
trimmedName,
Object.keys(other).length > 0 ? other : undefined,
),
"createForumTopic",
).catch((err) => {
throw wrapForumError(err, normalizedChatId, "createForumTopic");
});
logVerbose(
`[telegram] Created forum topic "${trimmedName}" (thread_id=${result.message_thread_id}) in chat ${normalizedChatId}`,
);
return {
messageThreadId: result.message_thread_id,
name: result.name,
iconColor: result.icon_color,
iconCustomEmojiId: result.icon_custom_emoji_id,
};
}
/**
* Edit name and/or icon of a forum topic.
* The bot must be an administrator with can_manage_topics permission.
*/
export async function editForumTopicTelegram(
chatId: string,
messageThreadId: number,
opts: EditForumTopicOpts = {},
): Promise<{ ok: true }> {
if (opts.name != null && opts.name.length > 128) {
throw new Error("Topic name must be 128 characters or less");
}
const cfg = loadConfig();
const account = resolveTelegramAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.token, account);
const normalizedChatId = normalizeChatId(chatId);
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 requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
withTelegramApiErrorLogging({
operation: label ?? "request",
fn: () => request(fn, label),
});
const other: { name?: string; icon_custom_emoji_id?: string } = {};
if (opts.name != null) {
other.name = opts.name;
}
if (opts.iconCustomEmojiId != null) {
other.icon_custom_emoji_id = opts.iconCustomEmojiId;
}
if (Object.keys(other).length === 0) {
throw new Error("At least one of name or iconCustomEmojiId must be provided");
}
await requestWithDiag(
() => api.editForumTopic(normalizedChatId, messageThreadId, other),
"editForumTopic",
).catch((err) => {
throw wrapForumError(err, normalizedChatId, "editForumTopic");
});
logVerbose(
`[telegram] Edited forum topic (thread_id=${messageThreadId}) in chat ${normalizedChatId}`,
);
return { ok: true };
}
/**
* Close a forum topic.
* The bot must be an administrator with can_manage_topics permission.
*/
export async function closeForumTopicTelegram(
chatId: string,
messageThreadId: number,
opts: ForumTopicOpts = {},
): Promise<{ ok: true }> {
const cfg = loadConfig();
const account = resolveTelegramAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.token, account);
const normalizedChatId = normalizeChatId(chatId);
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 requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
withTelegramApiErrorLogging({
operation: label ?? "request",
fn: () => request(fn, label),
});
await requestWithDiag(
() => api.closeForumTopic(normalizedChatId, messageThreadId),
"closeForumTopic",
).catch((err) => {
throw wrapForumError(err, normalizedChatId, "closeForumTopic");
});
logVerbose(
`[telegram] Closed forum topic (thread_id=${messageThreadId}) in chat ${normalizedChatId}`,
);
return { ok: true };
}
/**
* Reopen a closed forum topic.
* The bot must be an administrator with can_manage_topics permission.
*/
export async function reopenForumTopicTelegram(
chatId: string,
messageThreadId: number,
opts: ForumTopicOpts = {},
): Promise<{ ok: true }> {
const cfg = loadConfig();
const account = resolveTelegramAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.token, account);
const normalizedChatId = normalizeChatId(chatId);
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 requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
withTelegramApiErrorLogging({
operation: label ?? "request",
fn: () => request(fn, label),
});
await requestWithDiag(
() => api.reopenForumTopic(normalizedChatId, messageThreadId),
"reopenForumTopic",
).catch((err) => {
throw wrapForumError(err, normalizedChatId, "reopenForumTopic");
});
logVerbose(
`[telegram] Reopened forum topic (thread_id=${messageThreadId}) in chat ${normalizedChatId}`,
);
return { ok: true };
}
/**
* Delete a forum topic along with all its messages.
* The bot must be an administrator with can_manage_topics permission.
*/
export async function deleteForumTopicTelegram(
chatId: string,
messageThreadId: number,
opts: ForumTopicOpts = {},
): Promise<{ ok: true }> {
const cfg = loadConfig();
const account = resolveTelegramAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.token, account);
const normalizedChatId = normalizeChatId(chatId);
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 requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
withTelegramApiErrorLogging({
operation: label ?? "request",
fn: () => request(fn, label),
});
await requestWithDiag(
() => api.deleteForumTopic(normalizedChatId, messageThreadId),
"deleteForumTopic",
).catch((err) => {
throw wrapForumError(err, normalizedChatId, "deleteForumTopic");
});
logVerbose(
`[telegram] Deleted forum topic (thread_id=${messageThreadId}) in chat ${normalizedChatId}`,
);
return { ok: true };
}