Compare commits

...

15 Commits

Author SHA1 Message Date
B. Petersen
9b00fbbf80 add database/blobdir usage to get_info() 2021-11-18 22:19:13 +01:00
Hocuri
1b9148f28e Add section comments to get_connectivity_html() (#2807) 2021-11-18 10:13:47 +01:00
Hocuri
e0129c3b43 Don't set draft after creating group (#2805)
* Don't set draft after creating group

* Remove DC_STR_NEWGROUPDRAFT, add note to Changelog

* Fix the (Rust) test

* Also fix python test
2021-11-16 10:44:30 +01:00
link2xt
60d41022ea scripts: switch from python 3.6 to python 3.7
PEP 562 (__getattr__ in deltachat.const module) is implemented only since python 3.7
2021-11-14 00:00:00 +00:00
bjoern
dd4f2ac671 prepare 1.65 (#2812)
* update changelog for 1.65.0

* bump version to 1.65.0
2021-11-15 14:51:28 +01:00
bjoern
eebb2a3b68 do not assume 'no ephemeral timer' on partial downloads (#2811)
* do not assume 'no ephemeral timer' on partial downloads

* add a test to check that ephemeral timers are not disabled on partial downloads
2021-11-15 11:45:06 +01:00
link2xt
0d62069b67 python: add mypy support and some type hints
`deltachat.const` module now defines `__getattr__` and `__dir__` as
suggested by https://www.python.org/dev/peps/pep-0562/
mypy detects that `__getattr__` is defined and does not show errors
for `DC_*` constants which cannot be detected statically.
mypy is added to `tox.ini`, so type check can be run with `tox -e mypy`.
2021-11-14 11:06:44 +03:00
link2xt
56cf2e6596 Replace error! on verification failure with warn!
A message is added into 1:1 chat anyway, and user does not know what `StockMessage::ContactNotVerified` means.
2021-11-14 02:02:23 +03:00
Simon Laux
59bd5481b9 fix 1.61.0 changelog (#2806) 2021-11-13 22:06:29 +01:00
Hocuri
6c8da526a0 Fix: Only show the "Cannot login" device message if it's actually authentication that failed (#2804)
Generally we could also just remove the device message as we have the
connectivity view, OTOH if you are not using DC a lot, it may be useful
to be notified without opening DC.

And apart from this one bug it's working fine.
2021-11-13 19:20:01 +01:00
link2xt
13bc8b78d7 python: enable isolated build in tox.ini
This makes tox install build system configured in pyproject.toml
according to PEP 518 rather than assuming setuptools.
2021-11-13 03:45:58 +00:00
link2xt
c7c68094d9 setup.py: restore compatibility with setup.py sdist command
Otherwise source package with version 0.0.0 is created.
2021-11-12 23:41:17 +00:00
bjoern
84f54b10dc prepare 1.64 (#2802)
* update changelog for 1.64

* bump version to 1.64.0
2021-11-11 16:45:30 +01:00
bjoern
cebc9e3e91 add 'waiting for being added to the group' only for group-joins (#2797)
* add 'waiting for being added to the group' only for group-joins, not for setup-contact

* add a comment why the message is not added on setup-contact
2021-11-07 20:30:55 +01:00
link2xt
1379f8a055 Factor apply_group_changes out of create_or_lookup_group
`apply_group_changes` is executed regardless of whether the group is
created via `create_or_lookup_group` or found via
`lookup_chat_by_reply`. This change removes the need for
`lookup_chat_by_reply` to return `None` when group ID exists in the
database to let `create_or_lookup_group` run. As a side effect of this
Delta Chat replies to ad hoc groups are now correctly assigned to
chats when there are multiple groups with the same group ID, such as
ad hoc groups with the same member lists.
2021-11-07 18:34:44 +03:00
32 changed files with 626 additions and 386 deletions

View File

@@ -150,4 +150,4 @@ jobs:
DCC_RS_TARGET: debug
DCC_RS_DEV: ${{ github.workspace }}
working-directory: python
run: tox -e lint,doc,py3
run: tox -e lint,mypy,doc,py3

View File

@@ -1,5 +1,32 @@
# Changelog
## Unreleased
- Removed DC_STR_NEWGROUPDRAFT, we don't set draft after creating group anymore #2805
## 1.65.0
### Changes
- python: add mypy support and some type hints #2809
### Fixes
- do not disable ephemeral timer when downloading a message partially #2811
- apply existing ephemeral timer also to partially downloaded messages;
after full download, the ephemeral timer starts over #2811
- replace user-visible error on verification failure with warning;
the error is logged to the corresponding chat anyway #2808
## 1.64.0
### Fixes
- add 'waiting for being added to the group' only for group-joins,
not for setup-contact #2797
- prioritize In-Reply-To: and References: headers over group IDs when assigning
messages to chats to fix incorrect assignment of Delta Chat replies to
classic email threads #2795
## 1.63.0
### API changes
@@ -40,10 +67,10 @@
## 1.61.0
### API Changes
- download-on-demand added: `dc_msg_get_download_status()`, `dc_download_full_msg()`
- download-on-demand added: `dc_msg_get_download_state()`, `dc_download_full_msg()`
and `download_limit` config option #2631 #2696
- `dc_create_broadcast_list()` and chat type `DC_CHAT_TYPE_BROADCAST` added #2707 #2722
- allow ui-specific configs: `dc_set_ui_config()` and `dc_get_ui_config()` #2672
- allow ui-specific configs using `ui.`-prefix in key (`dc_set_config(context, "ui.*", value)`) #2672
- new strings from `DC_STR_PARTIAL_DOWNLOAD_MSG_BODY`
to `DC_STR_PART_OF_TOTAL_USED` #2631 #2694 #2707 #2723
- emit warnings and errors from account manager with account-id 0 #2712

4
Cargo.lock generated
View File

@@ -1072,7 +1072,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "1.63.0"
version = "1.65.0"
dependencies = [
"ansi_term",
"anyhow",
@@ -1152,7 +1152,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "1.63.0"
version = "1.65.0"
dependencies = [
"anyhow",
"async-std",

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "1.63.0"
version = "1.65.0"
authors = ["Delta Chat Developers (ML) <delta@codespeak.net>"]
edition = "2018"
license = "MPL-2.0"

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "1.63.0"
version = "1.65.0"
description = "Deltachat FFI"
authors = ["Delta Chat Developers (ML) <delta@codespeak.net>"]
edition = "2018"

View File

@@ -5613,12 +5613,6 @@ void dc_event_unref(dc_event_t* event);
/// if nothing else is set by the dc_set_config()-option `selfstatus`.
#define DC_STR_STATUSLINE 13
/// "Hi, i've created the group %1$s for us."
///
/// Used as a draft text after group creation.
/// - %1$s will be replaced by the group name
#define DC_STR_NEWGROUPDRAFT 14
/// "Group name changed from %1$s to %2$s."
///
/// Used in status messages for group name changes.

19
python/mypy.ini Normal file
View File

@@ -0,0 +1,19 @@
[mypy]
[mypy-deltachat.capi.*]
ignore_missing_imports = True
[mypy-pluggy.*]
ignore_missing_imports = True
[mypy-cffi.*]
ignore_missing_imports = True
[mypy-imapclient.*]
ignore_missing_imports = True
[mypy-pytest.*]
ignore_missing_imports = True
[mypy-_pytest.*]
ignore_missing_imports = True

View File

@@ -1,5 +1,6 @@
[build-system]
requires = ["setuptools>=45", "wheel", "setuptools_scm>=6.2", "cffi>=1.0.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools_scm]
root = ".."

View File

@@ -12,6 +12,7 @@ def main():
long_description=long_description,
author='holger krekel, Floris Bruynooghe, Bjoern Petersen and contributors',
install_requires=['cffi>=1.0.0', 'pluggy', 'imapclient', 'requests'],
setup_requires=['setuptools_scm'], # required for compatibility with `python3 setup.py sdist`
packages=setuptools.find_packages('src'),
package_dir={'': 'src'},
cffi_modules=['src/deltachat/_build.py:ffibuilder'],

View File

@@ -19,9 +19,9 @@ except DistributionNotFound:
def get_dc_event_name(integer, _DC_EVENTNAME_MAP={}):
if not _DC_EVENTNAME_MAP:
for name, val in vars(const).items():
for name in dir(const):
if name.startswith("DC_EVENT_"):
_DC_EVENTNAME_MAP[val] = name
_DC_EVENTNAME_MAP[getattr(const, name)] = name
return _DC_EVENTNAME_MAP[integer]

View File

@@ -15,6 +15,7 @@ from .contact import Contact
from .tracker import ImexTracker, ConfigureTracker
from . import hookspec
from .events import EventThread
from typing import Union, Any, Dict, Optional, List, Generator
class MissingCredentials(ValueError):
@@ -28,7 +29,7 @@ class Account(object):
"""
MissingCredentials = MissingCredentials
def __init__(self, db_path, os_name=None, logging=True):
def __init__(self, db_path, os_name=None, logging=True) -> None:
""" initialize account object.
:param db_path: a path to the account database. The database
@@ -58,11 +59,11 @@ class Account(object):
hook = hookspec.Global._get_plugin_manager().hook
hook.dc_account_init(account=self)
def disable_logging(self):
def disable_logging(self) -> None:
""" disable logging. """
self._logging = False
def enable_logging(self):
def enable_logging(self) -> None:
""" re-enable logging. """
self._logging = True
@@ -73,7 +74,7 @@ class Account(object):
if self._logging:
self._pm.hook.ac_log_line(message=msg)
def _check_config_key(self, name):
def _check_config_key(self, name: str) -> None:
if name not in self._configkeys:
raise KeyError("{!r} not a valid config key, existing keys: {!r}".format(
name, self._configkeys))
@@ -105,19 +106,19 @@ class Account(object):
cursor += len(entry) + 1
log("")
def set_stock_translation(self, id, string):
def set_stock_translation(self, id: int, string: str) -> None:
""" set stock translation string.
:param id: id of stock string (const.DC_STR_*)
:param value: string to set as new transalation
:returns: None
"""
string = string.encode("utf8")
res = lib.dc_set_stock_translation(self._dc_context, id, string)
bytestring = string.encode("utf8")
res = lib.dc_set_stock_translation(self._dc_context, id, bytestring)
if res == 0:
raise ValueError("could not set translation string")
def set_config(self, name, value):
def set_config(self, name: str, value: Optional[str]) -> None:
""" set configuration values.
:param name: config key name (unicode)
@@ -125,16 +126,16 @@ class Account(object):
:returns: None
"""
self._check_config_key(name)
name = name.encode("utf8")
if name == b"addr" and self.is_configured():
namebytes = name.encode("utf8")
if namebytes == b"addr" and self.is_configured():
raise ValueError("can not change 'addr' after account is configured.")
if value is not None:
value = value.encode("utf8")
valuebytes = value.encode("utf8")
else:
value = ffi.NULL
lib.dc_set_config(self._dc_context, name, value)
valuebytes = ffi.NULL
lib.dc_set_config(self._dc_context, namebytes, valuebytes)
def get_config(self, name):
def get_config(self, name: str):
""" return unicode string value.
:param name: configuration key to lookup (eg "addr" or "mail_pw")
@@ -143,12 +144,12 @@ class Account(object):
"""
if name != "sys.config_keys":
self._check_config_key(name)
name = name.encode("utf8")
res = lib.dc_get_config(self._dc_context, name)
namebytes = name.encode("utf8")
res = lib.dc_get_config(self._dc_context, namebytes)
assert res != ffi.NULL, "config value not found for: {!r}".format(name)
return from_dc_charpointer(res)
def _preconfigure_keypair(self, addr, public, secret):
def _preconfigure_keypair(self, addr: str, public: str, secret: str) -> None:
"""See dc_preconfigure_keypair() in deltachat.h.
In other words, you don't need this.
@@ -160,7 +161,7 @@ class Account(object):
if res == 0:
raise Exception("Failed to set key")
def update_config(self, kwargs):
def update_config(self, kwargs: Dict[str, Any]) -> None:
""" update config values.
:param kwargs: name=value config settings for this account.
@@ -170,7 +171,7 @@ class Account(object):
for key, value in kwargs.items():
self.set_config(key, str(value))
def is_configured(self):
def is_configured(self) -> bool:
""" determine if the account is configured already; an initial connection
to SMTP/IMAP has been verified.
@@ -178,7 +179,7 @@ class Account(object):
"""
return True if lib.dc_is_configured(self._dc_context) else False
def set_avatar(self, img_path):
def set_avatar(self, img_path: Optional[str]) -> None:
"""Set self avatar.
:raises ValueError: if profile image could not be set
@@ -190,12 +191,12 @@ class Account(object):
assert os.path.exists(img_path), img_path
self.set_config("selfavatar", img_path)
def check_is_configured(self):
def check_is_configured(self) -> None:
""" Raise ValueError if this account is not configured. """
if not self.is_configured():
raise ValueError("need to configure first")
def get_latest_backupfile(self, backupdir):
def get_latest_backupfile(self, backupdir) -> Optional[str]:
""" return the latest backup file in a given directory.
"""
res = lib.dc_imex_has_backup(self._dc_context, as_dc_charpointer(backupdir))
@@ -203,7 +204,7 @@ class Account(object):
return None
return from_dc_charpointer(res)
def get_blobdir(self):
def get_blobdir(self) -> Optional[str]:
""" return the directory for files.
All sent files are copied to this directory if necessary.
@@ -211,15 +212,15 @@ class Account(object):
"""
return from_dc_charpointer(lib.dc_get_blobdir(self._dc_context))
def get_self_contact(self):
def get_self_contact(self) -> Contact:
""" return this account's identity as a :class:`deltachat.contact.Contact`.
:returns: :class:`deltachat.contact.Contact`
"""
return Contact(self, const.DC_CONTACT_ID_SELF)
def create_contact(self, obj, name=None):
""" create a (new) Contact or return an existing one.
def create_contact(self, obj, name: Optional[str] = None) -> Contact:
"""create a (new) Contact or return an existing one.
Calling this method will always result in the same
underlying contact id. If there already is a Contact
@@ -236,13 +237,13 @@ class Account(object):
contact_id = lib.dc_create_contact(self._dc_context, name, addr)
return Contact(self, contact_id)
def get_contact(self, obj):
def get_contact(self, obj) -> Optional[Contact]:
if isinstance(obj, Contact):
return obj
(_, addr) = self.get_contact_addr_and_name(obj)
return self.get_contact_by_addr(addr)
def get_contact_addr_and_name(self, obj, name=None):
def get_contact_addr_and_name(self, obj, name: Optional[str] = None):
if isinstance(obj, Account):
if not obj.is_configured():
raise ValueError("can only add addresses from configured accounts")
@@ -260,7 +261,7 @@ class Account(object):
name = displayname
return (name, addr)
def delete_contact(self, contact):
def delete_contact(self, contact: Contact) -> bool:
""" delete a Contact.
:param contact: contact object obtained
@@ -271,22 +272,23 @@ 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):
def get_contact_by_addr(self, email: str) -> Optional[Contact]:
""" 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)
return None
def get_contact_by_id(self, contact_id):
""" return Contact instance or None.
def get_contact_by_id(self, contact_id: int) -> Contact:
""" return Contact instance or raise an exception.
:param contact_id: integer id of this contact.
:returns: None or :class:`deltachat.contact.Contact` instance.
:returns: :class:`deltachat.contact.Contact` instance.
"""
return Contact(self, contact_id)
def get_blocked_contacts(self):
def get_blocked_contacts(self) -> List[Contact]:
""" return a list of all blocked contacts.
:returns: list of :class:`deltachat.contact.Contact` objects.
@@ -297,8 +299,13 @@ class Account(object):
)
return list(iter_array(dc_array, lambda x: Contact(self, x)))
def get_contacts(self, query=None, with_self=False, only_verified=False):
""" get a (filtered) list of contacts.
def get_contacts(
self,
query: Optional[str] = None,
with_self: bool = False,
only_verified: bool = False,
) -> List[Contact]:
"""get a (filtered) list of contacts.
:param query: if a string is specified, only return contacts
whose name or e-mail matches query.
@@ -318,7 +325,7 @@ class Account(object):
)
return list(iter_array(dc_array, lambda x: Contact(self, x)))
def get_fresh_messages(self):
def get_fresh_messages(self) -> Generator[Message, None, None]:
""" yield all fresh messages from all chats. """
dc_array = ffi.gc(
lib.dc_get_fresh_msgs(self._dc_context),
@@ -326,12 +333,17 @@ class Account(object):
)
yield from iter_array(dc_array, lambda x: Message.from_db(self, x))
def create_chat(self, obj):
def create_chat(self, obj) -> Chat:
""" Create a 1:1 chat with Account, Contact or e-mail address. """
return self.create_contact(obj).create_chat()
def create_group_chat(self, name, contacts=None, verified=False):
""" create a new group chat object.
def create_group_chat(
self,
name: str,
contacts: Optional[List[Contact]] = None,
verified: bool = False,
) -> Chat:
"""create a new group chat object.
Chats are unpromoted until the first message is sent.
@@ -347,7 +359,7 @@ class Account(object):
chat.add_contact(contact)
return chat
def get_chats(self):
def get_chats(self) -> List[Chat]:
""" return list of chats.
:returns: a list of :class:`deltachat.chat.Chat` objects.
@@ -364,17 +376,17 @@ class Account(object):
chatlist.append(Chat(self, chat_id))
return chatlist
def get_device_chat(self):
def get_device_chat(self) -> Chat:
return Contact(self, const.DC_CONTACT_ID_DEVICE).create_chat()
def get_message_by_id(self, msg_id):
def get_message_by_id(self, msg_id: int) -> Message:
""" return Message instance.
:param msg_id: integer id of this message.
:returns: :class:`deltachat.message.Message` instance.
"""
return Message.from_db(self, msg_id)
def get_chat_by_id(self, chat_id):
def get_chat_by_id(self, chat_id: int) -> Chat:
""" return Chat instance.
:param chat_id: integer id of this chat.
:returns: :class:`deltachat.chat.Chat` instance.
@@ -386,19 +398,18 @@ class Account(object):
lib.dc_chat_unref(res)
return Chat(self, chat_id)
def mark_seen_messages(self, messages):
def mark_seen_messages(self, messages: List[Union[int, Message]]) -> None:
""" mark the given set of messages as seen.
:param messages: a list of message ids or Message instances.
"""
arr = array("i")
for msg in messages:
msg = getattr(msg, "id", msg)
arr.append(msg)
arr.append(getattr(msg, "id", msg))
msg_ids = ffi.cast("uint32_t*", ffi.from_buffer(arr))
lib.dc_markseen_msgs(self._dc_context, msg_ids, len(messages))
def forward_messages(self, messages, chat):
def forward_messages(self, messages: List[Message], chat: Chat) -> None:
""" Forward list of messages to a chat.
:param messages: list of :class:`deltachat.message.Message` object.
@@ -408,7 +419,7 @@ class Account(object):
msg_ids = [msg.id for msg in messages]
lib.dc_forward_msgs(self._dc_context, msg_ids, len(msg_ids), chat.id)
def delete_messages(self, messages):
def delete_messages(self, messages: List[Message]) -> None:
""" delete messages (local and remote).
:param messages: list of :class:`deltachat.message.Message` object.
@@ -477,7 +488,7 @@ class Account(object):
raise RuntimeError("could not send out autocrypt setup message")
return from_dc_charpointer(res)
def get_setup_contact_qr(self):
def get_setup_contact_qr(self) -> Optional[str]:
""" get/create Setup-Contact QR Code as ascii-string.
this string needs to be transferred to another DC account
@@ -527,7 +538,9 @@ class Account(object):
raise ValueError("could not join group")
return Chat(self, chat_id)
def set_location(self, latitude=0.0, longitude=0.0, accuracy=0.0):
def set_location(
self, latitude: float = 0.0, longitude: float = 0.0, accuracy: float = 0.0
) -> None:
"""set a new location. It effects all chats where we currently
have enabled location streaming.
@@ -621,7 +634,7 @@ class Account(object):
"""
lib.dc_maybe_network(self._dc_context)
def configure(self, reconfigure=False):
def configure(self, reconfigure: bool = False) -> ConfigureTracker:
""" Start configuration process and return a Configtracker instance
on which you can block with wait_finish() to get a True/False success
value for the configuration process.
@@ -634,11 +647,11 @@ class Account(object):
lib.dc_configure(self._dc_context)
return configtracker
def wait_shutdown(self):
def wait_shutdown(self) -> None:
""" wait until shutdown of this account has completed. """
self._shutdown_event.wait()
def stop_io(self):
def stop_io(self) -> None:
""" stop core IO scheduler if it is running. """
self.log("stop_ongoing")
self.stop_ongoing()
@@ -646,7 +659,7 @@ class Account(object):
self.log("dc_stop_io (stop core IO scheduler)")
lib.dc_stop_io(self._dc_context)
def shutdown(self):
def shutdown(self) -> None:
""" shutdown and destroy account (stop callback thread, close and remove
underlying dc_context)."""
if self._dc_context is None:

View File

@@ -9,6 +9,7 @@ from .cutil import as_dc_charpointer, from_dc_charpointer, iter_array
from .capi import lib, ffi
from . import const
from .message import Message
from typing import Optional
class Chat(object):
@@ -17,20 +18,20 @@ class Chat(object):
You obtain instances of it through :class:`deltachat.account.Account`.
"""
def __init__(self, account, id):
def __init__(self, account, id) -> None:
from .account import Account
assert isinstance(account, Account), repr(account)
self.account = account
self.id = id
def __eq__(self, other):
def __eq__(self, other) -> bool:
return self.id == getattr(other, "id", None) and \
self.account._dc_context == other.account._dc_context
def __ne__(self, other):
def __ne__(self, other) -> bool:
return not (self == other)
def __repr__(self):
def __repr__(self) -> str:
return "<Chat id={} name={}>".format(self.id, self.get_name())
@property
@@ -40,7 +41,7 @@ class Chat(object):
lib.dc_chat_unref
)
def delete(self):
def delete(self) -> None:
"""Delete this chat and all its messages.
Note:
@@ -50,24 +51,24 @@ class Chat(object):
"""
lib.dc_delete_chat(self.account._dc_context, self.id)
def block(self):
def block(self) -> None:
"""Block this chat."""
lib.dc_block_chat(self.account._dc_context, self.id)
def accept(self):
def accept(self) -> None:
"""Accept this contact request chat."""
lib.dc_accept_chat(self.account._dc_context, self.id)
# ------ chat status/metadata API ------------------------------
def is_group(self):
def is_group(self) -> bool:
""" 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) == const.DC_CHAT_TYPE_GROUP
def is_muted(self):
def is_muted(self) -> bool:
""" return true if this chat is muted.
:returns: True if chat is muted, False otherwise.
@@ -90,7 +91,7 @@ class Chat(object):
"""
return not lib.dc_chat_is_unpromoted(self._dc_chat)
def can_send(self):
def can_send(self) -> bool:
"""Check if messages can be sent to a give chat.
This is not true eg. for the contact requests or for the device-talk
@@ -98,30 +99,30 @@ class Chat(object):
"""
return lib.dc_chat_can_send(self._dc_chat)
def is_protected(self):
def is_protected(self) -> bool:
""" return True if this chat is a protected chat.
:returns: True if chat is protected, False otherwise.
"""
return lib.dc_chat_is_protected(self._dc_chat)
def get_name(self):
def get_name(self) -> Optional[str]:
""" return name of this chat.
:returns: unicode name
"""
return from_dc_charpointer(lib.dc_chat_get_name(self._dc_chat))
def set_name(self, name):
def set_name(self, name: str) -> bool:
""" set name of this chat.
:param name: as a unicode string.
:returns: None
:returns: True on success, False otherwise
"""
name = as_dc_charpointer(name)
return lib.dc_set_chat_name(self.account._dc_context, self.id, name)
return bool(lib.dc_set_chat_name(self.account._dc_context, self.id, name))
def mute(self, duration=None):
def mute(self, duration: Optional[int] = None) -> None:
""" mutes the chat
:param duration: Number of seconds to mute the chat for. None to mute until unmuted again.
@@ -135,7 +136,7 @@ class Chat(object):
if not bool(ret):
raise ValueError("Call to dc_set_chat_mute_duration failed")
def unmute(self):
def unmute(self) -> None:
""" unmutes the chat
:returns: None
@@ -144,7 +145,7 @@ class Chat(object):
if not bool(ret):
raise ValueError("Failed to unmute chat")
def get_mute_duration(self):
def get_mute_duration(self) -> int:
""" Returns the number of seconds until the mute of this chat is lifted.
:param duration:
@@ -152,37 +153,37 @@ class Chat(object):
"""
return lib.dc_chat_get_remaining_mute_duration(self._dc_chat)
def get_ephemeral_timer(self):
def get_ephemeral_timer(self) -> int:
""" get ephemeral timer.
:returns: ephemeral timer value in seconds
"""
return lib.dc_get_chat_ephemeral_timer(self.account._dc_context, self.id)
def set_ephemeral_timer(self, timer):
def set_ephemeral_timer(self, timer: int) -> bool:
""" set ephemeral timer.
:param: timer value in seconds
:returns: None
:returns: True on success, False otherwise
"""
return lib.dc_set_chat_ephemeral_timer(self.account._dc_context, self.id, timer)
return bool(lib.dc_set_chat_ephemeral_timer(self.account._dc_context, self.id, timer))
def get_type(self):
def get_type(self) -> int:
""" (deprecated) return type of this chat.
:returns: one of const.DC_CHAT_TYPE_*
"""
return lib.dc_chat_get_type(self._dc_chat)
def get_encryption_info(self):
def get_encryption_info(self) -> Optional[str]:
"""Return encryption info for this chat.
:returns: a string with encryption preferences of all chat members"""
res = lib.dc_get_chat_encrinfo(self.account._dc_context, self.id)
return from_dc_charpointer(res)
def get_join_qr(self):
def get_join_qr(self) -> Optional[str]:
""" get/create Join-Group QR Code as ascii-string.
this string needs to be transferred to another DC account
@@ -194,7 +195,7 @@ class Chat(object):
# ------ chat messaging API ------------------------------
def send_msg(self, msg):
def send_msg(self, msg: Message) -> Message:
"""send a message by using a ready Message object.
:param msg: a :class:`deltachat.message.Message` instance

View File

@@ -1,7 +1,13 @@
from typing import Any, List
from .capi import lib
for name in dir(lib):
def __getattr__(name: str) -> Any:
if name.startswith("DC_"):
globals()[name] = getattr(lib, name)
del name
return getattr(lib, name)
return globals()[name]
def __dir__() -> List[str]:
return sorted(name for name in dir(lib) if name.startswith("DC_"))

View File

@@ -1,6 +1,9 @@
from .capi import lib
from .capi import ffi
from datetime import datetime, timezone
from typing import Optional, TypeVar, Generator, Callable
T = TypeVar('T')
def as_dc_charpointer(obj):
@@ -11,21 +14,22 @@ def as_dc_charpointer(obj):
return obj
def iter_array(dc_array_t, constructor):
def iter_array(dc_array_t, constructor: Callable[[int], T]) -> Generator[T, None, None]:
for i in range(0, lib.dc_array_get_cnt(dc_array_t)):
yield constructor(lib.dc_array_get_id(dc_array_t, i))
def from_dc_charpointer(obj):
def from_dc_charpointer(obj) -> Optional[str]:
if obj != ffi.NULL:
return ffi.string(ffi.gc(obj, lib.dc_str_unref)).decode("utf8")
return None
class DCLot:
def __init__(self, dc_lot):
def __init__(self, dc_lot) -> None:
self._dc_lot = dc_lot
def id(self):
def id(self) -> int:
return lib.dc_lot_get_id(self._dc_lot)
def state(self):

View File

@@ -11,7 +11,7 @@ from imapclient import IMAPClient
from imapclient.exceptions import IMAPClientError
import imaplib
import deltachat
from deltachat import const
from deltachat import const, Account
SEEN = b'\\Seen'
@@ -62,7 +62,7 @@ def dc_account_after_shutdown(account):
class DirectImap:
def __init__(self, account):
def __init__(self, account: Account) -> None:
self.account = account
self.logid = account.get_config("displayname") or id(account)
self._idling = False

View File

@@ -13,7 +13,7 @@ from .cutil import from_dc_charpointer
class FFIEvent:
def __init__(self, name, data1, data2):
def __init__(self, name: str, data1, data2):
self.name = name
self.data1 = data1
self.data2 = data2
@@ -29,13 +29,13 @@ class FFIEventLogger:
# to prevent garbled logging
_loglock = threading.RLock()
def __init__(self, account):
def __init__(self, account) -> None:
self.account = account
self.logid = self.account.get_config("displayname")
self.init_time = time.time()
@account_hookimpl
def ac_process_ffi_event(self, ffi_event):
def ac_process_ffi_event(self, ffi_event: FFIEvent) -> None:
self.account.log(str(ffi_event))
@account_hookimpl
@@ -69,7 +69,7 @@ class FFIEventTracker:
self._event_queue = Queue()
@account_hookimpl
def ac_process_ffi_event(self, ffi_event):
def ac_process_ffi_event(self, ffi_event: FFIEvent):
self._event_queue.put(ffi_event)
def set_timeout(self, timeout):
@@ -96,7 +96,7 @@ class FFIEventTracker:
if rex.match(ev.name):
return ev
def get_info_contains(self, regex):
def get_info_contains(self, regex: str) -> FFIEvent:
rex = re.compile(regex)
while 1:
ev = self.get_matching("DC_EVENT_INFO")
@@ -176,6 +176,7 @@ class FFIEventTracker:
ev = self.get_matching("DC_EVENT_MSGS_CHANGED")
if ev.data2 > 0:
return self.account.get_message_by_id(ev.data2)
return None
def wait_msg_delivered(self, msg):
ev = self.get_matching("DC_EVENT_MSG_DELIVERED")
@@ -189,7 +190,7 @@ class EventThread(threading.Thread):
With each Account init this callback thread is started.
"""
def __init__(self, account):
def __init__(self, account) -> None:
self.account = account
super(EventThread, self).__init__(name="events")
self.setDaemon(True)
@@ -202,17 +203,17 @@ class EventThread(threading.Thread):
yield
self.account.log(message + " FINISHED")
def mark_shutdown(self):
def mark_shutdown(self) -> None:
self._marked_for_shutdown = True
def wait(self, timeout=None):
def wait(self, timeout=None) -> None:
if self == threading.current_thread():
# we are in the callback thread and thus cannot
# wait for the thread-loop to finish.
return
self.join(timeout=timeout)
def run(self):
def run(self) -> None:
""" get and run events until shutdown. """
with self.log_execution("EVENT THREAD"):
self._inner_run()
@@ -250,7 +251,7 @@ class EventThread(threading.Thread):
if self.account._dc_context is not None:
raise
def _map_ffi_event(self, ffi_event):
def _map_ffi_event(self, ffi_event: FFIEvent):
name = ffi_event.name
account = self.account
if name == "DC_EVENT_CONFIGURE_PROGRESS":

View File

@@ -14,7 +14,7 @@ class Provider(object):
:param domain: The email to get the provider info for.
"""
def __init__(self, account, addr):
def __init__(self, account, addr) -> None:
provider = ffi.gc(
lib.dc_provider_new_from_email(account._dc_context, as_dc_charpointer(addr)),
lib.dc_provider_unref,

View File

@@ -9,6 +9,7 @@ import fnmatch
import time
import weakref
import tempfile
from typing import List, Dict, Callable
import pytest
import requests
@@ -126,7 +127,7 @@ def pytest_report_header(config, startdir):
class SessionLiveConfigFromFile:
def __init__(self, fn):
def __init__(self, fn) -> None:
self.fn = fn
self.configlist = []
for line in open(fn):
@@ -137,19 +138,21 @@ class SessionLiveConfigFromFile:
d[name] = value
self.configlist.append(d)
def get(self, index):
def get(self, index: int):
return self.configlist[index]
def exists(self):
def exists(self) -> bool:
return bool(self.configlist)
class SessionLiveConfigFromURL:
def __init__(self, url):
configlist: List[Dict[str, str]]
def __init__(self, url: str) -> None:
self.configlist = []
self.url = url
def get(self, index):
def get(self, index: int):
try:
return self.configlist[index]
except IndexError:
@@ -162,7 +165,7 @@ class SessionLiveConfigFromURL:
self.configlist.append(config)
return config
def exists(self):
def exists(self) -> bool:
return bool(self.configlist)
@@ -179,7 +182,7 @@ def session_liveconfig(request):
@pytest.fixture
def data(request):
class Data:
def __init__(self):
def __init__(self) -> None:
# 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
@@ -210,7 +213,10 @@ def data(request):
def acfactory(pytestconfig, tmpdir, request, session_liveconfig, data):
class AccountMaker:
def __init__(self):
_finalizers: List[Callable[[], None]]
_accounts: List[Account]
def __init__(self) -> None:
self.live_count = 0
self.offline_count = 0
self._finalizers = []
@@ -423,7 +429,7 @@ def acfactory(pytestconfig, tmpdir, request, session_liveconfig, data):
pass
imap.dump_imap_structures(tmpdir, logfile=logfile)
def get_accepted_chat(self, ac1, ac2):
def get_accepted_chat(self, ac1: Account, ac2: Account):
ac2.create_chat(ac1)
return ac1.create_chat(ac2)
@@ -451,7 +457,9 @@ def acfactory(pytestconfig, tmpdir, request, session_liveconfig, data):
class BotProcess:
def __init__(self, popen, bot_cfg):
stdout_queue: queue.Queue
def __init__(self, popen, bot_cfg) -> None:
self.popen = popen
self.addr = bot_cfg["addr"]
@@ -459,10 +467,10 @@ class BotProcess:
# 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.setDaemon(True)
t.start()
def _run_stdout_thread(self):
def _run_stdout_thread(self) -> None:
try:
while 1:
line = self.popen.stdout.readline()
@@ -474,10 +482,10 @@ class BotProcess:
finally:
self.stdout_queue.put(None)
def kill(self):
def kill(self) -> None:
self.popen.kill()
def wait(self, timeout=30):
def wait(self, timeout=30) -> None:
self.popen.wait(timeout=timeout)
def fnmatch_lines(self, pattern_lines):
@@ -509,14 +517,14 @@ def tmp_db_path(tmpdir):
@pytest.fixture
def lp():
class Printer:
def sec(self, msg):
def sec(self, msg: str) -> None:
print()
print("=" * 10, msg, "=" * 10)
def step(self, msg):
def step(self, msg: str) -> None:
print("-" * 5, "step " + msg, "-" * 5)
def indent(self, msg):
def indent(self, msg: str) -> None:
print(" " + msg)
return Printer()

View File

@@ -265,23 +265,23 @@ class TestOfflineChat:
assert d["draft"] == "" if chat.get_draft() is None else chat.get_draft()
def test_group_chat_creation_with_translation(self, ac1):
ac1.set_stock_translation(const.DC_STR_NEWGROUPDRAFT, "xyz %1$s")
ac1.set_stock_translation(const.DC_STR_MSGGRPNAME, "abc %1$s xyz %2$s")
ac1._evtracker.consume_events()
with pytest.raises(ValueError):
ac1.set_stock_translation(const.DC_STR_NEWGROUPDRAFT, "xyz %2$s")
ac1.set_stock_translation(const.DC_STR_FILE, "xyz %1$s")
ac1._evtracker.get_matching("DC_EVENT_WARNING")
with pytest.raises(ValueError):
ac1.set_stock_translation(const.DC_STR_CONTACT_NOT_VERIFIED, "xyz %2$s")
ac1._evtracker.get_matching("DC_EVENT_WARNING")
with pytest.raises(ValueError):
ac1.set_stock_translation(500, "xyz %1$s")
ac1._evtracker.get_matching("DC_EVENT_WARNING")
contact1 = ac1.create_contact("some1@example.org", name="some1")
contact2 = ac1.create_contact("some2@example.org", name="some2")
chat = ac1.create_group_chat(name="title1", contacts=[contact1, contact2])
assert chat.get_name() == "title1"
assert contact1 in chat.get_contacts()
assert contact2 in chat.get_contacts()
assert not chat.is_promoted()
msg = chat.get_draft()
assert msg.text == "xyz title1"
chat = ac1.create_group_chat(name="homework", contacts=[])
assert chat.get_name() == "homework"
chat.send_text("Now we have a group for homework")
assert chat.is_promoted()
chat.set_name("Homework")
assert chat.get_messages()[-1].text == "abc homework xyz Homework by me."
@pytest.mark.parametrize("verified", [True, False])
def test_group_chat_qr(self, acfactory, ac1, verified):

View File

@@ -1,7 +1,9 @@
[tox]
isolated_build = true
envlist =
py3
lint
mypy
auditwheels
[testenv]
@@ -42,6 +44,15 @@ commands =
flake8 tests/ examples/
rst-lint --encoding 'utf-8' README.rst
[testenv:mypy]
deps =
mypy
typing
types-setuptools
types-requests
commands =
mypy --no-incremental src/
[testenv:doc]
changedir=doc
deps =

View File

@@ -2,13 +2,13 @@
set -x -e
# we use the python3.6 environment as the base environment
/opt/python/cp36-cp36m/bin/pip install tox devpi-client auditwheel
# we use the python3.7 environment as the base environment
/opt/python/cp37-cp37m/bin/pip install tox devpi-client auditwheel
pushd /usr/bin
ln -s /opt/_internal/cpython-3.6.*/bin/tox
ln -s /opt/_internal/cpython-3.6.*/bin/devpi
ln -s /opt/_internal/cpython-3.6.*/bin/auditwheel
ln -s /opt/_internal/cpython-3.7.*/bin/tox
ln -s /opt/_internal/cpython-3.7.*/bin/devpi
ln -s /opt/_internal/cpython-3.7.*/bin/auditwheel
popd

View File

@@ -2,13 +2,13 @@
set -x -e
# we use the python3.6 environment as the base environment
/opt/python/cp36-cp36m/bin/pip install tox devpi-client auditwheel
# we use the python3.7 environment as the base environment
/opt/python/cp37-cp37m/bin/pip install tox devpi-client auditwheel
pushd /usr/bin
ln -s /opt/_internal/cpython-3.6.*/bin/tox
ln -s /opt/_internal/cpython-3.6.*/bin/devpi
ln -s /opt/_internal/cpython-3.6.*/bin/auditwheel
ln -s /opt/_internal/cpython-3.7.*/bin/tox
ln -s /opt/_internal/cpython-3.7.*/bin/devpi
ln -s /opt/_internal/cpython-3.7.*/bin/auditwheel
popd

View File

@@ -19,11 +19,9 @@ export DCC_RS_TARGET=release
# Configure access to a base python and to several python interpreters
# needed by tox below.
export PATH=$PATH:/opt/python/cp36-cp36m/bin
export PATH=$PATH:/opt/python/cp37-cp37m/bin
export PYTHONDONTWRITEBYTECODE=1
pushd /bin
rm -f python3.6
ln -s /opt/python/cp36-cp36m/bin/python3.6
rm -f python3.7
ln -s /opt/python/cp37-cp37m/bin/python3.7
rm -f python3.8

View File

@@ -2267,7 +2267,6 @@ pub async fn create_group_chat(
let chat_name = improve_single_line_input(chat_name);
ensure!(!chat_name.is_empty(), "Invalid chat name");
let draft_txt = stock_str::new_group_draft(context, &chat_name).await;
let grpid = dc_create_id();
let row_id = context
@@ -2288,9 +2287,6 @@ pub async fn create_group_chat(
let chat_id = ChatId::new(u32::try_from(row_id)?);
if !is_contact_in_chat(context, chat_id, DC_CONTACT_ID_SELF).await? {
add_to_chat_contacts_table(context, chat_id, DC_CONTACT_ID_SELF).await?;
let mut draft_msg = Message::new(Viewtype::Text);
draft_msg.set_text(Some(draft_txt));
chat_id.set_draft_raw(context, &mut draft_msg).await?;
}
context.emit_event(EventType::MsgsChanged {

View File

@@ -399,10 +399,20 @@ mod tests {
assert_eq!(chats.get_chat_id(1), chat_id2);
assert_eq!(chats.get_chat_id(2), chat_id1);
// drafts are sorted to the top
let mut msg = Message::new(Viewtype::Text);
msg.set_text(Some("hello".to_string()));
chat_id2.set_draft(&t, Some(&mut msg)).await.unwrap();
// New drafts are sorted to the top
// We have to set a draft on the other two messages, too, as
// chat timestamps are only exact to the second and sorting by timestamp
// would not work.
// Message timestamps are "smeared" and unique, so we don't have this problem
// if we have any message (can be a draft) in all chats.
// Instead of setting drafts for chat_id1 and chat_id3, we could also sleep
// 2s here.
for chat_id in &[chat_id1, chat_id3, chat_id2] {
let mut msg = Message::new(Viewtype::Text);
msg.set_text(Some("hello".to_string()));
chat_id.set_draft(&t, Some(&mut msg)).await.unwrap();
}
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.get_chat_id(0), chat_id2);

View File

@@ -27,6 +27,9 @@ use crate::scheduler::Scheduler;
use crate::securejoin::Bob;
use crate::sql::Sql;
use async_std::prelude::*;
use humansize::{file_size_opts, FileSize};
#[derive(Clone, Debug)]
pub struct Context {
pub(crate) inner: Arc<InnerContext>,
@@ -296,7 +299,9 @@ impl Context {
let contacts = Contact::get_real_cnt(self).await? as usize;
let is_configured = self.get_config_int(Config::Configured).await?;
let socks5_enabled = self.get_config_int(Config::Socks5Enabled).await?;
let dbversion = self
let database = self.get_dbfile();
let database_bytes = std::fs::metadata(database)?.len();
let database_version = self
.sql
.get_raw_config_int("dbversion")
.await?
@@ -347,16 +352,42 @@ impl Context {
let mut res = get_info();
// get information about blob directory
let blobdir = self.get_blobdir();
let mut blobdir_files = 0;
let mut blobdir_bytes = 0;
let scan_start = std::time::SystemTime::now();
let mut dir_handle = async_std::fs::read_dir(blobdir).await?;
while let Some(entry) = dir_handle.next().await {
blobdir_files += 1;
blobdir_bytes += std::fs::metadata(entry?.path())?.len();
}
let scan_sec = scan_start.elapsed().unwrap_or_default().as_secs() as f64;
// insert values
res.insert("bot", self.get_config_int(Config::Bot).await?.to_string());
res.insert("number_of_chats", chats.to_string());
res.insert("number_of_chat_messages", unblocked_msgs.to_string());
res.insert("messages_in_contact_requests", request_msgs.to_string());
res.insert("number_of_contacts", contacts.to_string());
res.insert("database_dir", self.get_dbfile().display().to_string());
res.insert("database_version", dbversion.to_string());
res.insert("database", database.display().to_string());
res.insert("database_version", database_version.to_string());
res.insert(
"database_size",
database_bytes
.file_size(file_size_opts::BINARY)
.unwrap_or_default(),
);
res.insert("journal_mode", journal_mode);
res.insert("blobdir", self.get_blobdir().display().to_string());
res.insert("blobdir", blobdir.display().to_string());
res.insert("blobdir_files", blobdir_files.to_string());
res.insert(
"blobdir_size",
blobdir_bytes
.file_size(file_size_opts::BINARY)
.unwrap_or_default(),
);
res.insert("blobdir_scan_sec", format!("{:.3}s", scan_sec));
res.insert("display_name", displayname.unwrap_or_else(|| unset.into()));
res.insert(
"selfavatar",

View File

@@ -3,7 +3,7 @@
use std::cmp::min;
use std::convert::TryFrom;
use anyhow::{bail, ensure, Result};
use anyhow::{bail, ensure, Context as _, Result};
use itertools::join;
use mailparse::SingleInfo;
use num_traits::FromPrimitive;
@@ -187,6 +187,11 @@ pub(crate) async fn dc_receive_imf_inner(
.and_then(|value| mailparse::dateparse(value).ok())
.map_or(rcvd_timestamp, |value| min(value, rcvd_timestamp));
if mime_parser.is_system_message == SystemMessage::LocationStreamingEnabled {
let better_msg = stock_str::msg_location_enabled_by(context, from_id as u32).await;
set_better_msg(&mut mime_parser, &better_msg);
}
// Add parts
let chat_id = add_parts(
context,
@@ -551,7 +556,6 @@ async fn add_parts(
if let Some((new_chat_id, new_chat_id_blocked)) = create_or_lookup_group(
context,
mime_parser,
sent_timestamp,
if test_normal_chat.is_none() {
allow_creation
} else {
@@ -589,6 +593,16 @@ async fn add_parts(
}
}
}
apply_group_changes(
context,
mime_parser,
sent_timestamp,
chat_id,
from_id,
to_ids,
)
.await?;
}
if chat_id.is_none() {
@@ -776,7 +790,6 @@ async fn add_parts(
if let Some((new_chat_id, new_chat_id_blocked)) = create_or_lookup_group(
context,
mime_parser,
sent_timestamp,
allow_creation,
Blocked::Not,
from_id,
@@ -851,9 +864,10 @@ async fn add_parts(
DC_CHAT_ID_TRASH
});
// Extract ephemeral timer from the message.
let mut ephemeral_timer = if let Some(value) = mime_parser.get_header(HeaderDef::EphemeralTimer)
{
// Extract ephemeral timer from the message or use the existing timer if the message is not fully downloaded.
let mut ephemeral_timer = if is_partial_download.is_some() {
chat_id.get_ephemeral_timer(context).await?
} else if let Some(value) = mime_parser.get_header(HeaderDef::EphemeralTimer) {
match value.parse::<EphemeralTimer>() {
Ok(timer) => timer,
Err(err) => {
@@ -1309,15 +1323,6 @@ async fn lookup_chat_by_reply(
if let Some(parent) = parent {
let parent_chat = Chat::load_from_db(context, parent.chat_id).await?;
if let Some(msg_grpid) = try_getting_grpid(mime_parser) {
if msg_grpid == parent_chat.grpid {
// This message will be assigned to this chat, anyway.
// But if we assigned it now, create_or_lookup_group() will not be called
// and group commands will not be executed.
return Ok(None);
}
}
if parent.error.is_some() {
// If the parent msg is undecipherable, then it may have been assigned to the wrong chat
// (undecipherable group msgs often get assigned to the 1:1 chat with the sender).
@@ -1378,42 +1383,22 @@ async fn is_probably_private_reply(
Ok(true)
}
/// This function tries to extract the group-id from the message and returns the
/// corresponding chat_id. If the chat does not exist, it is created.
/// If the message contains groups commands (name, profile image, changed members),
/// they are executed as well.
///
/// If no group-id could be extracted, message is assigned to the same chat as the
/// parent message.
///
/// If there is no parent message in the database, and there are more than two members,
/// a new ad-hoc group is created.
/// This function tries to extract the group-id from the message and returns the corresponding
/// chat_id. If the chat does not exist, it is created. If there is no group-id and there are more
/// than two members, a new ad hoc group is created.
///
/// On success the function returns the found/created (chat_id, chat_blocked) tuple.
#[allow(non_snake_case, clippy::cognitive_complexity)]
async fn create_or_lookup_group(
context: &Context,
mime_parser: &mut MimeMessage,
sent_timestamp: i64,
allow_creation: bool,
create_blocked: Blocked,
from_id: u32,
to_ids: &ContactIds,
) -> Result<Option<(ChatId, Blocked)>> {
let mut chat_id_blocked = Blocked::Not;
let mut recreate_member_list = false;
let mut send_EVENT_CHAT_MODIFIED = false;
let mut X_MrAddToGrp = None;
let mut better_msg: String = From::from("");
if mime_parser.is_system_message == SystemMessage::LocationStreamingEnabled {
better_msg = stock_str::msg_location_enabled_by(context, from_id as u32).await;
set_better_msg(mime_parser, &better_msg);
}
let grpid = if let Some(grpid) = try_getting_grpid(mime_parser) {
grpid
} else {
} else if allow_creation {
let mut member_ids: Vec<u32> = to_ids.iter().copied().collect();
if !member_ids.contains(&(from_id as u32)) {
member_ids.push(from_id as u32);
@@ -1421,24 +1406,26 @@ async fn create_or_lookup_group(
if !member_ids.contains(&(DC_CONTACT_ID_SELF as u32)) {
member_ids.push(DC_CONTACT_ID_SELF as u32);
}
if !allow_creation {
info!(context, "creating ad-hoc group prevented from caller");
return Ok(None);
}
return create_adhoc_group(context, mime_parser, create_blocked, &member_ids)
let res = create_adhoc_group(context, mime_parser, create_blocked, &member_ids)
.await
.map(|chat_id| chat_id.map(|chat_id| (chat_id, create_blocked)))
.map_err(|err| {
info!(context, "could not create adhoc-group: {:?}", err);
err
});
.context("could not create ad hoc group")?
.map(|chat_id| (chat_id, create_blocked));
return Ok(res);
} else {
info!(context, "creating ad-hoc group prevented from caller");
return Ok(None);
};
// check, if we have a chat with this group ID
let mut chat_id = chat::get_chat_id_by_grpid(context, &grpid)
.await?
.map(|(chat_id, _protected, _blocked)| chat_id);
let mut chat_id;
let mut chat_id_blocked;
if let Some((id, _protected, blocked)) = chat::get_chat_id_by_grpid(context, &grpid).await? {
chat_id = Some(id);
chat_id_blocked = blocked;
} else {
chat_id = None;
chat_id_blocked = Default::default();
}
// For chat messages, we don't have to guess (is_*probably*_private_reply()) but we know for sure that
// they belong to the group because of the Chat-Group-Id or Message-Id header
@@ -1450,69 +1437,6 @@ async fn create_or_lookup_group(
}
}
// now we have a grpid that is non-empty
// but we might not know about this group
let grpname = mime_parser.get_header(HeaderDef::ChatGroupName).cloned();
let mut removed_id = None;
if let Some(removed_addr) = mime_parser
.get_header(HeaderDef::ChatGroupMemberRemoved)
.cloned()
{
removed_id = Contact::lookup_id_by_addr(context, &removed_addr, Origin::Unknown).await?;
match removed_id {
Some(contact_id) => {
mime_parser.is_system_message = SystemMessage::MemberRemovedFromGroup;
better_msg = if contact_id == from_id {
stock_str::msg_group_left(context, from_id).await
} else {
stock_str::msg_del_member(context, &removed_addr, from_id).await
};
}
None => warn!(context, "removed {:?} has no contact_id", removed_addr),
}
} else {
let field = mime_parser
.get_header(HeaderDef::ChatGroupMemberAdded)
.cloned();
if let Some(added_member) = field {
mime_parser.is_system_message = SystemMessage::MemberAddedToGroup;
better_msg = stock_str::msg_add_member(context, &added_member, from_id).await;
X_MrAddToGrp = Some(added_member);
} else if let Some(old_name) = mime_parser.get_header(HeaderDef::ChatGroupNameChanged) {
better_msg = stock_str::msg_grp_name(
context,
old_name,
if let Some(ref name) = grpname {
name
} else {
""
},
from_id as u32,
)
.await;
mime_parser.is_system_message = SystemMessage::GroupNameChanged;
} else if let Some(value) = mime_parser.get_header(HeaderDef::ChatContent) {
if value == "group-avatar-changed" {
if let Some(avatar_action) = &mime_parser.group_avatar {
// this is just an explicit message containing the group-avatar,
// apart from that, the group-avatar is send along with various other messages
mime_parser.is_system_message = SystemMessage::GroupImageChanged;
better_msg = match avatar_action {
AvatarAction::Delete => {
stock_str::msg_grp_img_deleted(context, from_id).await
}
AvatarAction::Change(_) => {
stock_str::msg_grp_img_changed(context, from_id).await
}
};
}
}
}
}
set_better_msg(mime_parser, &better_msg);
let create_protected = if mime_parser.get_header(HeaderDef::ChatVerified).is_some() {
if let Err(err) = check_verified_properties(context, mime_parser, from_id, to_ids).await {
warn!(context, "verification problem: {}", err);
@@ -1524,55 +1448,56 @@ async fn create_or_lookup_group(
ProtectionStatus::Unprotected
};
// check if the group does not exist but should be created
let group_explicitly_left = chat::is_group_explicitly_left(context, &grpid)
.await
.unwrap_or_default();
let self_addr = context
.get_config(Config::ConfiguredAddr)
.await?
.unwrap_or_default();
.context("no address configured")?;
if chat_id.is_none()
&& !mime_parser.is_mailinglist_message()
&& !grpid.is_empty()
&& grpname.is_some()
&& mime_parser.get_header(HeaderDef::ChatGroupName).is_some()
// otherwise, a pending "quit" message may pop up
&& removed_id.is_none()
&& mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved).is_none()
// re-create explicitly left groups only if ourself is re-added
&& (!group_explicitly_left
|| X_MrAddToGrp.is_some() && addr_cmp(&self_addr, X_MrAddToGrp.as_ref().unwrap()))
&& (!chat::is_group_explicitly_left(context, &grpid).await?
|| mime_parser.get_header(HeaderDef::ChatGroupMemberAdded).map_or(false, |member_addr| addr_cmp(&self_addr, member_addr)))
{
// group does not exist but should be created
// Group does not exist but should be created.
if !allow_creation {
info!(context, "creating group forbidden by caller");
return Ok(None);
}
chat_id = ChatId::create_multiuser_record(
let grpname = mime_parser.get_header(HeaderDef::ChatGroupName).unwrap();
let new_chat_id = ChatId::create_multiuser_record(
context,
Chattype::Group,
&grpid,
grpname.as_ref().unwrap(),
grpname,
create_blocked,
create_protected,
)
.await
.map(Some)
.unwrap_or_else(|err| {
warn!(
context,
"Failed to create group '{}' for grpid={}: {:?}",
grpname.as_ref().unwrap(),
grpid,
err,
);
None
});
.with_context(|| format!("Failed to create group '{}' for grpid={}", grpname, grpid))?;
chat_id = Some(new_chat_id);
chat_id_blocked = create_blocked;
recreate_member_list = true;
// Create initial member list.
chat::add_to_chat_contacts_table(context, new_chat_id, DC_CONTACT_ID_SELF).await?;
if from_id > DC_CONTACT_ID_LAST_SPECIAL
&& !chat::is_contact_in_chat(context, new_chat_id, from_id).await?
{
chat::add_to_chat_contacts_table(context, new_chat_id, from_id).await?;
}
for &to_id in to_ids.iter() {
info!(context, "adding to={:?} to chat id={}", to_id, new_chat_id);
if !Contact::addr_equals_contact(context, &self_addr, to_id).await?
&& !chat::is_contact_in_chat(context, new_chat_id, to_id).await?
{
chat::add_to_chat_contacts_table(context, new_chat_id, to_id).await?;
}
}
// once, we have protected-chats explained in UI, we can uncomment the following lines.
// ("verified groups" did not add a message anyway)
@@ -1580,25 +1505,16 @@ async fn create_or_lookup_group(
//if create_protected == ProtectionStatus::Protected {
// set from_id=0 as it is not clear that the sender of this random group message
// actually really has enabled chat-protection at some point.
//chat_id
//new_chat_id
// .add_protection_msg(context, ProtectionStatus::Protected, false, 0)
// .await?;
//}
} else if let Some(chat_id) = chat_id {
if create_protected == ProtectionStatus::Protected {
let chat = Chat::load_from_db(context, chat_id).await?;
if !chat.is_protected() {
chat_id
.inner_set_protection(context, ProtectionStatus::Protected)
.await?;
recreate_member_list = true;
}
}
context.emit_event(EventType::ChatModified(new_chat_id));
}
// again, check chat_id
let chat_id = if let Some(chat_id) = chat_id {
chat_id
if let Some(chat_id) = chat_id {
Ok(Some((chat_id, chat_id_blocked)))
} else if mime_parser.decrypting_failed {
// It is possible that the message was sent to a valid,
// yet unknown group, which was rejected because
@@ -1606,65 +1522,141 @@ async fn create_or_lookup_group(
// not found. We can't create a properly named group in
// this case, so assign error message to 1:1 chat with the
// sender instead.
return Ok(None);
Ok(None)
} else {
// The message was decrypted successfully, but contains a late "quit" or otherwise
// unwanted message.
info!(context, "message belongs to unwanted group (TRASH)");
return Ok(Some((DC_CHAT_ID_TRASH, chat_id_blocked)));
};
Ok(Some((DC_CHAT_ID_TRASH, Blocked::Not)))
}
}
// We have a valid chat_id > DC_CHAT_ID_LAST_SPECIAL.
/// Apply group member list, name, avatar and protection status changes from the MIME message.
async fn apply_group_changes(
context: &Context,
mime_parser: &mut MimeMessage,
sent_timestamp: i64,
chat_id: ChatId,
from_id: u32,
to_ids: &ContactIds,
) -> Result<()> {
let mut chat = Chat::load_from_db(context, chat_id).await?;
if chat.typ != Chattype::Group {
return Ok(());
}
// execute group commands
if X_MrAddToGrp.is_some() {
recreate_member_list = true;
} else if mime_parser
.get_header(HeaderDef::ChatGroupNameChanged)
.is_some()
let self_addr = context
.get_config(Config::ConfiguredAddr)
.await?
.context("no address configured")?;
let mut recreate_member_list = false;
let mut send_event_chat_modified = false;
let removed_id;
if let Some(removed_addr) = mime_parser
.get_header(HeaderDef::ChatGroupMemberRemoved)
.cloned()
{
if let Some(ref grpname) = grpname {
if grpname.len() < 200
&& chat_id
removed_id = Contact::lookup_id_by_addr(context, &removed_addr, Origin::Unknown).await?;
match removed_id {
Some(contact_id) => {
mime_parser.is_system_message = SystemMessage::MemberRemovedFromGroup;
let better_msg = if contact_id == from_id {
stock_str::msg_group_left(context, from_id).await
} else {
stock_str::msg_del_member(context, &removed_addr, from_id).await
};
set_better_msg(mime_parser, &better_msg);
}
None => warn!(context, "removed {:?} has no contact_id", removed_addr),
}
} else {
removed_id = None;
if let Some(added_member) = mime_parser
.get_header(HeaderDef::ChatGroupMemberAdded)
.cloned()
{
mime_parser.is_system_message = SystemMessage::MemberAddedToGroup;
let better_msg = stock_str::msg_add_member(context, &added_member, from_id).await;
set_better_msg(mime_parser, &better_msg);
recreate_member_list = true;
} else if let Some(old_name) = mime_parser.get_header(HeaderDef::ChatGroupNameChanged) {
if let Some(grpname) = mime_parser
.get_header(HeaderDef::ChatGroupName)
.filter(|grpname| grpname.len() < 200)
{
if chat_id
.update_timestamp(context, Param::GroupNameTimestamp, sent_timestamp)
.await?
{
info!(context, "updating grpname for chat {}", chat_id);
if context
.sql
.execute(
"UPDATE chats SET name=? WHERE id=?;",
paramsv![grpname.to_string(), chat_id],
)
.await
.is_ok()
{
context.emit_event(EventType::ChatModified(chat_id));
info!(context, "updating grpname for chat {}", chat_id);
context
.sql
.execute(
"UPDATE chats SET name=? WHERE id=?;",
paramsv![grpname.to_string(), chat_id],
)
.await?;
send_event_chat_modified = true;
}
let better_msg =
stock_str::msg_grp_name(context, old_name, grpname, from_id as u32).await;
set_better_msg(mime_parser, &better_msg);
mime_parser.is_system_message = SystemMessage::GroupNameChanged;
}
} else if let Some(value) = mime_parser.get_header(HeaderDef::ChatContent) {
if value == "group-avatar-changed" {
if let Some(avatar_action) = &mime_parser.group_avatar {
// this is just an explicit message containing the group-avatar,
// apart from that, the group-avatar is send along with various other messages
mime_parser.is_system_message = SystemMessage::GroupImageChanged;
let better_msg = match avatar_action {
AvatarAction::Delete => {
stock_str::msg_grp_img_deleted(context, from_id).await
}
AvatarAction::Change(_) => {
stock_str::msg_grp_img_changed(context, from_id).await
}
};
set_better_msg(mime_parser, &better_msg);
}
}
}
} else if mime_parser.is_system_message == SystemMessage::ChatProtectionEnabled {
recreate_member_list = true;
}
if mime_parser.get_header(HeaderDef::ChatVerified).is_some() {
if let Err(err) = check_verified_properties(context, mime_parser, from_id, to_ids).await {
warn!(context, "verification problem: {}", err);
let s = format!("{}. See 'Info' for more details", err);
mime_parser.repl_msg_by_error(&s);
}
if !chat.is_protected() {
chat_id
.inner_set_protection(context, ProtectionStatus::Protected)
.await?;
recreate_member_list = true;
}
}
if let Some(avatar_action) = &mime_parser.group_avatar {
info!(context, "group-avatar change for {}", chat_id);
if let Ok(mut chat) = Chat::load_from_db(context, chat_id).await {
if chat
.param
.update_timestamp(Param::AvatarTimestamp, sent_timestamp)?
{
match avatar_action {
AvatarAction::Change(profile_image) => {
chat.param.set(Param::ProfileImage, profile_image);
}
AvatarAction::Delete => {
chat.param.remove(Param::ProfileImage);
}
};
chat.update_param(context).await?;
send_EVENT_CHAT_MODIFIED = true;
}
if chat
.param
.update_timestamp(Param::AvatarTimestamp, sent_timestamp)?
{
match avatar_action {
AvatarAction::Change(profile_image) => {
chat.param.set(Param::ProfileImage, profile_image);
}
AvatarAction::Delete => {
chat.param.remove(Param::ProfileImage);
}
};
chat.update_param(context).await?;
send_event_chat_modified = true;
}
}
@@ -1684,8 +1676,7 @@ async fn create_or_lookup_group(
"DELETE FROM chats_contacts WHERE chat_id=?;",
paramsv![chat_id],
)
.await
.ok();
.await?;
chat::add_to_chat_contacts_table(context, chat_id, DC_CONTACT_ID_SELF).await?;
}
@@ -1703,7 +1694,7 @@ async fn create_or_lookup_group(
chat::add_to_chat_contacts_table(context, chat_id, to_id).await?;
}
}
send_EVENT_CHAT_MODIFIED = true;
send_event_chat_modified = true;
}
} else if let Some(contact_id) = removed_id {
if chat_id
@@ -1711,14 +1702,14 @@ async fn create_or_lookup_group(
.await?
{
chat::remove_from_chat_contacts_table(context, chat_id, contact_id).await?;
send_EVENT_CHAT_MODIFIED = true;
send_event_chat_modified = true;
}
}
if send_EVENT_CHAT_MODIFIED {
if send_event_chat_modified {
context.emit_event(EventType::ChatModified(chat_id));
}
Ok(Some((chat_id, chat_id_blocked)))
Ok(())
}
/// Create or lookup a mailing list chat.
@@ -4597,6 +4588,71 @@ Reply to all"#,
}
}
/// Tests that replies to similar ad hoc groups are correctly assigned to chats.
///
/// The difficutly here is that ad hoc groups don't have unique group IDs, because both
/// messages have the same recipient lists and only differ in the subject and message contents.
/// The messages can be properly assigned to chats only using the In-Reply-To or References
/// headers.
#[async_std::test]
async fn test_chat_assignment_adhoc() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_alice().await;
alice.set_config(Config::ShowEmails, Some("2")).await?;
bob.set_config(Config::ShowEmails, Some("2")).await?;
let first_thread_mime = br#"Subject: First thread
Message-ID: first@example.org
To: Alice <alice@example.com>, Bob <bob@example.net>
From: Claire <claire@example.org>
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
First thread."#;
let second_thread_mime = br#"Subject: Second thread
Message-ID: second@example.org
To: Alice <alice@example.com>, Bob <bob@example.net>
From: Claire <claire@example.org>
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
Second thread."#;
// Alice receives two classic emails from Claire.
dc_receive_imf(&alice, first_thread_mime, "Inbox", 1, false).await?;
let alice_first_msg = alice.get_last_msg().await;
dc_receive_imf(&alice, second_thread_mime, "Inbox", 2, false).await?;
let alice_second_msg = alice.get_last_msg().await;
// Bob receives the same two emails.
dc_receive_imf(&bob, first_thread_mime, "Inbox", 1, false).await?;
let bob_first_msg = bob.get_last_msg().await;
dc_receive_imf(&bob, second_thread_mime, "Inbox", 2, false).await?;
let bob_second_msg = bob.get_last_msg().await;
// Messages go to separate chats both for Alice and Bob.
assert!(alice_first_msg.chat_id != alice_second_msg.chat_id);
assert!(bob_first_msg.chat_id != bob_second_msg.chat_id);
// Alice replies to both chats. Bob receives two messages and assigns them to corresponding
// chats.
alice_first_msg.chat_id.accept(&alice).await?;
let alice_first_reply = alice
.send_text(alice_first_msg.chat_id, "First reply")
.await;
bob.recv_msg(&alice_first_reply).await;
let bob_first_reply = bob.get_last_msg().await;
assert_eq!(bob_first_reply.chat_id, bob_first_msg.chat_id);
alice_second_msg.chat_id.accept(&alice).await?;
let alice_second_reply = alice
.send_text(alice_second_msg.chat_id, "Second reply")
.await;
bob.recv_msg(&alice_second_reply).await;
let bob_second_reply = bob.get_last_msg().await;
assert_eq!(bob_second_reply.chat_id, bob_second_msg.chat_id);
Ok(())
}
/// Test that read receipts don't create chats.
#[async_std::test]
async fn test_read_receipts_dont_create_chats() -> Result<()> {

View File

@@ -227,6 +227,7 @@ mod tests {
use crate::chat::send_msg;
use crate::constants::Viewtype;
use crate::dc_receive_imf::dc_receive_imf_inner;
use crate::ephemeral::Timer;
use crate::test_utils::TestContext;
use num_traits::FromPrimitive;
@@ -343,4 +344,40 @@ mod tests {
Ok(())
}
#[async_std::test]
async fn test_partial_download_and_ephemeral() -> Result<()> {
let t = TestContext::new_alice().await;
let chat_id = t
.create_chat_with_contact("bob", "bob@example.org")
.await
.id;
chat_id
.set_ephemeral_timer(&t, Timer::Enabled { duration: 60 })
.await?;
// download message from bob partially, this must not change the ephemeral timer
dc_receive_imf_inner(
&t,
b"From: Bob <bob@example.org>\n\
To: Alice <alice@example.com>\n\
Chat-Version: 1.0\n\
Subject: subject\n\
Message-ID: <first@example.org>\n\
Date: Sun, 14 Nov 2021 00:10:00 +0000\
Content-Type: text/plain",
"INBOX",
1,
false,
Some(100000),
false,
)
.await?;
assert_eq!(
chat_id.get_ephemeral_timer(&t).await?,
Timer::Enabled { duration: 60 }
);
Ok(())
}
}

View File

@@ -361,6 +361,7 @@ impl Imap {
let lock = context.wrong_pw_warning_mutex.lock().await;
if self.login_failed_once
&& err.to_string().to_lowercase().contains("authentication")
&& context.get_config_bool(Config::NotifyAboutWrongPw).await?
{
if let Err(e) = context.set_config(Config::NotifyAboutWrongPw, None).await {

View File

@@ -346,6 +346,10 @@ impl Context {
<body>"#
.to_string();
// =============================================================================================
// Get the states from the RwLock
// =============================================================================================
let lock = self.scheduler.read().await;
let (folders_states, smtp) = match &*lock {
Scheduler::Running {
@@ -380,6 +384,13 @@ impl Context {
};
drop(lock);
// =============================================================================================
// Add e.g.
// Incoming messages
// - "Inbox": Connected
// - "Sent": Connected
// =============================================================================================
ret += &format!("<h3>{}</h3><ul>", stock_str::incoming_messages(self).await);
for (folder, watch, state) in &folders_states {
let w = self.get_config(*watch).await.ok_or_log(self);
@@ -417,6 +428,12 @@ impl Context {
}
ret += "</ul>";
// =============================================================================================
// Add e.g.
// Outgoing messages
// Your last message was sent successfully
// =============================================================================================
ret += &format!(
"<h3>{}</h3><ul><li>",
stock_str::outgoing_messages(self).await
@@ -427,6 +444,13 @@ impl Context {
ret += &*escaper::encode_minimal(&detailed.to_string_smtp(self).await);
ret += "</li></ul>";
// =============================================================================================
// Add e.g.
// Storage on testrun.org
// 1.34 GiB of 2 GiB used
// [======67%===== ]
// =============================================================================================
let domain = dc_tools::EmailAddress::new(
&self
.get_config(Config::ConfiguredAddr)
@@ -527,6 +551,8 @@ impl Context {
}
ret += "</ul>";
// =============================================================================================
ret += "</body></html>\n";
Ok(ret)
}

View File

@@ -533,9 +533,18 @@ pub(crate) async fn handle_securejoin_handshake(
Ok(HandshakeMessage::Done)
}
Some(_stage) => {
let msg = stock_str::secure_join_replies(context, contact_id).await;
chat::add_info_msg(context, bobstate.chat_id(context).await?, msg, time())
if join_vg {
// the message reads "Alice replied, waiting for being added to the group…";
// show it only on secure-join and not on setup-contact therefore.
let msg = stock_str::secure_join_replies(context, contact_id).await;
chat::add_info_msg(
context,
bobstate.chat_id(context).await?,
msg,
time(),
)
.await?;
}
joiner_progress!(context, bobstate.invite().contact_id(), 400);
Ok(HandshakeMessage::Done)
}
@@ -864,7 +873,7 @@ async fn could_not_establish_secure_connection(
)
.await;
chat::add_info_msg(context, chat_id, &msg, time()).await?;
error!(
warn!(
context,
"StockMessage::ContactNotVerified posted to 1:1 chat ({})", details
);

View File

@@ -56,9 +56,6 @@ pub enum StockMessage {
#[strum(props(fallback = "Sent with my Delta Chat Messenger: https://delta.chat"))]
StatusLine = 13,
#[strum(props(fallback = "Hello, I\'ve just created the group \"%1$s\" for us."))]
NewGroupDraft = 14,
#[strum(props(fallback = "Group name changed from \"%1$s\" to \"%2$s\"."))]
MsgGrpName = 15,
@@ -457,13 +454,6 @@ pub(crate) async fn status_line(context: &Context) -> String {
translated(context, StockMessage::StatusLine).await
}
/// Stock string: `Hello, I've just created the group "%1$s" for us.`.
pub(crate) async fn new_group_draft(context: &Context, group_name: impl AsRef<str>) -> String {
translated(context, StockMessage::NewGroupDraft)
.await
.replace1(group_name)
}
/// Stock string: `Group name changed from "%1$s" to "%2$s".`.
pub(crate) async fn msg_grp_name(
context: &Context,