From 5fcd0a6411bd205c86f1caaf2ce7f10276600126 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 14 Jun 2026 14:14:08 +0200 Subject: [PATCH] refac --- CHANGELOG.md | 9 ++ cptr/utils/adapters/discord.py | 68 +++++++++- cptr/utils/adapters/signal.py | 85 +++++++++++- cptr/utils/adapters/slack.py | 83 ++++++++++- cptr/utils/adapters/telegram.py | 116 +++++++++++++++- cptr/utils/adapters/whatsapp.py | 151 +++++++++++++++++++-- cptr/utils/bridge.py | 234 +++++++++++++++++++++++++++++++- pyproject.toml | 2 +- 8 files changed, 725 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f72734..7a10d64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.3] - 2026-06-14 + +### Changed + +- đŸ’Ŧ **Telegram rich message formatting.** Telegram messages now use `sendRichMessage` / `sendRichMessageDraft` (Bot API `InputRichMessage`) for properly rendered Markdown with bold, italic, code blocks, and links. Falls back gracefully to plain text when the API is unavailable. +- 📝 **Increased Telegram streaming buffer.** The streaming buffer limit for Telegram was raised from 4 096 to 32 768 characters, allowing longer messages to stream without premature chunking. +- 🔒 **Proxy middleware authentication.** The reverse-proxy middleware now verifies the `cptr_session` cookie before forwarding requests, preventing unauthenticated access to proxied local services. +- 📖 **README refresh.** Updated the README with revised feature descriptions and setup instructions. + ## [0.4.2] - 2026-06-14 ### Fixed diff --git a/cptr/utils/adapters/discord.py b/cptr/utils/adapters/discord.py index 056755b..92392b5 100644 --- a/cptr/utils/adapters/discord.py +++ b/cptr/utils/adapters/discord.py @@ -17,7 +17,7 @@ from typing import Optional import httpx -from cptr.utils.bridge import BaseAdapter, MessageEvent, chunk_message +from cptr.utils.bridge import Attachment, BaseAdapter, MessageEvent, chunk_message logger = logging.getLogger(__name__) @@ -232,7 +232,29 @@ class DiscordAdapter(BaseAdapter): return content = data.get("content", "").strip() - if not content: + + # Process Discord attachments + attachments: list[Attachment] = [] + for att in data.get("attachments", []): + url = att.get("url") + if not url: + continue + file_data = await self._download_url(url) + if not file_data: + continue + fname = att.get("filename", "file") + ctype = att.get("content_type", "application/octet-stream") + if ctype.startswith("image/"): + att_type = "image" + elif ctype.startswith("audio/"): + att_type = "audio" + else: + att_type = "document" + attachments.append(Attachment( + type=att_type, filename=fname, data=file_data, mime_type=ctype, + )) + + if not content and not attachments: return event = MessageEvent( @@ -241,11 +263,53 @@ class DiscordAdapter(BaseAdapter): sender_id=author.get("id", ""), sender_name=author.get("global_name") or author.get("username", "User"), text=content, + attachments=attachments, ) if self.on_message: await self.on_message(event) + async def _download_url(self, url: str) -> bytes | None: + """Download a file from a URL (Discord CDN).""" + if not self._http: + return None + try: + resp = await self._http.get(url) + if resp.status_code == 200: + return resp.content + logger.warning("[discord] File download failed: HTTP %d", resp.status_code) + except Exception: + logger.exception("[discord] Failed to download %s", url) + return None + + async def send_photo(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None: + """Send a photo as a file attachment.""" + return await self._send_file(chat_id, data, filename, caption) + + async def send_document(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None: + """Send a document as a file attachment.""" + return await self._send_file(chat_id, data, filename, caption) + + async def _send_file(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None: + """Send a file via Discord multipart upload.""" + if not self._http: + return None + try: + files = {"files[0]": (filename, data, "application/octet-stream")} + form_data = {} + if caption: + form_data["content"] = caption[:MAX_MESSAGE_LEN] + resp = await self._http.post( + f"{API_BASE}/channels/{chat_id}/messages", + files=files, + data=form_data, + ) + if resp.status_code == 200: + return resp.json().get("id") + except Exception: + logger.exception("[discord] Failed to send file") + return None + async def verify_token(token: str) -> dict: async with httpx.AsyncClient(timeout=10) as client: diff --git a/cptr/utils/adapters/signal.py b/cptr/utils/adapters/signal.py index 50385c8..3302372 100644 --- a/cptr/utils/adapters/signal.py +++ b/cptr/utils/adapters/signal.py @@ -19,7 +19,7 @@ from urllib.parse import quote import httpx -from cptr.utils.bridge import BaseAdapter, MessageEvent, chunk_message +from cptr.utils.bridge import Attachment, BaseAdapter, MessageEvent, chunk_message logger = logging.getLogger(__name__) @@ -29,6 +29,16 @@ RECONNECT_BASE_DELAY = 2.0 RECONNECT_MAX_DELAY = 60.0 +def _ext_for_mime(mime: str) -> str: + """Map common MIME types to file extensions.""" + mapping = { + "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", + "image/webp": ".webp", "audio/ogg": ".ogg", "audio/mpeg": ".mp3", + "audio/aac": ".aac", "video/mp4": ".mp4", "application/pdf": ".pdf", + } + return mapping.get(mime, "") + + def _parse_token(token: str) -> tuple[str, str]: """Parse 'base_url|phone_number' format.""" if "|" in token: @@ -171,7 +181,29 @@ class SignalAdapter(BaseAdapter): return text = (data_message.get("message") or "").strip() - if not text: + + # Process attachments from signal-cli + attachments: list[Attachment] = [] + for att in data_message.get("attachments", []): + att_id = att.get("id") + if not att_id: + continue + file_data = await self._download_attachment(att_id) + if not file_data: + continue + content_type = att.get("contentType", "application/octet-stream") + fname = att.get("filename") or f"attachment{_ext_for_mime(content_type)}" + if content_type.startswith("image/"): + att_type = "image" + elif content_type.startswith("audio/"): + att_type = "audio" + else: + att_type = "document" + attachments.append(Attachment( + type=att_type, filename=fname, data=file_data, mime_type=content_type, + )) + + if not text and not attachments: return source = envelope.get("source", "") @@ -187,11 +219,60 @@ class SignalAdapter(BaseAdapter): sender_id=source, sender_name=source_name, text=text, + attachments=attachments, ) if self.on_message: await self.on_message(event) + async def _download_attachment(self, attachment_id: str) -> bytes | None: + """Download an attachment from signal-cli REST API.""" + if not self._http: + return None + try: + resp = await self._http.get( + f"{self._base_url}/v1/attachments/{attachment_id}", + ) + if resp.status_code == 200: + return resp.content + logger.warning("[signal] Attachment download failed: HTTP %d", resp.status_code) + except Exception: + logger.exception("[signal] Failed to download attachment %s", attachment_id) + return None + + async def send_photo(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None: + """Send a photo as a base64 attachment.""" + return await self._send_with_attachment(chat_id, data, filename, caption) + + async def send_document(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None: + """Send a document as a base64 attachment.""" + return await self._send_with_attachment(chat_id, data, filename, caption) + + async def _send_with_attachment( + self, chat_id: str, data: bytes, filename: str, caption: str = "", + ) -> str | None: + """Send a message with a base64-encoded attachment via signal-cli.""" + if not self._http: + return None + import base64 + try: + resp = await self._http.post( + f"{self._base_url}/v2/send", + json={ + "message": caption or "", + "number": self._phone, + "recipients": [chat_id], + "base64_attachments": [ + base64.b64encode(data).decode("ascii") + ], + }, + ) + result = resp.json() + return str(result.get("timestamp", "")) + except Exception: + logger.exception("[signal] Failed to send attachment") + return None + async def verify_token(token: str) -> dict: """Verify signal-cli REST API connection and phone number.""" diff --git a/cptr/utils/adapters/slack.py b/cptr/utils/adapters/slack.py index 166b1fd..21dcb40 100644 --- a/cptr/utils/adapters/slack.py +++ b/cptr/utils/adapters/slack.py @@ -16,7 +16,7 @@ from typing import Optional import httpx -from cptr.utils.bridge import BaseAdapter, MessageEvent, chunk_message +from cptr.utils.bridge import Attachment, BaseAdapter, MessageEvent, chunk_message logger = logging.getLogger(__name__) @@ -192,7 +192,29 @@ class SlackAdapter(BaseAdapter): return text = (event.get("text") or "").strip() - if not text: + + # Process file attachments + attachments: list[Attachment] = [] + for f in event.get("files", []): + url = f.get("url_private") + if not url: + continue + file_data = await self._download_file(url) + if not file_data: + continue + fname = f.get("name") or f.get("title") or "file" + mime = f.get("mimetype", "application/octet-stream") + if mime.startswith("image/"): + att_type = "image" + elif mime.startswith("audio/"): + att_type = "audio" + else: + att_type = "document" + attachments.append(Attachment( + type=att_type, filename=fname, data=file_data, mime_type=mime, + )) + + if not text and not attachments: return # Resolve display name @@ -205,6 +227,7 @@ class SlackAdapter(BaseAdapter): sender_id=user_id, sender_name=sender_name, text=text, + attachments=attachments, ) if self.on_message: @@ -223,6 +246,62 @@ class SlackAdapter(BaseAdapter): pass return "User" + async def _download_file(self, url: str) -> bytes | None: + """Download a file from Slack (requires bot token auth).""" + if not self._http: + return None + try: + resp = await self._http.get(url) + if resp.status_code == 200: + return resp.content + logger.warning("[slack] File download failed: HTTP %d", resp.status_code) + except Exception: + logger.exception("[slack] Failed to download %s", url) + return None + + async def send_photo(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None: + """Send a photo via Slack file upload.""" + return await self._upload_file(chat_id, data, filename, caption) + + async def send_document(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None: + """Send a document via Slack file upload.""" + return await self._upload_file(chat_id, data, filename, caption) + + async def _upload_file(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None: + """Upload a file to Slack via files.uploadV2.""" + if not self._http: + return None + try: + # Step 1: Get upload URL + resp = await self._http.get( + f"{API_BASE}/files.getUploadURLExternal", + params={"filename": filename, "length": len(data)}, + ) + url_data = resp.json() + if not url_data.get("ok"): + logger.warning("[slack] files.getUploadURLExternal failed: %s", url_data.get("error")) + return None + + # Step 2: Upload to the provided URL + upload_url = url_data["upload_url"] + file_id = url_data["file_id"] + await self._http.post(upload_url, content=data) + + # Step 3: Complete the upload + await self._http.post( + f"{API_BASE}/files.completeUploadExternal", + json={ + "files": [{"id": file_id, "title": filename}], + "channel_id": chat_id, + "initial_comment": caption or "", + }, + ) + return file_id + except Exception: + logger.exception("[slack] Failed to upload file") + return None + + async def verify_token(token: str) -> dict: """Verify a Slack bot token by calling auth.test.""" diff --git a/cptr/utils/adapters/telegram.py b/cptr/utils/adapters/telegram.py index 5443e02..03ab97b 100644 --- a/cptr/utils/adapters/telegram.py +++ b/cptr/utils/adapters/telegram.py @@ -20,7 +20,7 @@ from typing import Optional import httpx -from cptr.utils.bridge import BaseAdapter, MessageEvent, chunk_message +from cptr.utils.bridge import Attachment, BaseAdapter, MessageEvent, chunk_message logger = logging.getLogger(__name__) @@ -238,7 +238,65 @@ class TelegramAdapter(BaseAdapter): return text = message.get("text") or message.get("caption") or "" - if not text.strip(): + + # Collect attachments from media types + attachments: list = [] + + # Photos — Telegram sends multiple sizes, pick the largest + if message.get("photo"): + photo = message["photo"][-1] # largest size + file_data = await self._download_file(photo["file_id"]) + if file_data: + attachments.append(Attachment( + type="image", + filename="photo.jpg", + data=file_data, + mime_type="image/jpeg", + )) + + # Documents (PDF, spreadsheets, etc.) + if message.get("document"): + doc = message["document"] + file_data = await self._download_file(doc["file_id"]) + if file_data: + fname = doc.get("file_name", "document") + mime = doc.get("mime_type", "application/octet-stream") + att_type = "image" if mime.startswith("image/") else "document" + attachments.append(Attachment( + type=att_type, + filename=fname, + data=file_data, + mime_type=mime, + )) + + # Voice messages (OGG/Opus) + if message.get("voice"): + voice = message["voice"] + file_data = await self._download_file(voice["file_id"]) + if file_data: + attachments.append(Attachment( + type="audio", + filename="voice.ogg", + data=file_data, + mime_type=voice.get("mime_type", "audio/ogg"), + )) + + # Audio files (music, audio messages sent as files) + if message.get("audio"): + audio = message["audio"] + file_data = await self._download_file(audio["file_id"]) + if file_data: + from cptr.utils.bridge import Attachment + fname = audio.get("file_name", "audio.mp3") + attachments.append(Attachment( + type="audio", + filename=fname, + data=file_data, + mime_type=audio.get("mime_type", "audio/mpeg"), + )) + + # Skip if no text AND no attachments + if not text.strip() and not attachments: return chat = message.get("chat", {}) @@ -253,11 +311,65 @@ class TelegramAdapter(BaseAdapter): + (" " + sender.get("last_name", "") if sender.get("last_name") else "") ).strip() or "User", text=text, + attachments=attachments, ) if self.on_message: await self.on_message(event) + async def _download_file(self, file_id: str) -> bytes | None: + """Download a file from Telegram servers via getFile API.""" + try: + file_info = await self._api("getFile", file_id=file_id) + file_path = file_info.get("file_path") + if not file_path: + return None + url = f"https://api.telegram.org/file/bot{self._token}/{file_path}" + resp = await self._client.get(url) + if resp.status_code == 200: + return resp.content + logger.warning("[telegram] File download failed: HTTP %d", resp.status_code) + return None + except Exception: + logger.exception("[telegram] Failed to download file %s", file_id) + return None + + async def send_photo(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None: + """Send a photo via multipart upload.""" + if not self._client: + return None + url = f"{self._base}/sendPhoto" + files = {"photo": (filename, data, "image/jpeg")} + form_data = {"chat_id": chat_id} + if caption: + form_data["caption"] = caption[:1024] + try: + resp = await self._client.post(url, files=files, data=form_data) + body = resp.json() + if body.get("ok"): + return str(body.get("result", {}).get("message_id", "")) + except Exception: + logger.exception("[telegram] Failed to send photo") + return None + + async def send_document(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None: + """Send a document via multipart upload.""" + if not self._client: + return None + url = f"{self._base}/sendDocument" + files = {"document": (filename, data, "application/octet-stream")} + form_data = {"chat_id": chat_id} + if caption: + form_data["caption"] = caption[:1024] + try: + resp = await self._client.post(url, files=files, data=form_data) + body = resp.json() + if body.get("ok"): + return str(body.get("result", {}).get("message_id", "")) + except Exception: + logger.exception("[telegram] Failed to send document") + return None + # ── API helpers ──────────────────────────────────────── async def _api(self, method: str, **params) -> any: diff --git a/cptr/utils/adapters/whatsapp.py b/cptr/utils/adapters/whatsapp.py index fa9ad48..414c74d 100644 --- a/cptr/utils/adapters/whatsapp.py +++ b/cptr/utils/adapters/whatsapp.py @@ -20,7 +20,7 @@ from typing import Optional import httpx -from cptr.utils.bridge import BaseAdapter, MessageEvent, chunk_message +from cptr.utils.bridge import Attachment, BaseAdapter, MessageEvent, chunk_message logger = logging.getLogger(__name__) @@ -96,13 +96,64 @@ class WhatsAppAdapter(BaseAdapter): for change in entry.get("changes", []): value = change.get("value", {}) for message in value.get("messages", []): - if message.get("type") != "text": + msg_type = message.get("type", "") + + text = "" + attachments: list[Attachment] = [] + + if msg_type == "text": + text = message.get("text", {}).get("body", "").strip() + elif msg_type == "image": + image = message.get("image", {}) + text = image.get("caption", "").strip() + media_data = await self._download_media(image.get("id", "")) + if media_data: + attachments.append(Attachment( + type="image", + filename="photo.jpg", + data=media_data, + mime_type=image.get("mime_type", "image/jpeg"), + )) + elif msg_type == "audio": + audio = message.get("audio", {}) + media_data = await self._download_media(audio.get("id", "")) + if media_data: + attachments.append(Attachment( + type="audio", + filename="voice.ogg", + data=media_data, + mime_type=audio.get("mime_type", "audio/ogg"), + )) + elif msg_type == "document": + doc = message.get("document", {}) + text = doc.get("caption", "").strip() + media_data = await self._download_media(doc.get("id", "")) + if media_data: + fname = doc.get("filename", "document") + attachments.append(Attachment( + type="document", + filename=fname, + data=media_data, + mime_type=doc.get("mime_type", "application/octet-stream"), + )) + elif msg_type == "video": + video = message.get("video", {}) + text = video.get("caption", "").strip() + media_data = await self._download_media(video.get("id", "")) + if media_data: + attachments.append(Attachment( + type="document", + filename="video.mp4", + data=media_data, + mime_type=video.get("mime_type", "video/mp4"), + )) + else: + continue + + if not text and not attachments: continue sender = message.get("from", "") - text = message.get("text", {}).get("body", "").strip() - if not text: - continue # Resolve sender name from contacts contacts = value.get("contacts", []) @@ -119,6 +170,7 @@ class WhatsAppAdapter(BaseAdapter): sender_id=sender, sender_name=sender_name, text=text, + attachments=attachments, ) await self._message_queue.put(event) @@ -162,8 +214,6 @@ class WhatsAppAdapter(BaseAdapter): async def edit(self, chat_id: str, message_id: str, text: str) -> None: """WhatsApp doesn't support message editing. Send a new message instead.""" - # WhatsApp has no edit API — for streaming we just send new messages - # The stream loop will handle this gracefully pass async def send_typing(self, chat_id: str) -> None: @@ -171,18 +221,101 @@ class WhatsAppAdapter(BaseAdapter): if not self._http: return try: - # Mark as read (closest to typing indicator) await self._http.post( f"{API_BASE}/{self._phone_number_id}/messages", json={ "messaging_product": "whatsapp", "status": "read", - "message_id": "placeholder", # Not ideal but API requires it + "message_id": "placeholder", }, ) except Exception: pass + # ── Media ────────────────────────────────────────────── + + async def _download_media(self, media_id: str) -> bytes | None: + """Download media from WhatsApp Cloud API. + + Two-step: GET /{media_id} for URL, then GET the URL for the bytes. + """ + if not self._http or not media_id: + return None + try: + # Step 1: Get the media URL + resp = await self._http.get(f"{API_BASE}/{media_id}") + data = resp.json() + url = data.get("url") + if not url: + return None + # Step 2: Download the actual file (requires auth header) + resp2 = await self._http.get(url) + if resp2.status_code == 200: + return resp2.content + logger.warning("[whatsapp] Media download failed: HTTP %d", resp2.status_code) + except Exception: + logger.exception("[whatsapp] Failed to download media %s", media_id) + return None + + async def send_photo(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None: + """Send a photo via WhatsApp media upload.""" + media_id = await self._upload_media(data, filename, "image/jpeg") + if not media_id: + return await self.send(chat_id, caption) if caption else None + return await self._send_media_message(chat_id, "image", media_id, caption) + + async def send_document(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None: + """Send a document via WhatsApp media upload.""" + media_id = await self._upload_media(data, filename, "application/octet-stream") + if not media_id: + return await self.send(chat_id, caption) if caption else None + return await self._send_media_message(chat_id, "document", media_id, caption, filename) + + async def _upload_media(self, data: bytes, filename: str, mime_type: str) -> str | None: + """Upload media to WhatsApp Cloud API, return media_id.""" + if not self._http: + return None + try: + resp = await self._http.post( + f"{API_BASE}/{self._phone_number_id}/media", + files={"file": (filename, data, mime_type)}, + data={"messaging_product": "whatsapp", "type": mime_type}, + ) + result = resp.json() + return result.get("id") + except Exception: + logger.exception("[whatsapp] Failed to upload media") + return None + + async def _send_media_message( + self, chat_id: str, media_type: str, media_id: str, caption: str = "", filename: str = "", + ) -> str | None: + """Send a media message using an uploaded media_id.""" + if not self._http: + return None + media_obj: dict = {"id": media_id} + if caption: + media_obj["caption"] = caption[:1024] + if filename and media_type == "document": + media_obj["filename"] = filename + try: + resp = await self._http.post( + f"{API_BASE}/{self._phone_number_id}/messages", + json={ + "messaging_product": "whatsapp", + "to": chat_id, + "type": media_type, + media_type: media_obj, + }, + ) + data = resp.json() + messages = data.get("messages", []) + return messages[0].get("id") if messages else None + except Exception: + logger.exception("[whatsapp] Failed to send %s message", media_type) + return None + + async def verify_token(token: str) -> dict: """Verify WhatsApp Cloud API credentials.""" diff --git a/cptr/utils/bridge.py b/cptr/utils/bridge.py index cef364a..287535d 100644 --- a/cptr/utils/bridge.py +++ b/cptr/utils/bridge.py @@ -16,7 +16,7 @@ import logging import time import uuid from abc import ABC, abstractmethod -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Awaitable, Callable, Optional logger = logging.getLogger(__name__) @@ -31,6 +31,16 @@ TYPING_INTERVAL = 5.0 # ── Adapter interface ──────────────────────────────────────── +@dataclass +class Attachment: + """A file attached to a message.""" + + type: str # "image", "audio", "document" + filename: str # Original filename or generated one + data: bytes # Raw file content + mime_type: str # e.g. "image/jpeg", "audio/ogg" + + @dataclass class MessageEvent: """Normalized inbound message from any platform.""" @@ -40,6 +50,7 @@ class MessageEvent: sender_id: str # Platform's user ID sender_name: str # Display name text: str # Message content + attachments: list[Attachment] = field(default_factory=list) class BaseAdapter(ABC): @@ -80,6 +91,18 @@ class BaseAdapter(ABC): """Show a typing indicator in the chat.""" ... + async def send_photo(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None: + """Send a photo. Default: falls back to send() with caption only.""" + if caption: + return await self.send(chat_id, caption) + return None + + async def send_document(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None: + """Send a file. Default: falls back to send() with caption only.""" + if caption: + return await self.send(chat_id, caption) + return None + # ── Message chunking ───────────────────────────────────────── @@ -353,9 +376,94 @@ class BotManager: pass return + if cmd == "/stop": + chat_id = await find_chat_for_thread(bot["id"], event.chat_id) + cancelled = False + if chat_id: + from cptr.utils.chat_task import _tasks, _task_chat, cancel_task + for mid, cid in list(_task_chat.items()): + if cid == chat_id and mid in _tasks and not _tasks[mid].done(): + await cancel_task(mid) + cancelled = True + break + if adapter: + try: + msg = "âšī¸ Stopped." if cancelled else "Nothing running." + await adapter.send(event.chat_id, msg) + except Exception: + pass + return + + if cmd == "/retry": + from cptr.models import ChatMessage + chat_id = await find_chat_for_thread(bot["id"], event.chat_id) + if not chat_id: + if adapter: + try: + await adapter.send(event.chat_id, "No active conversation. Send a message first.") + except Exception: + pass + return + # Check if a task is already running + from cptr.utils.chat_task import get_active_chat_ids + if chat_id in get_active_chat_ids(): + if adapter: + try: + await adapter.send(event.chat_id, "âŗ A task is already running. Use /stop first.") + except Exception: + pass + return + # Find the last user message + msgs = await ChatMessage.get_all_by_chat(chat_id) + last_user = None + for m in reversed(msgs): + if m.role == "user": + last_user = m + break + if not last_user: + if adapter: + try: + await adapter.send(event.chat_id, "Nothing to retry.") + except Exception: + pass + return + # Re-dispatch with the same text as a new branch + event.text = last_user.content + await self._dispatch_task(chat_id, event, bot, adapter) + return + + if cmd == "/model": + parts = clean.split(None, 1) + new_model = parts[1].strip() if len(parts) > 1 else "" + if not new_model: + if adapter: + try: + await adapter.send(event.chat_id, f"Current model: `{bot['model_id']}`") + except Exception: + pass + return + # Update bot config + from cptr.models import Config + bots_raw = await Config.get("bots") or [] + for b in bots_raw: + if b.get("id") == bot["id"]: + b["model_id"] = new_model + break + await Config.upsert({"bots": bots_raw}) + bot["model_id"] = new_model + if adapter: + try: + await adapter.send(event.chat_id, f"✅ Model switched to: `{new_model}`") + except Exception: + pass + return + if cmd == "/help": help_text = ( "/new — Start a new conversation\n" + "/stop — Stop the running agent\n" + "/retry — Retry the last message\n" + "/model [id] — Show or switch model\n" "/workspace — Switch workspace (starts new chat)\n" "/workspaces — List available workspaces\n" "/help — Show this message" @@ -434,7 +542,7 @@ class BotManager: pass return - if not clean: + if not clean and not event.attachments: return # 3. Thread mapping — find or create a cptr chat @@ -474,14 +582,130 @@ class BotManager: return chat.id + async def _process_attachments( + self, + attachments: list[Attachment], + adapter: BaseAdapter | None, + platform_chat_id: str, + ) -> tuple[list[dict], str]: + """Process inbound attachments: save to storage, transcribe voice. + + Returns (file_entries_for_meta, text_to_prepend). + file_entries match the web UI format so _load_message_history handles + them automatically (base64 for images, file:// refs for documents). + """ + from cptr.models import Config + from cptr.utils.config import _get_jwt_secret + from cptr.utils.crypto import decrypt_key + from cptr.utils.storage import get_storage + + file_entries: list[dict] = [] + text_parts: list[str] = [] + + for att in attachments: + if att.type == "audio": + # Voice message → transcribe via Whisper + transcript = await self._transcribe_voice(att, adapter, platform_chat_id) + if transcript: + text_parts.append(transcript) + continue + + # Images and documents → save to blob storage + file_id = str(uuid.uuid4()) + try: + await get_storage().put(file_id, att.data) + except Exception: + logger.exception("[bridge] Failed to save attachment %s", att.filename) + continue + + entry: dict = { + "id": file_id, + "name": att.filename, + "content_type": att.mime_type, + } + if att.type == "image": + entry["type"] = "image" + else: + entry["type"] = "file" + + file_entries.append(entry) + logger.info("[bridge] Saved attachment %s (%s, %d bytes)", att.filename, att.type, len(att.data)) + + return file_entries, "\n".join(text_parts) + + async def _transcribe_voice( + self, + att: Attachment, + adapter: BaseAdapter | None, + platform_chat_id: str, + ) -> str: + """Transcribe a voice attachment using the configured STT API. + + Returns the transcript text, or empty string on failure/not configured. + """ + from cptr.models import Config + from cptr.utils.config import _get_jwt_secret + from cptr.utils.crypto import decrypt_key + + api_key_encrypted = await Config.get("audio.stt_api_key") + if not api_key_encrypted: + # STT not configured — warn the user + if adapter: + try: + await adapter.send( + platform_chat_id, + "âš ī¸ Voice messages require speech-to-text to be configured in Settings → Audio.", + ) + except Exception: + pass + return "" + + api_key = decrypt_key(api_key_encrypted, _get_jwt_secret()) + base_url = (await Config.get("audio.stt_base_url")) or "https://api.openai.com/v1" + model = (await Config.get("audio.stt_model")) or "whisper-1" + + try: + from cptr.routers.audio import _transcribe_chunk + + transcript = await _transcribe_chunk( + data=att.data, + filename=att.filename, + content_type=att.mime_type, + base_url=base_url, + api_key=api_key, + model=model, + ) + if transcript: + logger.info("[bridge] Transcribed voice message: %d chars", len(transcript)) + return transcript + except Exception: + logger.exception("[bridge] Voice transcription failed") + if adapter: + try: + await adapter.send(platform_chat_id, "âš ī¸ Failed to transcribe voice message.") + except Exception: + pass + return "" + async def _dispatch_task( self, chat_id: str, event: MessageEvent, bot: dict, adapter: BaseAdapter | None, ) -> None: - from cptr.models import Chat, ChatMessage + from cptr.models import Chat, ChatMessage, Config from cptr.routers.chat import _resolve_connection from cptr.utils.chat_task import start_task, _task_chat from cptr.utils.config import now_ms + # ── Process attachments ── + user_meta: dict | None = None + if event.attachments: + file_entries, text_prepend = await self._process_attachments( + event.attachments, adapter, event.chat_id, + ) + if file_entries: + user_meta = {"files": file_entries} + if text_prepend: + event.text = f"{text_prepend}\n{event.text}".strip() if event.text else text_prepend + # Get current chat state for parent_id threading chat = await Chat.get_by_id(chat_id) parent_id = chat.current_message_id if chat else None @@ -492,7 +716,7 @@ class BotManager: await ChatMessage.create( chat_id=chat_id, role="user", content=event.text, parent_id=parent_id, - meta={"queued": True}, created_at=now_ms(), + meta={"queued": True, **(user_meta or {})}, created_at=now_ms(), ) logger.info("[bridge] Queued message for busy chat %s", chat_id[:8]) return @@ -500,7 +724,7 @@ class BotManager: # Create user message — parent_id chains to previous assistant reply user_msg = await ChatMessage.create( chat_id=chat_id, role="user", content=event.text, - parent_id=parent_id, created_at=now_ms(), + parent_id=parent_id, meta=user_meta, created_at=now_ms(), ) # Resolve model connection diff --git a/pyproject.toml b/pyproject.toml index 425683d..9c623fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.4.2" +version = "0.4.3" description = "Your computer, from anywhere. Code, manage, and control your machine from the web." license = {file = "LICENSE"} readme = "README.md"