Compare commits

...

11 Commits

Author SHA1 Message Date
Peter Steinberger
f1cd55a211 Merge remote-tracking branch 'origin/feat/telegram-topic-create' into feat/telegram-topic-create 2026-02-18 01:37:51 +01:00
Peter Steinberger
88d1b9126d fix(telegram): normalize topic-create targets and add regression tests 2026-02-18 01:36:27 +01:00
Claw
b007163057 fix: add action gate check and config type for createForumTopic
Address review feedback:
- Add isActionEnabled() gate in telegram-actions.ts
- Add gate() check in telegram adapter listActions
- Add createForumTopic to TelegramActionConfig type
2026-02-18 01:34:13 +01:00
Claw
68809ab80a chore: fix formatting in frontend-design SKILL.md 2026-02-18 01:34:13 +01:00
Claw
ae95bd3ec8 feat(telegram): add forum topic creation support (#10427)
Add `topic-create` action to the Telegram message adapter, enabling
programmatic creation of forum topics in supergroups.

Changes:
- Add `createForumTopicTelegram()` to `src/telegram/send.ts`
- Add `createForumTopic` handler in `telegram-actions.ts`
- Wire `topic-create` action in Telegram adapter
- Register `topic-create` in message action names and spec

The bot requires `can_manage_topics` permission in the target group.
Supports optional `iconColor` and `iconCustomEmojiId` parameters.

Closes #10427
2026-02-18 01:34:13 +01:00
Claw
6829b3c322 Add frontend-design skill 2026-02-18 01:34:13 +01:00
Peter Steinberger
d861a42ba8 fix(telegram): normalize topic-create targets and add regression tests 2026-02-17 23:47:44 +01:00
Claw
b5bfb73bce fix: add action gate check and config type for createForumTopic
Address review feedback:
- Add isActionEnabled() gate in telegram-actions.ts
- Add gate() check in telegram adapter listActions
- Add createForumTopic to TelegramActionConfig type
2026-02-15 10:32:46 +00:00
Claw
740dbf6ad9 chore: fix formatting in frontend-design SKILL.md 2026-02-15 10:26:44 +00:00
Claw
81e9c625b5 feat(telegram): add forum topic creation support (#10427)
Add `topic-create` action to the Telegram message adapter, enabling
programmatic creation of forum topics in supergroups.

Changes:
- Add `createForumTopicTelegram()` to `src/telegram/send.ts`
- Add `createForumTopic` handler in `telegram-actions.ts`
- Wire `topic-create` action in Telegram adapter
- Register `topic-create` in message action names and spec

The bot requires `can_manage_topics` permission in the target group.
Supports optional `iconColor` and `iconCustomEmojiId` parameters.

Closes #10427
2026-02-15 10:18:26 +00:00
Claw
1754daa0be Add frontend-design skill 2026-02-15 09:41:55 +00:00
8 changed files with 234 additions and 0 deletions

View File

@@ -8,6 +8,7 @@ import {
} from "../../telegram/inline-buttons.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
import {
createForumTopicTelegram,
deleteMessageTelegram,
editMessageTelegram,
reactMessageTelegram,
@@ -339,5 +340,35 @@ export async function handleTelegramAction(
return jsonResult({ ok: true, ...stats });
}
if (action === "createForumTopic") {
if (!isActionEnabled("createForumTopic")) {
throw new Error("Telegram createForumTopic is disabled.");
}
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,
topicId: result.topicId,
name: result.name,
chatId: result.chatId,
});
}
throw new Error(`Unsupported Telegram action: ${action}`);
}

View File

@@ -498,6 +498,33 @@ describe("telegramMessageActions", () => {
expect(String(callPayload.messageId)).toBe("456");
expect(callPayload.emoji).toBe("ok");
});
it("maps topic-create params into createForumTopic", async () => {
const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig;
await telegramMessageActions.handleAction?.({
channel: "telegram",
action: "topic-create",
params: {
to: "telegram:group:-1001234567890:topic:271",
name: "Build Updates",
},
cfg,
accountId: undefined,
});
expect(handleTelegramAction).toHaveBeenCalledWith(
{
action: "createForumTopic",
chatId: "telegram:group:-1001234567890:topic:271",
name: "Build Updates",
iconColor: undefined,
iconCustomEmojiId: undefined,
accountId: undefined,
},
cfg,
);
});
});
describe("signalMessageActions", () => {

View File

@@ -69,6 +69,9 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
actions.add("sticker");
actions.add("sticker-search");
}
if (gate("createForumTopic")) {
actions.add("topic-create");
}
return Array.from(actions);
},
supportsButtons: ({ cfg }) => {
@@ -199,6 +202,27 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
);
}
if (action === "topic-create") {
const chatId =
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "channelId") ??
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,
);
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
},
};

View File

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

View File

@@ -18,6 +18,8 @@ export type TelegramActionConfig = {
editMessage?: boolean;
/** Enable sticker actions (send and search). */
sticker?: boolean;
/** Enable forum topic creation. */
createForumTopic?: boolean;
};
export type TelegramNetworkConfig = {

View File

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

View File

@@ -12,6 +12,7 @@ installTelegramSendTestHooks();
const { botApi, botCtorSpy, loadConfig, loadWebMedia } = getTelegramSendTestMocks();
const {
buildInlineKeyboard,
createForumTopicTelegram,
editMessageTelegram,
reactMessageTelegram,
sendMessageTelegram,
@@ -1434,3 +1435,49 @@ describe("sendPollTelegram", () => {
expect(api.sendPoll).not.toHaveBeenCalled();
});
});
describe("createForumTopicTelegram", () => {
it("uses base chat id when target includes topic suffix", async () => {
const createForumTopic = vi.fn().mockResolvedValue({
message_thread_id: 272,
name: "Build Updates",
});
const api = { createForumTopic } as unknown as {
createForumTopic: typeof createForumTopic;
};
const result = await createForumTopicTelegram("telegram:group:-1001234567890:topic:271", "x", {
token: "tok",
api,
});
expect(createForumTopic).toHaveBeenCalledWith("-1001234567890", "x", undefined);
expect(result).toEqual({
topicId: 272,
name: "Build Updates",
chatId: "-1001234567890",
});
});
it("forwards optional icon fields", async () => {
const createForumTopic = vi.fn().mockResolvedValue({
message_thread_id: 300,
name: "Roadmap",
});
const api = { createForumTopic } as unknown as {
createForumTopic: typeof createForumTopic;
};
await createForumTopicTelegram("-1001234567890", "Roadmap", {
token: "tok",
api,
iconColor: 0x6fb9f0,
iconCustomEmojiId: " 1234567890 ",
});
expect(createForumTopic).toHaveBeenCalledWith("-1001234567890", "Roadmap", {
icon_color: 0x6fb9f0,
icon_custom_emoji_id: "1234567890",
});
});
});

View File

@@ -1077,3 +1077,104 @@ export async function sendPollTelegram(
return { messageId, chatId: resolvedChatId, pollId };
}
// ---------------------------------------------------------------------------
// Forum topic creation
// ---------------------------------------------------------------------------
type TelegramCreateForumTopicOpts = {
token?: string;
accountId?: string;
api?: Bot["api"];
verbose?: boolean;
retry?: RetryConfig;
/** Icon color for the topic (must be one of 0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F). */
iconColor?: number;
/** Custom emoji ID for the topic icon. */
iconCustomEmojiId?: string;
};
export type TelegramCreateForumTopicResult = {
topicId: number;
name: string;
chatId: string;
};
/**
* Create a forum topic in a Telegram supergroup.
* Requires the bot to have `can_manage_topics` permission.
*
* @param chatId - Supergroup chat ID
* @param name - Topic name (1-128 characters)
* @param opts - Optional configuration
*/
export async function createForumTopicTelegram(
chatId: string,
name: string,
opts: TelegramCreateForumTopicOpts = {},
): Promise<TelegramCreateForumTopicResult> {
if (!name?.trim()) {
throw new Error("Forum topic name is required");
}
const trimmedName = name.trim();
if (trimmedName.length > 128) {
throw new Error("Forum topic name must be 128 characters or fewer");
}
const cfg = loadConfig();
const account = resolveTelegramAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.token, account);
// Accept topic-qualified targets (e.g. telegram:group:<id>:topic:<thread>)
// but createForumTopic must always target the base supergroup chat id.
const target = parseTelegramTarget(chatId);
const normalizedChatId = normalizeChatId(target.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 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 extra: Record<string, unknown> = {};
if (opts.iconColor != null) {
extra.icon_color = opts.iconColor;
}
if (opts.iconCustomEmojiId?.trim()) {
extra.icon_custom_emoji_id = opts.iconCustomEmojiId.trim();
}
const hasExtra = Object.keys(extra).length > 0;
const result = await requestWithDiag(
() => api.createForumTopic(normalizedChatId, trimmedName, hasExtra ? extra : undefined),
"createForumTopic",
);
const topicId = result.message_thread_id;
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,
direction: "outbound",
});
return {
topicId,
name: result.name ?? trimmedName,
chatId: normalizedChatId,
};
}