initial working state
This commit is contained in:
parent
816fb34f75
commit
bb9a24c1ed
15 changed files with 688 additions and 0 deletions
6
main.py
Normal file
6
main.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
def main():
|
||||||
|
print("Hello from synclean!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
17
src/synclean/__main__.py
Normal file
17
src/synclean/__main__.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import sys
|
||||||
|
from synclean.cli.commands import cli
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
cli()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nGoodbye!")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"An error occurred: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
42
src/synclean/api/rooms.py
Normal file
42
src/synclean/api/rooms.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
from typing import List
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from synclean.api.synapse import SynapseApiClient
|
||||||
|
from synclean.models.pagination import PaginationParams, RoomPaginationParams
|
||||||
|
from synclean.models.room import Room, RoomListResponse
|
||||||
|
from synclean.models.media import MediaURIs
|
||||||
|
|
||||||
|
|
||||||
|
class RoomAPI:
|
||||||
|
def __init__(self, client: SynapseApiClient):
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def get_rooms_list(self, pagination: RoomPaginationParams = None) -> RoomListResponse:
|
||||||
|
if pagination is None:
|
||||||
|
pagination = RoomPaginationParams()
|
||||||
|
|
||||||
|
params = pagination.to_query_params()
|
||||||
|
|
||||||
|
query_string = urlencode(params)
|
||||||
|
response = self.client.request("GET", "/v1/rooms", params)
|
||||||
|
return RoomListResponse.from_api_response(response)
|
||||||
|
|
||||||
|
def get_room_details(self, room_id: str):
|
||||||
|
response = self.client.request("GET", f"/v1/rooms/{room_id}")
|
||||||
|
return Room.from_api_response(response)
|
||||||
|
|
||||||
|
def get_room_avatar(self, room_id: str):
|
||||||
|
room_details = self.get_room_details(room_id)
|
||||||
|
room_avatar = room_details.avatar
|
||||||
|
if room_avatar:
|
||||||
|
return room_avatar
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_media_from_room(self, room_id):
|
||||||
|
response = self.client.request("GET", f"/v1/room/{room_id}/media")
|
||||||
|
return MediaURIs.from_json(response)
|
||||||
|
|
||||||
|
def delete_room_media_by_id(self, server_name: str,media_id: str):
|
||||||
|
response = self.client.request("DELETE", f"/v1/media/{server_name}/{media_id}")
|
||||||
|
return
|
||||||
|
|
28
src/synclean/api/synapse.py
Normal file
28
src/synclean/api/synapse.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from logging import Logger
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from synclean.models.config import SynapseConfig
|
||||||
|
from synclean.utils.exceptions import AuthenticationError, APIError
|
||||||
|
|
||||||
|
|
||||||
|
class SynapseApiClient:
|
||||||
|
"""Synapse API endpoints."""
|
||||||
|
|
||||||
|
def __init__(self, config: SynapseConfig) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.logger = Logger(__name__)
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update(config.headers)
|
||||||
|
|
||||||
|
def request(self, method: str, endpoint: str, params = None) -> Dict[str, Any]:
|
||||||
|
"""Send a request to the Synapse API."""
|
||||||
|
url = f"{self.config.base_url}{endpoint}"
|
||||||
|
try:
|
||||||
|
response = self.session.request(method, url, params)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
if e.response.status_code == 401:
|
||||||
|
raise AuthenticationError(f"Authentication failed: {e}") from e
|
||||||
|
raise APIError(f"Network error: {e}") from e
|
19
src/synclean/api/users.py
Normal file
19
src/synclean/api/users.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
from synclean.api.synapse import SynapseApiClient
|
||||||
|
from synclean.models.pagination import UserPaginationParams
|
||||||
|
from synclean.models.user import UserMediaList
|
||||||
|
|
||||||
|
|
||||||
|
class UserAPI:
|
||||||
|
def __init__(self, client: SynapseApiClient):
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def get_users_list_by_media(self, pagination: UserPaginationParams = None) -> UserMediaList:
|
||||||
|
if pagination is None:
|
||||||
|
pagination = UserPaginationParams()
|
||||||
|
|
||||||
|
params = pagination.to_query_params()
|
||||||
|
|
||||||
|
response = self.client.request("GET", "/v1/statistics/users/media", params)
|
||||||
|
return UserMediaList.from_api_response(response)
|
||||||
|
|
||||||
|
|
180
src/synclean/cli/commands.py
Normal file
180
src/synclean/cli/commands.py
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
from typing import Optional
|
||||||
|
import click
|
||||||
|
from synclean.api.rooms import RoomAPI
|
||||||
|
from synclean.api.synapse import SynapseApiClient
|
||||||
|
from synclean.api.users import UserAPI
|
||||||
|
from synclean.config.settings import SettingsManager
|
||||||
|
from synclean.models.enums import RoomOrderBy, Direction, UserOrderBy
|
||||||
|
from synclean.models.pagination import PaginationParams, RoomPaginationParams, UserPaginationParams
|
||||||
|
|
||||||
|
pass_api = click.make_pass_decorator(SynapseApiClient)
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx):
|
||||||
|
"""Synclean CLI."""
|
||||||
|
settings = SettingsManager()
|
||||||
|
config = settings.load_config()
|
||||||
|
ctx.obj = SynapseApiClient(config)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def room():
|
||||||
|
"""Room commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def user():
|
||||||
|
"""User commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@room.command("ls")
|
||||||
|
@click.option("--limit", "-l", default=100, help="Number of rooms to display per page.")
|
||||||
|
@click.option("--offset", "-o", default=0, help="Number of the room to start from.")
|
||||||
|
@click.option("--search", "-s", help="Search term to filter rooms by.")
|
||||||
|
@click.option("--order-by", "-O",
|
||||||
|
type=click.Choice(["name", "joined_members", "canonical_alias", "creator", "version",]),
|
||||||
|
default="name", help="Field to order result by.")
|
||||||
|
@click.option("--direction", "-d",
|
||||||
|
type=click.Choice(["f", "b"]),
|
||||||
|
default="b", help="Direction to order result by. (f=forward, b=backward)")
|
||||||
|
@pass_api
|
||||||
|
def room_ls(
|
||||||
|
api: SynapseApiClient,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
search: Optional[str],
|
||||||
|
order_by: str,
|
||||||
|
direction: str
|
||||||
|
):
|
||||||
|
order_by_enum = RoomOrderBy(order_by)
|
||||||
|
direction_enum = Direction(direction)
|
||||||
|
|
||||||
|
rooms_api = RoomAPI(api)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
pagination_params = RoomPaginationParams(limit, offset, order_by_enum, direction_enum, search)
|
||||||
|
|
||||||
|
rooms = rooms_api.get_rooms_list(pagination_params)
|
||||||
|
if rooms:
|
||||||
|
for r in rooms.rooms:
|
||||||
|
click.echo(f"Name: {r.name}")
|
||||||
|
click.echo(f"ID: {r.room_id}")
|
||||||
|
click.echo(f"Joined Members: {r.joined_members}")
|
||||||
|
click.echo("=" * 50)
|
||||||
|
else:
|
||||||
|
click.echo("No rooms found.")
|
||||||
|
|
||||||
|
offset += limit
|
||||||
|
|
||||||
|
choice = input("Next page? (y/n): ")
|
||||||
|
if choice.lower() != "y":
|
||||||
|
break
|
||||||
|
|
||||||
|
@room.command("show")
|
||||||
|
@click.argument("room_id")
|
||||||
|
@click.option("--media", is_flag=True, help="List media in the room")
|
||||||
|
@click.option("--rm-all", is_flag=True, help="Remove all media in the room")
|
||||||
|
@click.option("--rm", "remove_media_id", help="Remove media by ID")
|
||||||
|
@click.option("--avatar", is_flag=True, help="Show avatar URI")
|
||||||
|
@pass_api
|
||||||
|
def show_room(
|
||||||
|
api: SynapseApiClient,
|
||||||
|
room_id: str,
|
||||||
|
media: bool,
|
||||||
|
rm_all: bool,
|
||||||
|
remove_media_id: Optional[str],
|
||||||
|
avatar: bool
|
||||||
|
):
|
||||||
|
if room_id is None:
|
||||||
|
click.echo("You must specify a room ID.", err=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if rm_all or remove_media_id:
|
||||||
|
if not media:
|
||||||
|
click.echo("You must specify --media to remove media.")
|
||||||
|
return
|
||||||
|
|
||||||
|
room_api = RoomAPI(api)
|
||||||
|
room_details = room_api.get_room_details(room_id)
|
||||||
|
|
||||||
|
if not room_details:
|
||||||
|
click.echo(f"Error: Room {room_id} not found.", err=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if avatar:
|
||||||
|
if not room_details.avatar:
|
||||||
|
click.echo("Room has no avatar.")
|
||||||
|
return
|
||||||
|
click.echo(room_details.avatar)
|
||||||
|
# TODO: implement adding avatar to blacklist
|
||||||
|
|
||||||
|
if rm_all:
|
||||||
|
if click.confirm("Are you sure you want to remove all media in this room?"):
|
||||||
|
# TODO: implement removing all media from the room
|
||||||
|
pass
|
||||||
|
|
||||||
|
if remove_media_id:
|
||||||
|
if click.confirm(f"Are you sure you want to remove media {remove_media_id} from the room?"):
|
||||||
|
# TODO: implement removing media by ID
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not media:
|
||||||
|
click.echo(f"Room details: {room_id}")
|
||||||
|
click.echo("=" * 40)
|
||||||
|
click.echo(f"Name: {room_details.name}")
|
||||||
|
click.echo(f"Avatar: {room_details.avatar}")
|
||||||
|
click.echo(f"ID: {room_details.room_id}")
|
||||||
|
click.echo(f"Members: {room_details.joined_members} (Local: {room_details.joined_local_members})")
|
||||||
|
click.echo(f"Creator: {room_details.creator}")
|
||||||
|
click.echo(f"Encryption: {room_details.encryption}")
|
||||||
|
click.echo(f"Public: {room_details.public}")
|
||||||
|
click.echo(f"Guest Access: {room_details.guest_access}")
|
||||||
|
click.echo(f"History Visibility: {room_details.history_visibility}")
|
||||||
|
click.echo(f"State Events: {room_details.state_events}")
|
||||||
|
click.echo(f"Room Type: {room_details.room_type}")
|
||||||
|
else:
|
||||||
|
room_media = room_api.get_media_from_room(room_id)
|
||||||
|
if room_media:
|
||||||
|
for uri in room_media.all_uris:
|
||||||
|
click.echo(f"URI: {uri}")
|
||||||
|
|
||||||
|
@user.command("ls")
|
||||||
|
@click.option("--limit", "-l", default=10, help="Number of users to list.")
|
||||||
|
@click.option("--offset", "-o", default=0, help="Number of the room to start from.")
|
||||||
|
@click.option("--search", "-s", help="Search term to filter by.")
|
||||||
|
@click.option("--order-by", "-O",
|
||||||
|
type=click.Choice(["user_id", "display_name", "media_length", "media_count"]),
|
||||||
|
default="media_length", help="Field to order result by.")
|
||||||
|
@click.option("--direction", "-d",
|
||||||
|
type=click.Choice(["f", "b"]),
|
||||||
|
default="b", help="Direction to order result by. (f=forward, b=backward)")
|
||||||
|
@pass_api
|
||||||
|
def user_ls(
|
||||||
|
api: SynapseApiClient,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
search: Optional[str],
|
||||||
|
order_by: str,
|
||||||
|
direction: str
|
||||||
|
):
|
||||||
|
|
||||||
|
order_by_enum = UserOrderBy(order_by)
|
||||||
|
direction_enum = Direction(direction)
|
||||||
|
|
||||||
|
users_api = UserAPI(api)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
pagination_params = UserPaginationParams(limit, offset, order_by_enum, direction_enum, search)
|
||||||
|
|
||||||
|
users = users_api.get_users_list_by_media(pagination_params)
|
||||||
|
|
||||||
|
if users:
|
||||||
|
for u in users.users:
|
||||||
|
click.echo(f"Display name: {u.display_name}")
|
||||||
|
click.echo(f"Media count: {u.media_count}")
|
||||||
|
click.echo(f"Media length: {u.media_length_mb()}MB")
|
||||||
|
click.echo(f"User ID: {u.user_id}")
|
||||||
|
click.echo("=" * 40)
|
24
src/synclean/config/paths.py
Normal file
24
src/synclean/config/paths.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
from pathlib import Path
|
||||||
|
import platformdirs
|
||||||
|
|
||||||
|
|
||||||
|
APP_NAME = "synclean"
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_dir() -> Path:
|
||||||
|
"""Get platform-specific application directory"""
|
||||||
|
app_dir = Path(platformdirs.user_data_dir(APP_NAME))
|
||||||
|
app_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return app_dir
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_path() -> Path:
|
||||||
|
return get_app_dir() / "config.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_avatars_path() -> Path:
|
||||||
|
return get_app_dir() / "users.json"
|
||||||
|
|
||||||
|
|
||||||
|
def get_room_avatars_path() -> Path:
|
||||||
|
return get_app_dir() / "rooms.json"
|
57
src/synclean/config/settings.py
Normal file
57
src/synclean/config/settings.py
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
import yaml
|
||||||
|
from .paths import get_config_path
|
||||||
|
from synclean.models.config import SynapseConfig
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsManager:
|
||||||
|
"""Manage Synapse configuration persistence in YAML format."""
|
||||||
|
DEFAULT_PATH = get_config_path()
|
||||||
|
|
||||||
|
def __init__(self, config_path: Path = DEFAULT_PATH) -> None:
|
||||||
|
self.config_path = config_path
|
||||||
|
|
||||||
|
def load_config(self) -> SynapseConfig:
|
||||||
|
"""Load Synapse configuration from YAML file with validation."""
|
||||||
|
try:
|
||||||
|
with self.config_path.open('r', encoding="utf-8") as f:
|
||||||
|
config_data = yaml.safe_load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise FileNotFoundError(f"Config file not found: {self.config_path}") from None
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise ValueError(f"Invalid YAML syntax: {e}") from e
|
||||||
|
|
||||||
|
if not config_data:
|
||||||
|
raise ValueError("Config file is empty")
|
||||||
|
|
||||||
|
return SynapseConfig.from_dict(config_data)
|
||||||
|
|
||||||
|
def save_config(self, config: SynapseConfig) -> None:
|
||||||
|
"""Save configuration to YAML file with safety checks."""
|
||||||
|
config_dict = config.to_dict()
|
||||||
|
|
||||||
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with self.config_path.open('w', encoding='utf-8') as f:
|
||||||
|
yaml.safe_dump(
|
||||||
|
config_dict,
|
||||||
|
f,
|
||||||
|
sort_keys=False,
|
||||||
|
default_flow_style=None,
|
||||||
|
allow_unicode=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.config_path.chmod(0o600)
|
||||||
|
|
||||||
|
def update_blacklist(self, add_usernames: List[str] = None,
|
||||||
|
remove_usernames: List[str] = None) -> None:
|
||||||
|
"""Update blacklist in the config file."""
|
||||||
|
config = self.load_config()
|
||||||
|
if add_usernames:
|
||||||
|
config.add_to_blacklist(*add_usernames)
|
||||||
|
|
||||||
|
if remove_usernames:
|
||||||
|
config.remove_from_blacklist(*remove_usernames)
|
||||||
|
|
||||||
|
self.save_config(config)
|
83
src/synclean/models/config.py
Normal file
83
src/synclean/models/config.py
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, Set, Union
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SynapseConfig:
|
||||||
|
"""Store and validate configuration for Synapse API connection."""
|
||||||
|
homeserver: str
|
||||||
|
access_token: str
|
||||||
|
blacklist: Set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate Synapse config after initialization."""
|
||||||
|
if not self.homeserver.strip():
|
||||||
|
raise ValueError("Homeserver URL cannot be empty")
|
||||||
|
parsed = urlparse(self.homeserver)
|
||||||
|
if not all([parsed.scheme, parsed.netloc]):
|
||||||
|
raise ValueError(f"Invalid homeserver URL: {self.homeserver}")
|
||||||
|
|
||||||
|
if not self.access_token.strip():
|
||||||
|
raise ValueError("Access token cannot be empty")
|
||||||
|
|
||||||
|
if not isinstance(self.blacklist, set):
|
||||||
|
raise TypeError("Blacklist must be a set")
|
||||||
|
for username in self.blacklist:
|
||||||
|
if not isinstance(username, str) or not username.strip():
|
||||||
|
raise ValueError("Blacklist contains invalid username")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self) -> str:
|
||||||
|
"""Get normalized base URL with trailing slash."""
|
||||||
|
return self.homeserver.rstrip('/') + '/_synapse/admin'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def headers(self) -> Dict[str, str]:
|
||||||
|
"""Generate request headers with authentication."""
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_to_blacklist(self, *usernames: str) -> None:
|
||||||
|
"""Add one or more username to the blacklist."""
|
||||||
|
for username in usernames:
|
||||||
|
username = username.strip()
|
||||||
|
if not username:
|
||||||
|
raise ValueError("Username cannot be empty")
|
||||||
|
self.blacklist.add(username)
|
||||||
|
|
||||||
|
def remove_from_blacklist(self, *usernames: str) -> None:
|
||||||
|
"""Remove one or more username from the blacklist."""
|
||||||
|
for username in usernames:
|
||||||
|
self.blacklist.discard(username)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, str]) -> "SynapseConfig":
|
||||||
|
"""Create from dictionary with validation."""
|
||||||
|
try:
|
||||||
|
blacklist = set(data.get("blacklist", []))
|
||||||
|
return cls(
|
||||||
|
homeserver=str(data["homeserver"]),
|
||||||
|
access_token=str(data["access_token"]),
|
||||||
|
blacklist=blacklist
|
||||||
|
)
|
||||||
|
except KeyError as e:
|
||||||
|
raise ValueError(f"Missing required field: {e}") from e
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
raise ValueError(f"Invalid data type: {e}") from e
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Union[str, Set[str]]]:
|
||||||
|
"""Serialize to dictionary without sensitive exposure."""
|
||||||
|
return {
|
||||||
|
"homeserver": self.homeserver,
|
||||||
|
"access_token": self.access_token,
|
||||||
|
"blacklist": sorted(self.blacklist)
|
||||||
|
}
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Safe representation excluding access token."""
|
||||||
|
return (f"SynapseConfig(homeserver={self.homeserver!r},"
|
||||||
|
f"blacklist={len(self.blacklist)})")
|
30
src/synclean/models/enums.py
Normal file
30
src/synclean/models/enums.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class RoomOrderBy(Enum):
|
||||||
|
"""Available room ordering options."""
|
||||||
|
NAME = "name"
|
||||||
|
CANONICAL_ALIAS = "canonical_alias"
|
||||||
|
JOINED_MEMBERS = "joined_members"
|
||||||
|
JOINED_LOCAL_MEMBERS = "joined_local_members"
|
||||||
|
VERSION = "version"
|
||||||
|
CREATOR = "creator"
|
||||||
|
ENCRYPTION = "encryption"
|
||||||
|
FEDERATABLE = "federatable"
|
||||||
|
JOIN_RULE = "join_rule"
|
||||||
|
GUEST_ACCESS = "guest_access"
|
||||||
|
HISTORY_VISIBILITY = "history_visibility"
|
||||||
|
STATE_EVENTS = "state_events"
|
||||||
|
|
||||||
|
class UserOrderBy(Enum):
|
||||||
|
"""Available user ordering options."""
|
||||||
|
USER_ID = "user_id"
|
||||||
|
DISPLAY_NAME = "display_name"
|
||||||
|
MEDIA_LENGTH = "media_length"
|
||||||
|
MEDIA_COUNT = "media_count"
|
||||||
|
|
||||||
|
|
||||||
|
class Direction(Enum):
|
||||||
|
"""Sort direction."""
|
||||||
|
FORWARD = "f"
|
||||||
|
BACKWARD = "b"
|
29
src/synclean/models/media.py
Normal file
29
src/synclean/models/media.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Set
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MediaURIs:
|
||||||
|
local: List[str] = field(default_factory=list)
|
||||||
|
remote: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, data: dict) -> "MediaURIs":
|
||||||
|
return cls(
|
||||||
|
local=data.get("local", []),
|
||||||
|
remote=data.get("remote", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
def contains_avatar(self, avatar_uri: str) -> bool:
|
||||||
|
"""Check if the media URIs contain the avatar URI."""
|
||||||
|
return avatar_uri in self.all_uris
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_uris(self) -> Set[str]:
|
||||||
|
"""Get all media URIs as a set."""
|
||||||
|
return set(self.local) | set(self.remote)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_count(self) -> int:
|
||||||
|
"""Get total media count."""
|
||||||
|
return len(self.local) + len(self.remote)
|
41
src/synclean/models/pagination.py
Normal file
41
src/synclean/models/pagination.py
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Dict, Any, TypeVar, Generic
|
||||||
|
from synclean.models.enums import RoomOrderBy, Direction, UserOrderBy
|
||||||
|
|
||||||
|
OrderByType = TypeVar("OrderByType")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PaginationParams(Generic[OrderByType]):
|
||||||
|
"""Parameters for pagination."""
|
||||||
|
limit: int = 100
|
||||||
|
offset: int = 0
|
||||||
|
order_by: OrderByType = None
|
||||||
|
direction: Direction = Direction.FORWARD
|
||||||
|
search_term: Optional[str] = None
|
||||||
|
|
||||||
|
def to_query_params(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to query parameters for API request."""
|
||||||
|
params = {
|
||||||
|
'limit': self.limit,
|
||||||
|
'from': self.offset,
|
||||||
|
'order_by': self.order_by.value,
|
||||||
|
'dir': self.direction.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.search_term:
|
||||||
|
params['search_term'] = self.search_term
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoomPaginationParams(PaginationParams[RoomOrderBy]):
|
||||||
|
"""Pagination parameters for rooms."""
|
||||||
|
order_by = RoomOrderBy = RoomOrderBy.NAME
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserPaginationParams(PaginationParams[UserOrderBy]):
|
||||||
|
"""Pagination parameters for users."""
|
||||||
|
order_by = UserOrderBy = UserOrderBy.MEDIA_LENGTH
|
67
src/synclean/models/room.py
Normal file
67
src/synclean/models/room.py
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Room:
|
||||||
|
"""Dataclass representing a Matrix room"""
|
||||||
|
room_id: str
|
||||||
|
name: Optional[str] = None
|
||||||
|
canonical_alias: Optional[str] = None
|
||||||
|
joined_members: int = 0
|
||||||
|
joined_local_members: int = 0
|
||||||
|
version: Optional[str] = None
|
||||||
|
creator: Optional[str] = None
|
||||||
|
encryption: Optional[str] = None
|
||||||
|
federatable: bool = True
|
||||||
|
public: bool = True
|
||||||
|
join_rules: Optional[str] = None
|
||||||
|
guest_access: Optional[str] = None
|
||||||
|
history_visibility: Optional[str] = None
|
||||||
|
state_events: int = 0
|
||||||
|
room_type: Optional[str] = None
|
||||||
|
|
||||||
|
avatar: Optional[str] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_response(cls, data: Dict[str, Any]) -> "Room":
|
||||||
|
"""Create a Room object from a dictionary."""
|
||||||
|
return cls(
|
||||||
|
room_id=str(data['room_id']),
|
||||||
|
name=data.get('name'),
|
||||||
|
canonical_alias=data.get('canonical_alias'),
|
||||||
|
joined_members=data.get('joined_members', 0),
|
||||||
|
joined_local_members=data.get('joined_local_members', 0),
|
||||||
|
version=data.get('version'),
|
||||||
|
creator=data.get('creator'),
|
||||||
|
encryption=data.get('encryption'),
|
||||||
|
federatable=data.get('federatable', True),
|
||||||
|
public=data.get('public', True),
|
||||||
|
join_rules=data.get('join_rules'),
|
||||||
|
guest_access=data.get('guest_access'),
|
||||||
|
history_visibility=data.get('history_visibility'),
|
||||||
|
state_events=data.get('state_events', 0),
|
||||||
|
room_type=data.get('room_type'),
|
||||||
|
avatar=data.get('avatar'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoomListResponse:
|
||||||
|
"""Response from room list API"""
|
||||||
|
rooms: List[Room]
|
||||||
|
next_batch: Optional[int]
|
||||||
|
prev_batch: Optional[int]
|
||||||
|
total_rooms: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_response(cls, data: Dict[str, Any]) -> "RoomListResponse":
|
||||||
|
"""Create RoomListResponse from API response."""
|
||||||
|
rooms = [Room.from_api_response(room_data) for room_data in data.get('rooms', [])]
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
rooms=rooms,
|
||||||
|
next_batch=data.get('next_batch'),
|
||||||
|
prev_batch=data.get('prev_batch'),
|
||||||
|
total_rooms=data.get('total_rooms', len(rooms))
|
||||||
|
)
|
42
src/synclean/models/user.py
Normal file
42
src/synclean/models/user.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Optional, Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserMedia:
|
||||||
|
display_name: str
|
||||||
|
media_count: int
|
||||||
|
media_length: int
|
||||||
|
user_id: str
|
||||||
|
|
||||||
|
def media_length_mb(self, precision: int = 2) -> float:
|
||||||
|
"""Convert media length to MB."""
|
||||||
|
mb = self.media_length / (1024 * 1024)
|
||||||
|
return round(mb, precision)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_response(cls, data: Dict[str, Any]) -> "UserMedia":
|
||||||
|
"""Create UserMedia from API response."""
|
||||||
|
return cls(
|
||||||
|
display_name=data.get("displayname"),
|
||||||
|
media_count=data.get("media_count"),
|
||||||
|
media_length=data.get("media_length"),
|
||||||
|
user_id=data.get("user_id")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserMediaList:
|
||||||
|
users: List[UserMedia]
|
||||||
|
total: int
|
||||||
|
next_token: Optional[int] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_response(cls, data: Dict[str, Any]) -> "UserMediaList":
|
||||||
|
"""Create UserMediaList from API response."""
|
||||||
|
users = [UserMedia.from_api_response(user_data) for user_data in data.get("users", [])]
|
||||||
|
return cls(
|
||||||
|
users=users,
|
||||||
|
total=data.get("total", 0),
|
||||||
|
next_token=data.get("next_token")
|
||||||
|
)
|
23
src/synclean/utils/exceptions.py
Normal file
23
src/synclean/utils/exceptions.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
"""Custom exceptions for Synclean."""
|
||||||
|
|
||||||
|
class SyncleanException(Exception):
|
||||||
|
"""Base class for Synclean exceptions."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class APIError(SyncleanException):
|
||||||
|
"""Exception for API errors."""
|
||||||
|
def __init__(self, message: str, status_code: int = None, error_code: str = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.error_code = error_code
|
||||||
|
|
||||||
|
class AuthenticationError(SyncleanException):
|
||||||
|
"""Exception for authentication errors."""
|
||||||
|
|
||||||
|
class MediaNotFoundError(SyncleanException):
|
||||||
|
"""Exception for media not found errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class InvalidConfigurationError(SyncleanException):
|
||||||
|
"""Exception for invalid configuration errors."""
|
||||||
|
pass
|
Loading…
Add table
Add a link
Reference in a new issue