fix: unify telegram thread handling

This commit is contained in:
Ayaan Zaidi
2026-02-02 08:53:42 +05:30
committed by Ayaan Zaidi
parent 5020bfa2a9
commit 19b8416a81
10 changed files with 151 additions and 46 deletions

View File

@@ -138,6 +138,34 @@ describe("deliverReplies", () => {
);
});
it("keeps message_thread_id=1 when allowed", async () => {
const runtime = { error: vi.fn(), log: vi.fn() };
const sendMessage = vi.fn().mockResolvedValue({
message_id: 4,
chat: { id: "123" },
});
const bot = { api: { sendMessage } } as unknown as Bot;
await deliverReplies({
replies: [{ text: "Hello" }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "off",
textLimit: 4000,
thread: { id: 1, scope: "dm" },
});
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.any(String),
expect.objectContaining({
message_thread_id: 1,
}),
);
});
it("does not include link_preview_options when linkPreview is true", async () => {
const runtime = { error: vi.fn(), log: vi.fn() };
const sendMessage = vi.fn().mockResolvedValue({

View File

@@ -22,7 +22,11 @@ import {
import { buildInlineKeyboard } from "../send.js";
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
import { resolveTelegramVoiceSend } from "../voice.js";
import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";
import {
buildTelegramThreadParams,
resolveTelegramReplyId,
type TelegramThreadSpec,
} from "./helpers.js";
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
@@ -35,7 +39,7 @@ export async function deliverReplies(params: {
bot: Bot;
replyToMode: ReplyToMode;
textLimit: number;
messageThreadId?: number;
thread?: TelegramThreadSpec | number | null;
tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
/** Callback invoked before sending a voice message to switch typing indicator. */
@@ -52,7 +56,7 @@ export async function deliverReplies(params: {
bot,
replyToMode,
textLimit,
messageThreadId,
thread,
linkPreview,
replyQuoteText,
} = params;
@@ -114,7 +118,7 @@ export async function deliverReplies(params: {
replyToMessageId:
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined,
replyQuoteText,
messageThreadId,
thread,
textMode: "html",
plainText: chunk.text,
linkPreview,
@@ -162,8 +166,8 @@ export async function deliverReplies(params: {
...(shouldAttachButtonsToMedia ? { reply_markup: replyMarkup } : {}),
...buildTelegramSendParams({
replyToMessageId,
messageThreadId,
replyQuoteText,
thread,
}),
};
if (isGif) {
@@ -227,7 +231,7 @@ export async function deliverReplies(params: {
replyToId,
replyToMode,
hasReplied,
messageThreadId,
thread,
linkPreview,
replyMarkup,
replyQuoteText,
@@ -268,7 +272,7 @@ export async function deliverReplies(params: {
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
await sendTelegramText(bot, chatId, chunk.html, runtime, {
replyToMessageId: replyToMessageIdFollowup,
messageThreadId,
thread,
textMode: "html",
plainText: chunk.text,
linkPreview,
@@ -447,7 +451,7 @@ async function sendTelegramVoiceFallbackText(opts: {
replyToId?: number;
replyToMode: ReplyToMode;
hasReplied: boolean;
messageThreadId?: number;
thread?: TelegramThreadSpec | number | null;
linkPreview?: boolean;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
replyQuoteText?: string;
@@ -460,7 +464,7 @@ async function sendTelegramVoiceFallbackText(opts: {
replyToMessageId:
opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined,
replyQuoteText: opts.replyQuoteText,
messageThreadId: opts.messageThreadId,
thread: opts.thread,
textMode: "html",
plainText: chunk.text,
linkPreview: opts.linkPreview,
@@ -475,10 +479,10 @@ async function sendTelegramVoiceFallbackText(opts: {
function buildTelegramSendParams(opts?: {
replyToMessageId?: number;
messageThreadId?: number;
thread?: TelegramThreadSpec | number | null;
replyQuoteText?: string;
}): Record<string, unknown> {
const threadParams = buildTelegramThreadParams(opts?.messageThreadId);
const threadParams = buildTelegramThreadParams(opts?.thread);
const params: Record<string, unknown> = {};
const quoteText = opts?.replyQuoteText?.trim();
if (opts?.replyToMessageId) {
@@ -505,7 +509,7 @@ async function sendTelegramText(
opts?: {
replyToMessageId?: number;
replyQuoteText?: string;
messageThreadId?: number;
thread?: TelegramThreadSpec | number | null;
textMode?: "markdown" | "html";
plainText?: string;
linkPreview?: boolean;
@@ -515,7 +519,7 @@ async function sendTelegramText(
const baseParams = buildTelegramSendParams({
replyToMessageId: opts?.replyToMessageId,
replyQuoteText: opts?.replyQuoteText,
messageThreadId: opts?.messageThreadId,
thread: opts?.thread,
});
// Add link_preview_options when link preview is disabled.
const linkPreviewEnabled = opts?.linkPreview ?? true;

View File

@@ -41,6 +41,12 @@ describe("buildTelegramThreadParams", () => {
expect(buildTelegramThreadParams(99)).toEqual({ message_thread_id: 99 });
});
it("keeps thread id=1 for dm threads", () => {
expect(buildTelegramThreadParams({ id: 1, scope: "dm" })).toEqual({
message_thread_id: 1,
});
});
it("normalizes thread ids to integers", () => {
expect(buildTelegramThreadParams(42.9)).toEqual({ message_thread_id: 42 });
});

View File

@@ -12,6 +12,13 @@ import { formatLocationText, type NormalizedLocation } from "../../channels/loca
const TELEGRAM_GENERAL_TOPIC_ID = 1;
export type TelegramThreadScope = "dm" | "forum" | "none";
export type TelegramThreadSpec = {
id?: number;
scope: TelegramThreadScope;
};
/**
* Resolve the thread ID for Telegram forum topics.
* For non-forum groups, returns undefined even if messageThreadId is present
@@ -33,17 +40,47 @@ export function resolveTelegramForumThreadId(params: {
return params.messageThreadId;
}
export function resolveTelegramThreadSpec(params: {
isGroup: boolean;
isForum?: boolean;
messageThreadId?: number | null;
}): TelegramThreadSpec {
if (params.isGroup) {
const id = resolveTelegramForumThreadId({
isForum: params.isForum,
messageThreadId: params.messageThreadId,
});
return {
id,
scope: params.isForum ? "forum" : "none",
};
}
if (params.messageThreadId == null) {
return { scope: "dm" };
}
return {
id: params.messageThreadId,
scope: "dm",
};
}
/**
* Build thread params for Telegram API calls (messages, media).
* General forum topic (id=1) must be treated like a regular supergroup send:
* Telegram rejects sendMessage/sendMedia with message_thread_id=1 ("thread not found").
*/
export function buildTelegramThreadParams(messageThreadId?: number) {
if (messageThreadId == null) {
export function buildTelegramThreadParams(thread?: TelegramThreadSpec | number | null) {
let spec: TelegramThreadSpec | undefined;
if (typeof thread === "number") {
spec = { id: thread, scope: "forum" };
} else if (thread && typeof thread === "object") {
spec = thread;
}
if (!spec?.id) {
return undefined;
}
const normalized = Math.trunc(messageThreadId);
if (normalized === TELEGRAM_GENERAL_TOPIC_ID) {
const normalized = Math.trunc(spec.id);
if (normalized === TELEGRAM_GENERAL_TOPIC_ID && spec.scope === "forum") {
return undefined;
}
return { message_thread_id: normalized };