From ad00254122f981b29df7e9655dd8b7823f578950 Mon Sep 17 00:00:00 2001 From: Padawan-GM Date: Mon, 16 Feb 2026 12:16:32 +0100 Subject: [PATCH] Let clanker cook --- libsignal.py | 270 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 169 insertions(+), 101 deletions(-) diff --git a/libsignal.py b/libsignal.py index 674252b..eb345b5 100644 --- a/libsignal.py +++ b/libsignal.py @@ -1,11 +1,14 @@ import asyncio +import logging from json import loads from pathlib import Path -from typing import Callable, Literal +from typing import Any, Callable, Literal from uuid import uuid4 from aiohttp import ClientSession +logger = logging.getLogger(__name__) + class User: def __init__( @@ -41,8 +44,9 @@ class User: self.username = username @classmethod - def self(cls): - return cls("", number="+393406838100") + def self(cls, number: str | None = None): + """Create a User instance representing the current user.""" + return cls("", number=number) def __repr__(self) -> str: return f'' @@ -54,16 +58,16 @@ class Group: id: str, name: str | None = None, description: str | None = None, - members: list[User] = [], - admins: list[User] = [], - banned: list[User] = [], + members: list[User] | None = None, + admins: list[User] | None = None, + banned: list[User] | None = None, ): self.id = id self.name = name self.description = description - self.members = members - self.admins = admins - self.banned = banned + self.members = members or [] + self.admins = admins or [] + self.banned = banned or [] def __repr__(self) -> str: return f'' @@ -113,11 +117,11 @@ class Message: user: User | None = None, group: Group | None = None, timestamp: int | None = None, - attachments: list[Path] = [], - view_once: bool = False, # TODO: fix + attachments: list[Path] | None = None, + view_once: bool = False, sticker: Sticker | None = None, - mentions: list[MessageMention] = [], - styles: list[MessageStyle] = [], + mentions: list[MessageMention] | None = None, + styles: list[MessageStyle] | None = None, quote: "Message | Poll | None" = None, edit: "Message | None" = None, ): @@ -127,11 +131,11 @@ class Message: self.group = group self.timestamp = timestamp - self.attachments = attachments + self.attachments = attachments or [] self.view_once = view_once self.sticker = sticker - self.mentions = mentions - self.styles = styles + self.mentions = mentions or [] + self.styles = styles or [] self.quote = quote self.edit = edit @@ -141,22 +145,23 @@ class Message: user=self.user, group=self.group, timestamp=self.timestamp, - attachments=self.attachments, + attachments=self.attachments.copy(), view_once=self.view_once, sticker=self.sticker, - mentions=self.mentions, - styles=self.styles, + mentions=self.mentions.copy(), + styles=self.styles.copy(), quote=self.quote, edit=self.edit, ) if isinstance(b, Message): a.text = a.text or "" + offset = len(a.text) for mention in b.mentions: a.mentions.append( MessageMention( - mention.start + len(a.text), - mention.length + len(a.text), + mention.start + offset, + mention.length, mention.recipient, ), ) @@ -187,12 +192,30 @@ class Poll: class Signal: - def __init__(self, host: str, port: int): + def __init__(self, host: str, port: int, self_number: str | None = None): self.url = host.rstrip("/") + ":" + str(port) self._hooks: list[Callable] = [] + self._session: ClientSession | None = None + self._self_number = self_number - async def _post(self, method: str, **params): - async with ClientSession() as session: + async def __aenter__(self): + self._session = ClientSession() + return self + + async def __aexit__(self, *args): + if self._session: + await self._session.close() + + async def _get_session(self) -> ClientSession: + """Get or create a session for API calls.""" + if self._session is None: + self._session = ClientSession() + return self._session + + async def _post(self, method: str, **params) -> dict[str, Any]: + """Make a JSON-RPC POST request to the Signal API.""" + session = await self._get_session() + try: async with session.post( self.url + "/api/v1/rpc", json={ @@ -203,12 +226,23 @@ class Signal: }, ) as response: if response.status == 200: - return await response.json() + result = await response.json() + if "error" in result: + raise ValueError(f"API error: {result['error']}") + return result + text = await response.text() raise ConnectionError( - f"Error while sending {method} to Server: HTTP code {response.status}." + f"Error while sending {method} to Server: HTTP {response.status}. {text}" ) + except Exception as e: + logger.error(f"Failed to call {method}: {e}") + raise + + async def sendMessage(self, message: Message, recipient: User | Group) -> dict[str, Any]: + """Send a message to a user or group.""" + if not message.text and not message.attachments and not message.sticker: + raise ValueError("Message must have text, attachments, or sticker") - async def sendMessage(self, message: Message, recipient: User | Group): params: dict = {} if isinstance(recipient, User): params["recipient"] = recipient.id @@ -259,11 +293,12 @@ class Signal: timestamp = result.get("result", {}).get("timestamp") if timestamp: message.timestamp = timestamp - message.user = User.self() + message.user = User.self(self._self_number) return result - async def sendReaction(self, emoji: str, message: Message, recipient: User | Group): + async def sendReaction(self, emoji: str, message: Message, recipient: User | Group) -> dict[str, Any]: + """Send a reaction emoji to a message.""" params: dict = {"emoji": emoji} if isinstance(recipient, User): params["recipient"] = recipient.id @@ -278,7 +313,8 @@ class Signal: return await self._post("sendReaction", **params) - async def startTyping(self, recipient: User | Group): + async def startTyping(self, recipient: User | Group) -> dict[str, Any]: + """Start typing indicator for a recipient.""" params: dict = {} if isinstance(recipient, User): params["recipient"] = recipient.id @@ -287,7 +323,8 @@ class Signal: return await self._post("sendTyping", **params) - async def stopTyping(self, recipient: User | Group): + async def stopTyping(self, recipient: User | Group) -> dict[str, Any]: + """Stop typing indicator for a recipient.""" params: dict = {"stop": True} if isinstance(recipient, User): params["recipient"] = recipient.id @@ -296,7 +333,11 @@ class Signal: return await self._post("sendTyping", **params) - async def deleteMessage(self, message: Message, recipient: User | Group): + async def deleteMessage(self, message: Message, recipient: User | Group) -> dict[str, Any]: + """Delete a message remotely.""" + if not message.timestamp: + raise ValueError("Message must have a timestamp to be deleted") + params: dict = {} if isinstance(recipient, User): params["recipient"] = recipient.id @@ -307,7 +348,8 @@ class Signal: return await self._post("remoteDelete", **params) - async def sendPoll(self, poll: Poll, recipient: User | Group): + async def sendPoll(self, poll: Poll, recipient: User | Group) -> dict[str, Any]: + """Send a poll to a user or group.""" params: dict = {} if isinstance(recipient, User): params["recipient"] = recipient.id @@ -325,7 +367,7 @@ class Signal: timestamp = result.get("result", {}).get("timestamp") if timestamp: poll.timestamp = timestamp - poll.user = User.self() + poll.user = User.self(self._self_number) return result @@ -383,76 +425,83 @@ class Signal: user: list[User] | User | None = None, group: list[Group] | Group | None = None, ) -> Callable: + """Decorator to register a message handler with optional user/group filters.""" def wrapper(func: Callable): async def callback(data: dict): - envelope = data.get("envelope", {}) - if envelope and envelope.get("dataMessage"): - dataMessage = envelope["dataMessage"] + try: + envelope = data.get("envelope", {}) + if envelope and envelope.get("dataMessage"): + dataMessage = envelope["dataMessage"] - message: str | None = dataMessage["message"] + message: str | None = dataMessage.get("message") - groupId: str | None = None + groupId: str | None = None - if dataMessage.get("groupInfo"): - groupId = dataMessage["groupInfo"]["groupId"] + if dataMessage.get("groupInfo"): + groupId = dataMessage["groupInfo"]["groupId"] - sticker: Sticker | None = None - if dataMessage.get("sticker"): - s = dataMessage["sticker"] - sticker = Sticker(s["packId"], s["stickerId"]) + sticker: Sticker | None = None + if dataMessage.get("sticker"): + s = dataMessage["sticker"] + sticker = Sticker(s["packId"], s["stickerId"]) - # attachments - # contacts + # attachments + # contacts - viewOnce: bool = dataMessage["viewOnce"] + viewOnce: bool = dataMessage.get("viewOnce", False) - sourceUuid: str = envelope["sourceUuid"] - timestamp: int = envelope["timestamp"] + sourceUuid: str = envelope["sourceUuid"] + timestamp: int = envelope["timestamp"] - msg_user: User = await self.getUser(sourceUuid) - msg_group: Group | None = ( - (await self.getGroup(groupId)) if groupId else None - ) - - if message or sticker: - msg = Message( - text=message, - user=msg_user, - group=msg_group, - timestamp=timestamp, - sticker=sticker, - view_once=viewOnce, + msg_user: User = await self.getUser(sourceUuid) + msg_group: Group | None = ( + (await self.getGroup(groupId)) if groupId else None ) - await self._post( - "sendReceipt", - recipient=msg_user.id, - targetTimestamp=timestamp, - type="viewed", - ) + if message or sticker: + msg = Message( + text=message, + user=msg_user, + group=msg_group, + timestamp=timestamp, + sticker=sticker, + view_once=viewOnce, + ) - if not user and not group: - return await func(msg) + try: + await self._post( + "sendReceipt", + recipient=msg_user.id, + targetTimestamp=timestamp, + type="viewed", + ) + except Exception as e: + logger.warning(f"Failed to send receipt: {e}") - if isinstance(user, User) and user.id == msg_user.id: - return await func(msg) + if not user and not group: + return await func(msg) - if ( - isinstance(group, Group) - and msg_group - and group.id == msg_group.id - ): - return await func(msg) + if isinstance(user, User) and user.id == msg_user.id: + return await func(msg) - if isinstance(user, list): - for usr in user: - if usr.id == msg_user.id: - return await func(msg) + if ( + isinstance(group, Group) + and msg_group + and group.id == msg_group.id + ): + return await func(msg) - if isinstance(group, list) and msg_group: - for grp in group: - if grp.id == msg_group.id: - return await func(msg) + if isinstance(user, list): + for usr in user: + if usr.id == msg_user.id: + return await func(msg) + + if isinstance(group, list) and msg_group: + for grp in group: + if grp.id == msg_group.id: + return await func(msg) + except Exception as e: + logger.error(f"Error in message handler: {e}", exc_info=True) self._hooks.append(callback) return func @@ -460,26 +509,45 @@ class Signal: return wrapper def onMessageRaw(self) -> Callable: + """Decorator to register a raw message handler that receives unprocessed data.""" def wrapper(func: Callable): async def callback(data: dict): - await func(data) + try: + await func(data) + except Exception as e: + logger.error(f"Error in raw message handler: {e}", exc_info=True) self._hooks.append(callback) return func return wrapper - async def loop(self): - async with ClientSession() as session: - async with session.get(self.url + "/api/v1/events") as response: - if response.status == 200: - async for msg in response.content: - msg_data = msg.decode().strip() - if msg_data.startswith("data"): - try: - data = loads(msg_data.lstrip("data:")) - except Exception: - data = {} + async def loop(self, retry_delay: int = 5): + """ + Start the event loop to receive messages from Signal. + Automatically reconnects on connection loss. + """ + while True: + try: + session = await self._get_session() + logger.info(f"Connecting to Signal events at {self.url}/api/v1/events") + async with session.get(self.url + "/api/v1/events") as response: + if response.status == 200: + logger.info("Connected to Signal events") + async for msg in response.content: + msg_data = msg.decode().strip() + if msg_data.startswith("data"): + try: + data = loads(msg_data.lstrip("data:")) + except Exception as e: + logger.warning(f"Failed to parse message: {e}") + continue - for callback in self._hooks: - asyncio.create_task(callback(data)) + for callback in self._hooks: + asyncio.create_task(callback(data)) + else: + logger.error(f"Failed to connect: HTTP {response.status}") + await asyncio.sleep(retry_delay) + except Exception as e: + logger.error(f"Connection lost: {e}. Retrying in {retry_delay}s...") + await asyncio.sleep(retry_delay)