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/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]}")
@@ -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"]