simplex-chat 6.5.1__py3-none-any.whl
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/__init__.py +59 -0
- simplex_chat/__main__.py +35 -0
- simplex_chat/_native.py +257 -0
- simplex_chat/_version.py +9 -0
- simplex_chat/api.py +704 -0
- simplex_chat/bot.py +707 -0
- simplex_chat/core.py +200 -0
- simplex_chat/filters.py +45 -0
- simplex_chat/py.typed +0 -0
- simplex_chat/types/__init__.py +16 -0
- simplex_chat/types/_commands.py +705 -0
- simplex_chat/types/_events.py +379 -0
- simplex_chat/types/_responses.py +360 -0
- simplex_chat/types/_types.py +3506 -0
- simplex_chat/util.py +128 -0
- simplex_chat-6.5.1.dist-info/METADATA +98 -0
- simplex_chat-6.5.1.dist-info/RECORD +19 -0
- simplex_chat-6.5.1.dist-info/WHEEL +4 -0
- simplex_chat-6.5.1.dist-info/licenses/LICENSE +661 -0
simplex_chat/core.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Internal typed async wrapper around libsimplex's 8 C ABI functions.
|
|
2
|
+
|
|
3
|
+
Users interact with `Bot` / `ChatApi`. This module is exposed as
|
|
4
|
+
`simplex_chat.core` for tests and the api.ChatApi class only.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import ctypes
|
|
11
|
+
import json
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import Any, TypedDict
|
|
14
|
+
|
|
15
|
+
from . import _native
|
|
16
|
+
from .types import T, CR, CEvt
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ChatAPIError(Exception):
|
|
20
|
+
"""Raised when chat_send_cmd / chat_recv_msg_wait returns a chat error."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message: str, chat_error: T.ChatError | None = None):
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.chat_error = chat_error
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ChatInitError(Exception):
|
|
28
|
+
"""Raised when chat_migrate_init returns a DBMigrationResult error."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, db_migration_error: dict[str, Any]):
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.db_migration_error = db_migration_error
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MigrationConfirmation(StrEnum):
|
|
36
|
+
YES_UP = "yesUp"
|
|
37
|
+
YES_UP_DOWN = "yesUpDown"
|
|
38
|
+
CONSOLE = "console"
|
|
39
|
+
ERROR = "error"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CryptoArgs(TypedDict): # wire-format JSON; camelCase fields
|
|
43
|
+
fileKey: str
|
|
44
|
+
fileNonce: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_and_free(ptr: int | None) -> str:
|
|
48
|
+
"""Copy a Haskell-allocated null-terminated UTF-8 string and free its buffer.
|
|
49
|
+
|
|
50
|
+
Mirrors HandleCResult in packages/simplex-chat-nodejs/cpp/simplex.cc:157-165.
|
|
51
|
+
"""
|
|
52
|
+
if not ptr:
|
|
53
|
+
raise RuntimeError("null pointer returned from libsimplex")
|
|
54
|
+
try:
|
|
55
|
+
return ctypes.string_at(ptr).decode("utf-8")
|
|
56
|
+
finally:
|
|
57
|
+
_native.libc().free(ctypes.c_void_p(ptr))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def chat_send_cmd(ctrl: int, cmd: str) -> CR.ChatResponse:
|
|
61
|
+
def _call() -> str:
|
|
62
|
+
ptr = _native.lib().chat_send_cmd(ctrl, cmd.encode("utf-8"))
|
|
63
|
+
return _read_and_free(ptr)
|
|
64
|
+
|
|
65
|
+
raw = await asyncio.to_thread(_call)
|
|
66
|
+
parsed = json.loads(raw)
|
|
67
|
+
if "result" in parsed and isinstance(parsed["result"], dict):
|
|
68
|
+
return parsed["result"] # type: ignore[return-value]
|
|
69
|
+
err = parsed.get("error")
|
|
70
|
+
if isinstance(err, dict):
|
|
71
|
+
raise ChatAPIError(f"chat command error: {err.get('type')}", err) # type: ignore[arg-type]
|
|
72
|
+
raise ChatAPIError(f"invalid chat command result: {raw[:200]}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def chat_recv_msg_wait(ctrl: int, wait_us: int = 500_000) -> CEvt.ChatEvent | None:
|
|
76
|
+
def _call() -> str:
|
|
77
|
+
# On timeout, the C side returns a non-NULL pointer to a single NUL byte
|
|
78
|
+
# (see Mobile.hs `fromMaybe ""`), so `_read_and_free` returns "" — no
|
|
79
|
+
# NULL-pointer guard is needed here.
|
|
80
|
+
ptr = _native.lib().chat_recv_msg_wait(ctrl, wait_us)
|
|
81
|
+
return _read_and_free(ptr)
|
|
82
|
+
|
|
83
|
+
raw = await asyncio.to_thread(_call)
|
|
84
|
+
if not raw:
|
|
85
|
+
return None
|
|
86
|
+
parsed = json.loads(raw)
|
|
87
|
+
if "result" in parsed and isinstance(parsed["result"], dict):
|
|
88
|
+
return parsed["result"] # type: ignore[return-value]
|
|
89
|
+
err = parsed.get("error")
|
|
90
|
+
if isinstance(err, dict):
|
|
91
|
+
raise ChatAPIError(f"chat event error: {err.get('type')}", err) # type: ignore[arg-type]
|
|
92
|
+
raise ChatAPIError(f"invalid chat event: {raw[:200]}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def chat_migrate_init(db_path: str, db_key: str, confirm: MigrationConfirmation) -> int:
|
|
96
|
+
"""Initialize chat controller. Returns opaque ctrl pointer as Python int."""
|
|
97
|
+
|
|
98
|
+
def _call() -> tuple[int, str]:
|
|
99
|
+
ctrl = ctypes.c_void_p()
|
|
100
|
+
ptr = _native.lib().chat_migrate_init(
|
|
101
|
+
db_path.encode("utf-8"),
|
|
102
|
+
db_key.encode("utf-8"),
|
|
103
|
+
confirm.encode("utf-8"),
|
|
104
|
+
ctypes.byref(ctrl),
|
|
105
|
+
)
|
|
106
|
+
return (ctrl.value or 0, _read_and_free(ptr))
|
|
107
|
+
|
|
108
|
+
ctrl_val, raw = await asyncio.to_thread(_call)
|
|
109
|
+
parsed = json.loads(raw)
|
|
110
|
+
if parsed.get("type") == "ok":
|
|
111
|
+
if not ctrl_val:
|
|
112
|
+
# ABI invariant: type=="ok" → out-param written. Defensive guard so a
|
|
113
|
+
# broken libsimplex doesn't hand us a NULL controller that would only
|
|
114
|
+
# crash on first use much later.
|
|
115
|
+
raise RuntimeError("chat_migrate_init returned ok but did not set ctrl pointer")
|
|
116
|
+
return ctrl_val
|
|
117
|
+
raise ChatInitError(
|
|
118
|
+
"Database or migration error (see db_migration_error)",
|
|
119
|
+
parsed,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def chat_close_store(ctrl: int) -> None:
|
|
124
|
+
def _call() -> str:
|
|
125
|
+
ptr = _native.lib().chat_close_store(ctrl)
|
|
126
|
+
return _read_and_free(ptr)
|
|
127
|
+
|
|
128
|
+
res = await asyncio.to_thread(_call)
|
|
129
|
+
if res:
|
|
130
|
+
raise RuntimeError(res)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def chat_write_file(ctrl: int, path: str, data: bytes) -> CryptoArgs:
|
|
134
|
+
def _call() -> str:
|
|
135
|
+
ptr = _native.lib().chat_write_file(ctrl, path.encode("utf-8"), data, len(data))
|
|
136
|
+
return _read_and_free(ptr)
|
|
137
|
+
|
|
138
|
+
raw = await asyncio.to_thread(_call)
|
|
139
|
+
return _crypto_args_result(raw)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def chat_read_file(path: str, args: CryptoArgs) -> bytes:
|
|
143
|
+
def _call() -> bytes:
|
|
144
|
+
ptr = _native.lib().chat_read_file(
|
|
145
|
+
path.encode("utf-8"),
|
|
146
|
+
args["fileKey"].encode("utf-8"),
|
|
147
|
+
args["fileNonce"].encode("utf-8"),
|
|
148
|
+
)
|
|
149
|
+
if not ptr:
|
|
150
|
+
raise RuntimeError("chat_read_file returned null")
|
|
151
|
+
addr = ctypes.cast(ptr, ctypes.c_void_p).value
|
|
152
|
+
assert addr is not None # `if not ptr` above already filtered NULL
|
|
153
|
+
try:
|
|
154
|
+
status = ctypes.cast(addr, ctypes.POINTER(ctypes.c_uint8))[0]
|
|
155
|
+
if status == 1:
|
|
156
|
+
msg = ctypes.string_at(addr + 1).decode("utf-8")
|
|
157
|
+
raise RuntimeError(msg)
|
|
158
|
+
if status != 0:
|
|
159
|
+
raise RuntimeError(f"unexpected status {status} from chat_read_file")
|
|
160
|
+
# `addr + 1` is unaligned for a uint32 read. On the supported platforms
|
|
161
|
+
# (linux-x86_64, linux-aarch64, macos-aarch64, windows-x86_64) this is
|
|
162
|
+
# silently handled; matches the Node.js binding (cpp/simplex.cc:344).
|
|
163
|
+
length = ctypes.cast(addr + 1, ctypes.POINTER(ctypes.c_uint32))[0]
|
|
164
|
+
return ctypes.string_at(addr + 5, length)
|
|
165
|
+
finally:
|
|
166
|
+
_native.libc().free(ctypes.c_void_p(addr))
|
|
167
|
+
|
|
168
|
+
return await asyncio.to_thread(_call)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def chat_encrypt_file(ctrl: int, src: str, dst: str) -> CryptoArgs:
|
|
172
|
+
def _call() -> str:
|
|
173
|
+
ptr = _native.lib().chat_encrypt_file(ctrl, src.encode("utf-8"), dst.encode("utf-8"))
|
|
174
|
+
return _read_and_free(ptr)
|
|
175
|
+
|
|
176
|
+
return _crypto_args_result(await asyncio.to_thread(_call))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def chat_decrypt_file(src: str, args: CryptoArgs, dst: str) -> None:
|
|
180
|
+
def _call() -> str:
|
|
181
|
+
ptr = _native.lib().chat_decrypt_file(
|
|
182
|
+
src.encode("utf-8"),
|
|
183
|
+
args["fileKey"].encode("utf-8"),
|
|
184
|
+
args["fileNonce"].encode("utf-8"),
|
|
185
|
+
dst.encode("utf-8"),
|
|
186
|
+
)
|
|
187
|
+
return _read_and_free(ptr)
|
|
188
|
+
|
|
189
|
+
res = await asyncio.to_thread(_call)
|
|
190
|
+
if res:
|
|
191
|
+
raise RuntimeError(res)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _crypto_args_result(raw: str) -> CryptoArgs:
|
|
195
|
+
parsed = json.loads(raw)
|
|
196
|
+
if parsed.get("type") == "result":
|
|
197
|
+
return parsed["cryptoArgs"]
|
|
198
|
+
if parsed.get("type") == "error":
|
|
199
|
+
raise RuntimeError(parsed.get("writeError", "unknown write error"))
|
|
200
|
+
raise RuntimeError(f"unexpected result: {raw[:200]}")
|
simplex_chat/filters.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Compile kwarg-based message filters into a single predicate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compile_message_filter(kw: dict[str, Any]) -> Callable[[Any], bool]:
|
|
10
|
+
"""Compile filter kwargs into a single predicate function.
|
|
11
|
+
|
|
12
|
+
Multiple kwargs combine with AND; tuples within a kwarg combine with OR.
|
|
13
|
+
`when` is the last predicate evaluated.
|
|
14
|
+
"""
|
|
15
|
+
predicates: list[Callable[[Any], bool]] = []
|
|
16
|
+
|
|
17
|
+
if (ct := kw.get("content_type")) is not None:
|
|
18
|
+
ct_set = (ct,) if isinstance(ct, str) else tuple(ct)
|
|
19
|
+
predicates.append(lambda m: m.content.get("type") in ct_set)
|
|
20
|
+
|
|
21
|
+
if (t := kw.get("text")) is not None:
|
|
22
|
+
if isinstance(t, re.Pattern):
|
|
23
|
+
predicates.append(lambda m: bool(t.search(m.content.get("text", "") or "")))
|
|
24
|
+
else:
|
|
25
|
+
predicates.append(lambda m: m.content.get("text") == t)
|
|
26
|
+
|
|
27
|
+
if (cht := kw.get("chat_type")) is not None:
|
|
28
|
+
cht_set = (cht,) if isinstance(cht, str) else tuple(cht)
|
|
29
|
+
predicates.append(lambda m: m.chat_item["chatInfo"]["type"] in cht_set)
|
|
30
|
+
|
|
31
|
+
if (gid := kw.get("group_id")) is not None:
|
|
32
|
+
gid_set: tuple[int, ...] = (gid,) if isinstance(gid, int) else tuple(gid)
|
|
33
|
+
|
|
34
|
+
def gid_match(m: Any) -> bool:
|
|
35
|
+
ci = m.chat_item["chatInfo"]
|
|
36
|
+
return ci["type"] == "group" and ci["groupInfo"]["groupId"] in gid_set
|
|
37
|
+
|
|
38
|
+
predicates.append(gid_match)
|
|
39
|
+
|
|
40
|
+
if (when := kw.get("when")) is not None:
|
|
41
|
+
predicates.append(when)
|
|
42
|
+
|
|
43
|
+
if not predicates:
|
|
44
|
+
return lambda _m: True
|
|
45
|
+
return lambda m: all(p(m) for p in predicates)
|
simplex_chat/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""SimpleX Chat wire types — auto-generated from Haskell.
|
|
2
|
+
|
|
3
|
+
Re-exports the four generated modules as namespaces:
|
|
4
|
+
|
|
5
|
+
- ``T`` — :mod:`._types` (records, enums, discriminated unions)
|
|
6
|
+
- ``CC`` — :mod:`._commands` (command TypedDicts + ``<Cmd>_cmd_string`` helpers)
|
|
7
|
+
- ``CR`` — :mod:`._responses` (``ChatResponse`` and member TypedDicts)
|
|
8
|
+
- ``CEvt`` — :mod:`._events` (``ChatEvent`` and member TypedDicts)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from . import _commands as CC
|
|
12
|
+
from . import _events as CEvt
|
|
13
|
+
from . import _responses as CR
|
|
14
|
+
from . import _types as T
|
|
15
|
+
|
|
16
|
+
__all__ = ["T", "CC", "CR", "CEvt"]
|