Merge branch 'master' into ep/p2p-group-signing

This commit is contained in:
Evgeny Poberezkin
2026-08-19 08:21:35 +01:00
239 changed files with 8629 additions and 1122 deletions
@@ -42,6 +42,10 @@ asyncio_mode = "auto"
line-length = 100
target-version = "py311"
[tool.ruff.lint]
# Generated by the Haskell codegen; regenerating is the only way to change them.
exclude = ["src/simplex_chat/types/_*.py"]
[tool.ruff.format]
# `src/simplex_chat/types/*.py` are generated by the Haskell codegen
# (bots/src/API/Docs/Generate/Python.hs). Re-formatting them locally
@@ -1,5 +1,6 @@
"""SimpleX Chat — Python client library for chat bots."""
from . import util as util # re-export the util namespace
from ._version import __version__
from .api import (
ChatApi,
@@ -32,17 +33,16 @@ from .bot import (
VideoMessage,
VoiceMessage,
)
from .core import ChatAPIError, ChatInitError, CryptoArgs, MigrationConfirmation
from . import util as util # re-export the util namespace
from .core import ChatAPIError, ChatError, ChatInitError, CryptoArgs, MigrationConfirmation
__all__ = [
"__version__",
"Bot",
"BotCommand",
"BotProfile",
"ChatAPIError",
"ChatApi",
"ChatCommandError",
"ChatError",
"ChatInitError",
"ChatMessage",
"Client",
@@ -68,5 +68,6 @@ __all__ = [
"UnknownMessage",
"VideoMessage",
"VoiceMessage",
"__version__",
"util",
]
@@ -26,7 +26,7 @@ def main(argv: list[str] | None = None) -> int:
path = _native._resolve_libs_dir(args.backend)
print(f"libsimplex installed at: {path}")
return 0
except Exception as e:
except Exception as e: # noqa: BLE001 - a CLI: report any failure, don't traceback
print(f"install failed: {e}", file=sys.stderr)
return 1
@@ -99,7 +99,7 @@ def _stream_to_file(url: str, dest: Path, *, timeout: float = 60.0) -> None:
`timeout` is per-request; we don't touch `socket.setdefaulttimeout`
so other socket users in the same process aren't affected.
"""
with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310 - https://github.com/...
with urllib.request.urlopen(url, timeout=timeout) as resp:
total = int(resp.headers.get("Content-Length") or 0)
received = 0
with dest.open("wb") as out:
@@ -112,7 +112,7 @@ def _stream_to_file(url: str, dest: Path, *, timeout: float = 60.0) -> None:
else:
msg = f"\r download: {received >> 20} MiB"
print(msg, end="", file=sys.stderr, flush=True)
print("", file=sys.stderr, flush=True) # newline after final progress line
print(file=sys.stderr, flush=True) # newline after final progress line
def _download(target: Path, backend: Backend) -> None:
@@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440
post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged.
"""
__version__ = "7.0.0" # PEP 440 — read by hatchling for wheel metadata
LIBS_VERSION = "7.0.0" # simplex-chat-libs release tag (no 'v' prefix)
__version__ = "7.1.0b0" # PEP 440 — read by hatchling for wheel metadata
LIBS_VERSION = "7.1.0-beta.0" # simplex-chat-libs release tag (no 'v' prefix)
@@ -8,7 +8,7 @@ from typing import Any, Literal
from . import _native, core, util
from .core import MigrationConfirmation
from .types import CC, CEvt, CR, T
from .types import CC, CR, CEvt, T
# Mirrors Node `ConnReqType` enum (api.ts:15-18) — the two possible outcomes
# of `api_connect` / `api_connect_active_user` depending on the link kind.
@@ -39,7 +39,7 @@ def _db_to_migrate_args(db: Db) -> tuple[str, str, _native.Backend]:
raise TypeError(f"Unknown db: {db!r}")
class ChatCommandError(Exception):
class ChatCommandError(core.ChatError):
"""A chat command returned an unexpected response type.
`response` is the raw wire response; `response_type` exposes its `type`
@@ -71,7 +71,7 @@ class ChatApi:
cls,
db: Db,
confirm: MigrationConfirmation = MigrationConfirmation.YES_UP,
) -> "ChatApi":
) -> ChatApi:
path_or_prefix, key_or_conn, backend = _db_to_migrate_args(db)
# Trigger lazy lib load with the right backend BEFORE chat_migrate_init.
_native.lib_for(backend)
@@ -96,8 +96,12 @@ class ChatApi:
return self._started
async def start_chat(self) -> None:
# serviceRequests is off: a bot answers its own address, it does not
# serve requests routed to it as a service.
r = await self.send_chat_cmd(
CC.StartChat_cmd_string({"mainApp": True, "enableSndFiles": True})
CC.StartChat_cmd_string(
{"mainApp": True, "enableSndFiles": True, "serviceRequests": False}
)
)
if r.get("type") not in ("chatStarted", "chatRunning"):
raise ChatCommandError("error starting chat", r)
@@ -142,12 +146,7 @@ class ChatApi:
return r["contactLink"]
raise ChatCommandError("error loading user address", r)
except core.ChatAPIError as e:
ce = e.chat_error
if (
ce is not None
and ce.get("type") == "errorStore"
and ce.get("storeError", {}).get("type") == "userContactLinkNotFound"
):
if e.store_error_type == "userContactLinkNotFound":
return None
raise
@@ -510,8 +509,10 @@ class ChatApi:
raise ChatCommandError("error accepting contact request", r)
async def api_reject_contact_request(self, contact_req_id: int) -> None:
# notify is not rendered into the command string, so the core reads its
# own default of off; this only keeps the argument type satisfied.
r = await self.send_chat_cmd(
CC.APIRejectContact_cmd_string({"contactReqId": contact_req_id})
CC.APIRejectContact_cmd_string({"contactReqId": contact_req_id, "notify": False})
)
if r["type"] != "contactRequestRejected":
raise ChatCommandError("error rejecting contact request", r)
@@ -607,6 +608,28 @@ class ChatApi:
if r["type"] != "cmdOk":
raise ChatCommandError("error setting contact custom data", r)
async def api_merge_contact_custom_data(
self, contact: T.Contact, key: str, value: object | None
) -> None:
"""Set or drop one key of a contact's custom data, keeping the rest.
The set command replaces the whole column. `value=None` removes `key`.
"""
await self.api_set_contact_custom_data(
contact["contactId"], util.merged_custom_data(contact.get("customData"), key, value)
)
async def api_merge_group_custom_data(
self, group: T.GroupInfo, key: str, value: object | None
) -> None:
"""Set or drop one key of a group's custom data, keeping the rest.
See `api_merge_contact_custom_data`.
"""
await self.api_set_group_custom_data(
group["groupId"], util.merged_custom_data(group.get("customData"), key, value)
)
async def api_set_auto_accept_member_contacts(self, user_id: int, on_off: bool) -> None:
r = await self.send_chat_cmd(
CC.APISetUserAutoAcceptMemberContacts_cmd_string({"userId": user_id, "onOff": on_off})
@@ -632,12 +655,7 @@ class ChatApi:
return r["user"]
raise ChatCommandError("unexpected response", r)
except core.ChatAPIError as e:
ce = e.chat_error
if (
ce is not None
and ce.get("type") == "error"
and ce.get("errorType", {}).get("type") == "noActiveUser"
):
if e.error_type == "noActiveUser":
return None
raise
@@ -719,3 +737,13 @@ class ChatApi:
if r["type"] == "newMemberContactSentInv":
return r["contact"]
raise ChatCommandError("error sending member contact invitation", r)
async def api_accept_member_contact(self, contact_id: int) -> T.Contact:
"""Accept a direct connection a group member opened with us.
The core rejects a second accept with "connection already started".
"""
r = await self.send_chat_cmd(f"/_accept member contact @{contact_id}")
if r["type"] == "memberContactAccepted":
return r["contact"]
raise ChatCommandError("error accepting member contact", r)
@@ -121,8 +121,8 @@ class Bot(Client):
async def _post_start(self, user: T.User) -> None:
"""Bots sync address first, then embed the link in the profile."""
link = await self._sync_address(user)
await self._maybe_sync_profile(user, contact_link=link)
self._contact_link = await self._sync_address(user)
await self._maybe_sync_profile(user)
async def _sync_address(self, user: T.User) -> str | None:
"""Address sync. Returns the public link if any, for embedding in the profile."""
@@ -14,7 +14,7 @@ import os
import signal as _signal
from collections.abc import AsyncIterator, Awaitable, Callable
from dataclasses import dataclass
from typing import Any, Generic, Literal, TypeVar, overload
from typing import Any, Generic, Literal, Self, TypeVar, overload
from . import util
from .api import ChatApi, ChatCommandError, ContactAlreadyExistsError, Db
@@ -58,7 +58,7 @@ class ParsedCommand:
class Message(Generic[C]):
chat_item: T.AChatItem
content: C
client: "Client"
client: Client
@property
def chat_info(self) -> T.ChatInfo:
@@ -71,7 +71,7 @@ class Message(Generic[C]):
return c.get("text") # type: ignore[return-value]
return None
async def reply(self, text: str) -> "Message[T.MsgContent]":
async def reply(self, text: str) -> Message[T.MsgContent]:
items = await self.client.api.api_send_text_reply(self.chat_item, text)
ci = items[0]
content = ci["chatItem"]["content"]
@@ -79,7 +79,7 @@ class Message(Generic[C]):
msg_content: T.MsgContent = content["msgContent"] # type: ignore[index]
return Message(chat_item=ci, content=msg_content, client=self.client)
async def reply_content(self, content: T.MsgContent) -> "Message[T.MsgContent]":
async def reply_content(self, content: T.MsgContent) -> Message[T.MsgContent]:
items = await self.client.api.api_send_messages(
self.chat_info, [{"msgContent": content, "mentions": {}}]
)
@@ -162,6 +162,11 @@ class Client:
self._api: ChatApi | None = None
self._serving = False
self._stop_event = asyncio.Event()
# Set by Bot once its address is known, so a later `sync_profile()`
# embeds the same link the startup sync would have.
self._contact_link: str | None = None
self._signal_handlers_installed = False
self._interrupts = 0
self._message_handlers: list[tuple[Callable[[Message[Any]], bool], MessageHandler]] = []
self._command_handlers: list[
tuple[tuple[str, ...], Callable[[Message[Any]], bool], CommandHandler]
@@ -184,6 +189,26 @@ class Client:
raise RuntimeError("Client not initialized — call run() or use `async with client:`")
return self._api
@property
def profile(self) -> Profile:
"""The profile this client identifies with.
Mutable: change a field and call `sync_profile()` to apply it.
"""
return self._profile
@profile.setter
def profile(self, profile: Profile) -> None:
self._profile = profile
@property
def stop_requested(self) -> bool:
"""Whether `stop()` has been called, including during startup.
Sticky: a caller doing its own setup can unwind instead of serving.
"""
return self._stop_event.is_set()
# ------------------------------------------------------------------ #
# Decorators
# ------------------------------------------------------------------ #
@@ -312,16 +337,12 @@ class Client:
# Lifecycle
# ------------------------------------------------------------------ #
async def __aenter__(self) -> "Client":
async def __aenter__(self) -> Self:
# Order matters: libsimplex `/_start` requires an active user, so
# ensure (or create) the user first, THEN start the chat, THEN
# do post-start setup (profile sync; Bot adds address sync).
# Clear `_stop_event` here (not in `serve_forever`/`events`) so that
# a `stop()` call landing between `__aenter__` and the receive loop
# — e.g. a signal handler firing while signal handlers are being
# wired up — is preserved and causes the loop to exit immediately
# on entry.
self._stop_event.clear()
# `_stop_event` is never cleared: a stop requested during startup has
# to survive into the receive loop. A stopped client is spent.
self._api = await ChatApi.init(self._db, self._confirm_migrations)
try:
user = await self._ensure_active_user()
@@ -372,7 +393,7 @@ class Client:
Default (Client): sync profile only. Bot overrides to also sync its
address and embed the connection link in the profile.
"""
await self._maybe_sync_profile(user, contact_link=None)
await self._maybe_sync_profile(user)
def run(self) -> None:
"""Blocking entry: runs serve_forever() with SIGINT/SIGTERM handlers installed.
@@ -390,33 +411,39 @@ class Client:
)
async def _main() -> None:
# Before startup: a signal during migrations would otherwise hit
# the default disposition and kill the process mid-write.
self.install_signal_handlers()
async with self:
loop = asyncio.get_running_loop()
# First Ctrl+C → graceful stop (~500ms, bounded by the
# receive-loop poll interval). Second Ctrl+C → force-exit
# immediately (in case stop_chat / close hang on a wedged
# FFI call). Standard CLI UX (jupyter, ipython, …).
sigint_count = 0
def on_interrupt() -> None:
nonlocal sigint_count
sigint_count += 1
if sigint_count == 1:
log.info("stopping... (press Ctrl+C again to force exit)")
self.stop()
else:
os._exit(130) # 128 + SIGINT
if hasattr(_signal, "SIGINT"):
try:
loop.add_signal_handler(_signal.SIGINT, on_interrupt)
loop.add_signal_handler(_signal.SIGTERM, self.stop)
except NotImplementedError: # Windows
_signal.signal(_signal.SIGINT, lambda *_: on_interrupt())
await self.serve_forever()
asyncio.run(_main())
def install_signal_handlers(self) -> None:
"""Route SIGINT and SIGTERM to `stop()`. Idempotent.
`run()` calls this itself; call it directly when driving the client
yourself. First Ctrl+C stops, a second force-exits. Needs a running loop.
"""
if self._signal_handlers_installed or not hasattr(_signal, "SIGINT"):
return
self._signal_handlers_installed = True
def on_interrupt() -> None:
self._interrupts += 1
if self._interrupts == 1:
log.info("stopping... (press Ctrl+C again to force exit)")
self.stop()
else:
os._exit(130) # 128 + SIGINT
try:
loop = asyncio.get_running_loop()
loop.add_signal_handler(_signal.SIGINT, on_interrupt)
loop.add_signal_handler(_signal.SIGTERM, self.stop)
except NotImplementedError: # Windows
_signal.signal(_signal.SIGINT, lambda *_: on_interrupt())
async def serve_forever(self) -> None:
if self._serving:
raise RuntimeError("already serving")
@@ -450,10 +477,7 @@ class Client:
self._serving = True
try:
while not self._stop_event.is_set():
try:
event = await self.api.recv_chat_event(wait_us=500_000)
except asyncio.CancelledError:
raise
event = await self.api.recv_chat_event(wait_us=500_000)
if event is None:
continue
try:
@@ -551,7 +575,7 @@ class Client:
text: str,
*,
timeout: float = 30.0,
) -> "Message[T.MsgContent]":
) -> Message[T.MsgContent]:
"""Send text to a direct contact and wait for the next reply from them.
Waiters are FIFO per contact_id: two concurrent calls to the same
@@ -815,20 +839,35 @@ class Client:
log.info("user: %s", user["profile"]["displayName"])
return user
async def _maybe_sync_profile(self, user: T.User, *, contact_link: str | None) -> None:
async def sync_profile(self) -> bool:
"""Apply the current `profile` to the active user. True if it changed.
For what the startup sync cannot know yet, such as a display name that
depends on the database. Raises `ChatAPIError` if the core refuses it.
"""
user = await self.api.api_get_active_user()
if user is None:
raise RuntimeError("no active user")
return await self._sync_profile(user)
async def _maybe_sync_profile(self, user: T.User) -> bool:
"""The startup sync — `sync_profile()` unless the caller opted out."""
if not self._update_profile:
return False
return await self._sync_profile(user)
async def _sync_profile(self, user: T.User) -> bool:
"""Update the user profile on the wire if its fields changed.
`contact_link` is only set by Bot (to embed its address). Mirrors
`_contact_link` is only set by Bot (to embed its address). Mirrors
Node `updateBotUserProfile` (bot.ts:199-214). Field-by-field
comparison because user["profile"] is LocalProfile (has extra
fields profileId, localAlias, preferences, peerType) so a full
dict equality would always differ.
"""
if not self._update_profile:
return
new_profile = self._profile_to_wire()
if contact_link is not None:
new_profile["contactLink"] = contact_link
if self._contact_link is not None:
new_profile["contactLink"] = self._contact_link
cur = user["profile"]
changed = (
cur["displayName"] != new_profile["displayName"]
@@ -842,6 +881,7 @@ class Client:
if changed:
log.info("profile changed, updating...")
await self.api.api_update_profile(user["userId"], new_profile)
return changed
def _profile_to_wire(self) -> T.Profile:
"""Convert the user-facing Profile dataclass to wire format.
@@ -857,7 +897,7 @@ class Client:
if self._profile.short_descr is not None:
p["shortDescr"] = self._profile.short_descr
if self._profile.image is not None:
p["image"] = self._profile.image
p["image"] = util.check_profile_image(self._profile.image)
return p
# ------------------------------------------------------------------ #
@@ -13,16 +13,46 @@ from enum import StrEnum
from typing import Any, TypedDict
from . import _native
from .types import T, CR, CEvt
from .types import CR, CEvt, T
class ChatAPIError(Exception):
class ChatError(Exception):
"""Base class for every failure of a chat command.
Catch this for both `ChatAPIError` and `api.ChatCommandError`.
"""
class ChatAPIError(ChatError):
"""Raised when chat_send_cmd / chat_recv_msg_wait returns a chat error."""
def __init__(self, message: str, chat_error: T.ChatError | None = None):
super().__init__(message)
self.chat_error = chat_error
@property
def error_type(self) -> str | None:
"""Tag of the nested `errorType`, e.g. `noActiveUser`, or None."""
return self._nested("errorType").get("type")
@property
def store_error_type(self) -> str | None:
"""Tag of the nested `storeError`, e.g. `duplicateName`, or None."""
return self._nested("storeError").get("type")
@property
def command_error(self) -> str | None:
"""What the core says the caller did wrong, or None.
The only part of a `commandError` worth reading: the tag says nothing.
"""
error = self._nested("errorType")
return error.get("message") if error.get("type") == "commandError" else None
def _nested(self, key: str) -> dict[str, Any]:
nested = (self.chat_error or {}).get(key) # type: ignore[attr-defined]
return nested if isinstance(nested, dict) else {}
class ChatInitError(Exception):
"""Raised when chat_migrate_init returns a DBMigrationResult error."""
@@ -3,7 +3,8 @@
from __future__ import annotations
import re
from typing import Any, Callable
from collections.abc import Callable
from typing import Any
def compile_message_filter(kw: dict[str, Any]) -> Callable[[Any], bool]:
@@ -637,6 +637,19 @@ def APISetUserAutoAcceptMemberContacts_cmd_string(self: APISetUserAutoAcceptMemb
APISetUserAutoAcceptMemberContacts_Response = CR.CmdOk | CR.ChatCmdError
# Set auto-accept group invitations.
# Network usage: no.
class APISetUserAutoAcceptGroupInvitations(TypedDict):
userId: int # int64
onOff: bool
def APISetUserAutoAcceptGroupInvitations_cmd_string(self: APISetUserAutoAcceptGroupInvitations) -> str:
return '/_set accept group invitations ' + str(self['userId']) + ' ' + ('on' if self['onOff'] else 'off')
APISetUserAutoAcceptGroupInvitations_Response = CR.CmdOk | CR.ChatCmdError
# User profile commands
# Most bots don't need to use these commands, as bot profile can be configured manually via CLI or desktop client. These commands can be used by bots that need to manage multiple user profiles (e.g., the profiles of support agents).
@@ -2767,6 +2767,7 @@ class RoleGroupPreference(TypedDict):
class SMPAgentError_A_MESSAGE(TypedDict):
type: Literal["A_MESSAGE"]
messageErr: str
class SMPAgentError_A_PROHIBITED(TypedDict):
type: Literal["A_PROHIBITED"]
@@ -3529,6 +3530,7 @@ class User(TypedDict):
sendRcptsContacts: bool
sendRcptsSmallGroups: bool
autoAcceptMemberContacts: bool
autoAcceptGroupInvitations: bool
userMemberProfileUpdatedAt: NotRequired[str] # ISO-8601 timestamp
userChatRelay: bool
clientService: bool
@@ -120,6 +120,46 @@ def ci_bot_command(chat_item: T.ChatItem) -> tuple[str, str] | None:
return m.group(1), m.group(2).strip()
def merged_custom_data(
custom_data: dict[str, object] | None, key: str, value: object | None
) -> dict[str, object] | None:
"""`custom_data` with `key` set to `value`, or removed when `value` is None.
Returns None, which the set commands read as "clear the column", if empty.
"""
data = dict(custom_data or {})
if value is None:
data.pop(key, None)
else:
data[key] = value
return data or None
# The apps decode these two and nothing else (base64ToBitmap in mobile and
# desktop), while the core stores any string starting with "data:".
PROFILE_IMAGE_PREFIXES = ("data:image/png;base64,", "data:image/jpg;base64,")
def check_profile_image(image: str) -> str:
"""`image` unchanged, or ValueError if no client could render it.
An image the apps cannot decode is still stored and broadcast, and shows
as an empty avatar to everyone.
"""
if image.startswith(PROFILE_IMAGE_PREFIXES):
return image
raise ValueError(f"profile image must start with {' or '.join(PROFILE_IMAGE_PREFIXES)}")
def conn_status(contact: T.Contact) -> str | None:
"""Tag of a contact's active connection status, or None if it has none.
A contact exists before its connection does, so the two are not the same.
"""
status = (contact.get("activeConn") or {}).get("connStatus") or {}
return status.get("type")
def reaction_text(reaction: T.ACIReaction) -> str:
"""Format an `ACIReaction` as the emoji character or tag string."""
r = reaction["chatReaction"]["reaction"] # type: ignore[index]
@@ -0,0 +1,174 @@
"""ChatApi commands and error classification, without the native controller.
`ChatApi` only touches the FFI through `send_chat_cmd`, so replacing that one
method exercises every wrapper: the command string it builds and the response
shape it accepts.
"""
from __future__ import annotations
from typing import Any
import pytest
from simplex_chat import ChatApi, ChatAPIError, ChatCommandError, ChatError
class FakeCtrl(ChatApi):
"""ChatApi with the FFI call replaced by a scripted response."""
def __init__(self, response: Any = None, raises: Exception | None = None) -> None:
super().__init__(ctrl=1)
self.response = response
self.raises = raises
self.sent: list[str] = []
async def send_chat_cmd(self, cmd: str) -> Any:
self.sent.append(cmd)
if self.raises is not None:
raise self.raises
return self.response
# ---------------------------------------------------------------------- #
# Error hierarchy
# ---------------------------------------------------------------------- #
def test_both_command_failures_share_one_base():
# The two are raised from different layers for the same kind of failure;
# callers should not have to name both.
assert issubclass(ChatAPIError, ChatError)
assert issubclass(ChatCommandError, ChatError)
def test_store_error_type_reads_the_nested_tag():
e = ChatAPIError("x", {"type": "errorStore", "storeError": {"type": "duplicateName"}})
assert e.store_error_type == "duplicateName"
assert e.error_type is None
def test_error_type_reads_the_nested_tag():
e = ChatAPIError("x", {"type": "error", "errorType": {"type": "noActiveUser"}})
assert e.error_type == "noActiveUser"
assert e.store_error_type is None
def test_command_error_carries_the_message():
# The tag is always "commandError"; the message is the whole content.
e = ChatAPIError(
"x", {"type": "error", "errorType": {"type": "commandError", "message": "name too long"}}
)
assert e.command_error == "name too long"
def test_command_error_of_another_failure():
e = ChatAPIError("x", {"type": "errorStore", "storeError": {"type": "duplicateName"}})
assert e.command_error is None
def test_error_tags_of_an_unrelated_error():
e = ChatAPIError("x", {"type": "errorAgent", "agentError": {"type": "CRITICAL"}})
assert e.error_type is None
assert e.store_error_type is None
def test_error_tags_without_a_chat_error():
# Raised when the controller returns something that is not valid JSON-RPC.
e = ChatAPIError("invalid chat command result")
assert e.error_type is None
assert e.store_error_type is None
# ---------------------------------------------------------------------- #
# Errors surfaced as absence
# ---------------------------------------------------------------------- #
async def test_missing_address_reads_as_none():
api = FakeCtrl(
raises=ChatAPIError(
"x", {"type": "errorStore", "storeError": {"type": "userContactLinkNotFound"}}
)
)
assert await api.api_get_user_address(1) is None
async def test_another_store_error_still_raises():
api = FakeCtrl(
raises=ChatAPIError("x", {"type": "errorStore", "storeError": {"type": "dBBusyError"}})
)
with pytest.raises(ChatAPIError):
await api.api_get_user_address(1)
async def test_no_active_user_reads_as_none():
api = FakeCtrl(
raises=ChatAPIError("x", {"type": "error", "errorType": {"type": "noActiveUser"}})
)
assert await api.api_get_active_user() is None
async def test_another_error_from_the_user_query_still_raises():
api = FakeCtrl(
raises=ChatAPIError("x", {"type": "error", "errorType": {"type": "invalidConnReq"}})
)
with pytest.raises(ChatAPIError):
await api.api_get_active_user()
# ---------------------------------------------------------------------- #
# Member contacts
# ---------------------------------------------------------------------- #
async def test_accept_member_contact():
contact = {"contactId": 7}
api = FakeCtrl({"type": "memberContactAccepted", "contact": contact})
assert await api.api_accept_member_contact(7) is contact
assert api.sent == ["/_accept member contact @7"]
async def test_accept_member_contact_rejected():
# The core answers a second accept with a command error, not a contact.
api = FakeCtrl({"type": "chatCmdError"})
with pytest.raises(ChatCommandError):
await api.api_accept_member_contact(7)
# ---------------------------------------------------------------------- #
# Custom data
# ---------------------------------------------------------------------- #
async def test_merge_contact_custom_data_keeps_other_keys():
api = FakeCtrl({"type": "cmdOk"})
contact = {"contactId": 4, "customData": {"other": 1}}
await api.api_merge_contact_custom_data(contact, "mine", {"roster": "active"})
assert api.sent == ['/_set custom @4 {"other": 1, "mine": {"roster": "active"}}']
async def test_merge_contact_custom_data_removing_the_last_key_clears_the_column():
api = FakeCtrl({"type": "cmdOk"})
contact = {"contactId": 4, "customData": {"mine": 1}}
await api.api_merge_contact_custom_data(contact, "mine", None)
assert api.sent == ["/_set custom @4"]
async def test_merge_group_custom_data_keeps_other_keys():
api = FakeCtrl({"type": "cmdOk"})
group = {"groupId": 9, "customData": {"other": 1}}
await api.api_merge_group_custom_data(group, "mine", {"rostered": True})
assert api.sent == ['/_set custom #9 {"other": 1, "mine": {"rostered": true}}']
async def test_merge_group_custom_data_on_a_group_with_no_custom_data():
api = FakeCtrl({"type": "cmdOk"})
await api.api_merge_group_custom_data({"groupId": 9}, "mine", 1)
assert api.sent == ['/_set custom #9 {"mine": 1}']
async def test_a_failed_custom_data_write_raises():
api = FakeCtrl({"type": "chatCmdError"})
with pytest.raises(ChatCommandError):
await api.api_merge_group_custom_data({"groupId": 9}, "mine", 1)
@@ -614,3 +614,238 @@ def test_events_raises_if_already_serving():
pass
asyncio.run(go())
class _StubApi:
"""The controller calls `__aenter__` makes, with nothing behind them."""
def __init__(self) -> None:
self.profiles: list[dict] = []
self.user: dict = {"userId": 1, "profile": {"displayName": "x", "fullName": ""}}
self.address: dict | None = None
@classmethod
async def init(cls, *_a, **_kw):
return cls()
@property
def started(self):
return False
async def start_chat(self):
pass
async def stop_chat(self):
pass
async def close(self):
pass
async def api_get_active_user(self):
return self.user
async def api_update_profile(self, _user_id, profile):
self.profiles.append(profile)
async def api_get_user_address(self, _user_id):
return self.address
async def api_set_address_settings(self, _user_id, _settings):
pass
async def send_chat_cmd(self, _cmd):
return {"type": "cmdOk"}
def _client_with_stub_api(monkeypatch, **kw) -> tuple[Client, _StubApi]:
import simplex_chat.client as client_mod
api = _StubApi()
monkeypatch.setattr(client_mod, "ChatApi", _init_returning(api))
client = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test"), **kw)
return client, api
def _init_returning(api: _StubApi):
"""A stand-in for the ChatApi class whose `init` hands back `api`."""
return type("_Init", (), {"init": staticmethod(lambda *_a, **_kw: _done(api))})
async def _done(value):
return value
def test_stop_before_start_is_not_lost(monkeypatch):
"""A signal handler installed before startup — the only way to survive a
Ctrl+C during database migrations sets the stop event before __aenter__
runs. Clearing it there would begin serving a client the operator has
already stopped."""
c, api = _client_with_stub_api(monkeypatch)
async def go():
c.stop()
assert c.stop_requested
async with c:
assert c.stop_requested, "stop intent was cleared by __aenter__"
await c.serve_forever() # must return immediately, never polling
api.recv_chat_event = _never_called # type: ignore[attr-defined]
asyncio.run(go())
async def _never_called(*_a, **_kw):
raise AssertionError("receive loop should have exited immediately")
def test_stop_requested_is_false_until_stopped(monkeypatch):
c, _ = _client_with_stub_api(monkeypatch)
assert not c.stop_requested
c.stop()
assert c.stop_requested
def test_install_signal_handlers_routes_both_signals(monkeypatch):
import signal as signal_mod
c, _ = _client_with_stub_api(monkeypatch)
registered: dict[int, object] = {}
async def go():
loop = asyncio.get_running_loop()
monkeypatch.setattr(
loop, "add_signal_handler", lambda sig, cb, *a: registered.__setitem__(sig, cb)
)
c.install_signal_handlers()
asyncio.run(go())
assert set(registered) == {signal_mod.SIGINT, signal_mod.SIGTERM}
registered[signal_mod.SIGINT]() # type: ignore[operator]
assert c.stop_requested
def test_install_signal_handlers_is_idempotent(monkeypatch):
c, _ = _client_with_stub_api(monkeypatch)
calls: list[int] = []
async def go():
loop = asyncio.get_running_loop()
monkeypatch.setattr(loop, "add_signal_handler", lambda sig, cb, *a: calls.append(sig))
c.install_signal_handlers()
c.install_signal_handlers()
asyncio.run(go())
assert len(calls) == 2, "second call re-registered the handlers"
def test_second_interrupt_force_exits(monkeypatch):
"""A stop that hangs in stop_chat/close must not trap the operator."""
import signal as signal_mod
import simplex_chat.client as client_mod
c, _ = _client_with_stub_api(monkeypatch)
registered: dict[int, object] = {}
exits: list[int] = []
monkeypatch.setattr(client_mod.os, "_exit", lambda code: exits.append(code))
async def go():
loop = asyncio.get_running_loop()
monkeypatch.setattr(
loop, "add_signal_handler", lambda sig, cb, *a: registered.__setitem__(sig, cb)
)
c.install_signal_handlers()
asyncio.run(go())
on_interrupt = registered[signal_mod.SIGINT]
on_interrupt() # type: ignore[operator]
assert exits == []
on_interrupt() # type: ignore[operator]
assert exits == [130]
def test_sync_profile_applies_a_change_made_after_start(monkeypatch):
"""The name a bot can use may only be knowable once the database is
readable, which is after start. Without this the profile could only be
set before the client was started."""
c, api = _client_with_stub_api(monkeypatch, update_profile=False)
async def go():
async with c:
assert api.profiles == [], "update_profile=False still synced on start"
c.profile.display_name = "Helpdesk"
assert await c.sync_profile() is True
asyncio.run(go())
assert api.profiles == [{"displayName": "Helpdesk", "fullName": ""}]
def test_sync_profile_is_a_no_op_when_nothing_differs(monkeypatch):
"""api_update_profile broadcasts to every contact; an unchanged profile
must not become traffic for all of them."""
c, api = _client_with_stub_api(monkeypatch, update_profile=False)
async def go():
async with c:
assert await c.sync_profile() is False
asyncio.run(go())
assert api.profiles == []
def test_sync_profile_without_an_active_user(monkeypatch):
c, api = _client_with_stub_api(monkeypatch, update_profile=False)
async def go():
async with c:
api.user = None # type: ignore[assignment]
with pytest.raises(RuntimeError, match="no active user"):
await c.sync_profile()
asyncio.run(go())
def test_sync_profile_keeps_the_bot_address_in_the_profile(monkeypatch):
"""The address is embedded by the startup sync; a later sync must not
drop it, or the profile would stop advertising where to connect."""
import simplex_chat.client as client_mod
api = _StubApi()
api.address = {
"connLinkContact": {"connFullLink": "https://l"},
"addressSettings": {"businessAddress": False, "autoAccept": {"acceptIncognito": False}},
}
api.user = {
"userId": 1,
"profile": {"displayName": "x", "fullName": "", "contactLink": "https://l"},
}
monkeypatch.setattr(client_mod, "ChatApi", _init_returning(api))
bot = Bot(
profile=BotProfile(display_name="x"),
db=SqliteDb(file_prefix="/tmp/test"),
update_profile=False,
)
async def go():
async with bot:
bot.profile.display_name = "Helpdesk"
await bot.sync_profile()
asyncio.run(go())
assert api.profiles[0]["contactLink"] == "https://l"
def test_profile_can_be_replaced(monkeypatch):
c, _ = _client_with_stub_api(monkeypatch)
c.profile = Profile(display_name="other", full_name="Other")
assert c._profile_to_wire() == {"displayName": "other", "fullName": "Other"}
def test_the_profile_image_is_checked_before_it_is_sent(monkeypatch):
"""An image the apps cannot decode is stored and broadcast by the core,
and then shows as an empty avatar to every contact."""
c, _ = _client_with_stub_api(monkeypatch)
c.profile = Profile(display_name="x", image="data:image/jpeg;base64,AAA")
with pytest.raises(ValueError, match="must start with"):
c._profile_to_wire()
c.profile = Profile(display_name="x", image="data:image/png;base64,AAA")
assert c._profile_to_wire()["image"] == "data:image/png;base64,AAA"
@@ -2,7 +2,7 @@
import typing
from simplex_chat.types import CC, CEvt, CR, T
from simplex_chat.types import CC, CR, CEvt, T
def test_types_module_imports():
@@ -3,7 +3,7 @@ from pathlib import Path
import pytest
from simplex_chat._native import _cache_root, _resolve_libs_dir, _download
from simplex_chat._native import _cache_root, _download, _resolve_libs_dir
from simplex_chat._version import LIBS_VERSION
@@ -1,6 +1,8 @@
from unittest.mock import patch
import pytest
from simplex_chat._native import _platform_tag, _libs_url, _libname
from simplex_chat._native import _libname, _libs_url, _platform_tag
from simplex_chat._version import LIBS_VERSION
@@ -1,3 +1,5 @@
import pytest
from simplex_chat import util
@@ -173,3 +175,73 @@ def test_reaction_text_emoji():
def test_reaction_text_tag():
r = {"chatReaction": {"reaction": {"type": "unknown", "tag": "thumbs_up"}}}
assert util.reaction_text(r) == "thumbs_up"
def test_merged_custom_data_adds_a_key_keeping_the_others():
data = {"other": {"kept": True}}
assert util.merged_custom_data(data, "mine", {"roster": "active"}) == {
"other": {"kept": True},
"mine": {"roster": "active"},
}
def test_merged_custom_data_does_not_mutate_the_original():
data = {"other": 1}
util.merged_custom_data(data, "mine", 2)
assert data == {"other": 1}
def test_merged_custom_data_replaces_an_existing_key():
assert util.merged_custom_data({"mine": "old"}, "mine", "new") == {"mine": "new"}
def test_merged_custom_data_on_an_empty_column():
assert util.merged_custom_data(None, "mine", 1) == {"mine": 1}
def test_merged_custom_data_removes_a_key():
assert util.merged_custom_data({"mine": 1, "other": 2}, "mine", None) == {"other": 2}
def test_merged_custom_data_clears_the_column_when_nothing_is_left():
# None is what the set commands read as "clear"; {} would be a wasted write
# of an empty object.
assert util.merged_custom_data({"mine": 1}, "mine", None) is None
def test_merged_custom_data_removing_a_key_that_is_not_there():
assert util.merged_custom_data({"other": 2}, "mine", None) == {"other": 2}
def test_conn_status_reads_the_tag():
contact = {"activeConn": {"connStatus": {"type": "ready"}}}
assert util.conn_status(contact) == "ready"
def test_conn_status_without_a_connection():
# api_create_member_contact produces exactly this: a contact row before
# any connection exists.
assert util.conn_status({"contactId": 3}) is None
def test_conn_status_with_a_null_connection():
assert util.conn_status({"activeConn": None}) is None
def test_check_profile_image_accepts_what_the_apps_decode():
png = "data:image/png;base64,AAA"
jpg = "data:image/jpg;base64,AAA"
assert util.check_profile_image(png) == png
assert util.check_profile_image(jpg) == jpg
def test_check_profile_image_rejects_another_media_type():
# image/jpeg is the easy mistake: the file extension is .jpeg, and the
# core stores it, but no client strips that prefix before decoding.
with pytest.raises(ValueError, match="must start with"):
util.check_profile_image("data:image/jpeg;base64,AAA")
def test_check_profile_image_rejects_a_remote_url():
with pytest.raises(ValueError, match="must start with"):
util.check_profile_image("https://simplex.chat/logo.png")