refactor(telegram): streamline media runtime options

This commit is contained in:
Peter Steinberger
2026-04-03 19:08:46 +09:00
parent 122e6f0f79
commit 80c5764482
8 changed files with 195 additions and 93 deletions

View File

@@ -933,6 +933,12 @@ channels:
dangerouslyAllowPrivateNetwork: true
```
- The same opt-in is available per account at
`channels.telegram.accounts.<accountId>.network.dangerouslyAllowPrivateNetwork`.
- If your proxy resolves Telegram media hosts into `198.18.x.x`, leave the
dangerous flag off first. Telegram media already allows the RFC 2544
benchmark range by default.
<Warning>
`channels.telegram.network.dangerouslyAllowPrivateNetwork` weakens Telegram
media SSRF protections. Use it only for trusted operator-controlled proxy

View File

@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withEnv } from "../../../test/helpers/plugins/env.js";
import {
listTelegramAccountIds,
resolveTelegramMediaRuntimeOptions,
resetMissingDefaultWarnFlag,
resolveTelegramPollActionGateState,
resolveDefaultTelegramAccountId,
@@ -416,3 +417,67 @@ describe("resolveTelegramAccount groups inheritance (#30673)", () => {
expect(resolved.config.groups).toEqual({ "-100123": { requireMention: false } });
});
});
describe("resolveTelegramMediaRuntimeOptions", () => {
it("uses per-account network overrides for Telegram media downloads", () => {
const resolved = resolveTelegramMediaRuntimeOptions({
cfg: {
channels: {
telegram: {
apiRoot: "https://api.telegram.org",
network: {
dangerouslyAllowPrivateNetwork: false,
},
accounts: {
work: {
botToken: "123:work",
apiRoot: "http://tg-proxy.internal:8081",
network: {
dangerouslyAllowPrivateNetwork: true,
},
},
},
},
},
},
accountId: "work",
token: "123:work",
});
expect(resolved).toEqual({
token: "123:work",
apiRoot: "http://tg-proxy.internal:8081",
dangerouslyAllowPrivateNetwork: true,
transport: undefined,
});
});
it("falls back to top-level Telegram media settings when account override is absent", () => {
const resolved = resolveTelegramMediaRuntimeOptions({
cfg: {
channels: {
telegram: {
apiRoot: "http://tg-proxy.internal:8081",
network: {
dangerouslyAllowPrivateNetwork: true,
},
accounts: {
work: {
botToken: "123:work",
},
},
},
},
},
accountId: "work",
token: "123:work",
});
expect(resolved).toEqual({
token: "123:work",
apiRoot: "http://tg-proxy.internal:8081",
dangerouslyAllowPrivateNetwork: true,
transport: undefined,
});
});
});

View File

@@ -20,6 +20,7 @@ import {
} from "openclaw/plugin-sdk/routing";
import { formatSetExplicitDefaultInstruction } from "openclaw/plugin-sdk/routing";
import { createSubsystemLogger, isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env";
import type { TelegramTransport } from "./fetch.js";
import { resolveTelegramToken } from "./token.js";
let log: ReturnType<typeof createSubsystemLogger> | null = null;
@@ -57,6 +58,13 @@ export type ResolvedTelegramAccount = {
config: TelegramAccountConfig;
};
export type TelegramMediaRuntimeOptions = {
token: string;
transport?: TelegramTransport;
apiRoot?: string;
dangerouslyAllowPrivateNetwork?: boolean;
};
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const ids = new Set<string>();
for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) {
@@ -155,6 +163,24 @@ export function createTelegramActionGate(params: {
});
}
export function resolveTelegramMediaRuntimeOptions(params: {
cfg: OpenClawConfig;
accountId?: string | null;
token: string;
transport?: TelegramTransport;
}): TelegramMediaRuntimeOptions {
const normalizedAccountId = normalizeOptionalAccountId(params.accountId);
const accountCfg = normalizedAccountId
? mergeTelegramAccountConfig(params.cfg, normalizedAccountId)
: params.cfg.channels?.telegram;
return {
token: params.token,
transport: params.transport,
apiRoot: accountCfg?.apiRoot,
dangerouslyAllowPrivateNetwork: accountCfg?.network?.dangerouslyAllowPrivateNetwork,
};
}
export type TelegramPollActionGateState = {
sendMessageEnabled: boolean;
pollEnabled: boolean;

View File

@@ -6,7 +6,7 @@ import {
} from "openclaw/plugin-sdk/channel-inbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
import { mergeTelegramAccountConfig } from "./accounts.js";
import { resolveTelegramMediaRuntimeOptions } from "./accounts.js";
import {
hasInboundMedia,
isRecoverableMediaGroupError,
@@ -85,9 +85,12 @@ export function createTelegramInboundBufferRuntime(params: {
runtime,
telegramTransport,
} = params;
const telegramCfg = accountId
? mergeTelegramAccountConfig(cfg, accountId)
: cfg.channels?.telegram;
const mediaRuntimeOptions = resolveTelegramMediaRuntimeOptions({
cfg,
accountId,
token: opts.token,
transport: telegramTransport,
});
const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000;
const TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS =
typeof opts.testTimings?.textFragmentGapMs === "number" &&
@@ -151,18 +154,15 @@ export function createTelegramInboundBufferRuntime(params: {
return [];
}
try {
const media = await resolveMedia(
{
const media = await resolveMedia({
ctx: {
message: replyMessage,
me: ctx.me,
getFile: async () => await bot.api.getFile(replyFileId),
},
mediaMaxBytes,
opts.token,
telegramTransport,
telegramCfg?.apiRoot,
telegramCfg?.network?.dangerouslyAllowPrivateNetwork,
);
maxBytes: mediaMaxBytes,
...mediaRuntimeOptions,
});
if (!media) {
return [];
}
@@ -192,14 +192,11 @@ export function createTelegramInboundBufferRuntime(params: {
for (const { ctx } of entry.messages) {
let media;
try {
media = await resolveMedia(
media = await resolveMedia({
ctx,
mediaMaxBytes,
opts.token,
telegramTransport,
telegramCfg?.apiRoot,
telegramCfg?.network?.dangerouslyAllowPrivateNetwork,
);
maxBytes: mediaMaxBytes,
...mediaRuntimeOptions,
});
} catch (mediaErr) {
if (!isRecoverableMediaGroupError(mediaErr)) {
throw mediaErr;

View File

@@ -36,6 +36,7 @@ import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-run
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
import { resolveTelegramMediaRuntimeOptions } from "./accounts.js";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import {
isSenderAllowed,
@@ -117,6 +118,12 @@ export const registerTelegramHandlers = ({
logger,
telegramDeps = defaultTelegramBotDeps,
}: RegisterTelegramHandlerParams) => {
const mediaRuntimeOptions = resolveTelegramMediaRuntimeOptions({
cfg,
accountId,
token: opts.token,
transport: telegramTransport,
});
const DEFAULT_TEXT_FRAGMENT_MAX_GAP_MS = 1500;
const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000;
const TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS =
@@ -381,14 +388,11 @@ export const registerTelegramHandlers = ({
for (const { ctx } of entry.messages) {
let media;
try {
media = await resolveMedia(
media = await resolveMedia({
ctx,
mediaMaxBytes,
opts.token,
telegramTransport,
telegramCfg.apiRoot,
telegramCfg.network?.dangerouslyAllowPrivateNetwork,
);
maxBytes: mediaMaxBytes,
...mediaRuntimeOptions,
});
} catch (mediaErr) {
if (!isRecoverableMediaGroupError(mediaErr)) {
throw mediaErr;
@@ -486,18 +490,15 @@ export const registerTelegramHandlers = ({
return [];
}
try {
const media = await resolveMedia(
{
const media = await resolveMedia({
ctx: {
message: replyMessage,
me: ctx.me,
getFile: async () => await bot.api.getFile(replyFileId),
},
mediaMaxBytes,
opts.token,
telegramTransport,
telegramCfg.apiRoot,
telegramCfg.network?.dangerouslyAllowPrivateNetwork,
);
maxBytes: mediaMaxBytes,
...mediaRuntimeOptions,
});
if (!media) {
return [];
}
@@ -1015,14 +1016,11 @@ export const registerTelegramHandlers = ({
let media: Awaited<ReturnType<typeof resolveMedia>> = null;
try {
media = await resolveMedia(
media = await resolveMedia({
ctx,
mediaMaxBytes,
opts.token,
telegramTransport,
telegramCfg.apiRoot,
telegramCfg.network?.dangerouslyAllowPrivateNetwork,
);
maxBytes: mediaMaxBytes,
...mediaRuntimeOptions,
});
} catch (mediaErr) {
if (isMediaSizeLimitError(mediaErr)) {
if (sendOversizeWarning) {

View File

@@ -219,6 +219,15 @@ vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
};
});
vi.doMock("./bot-message-context.session.runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./bot-message-context.session.runtime.js")>();
return {
...actual,
readSessionUpdatedAt: () => undefined,
resolveStorePath: (storePath?: string) => storePath ?? "/tmp/sessions.json",
};
});
vi.doMock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-runtime")>();
return {

View File

@@ -151,9 +151,21 @@ function createFileTooBigError(): Error {
return new Error("GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)");
}
function resolveMediaWithDefaults(
ctx: TelegramContext,
overrides: Partial<Parameters<typeof resolveMedia>[0]> = {},
) {
return resolveMedia({
ctx,
maxBytes: MAX_MEDIA_BYTES,
token: BOT_TOKEN,
...overrides,
});
}
async function expectTransientGetFileRetrySuccess() {
const getFile = setupTransientGetFileRetry();
const promise = resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
const promise = resolveMediaWithDefaults(makeCtx("voice", getFile));
await flushRetryTimers();
const result = await promise;
expect(getFile).toHaveBeenCalledTimes(2);
@@ -196,7 +208,7 @@ describe("resolveMedia getFile retry", () => {
async (mediaField) => {
const getFile = vi.fn().mockRejectedValue(new Error("Network request for 'getFile' failed!"));
const promise = resolveMedia(makeCtx(mediaField, getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
const promise = resolveMediaWithDefaults(makeCtx(mediaField, getFile));
await flushRetryTimers();
const result = await promise;
@@ -209,9 +221,9 @@ describe("resolveMedia getFile retry", () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "voice/file_0.oga" });
fetchRemoteMedia.mockRejectedValueOnce(new Error("download failed"));
await expect(
resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN),
).rejects.toThrow("download failed");
await expect(resolveMediaWithDefaults(makeCtx("voice", getFile))).rejects.toThrow(
"download failed",
);
expect(getFile).toHaveBeenCalledTimes(1);
});
@@ -221,7 +233,7 @@ describe("resolveMedia getFile retry", () => {
const fileTooBigError = createFileTooBigError();
const getFile = vi.fn().mockRejectedValue(fileTooBigError);
const result = await resolveMedia(makeCtx("video", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
const result = await resolveMediaWithDefaults(makeCtx("video", getFile));
// Should NOT retry - "file is too big" is a permanent error, not transient.
expect(getFile).toHaveBeenCalledTimes(1);
@@ -234,7 +246,7 @@ describe("resolveMedia getFile retry", () => {
);
const getFile = vi.fn().mockRejectedValue(fileTooBigError);
const result = await resolveMedia(makeCtx("video", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
const result = await resolveMediaWithDefaults(makeCtx("video", getFile));
expect(getFile).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
@@ -245,7 +257,7 @@ describe("resolveMedia getFile retry", () => {
async (mediaField) => {
const getFile = vi.fn().mockRejectedValue(createFileTooBigError());
const result = await resolveMedia(makeCtx(mediaField, getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
const result = await resolveMediaWithDefaults(makeCtx(mediaField, getFile));
expect(getFile).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
@@ -254,9 +266,9 @@ describe("resolveMedia getFile retry", () => {
it("throws when getFile returns no file_path", async () => {
const getFile = vi.fn().mockResolvedValue({});
await expect(
resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN),
).rejects.toThrow("Telegram getFile returned no file_path");
await expect(resolveMediaWithDefaults(makeCtx("voice", getFile))).rejects.toThrow(
"Telegram getFile returned no file_path",
);
expect(getFile).toHaveBeenCalledTimes(1);
});
@@ -283,7 +295,7 @@ describe("resolveMedia getFile retry", () => {
});
const ctx = makeCtx("sticker", getFile);
const promise = resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
const promise = resolveMediaWithDefaults(ctx);
await flushRetryTimers();
const result = await promise;
@@ -297,7 +309,7 @@ describe("resolveMedia getFile retry", () => {
const getFile = vi.fn().mockRejectedValue(new Error("Network request for 'getFile' failed!"));
const ctx = makeCtx("sticker", getFile);
const promise = resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
const promise = resolveMediaWithDefaults(ctx);
await flushRetryTimers();
const result = await promise;
@@ -319,12 +331,9 @@ describe("resolveMedia getFile retry", () => {
contentType: "application/pdf",
});
const result = await resolveMedia(
makeCtx("document", getFile),
MAX_MEDIA_BYTES,
BOT_TOKEN,
callerTransport,
);
const result = await resolveMediaWithDefaults(makeCtx("document", getFile), {
transport: callerTransport,
});
expect(result).not.toBeNull();
expect(fetchRemoteMedia).toHaveBeenCalledWith(
@@ -348,12 +357,9 @@ describe("resolveMedia getFile retry", () => {
contentType: "image/webp",
});
const result = await resolveMedia(
makeCtx("sticker", getFile),
MAX_MEDIA_BYTES,
BOT_TOKEN,
callerTransport,
);
const result = await resolveMediaWithDefaults(makeCtx("sticker", getFile), {
transport: callerTransport,
});
expect(result).not.toBeNull();
expect(fetchRemoteMedia).toHaveBeenCalledWith(
@@ -366,10 +372,8 @@ describe("resolveMedia getFile retry", () => {
it("uses local absolute file paths directly for media downloads", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" });
const result = await resolveMedia(
const result = await resolveMediaWithDefaults(
makeCtx("document", getFile, { mime_type: "application/pdf" }),
MAX_MEDIA_BYTES,
BOT_TOKEN,
);
expect(fetchRemoteMedia).not.toHaveBeenCalled();
@@ -388,7 +392,7 @@ describe("resolveMedia getFile retry", () => {
.fn()
.mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/sticker.webp" });
const result = await resolveMedia(makeCtx("sticker", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
const result = await resolveMediaWithDefaults(makeCtx("sticker", getFile));
expect(fetchRemoteMedia).not.toHaveBeenCalled();
expect(saveMediaBuffer).not.toHaveBeenCalled();
@@ -425,7 +429,7 @@ describe("resolveMedia original filename preservation", () => {
});
const ctx = makeCtx("document", getFile, { file_name: "business-plan.pdf" });
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
const result = await resolveMediaWithDefaults(ctx);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
@@ -450,7 +454,7 @@ describe("resolveMedia original filename preservation", () => {
});
const ctx = makeCtx("audio", getFile, { file_name: "my-song.mp3" });
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
const result = await resolveMediaWithDefaults(ctx);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
@@ -475,7 +479,7 @@ describe("resolveMedia original filename preservation", () => {
});
const ctx = makeCtx("video", getFile, { file_name: "presentation.mp4" });
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
const result = await resolveMediaWithDefaults(ctx);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
@@ -492,7 +496,7 @@ describe("resolveMedia original filename preservation", () => {
mockPdfFetchAndSave("file_42.pdf");
const ctx = makeCtx("document", getFile);
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
const result = await resolveMediaWithDefaults(ctx);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
@@ -509,7 +513,7 @@ describe("resolveMedia original filename preservation", () => {
mockPdfFetchAndSave(undefined);
const ctx = makeCtx("document", getFile);
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
const result = await resolveMediaWithDefaults(ctx);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
@@ -526,13 +530,9 @@ describe("resolveMedia original filename preservation", () => {
mockPdfFetchAndSave("file_42.pdf");
const ctx = makeCtx("document", getFile);
const result = await resolveMedia(
ctx,
MAX_MEDIA_BYTES,
BOT_TOKEN,
undefined,
"http://192.168.1.50:8081/custom-bot-api/",
);
const result = await resolveMediaWithDefaults(ctx, {
apiRoot: "http://192.168.1.50:8081/custom-bot-api/",
});
expect(fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({
@@ -551,7 +551,7 @@ describe("resolveMedia original filename preservation", () => {
mockPdfFetchAndSave("file_42.pdf");
const ctx = makeCtx("document", getFile);
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN, undefined, undefined, true);
const result = await resolveMediaWithDefaults(ctx, { dangerouslyAllowPrivateNetwork: true });
expect(fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({
@@ -571,7 +571,7 @@ describe("resolveMedia original filename preservation", () => {
const customApiRoot = "http://192.168.1.50:8081/custom-bot-api";
const ctx = makeCtx("document", getFile);
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN, undefined, customApiRoot);
const result = await resolveMediaWithDefaults(ctx, { apiRoot: customApiRoot });
// Verify the URL uses the custom apiRoot, not the default Telegram API
expect(fetchRemoteMedia).toHaveBeenCalledWith(
@@ -596,7 +596,7 @@ describe("resolveMedia original filename preservation", () => {
const customApiRoot = "http://localhost:8081/bot";
const ctx = makeCtx("sticker", getFile);
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN, undefined, customApiRoot);
const result = await resolveMediaWithDefaults(ctx, { apiRoot: customApiRoot });
// Verify the URL uses the custom apiRoot for sticker downloads
expect(fetchRemoteMedia).toHaveBeenCalledWith(

View File

@@ -297,19 +297,20 @@ async function resolveStickerMedia(params: {
}
}
export async function resolveMedia(
ctx: TelegramContext,
maxBytes: number,
token: string,
transport?: TelegramTransport,
apiRoot?: string,
dangerouslyAllowPrivateNetwork?: boolean,
): Promise<{
export async function resolveMedia(params: {
ctx: TelegramContext;
maxBytes: number;
token: string;
transport?: TelegramTransport;
apiRoot?: string;
dangerouslyAllowPrivateNetwork?: boolean;
}): Promise<{
path: string;
contentType?: string;
placeholder: string;
stickerMetadata?: StickerMetadata;
} | null> {
const { ctx, maxBytes, token, transport, apiRoot, dangerouslyAllowPrivateNetwork } = params;
const msg = ctx.message;
const stickerResolved = await resolveStickerMedia({
msg,