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

This commit is contained in:
Peter Steinberger
2026-08-01 10:24:20 -07:00
parent b9bf34a3cd
commit 48d4bc8ced
5 changed files with 585 additions and 86 deletions

View File

@@ -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,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 };
}

View File

@@ -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(() => {});
}
}
}

View File

@@ -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),
})),
}),
),
),

View File

@@ -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<void>>(async () => undefined),
}));
vi.mock("openclaw/plugin-sdk/hook-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/hook-runtime")>(
"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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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: {

View File

@@ -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;