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/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""SimpleX Chat — Python client library for chat bots."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .api import ChatApi, ChatCommandError, ConnReqType, Db, PostgresDb, SqliteDb
|
|
5
|
+
from .bot import (
|
|
6
|
+
Bot,
|
|
7
|
+
BotCommand,
|
|
8
|
+
BotProfile,
|
|
9
|
+
ChatMessage,
|
|
10
|
+
CommandHandler,
|
|
11
|
+
EventHandler,
|
|
12
|
+
FileMessage,
|
|
13
|
+
ImageMessage,
|
|
14
|
+
LinkMessage,
|
|
15
|
+
Message,
|
|
16
|
+
MessageHandler,
|
|
17
|
+
Middleware,
|
|
18
|
+
ParsedCommand,
|
|
19
|
+
ReportMessage,
|
|
20
|
+
TextMessage,
|
|
21
|
+
UnknownMessage,
|
|
22
|
+
VideoMessage,
|
|
23
|
+
VoiceMessage,
|
|
24
|
+
)
|
|
25
|
+
from .core import ChatAPIError, ChatInitError, CryptoArgs, MigrationConfirmation
|
|
26
|
+
from . import util as util # re-export the util namespace
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"__version__",
|
|
30
|
+
"Bot",
|
|
31
|
+
"BotCommand",
|
|
32
|
+
"BotProfile",
|
|
33
|
+
"ChatAPIError",
|
|
34
|
+
"ChatApi",
|
|
35
|
+
"ChatCommandError",
|
|
36
|
+
"ChatInitError",
|
|
37
|
+
"ChatMessage",
|
|
38
|
+
"CommandHandler",
|
|
39
|
+
"ConnReqType",
|
|
40
|
+
"CryptoArgs",
|
|
41
|
+
"Db",
|
|
42
|
+
"EventHandler",
|
|
43
|
+
"FileMessage",
|
|
44
|
+
"ImageMessage",
|
|
45
|
+
"LinkMessage",
|
|
46
|
+
"Message",
|
|
47
|
+
"MessageHandler",
|
|
48
|
+
"Middleware",
|
|
49
|
+
"MigrationConfirmation",
|
|
50
|
+
"ParsedCommand",
|
|
51
|
+
"PostgresDb",
|
|
52
|
+
"ReportMessage",
|
|
53
|
+
"SqliteDb",
|
|
54
|
+
"TextMessage",
|
|
55
|
+
"UnknownMessage",
|
|
56
|
+
"VideoMessage",
|
|
57
|
+
"VoiceMessage",
|
|
58
|
+
"util",
|
|
59
|
+
]
|
simplex_chat/__main__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""CLI: ``python -m simplex_chat install [--backend=sqlite|postgres]``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import _native
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: list[str] | None = None) -> int:
|
|
12
|
+
p = argparse.ArgumentParser(prog="simplex_chat")
|
|
13
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
14
|
+
install = sub.add_parser("install", help="Pre-fetch libsimplex into the user cache")
|
|
15
|
+
install.add_argument(
|
|
16
|
+
"--backend",
|
|
17
|
+
choices=["sqlite", "postgres"],
|
|
18
|
+
default="sqlite",
|
|
19
|
+
help="which libsimplex variant to download (default: sqlite)",
|
|
20
|
+
)
|
|
21
|
+
args = p.parse_args(argv)
|
|
22
|
+
# `args.command` is always set: `add_subparsers(required=True)` makes
|
|
23
|
+
# argparse exit before reaching this point if no subcommand is given.
|
|
24
|
+
assert args.command == "install"
|
|
25
|
+
try:
|
|
26
|
+
path = _native._resolve_libs_dir(args.backend)
|
|
27
|
+
print(f"libsimplex installed at: {path}")
|
|
28
|
+
return 0
|
|
29
|
+
except Exception as e:
|
|
30
|
+
print(f"install failed: {e}", file=sys.stderr)
|
|
31
|
+
return 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
sys.exit(main())
|
simplex_chat/_native.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Native libsimplex loader: platform detection, lazy download, ctypes setup.
|
|
2
|
+
|
|
3
|
+
Internal — users interact with `Bot` / `ChatApi`, never with this module.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ctypes
|
|
9
|
+
import errno
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import threading
|
|
15
|
+
import urllib.request
|
|
16
|
+
import zipfile
|
|
17
|
+
from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
from ._version import LIBS_VERSION
|
|
22
|
+
|
|
23
|
+
Backend = Literal["sqlite", "postgres"]
|
|
24
|
+
|
|
25
|
+
_GITHUB_REPO = "simplex-chat/simplex-chat-libs"
|
|
26
|
+
|
|
27
|
+
_PLATFORM_MAP = {
|
|
28
|
+
"linux": ("linux", {"x86_64": "x86_64", "aarch64": "aarch64"}),
|
|
29
|
+
"darwin": ("macos", {"x86_64": "x86_64", "arm64": "aarch64"}),
|
|
30
|
+
"win32": ("windows", {"AMD64": "x86_64", "x86_64": "x86_64"}),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_LIBNAME = {"linux": "libsimplex.so", "darwin": "libsimplex.dylib", "win32": "libsimplex.dll"}
|
|
34
|
+
|
|
35
|
+
SUPPORTED = (
|
|
36
|
+
"linux-x86_64",
|
|
37
|
+
"linux-aarch64",
|
|
38
|
+
"macos-x86_64",
|
|
39
|
+
"macos-aarch64",
|
|
40
|
+
"windows-x86_64",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _platform_tag() -> str:
|
|
45
|
+
info = _PLATFORM_MAP.get(sys.platform)
|
|
46
|
+
if not info:
|
|
47
|
+
raise RuntimeError(f"Unsupported platform: {sys.platform}")
|
|
48
|
+
sysname, archs = info
|
|
49
|
+
arch = archs.get(platform.machine())
|
|
50
|
+
if not arch:
|
|
51
|
+
raise RuntimeError(f"Unsupported architecture: {sys.platform}/{platform.machine()}")
|
|
52
|
+
tag = f"{sysname}-{arch}"
|
|
53
|
+
if tag not in SUPPORTED:
|
|
54
|
+
raise RuntimeError(f"Unsupported combination: {tag}; supported: {SUPPORTED}")
|
|
55
|
+
return tag
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _libname() -> str:
|
|
59
|
+
return _LIBNAME[sys.platform]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _libs_url(backend: Backend) -> str:
|
|
63
|
+
suffix = "-postgres" if backend == "postgres" else ""
|
|
64
|
+
return (
|
|
65
|
+
f"https://github.com/{_GITHUB_REPO}/releases/download/"
|
|
66
|
+
f"v{LIBS_VERSION}/simplex-chat-libs-{_platform_tag()}{suffix}.zip"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cache_root() -> Path:
|
|
71
|
+
if sys.platform == "darwin":
|
|
72
|
+
return Path.home() / "Library" / "Caches" / "simplex-chat"
|
|
73
|
+
if sys.platform == "win32":
|
|
74
|
+
return Path(os.environ["LOCALAPPDATA"]) / "simplex-chat"
|
|
75
|
+
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
76
|
+
return Path(base) / "simplex-chat"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_libs_dir(backend: Backend) -> Path:
|
|
80
|
+
if override := os.environ.get("SIMPLEX_LIBS_DIR"):
|
|
81
|
+
return Path(override)
|
|
82
|
+
if backend == "postgres" and _platform_tag() != "linux-x86_64":
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
"postgres backend is only supported on linux-x86_64; "
|
|
85
|
+
f"current platform is {_platform_tag()}"
|
|
86
|
+
)
|
|
87
|
+
target = _cache_root() / f"v{LIBS_VERSION}" / backend
|
|
88
|
+
if not (target / _libname()).exists():
|
|
89
|
+
_download(target, backend)
|
|
90
|
+
return target
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_DOWNLOAD_CHUNK = 1 << 16 # 64 KiB
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _stream_to_file(url: str, dest: Path, *, timeout: float = 60.0) -> None:
|
|
97
|
+
"""Stream `url` → `dest`, printing a carriage-return progress bar.
|
|
98
|
+
|
|
99
|
+
`timeout` is per-request; we don't touch `socket.setdefaulttimeout`
|
|
100
|
+
so other socket users in the same process aren't affected.
|
|
101
|
+
"""
|
|
102
|
+
with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310 - https://github.com/...
|
|
103
|
+
total = int(resp.headers.get("Content-Length") or 0)
|
|
104
|
+
received = 0
|
|
105
|
+
with dest.open("wb") as out:
|
|
106
|
+
while chunk := resp.read(_DOWNLOAD_CHUNK):
|
|
107
|
+
out.write(chunk)
|
|
108
|
+
received += len(chunk)
|
|
109
|
+
if total > 0:
|
|
110
|
+
pct = min(100, received * 100 // total)
|
|
111
|
+
msg = f"\r download: {received >> 20} / {total >> 20} MiB ({pct}%)"
|
|
112
|
+
else:
|
|
113
|
+
msg = f"\r download: {received >> 20} MiB"
|
|
114
|
+
print(msg, end="", file=sys.stderr, flush=True)
|
|
115
|
+
print("", file=sys.stderr, flush=True) # newline after final progress line
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _download(target: Path, backend: Backend) -> None:
|
|
119
|
+
"""Download libs zip → atomic rename into `target`. Concurrent processes safe.
|
|
120
|
+
|
|
121
|
+
Atomicity strategy: each process extracts to its own sibling tempdir on the same
|
|
122
|
+
filesystem, then `os.rename` the `libs/` subdir to `target`. POSIX `os.rename`
|
|
123
|
+
onto a NON-EXISTENT path is atomic; if the target exists (another process won
|
|
124
|
+
the race), `os.rename` fails on most platforms — we then verify the winner has
|
|
125
|
+
what we need and proceed. NEVER rmtree the target: that creates a TOCTOU
|
|
126
|
+
window where another process is reading/loading the file we're deleting.
|
|
127
|
+
"""
|
|
128
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
url = _libs_url(backend)
|
|
130
|
+
print(
|
|
131
|
+
f"Downloading libsimplex ({_platform_tag()}, {backend}) v{LIBS_VERSION} from {url} ...",
|
|
132
|
+
file=sys.stderr,
|
|
133
|
+
flush=True,
|
|
134
|
+
)
|
|
135
|
+
with tempfile.TemporaryDirectory(dir=target.parent) as tmp:
|
|
136
|
+
zip_path = Path(tmp) / "libs.zip"
|
|
137
|
+
_stream_to_file(url, zip_path, timeout=60.0)
|
|
138
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
139
|
+
zf.extractall(tmp)
|
|
140
|
+
# zip layout: <tmp>/libs/libsimplex.* + libHS*.*
|
|
141
|
+
extracted_libs = Path(tmp) / "libs"
|
|
142
|
+
if not extracted_libs.is_dir():
|
|
143
|
+
raise RuntimeError(f"libs/ missing from {_libs_url(backend)}")
|
|
144
|
+
try:
|
|
145
|
+
os.rename(extracted_libs, target)
|
|
146
|
+
except OSError as e:
|
|
147
|
+
# EEXIST / ENOTEMPTY mean another process won the race — fall through
|
|
148
|
+
# and check that the winner left a usable libsimplex behind. Anything
|
|
149
|
+
# else (ENOSPC, EACCES, EROFS, Windows codes mapped to None) is a real
|
|
150
|
+
# failure and must propagate. Same VERSION cached → same content →
|
|
151
|
+
# safe to proceed once we've confirmed the file is there.
|
|
152
|
+
if e.errno not in (errno.EEXIST, errno.ENOTEMPTY):
|
|
153
|
+
raise
|
|
154
|
+
if not (target / _libname()).exists():
|
|
155
|
+
raise RuntimeError(
|
|
156
|
+
f"another process partially populated {target} but libsimplex "
|
|
157
|
+
f"is missing; remove the directory manually and retry"
|
|
158
|
+
) from e
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_lock = threading.Lock()
|
|
162
|
+
_lib: ctypes.CDLL | None = None
|
|
163
|
+
_libc: ctypes.CDLL | None = None
|
|
164
|
+
_backend: Backend | None = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _load_libc() -> ctypes.CDLL:
|
|
168
|
+
if sys.platform == "win32":
|
|
169
|
+
return ctypes.CDLL("msvcrt")
|
|
170
|
+
return ctypes.CDLL(None) # libc on POSIX is the process's own symbol table
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _setup_signatures(lib: ctypes.CDLL) -> None:
|
|
174
|
+
"""Declare argtypes/restype for the 8 chat_* functions exported by libsimplex.
|
|
175
|
+
|
|
176
|
+
All result strings come back as raw c_void_p so the caller can free them
|
|
177
|
+
after copying — matches HandleCResult in cpp/simplex.cc:157-165.
|
|
178
|
+
"""
|
|
179
|
+
lib.chat_migrate_init.argtypes = [c_char_p, c_char_p, c_char_p, POINTER(c_void_p)]
|
|
180
|
+
lib.chat_migrate_init.restype = c_void_p
|
|
181
|
+
lib.chat_close_store.argtypes = [c_void_p]
|
|
182
|
+
lib.chat_close_store.restype = c_void_p
|
|
183
|
+
lib.chat_send_cmd.argtypes = [c_void_p, c_char_p]
|
|
184
|
+
lib.chat_send_cmd.restype = c_void_p
|
|
185
|
+
lib.chat_recv_msg_wait.argtypes = [c_void_p, c_int]
|
|
186
|
+
lib.chat_recv_msg_wait.restype = c_void_p
|
|
187
|
+
# chat_write_file's payload is treated read-only by libsimplex; passing
|
|
188
|
+
# `bytes` via c_char_p avoids the from_buffer_copy doubling. ctypes pins
|
|
189
|
+
# the bytes buffer for the duration of the call.
|
|
190
|
+
lib.chat_write_file.argtypes = [c_void_p, c_char_p, c_char_p, c_int]
|
|
191
|
+
lib.chat_write_file.restype = c_void_p
|
|
192
|
+
lib.chat_read_file.argtypes = [c_char_p, c_char_p, c_char_p]
|
|
193
|
+
lib.chat_read_file.restype = POINTER(c_uint8)
|
|
194
|
+
lib.chat_encrypt_file.argtypes = [c_void_p, c_char_p, c_char_p]
|
|
195
|
+
lib.chat_encrypt_file.restype = c_void_p
|
|
196
|
+
lib.chat_decrypt_file.argtypes = [c_char_p, c_char_p, c_char_p, c_char_p]
|
|
197
|
+
lib.chat_decrypt_file.restype = c_void_p
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _hs_init(lib: ctypes.CDLL) -> None:
|
|
201
|
+
"""Initialize the Haskell runtime exactly once. Mirrors cpp/simplex.cc:13-32."""
|
|
202
|
+
if sys.platform == "win32":
|
|
203
|
+
argv_strs = [b"simplex", b"+RTS", b"-A64m", b"-H64m", b"--install-signal-handlers=no"]
|
|
204
|
+
else:
|
|
205
|
+
argv_strs = [
|
|
206
|
+
b"simplex",
|
|
207
|
+
b"+RTS",
|
|
208
|
+
b"-A64m",
|
|
209
|
+
b"-H64m",
|
|
210
|
+
b"-xn",
|
|
211
|
+
b"--install-signal-handlers=no",
|
|
212
|
+
]
|
|
213
|
+
argc = c_int(len(argv_strs))
|
|
214
|
+
arr = (c_char_p * (len(argv_strs) + 1))(*argv_strs, None)
|
|
215
|
+
arr_ptr = ctypes.byref(ctypes.cast(arr, POINTER(c_char_p)))
|
|
216
|
+
lib.hs_init_with_rtsopts.argtypes = [POINTER(c_int), POINTER(POINTER(c_char_p))]
|
|
217
|
+
lib.hs_init_with_rtsopts.restype = None
|
|
218
|
+
lib.hs_init_with_rtsopts(ctypes.byref(argc), arr_ptr)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def lib_for(backend: Backend) -> ctypes.CDLL:
|
|
222
|
+
"""Resolve, load, and initialize libsimplex for the given backend.
|
|
223
|
+
|
|
224
|
+
Idempotent for the same backend; raises if called with a different backend.
|
|
225
|
+
Concurrent calls serialize on the module-level lock.
|
|
226
|
+
"""
|
|
227
|
+
global _lib, _libc, _backend
|
|
228
|
+
with _lock:
|
|
229
|
+
if _lib is not None:
|
|
230
|
+
if _backend != backend:
|
|
231
|
+
raise RuntimeError(
|
|
232
|
+
f"libsimplex already loaded with backend={_backend!r}; "
|
|
233
|
+
f"cannot switch to {backend!r} in the same process"
|
|
234
|
+
)
|
|
235
|
+
return _lib
|
|
236
|
+
libs_dir = _resolve_libs_dir(backend)
|
|
237
|
+
lib = ctypes.CDLL(str(libs_dir / _libname()))
|
|
238
|
+
_setup_signatures(lib)
|
|
239
|
+
_hs_init(lib)
|
|
240
|
+
_libc = _load_libc()
|
|
241
|
+
_lib = lib
|
|
242
|
+
_backend = backend
|
|
243
|
+
return lib
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def libc() -> ctypes.CDLL:
|
|
247
|
+
"""libc — needed by `core` to free Haskell-allocated result strings."""
|
|
248
|
+
if _libc is None:
|
|
249
|
+
raise RuntimeError("lib_for() must be called before libc()")
|
|
250
|
+
return _libc
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def lib() -> ctypes.CDLL:
|
|
254
|
+
"""Loaded libsimplex handle. Raises if `lib_for()` has not been called."""
|
|
255
|
+
if _lib is None:
|
|
256
|
+
raise RuntimeError("lib_for() must be called before lib()")
|
|
257
|
+
return _lib
|
simplex_chat/_version.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Single source of truth for both the Python package version and the
|
|
2
|
+
simplex-chat-libs release tag we depend on.
|
|
3
|
+
|
|
4
|
+
Bump both together for normal releases. For wrapper-only fixes use a PEP 440
|
|
5
|
+
post-release: __version__ = "6.5.1.post1", LIBS_VERSION unchanged.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "6.5.1" # PEP 440 — read by hatchling for wheel metadata
|
|
9
|
+
LIBS_VERSION = "6.5.1" # simplex-chat-libs release tag (no 'v' prefix)
|