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:
parent
72fea5e305
commit
061886fd6d
13233
package-lock.json
generated
Normal file
13233
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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}`);
|
||||
}
|
||||
|
||||
@ -1 +1 @@
|
||||
2567ca5bbc065b922d96717a488d5db3120b5b033c5d0508682d1aa8fbba470a
|
||||
403d6f15feb541de53fc5a239bc15aae7400d73b72082934ce2af142431ae94c
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}.`);
|
||||
},
|
||||
};
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
439
src/telegram/forum.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user