refactor(telegram): centralize target parsing
This commit is contained in:
@@ -20,10 +20,7 @@ vi.mock("../agents/model-catalog.js", () => ({
|
|||||||
|
|
||||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||||
import {
|
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||||
parseTelegramTarget,
|
|
||||||
runCronIsolatedAgentTurn,
|
|
||||||
} from "./isolated-agent.js";
|
|
||||||
|
|
||||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cron-"));
|
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cron-"));
|
||||||
@@ -673,63 +670,3 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseTelegramTarget", () => {
|
|
||||||
it("parses plain chatId", () => {
|
|
||||||
expect(parseTelegramTarget("-1001234567890")).toEqual({
|
|
||||||
chatId: "-1001234567890",
|
|
||||||
topicId: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses @username", () => {
|
|
||||||
expect(parseTelegramTarget("@mychannel")).toEqual({
|
|
||||||
chatId: "@mychannel",
|
|
||||||
topicId: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses chatId:topicId format", () => {
|
|
||||||
expect(parseTelegramTarget("-1001234567890:123")).toEqual({
|
|
||||||
chatId: "-1001234567890",
|
|
||||||
topicId: 123,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses chatId:topic:topicId format", () => {
|
|
||||||
expect(parseTelegramTarget("-1001234567890:topic:456")).toEqual({
|
|
||||||
chatId: "-1001234567890",
|
|
||||||
topicId: 456,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("trims whitespace", () => {
|
|
||||||
expect(parseTelegramTarget(" -1001234567890:99 ")).toEqual({
|
|
||||||
chatId: "-1001234567890",
|
|
||||||
topicId: 99,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not treat non-numeric suffix as topicId", () => {
|
|
||||||
expect(parseTelegramTarget("-1001234567890:abc")).toEqual({
|
|
||||||
chatId: "-1001234567890:abc",
|
|
||||||
topicId: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips internal telegram prefix", () => {
|
|
||||||
expect(parseTelegramTarget("telegram:123")).toEqual({
|
|
||||||
chatId: "123",
|
|
||||||
topicId: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips internal telegram + group prefixes before parsing topic", () => {
|
|
||||||
expect(
|
|
||||||
parseTelegramTarget("telegram:group:-1001234567890:topic:456"),
|
|
||||||
).toEqual({
|
|
||||||
chatId: "-1001234567890",
|
|
||||||
topicId: 456,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -50,45 +50,6 @@ import { resolveTelegramToken } from "../telegram/token.js";
|
|||||||
import { normalizeE164 } from "../utils.js";
|
import { normalizeE164 } from "../utils.js";
|
||||||
import type { CronJob } from "./types.js";
|
import type { CronJob } from "./types.js";
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a Telegram delivery target into chatId and optional topicId.
|
|
||||||
* Supports formats:
|
|
||||||
* - `chatId` (plain chat ID or @username)
|
|
||||||
* - `chatId:topicId` (chat ID with topic/thread ID)
|
|
||||||
* - `chatId:topic:topicId` (alternative format with explicit "topic" marker)
|
|
||||||
*/
|
|
||||||
export function parseTelegramTarget(to: string): {
|
|
||||||
chatId: string;
|
|
||||||
topicId: number | undefined;
|
|
||||||
} {
|
|
||||||
let trimmed = to.trim();
|
|
||||||
|
|
||||||
// Cron "lastTo" values can include internal prefixes like `telegram:...` or
|
|
||||||
// `telegram:group:...` (see normalizeChatId in telegram/send.ts).
|
|
||||||
// Strip these before parsing `:topic:` / `:<topicId>` suffixes.
|
|
||||||
while (true) {
|
|
||||||
const next = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
|
|
||||||
if (next === trimmed) break;
|
|
||||||
trimmed = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try format: chatId:topic:topicId
|
|
||||||
const topicMatch = /^(.+?):topic:(\d+)$/.exec(trimmed);
|
|
||||||
if (topicMatch) {
|
|
||||||
return { chatId: topicMatch[1], topicId: parseInt(topicMatch[2], 10) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try format: chatId:topicId (where topicId is numeric)
|
|
||||||
// Be careful not to match @username or other non-numeric suffixes
|
|
||||||
const colonMatch = /^(.+):(\d+)$/.exec(trimmed);
|
|
||||||
if (colonMatch) {
|
|
||||||
return { chatId: colonMatch[1], topicId: parseInt(colonMatch[2], 10) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plain chatId, no topic
|
|
||||||
return { chatId: trimmed, topicId: undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RunCronAgentTurnResult = {
|
export type RunCronAgentTurnResult = {
|
||||||
status: "ok" | "error" | "skipped";
|
status: "ok" | "error" | "skipped";
|
||||||
summary?: string;
|
summary?: string;
|
||||||
@@ -526,7 +487,6 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
summary: "Delivery skipped (no Telegram chatId).",
|
summary: "Delivery skipped (no Telegram chatId).",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const { chatId, topicId } = parseTelegramTarget(resolvedDelivery.to);
|
|
||||||
const textLimit = resolveTextChunkLimit(params.cfg, "telegram");
|
const textLimit = resolveTextChunkLimit(params.cfg, "telegram");
|
||||||
try {
|
try {
|
||||||
for (const payload of payloads) {
|
for (const payload of payloads) {
|
||||||
@@ -537,23 +497,29 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
payload.text ?? "",
|
payload.text ?? "",
|
||||||
textLimit,
|
textLimit,
|
||||||
)) {
|
)) {
|
||||||
await params.deps.sendMessageTelegram(chatId, chunk, {
|
await params.deps.sendMessageTelegram(
|
||||||
verbose: false,
|
resolvedDelivery.to,
|
||||||
token: telegramToken || undefined,
|
chunk,
|
||||||
messageThreadId: topicId,
|
{
|
||||||
});
|
verbose: false,
|
||||||
|
token: telegramToken || undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let first = true;
|
let first = true;
|
||||||
for (const url of mediaList) {
|
for (const url of mediaList) {
|
||||||
const caption = first ? (payload.text ?? "") : "";
|
const caption = first ? (payload.text ?? "") : "";
|
||||||
first = false;
|
first = false;
|
||||||
await params.deps.sendMessageTelegram(chatId, caption, {
|
await params.deps.sendMessageTelegram(
|
||||||
verbose: false,
|
resolvedDelivery.to,
|
||||||
mediaUrl: url,
|
caption,
|
||||||
token: telegramToken || undefined,
|
{
|
||||||
messageThreadId: topicId,
|
verbose: false,
|
||||||
});
|
mediaUrl: url,
|
||||||
|
token: telegramToken || undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,6 +302,31 @@ describe("sendMessageTelegram", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => {
|
||||||
|
const chatId = "-1001234567890";
|
||||||
|
const sendMessage = vi.fn().mockResolvedValue({
|
||||||
|
message_id: 55,
|
||||||
|
chat: { id: chatId },
|
||||||
|
});
|
||||||
|
const api = { sendMessage } as unknown as {
|
||||||
|
sendMessage: typeof sendMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
await sendMessageTelegram(
|
||||||
|
`telegram:group:${chatId}:topic:271`,
|
||||||
|
"hello forum",
|
||||||
|
{
|
||||||
|
token: "tok",
|
||||||
|
api,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello forum", {
|
||||||
|
parse_mode: "HTML",
|
||||||
|
message_thread_id: 271,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("includes reply_to_message_id for threaded replies", async () => {
|
it("includes reply_to_message_id for threaded replies", async () => {
|
||||||
const chatId = "123";
|
const chatId = "123";
|
||||||
const sendMessage = vi.fn().mockResolvedValue({
|
const sendMessage = vi.fn().mockResolvedValue({
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { loadWebMedia } from "../web/media.js";
|
|||||||
import { resolveTelegramAccount } from "./accounts.js";
|
import { resolveTelegramAccount } from "./accounts.js";
|
||||||
import { resolveTelegramFetch } from "./fetch.js";
|
import { resolveTelegramFetch } from "./fetch.js";
|
||||||
import { markdownToTelegramHtml } from "./format.js";
|
import { markdownToTelegramHtml } from "./format.js";
|
||||||
|
import {
|
||||||
|
parseTelegramTarget,
|
||||||
|
stripTelegramInternalPrefixes,
|
||||||
|
} from "./targets.js";
|
||||||
|
|
||||||
type TelegramSendOpts = {
|
type TelegramSendOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -65,7 +69,7 @@ function normalizeChatId(to: string): string {
|
|||||||
// Common internal prefixes that sometimes leak into outbound sends.
|
// Common internal prefixes that sometimes leak into outbound sends.
|
||||||
// - ctx.To uses `telegram:<id>`
|
// - ctx.To uses `telegram:<id>`
|
||||||
// - group sessions often use `telegram:group:<id>`
|
// - group sessions often use `telegram:group:<id>`
|
||||||
let normalized = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
|
let normalized = stripTelegramInternalPrefixes(trimmed);
|
||||||
|
|
||||||
// Accept t.me links for public chats/channels.
|
// Accept t.me links for public chats/channels.
|
||||||
// (Invite links like `t.me/+...` are not resolvable via Bot API.)
|
// (Invite links like `t.me/+...` are not resolvable via Bot API.)
|
||||||
@@ -110,7 +114,8 @@ export async function sendMessageTelegram(
|
|||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
});
|
});
|
||||||
const token = resolveToken(opts.token, account);
|
const token = resolveToken(opts.token, account);
|
||||||
const chatId = normalizeChatId(to);
|
const target = parseTelegramTarget(to);
|
||||||
|
const chatId = normalizeChatId(target.chatId);
|
||||||
// Use provided api or create a new Bot instance. The nullish coalescing
|
// Use provided api or create a new Bot instance. The nullish coalescing
|
||||||
// operator ensures api is always defined (Bot.api is always non-null).
|
// operator ensures api is always defined (Bot.api is always non-null).
|
||||||
const fetchImpl = resolveTelegramFetch();
|
const fetchImpl = resolveTelegramFetch();
|
||||||
@@ -123,8 +128,12 @@ export async function sendMessageTelegram(
|
|||||||
// Build optional params for forum topics and reply threading.
|
// Build optional params for forum topics and reply threading.
|
||||||
// Only include these if actually provided to keep API calls clean.
|
// Only include these if actually provided to keep API calls clean.
|
||||||
const threadParams: Record<string, number> = {};
|
const threadParams: Record<string, number> = {};
|
||||||
if (opts.messageThreadId != null) {
|
const messageThreadId =
|
||||||
threadParams.message_thread_id = Math.trunc(opts.messageThreadId);
|
opts.messageThreadId != null
|
||||||
|
? opts.messageThreadId
|
||||||
|
: target.messageThreadId;
|
||||||
|
if (messageThreadId != null) {
|
||||||
|
threadParams.message_thread_id = Math.trunc(messageThreadId);
|
||||||
}
|
}
|
||||||
if (opts.replyToMessageId != null) {
|
if (opts.replyToMessageId != null) {
|
||||||
threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId);
|
threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId);
|
||||||
|
|||||||
72
src/telegram/targets.test.ts
Normal file
72
src/telegram/targets.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseTelegramTarget,
|
||||||
|
stripTelegramInternalPrefixes,
|
||||||
|
} from "./targets.js";
|
||||||
|
|
||||||
|
describe("stripTelegramInternalPrefixes", () => {
|
||||||
|
it("strips telegram prefix", () => {
|
||||||
|
expect(stripTelegramInternalPrefixes("telegram:123")).toBe("123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips telegram+group prefixes", () => {
|
||||||
|
expect(stripTelegramInternalPrefixes("telegram:group:-100123")).toBe(
|
||||||
|
"-100123",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent", () => {
|
||||||
|
expect(stripTelegramInternalPrefixes("@mychannel")).toBe("@mychannel");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseTelegramTarget", () => {
|
||||||
|
it("parses plain chatId", () => {
|
||||||
|
expect(parseTelegramTarget("-1001234567890")).toEqual({
|
||||||
|
chatId: "-1001234567890",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses @username", () => {
|
||||||
|
expect(parseTelegramTarget("@mychannel")).toEqual({
|
||||||
|
chatId: "@mychannel",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses chatId:topicId format", () => {
|
||||||
|
expect(parseTelegramTarget("-1001234567890:123")).toEqual({
|
||||||
|
chatId: "-1001234567890",
|
||||||
|
messageThreadId: 123,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses chatId:topic:topicId format", () => {
|
||||||
|
expect(parseTelegramTarget("-1001234567890:topic:456")).toEqual({
|
||||||
|
chatId: "-1001234567890",
|
||||||
|
messageThreadId: 456,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace", () => {
|
||||||
|
expect(parseTelegramTarget(" -1001234567890:99 ")).toEqual({
|
||||||
|
chatId: "-1001234567890",
|
||||||
|
messageThreadId: 99,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat non-numeric suffix as topicId", () => {
|
||||||
|
expect(parseTelegramTarget("-1001234567890:abc")).toEqual({
|
||||||
|
chatId: "-1001234567890:abc",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips internal prefixes before parsing", () => {
|
||||||
|
expect(
|
||||||
|
parseTelegramTarget("telegram:group:-1001234567890:topic:456"),
|
||||||
|
).toEqual({
|
||||||
|
chatId: "-1001234567890",
|
||||||
|
messageThreadId: 456,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
43
src/telegram/targets.ts
Normal file
43
src/telegram/targets.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export type TelegramTarget = {
|
||||||
|
chatId: string;
|
||||||
|
messageThreadId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function stripTelegramInternalPrefixes(to: string): string {
|
||||||
|
let trimmed = to.trim();
|
||||||
|
while (true) {
|
||||||
|
const next = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
|
||||||
|
if (next === trimmed) return trimmed;
|
||||||
|
trimmed = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Telegram delivery target into chatId and optional topic/thread ID.
|
||||||
|
*
|
||||||
|
* Supported formats:
|
||||||
|
* - `chatId` (plain chat ID, t.me link, @username, or internal prefixes like `telegram:...`)
|
||||||
|
* - `chatId:topicId` (numeric topic/thread ID)
|
||||||
|
* - `chatId:topic:topicId` (explicit topic marker; preferred)
|
||||||
|
*/
|
||||||
|
export function parseTelegramTarget(to: string): TelegramTarget {
|
||||||
|
const normalized = stripTelegramInternalPrefixes(to);
|
||||||
|
|
||||||
|
const topicMatch = /^(.+?):topic:(\d+)$/.exec(normalized);
|
||||||
|
if (topicMatch) {
|
||||||
|
return {
|
||||||
|
chatId: topicMatch[1],
|
||||||
|
messageThreadId: Number.parseInt(topicMatch[2], 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const colonMatch = /^(.+):(\d+)$/.exec(normalized);
|
||||||
|
if (colonMatch) {
|
||||||
|
return {
|
||||||
|
chatId: colonMatch[1],
|
||||||
|
messageThreadId: Number.parseInt(colonMatch[2], 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { chatId: normalized };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user