first draft, first day
Partly copied from deltachat jsonrpc client.
This commit is contained in:
parent
882632534c
commit
2272b28038
66
src/Account.py
Normal file
66
src/Account.py
Normal 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 account’s 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
180
src/Chat.py
Normal 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
16
src/Signal.py
Normal 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
205
src/_utils.py
Normal 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
44
src/example.py
Normal 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()
|
||||
Loading…
Reference in a new issue