mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 20:08:34 +00:00
Merge branch 'master' into f/directory-integration-plan
This commit is contained in:
@@ -16,7 +16,7 @@ import urllib.request
|
||||
import zipfile
|
||||
from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from ._version import LIBS_VERSION
|
||||
|
||||
@@ -166,7 +166,8 @@ _backend: Backend | None = None
|
||||
|
||||
def _load_libc() -> ctypes.CDLL:
|
||||
if sys.platform == "win32":
|
||||
return ctypes.CDLL("msvcrt")
|
||||
# libsimplex.dll allocates results with UCRT malloc; msvcrt free would corrupt the heap.
|
||||
return ctypes.CDLL("ucrtbase")
|
||||
return ctypes.CDLL(None) # libc on POSIX is the process's own symbol table
|
||||
|
||||
|
||||
@@ -255,3 +256,19 @@ def lib() -> ctypes.CDLL:
|
||||
if _lib is None:
|
||||
raise RuntimeError("lib_for() must be called before lib()")
|
||||
return _lib
|
||||
|
||||
|
||||
QUEUE_SIZE_UNSUPPORTED = (
|
||||
"loaded libsimplex does not export chat_migrate_init_queue; queue size needs a newer libsimplex"
|
||||
)
|
||||
|
||||
|
||||
def migrate_init_queue() -> Any:
|
||||
"""`chat_migrate_init_queue`, which older libsimplex releases do not export."""
|
||||
try:
|
||||
fn = lib().chat_migrate_init_queue
|
||||
except AttributeError as e:
|
||||
raise RuntimeError(QUEUE_SIZE_UNSUPPORTED) from e
|
||||
fn.argtypes = [c_char_p, c_char_p, c_char_p, c_int, POINTER(c_void_p)]
|
||||
fn.restype = c_void_p
|
||||
return fn
|
||||
|
||||
@@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440
|
||||
post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged.
|
||||
"""
|
||||
|
||||
__version__ = "7.1.0b3" # PEP 440 — read by hatchling for wheel metadata
|
||||
LIBS_VERSION = "7.1.0-beta.3" # simplex-chat-libs release tag (no 'v' prefix)
|
||||
__version__ = "7.1.0b4" # PEP 440 — read by hatchling for wheel metadata
|
||||
LIBS_VERSION = "7.1.0-beta.4" # simplex-chat-libs release tag (no 'v' prefix)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -65,17 +67,20 @@ class ChatApi:
|
||||
def __init__(self, ctrl: int):
|
||||
self._ctrl: int | None = ctrl
|
||||
self._started = False
|
||||
self._recv_executor: ThreadPoolExecutor | None = None
|
||||
|
||||
@classmethod
|
||||
async def init(
|
||||
cls,
|
||||
db: Db,
|
||||
confirm: MigrationConfirmation = MigrationConfirmation.YES_UP,
|
||||
queue_size: int | None = None,
|
||||
) -> ChatApi:
|
||||
path_or_prefix, key_or_conn, backend = _db_to_migrate_args(db)
|
||||
# Trigger lazy lib load with the right backend BEFORE chat_migrate_init.
|
||||
_native.lib_for(backend)
|
||||
ctrl = await core.chat_migrate_init(path_or_prefix, key_or_conn, confirm)
|
||||
# It may download ~100 MB, so it must not block the event loop.
|
||||
await asyncio.to_thread(_native.lib_for, backend)
|
||||
ctrl = await core.chat_migrate_init(path_or_prefix, key_or_conn, confirm, queue_size)
|
||||
return cls(ctrl)
|
||||
|
||||
@property
|
||||
@@ -114,6 +119,14 @@ class ChatApi:
|
||||
self._started = False
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Stop the chat and close its store; the store stays open if stopping fails."""
|
||||
# a running controller keeps using the database connections that closing frees
|
||||
await self.stop_chat()
|
||||
if self._recv_executor is not None:
|
||||
# Waits for a receive already in flight (up to wait_us) so the store
|
||||
# never closes underneath one; run off-loop since shutdown blocks.
|
||||
await asyncio.to_thread(self._recv_executor.shutdown, wait=True)
|
||||
self._recv_executor = None
|
||||
await core.chat_close_store(self.ctrl)
|
||||
self._ctrl = None
|
||||
self._started = False
|
||||
@@ -122,7 +135,14 @@ class ChatApi:
|
||||
return await core.chat_send_cmd(self.ctrl, cmd)
|
||||
|
||||
async def recv_chat_event(self, wait_us: int = 500_000) -> CEvt.ChatEvent | None:
|
||||
return await core.chat_recv_msg_wait(self.ctrl, wait_us)
|
||||
ctrl = self.ctrl # raises before touching the executor if close() was called
|
||||
if self._recv_executor is None:
|
||||
# A receive blocks for up to wait_us almost back to back, so it would
|
||||
# otherwise pin one of the default executor's few worker threads.
|
||||
self._recv_executor = ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix="simplex-recv"
|
||||
)
|
||||
return await core.chat_recv_msg_wait(ctrl, wait_us, self._recv_executor)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Address commands
|
||||
@@ -158,6 +178,8 @@ class ChatApi:
|
||||
)
|
||||
if r["type"] == "userProfileUpdated":
|
||||
return r["updateSummary"]
|
||||
if r["type"] == "userProfileNoChange":
|
||||
return {"updateSuccesses": 0, "updateFailures": 0, "changedContacts": []}
|
||||
raise ChatCommandError("error setting profile address", r)
|
||||
|
||||
async def api_set_address_settings(self, user_id: int, settings: T.AddressSettings) -> None:
|
||||
@@ -236,6 +258,8 @@ class ChatApi:
|
||||
)
|
||||
if r["type"] == "chatItemUpdated":
|
||||
return r["chatItem"]["chatItem"]
|
||||
if r["type"] == "chatItemNotChanged":
|
||||
return r["chatItem"]["chatItem"]
|
||||
raise ChatCommandError("error updating chat item", r)
|
||||
|
||||
async def api_delete_chat_items(
|
||||
@@ -302,6 +326,8 @@ class ChatApi:
|
||||
)
|
||||
if r["type"] == "rcvFileAccepted":
|
||||
return r["chatItem"]
|
||||
if r["type"] == "rcvFileAcceptedSndCancelled":
|
||||
raise ChatCommandError("file cancelled by sender", r)
|
||||
raise ChatCommandError("error receiving file", r)
|
||||
|
||||
async def api_cancel_file(self, file_id: int) -> None:
|
||||
@@ -477,12 +503,13 @@ class ChatApi:
|
||||
self,
|
||||
user_id: int,
|
||||
incognito: bool,
|
||||
prepared_link: T.CreatedConnLink | None = None,
|
||||
prepared_link: T.CreatedConnLink,
|
||||
) -> ConnReqType:
|
||||
args: CC.APIConnect = {"userId": user_id, "incognito": incognito}
|
||||
if prepared_link is not None:
|
||||
args["preparedLink_"] = prepared_link
|
||||
r = await self.send_chat_cmd(CC.APIConnect_cmd_string(args))
|
||||
r = await self.send_chat_cmd(
|
||||
CC.APIConnect_cmd_string(
|
||||
{"userId": user_id, "incognito": incognito, "preparedLink_": prepared_link}
|
||||
)
|
||||
)
|
||||
return self._handle_connect_result(r)
|
||||
|
||||
async def api_connect_active_user(self, conn_link: str) -> ConnReqType:
|
||||
|
||||
@@ -90,6 +90,7 @@ class Bot(Client):
|
||||
welcome: str | T.MsgContent | None = None,
|
||||
commands: list[BotCommand] | None = None,
|
||||
confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP,
|
||||
queue_size: int | None = None,
|
||||
create_address: bool = True,
|
||||
update_address: bool = True,
|
||||
update_profile: bool = True,
|
||||
@@ -103,6 +104,7 @@ class Bot(Client):
|
||||
profile=profile,
|
||||
db=db,
|
||||
confirm_migrations=confirm_migrations,
|
||||
queue_size=queue_size,
|
||||
update_profile=update_profile,
|
||||
log_contacts=log_contacts,
|
||||
log_network=log_network,
|
||||
|
||||
@@ -149,6 +149,7 @@ class Client:
|
||||
profile: Profile,
|
||||
db: Db,
|
||||
confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP,
|
||||
queue_size: int | None = None,
|
||||
update_profile: bool = True,
|
||||
log_contacts: bool = False,
|
||||
log_network: bool = False,
|
||||
@@ -156,6 +157,7 @@ class Client:
|
||||
self._profile = profile
|
||||
self._db = db
|
||||
self._confirm_migrations = confirm_migrations
|
||||
self._queue_size = queue_size
|
||||
self._update_profile = update_profile
|
||||
self._log_contacts = log_contacts
|
||||
self._log_network = log_network
|
||||
@@ -343,7 +345,7 @@ class Client:
|
||||
# do post-start setup (profile sync; Bot adds address sync).
|
||||
# `_stop_event` is never cleared: a stop requested during startup has
|
||||
# to survive into the receive loop. A stopped client is spent.
|
||||
self._api = await ChatApi.init(self._db, self._confirm_migrations)
|
||||
self._api = await ChatApi.init(self._db, self._confirm_migrations, self._queue_size)
|
||||
try:
|
||||
user = await self._ensure_active_user()
|
||||
await self._api.start_chat()
|
||||
@@ -362,11 +364,6 @@ class Client:
|
||||
api = self._api
|
||||
if api is None:
|
||||
return
|
||||
if api.started:
|
||||
try:
|
||||
await api.stop_chat()
|
||||
except Exception:
|
||||
log.exception("stop_chat failed during init rollback")
|
||||
try:
|
||||
await api.close()
|
||||
except Exception:
|
||||
@@ -379,13 +376,16 @@ class Client:
|
||||
if api is None:
|
||||
return
|
||||
# Null out the reference up-front so the Client appears closed even
|
||||
# if stop_chat / close raise — otherwise `client.api` would still
|
||||
# if close raises — otherwise `client.api` would still
|
||||
# hand back a half-shutdown controller after `async with` exits.
|
||||
self._api = None
|
||||
try:
|
||||
await api.stop_chat()
|
||||
finally:
|
||||
await api.close()
|
||||
except BaseException:
|
||||
# A failed stop leaves the store open; keep it so the caller can retry.
|
||||
if api.initialized:
|
||||
self._api = api
|
||||
raise
|
||||
|
||||
async def _post_start(self, user: T.User) -> None:
|
||||
"""Hook for subclasses to add work between `start_chat` and serving.
|
||||
@@ -617,7 +617,7 @@ class Client:
|
||||
# message resolve a future no one is waiting on.
|
||||
if waiter in waiters:
|
||||
waiters.remove(waiter)
|
||||
if not waiters:
|
||||
if not waiters and self._reply_waiters.get(contact_id) is waiters:
|
||||
self._reply_waiters.pop(contact_id, None)
|
||||
|
||||
async def _receive_loop(self) -> None:
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import ctypes
|
||||
import json
|
||||
from concurrent.futures import Executor
|
||||
from enum import StrEnum
|
||||
from typing import Any, TypedDict
|
||||
|
||||
@@ -102,7 +103,9 @@ async def chat_send_cmd(ctrl: int, cmd: str) -> CR.ChatResponse:
|
||||
raise ChatAPIError(f"invalid chat command result: {raw[:200]}")
|
||||
|
||||
|
||||
async def chat_recv_msg_wait(ctrl: int, wait_us: int = 500_000) -> CEvt.ChatEvent | None:
|
||||
async def chat_recv_msg_wait(
|
||||
ctrl: int, wait_us: int = 500_000, executor: Executor | None = None
|
||||
) -> CEvt.ChatEvent | None:
|
||||
def _call() -> str:
|
||||
# On timeout, the C side returns a non-NULL pointer to a single NUL byte
|
||||
# (see Mobile.hs `fromMaybe ""`), so `_read_and_free` returns "" — no
|
||||
@@ -110,7 +113,10 @@ async def chat_recv_msg_wait(ctrl: int, wait_us: int = 500_000) -> CEvt.ChatEven
|
||||
ptr = _native.lib().chat_recv_msg_wait(ctrl, wait_us)
|
||||
return _read_and_free(ptr)
|
||||
|
||||
raw = await asyncio.to_thread(_call)
|
||||
if executor is None:
|
||||
raw = await asyncio.to_thread(_call)
|
||||
else:
|
||||
raw = await asyncio.get_running_loop().run_in_executor(executor, _call)
|
||||
if not raw:
|
||||
return None
|
||||
parsed = json.loads(raw)
|
||||
@@ -122,17 +128,29 @@ async def chat_recv_msg_wait(ctrl: int, wait_us: int = 500_000) -> CEvt.ChatEven
|
||||
raise ChatAPIError(f"invalid chat event: {raw[:200]}")
|
||||
|
||||
|
||||
async def chat_migrate_init(db_path: str, db_key: str, confirm: MigrationConfirmation) -> int:
|
||||
"""Initialize chat controller. Returns opaque ctrl pointer as Python int."""
|
||||
async def chat_migrate_init(
|
||||
db_path: str,
|
||||
db_key: str,
|
||||
confirm: MigrationConfirmation,
|
||||
queue_size: int | None = None,
|
||||
) -> int:
|
||||
"""Initialize chat controller. Returns opaque ctrl pointer as Python int.
|
||||
|
||||
`queue_size` is the size of internal queues; the core default is used when None.
|
||||
"""
|
||||
# ctypes silently wraps ints that do not fit C int.
|
||||
if queue_size is not None and ctypes.c_int(queue_size).value != queue_size:
|
||||
raise ValueError(f"queue_size {queue_size} does not fit C int")
|
||||
|
||||
init_queue = _native.migrate_init_queue() if queue_size is not None else None
|
||||
|
||||
def _call() -> tuple[int, str]:
|
||||
ctrl = ctypes.c_void_p()
|
||||
ptr = _native.lib().chat_migrate_init(
|
||||
db_path.encode("utf-8"),
|
||||
db_key.encode("utf-8"),
|
||||
confirm.encode("utf-8"),
|
||||
ctypes.byref(ctrl),
|
||||
)
|
||||
args = (db_path.encode("utf-8"), db_key.encode("utf-8"), confirm.encode("utf-8"))
|
||||
if init_queue is None:
|
||||
ptr = _native.lib().chat_migrate_init(*args, ctypes.byref(ctrl))
|
||||
else:
|
||||
ptr = init_queue(*args, queue_size, ctypes.byref(ctrl))
|
||||
return (ctrl.value or 0, _read_and_free(ptr))
|
||||
|
||||
ctrl_val, raw = await asyncio.to_thread(_call)
|
||||
|
||||
@@ -498,7 +498,7 @@ class APIConnect(TypedDict):
|
||||
|
||||
|
||||
def APIConnect_cmd_string(self: APIConnect) -> str:
|
||||
return '/_connect ' + str(self['userId']) + ((' ' + T.CreatedConnLink_cmd_string(self.get('preparedLink_'))) if self.get('preparedLink_') is not None else '')
|
||||
return '/_connect ' + str(self['userId']) + (' incognito=on' if self['incognito'] else '') + ((' ' + T.CreatedConnLink_cmd_string(self.get('preparedLink_'))) if self.get('preparedLink_') is not None else '')
|
||||
|
||||
APIConnect_Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError
|
||||
|
||||
|
||||
@@ -220,83 +220,7 @@ BadgeRedeemError = (
|
||||
|
||||
BadgeRedeemError_Tag = Literal["invalidCode", "serviceNotConfigured", "badgeActive", "serviceError", "invalidResponse", "unknownKeyIndex", "credentialNotVerified"]
|
||||
|
||||
class BadgeServiceErrorCode_badRequest(TypedDict):
|
||||
type: Literal["badRequest"]
|
||||
|
||||
class BadgeServiceErrorCode_unsupportedVersion(TypedDict):
|
||||
type: Literal["unsupportedVersion"]
|
||||
|
||||
class BadgeServiceErrorCode_unknownPurchaseKey(TypedDict):
|
||||
type: Literal["unknownPurchaseKey"]
|
||||
|
||||
class BadgeServiceErrorCode_unknownOfferId(TypedDict):
|
||||
type: Literal["unknownOfferId"]
|
||||
|
||||
class BadgeServiceErrorCode_offerDisabled(TypedDict):
|
||||
type: Literal["offerDisabled"]
|
||||
|
||||
class BadgeServiceErrorCode_offerMismatch(TypedDict):
|
||||
type: Literal["offerMismatch"]
|
||||
|
||||
class BadgeServiceErrorCode_productUnavailable(TypedDict):
|
||||
type: Literal["productUnavailable"]
|
||||
|
||||
class BadgeServiceErrorCode_paymentNotEntitled(TypedDict):
|
||||
type: Literal["paymentNotEntitled"]
|
||||
|
||||
class BadgeServiceErrorCode_paymentPending(TypedDict):
|
||||
type: Literal["paymentPending"]
|
||||
|
||||
class BadgeServiceErrorCode_providerUnavailable(TypedDict):
|
||||
type: Literal["providerUnavailable"]
|
||||
|
||||
class BadgeServiceErrorCode_rateLimited(TypedDict):
|
||||
type: Literal["rateLimited"]
|
||||
|
||||
class BadgeServiceErrorCode_codeInvalid(TypedDict):
|
||||
type: Literal["codeInvalid"]
|
||||
|
||||
class BadgeServiceErrorCode_codeUsed(TypedDict):
|
||||
type: Literal["codeUsed"]
|
||||
|
||||
class BadgeServiceErrorCode_codeExpired(TypedDict):
|
||||
type: Literal["codeExpired"]
|
||||
|
||||
class BadgeServiceErrorCode_receiptInvalid(TypedDict):
|
||||
type: Literal["receiptInvalid"]
|
||||
|
||||
class BadgeServiceErrorCode_receiptUsed(TypedDict):
|
||||
type: Literal["receiptUsed"]
|
||||
|
||||
class BadgeServiceErrorCode_internal(TypedDict):
|
||||
type: Literal["internal"]
|
||||
|
||||
class BadgeServiceErrorCode_unknown(TypedDict):
|
||||
type: Literal["unknown"]
|
||||
: str
|
||||
|
||||
BadgeServiceErrorCode = (
|
||||
BadgeServiceErrorCode_badRequest
|
||||
| BadgeServiceErrorCode_unsupportedVersion
|
||||
| BadgeServiceErrorCode_unknownPurchaseKey
|
||||
| BadgeServiceErrorCode_unknownOfferId
|
||||
| BadgeServiceErrorCode_offerDisabled
|
||||
| BadgeServiceErrorCode_offerMismatch
|
||||
| BadgeServiceErrorCode_productUnavailable
|
||||
| BadgeServiceErrorCode_paymentNotEntitled
|
||||
| BadgeServiceErrorCode_paymentPending
|
||||
| BadgeServiceErrorCode_providerUnavailable
|
||||
| BadgeServiceErrorCode_rateLimited
|
||||
| BadgeServiceErrorCode_codeInvalid
|
||||
| BadgeServiceErrorCode_codeUsed
|
||||
| BadgeServiceErrorCode_codeExpired
|
||||
| BadgeServiceErrorCode_receiptInvalid
|
||||
| BadgeServiceErrorCode_receiptUsed
|
||||
| BadgeServiceErrorCode_internal
|
||||
| BadgeServiceErrorCode_unknown
|
||||
)
|
||||
|
||||
BadgeServiceErrorCode_Tag = Literal["badRequest", "unsupportedVersion", "unknownPurchaseKey", "unknownOfferId", "offerDisabled", "offerMismatch", "productUnavailable", "paymentNotEntitled", "paymentPending", "providerUnavailable", "rateLimited", "codeInvalid", "codeUsed", "codeExpired", "receiptInvalid", "receiptUsed", "internal", "unknown"]
|
||||
BadgeServiceErrorCode = Literal["bad_request", "unsupported_version", "unknown_purchase_key", "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", "payment_not_entitled", "payment_pending", "provider_unavailable", "rate_limited", "code_invalid", "code_used", "code_expired", "receipt_invalid", "receipt_used", "internal"]
|
||||
|
||||
BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"]
|
||||
|
||||
@@ -2031,13 +1955,8 @@ class GroupInfo(TypedDict):
|
||||
rosterVersion: NotRequired[int] # int64
|
||||
membersRequireAttention: int # int
|
||||
viaGroupLinkUri: NotRequired[str]
|
||||
groupKeys: NotRequired["GroupKeys"]
|
||||
groupDomainVerified: NotRequired[bool]
|
||||
|
||||
class GroupKeys(TypedDict):
|
||||
publicGroupKeys: NotRequired["PublicGroupKeys"]
|
||||
memberPrivKey: str
|
||||
|
||||
class GroupLink(TypedDict):
|
||||
userContactLinkId: int # int64
|
||||
connLinkContact: "CreatedConnLink"
|
||||
@@ -2172,18 +2091,6 @@ class GroupRelay(TypedDict):
|
||||
relayLink: NotRequired[str]
|
||||
relayCap: "RelayCapabilities"
|
||||
|
||||
class GroupRootKey_private(TypedDict):
|
||||
type: Literal["private"]
|
||||
rootPrivKey: str
|
||||
|
||||
class GroupRootKey_public(TypedDict):
|
||||
type: Literal["public"]
|
||||
rootPubKey: str
|
||||
|
||||
GroupRootKey = GroupRootKey_private | GroupRootKey_public
|
||||
|
||||
GroupRootKey_Tag = Literal["private", "public"]
|
||||
|
||||
class GroupShortLinkData(TypedDict):
|
||||
groupProfile: "GroupProfile"
|
||||
publicGroupData: NotRequired["PublicGroupData"]
|
||||
@@ -2626,10 +2533,6 @@ class PublicGroupAccess(TypedDict):
|
||||
class PublicGroupData(TypedDict):
|
||||
publicMemberCount: int # int64
|
||||
|
||||
class PublicGroupKeys(TypedDict):
|
||||
publicGroupId: str
|
||||
groupRootKey: "GroupRootKey"
|
||||
|
||||
class PublicGroupProfile(TypedDict):
|
||||
groupType: "GroupType"
|
||||
groupLink: str
|
||||
|
||||
@@ -101,7 +101,7 @@ def ci_content_text(chat_item: T.ChatItem) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
_BOT_COMMAND_RE = re.compile(r"^/([^\s]+)(.*)$")
|
||||
_BOT_COMMAND_RE = re.compile(r"^/([^\s]+)(.*)$", re.DOTALL)
|
||||
|
||||
|
||||
def ci_bot_command(chat_item: T.ChatItem) -> tuple[str, str] | None:
|
||||
|
||||
@@ -7,6 +7,7 @@ shape it accepts.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -172,3 +173,46 @@ async def test_a_failed_custom_data_write_raises():
|
||||
api = FakeCtrl({"type": "chatCmdError"})
|
||||
with pytest.raises(ChatCommandError):
|
||||
await api.api_merge_group_custom_data({"groupId": 9}, "mine", 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Documented success responses
|
||||
# ---------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_update_chat_item_accepts_not_changed():
|
||||
chat_item = {"meta": {"itemId": 2}}
|
||||
api = FakeCtrl({"type": "chatItemNotChanged", "chatItem": {"chatItem": chat_item}})
|
||||
msg_content = {"type": "text", "text": "same"}
|
||||
assert asyncio.run(api.api_update_chat_item("direct", 1, 2, msg_content)) == chat_item
|
||||
|
||||
|
||||
def test_set_profile_address_accepts_no_change():
|
||||
api = FakeCtrl({"type": "userProfileNoChange"})
|
||||
summary = asyncio.run(api.api_set_profile_address(1, True))
|
||||
assert summary == {"updateSuccesses": 0, "updateFailures": 0, "changedContacts": []}
|
||||
|
||||
|
||||
def test_receive_file_reports_cancelled_by_sender():
|
||||
api = FakeCtrl({"type": "rcvFileAcceptedSndCancelled", "rcvFileTransfer": {}})
|
||||
with pytest.raises(ChatCommandError, match="file cancelled by sender"):
|
||||
asyncio.run(api.api_receive_file(3))
|
||||
|
||||
|
||||
async def test_init_loads_library_off_the_event_loop(monkeypatch):
|
||||
import threading
|
||||
|
||||
from simplex_chat import _native, core
|
||||
from simplex_chat.api import SqliteDb
|
||||
|
||||
threads: list[int] = []
|
||||
monkeypatch.setattr(_native, "lib_for", lambda _backend: threads.append(threading.get_ident()))
|
||||
|
||||
async def fake_migrate_init(*_args):
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(core, "chat_migrate_init", fake_migrate_init)
|
||||
|
||||
loop_thread = threading.get_ident()
|
||||
await ChatApi.init(SqliteDb(file_prefix="/tmp/unused"))
|
||||
assert threads and threads[0] != loop_thread
|
||||
|
||||
@@ -14,11 +14,13 @@ import pytest
|
||||
from simplex_chat import (
|
||||
Bot,
|
||||
BotProfile,
|
||||
ChatCommandError,
|
||||
Client,
|
||||
ContactAlreadyExistsError,
|
||||
Profile,
|
||||
SqliteDb,
|
||||
)
|
||||
from simplex_chat.core import MigrationConfirmation
|
||||
|
||||
|
||||
class FakeApi:
|
||||
@@ -292,6 +294,31 @@ def test_send_and_wait_parallel_different_contacts():
|
||||
assert (a, b) == ("A", "B")
|
||||
|
||||
|
||||
def test_send_and_wait_keeps_waiter_registered_during_previous_cleanup():
|
||||
bot, _api = _bot_with_fake_api()
|
||||
|
||||
def reply(text: str) -> dict[str, Any]:
|
||||
return {"type": "newChatItems", "chatItems": [
|
||||
{
|
||||
"chatInfo": {"type": "direct", "contact": {"contactId": 42}},
|
||||
"chatItem": {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": text}}},
|
||||
}
|
||||
]}
|
||||
|
||||
async def go() -> tuple[str, str]:
|
||||
first = asyncio.create_task(bot.send_and_wait(42, "a", timeout=2.0))
|
||||
await asyncio.sleep(0)
|
||||
# Created before the reply is dispatched, so it registers before `first` runs its cleanup.
|
||||
second = asyncio.create_task(bot.send_and_wait(42, "b", timeout=2.0))
|
||||
await bot._dispatch_event(reply("ra")) # type: ignore[arg-type]
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
await bot._dispatch_event(reply("rb")) # type: ignore[arg-type]
|
||||
return (await first).text or "", (await second).text or ""
|
||||
|
||||
assert asyncio.run(go()) == ("ra", "rb")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# connect_to
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -466,6 +493,10 @@ def test_aexit_nulls_api_even_if_close_raises(monkeypatch):
|
||||
async def stop_chat(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def initialized(self):
|
||||
return False
|
||||
|
||||
async def close(self):
|
||||
raise RuntimeError("close failed")
|
||||
|
||||
@@ -498,6 +529,83 @@ def test_aexit_nulls_api_even_if_close_raises(monkeypatch):
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_aexit_keeps_api_for_retry_when_stop_fails(monkeypatch):
|
||||
"""A failed stop leaves the store open, so the Client must keep the
|
||||
controller: dropping it would leak the store with no way to close it."""
|
||||
import simplex_chat.client as client_mod
|
||||
|
||||
stop_results = ["chatCmdError", "chatStopped"]
|
||||
closed: list[bool] = [False]
|
||||
|
||||
class _FailingStopApi:
|
||||
@classmethod
|
||||
async def init(cls, *_a, **_kw):
|
||||
return cls()
|
||||
|
||||
@property
|
||||
def initialized(self):
|
||||
return not closed[0]
|
||||
|
||||
async def start_chat(self):
|
||||
pass
|
||||
|
||||
async def close(self):
|
||||
response = stop_results.pop(0)
|
||||
if response != "chatStopped":
|
||||
raise ChatCommandError("error stopping chat", {"type": response})
|
||||
closed[0] = True
|
||||
|
||||
async def api_get_active_user(self):
|
||||
return {"userId": 1, "profile": {"displayName": "x"}}
|
||||
|
||||
async def send_chat_cmd(self, _cmd):
|
||||
return {"type": "cmdOk"}
|
||||
|
||||
monkeypatch.setattr(client_mod, "ChatApi", _FailingStopApi)
|
||||
|
||||
c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test"))
|
||||
|
||||
async def go():
|
||||
with pytest.raises(ChatCommandError, match="error stopping chat"):
|
||||
async with c:
|
||||
pass
|
||||
assert c._api is not None, "controller dropped while its store is still open"
|
||||
assert closed == [False]
|
||||
await c.__aexit__(None, None, None)
|
||||
assert closed == [True]
|
||||
assert c._api is None
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("queue_size", [None, 65536])
|
||||
def test_bot_passes_queue_size_to_chat_api_init(monkeypatch, queue_size):
|
||||
import simplex_chat.client as client_mod
|
||||
|
||||
init_args: list[tuple[Any, ...]] = []
|
||||
|
||||
class StopInit(RuntimeError):
|
||||
pass
|
||||
|
||||
class FakeChatApi:
|
||||
@classmethod
|
||||
async def init(cls, *args):
|
||||
init_args.append(args)
|
||||
raise StopInit
|
||||
|
||||
monkeypatch.setattr(client_mod, "ChatApi", FakeChatApi)
|
||||
db = SqliteDb(file_prefix="/tmp/test")
|
||||
bot = Bot(profile=BotProfile(display_name="x"), db=db, queue_size=queue_size)
|
||||
|
||||
async def go():
|
||||
with pytest.raises(StopInit):
|
||||
async with bot:
|
||||
pytest.fail("should not enter the with-block")
|
||||
|
||||
asyncio.run(go())
|
||||
assert init_args == [(db, MigrationConfirmation.YES_UP, queue_size)]
|
||||
|
||||
|
||||
def test_aenter_rolls_back_partial_init_on_post_start_failure(monkeypatch):
|
||||
"""If anything in __aenter__ raises after ChatApi.init succeeded — including
|
||||
_post_start — the controller must be closed. Otherwise the with-block isn't
|
||||
@@ -550,7 +658,7 @@ def test_aenter_rolls_back_partial_init_on_post_start_failure(monkeypatch):
|
||||
pytest.fail("should not enter the with-block")
|
||||
|
||||
asyncio.run(go())
|
||||
assert closed == ["stop", "close"], f"controller not cleaned up: {closed}"
|
||||
assert closed == ["close"], f"controller not cleaned up: {closed}"
|
||||
assert c._api is None, "Client._api should be reset to None after rollback"
|
||||
|
||||
|
||||
|
||||
@@ -39,3 +39,9 @@ def test_chat_ref_cmd_string_direct():
|
||||
"""Sanity check the codegen fix for ChatRef-bearing commands."""
|
||||
assert T.ChatRef_cmd_string({"chatType": "direct", "chatId": 7}) == "@7"
|
||||
assert T.ChatRef_cmd_string({"chatType": "group", "chatId": 42}) == "#42"
|
||||
|
||||
|
||||
def test_api_connect_cmd_string_renders_incognito():
|
||||
link = {"connFullLink": "L"}
|
||||
assert CC.APIConnect_cmd_string({"userId": 1, "incognito": True, "preparedLink_": link}) == "/_connect 1 incognito=on L"
|
||||
assert CC.APIConnect_cmd_string({"userId": 1, "incognito": False, "preparedLink_": link}) == "/_connect 1 L"
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""core.chat_migrate_init picks the FFI export by queue_size, with a fake libsimplex."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from simplex_chat import core
|
||||
from simplex_chat.core import ChatInitError, MigrationConfirmation
|
||||
|
||||
CTRL = 42
|
||||
|
||||
|
||||
class FakeLib:
|
||||
"""Records calls; each export writes CTRL to the out-param and returns the JSON result."""
|
||||
|
||||
def __init__(self, result: dict[str, Any]) -> None:
|
||||
self.result = json.dumps(result)
|
||||
self.calls: list[tuple[str, tuple[Any, ...]]] = []
|
||||
|
||||
def _init(self, name: str, args: tuple[Any, ...]) -> str:
|
||||
*call_args, ctrl_ref = args
|
||||
self.calls.append((name, tuple(call_args)))
|
||||
ctrl_ref._obj.value = CTRL
|
||||
return self.result
|
||||
|
||||
def chat_migrate_init(self, *args: Any) -> str:
|
||||
return self._init("chat_migrate_init", args)
|
||||
|
||||
@property
|
||||
def chat_migrate_init_queue(self) -> Any:
|
||||
lib = self
|
||||
|
||||
class Fn:
|
||||
argtypes: Any = None
|
||||
restype: Any = None
|
||||
|
||||
def __call__(self, *args: Any) -> str:
|
||||
return lib._init("chat_migrate_init_queue", args)
|
||||
|
||||
return Fn()
|
||||
|
||||
|
||||
class OldLib(FakeLib):
|
||||
"""A libsimplex released before chat_migrate_init_queue existed."""
|
||||
|
||||
def __getattribute__(self, name: str) -> Any:
|
||||
if name == "chat_migrate_init_queue":
|
||||
raise AttributeError(name)
|
||||
return super().__getattribute__(name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_lib(monkeypatch: pytest.MonkeyPatch):
|
||||
def install(result: dict[str, Any]) -> FakeLib:
|
||||
lib = FakeLib(result)
|
||||
monkeypatch.setattr(core._native, "lib", lambda: lib)
|
||||
monkeypatch.setattr(core, "_read_and_free", lambda ptr: ptr)
|
||||
return lib
|
||||
|
||||
return install
|
||||
|
||||
|
||||
def migrate_init(queue_size: int | None = None) -> int:
|
||||
return asyncio.run(
|
||||
core.chat_migrate_init("/tmp/db", "key", MigrationConfirmation.YES_UP, queue_size)
|
||||
)
|
||||
|
||||
|
||||
def test_without_queue_size_uses_chat_migrate_init(fake_lib):
|
||||
lib = fake_lib({"type": "ok"})
|
||||
assert migrate_init() == CTRL
|
||||
assert lib.calls == [("chat_migrate_init", (b"/tmp/db", b"key", b"yesUp"))]
|
||||
|
||||
|
||||
def test_with_queue_size_uses_chat_migrate_init_queue(fake_lib):
|
||||
lib = fake_lib({"type": "ok"})
|
||||
assert migrate_init(65536) == CTRL
|
||||
assert lib.calls == [("chat_migrate_init_queue", (b"/tmp/db", b"key", b"yesUp", 65536))]
|
||||
|
||||
|
||||
def test_invalid_queue_size_result_raises_init_error(fake_lib):
|
||||
fake_lib({"type": "invalidQueueSize"})
|
||||
with pytest.raises(ChatInitError) as e:
|
||||
migrate_init(0)
|
||||
assert e.value.db_migration_error == {"type": "invalidQueueSize"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("queue_size", [2**31, -(2**31) - 1])
|
||||
def test_queue_size_outside_c_int_is_rejected_before_ffi(fake_lib, queue_size):
|
||||
lib = fake_lib({"type": "ok"})
|
||||
with pytest.raises(ValueError, match="does not fit C int"):
|
||||
migrate_init(queue_size)
|
||||
assert lib.calls == []
|
||||
|
||||
|
||||
def test_queue_size_on_old_lib_raises_clear_error(monkeypatch):
|
||||
lib = OldLib({"type": "ok"})
|
||||
monkeypatch.setattr(core._native, "lib", lambda: lib)
|
||||
with pytest.raises(RuntimeError, match="does not export chat_migrate_init_queue"):
|
||||
migrate_init(65536)
|
||||
assert lib.calls == []
|
||||
|
||||
|
||||
def test_setup_signatures_accepts_old_lib():
|
||||
class Fn:
|
||||
argtypes: Any = None
|
||||
restype: Any = None
|
||||
|
||||
class Lib:
|
||||
def __getattr__(self, name: str) -> Fn:
|
||||
if name == "chat_migrate_init_queue":
|
||||
raise AttributeError(name)
|
||||
fn = Fn()
|
||||
setattr(self, name, fn)
|
||||
return fn
|
||||
|
||||
from simplex_chat import _native
|
||||
|
||||
_native._setup_signatures(Lib()) # type: ignore[arg-type]
|
||||
@@ -91,3 +91,13 @@ def test_atomic_install(tmp_path, monkeypatch):
|
||||
_download(target, "sqlite")
|
||||
assert (target / "libsimplex.so").read_text() == "fake-so"
|
||||
assert (target / "libHS-stub.so").read_text() == "fake-hs"
|
||||
|
||||
|
||||
def test_libc_on_windows_is_ucrt(monkeypatch):
|
||||
loaded: list[str | None] = []
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
monkeypatch.setattr("ctypes.CDLL", lambda name: loaded.append(name))
|
||||
from simplex_chat import _native
|
||||
|
||||
_native._load_libc()
|
||||
assert loaded == ["ucrtbase"]
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""ChatApi receives run on a dedicated per-instance thread, not the default pool.
|
||||
|
||||
Uses a fake libsimplex (see tests/test_core_migrate_init.py for the pattern):
|
||||
`core._native.lib` and `core._read_and_free` are monkeypatched so `chat_recv_msg_wait`
|
||||
sleeps for a controlled time and returns a scripted result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from simplex_chat import ChatApi, ChatCommandError
|
||||
|
||||
RECV_SLEEP = 0.3
|
||||
|
||||
|
||||
class FakeRecvLib:
|
||||
"""Fake chat_recv_msg_wait: blocks for `sleep` seconds, then returns a scripted result.
|
||||
|
||||
`events` records "stop" / "recv_start" / "recv_end" / "close_store" in call order, across
|
||||
all ChatApi instances sharing this fake, so tests can assert ordering between stop, receive
|
||||
and store-close calls (not just that they happened).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sleep: float = RECV_SLEEP,
|
||||
results: list[str] | None = None,
|
||||
stop_response: str = "chatStopped",
|
||||
) -> None:
|
||||
self.sleep = sleep
|
||||
self._results = iter(results or [])
|
||||
self._stop_response = stop_response
|
||||
self.calls: list[tuple[int, int]] = [] # (ctrl, thread ident)
|
||||
self.events: list[str] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def chat_recv_msg_wait(self, ctrl: int, wait_us: int) -> str:
|
||||
with self._lock:
|
||||
self.events.append("recv_start")
|
||||
time.sleep(self.sleep)
|
||||
with self._lock:
|
||||
self.events.append("recv_end")
|
||||
self.calls.append((ctrl, threading.get_ident()))
|
||||
return next(self._results, "")
|
||||
|
||||
def chat_send_cmd(self, ctrl: int, cmd: bytes) -> str:
|
||||
assert cmd == b"/_stop", f"unexpected command {cmd!r}"
|
||||
with self._lock:
|
||||
self.events.append("stop")
|
||||
return json.dumps({"result": {"type": self._stop_response}})
|
||||
|
||||
def chat_close_store(self, ctrl: int) -> str:
|
||||
with self._lock:
|
||||
self.events.append("close_store")
|
||||
return ""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_lib(monkeypatch: pytest.MonkeyPatch):
|
||||
def install(
|
||||
sleep: float = RECV_SLEEP,
|
||||
results: list[str] | None = None,
|
||||
stop_response: str = "chatStopped",
|
||||
) -> FakeRecvLib:
|
||||
lib = FakeRecvLib(sleep=sleep, results=results, stop_response=stop_response)
|
||||
monkeypatch.setattr("simplex_chat.core._native.lib", lambda: lib)
|
||||
monkeypatch.setattr("simplex_chat.core._read_and_free", lambda ptr: ptr)
|
||||
return lib
|
||||
|
||||
return install
|
||||
|
||||
|
||||
def _recv_thread_names() -> list[str]:
|
||||
return [t.name for t in threading.enumerate() if t.name.startswith("simplex-recv")]
|
||||
|
||||
|
||||
async def test_receives_do_not_use_the_default_executor(fake_lib):
|
||||
fake_lib(sleep=RECV_SLEEP)
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.set_default_executor(ThreadPoolExecutor(max_workers=1))
|
||||
|
||||
apis = [ChatApi(ctrl=i) for i in range(3)]
|
||||
recv_tasks = [asyncio.create_task(api.recv_chat_event()) for api in apis]
|
||||
try:
|
||||
await asyncio.sleep(0.05) # let all three receives claim their own thread
|
||||
|
||||
start = time.monotonic()
|
||||
await asyncio.to_thread(lambda: None)
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
await asyncio.gather(*recv_tasks)
|
||||
finally:
|
||||
for api in apis:
|
||||
await api.close()
|
||||
|
||||
assert elapsed < 0.1
|
||||
|
||||
|
||||
async def test_one_receive_thread_per_chatapi_reused(fake_lib):
|
||||
lib = fake_lib(sleep=0.02)
|
||||
api = ChatApi(ctrl=1)
|
||||
other_api = ChatApi(ctrl=2)
|
||||
try:
|
||||
for _ in range(3):
|
||||
await api.recv_chat_event()
|
||||
|
||||
idents = {ident for ctrl, ident in lib.calls if ctrl == 1}
|
||||
assert len(idents) == 1
|
||||
recv_ident = idents.pop()
|
||||
thread = next(t for t in threading.enumerate() if t.ident == recv_ident)
|
||||
assert thread.name.startswith("simplex-recv")
|
||||
assert thread.ident != threading.get_ident()
|
||||
|
||||
await other_api.recv_chat_event()
|
||||
other_idents = {ident for ctrl, ident in lib.calls if ctrl == 2}
|
||||
assert other_idents and other_idents != {thread.ident}
|
||||
finally:
|
||||
await api.close()
|
||||
await other_api.close()
|
||||
|
||||
|
||||
def test_no_thread_until_first_receive():
|
||||
# A bare ThreadPoolExecutor spawns no worker thread until the first submit,
|
||||
# so the real assertion is the attribute itself, not threading.enumerate().
|
||||
api = ChatApi(ctrl=1)
|
||||
assert api._recv_executor is None
|
||||
|
||||
|
||||
async def test_close_shuts_down_the_executor_without_blocking_the_loop(fake_lib):
|
||||
fake_lib(sleep=RECV_SLEEP)
|
||||
api = ChatApi(ctrl=1)
|
||||
recv_task = asyncio.create_task(api.recv_chat_event())
|
||||
await asyncio.sleep(0.05) # let the receive claim its executor thread
|
||||
assert _recv_thread_names() != []
|
||||
|
||||
sleep_task = asyncio.create_task(asyncio.sleep(0.01))
|
||||
close_task = asyncio.create_task(api.close())
|
||||
|
||||
await asyncio.wait_for(sleep_task, timeout=0.2)
|
||||
assert not close_task.done() # shutdown still waiting on the in-flight receive
|
||||
|
||||
await close_task
|
||||
await recv_task
|
||||
|
||||
assert _recv_thread_names() == []
|
||||
|
||||
|
||||
async def test_close_shuts_down_executor_before_closing_the_store(fake_lib):
|
||||
lib = fake_lib(sleep=RECV_SLEEP)
|
||||
api = ChatApi(ctrl=1)
|
||||
recv_task = asyncio.create_task(api.recv_chat_event())
|
||||
try:
|
||||
await asyncio.sleep(0.05) # ensure the receive is in flight before close() starts
|
||||
await api.close()
|
||||
await recv_task
|
||||
finally:
|
||||
if not recv_task.done():
|
||||
recv_task.cancel()
|
||||
|
||||
# recv_end (executor drained) must precede close_store: a receive must never
|
||||
# be in flight while the store closes underneath it.
|
||||
assert lib.events == ["recv_start", "stop", "recv_end", "close_store"]
|
||||
|
||||
|
||||
async def test_close_stops_the_chat_before_closing_the_store(fake_lib):
|
||||
lib = fake_lib()
|
||||
api = ChatApi(ctrl=1)
|
||||
await api.close()
|
||||
assert lib.events == ["stop", "close_store"]
|
||||
assert not api.initialized
|
||||
|
||||
|
||||
async def test_close_does_not_close_the_store_when_stop_fails(fake_lib):
|
||||
lib = fake_lib(stop_response="chatCmdError")
|
||||
api = ChatApi(ctrl=1)
|
||||
with pytest.raises(ChatCommandError, match="error stopping chat"):
|
||||
await api.close()
|
||||
assert lib.events == ["stop"]
|
||||
assert api.initialized
|
||||
|
||||
|
||||
async def test_recv_chat_event_after_close_raises_before_touching_executor(fake_lib):
|
||||
fake_lib()
|
||||
api = ChatApi(ctrl=1)
|
||||
await api.close()
|
||||
with pytest.raises(RuntimeError, match="controller not initialized"):
|
||||
await api.recv_chat_event()
|
||||
assert api._recv_executor is None
|
||||
|
||||
|
||||
async def test_receive_parses_event_json_and_none_on_timeout(fake_lib):
|
||||
event: dict[str, Any] = {"type": "chatItemUpdated", "chatItem": {}}
|
||||
fake_lib(sleep=0.01, results=[json.dumps({"result": event}), ""])
|
||||
api = ChatApi(ctrl=1)
|
||||
try:
|
||||
assert await api.recv_chat_event() == event
|
||||
assert await api.recv_chat_event() is None
|
||||
finally:
|
||||
await api.close()
|
||||
@@ -167,6 +167,11 @@ def test_ci_bot_command_no_text():
|
||||
assert util.ci_bot_command(ci) is None
|
||||
|
||||
|
||||
def test_ci_bot_command_multiline_params():
|
||||
ci = {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "/review line1\nline2"}}}
|
||||
assert util.ci_bot_command(ci) == ("review", "line1\nline2")
|
||||
|
||||
|
||||
def test_reaction_text_emoji():
|
||||
r = {"chatReaction": {"reaction": {"type": "emoji", "emoji": "🎉"}}}
|
||||
assert util.reaction_text(r) == "🎉"
|
||||
|
||||
Reference in New Issue
Block a user