mirror of
https://github.com/chatmail/core.git
synced 2026-04-06 15:42:10 +03:00
Merge remote-tracking branch 'origin/master' into feat/async-jobs
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
from deltachat import capi, const
|
||||
from deltachat.capi import ffi
|
||||
from deltachat.account import Account # noqa
|
||||
import sys
|
||||
|
||||
from . import capi, const, hookspec
|
||||
from .capi import ffi
|
||||
from .account import Account # noqa
|
||||
from .message import Message # noqa
|
||||
from .contact import Contact # noqa
|
||||
from .chat import Chat # noqa
|
||||
from .hookspec import account_hookimpl, global_hookimpl # noqa
|
||||
from . import eventlogger
|
||||
|
||||
from pkg_resources import get_distribution, DistributionNotFound
|
||||
try:
|
||||
@@ -64,3 +71,62 @@ def get_dc_event_name(integer, _DC_EVENTNAME_MAP={}):
|
||||
if name.startswith("DC_EVENT_"):
|
||||
_DC_EVENTNAME_MAP[val] = name
|
||||
return _DC_EVENTNAME_MAP[integer]
|
||||
|
||||
|
||||
def register_global_plugin(plugin):
|
||||
""" Register a global plugin which implements one or more
|
||||
of the :class:`deltachat.hookspec.Global` hooks.
|
||||
"""
|
||||
gm = hookspec.Global._get_plugin_manager()
|
||||
gm.register(plugin)
|
||||
gm.check_pending()
|
||||
|
||||
|
||||
def unregister_global_plugin(plugin):
|
||||
gm = hookspec.Global._get_plugin_manager()
|
||||
gm.unregister(plugin)
|
||||
|
||||
|
||||
register_global_plugin(eventlogger)
|
||||
|
||||
|
||||
def run_cmdline(argv=None, account_plugins=None):
|
||||
""" Run a simple default command line app, registering the specified
|
||||
account plugins. """
|
||||
import argparse
|
||||
if argv is None:
|
||||
argv = sys.argv
|
||||
|
||||
parser = argparse.ArgumentParser(prog=argv[0] if argv else None)
|
||||
parser.add_argument("db", action="store", help="database file")
|
||||
parser.add_argument("--show-ffi", action="store_true", help="show low level ffi events")
|
||||
parser.add_argument("--email", action="store", help="email address")
|
||||
parser.add_argument("--password", action="store", help="password")
|
||||
|
||||
args = parser.parse_args(argv[1:])
|
||||
|
||||
ac = Account(args.db)
|
||||
|
||||
if args.show_ffi:
|
||||
log = eventlogger.FFIEventLogger(ac, "bot")
|
||||
ac.add_account_plugin(log)
|
||||
|
||||
if not ac.is_configured():
|
||||
assert args.email and args.password, (
|
||||
"you must specify --email and --password once to configure this database/account"
|
||||
)
|
||||
ac.set_config("addr", args.email)
|
||||
ac.set_config("mail_pw", args.password)
|
||||
ac.set_config("mvbox_move", "0")
|
||||
ac.set_config("mvbox_watch", "0")
|
||||
ac.set_config("sentbox_watch", "0")
|
||||
|
||||
for plugin in account_plugins or []:
|
||||
ac.add_account_plugin(plugin)
|
||||
|
||||
# start IO threads and configure if neccessary
|
||||
ac.start()
|
||||
|
||||
print("{}: waiting for message".format(ac.get_config("addr")))
|
||||
|
||||
ac.wait_shutdown()
|
||||
|
||||
@@ -2,21 +2,25 @@
|
||||
|
||||
from __future__ import print_function
|
||||
import atexit
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from email.utils import parseaddr
|
||||
import queue
|
||||
from threading import Event
|
||||
import os
|
||||
import time
|
||||
from array import array
|
||||
from queue import Queue
|
||||
|
||||
import deltachat
|
||||
from . import const
|
||||
from .capi import ffi, lib
|
||||
from .cutil import as_dc_charpointer, from_dc_charpointer, iter_array, DCLot
|
||||
from .chat import Chat
|
||||
from .message import Message
|
||||
from .message import Message, map_system_message
|
||||
from .contact import Contact
|
||||
from .eventlogger import EventLogger
|
||||
from .hookspec import get_plugin_manager, hookimpl
|
||||
from .tracker import ImexTracker
|
||||
from . import hookspec, iothreads
|
||||
|
||||
|
||||
class MissingCredentials(ValueError):
|
||||
""" Account is missing `addr` and `mail_pw` config values. """
|
||||
|
||||
|
||||
class Account(object):
|
||||
@@ -24,45 +28,53 @@ class Account(object):
|
||||
by the underlying deltachat core library. All public Account methods are
|
||||
meant to be memory-safe and return memory-safe objects.
|
||||
"""
|
||||
def __init__(self, db_path, logid=None, os_name=None, debug=True):
|
||||
MissingCredentials = MissingCredentials
|
||||
|
||||
def __init__(self, db_path, os_name=None):
|
||||
""" initialize account object.
|
||||
|
||||
:param db_path: a path to the account database. The database
|
||||
will be created if it doesn't exist.
|
||||
:param logid: an optional logging prefix that should be used with
|
||||
the default internal logging.
|
||||
:param os_name: this will be put to the X-Mailer header in outgoing messages
|
||||
:param debug: turn on debug logging for events.
|
||||
"""
|
||||
# initialize per-account plugin system
|
||||
self._pm = hookspec.PerAccount._make_plugin_manager()
|
||||
self.add_account_plugin(self)
|
||||
|
||||
self._dc_context = ffi.gc(
|
||||
lib.dc_context_new(ffi.NULL, as_dc_charpointer(os_name)),
|
||||
_destroy_dc_context,
|
||||
)
|
||||
self._evlogger = EventLogger(self, logid, debug)
|
||||
self._threads = IOThreads(self._dc_context, self._evlogger._log_event)
|
||||
|
||||
# register event call back and initialize plugin system
|
||||
def _ll_event(ctx, evt_name, data1, data2):
|
||||
assert ctx == self._dc_context
|
||||
self.pluggy.hook.process_low_level_event(
|
||||
account=self, event_name=evt_name, data1=data1, data2=data2
|
||||
)
|
||||
hook = hookspec.Global._get_plugin_manager().hook
|
||||
|
||||
self.pluggy = get_plugin_manager()
|
||||
self.pluggy.register(self._evlogger)
|
||||
deltachat.set_context_callback(self._dc_context, _ll_event)
|
||||
self._threads = iothreads.IOThreads(self)
|
||||
self._hook_event_queue = queue.Queue()
|
||||
self._in_use_iter_events = False
|
||||
self._shutdown_event = Event()
|
||||
|
||||
# open database
|
||||
self.db_path = db_path
|
||||
if hasattr(db_path, "encode"):
|
||||
db_path = db_path.encode("utf8")
|
||||
if not lib.dc_open(self._dc_context, db_path, ffi.NULL):
|
||||
raise ValueError("Could not dc_open: {}".format(db_path))
|
||||
self._configkeys = self.get_config("sys.config_keys").split()
|
||||
atexit.register(self.shutdown)
|
||||
hook.dc_account_init(account=self)
|
||||
|
||||
@hookspec.account_hookimpl
|
||||
def ac_process_ffi_event(self, ffi_event):
|
||||
for name, kwargs in self._map_ffi_event(ffi_event):
|
||||
ev = HookEvent(self, name=name, kwargs=kwargs)
|
||||
self._hook_event_queue.put(ev)
|
||||
|
||||
# def __del__(self):
|
||||
# self.shutdown()
|
||||
|
||||
def ac_log_line(self, msg):
|
||||
self._pm.hook.ac_log_line(message=msg)
|
||||
|
||||
def _check_config_key(self, name):
|
||||
if name not in self._configkeys:
|
||||
raise KeyError("{!r} not a valid config key, existing keys: {!r}".format(
|
||||
@@ -134,16 +146,15 @@ class Account(object):
|
||||
if res == 0:
|
||||
raise Exception("Failed to set key")
|
||||
|
||||
def configure(self, **kwargs):
|
||||
""" set config values and configure this account.
|
||||
def update_config(self, kwargs):
|
||||
""" update config values.
|
||||
|
||||
:param kwargs: name=value config settings for this account.
|
||||
values need to be unicode.
|
||||
:returns: None
|
||||
"""
|
||||
for name, value in kwargs.items():
|
||||
self.set_config(name, value)
|
||||
lib.dc_configure(self._dc_context)
|
||||
for key, value in kwargs.items():
|
||||
self.set_config(key, str(value))
|
||||
|
||||
def is_configured(self):
|
||||
""" determine if the account is configured already; an initial connection
|
||||
@@ -151,7 +162,7 @@ class Account(object):
|
||||
|
||||
:returns: True if account is configured.
|
||||
"""
|
||||
return lib.dc_is_configured(self._dc_context)
|
||||
return bool(lib.dc_is_configured(self._dc_context))
|
||||
|
||||
def set_avatar(self, img_path):
|
||||
"""Set self avatar.
|
||||
@@ -202,8 +213,7 @@ class Account(object):
|
||||
|
||||
:returns: :class:`deltachat.contact.Contact`
|
||||
"""
|
||||
self.check_is_configured()
|
||||
return Contact(self._dc_context, const.DC_CONTACT_ID_SELF)
|
||||
return Contact(self, const.DC_CONTACT_ID_SELF)
|
||||
|
||||
def create_contact(self, email, name=None):
|
||||
""" create a (new) Contact. If there already is a Contact
|
||||
@@ -214,11 +224,14 @@ class Account(object):
|
||||
:param name: display name for this contact (optional)
|
||||
:returns: :class:`deltachat.contact.Contact` instance.
|
||||
"""
|
||||
name = as_dc_charpointer(name)
|
||||
email = as_dc_charpointer(email)
|
||||
contact_id = lib.dc_create_contact(self._dc_context, name, email)
|
||||
realname, addr = parseaddr(email)
|
||||
if name:
|
||||
realname = name
|
||||
realname = as_dc_charpointer(realname)
|
||||
addr = as_dc_charpointer(addr)
|
||||
contact_id = lib.dc_create_contact(self._dc_context, realname, addr)
|
||||
assert contact_id > const.DC_CHAT_ID_LAST_SPECIAL
|
||||
return Contact(self._dc_context, contact_id)
|
||||
return Contact(self, contact_id)
|
||||
|
||||
def delete_contact(self, contact):
|
||||
""" delete a Contact.
|
||||
@@ -231,6 +244,14 @@ class Account(object):
|
||||
assert contact_id > const.DC_CHAT_ID_LAST_SPECIAL
|
||||
return bool(lib.dc_delete_contact(self._dc_context, contact_id))
|
||||
|
||||
def get_contact_by_addr(self, email):
|
||||
""" get a contact for the email address or None if it's blocked or doesn't exist. """
|
||||
_, addr = parseaddr(email)
|
||||
addr = as_dc_charpointer(addr)
|
||||
contact_id = lib.dc_lookup_contact_id_by_addr(self._dc_context, addr)
|
||||
if contact_id:
|
||||
return self.get_contact_by_id(contact_id)
|
||||
|
||||
def get_contacts(self, query=None, with_self=False, only_verified=False):
|
||||
""" get a (filtered) list of contacts.
|
||||
|
||||
@@ -250,7 +271,15 @@ class Account(object):
|
||||
lib.dc_get_contacts(self._dc_context, flags, query),
|
||||
lib.dc_array_unref
|
||||
)
|
||||
return list(iter_array(dc_array, lambda x: Contact(self._dc_context, x)))
|
||||
return list(iter_array(dc_array, lambda x: Contact(self, x)))
|
||||
|
||||
def get_fresh_messages(self):
|
||||
""" yield all fresh messages from all chats. """
|
||||
dc_array = ffi.gc(
|
||||
lib.dc_get_fresh_msgs(self._dc_context),
|
||||
lib.dc_array_unref
|
||||
)
|
||||
yield from iter_array(dc_array, lambda x: Message.from_db(self, x))
|
||||
|
||||
def create_chat_by_contact(self, contact):
|
||||
""" create or get an existing 1:1 chat object for the specified contact or contact id.
|
||||
@@ -272,6 +301,9 @@ class Account(object):
|
||||
""" create or get an existing chat object for the
|
||||
the specified message.
|
||||
|
||||
If this message is in the deaddrop chat then
|
||||
the sender will become an accepted contact.
|
||||
|
||||
:param message: messsage id or message instance.
|
||||
:returns: a :class:`deltachat.chat.Chat` object.
|
||||
"""
|
||||
@@ -324,6 +356,13 @@ class Account(object):
|
||||
"""
|
||||
return Message.from_db(self, msg_id)
|
||||
|
||||
def get_contact_by_id(self, contact_id):
|
||||
""" return Contact instance or None.
|
||||
:param contact_id: integer id of this contact.
|
||||
:returns: None or :class:`deltachat.contact.Contact` instance.
|
||||
"""
|
||||
return Contact(self, contact_id)
|
||||
|
||||
def get_chat_by_id(self, chat_id):
|
||||
""" return Chat instance.
|
||||
:param chat_id: integer id of this chat.
|
||||
@@ -368,13 +407,18 @@ class Account(object):
|
||||
lib.dc_delete_msgs(self._dc_context, msg_ids, len(msg_ids))
|
||||
|
||||
def export_self_keys(self, path):
|
||||
""" export public and private keys to the specified directory. """
|
||||
""" export public and private keys to the specified directory.
|
||||
|
||||
Note that the account does not have to be started.
|
||||
"""
|
||||
return self._export(path, imex_cmd=1)
|
||||
|
||||
def export_all(self, path):
|
||||
"""return new file containing a backup of all database state
|
||||
(chats, contacts, keys, media, ...). The file is created in the
|
||||
the `path` directory.
|
||||
|
||||
Note that the account does not have to be started.
|
||||
"""
|
||||
export_files = self._export(path, 11)
|
||||
if len(export_files) != 1:
|
||||
@@ -382,7 +426,7 @@ class Account(object):
|
||||
return export_files[0]
|
||||
|
||||
def _export(self, path, imex_cmd):
|
||||
with ImexTracker(self) as imex_tracker:
|
||||
with self.temp_plugin(ImexTracker()) as imex_tracker:
|
||||
lib.dc_imex(self._dc_context, imex_cmd, as_dc_charpointer(path), ffi.NULL)
|
||||
return imex_tracker.wait_finish()
|
||||
|
||||
@@ -390,6 +434,8 @@ class Account(object):
|
||||
""" Import private keys found in the `path` directory.
|
||||
The last imported key is made the default keys unless its name
|
||||
contains the string legacy. Public keys are not imported.
|
||||
|
||||
Note that the account does not have to be started.
|
||||
"""
|
||||
self._import(path, imex_cmd=2)
|
||||
|
||||
@@ -402,7 +448,7 @@ class Account(object):
|
||||
self._import(path, imex_cmd=12)
|
||||
|
||||
def _import(self, path, imex_cmd):
|
||||
with ImexTracker(self) as imex_tracker:
|
||||
with self.temp_plugin(ImexTracker()) as imex_tracker:
|
||||
lib.dc_imex(self._dc_context, imex_cmd, as_dc_charpointer(path), ffi.NULL)
|
||||
imex_tracker.wait_finish()
|
||||
|
||||
@@ -469,46 +515,6 @@ class Account(object):
|
||||
raise ValueError("could not join group")
|
||||
return Chat(self, chat_id)
|
||||
|
||||
def stop_ongoing(self):
|
||||
lib.dc_stop_ongoing_process(self._dc_context)
|
||||
|
||||
#
|
||||
# meta API for start/stop and event based processing
|
||||
#
|
||||
|
||||
def wait_next_incoming_message(self):
|
||||
""" wait for and return next incoming message. """
|
||||
ev = self._evlogger.get_matching("DC_EVENT_INCOMING_MSG")
|
||||
return self.get_message_by_id(ev[2])
|
||||
|
||||
def start_threads(self):
|
||||
""" start IMAP/SMTP threads (and configure account if it hasn't happened).
|
||||
|
||||
:raises: ValueError if 'addr' or 'mail_pw' are not configured.
|
||||
:returns: None
|
||||
"""
|
||||
if not self.is_configured():
|
||||
self.configure()
|
||||
self._threads.start()
|
||||
|
||||
def stop_threads(self, wait=True):
|
||||
""" stop IMAP/SMTP threads. """
|
||||
if self._threads.is_started():
|
||||
self.stop_ongoing()
|
||||
self._threads.stop(wait=wait)
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
""" stop threads and close and remove underlying dc_context and callbacks. """
|
||||
if hasattr(self, "_dc_context") and hasattr(self, "_threads"):
|
||||
# print("SHUTDOWN", self)
|
||||
self.stop_threads(wait=False)
|
||||
lib.dc_close(self._dc_context)
|
||||
self.stop_threads(wait=wait) # to wait for threads
|
||||
deltachat.clear_context_callback(self._dc_context)
|
||||
del self._dc_context
|
||||
atexit.unregister(self.shutdown)
|
||||
self.pluggy.unregister(self._evlogger)
|
||||
|
||||
def set_location(self, latitude=0.0, longitude=0.0, accuracy=0.0):
|
||||
"""set a new location. It effects all chats where we currently
|
||||
have enabled location streaming.
|
||||
@@ -523,88 +529,122 @@ class Account(object):
|
||||
if dc_res == 0:
|
||||
raise ValueError("no chat is streaming locations")
|
||||
|
||||
#
|
||||
# meta API for start/stop and event based processing
|
||||
#
|
||||
|
||||
class ImexTracker:
|
||||
def __init__(self, account):
|
||||
self._imex_events = Queue()
|
||||
self.account = account
|
||||
def add_account_plugin(self, plugin, name=None):
|
||||
""" add an account plugin which implements one or more of
|
||||
the :class:`deltachat.hookspec.PerAccount` hooks.
|
||||
"""
|
||||
self._pm.register(plugin, name=name)
|
||||
self._pm.check_pending()
|
||||
return plugin
|
||||
|
||||
def __enter__(self):
|
||||
self.account.pluggy.register(self)
|
||||
return self
|
||||
@contextmanager
|
||||
def temp_plugin(self, plugin):
|
||||
""" run a with-block with the given plugin temporarily registered. """
|
||||
self._pm.register(plugin)
|
||||
yield plugin
|
||||
self._pm.unregister(plugin)
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.account.pluggy.unregister(self)
|
||||
def stop_ongoing(self):
|
||||
""" Stop ongoing securejoin, configuration or other core jobs. """
|
||||
lib.dc_stop_ongoing_process(self._dc_context)
|
||||
|
||||
@hookimpl
|
||||
def process_low_level_event(self, account, event_name, data1, data2):
|
||||
# there could be multiple accounts instantiated
|
||||
if self.account is not account:
|
||||
def start(self, callback_thread=True):
|
||||
""" start this account (activate imap/smtp threads etc.)
|
||||
and return immediately.
|
||||
|
||||
If this account is not configured, an internal configuration
|
||||
job will be scheduled if config values are sufficiently specified.
|
||||
|
||||
You may call `wait_shutdown` or `shutdown` after the
|
||||
account is in started mode.
|
||||
|
||||
:raises MissingCredentials: if `addr` and `mail_pw` values are not set.
|
||||
|
||||
:returns: None
|
||||
"""
|
||||
if not self.is_configured():
|
||||
if not self.get_config("addr") or not self.get_config("mail_pw"):
|
||||
raise MissingCredentials("addr or mail_pwd not set in config")
|
||||
lib.dc_configure(self._dc_context)
|
||||
self._threads.start(callback_thread=callback_thread)
|
||||
|
||||
def wait_shutdown(self):
|
||||
""" wait until shutdown of this account has completed. """
|
||||
self._shutdown_event.wait()
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
""" shutdown account, stop threads and close and remove
|
||||
underlying dc_context and callbacks. """
|
||||
dc_context = self._dc_context
|
||||
if dc_context is None:
|
||||
return
|
||||
if event_name == "DC_EVENT_IMEX_PROGRESS":
|
||||
self._imex_events.put(data1)
|
||||
elif event_name == "DC_EVENT_IMEX_FILE_WRITTEN":
|
||||
self._imex_events.put(data1)
|
||||
|
||||
def wait_finish(self, progress_timeout=60):
|
||||
""" Return list of written files, raise ValueError if ExportFailed. """
|
||||
files_written = []
|
||||
while True:
|
||||
ev = self._imex_events.get(timeout=progress_timeout)
|
||||
if isinstance(ev, str):
|
||||
files_written.append(ev)
|
||||
elif ev == 0:
|
||||
raise ValueError("export failed, exp-files: {}".format(files_written))
|
||||
elif ev == 1000:
|
||||
return files_written
|
||||
if self._threads.is_started():
|
||||
self.stop_ongoing()
|
||||
self._threads.stop(wait=False)
|
||||
lib.dc_close(dc_context)
|
||||
self._hook_event_queue.put(None)
|
||||
self._threads.stop(wait=wait) # to wait for threads
|
||||
self._dc_context = None
|
||||
atexit.unregister(self.shutdown)
|
||||
self._shutdown_event.set()
|
||||
hook = hookspec.Global._get_plugin_manager().hook
|
||||
hook.dc_account_after_shutdown(account=self, dc_context=dc_context)
|
||||
|
||||
def _handle_current_events(self):
|
||||
""" handle all currently queued events and then return. """
|
||||
while 1:
|
||||
try:
|
||||
event = self._hook_event_queue.get(block=False)
|
||||
except queue.Empty:
|
||||
break
|
||||
else:
|
||||
event.call_hook()
|
||||
|
||||
class IOThreads:
|
||||
def __init__(self, dc_context, log_event=lambda *args: None):
|
||||
self._dc_context = dc_context
|
||||
self._thread_quitflag = False
|
||||
self._name2thread = {}
|
||||
self._log_event = log_event
|
||||
self._log_running = True
|
||||
def iter_events(self, timeout=None):
|
||||
""" yield hook events until shutdown.
|
||||
|
||||
# Make sure the current
|
||||
self._start_one_thread("deltachat-log", self.dc_thread_run)
|
||||
It is not allowed to call iter_events() from multiple threads.
|
||||
"""
|
||||
if self._in_use_iter_events:
|
||||
raise RuntimeError("can only call iter_events() from one thread")
|
||||
self._in_use_iter_events = True
|
||||
while 1:
|
||||
event = self._hook_event_queue.get(timeout=timeout)
|
||||
if event is None:
|
||||
break
|
||||
yield event
|
||||
|
||||
def is_started(self):
|
||||
return lib.dc_is_open(self._dc_context) and lib.dc_is_running(self._dc_context)
|
||||
|
||||
def start(self, imap=True, smtp=True, mvbox=False, sentbox=False):
|
||||
assert not self.is_started()
|
||||
|
||||
lib.dc_context_run(self._dc_context)
|
||||
|
||||
def _start_one_thread(self, name, func):
|
||||
self._name2thread[name] = t = threading.Thread(target=func, name=name)
|
||||
t.setDaemon(1)
|
||||
t.start()
|
||||
|
||||
def stop(self, wait=False):
|
||||
if self.is_started():
|
||||
lib.dc_context_shutdown(self._dc_context)
|
||||
|
||||
def dc_thread_run(self):
|
||||
self._log_event("py-bindings-info", 0, "DC LOG THREAD START")
|
||||
|
||||
while self._log_running:
|
||||
if lib.dc_is_open(self._dc_context) and lib.dc_has_next_event(self._dc_context):
|
||||
event = lib.dc_get_next_event(self._dc_context)
|
||||
if event != ffi.NULL:
|
||||
deltachat.py_dc_callback(
|
||||
self._dc_context,
|
||||
lib.dc_event_get_id(event),
|
||||
lib.dc_event_get_data1(event),
|
||||
lib.dc_event_get_data2(event)
|
||||
)
|
||||
lib.dc_event_unref(event)
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
|
||||
self._log_event("py-bindings-info", 0, "DC LOG THREAD FINISHED")
|
||||
def _map_ffi_event(self, ffi_event):
|
||||
name = ffi_event.name
|
||||
if name == "DC_EVENT_CONFIGURE_PROGRESS":
|
||||
data1 = ffi_event.data1
|
||||
if data1 == 0 or data1 == 1000:
|
||||
success = data1 == 1000
|
||||
yield "ac_configure_completed", dict(success=success)
|
||||
elif name == "DC_EVENT_INCOMING_MSG":
|
||||
msg = self.get_message_by_id(ffi_event.data2)
|
||||
yield map_system_message(msg) or ("ac_incoming_message", dict(message=msg))
|
||||
elif name == "DC_EVENT_MSGS_CHANGED":
|
||||
if ffi_event.data2 != 0:
|
||||
msg = self.get_message_by_id(ffi_event.data2)
|
||||
if msg.is_outgoing():
|
||||
res = map_system_message(msg)
|
||||
if res and res[0].startswith("ac_member"):
|
||||
yield res
|
||||
yield "ac_outgoing_message", dict(message=msg)
|
||||
elif msg.is_in_fresh():
|
||||
yield map_system_message(msg) or ("ac_incoming_message", dict(message=msg))
|
||||
elif name == "DC_EVENT_MSG_DELIVERED":
|
||||
msg = self.get_message_by_id(ffi_event.data2)
|
||||
yield "ac_message_delivered", dict(message=msg)
|
||||
elif name == "DC_EVENT_CHAT_MODIFIED":
|
||||
chat = self.get_chat_by_id(ffi_event.data1)
|
||||
yield "ac_chat_modified", dict(chat=chat)
|
||||
|
||||
|
||||
def _destroy_dc_context(dc_context, dc_context_unref=lib.dc_context_unref):
|
||||
@@ -631,3 +671,17 @@ class ScannedQRCode:
|
||||
@property
|
||||
def contact_id(self):
|
||||
return self._dc_lot.id()
|
||||
|
||||
|
||||
class HookEvent:
|
||||
def __init__(self, account, name, kwargs):
|
||||
assert hasattr(account._pm.hook, name), name
|
||||
self.account = account
|
||||
self.name = name
|
||||
self.kwargs = kwargs
|
||||
|
||||
def call_hook(self):
|
||||
hook = getattr(self.account._pm.hook, self.name, None)
|
||||
if hook is None:
|
||||
raise ValueError("event_name {} unknown".format(self.name))
|
||||
return hook(**self.kwargs)
|
||||
|
||||
@@ -30,7 +30,7 @@ class Chat(object):
|
||||
return not (self == other)
|
||||
|
||||
def __repr__(self):
|
||||
return "<Chat id={} name={} dc_context={}>".format(self.id, self.get_name(), self._dc_context)
|
||||
return "<Chat id={} name={}>".format(self.id, self.get_name())
|
||||
|
||||
@property
|
||||
def _dc_chat(self):
|
||||
@@ -51,6 +51,16 @@ class Chat(object):
|
||||
|
||||
# ------ chat status/metadata API ------------------------------
|
||||
|
||||
def is_group(self):
|
||||
""" return true if this chat is a group chat.
|
||||
|
||||
:returns: True if chat is a group-chat, false if it's a contact 1:1 chat.
|
||||
"""
|
||||
return lib.dc_chat_get_type(self._dc_chat) in (
|
||||
const.DC_CHAT_TYPE_GROUP,
|
||||
const.DC_CHAT_TYPE_VERIFIED_GROUP
|
||||
)
|
||||
|
||||
def is_deaddrop(self):
|
||||
""" return true if this chat is a deaddrop chat.
|
||||
|
||||
@@ -129,7 +139,7 @@ class Chat(object):
|
||||
return bool(lib.dc_chat_get_remaining_mute_duration(self.id))
|
||||
|
||||
def get_type(self):
|
||||
""" return type of this chat.
|
||||
""" (deprecated) return type of this chat.
|
||||
|
||||
:returns: one of const.DC_CHAT_TYPE_*
|
||||
"""
|
||||
@@ -353,7 +363,7 @@ class Chat(object):
|
||||
lib.dc_array_unref
|
||||
)
|
||||
return list(iter_array(
|
||||
dc_array, lambda id: Contact(self._dc_context, id))
|
||||
dc_array, lambda id: Contact(self.account, id))
|
||||
)
|
||||
|
||||
def set_profile_image(self, img_path):
|
||||
@@ -405,12 +415,6 @@ class Chat(object):
|
||||
"""
|
||||
return lib.dc_chat_get_color(self._dc_chat)
|
||||
|
||||
def get_subtitle(self):
|
||||
"""return the subtitle of the chat
|
||||
:returns: the subtitle
|
||||
"""
|
||||
return from_dc_charpointer(lib.dc_chat_get_subtitle(self._dc_chat))
|
||||
|
||||
# ------ location streaming API ------------------------------
|
||||
|
||||
def is_sending_locations(self):
|
||||
|
||||
@@ -11,6 +11,7 @@ from os.path import join as joinpath
|
||||
DC_GCL_ARCHIVED_ONLY = 0x01
|
||||
DC_GCL_NO_SPECIALS = 0x02
|
||||
DC_GCL_ADD_ALLDONE_HINT = 0x04
|
||||
DC_GCL_FOR_FORWARDING = 0x08
|
||||
DC_GCL_VERIFIED_ONLY = 0x01
|
||||
DC_GCL_ADD_SELF = 0x02
|
||||
DC_QR_ASK_VERIFYCONTACT = 200
|
||||
@@ -98,7 +99,6 @@ DC_EVENT_IMEX_PROGRESS = 2051
|
||||
DC_EVENT_IMEX_FILE_WRITTEN = 2052
|
||||
DC_EVENT_SECUREJOIN_INVITER_PROGRESS = 2060
|
||||
DC_EVENT_SECUREJOIN_JOINER_PROGRESS = 2061
|
||||
DC_EVENT_SECUREJOIN_MEMBER_ADDED = 2062
|
||||
DC_EVENT_FILE_COPIED = 2055
|
||||
DC_EVENT_IS_OFFLINE = 2081
|
||||
DC_EVENT_GET_STRING = 2091
|
||||
@@ -115,8 +115,6 @@ DC_CHAT_VISIBILITY_PINNED = 2
|
||||
DC_STR_NOMESSAGES = 1
|
||||
DC_STR_SELF = 2
|
||||
DC_STR_DRAFT = 3
|
||||
DC_STR_MEMBER = 4
|
||||
DC_STR_CONTACT = 6
|
||||
DC_STR_VOICEMESSAGE = 7
|
||||
DC_STR_DEADDROP = 8
|
||||
DC_STR_IMAGE = 9
|
||||
@@ -148,7 +146,6 @@ DC_STR_ARCHIVEDCHATS = 40
|
||||
DC_STR_STARREDMSGS = 41
|
||||
DC_STR_AC_SETUP_MSG_SUBJECT = 42
|
||||
DC_STR_AC_SETUP_MSG_BODY = 43
|
||||
DC_STR_SELFTALK_SUBTITLE = 50
|
||||
DC_STR_CANNOT_LOGIN = 60
|
||||
DC_STR_SERVER_RESPONSE = 61
|
||||
DC_STR_MSGACTIONBYUSER = 62
|
||||
|
||||
@@ -10,8 +10,9 @@ class Contact(object):
|
||||
|
||||
You obtain instances of it through :class:`deltachat.account.Account`.
|
||||
"""
|
||||
def __init__(self, dc_context, id):
|
||||
self._dc_context = dc_context
|
||||
def __init__(self, account, id):
|
||||
self.account = account
|
||||
self._dc_context = account._dc_context
|
||||
self.id = id
|
||||
|
||||
def __eq__(self, other):
|
||||
@@ -57,3 +58,7 @@ class Contact(object):
|
||||
if dc_res == ffi.NULL:
|
||||
return None
|
||||
return from_dc_charpointer(dc_res)
|
||||
|
||||
def get_chat(self):
|
||||
"""return 1:1 chat for this contact. """
|
||||
return self.account.create_chat_by_contact(self)
|
||||
|
||||
@@ -1,28 +1,88 @@
|
||||
import deltachat
|
||||
import threading
|
||||
import re
|
||||
import time
|
||||
import re
|
||||
from queue import Queue, Empty
|
||||
from .hookspec import hookimpl
|
||||
from .hookspec import account_hookimpl, global_hookimpl
|
||||
|
||||
|
||||
class EventLogger:
|
||||
@global_hookimpl
|
||||
def dc_account_init(account):
|
||||
# send all FFI events for this account to a plugin hook
|
||||
def _ll_event(ctx, evt_name, data1, data2):
|
||||
assert ctx == account._dc_context
|
||||
ffi_event = FFIEvent(name=evt_name, data1=data1, data2=data2)
|
||||
account._pm.hook.ac_process_ffi_event(
|
||||
account=account, ffi_event=ffi_event
|
||||
)
|
||||
deltachat.set_context_callback(account._dc_context, _ll_event)
|
||||
|
||||
|
||||
@global_hookimpl
|
||||
def dc_account_after_shutdown(dc_context):
|
||||
deltachat.clear_context_callback(dc_context)
|
||||
|
||||
|
||||
class FFIEvent:
|
||||
def __init__(self, name, data1, data2):
|
||||
self.name = name
|
||||
self.data1 = data1
|
||||
self.data2 = data2
|
||||
|
||||
def __str__(self):
|
||||
return "{name} data1={data1} data2={data2}".format(**self.__dict__)
|
||||
|
||||
|
||||
class FFIEventLogger:
|
||||
""" If you register an instance of this logger with an Account
|
||||
you'll get all ffi-events printed.
|
||||
"""
|
||||
# to prevent garbled logging
|
||||
_loglock = threading.RLock()
|
||||
|
||||
def __init__(self, account, logid=None, debug=True):
|
||||
def __init__(self, account, logid):
|
||||
"""
|
||||
:param logid: an optional logging prefix that should be used with
|
||||
the default internal logging.
|
||||
"""
|
||||
self.account = account
|
||||
self._event_queue = Queue()
|
||||
self._debug = debug
|
||||
if logid is None:
|
||||
logid = str(self.account._dc_context).strip(">").split()[-1]
|
||||
self.logid = logid
|
||||
self._timeout = None
|
||||
self.init_time = time.time()
|
||||
|
||||
@hookimpl
|
||||
def process_low_level_event(self, account, event_name, data1, data2):
|
||||
if self.account == account:
|
||||
self._log_event(event_name, data1, data2)
|
||||
self._event_queue.put((event_name, data1, data2))
|
||||
@account_hookimpl
|
||||
def ac_process_ffi_event(self, ffi_event):
|
||||
self._log_event(ffi_event)
|
||||
|
||||
def _log_event(self, ffi_event):
|
||||
# don't show events that are anyway empty impls now
|
||||
if ffi_event.name == "DC_EVENT_GET_STRING":
|
||||
return
|
||||
self.account.ac_log_line(str(ffi_event))
|
||||
|
||||
@account_hookimpl
|
||||
def ac_log_line(self, message):
|
||||
t = threading.currentThread()
|
||||
tname = getattr(t, "name", t)
|
||||
if tname == "MainThread":
|
||||
tname = "MAIN"
|
||||
elapsed = time.time() - self.init_time
|
||||
locname = tname
|
||||
if self.logid:
|
||||
locname += "-" + self.logid
|
||||
s = "{:2.2f} [{}] {}".format(elapsed, locname, message)
|
||||
with self._loglock:
|
||||
print(s, flush=True)
|
||||
|
||||
|
||||
class FFIEventTracker:
|
||||
def __init__(self, account, timeout=None):
|
||||
self.account = account
|
||||
self._timeout = timeout
|
||||
self._event_queue = Queue()
|
||||
|
||||
@account_hookimpl
|
||||
def ac_process_ffi_event(self, ffi_event):
|
||||
self._event_queue.put(ffi_event)
|
||||
|
||||
def set_timeout(self, timeout):
|
||||
self._timeout = timeout
|
||||
@@ -32,10 +92,10 @@ class EventLogger:
|
||||
self.get(check_error=check_error)
|
||||
|
||||
def get(self, timeout=None, check_error=True):
|
||||
timeout = timeout or self._timeout
|
||||
timeout = timeout if timeout is not None else self._timeout
|
||||
ev = self._event_queue.get(timeout=timeout)
|
||||
if check_error and ev[0] == "DC_EVENT_ERROR":
|
||||
raise ValueError("{}({!r},{!r})".format(*ev))
|
||||
if check_error and ev.name == "DC_EVENT_ERROR":
|
||||
raise ValueError(str(ev))
|
||||
return ev
|
||||
|
||||
def ensure_event_not_queued(self, event_name_regex):
|
||||
@@ -47,35 +107,31 @@ class EventLogger:
|
||||
except Empty:
|
||||
break
|
||||
else:
|
||||
assert not rex.match(ev[0]), "event found {}".format(ev)
|
||||
assert not rex.match(ev.name), "event found {}".format(ev)
|
||||
|
||||
def get_matching(self, event_name_regex, check_error=True, timeout=None):
|
||||
self._log("-- waiting for event with regex: {} --".format(event_name_regex))
|
||||
self.account.ac_log_line("-- waiting for event with regex: {} --".format(event_name_regex))
|
||||
rex = re.compile("(?:{}).*".format(event_name_regex))
|
||||
while 1:
|
||||
ev = self.get(timeout=timeout, check_error=check_error)
|
||||
if rex.match(ev[0]):
|
||||
if rex.match(ev.name):
|
||||
return ev
|
||||
|
||||
def get_info_matching(self, regex):
|
||||
rex = re.compile("(?:{}).*".format(regex))
|
||||
while 1:
|
||||
ev = self.get_matching("DC_EVENT_INFO")
|
||||
if rex.match(ev[2]):
|
||||
if rex.match(ev.data2):
|
||||
return ev
|
||||
|
||||
def _log_event(self, evt_name, data1, data2):
|
||||
# don't show events that are anyway empty impls now
|
||||
if evt_name == "DC_EVENT_GET_STRING":
|
||||
return
|
||||
if self._debug:
|
||||
evpart = "{}({!r},{!r})".format(evt_name, data1, data2)
|
||||
self._log(evpart)
|
||||
def wait_next_incoming_message(self):
|
||||
""" wait for and return next incoming message. """
|
||||
ev = self.get_matching("DC_EVENT_INCOMING_MSG")
|
||||
return self.account.get_message_by_id(ev.data2)
|
||||
|
||||
def _log(self, msg):
|
||||
t = threading.currentThread()
|
||||
tname = getattr(t, "name", t)
|
||||
if tname == "MainThread":
|
||||
tname = "MAIN"
|
||||
with self._loglock:
|
||||
print("{:2.2f} [{}-{}] {}".format(time.time() - self.init_time, tname, self.logid, msg))
|
||||
def wait_next_messages_changed(self):
|
||||
""" wait for and return next message-changed message or None
|
||||
if the event contains no msgid"""
|
||||
ev = self.get_matching("DC_EVENT_MSGS_CHANGED")
|
||||
if ev.data2 > 0:
|
||||
return self.account.get_message_by_id(ev.data2)
|
||||
|
||||
@@ -1,25 +1,92 @@
|
||||
""" Hooks for python bindings """
|
||||
""" Hooks for Python bindings to Delta Chat Core Rust CFFI"""
|
||||
|
||||
import pluggy
|
||||
|
||||
name = "deltachat"
|
||||
|
||||
hookspec = pluggy.HookspecMarker(name)
|
||||
hookimpl = pluggy.HookimplMarker(name)
|
||||
_plugin_manager = None
|
||||
account_spec_name = "deltachat-account"
|
||||
account_hookspec = pluggy.HookspecMarker(account_spec_name)
|
||||
account_hookimpl = pluggy.HookimplMarker(account_spec_name)
|
||||
|
||||
global_spec_name = "deltachat-global"
|
||||
global_hookspec = pluggy.HookspecMarker(global_spec_name)
|
||||
global_hookimpl = pluggy.HookimplMarker(global_spec_name)
|
||||
|
||||
|
||||
def get_plugin_manager():
|
||||
global _plugin_manager
|
||||
if _plugin_manager is None:
|
||||
_plugin_manager = pluggy.PluginManager(name)
|
||||
_plugin_manager.add_hookspecs(DeltaChatHookSpecs)
|
||||
return _plugin_manager
|
||||
class PerAccount:
|
||||
""" per-Account-instance hook specifications.
|
||||
|
||||
Except for ac_process_ffi_event all hooks are executed
|
||||
in the thread which calls Account.wait_shutdown().
|
||||
"""
|
||||
@classmethod
|
||||
def _make_plugin_manager(cls):
|
||||
pm = pluggy.PluginManager(account_spec_name)
|
||||
pm.add_hookspecs(cls)
|
||||
return pm
|
||||
|
||||
@account_hookspec
|
||||
def ac_process_ffi_event(self, ffi_event):
|
||||
""" process a CFFI low level events for a given account.
|
||||
|
||||
ffi_event has "name", "data1", "data2" values as specified
|
||||
with `DC_EVENT_* <https://c.delta.chat/group__DC__EVENT.html>`_.
|
||||
|
||||
DANGER: this hook is executed from the callback invoked by core.
|
||||
Hook implementations need to be short running and can typically
|
||||
not call back into core because this would easily cause recursion issues.
|
||||
"""
|
||||
|
||||
@account_hookspec
|
||||
def ac_log_line(self, message):
|
||||
""" log a message related to the account. """
|
||||
|
||||
@account_hookspec
|
||||
def ac_configure_completed(self, success):
|
||||
""" Called when a configure process completed. """
|
||||
|
||||
@account_hookspec
|
||||
def ac_incoming_message(self, message):
|
||||
""" Called on any incoming message (to deaddrop or chat). """
|
||||
|
||||
@account_hookspec
|
||||
def ac_outgoing_message(self, message):
|
||||
""" Called on each outgoing message (both system and "normal")."""
|
||||
|
||||
@account_hookspec
|
||||
def ac_message_delivered(self, message):
|
||||
""" Called when an outgoing message has been delivered to SMTP. """
|
||||
|
||||
@account_hookspec
|
||||
def ac_chat_modified(self, chat):
|
||||
""" Chat was created or modified regarding membership, avatar, title. """
|
||||
|
||||
@account_hookspec
|
||||
def ac_member_added(self, chat, contact, message):
|
||||
""" Called for each contact added to an accepted chat. """
|
||||
|
||||
@account_hookspec
|
||||
def ac_member_removed(self, chat, contact, message):
|
||||
""" Called for each contact removed from a chat. """
|
||||
|
||||
|
||||
class DeltaChatHookSpecs:
|
||||
""" Plugin Hook specifications for Python bindings to Delta Chat CFFI. """
|
||||
class Global:
|
||||
""" global hook specifications using a per-process singleton
|
||||
plugin manager instance.
|
||||
|
||||
@hookspec
|
||||
def process_low_level_event(self, account, event_name, data1, data2):
|
||||
""" process a CFFI low level events for a given account. """
|
||||
"""
|
||||
_plugin_manager = None
|
||||
|
||||
@classmethod
|
||||
def _get_plugin_manager(cls):
|
||||
if cls._plugin_manager is None:
|
||||
cls._plugin_manager = pm = pluggy.PluginManager(global_spec_name)
|
||||
pm.add_hookspecs(cls)
|
||||
return cls._plugin_manager
|
||||
|
||||
@global_hookspec
|
||||
def dc_account_init(self, account):
|
||||
""" called when `Account::__init__()` function starts executing. """
|
||||
|
||||
@global_hookspec
|
||||
def dc_account_after_shutdown(self, account, dc_context):
|
||||
""" Called after the account has been shutdown. """
|
||||
|
||||
106
python/src/deltachat/iothreads.py
Normal file
106
python/src/deltachat/iothreads.py
Normal file
@@ -0,0 +1,106 @@
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from .capi import lib
|
||||
|
||||
|
||||
class IOThreads:
|
||||
def __init__(self, account):
|
||||
self.account = account
|
||||
self._dc_context = account._dc_context
|
||||
self._thread_quitflag = False
|
||||
self._name2thread = {}
|
||||
|
||||
def is_started(self):
|
||||
return len(self._name2thread) > 0
|
||||
|
||||
def start(self, callback_thread):
|
||||
assert not self.is_started()
|
||||
self._start_one_thread("inbox", self.imap_thread_run)
|
||||
self._start_one_thread("smtp", self.smtp_thread_run)
|
||||
|
||||
if callback_thread:
|
||||
self._start_one_thread("cb", self.cb_thread_run)
|
||||
|
||||
if int(self.account.get_config("mvbox_watch")):
|
||||
self._start_one_thread("mvbox", self.mvbox_thread_run)
|
||||
|
||||
if int(self.account.get_config("sentbox_watch")):
|
||||
self._start_one_thread("sentbox", self.sentbox_thread_run)
|
||||
|
||||
def _start_one_thread(self, name, func):
|
||||
self._name2thread[name] = t = threading.Thread(target=func, name=name)
|
||||
t.setDaemon(1)
|
||||
t.start()
|
||||
|
||||
@contextmanager
|
||||
def log_execution(self, message):
|
||||
self.account.ac_log_line(message + " START")
|
||||
yield
|
||||
self.account.ac_log_line(message + " FINISHED")
|
||||
|
||||
def stop(self, wait=False):
|
||||
self._thread_quitflag = True
|
||||
|
||||
# Workaround for a race condition. Make sure that thread is
|
||||
# not in between checking for quitflag and entering idle.
|
||||
time.sleep(0.5)
|
||||
|
||||
lib.dc_interrupt_imap_idle(self._dc_context)
|
||||
lib.dc_interrupt_smtp_idle(self._dc_context)
|
||||
if "mvbox" in self._name2thread:
|
||||
lib.dc_interrupt_mvbox_idle(self._dc_context)
|
||||
if "sentbox" in self._name2thread:
|
||||
lib.dc_interrupt_sentbox_idle(self._dc_context)
|
||||
if wait:
|
||||
for name, thread in self._name2thread.items():
|
||||
if thread != threading.currentThread():
|
||||
thread.join()
|
||||
|
||||
def cb_thread_run(self):
|
||||
with self.log_execution("CALLBACK THREAD START"):
|
||||
it = self.account.iter_events()
|
||||
while not self._thread_quitflag:
|
||||
try:
|
||||
ev = next(it)
|
||||
except StopIteration:
|
||||
break
|
||||
self.account.ac_log_line("calling hook name={} kwargs={}".format(ev.name, ev.kwargs))
|
||||
ev.call_hook()
|
||||
|
||||
def imap_thread_run(self):
|
||||
with self.log_execution("INBOX THREAD START"):
|
||||
while not self._thread_quitflag:
|
||||
lib.dc_perform_imap_jobs(self._dc_context)
|
||||
if not self._thread_quitflag:
|
||||
lib.dc_perform_imap_fetch(self._dc_context)
|
||||
if not self._thread_quitflag:
|
||||
lib.dc_perform_imap_idle(self._dc_context)
|
||||
|
||||
def mvbox_thread_run(self):
|
||||
with self.log_execution("MVBOX THREAD"):
|
||||
while not self._thread_quitflag:
|
||||
lib.dc_perform_mvbox_jobs(self._dc_context)
|
||||
if not self._thread_quitflag:
|
||||
lib.dc_perform_mvbox_fetch(self._dc_context)
|
||||
if not self._thread_quitflag:
|
||||
lib.dc_perform_mvbox_idle(self._dc_context)
|
||||
|
||||
def sentbox_thread_run(self):
|
||||
with self.log_execution("SENTBOX THREAD"):
|
||||
while not self._thread_quitflag:
|
||||
lib.dc_perform_sentbox_jobs(self._dc_context)
|
||||
if not self._thread_quitflag:
|
||||
lib.dc_perform_sentbox_fetch(self._dc_context)
|
||||
if not self._thread_quitflag:
|
||||
lib.dc_perform_sentbox_idle(self._dc_context)
|
||||
|
||||
def smtp_thread_run(self):
|
||||
with self.log_execution("SMTP THREAD"):
|
||||
while not self._thread_quitflag:
|
||||
lib.dc_perform_smtp_jobs(self._dc_context)
|
||||
if not self._thread_quitflag:
|
||||
lib.dc_perform_smtp_idle(self._dc_context)
|
||||
@@ -28,7 +28,9 @@ class Message(object):
|
||||
return self.account == other.account and self.id == other.id
|
||||
|
||||
def __repr__(self):
|
||||
return "<Message id={} dc_context={}>".format(self.id, self._dc_context)
|
||||
c = self.get_sender_contact()
|
||||
return "<Message id={} sender={}/{} outgoing={} chat={}/{}>".format(
|
||||
self.id, c.id, c.addr, self.is_outgoing(), self.chat.id, self.chat.get_name())
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, account, id):
|
||||
@@ -50,6 +52,16 @@ class Message(object):
|
||||
lib.dc_msg_unref
|
||||
))
|
||||
|
||||
def accept_sender_contact(self):
|
||||
""" ensure that the sender is an accepted contact
|
||||
and that the message has a non-deaddrop chat object.
|
||||
"""
|
||||
self.account.create_chat_by_message(self)
|
||||
self._dc_msg = ffi.gc(
|
||||
lib.dc_get_msg(self._dc_context, self.id),
|
||||
lib.dc_msg_unref
|
||||
)
|
||||
|
||||
@props.with_doc
|
||||
def text(self):
|
||||
"""unicode text of this messages (might be empty if not a text message). """
|
||||
@@ -81,6 +93,10 @@ class Message(object):
|
||||
"""mime type of the file (if it exists)"""
|
||||
return from_dc_charpointer(lib.dc_msg_get_filemime(self._dc_msg))
|
||||
|
||||
def is_system_message(self):
|
||||
""" return True if this message is a system/info message. """
|
||||
return lib.dc_msg_is_info(self._dc_msg)
|
||||
|
||||
def is_setup_message(self):
|
||||
""" return True if this message is a setup message. """
|
||||
return lib.dc_msg_is_setupmessage(self._dc_msg)
|
||||
@@ -159,6 +175,13 @@ class Message(object):
|
||||
chat_id = lib.dc_msg_get_chat_id(self._dc_msg)
|
||||
return Chat(self.account, chat_id)
|
||||
|
||||
def get_sender_chat(self):
|
||||
"""return the 1:1 chat with the sender of this message.
|
||||
|
||||
:returns: :class:`deltachat.chat.Chat` instance
|
||||
"""
|
||||
return self.get_sender_contact().get_chat()
|
||||
|
||||
def get_sender_contact(self):
|
||||
"""return the contact of who wrote the message.
|
||||
|
||||
@@ -166,7 +189,7 @@ class Message(object):
|
||||
"""
|
||||
from .contact import Contact
|
||||
contact_id = lib.dc_msg_get_from_id(self._dc_msg)
|
||||
return Contact(self._dc_context, contact_id)
|
||||
return Contact(self.account, contact_id)
|
||||
|
||||
#
|
||||
# Message State query methods
|
||||
@@ -207,6 +230,13 @@ class Message(object):
|
||||
"""
|
||||
return self._msgstate == const.DC_STATE_IN_SEEN
|
||||
|
||||
def is_outgoing(self):
|
||||
"""Return True if Message is outgoing. """
|
||||
return self._msgstate in (
|
||||
const.DC_STATE_OUT_PREPARING, const.DC_STATE_OUT_PENDING,
|
||||
const.DC_STATE_OUT_FAILED, const.DC_STATE_OUT_MDN_RCVD,
|
||||
const.DC_STATE_OUT_DELIVERED)
|
||||
|
||||
def is_out_preparing(self):
|
||||
"""Return True if Message is outgoing, but its file is being prepared.
|
||||
"""
|
||||
@@ -269,6 +299,10 @@ class Message(object):
|
||||
""" return True if it's a file message. """
|
||||
return self._view_type == const.DC_MSG_FILE
|
||||
|
||||
def mark_seen(self):
|
||||
""" mark this message as seen. """
|
||||
self.account.mark_seen_messages([self.id])
|
||||
|
||||
|
||||
# some code for handling DC_MSG_* view types
|
||||
|
||||
@@ -288,3 +322,29 @@ def get_viewtype_code_from_name(view_type_name):
|
||||
return code
|
||||
raise ValueError("message typecode not found for {!r}, "
|
||||
"available {!r}".format(view_type_name, list(_view_type_mapping.values())))
|
||||
|
||||
|
||||
#
|
||||
# some helper code for turning system messages into hook events
|
||||
#
|
||||
|
||||
def map_system_message(msg):
|
||||
if msg.is_system_message():
|
||||
res = parse_system_add_remove(msg.text)
|
||||
if res:
|
||||
contact = msg.account.get_contact_by_addr(res[1])
|
||||
if contact:
|
||||
d = dict(chat=msg.chat, contact=contact, message=msg)
|
||||
return "ac_member_" + res[0], d
|
||||
|
||||
|
||||
def parse_system_add_remove(text):
|
||||
# Member Me (x@y) removed by a@b.
|
||||
# Member x@y removed by a@b
|
||||
text = text.lower()
|
||||
parts = text.split()
|
||||
if parts[0] == "member":
|
||||
if parts[2] in ("removed", "added"):
|
||||
return parts[2], parts[1]
|
||||
if parts[3] in ("removed", "added"):
|
||||
return parts[3], parts[2].strip("()")
|
||||
|
||||
381
python/src/deltachat/testplugin.py
Normal file
381
python/src/deltachat/testplugin.py
Normal file
@@ -0,0 +1,381 @@
|
||||
from __future__ import print_function
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import queue
|
||||
import threading
|
||||
import fnmatch
|
||||
import pytest
|
||||
import requests
|
||||
import time
|
||||
from . import Account, const
|
||||
from .tracker import ConfigureTracker
|
||||
from .capi import lib
|
||||
from .eventlogger import FFIEventLogger, FFIEventTracker
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
from _pytest._code import Source
|
||||
|
||||
import tempfile
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--liveconfig", action="store", default=None,
|
||||
help="a file with >=2 lines where each line "
|
||||
"contains NAME=VALUE config settings for one account"
|
||||
)
|
||||
parser.addoption(
|
||||
"--ignored", action="store_true",
|
||||
help="Also run tests marked with the ignored marker",
|
||||
)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line(
|
||||
"markers", "ignored: Mark test as bing slow, skipped unless --ignored is used."
|
||||
)
|
||||
cfg = config.getoption('--liveconfig')
|
||||
if not cfg:
|
||||
cfg = os.getenv('DCC_NEW_TMP_EMAIL')
|
||||
if cfg:
|
||||
config.option.liveconfig = cfg
|
||||
|
||||
|
||||
def pytest_runtest_setup(item):
|
||||
if (list(item.iter_markers(name="ignored"))
|
||||
and not item.config.getoption("ignored")):
|
||||
pytest.skip("Ignored tests not requested, use --ignored")
|
||||
|
||||
|
||||
def pytest_report_header(config, startdir):
|
||||
summary = []
|
||||
|
||||
t = tempfile.mktemp()
|
||||
m = MonkeyPatch()
|
||||
try:
|
||||
m.setattr(sys.stdout, "write", lambda x: len(x))
|
||||
ac = Account(t)
|
||||
info = ac.get_info()
|
||||
ac.shutdown()
|
||||
finally:
|
||||
m.undo()
|
||||
os.remove(t)
|
||||
summary.extend(['Deltachat core={} sqlite={}'.format(
|
||||
info['deltachat_core_version'],
|
||||
info['sqlite_version'],
|
||||
)])
|
||||
|
||||
cfg = config.option.liveconfig
|
||||
if cfg:
|
||||
if "?" in cfg:
|
||||
url, token = cfg.split("?", 1)
|
||||
summary.append('Liveconfig provider: {}?<token ommitted>'.format(url))
|
||||
else:
|
||||
summary.append('Liveconfig file: {}'.format(cfg))
|
||||
return summary
|
||||
|
||||
|
||||
class SessionLiveConfigFromFile:
|
||||
def __init__(self, fn):
|
||||
self.fn = fn
|
||||
self.configlist = []
|
||||
for line in open(fn):
|
||||
if line.strip() and not line.strip().startswith('#'):
|
||||
d = {}
|
||||
for part in line.split():
|
||||
name, value = part.split("=")
|
||||
d[name] = value
|
||||
self.configlist.append(d)
|
||||
|
||||
def get(self, index):
|
||||
return self.configlist[index]
|
||||
|
||||
def exists(self):
|
||||
return bool(self.configlist)
|
||||
|
||||
|
||||
class SessionLiveConfigFromURL:
|
||||
def __init__(self, url):
|
||||
self.configlist = []
|
||||
self.url = url
|
||||
|
||||
def get(self, index):
|
||||
try:
|
||||
return self.configlist[index]
|
||||
except IndexError:
|
||||
assert index == len(self.configlist), index
|
||||
res = requests.post(self.url)
|
||||
if res.status_code != 200:
|
||||
pytest.skip("creating newtmpuser failed {!r}".format(res))
|
||||
d = res.json()
|
||||
config = dict(addr=d["email"], mail_pw=d["password"])
|
||||
self.configlist.append(config)
|
||||
return config
|
||||
|
||||
def exists(self):
|
||||
return bool(self.configlist)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def session_liveconfig(request):
|
||||
liveconfig_opt = request.config.option.liveconfig
|
||||
if liveconfig_opt:
|
||||
if liveconfig_opt.startswith("http"):
|
||||
return SessionLiveConfigFromURL(liveconfig_opt)
|
||||
else:
|
||||
return SessionLiveConfigFromFile(liveconfig_opt)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data(request):
|
||||
class Data:
|
||||
def __init__(self):
|
||||
# trying to find test data heuristically
|
||||
# because we are run from a dev-setup with pytest direct,
|
||||
# through tox, and then maybe also from deltachat-binding
|
||||
# users like "deltabot".
|
||||
self.paths = [os.path.normpath(x) for x in [
|
||||
os.path.join(os.path.dirname(request.fspath.strpath), "data"),
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "..", "test-data")
|
||||
]]
|
||||
|
||||
def get_path(self, bn):
|
||||
""" return path of file or None if it doesn't exist. """
|
||||
for path in self.paths:
|
||||
fn = os.path.join(path, *bn.split("/"))
|
||||
if os.path.exists(fn):
|
||||
return fn
|
||||
print("WARNING: path does not exist: {!r}".format(fn))
|
||||
|
||||
def read_path(self, bn, mode="r"):
|
||||
fn = self.get_path(bn)
|
||||
if fn is not None:
|
||||
with open(fn, mode) as f:
|
||||
return f.read()
|
||||
|
||||
return Data()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acfactory(pytestconfig, tmpdir, request, session_liveconfig, data):
|
||||
|
||||
class AccountMaker:
|
||||
def __init__(self):
|
||||
self.live_count = 0
|
||||
self.offline_count = 0
|
||||
self._finalizers = []
|
||||
self.init_time = time.time()
|
||||
self._generated_keys = ["alice", "bob", "charlie",
|
||||
"dom", "elena", "fiona"]
|
||||
|
||||
def finalize(self):
|
||||
while self._finalizers:
|
||||
fin = self._finalizers.pop()
|
||||
fin()
|
||||
|
||||
def make_account(self, path, logid, quiet=False):
|
||||
ac = Account(path)
|
||||
ac._evtracker = ac.add_account_plugin(FFIEventTracker(ac))
|
||||
ac._configtracker = ac.add_account_plugin(ConfigureTracker())
|
||||
if not quiet:
|
||||
ac.add_account_plugin(FFIEventLogger(ac, logid=logid))
|
||||
self._finalizers.append(ac.shutdown)
|
||||
return ac
|
||||
|
||||
def get_unconfigured_account(self):
|
||||
self.offline_count += 1
|
||||
tmpdb = tmpdir.join("offlinedb%d" % self.offline_count)
|
||||
ac = self.make_account(tmpdb.strpath, logid="ac{}".format(self.offline_count))
|
||||
ac._evtracker.init_time = self.init_time
|
||||
ac._evtracker.set_timeout(2)
|
||||
return ac
|
||||
|
||||
def _preconfigure_key(self, account, addr):
|
||||
# Only set a key if we haven't used it yet for another account.
|
||||
if self._generated_keys:
|
||||
keyname = self._generated_keys.pop(0)
|
||||
fname_pub = data.read_path("key/{name}-public.asc".format(name=keyname))
|
||||
fname_sec = data.read_path("key/{name}-secret.asc".format(name=keyname))
|
||||
if fname_pub and fname_sec:
|
||||
account._preconfigure_keypair(addr, fname_pub, fname_sec)
|
||||
return True
|
||||
else:
|
||||
print("WARN: could not use preconfigured keys for {!r}".format(addr))
|
||||
|
||||
def get_configured_offline_account(self):
|
||||
ac = self.get_unconfigured_account()
|
||||
|
||||
# do a pseudo-configured account
|
||||
addr = "addr{}@offline.org".format(self.offline_count)
|
||||
ac.set_config("addr", addr)
|
||||
self._preconfigure_key(ac, addr)
|
||||
lib.dc_set_config(ac._dc_context, b"configured_addr", addr.encode("ascii"))
|
||||
ac.set_config("mail_pw", "123")
|
||||
lib.dc_set_config(ac._dc_context, b"configured_mail_pw", b"123")
|
||||
lib.dc_set_config(ac._dc_context, b"configured", b"1")
|
||||
return ac
|
||||
|
||||
def get_online_config(self, pre_generated_key=True, quiet=False):
|
||||
if not session_liveconfig:
|
||||
pytest.skip("specify DCC_NEW_TMP_EMAIL or --liveconfig")
|
||||
configdict = session_liveconfig.get(self.live_count)
|
||||
self.live_count += 1
|
||||
if "e2ee_enabled" not in configdict:
|
||||
configdict["e2ee_enabled"] = "1"
|
||||
|
||||
# Enable strict certificate checks for online accounts
|
||||
configdict["imap_certificate_checks"] = str(const.DC_CERTCK_STRICT)
|
||||
configdict["smtp_certificate_checks"] = str(const.DC_CERTCK_STRICT)
|
||||
|
||||
tmpdb = tmpdir.join("livedb%d" % self.live_count)
|
||||
ac = self.make_account(tmpdb.strpath, logid="ac{}".format(self.live_count), quiet=quiet)
|
||||
if pre_generated_key:
|
||||
self._preconfigure_key(ac, configdict['addr'])
|
||||
ac._evtracker.init_time = self.init_time
|
||||
ac._evtracker.set_timeout(30)
|
||||
return ac, dict(configdict)
|
||||
|
||||
def get_online_configuring_account(self, mvbox=False, sentbox=False, move=False,
|
||||
pre_generated_key=True, quiet=False, config={}):
|
||||
ac, configdict = self.get_online_config(
|
||||
pre_generated_key=pre_generated_key, quiet=quiet)
|
||||
configdict.update(config)
|
||||
configdict["mvbox_watch"] = str(int(mvbox))
|
||||
configdict["mvbox_move"] = str(int(move))
|
||||
configdict["sentbox_watch"] = str(int(sentbox))
|
||||
ac.update_config(configdict)
|
||||
ac.start()
|
||||
return ac
|
||||
|
||||
def get_one_online_account(self, pre_generated_key=True, mvbox=False, move=False):
|
||||
ac1 = self.get_online_configuring_account(
|
||||
pre_generated_key=pre_generated_key, mvbox=mvbox, move=move)
|
||||
ac1._configtracker.wait_imap_connected()
|
||||
ac1._configtracker.wait_smtp_connected()
|
||||
ac1._configtracker.wait_finish()
|
||||
return ac1
|
||||
|
||||
def get_two_online_accounts(self, move=False, quiet=False):
|
||||
ac1 = self.get_online_configuring_account(move=True, quiet=quiet)
|
||||
ac2 = self.get_online_configuring_account(quiet=quiet)
|
||||
ac1._configtracker.wait_finish()
|
||||
ac2._configtracker.wait_finish()
|
||||
return ac1, ac2
|
||||
|
||||
def clone_online_account(self, account, pre_generated_key=True):
|
||||
self.live_count += 1
|
||||
tmpdb = tmpdir.join("livedb%d" % self.live_count)
|
||||
ac = self.make_account(tmpdb.strpath, logid="ac{}".format(self.live_count))
|
||||
if pre_generated_key:
|
||||
self._preconfigure_key(ac, account.get_config("addr"))
|
||||
ac._evtracker.init_time = self.init_time
|
||||
ac._evtracker.set_timeout(30)
|
||||
ac.update_config(dict(
|
||||
addr=account.get_config("addr"),
|
||||
mail_pw=account.get_config("mail_pw"),
|
||||
mvbox_watch=account.get_config("mvbox_watch"),
|
||||
mvbox_move=account.get_config("mvbox_move"),
|
||||
sentbox_watch=account.get_config("sentbox_watch"),
|
||||
))
|
||||
ac.start()
|
||||
return ac
|
||||
|
||||
def run_bot_process(self, module, ffi=True):
|
||||
fn = module.__file__
|
||||
|
||||
bot_ac, bot_cfg = self.get_online_config()
|
||||
|
||||
args = [
|
||||
sys.executable,
|
||||
"-u",
|
||||
fn,
|
||||
"--email", bot_cfg["addr"],
|
||||
"--password", bot_cfg["mail_pw"],
|
||||
bot_ac.db_path,
|
||||
]
|
||||
if ffi:
|
||||
args.insert(-1, "--show-ffi")
|
||||
print("$", " ".join(args))
|
||||
popen = subprocess.Popen(
|
||||
args=args,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, # combine stdout/stderr in one stream
|
||||
bufsize=0, # line buffering
|
||||
close_fds=True, # close all FDs other than 0/1/2
|
||||
universal_newlines=True # give back text
|
||||
)
|
||||
bot = BotProcess(popen, bot_cfg)
|
||||
self._finalizers.append(bot.kill)
|
||||
return bot
|
||||
|
||||
am = AccountMaker()
|
||||
request.addfinalizer(am.finalize)
|
||||
return am
|
||||
|
||||
|
||||
class BotProcess:
|
||||
def __init__(self, popen, bot_cfg):
|
||||
self.popen = popen
|
||||
self.addr = bot_cfg["addr"]
|
||||
|
||||
# we read stdout as quickly as we can in a thread and make
|
||||
# the (unicode) lines available for readers through a queue.
|
||||
self.stdout_queue = queue.Queue()
|
||||
self.stdout_thread = t = threading.Thread(target=self._run_stdout_thread, name="bot-stdout-thread")
|
||||
t.setDaemon(1)
|
||||
t.start()
|
||||
|
||||
def _run_stdout_thread(self):
|
||||
try:
|
||||
while 1:
|
||||
line = self.popen.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
self.stdout_queue.put(line)
|
||||
finally:
|
||||
self.stdout_queue.put(None)
|
||||
|
||||
def kill(self):
|
||||
self.popen.kill()
|
||||
|
||||
def wait(self, timeout=30):
|
||||
self.popen.wait(timeout=timeout)
|
||||
|
||||
def fnmatch_lines(self, pattern_lines):
|
||||
patterns = [x.strip() for x in Source(pattern_lines.rstrip()).lines if x.strip()]
|
||||
for next_pattern in patterns:
|
||||
print("+++FNMATCH:", next_pattern)
|
||||
ignored = []
|
||||
while 1:
|
||||
line = self.stdout_queue.get(timeout=15)
|
||||
if line is None:
|
||||
if ignored:
|
||||
print("BOT stdout terminated after these lines")
|
||||
for line in ignored:
|
||||
print(line)
|
||||
raise IOError("BOT stdout-thread terminated")
|
||||
if fnmatch.fnmatch(line, next_pattern):
|
||||
print("+++MATCHED:", line)
|
||||
break
|
||||
else:
|
||||
print("+++IGN:", line)
|
||||
ignored.append(line)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_db_path(tmpdir):
|
||||
return tmpdir.join("test.db").strpath
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lp():
|
||||
class Printer:
|
||||
def sec(self, msg):
|
||||
print()
|
||||
print("=" * 10, msg, "=" * 10)
|
||||
|
||||
def step(self, msg):
|
||||
print("-" * 5, "step " + msg, "-" * 5)
|
||||
return Printer()
|
||||
76
python/src/deltachat/tracker.py
Normal file
76
python/src/deltachat/tracker.py
Normal file
@@ -0,0 +1,76 @@
|
||||
|
||||
from queue import Queue
|
||||
from threading import Event
|
||||
|
||||
from .hookspec import account_hookimpl
|
||||
|
||||
|
||||
class ImexFailed(RuntimeError):
|
||||
""" Exception for signalling that import/export operations failed."""
|
||||
|
||||
|
||||
class ImexTracker:
|
||||
def __init__(self):
|
||||
self._imex_events = Queue()
|
||||
|
||||
@account_hookimpl
|
||||
def ac_process_ffi_event(self, ffi_event):
|
||||
if ffi_event.name == "DC_EVENT_IMEX_PROGRESS":
|
||||
self._imex_events.put(ffi_event.data1)
|
||||
elif ffi_event.name == "DC_EVENT_IMEX_FILE_WRITTEN":
|
||||
self._imex_events.put(ffi_event.data1)
|
||||
|
||||
def wait_finish(self, progress_timeout=60):
|
||||
""" Return list of written files, raise ValueError if ExportFailed. """
|
||||
files_written = []
|
||||
while True:
|
||||
ev = self._imex_events.get(timeout=progress_timeout)
|
||||
if isinstance(ev, str):
|
||||
files_written.append(ev)
|
||||
elif ev == 0:
|
||||
raise ImexFailed("export failed, exp-files: {}".format(files_written))
|
||||
elif ev == 1000:
|
||||
return files_written
|
||||
|
||||
|
||||
class ConfigureFailed(RuntimeError):
|
||||
""" Exception for signalling that configuration failed."""
|
||||
|
||||
|
||||
class ConfigureTracker:
|
||||
ConfigureFailed = ConfigureFailed
|
||||
|
||||
def __init__(self):
|
||||
self._configure_events = Queue()
|
||||
self._smtp_finished = Event()
|
||||
self._imap_finished = Event()
|
||||
self._ffi_events = []
|
||||
|
||||
@account_hookimpl
|
||||
def ac_process_ffi_event(self, ffi_event):
|
||||
self._ffi_events.append(ffi_event)
|
||||
if ffi_event.name == "DC_EVENT_SMTP_CONNECTED":
|
||||
self._smtp_finished.set()
|
||||
elif ffi_event.name == "DC_EVENT_IMAP_CONNECTED":
|
||||
self._imap_finished.set()
|
||||
|
||||
@account_hookimpl
|
||||
def ac_configure_completed(self, success):
|
||||
self._configure_events.put(success)
|
||||
|
||||
def wait_smtp_connected(self):
|
||||
""" wait until smtp is configured. """
|
||||
self._smtp_finished.wait()
|
||||
|
||||
def wait_imap_connected(self):
|
||||
""" wait until smtp is configured. """
|
||||
self._imap_finished.wait()
|
||||
|
||||
def wait_finish(self):
|
||||
""" wait until configure is completed.
|
||||
|
||||
Raise Exception if Configure failed
|
||||
"""
|
||||
if not self._configure_events.get():
|
||||
content = "\n".join(map(str, self._ffi_events))
|
||||
raise ConfigureFailed(content)
|
||||
Reference in New Issue
Block a user