Let clanker cook
This commit is contained in:
parent
d78dcfc4f2
commit
ad00254122
1 changed files with 169 additions and 101 deletions
148
libsignal.py
148
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'<User "{self.name}" at "{self.number}">'
|
||||
|
|
@ -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'<Group "{self.name}" at "{self.id}">'
|
||||
|
|
@ -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,13 +425,15 @@ 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):
|
||||
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
|
||||
|
||||
|
|
@ -404,7 +448,7 @@ class Signal:
|
|||
# attachments
|
||||
# contacts
|
||||
|
||||
viewOnce: bool = dataMessage["viewOnce"]
|
||||
viewOnce: bool = dataMessage.get("viewOnce", False)
|
||||
|
||||
sourceUuid: str = envelope["sourceUuid"]
|
||||
timestamp: int = envelope["timestamp"]
|
||||
|
|
@ -424,12 +468,15 @@ class Signal:
|
|||
view_once=viewOnce,
|
||||
)
|
||||
|
||||
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 not user and not group:
|
||||
return await func(msg)
|
||||
|
|
@ -453,6 +500,8 @@ class Signal:
|
|||
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):
|
||||
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 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:
|
||||
data = {}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse message: {e}")
|
||||
continue
|
||||
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue