This commit is contained in:
hotfingerdip 2026-01-30 14:13:20 +07:00 committed by GitHub
commit 6212c73534
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 725 additions and 0 deletions

View File

@ -188,6 +188,27 @@ function buildGatewaySchema() {
};
}
function buildForumTopicSchema() {
return {
iconColor: Type.Optional(
Type.Number({
description:
"Topic icon color in RGB (e.g. 0x6FB9F0). Only for Telegram forum topic-create.",
}),
),
iconCustomEmojiId: Type.Optional(
Type.String({
description: "Custom emoji id for the topic icon. For Telegram topic-create/topic-edit.",
}),
),
messageThreadId: Type.Optional(
Type.Number({
description: "Forum topic thread id. For Telegram topic-edit/close/reopen/delete.",
}),
),
};
}
function buildChannelManagementSchema() {
return {
name: Type.Optional(Type.String()),
@ -220,6 +241,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean; include
...buildModerationSchema(),
...buildGatewaySchema(),
...buildChannelManagementSchema(),
...buildForumTopicSchema(),
};
}

View File

@ -2,9 +2,14 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
import {
closeForumTopicTelegram,
createForumTopicTelegram,
deleteForumTopicTelegram,
deleteMessageTelegram,
editForumTopicTelegram,
editMessageTelegram,
reactMessageTelegram,
reopenForumTopicTelegram,
sendMessageTelegram,
sendStickerTelegram,
} from "../../telegram/send.js";
@ -318,5 +323,146 @@ export async function handleTelegramAction(
return jsonResult({ ok: true, ...stats });
}
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(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(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(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(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(chatId ?? "", messageThreadId ?? 0, {
token,
accountId: accountId ?? undefined,
});
return jsonResult({ ok: true, deleted: true });
}
throw new Error(`Unsupported Telegram action: ${action}`);
}

View File

@ -118,4 +118,209 @@ describe("telegramMessageActions", () => {
expect(handleTelegramAction).not.toHaveBeenCalled();
});
it("excludes forum topic actions when not enabled", () => {
const cfg = { channels: { telegram: { botToken: "tok" } } } as MoltbotConfig;
const actions = telegramMessageActions.listActions({ cfg });
expect(actions).not.toContain("topic-create");
expect(actions).not.toContain("topic-edit");
expect(actions).not.toContain("topic-close");
expect(actions).not.toContain("topic-reopen");
expect(actions).not.toContain("topic-delete");
});
it("includes forum topic actions when forumTopics is enabled", () => {
const cfg = {
channels: { telegram: { botToken: "tok", actions: { forumTopics: true } } },
} as MoltbotConfig;
const actions = telegramMessageActions.listActions({ cfg });
expect(actions).toContain("topic-create");
expect(actions).toContain("topic-edit");
expect(actions).toContain("topic-close");
expect(actions).toContain("topic-reopen");
expect(actions).toContain("topic-delete");
});
it("maps topic-create action to createForumTopic", async () => {
handleTelegramAction.mockClear();
const cfg = {
channels: { telegram: { botToken: "tok", actions: { forumTopics: true } } },
} as MoltbotConfig;
await telegramMessageActions.handleAction({
action: "topic-create",
params: {
to: "-1001234567890",
name: "My New Topic",
iconColor: 0x6fb9f0,
iconCustomEmojiId: "emoji123",
},
cfg,
accountId: undefined,
});
expect(handleTelegramAction).toHaveBeenCalledWith(
{
action: "createForumTopic",
chatId: "-1001234567890",
name: "My New Topic",
iconColor: 0x6fb9f0,
iconCustomEmojiId: "emoji123",
accountId: undefined,
},
cfg,
);
});
it("maps topic-edit action to editForumTopic", async () => {
handleTelegramAction.mockClear();
const cfg = {
channels: { telegram: { botToken: "tok", actions: { forumTopics: true } } },
} as MoltbotConfig;
await telegramMessageActions.handleAction({
action: "topic-edit",
params: {
to: "-1001234567890",
messageThreadId: 42,
name: "Renamed Topic",
},
cfg,
accountId: undefined,
});
expect(handleTelegramAction).toHaveBeenCalledWith(
{
action: "editForumTopic",
chatId: "-1001234567890",
messageThreadId: 42,
name: "Renamed Topic",
iconCustomEmojiId: undefined,
accountId: undefined,
},
cfg,
);
});
it("maps topic-close action to closeForumTopic", async () => {
handleTelegramAction.mockClear();
const cfg = {
channels: { telegram: { botToken: "tok", actions: { forumTopics: true } } },
} as MoltbotConfig;
await telegramMessageActions.handleAction({
action: "topic-close",
params: {
to: "-1001234567890",
messageThreadId: 42,
},
cfg,
accountId: undefined,
});
expect(handleTelegramAction).toHaveBeenCalledWith(
{
action: "closeForumTopic",
chatId: "-1001234567890",
messageThreadId: 42,
accountId: undefined,
},
cfg,
);
});
it("maps topic-reopen action to reopenForumTopic", async () => {
handleTelegramAction.mockClear();
const cfg = {
channels: { telegram: { botToken: "tok", actions: { forumTopics: true } } },
} as MoltbotConfig;
await telegramMessageActions.handleAction({
action: "topic-reopen",
params: {
to: "-1001234567890",
messageThreadId: 42,
},
cfg,
accountId: undefined,
});
expect(handleTelegramAction).toHaveBeenCalledWith(
{
action: "reopenForumTopic",
chatId: "-1001234567890",
messageThreadId: 42,
accountId: undefined,
},
cfg,
);
});
it("maps topic-delete action to deleteForumTopic", async () => {
handleTelegramAction.mockClear();
const cfg = {
channels: { telegram: { botToken: "tok", actions: { forumTopics: true } } },
} as MoltbotConfig;
await telegramMessageActions.handleAction({
action: "topic-delete",
params: {
to: "-1001234567890",
messageThreadId: 42,
},
cfg,
accountId: undefined,
});
expect(handleTelegramAction).toHaveBeenCalledWith(
{
action: "deleteForumTopic",
chatId: "-1001234567890",
messageThreadId: 42,
accountId: undefined,
},
cfg,
);
});
it("requires messageThreadId for topic-edit", async () => {
handleTelegramAction.mockClear();
const cfg = {
channels: { telegram: { botToken: "tok", actions: { forumTopics: true } } },
} as MoltbotConfig;
await expect(
telegramMessageActions.handleAction({
action: "topic-edit",
params: {
to: "-1001234567890",
name: "Renamed",
},
cfg,
accountId: undefined,
}),
).rejects.toThrow();
expect(handleTelegramAction).not.toHaveBeenCalled();
});
it("requires name for topic-create", async () => {
handleTelegramAction.mockClear();
const cfg = {
channels: { telegram: { botToken: "tok", actions: { forumTopics: true } } },
} as MoltbotConfig;
await expect(
telegramMessageActions.handleAction({
action: "topic-create",
params: {
to: "-1001234567890",
},
cfg,
accountId: undefined,
}),
).rejects.toThrow();
expect(handleTelegramAction).not.toHaveBeenCalled();
});
});

View File

@ -52,6 +52,13 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
actions.add("sticker");
actions.add("sticker-search");
}
if (gate("forumTopics", false)) {
actions.add("topic-create");
actions.add("topic-edit");
actions.add("topic-close");
actions.add("topic-reopen");
actions.add("topic-delete");
}
return Array.from(actions);
},
supportsButtons: ({ cfg }) => {
@ -183,6 +190,106 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
);
}
if (action === "topic-create") {
const chatId =
readStringOrNumberParam(params, "chatId") ??
readStringParam(params, "to", { required: true });
const name = readStringParam(params, "name", { 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 === "topic-edit") {
const chatId =
readStringOrNumberParam(params, "chatId") ??
readStringParam(params, "to", { required: true });
const messageThreadId = readNumberParam(params, "messageThreadId", {
required: true,
integer: true,
});
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,
);
}
if (action === "topic-close") {
const chatId =
readStringOrNumberParam(params, "chatId") ??
readStringParam(params, "to", { required: true });
const messageThreadId = readNumberParam(params, "messageThreadId", {
required: true,
integer: true,
});
return await handleTelegramAction(
{
action: "closeForumTopic",
chatId,
messageThreadId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "topic-reopen") {
const chatId =
readStringOrNumberParam(params, "chatId") ??
readStringParam(params, "to", { required: true });
const messageThreadId = readNumberParam(params, "messageThreadId", {
required: true,
integer: true,
});
return await handleTelegramAction(
{
action: "reopenForumTopic",
chatId,
messageThreadId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "topic-delete") {
const chatId =
readStringOrNumberParam(params, "chatId") ??
readStringParam(params, "to", { required: true });
const messageThreadId = readNumberParam(params, "messageThreadId", {
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

@ -48,6 +48,11 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
"timeout",
"kick",
"ban",
"topic-create",
"topic-edit",
"topic-close",
"topic-reopen",
"topic-delete",
] as const;
export type ChannelMessageActionName = (typeof CHANNEL_MESSAGE_ACTION_NAMES)[number];

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

@ -53,6 +53,11 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
timeout: "none",
kick: "none",
ban: "none",
"topic-create": "to",
"topic-edit": "to",
"topic-close": "to",
"topic-reopen": "to",
"topic-delete": "to",
};
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {

View File

@ -722,3 +722,236 @@ export async function sendStickerTelegram(
return { messageId, chatId: resolvedChatId };
}
// ---------------------------------------------------------------------------
// Forum topic management
// ---------------------------------------------------------------------------
type TelegramForumTopicOpts = {
token?: string;
accountId?: string;
verbose?: boolean;
api?: Bot["api"];
retry?: RetryConfig;
};
type TelegramCreateForumTopicResult = {
messageThreadId: number;
name: string;
iconColor: number;
iconCustomEmojiId?: string;
};
export async function createForumTopicTelegram(
chatIdInput: string | number,
name: string,
opts: TelegramForumTopicOpts & {
iconColor?: number;
iconCustomEmojiId?: string;
} = {},
): Promise<TelegramCreateForumTopicResult> {
if (!name?.trim()) {
throw new Error("Forum topic name is required (1-128 characters)");
}
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;
});
const other: Record<string, unknown> = {};
if (opts.iconColor != null) other.icon_color = opts.iconColor;
if (opts.iconCustomEmojiId) other.icon_custom_emoji_id = opts.iconCustomEmojiId;
const result = await requestWithDiag(
() =>
api.createForumTopic(chatId, name.trim(), Object.keys(other).length > 0 ? other : undefined),
"createForumTopic",
);
logVerbose(`[telegram] Created forum topic "${name}" in chat ${chatId}`);
return {
messageThreadId: result.message_thread_id,
name: result.name,
iconColor: result.icon_color,
iconCustomEmojiId: result.icon_custom_emoji_id,
};
}
export async function editForumTopicTelegram(
chatIdInput: string | number,
messageThreadId: number,
opts: TelegramForumTopicOpts & {
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;
});
const other: Record<string, unknown> = {};
if (opts.name != null) other.name = opts.name.trim();
if (opts.iconCustomEmojiId != null) other.icon_custom_emoji_id = opts.iconCustomEmojiId;
await requestWithDiag(
() =>
api.editForumTopic(
chatId,
messageThreadId,
Object.keys(other).length > 0 ? other : undefined,
),
"editForumTopic",
);
logVerbose(`[telegram] Edited forum topic ${messageThreadId} in chat ${chatId}`);
return { ok: true };
}
export async function closeForumTopicTelegram(
chatIdInput: string | number,
messageThreadId: number,
opts: TelegramForumTopicOpts = {},
): 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;
});
await requestWithDiag(() => api.closeForumTopic(chatId, messageThreadId), "closeForumTopic");
logVerbose(`[telegram] Closed forum topic ${messageThreadId} in chat ${chatId}`);
return { ok: true };
}
export async function reopenForumTopicTelegram(
chatIdInput: string | number,
messageThreadId: number,
opts: TelegramForumTopicOpts = {},
): 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;
});
await requestWithDiag(() => api.reopenForumTopic(chatId, messageThreadId), "reopenForumTopic");
logVerbose(`[telegram] Reopened forum topic ${messageThreadId} in chat ${chatId}`);
return { ok: true };
}
export async function deleteForumTopicTelegram(
chatIdInput: string | number,
messageThreadId: number,
opts: TelegramForumTopicOpts = {},
): 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;
});
await requestWithDiag(() => api.deleteForumTopic(chatId, messageThreadId), "deleteForumTopic");
logVerbose(`[telegram] Deleted forum topic ${messageThreadId} in chat ${chatId}`);
return { ok: true };
}