From 3b50c1c3f107c7cba177a0b1be59a7d8e6e0d163 Mon Sep 17 00:00:00 2001 From: vinceferro Date: Mon, 24 Aug 2026 08:12:41 +0000 Subject: [PATCH] fix(telegram): accept image files delivered as application/octet-stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS 'Send as File' — the highest-quality path for photos — delivers images with mime_type=application/octet-stream and no preview; only the filename extension reveals the type. The document handler rejected these as unsupported documents, so iPhone users could not send images as files at all. Route by extension fallback when the mime is not image/*: detect .jpg/ .jpeg/.png/.webp/.gif, normalize the file part's mime (and data URI prefix) accordingly, and reuse the existing image capability check. Verified end-to-end against a live opencode serve: octet-stream jpg now reaches the model as image/jpeg and gets described correctly. Co-authored-by: opencode x-preview-f-free --- src/bot/handlers/document-handler.ts | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/bot/handlers/document-handler.ts b/src/bot/handlers/document-handler.ts index 0765cbe77..1ea4c9e22 100644 --- a/src/bot/handlers/document-handler.ts +++ b/src/bot/handlers/document-handler.ts @@ -130,6 +130,44 @@ export async function handleDocumentMessage( "text/rtf", ]; + // Image files: route to the model as image parts. Two delivery shapes exist — + // Telegram sets mime_type="image/jpeg" for some clients, but iOS "Send as File" + // (the highest-quality path) delivers application/octet-stream with only the + // filename extension to go on. Trust the extension as the fallback signal. + const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".gif"]; + const looksLikeImage = + (typeof mimeType === "string" && mimeType.startsWith("image/")) || + IMAGE_EXTENSIONS.some((ext) => filename.toLowerCase().endsWith(ext)); + + if (looksLikeImage) { + const storedModel = getStored(); + const capabilities = await getCapabilities(storedModel.providerID, storedModel.modelID); + if (!supportsInput(capabilities, "image")) { + logger.warn( + `[Document] Model doesn't support image input (${storedModel.providerID}/${storedModel.modelID})`, + ); + await ctx.reply(t("bot.photo_model_no_image")); + if (caption.trim().length > 0) { + await processPrompt(ctx, caption, deps); + } + return; + } + await ctx.reply(t("bot.file_downloading")); + const downloadedFile = await downloadFile(ctx.api, doc.file_id); + const effectiveMime = mimeType.startsWith("image/") ? mimeType : "image/jpeg"; + const filePart = { + type: "file" as const, + mime: effectiveMime, + filename: filename, + url: toDataUri(downloadedFile.buffer, effectiveMime), + }; + logger.info( + `[Document] Sending image document (${downloadedFile.buffer.length} bytes, ${filename}, ${mimeType})`, + ); + await processPrompt(ctx, caption, deps, [filePart]); + return; + } + if (DOCUMENT_MIME_TYPES.includes(mimeType)) { const storedModel = getStored(); const capabilities = await getCapabilities(storedModel.providerID, storedModel.modelID);