Let clanker cook
This commit is contained in:
parent
d78dcfc4f2
commit
ad00254122
1 changed files with 169 additions and 101 deletions
270
libsignal.py
270
libsignal.py
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue