fix(telegram): apply group media policy before inbound downloads (#117537)

* fix(telegram): apply group media policy before inbound downloads

* test(telegram): consolidate inbound media owner regression matrix

* fix(telegram): format media group ingest

* test(telegram): restore media group policy coverage

---------

Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
Peter Steinberger
2026-08-01 20:31:21 -07:00
committed by GitHub
parent 14322647cd
commit aadbb00f2c
5 changed files with 645 additions and 564 deletions
@@ -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<string, BufferedMediaGroupEntry>();
const queue = new KeyedAsyncQueue();
const shouldSkipMediaDownloadForUnaddressedMentionGroup = async (
const resolveUnaddressedGroupMediaDisposition = async (
authorization: MediaAuthorization & { ctx: TelegramContext; msg: Message },
): Promise<boolean> => {
): Promise<TelegramGroupMediaDisposition> => {
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,12 +158,18 @@ 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,
{
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;
@@ -215,10 +225,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 +295,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 +348,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 +456,5 @@ export function createTelegramInboundMediaGroupRuntime(
return true;
};
return { handleMediaGroup, shouldSkipMediaDownloadForUnaddressedMentionGroup };
return { handleMediaGroup, resolveUnaddressedGroupMediaDisposition };
}
@@ -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(() => {});
}
}
}
@@ -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,7 @@ 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 +398,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 +414,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),
})),
}),
),
),
File diff suppressed because it is too large Load Diff
@@ -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;