core, libs: configurable queue size, library fixes (#7542)

* core: add chat_migrate_init_queue FFI export

* bots: fix BadgeServiceErrorCode API type

* nodejs: pass required command fields

* nodejs: fix migration error types

* nodejs: install libsimplex from SIMPLEX_LIBS_DIR

* nodejs: add queue size option

* nodejs: regenerate docs

* python: add queue size option

* bots: pass incognito in APIConnect

* nodejs: accept documented success responses

* python: accept documented success responses

* nodejs: dispatch each bot message once

* nodejs: fix startChat events loop lifecycle

* nodejs, python: parse multi-line bot commands

* nodejs: fix file buffer handling in addon

* nodejs: keep events loop when chat stop fails

* python: fix send_and_wait race, load lib off loop

* python: make queue size export optional

* nodejs: receive events on a dedicated thread

* nodejs: release haskell thread after receive

* python: receive on a dedicated thread per chat

* python: test receive shutdown order

* nodejs: one receive thread per chat controller

* nodejs: harden receiver shutdown and tests

* nodejs: stop chat before closing store

* python: stop chat before closing store

* nodejs, python: harden close regression and retry

* python: free results with ucrt on windows

* nodejs: enable c++ exceptions on mac and windows
This commit is contained in:
sh
2026-09-19 12:18:46 +01:00
committed by GitHub
parent ecb9d87157
commit c8f20bcc91
78 changed files with 1670 additions and 571 deletions
+3 -3
View File
@@ -1537,15 +1537,15 @@ Connect via prepared SimpleX link. The link can be 1-time invitation link, conta
**Syntax**:
```
/_connect <userId>[ <str(preparedLink_)>]
/_connect <userId>[ incognito=on][ <str(preparedLink_)>]
```
```javascript
'/_connect ' + userId + (preparedLink_ ? ' ' + CreatedConnLink.cmdString(preparedLink_) : '') // JavaScript
'/_connect ' + userId + (incognito ? ' incognito=on' : '') + (preparedLink_ ? ' ' + CreatedConnLink.cmdString(preparedLink_) : '') // JavaScript
```
```python
'/_connect ' + str(userId) + ((' ' + CreatedConnLink_cmd_string(preparedLink_)) if preparedLink_ is not None else '') # Python
'/_connect ' + str(userId) + (' incognito=on' if incognito else '') + ((' ' + CreatedConnLink_cmd_string(preparedLink_)) if preparedLink_ is not None else '') # Python
```
**Responses**:
+18 -56
View File
@@ -466,62 +466,24 @@ CredentialNotVerified:
## BadgeServiceErrorCode
**Discriminated union type**:
BadRequest:
- type: "badRequest"
UnsupportedVersion:
- type: "unsupportedVersion"
UnknownPurchaseKey:
- type: "unknownPurchaseKey"
UnknownOfferId:
- type: "unknownOfferId"
OfferDisabled:
- type: "offerDisabled"
OfferMismatch:
- type: "offerMismatch"
ProductUnavailable:
- type: "productUnavailable"
PaymentNotEntitled:
- type: "paymentNotEntitled"
PaymentPending:
- type: "paymentPending"
ProviderUnavailable:
- type: "providerUnavailable"
RateLimited:
- type: "rateLimited"
CodeInvalid:
- type: "codeInvalid"
CodeUsed:
- type: "codeUsed"
CodeExpired:
- type: "codeExpired"
ReceiptInvalid:
- type: "receiptInvalid"
ReceiptUsed:
- type: "receiptUsed"
Internal:
- type: "internal"
Unknown:
- type: "unknown"
- : string
**Enum type**:
- "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"
---
+1 -1
View File
@@ -140,7 +140,7 @@ chatCommandsDocsData =
[ ("APIAddContact", [], "Create 1-time invitation link.", ["CRInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False)),
-- `Maybe` in `connectTarget :: Maybe ConnectTarget` is used to signal parse failure to the runtime (the handler returns CEInvalidConnReq on Nothing); it is NOT API-level optionality. The parameter is required from callers.
("APIConnectPlan", [], "Determine SimpleX link type and if the bot is already connected via this link or name.", ["CRConnectionPlan", "CRChatCmdError"], [], Just UNInteractive, "/_connect plan " <> Param "userId" <> " " <> Param "connectTarget"),
("APIConnect", [], "Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> Optional "" (" " <> Param "$0") "preparedLink_"),
("APIConnect", [], "Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> Optional "" (" " <> Param "$0") "preparedLink_"),
("Connect", [], "Connect via SimpleX link or name as string in the active user profile.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRConnectionPlan", "CRSentInvitationToContact", "CRStartedConnectionToContact", "CRStartedConnectionToGroup", "CRChatCmdError"], [], Just UNInteractive, "/connect" <> Optional "" (" " <> Param "$0") "connTarget_"),
("APIAcceptContact", ["incognito"], "Accept contact request.", ["CRAcceptingContactRequest", "CRChatCmdError"], [], Just UNInteractive, "/_accept " <> Param "contactReqId"),
("APIRejectContact", [], "Reject contact request. The user who sent the request is **not notified**.", ["CRContactRequestRejected", "CRChatCmdError"], [], Nothing, "/_reject " <> Param "contactReqId")
+1 -1
View File
@@ -217,7 +217,7 @@ chatTypesDocsData =
(sti @AutoAccept, STRecord, "", [], "", ""),
(sti @BadgeProof, STRecord, "", [], "", ""),
(sti @BadgeRedeemError, STUnion, "BRE", [], "", ""),
(sti @BadgeServiceErrorCode, STUnion, "BSE", [], "", ""),
(sti @BadgeServiceErrorCode, STEnum' (consSep "BSE" '_'), "", ["BSEUnknown"], "", ""),
(sti @BlockingInfo, STRecord, "", [], "", ""),
(sti @BlockingReason, STEnum, "BR", [], "", ""),
(sti @BrokerErrorType, STUnion, "", [], "", ""),
+2
View File
@@ -393,6 +393,7 @@
"chat_encrypt_file"
"chat_encrypt_media"
"chat_migrate_init"
"chat_migrate_init_queue"
"chat_parse_markdown"
"chat_parse_server"
"chat_parse_uri"
@@ -515,6 +516,7 @@
"chat_encrypt_file"
"chat_encrypt_media"
"chat_migrate_init"
"chat_migrate_init_queue"
"chat_parse_markdown"
"chat_parse_server"
"chat_parse_uri"
+2
View File
@@ -2,7 +2,9 @@ LIBRARY libsimplex
EXPORTS
hs_init
hs_init_with_rtsopts
hs_thread_done
chat_migrate_init
chat_migrate_init_queue
chat_close_store
chat_send_cmd
chat_send_cmd_retry
@@ -570,7 +570,7 @@ export namespace APIConnect {
export type Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError
export function cmdString(self: APIConnect): string {
return '/_connect ' + self.userId + (self.preparedLink_ ? ' ' + T.CreatedConnLink.cmdString(self.preparedLink_) : '')
return '/_connect ' + self.userId + (self.incognito ? ' incognito=on' : '') + (self.preparedLink_ ? ' ' + T.CreatedConnLink.cmdString(self.preparedLink_) : '')
}
}
@@ -301,123 +301,24 @@ export namespace BadgeRedeemError {
}
}
export type 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
export namespace BadgeServiceErrorCode {
export type Tag =
| "badRequest"
| "unsupportedVersion"
| "unknownPurchaseKey"
| "unknownOfferId"
| "offerDisabled"
| "offerMismatch"
| "productUnavailable"
| "paymentNotEntitled"
| "paymentPending"
| "providerUnavailable"
| "rateLimited"
| "codeInvalid"
| "codeUsed"
| "codeExpired"
| "receiptInvalid"
| "receiptUsed"
| "internal"
| "unknown"
interface Interface {
type: Tag
}
export interface BadRequest extends Interface {
type: "badRequest"
}
export interface UnsupportedVersion extends Interface {
type: "unsupportedVersion"
}
export interface UnknownPurchaseKey extends Interface {
type: "unknownPurchaseKey"
}
export interface UnknownOfferId extends Interface {
type: "unknownOfferId"
}
export interface OfferDisabled extends Interface {
type: "offerDisabled"
}
export interface OfferMismatch extends Interface {
type: "offerMismatch"
}
export interface ProductUnavailable extends Interface {
type: "productUnavailable"
}
export interface PaymentNotEntitled extends Interface {
type: "paymentNotEntitled"
}
export interface PaymentPending extends Interface {
type: "paymentPending"
}
export interface ProviderUnavailable extends Interface {
type: "providerUnavailable"
}
export interface RateLimited extends Interface {
type: "rateLimited"
}
export interface CodeInvalid extends Interface {
type: "codeInvalid"
}
export interface CodeUsed extends Interface {
type: "codeUsed"
}
export interface CodeExpired extends Interface {
type: "codeExpired"
}
export interface ReceiptInvalid extends Interface {
type: "receiptInvalid"
}
export interface ReceiptUsed extends Interface {
type: "receiptUsed"
}
export interface Internal extends Interface {
type: "internal"
}
export interface Unknown extends Interface {
type: "unknown"
: string
}
export enum BadgeServiceErrorCode {
Bad_request = "bad_request",
Unsupported_version = "unsupported_version",
Unknown_purchase_key = "unknown_purchase_key",
Unknown_offer_id = "unknown_offer_id",
Offer_disabled = "offer_disabled",
Offer_mismatch = "offer_mismatch",
Product_unavailable = "product_unavailable",
Payment_not_entitled = "payment_not_entitled",
Payment_pending = "payment_pending",
Provider_unavailable = "provider_unavailable",
Rate_limited = "rate_limited",
Code_invalid = "code_invalid",
Code_used = "code_used",
Code_expired = "code_expired",
Receipt_invalid = "receipt_invalid",
Receipt_used = "receipt_used",
Internal = "internal",
}
export enum BadgeStatus {
+2
View File
@@ -11,6 +11,8 @@
],
"cflags!": [ "-fno-exceptions" ],
"cflags_cc!": [ "-fno-exceptions" ],
"xcode_settings": { "GCC_ENABLE_CPP_EXCEPTIONS": "YES" },
"msvs_settings": { "VCCLCompilerTool": { "ExceptionHandling": 1 } },
"defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ],
"conditions": [
["OS=='mac'", {
+264 -28
View File
@@ -3,6 +3,14 @@
#include <string>
#include <functional>
#include <cstdlib>
#include <climits>
#include <memory>
#include <thread>
#include <system_error>
#include <mutex>
#include <condition_variable>
#include <deque>
#include <unordered_map>
#include "simplex.h"
namespace simplex {
@@ -81,6 +89,11 @@ class ResultAsyncWorker : public AsyncWorker {
return ctrl_;
}
// the worker thread reads this object's memory until the worker completes
void KeepAlive(Object obj) {
keep_alive_ = Persistent(obj);
}
protected:
std::string result_;
uintptr_t ctrl_ = 0;
@@ -88,6 +101,7 @@ class ResultAsyncWorker : public AsyncWorker {
private:
ExecuteFn execute_fn_;
ResultProcessor result_processor_;
ObjectReference keep_alive_;
};
class BinaryAsyncWorker : public AsyncWorker {
@@ -97,21 +111,25 @@ class BinaryAsyncWorker : public AsyncWorker {
BinaryAsyncWorker(Function& callback, ExecuteFn execute_fn)
: AsyncWorker(callback), execute_fn_(std::move(execute_fn)) {}
~BinaryAsyncWorker() {
free(original_buf);
}
void Execute() override {
execute_fn_(this);
}
void OnOK() override {
HandleScope scope(Env());
if (original_buf == nullptr || binary_len == 0) {
Callback().Call({Env().Null(), Env().Undefined()});
char* buf = original_buf;
original_buf = nullptr;
if (binary_len == 0) {
free(buf);
Callback().Call({Env().Null(), Buffer<char>::New(Env(), 0)});
return;
}
char* data_ptr = original_buf + 5;
auto finalizer = [](Napi::Env env, char* finalize_data, char* orig) {
free(orig);
};
Napi::Buffer<char> buffer = Napi::Buffer<char>::New(Env(), data_ptr, binary_len, finalizer, original_buf);
// Copies when the runtime forbids external buffers (Electron); the finalizer then runs immediately.
Buffer<char> buffer = Buffer<char>::NewOrCopy(Env(), buf + 5, binary_len, [](Napi::Env, char*, char* orig) { free(orig); }, buf);
Callback().Call({Env().Null(), buffer});
}
@@ -176,6 +194,159 @@ Napi::Promise CreatePromiseAndCallback(Env env, Function& cb_out) {
return deferred.Promise();
}
const char* const RECEIVER_STOPPED = "chat receiver stopped";
struct RecvRequest {
int wait = 0;
std::shared_ptr<Promise::Deferred> deferred;
};
// Holds the event loop open only while receives are pending; used only on the JS main thread.
class PendingReceives {
public:
explicit PendingReceives(ThreadSafeFunction tsfn) : tsfn_(tsfn) {}
const ThreadSafeFunction& Tsfn() const {
return tsfn_;
}
void Add(Napi::Env env) {
if (count_++ == 0) tsfn_.Ref(env);
}
void Remove(Napi::Env env) {
if (--count_ == 0) tsfn_.Unref(env);
}
private:
ThreadSafeFunction tsfn_;
size_t count_ = 0;
};
// A blocking receive would hold a libuv pool thread for up to `wait`, stalling fs, dns and crypto.
class Receiver {
public:
// Returns nullptr with a pending JS exception if the TSFN cannot be created, throws std::system_error if the thread cannot start.
static std::shared_ptr<Receiver> Start(Napi::Env env, chat_ctrl ctrl) {
ThreadSafeFunction tsfn = ThreadSafeFunction::New(env, Function::New(env, [](const CallbackInfo&) {}), "chat_recv_msg_wait", 0, 1);
if (env.IsExceptionPending()) {
return nullptr;
}
auto receiver = std::make_shared<Receiver>(ctrl, tsfn);
try {
receiver->thread_ = std::thread(&Receiver::Run, receiver.get());
} catch (const std::system_error&) {
tsfn.Release();
throw;
}
return receiver;
}
Receiver(chat_ctrl ctrl, ThreadSafeFunction tsfn) : ctrl_(ctrl), pending_(std::make_shared<PendingReceives>(tsfn)) {}
Receiver(const Receiver&) = delete;
Receiver& operator=(const Receiver&) = delete;
~Receiver() {
Stop();
}
void Enqueue(Napi::Env env, RecvRequest request) {
pending_->Add(env);
{
std::lock_guard<std::mutex> lock(mutex_);
queue_.push_back(std::move(request));
}
cv_.notify_one();
}
void RequestStop() {
{
std::lock_guard<std::mutex> lock(mutex_);
stop_ = true;
}
cv_.notify_one();
}
// Waits for the receive in progress, so it must not run on the JS main thread outside env teardown.
void Stop() {
RequestStop();
if (thread_.joinable()) {
thread_.join();
}
}
private:
void Run() {
for (;;) {
RecvRequest request;
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this] { return stop_ || !queue_.empty(); });
if (stop_) break;
request = std::move(queue_.front());
queue_.pop_front();
}
char* c_res = chat_recv_msg_wait(ctrl_, request.wait);
napi_status status = Settle(request.deferred, [c_res](Napi::Env env, Promise::Deferred& deferred) {
if (c_res == nullptr) {
deferred.Reject(Error::New(env, "chat_recv_msg_wait failed").Value());
} else {
deferred.Resolve(String::New(env, c_res));
free(c_res);
}
});
if (status != napi_ok) {
free(c_res);
}
}
std::deque<RecvRequest> unserved;
{
std::lock_guard<std::mutex> lock(mutex_);
unserved.swap(queue_);
}
for (RecvRequest& request : unserved) {
Settle(request.deferred, [](Napi::Env env, Promise::Deferred& deferred) {
deferred.Reject(Error::New(env, RECEIVER_STOPPED).Value());
});
}
pending_->Tsfn().Release();
// Each OS thread that enters Haskell keeps an RTS task record until it calls hs_thread_done.
hs_thread_done();
}
template <typename SettleFn>
napi_status Settle(std::shared_ptr<Promise::Deferred> deferred, SettleFn settle) {
// The callback may run after this Receiver is destroyed, so it owns the pending count.
std::shared_ptr<PendingReceives> pending = pending_;
return pending->Tsfn().BlockingCall([pending, deferred, settle](Napi::Env env, Function) {
settle(env, *deferred);
pending->Remove(env);
});
}
const chat_ctrl ctrl_;
const std::shared_ptr<PendingReceives> pending_;
std::mutex mutex_;
std::condition_variable cv_;
std::deque<RecvRequest> queue_;
bool stop_ = false;
std::thread thread_;
};
// Keyed by chat_ctrl, accessed only on the JS main thread.
using Receivers = std::unordered_map<uintptr_t, std::shared_ptr<Receiver>>;
std::shared_ptr<Receiver> TakeReceiver(Receivers& receivers, chat_ctrl ctrl) {
auto it = receivers.find(reinterpret_cast<uintptr_t>(ctrl));
if (it == receivers.end()) {
return nullptr;
}
std::shared_ptr<Receiver> receiver = std::move(it->second);
receivers.erase(it);
return receiver;
}
// Common result processors
ResultAsyncWorker::ResultProcessor MigrateResultProcessor() {
return [](ResultAsyncWorker* worker, Napi::Env env) {
@@ -215,6 +386,39 @@ Value ChatMigrateInit(const CallbackInfo& args) {
return promise;
}
Value ChatMigrateInitQueue(const CallbackInfo& args) {
Env env = args.Env();
if (args.Length() < 4 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString() || !args[3].IsNumber()) {
TypeError::New(env, "Expected three string arguments and number").ThrowAsJavaScriptException();
return env.Undefined();
}
std::string path = args[0].As<String>().Utf8Value();
std::string key = args[1].As<String>().Utf8Value();
std::string confirm = args[2].As<String>().Utf8Value();
Number queue_size_arg = args[3].As<Number>();
int queue_size = queue_size_arg.Int32Value();
if (static_cast<double>(queue_size) != queue_size_arg.DoubleValue()) {
RangeError::New(env, "Expected 32-bit integer queue size").ThrowAsJavaScriptException();
return env.Undefined();
}
Function cb;
Promise promise = CreatePromiseAndCallback(env, cb);
auto execute_fn = [path, key, confirm, queue_size](ResultAsyncWorker* worker) {
chat_ctrl ctrl = nullptr;
char* c_res = chat_migrate_init_queue(path.c_str(), key.c_str(), confirm.c_str(), queue_size, &ctrl);
worker->SetCtrl(reinterpret_cast<uintptr_t>(ctrl));
HandleCResult(worker, c_res, "chat_migrate_init_queue");
};
ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn), MigrateResultProcessor());
worker->Queue();
return promise;
}
Value ChatCloseStore(const CallbackInfo& args) {
Env env = args.Env();
if (args.Length() < 1 || !args[0].IsBigInt()) {
@@ -223,11 +427,15 @@ Value ChatCloseStore(const CallbackInfo& args) {
}
chat_ctrl ctrl = FromChatCtrlBigInt(args[0]);
std::shared_ptr<Receiver> receiver = TakeReceiver(*static_cast<Receivers*>(args.Data()), ctrl);
Function cb;
Promise promise = CreatePromiseAndCallback(env, cb);
auto execute_fn = [ctrl](ResultAsyncWorker* worker) {
auto execute_fn = [ctrl, receiver](ResultAsyncWorker* worker) {
if (receiver) {
receiver->Stop();
}
char* c_res = chat_close_store(ctrl);
HandleCResult(worker, c_res, "chat_close_store");
};
@@ -271,44 +479,63 @@ Value ChatRecvMsgWait(const CallbackInfo& args) {
chat_ctrl ctrl = FromChatCtrlBigInt(args[0]);
int wait = static_cast<int>(args[1].As<Number>().Int32Value());
Receivers& receivers = *static_cast<Receivers*>(args.Data());
Function cb;
Promise promise = CreatePromiseAndCallback(env, cb);
auto deferred = std::make_shared<Promise::Deferred>(Promise::Deferred::New(env));
auto it = receivers.find(reinterpret_cast<uintptr_t>(ctrl));
if (it == receivers.end()) {
std::shared_ptr<Receiver> receiver;
try {
receiver = Receiver::Start(env, ctrl);
} catch (const std::system_error& e) {
deferred->Reject(Error::New(env, e.what()).Value());
return deferred->Promise();
}
if (!receiver) {
return env.Undefined();
}
it = receivers.emplace(reinterpret_cast<uintptr_t>(ctrl), std::move(receiver)).first;
}
it->second->Enqueue(env, {wait, deferred});
auto execute_fn = [ctrl, wait](ResultAsyncWorker* worker) {
char* c_res = chat_recv_msg_wait(ctrl, wait);
HandleCResult(worker, c_res, "chat_recv_msg_wait");
};
ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn));
worker->Queue();
return promise;
return deferred->Promise();
}
Value ChatWriteFile(const CallbackInfo& args) {
Env env = args.Env();
if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !args[2].IsArrayBuffer()) {
TypeError::New(env, "Expected bigint (ctrl), string (path), ArrayBuffer").ThrowAsJavaScriptException();
if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !(args[2].IsArrayBuffer() || args[2].IsTypedArray())) {
TypeError::New(env, "Expected bigint (ctrl), string (path), ArrayBuffer or Uint8Array").ThrowAsJavaScriptException();
return env.Undefined();
}
chat_ctrl ctrl = FromChatCtrlBigInt(args[0]);
std::string path = args[1].As<String>().Utf8Value();
ArrayBuffer ab = args[2].As<ArrayBuffer>();
char* data = static_cast<char*>(ab.Data());
size_t len = ab.ByteLength();
char* data;
size_t len;
if (args[2].IsArrayBuffer()) {
ArrayBuffer ab = args[2].As<ArrayBuffer>();
data = static_cast<char*>(ab.Data());
len = ab.ByteLength();
} else {
TypedArray view = args[2].As<TypedArray>();
data = static_cast<char*>(view.ArrayBuffer().Data()) + view.ByteOffset();
len = view.ByteLength();
}
if (len > static_cast<size_t>(INT_MAX)) {
RangeError::New(env, "Buffer is too large").ThrowAsJavaScriptException();
return env.Undefined();
}
Function cb;
Promise promise = CreatePromiseAndCallback(env, cb);
auto execute_fn = [ctrl, path, ab, data, len](ResultAsyncWorker* worker) {
(void)ab; // to keep ArrayBuffer alive
auto execute_fn = [ctrl, path, data, len](ResultAsyncWorker* worker) {
char* c_res = chat_write_file(ctrl, path.c_str(), data, static_cast<int>(len));
HandleCResult(worker, c_res, "chat_write_file");
};
ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn));
worker->KeepAlive(args[2].As<Object>());
worker->Queue();
return promise;
@@ -410,10 +637,19 @@ Value ChatDecryptFile(const CallbackInfo& args) {
Object Init(Env env, Object exports) {
haskell_init();
auto* receivers = new Receivers();
// Stopping all receivers before joining any bounds teardown by the longest in-flight receive.
env.AddCleanupHook([receivers]() {
for (auto& entry : *receivers) {
entry.second->RequestStop();
}
delete receivers;
});
exports.Set("chat_migrate_init", Function::New(env, ChatMigrateInit));
exports.Set("chat_close_store", Function::New(env, ChatCloseStore));
exports.Set("chat_migrate_init_queue", Function::New(env, ChatMigrateInitQueue));
exports.Set("chat_close_store", Function::New(env, ChatCloseStore, "chat_close_store", receivers));
exports.Set("chat_send_cmd", Function::New(env, ChatSendCmd));
exports.Set("chat_recv_msg_wait", Function::New(env, ChatRecvMsgWait));
exports.Set("chat_recv_msg_wait", Function::New(env, ChatRecvMsgWait, "chat_recv_msg_wait", receivers));
exports.Set("chat_write_file", Function::New(env, ChatWriteFile));
exports.Set("chat_read_file", Function::New(env, ChatReadFile));
exports.Set("chat_encrypt_file", Function::New(env, ChatEncryptFile));
@@ -11,11 +11,13 @@
extern "C" void hs_init(int argc, char **argv[]);
extern "C" void hs_init_with_rtsopts(int * argc, char **argv[]);
extern "C" void hs_thread_done(void);
typedef long* chat_ctrl;
// the last parameter is used to return the pointer to chat controller
extern "C" char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl);
extern "C" char *chat_migrate_init_queue(const char *path, const char *key, const char *confirm, const int queueSize, chat_ctrl *ctrl);
extern "C" char *chat_close_store(chat_ctrl ctrl);
extern "C" char *chat_reopen_store(chat_ctrl ctrl);
extern "C" char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
@@ -21,3 +21,4 @@ It automates creating and updating of the bot profile, address and bot commands
## Functions
- [run](bot.Function.run.md)
- [subscribeChatItems](bot.Function.subscribeChatItems.md)
@@ -26,7 +26,7 @@ Defined in: [src/api.ts:103](../src/api.ts#L103)
> **get** **ctrl**(): `bigint`
Defined in: [src/api.ts:329](../src/api.ts#L329)
Defined in: [src/api.ts:344](../src/api.ts#L344)
Chat controller reference
@@ -42,7 +42,7 @@ Chat controller reference
> **get** **initialized**(): `boolean`
Defined in: [src/api.ts:315](../src/api.ts#L315)
Defined in: [src/api.ts:330](../src/api.ts#L330)
Chat controller is initialized
@@ -58,7 +58,7 @@ Chat controller is initialized
> **get** **started**(): `boolean`
Defined in: [src/api.ts:322](../src/api.ts#L322)
Defined in: [src/api.ts:337](../src/api.ts#L337)
Chat controller is started
@@ -72,7 +72,7 @@ Chat controller is started
> **apiAcceptContactRequest**(`contactReqId`): `Promise`\<`Contact`\>
Defined in: [src/api.ts:731](../src/api.ts#L731)
Defined in: [src/api.ts:750](../src/api.ts#L750)
Accept contact request.
Network usage: interactive.
@@ -93,7 +93,7 @@ Network usage: interactive.
> **apiAcceptMember**(`groupId`, `groupMemberId`, `memberRole`): `Promise`\<`GroupMember`\>
Defined in: [src/api.ts:551](../src/api.ts#L551)
Defined in: [src/api.ts:570](../src/api.ts#L570)
Accept group member. Requires Admin role.
Network usage: background.
@@ -122,7 +122,7 @@ Network usage: background.
> **apiAddMember**(`groupId`, `contactId`, `memberRole`): `Promise`\<`GroupMember`\>
Defined in: [src/api.ts:531](../src/api.ts#L531)
Defined in: [src/api.ts:550](../src/api.ts#L550)
Add contact to group. Requires bot to have Admin role.
Network usage: interactive.
@@ -151,7 +151,7 @@ Network usage: interactive.
> **apiBlockMembersForAll**(`groupId`, `groupMemberIds`, `blocked`): `Promise`\<`void`\>
Defined in: [src/api.ts:571](../src/api.ts#L571)
Defined in: [src/api.ts:590](../src/api.ts#L590)
Block members. Requires Moderator role.
Network usage: background.
@@ -180,7 +180,7 @@ Network usage: background.
> **apiCancelFile**(`fileId`): `Promise`\<`void`\>
Defined in: [src/api.ts:521](../src/api.ts#L521)
Defined in: [src/api.ts:540](../src/api.ts#L540)
Cancel file.
Network usage: background.
@@ -199,9 +199,9 @@ Network usage: background.
### apiChatItemReaction()
> **apiChatItemReaction**(`chatType`, `chatId`, `chatItemId`, `add`, `reaction`): `Promise`\<`ChatItemDeletion`[]\>
> **apiChatItemReaction**(`chatType`, `chatId`, `chatItemId`, `add`, `reaction`): `Promise`\<`ACIReaction`\>
Defined in: [src/api.ts:495](../src/api.ts#L495)
Defined in: [src/api.ts:513](../src/api.ts#L513)
Add/remove message reaction.
Network usage: background.
@@ -230,15 +230,15 @@ Network usage: background.
#### Returns
`Promise`\<`ChatItemDeletion`[]\>
`Promise`\<`ACIReaction`\>
***
### apiConnect()
> **apiConnect**(`userId`, `incognito`, `preparedLink?`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\>
> **apiConnect**(`userId`, `incognito`, `preparedLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\>
Defined in: [src/api.ts:700](../src/api.ts#L700)
Defined in: [src/api.ts:719](../src/api.ts#L719)
Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link
Network usage: interactive.
@@ -253,7 +253,7 @@ Network usage: interactive.
`boolean`
##### preparedLink?
##### preparedLink
`CreatedConnLink`
@@ -267,7 +267,7 @@ Network usage: interactive.
> **apiConnectActiveUser**(`connLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\>
Defined in: [src/api.ts:709](../src/api.ts#L709)
Defined in: [src/api.ts:728](../src/api.ts#L728)
Connect via SimpleX link as string in the active user profile.
Network usage: interactive.
@@ -288,7 +288,7 @@ Network usage: interactive.
> **apiConnectPlan**(`userId`, `connectionLink`): `Promise`\<\[`ConnectionPlan`, `CreatedConnLink`\]\>
Defined in: [src/api.ts:690](../src/api.ts#L690)
Defined in: [src/api.ts:709](../src/api.ts#L709)
Determine SimpleX link type and if the bot is already connected via this link.
Network usage: interactive.
@@ -313,7 +313,7 @@ Network usage: interactive.
> **apiCreateActiveUser**(`profile?`): `Promise`\<`User`\>
Defined in: [src/api.ts:849](../src/api.ts#L849)
Defined in: [src/api.ts:887](../src/api.ts#L887)
Create new user profile
Network usage: no.
@@ -334,7 +334,7 @@ Network usage: no.
> **apiCreateGroupLink**(`groupId`, `memberRole`): `Promise`\<`string`\>
Defined in: [src/api.ts:631](../src/api.ts#L631)
Defined in: [src/api.ts:650](../src/api.ts#L650)
Create group link.
Network usage: interactive.
@@ -359,7 +359,7 @@ Network usage: interactive.
> **apiCreateLink**(`userId`): `Promise`\<`string`\>
Defined in: [src/api.ts:677](../src/api.ts#L677)
Defined in: [src/api.ts:696](../src/api.ts#L696)
Create 1-time invitation link.
Network usage: interactive.
@@ -380,7 +380,7 @@ Network usage: interactive.
> **apiCreateMemberContact**(`groupId`, `groupMemberId`): `Promise`\<`Contact`\>
Defined in: [src/api.ts:915](../src/api.ts#L915)
Defined in: [src/api.ts:953](../src/api.ts#L953)
Create a direct message contact with a group member.
Returns the created contact.
@@ -406,7 +406,7 @@ Network usage: interactive.
> **apiCreateUserAddress**(`userId`): `Promise`\<`CreatedConnLink`\>
Defined in: [src/api.ts:346](../src/api.ts#L346)
Defined in: [src/api.ts:361](../src/api.ts#L361)
Create bot address.
Network usage: interactive.
@@ -427,7 +427,7 @@ Network usage: interactive.
> **apiDeleteChat**(`chatType`, `chatId`, `deleteMode?`): `Promise`\<`void`\>
Defined in: [src/api.ts:771](../src/api.ts#L771)
Defined in: [src/api.ts:809](../src/api.ts#L809)
Delete chat.
Network usage: background.
@@ -456,7 +456,7 @@ Network usage: background.
> **apiDeleteChatItems**(`chatType`, `chatId`, `chatItemIds`, `deleteMode`): `Promise`\<`ChatItemDeletion`[]\>
Defined in: [src/api.ts:470](../src/api.ts#L470)
Defined in: [src/api.ts:488](../src/api.ts#L488)
Delete message.
Network usage: background.
@@ -489,7 +489,7 @@ Network usage: background.
> **apiDeleteGroupLink**(`groupId`): `Promise`\<`void`\>
Defined in: [src/api.ts:653](../src/api.ts#L653)
Defined in: [src/api.ts:672](../src/api.ts#L672)
Delete group link.
Network usage: background.
@@ -510,7 +510,7 @@ Network usage: background.
> **apiDeleteMemberChatItem**(`groupId`, `chatItemIds`): `Promise`\<`ChatItemDeletion`[]\>
Defined in: [src/api.ts:485](../src/api.ts#L485)
Defined in: [src/api.ts:503](../src/api.ts#L503)
Moderate message. Requires Moderator role (and higher than message author's).
Network usage: background.
@@ -535,7 +535,7 @@ Network usage: background.
> **apiDeleteUser**(`userId`, `delSMPQueues`, `viewPwd?`): `Promise`\<`void`\>
Defined in: [src/api.ts:879](../src/api.ts#L879)
Defined in: [src/api.ts:917](../src/api.ts#L917)
Delete user profile.
Network usage: background.
@@ -564,7 +564,7 @@ Network usage: background.
> **apiDeleteUserAddress**(`userId`): `Promise`\<`void`\>
Defined in: [src/api.ts:356](../src/api.ts#L356)
Defined in: [src/api.ts:371](../src/api.ts#L371)
Deletes a user address.
Network usage: background.
@@ -585,7 +585,7 @@ Network usage: background.
> **apiGetActiveUser**(): `Promise`\<`User` \| `undefined`\>
Defined in: [src/api.ts:829](../src/api.ts#L829)
Defined in: [src/api.ts:867](../src/api.ts#L867)
Get active user profile
Network usage: no.
@@ -600,7 +600,7 @@ Network usage: no.
> **apiGetChat**(`chatType`, `chatId`, `count`): `Promise`\<`any`\>
Defined in: [src/api.ts:819](../src/api.ts#L819)
Defined in: [src/api.ts:857](../src/api.ts#L857)
Get chat items.
Network usage: no.
@@ -625,11 +625,48 @@ Network usage: no.
***
### apiGetChats()
> **apiGetChats**(`userId`, `pagination`, `query?`, `pendingConnections?`): `Promise`\<`AChat`[]\>
Defined in: [src/api.ts:794](../src/api.ts#L794)
Get chat previews (paginated).
Network usage: no.
Prefer this over apiListContacts / apiListGroups for any scan: those
methods load every record into memory in a single response and will fail
on large databases.
#### Parameters
##### userId
`number`
##### pagination
`Last`
##### query?
`ChatListQuery` = `...`
##### pendingConnections?
`boolean` = `false`
#### Returns
`Promise`\<`AChat`[]\>
***
### apiGetGroupLink()
> **apiGetGroupLink**(`groupId`): `Promise`\<`GroupLink`\>
Defined in: [src/api.ts:662](../src/api.ts#L662)
Defined in: [src/api.ts:681](../src/api.ts#L681)
Get group link.
Network usage: no.
@@ -650,7 +687,7 @@ Network usage: no.
> **apiGetGroupLinkStr**(`groupId`): `Promise`\<`string`\>
Defined in: [src/api.ts:668](../src/api.ts#L668)
Defined in: [src/api.ts:687](../src/api.ts#L687)
#### Parameters
@@ -668,7 +705,7 @@ Defined in: [src/api.ts:668](../src/api.ts#L668)
> **apiGetUserAddress**(`userId`): `Promise`\<`UserContactLink` \| `undefined`\>
Defined in: [src/api.ts:366](../src/api.ts#L366)
Defined in: [src/api.ts:381](../src/api.ts#L381)
Get bot address and settings.
Network usage: no.
@@ -689,7 +726,7 @@ Network usage: no.
> **apiJoinGroup**(`groupId`): `Promise`\<`GroupInfo`\>
Defined in: [src/api.ts:541](../src/api.ts#L541)
Defined in: [src/api.ts:560](../src/api.ts#L560)
Join group.
Network usage: interactive.
@@ -710,7 +747,7 @@ Network usage: interactive.
> **apiLeaveGroup**(`groupId`): `Promise`\<`GroupInfo`\>
Defined in: [src/api.ts:591](../src/api.ts#L591)
Defined in: [src/api.ts:610](../src/api.ts#L610)
Leave group.
Network usage: background.
@@ -731,7 +768,7 @@ Network usage: background.
> **apiListContacts**(`userId`): `Promise`\<`Contact`[]\>
Defined in: [src/api.ts:751](../src/api.ts#L751)
Defined in: [src/api.ts:770](../src/api.ts#L770)
Get contacts.
Network usage: no.
@@ -752,7 +789,7 @@ Network usage: no.
> **apiListGroups**(`userId`, `contactId?`, `search?`): `Promise`\<`GroupInfo`[]\>
Defined in: [src/api.ts:761](../src/api.ts#L761)
Defined in: [src/api.ts:780](../src/api.ts#L780)
Get groups.
Network usage: no.
@@ -781,7 +818,7 @@ Network usage: no.
> **apiListMembers**(`groupId`): `Promise`\<`GroupMember`[]\>
Defined in: [src/api.ts:601](../src/api.ts#L601)
Defined in: [src/api.ts:620](../src/api.ts#L620)
Get group members.
Network usage: no.
@@ -802,7 +839,7 @@ Network usage: no.
> **apiListUsers**(): `Promise`\<`UserInfo`[]\>
Defined in: [src/api.ts:859](../src/api.ts#L859)
Defined in: [src/api.ts:897](../src/api.ts#L897)
Get all user profiles
Network usage: no.
@@ -817,7 +854,7 @@ Network usage: no.
> **apiNewGroup**(`userId`, `groupProfile`): `Promise`\<`GroupInfo`\>
Defined in: [src/api.ts:611](../src/api.ts#L611)
Defined in: [src/api.ts:630](../src/api.ts#L630)
Create group.
Network usage: no.
@@ -842,7 +879,7 @@ Network usage: no.
> **apiReceiveFile**(`fileId`): `Promise`\<`AChatItem`\>
Defined in: [src/api.ts:511](../src/api.ts#L511)
Defined in: [src/api.ts:529](../src/api.ts#L529)
Receive file.
Network usage: no.
@@ -863,7 +900,7 @@ Network usage: no.
> **apiRejectContactRequest**(`contactReqId`): `Promise`\<`void`\>
Defined in: [src/api.ts:741](../src/api.ts#L741)
Defined in: [src/api.ts:760](../src/api.ts#L760)
Reject contact request. The user who sent the request is **not notified**.
Network usage: no.
@@ -884,7 +921,7 @@ Network usage: no.
> **apiRemoveMembers**(`groupId`, `memberIds`, `withMessages?`): `Promise`\<`GroupMember`[]\>
Defined in: [src/api.ts:581](../src/api.ts#L581)
Defined in: [src/api.ts:600](../src/api.ts#L600)
Remove members. Requires Admin role.
Network usage: background.
@@ -913,7 +950,7 @@ Network usage: background.
> **apiSendMemberContactInvitation**(`contactId`, `message?`): `Promise`\<`Contact`\>
Defined in: [src/api.ts:926](../src/api.ts#L926)
Defined in: [src/api.ts:964](../src/api.ts#L964)
Send a direct message invitation to a group member contact.
The contact must have been created with [apiCreateMemberContact](#apicreatemembercontact).
@@ -939,7 +976,7 @@ Network usage: interactive.
> **apiSendMessages**(`chat`, `messages`, `liveMessage?`): `Promise`\<`AChatItem`[]\>
Defined in: [src/api.ts:415](../src/api.ts#L415)
Defined in: [src/api.ts:432](../src/api.ts#L432)
Send messages.
Network usage: background.
@@ -968,7 +1005,7 @@ Network usage: background.
> **apiSendTextMessage**(`chat`, `text`, `inReplyTo?`): `Promise`\<`AChatItem`[]\>
Defined in: [src/api.ts:437](../src/api.ts#L437)
Defined in: [src/api.ts:455](../src/api.ts#L455)
Send text message.
Network usage: background.
@@ -997,7 +1034,7 @@ Network usage: background.
> **apiSendTextReply**(`chatItem`, `text`): `Promise`\<`AChatItem`[]\>
Defined in: [src/api.ts:445](../src/api.ts#L445)
Defined in: [src/api.ts:463](../src/api.ts#L463)
Send text message in reply to received message.
Network usage: background.
@@ -1022,7 +1059,7 @@ Network usage: background.
> **apiSetActiveUser**(`userId`, `viewPwd?`): `Promise`\<`User`\>
Defined in: [src/api.ts:869](../src/api.ts#L869)
Defined in: [src/api.ts:907](../src/api.ts#L907)
Set active user profile
Network usage: no.
@@ -1047,7 +1084,7 @@ Network usage: no.
> **apiSetAddressSettings**(`userId`, `__namedParameters`): `Promise`\<`void`\>
Defined in: [src/api.ts:398](../src/api.ts#L398)
Defined in: [src/api.ts:415](../src/api.ts#L415)
Set bot address settings.
Network usage: interactive.
@@ -1072,7 +1109,7 @@ Network usage: interactive.
> **apiSetAutoAcceptMemberContacts**(`userId`, `onOff`): `Promise`\<`void`\>
Defined in: [src/api.ts:808](../src/api.ts#L808)
Defined in: [src/api.ts:846](../src/api.ts#L846)
Set auto-accept member contacts.
Network usage: no.
@@ -1097,7 +1134,7 @@ Network usage: no.
> **apiSetContactCustomData**(`contactId`, `customData?`): `Promise`\<`void`\>
Defined in: [src/api.ts:798](../src/api.ts#L798)
Defined in: [src/api.ts:836](../src/api.ts#L836)
Set contact custom data.
Network usage: no.
@@ -1122,7 +1159,7 @@ Network usage: no.
> **apiSetContactPrefs**(`contactId`, `preferences`): `Promise`\<`void`\>
Defined in: [src/api.ts:905](../src/api.ts#L905)
Defined in: [src/api.ts:943](../src/api.ts#L943)
Configure chat preference overrides for the contact.
Network usage: background.
@@ -1147,7 +1184,7 @@ Network usage: background.
> **apiSetGroupCustomData**(`groupId`, `customData?`): `Promise`\<`void`\>
Defined in: [src/api.ts:788](../src/api.ts#L788)
Defined in: [src/api.ts:826](../src/api.ts#L826)
Set group custom data.
Network usage: no.
@@ -1172,7 +1209,7 @@ Network usage: no.
> **apiSetGroupLinkMemberRole**(`groupId`, `memberRole`): `Promise`\<`void`\>
Defined in: [src/api.ts:644](../src/api.ts#L644)
Defined in: [src/api.ts:663](../src/api.ts#L663)
Set member role for group link.
Network usage: no.
@@ -1197,7 +1234,7 @@ Network usage: no.
> **apiSetMembersRole**(`groupId`, `groupMemberIds`, `memberRole`): `Promise`\<`void`\>
Defined in: [src/api.ts:561](../src/api.ts#L561)
Defined in: [src/api.ts:580](../src/api.ts#L580)
Set members role. Requires Admin role.
Network usage: background.
@@ -1226,7 +1263,7 @@ Network usage: background.
> **apiSetProfileAddress**(`userId`, `enable`): `Promise`\<`UserProfileUpdateSummary`\>
Defined in: [src/api.ts:384](../src/api.ts#L384)
Defined in: [src/api.ts:399](../src/api.ts#L399)
Add address to bot profile.
Network usage: interactive.
@@ -1251,7 +1288,7 @@ Network usage: interactive.
> **apiUpdateChatItem**(`chatType`, `chatId`, `chatItemId`, `msgContent`, `liveMessage`): `Promise`\<`ChatItem`\>
Defined in: [src/api.ts:453](../src/api.ts#L453)
Defined in: [src/api.ts:471](../src/api.ts#L471)
Update message.
Network usage: background.
@@ -1288,7 +1325,7 @@ Network usage: background.
> **apiUpdateGroupProfile**(`groupId`, `groupProfile`): `Promise`\<`GroupInfo`\>
Defined in: [src/api.ts:621](../src/api.ts#L621)
Defined in: [src/api.ts:640](../src/api.ts#L640)
Update group profile.
Network usage: background.
@@ -1313,7 +1350,7 @@ Network usage: background.
> **apiUpdateProfile**(`userId`, `profile`): `Promise`\<`UserProfileUpdateSummary` \| `undefined`\>
Defined in: [src/api.ts:889](../src/api.ts#L889)
Defined in: [src/api.ts:927](../src/api.ts#L927)
Update user profile.
Network usage: background.
@@ -1338,9 +1375,10 @@ Network usage: background.
> **close**(): `Promise`\<`void`\>
Defined in: [src/api.ts:148](../src/api.ts#L148)
Defined in: [src/api.ts:158](../src/api.ts#L158)
Close chat database.
Stop chat controller and close chat database.
The database is not closed if stopping fails.
Usually doesn't need to be called in chat bots.
#### Returns
@@ -1353,7 +1391,7 @@ Usually doesn't need to be called in chat bots.
> **off**\<`K`\>(`event`, `subscriber?`): `void`
Defined in: [src/api.ts:287](../src/api.ts#L287)
Defined in: [src/api.ts:302](../src/api.ts#L302)
Unsubscribe all or a specific handler from a specific event.
@@ -1387,7 +1425,7 @@ An optional subscriber function for the event.
> **offAny**(`receiver?`): `void`
Defined in: [src/api.ts:303](../src/api.ts#L303)
Defined in: [src/api.ts:318](../src/api.ts#L318)
Unsubscribe all or a specific handler from any events.
@@ -1411,7 +1449,7 @@ An optional subscriber function for the event.
> **on**\<`K`\>(`subscribers`): `void`
Defined in: [src/api.ts:197](../src/api.ts#L197)
Defined in: [src/api.ts:212](../src/api.ts#L212)
Subscribe multiple event handlers at once.
@@ -1441,7 +1479,7 @@ If the same function is subscribed to event.
> **on**\<`K`\>(`event`, `subscriber`): `void`
Defined in: [src/api.ts:205](../src/api.ts#L205)
Defined in: [src/api.ts:220](../src/api.ts#L220)
Subscribe a handler to a specific event.
@@ -1479,7 +1517,7 @@ If the same function is subscribed to event.
> **onAny**(`receiver`): `void`
Defined in: [src/api.ts:228](../src/api.ts#L228)
Defined in: [src/api.ts:243](../src/api.ts#L243)
Subscribe a handler to any event.
@@ -1505,7 +1543,7 @@ If the same function is subscribed to event.
> **once**\<`K`\>(`event`, `subscriber`): `void`
Defined in: [src/api.ts:239](../src/api.ts#L239)
Defined in: [src/api.ts:254](../src/api.ts#L254)
Subscribe a handler to a specific event to be delivered one time.
@@ -1543,13 +1581,13 @@ If the same function is subscribed to event.
> **recvChatEvent**(`wait?`): `Promise`\<`ChatEvent` \| `undefined`\>
Defined in: [src/api.ts:338](../src/api.ts#L338)
Defined in: [src/api.ts:353](../src/api.ts#L353)
#### Parameters
##### wait?
`number` = `5_000_000`
`number` = `500_000`
#### Returns
@@ -1561,7 +1599,7 @@ Defined in: [src/api.ts:338](../src/api.ts#L338)
> **sendChatCmd**(`cmd`): `Promise`\<`ChatResponse`\>
Defined in: [src/api.ts:334](../src/api.ts#L334)
Defined in: [src/api.ts:349](../src/api.ts#L349)
#### Parameters
@@ -1579,7 +1617,7 @@ Defined in: [src/api.ts:334](../src/api.ts#L334)
> **startChat**(): `Promise`\<`void`\>
Defined in: [src/api.ts:122](../src/api.ts#L122)
Defined in: [src/api.ts:124](../src/api.ts#L124)
Start chat controller. Must be called with the existing user profile.
@@ -1593,10 +1631,10 @@ Start chat controller. Must be called with the existing user profile.
> **stopChat**(): `Promise`\<`void`\>
Defined in: [src/api.ts:136](../src/api.ts#L136)
Defined in: [src/api.ts:147](../src/api.ts#L147)
Stop chat controller.
Must be called before closing the database.
`close` calls it before closing the database.
Usually doesn't need to be called in chat bots.
#### Returns
@@ -1611,7 +1649,7 @@ Usually doesn't need to be called in chat bots.
> **wait**\<`K`\>(`event`): `Promise`\<`ChatEvent` & `object`\>
Defined in: [src/api.ts:247](../src/api.ts#L247)
Defined in: [src/api.ts:262](../src/api.ts#L262)
Waits for specific event, with an optional predicate.
Returns `undefined` on timeout if specified.
@@ -1636,7 +1674,7 @@ Returns `undefined` on timeout if specified.
> **wait**\<`K`\>(`event`, `predicate`): `Promise`\<`ChatEvent` & `object`\>
Defined in: [src/api.ts:248](../src/api.ts#L248)
Defined in: [src/api.ts:263](../src/api.ts#L263)
Waits for specific event, with an optional predicate.
Returns `undefined` on timeout if specified.
@@ -1665,7 +1703,7 @@ Returns `undefined` on timeout if specified.
> **wait**\<`K`\>(`event`, `timeout`): `Promise`\<ChatEvent & \{ type: K; \} \| `undefined`\>
Defined in: [src/api.ts:249](../src/api.ts#L249)
Defined in: [src/api.ts:264](../src/api.ts#L264)
Waits for specific event, with an optional predicate.
Returns `undefined` on timeout if specified.
@@ -1694,7 +1732,7 @@ Returns `undefined` on timeout if specified.
> **wait**\<`K`\>(`event`, `predicate`, `timeout`): `Promise`\<ChatEvent & \{ type: K; \} \| `undefined`\>
Defined in: [src/api.ts:250](../src/api.ts#L250)
Defined in: [src/api.ts:265](../src/api.ts#L265)
Waits for specific event, with an optional predicate.
Returns `undefined` on timeout if specified.
@@ -1727,9 +1765,9 @@ Returns `undefined` on timeout if specified.
### init()
> `static` **init**(`db`, `confirm?`): `Promise`\<`ChatApi`\>
> `static` **init**(`db`, `confirm?`, `queueSize?`): `Promise`\<`ChatApi`\>
Defined in: [src/api.ts:110](../src/api.ts#L110)
Defined in: [src/api.ts:111](../src/api.ts#L111)
Initializes the ChatApi.
@@ -1747,6 +1785,12 @@ Database configuration (sqlite or postgres).
Migration confirmation mode.
##### queueSize?
`number`
Size of internal queues, the core default is used when omitted.
#### Returns
`Promise`\<`ChatApi`\>
@@ -8,7 +8,7 @@
> **run**(`__namedParameters`): `Promise`\<\[[`ChatApi`](api.Class.ChatApi.md), `User`, `UserContactLink` \| `undefined`\]\>
Defined in: [src/bot.ts:47](../src/bot.ts#L47)
Defined in: [src/bot.ts:48](../src/bot.ts#L48)
## Parameters
@@ -0,0 +1,27 @@
[**simplex-chat**](README.md)
***
[simplex-chat](README.md) / [bot](Namespace.bot.md) / subscribeChatItems
# Function: subscribeChatItems()
> **subscribeChatItems**(`bot`, `onMessage`, `commands`): `void`
Defined in: [src/bot.ts:108](../src/bot.ts#L108)
## Parameters
### bot
[`ChatApi`](api.Class.ChatApi.md)
### onMessage
((`chatItem`, `content`) => `void` \| `Promise`\<`void`\>) \| `undefined`
### commands
## Returns
`void`
@@ -6,7 +6,7 @@
# Interface: BotConfig
Defined in: [src/bot.ts:35](../src/bot.ts#L35)
Defined in: [src/bot.ts:36](../src/bot.ts#L36)
## Properties
@@ -14,7 +14,7 @@ Defined in: [src/bot.ts:35](../src/bot.ts#L35)
> **dbOpts**: [`BotDbOpts`](bot.TypeAlias.BotDbOpts.md)
Defined in: [src/bot.ts:37](../src/bot.ts#L37)
Defined in: [src/bot.ts:38](../src/bot.ts#L38)
***
@@ -22,7 +22,7 @@ Defined in: [src/bot.ts:37](../src/bot.ts#L37)
> `optional` **events?**: [`EventSubscribers`](api.TypeAlias.EventSubscribers.md)
Defined in: [src/bot.ts:44](../src/bot.ts#L44)
Defined in: [src/bot.ts:45](../src/bot.ts#L45)
***
@@ -30,7 +30,7 @@ Defined in: [src/bot.ts:44](../src/bot.ts#L44)
> `optional` **onCommands?**: `object`
Defined in: [src/bot.ts:41](../src/bot.ts#L41)
Defined in: [src/bot.ts:42](../src/bot.ts#L42)
#### Index Signature
@@ -42,7 +42,7 @@ Defined in: [src/bot.ts:41](../src/bot.ts#L41)
> `optional` **onMessage?**: (`chatItem`, `content`) => `void` \| `Promise`\<`void`\>
Defined in: [src/bot.ts:39](../src/bot.ts#L39)
Defined in: [src/bot.ts:40](../src/bot.ts#L40)
#### Parameters
@@ -64,7 +64,7 @@ Defined in: [src/bot.ts:39](../src/bot.ts#L39)
> **options**: [`BotOptions`](bot.Interface.BotOptions.md)
Defined in: [src/bot.ts:38](../src/bot.ts#L38)
Defined in: [src/bot.ts:39](../src/bot.ts#L39)
***
@@ -72,4 +72,4 @@ Defined in: [src/bot.ts:38](../src/bot.ts#L38)
> **profile**: `Profile`
Defined in: [src/bot.ts:36](../src/bot.ts#L36)
Defined in: [src/bot.ts:37](../src/bot.ts#L37)
@@ -6,7 +6,7 @@
# Interface: BotOptions
Defined in: [src/bot.ts:11](../src/bot.ts#L11)
Defined in: [src/bot.ts:12](../src/bot.ts#L12)
## Properties
@@ -14,7 +14,7 @@ Defined in: [src/bot.ts:11](../src/bot.ts#L11)
> `optional` **addressSettings?**: [`BotAddressSettings`](api.Interface.BotAddressSettings.md)
Defined in: [src/bot.ts:15](../src/bot.ts#L15)
Defined in: [src/bot.ts:16](../src/bot.ts#L16)
***
@@ -22,7 +22,7 @@ Defined in: [src/bot.ts:15](../src/bot.ts#L15)
> `optional` **allowFiles?**: `boolean`
Defined in: [src/bot.ts:16](../src/bot.ts#L16)
Defined in: [src/bot.ts:17](../src/bot.ts#L17)
***
@@ -30,7 +30,7 @@ Defined in: [src/bot.ts:16](../src/bot.ts#L16)
> `optional` **commands?**: `ChatBotCommand`[]
Defined in: [src/bot.ts:17](../src/bot.ts#L17)
Defined in: [src/bot.ts:18](../src/bot.ts#L18)
***
@@ -38,7 +38,7 @@ Defined in: [src/bot.ts:17](../src/bot.ts#L17)
> `optional` **createAddress?**: `boolean`
Defined in: [src/bot.ts:12](../src/bot.ts#L12)
Defined in: [src/bot.ts:13](../src/bot.ts#L13)
***
@@ -46,7 +46,7 @@ Defined in: [src/bot.ts:12](../src/bot.ts#L12)
> `optional` **logContacts?**: `boolean`
Defined in: [src/bot.ts:19](../src/bot.ts#L19)
Defined in: [src/bot.ts:20](../src/bot.ts#L20)
***
@@ -54,7 +54,7 @@ Defined in: [src/bot.ts:19](../src/bot.ts#L19)
> `optional` **logNetwork?**: `boolean`
Defined in: [src/bot.ts:20](../src/bot.ts#L20)
Defined in: [src/bot.ts:21](../src/bot.ts#L21)
***
@@ -62,7 +62,7 @@ Defined in: [src/bot.ts:20](../src/bot.ts#L20)
> `optional` **updateAddress?**: `boolean`
Defined in: [src/bot.ts:13](../src/bot.ts#L13)
Defined in: [src/bot.ts:14](../src/bot.ts#L14)
***
@@ -70,7 +70,7 @@ Defined in: [src/bot.ts:13](../src/bot.ts#L13)
> `optional` **updateProfile?**: `boolean`
Defined in: [src/bot.ts:14](../src/bot.ts#L14)
Defined in: [src/bot.ts:15](../src/bot.ts#L15)
***
@@ -78,4 +78,4 @@ Defined in: [src/bot.ts:14](../src/bot.ts#L14)
> `optional` **useBotProfile?**: `boolean`
Defined in: [src/bot.ts:18](../src/bot.ts#L18)
Defined in: [src/bot.ts:19](../src/bot.ts#L19)
@@ -15,3 +15,7 @@ Defined in: [src/bot.ts:7](../src/bot.ts#L7)
### confirmMigrations?
> `optional` **confirmMigrations?**: [`MigrationConfirmation`](core.Enumeration.MigrationConfirmation.md)
### queueSize?
> `optional` **queueSize?**: `number`
@@ -6,7 +6,7 @@
# Class: ChatAPIError
Defined in: [src/core.ts:92](../src/core.ts#L92)
Defined in: [src/core.ts:95](../src/core.ts#L95)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:92](../src/core.ts#L92)
> **new ChatAPIError**(`message`, `chatError?`): `ChatAPIError`
Defined in: [src/core.ts:93](../src/core.ts#L93)
Defined in: [src/core.ts:96](../src/core.ts#L96)
#### Parameters
@@ -44,7 +44,7 @@ Defined in: [src/core.ts:93](../src/core.ts#L93)
> **chatError**: `ChatError` \| `undefined` = `undefined`
Defined in: [src/core.ts:93](../src/core.ts#L93)
Defined in: [src/core.ts:96](../src/core.ts#L96)
***
@@ -52,7 +52,7 @@ Defined in: [src/core.ts:93](../src/core.ts#L93)
> **message**: `string`
Defined in: [src/core.ts:93](../src/core.ts#L93)
Defined in: [src/core.ts:96](../src/core.ts#L96)
#### Inherited from
@@ -6,7 +6,7 @@
# Class: ChatInitError
Defined in: [src/core.ts:116](../src/core.ts#L116)
Defined in: [src/core.ts:119](../src/core.ts#L119)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:116](../src/core.ts#L116)
> **new ChatInitError**(`message`, `dbMigrationError`): `ChatInitError`
Defined in: [src/core.ts:117](../src/core.ts#L117)
Defined in: [src/core.ts:120](../src/core.ts#L120)
#### Parameters
@@ -44,7 +44,7 @@ Defined in: [src/core.ts:117](../src/core.ts#L117)
> **dbMigrationError**: [`DBMigrationError`](core.TypeAlias.DBMigrationError.md)
Defined in: [src/core.ts:117](../src/core.ts#L117)
Defined in: [src/core.ts:120](../src/core.ts#L120)
***
@@ -52,7 +52,7 @@ Defined in: [src/core.ts:117](../src/core.ts#L117)
> **message**: `string`
Defined in: [src/core.ts:117](../src/core.ts#L117)
Defined in: [src/core.ts:120](../src/core.ts#L120)
#### Inherited from
@@ -6,7 +6,7 @@
# Interface: ErrorMigration
Defined in: [src/core.ts:144](../src/core.ts#L144)
Defined in: [src/core.ts:152](../src/core.ts#L152)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:144](../src/core.ts#L144)
> **dbFile**: `string`
Defined in: [src/core.ts:146](../src/core.ts#L146)
Defined in: [src/core.ts:154](../src/core.ts#L154)
***
@@ -26,7 +26,7 @@ Defined in: [src/core.ts:146](../src/core.ts#L146)
> **migrationError**: [`MigrationError`](core.TypeAlias.MigrationError.md)
Defined in: [src/core.ts:147](../src/core.ts#L147)
Defined in: [src/core.ts:155](../src/core.ts#L155)
***
@@ -34,7 +34,7 @@ Defined in: [src/core.ts:147](../src/core.ts#L147)
> **type**: `"errorMigration"`
Defined in: [src/core.ts:145](../src/core.ts#L145)
Defined in: [src/core.ts:153](../src/core.ts#L153)
#### Overrides
@@ -6,7 +6,7 @@
# Interface: ErrorNotADatabase
Defined in: [src/core.ts:139](../src/core.ts#L139)
Defined in: [src/core.ts:147](../src/core.ts#L147)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:139](../src/core.ts#L139)
> **dbFile**: `string`
Defined in: [src/core.ts:141](../src/core.ts#L141)
Defined in: [src/core.ts:149](../src/core.ts#L149)
***
@@ -26,7 +26,7 @@ Defined in: [src/core.ts:141](../src/core.ts#L141)
> **type**: `"errorNotADatabase"`
Defined in: [src/core.ts:140](../src/core.ts#L140)
Defined in: [src/core.ts:148](../src/core.ts#L148)
#### Overrides
@@ -6,7 +6,7 @@
# Interface: ErrorSQL
Defined in: [src/core.ts:150](../src/core.ts#L150)
Defined in: [src/core.ts:158](../src/core.ts#L158)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:150](../src/core.ts#L150)
> **dbFile**: `string`
Defined in: [src/core.ts:152](../src/core.ts#L152)
Defined in: [src/core.ts:160](../src/core.ts#L160)
***
@@ -26,7 +26,7 @@ Defined in: [src/core.ts:152](../src/core.ts#L152)
> **migrationSQLError**: `string`
Defined in: [src/core.ts:153](../src/core.ts#L153)
Defined in: [src/core.ts:161](../src/core.ts#L161)
***
@@ -34,7 +34,7 @@ Defined in: [src/core.ts:153](../src/core.ts#L153)
> **type**: `"errorSQL"`
Defined in: [src/core.ts:151](../src/core.ts#L151)
Defined in: [src/core.ts:159](../src/core.ts#L159)
#### Overrides
@@ -6,7 +6,7 @@
# Interface: InvalidConfirmation
Defined in: [src/core.ts:135](../src/core.ts#L135)
Defined in: [src/core.ts:139](../src/core.ts#L139)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:135](../src/core.ts#L135)
> **type**: `"invalidConfirmation"`
Defined in: [src/core.ts:136](../src/core.ts#L136)
Defined in: [src/core.ts:140](../src/core.ts#L140)
#### Overrides
@@ -0,0 +1,25 @@
[**simplex-chat**](README.md)
***
[simplex-chat](README.md) / [core](Namespace.core.md) / [DBMigrationError](core.Namespace.DBMigrationError.md) / InvalidQueueSize
# Interface: InvalidQueueSize
Defined in: [src/core.ts:143](../src/core.ts#L143)
## Extends
- `Interface`
## Properties
### type
> **type**: `"invalidQueueSize"`
Defined in: [src/core.ts:144](../src/core.ts#L144)
#### Overrides
`Interface.type`
@@ -6,6 +6,6 @@
# Type Alias: Tag
> **Tag** = `"invalidConfirmation"` \| `"errorNotADatabase"` \| `"errorMigration"` \| `"errorSQL"`
> **Tag** = `"invalidConfirmation"` \| `"invalidQueueSize"` \| `"errorNotADatabase"` \| `"errorMigration"` \| `"errorSQL"`
Defined in: [src/core.ts:129](../src/core.ts#L129)
Defined in: [src/core.ts:133](../src/core.ts#L133)
@@ -6,7 +6,7 @@
# Enumeration: MigrationConfirmation
Defined in: [src/core.ts:101](../src/core.ts#L101)
Defined in: [src/core.ts:104](../src/core.ts#L104)
Migration confirmation mode
@@ -16,7 +16,7 @@ Migration confirmation mode
> **Console**: `"console"`
Defined in: [src/core.ts:104](../src/core.ts#L104)
Defined in: [src/core.ts:107](../src/core.ts#L107)
***
@@ -24,7 +24,7 @@ Defined in: [src/core.ts:104](../src/core.ts#L104)
> **Error**: `"error"`
Defined in: [src/core.ts:105](../src/core.ts#L105)
Defined in: [src/core.ts:108](../src/core.ts#L108)
***
@@ -32,7 +32,7 @@ Defined in: [src/core.ts:105](../src/core.ts#L105)
> **YesUp**: `"yesUp"`
Defined in: [src/core.ts:102](../src/core.ts#L102)
Defined in: [src/core.ts:105](../src/core.ts#L105)
***
@@ -40,4 +40,4 @@ Defined in: [src/core.ts:102](../src/core.ts#L102)
> **YesUpDown**: `"yesUpDown"`
Defined in: [src/core.ts:103](../src/core.ts#L103)
Defined in: [src/core.ts:106](../src/core.ts#L106)
@@ -8,7 +8,7 @@
> **chatCloseStore**(`ctrl`): `Promise`\<`void`\>
Defined in: [src/core.ts:17](../src/core.ts#L17)
Defined in: [src/core.ts:20](../src/core.ts#L20)
Close chat store
@@ -8,7 +8,7 @@
> **chatDecryptFile**(`fromPath`, `__namedParameters`, `toPath`): `Promise`\<`void`\>
Defined in: [src/core.ts:73](../src/core.ts#L73)
Defined in: [src/core.ts:76](../src/core.ts#L76)
Decrypt file
@@ -8,7 +8,7 @@
> **chatEncryptFile**(`ctrl`, `fromPath`, `toPath`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\>
Defined in: [src/core.ts:65](../src/core.ts#L65)
Defined in: [src/core.ts:68](../src/core.ts#L68)
Encrypt file
@@ -6,9 +6,9 @@
# Function: chatMigrateInit()
> **chatMigrateInit**(`dbPath`, `dbKey`, `confirm`): `Promise`\<`bigint`\>
> **chatMigrateInit**(`dbPath`, `dbKey`, `confirm`, `queueSize?`): `Promise`\<`bigint`\>
Defined in: [src/core.ts:7](../src/core.ts#L7)
Defined in: [src/core.ts:8](../src/core.ts#L8)
Initialize chat controller
@@ -26,6 +26,12 @@ Initialize chat controller
[`MigrationConfirmation`](core.Enumeration.MigrationConfirmation.md)
### queueSize?
`number`
Size of internal queues, the core default is used when omitted.
## Returns
`Promise`\<`bigint`\>
@@ -6,9 +6,9 @@
# Function: chatReadFile()
> **chatReadFile**(`path`, `__namedParameters`): `Promise`\<`ArrayBuffer`\>
> **chatReadFile**(`path`, `__namedParameters`): `Promise`\<`Buffer`\<`ArrayBufferLike`\>\>
Defined in: [src/core.ts:58](../src/core.ts#L58)
Defined in: [src/core.ts:61](../src/core.ts#L61)
Read buffer from encrypted file
@@ -24,4 +24,4 @@ Read buffer from encrypted file
## Returns
`Promise`\<`ArrayBuffer`\>
`Promise`\<`Buffer`\<`ArrayBufferLike`\>\>
@@ -8,7 +8,7 @@
> **chatRecvMsgWait**(`ctrl`, `wait`): `Promise`\<`ChatEvent` \| `undefined`\>
Defined in: [src/core.ts:37](../src/core.ts#L37)
Defined in: [src/core.ts:40](../src/core.ts#L40)
Receive chat event
@@ -8,7 +8,7 @@
> **chatSendCmd**(`ctrl`, `cmd`): `Promise`\<`ChatResponse`\>
Defined in: [src/core.ts:25](../src/core.ts#L25)
Defined in: [src/core.ts:28](../src/core.ts#L28)
Send chat command as string
@@ -8,7 +8,7 @@
> **chatWriteFile**(`ctrl`, `path`, `buffer`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\>
Defined in: [src/core.ts:50](../src/core.ts#L50)
Defined in: [src/core.ts:53](../src/core.ts#L53)
Write buffer to encrypted file
@@ -24,7 +24,7 @@ Write buffer to encrypted file
### buffer
`ArrayBuffer`
`ArrayBuffer` \| `Uint8Array`\<`ArrayBufferLike`\>
## Returns
@@ -6,7 +6,7 @@
# Interface: APIResult\<R\>
Defined in: [src/core.ts:87](../src/core.ts#L87)
Defined in: [src/core.ts:90](../src/core.ts#L90)
## Type Parameters
@@ -20,7 +20,7 @@ Defined in: [src/core.ts:87](../src/core.ts#L87)
> `optional` **error?**: `ChatError`
Defined in: [src/core.ts:89](../src/core.ts#L89)
Defined in: [src/core.ts:92](../src/core.ts#L92)
***
@@ -28,4 +28,4 @@ Defined in: [src/core.ts:89](../src/core.ts#L89)
> `optional` **result?**: `R`
Defined in: [src/core.ts:88](../src/core.ts#L88)
Defined in: [src/core.ts:91](../src/core.ts#L91)
@@ -6,7 +6,7 @@
# Interface: CryptoArgs
Defined in: [src/core.ts:111](../src/core.ts#L111)
Defined in: [src/core.ts:114](../src/core.ts#L114)
File encryption key and nonce
@@ -16,7 +16,7 @@ File encryption key and nonce
> **fileKey**: `string`
Defined in: [src/core.ts:112](../src/core.ts#L112)
Defined in: [src/core.ts:115](../src/core.ts#L115)
***
@@ -24,4 +24,4 @@ Defined in: [src/core.ts:112](../src/core.ts#L112)
> **fileNonce**: `string`
Defined in: [src/core.ts:113](../src/core.ts#L113)
Defined in: [src/core.ts:116](../src/core.ts#L116)
@@ -6,7 +6,7 @@
# Interface: UpMigration
Defined in: [src/core.ts:185](../src/core.ts#L185)
Defined in: [src/core.ts:193](../src/core.ts#L193)
## Properties
@@ -14,7 +14,7 @@ Defined in: [src/core.ts:185](../src/core.ts#L185)
> **upName**: `string`
Defined in: [src/core.ts:186](../src/core.ts#L186)
Defined in: [src/core.ts:194](../src/core.ts#L194)
***
@@ -22,4 +22,4 @@ Defined in: [src/core.ts:186](../src/core.ts#L186)
> **withDown**: `boolean`
Defined in: [src/core.ts:187](../src/core.ts#L187)
Defined in: [src/core.ts:195](../src/core.ts#L195)
@@ -6,7 +6,7 @@
# Interface: MTREDifferent
Defined in: [src/core.ts:206](../src/core.ts#L206)
Defined in: [src/core.ts:214](../src/core.ts#L214)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:206](../src/core.ts#L206)
> **downMigrations**: `string`[]
Defined in: [src/core.ts:208](../src/core.ts#L208)
Defined in: [src/core.ts:216](../src/core.ts#L216)
***
@@ -26,7 +26,7 @@ Defined in: [src/core.ts:208](../src/core.ts#L208)
> **type**: `"different"`
Defined in: [src/core.ts:207](../src/core.ts#L207)
Defined in: [src/core.ts:215](../src/core.ts#L215)
#### Overrides
@@ -6,7 +6,7 @@
# Interface: MTRENoDown
Defined in: [src/core.ts:201](../src/core.ts#L201)
Defined in: [src/core.ts:209](../src/core.ts#L209)
## Extends
@@ -14,20 +14,20 @@ Defined in: [src/core.ts:201](../src/core.ts#L201)
## Properties
### dbMigrations
> **dbMigrations**: `string`[]
Defined in: [src/core.ts:211](../src/core.ts#L211)
***
### type
> **type**: `"noDown"`
Defined in: [src/core.ts:202](../src/core.ts#L202)
Defined in: [src/core.ts:210](../src/core.ts#L210)
#### Overrides
`Interface.type`
***
### upMigrations
> **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md)
Defined in: [src/core.ts:203](../src/core.ts#L203)
@@ -8,4 +8,4 @@
> **Tag** = `"noDown"` \| `"different"`
Defined in: [src/core.ts:195](../src/core.ts#L195)
Defined in: [src/core.ts:203](../src/core.ts#L203)
@@ -6,7 +6,7 @@
# Interface: MEDowngrade
Defined in: [src/core.ts:174](../src/core.ts#L174)
Defined in: [src/core.ts:182](../src/core.ts#L182)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:174](../src/core.ts#L174)
> **downMigrations**: `string`[]
Defined in: [src/core.ts:176](../src/core.ts#L176)
Defined in: [src/core.ts:184](../src/core.ts#L184)
***
@@ -26,7 +26,7 @@ Defined in: [src/core.ts:176](../src/core.ts#L176)
> **type**: `"downgrade"`
Defined in: [src/core.ts:175](../src/core.ts#L175)
Defined in: [src/core.ts:183](../src/core.ts#L183)
#### Overrides
@@ -6,7 +6,7 @@
# Interface: MEUpgrade
Defined in: [src/core.ts:169](../src/core.ts#L169)
Defined in: [src/core.ts:177](../src/core.ts#L177)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:169](../src/core.ts#L169)
> **type**: `"upgrade"`
Defined in: [src/core.ts:170](../src/core.ts#L170)
Defined in: [src/core.ts:178](../src/core.ts#L178)
#### Overrides
@@ -28,6 +28,6 @@ Defined in: [src/core.ts:170](../src/core.ts#L170)
### upMigrations
> **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md)
> **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md)[]
Defined in: [src/core.ts:171](../src/core.ts#L171)
Defined in: [src/core.ts:179](../src/core.ts#L179)
@@ -6,7 +6,7 @@
# Interface: MigrationError
Defined in: [src/core.ts:179](../src/core.ts#L179)
Defined in: [src/core.ts:187](../src/core.ts#L187)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:179](../src/core.ts#L179)
> **mtrError**: [`MTRError`](core.TypeAlias.MTRError.md)
Defined in: [src/core.ts:181](../src/core.ts#L181)
Defined in: [src/core.ts:189](../src/core.ts#L189)
***
@@ -26,7 +26,7 @@ Defined in: [src/core.ts:181](../src/core.ts#L181)
> **type**: `"migrationError"`
Defined in: [src/core.ts:180](../src/core.ts#L180)
Defined in: [src/core.ts:188](../src/core.ts#L188)
#### Overrides
@@ -8,4 +8,4 @@
> **Tag** = `"upgrade"` \| `"downgrade"` \| `"migrationError"`
Defined in: [src/core.ts:163](../src/core.ts#L163)
Defined in: [src/core.ts:171](../src/core.ts#L171)
@@ -12,6 +12,7 @@
- [ErrorNotADatabase](core.DBMigrationError.Interface.ErrorNotADatabase.md)
- [ErrorSQL](core.DBMigrationError.Interface.ErrorSQL.md)
- [InvalidConfirmation](core.DBMigrationError.Interface.InvalidConfirmation.md)
- [InvalidQueueSize](core.DBMigrationError.Interface.InvalidQueueSize.md)
## Type Aliases
@@ -6,6 +6,6 @@
# Type Alias: DBMigrationError
> **DBMigrationError** = [`InvalidConfirmation`](core.DBMigrationError.Interface.InvalidConfirmation.md) \| [`ErrorNotADatabase`](core.DBMigrationError.Interface.ErrorNotADatabase.md) \| [`ErrorMigration`](core.DBMigrationError.Interface.ErrorMigration.md) \| [`ErrorSQL`](core.DBMigrationError.Interface.ErrorSQL.md)
> **DBMigrationError** = [`InvalidConfirmation`](core.DBMigrationError.Interface.InvalidConfirmation.md) \| [`InvalidQueueSize`](core.DBMigrationError.Interface.InvalidQueueSize.md) \| [`ErrorNotADatabase`](core.DBMigrationError.Interface.ErrorNotADatabase.md) \| [`ErrorMigration`](core.DBMigrationError.Interface.ErrorMigration.md) \| [`ErrorSQL`](core.DBMigrationError.Interface.ErrorSQL.md)
Defined in: [src/core.ts:122](../src/core.ts#L122)
Defined in: [src/core.ts:125](../src/core.ts#L125)
@@ -8,4 +8,4 @@
> **MTRError** = [`MTRENoDown`](core.MTRError.Interface.MTRENoDown.md) \| [`MTREDifferent`](core.MTRError.Interface.MTREDifferent.md)
Defined in: [src/core.ts:190](../src/core.ts#L190)
Defined in: [src/core.ts:198](../src/core.ts#L198)
@@ -8,4 +8,4 @@
> **MigrationError** = [`MEUpgrade`](core.MigrationError.Interface.MEUpgrade.md) \| [`MEDowngrade`](core.MigrationError.Interface.MEDowngrade.md) \| [`MigrationError`](core.MigrationError.Interface.MigrationError.md)
Defined in: [src/core.ts:157](../src/core.ts#L157)
Defined in: [src/core.ts:165](../src/core.ts#L165)
+40 -22
View File
@@ -106,13 +106,15 @@ export class ChatApi {
* Initializes the ChatApi.
* @param {DbConfig} db - Database configuration (sqlite or postgres).
* @param {core.MigrationConfirmation} [confirm=core.MigrationConfirmation.YesUp] - Migration confirmation mode.
* @param {number} [queueSize] - Size of internal queues, the core default is used when omitted.
*/
static async init(
db: DbConfig,
confirm = core.MigrationConfirmation.YesUp
confirm = core.MigrationConfirmation.YesUp,
queueSize?: number
): Promise<ChatApi> {
const [path, key] = dbConfigToMigrateArgs(db)
const ctrl = await core.chatMigrateInit(path, key, confirm)
const ctrl = await core.chatMigrateInit(path, key, confirm, queueSize)
return new ChatApi(ctrl)
}
@@ -120,39 +122,52 @@ export class ChatApi {
* Start chat controller. Must be called with the existing user profile.
*/
async startChat(): Promise<void> {
if (this.eventsLoop) throw new Error("chat already started")
const ctrl = this.ctrl
this.receiveEvents = true
this.eventsLoop = this.runEventsLoop()
const r = await this.sendChatCmd(CC.StartChat.cmdString({mainApp: true, enableSndFiles: true}))
let r: ChatResponse
try {
r = await core.chatSendCmd(ctrl, CC.StartChat.cmdString({mainApp: true, enableSndFiles: true, serviceRequests: false}))
} catch (e) {
await this.stopEventsLoop()
throw e
}
if (r.type !== "chatStarted" && r.type !== "chatRunning") {
await this.stopEventsLoop()
throw new ChatCommandError("error starting chat", r)
}
}
/**
* Stop chat controller.
* Must be called before closing the database.
* `close` calls it before closing the database.
* Usually doesn't need to be called in chat bots.
*/
async stopChat(): Promise<void> {
const r = await this.sendChatCmd("/_stop")
if (r.type !== "chatStopped") throw new ChatCommandError("error starting chat", r)
this.receiveEvents = false
if (this.eventsLoop) await this.eventsLoop
this.eventsLoop = undefined
if (r.type !== "chatStopped") throw new ChatCommandError("error stopping chat", r)
await this.stopEventsLoop()
}
/**
* Close chat database.
* Stop chat controller and close chat database.
* The database is not closed if stopping fails.
* Usually doesn't need to be called in chat bots.
*/
async close(): Promise<void> {
this.receiveEvents = false
if (this.eventsLoop) await this.eventsLoop
this.eventsLoop = undefined
// a running controller keeps using the database connections that closing frees
await this.stopChat()
await core.chatCloseStore(this.ctrl)
this.ctrl_ = undefined
}
private async stopEventsLoop(): Promise<void> {
this.receiveEvents = false
if (this.eventsLoop) await this.eventsLoop
this.eventsLoop = undefined
}
private async runEventsLoop(): Promise<void> {
while (this.receiveEvents) {
try {
@@ -335,7 +350,7 @@ export class ChatApi {
return await core.chatSendCmd(this.ctrl, cmd)
}
async recvChatEvent(wait: number = 5_000_000): Promise<ChatEvent | undefined> {
async recvChatEvent(wait: number = 500_000): Promise<ChatEvent | undefined> {
return await core.chatRecvMsgWait(this.ctrl, wait)
}
@@ -386,8 +401,10 @@ export class ChatApi {
switch (r.type) {
case "userProfileUpdated":
return r.updateSummary
case "userProfileNoChange":
return {updateSuccesses: 0, updateFailures: 0, changedContacts: []}
default:
throw new ChatCommandError("error loading user address", r)
throw new ChatCommandError("error setting profile address", r)
}
}
@@ -460,7 +477,7 @@ export class ChatApi {
updatedMessage: {msgContent, mentions: {}},
})
)
if (r.type === "chatItemUpdated") return r.chatItem.chatItem
if (r.type === "chatItemUpdated" || r.type === "chatItemNotChanged") return r.chatItem.chatItem
throw new ChatCommandError("error updating chat item", r)
}
@@ -499,10 +516,10 @@ export class ChatApi {
chatItemId: number,
add: boolean,
reaction: T.MsgReaction
) {
): Promise<T.ACIReaction> {
const r = await this.sendChatCmd(CC.APIChatItemReaction.cmdString({chatRef: {chatType, chatId}, chatItemId, add, reaction}))
if (r.type === "chatItemsDeleted") return r.chatItemDeletions
throw new ChatCommandError("error setting item reaction", r)
if (r.type === "chatItemReaction") return r.reaction
throw new ChatCommandError("error setting item reaction", r)
}
/**
@@ -512,6 +529,7 @@ export class ChatApi {
async apiReceiveFile(fileId: number): Promise<T.AChatItem> {
const r = await this.sendChatCmd(CC.ReceiveFile.cmdString({fileId, userApprovedRelays: true}))
if (r.type === "rcvFileAccepted") return r.chatItem
if (r.type === "rcvFileAcceptedSndCancelled") throw new ChatCommandError("file cancelled by sender", r)
throw new ChatCommandError("error receiving file", r)
}
@@ -698,7 +716,7 @@ export class ChatApi {
* Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link
* Network usage: interactive.
*/
async apiConnect(userId: number, incognito: boolean, preparedLink?: T.CreatedConnLink): Promise<ConnReqType> {
async apiConnect(userId: number, incognito: boolean, preparedLink: T.CreatedConnLink): Promise<ConnReqType> {
const r = await this.sendChatCmd(CC.APIConnect.cmdString({userId, incognito, preparedLink_: preparedLink}))
return this.handleConnectResult(r)
}
@@ -740,7 +758,7 @@ export class ChatApi {
* Network usage: no.
*/
async apiRejectContactRequest(contactReqId: number): Promise<void> {
const r = await this.sendChatCmd(CC.APIRejectContact.cmdString({contactReqId}))
const r = await this.sendChatCmd(CC.APIRejectContact.cmdString({contactReqId, notify: false}))
if (r.type === "contactRequestRejected") return
throw new ChatCommandError("error rejecting contact request", r)
}
+17 -30
View File
@@ -6,6 +6,7 @@ import equal = require("fast-deep-equal")
export type BotDbOpts = api.DbConfig & {
confirmMigrations?: core.MigrationConfirmation
queueSize?: number
}
export interface BotOptions {
@@ -45,10 +46,9 @@ export interface BotConfig {
}
export async function run({profile, dbOpts, options = defaultOpts, onMessage, onCommands = {}, events = {}}: BotConfig): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> {
const bot = await api.ChatApi.init(dbOpts, dbOpts.confirmMigrations || core.MigrationConfirmation.YesUp)
const bot = await api.ChatApi.init(dbOpts, dbOpts.confirmMigrations || core.MigrationConfirmation.YesUp, dbOpts.queueSize)
const opts = fullOptions(options)
if (onMessage) subscribeMessages(bot, onMessage)
if (Object.keys(onCommands).length > 0) subscribeCommands(bot, onCommands)
if (onMessage || Object.keys(onCommands).length > 0) subscribeChatItems(bot, onMessage, onCommands)
if (Object.keys(events).length > 0) bot.on(events)
subscribeLogEvents(bot, opts)
const botProfile = mkBotProfile(profile, opts)
@@ -105,40 +105,27 @@ function mkBotProfile(profile: T.Profile, opts: Required<BotOptions>): T.Profile
return profile
}
function subscribeMessages(bot: api.ChatApi, onMessage: (chatItem: T.AChatItem, content: T.MsgContent) => void | Promise<void>) {
export function subscribeChatItems(
bot: api.ChatApi,
onMessage: ((chatItem: T.AChatItem, content: T.MsgContent) => void | Promise<void>) | undefined,
commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise<void>)}
) {
bot.on("newChatItems", async ({chatItems}) => {
for (const ci of chatItems) {
if (ci.chatItem.content.type === "rcvMsgContent") {
try {
const p = onMessage(ci, ci.chatItem.content.msgContent)
if (p instanceof Promise) await p
} catch (e) {
console.log("message processing error", e)
}
const content = ci.chatItem.content
if (content.type !== "rcvMsgContent") continue
const cmd = util.ciBotCommand(ci.chatItem)
const cmdFunc = cmd && (commands[cmd.keyword] || commands[""])
try {
if (cmd && cmdFunc) await cmdFunc(ci, cmd)
else if (onMessage) await onMessage(ci, content.msgContent)
} catch (e) {
console.log(cmd && cmdFunc ? `${cmd.keyword} command processing error` : "message processing error", e)
}
}
})
}
function subscribeCommands(bot: api.ChatApi, commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise<void>)}) {
bot.on("newChatItems", async (evt) => {
for (const ci of evt.chatItems) {
const cmd = util.ciBotCommand(ci.chatItem)
if (cmd) {
const cmdFunc = commands[cmd.keyword] || commands[""]
if (cmdFunc) {
try {
const p = cmdFunc(ci, cmd)
if (p instanceof Promise) await p
} catch(e) {
console.log(`${cmd} command processing error`, e)
}
}
}
}
})
}
function subscribeLogEvents(bot: api.ChatApi, opts: Required<BotOptions>) {
if (opts.logContacts) {
bot.on({
+15 -7
View File
@@ -3,9 +3,12 @@ import * as simplex from "./simplex"
/**
* Initialize chat controller
* @param {number} [queueSize] - Size of internal queues, the core default is used when omitted.
*/
export async function chatMigrateInit(dbPath: string, dbKey: string, confirm: MigrationConfirmation): Promise<bigint> {
const [ctrl, res] = await simplex.chat_migrate_init(dbPath, dbKey, confirm)
export async function chatMigrateInit(dbPath: string, dbKey: string, confirm: MigrationConfirmation, queueSize?: number): Promise<bigint> {
const [ctrl, res] = queueSize === undefined
? await simplex.chat_migrate_init(dbPath, dbKey, confirm)
: await simplex.chat_migrate_init_queue(dbPath, dbKey, confirm, queueSize)
const json = JSON.parse(res)
if (json.type === 'ok') return ctrl
throw new ChatInitError("Database or migration error (see dbMigrationError property)", json as DBMigrationError)
@@ -47,7 +50,7 @@ export async function chatRecvMsgWait(ctrl: bigint, wait: number): Promise<ChatE
/**
* Write buffer to encrypted file
*/
export async function chatWriteFile(ctrl: bigint, path: string, buffer: ArrayBuffer): Promise<CryptoArgs> {
export async function chatWriteFile(ctrl: bigint, path: string, buffer: ArrayBuffer | Uint8Array): Promise<CryptoArgs> {
const res = await simplex.chat_write_file(ctrl, path, buffer)
return cryptoArgsResult(res)
}
@@ -55,7 +58,7 @@ export async function chatWriteFile(ctrl: bigint, path: string, buffer: ArrayBuf
/**
* Read buffer from encrypted file
*/
export async function chatReadFile(path: string, {fileKey, fileNonce}: CryptoArgs): Promise<ArrayBuffer> {
export async function chatReadFile(path: string, {fileKey, fileNonce}: CryptoArgs): Promise<Buffer> {
return await simplex.chat_read_file(path, fileKey, fileNonce)
}
@@ -121,12 +124,13 @@ export class ChatInitError extends Error {
export type DBMigrationError =
| DBMigrationError.InvalidConfirmation
| DBMigrationError.InvalidQueueSize
| DBMigrationError.ErrorNotADatabase // invalid/corrupt database file or incorrect encryption key
| DBMigrationError.ErrorMigration
| DBMigrationError.ErrorSQL
export namespace DBMigrationError {
export type Tag = "invalidConfirmation" | "errorNotADatabase" | "errorMigration" | "errorSQL"
export type Tag = "invalidConfirmation" | "invalidQueueSize" | "errorNotADatabase" | "errorMigration" | "errorSQL"
interface Interface {
type: Tag
@@ -136,6 +140,10 @@ export namespace DBMigrationError {
type: "invalidConfirmation"
}
export interface InvalidQueueSize extends Interface {
type: "invalidQueueSize"
}
export interface ErrorNotADatabase extends Interface {
type: "errorNotADatabase"
dbFile: string
@@ -168,7 +176,7 @@ export namespace MigrationError {
export interface MEUpgrade extends Interface {
type: "upgrade"
upMigrations: UpMigration
upMigrations: UpMigration[]
}
export interface MEDowngrade extends Interface {
@@ -200,7 +208,7 @@ export namespace MTRError {
export interface MTRENoDown extends Interface {
type: "noDown"
upMigrations: UpMigration
dbMigrations: string[]
}
export interface MTREDifferent extends Interface {
@@ -6,6 +6,10 @@ const extract = require('extract-zip');
const GITHUB_REPO = 'simplex-chat/simplex-chat-libs';
const RELEASE_TAG = 'v7.1.0-beta.3';
const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase();
// A locally built libsimplex, copied into libs/ rather than loaded in place:
// the addon's RUNPATH is $ORIGIN/../../libs.
const LIBS_DIR_OVERRIDE = process.env.SIMPLEX_LIBS_DIR || process.env.npm_config_simplex_libs_dir;
const LIB_NAMES = ['libsimplex.so', 'libsimplex.dylib', 'libsimplex.dll'];
if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') {
console.error(`✗ Invalid SIMPLEX_BACKEND: "${BACKEND}". Must be "sqlite" or "postgres".`);
@@ -83,8 +87,31 @@ function isAlreadyInstalled() {
}
}
// No version check: the files behind SIMPLEX_LIBS_DIR change on every rebuild.
function installFromOverride() {
if (!fs.existsSync(LIBS_DIR_OVERRIDE)) {
throw new Error(`SIMPLEX_LIBS_DIR does not exist: ${LIBS_DIR_OVERRIDE}`);
}
const lib = LIB_NAMES.find((name) => fs.existsSync(path.join(LIBS_DIR_OVERRIDE, name)));
if (!lib) {
throw new Error(`No ${LIB_NAMES.join(' / ')} in SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`);
}
console.log(`Using libraries from SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`);
cleanLibsDirectory();
copyDirSync(LIBS_DIR_OVERRIDE, LIBS_DIR);
// Not a release tag: a later install without the variable sees a mismatch
// and replaces these files with the released ones.
fs.writeFileSync(INSTALLED_FILE, `${LIBS_DIR_OVERRIDE}:${BACKEND}`, 'utf-8');
console.log(`✓ Installed ${lib} and its runtime libraries from ${LIBS_DIR_OVERRIDE}`);
}
async function install() {
try {
if (LIBS_DIR_OVERRIDE) {
installFromOverride();
return;
}
// Check if already installed
if (isAlreadyInstalled()) {
return;
+3 -2
View File
@@ -1,10 +1,11 @@
// These functions are defined in CPP add-on ../cpp/simplex.cc
export function chat_migrate_init(dbPath: string, dbKey: string, confirm: string): Promise<[bigint, string]>
export function chat_migrate_init_queue(dbPath: string, dbKey: string, confirm: string, queueSize: number): Promise<[bigint, string]>
export function chat_close_store(ctrl: bigint): Promise<string>
export function chat_send_cmd(ctrl: bigint, cmd: string): Promise<string>
export function chat_recv_msg_wait(ctrl: bigint, wait: number): Promise<string>
export function chat_write_file(ctrl: bigint, path: string, buffer: ArrayBuffer): Promise<string>
export function chat_read_file(path: string, key: string, nonce: string): Promise<ArrayBuffer>
export function chat_write_file(ctrl: bigint, path: string, buffer: ArrayBuffer | Uint8Array): Promise<string>
export function chat_read_file(path: string, key: string, nonce: string): Promise<Buffer>
export function chat_encrypt_file(ctrl: bigint, fromPath: string, toPath: string): Promise<string>
export function chat_decrypt_file(fromPath: string, key: string, nonce: string, toPath: string): Promise<string>
+1 -1
View File
@@ -78,7 +78,7 @@ export interface BotCommand {
export function ciBotCommand(chatItem: T.ChatItem): BotCommand | undefined {
const msg = ciContentText(chatItem)?.trim()
if (msg) {
const r = msg.match(/^\/([^\s]+)(.*)/)
const r = msg.match(/^\/([^\s]+)([\s\S]*)/)
if (r && r.length >= 3) {
return {keyword: r[1], params: r[2].trim()}
}
@@ -58,8 +58,8 @@ describe("API tests (use preset servers)", () => {
await bob.stopChat()
await alice.close()
await bob.close()
await expect(alice.startChat).rejects.toThrow()
await expect(bob.startChat).rejects.toThrow()
await expect(alice.startChat()).rejects.toThrow("chat api controller not initialized")
await expect(bob.startChat()).rejects.toThrow("chat api controller not initialized")
expect(servers.length).toBe(2)
expect(servers[0] !== servers[1]).toBe(true)
expect(eventCount > 0).toBe(true)
@@ -0,0 +1,102 @@
import {ChatResponse, T} from "@simplex-chat/types"
import * as api from "../src/api"
import * as core from "../src/core"
const user = {userId: 1} as T.User
async function chatWithResponse(response: object): Promise<api.ChatApi> {
jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1))
jest.spyOn(core, "chatSendCmd").mockResolvedValue(response as ChatResponse)
return api.ChatApi.init({type: "sqlite", filePrefix: "unused"})
}
afterEach(() => jest.restoreAllMocks())
describe("documented success responses", () => {
it("apiChatItemReaction returns the reaction", async () => {
const reaction = {chatReaction: {reaction: {type: "emoji", emoji: "👍"}}}
const chat = await chatWithResponse({type: "chatItemReaction", user, added: true, reaction})
await expect(chat.apiChatItemReaction(T.ChatType.Direct, 1, 2, true, {type: "emoji", emoji: "👍"})).resolves.toEqual(reaction)
})
it("apiUpdateChatItem accepts chatItemNotChanged", async () => {
const chatItem = {meta: {itemId: 2}}
const chat = await chatWithResponse({type: "chatItemNotChanged", user, chatItem: {chatItem}})
await expect(chat.apiUpdateChatItem(T.ChatType.Direct, 1, 2, {type: "text", text: "same"}, false)).resolves.toEqual(chatItem)
})
it("apiSetProfileAddress accepts userProfileNoChange", async () => {
const chat = await chatWithResponse({type: "userProfileNoChange", user})
await expect(chat.apiSetProfileAddress(1, true)).resolves.toEqual({updateSuccesses: 0, updateFailures: 0, changedContacts: []})
})
it("apiReceiveFile reports a file cancelled by sender", async () => {
const chat = await chatWithResponse({type: "rcvFileAcceptedSndCancelled", user, rcvFileTransfer: {}})
await expect(chat.apiReceiveFile(3)).rejects.toThrow("file cancelled by sender")
})
})
describe("startChat lifecycle", () => {
function chatWithResponses(...responses: object[]): Promise<api.ChatApi> {
jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1))
jest.spyOn(core, "chatRecvMsgWait").mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(undefined), 10)))
const send = jest.spyOn(core, "chatSendCmd")
for (const r of responses) send.mockResolvedValueOnce(r as ChatResponse)
return api.ChatApi.init({type: "sqlite", filePrefix: "unused"})
}
it("rejects a second start", async () => {
const chat = await chatWithResponses({type: "chatStarted"}, {type: "chatStopped"})
await chat.startChat()
await expect(chat.startChat()).rejects.toThrow("chat already started")
await chat.stopChat()
})
it("stops the events loop when start fails", async () => {
const chat = await chatWithResponses({type: "chatCmdError"})
await expect(chat.startChat()).rejects.toThrow("error starting chat")
expect(chat.started).toBe(false)
})
it("rejects start after close", async () => {
const chat = await chatWithResponses({type: "chatStopped"})
jest.spyOn(core, "chatCloseStore").mockResolvedValue()
await chat.close()
await expect(chat.startChat()).rejects.toThrow("chat api controller not initialized")
})
it("stops the chat before closing the store", async () => {
const chat = await chatWithResponses()
const calls: string[] = []
jest.mocked(core.chatSendCmd).mockImplementation(async (_ctrl, cmd) => {
calls.push(`send ${cmd}`)
return {type: "chatStopped"} as ChatResponse
})
jest.spyOn(core, "chatCloseStore").mockImplementation(async () => { calls.push("closeStore") })
await chat.close()
expect(calls).toEqual(["send /_stop", "closeStore"])
expect(chat.initialized).toBe(false)
})
it("does not close the store when stopping fails", async () => {
const chat = await chatWithResponses({type: "chatCmdError"})
const closeStore = jest.spyOn(core, "chatCloseStore").mockResolvedValue()
await expect(chat.close()).rejects.toThrow("error stopping chat")
expect(closeStore).not.toHaveBeenCalled()
expect(chat.initialized).toBe(true)
})
it("reports stop failures as stop errors", async () => {
const chat = await chatWithResponses({type: "chatStarted"}, {type: "chatCmdError"}, {type: "chatStopped"})
await chat.startChat()
await expect(chat.stopChat()).rejects.toThrow("error stopping chat")
expect(chat.started).toBe(true)
await chat.stopChat()
})
it("receives with a 500 ms wait", async () => {
const chat = await chatWithResponses()
await chat.recvChatEvent()
expect(core.chatRecvMsgWait).toHaveBeenCalledWith(BigInt(1), 500_000)
})
})
@@ -0,0 +1,50 @@
import {ChatEvent, T} from "@simplex-chat/types"
import * as api from "../src/api"
import {subscribeChatItems} from "../src/bot"
type Handler = (evt: ChatEvent) => Promise<void>
function fakeBot(): {bot: api.ChatApi, deliver: (items: T.AChatItem[]) => Promise<void>} {
let handler: Handler | undefined
const bot = {on: (_event: string, h: Handler) => { handler = h }} as unknown as api.ChatApi
const deliver = (chatItems: T.AChatItem[]) => handler!({type: "newChatItems", chatItems} as unknown as ChatEvent)
return {bot, deliver}
}
function item(type: "rcvMsgContent" | "sndMsgContent", text: string): T.AChatItem {
return {chatItem: {content: {type, msgContent: {type: "text", text}}}} as unknown as T.AChatItem
}
describe("subscribeChatItems", () => {
it("sends a known command only to its handler", async () => {
const {bot, deliver} = fakeBot()
const calls: string[] = []
subscribeChatItems(bot, async () => { calls.push("message") }, {help: async () => { calls.push("help") }})
await deliver([item("rcvMsgContent", "/help")])
expect(calls).toEqual(["help"])
})
it("sends an unknown command to the fallback handler", async () => {
const {bot, deliver} = fakeBot()
const calls: string[] = []
subscribeChatItems(bot, async () => { calls.push("message") }, {"": async (_ci, cmd) => { calls.push(`fallback:${cmd.keyword}`) }})
await deliver([item("rcvMsgContent", "/unknown")])
expect(calls).toEqual(["fallback:unknown"])
})
it("sends unhandled commands and text to onMessage", async () => {
const {bot, deliver} = fakeBot()
const calls: string[] = []
subscribeChatItems(bot, async (_ci, content) => { calls.push(`message:${(content as T.MsgContent & {text: string}).text}`) }, {help: async () => { calls.push("help") }})
await deliver([item("rcvMsgContent", "/unknown"), item("rcvMsgContent", "hello")])
expect(calls).toEqual(["message:/unknown", "message:hello"])
})
it("ignores sent items", async () => {
const {bot, deliver} = fakeBot()
const calls: string[] = []
subscribeChatItems(bot, async () => { calls.push("message") }, {help: async () => { calls.push("help") }})
await deliver([item("sndMsgContent", "/help"), item("sndMsgContent", "hello")])
expect(calls).toEqual([])
})
})
@@ -0,0 +1,13 @@
import {CC} from "@simplex-chat/types"
describe("APIConnect.cmdString", () => {
const preparedLink_ = {connFullLink: "L"}
it("renders incognito=on", () => {
expect(CC.APIConnect.cmdString({userId: 1, incognito: true, preparedLink_})).toBe("/_connect 1 incognito=on L")
})
it("omits incognito when off", () => {
expect(CC.APIConnect.cmdString({userId: 1, incognito: false, preparedLink_})).toBe("/_connect 1 L")
})
})
+153 -5
View File
@@ -1,3 +1,4 @@
import {execFile, spawnSync} from "child_process";
import * as fs from "fs";
import * as path from "path";
import {core} from "../src/index";
@@ -9,17 +10,34 @@ describe("Core tests", () => {
beforeEach(() => fs.mkdirSync(tmpDir, {recursive: true}));
afterEach(() => fs.rmSync(tmpDir, {recursive: true, force: true}));
async function stopAndClose(ctrl: bigint): Promise<void> {
await expect(core.chatSendCmd(ctrl, "/_stop")).resolves.toMatchObject({type: "chatStopped"});
await core.chatCloseStore(ctrl);
}
it("should initialize chat controller", async () => {
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
expect(typeof ctrl).toBe("bigint");
await expect(core.chatCloseStore(ctrl)).resolves.toBe(undefined);
await expect(stopAndClose(ctrl)).resolves.toBe(undefined);
await expect(core.chatMigrateInit(dbPath, "wrong_key", core.MigrationConfirmation.YesUp)).rejects.toMatchObject({
message: "Database or migration error (see dbMigrationError property)",
dbMigrationError: expect.objectContaining({type: "errorNotADatabase"})
});
});
it("should initialize chat controller with queue size", async () => {
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 65536);
expect(typeof ctrl).toBe("bigint");
await expect(stopAndClose(ctrl)).resolves.toBe(undefined);
await expect(core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 0)).rejects.toMatchObject({
dbMigrationError: {type: "invalidQueueSize"}
});
await expect(core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 2 ** 31)).rejects.toThrow("Expected 32-bit integer queue size");
await expect(core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 1.5)).rejects.toThrow("Expected 32-bit integer queue size");
});
it("should send command and receive event", async () => {
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
@@ -41,7 +59,7 @@ describe("Core tests", () => {
chatError: expect.objectContaining({type: "error"})
});
await core.chatCloseStore(ctrl);
await stopAndClose(ctrl);
});
it("should write/read encrypted file from/to buffer", async () => {
@@ -59,7 +77,25 @@ describe("Core tests", () => {
await expect(core.chatWriteFile(ctrl, path.join(tmpDir, "unknown", "unknown.txt"), buffer)).rejects.toThrow();
await expect(core.chatReadFile(path.join(tmpDir, "unknown.txt"), cryptoArgs)).rejects.toThrow();
await core.chatCloseStore(ctrl);
await stopAndClose(ctrl);
});
it("should write the view of a Uint8Array and read an empty file", async () => {
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
const viewPath = path.join(tmpDir, "view.txt");
const viewArgs = await core.chatWriteFile(ctrl, viewPath, Buffer.from("xxabcxx").subarray(2, 5));
const view = await core.chatReadFile(viewPath, viewArgs);
expect(Buffer.isBuffer(view)).toBe(true);
expect(view.toString()).toBe("abc");
const emptyPath = path.join(tmpDir, "empty.txt");
const emptyArgs = await core.chatWriteFile(ctrl, emptyPath, new Uint8Array(0));
const empty = await core.chatReadFile(emptyPath, emptyArgs);
expect(Buffer.isBuffer(empty)).toBe(true);
expect(empty.length).toBe(0);
await stopAndClose(ctrl);
});
it("should encrypt/decrypt file", async () => {
@@ -80,6 +116,118 @@ describe("Core tests", () => {
await expect(core.chatEncryptFile(ctrl, path.join(tmpDir, "unknown.txt"), encryptedPath)).rejects.toThrow();
await expect(core.chatDecryptFile(path.join(tmpDir, "unknown.txt"), cryptoArgs, decryptedPath)).rejects.toThrow();
await core.chatCloseStore(ctrl);
await stopAndClose(ctrl);
});
it("should not block the libuv pool while receiving", async () => {
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
const receives = [1, 2, 3, 4].map(() => core.chatRecvMsgWait(ctrl, 2_000_000));
const start = Date.now();
await fs.promises.stat(tmpDir);
expect(Date.now() - start).toBeLessThan(200);
await Promise.all(receives);
await stopAndClose(ctrl);
}, 10000);
const itOnLinux = process.platform === "linux" ? it : it.skip;
// Thread count is read from /proc/self/task, which only exists on Linux.
itOnLinux("should keep the thread count constant while receiving", async () => {
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
for (let i = 0; i < 10; i++) await core.chatRecvMsgWait(ctrl, 1);
const threadCount = () => fs.readdirSync("/proc/self/task").length;
const warmCount = threadCount();
const counts = new Set<number>();
for (let i = 0; i < 200; i++) {
await core.chatRecvMsgWait(ctrl, 1);
counts.add(threadCount());
}
expect({warmCount, counts: [...counts]}).toEqual({warmCount, counts: [warmCount]});
await stopAndClose(ctrl);
}, 30000);
it("should receive on two controllers concurrently", async () => {
const ctrlA = await core.chatMigrateInit(path.join(tmpDir, "simplex_a"), "key", core.MigrationConfirmation.YesUp);
const ctrlB = await core.chatMigrateInit(path.join(tmpDir, "simplex_b"), "key", core.MigrationConfirmation.YesUp);
const start = Date.now();
await expect(Promise.all([core.chatRecvMsgWait(ctrlA, 1_000_000), core.chatRecvMsgWait(ctrlB, 1_000_000)]))
.resolves.toEqual([undefined, undefined]);
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(900);
expect(elapsed).toBeLessThan(1800);
await stopAndClose(ctrlA);
await stopAndClose(ctrlB);
}, 10000);
it("should receive events of one controller in order", async () => {
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
for (const action of ["first", "second"]) {
await expect(core.chatSendCmd(ctrl, `/debug event {"type": "timedAction", "action": "${action}", "durationMilliseconds": 1}`))
.resolves.toMatchObject({type: "cmdOk"});
}
const events = await Promise.all([core.chatRecvMsgWait(ctrl, 500_000), core.chatRecvMsgWait(ctrl, 500_000)]);
expect(events).toMatchObject([
{type: "timedAction", action: "first"},
{type: "timedAction", action: "second"}
]);
await stopAndClose(ctrl);
}, 10000);
it("should close the store while a receive is in flight", async () => {
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
// starts the receiver thread, so the next request only has to wake it
await core.chatRecvMsgWait(ctrl, 1);
const settle = (p: Promise<unknown>) => p.then((event) => ({event}), (e: Error) => ({error: e.message}));
const receives = Promise.all([settle(core.chatRecvMsgWait(ctrl, 3_000_000)), settle(core.chatRecvMsgWait(ctrl, 3_000_000))]);
// the thread enters the first receive within this margin even under load, so the second one is still queued at close
await new Promise((resolve) => setTimeout(resolve, 500));
await expect(core.chatSendCmd(ctrl, "/_stop")).resolves.toMatchObject({type: "chatStopped"});
let closed = false;
const close = core.chatCloseStore(ctrl).then(() => { closed = true; });
const timerStart = Date.now();
const timerDelay = await new Promise<number>((resolve) => setTimeout(() => resolve(Date.now() - timerStart), 10));
expect({closed, timerDelayBelow100ms: timerDelay < 100}).toEqual({closed: false, timerDelayBelow100ms: true});
await close;
expect(await receives).toEqual([{event: undefined}, {error: "chat receiver stopped"}]);
}, 10000);
it("should let the process exit while a receiver is idle", () => {
const childDbPath = path.resolve(tmpDir, "simplex_child");
const script = `
const simplex = require("./build/Release/simplex.node");
simplex.chat_migrate_init(${JSON.stringify(childDbPath)}, "key", "yesUp")
.then(([ctrl]) => simplex.chat_recv_msg_wait(ctrl, 1))
.then((res) => console.log("received " + JSON.stringify(res)));
`;
const child = spawnSync(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 10000, encoding: "utf8"});
if (child.status !== 0 || child.signal !== null) console.log("child stderr:", child.stderr);
expect({status: child.status, signal: child.signal, stdout: child.stdout.trim()})
.toEqual({status: 0, signal: null, stdout: 'received ""'});
}, 15000);
it("should not crash when closing stopped controllers repeatedly", async () => {
const script = `
const fs = require("fs"), path = require("path");
const simplex = require("./build/Release/simplex.node");
(async () => {
for (let i = 0; i < 40; i++) {
const dir = fs.mkdtempSync(path.join(${JSON.stringify(path.resolve(tmpDir))}, "close-"));
const [ctrl] = await simplex.chat_migrate_init(path.join(dir, "simplex"), "key", "yesUp");
await simplex.chat_send_cmd(ctrl, "/v");
await simplex.chat_send_cmd(ctrl, "/_stop");
const res = await simplex.chat_close_store(ctrl);
fs.rmSync(dir, {recursive: true, force: true});
if (res !== "") throw new Error("close failed: " + res);
}
})();
`;
const runChild = () => new Promise<{code: number | null, signal: NodeJS.Signals | null, stderr: string}>((resolve) => {
const child = execFile(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 150000}, (_error, _stdout, stderr) =>
resolve({code: child.exitCode, signal: child.signalCode, stderr}));
});
const childCount = 3;
const results = await Promise.all(Array.from({length: childCount}, runChild));
for (const r of results) if (r.code !== 0 || r.signal !== null) console.log("child stderr:", r.stderr);
expect(results.map(({code, signal}) => ({code, signal}))).toEqual(Array(childCount).fill({code: 0, signal: null}));
}, 180000);
});
@@ -34,4 +34,8 @@ describe("ciBotCommand", () => {
const ci = {content: {type: "rcvDeleted"}} as T.ChatItem
expect(ciBotCommand(ci)).toBeUndefined()
})
it("keeps multi-line params", () => {
expect(ciBotCommand(rcvText("/review line1\nline2"))).toEqual({keyword: "review", params: "line1\nline2"})
})
})
@@ -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
@@ -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"]
@@ -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) == "🎉"
+25 -9
View File
@@ -12,6 +12,7 @@ module Simplex.Chat.Mobile where
import Control.Concurrent.STM
import Control.Exception (SomeException, catch)
import Control.Monad
import Control.Monad.Except
import Control.Monad.Reader
import Data.Aeson (ToJSON (..))
@@ -35,6 +36,7 @@ import Foreign.Ptr
import Foreign.StablePtr
import Foreign.Storable (poke)
import GHC.IO.Encoding (setFileSystemEncoding, setForeignEncoding, setLocaleEncoding)
import Numeric.Natural (Natural)
import Simplex.Chat
import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode)
import Simplex.Chat.Controller
@@ -73,6 +75,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB
data DBMigrationResult
= DBMOk
| DBMInvalidConfirmation
| DBMInvalidQueueSize
| DBMErrorNotADatabase {dbFile :: String}
| DBMErrorMigration {dbFile :: String, migrationError :: MigrationError}
| DBMErrorSQL {dbFile :: String, migrationSQLError :: String}
@@ -113,6 +116,8 @@ foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString
foreign export ccall "chat_migrate_init_key" cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString
foreign export ccall "chat_migrate_init_queue" cChatMigrateInitQueue :: CString -> CString -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString
foreign export ccall "chat_close_store" cChatCloseStore :: StablePtr ChatController -> IO CString
foreign export ccall "chat_reopen_store" cChatReopenStore :: StablePtr ChatController -> IO CString
@@ -166,7 +171,14 @@ cChatMigrateInit fp key conf = cChatMigrateInitKey fp key 0 conf 0
-- For postgres first param is schema prefix, second param is database connection string.
cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString
cChatMigrateInitKey fp key keepKey conf background ctrl = do
cChatMigrateInitKey fp key keepKey conf background = cChatMigrateInit_ fp key (keepKey /= 0) conf (background /= 0) mobileQueueSize
-- | queueSize is the size of internal queues, same as terminal option --queue-size
cChatMigrateInitQueue :: CString -> CString -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString
cChatMigrateInitQueue fp key conf queueSize = cChatMigrateInit_ fp key False conf False (fromIntegral queueSize)
cChatMigrateInit_ :: CString -> CString -> Bool -> CString -> Bool -> Int -> Ptr (StablePtr ChatController) -> IO CJSONString
cChatMigrateInit_ fp key keepKey conf background queueSize ctrl = do
-- ensure we are set to UTF-8; iOS does not have locale, and will default to
-- US-ASCII all the time.
setLocaleEncoding utf8
@@ -176,7 +188,7 @@ cChatMigrateInitKey fp key keepKey conf background ctrl = do
chatDbOpts <- mobileDbOpts fp key
confirm <- peekCAString conf
r <-
chatMigrateInitKey chatDbOpts (keepKey /= 0) confirm (background /= 0) >>= \case
chatMigrateInitKey chatDbOpts keepKey confirm background queueSize >>= \case
Right cc -> (newStablePtr cc >>= poke ctrl) $> DBMOk
Left e -> pure e
newCStringFromLazyBS $ J.encode r
@@ -254,8 +266,11 @@ cChatParseBadgeCode cCode = do
cChatJsonLength :: CString -> IO CInt
cChatJsonLength s = fromIntegral . subtract 2 . LB.length . J.encode . safeDecodeUtf8 <$> B.packCString s
mobileChatOpts :: ChatDbOpts -> ChatOpts
mobileChatOpts dbOptions =
mobileQueueSize :: Int
mobileQueueSize = 4096
mobileChatOpts :: ChatDbOpts -> Natural -> ChatOpts
mobileChatOpts dbOptions tbqSize =
ChatOpts
{ coreOptions =
CoreChatOpts
@@ -268,7 +283,7 @@ mobileChatOpts dbOptions =
logServerHosts = True,
logAgent = Nothing,
logFile = Nothing,
tbqSize = 4096,
tbqSize,
maxChats = 5000,
deviceName = Nothing,
chatRelay = False,
@@ -313,18 +328,19 @@ getActiveUser_ st = find activeUser <$> withTransaction st getUsers
chatMigrateInit :: String -> ScrubbedBytes -> String -> IO (Either DBMigrationResult ChatController)
chatMigrateInit dbFilePrefix dbKey confirm = do
let chatDBOpts = ChatDbOpts {dbFilePrefix, dbKey, trackQueries = DB.TQSlow 5000, vacuumOnMigration = True}
chatMigrateInitKey chatDBOpts False confirm False
chatMigrateInitKey chatDBOpts False confirm False mobileQueueSize
#endif
chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> IO (Either DBMigrationResult ChatController)
chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do
chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> Int -> IO (Either DBMigrationResult ChatController)
chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode queueSize = runExceptT $ do
unless (queueSize > 0) $ throwError DBMInvalidQueueSize
confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm
let migrationConfig = MigrationConfig confirmMigrations (Just "")
chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey chatDBFunctions) migrationConfig
agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey []) migrationConfig
ExceptT $ initialize chatStore ChatDatabase {chatStore, agentStore}
where
opts = mobileChatOpts $ removeDbKey chatDbOpts
opts = mobileChatOpts (removeDbKey chatDbOpts) (fromIntegral queueSize)
initialize st db = do
user_ <- liftIO $ getActiveUser_ st
first DBMAgentError <$> newChatController db user_ defaultMobileConfig opts backgroundMode
+21 -2
View File
@@ -25,7 +25,7 @@ import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Time.Clock (getCurrentTime)
import Data.Word (Word8, Word32)
import Foreign.C
import Foreign.Marshal.Alloc (mallocBytes)
import Foreign.Marshal.Alloc (alloca, mallocBytes)
import Foreign.Marshal.Utils (copyBytes)
import Foreign.Ptr
import Foreign.StablePtr
@@ -34,7 +34,7 @@ import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEnco
import JSONFixtures
import Simplex.Chat
import Simplex.Chat.Badges (BadgeInfo (..), BadgeRequest (..), BadgeType (..), generateMasterKey, verifyCredential)
import Simplex.Chat.Controller (ChatController (..), ChatDatabase (..))
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatDatabase (..))
import Simplex.Chat.Mobile hiding (error)
import Simplex.Chat.Mobile.Badges hiding (error)
import Simplex.Chat.Mobile.File
@@ -44,6 +44,8 @@ import Simplex.Chat.Options.DB
import Simplex.Chat.Store
import Simplex.Chat.Store.Profiles
import Simplex.Chat.Types (AgentUserId (..), Profile (..))
import Simplex.Messaging.Agent.Client (AgentClient (..))
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), Env (..))
import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..))
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import qualified Simplex.Messaging.Crypto as C
@@ -65,6 +67,7 @@ mobileTests = do
setForeignEncoding utf8
it "start new chat without user" testChatApiNoUser
it "start new chat with existing user" testChatApi
it "should set queue size via C API" testChatMigrateInitQueueCApi
it "should encrypt/decrypt WebRTC frames" testMediaApi
it "should encrypt/decrypt WebRTC frames via C API" testMediaCApi
describe "should read/write encrypted files via C API" $ do
@@ -165,6 +168,22 @@ testChatApi ps = do
chatParseMarkdown "hello" `shouldBe` "{}"
chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown
testChatMigrateInitQueueCApi :: TestParams -> IO ()
testChatMigrateInitQueueCApi ps = do
cPath <- newCString $ tmpPath ps </> "1"
cKey <- newCString ""
cConfirm <- newCString "yesUp"
alloca $ \ctrlPtr -> do
let migrateInit queueSize = peekCAString =<< cChatMigrateInitQueue cPath cKey cConfirm queueSize ctrlPtr
migrateInit 0 `shouldReturn` jsonStr DBMInvalidQueueSize
migrateInit (-1) `shouldReturn` jsonStr DBMInvalidQueueSize
migrateInit 65536 `shouldReturn` jsonStr DBMOk
ChatController {config = ChatConfig {tbqSize}, smpAgent = AgentClient {agentEnv = Env {config = AgentConfig {tbqSize = agentQSize}}}} <- deRefStablePtr =<< peek ctrlPtr
tbqSize `shouldBe` 65536
agentQSize `shouldBe` 65536
where
jsonStr = LB.unpack . J.encode
testMediaApi :: HasCallStack => TestParams -> IO ()
testMediaApi ps = do
let tmp = tmpPath ps