simplex-chat-python: split Client from Bot, add request/response API (#6976)

* simplex-chat-python: split Client from Bot, add request/response API

Client is now the base class for SimpleX participants that talk TO
services (monitors, probes, automated participants). Bot extends Client
with server features (address, auto-accept, welcome, commands).

New methods on Client (inherited by Bot):
  connect_to(link)           idempotent contact handshake
  send_and_wait(id, text)    send a message and await the reply
  events()                   async iterator over chat events
  @on_message(contact_id=N)  filter by sender in decorators

BotProfile renamed to Profile (alias kept). New ContactAlreadyExistsError
subclass for cleaner error handling.

* simplex-chat-python: narrow event payload type per @on_event tag

@client.on_event("contactConnected") now types the handler's event
parameter as CEvt.ContactConnected instead of the unnarrowed
CEvt.ChatEvent union — mirroring how @on_message narrows by
content_type.

The 50 overloads are generated by the Haskell codegen into _events.py
(as a Protocol class), so new events stay in sync automatically.
Client.on_event is exposed as a property typed as that Protocol; the
runtime implementation is unchanged.
This commit is contained in:
sh
2026-05-13 15:51:00 +00:00
committed by GitHub
parent 3b4bf92015
commit 9584992c83
10 changed files with 2089 additions and 663 deletions
@@ -1,6 +1,6 @@
import pytest
from simplex_chat import Bot, BotCommand, BotProfile, Middleware, SqliteDb
from simplex_chat import Bot, BotCommand, BotProfile, Client, Middleware, Profile, SqliteDb
from simplex_chat.api import ChatApi
@@ -57,9 +57,9 @@ def test_command_keyword_tuple():
def test_bot_profile_to_wire_default():
"""use_bot_profile=True (default) sets peerType=bot and disables calls/voice."""
"""Bot's profile wire-form sets peerType=bot and disables calls/voice."""
bot = _bot()
p = bot._bot_profile_to_wire()
p = bot._profile_to_wire()
assert p["displayName"] == "x"
assert p.get("peerType") == "bot"
prefs = p.get("preferences") or {}
@@ -74,7 +74,7 @@ def test_bot_profile_to_wire_allow_files():
db=SqliteDb(file_prefix="/tmp/test"),
allow_files=True,
)
prefs = bot._bot_profile_to_wire().get("preferences") or {}
prefs = bot._profile_to_wire().get("preferences") or {}
assert prefs.get("files", {}).get("allow") == "yes"
@@ -84,32 +84,26 @@ def test_bot_profile_to_wire_with_commands():
db=SqliteDb(file_prefix="/tmp/test"),
commands=[BotCommand(keyword="ping", label="Ping bot"), BotCommand("help", "Show help")],
)
cmds = bot._bot_profile_to_wire().get("preferences", {}).get("commands") or []
cmds = bot._profile_to_wire().get("preferences", {}).get("commands") or []
assert len(cmds) == 2
assert cmds[0] == {"type": "command", "keyword": "ping", "label": "Ping bot"}
assert cmds[1] == {"type": "command", "keyword": "help", "label": "Show help"}
def test_bot_profile_to_wire_no_bot_profile():
bot = Bot(
profile=BotProfile(display_name="x"),
db=SqliteDb(file_prefix="/tmp/test"),
use_bot_profile=False,
)
p = bot._bot_profile_to_wire()
def test_client_profile_to_wire_has_no_bot_extras():
"""Client's wire profile has no peerType=bot, no command list, no calls/voice prefs.
That's the whole point of having Client as a separate class."""
c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test"))
p = c._profile_to_wire()
assert p["displayName"] == "x"
assert "peerType" not in p
assert "preferences" not in p
def test_commands_without_bot_profile_raises():
bot = Bot(
profile=BotProfile(display_name="x"),
db=SqliteDb(file_prefix="/tmp/test"),
use_bot_profile=False,
commands=[BotCommand("ping", "Ping bot")],
)
with pytest.raises(ValueError, match="use_bot_profile=False"):
bot._bot_profile_to_wire()
def test_bot_profile_alias_is_profile():
"""`BotProfile` is kept as an alias for backwards compatibility."""
assert BotProfile is Profile
assert BotProfile(display_name="x") == Profile(display_name="x")
def test_dispatch_message_first_match_wins():