This commit is contained in:
Timothy Jaeryang Baek
2026-06-14 14:14:08 +02:00
Unverified
parent 0b3eaea596
commit 5fcd0a6411
8 changed files with 725 additions and 23 deletions
+9
View File
@@ -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
+66 -2
View File
@@ -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:
+83 -2
View File
@@ -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."""
+81 -2
View File
@@ -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."""
+114 -2
View File
@@ -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:
+142 -9
View File
@@ -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."""
+229 -5
View File
@@ -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 <name> — 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
+1 -1
View File
@@ -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"