mirror of
https://github.com/open-webui/computer.git
synced 2026-11-15 18:35:31 +03:00
refac
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user