Let clanker cook

This commit is contained in:
Padawan-GM 2026-02-16 12:16:32 +01:00
parent d78dcfc4f2
commit ad00254122

View file

@ -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,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)