simplex-chat 6.5.2__tar.gz → 7.1.0b6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/PKG-INFO +2 -2
  2. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/examples/squaring_bot.py +23 -1
  3. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/pyproject.toml +4 -0
  4. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/__init__.py +4 -3
  5. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/__main__.py +1 -1
  6. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/_native.py +48 -12
  7. simplex_chat-7.1.0b6/src/simplex_chat/_version.py +16 -0
  8. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/api.py +101 -28
  9. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/bot.py +52 -6
  10. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/client.py +96 -56
  11. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/core.py +60 -12
  12. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/filters.py +2 -1
  13. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/types/_commands.py +164 -16
  14. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/types/_events.py +66 -15
  15. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/types/_responses.py +73 -1
  16. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/types/_types.py +332 -37
  17. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/util.py +41 -1
  18. simplex_chat-7.1.0b6/tests/test_api.py +218 -0
  19. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/tests/test_bot_registration.py +55 -0
  20. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/tests/test_client_and_waiters.py +344 -1
  21. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/tests/test_codegen.py +7 -1
  22. simplex_chat-7.1.0b6/tests/test_core_migrate_init.py +123 -0
  23. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/tests/test_native_cache.py +52 -6
  24. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/tests/test_native_url.py +6 -3
  25. simplex_chat-7.1.0b6/tests/test_recv_executor.py +207 -0
  26. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/tests/test_util.py +77 -0
  27. simplex_chat-6.5.2/src/simplex_chat/_version.py +0 -9
  28. simplex_chat-6.5.2/uv.lock +0 -160
  29. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/.gitignore +0 -0
  30. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/LICENSE +0 -0
  31. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/README.md +0 -0
  32. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/py.typed +0 -0
  33. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/types/__init__.py +0 -0
  34. {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/tests/test_filters.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: simplex-chat
3
- Version: 6.5.2
3
+ Version: 7.1.0b6
4
4
  Summary: SimpleX Chat Python library for chat bots
5
5
  Project-URL: Homepage, https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-python
6
6
  Project-URL: Issues, https://github.com/simplex-chat/simplex-chat/issues
@@ -26,7 +26,15 @@ bot = Bot(
26
26
  profile=BotProfile(display_name="Squaring bot"),
27
27
  db=SqliteDb(file_prefix="./squaring_bot"),
28
28
  welcome="Send me a number, I'll square it.",
29
- commands=[BotCommand(keyword="help", label="Show help")],
29
+ commands=[
30
+ # `params=None` (default): the client SENDS `/help` immediately
31
+ # when the user taps it in the commands menu.
32
+ BotCommand(keyword="help", label="Show help"),
33
+ # `params="<number>"`: the client PASTES `/square <number>`
34
+ # into the input box and positions the cursor at the end. The
35
+ # user replaces `<number>` with the actual number and sends.
36
+ BotCommand(keyword="square", label="Square a number", params="<number>"),
37
+ ],
30
38
  )
31
39
 
32
40
  NUMBER_RE = re.compile(r"^-?\d+(\.\d+)?$")
@@ -48,5 +56,19 @@ async def help_cmd(msg: Message, _cmd: ParsedCommand) -> None:
48
56
  await msg.reply("Send a number, I'll square it.")
49
57
 
50
58
 
59
+ @bot.on_command("square")
60
+ async def square_cmd(msg: Message, cmd: ParsedCommand) -> None:
61
+ """Demonstrates the `params` flow: `cmd.args` is the trimmed text
62
+ AFTER `/square`. When the user tapped the menu entry above, the
63
+ client pasted `/square <number>` and the user replaced
64
+ `<number>` with the actual value before sending."""
65
+ try:
66
+ n = float(cmd.args)
67
+ except ValueError:
68
+ await msg.reply(f"Usage: /square <number> (got {cmd.args!r})")
69
+ return
70
+ await msg.reply(f"{n} * {n} = {n * n}")
71
+
72
+
51
73
  if __name__ == "__main__":
52
74
  bot.run()
@@ -42,6 +42,10 @@ asyncio_mode = "auto"
42
42
  line-length = 100
43
43
  target-version = "py311"
44
44
 
45
+ [tool.ruff.lint]
46
+ # Generated by the Haskell codegen; regenerating is the only way to change them.
47
+ exclude = ["src/simplex_chat/types/_*.py"]
48
+
45
49
  [tool.ruff.format]
46
50
  # `src/simplex_chat/types/*.py` are generated by the Haskell codegen
47
51
  # (bots/src/API/Docs/Generate/Python.hs). Re-formatting them locally
@@ -1,5 +1,6 @@
1
1
  """SimpleX Chat — Python client library for chat bots."""
2
2
 
3
+ from . import util as util # re-export the util namespace
3
4
  from ._version import __version__
4
5
  from .api import (
5
6
  ChatApi,
@@ -32,17 +33,16 @@ from .bot import (
32
33
  VideoMessage,
33
34
  VoiceMessage,
34
35
  )
35
- from .core import ChatAPIError, ChatInitError, CryptoArgs, MigrationConfirmation
36
- from . import util as util # re-export the util namespace
36
+ from .core import ChatAPIError, ChatError, ChatInitError, CryptoArgs, MigrationConfirmation
37
37
 
38
38
  __all__ = [
39
- "__version__",
40
39
  "Bot",
41
40
  "BotCommand",
42
41
  "BotProfile",
43
42
  "ChatAPIError",
44
43
  "ChatApi",
45
44
  "ChatCommandError",
45
+ "ChatError",
46
46
  "ChatInitError",
47
47
  "ChatMessage",
48
48
  "Client",
@@ -68,5 +68,6 @@ __all__ = [
68
68
  "UnknownMessage",
69
69
  "VideoMessage",
70
70
  "VoiceMessage",
71
+ "__version__",
71
72
  "util",
72
73
  ]
@@ -26,7 +26,7 @@ def main(argv: list[str] | None = None) -> int:
26
26
  path = _native._resolve_libs_dir(args.backend)
27
27
  print(f"libsimplex installed at: {path}")
28
28
  return 0
29
- except Exception as e:
29
+ except Exception as e: # noqa: BLE001 - a CLI: report any failure, don't traceback
30
30
  print(f"install failed: {e}", file=sys.stderr)
31
31
  return 1
32
32
 
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import ctypes
9
9
  import errno
10
+ import hashlib
10
11
  import os
11
12
  import platform
12
13
  import sys
@@ -16,9 +17,9 @@ import urllib.request
16
17
  import zipfile
17
18
  from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p
18
19
  from pathlib import Path
19
- from typing import Literal
20
+ from typing import Any, Literal
20
21
 
21
- from ._version import LIBS_VERSION
22
+ from ._version import LIBS_RELEASE, LIBS_SHA256
22
23
 
23
24
  Backend = Literal["sqlite", "postgres"]
24
25
 
@@ -59,11 +60,14 @@ def _libname() -> str:
59
60
  return _LIBNAME[sys.platform]
60
61
 
61
62
 
62
- def _libs_url(backend: Backend) -> str:
63
+ def _libs_asset(backend: Backend) -> str:
63
64
  suffix = "-postgres" if backend == "postgres" else ""
65
+ return f"simplex-chat-libs-{_platform_tag()}{suffix}.zip"
66
+
67
+
68
+ def _libs_url(backend: Backend) -> str:
64
69
  return (
65
- f"https://github.com/{_GITHUB_REPO}/releases/download/"
66
- f"v{LIBS_VERSION}/simplex-chat-libs-{_platform_tag()}{suffix}.zip"
70
+ f"https://github.com/{_GITHUB_REPO}/releases/download/{LIBS_RELEASE}/{_libs_asset(backend)}"
67
71
  )
68
72
 
69
73
 
@@ -84,7 +88,7 @@ def _resolve_libs_dir(backend: Backend) -> Path:
84
88
  "postgres backend is only supported on linux-x86_64; "
85
89
  f"current platform is {_platform_tag()}"
86
90
  )
87
- target = _cache_root() / f"v{LIBS_VERSION}" / backend
91
+ target = _cache_root() / LIBS_RELEASE / backend
88
92
  if not (target / _libname()).exists():
89
93
  _download(target, backend)
90
94
  return target
@@ -99,7 +103,7 @@ def _stream_to_file(url: str, dest: Path, *, timeout: float = 60.0) -> None:
99
103
  `timeout` is per-request; we don't touch `socket.setdefaulttimeout`
100
104
  so other socket users in the same process aren't affected.
101
105
  """
102
- with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310 - https://github.com/...
106
+ with urllib.request.urlopen(url, timeout=timeout) as resp:
103
107
  total = int(resp.headers.get("Content-Length") or 0)
104
108
  received = 0
105
109
  with dest.open("wb") as out:
@@ -112,7 +116,7 @@ def _stream_to_file(url: str, dest: Path, *, timeout: float = 60.0) -> None:
112
116
  else:
113
117
  msg = f"\r download: {received >> 20} MiB"
114
118
  print(msg, end="", file=sys.stderr, flush=True)
115
- print("", file=sys.stderr, flush=True) # newline after final progress line
119
+ print(file=sys.stderr, flush=True) # newline after final progress line
116
120
 
117
121
 
118
122
  def _download(target: Path, backend: Backend) -> None:
@@ -125,16 +129,21 @@ def _download(target: Path, backend: Backend) -> None:
125
129
  what we need and proceed. NEVER rmtree the target: that creates a TOCTOU
126
130
  window where another process is reading/loading the file we're deleting.
127
131
  """
132
+ asset = _libs_asset(backend)
133
+ sha256 = LIBS_SHA256.get(asset)
134
+ if sha256 is None:
135
+ raise RuntimeError(f"no SHA-256 is pinned for {asset}")
128
136
  target.parent.mkdir(parents=True, exist_ok=True)
129
137
  url = _libs_url(backend)
130
138
  print(
131
- f"Downloading libsimplex ({_platform_tag()}, {backend}) v{LIBS_VERSION} from {url} ...",
139
+ f"Downloading libsimplex ({_platform_tag()}, {backend}) {LIBS_RELEASE} from {url} ...",
132
140
  file=sys.stderr,
133
141
  flush=True,
134
142
  )
135
143
  with tempfile.TemporaryDirectory(dir=target.parent) as tmp:
136
144
  zip_path = Path(tmp) / "libs.zip"
137
145
  _stream_to_file(url, zip_path, timeout=60.0)
146
+ _verify_sha256(zip_path, sha256, url)
138
147
  with zipfile.ZipFile(zip_path) as zf:
139
148
  zf.extractall(tmp)
140
149
  # zip layout: <tmp>/libs/libsimplex.* + libHS*.*
@@ -158,6 +167,16 @@ def _download(target: Path, backend: Backend) -> None:
158
167
  ) from e
159
168
 
160
169
 
170
+ def _verify_sha256(file: Path, expected: str, url: str) -> None:
171
+ digest = hashlib.sha256()
172
+ with file.open("rb") as f:
173
+ while chunk := f.read(_DOWNLOAD_CHUNK):
174
+ digest.update(chunk)
175
+ actual = digest.hexdigest()
176
+ if actual != expected:
177
+ raise RuntimeError(f"SHA-256 of {url} is {actual}, expected {expected}")
178
+
179
+
161
180
  _lock = threading.Lock()
162
181
  _lib: ctypes.CDLL | None = None
163
182
  _libc: ctypes.CDLL | None = None
@@ -166,7 +185,8 @@ _backend: Backend | None = None
166
185
 
167
186
  def _load_libc() -> ctypes.CDLL:
168
187
  if sys.platform == "win32":
169
- return ctypes.CDLL("msvcrt")
188
+ # libsimplex.dll allocates results with UCRT malloc; msvcrt free would corrupt the heap.
189
+ return ctypes.CDLL("ucrtbase")
170
190
  return ctypes.CDLL(None) # libc on POSIX is the process's own symbol table
171
191
 
172
192
 
@@ -229,8 +249,8 @@ def lib_for(backend: Backend) -> ctypes.CDLL:
229
249
  if _lib is not None:
230
250
  if _backend != backend:
231
251
  raise RuntimeError(
232
- f"libsimplex already loaded with backend={_backend!r}; "
233
- f"cannot switch to {backend!r} in the same process"
252
+ f"libsimplex already loaded with backend={_backend}; "
253
+ f"cannot switch to {backend} in the same process"
234
254
  )
235
255
  return _lib
236
256
  libs_dir = _resolve_libs_dir(backend)
@@ -255,3 +275,19 @@ def lib() -> ctypes.CDLL:
255
275
  if _lib is None:
256
276
  raise RuntimeError("lib_for() must be called before lib()")
257
277
  return _lib
278
+
279
+
280
+ QUEUE_SIZE_UNSUPPORTED = (
281
+ "loaded libsimplex does not export chat_migrate_init_queue; queue size needs a newer libsimplex"
282
+ )
283
+
284
+
285
+ def migrate_init_queue() -> Any:
286
+ """`chat_migrate_init_queue`, which older libsimplex releases do not export."""
287
+ try:
288
+ fn = lib().chat_migrate_init_queue
289
+ except AttributeError as e:
290
+ raise RuntimeError(QUEUE_SIZE_UNSUPPORTED) from e
291
+ fn.argtypes = [c_char_p, c_char_p, c_char_p, c_int, POINTER(c_void_p)]
292
+ fn.restype = c_void_p
293
+ return fn
@@ -0,0 +1,16 @@
1
+ """Package version and the simplex-chat-libs release it loads.
2
+
3
+ Written by scripts/ci/packages_release.py: the release PR bumps the version
4
+ and pins new library releases with the SHA-256 of their assets.
5
+ """
6
+
7
+ __version__ = "7.1.0b6" # PEP 440 — read by hatchling for wheel metadata
8
+ LIBS_RELEASE = "core-7.1.0.11" # simplex-chat-libs release tag
9
+ LIBS_SHA256 = {
10
+ "simplex-chat-libs-linux-aarch64.zip": "d66aa1b30b9fa20225dad8ef75a9c605961e1c0adde2ba0283e2fcac4e15d260",
11
+ "simplex-chat-libs-linux-x86_64-postgres.zip": "925b3166f969b12082ff8ee659e5f2a9933fe40120deb80c9aa3002a5ba6c803",
12
+ "simplex-chat-libs-linux-x86_64.zip": "dabad0824e8813ea4a084fbf37e1ba4cf6dcea9cd8ccadc521fec200f246208a",
13
+ "simplex-chat-libs-macos-aarch64.zip": "aa74608b149fff7508a47eaac4284dcc6d189c6ed8b7c18428df9158bc176a61",
14
+ "simplex-chat-libs-macos-x86_64.zip": "a0e21457e1077be7f71b46c1c27452945b2cee2b6c50efb0004412ce5b7042ec",
15
+ "simplex-chat-libs-windows-x86_64.zip": "c01dad574daa101a1cbfd31ddbf2b3d0d986a4a42f97d789d2f878b2050b2b4b",
16
+ }
@@ -2,13 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import json
7
+ from concurrent.futures import ThreadPoolExecutor
6
8
  from dataclasses import dataclass
7
9
  from typing import Any, Literal
8
10
 
9
11
  from . import _native, core, util
10
12
  from .core import MigrationConfirmation
11
- from .types import CC, CEvt, CR, T
13
+ from .types import CC, CR, CEvt, T
12
14
 
13
15
  # Mirrors Node `ConnReqType` enum (api.ts:15-18) — the two possible outcomes
14
16
  # of `api_connect` / `api_connect_active_user` depending on the link kind.
@@ -39,7 +41,7 @@ def _db_to_migrate_args(db: Db) -> tuple[str, str, _native.Backend]:
39
41
  raise TypeError(f"Unknown db: {db!r}")
40
42
 
41
43
 
42
- class ChatCommandError(Exception):
44
+ class ChatCommandError(core.ChatError):
43
45
  """A chat command returned an unexpected response type.
44
46
 
45
47
  `response` is the raw wire response; `response_type` exposes its `type`
@@ -65,17 +67,20 @@ class ChatApi:
65
67
  def __init__(self, ctrl: int):
66
68
  self._ctrl: int | None = ctrl
67
69
  self._started = False
70
+ self._recv_executor: ThreadPoolExecutor | None = None
68
71
 
69
72
  @classmethod
70
73
  async def init(
71
74
  cls,
72
75
  db: Db,
73
76
  confirm: MigrationConfirmation = MigrationConfirmation.YES_UP,
74
- ) -> "ChatApi":
77
+ queue_size: int | None = None,
78
+ ) -> ChatApi:
75
79
  path_or_prefix, key_or_conn, backend = _db_to_migrate_args(db)
76
80
  # Trigger lazy lib load with the right backend BEFORE chat_migrate_init.
77
- _native.lib_for(backend)
78
- ctrl = await core.chat_migrate_init(path_or_prefix, key_or_conn, confirm)
81
+ # It may download ~100 MB, so it must not block the event loop.
82
+ await asyncio.to_thread(_native.lib_for, backend)
83
+ ctrl = await core.chat_migrate_init(path_or_prefix, key_or_conn, confirm, queue_size)
79
84
  return cls(ctrl)
80
85
 
81
86
  @property
@@ -96,8 +101,12 @@ class ChatApi:
96
101
  return self._started
97
102
 
98
103
  async def start_chat(self) -> None:
104
+ # serviceRequests is off: a bot answers its own address, it does not
105
+ # serve requests routed to it as a service.
99
106
  r = await self.send_chat_cmd(
100
- CC.StartChat_cmd_string({"mainApp": True, "enableSndFiles": True})
107
+ CC.StartChat_cmd_string(
108
+ {"mainApp": True, "enableSndFiles": True, "serviceRequests": False}
109
+ )
101
110
  )
102
111
  if r.get("type") not in ("chatStarted", "chatRunning"):
103
112
  raise ChatCommandError("error starting chat", r)
@@ -110,6 +119,14 @@ class ChatApi:
110
119
  self._started = False
111
120
 
112
121
  async def close(self) -> None:
122
+ """Stop the chat and close its store; the store stays open if stopping fails."""
123
+ # a running controller keeps using the database connections that closing frees
124
+ await self.stop_chat()
125
+ if self._recv_executor is not None:
126
+ # Waits for a receive already in flight (up to wait_us) so the store
127
+ # never closes underneath one; run off-loop since shutdown blocks.
128
+ await asyncio.to_thread(self._recv_executor.shutdown, wait=True)
129
+ self._recv_executor = None
113
130
  await core.chat_close_store(self.ctrl)
114
131
  self._ctrl = None
115
132
  self._started = False
@@ -118,7 +135,14 @@ class ChatApi:
118
135
  return await core.chat_send_cmd(self.ctrl, cmd)
119
136
 
120
137
  async def recv_chat_event(self, wait_us: int = 500_000) -> CEvt.ChatEvent | None:
121
- return await core.chat_recv_msg_wait(self.ctrl, wait_us)
138
+ ctrl = self.ctrl # raises before touching the executor if close() was called
139
+ if self._recv_executor is None:
140
+ # A receive blocks for up to wait_us almost back to back, so it would
141
+ # otherwise pin one of the default executor's few worker threads.
142
+ self._recv_executor = ThreadPoolExecutor(
143
+ max_workers=1, thread_name_prefix="simplex-recv"
144
+ )
145
+ return await core.chat_recv_msg_wait(ctrl, wait_us, self._recv_executor)
122
146
 
123
147
  # ------------------------------------------------------------------ #
124
148
  # Address commands
@@ -142,12 +166,7 @@ class ChatApi:
142
166
  return r["contactLink"]
143
167
  raise ChatCommandError("error loading user address", r)
144
168
  except core.ChatAPIError as e:
145
- ce = e.chat_error
146
- if (
147
- ce is not None
148
- and ce.get("type") == "errorStore"
149
- and ce.get("storeError", {}).get("type") == "userContactLinkNotFound"
150
- ):
169
+ if e.store_error_type == "userContactLinkNotFound":
151
170
  return None
152
171
  raise
153
172
 
@@ -159,6 +178,8 @@ class ChatApi:
159
178
  )
160
179
  if r["type"] == "userProfileUpdated":
161
180
  return r["updateSummary"]
181
+ if r["type"] == "userProfileNoChange":
182
+ return {"updateSuccesses": 0, "updateFailures": 0, "changedContacts": []}
162
183
  raise ChatCommandError("error setting profile address", r)
163
184
 
164
185
  async def api_set_address_settings(self, user_id: int, settings: T.AddressSettings) -> None:
@@ -193,6 +214,7 @@ class ChatApi:
193
214
  "sendRef": send_ref,
194
215
  "composedMessages": messages,
195
216
  "liveMessage": live_message,
217
+ "signMessages": False,
196
218
  }
197
219
  )
198
220
  )
@@ -236,6 +258,8 @@ class ChatApi:
236
258
  )
237
259
  if r["type"] == "chatItemUpdated":
238
260
  return r["chatItem"]["chatItem"]
261
+ if r["type"] == "chatItemNotChanged":
262
+ return r["chatItem"]["chatItem"]
239
263
  raise ChatCommandError("error updating chat item", r)
240
264
 
241
265
  async def api_delete_chat_items(
@@ -302,6 +326,8 @@ class ChatApi:
302
326
  )
303
327
  if r["type"] == "rcvFileAccepted":
304
328
  return r["chatItem"]
329
+ if r["type"] == "rcvFileAcceptedSndCancelled":
330
+ raise ChatCommandError("file cancelled by sender", r)
305
331
  raise ChatCommandError("error receiving file", r)
306
332
 
307
333
  async def api_cancel_file(self, file_id: int) -> None:
@@ -325,6 +351,23 @@ class ChatApi:
325
351
  return r["member"]
326
352
  raise ChatCommandError("error adding member", r)
327
353
 
354
+ async def api_add_member_via_group(
355
+ self, group_id: int, via_group_id: int, group_member_id: int, member_role: T.GroupMemberRole
356
+ ) -> T.GroupMember:
357
+ r = await self.send_chat_cmd(
358
+ CC.APIAddMemberViaGroup_cmd_string(
359
+ {
360
+ "groupId": group_id,
361
+ "viaGroupId": via_group_id,
362
+ "groupMemberId": group_member_id,
363
+ "memberRole": member_role,
364
+ }
365
+ )
366
+ )
367
+ if r["type"] == "sentGroupInvitationViaGroup":
368
+ return r["member"]
369
+ raise ChatCommandError("error adding member", r)
370
+
328
371
  async def api_join_group(self, group_id: int) -> T.GroupInfo:
329
372
  r = await self.send_chat_cmd(CC.APIJoinGroup_cmd_string({"groupId": group_id}))
330
373
  if r["type"] == "userAcceptedGroupSent":
@@ -466,7 +509,7 @@ class ChatApi:
466
509
  ) -> tuple[T.ConnectionPlan, T.CreatedConnLink]:
467
510
  r = await self.send_chat_cmd(
468
511
  CC.APIConnectPlan_cmd_string(
469
- {"userId": user_id, "connectionLink": connection_link, "resolveKnown": False}
512
+ {"userId": user_id, "connectTarget": connection_link, "resolveMode": "unknown"}
470
513
  )
471
514
  )
472
515
  if r["type"] == "connectionPlan":
@@ -477,17 +520,18 @@ class ChatApi:
477
520
  self,
478
521
  user_id: int,
479
522
  incognito: bool,
480
- prepared_link: T.CreatedConnLink | None = None,
523
+ prepared_link: T.CreatedConnLink,
481
524
  ) -> ConnReqType:
482
- args: CC.APIConnect = {"userId": user_id, "incognito": incognito}
483
- if prepared_link is not None:
484
- args["preparedLink_"] = prepared_link
485
- r = await self.send_chat_cmd(CC.APIConnect_cmd_string(args))
525
+ r = await self.send_chat_cmd(
526
+ CC.APIConnect_cmd_string(
527
+ {"userId": user_id, "incognito": incognito, "preparedLink_": prepared_link}
528
+ )
529
+ )
486
530
  return self._handle_connect_result(r)
487
531
 
488
532
  async def api_connect_active_user(self, conn_link: str) -> ConnReqType:
489
533
  r = await self.send_chat_cmd(
490
- CC.Connect_cmd_string({"incognito": False, "connLink_": conn_link})
534
+ CC.Connect_cmd_string({"incognito": False, "connTarget_": conn_link})
491
535
  )
492
536
  return self._handle_connect_result(r)
493
537
 
@@ -509,8 +553,10 @@ class ChatApi:
509
553
  raise ChatCommandError("error accepting contact request", r)
510
554
 
511
555
  async def api_reject_contact_request(self, contact_req_id: int) -> None:
556
+ # notify is not rendered into the command string, so the core reads its
557
+ # own default of off; this only keeps the argument type satisfied.
512
558
  r = await self.send_chat_cmd(
513
- CC.APIRejectContact_cmd_string({"contactReqId": contact_req_id})
559
+ CC.APIRejectContact_cmd_string({"contactReqId": contact_req_id, "notify": False})
514
560
  )
515
561
  if r["type"] != "contactRequestRejected":
516
562
  raise ChatCommandError("error rejecting contact request", r)
@@ -606,6 +652,28 @@ class ChatApi:
606
652
  if r["type"] != "cmdOk":
607
653
  raise ChatCommandError("error setting contact custom data", r)
608
654
 
655
+ async def api_merge_contact_custom_data(
656
+ self, contact: T.Contact, key: str, value: object | None
657
+ ) -> None:
658
+ """Set or drop one key of a contact's custom data, keeping the rest.
659
+
660
+ The set command replaces the whole column. `value=None` removes `key`.
661
+ """
662
+ await self.api_set_contact_custom_data(
663
+ contact["contactId"], util.merged_custom_data(contact.get("customData"), key, value)
664
+ )
665
+
666
+ async def api_merge_group_custom_data(
667
+ self, group: T.GroupInfo, key: str, value: object | None
668
+ ) -> None:
669
+ """Set or drop one key of a group's custom data, keeping the rest.
670
+
671
+ See `api_merge_contact_custom_data`.
672
+ """
673
+ await self.api_set_group_custom_data(
674
+ group["groupId"], util.merged_custom_data(group.get("customData"), key, value)
675
+ )
676
+
609
677
  async def api_set_auto_accept_member_contacts(self, user_id: int, on_off: bool) -> None:
610
678
  r = await self.send_chat_cmd(
611
679
  CC.APISetUserAutoAcceptMemberContacts_cmd_string({"userId": user_id, "onOff": on_off})
@@ -631,17 +699,12 @@ class ChatApi:
631
699
  return r["user"]
632
700
  raise ChatCommandError("unexpected response", r)
633
701
  except core.ChatAPIError as e:
634
- ce = e.chat_error
635
- if (
636
- ce is not None
637
- and ce.get("type") == "error"
638
- and ce.get("errorType", {}).get("type") == "noActiveUser"
639
- ):
702
+ if e.error_type == "noActiveUser":
640
703
  return None
641
704
  raise
642
705
 
643
706
  async def api_create_active_user(self, profile: T.Profile | None = None) -> T.User:
644
- new_user: T.NewUser = {"pastTimestamp": False, "userChatRelay": False}
707
+ new_user: T.NewUser = {"pastTimestamp": False, "userChatRelay": False, "clientService": False}
645
708
  if profile is not None:
646
709
  new_user["profile"] = profile
647
710
  r = await self.send_chat_cmd(CC.CreateActiveUser_cmd_string({"newUser": new_user}))
@@ -718,3 +781,13 @@ class ChatApi:
718
781
  if r["type"] == "newMemberContactSentInv":
719
782
  return r["contact"]
720
783
  raise ChatCommandError("error sending member contact invitation", r)
784
+
785
+ async def api_accept_member_contact(self, contact_id: int) -> T.Contact:
786
+ """Accept a direct connection a group member opened with us.
787
+
788
+ The core rejects a second accept with "connection already started".
789
+ """
790
+ r = await self.send_chat_cmd(f"/_accept member contact @{contact_id}")
791
+ if r["type"] == "memberContactAccepted":
792
+ return r["contact"]
793
+ raise ChatCommandError("error accepting member contact", r)
@@ -33,8 +33,38 @@ from .types import T
33
33
 
34
34
  @dataclass(slots=True)
35
35
  class BotCommand:
36
+ """One entry in the bot's advertised slash-command list (wire-side
37
+ `groupPreferences.commands` or profile `preferences.commands`).
38
+
39
+ `keyword` and `label` are required: `keyword` is what the user
40
+ types after `/`; `label` is the human-readable description shown
41
+ next to the keyword in the SimpleX client's commands menu.
42
+
43
+ `params` is an optional placeholder string that controls how the
44
+ client behaves when the user taps the command in the menu:
45
+
46
+ * `params=None` (default) — the client SENDS `/<keyword>`
47
+ immediately on tap; no input-box detour. Use this for
48
+ zero-argument commands (`/help`, `/ping`) where the action is
49
+ unambiguous.
50
+
51
+ * `params="<value>"` — the client PASTES `/<keyword> <params>`
52
+ into the input box and positions the cursor at the end. The
53
+ user edits the placeholder and sends. Use this for commands
54
+ that take a required argument (`/review <pr-url>`,
55
+ `/order <number>`) so the user sees the expected shape
56
+ without having to remember it.
57
+
58
+ Mirrors `CBCCommand` in the Haskell core
59
+ (`Simplex.Chat.Types.Preferences`) and the wire TypedDict
60
+ `ChatBotCommand_command`. Both SimpleX clients (Android/Kotlin
61
+ and iOS/Swift) implement the paste-vs-send branch on the
62
+ `params` field; see `CommandsMenuView.{kt,swift}` for the
63
+ reference UI behaviour.
64
+ """
36
65
  keyword: str
37
66
  label: str
67
+ params: str | None = None
38
68
 
39
69
 
40
70
  class Bot(Client):
@@ -60,6 +90,7 @@ class Bot(Client):
60
90
  welcome: str | T.MsgContent | None = None,
61
91
  commands: list[BotCommand] | None = None,
62
92
  confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP,
93
+ queue_size: int | None = None,
63
94
  create_address: bool = True,
64
95
  update_address: bool = True,
65
96
  update_profile: bool = True,
@@ -73,6 +104,7 @@ class Bot(Client):
73
104
  profile=profile,
74
105
  db=db,
75
106
  confirm_migrations=confirm_migrations,
107
+ queue_size=queue_size,
76
108
  update_profile=update_profile,
77
109
  log_contacts=log_contacts,
78
110
  log_network=log_network,
@@ -91,8 +123,8 @@ class Bot(Client):
91
123
 
92
124
  async def _post_start(self, user: T.User) -> None:
93
125
  """Bots sync address first, then embed the link in the profile."""
94
- link = await self._sync_address(user)
95
- await self._maybe_sync_profile(user, contact_link=link)
126
+ self._contact_link = await self._sync_address(user)
127
+ await self._maybe_sync_profile(user)
96
128
 
97
129
  async def _sync_address(self, user: T.User) -> str | None:
98
130
  """Address sync. Returns the public link if any, for embedding in the profile."""
@@ -145,10 +177,24 @@ class Bot(Client):
145
177
  "files": {"allow": "yes" if self._allow_files else "no"},
146
178
  }
147
179
  if self._commands:
148
- prefs["commands"] = [
149
- {"type": "command", "keyword": c.keyword, "label": c.label}
150
- for c in self._commands
151
- ]
180
+ cmds: list[T.ChatBotCommand] = []
181
+ for c in self._commands:
182
+ entry: T.ChatBotCommand_command = {
183
+ "type": "command",
184
+ "keyword": c.keyword,
185
+ "label": c.label,
186
+ }
187
+ # `params` is `NotRequired[str]` on the wire; omit the
188
+ # key entirely when None so the Haskell parser sees
189
+ # `Nothing` rather than `Just ""`. The two have
190
+ # different client semantics: `Nothing` (`params=None`)
191
+ # triggers an immediate send on tap; `Just ""` would
192
+ # paste `/<keyword> ` (with a trailing space) into the
193
+ # input box, which is rarely what the operator wants.
194
+ if c.params is not None:
195
+ entry["params"] = c.params
196
+ cmds.append(entry)
197
+ prefs["commands"] = cmds
152
198
  p["preferences"] = prefs
153
199
  p["peerType"] = "bot"
154
200
  return p