From 48d4bc8cedc47dceff285fdf2a96f6aa50482c4f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 10:24:20 -0700 Subject: [PATCH] fix(telegram): apply group media policy before inbound downloads --- ...ot-handlers.inbound-media-group.runtime.ts | 46 +- .../src/bot-handlers.inbound.runtime.ts | 63 +-- .../telegram/src/bot-message-context.body.ts | 25 +- ...te-telegram-bot.channel-post-media.test.ts | 519 +++++++++++++++++- .../telegram/src/group-config-helpers.ts | 18 +- 5 files changed, 585 insertions(+), 86 deletions(-) diff --git a/extensions/telegram/src/bot-handlers.inbound-media-group.runtime.ts b/extensions/telegram/src/bot-handlers.inbound-media-group.runtime.ts index 49c9244b3907..f72c082a1b83 100644 --- a/extensions/telegram/src/bot-handlers.inbound-media-group.runtime.ts +++ b/extensions/telegram/src/bot-handlers.inbound-media-group.runtime.ts @@ -29,6 +29,7 @@ import type { TelegramSpooledReplayDeferredParticipant } from "./bot-processing- import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js"; import { resolveMedia } from "./bot/delivery.resolve-media.js"; import { + buildTelegramGroupPeerId, buildTelegramThreadParams, getTelegramTextParts, hasBotMention, @@ -37,6 +38,7 @@ import { } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; import { isTelegramForumServiceMessage } from "./forum-service-message.js"; +import { resolveTelegramGroupIngestEnabled } from "./group-config-helpers.js"; import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; import type { TelegramMessageDispatchReplayClaim } from "./message-dispatch-dedupe.js"; @@ -68,6 +70,8 @@ type BufferedMediaGroupEntry = MediaGroupEntry & spooledReplayParticipants: TelegramSpooledReplayDeferredParticipant[]; }; +type TelegramGroupMediaDisposition = "process" | "skip" | "silent-ingest"; + export function createTelegramInboundMediaGroupRuntime( params: Pick< RegisterTelegramHandlerParams, @@ -114,9 +118,9 @@ export function createTelegramInboundMediaGroupRuntime( const buffer = new Map(); const queue = new KeyedAsyncQueue(); - const shouldSkipMediaDownloadForUnaddressedMentionGroup = async ( + const resolveUnaddressedGroupMediaDisposition = async ( authorization: MediaAuthorization & { ctx: TelegramContext; msg: Message }, - ): Promise => { + ): Promise => { const { ctx, msg, chatId, isGroup, isForum, resolvedThreadId, dmThreadId, senderId } = authorization; const textParts = getTelegramTextParts(msg); @@ -129,7 +133,7 @@ export function createTelegramInboundMediaGroupRuntime( // history, fires ingest hooks, and settles an explicit skipped result; // consuming them here tombstones the ingress row without any trace. if (!isGroup || !hasInboundMedia(msg) || mayNeedDownload) { - return false; + return "process"; } const sessionState = resolveTelegramSessionState({ chatId, @@ -154,13 +158,15 @@ export function createTelegramInboundMediaGroupRuntime( resolveGroupRequireMention(chatId, authorization.authorizationCfg), ); if (!requireMention) { - return false; + return "process"; } const botUsername = ctx.me?.username?.trim().toLowerCase(); - const mentionRegexes = buildMentionRegexes( - authorization.authorizationCfg, - sessionState.agentId, - ); + const mentionRegexes = buildMentionRegexes(authorization.authorizationCfg, sessionState.agentId, { + provider: "telegram", + conversationId: buildTelegramGroupPeerId(chatId, resolvedThreadId), + providerPolicy: + authorization.authorizationCfg.channels?.telegram?.accounts?.[accountId]?.mentionPatterns, + }); const hasAnyMention = textParts.entities.some((entity) => entity.type === "mention"); const explicitlyMentioned = botUsername ? hasBotMention(msg, botUsername) : false; const wasMentioned = matchesMentionWithExplicit({ @@ -215,10 +221,20 @@ export function createTelegramInboundMediaGroupRuntime( }, }); if (decision.shouldSkip) { + if ( + resolveTelegramGroupIngestEnabled({ + cfg: authorization.authorizationCfg, + chatId, + accountId, + topicConfig: authorization.topicConfig, + }) + ) { + return "silent-ingest"; + } logger.info({ chatId, reason: "no-mention" }, "skipping group media before download"); - return true; + return "skip"; } - return false; + return "process"; }; const processMediaGroup = async (entry: BufferedMediaGroupEntry) => { @@ -275,7 +291,11 @@ export function createTelegramInboundMediaGroupRuntime( }); primary = { ctx: combinedContext, msg: combinedMessage }; } - if (await shouldSkipMediaDownloadForUnaddressedMentionGroup({ ...entry, ...primary })) { + const mediaDisposition = await resolveUnaddressedGroupMediaDisposition({ + ...entry, + ...primary, + }); + if (mediaDisposition === "skip") { releaseDispatchDedupeClaims(entry.dispatchDedupeClaims); settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" }); return; @@ -324,7 +344,7 @@ export function createTelegramInboundMediaGroupRuntime( skippedCount++; } } - if (skippedCount > 0) { + if (skippedCount > 0 && mediaDisposition !== "silent-ingest") { const verb = skippedCount === 1 ? "was" : "were"; await withTelegramApiErrorLogging({ operation: "sendMessage", @@ -432,5 +452,5 @@ export function createTelegramInboundMediaGroupRuntime( return true; }; - return { handleMediaGroup, shouldSkipMediaDownloadForUnaddressedMentionGroup }; + return { handleMediaGroup, resolveUnaddressedGroupMediaDisposition }; } diff --git a/extensions/telegram/src/bot-handlers.inbound.runtime.ts b/extensions/telegram/src/bot-handlers.inbound.runtime.ts index c4fb54ce2cc1..32aa26a4177d 100644 --- a/extensions/telegram/src/bot-handlers.inbound.runtime.ts +++ b/extensions/telegram/src/bot-handlers.inbound.runtime.ts @@ -69,7 +69,7 @@ export function createTelegramHandlerInboundRuntime( resolveTelegramDebounceLane, } = createTelegramInboundDebounceRuntime({ cfg, bot, runtime }, messageRuntime); - const { handleMediaGroup, shouldSkipMediaDownloadForUnaddressedMentionGroup } = + const { handleMediaGroup, resolveUnaddressedGroupMediaDisposition } = createTelegramInboundMediaGroupRuntime( { accountId, @@ -204,23 +204,22 @@ export function createTelegramHandlerInboundRuntime( return; } - if ( - await shouldSkipMediaDownloadForUnaddressedMentionGroup({ - authorizationCfg, - ctx, - msg, - chatId, - isGroup, - isForum, - resolvedThreadId, - dmThreadId, - senderId, - effectiveGroupAllow, - effectiveDmAllow, - groupConfig, - topicConfig, - }) - ) { + const mediaDisposition = await resolveUnaddressedGroupMediaDisposition({ + authorizationCfg, + ctx, + msg, + chatId, + isGroup, + isForum, + resolvedThreadId, + dmThreadId, + senderId, + effectiveGroupAllow, + effectiveDmAllow, + groupConfig, + topicConfig, + }); + if (mediaDisposition === "skip") { releaseDispatchDedupeClaims(dispatchDedupeClaims); return; } @@ -254,7 +253,7 @@ export function createTelegramHandlerInboundRuntime( return; } if (isMediaSizeLimitError(mediaErr)) { - if (sendOversizeWarning) { + if (sendOversizeWarning && mediaDisposition !== "silent-ingest") { const limitMb = mediaErr instanceof TelegramBotApiFileTooLargeError ? Math.min(mediaErr.limitMb, Math.round(mediaMaxBytes / (1024 * 1024))) @@ -281,18 +280,20 @@ export function createTelegramHandlerInboundRuntime( releaseDispatchDedupeClaims(dispatchDedupeClaims, mediaErr); return; } - await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => - bot.api.sendMessage(chatId, "⚠️ Failed to download media. Please try again.", { - ...warningThreadParams, - reply_parameters: { - message_id: msg.message_id, - allow_sending_without_reply: true, - }, - }), - }).catch(() => {}); + if (mediaDisposition !== "silent-ingest") { + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime, + fn: () => + bot.api.sendMessage(chatId, "⚠️ Failed to download media. Please try again.", { + ...warningThreadParams, + reply_parameters: { + message_id: msg.message_id, + allow_sending_without_reply: true, + }, + }), + }).catch(() => {}); + } } } diff --git a/extensions/telegram/src/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts index b2b1f4763dc6..c831063d544d 100644 --- a/extensions/telegram/src/bot-message-context.body.ts +++ b/extensions/telegram/src/bot-message-context.body.ts @@ -14,7 +14,6 @@ import { type InboundEventKind, type NormalizedLocation, } from "openclaw/plugin-sdk/channel-inbound"; -import { resolveChannelGroupPolicy } from "openclaw/plugin-sdk/channel-policy"; import { hasControlCommand } from "openclaw/plugin-sdk/command-detection"; import { isAbortRequestText } from "openclaw/plugin-sdk/command-primitives-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; @@ -55,6 +54,7 @@ import { import { buildTelegramGroupPeerId, buildTelegramInboundOriginTarget } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; import { isTelegramForumServiceMessage } from "./forum-service-message.js"; +import { resolveTelegramGroupIngestEnabled } from "./group-config-helpers.js"; import { recordTelegramGroupHistoryEntry } from "./group-history-window.js"; import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; type TelegramMentionFacts = NonNullable< @@ -388,17 +388,10 @@ export async function resolveTelegramInboundBody(params: { messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined, }, }); - const telegramGroupPolicy = resolveChannelGroupPolicy({ - cfg, - channel: "telegram", - groupId: String(chatId), - accountId, - }); - const ingestEnabled = - topicConfig?.ingest ?? - telegramGroupPolicy.groupConfig?.ingest ?? - telegramGroupPolicy.defaultConfig?.ingest; - if (ingestEnabled === true && sessionKey) { + if ( + sessionKey && + resolveTelegramGroupIngestEnabled({ cfg, chatId, accountId, topicConfig }) + ) { fireAndForgetHook( triggerInternalHook( createInternalHookEvent( @@ -408,7 +401,7 @@ export async function resolveTelegramInboundBody(params: { toInternalMessageReceivedContext({ from: `telegram:group:${historyKey ?? chatId}`, to: originatingTo, - content: rawBody, + content: historyBody, timestamp: msg.date ? msg.date * 1000 : undefined, channelId: "telegram", accountId, @@ -424,6 +417,12 @@ export async function resolveTelegramInboundBody(params: { originatingTo, isGroup: true, groupId: `telegram:${chatId}`, + media: materializedMedia.map(({ path, contentType, kind, sourceMessageId }) => ({ + path, + contentType, + kind, + messageId: sourceMessageId ?? String(msg.message_id), + })), }), ), ), diff --git a/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts b/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts index c229bcd0a061..910d93d1a69b 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts @@ -1,13 +1,32 @@ // Telegram tests cover bot.create telegram bot.channel post media plugin behavior. import { setTimeout as delay } from "node:timers/promises"; +import { + createPluginStateKeyedStoreForTests, + createPluginStateSyncKeyedStoreForTests, +} from "openclaw/plugin-sdk/plugin-state-test-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { telegramBotInfoForTest } from "./bot.create-telegram-bot.test-support.js"; +import { setTelegramRuntime } from "./runtime.js"; +import type { TelegramRuntime } from "./runtime.types.js"; const saveRemoteMedia = vi.fn(); const saveMediaBuffer = vi.fn(); const readRemoteMediaBuffer = vi.fn(); const rootRead = vi.fn(); +const { triggerInternalHookMock } = vi.hoisted(() => ({ + triggerInternalHookMock: vi.fn<(event: unknown) => Promise>(async () => undefined), +})); + +vi.mock("openclaw/plugin-sdk/hook-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/hook-runtime", + ); + return { + ...actual, + triggerInternalHook: triggerInternalHookMock, + }; +}); vi.mock("openclaw/plugin-sdk/file-access-runtime", () => ({ root: async (rootDir: string) => ({ @@ -252,12 +271,33 @@ describe("createTelegramBot channel_post media", () => { createTelegramBot = (opts) => createTelegramBotBase({ botInfo: telegramBotInfoForTest, + telegramTransport: { + fetch: globalThis.fetch, + sourceFetch: globalThis.fetch, + close: async () => {}, + }, ...opts, telegramDeps: telegramBotDepsForTest, }); }); beforeEach(() => { + setTelegramRuntime({ + state: { + openKeyedStore: ((options) => + createPluginStateKeyedStoreForTests( + "telegram", + options, + )) as TelegramRuntime["state"]["openKeyedStore"], + openSyncKeyedStore: ((options) => + createPluginStateSyncKeyedStoreForTests( + "telegram", + options, + )) as TelegramRuntime["state"]["openSyncKeyedStore"], + }, + channel: {}, + } as TelegramRuntime); + triggerInternalHookMock.mockClear(); saveRemoteMedia.mockReset(); saveRemoteMedia.mockImplementation( async (params: { fetchImpl: typeof fetch; maxBytes: number; url: string }) => { @@ -658,19 +698,61 @@ describe("createTelegramBot channel_post media", () => { } }); - it("notifies mentioned requireMention groups when media download fails", async () => { - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, + it.each([ + { + name: "an explicitly ingest-enabled group", + groups: { "-100456": { requireMention: true, ingest: true } }, + shouldIngest: true, + }, + { + name: "wildcard ingest inherited by a specific group", + groups: { + "*": { requireMention: true, ingest: true }, + "-100456": { requireMention: true }, + }, + shouldIngest: true, + }, + { + name: "a group that explicitly disables inherited ingest", + groups: { + "*": { requireMention: true, ingest: true }, + "-100456": { requireMention: true, ingest: false }, + }, + shouldIngest: false, + }, + { + name: "a topic that enables ingest over its disabled group", + groups: { + "-100456": { + requireMention: true, + ingest: false, + topics: { "42": { ingest: true } }, }, }, + topicId: 42, + shouldIngest: true, + }, + { + name: "a topic that disables ingest over its enabled group", + groups: { + "-100456": { + requireMention: true, + ingest: true, + topics: { "42": { ingest: false } }, + }, + }, + topicId: 42, + shouldIngest: false, + }, + ])("honors $name before skipping unmentioned group media (#92067)", async (testCase) => { + const topicId = "topicId" in testCase ? testCase.topicId : undefined; + loadConfig.mockReturnValue({ + channels: { telegram: { groupPolicy: "open", groups: testCase.groups } }, }); - saveRemoteMedia.mockRejectedValueOnce(new Error("MediaFetchError: ECONNRESET")); - const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { - throw new Error("MediaFetchError: ECONNRESET"); - }); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + const getFile = vi.fn(async () => ({ file_path: "photos/ingested.jpg" })); + const fetchSpy = createImageFetchSpy(); try { createTelegramBot({ token: "tok" }); @@ -678,35 +760,416 @@ describe("createTelegramBot channel_post media", () => { await handler({ message: { - chat: { id: -100456, type: "supergroup", title: "Ops Chat" }, - message_id: 81182, + chat: { + id: -100456, + type: "supergroup", + title: "Ops Chat", + ...(topicId ? { is_forum: true } : {}), + }, + message_id: 92067, date: 1736380800, - caption: "@openclaw_bot check this", - photo: [{ file_id: "p1" }], + ...(topicId ? { message_thread_id: topicId, is_topic_message: true } : {}), + photo: [{ file_id: "ingested-photo" }], from: { id: 55, is_bot: false, first_name: "u" }, }, me: { id: 999, username: "openclaw_bot" }, - getFile: async () => ({ file_path: "photos/p1.jpg" }), + getFile, }); - await waitForMockCalls(sendMessageSpy, 1); - expect(sendMessageSpy).toHaveBeenCalledWith( - -100456, - "⚠️ Failed to download media. Please try again.", - { - reply_parameters: { - message_id: 81182, - allow_sending_without_reply: true, - }, - }, - ); - expect(replySpy).toHaveBeenCalledOnce(); - expectTypeOnlyMediaPayload("image", "@openclaw_bot check this"); + if (testCase.shouldIngest) { + expect(getFile).toHaveBeenCalledOnce(); + expect(fetchSpy).toHaveBeenCalledOnce(); + expect(triggerInternalHookMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: "message", + action: "received", + context: expect.objectContaining({ + content: expect.stringMatching(/\S/u), + media: [ + expect.objectContaining({ + path: "/tmp/telegram-media.bin", + contentType: "image/png", + kind: "image", + messageId: "92067", + }), + ], + }), + }), + ); + } else { + expect(getFile).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(triggerInternalHookMock).not.toHaveBeenCalled(); + } + expect(sendMessageSpy).not.toHaveBeenCalled(); + expect(replySpy).not.toHaveBeenCalled(); } finally { fetchSpy.mockRestore(); } }); + it("ingests an unmentioned group media album exactly once (#92067)", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "-100456": { requireMention: true, ingest: true } }, + }, + }, + }); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + const fetchSpy = createImageFetchSpy(); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const getFile = vi.fn(async () => ({ file_path: "photos/album.jpg" })); + + try { + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + for (const messageId of [92068, 92069]) { + await handler({ + message: { + chat: { id: -100456, type: "supergroup", title: "Ops Chat" }, + message_id: messageId, + date: 1736380800, + media_group_id: "ingested-album", + photo: [{ file_id: `photo-${messageId}` }], + from: { id: 55, is_bot: false, first_name: "u" }, + }, + me: { id: 999, username: "openclaw_bot" }, + getFile, + }); + } + + expect(getFile).not.toHaveBeenCalled(); + await flushChannelPostMediaGroup(setTimeoutSpy); + + expect(getFile).toHaveBeenCalledTimes(2); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(triggerInternalHookMock).toHaveBeenCalledOnce(); + expect(triggerInternalHookMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: "message", + action: "received", + context: expect.objectContaining({ + content: expect.stringMatching(/\S/u), + media: [ + expect.objectContaining({ + path: "/tmp/telegram-media.bin", + contentType: "image/png", + kind: "image", + messageId: "92068", + }), + expect.objectContaining({ + path: "/tmp/telegram-media.bin", + contentType: "image/png", + kind: "image", + messageId: "92069", + }), + ], + }), + }), + ); + expect(sendMessageSpy).not.toHaveBeenCalled(); + expect(replySpy).not.toHaveBeenCalled(); + } finally { + setTimeoutSpy.mockRestore(); + fetchSpy.mockRestore(); + } + }); + + it.each([ + { failure: "a download error", error: "Network request for 'getFile' failed!" }, + { failure: "an oversized file", error: "Bad Request: file is too big" }, + ])("silently ingests unmentioned group media after $failure (#92067)", async ({ error }) => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "-100456": { requireMention: true, ingest: true } }, + }, + }, + }); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100456, type: "supergroup", title: "Ops Chat" }, + message_id: 92070, + date: 1736380800, + photo: [{ file_id: "failed-ingested-photo" }], + from: { id: 55, is_bot: false, first_name: "u" }, + }, + me: { id: 999, username: "openclaw_bot" }, + getFile: async () => { + throw new Error(error); + }, + }); + + expect(sendMessageSpy).not.toHaveBeenCalled(); + expect(replySpy).not.toHaveBeenCalled(); + expect(saveRemoteMedia).not.toHaveBeenCalled(); + expect(triggerInternalHookMock).toHaveBeenCalledOnce(); + expect(triggerInternalHookMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: "message", + action: "received", + context: expect.objectContaining({ content: expect.stringMatching(/\S/u) }), + }), + ); + const event = triggerInternalHookMock.mock.calls[0]?.[0] as { + context?: { media?: unknown[] }; + }; + expect(event.context?.media).toBeUndefined(); + }); + + it.each([ + { name: "without a custom mention", caption: undefined, denyCustomMentions: false }, + { + name: "with a denied custom mention", + caption: "bert, see attachment", + denyCustomMentions: true, + }, + ])("silently ingests recoverable partial group albums $name (#92067)", async (testCase) => { + loadConfig.mockReturnValue({ + messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + channels: { + telegram: { + groupPolicy: "open", + ...(testCase.denyCustomMentions + ? { mentionPatterns: { mode: "deny" as const } } + : {}), + groups: { "-100456": { requireMention: true, ingest: true } }, + }, + }, + }); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + saveRemoteMedia.mockRejectedValueOnce(new Error("MediaFetchError: Failed to fetch media")); + const fetchSpy = createImageFetchSpy(); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const getFile = vi.fn(async () => ({ file_path: "photos/partial-ingested.jpg" })); + + try { + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + for (const messageId of [92071, 92072]) { + await handler({ + message: { + chat: { id: -100456, type: "supergroup", title: "Ops Chat" }, + message_id: messageId, + date: 1736380800, + media_group_id: "partial-ingested-album", + photo: [{ file_id: `photo-${messageId}` }], + ...(testCase.caption && messageId === 92071 ? { caption: testCase.caption } : {}), + from: { id: 55, is_bot: false, first_name: "u" }, + }, + me: { id: 999, username: "openclaw_bot" }, + getFile, + }); + } + + await flushChannelPostMediaGroup(setTimeoutSpy); + + expect(getFile).toHaveBeenCalledTimes(2); + expect(triggerInternalHookMock).toHaveBeenCalledOnce(); + expect(triggerInternalHookMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: "message", + action: "received", + context: expect.objectContaining({ + content: expect.stringMatching(/\S/u), + media: [ + expect.objectContaining({ + path: "/tmp/telegram-media.bin", + contentType: "image/png", + kind: "image", + messageId: "92072", + }), + ], + }), + }), + ); + expect(sendMessageSpy).not.toHaveBeenCalled(); + expect(replySpy).not.toHaveBeenCalled(); + } finally { + setTimeoutSpy.mockRestore(); + fetchSpy.mockRestore(); + } + }); + + it.each([ + { + name: "provider-wide disabled patterns", + providerPolicy: { mode: "deny" as const }, + accountPolicy: undefined, + accountId: undefined, + topicId: undefined, + shouldWarn: false, + }, + { + name: "a disabled conversation", + providerPolicy: { mode: "allow" as const, denyIn: ["-100456"] }, + accountPolicy: undefined, + accountId: undefined, + topicId: undefined, + shouldWarn: false, + }, + { + name: "a disabled forum topic", + providerPolicy: { mode: "allow" as const, denyIn: ["-100456:topic:42"] }, + accountPolicy: undefined, + accountId: undefined, + topicId: 42, + shouldWarn: false, + }, + { + name: "an explicitly allowed forum topic", + providerPolicy: { mode: "deny" as const, allowIn: ["-100456:topic:42"] }, + accountPolicy: undefined, + accountId: undefined, + topicId: 42, + shouldWarn: true, + }, + { + name: "an account override that disables provider patterns", + providerPolicy: { mode: "allow" as const }, + accountPolicy: { mode: "deny" as const }, + accountId: "work", + topicId: undefined, + shouldWarn: false, + }, + { + name: "an account override that explicitly allows its forum topic", + providerPolicy: { mode: "deny" as const }, + accountPolicy: { mode: "deny" as const, allowIn: ["-100456:topic:42"] }, + accountId: "work", + topicId: 42, + shouldWarn: true, + }, + ])("applies $name before classifying group media mentions (#92067)", async (testCase) => { + loadConfig.mockReturnValue({ + messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + channels: { + telegram: { + groupPolicy: "open", + mentionPatterns: testCase.providerPolicy, + groups: { "-100456": { requireMention: true, ingest: true } }, + ...(testCase.accountPolicy + ? { accounts: { work: { mentionPatterns: testCase.accountPolicy } } } + : {}), + }, + }, + }); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + + createTelegramBot({ token: "tok", ...(testCase.accountId ? { accountId: "work" } : {}) }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { + id: -100456, + type: "supergroup", + title: "Ops Chat", + ...(testCase.topicId ? { is_forum: true } : {}), + }, + message_id: 92073, + date: 1736380800, + caption: "bert, see attachment", + ...(testCase.topicId + ? { message_thread_id: testCase.topicId, is_topic_message: true } + : {}), + photo: [{ file_id: "custom-mentioned-photo" }], + from: { id: 55, is_bot: false, first_name: "u" }, + }, + me: { id: 999, username: "openclaw_bot" }, + getFile: async () => { + throw new Error("Network request for 'getFile' failed!"); + }, + }); + + if (testCase.shouldWarn) { + await waitForMockCalls(sendMessageSpy, 1); + expect(sendMessageSpy).toHaveBeenCalledWith( + -100456, + "⚠️ Failed to download media. Please try again.", + expect.anything(), + ); + expect(replySpy).toHaveBeenCalledOnce(); + } else { + expect(sendMessageSpy).not.toHaveBeenCalled(); + expect(replySpy).not.toHaveBeenCalled(); + expect(triggerInternalHookMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: "message", + action: "received", + context: expect.objectContaining({ content: "bert, see attachment" }), + }), + ); + } + }); + + it.each([false, true])( + "notifies mentioned requireMention groups when media download fails (ingest: %s)", + async (ingest) => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true, ...(ingest ? { ingest: true } : {}) } }, + }, + }, + }); + sendMessageSpy.mockClear(); + saveRemoteMedia.mockRejectedValueOnce(new Error("MediaFetchError: ECONNRESET")); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + throw new Error("MediaFetchError: ECONNRESET"); + }); + + try { + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100456, type: "supergroup", title: "Ops Chat" }, + message_id: 81182, + date: 1736380800, + caption: "@openclaw_bot check this", + photo: [{ file_id: "p1" }], + from: { id: 55, is_bot: false, first_name: "u" }, + }, + me: { id: 999, username: "openclaw_bot" }, + getFile: async () => ({ file_path: "photos/p1.jpg" }), + }); + await waitForMockCalls(sendMessageSpy, 1); + + expect(sendMessageSpy).toHaveBeenCalledWith( + -100456, + "⚠️ Failed to download media. Please try again.", + { + reply_parameters: { + message_id: 81182, + allow_sending_without_reply: true, + }, + }, + ); + expect(replySpy).toHaveBeenCalledOnce(); + expectTypeOnlyMediaPayload("image", "@openclaw_bot check this"); + } finally { + fetchSpy.mockRestore(); + } + }, + ); + it("treats targeted bot command captions as mentions before media download", async () => { loadConfig.mockReturnValue({ channels: { diff --git a/extensions/telegram/src/group-config-helpers.ts b/extensions/telegram/src/group-config-helpers.ts index 49be7d20cf3f..f733e5902e08 100644 --- a/extensions/telegram/src/group-config-helpers.ts +++ b/extensions/telegram/src/group-config-helpers.ts @@ -1,6 +1,7 @@ -import type { ScopeTree } from "openclaw/plugin-sdk/channel-policy"; +import { resolveChannelGroupPolicy, type ScopeTree } from "openclaw/plugin-sdk/channel-policy"; // Telegram helper module supports group config helpers behavior. import type { + OpenClawConfig, TelegramAccountConfig, TelegramDirectConfig, TelegramGroupConfig, @@ -42,6 +43,21 @@ export function resolveTelegramScopedGroupConfig( return { groupConfig, topicConfig }; } +export function resolveTelegramGroupIngestEnabled(params: { + cfg: OpenClawConfig; + chatId: string | number; + accountId?: string; + topicConfig?: TelegramTopicConfig; +}): boolean { + const { groupConfig, defaultConfig } = resolveChannelGroupPolicy({ + cfg: params.cfg, + channel: "telegram", + groupId: String(params.chatId), + accountId: params.accountId, + }); + return (params.topicConfig?.ingest ?? groupConfig?.ingest ?? defaultConfig?.ingest) === true; +} + export function resolveTelegramGroupPromptSettings(params: { groupConfig?: TelegramGroupConfig | TelegramDirectConfig; topicConfig?: TelegramTopicConfig;