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 asyncio
import logging
from json import loads from json import loads
from pathlib import Path from pathlib import Path
from typing import Callable, Literal from typing import Any, Callable, Literal
from uuid import uuid4 from uuid import uuid4
from aiohttp import ClientSession from aiohttp import ClientSession
logger = logging.getLogger(__name__)
class User: class User:
def __init__( def __init__(
@ -41,8 +44,9 @@ class User:
self.username = username self.username = username
@classmethod @classmethod
def self(cls): def self(cls, number: str | None = None):
return cls("", number="+393406838100") """Create a User instance representing the current user."""
return cls("", number=number)
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<User "{self.name}" at "{self.number}">' return f'<User "{self.name}" at "{self.number}">'
@ -54,16 +58,16 @@ class Group:
id: str, id: str,
name: str | None = None, name: str | None = None,
description: str | None = None, description: str | None = None,
members: list[User] = [], members: list[User] | None = None,
admins: list[User] = [], admins: list[User] | None = None,
banned: list[User] = [], banned: list[User] | None = None,
): ):
self.id = id self.id = id
self.name = name self.name = name
self.description = description self.description = description
self.members = members self.members = members or []
self.admins = admins self.admins = admins or []
self.banned = banned self.banned = banned or []
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<Group "{self.name}" at "{self.id}">' return f'<Group "{self.name}" at "{self.id}">'
@ -113,11 +117,11 @@ class Message:
user: User | None = None, user: User | None = None,
group: Group | None = None, group: Group | None = None,
timestamp: int | None = None, timestamp: int | None = None,
attachments: list[Path] = [], attachments: list[Path] | None = None,
view_once: bool = False, # TODO: fix view_once: bool = False,
sticker: Sticker | None = None, sticker: Sticker | None = None,
mentions: list[MessageMention] = [], mentions: list[MessageMention] | None = None,
styles: list[MessageStyle] = [], styles: list[MessageStyle] | None = None,
quote: "Message | Poll | None" = None, quote: "Message | Poll | None" = None,
edit: "Message | None" = None, edit: "Message | None" = None,
): ):
@ -127,11 +131,11 @@ class Message:
self.group = group self.group = group
self.timestamp = timestamp self.timestamp = timestamp
self.attachments = attachments self.attachments = attachments or []
self.view_once = view_once self.view_once = view_once
self.sticker = sticker self.sticker = sticker
self.mentions = mentions self.mentions = mentions or []
self.styles = styles self.styles = styles or []
self.quote = quote self.quote = quote
self.edit = edit self.edit = edit
@ -141,22 +145,23 @@ class Message:
user=self.user, user=self.user,
group=self.group, group=self.group,
timestamp=self.timestamp, timestamp=self.timestamp,
attachments=self.attachments, attachments=self.attachments.copy(),
view_once=self.view_once, view_once=self.view_once,
sticker=self.sticker, sticker=self.sticker,
mentions=self.mentions, mentions=self.mentions.copy(),
styles=self.styles, styles=self.styles.copy(),
quote=self.quote, quote=self.quote,
edit=self.edit, edit=self.edit,
) )
if isinstance(b, Message): if isinstance(b, Message):
a.text = a.text or "" a.text = a.text or ""
offset = len(a.text)
for mention in b.mentions: for mention in b.mentions:
a.mentions.append( a.mentions.append(
MessageMention( MessageMention(
mention.start + len(a.text), mention.start + offset,
mention.length + len(a.text), mention.length,
mention.recipient, mention.recipient,
), ),
) )
@ -187,12 +192,30 @@ class Poll:
class Signal: 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.url = host.rstrip("/") + ":" + str(port)
self._hooks: list[Callable] = [] self._hooks: list[Callable] = []
self._session: ClientSession | None = None
self._self_number = self_number
async def _post(self, method: str, **params): async def __aenter__(self):
async with ClientSession() as session: 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( async with session.post(
self.url + "/api/v1/rpc", self.url + "/api/v1/rpc",
json={ json={
@ -203,12 +226,23 @@ class Signal:
}, },
) as response: ) as response:
if response.status == 200: 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( 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 = {} params: dict = {}
if isinstance(recipient, User): if isinstance(recipient, User):
params["recipient"] = recipient.id params["recipient"] = recipient.id
@ -259,11 +293,12 @@ class Signal:
timestamp = result.get("result", {}).get("timestamp") timestamp = result.get("result", {}).get("timestamp")
if timestamp: if timestamp:
message.timestamp = timestamp message.timestamp = timestamp
message.user = User.self() message.user = User.self(self._self_number)
return result 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} params: dict = {"emoji": emoji}
if isinstance(recipient, User): if isinstance(recipient, User):
params["recipient"] = recipient.id params["recipient"] = recipient.id
@ -278,7 +313,8 @@ class Signal:
return await self._post("sendReaction", **params) 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 = {} params: dict = {}
if isinstance(recipient, User): if isinstance(recipient, User):
params["recipient"] = recipient.id params["recipient"] = recipient.id
@ -287,7 +323,8 @@ class Signal:
return await self._post("sendTyping", **params) 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} params: dict = {"stop": True}
if isinstance(recipient, User): if isinstance(recipient, User):
params["recipient"] = recipient.id params["recipient"] = recipient.id
@ -296,7 +333,11 @@ class Signal:
return await self._post("sendTyping", **params) 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 = {} params: dict = {}
if isinstance(recipient, User): if isinstance(recipient, User):
params["recipient"] = recipient.id params["recipient"] = recipient.id
@ -307,7 +348,8 @@ class Signal:
return await self._post("remoteDelete", **params) 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 = {} params: dict = {}
if isinstance(recipient, User): if isinstance(recipient, User):
params["recipient"] = recipient.id params["recipient"] = recipient.id
@ -325,7 +367,7 @@ class Signal:
timestamp = result.get("result", {}).get("timestamp") timestamp = result.get("result", {}).get("timestamp")
if timestamp: if timestamp:
poll.timestamp = timestamp poll.timestamp = timestamp
poll.user = User.self() poll.user = User.self(self._self_number)
return result return result
@ -383,76 +425,83 @@ class Signal:
user: list[User] | User | None = None, user: list[User] | User | None = None,
group: list[Group] | Group | None = None, group: list[Group] | Group | None = None,
) -> Callable: ) -> Callable:
"""Decorator to register a message handler with optional user/group filters."""
def wrapper(func: Callable): def wrapper(func: Callable):
async def callback(data: dict): async def callback(data: dict):
envelope = data.get("envelope", {}) try:
if envelope and envelope.get("dataMessage"): envelope = data.get("envelope", {})
dataMessage = envelope["dataMessage"] 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"): if dataMessage.get("groupInfo"):
groupId = dataMessage["groupInfo"]["groupId"] groupId = dataMessage["groupInfo"]["groupId"]
sticker: Sticker | None = None sticker: Sticker | None = None
if dataMessage.get("sticker"): if dataMessage.get("sticker"):
s = dataMessage["sticker"] s = dataMessage["sticker"]
sticker = Sticker(s["packId"], s["stickerId"]) sticker = Sticker(s["packId"], s["stickerId"])
# attachments # attachments
# contacts # contacts
viewOnce: bool = dataMessage["viewOnce"] viewOnce: bool = dataMessage.get("viewOnce", False)
sourceUuid: str = envelope["sourceUuid"] sourceUuid: str = envelope["sourceUuid"]
timestamp: int = envelope["timestamp"] timestamp: int = envelope["timestamp"]
msg_user: User = await self.getUser(sourceUuid) msg_user: User = await self.getUser(sourceUuid)
msg_group: Group | None = ( msg_group: Group | None = (
(await self.getGroup(groupId)) if groupId else 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,
) )
await self._post( if message or sticker:
"sendReceipt", msg = Message(
recipient=msg_user.id, text=message,
targetTimestamp=timestamp, user=msg_user,
type="viewed", group=msg_group,
) timestamp=timestamp,
sticker=sticker,
view_once=viewOnce,
)
if not user and not group: try:
return await func(msg) 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: if not user and not group:
return await func(msg) return await func(msg)
if ( if isinstance(user, User) and user.id == msg_user.id:
isinstance(group, Group) return await func(msg)
and msg_group
and group.id == msg_group.id
):
return await func(msg)
if isinstance(user, list): if (
for usr in user: isinstance(group, Group)
if usr.id == msg_user.id: and msg_group
return await func(msg) and group.id == msg_group.id
):
return await func(msg)
if isinstance(group, list) and msg_group: if isinstance(user, list):
for grp in group: for usr in user:
if grp.id == msg_group.id: if usr.id == msg_user.id:
return await func(msg) 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) self._hooks.append(callback)
return func return func
@ -460,26 +509,45 @@ class Signal:
return wrapper return wrapper
def onMessageRaw(self) -> Callable: def onMessageRaw(self) -> Callable:
"""Decorator to register a raw message handler that receives unprocessed data."""
def wrapper(func: Callable): def wrapper(func: Callable):
async def callback(data: dict): 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) self._hooks.append(callback)
return func return func
return wrapper return wrapper
async def loop(self): async def loop(self, retry_delay: int = 5):
async with ClientSession() as session: """
async with session.get(self.url + "/api/v1/events") as response: Start the event loop to receive messages from Signal.
if response.status == 200: Automatically reconnects on connection loss.
async for msg in response.content: """
msg_data = msg.decode().strip() while True:
if msg_data.startswith("data"): try:
try: session = await self._get_session()
data = loads(msg_data.lstrip("data:")) logger.info(f"Connecting to Signal events at {self.url}/api/v1/events")
except Exception: async with session.get(self.url + "/api/v1/events") as response:
data = {} 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: for callback in self._hooks:
asyncio.create_task(callback(data)) 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)