feat(telegram): wire replyToMode config, add forum topic support, fix messaging tool duplicates

Changes:
- Default replyToMode from "off" to "first" for better threading UX
- Add messageThreadId and replyToMessageId params for forum topic support
- Add messaging tool duplicate detection to suppress redundant block replies
- Add sendMessage action to telegram tool schema
- Add @grammyjs/types devDependency for proper TypeScript typing
- Remove @ts-nocheck and fix all type errors in send.ts
- Add comprehensive docs/telegram.md documentation
- Add PR-326-REVIEW.md with John Carmack-level code review

Test coverage:
- normalizeTextForComparison: 5 cases
- isMessagingToolDuplicate: 7 cases
- sendMessageTelegram thread params: 5 cases
- handleTelegramAction sendMessage: 4 cases
- Forum topic isolation: 4 cases

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
mneves75
2026-01-07 03:24:56 -03:00
committed by Peter Steinberger
parent 6cd32ec7f6
commit 33e2d53be3
18 changed files with 872 additions and 38 deletions

View File

@@ -4,15 +4,21 @@ import type { ClawdbotConfig } from "../../config/config.js";
import { handleTelegramAction } from "./telegram-actions.js";
const reactMessageTelegram = vi.fn(async () => ({ ok: true }));
const sendMessageTelegram = vi.fn(async () => ({
messageId: "789",
chatId: "123",
}));
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
vi.mock("../../telegram/send.js", () => ({
reactMessageTelegram: (...args: unknown[]) => reactMessageTelegram(...args),
sendMessageTelegram: (...args: unknown[]) => sendMessageTelegram(...args),
}));
describe("handleTelegramAction", () => {
beforeEach(() => {
reactMessageTelegram.mockClear();
sendMessageTelegram.mockClear();
process.env.TELEGRAM_BOT_TOKEN = "tok";
});
@@ -92,4 +98,74 @@ describe("handleTelegramAction", () => {
),
).rejects.toThrow(/Telegram reactions are disabled/);
});
it("sends a text message", async () => {
const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig;
const result = await handleTelegramAction(
{
action: "sendMessage",
to: "@testchannel",
content: "Hello, Telegram!",
},
cfg,
);
expect(sendMessageTelegram).toHaveBeenCalledWith(
"@testchannel",
"Hello, Telegram!",
{ token: "tok", mediaUrl: undefined },
);
expect(result.content).toContainEqual({
type: "text",
text: expect.stringContaining('"ok": true'),
});
});
it("sends a message with media", async () => {
const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig;
await handleTelegramAction(
{
action: "sendMessage",
to: "123456",
content: "Check this image!",
mediaUrl: "https://example.com/image.jpg",
},
cfg,
);
expect(sendMessageTelegram).toHaveBeenCalledWith(
"123456",
"Check this image!",
{ token: "tok", mediaUrl: "https://example.com/image.jpg" },
);
});
it("respects sendMessage gating", async () => {
const cfg = {
telegram: { botToken: "tok", actions: { sendMessage: false } },
} as ClawdbotConfig;
await expect(
handleTelegramAction(
{
action: "sendMessage",
to: "@testchannel",
content: "Hello!",
},
cfg,
),
).rejects.toThrow(/Telegram sendMessage is disabled/);
});
it("throws on missing bot token for sendMessage", async () => {
delete process.env.TELEGRAM_BOT_TOKEN;
const cfg = {} as ClawdbotConfig;
await expect(
handleTelegramAction(
{
action: "sendMessage",
to: "@testchannel",
content: "Hello!",
},
cfg,
),
).rejects.toThrow(/Telegram bot token missing/);
});
});

View File

@@ -1,7 +1,10 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../../config/config.js";
import { reactMessageTelegram } from "../../telegram/send.js";
import {
reactMessageTelegram,
sendMessageTelegram,
} from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import {
createActionGate,
@@ -49,5 +52,38 @@ export async function handleTelegramAction(
return jsonResult({ ok: true, removed: true });
}
if (action === "sendMessage") {
if (!isActionEnabled("sendMessage")) {
throw new Error("Telegram sendMessage is disabled.");
}
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", { required: true });
const mediaUrl = readStringParam(params, "mediaUrl");
// Optional threading parameters for forum topics and reply chains
const replyToMessageId = readNumberParam(params, "replyToMessageId", {
integer: true,
});
const messageThreadId = readNumberParam(params, "messageThreadId", {
integer: true,
});
const token = resolveTelegramToken(cfg).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or telegram.botToken.",
);
}
const result = await sendMessageTelegram(to, content, {
token,
mediaUrl: mediaUrl || undefined,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
});
return jsonResult({
ok: true,
messageId: result.messageId,
chatId: result.chatId,
});
}
throw new Error(`Unsupported Telegram action: ${action}`);
}

View File

@@ -13,4 +13,22 @@ export const TelegramToolSchema = Type.Union([
},
includeRemove: true,
}),
Type.Object({
action: Type.Literal("sendMessage"),
to: Type.String({ description: "Chat ID, @username, or t.me/username" }),
content: Type.String({ description: "Message text to send" }),
mediaUrl: Type.Optional(
Type.String({ description: "URL of image/video/audio to attach" }),
),
replyToMessageId: Type.Optional(
Type.Union([Type.String(), Type.Number()], {
description: "Message ID to reply to (for threading)",
}),
),
messageThreadId: Type.Optional(
Type.Union([Type.String(), Type.Number()], {
description: "Forum topic thread ID (for forum supergroups)",
}),
),
}),
]);

View File

@@ -7,7 +7,7 @@ export function createTelegramTool(): AnyAgentTool {
return {
label: "Telegram",
name: "telegram",
description: "Manage Telegram reactions.",
description: "Send messages and manage reactions on Telegram.",
parameters: TelegramToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;