From 74e2cd15519f0aed407c86bf8814591c490e4d67 Mon Sep 17 00:00:00 2001 From: Zero Date: Fri, 10 Jul 2026 10:32:13 -0400 Subject: [PATCH 1/2] feat: add DOC_EXTRACTOR_URL for document text extraction --- .env.example | 8 +++ .../services/document-extractor-service.ts | 69 +++++++++++++++++++ src/bot/handlers/document-handler.ts | 38 ++++++++-- src/config.ts | 4 ++ src/i18n/ar.ts | 1 + src/i18n/de.ts | 1 + src/i18n/en.ts | 1 + src/i18n/es.ts | 1 + src/i18n/fr.ts | 2 + src/i18n/ru.ts | 1 + src/i18n/zh.ts | 1 + 11 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 src/app/services/document-extractor-service.ts diff --git a/.env.example b/.env.example index b3916907..70c2ff40 100644 --- a/.env.example +++ b/.env.example @@ -123,6 +123,14 @@ OPENCODE_MODEL_ID=big-pickle # If STT_API_URL is not set, voice messages will get a "not configured" reply. # STT_API_URL= # STT_API_KEY= + +# Document Extractor API (optional) +# If set, the bot will extract text from PDF files using an external API +# when the current model doesn't natively support PDF input. +# The API should accept multipart/form-data POST with a "file" field and +# return JSON with a "text" field containing the extracted content. +# DOC_EXTRACTOR_URL= +# DOC_EXTRACTOR_API_KEY= # STT_MODEL= # STT_LANGUAGE= # STT_NOTE_PROMPT="The following text is transcribed from voice. It may contain homophone or phonetic errors. Infer the intended meaning from context." diff --git a/src/app/services/document-extractor-service.ts b/src/app/services/document-extractor-service.ts new file mode 100644 index 00000000..8f16bc3a --- /dev/null +++ b/src/app/services/document-extractor-service.ts @@ -0,0 +1,69 @@ +import { config } from "../../config.js"; +import { logger } from "../../utils/logger.js"; + +const REQUEST_TIMEOUT_MS = 60_000; + +export interface DocExtractorResult { + text: string; +} + +export function isDocExtractorConfigured(): boolean { + return Boolean(config.docExtractor.apiUrl && config.docExtractor.apiKey); +} + +export async function extractDocument( + fileBuffer: Buffer, + mimeType: string, + filename: string, +): Promise { + if (!isDocExtractorConfigured()) { + throw new Error( + "Document extractor is not configured: DOC_EXTRACTOR_URL and DOC_EXTRACTOR_API_KEY are required", + ); + } + + const url = config.docExtractor.apiUrl!; + + const formData = new FormData(); + formData.append("file", new Blob([new Uint8Array(fileBuffer)]), filename); + + logger.debug( + `[DocExtractor] Sending extraction request: url=${url}, mime=${mimeType}, filename=${filename}, size=${fileBuffer.length} bytes`, + ); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${config.docExtractor.apiKey}`, + }, + body: formData, + signal: controller.signal, + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + throw new Error( + `Document extractor API returned HTTP ${response.status}: ${errorBody || response.statusText}`, + ); + } + + const data = (await response.json()) as { text?: string }; + + if (typeof data.text !== "string") { + throw new Error("Document extractor API response does not contain a text field"); + } + + return { text: data.text }; + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") { + throw new Error(`Document extractor request timed out after ${REQUEST_TIMEOUT_MS}ms`); + } + throw err; + } finally { + clearTimeout(timeout); + } +} diff --git a/src/bot/handlers/document-handler.ts b/src/bot/handlers/document-handler.ts index e53882df..91df0055 100644 --- a/src/bot/handlers/document-handler.ts +++ b/src/bot/handlers/document-handler.ts @@ -7,6 +7,7 @@ import { isTextMimeType, isFileSizeAllowed, } from "../../app/services/file-download-service.js"; +import { isDocExtractorConfigured, extractDocument } from "../../app/services/document-extractor-service.js"; import { getModelCapabilities, supportsInput } from "../../app/services/model-capabilities-service.js"; import { getStoredModel } from "../../app/services/model-selection-service.js"; import { logger } from "../../utils/logger.js"; @@ -117,13 +118,36 @@ export async function handleDocumentMessage( const capabilities = await getCapabilities(storedModel.providerID, storedModel.modelID); if (!supportsInput(capabilities, "pdf")) { - logger.warn( - `[Document] Model ${storedModel.providerID}/${storedModel.modelID} doesn't support PDF input`, - ); - await ctx.reply(t("bot.model_no_pdf")); - - if (caption.trim().length > 0) { - await processPrompt(ctx, caption, deps); + if (isDocExtractorConfigured()) { + logger.warn( + `[Document] Model doesn't support PDF input, delegating to DOC_EXTRACTOR_URL`, + ); + await ctx.reply(t("bot.file_downloading")); + const downloadedFile = await downloadFile(ctx.api, doc.file_id); + + try { + const result = await extractDocument(downloadedFile.buffer, mimeType, filename); + const promptWithFile = `--- Content of ${filename} ---\n${result.text}\n--- End of file ---\n\n${caption}`; + logger.info( + `[Document] Sending extracted PDF text (${result.text.length} chars) as prompt`, + ); + await processPrompt(ctx, promptWithFile, deps); + } catch (extractErr) { + const errMsg = extractErr instanceof Error ? extractErr.message : String(extractErr); + logger.error(`[Document] PDF extraction failed: ${errMsg}`); + await ctx.reply(t("bot.document_extraction_error")); + if (caption.trim().length > 0) { + await processPrompt(ctx, caption, deps); + } + } + } else { + logger.warn( + `[Document] Model doesn't support PDF input and DOC_EXTRACTOR_URL is not configured`, + ); + await ctx.reply(t("bot.model_no_pdf")); + if (caption.trim().length > 0) { + await processPrompt(ctx, caption, deps); + } } return; } diff --git a/src/config.ts b/src/config.ts index 12051fc6..b252eaff 100644 --- a/src/config.ts +++ b/src/config.ts @@ -181,6 +181,10 @@ export const config = { language: getEnvVar("STT_LANGUAGE", false), notePrompt: getEnvVar("STT_NOTE_PROMPT", false), }, + docExtractor: { + apiUrl: getEnvVar("DOC_EXTRACTOR_URL", false), + apiKey: getEnvVar("DOC_EXTRACTOR_API_KEY", false), + }, tts: (() => { const provider = getOptionalTtsProviderEnvVar("TTS_PROVIDER", "openai"); const defaultVoice = diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index ae57c583..96e3abf9 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -111,6 +111,7 @@ export const ar: I18nDictionary = { "⚠️ تعذر معالجة ملف أو أكثر في هذه المجموعة. لم يتم إرسال أي ملف إلى OpenCode.", "bot.media_group_download_error": "🔴 تعذر تنزيل أحد الملفات. لم يتم إرسال أي ملف إلى OpenCode.", "bot.model_no_pdf": "⚠️ النموذج الحالي لا يدعم ملفات PDF. سيتم إرسال النص فقط.", + "bot.document_extraction_error": "🔴 فشل استخراج نص المستند: {error}", "bot.text_file_too_large": "⚠️ حجم الملف النصي أكبر من الحد المسموح ({maxSizeKb}KB)", "status.header_running": "🟢 خادم OpenCode يعمل", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 1c6c3648..a780901b 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -116,6 +116,7 @@ export const de: I18nDictionary = { "bot.media_group_download_error": "🔴 Eine der Dateien konnte nicht heruntergeladen werden. Es wurde nichts an OpenCode gesendet.", "bot.model_no_pdf": "⚠️ Das aktuelle Modell unterstützt keine PDF-Eingabe. Sende nur Text.", + "bot.document_extraction_error": "🔴 Dokumenttext konnte nicht extrahiert werden: {error}", "bot.text_file_too_large": "⚠️ Textdatei ist zu groß (max. {maxSizeKb}KB)", "status.header_running": "🟢 OpenCode-Server läuft", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index a9d2065c..a3a8d3f9 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -109,6 +109,7 @@ export const en = { "bot.media_group_download_error": "🔴 Failed to download one of the files. Nothing was sent to OpenCode.", "bot.model_no_pdf": "⚠️ Current model doesn't support PDF input. Sending text only.", + "bot.document_extraction_error": "🔴 Failed to extract document text: {error}", "bot.text_file_too_large": "⚠️ Text file is too large (max {maxSizeKb}KB)", "status.header_running": "🟢 OpenCode Server is running", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 83e4fd7e..971a3b9c 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -117,6 +117,7 @@ export const es: I18nDictionary = { "bot.media_group_download_error": "🔴 No se pudo descargar uno de los archivos. No se envió nada a OpenCode.", "bot.model_no_pdf": "⚠️ El modelo actual no admite entrada PDF. Enviaré solo texto.", + "bot.document_extraction_error": "🔴 No se pudo extraer el texto del documento: {error}", "bot.text_file_too_large": "⚠️ El archivo de texto es demasiado grande (max {maxSizeKb}KB)", "status.header_running": "🟢 OpenCode Server está en ejecución", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 6ab293fc..117f179b 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -118,6 +118,8 @@ export const fr: I18nDictionary = { "🔴 Impossible de télécharger l'un des fichiers. Rien n'a été envoyé à OpenCode.", "bot.model_no_pdf": "⚠️ Le modèle actuel ne prend pas en charge les PDF. Envoi du texte uniquement.", + "bot.document_extraction_error": + "🔴 Échec de l'extraction du texte du document : {error}", "bot.text_file_too_large": "⚠️ Le fichier texte est trop volumineux (max {maxSizeKb}KB)", "status.header_running": "🟢 Le serveur OpenCode est en cours d'exécution", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 9fb61339..b0288e4e 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -109,6 +109,7 @@ export const ru: I18nDictionary = { "bot.media_group_download_error": "🔴 Не удалось скачать один из файлов. В OpenCode ничего не отправлено.", "bot.model_no_pdf": "⚠️ Текущая модель не поддерживает PDF. Отправляю только текст.", + "bot.document_extraction_error": "🔴 Не удалось извлечь текст из документа: {error}", "bot.text_file_too_large": "⚠️ Текстовый файл слишком большой (макс. {maxSizeKb}КБ)", "status.header_running": "🟢 OpenCode Server запущен", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 71866814..9a81d8da 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -98,6 +98,7 @@ export const zh: I18nDictionary = { "⚠️ 此相册中有一个或多个文件无法处理。未向 OpenCode 发送任何内容。", "bot.media_group_download_error": "🔴 无法下载其中一个文件。未向 OpenCode 发送任何内容。", "bot.model_no_pdf": "⚠️ 当前模型不支持PDF输入。将仅发送文本。", + "bot.document_extraction_error": "🔴 文档文本提取失败:{error}", "bot.text_file_too_large": "⚠️ 文本文件过大(最大 {maxSizeKb}KB)", "status.header_running": "🟢 OpenCode 服务器正在运行", From 30eb7b92a1bf38cdeac08e21ba4d8ffb0d49c53f Mon Sep 17 00:00:00 2001 From: Zero Date: Sat, 11 Jul 2026 19:22:57 -0400 Subject: [PATCH 2/2] fix: address PR #184 review comments - Remove {error} placeholder from locale strings (avoids leaking API response body to chat) - Make DOC_EXTRACTOR_API_KEY optional; only send Authorization header when key is set - Pass mimeType to Blob constructor so the extractor receives correct Content-Type - Expand document MIME type support beyond application/pdf (DOCX, PPTX, XLSX, ODF, RTF) - Add DOC_EXTRACTOR_URL and DOC_EXTRACTOR_API_KEY to README env table - Add Document Text Extraction feature section to README - Add tests/app/services/document-extractor-service.test.ts (10 tests) - Add document extraction coverage to tests/bot/handlers/document.test.ts (5 tests) - Update .env.example and all 7 locale files to reflect broader document support --- .env.example | 5 +- README.md | 16 ++ .../services/document-extractor-service.ts | 15 +- src/bot/handlers/document-handler.ts | 24 ++- src/i18n/ar.ts | 4 +- src/i18n/de.ts | 4 +- src/i18n/en.ts | 4 +- src/i18n/es.ts | 4 +- src/i18n/fr.ts | 4 +- src/i18n/ru.ts | 4 +- src/i18n/zh.ts | 5 +- .../document-extractor-service.test.ts | 176 ++++++++++++++++++ tests/bot/handlers/document.test.ts | 157 ++++++++++++++++ 13 files changed, 395 insertions(+), 27 deletions(-) create mode 100644 tests/app/services/document-extractor-service.test.ts diff --git a/.env.example b/.env.example index 70c2ff40..fbe12102 100644 --- a/.env.example +++ b/.env.example @@ -125,10 +125,11 @@ OPENCODE_MODEL_ID=big-pickle # STT_API_KEY= # Document Extractor API (optional) -# If set, the bot will extract text from PDF files using an external API -# when the current model doesn't natively support PDF input. +# If set, the bot will extract text from PDF, DOCX, PPTX, and other documents +# using an external API when the current model doesn't natively support them. # The API should accept multipart/form-data POST with a "file" field and # return JSON with a "text" field containing the extracted content. +# Only DOC_EXTRACTOR_URL is required; API key is optional for self-hosted setups. # DOC_EXTRACTOR_URL= # DOC_EXTRACTOR_API_KEY= # STT_MODEL= diff --git a/README.md b/README.md index 3edd6ee7..f8e04b98 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,8 @@ When installed via npm, the configuration wizard handles the initial setup. The | `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` | | `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — | | `STT_NOTE_PROMPT` | Optional note prepended to the LLM prompt as `[Note: ...]` for voice transcriptions; empty / `false` / `0` disable it | No | — | +| `DOC_EXTRACTOR_URL` | Document text extraction API URL (enables PDF/DOCX/PPTX extraction) | No | — | +| `DOC_EXTRACTOR_API_KEY` | API key for the document extractor (optional for self-hosted extractors) | No | — | | `TTS_PROVIDER` | TTS provider: `openai` for OpenAI-compatible APIs, `elevenlabs` for ElevenLabs, or `google` for Google Cloud TTS | No | `openai` | | `TTS_API_URL` | TTS API base URL for OpenAI-compatible APIs or ElevenLabs | No | — | | `TTS_API_KEY` | TTS API key for OpenAI-compatible APIs or ElevenLabs | No | — | @@ -365,6 +367,20 @@ Supported provider examples (Whisper-compatible): If STT variables are not set, voice/audio transcription is disabled and the bot will ask you to configure STT. +### Document Text Extraction (Optional) + +If `DOC_EXTRACTOR_URL` is set, the bot will extract text from PDF, DOCX, PPTX, and other document files using an external API when the current model does not natively support document input. + +The API contract is: + +- **Endpoint:** `POST {DOC_EXTRACTOR_URL}` +- **Content-Type:** `multipart/form-data` +- **Field:** `file` — the document binary +- **Authorization:** `Bearer {DOC_EXTRACTOR_API_KEY}` (only sent when a key is configured) +- **Response:** JSON `{ "text": "extracted content..." }` + +If the extractor is not configured and the model doesn't support documents, the bot replies with a notice and forwards only the caption text. + ### Model Configuration The model picker uses OpenCode local model state (`favorite` + `recent`): diff --git a/src/app/services/document-extractor-service.ts b/src/app/services/document-extractor-service.ts index 8f16bc3a..02fbb8af 100644 --- a/src/app/services/document-extractor-service.ts +++ b/src/app/services/document-extractor-service.ts @@ -8,7 +8,7 @@ export interface DocExtractorResult { } export function isDocExtractorConfigured(): boolean { - return Boolean(config.docExtractor.apiUrl && config.docExtractor.apiKey); + return Boolean(config.docExtractor.apiUrl); } export async function extractDocument( @@ -18,14 +18,14 @@ export async function extractDocument( ): Promise { if (!isDocExtractorConfigured()) { throw new Error( - "Document extractor is not configured: DOC_EXTRACTOR_URL and DOC_EXTRACTOR_API_KEY are required", + "Document extractor is not configured: DOC_EXTRACTOR_URL is required", ); } const url = config.docExtractor.apiUrl!; const formData = new FormData(); - formData.append("file", new Blob([new Uint8Array(fileBuffer)]), filename); + formData.append("file", new Blob([new Uint8Array(fileBuffer)], { type: mimeType }), filename); logger.debug( `[DocExtractor] Sending extraction request: url=${url}, mime=${mimeType}, filename=${filename}, size=${fileBuffer.length} bytes`, @@ -35,11 +35,14 @@ export async function extractDocument( const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { + const headers: Record = {}; + if (config.docExtractor.apiKey) { + headers["Authorization"] = `Bearer ${config.docExtractor.apiKey}`; + } + const response = await fetch(url, { method: "POST", - headers: { - Authorization: `Bearer ${config.docExtractor.apiKey}`, - }, + headers, body: formData, signal: controller.signal, }); diff --git a/src/bot/handlers/document-handler.ts b/src/bot/handlers/document-handler.ts index 91df0055..682f3b3c 100644 --- a/src/bot/handlers/document-handler.ts +++ b/src/bot/handlers/document-handler.ts @@ -113,14 +113,28 @@ export async function handleDocumentMessage( return; } - if (mimeType === "application/pdf") { + const DOCUMENT_MIME_TYPES = [ + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.oasis.opendocument.spreadsheet", + "text/rtf", + ]; + + if (DOCUMENT_MIME_TYPES.includes(mimeType)) { const storedModel = getStored(); const capabilities = await getCapabilities(storedModel.providerID, storedModel.modelID); if (!supportsInput(capabilities, "pdf")) { if (isDocExtractorConfigured()) { logger.warn( - `[Document] Model doesn't support PDF input, delegating to DOC_EXTRACTOR_URL`, + `[Document] Model doesn't support PDF input, delegating document to DOC_EXTRACTOR_URL`, ); await ctx.reply(t("bot.file_downloading")); const downloadedFile = await downloadFile(ctx.api, doc.file_id); @@ -129,12 +143,12 @@ export async function handleDocumentMessage( const result = await extractDocument(downloadedFile.buffer, mimeType, filename); const promptWithFile = `--- Content of ${filename} ---\n${result.text}\n--- End of file ---\n\n${caption}`; logger.info( - `[Document] Sending extracted PDF text (${result.text.length} chars) as prompt`, + `[Document] Sending extracted document text from ${filename} (${result.text.length} chars) as prompt`, ); await processPrompt(ctx, promptWithFile, deps); } catch (extractErr) { const errMsg = extractErr instanceof Error ? extractErr.message : String(extractErr); - logger.error(`[Document] PDF extraction failed: ${errMsg}`); + logger.error(`[Document] Document extraction failed: ${errMsg}`); await ctx.reply(t("bot.document_extraction_error")); if (caption.trim().length > 0) { await processPrompt(ctx, caption, deps); @@ -165,7 +179,7 @@ export async function handleDocumentMessage( }; logger.info( - `[Document] Sending PDF (${downloadedFile.buffer.length} bytes, ${filename}) with prompt`, + `[Document] Sending document (${downloadedFile.buffer.length} bytes, ${filename}, ${mimeType}) with prompt`, ); await processPrompt(ctx, caption, deps, [filePart]); diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index 96e3abf9..f305ca88 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -106,12 +106,12 @@ export const ar: I18nDictionary = { "bot.file_too_large": "⚠️ حجم الملف أكبر من الحد المسموح ({maxSizeMb}MB)", "bot.file_download_error": "🔴 تعذر تنزيل الملف", "bot.file_type_unsupported": - "⚠️ نوع الملف غير مدعوم. أرسل صورة أو ملف PDF أو ملفًا نصيًا أو برمجيًا.", + "⚠️ نوع الملف غير مدعوم. أرسل صورة أو مستندًا (PDF، DOCX، PPTX) أو ملفًا نصيًا أو برمجيًا.", "bot.media_group_not_processed": "⚠️ تعذر معالجة ملف أو أكثر في هذه المجموعة. لم يتم إرسال أي ملف إلى OpenCode.", "bot.media_group_download_error": "🔴 تعذر تنزيل أحد الملفات. لم يتم إرسال أي ملف إلى OpenCode.", "bot.model_no_pdf": "⚠️ النموذج الحالي لا يدعم ملفات PDF. سيتم إرسال النص فقط.", - "bot.document_extraction_error": "🔴 فشل استخراج نص المستند: {error}", + "bot.document_extraction_error": "🔴 فشل استخراج نص المستند.", "bot.text_file_too_large": "⚠️ حجم الملف النصي أكبر من الحد المسموح ({maxSizeKb}KB)", "status.header_running": "🟢 خادم OpenCode يعمل", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index a780901b..57c5dcea 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -110,13 +110,13 @@ export const de: I18nDictionary = { "bot.file_too_large": "⚠️ Datei ist zu groß (max. {maxSizeMb}MB)", "bot.file_download_error": "🔴 Datei konnte nicht heruntergeladen werden", "bot.file_type_unsupported": - "⚠️ Dieser Dateityp wird nicht unterstützt. Sende ein Bild, PDF oder eine Text-/Code-Datei.", + "⚠️ Dieser Dateityp wird nicht unterstützt. Sende ein Bild, Dokument (PDF, DOCX, PPTX) oder eine Text-/Code-Datei.", "bot.media_group_not_processed": "⚠️ Eine oder mehrere Dateien in diesem Album können nicht verarbeitet werden. Es wurde nichts an OpenCode gesendet.", "bot.media_group_download_error": "🔴 Eine der Dateien konnte nicht heruntergeladen werden. Es wurde nichts an OpenCode gesendet.", "bot.model_no_pdf": "⚠️ Das aktuelle Modell unterstützt keine PDF-Eingabe. Sende nur Text.", - "bot.document_extraction_error": "🔴 Dokumenttext konnte nicht extrahiert werden: {error}", + "bot.document_extraction_error": "🔴 Dokumenttext konnte nicht extrahiert werden.", "bot.text_file_too_large": "⚠️ Textdatei ist zu groß (max. {maxSizeKb}KB)", "status.header_running": "🟢 OpenCode-Server läuft", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index a3a8d3f9..61ce5f8a 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -103,13 +103,13 @@ export const en = { "bot.file_too_large": "⚠️ File is too large (max {maxSizeMb}MB)", "bot.file_download_error": "🔴 Failed to download file", "bot.file_type_unsupported": - "⚠️ This file type is not supported. Send an image, PDF, or text/code file.", + "⚠️ This file type is not supported. Send an image, document (PDF, DOCX, PPTX), or text/code file.", "bot.media_group_not_processed": "⚠️ One or more files in this album cannot be processed. Nothing was sent to OpenCode.", "bot.media_group_download_error": "🔴 Failed to download one of the files. Nothing was sent to OpenCode.", "bot.model_no_pdf": "⚠️ Current model doesn't support PDF input. Sending text only.", - "bot.document_extraction_error": "🔴 Failed to extract document text: {error}", + "bot.document_extraction_error": "🔴 Failed to extract document text.", "bot.text_file_too_large": "⚠️ Text file is too large (max {maxSizeKb}KB)", "status.header_running": "🟢 OpenCode Server is running", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 971a3b9c..551920a6 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -111,13 +111,13 @@ export const es: I18nDictionary = { "bot.file_too_large": "⚠️ El archivo es demasiado grande (max {maxSizeMb}MB)", "bot.file_download_error": "🔴 No se pudo descargar el archivo", "bot.file_type_unsupported": - "⚠️ Este tipo de archivo no es compatible. Envía una imagen, PDF o archivo de texto/código.", + "⚠️ Este tipo de archivo no es compatible. Envía una imagen, documento (PDF, DOCX, PPTX) o archivo de texto/código.", "bot.media_group_not_processed": "⚠️ Uno o más archivos de este álbum no se pueden procesar. No se envió nada a OpenCode.", "bot.media_group_download_error": "🔴 No se pudo descargar uno de los archivos. No se envió nada a OpenCode.", "bot.model_no_pdf": "⚠️ El modelo actual no admite entrada PDF. Enviaré solo texto.", - "bot.document_extraction_error": "🔴 No se pudo extraer el texto del documento: {error}", + "bot.document_extraction_error": "🔴 No se pudo extraer el texto del documento.", "bot.text_file_too_large": "⚠️ El archivo de texto es demasiado grande (max {maxSizeKb}KB)", "status.header_running": "🟢 OpenCode Server está en ejecución", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 117f179b..b74cdf8c 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -111,7 +111,7 @@ export const fr: I18nDictionary = { "bot.file_too_large": "⚠️ Le fichier est trop volumineux (max {maxSizeMb}MB)", "bot.file_download_error": "🔴 Impossible de télécharger le fichier", "bot.file_type_unsupported": - "⚠️ Ce type de fichier n'est pas pris en charge. Envoyez une image, un PDF ou un fichier texte/code.", + "⚠️ Ce type de fichier n'est pas pris en charge. Envoyez une image, un document (PDF, DOCX, PPTX) ou un fichier texte/code.", "bot.media_group_not_processed": "⚠️ Un ou plusieurs fichiers de cet album ne peuvent pas être traités. Rien n'a été envoyé à OpenCode.", "bot.media_group_download_error": @@ -119,7 +119,7 @@ export const fr: I18nDictionary = { "bot.model_no_pdf": "⚠️ Le modèle actuel ne prend pas en charge les PDF. Envoi du texte uniquement.", "bot.document_extraction_error": - "🔴 Échec de l'extraction du texte du document : {error}", + "🔴 Échec de l'extraction du texte du document.", "bot.text_file_too_large": "⚠️ Le fichier texte est trop volumineux (max {maxSizeKb}KB)", "status.header_running": "🟢 Le serveur OpenCode est en cours d'exécution", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index b0288e4e..fb37b6de 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -103,13 +103,13 @@ export const ru: I18nDictionary = { "bot.file_too_large": "⚠️ Файл слишком большой (макс. {maxSizeMb}МБ)", "bot.file_download_error": "🔴 Не удалось скачать файл", "bot.file_type_unsupported": - "⚠️ Этот тип файла не поддерживается. Отправьте изображение, PDF или текстовый/кодовый файл.", + "⚠️ Этот тип файла не поддерживается. Отправьте изображение, документ (PDF, DOCX, PPTX) или текстовый/кодовый файл.", "bot.media_group_not_processed": "⚠️ Один или несколько файлов в альбоме нельзя обработать. В OpenCode ничего не отправлено.", "bot.media_group_download_error": "🔴 Не удалось скачать один из файлов. В OpenCode ничего не отправлено.", "bot.model_no_pdf": "⚠️ Текущая модель не поддерживает PDF. Отправляю только текст.", - "bot.document_extraction_error": "🔴 Не удалось извлечь текст из документа: {error}", + "bot.document_extraction_error": "🔴 Не удалось извлечь текст из документа.", "bot.text_file_too_large": "⚠️ Текстовый файл слишком большой (макс. {maxSizeKb}КБ)", "status.header_running": "🟢 OpenCode Server запущен", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 9a81d8da..f245cfe2 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -93,12 +93,13 @@ export const zh: I18nDictionary = { "bot.files_downloading": "⏳ 正在下载文件...", "bot.file_too_large": "⚠️ 文件过大(最大 {maxSizeMb}MB)", "bot.file_download_error": "🔴 下载文件失败", - "bot.file_type_unsupported": "⚠️ 不支持此文件类型。请发送图片、PDF 或文本/代码文件。", + "bot.file_type_unsupported": + "⚠️ 不支持此文件类型。请发送图片、文档(PDF、DOCX、PPTX)或文本/代码文件。", "bot.media_group_not_processed": "⚠️ 此相册中有一个或多个文件无法处理。未向 OpenCode 发送任何内容。", "bot.media_group_download_error": "🔴 无法下载其中一个文件。未向 OpenCode 发送任何内容。", "bot.model_no_pdf": "⚠️ 当前模型不支持PDF输入。将仅发送文本。", - "bot.document_extraction_error": "🔴 文档文本提取失败:{error}", + "bot.document_extraction_error": "🔴 文档文本提取失败。", "bot.text_file_too_large": "⚠️ 文本文件过大(最大 {maxSizeKb}KB)", "status.header_running": "🟢 OpenCode 服务器正在运行", diff --git a/tests/app/services/document-extractor-service.test.ts b/tests/app/services/document-extractor-service.test.ts new file mode 100644 index 00000000..68843f39 --- /dev/null +++ b/tests/app/services/document-extractor-service.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../../src/utils/logger.js", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +const mockDocExtractor = vi.hoisted(() => ({ + apiUrl: "", + apiKey: "", +})); + +vi.mock("../../../src/config.js", () => ({ + config: { + docExtractor: mockDocExtractor, + telegram: { token: "test", allowedUserId: 0, proxyUrl: "" }, + opencode: { + apiUrl: "http://localhost:4096", + username: "opencode", + password: "", + model: { provider: "test", modelId: "test" }, + }, + server: { logLevel: "error" }, + bot: { + sessionsListLimit: 10, + projectsListLimit: 10, + locale: "en", + }, + files: { maxFileSizeKb: 100 }, + }, +})); + +import { + isDocExtractorConfigured, + extractDocument, +} from "../../../src/app/services/document-extractor-service.js"; + +describe("isDocExtractorConfigured", () => { + beforeEach(() => { + mockDocExtractor.apiUrl = ""; + mockDocExtractor.apiKey = ""; + }); + + it("returns false when apiUrl is empty", () => { + expect(isDocExtractorConfigured()).toBe(false); + }); + + it("returns true when only apiUrl is set (apiKey optional)", () => { + mockDocExtractor.apiUrl = "https://extractor.example.com/extract"; + expect(isDocExtractorConfigured()).toBe(true); + }); + + it("returns true when both apiUrl and apiKey are set", () => { + mockDocExtractor.apiUrl = "https://extractor.example.com/extract"; + mockDocExtractor.apiKey = "sk-test-key"; + expect(isDocExtractorConfigured()).toBe(true); + }); +}); + +describe("extractDocument", () => { + beforeEach(() => { + mockDocExtractor.apiUrl = "https://extractor.example.com/extract"; + mockDocExtractor.apiKey = "sk-test-key"; + vi.restoreAllMocks(); + }); + + it("throws when doc extractor is not configured", async () => { + mockDocExtractor.apiUrl = ""; + + const fileBuffer = Buffer.from("fake-document-data"); + await expect(extractDocument(fileBuffer, "application/pdf", "doc.pdf")).rejects.toThrow( + "Document extractor is not configured", + ); + }); + + it("sends correct request and returns extracted text", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ text: "Extracted document content" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const fileBuffer = Buffer.from("fake-document-data"); + const result = await extractDocument(fileBuffer, "application/pdf", "doc.pdf"); + + expect(result).toEqual({ text: "Extracted document content" }); + + expect(fetchSpy).toHaveBeenCalledOnce(); + const [url, options] = fetchSpy.mock.calls[0]; + expect(url).toBe("https://extractor.example.com/extract"); + expect(options?.method).toBe("POST"); + expect((options?.headers as Record)["Authorization"]).toBe( + "Bearer sk-test-key", + ); + expect(options?.body).toBeInstanceOf(FormData); + }); + + it("includes mimeType in Blob constructor", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ text: "Content" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const fileBuffer = Buffer.from("fake-docx-data"); + await extractDocument(fileBuffer, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "doc.docx"); + + const formData = fetchSpy.mock.calls[0][1]?.body as FormData; + const fileField = formData.get("file") as Blob; + + expect(fileField).toBeInstanceOf(Blob); + expect(fileField.type).toBe("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + }); + + it("does not send Authorization header when apiKey is empty", async () => { + mockDocExtractor.apiKey = ""; + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ text: "Content" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const fileBuffer = Buffer.from("fake-document-data"); + await extractDocument(fileBuffer, "application/pdf", "doc.pdf"); + + const headers = fetchSpy.mock.calls[0][1]?.headers as Record; + expect(headers["Authorization"]).toBeUndefined(); + }); + + it("throws on non-OK HTTP response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Server error", { + status: 500, + statusText: "Internal Server Error", + }), + ); + + const fileBuffer = Buffer.from("fake-document-data"); + await expect(extractDocument(fileBuffer, "application/pdf", "doc.pdf")).rejects.toThrow( + "Document extractor API returned HTTP 500: Server error", + ); + }); + + it("throws when response has no text field", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ status: "ok" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const fileBuffer = Buffer.from("fake-document-data"); + await expect(extractDocument(fileBuffer, "application/pdf", "doc.pdf")).rejects.toThrow( + "Document extractor API response does not contain a text field", + ); + }); + + it("throws on timeout", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue( + new DOMException("The operation was aborted", "AbortError"), + ); + + const fileBuffer = Buffer.from("fake-document-data"); + await expect(extractDocument(fileBuffer, "application/pdf", "doc.pdf")).rejects.toThrow( + "Document extractor request timed out", + ); + }); +}); diff --git a/tests/bot/handlers/document.test.ts b/tests/bot/handlers/document.test.ts index 637dbf07..5e74e8ca 100644 --- a/tests/bot/handlers/document.test.ts +++ b/tests/bot/handlers/document.test.ts @@ -4,7 +4,13 @@ import { handleDocumentMessage, type DocumentHandlerDeps, } from "../../../src/bot/handlers/document-handler.js"; + +vi.mock("../../../src/app/services/document-extractor-service.js", () => ({ + isDocExtractorConfigured: vi.fn(), + extractDocument: vi.fn(), +})); import { t } from "../../../src/i18n/index.js"; +import { isDocExtractorConfigured } from "../../../src/app/services/document-extractor-service.js"; function createDocumentContext(overrides: Partial = {}): { ctx: Context; @@ -330,6 +336,157 @@ describe("bot/handlers/document", () => { }); }); + describe("document extraction via DOC_EXTRACTOR_URL", () => { + beforeEach(() => { + vi.mocked(isDocExtractorConfigured).mockReturnValue(true); + }); + + it("extracts and sends PDF text when model does not support PDF and extractor is configured", async () => { + const { ctx, replyMock } = createDocumentContext({ + document: { + file_id: "pdf-file-id", + file_unique_id: "pdf-unique-id", + file_name: "document.pdf", + mime_type: "application/pdf", + file_size: 5000, + }, + }); + const { deps, processPromptMock, downloadMock, getCapabilitiesMock } = createDocumentDeps({ + getModelCapabilities: vi.fn().mockResolvedValue({ + input: { pdf: false }, + }), + }); + + const { extractDocument: extractDoc } = await import("../../../src/app/services/document-extractor-service.js"); + vi.mocked(extractDoc).mockResolvedValue({ text: "Extracted PDF content" }); + + await handleDocumentMessage(ctx, deps); + + expect(replyMock).toHaveBeenCalledWith(t("bot.file_downloading")); + expect(downloadMock).toHaveBeenCalled(); + expect(extractDoc).toHaveBeenCalledWith( + expect.any(Buffer), + "application/pdf", + "document.pdf", + ); + expect(processPromptMock).toHaveBeenCalledWith( + ctx, + expect.stringContaining("Extracted PDF content"), + deps, + ); + }); + + it("extracts DOCX when model does not support PDF input", async () => { + const { ctx, replyMock } = createDocumentContext({ + document: { + file_id: "docx-file-id", + file_unique_id: "docx-unique-id", + file_name: "report.docx", + mime_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size: 5000, + }, + }); + const { deps, processPromptMock, getCapabilitiesMock } = createDocumentDeps({ + getModelCapabilities: vi.fn().mockResolvedValue({ + input: { pdf: false }, + }), + }); + + const { extractDocument: extractDoc } = await import("../../../src/app/services/document-extractor-service.js"); + vi.mocked(extractDoc).mockResolvedValue({ text: "DOCX content" }); + + await handleDocumentMessage(ctx, deps); + + expect(extractDoc).toHaveBeenCalledWith( + expect.any(Buffer), + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "report.docx", + ); + expect(processPromptMock).toHaveBeenCalledWith( + ctx, + expect.stringContaining("DOCX content"), + deps, + ); + }); + + it("shows extraction error and falls back to caption", async () => { + const { ctx, replyMock } = createDocumentContext({ + document: { + file_id: "pdf-file-id", + file_unique_id: "pdf-unique-id", + file_name: "document.pdf", + mime_type: "application/pdf", + file_size: 5000, + }, + caption: "Summarize this", + }); + const { deps, processPromptMock } = createDocumentDeps({ + getModelCapabilities: vi.fn().mockResolvedValue({ + input: { pdf: false }, + }), + }); + + const { extractDocument: extractDoc } = await import("../../../src/app/services/document-extractor-service.js"); + vi.mocked(extractDoc).mockRejectedValue(new Error("API unreachable")); + + await handleDocumentMessage(ctx, deps); + + expect(replyMock).toHaveBeenCalledWith(t("bot.document_extraction_error")); + expect(processPromptMock).toHaveBeenCalledWith(ctx, "Summarize this", deps); + }); + + it("shows extraction error without fallback when no caption", async () => { + const { ctx, replyMock } = createDocumentContext({ + document: { + file_id: "pdf-file-id", + file_unique_id: "pdf-unique-id", + file_name: "document.pdf", + mime_type: "application/pdf", + file_size: 5000, + }, + caption: "", + }); + const { deps, processPromptMock } = createDocumentDeps({ + getModelCapabilities: vi.fn().mockResolvedValue({ + input: { pdf: false }, + }), + }); + + const { extractDocument: extractDoc } = await import("../../../src/app/services/document-extractor-service.js"); + vi.mocked(extractDoc).mockRejectedValue(new Error("API unreachable")); + + await handleDocumentMessage(ctx, deps); + + expect(replyMock).toHaveBeenCalledWith(t("bot.document_extraction_error")); + expect(processPromptMock).not.toHaveBeenCalled(); + }); + + it("shows model_no_pdf when extractor is not configured", async () => { + vi.mocked(isDocExtractorConfigured).mockReturnValue(false); + + const { ctx, replyMock } = createDocumentContext({ + document: { + file_id: "pdf-file-id", + file_unique_id: "pdf-unique-id", + file_name: "document.pdf", + mime_type: "application/pdf", + file_size: 5000, + }, + caption: "Summarize", + }); + const { deps, processPromptMock } = createDocumentDeps({ + getModelCapabilities: vi.fn().mockResolvedValue({ + input: { pdf: false }, + }), + }); + + await handleDocumentMessage(ctx, deps); + + expect(replyMock).toHaveBeenCalledWith(t("bot.model_no_pdf")); + expect(processPromptMock).toHaveBeenCalledWith(ctx, "Summarize", deps); + }); + }); + describe("unsupported file types", () => { it("shows error for unsupported MIME types", async () => { const { ctx, replyMock } = createDocumentContext({