first draft, first day

Partly copied from deltachat jsonrpc client.
This commit is contained in:
Gandalf 2025-11-23 20:40:16 +01:00
parent 882632534c
commit 2272b28038
5 changed files with 511 additions and 0 deletions

66
src/Account.py Normal file
View file

@ -0,0 +1,66 @@
"""Account module."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional, Union
from warnings import warn
#from ._utils import AttrDict, futuremethod
#from .chat import Chat
#from .const import ChatlistFlag, ContactFlag, EventType, SpecialContactId
#from .contact import Contact
#from .message import Message
@dataclass
class Account:
"""Signal account."""
id: int
account: str
subscription: int
def subscribeReceive
def unsubscribeReceive
def register
# def updateAccount
def change_username(name:str)
""" Specify a username that can then be used to contact this account. This can either be just the nickname (e.g. test) or the complete username with discriminator (e.g. test.000). Returns the new username with discriminator and the username link. """
def delete_username
""" Delete the username associated with this account. """
def change_devicename(name:str)
""" Set a new device name for the primary or linked device """
def set_unidentified_sender(b:bool)
""" Enable if anyone should be able to send you unidentified sender messages. """
def set_discoverable_by_number(b:bool)
""" Enable/disable if the account should be discoverable by phone number """
def set_number_sharing(b:bool)
""" Indicates if Signal should share its phone number when sending a message. """
# fed updateAccount
def setPin(PIN:str)
""" Set a registration lock pin, to prevent others from registering your accounts phone number.
:param PIN: The registration lock PIN, that will be required for new registrations (resets after 7 days of inactivity)
"""
def removePin
""" Remove the registration lock pin. """
# def updateConfiguration
def set_read_receipts(b:bool)
""" Indicates if Signal should send read receipts. """
def set_unidentified_delivery_indicator(b:bool)
""" Indicates if Signal should show unidentified delivery indicators. """
def set_typing_indicators(b:bool)
""" Indicates if Signal should send/show typing indicators. """
def set_link_previews(b:bool)
""" Indicates if Signal should generate link previews. """
# fed updateConfiguration
def updateProfile
""" Update the profile information shown to message recipients. The profile is stored encrypted on the Signal servers. The decryption key is sent with every outgoing messages to contacts and included in every group."""
## Copied from deltachat
def create_contact(self, obj: Union[int, str, Contact, "Account"], name: Optional[str] = None) -> Contact
def create_chat(self, account: "Account") -> Chat:
"""Create a 1:1 chat with another account."""
return self.create_contact(account).create_chat()

180
src/Chat.py Normal file
View file

@ -0,0 +1,180 @@
"""Chat module."""
from __future__ import annotations
import calendar
from dataclasses import dataclass
from tempfile import NamedTemporaryFile
from typing import TYPE_CHECKING, Optional, Union
from ._utils import AttrDict
from .const import ChatVisibility, ViewType
from .contact import Contact
from .message import Message
if TYPE_CHECKING:
from datetime import datetime
from .account import Account
from .rpc import Rpc
@dataclass
class Chat:
"""Chat object which manages members and through which you can send and retrieve messages."""
account: "Account"
id: str
def delete(self) -> None:
"""Delete this chat and all its messages.
Note:
- does not delete messages on server
- the chat or contact is not blocked, new message will arrive
"""
self._rpc.delete_chat(self.account.id, self.id)
def block(self) -> None:
"""Block this chat."""
self._rpc.block_chat(self.account.id, self.id)
def accept(self) -> None:
"""Accept this contact request chat."""
self._rpc.accept_chat(self.account.id, self.id)
def leave(self) -> None:
"""Leave this chat."""
self._rpc.leave_group(self.account.id, self.id)
def mute(self, duration: Optional[int] = None) -> None:
"""Mute this chat, if a duration is not provided the chat is muted forever.
:param duration: mute duration from now in seconds. Must be greater than zero.
"""
if duration is not None:
assert duration > 0, "Invalid duration"
dur: dict = {"kind": "Until", "duration": duration}
else:
dur = {"kind": "Forever"}
self._rpc.set_chat_mute_duration(self.account.id, self.id, dur)
def unmute(self) -> None:
"""Unmute this chat."""
self._rpc.set_chat_mute_duration(self.account.id, self.id, {"kind": "NotMuted"})
def pin(self) -> None:
"""Pin this chat."""
self._rpc.set_chat_visibility(self.account.id, self.id, ChatVisibility.PINNED)
def unpin(self) -> None:
"""Unpin this chat."""
self._rpc.set_chat_visibility(self.account.id, self.id, ChatVisibility.NORMAL)
def archive(self) -> None:
"""Archive this chat."""
self._rpc.set_chat_visibility(self.account.id, self.id, ChatVisibility.ARCHIVED)
def unarchive(self) -> None:
"""Unarchive this chat."""
self._rpc.set_chat_visibility(self.account.id, self.id, ChatVisibility.NORMAL)
def set_name(self, name: str) -> None:
"""Set name of this chat."""
self._rpc.set_chat_name(self.account.id, self.id, name)
def set_ephemeral_timer(self, timer: int) -> None:
"""Set ephemeral timer of this chat in seconds.
0 means the timer is disabled, use 1 for immediate deletion.
"""
self._rpc.set_chat_ephemeral_timer(self.account.id, self.id, timer)
def send_message(
self,
text: Optional[str] = None,
html: Optional[str] = None,
viewtype: Optional[ViewType] = None,
file: Optional[str] = None,
filename: Optional[str] = None,
location: Optional[tuple[float, float]] = None,
override_sender_name: Optional[str] = None,
quoted_msg: Optional[Union[int, Message]] = None,
) -> Message:
"""Send a message and return the resulting Message instance."""
if isinstance(quoted_msg, Message):
quoted_msg = quoted_msg.id
draft = {
"text": text,
"html": html,
"viewtype": viewtype,
"file": file,
"filename": filename,
"location": location,
"overrideSenderName": override_sender_name,
"quotedMessageId": quoted_msg,
}
msg_id = self._rpc.send_msg(self.account.id, self.id, draft)
return Message(self.account, msg_id)
def send_text(self, text: str) -> Message:
"""Send a text message and return the resulting Message instance."""
msg_id = self._rpc.misc_send_text_message(self.account.id, self.id, text)
return Message(self.account, msg_id)
def send_file(self, path):
"""Send a file and return the resulting Message instance."""
return self.send_message(file=path)
def get_messages(self, info_only: bool = False, add_daymarker: bool = False) -> list[Message]:
"""Get the list of messages in this chat."""
msgs = self._rpc.get_message_ids(self.account.id, self.id, info_only, add_daymarker)
return [Message(self.account, msg_id) for msg_id in msgs]
def add_contact(self, *contact: Union[int, str, Contact]) -> None:
"""Add contacts to this group."""
for cnt in contact:
if isinstance(cnt, str):
contact_id = self.account.create_contact(cnt).id
elif not isinstance(cnt, int):
contact_id = cnt.id
else:
contact_id = cnt
self._rpc.add_contact_to_chat(self.account.id, self.id, contact_id)
def remove_contact(self, *contact: Union[int, str, Contact]) -> None:
"""Remove members from this group."""
for cnt in contact:
if isinstance(cnt, str):
contact_id = self.account.create_contact(cnt).id
elif not isinstance(cnt, int):
contact_id = cnt.id
else:
contact_id = cnt
self._rpc.remove_contact_from_chat(self.account.id, self.id, contact_id)
def get_contacts(self) -> list[Contact]:
"""Get the contacts belonging to this chat.
For single/direct chats self-address is not included.
"""
contacts = self._rpc.get_chat_contacts(self.account.id, self.id)
return [Contact(self.account, contact_id) for contact_id in contacts]
def get_past_contacts(self) -> list[Contact]:
"""Get past contacts for this chat."""
past_contacts = self._rpc.get_past_chat_contacts(self.account.id, self.id)
return [Contact(self.account, contact_id) for contact_id in past_contacts]
def set_image(self, path: str) -> None:
"""Set profile image of this chat.
:param path: Full path of the image to use as the group image.
"""
self._rpc.set_chat_profile_image(self.account.id, self.id, path)
def remove_image(self) -> None:
"""Remove profile image of this chat."""
self._rpc.set_chat_profile_image(self.account.id, self.id, None)

16
src/Signal.py Normal file
View file

@ -0,0 +1,16 @@
"""Account manager module."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._utils import AttrDict
from .account import Account
if TYPE_CHECKING:
from .rpc import Rpc
class Signal:
def __init__(self) -> None
def listAccounts

205
src/_utils.py Normal file
View file

@ -0,0 +1,205 @@
import argparse
import os
import re
import sys
from threading import Thread
from typing import TYPE_CHECKING, Callable, Iterable, Optional, Tuple, Type, Union
if TYPE_CHECKING:
from .client import Client
from .events import EventFilter
def _camel_to_snake(name: str) -> str:
name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
name = re.sub("__([A-Z])", r"_\1", name)
name = re.sub("([a-z0-9])([A-Z])", r"\1_\2", name)
return name.lower()
def _to_attrdict(obj):
if isinstance(obj, AttrDict):
return obj
if isinstance(obj, dict):
return AttrDict(obj)
if isinstance(obj, list):
return [_to_attrdict(elem) for elem in obj]
return obj
class AttrDict(dict):
"""Dictionary that allows accessing values using the "dot notation" as attributes."""
def __init__(self, *args, **kwargs) -> None:
super().__init__({_camel_to_snake(key): _to_attrdict(value) for key, value in dict(*args, **kwargs).items()})
def __getattr__(self, attr):
if attr in self:
return self[attr]
raise AttributeError("Attribute not found: " + str(attr))
def __setattr__(self, attr, val):
if attr in self:
raise AttributeError("Attribute-style access is read only")
super().__setattr__(attr, val)
def run_client_cli(
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
argv: Optional[list] = None,
**kwargs,
) -> None:
"""Run a simple command line app, using the given hooks.
Extra keyword arguments are passed to the internal Rpc object.
"""
from .client import Client
_run_cli(Client, hooks, argv, **kwargs)
def run_bot_cli(
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
argv: Optional[list] = None,
**kwargs,
) -> None:
"""Run a simple bot command line using the given hooks.
Extra keyword arguments are passed to the internal Rpc object.
"""
from .client import Bot
_run_cli(Bot, hooks, argv, **kwargs)
def _run_cli(
client_type: Type["Client"],
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
argv: Optional[list] = None,
**kwargs,
) -> None:
from .deltachat import DeltaChat
from .rpc import Rpc
if argv is None:
argv = sys.argv
parser = argparse.ArgumentParser(prog=argv[0] if argv else None)
parser.add_argument(
"accounts_dir",
help="accounts folder (default: current working directory)",
nargs="?",
)
parser.add_argument("--email", action="store", help="email address", default=os.getenv("DELTACHAT_EMAIL"))
parser.add_argument("--password", action="store", help="password", default=os.getenv("DELTACHAT_PASSWORD"))
args = parser.parse_args(argv[1:])
with Rpc(accounts_dir=args.accounts_dir, **kwargs) as rpc:
deltachat = DeltaChat(rpc)
core_version = (deltachat.get_system_info()).deltachat_core_version
accounts = deltachat.get_all_accounts()
account = accounts[0] if accounts else deltachat.add_account()
client = client_type(account, hooks)
client.logger.debug("Running deltachat core %s", core_version)
if not client.is_configured():
assert args.email, "Account is not configured and email must be provided"
assert args.password, "Account is not configured and password must be provided"
configure_thread = Thread(
target=client.configure,
daemon=True,
kwargs={"email": args.email, "password": args.password},
)
configure_thread.start()
client.run_forever()
def extract_addr(text: str) -> str:
"""Extract email address from the given text."""
match = re.match(r".*\((.+@.+)\)", text)
if match:
text = match.group(1)
text = text.rstrip(".")
return text.strip()
def parse_system_image_changed(text: str) -> Optional[Tuple[str, bool]]:
"""Return image changed/deleted info from parsing the given system message text."""
text = text.lower()
match = re.match(r"group image (changed|deleted) by (.+).", text)
if match:
action, actor = match.groups()
return (extract_addr(actor), action == "deleted")
return None
def parse_system_title_changed(text: str) -> Optional[Tuple[str, str]]:
text = text.lower()
match = re.match(r'group name changed from "(.+)" to ".+" by (.+).', text)
if match:
old_title, actor = match.groups()
return (extract_addr(actor), old_title)
return None
def parse_system_add_remove(text: str) -> Optional[Tuple[str, str, str]]:
"""Return add/remove info from parsing the given system message text.
returns a (action, affected, actor) tuple.
"""
# You removed member a@b.
# You added member a@b.
# Member Me (x@y) removed by a@b.
# Member x@y added by a@b
# Member With space (tmp1@x.org) removed by tmp2@x.org.
# Member With space (tmp1@x.org) removed by Another member (tmp2@x.org).",
# Group left by some one (tmp1@x.org).
# Group left by tmp1@x.org.
text = text.lower()
match = re.match(r"member (.+) (removed|added) by (.+)", text)
if match:
affected, action, actor = match.groups()
return action, extract_addr(affected), extract_addr(actor)
match = re.match(r"you (removed|added) member (.+)", text)
if match:
action, affected = match.groups()
return action, extract_addr(affected), "me"
if text.startswith("group left by "):
addr = extract_addr(text[13:])
if addr:
return "removed", addr, addr
return None
class futuremethod: # noqa: N801
"""Decorator for async methods."""
def __init__(self, func):
self._func = func
def __get__(self, instance, owner=None):
if instance is None:
return self
def future(*args):
generator = self._func(instance, *args)
res = next(generator)
def f():
try:
generator.send(res())
except StopIteration as e:
return e.value
return f
def wrapper(*args):
f = future(*args)
return f()
wrapper.future = future
return wrapper

44
src/example.py Normal file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""
Example echo bot without using hooks
"""
import logging
import sys
from signal-python import Signal
def main():
signal = Signal()
system_info = signal.get_system_info()
logging.info("Running %s", system_info)
accounts = signal.list_accounts()
account = accounts[0] if accounts else signal.add_account()
def process_messages():
for message in account.get_next_messages():
snapshot = message.get_snapshot()
if snapshot.from_id != SpecialContactId.SELF and not snapshot.is_bot and not snapshot.is_info:
snapshot.chat.send_text(snapshot.text)
snapshot.message.mark_seen()
# Process old messages.
process_messages()
while True:
event = account.wait_for_event()
if event["kind"] == EventType.INFO:
logging.info("%s", event["msg"])
elif event["kind"] == EventType.WARNING:
logging.warning("%s", event["msg"])
elif event["kind"] == EventType.ERROR:
logging.error("%s", event["msg"])
elif event["kind"] == EventType.INCOMING_MSG:
logging.info("Got an incoming message")
process_messages()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
main()