core, python: fix refused renames, extend the client API (#7379)

* core: do not commit refused name changes

* feat(python): add error base and missing commands

* feat(python): let callers drive startup themselves

* style(python): satisfy the linter, skip generated types

* feat(python): expose the message of a command error

* core: update query plans

* core: fix the batch limit parse error on GHC 8.10

* feat(python): reject profile images no client can render
This commit is contained in:
sh
2026-08-17 09:02:14 +01:00
committed by GitHub
parent caac5a61a9
commit 55d18efe24
19 changed files with 718 additions and 82 deletions
@@ -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:
@@ -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]:
@@ -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]