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.
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/PKG-INFO +2 -2
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/examples/squaring_bot.py +23 -1
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/pyproject.toml +4 -0
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/__init__.py +4 -3
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/__main__.py +1 -1
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/_native.py +48 -12
- simplex_chat-7.1.0b6/src/simplex_chat/_version.py +16 -0
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/api.py +101 -28
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/bot.py +52 -6
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/client.py +96 -56
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/core.py +60 -12
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/filters.py +2 -1
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/types/_commands.py +164 -16
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/types/_events.py +66 -15
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/types/_responses.py +73 -1
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/types/_types.py +332 -37
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/util.py +41 -1
- simplex_chat-7.1.0b6/tests/test_api.py +218 -0
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/tests/test_bot_registration.py +55 -0
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/tests/test_client_and_waiters.py +344 -1
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/tests/test_codegen.py +7 -1
- simplex_chat-7.1.0b6/tests/test_core_migrate_init.py +123 -0
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/tests/test_native_cache.py +52 -6
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/tests/test_native_url.py +6 -3
- simplex_chat-7.1.0b6/tests/test_recv_executor.py +207 -0
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/tests/test_util.py +77 -0
- simplex_chat-6.5.2/src/simplex_chat/_version.py +0 -9
- simplex_chat-6.5.2/uv.lock +0 -160
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/.gitignore +0 -0
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/LICENSE +0 -0
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/README.md +0 -0
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/py.typed +0 -0
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/src/simplex_chat/types/__init__.py +0 -0
- {simplex_chat-6.5.2 → simplex_chat-7.1.0b6}/tests/test_filters.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: simplex-chat
|
|
3
|
-
Version:
|
|
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=[
|
|
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
|
|
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
|
|
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() /
|
|
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:
|
|
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(
|
|
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})
|
|
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
|
-
|
|
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
|
|
233
|
-
f"cannot switch to {backend
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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, "
|
|
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
|
|
523
|
+
prepared_link: T.CreatedConnLink,
|
|
481
524
|
) -> ConnReqType:
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
await self._maybe_sync_profile(user
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|