diff --git a/.github/workflows/jsonrpc_api.yml b/.github/workflows/jsonrpc_api.yml new file mode 100644 index 000000000..4d58de1e5 --- /dev/null +++ b/.github/workflows/jsonrpc_api.yml @@ -0,0 +1,66 @@ +name: JSON-RPC API Test + +on: + push: + branches: [master] + pull_request: + branches: [master] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: 1.56.0 + override: true + - name: Rust Cache + uses: Swatinem/rust-cache@v1.3.0 + - name: Build + run: cargo build --verbose --features webserver -p deltachat-jsonrpc + - name: Run tests + run: cargo test --verbose --features webserver -p deltachat-jsonrpc + + ts_bindings: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 14.x + uses: actions/setup-node@v1 + with: + node-version: 14.x + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: 1.56.0 + override: true + - name: Rust Cache + uses: Swatinem/rust-cache@v1.3.0 + - name: npm ci + run: | + cd deltachat-jsonrpc/typescript + npm ci + - name: npm run generate_bindings + run: | + cd deltachat-jsonrpc/typescript + npm run generate_bindings + - name: npm run check_ts + run: | + cd deltachat-jsonrpc/typescript + npm run check_ts + - name: run integration tests + run: | + cd deltachat-jsonrpc/typescript + npm run build + cargo build --features webserver + npm run test:integration + - name: run prettier + run: | + cd deltachat-jsonrpc/typescript + npm run prettier:check diff --git a/Cargo.lock b/Cargo.lock index 66e2ee9ab..0a24730fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,12 @@ version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08f9b8508dccb7687a1d6c4ce66b2b0ecef467c94667de27d8d7fe1f8d2a9cdc" +[[package]] +name = "arrayref" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" + [[package]] name = "arrayvec" version = "0.5.2" @@ -294,6 +300,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "async-session" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "345022a2eed092cd105cc1b26fd61c341e100bd5fcbbd792df4baf31c2cc631f" +dependencies = [ + "anyhow", + "async-std", + "async-trait", + "base64 0.12.3", + "bincode", + "blake3", + "chrono", + "hmac 0.8.1", + "kv-log-macro", + "rand 0.7.3", + "serde", + "serde_json", + "sha2 0.9.9", +] + [[package]] name = "async-smtp" version = "0.4.0" @@ -313,6 +340,20 @@ dependencies = [ "thiserror", ] +[[package]] +name = "async-sse" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53bba003996b8fd22245cd0c59b869ba764188ed435392cf2796d03b805ade10" +dependencies = [ + "async-channel", + "async-std", + "http-types", + "log", + "memchr", + "pin-project-lite 0.1.12", +] + [[package]] name = "async-std" version = "1.11.0" @@ -336,7 +377,7 @@ dependencies = [ "memchr", "num_cpus", "once_cell", - "pin-project-lite", + "pin-project-lite 0.2.9", "pin-utils", "slab", "wasm-bindgen-futures", @@ -387,6 +428,19 @@ dependencies = [ "syn", ] +[[package]] +name = "async-tungstenite" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07b30ef0ea5c20caaa54baea49514a206308989c68be7ecd86c7f956e4da6378" +dependencies = [ + "futures-io", + "futures-util", + "log", + "pin-project-lite 0.2.9", + "tungstenite", +] + [[package]] name = "atomic-waker" version = "1.0.0" @@ -458,6 +512,15 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitfield" version = "0.13.2" @@ -482,6 +545,21 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake3" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b64485778c4f16a6a5a9d335e80d449ac6c70cdd6a06d2af18a6f6f775a125b3" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if 0.1.10", + "constant_time_eq", + "crypto-mac 0.8.0", + "digest 0.9.0", +] + [[package]] name = "block-buffer" version = "0.9.0" @@ -607,6 +685,12 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +[[package]] +name = "bytes" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" + [[package]] name = "cache-padded" version = "1.2.0" @@ -679,6 +763,7 @@ dependencies = [ "libc", "num-integer", "num-traits", + "serde", "time 0.1.44", "winapi", ] @@ -761,6 +846,18 @@ version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbdcdcb6d86f71c5e97409ad45898af11cbc995b4ee8112d59095a28d376c935" +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "convert_case" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb4a24b1aaf0fd0ce8b45161144d6f42cd91677fd5940fd431183eb023b3a2b8" + [[package]] name = "cookie" version = "0.14.4" @@ -770,7 +867,7 @@ dependencies = [ "aes-gcm", "base64 0.13.0", "hkdf", - "hmac", + "hmac 0.10.1", "percent-encoding", "rand 0.8.5", "sha2 0.9.9", @@ -927,6 +1024,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-mac" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "crypto-mac" version = "0.10.1" @@ -997,8 +1104,28 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d706e75d87e35569db781a9b5e2416cff1236a47ed380831f959382ccd5f858" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.10.2", + "darling_macro 0.10.2", +] + +[[package]] +name = "darling" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +dependencies = [ + "darling_core 0.13.4", + "darling_macro 0.13.4", +] + +[[package]] +name = "darling" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4529658bdda7fd6769b8614be250cdcfc3aeb0ee72fe66f9e41e5e5eb73eac02" +dependencies = [ + "darling_core 0.14.1", + "darling_macro 0.14.1", ] [[package]] @@ -1011,7 +1138,35 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.9.3", + "syn", +] + +[[package]] +name = "darling_core" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn", +] + +[[package]] +name = "darling_core" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "649c91bc01e8b1eac09fb91e8dbc7d517684ca6be8ebc75bb9cafc894f9fdb6f" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", "syn", ] @@ -1021,7 +1176,29 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9b5a2f4ac4969822c62224815d069952656cadc7084fdca9751e6d959189b72" dependencies = [ - "darling_core", + "darling_core 0.10.2", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +dependencies = [ + "darling_core 0.13.4", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfc69c5bfcbd2fc09a0f38451d2daf0e372e367986a83906d1b0dbc88134fb5" +dependencies = [ + "darling_core 0.14.1", "quote", "syn", ] @@ -1135,6 +1312,28 @@ dependencies = [ "zip", ] +[[package]] +name = "deltachat-jsonrpc" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-channel", + "async-std", + "deltachat", + "env_logger 0.9.0", + "futures", + "log", + "num-traits", + "serde", + "serde_json", + "tempfile", + "tide", + "tide-websockets", + "typescript-type-def", + "yerpc", + "yerpc-tide", +] + [[package]] name = "deltachat_derive" version = "2.0.0" @@ -1164,7 +1363,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2658621297f2cf68762a6f7dc0bb7e1ff2cfd6583daef8ee0fed6f7ec468ec0" dependencies = [ - "darling", + "darling 0.10.2", "derive_builder_core", "proc-macro2", "quote", @@ -1177,7 +1376,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2791ea3e372c8495c0bc2033991d76b512cd799d07491fbd6890124db9458bef" dependencies = [ - "darling", + "darling 0.10.2", "proc-macro2", "quote", "syn", @@ -1422,12 +1621,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" dependencies = [ "atty", - "humantime", + "humantime 1.3.0", "log", "regex", "termcolor", ] +[[package]] +name = "env_logger" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b2cf0344971ee6c64c31be0d530793fba457d322dfec2810c453d0ef228f9c3" +dependencies = [ + "atty", + "humantime 2.1.0", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "erased-serde" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad132dd8d0d0b546348d7d86cb3191aad14b34e5f979781fc005c80d4ac67ffd" +dependencies = [ + "serde", +] + [[package]] name = "errno" version = "0.2.8" @@ -1528,6 +1749,22 @@ dependencies = [ "windows-sys 0.30.0", ] +[[package]] +name = "femme" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2997b612abb06bc299486c807e68c5fd12e7618e69cf34c5958ca6b575674403" +dependencies = [ + "cfg-if 1.0.0", + "js-sys", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "filetime" version = "0.2.16" @@ -1646,7 +1883,7 @@ dependencies = [ "futures-io", "memchr", "parking", - "pin-project-lite", + "pin-project-lite 0.2.9", "waker-fn", ] @@ -1686,7 +1923,7 @@ dependencies = [ "futures-sink", "futures-task", "memchr", - "pin-project-lite", + "pin-project-lite 0.2.9", "pin-utils", "slab", ] @@ -1813,7 +2050,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51ab2f639c231793c5f6114bdb9bbe50a7dbbfcd7c7c6bd8475dec2d991e964f" dependencies = [ "digest 0.9.0", - "hmac", + "hmac 0.10.1", +] + +[[package]] +name = "hmac" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126888268dcc288495a26bf004b38c5fdbb31682f992c84ceb046a1f0fe38840" +dependencies = [ + "crypto-mac 0.8.0", + "digest 0.9.0", ] [[package]] @@ -1822,7 +2069,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1441c6b1e930e2817404b5046f1f989899143a12bf92de603b69f4e0aee1e15" dependencies = [ - "crypto-mac", + "crypto-mac 0.10.1", "digest 0.9.0", ] @@ -1837,6 +2084,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "http" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff8670570af52249509a86f5e3e18a08c60b177071826898fde8997cf5f6bfbb" +dependencies = [ + "bytes", + "fnv", + "itoa 1.0.1", +] + [[package]] name = "http-client" version = "6.5.2" @@ -1868,7 +2126,7 @@ dependencies = [ "cookie", "futures-lite", "infer", - "pin-project-lite", + "pin-project-lite 0.2.9", "rand 0.7.3", "serde", "serde_json", @@ -1913,6 +2171,12 @@ dependencies = [ "quick-error 1.2.3", ] +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "ident_case" version = "1.0.1" @@ -1962,6 +2226,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" +[[package]] +name = "input_buffer" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f97967975f448f1a7ddb12b0bc41069d09ed6a1c161a92687e057325db35d413" +dependencies = [ + "bytes", +] + [[package]] name = "instant" version = "0.1.12" @@ -2154,6 +2427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" dependencies = [ "cfg-if 1.0.0", + "serde", "value-bag", ] @@ -2676,6 +2950,12 @@ dependencies = [ "syn", ] +[[package]] +name = "pin-project-lite" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "257b64915a082f7811703966789728173279bdebb956b143dbcd23f6f970a777" + [[package]] name = "pin-project-lite" version = "0.2.9" @@ -2770,7 +3050,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "926d36b9553851b8b0005f1275891b392ee4d2d833852c417ed025477350fb9d" dependencies = [ - "env_logger", + "env_logger 0.7.1", "log", ] @@ -3087,6 +3367,12 @@ dependencies = [ "opaque-debug", ] +[[package]] +name = "route-recognizer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56770675ebc04927ded3e60633437841581c285dc6236109ea25fbf3beb7b59e" + [[package]] name = "rsa" version = "0.3.0" @@ -3328,6 +3614,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_fmt" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2963a69a2b3918c1dc75a45a18bd3fcd1120e31d3f59deb1b2f9b5d5ffb8baa4" +dependencies = [ + "serde", +] + [[package]] name = "serde_json" version = "1.0.81" @@ -3595,7 +3890,7 @@ dependencies = [ "async-channel", "cfg-if 1.0.0", "futures-core", - "pin-project-lite", + "pin-project-lite 0.2.9", ] [[package]] @@ -3610,6 +3905,12 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c" +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strum" version = "0.24.0" @@ -3652,11 +3953,20 @@ dependencies = [ "log", "mime_guess", "once_cell", - "pin-project-lite", + "pin-project-lite 0.2.9", "serde", "serde_json", ] +[[package]] +name = "sval" +version = "1.0.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45f6ee7c7b87caf59549e9fe45d6a69c75c8019e79e212a835c5da0e92f0ba08" +dependencies = [ + "serde", +] + [[package]] name = "syn" version = "1.0.95" @@ -3755,6 +4065,47 @@ dependencies = [ "syn", ] +[[package]] +name = "tide" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c459573f0dd2cc734b539047f57489ea875af8ee950860ded20cf93a79a1dee0" +dependencies = [ + "async-h1", + "async-session", + "async-sse", + "async-std", + "async-trait", + "femme", + "futures-util", + "http-client", + "http-types", + "kv-log-macro", + "log", + "pin-project-lite 0.2.9", + "route-recognizer", + "serde", + "serde_json", +] + +[[package]] +name = "tide-websockets" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3592c5cb5cb1b7a2ff3a0e5353170c1bb5b104b2f66dd06f73304169b52cc725" +dependencies = [ + "async-dup", + "async-std", + "async-tungstenite", + "base64 0.13.0", + "futures-util", + "pin-project", + "serde", + "serde_json", + "sha-1 0.9.8", + "tide", +] + [[package]] name = "time" version = "0.1.44" @@ -3835,7 +4186,7 @@ version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4903bf0427cf68dddd5aa6a93220756f8be0c34fcfa9f5e6191e103e15a31395" dependencies = [ - "pin-project-lite", + "pin-project-lite 0.2.9", ] [[package]] @@ -3899,6 +4250,26 @@ dependencies = [ "cfg-if 0.1.10", ] +[[package]] +name = "tungstenite" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fe8dada8c1a3aeca77d6b51a4f1314e0f4b8e438b7b1b71e3ddaca8080e4093" +dependencies = [ + "base64 0.13.0", + "byteorder", + "bytes", + "http", + "httparse", + "input_buffer", + "log", + "rand 0.8.5", + "sha-1 0.9.8", + "thiserror", + "url", + "utf-8", +] + [[package]] name = "twofish" version = "0.5.0" @@ -3916,6 +4287,28 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" +[[package]] +name = "typescript-type-def" +version = "0.5.1" +source = "git+https://github.com/Frando/rust-typescript-type-def?branch=yerpc#e3215df5b594f702a9725d81e1c90696fe9d30dd" +dependencies = [ + "serde_json", + "typescript-type-def-derive", +] + +[[package]] +name = "typescript-type-def-derive" +version = "0.5.1" +source = "git+https://github.com/Frando/rust-typescript-type-def?branch=yerpc#e3215df5b594f702a9725d81e1c90696fe9d30dd" +dependencies = [ + "darling 0.13.4", + "ident_case", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicase" version = "2.6.0" @@ -3996,6 +4389,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8parse" version = "0.2.0" @@ -4028,6 +4427,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2209b78d1249f7e6f3293657c9779fe31ced465df091bbd433a1cf88e916ec55" dependencies = [ "ctor", + "erased-serde", + "serde", + "serde_fmt", + "sval", "version_check 0.9.4", ] @@ -4085,6 +4488,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27370197c907c55e3f1a9fbe26f44e937fe6451368324e009cba39e139dc08ad" dependencies = [ "cfg-if 1.0.0", + "serde", + "serde_json", "wasm-bindgen-macro", ] @@ -4318,6 +4723,49 @@ dependencies = [ "zeroize", ] +[[package]] +name = "yerpc" +version = "0.1.0" +source = "git+https://github.com/Frando/yerpc#1443c26322d68c6d8593636506edc81411309951" +dependencies = [ + "anyhow", + "async-channel", + "async-mutex", + "async-trait", + "futures", + "log", + "serde", + "serde_json", + "typescript-type-def", + "yerpc_derive", +] + +[[package]] +name = "yerpc-tide" +version = "0.1.0" +source = "git+https://github.com/Frando/yerpc#1443c26322d68c6d8593636506edc81411309951" +dependencies = [ + "anyhow", + "async-std", + "futures", + "serde_json", + "tide", + "tide-websockets", + "yerpc", +] + +[[package]] +name = "yerpc_derive" +version = "0.1.0" +source = "git+https://github.com/Frando/yerpc#1443c26322d68c6d8593636506edc81411309951" +dependencies = [ + "convert_case", + "darling 0.14.1", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zeroize" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index dead0ad42..b43f642fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ tempfile = "3" members = [ "deltachat-ffi", "deltachat_derive", + "deltachat-jsonrpc" ] [[example]] diff --git a/deltachat-jsonrpc/.gitignore b/deltachat-jsonrpc/.gitignore new file mode 100644 index 000000000..f5b99da65 --- /dev/null +++ b/deltachat-jsonrpc/.gitignore @@ -0,0 +1,6 @@ +/target +/accounts + +types.ts + +.cargo \ No newline at end of file diff --git a/deltachat-jsonrpc/Cargo.toml b/deltachat-jsonrpc/Cargo.toml new file mode 100644 index 000000000..9a1b8e12d --- /dev/null +++ b/deltachat-jsonrpc/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "deltachat-jsonrpc" +version = "0.1.0" +authors = ["Delta Chat Developers (ML) "] +edition = "2021" +default-run = "webserver" +license = "MPL-2.0" + +[[bin]] +name = "webserver" +path = "src/webserver.rs" +required-features = ["webserver"] + +[dependencies] +anyhow = "1" +async-std = { version = "1", features = ["attributes"] } +deltachat = { path = ".." } +num-traits = "0.2" +serde = { version = "1.0", features = ["derive"] } +tempfile = "3.3.0" +log = "0.4" +async-channel = { version = "1.6.1" } +futures = { version = "0.3.19" } +serde_json = "1.0.75" +yerpc = { git = "https://github.com/Frando/yerpc", features = ["anyhow"] } +typescript-type-def = { git = "https://github.com/Frando/rust-typescript-type-def", branch = "yerpc", features = ["json_value"] } +# optional, depended on features +env_logger = { version = "0.9.0", optional = true } +tide = { version = "0.16.0", optional = true } +tide-websockets = { version = "0.4.0", optional = true } +yerpc-tide = { git = "https://github.com/Frando/yerpc", optional = true } + + +[features] +default = [] +webserver = ["env_logger", "tide", "tide-websockets", "yerpc-tide"] + +[profile.release] +lto = true diff --git a/deltachat-jsonrpc/README.MD b/deltachat-jsonrpc/README.MD new file mode 100644 index 000000000..ca784542d --- /dev/null +++ b/deltachat-jsonrpc/README.MD @@ -0,0 +1,75 @@ +# deltachat-jsonrpc + +## Build Requirements + +- Linux or Mac, scrips make use of features like `>` pipes and `&&` (maybe the newer versions of powershell support them, but I didn't try that.) +- rust (installed via rustup) + +## Start the webserver + +The webserver is an example usage. Goal of it is to be usable both as example and as base for deltachat-kaiOS. + +```sh +RUST_LOG=info cargo run --features webserver +``` + +## Generate Typescript Bindings + +```sh +cd typescript +npm i +npm run build +``` + +## Run the development example + +Mac + +```sh +alias firefox=/Applications/Firefox.app/Contents/MacOS/firefox +npm run example:build && firefox --devtools $(pwd)/example/browser-example.html +``` + +Linux: + +```sh +npm run example:run +``` + +## Compiling server for kaiOS or android: + +```sh +cross build --features=webserver --target armv7-linux-androideabi --release +``` + +## Run the tests + +### Rust tests + +``` +cargo test --features=webserver +``` + +### Typescript + +``` +cd typescript +npm run test +``` + +For the online tests to run you need a test account token for a mailadm instance, +you can use docker to spin up a local instance: https://github.com/deltachat/docker-mailadm + +> set the env var `DCC_NEW_TMP_EMAIL` to your mailadm token: example: +> `DCC_NEW_TMP_EMAIL=https://testrun.org/new_email?t=1h_195dksa6544` + +If your test fail with server shutdown at the start, then you might have a process from a last run still running probably and you need to kill that process manually to continue. + +#### Test Coverage + +You can test coverage with `npm run coverage`, but you need to have `DCC_NEW_TMP_EMAIL` set, otherwise the result will be useless because some functions can only be tested with the online tests. + +> If you are offline and want to see the coverage results anyway (even though they are NOT correct), you can bypass the error with `COVERAGE_OFFLINE=1 npm run coverage` + +Open `coverage/index.html` for a detailed report. +`bindings.ts` is probably the most interesting file for coverage, because it describes the api functions. diff --git a/deltachat-jsonrpc/TODO.md b/deltachat-jsonrpc/TODO.md new file mode 100644 index 000000000..fe79b4659 --- /dev/null +++ b/deltachat-jsonrpc/TODO.md @@ -0,0 +1,347 @@ +## Core system + +- [X] Base structure of JSON API code +- [X] Implement the first methods for testing + the code that should later be generated by the proc macro +- [X] Create the proc macro + - [X] json api + - [X] ts types + - [X] arguments (no args, one argument, multiple args) + - [X] return type + - [X] custom types as type aliases that ts file looks prettier + + +## Pre - MVP + +- [X] Web socket server +- [WIP] Web socket client (ts) + - [X] backend connection state changed events + - [X] Reconnect on connection loss / connection state + - [ ] find a way to type the event emitter callback functions +- [X] Events + +## MVP + +- [X] mocha integration test for ts api + - [X] basic tests + - [X] advanced / "online tests" (mailadm for burner accounts) +- [ ] coverage for a majority of the API +- [ ] Blobs served +- [ ] Blob upload (for attachments, setting profile-picture, importing backup and so on) +- [ ] Web push API? At least some kind of notification hook closure this lib can accept. + +## Other Ideas + +- [ ] make sure there can only be one connection at a time to the ws + - why? , it could give problems if its commanded from multiple connections +- [ ] encrypted connection? +- [ ] authenticated connection? +- [ ] Look into unit-testing for the proc macros? +- [ ] proc macro taking over doc comments to generated typescript file +- [X] GH action for tests (rust and typescript) + - [X] rust test + - [X] rust fmt + - [X] rust clippy + - [X] tsc check + - [X] prettier + - [X] mocha +- [X] scripts to check&fix prettier formatting + + + +## Apis + +replicate desktop api feature set: + +(this feature set is based on desktop version `1.20`, needs to be updated in the future) + +```rs + + +struct sendMessageParams { + text: Option, + filename: Option, // TODO we need to think about blobs some more + location: Option<(u32,u32)>, + quote_message_id: Option, +} + +struct QrCodeResponse = { + state: u32 // also enum in reality, for simlicity u32 here + id: u32 + text1: String +} + +impl Api { + +// root --------------------------------------------------------------- + +// NEEDS_THE_BLOB_QUESTION_ANSWERED_EVENTUALLY +async fn sc_set_profile_picture(&self, new_image: String) -> Result<()> {} + +// NEEDS_THE_BLOB_QUESTION_ANSWERED_EVENTUALLY +// 'getProfilePicture' equals to `dc.getContact(C.DC_CONTACT_ID_SELF).getProfileImage()` or `dc.get_config("selfavatar")` + +async fn sc_join_secure_join(&self, qrCode: String) -> Result {} +async fn sc_stop_ongoing_process(&self) -> Result {} +async fn sc_check_qr_code(&self, qrCode: String) -> Result {} + +// login ---------------------------------------------------- + +// INFO: login functions need to call stop&start io where applicable + +// login.newLogin: +// do instead in frontend: +// 1. call `add_account` +// 2. call `select_account` +// 3. set credentials via set config +// 4. call `sc_configure` + +// login.getLogins - is already implemented: `get_all_accounts` + +// login.loadAccount - Basically `select_account` + +// login.logout -> TODO: unselect account, which isn't implemented in the core yet + +// login.forgetAccount -> `remove_account` + +// login.getLastLoggedInAccount -> `get_selected_account_id` + +// login.updateCredentials -> do instead: set config then call `sc_configure` + +// backup ------------------------------------------------------------- + +// INFO: backup functions need to call stop&start io + +// NEEDS_THE_BLOB_QUESTION_ANSWERED_EVENTUALLY +async fn sc_backup_export(&self, out_dir: String) -> Result<()> {} +// NEEDS_THE_BLOB_QUESTION_ANSWERED_EVENTUALLY +async fn sc_backup_import(&self, file: String) -> Result<()> {} // will not return the same as in desktop because this function imports backup to the current context unlike it was in desktop + +// chatList ----------------------------------------------------------- + +// chatList.selectChat - will be removed from desktop +// chatList.getSelectedChatId - will be removed from desktop +// chatList.onChatModified - will be removed from desktop + +async fn sc_chatlist_get_general_fresh_message_counter(&self) -> Result // this method might be used for a favicon badge counter + +// contacts ------------------------------------------------------------ + +async fn sc_contacts_change_nickname(&self, contact_id: u32, new_name: String) -> Result<()> + + +// contacts.getChatIdByContactId - very similar to sc_contacts_create_chat_by_contact_id +// contacts.getDMChatId - very similar to sc_contacts_create_chat_by_contact_id + +async fn sc_contacts_get_encryption_info(&self, contact_id: u32) -> Result + +async fn sc_contacts_lookup_contact_id_by_addr(&self, email: String) -> Result + + +} + +``` +```ts + +class DeltaRemote { + // chat --------------------------------------------------------------- + call( + fnName: 'chat.getChatMedia', + chatId: number, + msgType1: number, + msgType2: number + ): Promise + call(fnName: 'chat.getEncryptionInfo', chatId: number): Promise + call(fnName: 'chat.getQrCode', chatId?: number): Promise + call(fnName: 'chat.leaveGroup', chatId: number): Promise + call(fnName: 'chat.setName', chatId: number, name: string): Promise + call( + fnName: 'chat.modifyGroup', + chatId: number, + name: string, + image: string, + remove: number[], + add: number[] + ): Promise + call( + fnName: 'chat.addContactToChat', + chatId: number, + contactId: number + ): Promise + call( + fnName: 'chat.setProfileImage', + chatId: number, + newImage: string + ): Promise + call( + fnName: 'chat.setMuteDuration', + chatId: number, + duration: MuteDuration + ): Promise + call( + fnName: 'chat.createGroupChat', + verified: boolean, + name: string + ): Promise + call(fnName: 'chat.delete', chatId: number): Promise + call( + fnName: 'chat.setVisibility', + chatId: number, + visibility: + | C.DC_CERTCK_AUTO + | C.DC_CERTCK_STRICT + | C.DC_CHAT_VISIBILITY_PINNED + ): Promise + call(fnName: 'chat.getChatContacts', chatId: number): Promise + call(fnName: 'chat.markNoticedChat', chatId: number): Promise + call(fnName: 'chat.getChatEphemeralTimer', chatId: number): Promise + call( + fnName: 'chat.setChatEphemeralTimer', + chatId: number, + ephemeralTimer: number + ): Promise + call(fnName: 'chat.sendVideoChatInvitation', chatId: number): Promise + call( + fnName: 'chat.decideOnContactRequest', + messageId: number, + decision: + | C.DC_DECISION_START_CHAT + | C.DC_DECISION_NOT_NOW + | C.DC_DECISION_BLOCK + ): Promise + // locations ---------------------------------------------------------- + call( + fnName: 'locations.setLocation', + latitude: number, + longitude: number, + accuracy: number + ): Promise + call( + fnName: 'locations.getLocations', + chatId: number, + contactId: number, + timestampFrom: number, + timestampTo: number + ): Promise + + // NOTHING HERE that is called directly from the frontend, yet + // messageList -------------------------------------------------------- + call( + fnName: 'messageList.sendMessage', + chatId: number, + params: sendMessageParams + ): Promise<[number, MessageType | null]> + call( + fnName: 'messageList.sendSticker', + chatId: number, + stickerPath: string + ): Promise + call(fnName: 'messageList.deleteMessage', id: number): Promise + call(fnName: 'messageList.getMessageInfo', msgId: number): Promise + call( + fnName: 'messageList.getDraft', + chatId: number + ): Promise + call( + fnName: 'messageList.setDraft', + chatId: number, + { + text, + file, + quotedMessageId, + }: { text?: string; file?: string; quotedMessageId?: number } + ): Promise + call( + fnName: 'messageList.messageIdToJson', + id: number + ): Promise<{ msg: null } | MessageType> + call( + fnName: 'messageList.forwardMessage', + msgId: number, + chatId: number + ): Promise + call( + fnName: 'messageList.searchMessages', + query: string, + chatId?: number + ): Promise + call( + fnName: 'messageList.msgIds2SearchResultItems', + msgIds: number[] + ): Promise<{ [id: number]: MessageSearchResult }> + call( + fnName: 'messageList.saveMessageHTML2Disk', + messageId: number + ): Promise + // settings ----------------------------------------------------------- + call(fnName: 'settings.keysImport', directory: string): Promise + call(fnName: 'settings.keysExport', directory: string): Promise + call( + fnName: 'settings.serverFlags', + { + mail_security, + send_security, + }: { + mail_security?: string + send_security?: string + } + ): Promise + call( + fnName: 'settings.setDesktopSetting', + key: keyof DesktopSettings, + value: string | number | boolean + ): Promise + call(fnName: 'settings.getDesktopSettings'): Promise + call( + fnName: 'settings.saveBackgroundImage', + file: string, + isDefaultPicture: boolean + ): Promise + call( + fnName: 'settings.estimateAutodeleteCount', + fromServer: boolean, + seconds: number + ): Promise + // stickers ----------------------------------------------------------- + call( + fnName: 'stickers.getStickers' + ): Promise<{ + [key: string]: string[] + }> // todo move to extras? because its not directly elated to core + // context ------------------------------------------------------------ + call(fnName: 'context.maybeNetwork'): Promise + // burner accounts ------------------------------------------------------------ + call( + fnName: 'burnerAccounts.create', + url: string + ): Promise<{ email: string; password: string }> // think about how to improve that api - probably use core api instead + // extras ------------------------------------------------------------- + call(fnName: 'extras.getLocaleData', locale: string): Promise + call(fnName: 'extras.setLocale', locale: string): Promise + call( + fnName: 'extras.getActiveTheme' + ): Promise<{ + theme: Theme + data: string + } | null> + call(fnName: 'extras.setThemeFilePath', address: string): void + call(fnName: 'extras.getAvailableThemes'): Promise + call(fnName: 'extras.setTheme', address: string): Promise + // catchall: ---------------------------------------------------------- + call(fnName: string): Promise + call(fnName: string, ...args: any[]): Promise { + return _callDcMethodAsync(fnName, ...args) + } +} + +export const DeltaBackend = new DeltaRemote() +``` + + +after that, or while doing it adjust api to be more complete + + + + +TODO different test to simulate two devices: + +to test autocrypt_initiate_key_transfer & autocrypt_continue_key_transfer \ No newline at end of file diff --git a/deltachat-jsonrpc/src/api/events.rs b/deltachat-jsonrpc/src/api/events.rs new file mode 100644 index 000000000..17972958a --- /dev/null +++ b/deltachat-jsonrpc/src/api/events.rs @@ -0,0 +1,69 @@ +use deltachat::{Event, EventType}; +use serde_json::{json, Value}; + +pub fn event_to_json_rpc_notification(event: Event) -> Value { + let (field1, field2): (Value, Value) = match &event.typ { + // events with a single string in field1 + EventType::Info(txt) + | EventType::SmtpConnected(txt) + | EventType::ImapConnected(txt) + | EventType::SmtpMessageSent(txt) + | EventType::ImapMessageDeleted(txt) + | EventType::ImapMessageMoved(txt) + | EventType::NewBlobFile(txt) + | EventType::DeletedBlobFile(txt) + | EventType::Warning(txt) + | EventType::Error(txt) + | EventType::ErrorSelfNotInGroup(txt) => (json!(txt), Value::Null), + EventType::ImexFileWritten(path) => (json!(path.to_str()), Value::Null), + // single number + EventType::MsgsNoticed(chat_id) | EventType::ChatModified(chat_id) => { + (json!(chat_id), Value::Null) + } + EventType::ImexProgress(progress) => (json!(progress), Value::Null), + // both fields contain numbers + EventType::MsgsChanged { chat_id, msg_id } + | EventType::IncomingMsg { chat_id, msg_id } + | EventType::MsgDelivered { chat_id, msg_id } + | EventType::MsgFailed { chat_id, msg_id } + | EventType::MsgRead { chat_id, msg_id } => (json!(chat_id), json!(msg_id)), + EventType::ChatEphemeralTimerModified { chat_id, timer } => (json!(chat_id), json!(timer)), + EventType::SecurejoinInviterProgress { + contact_id, + progress, + } + | EventType::SecurejoinJoinerProgress { + contact_id, + progress, + } => (json!(contact_id), json!(progress)), + // field 1 number or null + EventType::ContactsChanged(maybe_number) | EventType::LocationChanged(maybe_number) => ( + match maybe_number { + Some(number) => json!(number), + None => Value::Null, + }, + Value::Null, + ), + // number and maybe string + EventType::ConfigureProgress { progress, comment } => ( + json!(progress), + match comment { + Some(content) => json!(content), + None => Value::Null, + }, + ), + EventType::ConnectivityChanged => (Value::Null, Value::Null), + EventType::SelfavatarChanged => (Value::Null, Value::Null), + EventType::WebxdcStatusUpdate { + msg_id, + status_update_serial, + } => (json!(msg_id), json!(status_update_serial)), + }; + + json!({ + "id": event.typ.as_id(), + "contextId": event.id, + "field1": field1, + "field2": field2 + }) +} diff --git a/deltachat-jsonrpc/src/api/mod.rs b/deltachat-jsonrpc/src/api/mod.rs new file mode 100644 index 000000000..9a1a144a4 --- /dev/null +++ b/deltachat-jsonrpc/src/api/mod.rs @@ -0,0 +1,534 @@ +use anyhow::{anyhow, bail, Context, Result}; +use async_std::sync::{Arc, RwLock}; +use deltachat::{ + chat::{get_chat_msgs, ChatId}, + chatlist::Chatlist, + config::Config, + contact::{may_be_valid_addr, Contact, ContactId}, + context::get_info, + message::{Message, MsgId, Viewtype}, + provider::get_provider_info, +}; +use std::collections::BTreeMap; +use std::{collections::HashMap, str::FromStr}; +use yerpc::rpc; + +pub use deltachat::accounts::Accounts; + +pub mod events; +pub mod types; + +use crate::api::types::chat_list::{ChatListItemFetchResult, _get_chat_list_items_by_id}; + +use types::account::Account; +use types::chat::FullChat; +use types::chat_list::ChatListEntry; +use types::contact::ContactObject; +use types::message::MessageObject; +use types::provider_info::ProviderInfo; + +#[derive(Clone, Debug)] +pub struct CommandApi { + pub(crate) accounts: Arc>, +} + +impl CommandApi { + pub fn new(accounts: Accounts) -> Self { + CommandApi { + accounts: Arc::new(RwLock::new(accounts)), + } + } + + async fn get_context(&self, id: u32) -> Result { + let sc = self + .accounts + .read() + .await + .get_account(id) + .await + .ok_or_else(|| anyhow!("account with id {} not found", id))?; + Ok(sc) + } +} + +#[rpc(all_positional, ts_outdir = "typescript/generated")] +impl CommandApi { + // --------------------------------------------- + // Misc top level functions + // --------------------------------------------- + + // Check if an email address is valid. + async fn check_email_validity(&self, email: String) -> bool { + may_be_valid_addr(&email) + } + + /// Get general system info. + async fn get_system_info(&self) -> BTreeMap<&'static str, String> { + get_info() + } + + // --------------------------------------------- + // Account Management + // --------------------------------------------- + + async fn add_account(&self) -> Result { + self.accounts.write().await.add_account().await + } + + async fn remove_account(&self, account_id: u32) -> Result<()> { + self.accounts.write().await.remove_account(account_id).await + } + + async fn get_all_account_ids(&self) -> Vec { + self.accounts.read().await.get_all().await + } + + /// Select account id for internally selected state. + /// TODO: Likely this is deprecated as all methods take an account id now. + async fn select_account(&self, id: u32) -> Result<()> { + self.accounts.write().await.select_account(id).await + } + + /// Get the selected account id of the internal state.. + /// TODO: Likely this is deprecated as all methods take an account id now. + async fn get_selected_account_id(&self) -> Option { + self.accounts.read().await.get_selected_account_id().await + } + + /// Get a list of all configured accounts. + async fn get_all_accounts(&self) -> Result> { + let mut accounts = Vec::new(); + for id in self.accounts.read().await.get_all().await { + let context_option = self.accounts.read().await.get_account(id).await; + if let Some(ctx) = context_option { + accounts.push(Account::from_context(&ctx, id).await?) + } else { + println!("account with id {} doesn't exist anymore", id); + } + } + Ok(accounts) + } + + // --------------------------------------------- + // Methods that work on individual accounts + // --------------------------------------------- + + /// Get top-level info for an account. + async fn get_account_info(&self, account_id: u32) -> Result { + let context_option = self.accounts.read().await.get_account(account_id).await; + if let Some(ctx) = context_option { + Ok(Account::from_context(&ctx, account_id).await?) + } else { + Err(anyhow!( + "account with id {} doesn't exist anymore", + account_id + )) + } + } + + /// Returns provider for the given domain. + /// + /// This function looks up domain in offline database first. If not + /// found, it queries MX record for the domain and looks up offline + /// database for MX domains. + /// + /// For compatibility, email address can be passed to this function + /// instead of the domain. + async fn get_provider_info( + &self, + account_id: u32, + email: String, + ) -> Result> { + let ctx = self.get_context(account_id).await?; + + let socks5_enabled = ctx + .get_config_bool(deltachat::config::Config::Socks5Enabled) + .await?; + + let provider_info = + get_provider_info(&ctx, email.split('@').last().unwrap_or(""), socks5_enabled).await; + Ok(ProviderInfo::from_dc_type(provider_info)) + } + + /// Checks if the context is already configured. + async fn is_configured(&self, account_id: u32) -> Result { + let ctx = self.get_context(account_id).await?; + Ok(ctx.is_configured().await?) + } + + // Get system info for an account. + async fn get_info(&self, account_id: u32) -> Result> { + let ctx = self.get_context(account_id).await?; + Ok(ctx.get_info().await?) + } + + async fn set_config(&self, account_id: u32, key: String, value: Option) -> Result<()> { + let ctx = self.get_context(account_id).await?; + set_config(&ctx, &key, value.as_deref()).await + } + + async fn batch_set_config( + &self, + account_id: u32, + config: HashMap>, + ) -> Result<()> { + let ctx = self.get_context(account_id).await?; + for (key, value) in config.into_iter() { + set_config(&ctx, &key, value.as_deref()) + .await + .with_context(|| format!("Can't set {} to {:?}", key, value))?; + } + Ok(()) + } + + async fn get_config(&self, account_id: u32, key: String) -> Result> { + let ctx = self.get_context(account_id).await?; + get_config(&ctx, &key).await + } + + async fn batch_get_config( + &self, + account_id: u32, + keys: Vec, + ) -> Result>> { + let ctx = self.get_context(account_id).await?; + let mut result: HashMap> = HashMap::new(); + for key in keys { + result.insert(key.clone(), get_config(&ctx, &key).await?); + } + Ok(result) + } + + /// Configures this account with the currently set parameters. + /// Setup the credential config before calling this. + async fn configure(&self, account_id: u32) -> Result<()> { + let ctx = self.get_context(account_id).await?; + ctx.stop_io().await; + ctx.configure().await?; + ctx.start_io().await; + Ok(()) + } + + /// Signal an ongoing process to stop. + async fn stop_ongoing_process(&self, account_id: u32) -> Result<()> { + let ctx = self.get_context(account_id).await?; + ctx.stop_ongoing().await; + Ok(()) + } + + // --------------------------------------------- + // autocrypt + // --------------------------------------------- + + async fn autocrypt_initiate_key_transfer(&self, account_id: u32) -> Result { + let ctx = self.get_context(account_id).await?; + deltachat::imex::initiate_key_transfer(&ctx).await + } + + async fn autocrypt_continue_key_transfer( + &self, + account_id: u32, + message_id: u32, + setup_code: String, + ) -> Result<()> { + let ctx = self.get_context(account_id).await?; + deltachat::imex::continue_key_transfer(&ctx, MsgId::new(message_id), &setup_code).await + } + + // --------------------------------------------- + // chat list + // --------------------------------------------- + + async fn get_chatlist_entries( + &self, + account_id: u32, + list_flags: Option, + query_string: Option, + query_contact_id: Option, + ) -> Result> { + let ctx = self.get_context(account_id).await?; + let list = Chatlist::try_load( + &ctx, + list_flags.unwrap_or(0) as usize, + query_string.as_deref(), + query_contact_id.map(ContactId::new), + ) + .await?; + let mut l: Vec = Vec::new(); + for i in 0..list.len() { + l.push(ChatListEntry( + list.get_chat_id(i)?.to_u32(), + list.get_msg_id(i)?.unwrap_or_default().to_u32(), + )); + } + Ok(l) + } + + async fn get_chatlist_items_by_entries( + &self, + account_id: u32, + entries: Vec, + ) -> Result> { + // todo custom json deserializer for ChatListEntry? + let ctx = self.get_context(account_id).await?; + let mut result: HashMap = HashMap::new(); + for (_i, entry) in entries.iter().enumerate() { + result.insert( + entry.0, + match _get_chat_list_items_by_id(&ctx, entry).await { + Ok(res) => res, + Err(err) => ChatListItemFetchResult::Error { + id: entry.0, + error: format!("{:?}", err), + }, + }, + ); + } + Ok(result) + } + + // --------------------------------------------- + // chat + // --------------------------------------------- + + async fn chatlist_get_full_chat_by_id( + &self, + account_id: u32, + chat_id: u32, + ) -> Result { + let ctx = self.get_context(account_id).await?; + FullChat::from_dc_chat_id(&ctx, chat_id).await + } + + async fn accept_chat(&self, account_id: u32, chat_id: u32) -> Result<()> { + let ctx = self.get_context(account_id).await?; + ChatId::new(chat_id).accept(&ctx).await + } + + async fn block_chat(&self, account_id: u32, chat_id: u32) -> Result<()> { + let ctx = self.get_context(account_id).await?; + ChatId::new(chat_id).block(&ctx).await + } + + // --------------------------------------------- + // message list + // --------------------------------------------- + + async fn message_list_get_message_ids( + &self, + account_id: u32, + chat_id: u32, + flags: u32, + ) -> Result> { + let ctx = self.get_context(account_id).await?; + let msg = get_chat_msgs(&ctx, ChatId::new(chat_id), flags, None).await?; + Ok(msg + .iter() + .filter_map(|chat_item| match chat_item { + deltachat::chat::ChatItem::Message { msg_id } => Some(msg_id.to_u32()), + _ => None, + }) + .collect()) + } + + async fn message_get_message(&self, account_id: u32, message_id: u32) -> Result { + let ctx = self.get_context(account_id).await?; + MessageObject::from_message_id(&ctx, message_id).await + } + + async fn message_get_messages( + &self, + account_id: u32, + message_ids: Vec, + ) -> Result> { + let ctx = self.get_context(account_id).await?; + let mut messages: HashMap = HashMap::new(); + for message_id in message_ids { + messages.insert( + message_id, + MessageObject::from_message_id(&ctx, message_id).await?, + ); + } + Ok(messages) + } + + // --------------------------------------------- + // contact + // --------------------------------------------- + + /// Get a single contact options by ID. + async fn contacts_get_contact( + &self, + account_id: u32, + contact_id: u32, + ) -> Result { + let ctx = self.get_context(account_id).await?; + let contact_id = ContactId::new(contact_id); + + ContactObject::from_dc_contact( + &ctx, + deltachat::contact::Contact::get_by_id(&ctx, contact_id).await?, + ) + .await + } + + /// Add a single contact as a result of an explicit user action. + /// + /// Returns contact id of the created or existing contact + async fn contacts_create_contact( + &self, + account_id: u32, + email: String, + name: Option, + ) -> Result { + let ctx = self.get_context(account_id).await?; + if !may_be_valid_addr(&email) { + bail!(anyhow!( + "provided email address is not a valid email address" + )) + } + let contact_id = Contact::create(&ctx, &name.unwrap_or_default(), &email).await?; + Ok(contact_id.to_u32()) + } + + /// Returns contact id of the created or existing DM chat with that contact + async fn contacts_create_chat_by_contact_id( + &self, + account_id: u32, + contact_id: u32, + ) -> Result { + let ctx = self.get_context(account_id).await?; + let contact = Contact::get_by_id(&ctx, ContactId::new(contact_id)).await?; + ChatId::create_for_contact(&ctx, contact.id) + .await + .map(|id| id.to_u32()) + } + + async fn contacts_block(&self, account_id: u32, contact_id: u32) -> Result<()> { + let ctx = self.get_context(account_id).await?; + Contact::block(&ctx, ContactId::new(contact_id)).await + } + + async fn contacts_unblock(&self, account_id: u32, contact_id: u32) -> Result<()> { + let ctx = self.get_context(account_id).await?; + Contact::unblock(&ctx, ContactId::new(contact_id)).await + } + + async fn contacts_get_blocked(&self, account_id: u32) -> Result> { + let ctx = self.get_context(account_id).await?; + let blocked_ids = Contact::get_all_blocked(&ctx).await?; + let mut contacts: Vec = Vec::with_capacity(blocked_ids.len()); + for id in blocked_ids { + contacts.push( + ContactObject::from_dc_contact( + &ctx, + deltachat::contact::Contact::get_by_id(&ctx, id).await?, + ) + .await?, + ); + } + Ok(contacts) + } + + async fn contacts_get_contact_ids( + &self, + account_id: u32, + list_flags: u32, + query: Option, + ) -> Result> { + let ctx = self.get_context(account_id).await?; + let contacts = Contact::get_all(&ctx, list_flags, query).await?; + Ok(contacts.into_iter().map(|c| c.to_u32()).collect()) + } + + /// Get a list of contacts. + /// (formerly called getContacts2 in desktop) + async fn contacts_get_contacts( + &self, + account_id: u32, + list_flags: u32, + query: Option, + ) -> Result> { + let ctx = self.get_context(account_id).await?; + let contact_ids = Contact::get_all(&ctx, list_flags, query).await?; + let mut contacts: Vec = Vec::with_capacity(contact_ids.len()); + for id in contact_ids { + contacts.push( + ContactObject::from_dc_contact( + &ctx, + deltachat::contact::Contact::get_by_id(&ctx, id).await?, + ) + .await?, + ); + } + Ok(contacts) + } + + async fn contacts_get_contacts_by_ids( + &self, + account_id: u32, + ids: Vec, + ) -> Result> { + let ctx = self.get_context(account_id).await?; + + let mut contacts = HashMap::with_capacity(ids.len()); + for id in ids { + contacts.insert( + id, + ContactObject::from_dc_contact( + &ctx, + deltachat::contact::Contact::get_by_id(&ctx, ContactId::new(id)).await?, + ) + .await?, + ); + } + Ok(contacts) + } + + // --------------------------------------------- + // misc prototyping functions + // that might get removed later again + // --------------------------------------------- + + /// Returns the messageid of the sent message + async fn misc_send_text_message( + &self, + account_id: u32, + text: String, + chat_id: u32, + ) -> Result { + let ctx = self.get_context(account_id).await?; + + let mut msg = Message::new(Viewtype::Text); + msg.set_text(Some(text)); + + let message_id = deltachat::chat::send_msg(&ctx, ChatId::new(chat_id), &mut msg).await?; + Ok(message_id.to_u32()) + } +} + +// Helper functions (to prevent code duplication) +async fn set_config( + ctx: &deltachat::context::Context, + key: &str, + value: Option<&str>, +) -> Result<(), anyhow::Error> { + if key.starts_with("ui.") { + ctx.set_ui_config(key, value).await + } else { + ctx.set_config(Config::from_str(key).context("unknown key")?, value) + .await + } +} + +async fn get_config( + ctx: &deltachat::context::Context, + key: &str, +) -> Result, anyhow::Error> { + if key.starts_with("ui.") { + ctx.get_ui_config(key).await + } else { + ctx.get_config(Config::from_str(key).context("unknown key")?) + .await + } +} diff --git a/deltachat-jsonrpc/src/api/types/account.rs b/deltachat-jsonrpc/src/api/types/account.rs new file mode 100644 index 000000000..7bb6d3bbc --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/account.rs @@ -0,0 +1,46 @@ +use anyhow::Result; +use deltachat::config::Config; +use deltachat::contact::{Contact, ContactId}; +use serde::Serialize; +use typescript_type_def::TypeDef; + +use super::color_int_to_hex_string; + +#[derive(Serialize, TypeDef)] +#[serde(tag = "type")] +pub enum Account { + //#[serde(rename_all = "camelCase")] + Configured { + id: u32, + display_name: Option, + addr: Option, + // size: u32, + profile_image: Option, // TODO: This needs to be converted to work with blob http server. + color: String, + }, + Unconfigured { + id: u32, + }, +} + +impl Account { + pub async fn from_context(ctx: &deltachat::context::Context, id: u32) -> Result { + if ctx.is_configured().await? { + let display_name = ctx.get_config(Config::Displayname).await?; + let addr = ctx.get_config(Config::Addr).await?; + let profile_image = ctx.get_config(Config::Selfavatar).await?; + let color = color_int_to_hex_string( + Contact::get_by_id(ctx, ContactId::SELF).await?.get_color(), + ); + Ok(Account::Configured { + id, + display_name, + addr, + profile_image, + color, + }) + } else { + Ok(Account::Unconfigured { id }) + } + } +} diff --git a/deltachat-jsonrpc/src/api/types/chat.rs b/deltachat-jsonrpc/src/api/types/chat.rs new file mode 100644 index 000000000..20e04b2fc --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/chat.rs @@ -0,0 +1,91 @@ +use anyhow::{anyhow, Result}; +use deltachat::chat::get_chat_contacts; +use deltachat::chat::{Chat, ChatId}; +use deltachat::contact::{Contact, ContactId}; +use deltachat::context::Context; +use num_traits::cast::ToPrimitive; +use serde::Serialize; +use typescript_type_def::TypeDef; + +use super::color_int_to_hex_string; +use super::contact::ContactObject; + +#[derive(Serialize, TypeDef)] +pub struct FullChat { + id: u32, + name: String, + is_protected: bool, + profile_image: Option, //BLOBS ? + archived: bool, + // subtitle - will be moved to frontend because it uses translation functions + chat_type: u32, + is_unpromoted: bool, + is_self_talk: bool, + contacts: Vec, + contact_ids: Vec, + color: String, + fresh_message_counter: usize, + // is_group - please check over chat.type in frontend instead + is_contact_request: bool, + is_device_chat: bool, + self_in_group: bool, + is_muted: bool, + ephemeral_timer: u32, //TODO look if there are more important properties in newer core versions + can_send: bool, +} + +impl FullChat { + pub async fn from_dc_chat_id(context: &Context, chat_id: u32) -> Result { + let rust_chat_id = ChatId::new(chat_id); + let chat = Chat::load_from_db(context, rust_chat_id).await?; + + let contact_ids = get_chat_contacts(context, rust_chat_id).await?; + + let mut contacts = Vec::new(); + + for contact_id in &contact_ids { + contacts.push( + ContactObject::from_dc_contact( + context, + Contact::load_from_db(context, *contact_id).await?, + ) + .await?, + ) + } + + let profile_image = match chat.get_profile_image(context).await? { + Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()), + None => None, + }; + + let color = color_int_to_hex_string(chat.get_color(context).await?); + let fresh_message_counter = rust_chat_id.get_fresh_msg_cnt(context).await?; + let ephemeral_timer = rust_chat_id.get_ephemeral_timer(context).await?.to_u32(); + + let can_send = chat.can_send(context).await?; + + Ok(FullChat { + id: chat_id, + name: chat.name.clone(), + is_protected: chat.is_protected(), + profile_image, //BLOBS ? + archived: chat.get_visibility() == deltachat::chat::ChatVisibility::Archived, + chat_type: chat + .get_type() + .to_u32() + .ok_or_else(|| anyhow!("unknown chat type id"))?, // TODO get rid of this unwrap? + is_unpromoted: chat.is_unpromoted(), + is_self_talk: chat.is_self_talk(), + contacts, + contact_ids: contact_ids.iter().map(|id| id.to_u32()).collect(), + color, + fresh_message_counter, + is_contact_request: chat.is_contact_request(), + is_device_chat: chat.is_device_talk(), + self_in_group: contact_ids.contains(&ContactId::SELF), + is_muted: chat.is_muted(), + ephemeral_timer, + can_send, + }) + } +} diff --git a/deltachat-jsonrpc/src/api/types/chat_list.rs b/deltachat-jsonrpc/src/api/types/chat_list.rs new file mode 100644 index 000000000..c42d4e48e --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/chat_list.rs @@ -0,0 +1,117 @@ +use anyhow::Result; +use deltachat::constants::*; +use deltachat::contact::ContactId; +use deltachat::{ + chat::{get_chat_contacts, ChatVisibility}, + chatlist::Chatlist, +}; +use deltachat::{ + chat::{Chat, ChatId}, + message::MsgId, +}; +use num_traits::cast::ToPrimitive; +use serde::{Deserialize, Serialize}; +use typescript_type_def::TypeDef; + +use super::color_int_to_hex_string; + +#[derive(Deserialize, Serialize, TypeDef)] +pub struct ChatListEntry(pub u32, pub u32); + +#[derive(Serialize, TypeDef)] +#[serde(tag = "type")] +pub enum ChatListItemFetchResult { + #[serde(rename_all = "camelCase")] + ChatListItem { + id: u32, + name: String, + avatar_path: Option, + color: String, + last_updated: Option, + summary_text1: String, + summary_text2: String, + summary_status: u32, + is_protected: bool, + is_group: bool, + fresh_message_counter: usize, + is_self_talk: bool, + is_device_talk: bool, + is_sending_location: bool, + is_self_in_group: bool, + is_archived: bool, + is_pinned: bool, + is_muted: bool, + is_contact_request: bool, + }, + ArchiveLink, + #[serde(rename_all = "camelCase")] + Error { + id: u32, + error: String, + }, +} + +pub(crate) async fn _get_chat_list_items_by_id( + ctx: &deltachat::context::Context, + entry: &ChatListEntry, +) -> Result { + let chat_id = ChatId::new(entry.0); + let last_msgid = match entry.1 { + 0 => None, + _ => Some(MsgId::new(entry.1)), + }; + + if chat_id.is_archived_link() { + return Ok(ChatListItemFetchResult::ArchiveLink); + } + + let chat = Chat::load_from_db(ctx, chat_id).await?; + let summary = Chatlist::get_summary2(ctx, chat_id, last_msgid, Some(&chat)).await?; + + let summary_text1 = summary.prefix.map_or_else(String::new, |s| s.to_string()); + let summary_text2 = summary.text.to_owned(); + + let visibility = chat.get_visibility(); + + let avatar_path = chat + .get_profile_image(ctx) + .await? + .map(|path| path.to_str().unwrap_or("invalid/path").to_owned()); + + let last_updated = match last_msgid { + Some(id) => { + let last_message = deltachat::message::Message::load_from_db(ctx, id).await?; + Some(last_message.get_timestamp() * 1000) + } + None => None, + }; + + let self_in_group = get_chat_contacts(ctx, chat_id) + .await? + .contains(&ContactId::SELF); + + let fresh_message_counter = chat_id.get_fresh_msg_cnt(ctx).await?; + let color = color_int_to_hex_string(chat.get_color(ctx).await?); + + Ok(ChatListItemFetchResult::ChatListItem { + id: chat_id.to_u32(), + name: chat.get_name().to_owned(), + avatar_path, + color, + last_updated, + summary_text1, + summary_text2, + summary_status: summary.state.to_u32().expect("impossible"), // idea and a function to transform the constant to strings? or return string enum + is_protected: chat.is_protected(), + is_group: chat.get_type() == Chattype::Group, + fresh_message_counter, + is_self_talk: chat.is_self_talk(), + is_device_talk: chat.is_device_talk(), + is_self_in_group: self_in_group, + is_sending_location: chat.is_sending_locations(), + is_archived: visibility == ChatVisibility::Archived, + is_pinned: visibility == ChatVisibility::Pinned, + is_muted: chat.is_muted(), + is_contact_request: chat.is_contact_request(), + }) +} diff --git a/deltachat-jsonrpc/src/api/types/contact.rs b/deltachat-jsonrpc/src/api/types/contact.rs new file mode 100644 index 000000000..9e2bb9656 --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/contact.rs @@ -0,0 +1,50 @@ +use anyhow::Result; +use deltachat::contact::VerifiedStatus; +use deltachat::context::Context; +use serde::Serialize; +use typescript_type_def::TypeDef; + +use super::color_int_to_hex_string; + +#[derive(Serialize, TypeDef)] +#[serde(rename = "Contact")] +pub struct ContactObject { + address: String, + color: String, + auth_name: String, + status: String, + display_name: String, + id: u32, + name: String, + profile_image: Option, // BLOBS + name_and_addr: String, + is_blocked: bool, + is_verified: bool, +} + +impl ContactObject { + pub async fn from_dc_contact( + context: &Context, + contact: deltachat::contact::Contact, + ) -> Result { + let profile_image = match contact.get_profile_image(context).await? { + Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()), + None => None, + }; + let is_verified = contact.is_verified(context).await? == VerifiedStatus::BidirectVerified; + + Ok(ContactObject { + address: contact.get_addr().to_owned(), + color: color_int_to_hex_string(contact.get_color()), + auth_name: contact.get_authname().to_owned(), + status: contact.get_status().to_owned(), + display_name: contact.get_display_name().to_owned(), + id: contact.id.to_u32(), + name: contact.get_name().to_owned(), + profile_image, //BLOBS + name_and_addr: contact.get_name_n_addr(), + is_blocked: contact.is_blocked(), + is_verified, + }) + } +} diff --git a/deltachat-jsonrpc/src/api/types/message.rs b/deltachat-jsonrpc/src/api/types/message.rs new file mode 100644 index 000000000..ab6101674 --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/message.rs @@ -0,0 +1,127 @@ +use anyhow::{anyhow, Result}; +use deltachat::contact::Contact; +use deltachat::context::Context; +use deltachat::message::Message; +use deltachat::message::MsgId; +use num_traits::cast::ToPrimitive; +use serde::Serialize; +use typescript_type_def::TypeDef; + +use super::contact::ContactObject; + +#[derive(Serialize, TypeDef)] +#[serde(rename = "Message")] +pub struct MessageObject { + id: u32, + chat_id: u32, + from_id: u32, + quoted_text: Option, + quoted_message_id: Option, + text: Option, + has_location: bool, + has_html: bool, + view_type: u32, + state: u32, + + timestamp: i64, + sort_timestamp: i64, + received_timestamp: i64, + has_deviating_timestamp: bool, + + // summary - use/create another function if you need it + subject: String, + show_padlock: bool, + is_setupmessage: bool, + is_info: bool, + is_forwarded: bool, + + duration: i32, + dimensions_height: i32, + dimensions_width: i32, + + videochat_type: Option, + videochat_url: Option, + + override_sender_name: Option, + sender: ContactObject, + + setup_code_begin: Option, + + file: Option, + file_mime: Option, + file_bytes: u64, + file_name: Option, +} + +impl MessageObject { + pub async fn from_message_id(context: &Context, message_id: u32) -> Result { + let msg_id = MsgId::new(message_id); + let message = Message::load_from_db(context, msg_id).await?; + + let quoted_message_id = message + .quoted_message(context) + .await? + .map(|m| m.get_id().to_u32()); + + let sender_contact = Contact::load_from_db(context, message.get_from_id()).await?; + let sender = ContactObject::from_dc_contact(context, sender_contact).await?; + let file_bytes = message.get_filebytes(context).await; + let override_sender_name = message.get_override_sender_name(); + + Ok(MessageObject { + id: message_id, + chat_id: message.get_chat_id().to_u32(), + from_id: message.get_from_id().to_u32(), + quoted_text: message.quoted_text(), + quoted_message_id, + text: message.get_text(), + has_location: message.has_location(), + has_html: message.has_html(), + view_type: message + .get_viewtype() + .to_u32() + .ok_or_else(|| anyhow!("viewtype conversion to number failed"))?, + state: message + .get_state() + .to_u32() + .ok_or_else(|| anyhow!("state conversion to number failed"))?, + + timestamp: message.get_timestamp(), + sort_timestamp: message.get_sort_timestamp(), + received_timestamp: message.get_received_timestamp(), + has_deviating_timestamp: message.has_deviating_timestamp(), + + subject: message.get_subject().to_owned(), + show_padlock: message.get_showpadlock(), + is_setupmessage: message.is_setupmessage(), + is_info: message.is_info(), + is_forwarded: message.is_forwarded(), + + duration: message.get_duration(), + dimensions_height: message.get_height(), + dimensions_width: message.get_width(), + + videochat_type: match message.get_videochat_type() { + Some(vct) => Some( + vct.to_u32() + .ok_or_else(|| anyhow!("state conversion to number failed"))?, + ), + None => None, + }, + videochat_url: message.get_videochat_url(), + + override_sender_name, + sender, + + setup_code_begin: message.get_setupcodebegin(context).await, + + file: match message.get_file(context) { + Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()), + None => None, + }, //BLOBS + file_mime: message.get_filemime(), + file_bytes, + file_name: message.get_filename(), + }) + } +} diff --git a/deltachat-jsonrpc/src/api/types/mod.rs b/deltachat-jsonrpc/src/api/types/mod.rs new file mode 100644 index 000000000..1e524e0d4 --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/mod.rs @@ -0,0 +1,10 @@ +pub mod account; +pub mod chat; +pub mod chat_list; +pub mod contact; +pub mod message; +pub mod provider_info; + +pub fn color_int_to_hex_string(color: u32) -> String { + format!("{:#08x}", color).replace("0x", "#") +} diff --git a/deltachat-jsonrpc/src/api/types/provider_info.rs b/deltachat-jsonrpc/src/api/types/provider_info.rs new file mode 100644 index 000000000..22a43b163 --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/provider_info.rs @@ -0,0 +1,21 @@ +use deltachat::provider::Provider; +use num_traits::cast::ToPrimitive; +use serde::Serialize; +use typescript_type_def::TypeDef; + +#[derive(Serialize, TypeDef)] +pub struct ProviderInfo { + pub before_login_hint: String, + pub overview_page: String, + pub status: u32, // in reality this is an enum, but for simlicity and because it gets converted into a number anyway, we use an u32 here. +} + +impl ProviderInfo { + pub fn from_dc_type(provider: Option<&Provider>) -> Option { + provider.map(|p| ProviderInfo { + before_login_hint: p.before_login_hint.to_owned(), + overview_page: p.overview_page.to_owned(), + status: p.status.to_u32().unwrap(), + }) + } +} diff --git a/deltachat-jsonrpc/src/lib.rs b/deltachat-jsonrpc/src/lib.rs new file mode 100644 index 000000000..19a76cb0a --- /dev/null +++ b/deltachat-jsonrpc/src/lib.rs @@ -0,0 +1,58 @@ +pub mod api; +pub use api::events; + +#[cfg(test)] +mod tests { + use super::api::{Accounts, CommandApi}; + use async_channel::unbounded; + use async_std::task; + use futures::StreamExt; + use tempfile::TempDir; + use yerpc::{MessageHandle, RpcHandle}; + + #[async_std::test] + async fn basic_json_rpc_functionality() -> anyhow::Result<()> { + // println!("{}", ""); + let tmp_dir = TempDir::new().unwrap().path().into(); + println!("tmp_dir: {:?}", tmp_dir); + + let accounts = Accounts::new(tmp_dir).await?; + let cmd_api = CommandApi::new(accounts); + + let (sender, mut receiver) = unbounded::(); + + let (request_handle, mut rx) = RpcHandle::new(); + let session = cmd_api; + let handle = MessageHandle::new(request_handle, session); + task::spawn({ + async move { + while let Some(message) = rx.next().await { + let message = serde_json::to_string(&message)?; + // Abort serialization on error. + sender.send(message).await?; + } + let res: Result<(), anyhow::Error> = Ok(()); + res + } + }); + + { + let request = r#"{"jsonrpc":"2.0","method":"add_account","params":[],"id":1}"#; + let response = r#"{"jsonrpc":"2.0","id":1,"result":1}"#; + handle.handle_message(request).await; + let result = receiver.next().await; + println!("{:?}", result); + assert_eq!(result, Some(response.to_owned())); + } + { + let request = r#"{"jsonrpc":"2.0","method":"get_all_account_ids","params":[],"id":2}"#; + let response = r#"{"jsonrpc":"2.0","id":2,"result":[1]}"#; + handle.handle_message(request).await; + let result = receiver.next().await; + println!("{:?}", result); + assert_eq!(result, Some(response.to_owned())); + } + + Ok(()) + } +} diff --git a/deltachat-jsonrpc/src/webserver.rs b/deltachat-jsonrpc/src/webserver.rs new file mode 100644 index 000000000..ffe035c6a --- /dev/null +++ b/deltachat-jsonrpc/src/webserver.rs @@ -0,0 +1,44 @@ +use async_std::path::PathBuf; +use async_std::task; +use tide::Request; +use yerpc::RpcHandle; +use yerpc_tide::yerpc_handler; + +mod api; +use api::events::event_to_json_rpc_notification; +use api::{Accounts, CommandApi}; + +#[async_std::main] +async fn main() -> Result<(), std::io::Error> { + env_logger::init(); + log::info!("Starting"); + + let accounts = Accounts::new(PathBuf::from("./accounts")).await.unwrap(); + let state = CommandApi::new(accounts); + + let mut app = tide::with_state(state.clone()); + app.at("/ws").get(yerpc_handler(request_handler)); + + state.accounts.read().await.start_io().await; + app.listen("127.0.0.1:20808").await?; + + Ok(()) +} +async fn request_handler( + request: Request, + rpc: RpcHandle, +) -> anyhow::Result { + let state = request.state().clone(); + task::spawn(event_loop(state.clone(), rpc)); + Ok(state) +} + +async fn event_loop(state: CommandApi, rpc: RpcHandle) -> anyhow::Result<()> { + let mut events = state.accounts.read().await.get_event_emitter().await; + while let Ok(Some(event)) = events.recv().await { + // log::debug!("event {:?}", event); + let event = event_to_json_rpc_notification(event); + rpc.notify("event", Some(event)).await?; + } + Ok(()) +} diff --git a/deltachat-jsonrpc/typescript/.gitignore b/deltachat-jsonrpc/typescript/.gitignore new file mode 100644 index 000000000..98d74b443 --- /dev/null +++ b/deltachat-jsonrpc/typescript/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +test_dist +coverage +yarn.lock +package-lock.json diff --git a/deltachat-jsonrpc/typescript/.prettierignore b/deltachat-jsonrpc/typescript/.prettierignore new file mode 100644 index 000000000..47da942cf --- /dev/null +++ b/deltachat-jsonrpc/typescript/.prettierignore @@ -0,0 +1,3 @@ +coverage +dist +generated \ No newline at end of file diff --git a/deltachat-jsonrpc/typescript/deltachat.ts b/deltachat-jsonrpc/typescript/deltachat.ts new file mode 100644 index 000000000..d75cd0e48 --- /dev/null +++ b/deltachat-jsonrpc/typescript/deltachat.ts @@ -0,0 +1 @@ +export * from "./src/lib.js"; diff --git a/deltachat-jsonrpc/typescript/example.ts b/deltachat-jsonrpc/typescript/example.ts new file mode 100644 index 000000000..93e8212de --- /dev/null +++ b/deltachat-jsonrpc/typescript/example.ts @@ -0,0 +1,109 @@ +import { RawClient, RPC } from "./src/lib"; +import { eventIdToName } from "./src/events"; +import { WebsocketTransport, Request } from "yerpc"; + +type DeltaEvent = { id: number; contextId: number; field1: any; field2: any }; +var selectedAccount = 0; +window.addEventListener("DOMContentLoaded", (_event) => { + (window as any).selectDeltaAccount = (id: string) => { + selectedAccount = Number(id); + window.dispatchEvent(new Event("account-changed")); + }; + run().catch((err) => console.error("run failed", err)); +}); + +async function run() { + const $main = document.getElementById("main")!; + const $side = document.getElementById("side")!; + const $head = document.getElementById("header")!; + + const transport = new WebsocketTransport("ws://localhost:20808/ws"); + const client = new RawClient(transport); + + (window as any).client = client; + + transport.on("request", (request: Request) => { + const method = request.method; + if (method === "event") { + const params = request.params! as DeltaEvent; + const name = eventIdToName(params.id); + onIncomingEvent(params, name); + } + }); + + window.addEventListener("account-changed", async (_event: Event) => { + await client.selectAccount(selectedAccount); + listChatsForSelectedAccount(); + }); + + await Promise.all([loadAccountsInHeader(), listChatsForSelectedAccount()]); + + async function loadAccountsInHeader() { + const accounts = await client.getAllAccounts(); + for (const account of accounts) { + if (account.type === "Configured") { + write( + $head, + ` + ${account.addr!} +  ` + ); + } + } + } + + async function listChatsForSelectedAccount() { + clear($main); + const selectedAccount = await client.getSelectedAccountId(); + if (!selectedAccount) return write($main, "No account selected"); + const info = await client.getAccountInfo(selectedAccount); + if (info.type !== "Configured") { + return write($main, "Account is not configured"); + } + write($main, `

${info.addr!}

`); + const chats = await client.getChatlistEntries( + selectedAccount, + 0, + null, + null + ); + for (const [chatId, _messageId] of chats) { + const chat = await client.chatlistGetFullChatById( + selectedAccount, + chatId + ); + write($main, `

${chat.name}

`); + const messageIds = await client.messageListGetMessageIds( + selectedAccount, + chatId, + 0 + ); + const messages = await client.messageGetMessages( + selectedAccount, + messageIds + ); + for (const [_messageId, message] of Object.entries(messages)) { + write($main, `

${message.text}

`); + } + } + } + + function onIncomingEvent(event: DeltaEvent, name: string) { + write( + $side, + ` +

+ [${name} on account ${event.contextId}]
+ f1: ${JSON.stringify(event.field1)}
+ f2: ${JSON.stringify(event.field2)} +

` + ); + } +} + +function write(el: HTMLElement, html: string) { + el.innerHTML += html; +} +function clear(el: HTMLElement) { + el.innerHTML = ""; +} diff --git a/deltachat-jsonrpc/typescript/generated/client.ts b/deltachat-jsonrpc/typescript/generated/client.ts new file mode 100644 index 000000000..c18957abb --- /dev/null +++ b/deltachat-jsonrpc/typescript/generated/client.ts @@ -0,0 +1,249 @@ +// AUTO-GENERATED by yerpc-derive + +import * as T from "./types.js" +import * as RPC from "./jsonrpc.js" + +type RequestMethod = (method: string, params?: RPC.Params) => Promise; +type NotificationMethod = (method: string, params?: RPC.Params) => void; + +interface Transport { + request: RequestMethod, + notification: NotificationMethod +} + +export class RawClient { + constructor(private _transport: Transport) {} + + + public checkEmailValidity(email: string): Promise { + return (this._transport.request('check_email_validity', [email] as RPC.Params)) as Promise; + } + + /** + * Get general system info. + */ + public getSystemInfo(): Promise> { + return (this._transport.request('get_system_info', [] as RPC.Params)) as Promise>; + } + + + public addAccount(): Promise { + return (this._transport.request('add_account', [] as RPC.Params)) as Promise; + } + + + public removeAccount(accountId: T.U32): Promise { + return (this._transport.request('remove_account', [accountId] as RPC.Params)) as Promise; + } + + + public getAllAccountIds(): Promise<(T.U32)[]> { + return (this._transport.request('get_all_account_ids', [] as RPC.Params)) as Promise<(T.U32)[]>; + } + + /** + * Select account id for internally selected state. + * TODO: Likely this is deprecated as all methods take an account id now. + */ + public selectAccount(id: T.U32): Promise { + return (this._transport.request('select_account', [id] as RPC.Params)) as Promise; + } + + /** + * Get the selected account id of the internal state.. + * TODO: Likely this is deprecated as all methods take an account id now. + */ + public getSelectedAccountId(): Promise<(T.U32|null)> { + return (this._transport.request('get_selected_account_id', [] as RPC.Params)) as Promise<(T.U32|null)>; + } + + /** + * Get a list of all configured accounts. + */ + public getAllAccounts(): Promise<(T.Account)[]> { + return (this._transport.request('get_all_accounts', [] as RPC.Params)) as Promise<(T.Account)[]>; + } + + /** + * Get top-level info for an account. + */ + public getAccountInfo(accountId: T.U32): Promise { + return (this._transport.request('get_account_info', [accountId] as RPC.Params)) as Promise; + } + + /** + * Returns provider for the given domain. + * + * This function looks up domain in offline database first. If not + * found, it queries MX record for the domain and looks up offline + * database for MX domains. + * + * For compatibility, email address can be passed to this function + * instead of the domain. + */ + public getProviderInfo(accountId: T.U32, email: string): Promise<(T.ProviderInfo|null)> { + return (this._transport.request('get_provider_info', [accountId, email] as RPC.Params)) as Promise<(T.ProviderInfo|null)>; + } + + /** + * Checks if the context is already configured. + */ + public isConfigured(accountId: T.U32): Promise { + return (this._transport.request('is_configured', [accountId] as RPC.Params)) as Promise; + } + + + public getInfo(accountId: T.U32): Promise> { + return (this._transport.request('get_info', [accountId] as RPC.Params)) as Promise>; + } + + + public setConfig(accountId: T.U32, key: string, value: (string|null)): Promise { + return (this._transport.request('set_config', [accountId, key, value] as RPC.Params)) as Promise; + } + + + public batchSetConfig(accountId: T.U32, config: Record): Promise { + return (this._transport.request('batch_set_config', [accountId, config] as RPC.Params)) as Promise; + } + + + public getConfig(accountId: T.U32, key: string): Promise<(string|null)> { + return (this._transport.request('get_config', [accountId, key] as RPC.Params)) as Promise<(string|null)>; + } + + + public batchGetConfig(accountId: T.U32, keys: (string)[]): Promise> { + return (this._transport.request('batch_get_config', [accountId, keys] as RPC.Params)) as Promise>; + } + + /** + * Configures this account with the currently set parameters. + * Setup the credential config before calling this. + */ + public configure(accountId: T.U32): Promise { + return (this._transport.request('configure', [accountId] as RPC.Params)) as Promise; + } + + /** + * Signal an ongoing process to stop. + */ + public stopOngoingProcess(accountId: T.U32): Promise { + return (this._transport.request('stop_ongoing_process', [accountId] as RPC.Params)) as Promise; + } + + + public autocryptInitiateKeyTransfer(accountId: T.U32): Promise { + return (this._transport.request('autocrypt_initiate_key_transfer', [accountId] as RPC.Params)) as Promise; + } + + + public autocryptContinueKeyTransfer(accountId: T.U32, messageId: T.U32, setupCode: string): Promise { + return (this._transport.request('autocrypt_continue_key_transfer', [accountId, messageId, setupCode] as RPC.Params)) as Promise; + } + + + public getChatlistEntries(accountId: T.U32, listFlags: (T.U32|null), queryString: (string|null), queryContactId: (T.U32|null)): Promise<(T.ChatListEntry)[]> { + return (this._transport.request('get_chatlist_entries', [accountId, listFlags, queryString, queryContactId] as RPC.Params)) as Promise<(T.ChatListEntry)[]>; + } + + + public getChatlistItemsByEntries(accountId: T.U32, entries: (T.ChatListEntry)[]): Promise> { + return (this._transport.request('get_chatlist_items_by_entries', [accountId, entries] as RPC.Params)) as Promise>; + } + + + public chatlistGetFullChatById(accountId: T.U32, chatId: T.U32): Promise { + return (this._transport.request('chatlist_get_full_chat_by_id', [accountId, chatId] as RPC.Params)) as Promise; + } + + + public acceptChat(accountId: T.U32, chatId: T.U32): Promise { + return (this._transport.request('accept_chat', [accountId, chatId] as RPC.Params)) as Promise; + } + + + public blockChat(accountId: T.U32, chatId: T.U32): Promise { + return (this._transport.request('block_chat', [accountId, chatId] as RPC.Params)) as Promise; + } + + + public messageListGetMessageIds(accountId: T.U32, chatId: T.U32, flags: T.U32): Promise<(T.U32)[]> { + return (this._transport.request('message_list_get_message_ids', [accountId, chatId, flags] as RPC.Params)) as Promise<(T.U32)[]>; + } + + + public messageGetMessage(accountId: T.U32, messageId: T.U32): Promise { + return (this._transport.request('message_get_message', [accountId, messageId] as RPC.Params)) as Promise; + } + + + public messageGetMessages(accountId: T.U32, messageIds: (T.U32)[]): Promise> { + return (this._transport.request('message_get_messages', [accountId, messageIds] as RPC.Params)) as Promise>; + } + + /** + * Get a single contact options by ID. + */ + public contactsGetContact(accountId: T.U32, contactId: T.U32): Promise { + return (this._transport.request('contacts_get_contact', [accountId, contactId] as RPC.Params)) as Promise; + } + + /** + * Add a single contact as a result of an explicit user action. + * + * Returns contact id of the created or existing contact + */ + public contactsCreateContact(accountId: T.U32, email: string, name: (string|null)): Promise { + return (this._transport.request('contacts_create_contact', [accountId, email, name] as RPC.Params)) as Promise; + } + + /** + * Returns contact id of the created or existing DM chat with that contact + */ + public contactsCreateChatByContactId(accountId: T.U32, contactId: T.U32): Promise { + return (this._transport.request('contacts_create_chat_by_contact_id', [accountId, contactId] as RPC.Params)) as Promise; + } + + + public contactsBlock(accountId: T.U32, contactId: T.U32): Promise { + return (this._transport.request('contacts_block', [accountId, contactId] as RPC.Params)) as Promise; + } + + + public contactsUnblock(accountId: T.U32, contactId: T.U32): Promise { + return (this._transport.request('contacts_unblock', [accountId, contactId] as RPC.Params)) as Promise; + } + + + public contactsGetBlocked(accountId: T.U32): Promise<(T.Contact)[]> { + return (this._transport.request('contacts_get_blocked', [accountId] as RPC.Params)) as Promise<(T.Contact)[]>; + } + + + public contactsGetContactIds(accountId: T.U32, listFlags: T.U32, query: (string|null)): Promise<(T.U32)[]> { + return (this._transport.request('contacts_get_contact_ids', [accountId, listFlags, query] as RPC.Params)) as Promise<(T.U32)[]>; + } + + /** + * Get a list of contacts. + * (formerly called getContacts2 in desktop) + */ + public contactsGetContacts(accountId: T.U32, listFlags: T.U32, query: (string|null)): Promise<(T.Contact)[]> { + return (this._transport.request('contacts_get_contacts', [accountId, listFlags, query] as RPC.Params)) as Promise<(T.Contact)[]>; + } + + + public contactsGetContactsByIds(accountId: T.U32, ids: (T.U32)[]): Promise> { + return (this._transport.request('contacts_get_contacts_by_ids', [accountId, ids] as RPC.Params)) as Promise>; + } + + /** + * Returns the messageid of the sent message + */ + public miscSendTextMessage(accountId: T.U32, text: string, chatId: T.U32): Promise { + return (this._transport.request('misc_send_text_message', [accountId, text, chatId] as RPC.Params)) as Promise; + } + + +} diff --git a/deltachat-jsonrpc/typescript/generated/jsonrpc.ts b/deltachat-jsonrpc/typescript/generated/jsonrpc.ts new file mode 100644 index 000000000..0c5a51e82 --- /dev/null +++ b/deltachat-jsonrpc/typescript/generated/jsonrpc.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED by typescript-type-def + +export type JSONValue=(null|boolean|number|string|(JSONValue)[]|{[key:string]:JSONValue;}); +export type Params=((JSONValue)[]|Record); +export type U32=number; +export type Request={"jsonrpc":"2.0";"method":string;"params"?:Params;"id"?:U32;}; +export type I32=number; +export type Error={"code":I32;"message":string;"data"?:JSONValue;}; +export type Response={"jsonrpc":"2.0";"id":(U32|null);"result"?:JSONValue;"error"?:Error;}; +export type Message=(Request|Response); diff --git a/deltachat-jsonrpc/typescript/index.html b/deltachat-jsonrpc/typescript/index.html new file mode 100644 index 000000000..0ec6b6b24 --- /dev/null +++ b/deltachat-jsonrpc/typescript/index.html @@ -0,0 +1,54 @@ + + + + + + + +
+ +
+

log

+
+

+ Tip: open the dev console and use the client with + window.client +

+ + diff --git a/deltachat-jsonrpc/typescript/node-demo.js b/deltachat-jsonrpc/typescript/node-demo.js new file mode 100644 index 000000000..3d5afdc1b --- /dev/null +++ b/deltachat-jsonrpc/typescript/node-demo.js @@ -0,0 +1,13 @@ +import { Deltachat } from "./dist/deltachat.js"; + +run().catch(console.error); + +async function run() { + const delta = new Deltachat(); + delta.addEventListener("event", (event) => { + console.log("event", event.data); + }); + + const accounts = await delta.rpc.getAllAccounts(); + console.log("accounts", accounts); +} diff --git a/deltachat-jsonrpc/typescript/package.json b/deltachat-jsonrpc/typescript/package.json new file mode 100644 index 000000000..f6a34deaa --- /dev/null +++ b/deltachat-jsonrpc/typescript/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deltachat/jsonrpc-client", + "version": "0.1.0", + "main": "dist/deltachat.js", + "types": "dist/deltachat.d.ts", + "type": "module", + "author": "Delta Chat Developers (ML) ", + "license": "MPL-2.0", + "scripts": { + "prettier:check": "prettier --check **.ts", + "prettier:fix": "prettier --write **.ts", + "build": "npm run generate-bindings && tsc", + "bundle": "npm run build && esbuild --bundle dist/deltachat.js --outfile=dist/deltachat.bundle.js", + "generate-bindings": "cargo test", + "example:build": "tsc && esbuild --bundle dist/example.js --outfile=dist/example.bundle.js", + "example:dev": "esbuild example.ts --bundle --outdir=dist --servedir=.", + "coverage": "tsc -b test && COVERAGE=1 NODE_OPTIONS=--enable-source-maps c8 --include \"dist/*\" -r text -r html -r json mocha test_dist && node report_api_coverage.mjs", + "test": "rm -rf dist && npm run build && npm run coverage && npm run prettier:check" + }, + "dependencies": { + "isomorphic-ws": "^4.0.1", + "tiny-emitter": "git+https://github.com/Simon-Laux/tiny-emitter.git", + "yerpc": "^0.2.3" + }, + "devDependencies": { + "prettier": "^2.6.2", + "chai-as-promised": "^7.1.1", + "@types/chai": "^4.2.21", + "@types/chai-as-promised": "^7.1.5", + "@types/mocha": "^9.0.0", + "@types/node-fetch": "^2.5.7", + "@types/ws": "^7.2.4", + "c8": "^7.10.0", + "chai": "^4.3.4", + "esbuild": "^0.14.11", + "mocha": "^9.1.1", + "node-fetch": "^2.6.1", + "typescript": "^4.5.5", + "ws": "^8.5.0" + } +} diff --git a/deltachat-jsonrpc/typescript/report_api_coverage.mjs b/deltachat-jsonrpc/typescript/report_api_coverage.mjs new file mode 100644 index 000000000..1cd454bcc --- /dev/null +++ b/deltachat-jsonrpc/typescript/report_api_coverage.mjs @@ -0,0 +1,28 @@ +import { readFileSync } from "fs"; +// only checks for the coverge of the api functions in bindings.ts for now +const generated_file = "typescript/generated/client.ts"; +const json = JSON.parse(readFileSync("./coverage/coverage-final.json")); +const jsonCoverage = + json[Object.keys(json).find((k) => k.includes(generated_file))]; +const fnMap = Object.keys(jsonCoverage.fnMap).map( + (key) => jsonCoverage.fnMap[key] +); +const htmlCoverage = readFileSync( + "./coverage/" + generated_file + ".html", + "utf8" +); +const uncoveredLines = htmlCoverage + .split("\n") + .filter((line) => line.includes(`"function not covered"`)); +const uncoveredFunctions = uncoveredLines.map( + (line) => />([\w_]+)\(/.exec(line)[1] +); +console.log( + "\nUncovered api functions:\n" + + uncoveredFunctions + .map((uF) => fnMap.find(({ name }) => name === uF)) + .map( + ({ name, line }) => `.${name.padEnd(40)} (${generated_file}:${line})` + ) + .join("\n") +); diff --git a/deltachat-jsonrpc/typescript/src/client.ts b/deltachat-jsonrpc/typescript/src/client.ts new file mode 100644 index 000000000..110154ea9 --- /dev/null +++ b/deltachat-jsonrpc/typescript/src/client.ts @@ -0,0 +1,83 @@ +import * as T from "../generated/types.js"; +import * as RPC from "../generated/jsonrpc.js"; +import { RawClient } from "../generated/client.js"; +import { WebsocketTransport, BaseTransport, Request } from "yerpc"; +import { eventIdToName } from "./events.js"; +import { TinyEmitter } from "tiny-emitter"; + +export type EventNames = ReturnType | "ALL"; +export type WireEvent = { + id: number; + contextId: number; + field1: any; + field2: any; +}; +export type DeltachatEvent = WireEvent & { name: EventNames }; +export type Events = Record void>; + +export class BaseDeltachat< + Transport extends BaseTransport +> extends TinyEmitter { + rpc: RawClient; + account?: T.Account; + constructor(protected transport: Transport) { + super(); + this.rpc = new RawClient(this.transport); + this.transport.on("request", (request: Request) => { + const method = request.method; + if (method === "event") { + const params = request.params! as WireEvent; + const name = eventIdToName(params.id); + const event = { name, ...params }; + this.emit(name, event); + this.emit("ALL", event); + + if (this.contextEmitters[params.contextId]) { + this.contextEmitters[params.contextId].emit(name, event); + this.contextEmitters[params.contextId].emit("ALL", event); + } + } + }); + } + + async selectAccount(id: number) { + await this.rpc.selectAccount(id); + this.account = await this.rpc.getAccountInfo(id); + } + + async listAccounts(): Promise { + return await this.rpc.getAllAccounts(); + } + + private contextEmitters: TinyEmitter[] = []; + + getContextEvents(account_id: number) { + if (this.contextEmitters[account_id]) { + return this.contextEmitters[account_id]; + } else { + this.contextEmitters[account_id] = new TinyEmitter(); + return this.contextEmitters[account_id]; + } + } +} + +export type Opts = { + url: string; +}; + +export const DEFAULT_OPTS: Opts = { + url: "ws://localhost:20808/ws", +}; +export class Deltachat extends BaseDeltachat { + opts: Opts; + close() { + this.transport._socket.close(); + } + constructor(opts: Opts | string | undefined) { + if (typeof opts === "string") opts = { url: opts }; + if (opts) opts = { ...DEFAULT_OPTS, ...opts }; + else opts = { ...DEFAULT_OPTS }; + super(new WebsocketTransport(opts.url)); + this.opts = opts; + } +} diff --git a/deltachat-jsonrpc/typescript/src/events.ts b/deltachat-jsonrpc/typescript/src/events.ts new file mode 100644 index 000000000..24ee34a01 --- /dev/null +++ b/deltachat-jsonrpc/typescript/src/events.ts @@ -0,0 +1,44 @@ +// Manual update might be required from time to time as this is NOT generated + +export enum Event_TypeID { + INFO = 100, + SMTP_CONNECTED = 101, + IMAP_CONNECTED = 102, + SMTP_MESSAGE_SENT = 103, + IMAP_MESSAGE_DELETED = 104, + IMAP_MESSAGE_MOVED = 105, + NEW_BLOB_FILE = 150, + DELETED_BLOB_FILE = 151, + WARNING = 300, + ERROR = 400, + ERROR_NETWORK = 401, + ERROR_SELF_NOT_IN_GROUP = 410, + MSGS_CHANGED = 2000, + INCOMING_MSG = 2005, + MSGS_NOTICED = 2008, + MSG_DELIVERED = 2010, + MSG_FAILED = 2012, + MSG_READ = 2015, + CHAT_MODIFIED = 2020, + CHAT_EPHEMERAL_TIMER_MODIFIED = 2021, + CONTACTS_CHANGED = 2030, + LOCATION_CHANGED = 2035, + CONFIGURE_PROGRESS = 2041, + IMEX_PROGRESS = 2051, + IMEX_FILE_WRITTEN = 2052, + SECUREJOIN_INVITER_PROGRESS = 2060, + SECUREJOIN_JOINER_PROGRESS = 2061, + CONNECTIVITY_CHANGED = 2100, +} + +export function eventIdToName( + event_id: number +): keyof typeof Event_TypeID | "UNKNOWN_EVENT" { + const name = Event_TypeID[event_id]; + if (name) { + return name as keyof typeof Event_TypeID; + } else { + console.error("Unknown Event id:", event_id); + return "UNKNOWN_EVENT"; + } +} diff --git a/deltachat-jsonrpc/typescript/src/lib.ts b/deltachat-jsonrpc/typescript/src/lib.ts new file mode 100644 index 000000000..86b16bd42 --- /dev/null +++ b/deltachat-jsonrpc/typescript/src/lib.ts @@ -0,0 +1,6 @@ +export * as RPC from "../generated/jsonrpc.js"; +export * as T from "../generated/types.js"; +export { RawClient } from "../generated/client.js"; +export * from "./client.js"; +export * as yerpc from "yerpc"; +export * from "./events.js"; diff --git a/deltachat-jsonrpc/typescript/test/README.md b/deltachat-jsonrpc/typescript/test/README.md new file mode 100644 index 000000000..a489d8181 --- /dev/null +++ b/deltachat-jsonrpc/typescript/test/README.md @@ -0,0 +1 @@ +# tests need to be ported to new API diff --git a/deltachat-jsonrpc/typescript/test/basic.ts b/deltachat-jsonrpc/typescript/test/basic.ts new file mode 100644 index 000000000..bae2a3ced --- /dev/null +++ b/deltachat-jsonrpc/typescript/test/basic.ts @@ -0,0 +1,158 @@ +import { strictEqual } from "assert"; +import chai, { assert, expect } from "chai"; +import chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +import { Deltachat } from "../dist/deltachat.js"; + +import { + CMD_API_Server_Handle, + CMD_API_SERVER_PORT, + startCMD_API_Server, +} from "./test_base.js"; + +describe("basic tests", () => { + let server_handle: CMD_API_Server_Handle; + let dc: Deltachat; + + before(async () => { + server_handle = await startCMD_API_Server(CMD_API_SERVER_PORT); + // make sure server is up by the time we continue + await new Promise((res) => setTimeout(res, 100)); + + dc = new Deltachat({ + url: "ws://localhost:" + CMD_API_SERVER_PORT + "/ws", + }); + dc.on("ALL", (event) => { + //console.log("event", event); + }); + }); + + after(async () => { + dc && dc.close(); + await server_handle.close(); + }); + + it("check email", async () => { + const positive_test_cases = [ + "email@example.com", + "36aa165ae3406424e0c61af17700f397cad3fe8ab83d682d0bddf3338a5dd52e@yggmail@yggmail", + ]; + const negative_test_cases = ["email@", "example.com", "emai221"]; + + expect( + await Promise.all( + positive_test_cases.map((email) => dc.rpc.checkEmailValidity(email)) + ) + ).to.not.contain(false); + + expect( + await Promise.all( + negative_test_cases.map((email) => dc.rpc.checkEmailValidity(email)) + ) + ).to.not.contain(true); + }); + + it("system info", async () => { + const system_info = await dc.rpc.getSystemInfo(); + expect(system_info).to.contain.keys([ + "arch", + "num_cpus", + "deltachat_core_version", + "sqlite_version", + ]); + }); + + describe("account managment", () => { + it("should create account", async () => { + await dc.rpc.addAccount(); + assert((await dc.rpc.getAllAccountIds()).length === 1); + }); + + it("should remove the account again", async () => { + await dc.rpc.removeAccount((await dc.rpc.getAllAccountIds())[0]); + assert((await dc.rpc.getAllAccountIds()).length === 0); + }); + + it("should create multiple accounts", async () => { + await dc.rpc.addAccount(); + await dc.rpc.addAccount(); + await dc.rpc.addAccount(); + await dc.rpc.addAccount(); + assert((await dc.rpc.getAllAccountIds()).length === 4); + }); + }); + + describe("contact managment", function () { + let acc: number; + before(async () => { + acc = await dc.rpc.addAccount(); + }); + it("block and unblock contact", async function () { + const contactId = await dc.rpc.contactsCreateContact( + acc, + "example@delta.chat", + null + ); + expect((await dc.rpc.contactsGetContact(acc, contactId)).is_blocked).to.be + .false; + await dc.rpc.contactsBlock(acc, contactId); + expect((await dc.rpc.contactsGetContact(acc, contactId)).is_blocked).to.be + .true; + expect(await dc.rpc.contactsGetBlocked(acc)).to.have.length(1); + await dc.rpc.contactsUnblock(acc, contactId); + expect((await dc.rpc.contactsGetContact(acc, contactId)).is_blocked).to.be + .false; + expect(await dc.rpc.contactsGetBlocked(acc)).to.have.length(0); + }); + }); + + describe("configuration", function () { + let acc: number; + before(async () => { + acc = await dc.rpc.addAccount(); + }); + + it("set and retrive", async function () { + await dc.rpc.setConfig(acc, "addr", "valid@email"); + assert((await dc.rpc.getConfig(acc, "addr")) == "valid@email"); + }); + it("set invalid key should throw", async function () { + await expect(dc.rpc.setConfig(acc, "invalid_key", "some value")).to.be + .eventually.rejected; + }); + it("get invalid key should throw", async function () { + await expect(dc.rpc.getConfig(acc, "invalid_key")).to.be.eventually + .rejected; + }); + it("set and retrive ui.*", async function () { + await dc.rpc.setConfig(acc, "ui.chat_bg", "color:red"); + assert((await dc.rpc.getConfig(acc, "ui.chat_bg")) == "color:red"); + }); + it("set and retrive (batch)", async function () { + const config = { addr: "valid@email", mail_pw: "1234" }; + await dc.rpc.batchSetConfig(acc, config); + const retrieved = await dc.rpc.batchGetConfig(acc, Object.keys(config)); + expect(retrieved).to.deep.equal(config); + }); + it("set and retrive ui.* (batch)", async function () { + const config = { + "ui.chat_bg": "color:green", + "ui.enter_key_sends": "true", + }; + await dc.rpc.batchSetConfig(acc, config); + const retrieved = await dc.rpc.batchGetConfig(acc, Object.keys(config)); + expect(retrieved).to.deep.equal(config); + }); + it("set and retrive mixed(ui and core) (batch)", async function () { + const config = { + "ui.chat_bg": "color:yellow", + "ui.enter_key_sends": "false", + addr: "valid2@email", + mail_pw: "123456", + }; + await dc.rpc.batchSetConfig(acc, config); + const retrieved = await dc.rpc.batchGetConfig(acc, Object.keys(config)); + expect(retrieved).to.deep.equal(config); + }); + }); +}); diff --git a/deltachat-jsonrpc/typescript/test/online.ts b/deltachat-jsonrpc/typescript/test/online.ts new file mode 100644 index 000000000..5c04d4cd0 --- /dev/null +++ b/deltachat-jsonrpc/typescript/test/online.ts @@ -0,0 +1,189 @@ +import { assert, expect } from "chai"; +import { + Deltachat, + DeltachatEvent, + eventIdToName, + Event_TypeID, +} from "../dist/deltachat.js"; +import { + CMD_API_Server_Handle, + CMD_API_SERVER_PORT, + createTempUser, + startCMD_API_Server, +} from "./test_base.js"; + +describe("online tests", function () { + let server_handle: CMD_API_Server_Handle; + let dc: Deltachat; + let account: { email: string; password: string }; + let account2: { email: string; password: string }; + let acc1: number, acc2: number; + + before(async function () { + if (!process.env.DCC_NEW_TMP_EMAIL) { + if (process.env.COVERAGE && !process.env.COVERAGE_OFFLINE) { + console.error( + "CAN NOT RUN COVERAGE correctly: Missing DCC_NEW_TMP_EMAIL environment variable!\n\n", + "You can set COVERAGE_OFFLINE=1 to circumvent this check and skip the online tests, but those coverage results will be wrong, because some functions can only be tested in the online test" + ); + process.exit(1); + } + console.log( + "Missing DCC_NEW_TMP_EMAIL environment variable!, skip intergration tests" + ); + this.skip(); + } + server_handle = await startCMD_API_Server(CMD_API_SERVER_PORT); + dc = new Deltachat({ + url: "ws://localhost:" + CMD_API_SERVER_PORT + "/ws", + }); + + account = await createTempUser(process.env.DCC_NEW_TMP_EMAIL); + if (!account || !account.email || !account.password) { + console.log( + "We didn't got back an account from the api, skip intergration tests" + ); + this.skip(); + } + + account2 = await createTempUser(process.env.DCC_NEW_TMP_EMAIL); + if (!account2 || !account2.email || !account2.password) { + console.log( + "We didn't got back an account2 from the api, skip intergration tests" + ); + this.skip(); + } + }); + + after(async () => { + dc && dc.close(); + server_handle && (await server_handle.close()); + }); + + let are_configured = false; + + it("configure test accounts", async function () { + this.timeout(20000); + + acc1 = await dc.rpc.addAccount(); + await dc.rpc.setConfig(acc1, "addr", account.email); + await dc.rpc.setConfig(acc1, "mail_pw", account.password); + let configure_promise = dc.rpc.configure(acc1); + + acc2 = await dc.rpc.addAccount(); + await dc.rpc.batchSetConfig(acc2, { + addr: account2.email, + mail_pw: account2.password, + }); + + await Promise.all([configure_promise, dc.rpc.configure(acc2)]); + are_configured = true; + }); + + it("send and recieve text message", async function () { + if (!are_configured) { + this.skip(); + } + this.timeout(15000); + + const contactId = await dc.rpc.contactsCreateContact( + acc1, + account2.email, + null + ); + const chatId = await dc.rpc.contactsCreateChatByContactId(acc1, contactId); + const eventPromise = waitForEvent(dc, "INCOMING_MSG", acc2); + dc.rpc.miscSendTextMessage(acc1, "Hello", chatId); + const { field1: chatIdOnAccountB } = await eventPromise; + await dc.rpc.acceptChat(acc2, chatIdOnAccountB); + const messageList = await dc.rpc.messageListGetMessageIds( + acc2, + chatIdOnAccountB, + 0 + ); + + expect(messageList).have.length(1); + const message = await dc.rpc.messageGetMessage(acc2, messageList[0]); + expect(message.text).equal("Hello"); + }); + + it("send and recieve text message roundtrip, encrypted on answer onwards", async function () { + if (!are_configured) { + this.skip(); + } + this.timeout(7000); + + // send message from A to B + const contactId = await dc.rpc.contactsCreateContact( + acc1, + account2.email, + null + ); + const chatId = await dc.rpc.contactsCreateChatByContactId(acc1, contactId); + dc.rpc.miscSendTextMessage(acc1, "Hello2", chatId); + // wait for message from A + const event = await waitForEvent(dc, "INCOMING_MSG", acc2); + const { field1: chatIdOnAccountB } = event; + + await dc.rpc.acceptChat(acc2, chatIdOnAccountB); + const messageList = await dc.rpc.messageListGetMessageIds( + acc2, + chatIdOnAccountB, + 0 + ); + const message = await dc.rpc.messageGetMessage( + acc2, + messageList.reverse()[0] + ); + expect(message.text).equal("Hello2"); + // Send message back from B to A + dc.rpc.miscSendTextMessage(acc2, "super secret message", chatId); + // Check if answer arives at A and if it is encrypted + await waitForEvent(dc, "INCOMING_MSG", acc1); + + const messageId = ( + await dc.rpc.messageListGetMessageIds(acc1, chatId, 0) + ).reverse()[0]; + const message2 = await dc.rpc.messageGetMessage(acc1, messageId); + expect(message2.text).equal("super secret message"); + expect(message2.show_padlock).equal(true); + }); + + it("get provider info for example.com", async () => { + const acc = await dc.rpc.addAccount(); + const info = await dc.rpc.getProviderInfo(acc, "example.com"); + expect(info).to.be.not.null; + expect(info?.overview_page).to.equal( + "https://providers.delta.chat/example-com" + ); + expect(info?.status).to.equal(3); + }); + + it("get provider info - domain and email should give same result", async () => { + const acc = await dc.rpc.addAccount(); + const info_domain = await dc.rpc.getProviderInfo(acc, "example.com"); + const info_email = await dc.rpc.getProviderInfo(acc, "hi@example.com"); + expect(info_email).to.deep.equal(info_domain); + }); +}); + +type event_data = { + contextId: number; + id: Event_TypeID; + [key: string]: any; +}; +async function waitForEvent( + dc: Deltachat, + event: ReturnType, + accountId: number +): Promise { + return new Promise((res, rej) => { + const callback = (ev: DeltachatEvent) => { + if (ev.contextId == accountId) { + dc.off(event, callback); + res(ev); + } + }; + dc.on(event, callback); + }); +} diff --git a/deltachat-jsonrpc/typescript/test/test_base.ts b/deltachat-jsonrpc/typescript/test/test_base.ts new file mode 100644 index 000000000..eeaad570e --- /dev/null +++ b/deltachat-jsonrpc/typescript/test/test_base.ts @@ -0,0 +1,76 @@ +import { tmpdir } from "os"; +import { join } from "path"; +import { mkdtemp, rm } from "fs/promises"; +import { existsSync } from "fs"; +import { spawn } from "child_process"; +import { unwrapPromise } from "./ts_helpers.js"; +import fetch from "node-fetch"; +/* port is not configurable yet */ + +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export const CMD_API_SERVER_PORT = 20808; +export async function startCMD_API_Server(port: typeof CMD_API_SERVER_PORT) { + const tmp_dir = await mkdtemp(join(tmpdir(), "test_prefix")); + + const path_of_server = join(__dirname, "../../target/debug/webserver"); + + if (!existsSync(path_of_server)) { + throw new Error( + "server executable does not exist, you need to build it first" + + "\nserver executable not found at " + + path_of_server + ); + } + + const server = spawn(path_of_server, { + cwd: tmp_dir, + env: { + RUST_LOG: "info", + }, + }); + let should_close = false; + + server.on("exit", () => { + if (should_close) { + return; + } + throw new Error("Server quit"); + }); + + server.stderr.pipe(process.stderr); + + //server.stdout.pipe(process.stdout) + + return { + close: async () => { + should_close = true; + if (!server.kill(9)) { + console.log("server termination failed"); + } + await rm(tmp_dir, { recursive: true }); + }, + }; +} + +export type CMD_API_Server_Handle = unwrapPromise< + ReturnType +>; + +export async function createTempUser(url: string) { + async function postData(url = "") { + // Default options are marked with * + const response = await fetch(url, { + method: "POST", // *GET, POST, PUT, DELETE, etc. + headers: { + "cache-control": "no-cache", + }, + }); + return response.json(); // parses JSON response into native JavaScript objects + } + + return await postData(url); +} diff --git a/deltachat-jsonrpc/typescript/test/ts_helpers.ts b/deltachat-jsonrpc/typescript/test/ts_helpers.ts new file mode 100644 index 000000000..68d27917d --- /dev/null +++ b/deltachat-jsonrpc/typescript/test/ts_helpers.ts @@ -0,0 +1 @@ +export type unwrapPromise = T extends Promise ? U : never; diff --git a/deltachat-jsonrpc/typescript/test/tsconfig.json b/deltachat-jsonrpc/typescript/test/tsconfig.json new file mode 100644 index 000000000..046cd3591 --- /dev/null +++ b/deltachat-jsonrpc/typescript/test/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "rootDir": ".", + "outDir": "../test_dist", + "target": "ES2020", + "module": "es2020", + "moduleResolution": "node", + "declaration": false, + "esModuleInterop": true, + "noImplicitAny": true, + "isolatedModules": true, + "strictNullChecks": true, + "strict": true, + "sourceMap": true + }, + "compileOnSave": true +} diff --git a/deltachat-jsonrpc/typescript/tsconfig.json b/deltachat-jsonrpc/typescript/tsconfig.json new file mode 100644 index 000000000..2c3812a38 --- /dev/null +++ b/deltachat-jsonrpc/typescript/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "alwaysStrict": true, + "strict": true, + "sourceMap": true, + "strictNullChecks": true, + "rootDir": ".", + "outDir": "dist", + "lib": ["ES2017", "dom"], + "target": "ES2017", + "module": "es2015", + "declaration": true, + "esModuleInterop": true, + "moduleResolution": "node", + "noImplicitAny": true, + "isolatedModules": true + }, + "include": ["*.ts"], + "compileOnSave": false +}