diff --git a/extensions/telegram/src/bot-handlers.authorization.runtime.ts b/extensions/telegram/src/bot-handlers.authorization.runtime.ts index fbc17cf569e0..05153bb7c973 100644 --- a/extensions/telegram/src/bot-handlers.authorization.runtime.ts +++ b/extensions/telegram/src/bot-handlers.authorization.runtime.ts @@ -20,6 +20,8 @@ import { isTelegramCommandsAllowFromConfigured, resolveTelegramCommandAuthorization, resolveTelegramGroupAllowFromContext, + resolveTelegramMessageThreadSpec, + type TelegramThreadSpec, } from "./bot/helpers.js"; import { enforceTelegramDmAccess, isTelegramDmAccessAllowed } from "./dm-access.js"; import { @@ -95,9 +97,8 @@ export function createTelegramHandlerAuthorizationRuntime({ cfg: OpenClawConfig; chatId: number; isGroup: boolean; - isForum: boolean; senderId?: string; - messageThreadId?: number; + threadSpec: TelegramThreadSpec; }): Promise => { const authorizationCfg = params.cfg; const authorizationTelegramCfg = resolveTelegramAccount({ @@ -118,8 +119,7 @@ export function createTelegramHandlerAuthorizationRuntime({ allowFrom: authorizationSettings.allowFrom, senderId: params.senderId, isGroup: params.isGroup, - isForum: params.isForum, - messageThreadId: params.messageThreadId, + threadSpec: params.threadSpec, groupAllowFrom: authorizationSettings.groupAllowFrom, readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore, resolveTelegramGroupConfig, @@ -312,7 +312,6 @@ export function createTelegramHandlerAuthorizationRuntime({ chatId: number; isGroup: boolean; isForum: boolean; - messageThreadId?: number; senderId: string; senderUsername: string; requireConfiguredGroup: boolean; @@ -323,9 +322,8 @@ export function createTelegramHandlerAuthorizationRuntime({ cfg: authorizationCfg, chatId: params.chatId, isGroup: params.isGroup, - isForum: params.isForum, senderId: params.senderId, - messageThreadId: params.messageThreadId, + threadSpec: resolveTelegramMessageThreadSpec(params.msg, params.isForum), }); const { dmPolicy, diff --git a/extensions/telegram/src/bot-handlers.callback.runtime.ts b/extensions/telegram/src/bot-handlers.callback.runtime.ts index f16c14fbd874..e9670c45d455 100644 --- a/extensions/telegram/src/bot-handlers.callback.runtime.ts +++ b/extensions/telegram/src/bot-handlers.callback.runtime.ts @@ -26,7 +26,11 @@ import { isTelegramSpooledReplayUpdate, recordTelegramMessageProcessingResult, } from "./bot-processing-outcome.js"; -import { resolveTelegramForumFlag, withResolvedTelegramForumFlag } from "./bot/helpers.js"; +import { + resolveTelegramForumFlag, + resolveTelegramMessageThreadSpec, + withResolvedTelegramForumFlag, +} from "./bot/helpers.js"; import type { TelegramGetChat } from "./bot/types.js"; import { getTelegramCallbackQueryAnswerPromise } from "./callback-query-answer-state.js"; import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js"; @@ -159,9 +163,8 @@ export function registerTelegramCallbackQueryHandler( cfg: authorizationCfg, chatId, isGroup, - isForum, senderId, - messageThreadId, + threadSpec: resolveTelegramMessageThreadSpec(callbackMessage, isForum), }); const { resolvedThreadId, dmThreadId, storeAllowFrom, groupConfig } = eventAuthContext; const requireTopic = (groupConfig as { requireTopic?: boolean } | undefined)?.requireTopic; diff --git a/extensions/telegram/src/bot-handlers.message-events.runtime.ts b/extensions/telegram/src/bot-handlers.message-events.runtime.ts index ac90d236a835..53ca6879766d 100644 --- a/extensions/telegram/src/bot-handlers.message-events.runtime.ts +++ b/extensions/telegram/src/bot-handlers.message-events.runtime.ts @@ -136,7 +136,6 @@ export function registerTelegramMessageHandlers( chatId: normalizedMsg.chat.id, isGroup, isForum, - messageThreadId: normalizedMsg.message_thread_id, senderId: normalizedMsg.from?.id != null ? String(normalizedMsg.from.id) : "", senderUsername: normalizedMsg.from?.username ?? "", requireConfiguredGroup: params.requireConfiguredGroup, @@ -164,7 +163,6 @@ export function registerTelegramMessageHandlers( chatId: event.chatId, isGroup: event.isGroup, isForum: event.isForum, - messageThreadId: event.messageThreadId, senderId: event.senderId, senderUsername: event.senderUsername, requireConfiguredGroup: event.requireConfiguredGroup, diff --git a/extensions/telegram/src/bot-handlers.message.runtime.ts b/extensions/telegram/src/bot-handlers.message.runtime.ts index 9e1f69130b8b..0822f40250bc 100644 --- a/extensions/telegram/src/bot-handlers.message.runtime.ts +++ b/extensions/telegram/src/bot-handlers.message.runtime.ts @@ -27,7 +27,7 @@ import { type TelegramSpooledReplayDeferredParticipant, } from "./bot-processing-outcome.js"; import { resolveMedia } from "./bot/delivery.resolve-media.js"; -import { resolveTelegramForumThreadId } from "./bot/helpers.js"; +import { resolveTelegramMessageThreadSpec } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; import { resolveTelegramScopedGroupConfig } from "./group-config-helpers.js"; import type { TelegramCachedMessageNode, TelegramReplyChainEntry } from "./message-cache.js"; @@ -272,13 +272,7 @@ export function createTelegramHandlerMessageRuntime({ const replyChainNodes = await buildReplyChainForMessage(params.msg); const isGroupConversation = params.msg.chat.type === "group" || params.msg.chat.type === "supergroup"; - const isForum = - params.msg.chat.type === "supergroup" && - Boolean(params.msg.chat.is_forum || params.msg.is_topic_message); - const scopedThreadId = resolveTelegramForumThreadId({ - isForum, - messageThreadId: params.msg.message_thread_id, - }); + const scopedThreadId = resolveTelegramMessageThreadSpec(params.msg).id; const { groupConfig, topicConfig } = resolveTelegramScopedGroupConfig( runtimeTelegramCfg, params.msg.chat.id, diff --git a/extensions/telegram/src/bot-handlers.poll-answer.runtime.ts b/extensions/telegram/src/bot-handlers.poll-answer.runtime.ts index 6eade2a481b9..336036d6a68f 100644 --- a/extensions/telegram/src/bot-handlers.poll-answer.runtime.ts +++ b/extensions/telegram/src/bot-handlers.poll-answer.runtime.ts @@ -8,6 +8,7 @@ import { isTelegramSpooledReplayUpdate, recordTelegramMessageProcessingResult, } from "./bot-processing-outcome.js"; +import { resolveTelegramThreadSpec } from "./bot/helpers.js"; import { getPreparedTelegramPollAnswer } from "./poll-answer-context.js"; import { findTelegramPollRegistryEntry, retireTelegramPollRegistryEntry } from "./poll-registry.js"; @@ -95,9 +96,12 @@ export function registerTelegramPollHandlers( cfg: authorizationCfg, chatId, isGroup, - isForum, senderId, - messageThreadId: entry.messageThreadId, + threadSpec: resolveTelegramThreadSpec({ + isGroup, + isForum, + messageThreadId: entry.messageThreadId, + }), }); const senderAuthorization = await authorizeTelegramEventSender({ chatId, diff --git a/extensions/telegram/src/bot-handlers.reaction.runtime.ts b/extensions/telegram/src/bot-handlers.reaction.runtime.ts index 93132af2bf50..b5472cd2b19f 100644 --- a/extensions/telegram/src/bot-handlers.reaction.runtime.ts +++ b/extensions/telegram/src/bot-handlers.reaction.runtime.ts @@ -6,7 +6,11 @@ import { resolveTelegramAccount } from "./accounts.js"; import type { TelegramHandlerAuthorizationRuntime } from "./bot-handlers.authorization.runtime.js"; import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js"; import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; -import { buildTelegramGroupPeerId, buildTelegramParentPeer } from "./bot/helpers.js"; +import { + buildTelegramGroupPeerId, + buildTelegramParentPeer, + resolveTelegramThreadSpec, +} from "./bot/helpers.js"; import { resolveTelegramConversationRoute } from "./conversation-route.js"; /** Stable operator-facing reason for a forum reaction dropped without a known topic. */ @@ -108,9 +112,12 @@ export function registerTelegramReactionHandler( cfg: authorizationCfg, chatId, isGroup, - isForum, senderId, - ...(cachedForumThreadId === undefined ? {} : { messageThreadId: cachedForumThreadId }), + threadSpec: resolveTelegramThreadSpec({ + isGroup, + isForum, + messageThreadId: cachedForumThreadId, + }), }); const senderAuthorization = await authorizeTelegramEventSender({ chatId, diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index d4618d3861f1..0b1fe1520e13 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -3799,6 +3799,122 @@ describe("createTelegramBot", () => { expect(replySpy.mock.calls.at(1)?.[0].SessionKey).toContain("thread:124:99"); }); + it("authorizes and routes channel-DM messages with the canonical topic identity", async () => { + const chatId = -100123456700; + loadConfig.mockReturnValue({ + agents: { list: [{ id: "channel-topic-agent" }] }, + channels: { + telegram: { + groupPolicy: "allowlist", + groupAllowFrom: ["701"], + groups: { + [String(chatId)]: { + allowFrom: ["701"], + requireMention: false, + topics: { + "77": { + agentId: "channel-topic-agent", + allowFrom: ["700"], + requireMention: false, + }, + }, + }, + }, + }, + }, + }); + + await dispatchMessage({ + me: { id: 999, username: "openclaw_bot" }, + message: { + chat: { + id: chatId, + type: "supergroup", + title: "Channel Inbox", + is_direct_messages: true, + }, + from: { id: 700, first_name: "Ada" }, + text: "route this topic", + date: 1736380800, + message_id: 7700, + direct_messages_topic: { topic_id: 77 }, + message_thread_id: 999, + }, + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = requireValue(replySpy.mock.calls.at(0), "replySpy call")[0]; + expect(payload.MessageThreadId).toBe(77); + expect(payload.OriginatingTo).toBe(`telegram:${chatId}:direct-topic:77`); + expect(payload.SessionKey).toContain("agent:channel-topic-agent:"); + expect(payload.SessionKey).toContain(":topic:77"); + }); + + it.each([ + { + name: "topic allows and base chat denies", + baseAllowFrom: ["701"], + topicAllowFrom: ["700"], + expectedCalls: 1, + }, + { + name: "topic denies and base chat allows", + baseAllowFrom: ["700"], + topicAllowFrom: ["701"], + expectedCalls: 0, + }, + ])("authorizes channel-DM callbacks from the canonical topic: $name", async (testCase) => { + const chatId = -100123456701; + const pluginHandler = vi.fn(async () => ({ handled: true })); + expect( + registerPluginInteractiveHandler("channel-topic-actions", { + channel: "telegram", + namespace: "channel-topic", + handler: pluginHandler, + }), + ).toEqual({ ok: true }); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + groupAllowFrom: testCase.baseAllowFrom, + groups: { + [String(chatId)]: { + allowFrom: testCase.baseAllowFrom, + topics: { "77": { allowFrom: testCase.topicAllowFrom } }, + }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + await getCallbackHandler()({ + callbackQuery: { + id: `channel-topic-${testCase.expectedCalls}`, + data: "channel-topic:run", + from: { id: 700, first_name: "Ada" }, + message: { + chat: { + id: chatId, + type: "supergroup", + title: "Channel Inbox", + is_direct_messages: true, + }, + date: 1736380800, + message_id: 7701, + direct_messages_topic: { topic_id: 77 }, + message_thread_id: 999, + }, + }, + me: { id: 999, username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(pluginHandler).toHaveBeenCalledTimes(testCase.expectedCalls); + clearPluginInteractiveHandlers(); + }); + it("routes non-default account DMs to the per-account fallback session without explicit bindings", async () => { loadConfig.mockReturnValue({ channels: { diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 285a2b7f1dd4..93ed59094ce9 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -4660,6 +4660,94 @@ describe("createTelegramBot", () => { expect(mediaFetch).not.toHaveBeenCalled(); }); + it("uses refreshed channel-DM topic config for reply-media visibility", async () => { + mockTelegramConfig({ + groupPolicy: "allowlist", + contextVisibility: "allowlist", + groups: { + "-1010": { + requireMention: false, + allowFrom: ["1", "2"], + topics: { "77": { allowFrom: ["1"], requireMention: false } }, + }, + }, + }); + + const mediaFetch = vi.fn( + async () => + new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { "content-type": "image/png" }, + }), + ); + const ssrfMock = mockPinnedHostnameResolution(); + setTelegramPluginStateRuntimeForTests(); + + try { + const replyDelivered = waitForReplyCalls(1); + createTelegramBot({ + token: "tok", + telegramTransport: makeTelegramTransport(mediaFetch as typeof fetch), + }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const chat = { + id: -1010, + type: "supergroup", + title: "Channel Inbox", + is_direct_messages: true, + }; + + await handler({ + me: { id: 999, username: "openclaw_bot" }, + getFile: getEmptyTelegramFile, + message: { + chat, + message_id: 103, + text: "explain this", + date: 1736380800, + from: { id: 1, is_bot: false, first_name: "Allowed" }, + direct_messages_topic: { topic_id: 77 }, + message_thread_id: 999, + reply_to_message: { + chat, + message_id: 102, + caption: "hidden image", + date: 1736380750, + from: { id: 2, is_bot: false, first_name: "Hidden" }, + photo: [{ file_id: "hidden-channel-photo-1" }], + }, + }, + }); + await replyDelivered; + } finally { + ssrfMock.mockRestore(); + clearTelegramRuntime(); + resetPluginStateStoreForTests(); + } + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = mockMsgContextArg( + replySpy as unknown as MockCallSource, + 0, + 0, + "replySpy call", + ) as { ChannelStructuredContext?: unknown[] }; + const [conversationContext] = requireArray( + payload.ChannelStructuredContext, + "structured context", + ); + const contextRecord = requireRecord(conversationContext, "conversation context"); + const contextPayload = requireRecord(contextRecord.payload, "conversation context payload"); + const messages = requireArray(contextPayload.messages, "conversation context messages").map( + (message, index) => requireRecord(message, `conversation context message ${index + 1}`), + ); + const hiddenMessage = messages.find((message) => message.message_id === "102"); + expect(hiddenMessage?.media_ref).toBe("telegram:file/hidden-channel-photo-1"); + expect(hiddenMessage?.media_path).toBeUndefined(); + expect(getFileSpy).not.toHaveBeenCalled(); + expect(mediaFetch).not.toHaveBeenCalled(); + }); + it.each([ { name: "hydrates group reply media allowed through an option-level access group", diff --git a/extensions/telegram/src/message-cache.test.ts b/extensions/telegram/src/message-cache.test.ts index d2cb1da45064..01e1df2e280c 100644 --- a/extensions/telegram/src/message-cache.test.ts +++ b/extensions/telegram/src/message-cache.test.ts @@ -183,6 +183,43 @@ describe("telegram message cache", () => { } }); + it("keeps an authoritative supplied thread ahead of conflicting root message metadata", async () => { + const { bucketKey, entries, store } = createMemoryStore(); + const forum = { id: -1001, type: "supergroup", title: "QA", is_forum: true }; + const ancestor = message(904, "Lin", { + chat: forum, + text: "Ancestor without its own thread metadata", + }); + const reply = message(905, "Ada", { + chat: forum, + text: "Reply with embedded thread metadata", + message_thread_id: 88, + reply_to_message: ancestor, + }); + const root = message(906, "Grace", { + chat: forum, + text: "Authoritative root", + message_thread_id: 999, + reply_to_message: reply, + }); + const cache = cacheFor(bucketKey, store); + + const recorded = await record(cache, root, { + chatId: -1001, + threadId: 77, + providerObservedThreadId: 77, + }); + + expect(recorded.threadId).toBe("77"); + expect(resolveProviderObservedTelegramThreadId(recorded)).toBe(77); + expect((await get(cache, "905", { chatId: -1001 }))?.threadId).toBe("88"); + expect((await get(cache, "904", { chatId: -1001 }))?.threadId).toBe("88"); + const persistedRoot = Array.from(entries.values()).find( + (entry) => entry.sourceMessage.message_id === 906, + ); + expect(persistedRoot?.threadId).toBe("77"); + }); + it("does not resolve caller-only topic metadata as a provider-observed binding", async () => { const cache = createTelegramMessageCache(); await record( diff --git a/extensions/telegram/src/message-cache.ts b/extensions/telegram/src/message-cache.ts index 5befe048f02c..53d9ed8fdd96 100644 --- a/extensions/telegram/src/message-cache.ts +++ b/extensions/telegram/src/message-cache.ts @@ -286,11 +286,15 @@ function normalizeMessageNodes( promptContextProjectionMarker?: TelegramPromptContextProjectionMarker, threadBinding?: TelegramMessageThreadBinding, ) => { + const embeddedThreadId = parseTelegramMessageThreadId( + (message as { message_thread_id?: unknown }).message_thread_id, + ); + const inheritedThread = parseTelegramMessageThreadId(inheritedThreadId); const node = normalizeMessageNode(message, { threadId: - parseTelegramMessageThreadId( - (message as { message_thread_id?: unknown }).message_thread_id, - ) ?? inheritedThreadId, + mode === "authoritative" + ? (inheritedThread ?? embeddedThreadId) + : (embeddedThreadId ?? inheritedThread), ...(promptContextProjectionMarker ? { promptContextProjectionMarker } : {}), ...(threadBinding ? { threadBinding } : {}), }); diff --git a/extensions/telegram/src/sequential-key.test.ts b/extensions/telegram/src/sequential-key.test.ts index fe5e3ea9b554..aa1a9571d951 100644 --- a/extensions/telegram/src/sequential-key.test.ts +++ b/extensions/telegram/src/sequential-key.test.ts @@ -5,8 +5,9 @@ import { buildTelegramApprovalCallbackData } from "./approval-callback-data.js"; import { buildTelegramQuestionCallbackData } from "./question-callback-data.js"; import { getTelegramSequentialConstraints, getTelegramSequentialKey } from "./sequential-key.js"; -const mockChat = (chat: Pick & Partial>): Chat => - chat as Chat; +const mockChat = ( + chat: Pick & Partial>, +): Chat => chat as Chat; const mockMessage = (message: Pick & Partial): Message => ({ message_id: 1, @@ -55,6 +56,19 @@ describe("getTelegramSequentialKey", () => { }, "telegram:123", ], + [ + { + message: mockMessage({ + chat: mockChat({ id: -100123, type: "supergroup", is_direct_messages: true }), + direct_messages_topic: { + topic_id: 77, + user: { id: 700, is_bot: false, first_name: "Ada" }, + }, + message_thread_id: 999, + }), + }, + "telegram:-100123:topic:77", + ], [ { message: mockMessage({ diff --git a/extensions/telegram/src/sequential-key.ts b/extensions/telegram/src/sequential-key.ts index d55f1d5b0024..73dbe5455886 100644 --- a/extensions/telegram/src/sequential-key.ts +++ b/extensions/telegram/src/sequential-key.ts @@ -15,6 +15,7 @@ import { resolveTelegramBotHasTopicsEnabled, resolveTelegramForumThreadId, resolveTelegramMessageForumFlagHint, + resolveTelegramMessageThreadSpec, shouldUseTelegramDmThreadSession, } from "./bot/helpers.js"; import { getPreparedTelegramPollAnswer } from "./poll-answer-context.js"; @@ -246,21 +247,16 @@ export function getTelegramSequentialKey(ctx: TelegramSequentialKeyContext): str } return "telegram:approval"; } - const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup"; - const messageThreadId = msg?.message_thread_id; - const isForum = resolveTelegramMessageForumFlagHint({ - chatType: msg?.chat?.type, - isForum: msg?.chat?.is_forum, - isTopicMessage: msg?.is_topic_message, - }); - const threadId = isGroup - ? resolveTelegramForumThreadId({ isForum, messageThreadId }) - : shouldUseTelegramDmThreadSession({ - dmThreadId: messageThreadId, + const threadSpec = msg ? resolveTelegramMessageThreadSpec(msg) : undefined; + const threadId = + threadSpec?.scope === "dm" + ? shouldUseTelegramDmThreadSession({ + dmThreadId: threadSpec.id, botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(ctx.me), }) - ? messageThreadId - : undefined; + ? threadSpec.id + : undefined + : threadSpec?.id; if (typeof chatId === "number") { return threadId != null ? `telegram:${chatId}:topic:${threadId}` : `telegram:${chatId}`; } diff --git a/extensions/telegram/src/telegram-ingress-supersede-auth.ts b/extensions/telegram/src/telegram-ingress-supersede-auth.ts index 847602a6aff4..59055e72035f 100644 --- a/extensions/telegram/src/telegram-ingress-supersede-auth.ts +++ b/extensions/telegram/src/telegram-ingress-supersede-auth.ts @@ -1,11 +1,12 @@ // Telegram plugin module owns supersede sender authorization policy. +import type { Message } from "grammy/types"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resolveTelegramDmAllow } from "./access-groups.js"; import { mergeTelegramAccountConfig } from "./account-config.js"; import { resolveTelegramCommandAuthorization, resolveTelegramGroupAllowFromContext, - resolveTelegramMessageForumFlagHint, + resolveTelegramMessageThreadSpec, } from "./bot/helpers.js"; import { resolveTelegramScopedGroupConfig } from "./group-config-helpers.js"; import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; @@ -14,11 +15,8 @@ type UpdateSenderFacts = { senderId: string; senderUsername?: string; chatId: number; - chatType?: string; isGroup: boolean; - isTopicMessage?: boolean; - isForum?: boolean; - messageThreadId?: number; + message: Message; }; function extractUpdateSenderFacts(update: unknown): UpdateSenderFacts | null { @@ -49,19 +47,8 @@ function extractUpdateSenderFacts(update: unknown): UpdateSenderFacts | null { senderId: String(fromObj.id), ...(typeof fromObj.username === "string" ? { senderUsername: fromObj.username } : {}), chatId: chat.id, - chatType, isGroup: chatType !== "private", - ...(typeof chat.is_forum === "boolean" ? { isForum: chat.is_forum } : {}), - ...(typeof (msg as { is_topic_message?: unknown }).is_topic_message === "boolean" - ? { - isTopicMessage: (msg as { is_topic_message: boolean }).is_topic_message, - } - : {}), - ...(typeof (msg as { message_thread_id?: unknown }).message_thread_id === "number" - ? { - messageThreadId: (msg as { message_thread_id: number }).message_thread_id, - } - : {}), + message: msg as Message, }; } } @@ -78,15 +65,8 @@ function extractUpdateSenderFacts(update: unknown): UpdateSenderFacts | null { senderId: String(from.id), ...(typeof from.username === "string" ? { senderUsername: from.username } : {}), chatId: chat.id, - chatType, isGroup: chatType !== "private", - ...(typeof chat.is_forum === "boolean" ? { isForum: chat.is_forum } : {}), - ...(typeof message.is_topic_message === "boolean" - ? { isTopicMessage: message.is_topic_message as boolean } - : {}), - ...(typeof message.message_thread_id === "number" - ? { messageThreadId: message.message_thread_id as number } - : {}), + message: message as unknown as Message, }; } @@ -132,13 +112,6 @@ export async function isTelegramSpooledUpdateSenderAuthorized( const dmPolicy = accountCfg.dmPolicy ?? "pairing"; const allowFrom = accountCfg.allowFrom; const groupAllowFrom = accountCfg.groupAllowFrom ?? accountCfg.allowFrom; - const isForum = - resolveTelegramMessageForumFlagHint({ - chatType: facts.chatType as "private" | "group" | "supergroup" | "channel" | undefined, - isForum: facts.isForum, - isTopicMessage: facts.isTopicMessage, - }) ?? false; - const groupAllowContext = await resolveTelegramGroupAllowFromContext({ cfg: auth.cfg, chatId: facts.chatId, @@ -147,8 +120,7 @@ export async function isTelegramSpooledUpdateSenderAuthorized( allowFrom, senderId: facts.senderId, isGroup: facts.isGroup, - isForum, - messageThreadId: facts.messageThreadId, + threadSpec: resolveTelegramMessageThreadSpec(facts.message), groupAllowFrom, resolveTelegramGroupConfig: (chatId, messageThreadId, cfg) => { const telegramCfg = mergeTelegramAccountConfig(cfg, auth.accountId); diff --git a/extensions/telegram/src/telegram-ingress-supersede.test.ts b/extensions/telegram/src/telegram-ingress-supersede.test.ts index b67ed969ab03..b0770d4cbf4f 100644 --- a/extensions/telegram/src/telegram-ingress-supersede.test.ts +++ b/extensions/telegram/src/telegram-ingress-supersede.test.ts @@ -44,6 +44,8 @@ function messageUpdate(params: { messageThreadId?: number; isTopicMessage?: boolean; isForum?: boolean; + isDirectMessages?: boolean; + directMessagesTopicId?: number; entities?: Array<{ type: string; offset: number; length: number }>; }) { return { @@ -55,11 +57,17 @@ function messageUpdate(params: { id: params.chatId ?? Number(params.senderId), type: params.chatType ?? "private", ...(params.isForum !== undefined ? { is_forum: params.isForum } : {}), + ...(params.isDirectMessages !== undefined + ? { is_direct_messages: params.isDirectMessages } + : {}), }, ...(params.messageThreadId !== undefined ? { message_thread_id: params.messageThreadId } : {}), ...(params.isTopicMessage !== undefined ? { is_topic_message: params.isTopicMessage } : {}), + ...(params.directMessagesTopicId !== undefined + ? { direct_messages_topic: { topic_id: params.directMessagesTopicId } } + : {}), ...(params.entities ? { entities: params.entities } : {}), }, }; @@ -365,6 +373,56 @@ describe("telegram ingress supersede policy", () => { ).toBe(true); }); + it.each([ + { + name: "allows the topic sender when the base chat denies them", + baseAllowFrom: [STRANGER_ID], + topicAllowFrom: [OWNER_ID], + expected: true, + }, + { + name: "denies the topic sender when the base chat allows them", + baseAllowFrom: [OWNER_ID], + topicAllowFrom: [STRANGER_ID], + expected: false, + }, + ])("uses channel-DM topic authorization: $name", async (testCase) => { + const channelDmAuth = { + cfg: { + channels: { + telegram: { + groupPolicy: "allowlist", + groupAllowFrom: testCase.baseAllowFrom, + groups: { + "-1001": { + allowFrom: testCase.baseAllowFrom, + topics: { "77": { allowFrom: testCase.topicAllowFrom } }, + }, + }, + }, + }, + } as OpenClawConfig, + accountId: "default", + }; + const update = messageUpdate({ + updateId: 2, + text: "stop", + senderId: OWNER_ID, + chatId: -1001, + chatType: "supergroup", + isDirectMessages: true, + directMessagesTopicId: 77, + messageThreadId: 999, + }); + + expect( + await createShouldSupersedeTelegramSpooledPending(channelDmAuth)( + record("2", update), + claim("1", messageUpdate({ updateId: 1, text: "prior", senderId: OWNER_ID })), + ), + ).toBe(testCase.expected); + }); + it("reuses ingress command gate for sender authorization", async () => { expect( await isTelegramSpooledUpdateSenderAuthorized(