diff --git a/.env.example b/.env.example index 57f3f00..80af137 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,13 @@ TGCLIENT_WRITE_LIMIT_N=20 # мутации через MCP: столько TGCLIENT_WRITE_LIMIT_WINDOW=60 # ... за столько секунд, на аккаунт +# Каталог на диске сервера, из которого upload_* тула берут файлы +# (upload_voice/upload_file/upload_round с параметром file_path — ТОЛЬКО имя +# файла внутри каталога, пути запрещены). Пусто — отправка с диска отключена, +# тула принимают только data_base64. Внимание: файлы в этот каталог кладёте +# вы (volume-маунт / docker cp), каталог должен быть доступен контейнеру. +TGCLIENT_UPLOAD_DIR= + # Пуш-события сообщений в Synapse (tg_message_received/edited/deleted) — # payload несёт тексты переписки; включать осознанно (=1) TGCLIENT_NOTIFY_MESSAGES=0 diff --git a/README.md b/README.md index 1349561..586a9b2 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ `messages_read`, `messages_search`, `message_send/reply/edit/delete`; - медиа: `download_media` (base64), `upload_file`; **голосовые** (`upload_voice` ogg-opus + waveform 63×5 бит) и **кружки** (`upload_round`, mp4-квадрат ≤60 с); + каждый upload-тул принимает и base64, и `file_path` — имя файла внутри + каталога сервера `TGCLIENT_UPLOAD_DIR` (пути запрещены, по умолчанию выкл.); - **звонки**: `call_start/call_status/call_discard` — сигналинг MTProto (`phone.requestCall` → ринг у абонента, DH + confirm по voice-calls-спеке); **голос не передаётся** (Telethon без tgcalls): принявший вызов получает diff --git a/backend/app/config.py b/backend/app/config.py index b97033d..b8b84c2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -33,6 +33,10 @@ # payload несёт личные данные; включается осознанно (TGCLIENT_NOTIFY_MESSAGES=1) notify_messages: bool = False media_max_bytes: int = 20 * 1024 * 1024 # cap download/upload через MCP + # Каталог на диске сервера для отправки файлов через MCP-тулы + # (upload_*.file_path — только имя файла внутри каталога, путей нет): + # пусто — отправка с диска отключена, тула принимают только base64. + upload_dir: str = "" write_limit_n: int = 20 # мутаций на аккаунт за окно write_limit_window: float = 60.0 diff --git a/backend/app/mcp/server.py b/backend/app/mcp/server.py index 1bb6d53..e2496de 100644 --- a/backend/app/mcp/server.py +++ b/backend/app/mcp/server.py @@ -51,9 +51,9 @@ | «сходи по контактам / кто в группе» | `contacts_list` / `chat_participants` | | «что за файл тут?» | `messages_read` (media-метаданные) | | «скачай/файл есть?» | `download_media` (возвращает base64) | -| «отправь файл/фото» | `upload_file` | -| «отправь голосовое» | `upload_voice` (ogg-opus, duration+waveform) | -| «отправь кружок» | `upload_round` (mp4 квадрат ≤60с) | +| «отправь файл/фото» | `upload_file` (base64 или `file_path`) | +| «отправь голосовое» | `upload_voice` (base64 или `file_path`) | +| «отправь кружок» | `upload_round` (base64 или `file_path`) | | «позвони ему» | `call_start` (сигналинг: ринг у абонента, без аудио) | | «добавь мой акк / залогинь» | `account_login_start` → `code` → [`password`] | @@ -83,7 +83,11 @@ (деталь содержит секунды). - Голосовой файл (voice) и кружок (round) в медиа-метаданных видны сразу (`is_voice`/`is_round`, duration и waveform) — не нужен download для - понимания, что это голосовое. + понимания, что это голосовое. Достать голосовое: `download_media` вернёт + ogg-opus в `data_base64`; duration/waveform уже в метаданных. +- Отправка файла с диска сервера: `file_path` — **только имя файла** внутри + каталога `TGCLIENT_UPLOAD_DIR` (пути запрещены). Каталог не настроен — 503; + тогда передавайте содержимое `data_base64` как раньше. - Личные данные (peer-ids, тексты) — конфиденциальны: не выкладывай наружу. ## Подключение diff --git a/backend/app/mcp/tools.py b/backend/app/mcp/tools.py index 2dcfdb4..1b6c00e 100644 --- a/backend/app/mcp/tools.py +++ b/backend/app/mcp/tools.py @@ -16,6 +16,7 @@ import base64 from contextlib import suppress +from pathlib import Path from telethon import functions, errors from telethon.tl.types import DocumentAttributeAudio, DocumentAttributeVideo @@ -518,19 +519,21 @@ @mcp.tool() async def upload_file(account_id: int | None = None, dialog_id: int = 0, - data_base64: str = "", file_name: str = "file", + data_base64: str = "", file_path: str = "", + file_name: str = "file", caption: str = "", force_document: bool = False) -> dict: - """📎 Отправить файл/фото (base64). force_document=True — фото без превью, - как документ.""" + """📎 Отправить файл/фото: data_base64 ИЛИ file_path (имя файла внутри + TGCLIENT_UPLOAD_DIR на сервере — без путей). force_document=True — + фото без превью, как документ.""" return await _tool( - lambda: _upload_file(account_id, dialog_id, data_base64, file_name, caption, force_document) + lambda: _upload_file(account_id, dialog_id, data_base64, file_path, file_name, caption, force_document) ) - async def _upload_file(account_id, dialog_id, data_base64, file_name, caption, force_document) -> dict: + async def _upload_file(account_id, dialog_id, data_base64, file_path, file_name, caption, force_document) -> dict: db = get_db() client, row, entity = await _client_for_dialog(db, account_id, dialog_id, writable=True) - data = _decode_payload(data_base64) + data = _upload_bytes(data_base64, file_path) if (limited := _write_guard(row["id"])) is not None: return limited message = await call_guard(_manager(), row["id"], @@ -543,21 +546,23 @@ @mcp.tool() async def upload_voice(account_id: int | None = None, dialog_id: int = 0, - data_base64: str = "", duration: int = 0, + data_base64: str = "", file_path: str = "", + duration: int = 0, waveform_samples: list[int] | None = None) -> dict: - """🎤 Отправить голосовое: ogg-opus в base64. duration (сек) и - waveform_samples (до 63 значений 0..100) — если не переданы, - вычисляются из файла (гранула последней Ogg-страницы / плоская волна). - Без duration и без парсинга — 422.""" + """🎤 Отправить голосовое: ogg-opus в base64 (data_base64) ИЛИ файлом с + диска сервера (file_path — имя файла внутри TGCLIENT_UPLOAD_DIR, + без путей). duration (сек) и waveform_samples (до 63 значений 0..100) — + если не переданы, duration вычисляется из ogg-opus (для mp3/wav укажите + duration сами), волна — плоская. Без duration и без парсинга — 422.""" return await _tool( - lambda: _upload_voice(account_id, dialog_id, data_base64, duration, waveform_samples) + lambda: _upload_voice(account_id, dialog_id, data_base64, file_path, duration, waveform_samples) ) - async def _upload_voice(account_id, dialog_id, data_base64, duration, waveform_samples) -> dict: + async def _upload_voice(account_id, dialog_id, data_base64, file_path, duration, waveform_samples) -> dict: db = get_db() client, row, entity = await _client_for_dialog(db, account_id, dialog_id, writable=True) - data = _decode_payload(data_base64) + data = _upload_bytes(data_base64, file_path) if duration <= 0: duration = ogg_opus_duration(data) or 0 if duration <= 0: @@ -579,20 +584,21 @@ @mcp.tool() async def upload_round(account_id: int | None = None, dialog_id: int = 0, - data_base64: str = "", duration: int = 0, - size: int = 0) -> dict: + data_base64: str = "", file_path: str = "", + duration: int = 0, size: int = 0) -> dict: """⭕ Отправить кружок (video note): mp4 H.264/AAC, квадрат, ≤60 сек — - base64 + duration (сек) + размер квадрата (size, 240..640). Контейнер + base64 ИЛИ файлом с диска сервера (file_path внутри TGCLIENT_UPLOAD_DIR) + + duration (сек) + размер квадрата (size, 240..640). Контейнер не тот — Telegram покажет обычное видео, предупреждайте пользователя.""" return await _tool( - lambda: _upload_round(account_id, dialog_id, data_base64, duration, size) + lambda: _upload_round(account_id, dialog_id, data_base64, file_path, duration, size) ) - async def _upload_round(account_id, dialog_id, data_base64, duration, size) -> dict: + async def _upload_round(account_id, dialog_id, data_base64, file_path, duration, size) -> dict: db = get_db() client, row, entity = await _client_for_dialog(db, account_id, dialog_id, writable=True) - data = _decode_payload(data_base64) + data = _upload_bytes(data_base64, file_path) if duration <= 0: raise DomainError(422, "duration (сек) обязателен для кружка") if duration > 60: @@ -776,4 +782,36 @@ cap = get_settings().media_max_bytes if len(data) > cap: raise DomainError(413, f"payload {len(data)} байт > лимита {cap}") - return data \ No newline at end of file + return data + + +def _read_local_file(file_path: str) -> bytes: + """Аудио/файл с диска сервера для upload_* тулов (file_path вместо + data_base64): ТОЛЬКО имя файла внутри TGCLIENT_UPLOAD_DIR — подкаталоги и + пути запрещены, иначе персональный MCP-ключ мог бы прочитать произвольный + файл хоста (.env с секретами). Не настроено — 503, нет файла — 404.""" + if not file_path or "/" in file_path or "\\" in file_path or ".." in file_path: + raise DomainError( + 422, "file_path — только имя файла внутри TGCLIENT_UPLOAD_DIR (без путей)") + base_dir = get_settings().upload_dir + if not base_dir: + raise DomainError( + 503, "отправка файлов с диска отключена — задайте TGCLIENT_UPLOAD_DIR на сервере") + path = Path(base_dir) / file_path + if not path.is_file(): + raise DomainError(404, f"файл не найден в upload-каталоге: {file_path}") + data = path.read_bytes() + cap = get_settings().media_max_bytes + if len(data) > cap: + raise DomainError(413, f"файл {len(data)} байт > лимита {cap}") + return data + + +def _upload_bytes(data_base64: str, file_path: str) -> bytes: + """Payload upload_* тулов: file_path (диск сервера) или data_base64; + оба/ни один — 422.""" + if file_path and data_base64: + raise DomainError(422, "укажите что-то одно: file_path или data_base64") + if file_path: + return _read_local_file(file_path) + return _decode_payload(data_base64) \ No newline at end of file