fix(telegram): warn on oversized media
This commit is contained in:
parent
aae5926db9
commit
9254f4b738
@ -24,6 +24,7 @@
|
|||||||
- Google: merge consecutive messages to satisfy strict role alternation for Google provider models. Thanks @Asleep123 for PR #266.
|
- Google: merge consecutive messages to satisfy strict role alternation for Google provider models. Thanks @Asleep123 for PR #266.
|
||||||
- Postinstall: handle targetDir symlinks in the install script. Thanks @obviyus for PR #272.
|
- Postinstall: handle targetDir symlinks in the install script. Thanks @obviyus for PR #272.
|
||||||
- WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75.
|
- WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75.
|
||||||
|
- Telegram: warn users when inbound media exceeds the 5MB limit. Thanks @jarvis-medmatic.
|
||||||
- Auto-reply: add configurable ack reactions for inbound messages (default 👀 or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178.
|
- Auto-reply: add configurable ack reactions for inbound messages (default 👀 or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178.
|
||||||
- Polls: unify WhatsApp + Discord poll sends via the gateway + CLI (`clawdbot poll`). (#123) — thanks @dbhurley
|
- Polls: unify WhatsApp + Discord poll sends via the gateway + CLI (`clawdbot poll`). (#123) — thanks @dbhurley
|
||||||
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
||||||
|
|||||||
@ -655,23 +655,27 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
public let activeminutes: Int?
|
public let activeminutes: Int?
|
||||||
public let includeglobal: Bool?
|
public let includeglobal: Bool?
|
||||||
public let includeunknown: Bool?
|
public let includeunknown: Bool?
|
||||||
|
public let spawnedby: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
limit: Int?,
|
limit: Int?,
|
||||||
activeminutes: Int?,
|
activeminutes: Int?,
|
||||||
includeglobal: Bool?,
|
includeglobal: Bool?,
|
||||||
includeunknown: Bool?
|
includeunknown: Bool?,
|
||||||
|
spawnedby: String?
|
||||||
) {
|
) {
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.activeminutes = activeminutes
|
self.activeminutes = activeminutes
|
||||||
self.includeglobal = includeglobal
|
self.includeglobal = includeglobal
|
||||||
self.includeunknown = includeunknown
|
self.includeunknown = includeunknown
|
||||||
|
self.spawnedby = spawnedby
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case limit
|
case limit
|
||||||
case activeminutes = "activeMinutes"
|
case activeminutes = "activeMinutes"
|
||||||
case includeglobal = "includeGlobal"
|
case includeglobal = "includeGlobal"
|
||||||
case includeunknown = "includeUnknown"
|
case includeunknown = "includeUnknown"
|
||||||
|
case spawnedby = "spawnedBy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -681,6 +685,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
public let verboselevel: AnyCodable?
|
public let verboselevel: AnyCodable?
|
||||||
public let elevatedlevel: AnyCodable?
|
public let elevatedlevel: AnyCodable?
|
||||||
public let model: AnyCodable?
|
public let model: AnyCodable?
|
||||||
|
public let spawnedby: AnyCodable?
|
||||||
public let sendpolicy: AnyCodable?
|
public let sendpolicy: AnyCodable?
|
||||||
public let groupactivation: AnyCodable?
|
public let groupactivation: AnyCodable?
|
||||||
|
|
||||||
@ -690,6 +695,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
verboselevel: AnyCodable?,
|
verboselevel: AnyCodable?,
|
||||||
elevatedlevel: AnyCodable?,
|
elevatedlevel: AnyCodable?,
|
||||||
model: AnyCodable?,
|
model: AnyCodable?,
|
||||||
|
spawnedby: AnyCodable?,
|
||||||
sendpolicy: AnyCodable?,
|
sendpolicy: AnyCodable?,
|
||||||
groupactivation: AnyCodable?
|
groupactivation: AnyCodable?
|
||||||
) {
|
) {
|
||||||
@ -698,6 +704,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
self.verboselevel = verboselevel
|
self.verboselevel = verboselevel
|
||||||
self.elevatedlevel = elevatedlevel
|
self.elevatedlevel = elevatedlevel
|
||||||
self.model = model
|
self.model = model
|
||||||
|
self.spawnedby = spawnedby
|
||||||
self.sendpolicy = sendpolicy
|
self.sendpolicy = sendpolicy
|
||||||
self.groupactivation = groupactivation
|
self.groupactivation = groupactivation
|
||||||
}
|
}
|
||||||
@ -707,6 +714,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
case verboselevel = "verboseLevel"
|
case verboselevel = "verboseLevel"
|
||||||
case elevatedlevel = "elevatedLevel"
|
case elevatedlevel = "elevatedLevel"
|
||||||
case model
|
case model
|
||||||
|
case spawnedby = "spawnedBy"
|
||||||
case sendpolicy = "sendPolicy"
|
case sendpolicy = "sendPolicy"
|
||||||
case groupactivation = "groupActivation"
|
case groupactivation = "groupActivation"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -340,7 +340,6 @@ Set `telegram.enabled: false` to disable automatic startup.
|
|||||||
botToken: "your-bot-token",
|
botToken: "your-bot-token",
|
||||||
requireMention: true,
|
requireMention: true,
|
||||||
allowFrom: ["123456789"],
|
allowFrom: ["123456789"],
|
||||||
mediaMaxMb: 5,
|
|
||||||
proxy: "socks5://localhost:9050",
|
proxy: "socks5://localhost:9050",
|
||||||
webhookUrl: "https://example.com/telegram-webhook",
|
webhookUrl: "https://example.com/telegram-webhook",
|
||||||
webhookSecret: "secret",
|
webhookSecret: "secret",
|
||||||
@ -615,7 +614,6 @@ If you configure the same alias name (case-insensitive) yourself, your value win
|
|||||||
verboseDefault: "off",
|
verboseDefault: "off",
|
||||||
elevatedDefault: "on",
|
elevatedDefault: "on",
|
||||||
timeoutSeconds: 600,
|
timeoutSeconds: 600,
|
||||||
mediaMaxMb: 5,
|
|
||||||
heartbeat: {
|
heartbeat: {
|
||||||
every: "30m",
|
every: "30m",
|
||||||
target: "last"
|
target: "last"
|
||||||
|
|||||||
@ -18,7 +18,7 @@ Updated: 2025-12-07
|
|||||||
- **Proxy:** optional `telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
- **Proxy:** optional `telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
||||||
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `telegram.webhookUrl` is set (otherwise it long-polls).
|
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `telegram.webhookUrl` is set (otherwise it long-polls).
|
||||||
- **Sessions:** direct chats map to `main`; groups map to `telegram:group:<chatId>`; replies route back to the same surface.
|
- **Sessions:** direct chats map to `main`; groups map to `telegram:group:<chatId>`; replies route back to the same surface.
|
||||||
- **Config knobs:** `telegram.botToken`, `telegram.groups` (allowlist + mention defaults), `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`.
|
- **Config knobs:** `telegram.botToken`, `telegram.groups` (allowlist + mention defaults), `telegram.allowFrom`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`.
|
||||||
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||||
|
|
||||||
Open questions
|
Open questions
|
||||||
|
|||||||
@ -33,13 +33,14 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup
|
|||||||
- Sees only messages sent after it’s added to a chat; no pre-history access.
|
- Sees only messages sent after it’s added to a chat; no pre-history access.
|
||||||
- Cannot DM users first; they must initiate. Channels are receive-only unless the bot is an admin poster.
|
- Cannot DM users first; they must initiate. Channels are receive-only unless the bot is an admin poster.
|
||||||
- File size caps follow Telegram Bot API (up to 2 GB for documents; smaller for some media types).
|
- File size caps follow Telegram Bot API (up to 2 GB for documents; smaller for some media types).
|
||||||
|
- Inbound media saved to disk is capped at 5MB (hard limit).
|
||||||
- Typing indicators (`sendChatAction`) supported; native replies are **off by default** and enabled via `telegram.replyToMode` + reply tags.
|
- Typing indicators (`sendChatAction`) supported; native replies are **off by default** and enabled via `telegram.replyToMode` + reply tags.
|
||||||
|
|
||||||
## Planned implementation details
|
## Planned implementation details
|
||||||
- Library: grammY is the only client for send + gateway (fetch fallback removed); grammY throttler is enabled by default to stay under Bot API limits.
|
- Library: grammY is the only client for send + gateway (fetch fallback removed); grammY throttler is enabled by default to stay under Bot API limits.
|
||||||
- Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention or a `routing.groupChat.mentionPatterns` match by default (override per chat in config).
|
- Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention or a `routing.groupChat.mentionPatterns` match by default (override per chat in config).
|
||||||
- Outbound: text and media (photo/video/audio/document) with optional caption; chunked to limits. Typing cue sent best-effort.
|
- Outbound: text and media (photo/video/audio/document) with optional caption; chunked to limits. Typing cue sent best-effort.
|
||||||
- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported.
|
- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported.
|
||||||
- Ack reactions are controlled globally via `messages.ackReaction` + `messages.ackReactionScope`.
|
- Ack reactions are controlled globally via `messages.ackReaction` + `messages.ackReactionScope`.
|
||||||
- Mention gating precedence (most specific wins): `telegram.groups.<chatId>.requireMention` → `telegram.groups."*".requireMention` → default `true`.
|
- Mention gating precedence (most specific wins): `telegram.groups.<chatId>.requireMention` → `telegram.groups."*".requireMention` → default `true`.
|
||||||
|
|
||||||
@ -57,7 +58,6 @@ Example config:
|
|||||||
allowFrom: ["123456789"], // direct chat ids allowed (or "*")
|
allowFrom: ["123456789"], // direct chat ids allowed (or "*")
|
||||||
groupPolicy: "allowlist",
|
groupPolicy: "allowlist",
|
||||||
groupAllowFrom: ["tg:123456789", "@alice"],
|
groupAllowFrom: ["tg:123456789", "@alice"],
|
||||||
mediaMaxMb: 5,
|
|
||||||
proxy: "socks5://localhost:9050",
|
proxy: "socks5://localhost:9050",
|
||||||
webhookSecret: "mysecret",
|
webhookSecret: "mysecret",
|
||||||
webhookPath: "/telegram-webhook",
|
webhookPath: "/telegram-webhook",
|
||||||
|
|||||||
@ -247,7 +247,6 @@ export type TelegramConfig = {
|
|||||||
groupPolicy?: GroupPolicy;
|
groupPolicy?: GroupPolicy;
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
mediaMaxMb?: number;
|
|
||||||
proxy?: string;
|
proxy?: string;
|
||||||
webhookUrl?: string;
|
webhookUrl?: string;
|
||||||
webhookSecret?: string;
|
webhookSecret?: string;
|
||||||
|
|||||||
@ -516,7 +516,6 @@ export const ClawdbotSchema = z.object({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
|
||||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||||
heartbeat: HeartbeatSchema,
|
heartbeat: HeartbeatSchema,
|
||||||
maxConcurrent: z.number().int().positive().optional(),
|
maxConcurrent: z.number().int().positive().optional(),
|
||||||
@ -728,7 +727,6 @@ export const ClawdbotSchema = z.object({
|
|||||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
|
||||||
proxy: z.string().optional(),
|
proxy: z.string().optional(),
|
||||||
webhookUrl: z.string().optional(),
|
webhookUrl: z.string().optional(),
|
||||||
webhookSecret: z.string().optional(),
|
webhookSecret: z.string().optional(),
|
||||||
@ -749,7 +747,6 @@ export const ClawdbotSchema = z.object({
|
|||||||
ephemeral: z.boolean().optional(),
|
ephemeral: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
|
||||||
historyLimit: z.number().int().min(0).optional(),
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
actions: z
|
actions: z
|
||||||
.object({
|
.object({
|
||||||
@ -814,7 +811,6 @@ export const ClawdbotSchema = z.object({
|
|||||||
appToken: z.string().optional(),
|
appToken: z.string().optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
|
||||||
reactionNotifications: z
|
reactionNotifications: z
|
||||||
.enum(["off", "own", "all", "allowlist"])
|
.enum(["off", "own", "all", "allowlist"])
|
||||||
.optional(),
|
.optional(),
|
||||||
@ -879,7 +875,6 @@ export const ClawdbotSchema = z.object({
|
|||||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
imessage: z
|
imessage: z
|
||||||
@ -895,7 +890,6 @@ export const ClawdbotSchema = z.object({
|
|||||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
includeAttachments: z.boolean().optional(),
|
includeAttachments: z.boolean().optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
groups: z
|
groups: z
|
||||||
.record(
|
.record(
|
||||||
|
|||||||
@ -52,6 +52,9 @@ describe("media store", () => {
|
|||||||
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
|
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
|
||||||
"Media exceeds 5MB limit",
|
"Media exceeds 5MB limit",
|
||||||
);
|
);
|
||||||
|
await expect(store.saveMediaBuffer(huge)).rejects.toBeInstanceOf(
|
||||||
|
store.MediaTooLargeError,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("copies local files and cleans old media", async () => {
|
it("copies local files and cleans old media", async () => {
|
||||||
|
|||||||
@ -9,6 +9,18 @@ import { detectMime, extensionForMime } from "./mime.js";
|
|||||||
|
|
||||||
const MEDIA_DIR = path.join(CONFIG_DIR, "media");
|
const MEDIA_DIR = path.join(CONFIG_DIR, "media");
|
||||||
const MAX_BYTES = 5 * 1024 * 1024; // 5MB default
|
const MAX_BYTES = 5 * 1024 * 1024; // 5MB default
|
||||||
|
|
||||||
|
export class MediaTooLargeError extends Error {
|
||||||
|
maxBytes: number;
|
||||||
|
|
||||||
|
constructor(maxBytes: number) {
|
||||||
|
const limitMb = (maxBytes / (1024 * 1024)).toFixed(0);
|
||||||
|
super(`Media exceeds ${limitMb}MB limit`);
|
||||||
|
this.name = "MediaTooLargeError";
|
||||||
|
this.maxBytes = maxBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
||||||
|
|
||||||
export function getMediaDir() {
|
export function getMediaDir() {
|
||||||
@ -144,7 +156,7 @@ export async function saveMediaSource(
|
|||||||
throw new Error("Media path is not a file");
|
throw new Error("Media path is not a file");
|
||||||
}
|
}
|
||||||
if (stat.size > MAX_BYTES) {
|
if (stat.size > MAX_BYTES) {
|
||||||
throw new Error("Media exceeds 5MB limit");
|
throw new MediaTooLargeError(MAX_BYTES);
|
||||||
}
|
}
|
||||||
const buffer = await fs.readFile(source);
|
const buffer = await fs.readFile(source);
|
||||||
const mime = await detectMime({ buffer, filePath: source });
|
const mime = await detectMime({ buffer, filePath: source });
|
||||||
@ -162,9 +174,7 @@ export async function saveMediaBuffer(
|
|||||||
maxBytes = MAX_BYTES,
|
maxBytes = MAX_BYTES,
|
||||||
): Promise<SavedMedia> {
|
): Promise<SavedMedia> {
|
||||||
if (buffer.byteLength > maxBytes) {
|
if (buffer.byteLength > maxBytes) {
|
||||||
throw new Error(
|
throw new MediaTooLargeError(maxBytes);
|
||||||
`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const dir = path.join(MEDIA_DIR, subdir);
|
const dir = path.join(MEDIA_DIR, subdir);
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
|||||||
@ -4,15 +4,18 @@ const useSpy = vi.fn();
|
|||||||
const onSpy = vi.fn();
|
const onSpy = vi.fn();
|
||||||
const stopSpy = vi.fn();
|
const stopSpy = vi.fn();
|
||||||
const sendChatActionSpy = vi.fn();
|
const sendChatActionSpy = vi.fn();
|
||||||
|
const sendMessageSpy = vi.fn().mockResolvedValue({});
|
||||||
|
|
||||||
type ApiStub = {
|
type ApiStub = {
|
||||||
config: { use: (arg: unknown) => void };
|
config: { use: (arg: unknown) => void };
|
||||||
sendChatAction: typeof sendChatActionSpy;
|
sendChatAction: typeof sendChatActionSpy;
|
||||||
|
sendMessage: typeof sendMessageSpy;
|
||||||
};
|
};
|
||||||
|
|
||||||
const apiStub: ApiStub = {
|
const apiStub: ApiStub = {
|
||||||
config: { use: useSpy },
|
config: { use: useSpy },
|
||||||
sendChatAction: sendChatActionSpy,
|
sendChatAction: sendChatActionSpy,
|
||||||
|
sendMessage: sendMessageSpy,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("grammy", () => ({
|
vi.mock("grammy", () => ({
|
||||||
@ -31,6 +34,16 @@ vi.mock("@grammyjs/transformer-throttler", () => ({
|
|||||||
apiThrottler: () => throttlerSpy(),
|
apiThrottler: () => throttlerSpy(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const saveMediaBufferSpy = vi.fn();
|
||||||
|
vi.mock("../media/store.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../media/store.js")>();
|
||||||
|
saveMediaBufferSpy.mockImplementation(actual.saveMediaBuffer);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
saveMediaBuffer: saveMediaBufferSpy,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("../config/config.js", async (importOriginal) => {
|
vi.mock("../config/config.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||||
return {
|
return {
|
||||||
@ -108,6 +121,71 @@ describe("telegram inbound media", () => {
|
|||||||
fetchSpy.mockRestore();
|
fetchSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("notifies when media exceeds size limit", async () => {
|
||||||
|
const { createTelegramBot } = await import("./bot.js");
|
||||||
|
const replyModule = await import("../auto-reply/reply.js");
|
||||||
|
const storeModule = await import("../media/store.js");
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
|
||||||
|
onSpy.mockReset();
|
||||||
|
replySpy.mockReset();
|
||||||
|
sendChatActionSpy.mockReset();
|
||||||
|
sendMessageSpy.mockClear();
|
||||||
|
saveMediaBufferSpy.mockClear();
|
||||||
|
|
||||||
|
saveMediaBufferSpy.mockRejectedValueOnce(
|
||||||
|
new storeModule.MediaTooLargeError(5 * 1024 * 1024),
|
||||||
|
);
|
||||||
|
|
||||||
|
const runtimeError = vi.fn();
|
||||||
|
createTelegramBot({
|
||||||
|
token: "tok",
|
||||||
|
runtime: {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: runtimeError,
|
||||||
|
exit: () => {
|
||||||
|
throw new Error("exit");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const handler = onSpy.mock.calls[0]?.[1] as (
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, "fetch" as never)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
headers: { get: () => "image/jpeg" },
|
||||||
|
arrayBuffer: async () =>
|
||||||
|
new Uint8Array([0xff, 0xd8, 0xff, 0x00]).buffer,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
message: {
|
||||||
|
message_id: 4,
|
||||||
|
chat: { id: 1234, type: "private" },
|
||||||
|
photo: [{ file_id: "fid" }],
|
||||||
|
},
|
||||||
|
me: { username: "clawdbot_bot" },
|
||||||
|
getFile: async () => ({ file_path: "photos/too-big.jpg" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMessageSpy).toHaveBeenCalledWith(
|
||||||
|
1234,
|
||||||
|
"⚠️ File too large. Maximum size is 5MB.",
|
||||||
|
{ reply_to_message_id: 4 },
|
||||||
|
);
|
||||||
|
expect(replySpy).not.toHaveBeenCalled();
|
||||||
|
expect(runtimeError).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it("prefers proxyFetch over global fetch", async () => {
|
it("prefers proxyFetch over global fetch", async () => {
|
||||||
const { createTelegramBot } = await import("./bot.js");
|
const { createTelegramBot } = await import("./bot.js");
|
||||||
|
|
||||||
|
|||||||
@ -27,7 +27,7 @@ import { formatErrorMessage } from "../infra/errors.js";
|
|||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { mediaKindFromMime } from "../media/constants.js";
|
import { mediaKindFromMime } from "../media/constants.js";
|
||||||
import { detectMime, isGifMedia } from "../media/mime.js";
|
import { detectMime, isGifMedia } from "../media/mime.js";
|
||||||
import { saveMediaBuffer } from "../media/store.js";
|
import { MediaTooLargeError, saveMediaBuffer } from "../media/store.js";
|
||||||
import {
|
import {
|
||||||
formatLocationText,
|
formatLocationText,
|
||||||
type NormalizedLocation,
|
type NormalizedLocation,
|
||||||
@ -43,6 +43,8 @@ const PARSE_ERR_RE =
|
|||||||
// with a shared media_group_id. We buffer them and process as a single message after a short delay.
|
// with a shared media_group_id. We buffer them and process as a single message after a short delay.
|
||||||
const MEDIA_GROUP_TIMEOUT_MS = 500;
|
const MEDIA_GROUP_TIMEOUT_MS = 500;
|
||||||
|
|
||||||
|
const TELEGRAM_MEDIA_MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
type TelegramMessage = Message.CommonMessage;
|
type TelegramMessage = Message.CommonMessage;
|
||||||
|
|
||||||
type MediaGroupEntry = {
|
type MediaGroupEntry = {
|
||||||
@ -87,7 +89,6 @@ export type TelegramBotOptions = {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
allowFrom?: Array<string | number>;
|
allowFrom?: Array<string | number>;
|
||||||
groupAllowFrom?: Array<string | number>;
|
groupAllowFrom?: Array<string | number>;
|
||||||
mediaMaxMb?: number;
|
|
||||||
replyToMode?: ReplyToMode;
|
replyToMode?: ReplyToMode;
|
||||||
proxyFetch?: typeof fetch;
|
proxyFetch?: typeof fetch;
|
||||||
};
|
};
|
||||||
@ -155,8 +156,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off";
|
const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off";
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes = TELEGRAM_MEDIA_MAX_BYTES;
|
||||||
(opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024;
|
|
||||||
const logger = getChildLogger({ module: "telegram-auto-reply" });
|
const logger = getChildLogger({ module: "telegram-auto-reply" });
|
||||||
const mentionRegexes = buildMentionRegexes(cfg);
|
const mentionRegexes = buildMentionRegexes(cfg);
|
||||||
const resolveGroupPolicy = (chatId: string | number) =>
|
const resolveGroupPolicy = (chatId: string | number) =>
|
||||||
@ -174,6 +174,22 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
overrideOrder: "after-config",
|
overrideOrder: "after-config",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const notifyMediaTooLarge = async (
|
||||||
|
chatId: string | number,
|
||||||
|
messageId: number | undefined,
|
||||||
|
err: MediaTooLargeError,
|
||||||
|
) => {
|
||||||
|
const limitMb = Math.max(1, Math.floor(err.maxBytes / (1024 * 1024)));
|
||||||
|
await bot.api
|
||||||
|
.sendMessage(
|
||||||
|
chatId,
|
||||||
|
`⚠️ File too large. Maximum size is ${limitMb}MB.`,
|
||||||
|
messageId ? { reply_to_message_id: messageId } : undefined,
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
logger.warn({ chatId, error: err.message }, "media exceeds size limit");
|
||||||
|
};
|
||||||
|
|
||||||
const processMessage = async (
|
const processMessage = async (
|
||||||
primaryCtx: TelegramContext,
|
primaryCtx: TelegramContext,
|
||||||
allMedia: Array<{ path: string; contentType?: string }>,
|
allMedia: Array<{ path: string; contentType?: string }>,
|
||||||
@ -492,17 +508,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
opts.proxyFetch,
|
opts.proxyFetch,
|
||||||
);
|
);
|
||||||
} catch (mediaErr) {
|
} catch (mediaErr) {
|
||||||
const errMsg = String(mediaErr);
|
if (mediaErr instanceof MediaTooLargeError) {
|
||||||
if (errMsg.includes("exceeds") && errMsg.includes("MB limit")) {
|
await notifyMediaTooLarge(chatId, msg.message_id, mediaErr);
|
||||||
const limitMb = Math.round(mediaMaxBytes / (1024 * 1024));
|
|
||||||
await bot.api
|
|
||||||
.sendMessage(
|
|
||||||
chatId,
|
|
||||||
`⚠️ File too large. Maximum size is ${limitMb}MB.`,
|
|
||||||
{ reply_to_message_id: msg.message_id },
|
|
||||||
)
|
|
||||||
.catch(() => {});
|
|
||||||
logger.warn({ chatId, error: errMsg }, "media exceeds size limit");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw mediaErr;
|
throw mediaErr;
|
||||||
@ -527,12 +534,25 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
|
|
||||||
const allMedia: Array<{ path: string; contentType?: string }> = [];
|
const allMedia: Array<{ path: string; contentType?: string }> = [];
|
||||||
for (const { ctx } of entry.messages) {
|
for (const { ctx } of entry.messages) {
|
||||||
const media = await resolveMedia(
|
let media: Awaited<ReturnType<typeof resolveMedia>> = null;
|
||||||
ctx,
|
try {
|
||||||
mediaMaxBytes,
|
media = await resolveMedia(
|
||||||
opts.token,
|
ctx,
|
||||||
opts.proxyFetch,
|
mediaMaxBytes,
|
||||||
);
|
opts.token,
|
||||||
|
opts.proxyFetch,
|
||||||
|
);
|
||||||
|
} catch (mediaErr) {
|
||||||
|
if (mediaErr instanceof MediaTooLargeError) {
|
||||||
|
await notifyMediaTooLarge(
|
||||||
|
ctx.message.chat.id,
|
||||||
|
ctx.message.message_id,
|
||||||
|
mediaErr,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw mediaErr;
|
||||||
|
}
|
||||||
if (media) {
|
if (media) {
|
||||||
allMedia.push({ path: media.path, contentType: media.contentType });
|
allMedia.push({ path: media.path, contentType: media.contentType });
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user