2025-12-11 19:04:02 -08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import mimetypes
|
|
|
|
|
from pathlib import Path
|
2025-12-13 12:09:50 -08:00
|
|
|
from typing import Any, Dict, Optional, Tuple
|
2025-12-11 19:04:02 -08:00
|
|
|
|
|
|
|
|
import requests
|
|
|
|
|
|
2025-12-12 21:55:38 -08:00
|
|
|
from ProviderCore.base import FileProvider
|
2025-12-11 19:04:02 -08:00
|
|
|
|
|
|
|
|
|
2025-12-13 12:09:50 -08:00
|
|
|
_MATRIX_INIT_CHECK_CACHE: Dict[str, Tuple[bool, Optional[str]]] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_homeserver(value: str) -> str:
|
|
|
|
|
text = str(value or "").strip()
|
|
|
|
|
if not text:
|
|
|
|
|
return ""
|
|
|
|
|
if not text.startswith("http"):
|
|
|
|
|
text = f"https://{text}"
|
|
|
|
|
return text.rstrip("/")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _matrix_health_check(*, homeserver: str, access_token: Optional[str]) -> Tuple[bool, Optional[str]]:
|
|
|
|
|
"""Lightweight Matrix reachability/auth validation.
|
|
|
|
|
|
|
|
|
|
- Always checks `/versions` (no auth).
|
|
|
|
|
- If `access_token` is present, also checks `/whoami`.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
base = _normalize_homeserver(homeserver)
|
|
|
|
|
if not base:
|
|
|
|
|
return False, "Matrix homeserver missing"
|
|
|
|
|
|
|
|
|
|
resp = requests.get(f"{base}/_matrix/client/versions", timeout=5)
|
|
|
|
|
if resp.status_code != 200:
|
|
|
|
|
return False, f"Homeserver returned {resp.status_code}"
|
|
|
|
|
|
|
|
|
|
if access_token:
|
|
|
|
|
headers = {"Authorization": f"Bearer {access_token}"}
|
|
|
|
|
resp = requests.get(f"{base}/_matrix/client/v3/account/whoami", headers=headers, timeout=5)
|
|
|
|
|
if resp.status_code != 200:
|
|
|
|
|
return False, f"Authentication failed: {resp.status_code}"
|
|
|
|
|
|
|
|
|
|
return True, None
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
return False, str(exc)
|
|
|
|
|
|
|
|
|
|
|
2025-12-11 19:04:02 -08:00
|
|
|
class Matrix(FileProvider):
|
|
|
|
|
"""File provider for Matrix (Element) chat rooms."""
|
|
|
|
|
|
2025-12-13 12:09:50 -08:00
|
|
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
|
|
|
|
super().__init__(config)
|
|
|
|
|
self._init_ok: Optional[bool] = None
|
|
|
|
|
self._init_reason: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
matrix_conf = self.config.get("provider", {}).get("matrix", {}) if isinstance(self.config, dict) else {}
|
|
|
|
|
homeserver = matrix_conf.get("homeserver")
|
|
|
|
|
room_id = matrix_conf.get("room_id")
|
|
|
|
|
access_token = matrix_conf.get("access_token")
|
|
|
|
|
password = matrix_conf.get("password")
|
|
|
|
|
|
|
|
|
|
# Not configured: keep instance but mark invalid via validate().
|
|
|
|
|
if not (homeserver and room_id and (access_token or password)):
|
|
|
|
|
self._init_ok = None
|
|
|
|
|
self._init_reason = None
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
cache_key = f"{_normalize_homeserver(str(homeserver))}|room:{room_id}|has_token:{bool(access_token)}"
|
|
|
|
|
cached = _MATRIX_INIT_CHECK_CACHE.get(cache_key)
|
|
|
|
|
if cached is None:
|
|
|
|
|
ok, reason = _matrix_health_check(homeserver=str(homeserver), access_token=str(access_token) if access_token else None)
|
|
|
|
|
_MATRIX_INIT_CHECK_CACHE[cache_key] = (ok, reason)
|
|
|
|
|
else:
|
|
|
|
|
ok, reason = cached
|
|
|
|
|
|
|
|
|
|
self._init_ok = ok
|
|
|
|
|
self._init_reason = reason
|
|
|
|
|
if not ok:
|
|
|
|
|
raise Exception(reason or "Matrix unavailable")
|
|
|
|
|
|
2025-12-11 19:04:02 -08:00
|
|
|
def validate(self) -> bool:
|
|
|
|
|
if not self.config:
|
|
|
|
|
return False
|
2025-12-13 12:09:50 -08:00
|
|
|
if self._init_ok is False:
|
|
|
|
|
return False
|
2025-12-13 00:18:30 -08:00
|
|
|
matrix_conf = self.config.get("provider", {}).get("matrix", {})
|
2025-12-11 19:04:02 -08:00
|
|
|
return bool(
|
|
|
|
|
matrix_conf.get("homeserver")
|
|
|
|
|
and matrix_conf.get("room_id")
|
|
|
|
|
and (matrix_conf.get("access_token") or matrix_conf.get("password"))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def upload(self, file_path: str, **kwargs: Any) -> str:
|
|
|
|
|
path = Path(file_path)
|
|
|
|
|
if not path.exists():
|
|
|
|
|
raise FileNotFoundError(f"File not found: {file_path}")
|
|
|
|
|
|
2025-12-13 00:18:30 -08:00
|
|
|
matrix_conf = self.config.get("provider", {}).get("matrix", {})
|
2025-12-11 19:04:02 -08:00
|
|
|
homeserver = matrix_conf.get("homeserver")
|
|
|
|
|
access_token = matrix_conf.get("access_token")
|
|
|
|
|
room_id = matrix_conf.get("room_id")
|
|
|
|
|
|
|
|
|
|
if not homeserver:
|
|
|
|
|
raise Exception("Matrix homeserver missing")
|
|
|
|
|
if not access_token:
|
|
|
|
|
raise Exception("Matrix access_token missing")
|
|
|
|
|
if not room_id:
|
|
|
|
|
raise Exception("Matrix room_id missing")
|
|
|
|
|
|
|
|
|
|
if not homeserver.startswith("http"):
|
|
|
|
|
homeserver = f"https://{homeserver}"
|
|
|
|
|
|
|
|
|
|
# Upload media
|
|
|
|
|
upload_url = f"{homeserver}/_matrix/media/v3/upload"
|
|
|
|
|
headers = {
|
|
|
|
|
"Authorization": f"Bearer {access_token}",
|
|
|
|
|
"Content-Type": "application/octet-stream",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mime_type, _ = mimetypes.guess_type(path)
|
|
|
|
|
if mime_type:
|
|
|
|
|
headers["Content-Type"] = mime_type
|
|
|
|
|
|
|
|
|
|
filename = path.name
|
|
|
|
|
|
|
|
|
|
with open(path, "rb") as handle:
|
|
|
|
|
resp = requests.post(upload_url, headers=headers, data=handle, params={"filename": filename})
|
|
|
|
|
|
|
|
|
|
if resp.status_code != 200:
|
|
|
|
|
raise Exception(f"Matrix upload failed: {resp.text}")
|
|
|
|
|
|
|
|
|
|
content_uri = resp.json().get("content_uri")
|
|
|
|
|
if not content_uri:
|
|
|
|
|
raise Exception("No content_uri returned")
|
|
|
|
|
|
|
|
|
|
# Send message
|
|
|
|
|
send_url = f"{homeserver}/_matrix/client/v3/rooms/{room_id}/send/m.room.message"
|
|
|
|
|
|
|
|
|
|
# Determine message type
|
|
|
|
|
msgtype = "m.file"
|
|
|
|
|
ext = path.suffix.lower()
|
|
|
|
|
|
|
|
|
|
audio_exts = {".mp3", ".flac", ".wav", ".m4a", ".aac", ".ogg", ".opus", ".wma", ".mka", ".alac"}
|
|
|
|
|
video_exts = {".mp4", ".mkv", ".webm", ".mov", ".avi", ".flv", ".mpg", ".mpeg", ".ts", ".m4v", ".wmv"}
|
|
|
|
|
image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
|
|
|
|
|
|
|
|
|
|
if ext in audio_exts:
|
|
|
|
|
msgtype = "m.audio"
|
|
|
|
|
elif ext in video_exts:
|
|
|
|
|
msgtype = "m.video"
|
|
|
|
|
elif ext in image_exts:
|
|
|
|
|
msgtype = "m.image"
|
|
|
|
|
|
|
|
|
|
info = {"mimetype": mime_type, "size": path.stat().st_size}
|
|
|
|
|
payload = {"msgtype": msgtype, "body": filename, "url": content_uri, "info": info}
|
|
|
|
|
|
|
|
|
|
resp = requests.post(send_url, headers=headers, json=payload)
|
|
|
|
|
if resp.status_code != 200:
|
|
|
|
|
raise Exception(f"Matrix send message failed: {resp.text}")
|
|
|
|
|
|
|
|
|
|
event_id = resp.json().get("event_id")
|
|
|
|
|
return f"https://matrix.to/#/{room_id}/{event_id}"
|