Merge branch 'main' into feat/minimax_oauth

This commit is contained in:
xiaose 2026-01-28 14:04:58 +08:00
commit 730d229067
14 changed files with 237 additions and 30 deletions

View File

@ -24,13 +24,26 @@ jobs:
with: with:
github-token: ${{ steps.app-token.outputs.token }} github-token: ${{ steps.app-token.outputs.token }}
script: | script: |
// Labels prefixed with "r:" are auto-response triggers.
const rules = [ const rules = [
{ {
label: "skill-clawdhub", label: "r: skill",
close: true, close: true,
message: message:
"Thanks for the contribution! New skills should be published to Clawdhub for everyone to use. Were keeping the core lean on skills, so Im closing this out.", "Thanks for the contribution! New skills should be published to Clawdhub for everyone to use. Were keeping the core lean on skills, so Im closing this out.",
}, },
{
label: "r: support",
close: true,
message:
"Please use our support server https://molt.bot/discord and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.molt.bot/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
},
{
label: "r: third-party-extension",
close: true,
message:
"This would be better made as a third-party extension with our SDK that you maintain yourself. Docs: https://docs.molt.bot/plugin.",
},
]; ];
const labelName = context.payload.label?.name; const labelName = context.payload.label?.name;

View File

@ -71,6 +71,8 @@ Status: beta.
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes ### Fixes
- Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald.
- Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald.
- Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma. - Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma.
- Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94. - Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94.
- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355. - Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.
@ -78,6 +80,8 @@ Status: beta.
- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101. - Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.
- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops. - Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops.
- Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky. - Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky.
- Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow.
- Discord: fix directory config type import for target resolution. Thanks @thewilloftheshadow.
- Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. - Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb.
- Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent. - Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent.
- Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang. - Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang.
@ -95,6 +99,7 @@ Status: beta.
- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai. - Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
- Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24. - Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24.
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss. - Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
- Telegram: ignore non-forum group message_thread_id while preserving DM thread sessions. (#2731) Thanks @dylanneve1.
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne. - Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.

View File

@ -136,7 +136,7 @@ describe("models-config", () => {
} }
>; >;
}; };
expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic"); expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.chat/v1");
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
const ids = parsed.providers.minimax?.models?.map((model) => model.id); const ids = parsed.providers.minimax?.models?.map((model) => model.id);
expect(ids).toContain("MiniMax-M2.1"); expect(ids).toContain("MiniMax-M2.1");

View File

@ -275,7 +275,7 @@ describe("image tool MiniMax VLM routing", () => {
expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1);
const [url, init] = fetch.mock.calls[0]; const [url, init] = fetch.mock.calls[0];
expect(String(url)).toBe("https://api.minimax.io/v1/coding_plan/vlm"); expect(String(url)).toBe("https://api.minimax.chat/v1/coding_plan/vlm");
expect(init?.method).toBe("POST"); expect(init?.method).toBe("POST");
expect(String((init?.headers as Record<string, string>)?.Authorization)).toBe( expect(String((init?.headers as Record<string, string>)?.Authorization)).toBe(
"Bearer minimax-test", "Bearer minimax-test",

View File

@ -1,3 +1,6 @@
export type { DirectoryConfigParams } from "./plugins/directory-config.js";
export type { ChannelDirectoryEntry } from "./plugins/types.js";
export type MessagingTargetKind = "user" | "channel"; export type MessagingTargetKind = "user" | "channel";
export type MessagingTarget = { export type MessagingTarget = {

View File

@ -5,12 +5,11 @@ import {
type MessagingTarget, type MessagingTarget,
type MessagingTargetKind, type MessagingTargetKind,
type MessagingTargetParseOptions, type MessagingTargetParseOptions,
type DirectoryConfigParams,
type ChannelDirectoryEntry,
} from "../channels/targets.js"; } from "../channels/targets.js";
import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
import { listDiscordDirectoryPeersLive } from "./directory-live.js"; import { listDiscordDirectoryPeersLive } from "./directory-live.js";
import { resolveDiscordAccount } from "./accounts.js";
export type DiscordTargetKind = MessagingTargetKind; export type DiscordTargetKind = MessagingTargetKind;
@ -81,11 +80,16 @@ export async function resolveDiscordTarget(
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return undefined; if (!trimmed) return undefined;
// If already a known format, parse directly const parseOptions: DiscordTargetParseOptions = {};
const directParse = parseDiscordTarget(trimmed, options); const likelyUsername = isLikelyUsername(trimmed);
if (directParse && directParse.kind !== "channel" && !isLikelyUsername(trimmed)) { const shouldLookup = isExplicitUserLookup(trimmed, parseOptions) || likelyUsername;
const directParse = safeParseDiscordTarget(trimmed, parseOptions);
if (directParse && directParse.kind !== "channel" && !likelyUsername) {
return directParse; return directParse;
} }
if (!shouldLookup) {
return directParse ?? parseDiscordTarget(trimmed, parseOptions);
}
// Try to resolve as a username via directory lookup // Try to resolve as a username via directory lookup
try { try {
@ -101,13 +105,40 @@ export async function resolveDiscordTarget(
const userId = match.id.replace(/^user:/, ""); const userId = match.id.replace(/^user:/, "");
return buildMessagingTarget("user", userId, trimmed); return buildMessagingTarget("user", userId, trimmed);
} }
} catch (error) { } catch {
// Directory lookup failed - fall through to parse as-is // Directory lookup failed - fall through to parse as-is
// This preserves existing behavior for channel names // This preserves existing behavior for channel names
} }
// Fallback to original parsing (for channels, etc.) // Fallback to original parsing (for channels, etc.)
return parseDiscordTarget(trimmed, options); return parseDiscordTarget(trimmed, parseOptions);
}
function safeParseDiscordTarget(
input: string,
options: DiscordTargetParseOptions,
): MessagingTarget | undefined {
try {
return parseDiscordTarget(input, options);
} catch {
return undefined;
}
}
function isExplicitUserLookup(input: string, options: DiscordTargetParseOptions): boolean {
if (/^<@!?(\d+)>$/.test(input)) {
return true;
}
if (/^(user:|discord:)/.test(input)) {
return true;
}
if (input.startsWith("@")) {
return true;
}
if (/^\d+$/.test(input)) {
return options.defaultKind === "user";
}
return false;
} }
/** /**

View File

@ -70,3 +70,102 @@ describe("buildTelegramMessageContext dm thread sessions", () => {
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main"); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main");
}); });
}); });
describe("buildTelegramMessageContext group sessions without forum", () => {
const baseConfig = {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } },
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [] } },
} as never;
const buildContext = async (message: Record<string, unknown>) =>
await buildTelegramMessageContext({
primaryCtx: {
message,
me: { id: 7, username: "bot" },
} as never,
allMedia: [],
storeAllowFrom: [],
options: { forceWasMentioned: true },
bot: {
api: {
sendChatAction: vi.fn(),
setMessageReaction: vi.fn(),
},
} as never,
cfg: baseConfig,
account: { accountId: "default" } as never,
historyLimit: 0,
groupHistories: new Map(),
dmPolicy: "open",
allowFrom: [],
groupAllowFrom: [],
ackReactionScope: "off",
logger: { info: vi.fn() },
resolveGroupActivation: () => true,
resolveGroupRequireMention: () => false,
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
topicConfig: undefined,
}),
});
it("ignores message_thread_id for regular groups (not forums)", async () => {
// When someone replies to a message in a non-forum group, Telegram sends
// message_thread_id but this should NOT create a separate session
const ctx = await buildContext({
message_id: 1,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
date: 1700000000,
text: "@bot hello",
message_thread_id: 42, // This is a reply thread, NOT a forum topic
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
// Session key should NOT include :topic:42
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890");
// MessageThreadId should be undefined (not a forum)
expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined();
});
it("keeps same session for regular group with and without message_thread_id", async () => {
const ctxWithThread = await buildContext({
message_id: 1,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
date: 1700000000,
text: "@bot hello",
message_thread_id: 42,
from: { id: 42, first_name: "Alice" },
});
const ctxWithoutThread = await buildContext({
message_id: 2,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
date: 1700000001,
text: "@bot world",
from: { id: 42, first_name: "Alice" },
});
expect(ctxWithThread).not.toBeNull();
expect(ctxWithoutThread).not.toBeNull();
// Both messages should use the same session key
expect(ctxWithThread?.ctxPayload?.SessionKey).toBe(ctxWithoutThread?.ctxPayload?.SessionKey);
});
it("uses topic session for forum groups with message_thread_id", async () => {
const ctx = await buildContext({
message_id: 1,
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
date: 1700000000,
text: "@bot hello",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
// Session key SHOULD include :topic:99 for forums
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:99");
expect(ctx?.ctxPayload?.MessageThreadId).toBe(99);
});
});

View File

@ -173,7 +173,8 @@ export const buildTelegramMessageContext = async ({
}, },
}); });
const baseSessionKey = route.sessionKey; const baseSessionKey = route.sessionKey;
const dmThreadId = !isGroup ? resolvedThreadId : undefined; // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
const dmThreadId = !isGroup ? messageThreadId : undefined;
const threadKeys = const threadKeys =
dmThreadId != null dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) }) ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
@ -601,7 +602,8 @@ export const buildTelegramMessageContext = async ({
Sticker: allMedia[0]?.stickerMetadata, Sticker: allMedia[0]?.stickerMetadata,
...(locationData ? toLocationContext(locationData) : undefined), ...(locationData ? toLocationContext(locationData) : undefined),
CommandAuthorized: commandAuthorized, CommandAuthorized: commandAuthorized,
MessageThreadId: resolvedThreadId, // For groups: use resolvedThreadId (forum topics only); for DMs: use raw messageThreadId
MessageThreadId: isGroup ? resolvedThreadId : messageThreadId,
IsForum: isForum, IsForum: isForum,
// Originating channel for reply routing. // Originating channel for reply routing.
OriginatingChannel: "telegram" as const, OriginatingChannel: "telegram" as const,

View File

@ -360,6 +360,8 @@ export const registerTelegramNativeCommands = ({
topicConfig, topicConfig,
commandAuthorized, commandAuthorized,
} = auth; } = auth;
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const threadIdForSend = isGroup ? resolvedThreadId : messageThreadId;
const commandDefinition = findCommandByNativeName(command.name, "telegram"); const commandDefinition = findCommandByNativeName(command.name, "telegram");
const rawText = ctx.match?.trim() ?? ""; const rawText = ctx.match?.trim() ?? "";
@ -406,7 +408,7 @@ export const registerTelegramNativeCommands = ({
fn: () => fn: () =>
bot.api.sendMessage(chatId, title, { bot.api.sendMessage(chatId, title, {
...(replyMarkup ? { reply_markup: replyMarkup } : {}), ...(replyMarkup ? { reply_markup: replyMarkup } : {}),
...(resolvedThreadId != null ? { message_thread_id: resolvedThreadId } : {}), ...(threadIdForSend != null ? { message_thread_id: threadIdForSend } : {}),
}), }),
}); });
return; return;
@ -421,7 +423,8 @@ export const registerTelegramNativeCommands = ({
}, },
}); });
const baseSessionKey = route.sessionKey; const baseSessionKey = route.sessionKey;
const dmThreadId = !isGroup ? resolvedThreadId : undefined; // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
const dmThreadId = !isGroup ? messageThreadId : undefined;
const threadKeys = const threadKeys =
dmThreadId != null dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) }) ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
@ -466,7 +469,7 @@ export const registerTelegramNativeCommands = ({
CommandSource: "native" as const, CommandSource: "native" as const,
SessionKey: `telegram:slash:${senderId || chatId}`, SessionKey: `telegram:slash:${senderId || chatId}`,
CommandTargetSessionKey: sessionKey, CommandTargetSessionKey: sessionKey,
MessageThreadId: resolvedThreadId, MessageThreadId: threadIdForSend,
IsForum: isForum, IsForum: isForum,
// Originating context for sub-agent announce routing // Originating context for sub-agent announce routing
OriginatingChannel: "telegram" as const, OriginatingChannel: "telegram" as const,
@ -493,7 +496,7 @@ export const registerTelegramNativeCommands = ({
bot, bot,
replyToMode, replyToMode,
textLimit, textLimit,
messageThreadId: resolvedThreadId, messageThreadId: threadIdForSend,
tableMode, tableMode,
chunkMode, chunkMode,
linkPreview: telegramCfg.linkPreview, linkPreview: telegramCfg.linkPreview,
@ -541,7 +544,9 @@ export const registerTelegramNativeCommands = ({
requireAuth: match.command.requireAuth !== false, requireAuth: match.command.requireAuth !== false,
}); });
if (!auth) return; if (!auth) return;
const { resolvedThreadId, senderId, commandAuthorized } = auth; const { resolvedThreadId, senderId, commandAuthorized, isGroup } = auth;
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const threadIdForSend = isGroup ? resolvedThreadId : messageThreadId;
const result = await executePluginCommand({ const result = await executePluginCommand({
command: match.command, command: match.command,
@ -567,7 +572,7 @@ export const registerTelegramNativeCommands = ({
bot, bot,
replyToMode, replyToMode,
textLimit, textLimit,
messageThreadId: resolvedThreadId, messageThreadId: threadIdForSend,
tableMode, tableMode,
chunkMode, chunkMode,
linkPreview: telegramCfg.linkPreview, linkPreview: telegramCfg.linkPreview,

View File

@ -238,12 +238,17 @@ describe("createTelegramBot", () => {
expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123"); expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123");
expect( expect(
getTelegramSequentialKey({ getTelegramSequentialKey({
message: { chat: { id: 123 }, message_thread_id: 9 }, message: { chat: { id: 123, type: "private" }, message_thread_id: 9 },
}), }),
).toBe("telegram:123:topic:9"); ).toBe("telegram:123:topic:9");
expect( expect(
getTelegramSequentialKey({ getTelegramSequentialKey({
message: { chat: { id: 123, is_forum: true } }, message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 },
}),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123, type: "supergroup", is_forum: true } },
}), }),
).toBe("telegram:123:topic:1"); ).toBe("telegram:123:topic:1");
expect( expect(

View File

@ -340,12 +340,17 @@ describe("createTelegramBot", () => {
expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123"); expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123");
expect( expect(
getTelegramSequentialKey({ getTelegramSequentialKey({
message: { chat: { id: 123 }, message_thread_id: 9 }, message: { chat: { id: 123, type: "private" }, message_thread_id: 9 },
}), }),
).toBe("telegram:123:topic:9"); ).toBe("telegram:123:topic:9");
expect( expect(
getTelegramSequentialKey({ getTelegramSequentialKey({
message: { chat: { id: 123, is_forum: true } }, message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 },
}),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123, type: "supergroup", is_forum: true } },
}), }),
).toBe("telegram:123:topic:1"); ).toBe("telegram:123:topic:1");
expect( expect(

View File

@ -94,11 +94,12 @@ export function getTelegramSequentialKey(ctx: {
if (typeof chatId === "number") return `telegram:${chatId}:control`; if (typeof chatId === "number") return `telegram:${chatId}:control`;
return "telegram:control"; return "telegram:control";
} }
const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup";
const messageThreadId = msg?.message_thread_id;
const isForum = (msg?.chat as { is_forum?: boolean } | undefined)?.is_forum; const isForum = (msg?.chat as { is_forum?: boolean } | undefined)?.is_forum;
const threadId = resolveTelegramForumThreadId({ const threadId = isGroup
isForum, ? resolveTelegramForumThreadId({ isForum, messageThreadId })
messageThreadId: msg?.message_thread_id, : messageThreadId;
});
if (typeof chatId === "number") { if (typeof chatId === "number") {
return threadId != null ? `telegram:${chatId}:topic:${threadId}` : `telegram:${chatId}`; return threadId != null ? `telegram:${chatId}:topic:${threadId}` : `telegram:${chatId}`;
} }
@ -427,7 +428,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
peer: { kind: isGroup ? "group" : "dm", id: peerId }, peer: { kind: isGroup ? "group" : "dm", id: peerId },
}); });
const baseSessionKey = route.sessionKey; const baseSessionKey = route.sessionKey;
const dmThreadId = !isGroup ? resolvedThreadId : undefined; // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
const dmThreadId = !isGroup ? messageThreadId : undefined;
const threadKeys = const threadKeys =
dmThreadId != null dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) }) ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })

View File

@ -3,8 +3,34 @@ import {
buildTelegramThreadParams, buildTelegramThreadParams,
buildTypingThreadParams, buildTypingThreadParams,
normalizeForwardedContext, normalizeForwardedContext,
resolveTelegramForumThreadId,
} from "./helpers.js"; } from "./helpers.js";
describe("resolveTelegramForumThreadId", () => {
it("returns undefined for non-forum groups even with messageThreadId", () => {
// Reply threads in regular groups should not create separate sessions
expect(resolveTelegramForumThreadId({ isForum: false, messageThreadId: 42 })).toBeUndefined();
});
it("returns undefined for non-forum groups without messageThreadId", () => {
expect(
resolveTelegramForumThreadId({ isForum: false, messageThreadId: undefined }),
).toBeUndefined();
expect(
resolveTelegramForumThreadId({ isForum: undefined, messageThreadId: 99 }),
).toBeUndefined();
});
it("returns General topic (1) for forum groups without messageThreadId", () => {
expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: undefined })).toBe(1);
expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: null })).toBe(1);
});
it("returns the topic id for forum groups with messageThreadId", () => {
expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: 99 })).toBe(99);
});
});
describe("buildTelegramThreadParams", () => { describe("buildTelegramThreadParams", () => {
it("omits General topic thread id for message sends", () => { it("omits General topic thread id for message sends", () => {
expect(buildTelegramThreadParams(1)).toBeUndefined(); expect(buildTelegramThreadParams(1)).toBeUndefined();

View File

@ -13,14 +13,25 @@ import type {
const TELEGRAM_GENERAL_TOPIC_ID = 1; const TELEGRAM_GENERAL_TOPIC_ID = 1;
/**
* Resolve the thread ID for Telegram forum topics.
* For non-forum groups, returns undefined even if messageThreadId is present
* (reply threads in regular groups should not create separate sessions).
* For forum groups, returns the topic ID (or General topic ID=1 if unspecified).
*/
export function resolveTelegramForumThreadId(params: { export function resolveTelegramForumThreadId(params: {
isForum?: boolean; isForum?: boolean;
messageThreadId?: number | null; messageThreadId?: number | null;
}) { }) {
if (params.isForum && params.messageThreadId == null) { // Non-forum groups: ignore message_thread_id (reply threads are not real topics)
if (!params.isForum) {
return undefined;
}
// Forum groups: use the topic ID, defaulting to General topic
if (params.messageThreadId == null) {
return TELEGRAM_GENERAL_TOPIC_ID; return TELEGRAM_GENERAL_TOPIC_ID;
} }
return params.messageThreadId ?? undefined; return params.messageThreadId;
} }
/** /**