diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..79a6265 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [krypton-byte] diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index e9a5dda..bee8c8a 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -51,13 +51,9 @@ jobs: python-version: "3.12" - uses: astral-sh/setup-uv@v5 - uses: dtolnay/rust-toolchain@stable - - name: Rust cache (tryx root) + - name: Rust cache uses: Swatinem/rust-cache@v2 - - name: Rust cache (whatsapp-rust workspace) - uses: Swatinem/rust-cache@v2 - with: - workspaces: "libs/whatsapp-rust -> target" - name: Install dev dependencies run: uv sync --group dev --no-install-project - name: Build native extension diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index e4ee365..37c933c 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -50,14 +50,9 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - name: Rust cache (tryx root) + - name: Rust cache uses: Swatinem/rust-cache@v2 - - name: Rust cache (whatsapp-rust workspace) - uses: Swatinem/rust-cache@v2 - with: - workspaces: "libs/whatsapp-rust -> target" - - name: Install dev dependencies run: uv sync --group dev --no-install-project diff --git a/Cargo.lock b/Cargo.lock index 01a29a3..95bbe94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,18 +186,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", -] - -[[package]] -name = "audiopus_sys" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" -dependencies = [ - "cmake", - "log", - "pkg-config", + "syn 3.0.4", ] [[package]] @@ -277,34 +266,32 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.3" +version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +checksum = "9e3fac94a66da67200398458a25412bcc3f9b6443b5119a6cad9cf3ccfcd8cc6" dependencies = [ "bon-macros", - "rustversion", ] [[package]] name = "bon-macros" -version = "3.9.3" +version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +checksum = "d4654961ad0494e4774c5c60b4cb4cd0ae9b9d92d039d901638b1dba97ebebf5" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "ident_case", - "prettyplease", + "prettyplease 0.3.0", "proc-macro2", "quote", - "rustversion", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] name = "buffa" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf9e6224bc4ee1f189ad257120c156fb05f95b826f5369d620b24984476c200a" +checksum = "a92f2f5df67a9d5ccfc65237bfc954c56328aee40a04a9b92381ecba370be246" dependencies = [ "base64 0.22.1", "bytes", @@ -320,9 +307,9 @@ dependencies = [ [[package]] name = "buffa-build" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "247d43740a4854a9c1b98a5931d6d67d8f8b41ffeb2a43ca008d460e79b1eb2c" +checksum = "eee6b6b4a8d23f1f98a043f188f4d9d2ef8bee43191f42e93ac577f3c3b0f334" dependencies = [ "buffa", "buffa-codegen", @@ -331,13 +318,13 @@ dependencies = [ [[package]] name = "buffa-codegen" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2074a9c2f76b2ebe6af40f7bf67b6a19d6ce3663253ef2a55124dc93f38b230e" +checksum = "bb4bea10ef85ea3d06a20412c67cb612527feb25f33367eab850139441fb5dd9" dependencies = [ "buffa", "buffa-descriptor", - "prettyplease", + "prettyplease 0.2.37", "proc-macro2", "quote", "syn 2.0.119", @@ -346,9 +333,9 @@ dependencies = [ [[package]] name = "buffa-descriptor" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964d735e0b06cb24fa15a68c5aa99549abcc7e0bbeb76298f2fec57912fb79c1" +checksum = "2b7dd79a6898e70dac8adae959c8ad2cae51ed96397935c91c996280cedcb55e" dependencies = [ "buffa", "rustversion", @@ -382,7 +369,7 @@ checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -680,12 +667,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -704,15 +691,15 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -728,13 +715,13 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -854,7 +841,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1023,7 +1010,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1099,6 +1086,18 @@ dependencies = [ "polyval", ] +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1400,9 +1399,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", "pkg-config", @@ -1455,12 +1454,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "md5" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" - [[package]] name = "memchr" version = "2.8.3" @@ -1626,11 +1619,20 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "opus" -version = "0.3.1" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33718946cc77d4032911d4efe03a66dbcbfbd2bb16c3da06aaeadcc637c32216" +dependencies = [ + "opusic-sys", +] + +[[package]] +name = "opusic-sys" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c" +checksum = "c9d1ecdf206421bc74343ab3bb2f30ad2abbfee41fa341f7181fecbaf957769a" dependencies = [ - "audiopus_sys", + "cmake", ] [[package]] @@ -1732,6 +1734,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.4", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1758,7 +1770,7 @@ checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1996,7 +2008,7 @@ checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -2011,9 +2023,9 @@ dependencies = [ [[package]] name = "rtc-crypto" -version = "0.21.0-beta.1" +version = "0.21.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d47521f43ea9a6e16feafa78ba06633ca85a51f0ad66b906a5f01ccfe08ba90" +checksum = "df0f2e4801a79469b8094f715f563d946c76826336f8d9fefb47dc6628524f11" dependencies = [ "aes 0.8.4", "ccm", @@ -2030,9 +2042,9 @@ dependencies = [ [[package]] name = "rtc-datachannel" -version = "0.21.0-beta.1" +version = "0.21.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374adcfc6f138839109d84c521ccedae9410b11f62c1ae577cb786ed91494f50" +checksum = "0180e434128b3a3e9d8552ea958fd588839266cf08a2625741fbe65c292d1cb8" dependencies = [ "bytes", "log", @@ -2043,9 +2055,9 @@ dependencies = [ [[package]] name = "rtc-dtls" -version = "0.21.0-beta.1" +version = "0.21.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3baf3ee5b4d481d57fdee828ed4abd281ba45ad98594502cd4e866d14316b48f" +checksum = "b6cb87e1ec1b828914aace884307974a9ed40747e2f6ccaab93a4fff42528a73" dependencies = [ "bytecheck", "byteorder", @@ -2063,9 +2075,9 @@ dependencies = [ [[package]] name = "rtc-sctp" -version = "0.21.0-beta.1" +version = "0.21.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db38ed06af7bea634b210c3e8cf0e78e3bb36669e120968f5961a3c12a209b67" +checksum = "9d9dabdca726a7c5eed4d4ae5f3f9d4e9baf7a2ed53efaedaae35b4bcc24bc2a" dependencies = [ "bytes", "crc32c", @@ -2079,9 +2091,9 @@ dependencies = [ [[package]] name = "rtc-shared" -version = "0.21.0-beta.1" +version = "0.21.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "108063844e58fa8470139c2d6bea3a28362e07d43194acef7e0d57d829d27cd6" +checksum = "998b045d95d158251842c5146918f3213346ca7724e8c6ac750c37d467dbbcdd" dependencies = [ "bitflags 1.3.2", "bytes", @@ -2199,6 +2211,15 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + [[package]] name = "serde" version = "1.0.229" @@ -2235,7 +2256,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -2524,9 +2545,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -2600,7 +2621,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -2691,7 +2712,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -2984,6 +3005,7 @@ dependencies = [ "buffa", "buffa-build", "bytes", + "cbc", "chrono", "compact_str", "ctr 0.10.1", @@ -2995,7 +3017,6 @@ dependencies = [ "hmac 0.13.0", "itoa", "log", - "md5", "portable-atomic", "rand", "serde", @@ -3059,7 +3080,7 @@ version = "0.7.0" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -3209,6 +3230,7 @@ dependencies = [ "getrandom 0.4.3", "hashbrown 0.17.1", "hex", + "hmac 0.13.0", "itoa", "log", "opus", @@ -3219,8 +3241,10 @@ dependencies = [ "rtc-sctp", "rtc-shared", "scopeguard", + "send_wrapper", "serde", "serde_json", + "sha2", "smallvec", "thiserror 2.0.20", "tokio", @@ -3243,13 +3267,16 @@ dependencies = [ "bytes", "diesel", "diesel_migrations", + "gloo-timers", "libsqlite3-sys", "log", "scheduled-thread-pool", "serde_json", "sha2", + "thiserror 2.0.20", "tokio", "wacore", + "wacore-binary", ] [[package]] @@ -3619,7 +3646,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 45c1e69..5af9b61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ wacore-binary = { path = "libs/whatsapp-rust/wacore/binary", default-features = tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "signal", "sync", "process"] } async-trait = "0.1.89" pyo3-async-runtimes = { version = "0.28.0", features = ["attributes", "tokio-runtime"] } -buffa = { version = "0.9.1", default-features = false, features = ["json"] } +buffa = { version = "0.9.2", default-features = false, features = ["json"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter", "fmt"] } chrono = "0.4.44" diff --git a/README.id.md b/README.id.md index e923495..0211f8f 100644 --- a/README.id.md +++ b/README.id.md @@ -1,8 +1,13 @@ +
+Tryx + # Tryx [![PyPI version](https://img.shields.io/pypi/v/tryx?color=blue)](https://pypi.org/project/tryx/) [![Python](https://img.shields.io/pypi/pyversions/tryx.svg)](https://pypi.org/project/tryx/) [![License](https://img.shields.io/github/license/krypton-byte/tryx)](LICENSE) + +
[![Docs](https://img.shields.io/badge/docs-online-brightgreen)](https://krypton-byte.github.io/tryx/) **Bahasa:** [English](README.md) | [简体中文](README.zh.md) | Bahasa Indonesia diff --git a/README.md b/README.md index 95a24c2..fbbdeea 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +
+Tryx + # Tryx [![PyPI version](https://img.shields.io/pypi/v/tryx?color=blue)](https://pypi.org/project/tryx/) @@ -7,6 +10,8 @@ **Language:** English | [简体中文](README.zh.md) | [Bahasa Indonesia](README.id.md) +
+ Tryx is a Python automation library powered by Rust and PyO3. It provides a Python-friendly API on top of the `whatsapp-rust` crate, with async support, persistent sessions, and access to advanced WhatsApp Web protocol features. > Tryx is an independent project. It is not affiliated with WhatsApp, Meta, or their official products. diff --git a/README.zh.md b/README.zh.md index bc51c1e..371ee42 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,8 +1,13 @@ +
+Tryx + # Tryx [![PyPI version](https://img.shields.io/pypi/v/tryx?color=blue)](https://pypi.org/project/tryx/) [![Python](https://img.shields.io/pypi/pyversions/tryx.svg)](https://pypi.org/project/tryx/) [![License](https://img.shields.io/github/license/krypton-byte/tryx)](LICENSE) + +
[![Docs](https://img.shields.io/badge/docs-online-brightgreen)](https://krypton-byte.github.io/tryx/) **语言:** [English](README.md) | 简体中文 | [Bahasa Indonesia](README.id.md) diff --git a/assets/mascot.png b/assets/mascot.png new file mode 100644 index 0000000..7cf791c Binary files /dev/null and b/assets/mascot.png differ diff --git a/docs/api/client.md b/docs/api/client.md index 39c5d16..bbf05c9 100644 --- a/docs/api/client.md +++ b/docs/api/client.md @@ -1,4 +1,4 @@ -# Client API Gateway +# :material-api: Client API Gateway ::: tryx.client.Tryx options: diff --git a/docs/api/events.md b/docs/api/events.md index 4a18b08..c52b3da 100644 --- a/docs/api/events.md +++ b/docs/api/events.md @@ -1,4 +1,4 @@ -# Events API +# :material-broadcast: Events API ::: tryx.events.Dispatcher options: @@ -172,6 +172,37 @@ async def on_presence(client, event): pass ``` +## Example: Group Subject Change with LID Addressing + +When a group subject (name) is changed, the `Subject` action carries the renamer's identity. In LID-addressed groups, the phone-number alias arrives in a separate field: + +```python +from tryx.events import EvGroupUpdate, GroupNotificationAction + + +@app.on(EvGroupUpdate) +async def on_group_update(client, event): + action = event.data.action + + if isinstance(action, GroupNotificationAction.Subject): + renamer = action.subject_owner # JID (may be a LID) + renamer_pn = action.subject_owner_pn # phone-number JID (when LID addressing) + renamer_name = action.subject_owner_username # username (when enabled) + + # Prefer the phone-number JID for display when available + display_jid = renamer_pn or renamer + print(f"Group renamed to {action.subject!r} by {display_jid}") + + # Log all identity fields for audit + if renamer_pn: + print(f" LID: {renamer}, PN: {renamer_pn}") + if renamer_name: + print(f" Username: {renamer_name}") +``` + +!!! tip "LID vs Phone Number" + In WhatsApp's LID addressing mode, `subject_owner` may be a `@lid` JID that is not human-readable. The `subject_owner_pn` field provides the corresponding phone-number JID for display. When LID addressing is not active, `subject_owner_pn` is `None` and `subject_owner` already contains the phone-number JID. + ## Enum-like Support Types Common reason/state classes used by event payloads: diff --git a/docs/api/exceptions.md b/docs/api/exceptions.md index aa3c0bd..e40a726 100644 --- a/docs/api/exceptions.md +++ b/docs/api/exceptions.md @@ -1,4 +1,4 @@ -# Exceptions API +# :material-alert-circle: Exceptions API ::: tryx.exceptions.FailedBuildClient options: diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..99ad245 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,7 @@ +--- +icon: material/api +--- + +# :material-api: API Reference + +Complete API documentation for all Tryx modules. diff --git a/docs/api/types.md b/docs/api/types.md index 05c9122..6e02eff 100644 --- a/docs/api/types.md +++ b/docs/api/types.md @@ -1,4 +1,4 @@ -# Types API +# :material-shape: Types API ::: tryx.types.JID options: diff --git a/docs/assets/mascot.png b/docs/assets/mascot.png new file mode 100644 index 0000000..7cf791c Binary files /dev/null and b/docs/assets/mascot.png differ diff --git a/docs/core-concepts/architecture.md b/docs/core-concepts/architecture.md index 4194aea..b469839 100644 --- a/docs/core-concepts/architecture.md +++ b/docs/core-concepts/architecture.md @@ -1,4 +1,4 @@ -# Architecture +# :material-puzzle: Architecture Tryx splits performance-sensitive protocol work (Rust) from application ergonomics (Python). This gives you WhatsApp protocol handling at native diff --git a/docs/core-concepts/event-model.md b/docs/core-concepts/event-model.md index f18709e..65e6bd5 100644 --- a/docs/core-concepts/event-model.md +++ b/docs/core-concepts/event-model.md @@ -1,4 +1,4 @@ -# Event Model +# :material-bell-ring: Event Model Tryx uses an event-driven architecture. All WhatsApp interactions — messages, presence changes, group updates, contact syncs — flow through typed event diff --git a/docs/core-concepts/index.md b/docs/core-concepts/index.md new file mode 100644 index 0000000..d0cbb41 --- /dev/null +++ b/docs/core-concepts/index.md @@ -0,0 +1,7 @@ +--- +icon: material/puzzle-outline +--- + +# :material-puzzle: Core Concepts + +Understand the architecture and design principles behind Tryx. diff --git a/docs/core-concepts/storage-backends.md b/docs/core-concepts/storage-backends.md index efc2f9a..7d203eb 100644 --- a/docs/core-concepts/storage-backends.md +++ b/docs/core-concepts/storage-backends.md @@ -1,4 +1,4 @@ -# Storage Backends +# :material-database: Storage Backends Tryx supports a **3-tier storage architecture** so you can pick the right balance between simplicity, performance, and flexibility for your project. diff --git a/docs/core-concepts/type-system.md b/docs/core-concepts/type-system.md index cd37b2d..4330d47 100644 --- a/docs/core-concepts/type-system.md +++ b/docs/core-concepts/type-system.md @@ -1,4 +1,4 @@ -# Type System +# :material-code-braces: Type System Tryx ships with complete `.pyi` stub files and a `py.typed` marker, enabling full editor intelligence, static analysis, and API discoverability. diff --git a/docs/faq/qna.md b/docs/faq/qna.md index cebc82e..a885128 100644 --- a/docs/faq/qna.md +++ b/docs/faq/qna.md @@ -1,4 +1,4 @@ -# FAQ +# :material-frequently-asked-questions: FAQ Quick answers to common questions. Jump to the linked pages for implementation details. diff --git a/docs/getting-started/authentication.md b/docs/getting-started/authentication.md index f9d7555..8edbd32 100644 --- a/docs/getting-started/authentication.md +++ b/docs/getting-started/authentication.md @@ -1,4 +1,4 @@ -# Authentication Flow +# :material-key-variant: Authentication Flow Tryx follows the WhatsApp multi-device pairing flow. The first run links a session, and later runs reuse stored state. diff --git a/docs/getting-started/contributing.md b/docs/getting-started/contributing.md index e92d8d8..3f5d25a 100644 --- a/docs/getting-started/contributing.md +++ b/docs/getting-started/contributing.md @@ -1,4 +1,4 @@ -# Contributing +# :material-account-group: Contributing This guide explains how to contribute to Tryx development. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md new file mode 100644 index 0000000..ab1f94f --- /dev/null +++ b/docs/getting-started/index.md @@ -0,0 +1,7 @@ +--- +icon: material/rocket-launch-outline +--- + +# :material-rocket-launch: Getting Started + +Get up and running with Tryx quickly. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 76e3c9c..80a8510 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,4 +1,4 @@ -# Installation +# :material-package-variant: Installation This guide sets up a working development environment for building with Tryx. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 374aa80..1faa13f 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -1,4 +1,4 @@ -# Quick Start +# :material-rocket-launch: Quick Start Build a working WhatsApp bot in 5 minutes. This guide walks through the minimal setup, then expands with real functionality. diff --git a/docs/index.md b/docs/index.md index 997a3ae..f34b44b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,11 @@ +--- +icon: material/home +--- + +
+Tryx +
+ # Tryx **Rust-powered Python SDK for event-driven WhatsApp automation.** diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index e9bd46a..f50f4de 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -1,4 +1,4 @@ -# Deployment Guide +# :material-cloud-upload: Deployment Guide Deploy Tryx bots safely in production with stable session storage and predictable restarts. diff --git a/docs/operations/index.md b/docs/operations/index.md new file mode 100644 index 0000000..90185c6 --- /dev/null +++ b/docs/operations/index.md @@ -0,0 +1,7 @@ +--- +icon: material/cog-outline +--- + +# :material-cog: Operations + +Deployment, monitoring, and maintenance guides. diff --git a/docs/operations/performance.md b/docs/operations/performance.md index 88ab8b0..2b46c63 100644 --- a/docs/operations/performance.md +++ b/docs/operations/performance.md @@ -1,4 +1,4 @@ -# Performance Guide +# :material-speedometer: Performance Guide Optimize for predictable latency and stable memory under burst traffic. diff --git a/docs/operations/reliability.md b/docs/operations/reliability.md index 45759b6..501ff3a 100644 --- a/docs/operations/reliability.md +++ b/docs/operations/reliability.md @@ -1,4 +1,4 @@ -# Reliability Playbook +# :material-shield-check: Reliability Playbook This page focuses on idempotency, retry strategy, and safe handler design. diff --git a/docs/operations/security.md b/docs/operations/security.md index 642e73a..7a458a0 100644 --- a/docs/operations/security.md +++ b/docs/operations/security.md @@ -1,4 +1,4 @@ -# Security Practices +# :material-lock: Security Practices Secure your client around four risk zones: session state, operator controls, user input, and outbound behavior. diff --git a/docs/operations/troubleshooting.md b/docs/operations/troubleshooting.md index 8e07a4c..0fc5efd 100644 --- a/docs/operations/troubleshooting.md +++ b/docs/operations/troubleshooting.md @@ -1,4 +1,4 @@ -# Troubleshooting +# :material-wrench: Troubleshooting Use this page as a decision tree first, checklist second. diff --git a/docs/overrides/main.html b/docs/overrides/main.html index 0af326a..8c4bcf4 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -1,5 +1,11 @@ {% extends "base.html" %} +{% block scripts %} + {{ super() }} + + +{% endblock %} + {% block outdated %} You're not viewing the latest version. diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md index ca51fd0..7b3c1d3 100644 --- a/docs/reference/changelog.md +++ b/docs/reference/changelog.md @@ -1,4 +1,4 @@ -# Changelog Policy +# :material-history: Changelog Policy Tryx changelog is generated automatically with `python-semantic-release`. diff --git a/docs/reference/error-handling.md b/docs/reference/error-handling.md index c57fafa..05d8a1d 100644 --- a/docs/reference/error-handling.md +++ b/docs/reference/error-handling.md @@ -1,4 +1,4 @@ -# Error Handling +# :material-bug: Error Handling Use exception classes and failure classification to decide whether to retry, fail fast, or trigger operator action. diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 2d0cd25..7cb37ff 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -1,4 +1,4 @@ -# Glossary +# :material-book-alphabetical: Glossary --- diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 0000000..39e2867 --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,7 @@ +--- +icon: material/bookshelf +--- + +# :material-bookshelf: Reference + +Additional resources, FAQ, and changelog. diff --git a/docs/reference/roadmap.md b/docs/reference/roadmap.md index 301658b..e252cd1 100644 --- a/docs/reference/roadmap.md +++ b/docs/reference/roadmap.md @@ -1,4 +1,4 @@ -# Roadmap +# :material-map: Roadmap ## Near-Term diff --git a/docs/tutorials/command-bot.md b/docs/tutorials/command-bot.md index 208ed90..88a9da7 100644 --- a/docs/tutorials/command-bot.md +++ b/docs/tutorials/command-bot.md @@ -1,4 +1,4 @@ -# Tutorial: Command Bot +# :material-console: Tutorial: Command Bot Build a command-driven WhatsApp bot that stays maintainable as command count grows. This tutorial progresses from a minimal router to a production-ready diff --git a/docs/tutorials/group-automation.md b/docs/tutorials/group-automation.md index 3ae71de..5927c51 100644 --- a/docs/tutorials/group-automation.md +++ b/docs/tutorials/group-automation.md @@ -1,4 +1,4 @@ -# Tutorial: Group Automation +# :material-account-multiple: Tutorial: Group Automation Automate full group lifecycle with safe participant governance. diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md new file mode 100644 index 0000000..9d98f9b --- /dev/null +++ b/docs/tutorials/index.md @@ -0,0 +1,7 @@ +--- +icon: material/school-outline +--- + +# :material-school: Tutorials + +Step-by-step guides to build real-world integrations. diff --git a/docs/tutorials/media-workflows.md b/docs/tutorials/media-workflows.md index 67899e6..cf9f6e3 100644 --- a/docs/tutorials/media-workflows.md +++ b/docs/tutorials/media-workflows.md @@ -1,4 +1,4 @@ -# Tutorial: Media Workflows +# :material-image-multiple: Tutorial: Media Workflows This tutorial covers send, download, and recovery flows for media-heavy bots. diff --git a/docs/tutorials/poll-survey.md b/docs/tutorials/poll-survey.md index d0ed405..f06abac 100644 --- a/docs/tutorials/poll-survey.md +++ b/docs/tutorials/poll-survey.md @@ -1,4 +1,4 @@ -# Tutorial: Poll Survey Workflow +# :material-poll: Tutorial: Poll Survey Workflow Create poll surveys, collect encrypted votes, and aggregate results. diff --git a/docs/tutorials/profile-privacy.md b/docs/tutorials/profile-privacy.md index c825837..eeea536 100644 --- a/docs/tutorials/profile-privacy.md +++ b/docs/tutorials/profile-privacy.md @@ -1,4 +1,4 @@ -# Tutorial: Profile and Privacy +# :material-account-cog: Tutorial: Profile and Privacy Build admin-controlled profile and privacy management commands. diff --git a/libs/whatsapp-rust b/libs/whatsapp-rust index 1489b7d..539ed05 160000 --- a/libs/whatsapp-rust +++ b/libs/whatsapp-rust @@ -1 +1 @@ -Subproject commit 1489b7da9a6a7e3cadc4337ee46399da7de25915 +Subproject commit 539ed050817f351d080fb5092179b22cebaceffe diff --git a/mkdocs.yml b/mkdocs.yml index c724ea9..0d9ba16 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,60 +9,67 @@ edit_uri: edit/main/docs/ nav: - Home: index.md - Getting Started: - - Installation: getting-started/installation.md - - Quick Start: getting-started/quickstart.md - - Authentication Flow: getting-started/authentication.md - - Contributing: getting-started/contributing.md + - getting-started/index.md + - getting-started/installation.md + - getting-started/quickstart.md + - getting-started/authentication.md + - getting-started/contributing.md - Core Concepts: - - Architecture: core-concepts/architecture.md - - Event Model: core-concepts/event-model.md - - Storage Backends: core-concepts/storage-backends.md - - Type System: core-concepts/type-system.md + - core-concepts/index.md + - core-concepts/architecture.md + - core-concepts/event-model.md + - core-concepts/storage-backends.md + - core-concepts/type-system.md - API Reference: - - Client API Gateway: api/client.md - - Storage Backends: api/backend.md - - Client Namespaces: - - Contact: api/contact.md - - Chat Actions: api/chat-actions.md - - Community: api/community.md - - Newsletter: api/newsletter.md - - Groups: api/groups.md - - Status: api/status.md - - Chatstate: api/chatstate.md - - Blocking: api/blocking.md - - Polls: api/polls.md - - Presence: api/presence.md - - Privacy: api/privacy.md - - Profile: api/profile.md - - Events API: api/events.md - - Types API: api/types.md - - Exceptions: api/exceptions.md - - Helpers: api/helpers.md - - WACore: api/wacore.md + - api/index.md + - api/client.md + - api/backend.md + - api/contact.md + - api/chat-actions.md + - api/community.md + - api/newsletter.md + - api/groups.md + - api/status.md + - api/chatstate.md + - api/blocking.md + - api/polls.md + - api/presence.md + - api/privacy.md + - api/profile.md + - api/events.md + - api/types.md + - api/exceptions.md + - api/helpers.md + - api/wacore.md - Tutorials: - - Command Bot: tutorials/command-bot.md - - Profile & Privacy: tutorials/profile-privacy.md - - Poll Survey: tutorials/poll-survey.md - - Media Workflows: tutorials/media-workflows.md - - Group Automation: tutorials/group-automation.md + - tutorials/index.md + - tutorials/command-bot.md + - tutorials/profile-privacy.md + - tutorials/poll-survey.md + - tutorials/media-workflows.md + - tutorials/group-automation.md - Operations: - - Deployment: operations/deployment.md - - Reliability: operations/reliability.md - - Troubleshooting: operations/troubleshooting.md - - Performance: operations/performance.md - - Security: operations/security.md + - operations/index.md + - operations/deployment.md + - operations/reliability.md + - operations/troubleshooting.md + - operations/performance.md + - operations/security.md - Reference: - - FAQ: faq/qna.md - - Glossary: reference/glossary.md - - Error Handling: reference/error-handling.md - - Roadmap: reference/roadmap.md - - Changelog: reference/changelog.md + - reference/index.md + - faq/qna.md + - reference/glossary.md + - reference/error-handling.md + - reference/roadmap.md + - reference/changelog.md theme: name: material variant: modern language: en custom_dir: docs/overrides + logo: assets/mascot.png + favicon: assets/mascot.png font: text: Inter code: JetBrains Mono @@ -70,6 +77,7 @@ theme: - navigation.sections - navigation.tabs - navigation.tabs.sticky + - navigation.indexes - navigation.top - navigation.path - navigation.tracking @@ -87,7 +95,7 @@ theme: - search.highlight - search.share - toc.follow - - toc.integrate + # - toc.integrate # not compatible with navigation.indexes palette: - media: "(prefers-color-scheme: light)" scheme: default diff --git a/python/tryx/backend.pyi b/python/tryx/backend.pyi index 76f49b1..c9e222d 100644 --- a/python/tryx/backend.pyi +++ b/python/tryx/backend.pyi @@ -17,6 +17,8 @@ from __future__ import annotations from abc import ABC, abstractmethod from typing import Protocol, runtime_checkable +from .types import JID + # ── Built-in backends ──────────────────────────────────────────────────────── class BackendBase: @@ -31,6 +33,15 @@ class BackendBase: ... +class StoredDeviceSummary: + """Summary of a stored WhatsApp device session.""" + + id: int + pn: JID | None + lid: JID | None + push_name: str + linked: bool + class SqliteStore(BackendBase): """Built-in SQLite storage backend. @@ -40,6 +51,7 @@ class SqliteStore(BackendBase): Args: path: Filesystem path to the SQLite database file. Created automatically if it doesn't exist. + device_id: Device ID index (default 0). Example:: @@ -49,13 +61,14 @@ class SqliteStore(BackendBase): """ path: str + device_id: int - def __init__(self, path: str) -> None: - """ - Create a SQLite storage backend. + def __init__(self, path: str, device_id: int = 0) -> None: + """Create a SQLite storage backend. Args: path: Filesystem path to the database file. + device_id: Device ID index (default 0). Example:: @@ -63,6 +76,41 @@ class SqliteStore(BackendBase): """ ... + async def create_new_device(self) -> int: + """Create a new device entry in the database and return its ID. + + Returns: + The newly created device ID. + """ + ... + + async def list_devices(self) -> list[StoredDeviceSummary]: + """List all stored device sessions in the database. + + Returns: + List of StoredDeviceSummary entries. + """ + ... + + async def remove_device(self, device_id: int) -> None: + """Delete a device session by ID. + + Args: + device_id: ID of the device to remove. + """ + ... + + async def device_exists(self, device_id: int) -> bool: + """Check if a device with the given ID exists. + + Args: + device_id: ID of the device to check. + + Returns: + True if the device exists, False otherwise. + """ + ... + # ── Internal Rust structs (JSON-serialized across FFI) ────────────────────── class Device: diff --git a/python/tryx/client.pyi b/python/tryx/client.pyi index b0fb73d..103c462 100644 --- a/python/tryx/client.pyi +++ b/python/tryx/client.pyi @@ -28,6 +28,8 @@ class IsOnWhatsAppResult: jid: JID is_registered: bool + username: str | None + pn_jid: JID | None class UserInfo: """Basic profile metadata for a single user.""" @@ -37,6 +39,15 @@ class UserInfo: status: str | None picture_id: str | None is_business: bool + username: str | None + +class UsernameLookupUser: + """User lookup result by username.""" + + jid: JID + pn_jid: JID | None + username: str | None + is_business: bool class ContactInfo: """Resolved contact information from phone lookups.""" @@ -586,6 +597,47 @@ class CallHandle: """ ... + async def resume_video( + self, video_source: VideoSource, video_sink: VideoSink + ) -> None: + """Resume video transmission in the call. + + Args: + video_source: Source providing outgoing video frames. + video_sink: Sink receiving incoming video frames. + + Raises: + RuntimeError: If the call has ended or video adapter is missing. + """ + ... + + async def re_request_video_upgrade(self) -> None: + """Re-request video upgrade in the call. + + Raises: + RuntimeError: If the call has ended. + """ + ... + + async def announce_video_enabled(self) -> None: + """Announce to call participants that local video is enabled. + + Raises: + RuntimeError: If the call has ended. + """ + ... + + async def set_video_orientation(self, orientation: int) -> None: + """Set the local video orientation. + + Args: + orientation: Orientation angle (e.g. 0, 90, 180, 270). + + Raises: + RuntimeError: If the call has ended. + """ + ... + async def invite_participant(self, target: JID) -> None: """Invite a participant to a group call. @@ -1195,6 +1247,23 @@ class ContactClient: """ ... + async def find_by_username( + self, username: str, username_key: str | None = None + ) -> UsernameLookupUser | None: + """Look up a contact by their WhatsApp username. + + Args: + username: The username to search for. + username_key: Optional username lookup key. + + Returns: + UsernameLookupUser if found, None otherwise. + + Raises: + RuntimeError: If the client is not running. + """ + ... + class ChatActionsClient: """Chat-level actions such as archive, pin, mute, and reactions. @@ -1672,7 +1741,7 @@ class GroupMetadata: """Full metadata for a WhatsApp group.""" id: JID - subject: str + subject: str | None participants: list[GroupParticipant] addressing_mode: str creator: JID | None @@ -2371,21 +2440,49 @@ class MembershipRequest: request_time: int | None class GroupInfo: - """Basic group information returned by query_info.""" + """Basic group information returned by query_info / routing_info.""" participants: list[JID] addressing_mode: str lid_to_pn_map: list[tuple[str, JID]] +class GroupOverview: + """Overview of a participating group. + + Contains identity, subject, hierarchy, and participant count. + """ + + id: JID + subject: str | None + hierarchy: str + parent_jid: JID | None + participant_count: int | None + class GroupsClient: """WhatsApp group management operations. All async methods raise ``RuntimeError`` if the client is not running. """ + async def routing_info(self, jid: JID) -> GroupInfo: + """Query basic group routing info (participants, addressing mode). + + Args: + jid: Group JID. + + Returns: + GroupInfo with participants and addressing mode. + + Raises: + RuntimeError: If the client is not running. + """ + ... + async def query_info(self, jid: JID) -> GroupInfo: """Query basic group info (participants, addressing mode). + Alias for :meth:`routing_info`. + Args: jid: Group JID. @@ -2402,11 +2499,24 @@ class GroupsClient: """ ... - async def get_participating(self) -> dict[str, GroupMetadata]: + async def list_participating(self) -> list[GroupOverview]: + """Get an overview of all groups the account is participating in. + + Returns: + List of GroupOverview objects. + + Raises: + RuntimeError: If the client is not running. + """ + ... + + async def get_participating(self) -> list[GroupOverview]: """Get all groups the account is participating in. + Alias for :meth:`list_participating`. + Returns: - Dict mapping group JID strings to GroupMetadata. + List of GroupOverview objects. Raises: RuntimeError: If the client is not running. @@ -2414,14 +2524,44 @@ class GroupsClient: Example:: groups = await client.groups.get_participating() - for jid_str, metadata in groups.items(): - print(metadata.subject) + for overview in groups: + print(overview.subject) + """ + ... + + async def fetch_overviews(self, jids: list[JID]) -> list[GroupOverview]: + """Fetch group overviews for multiple groups. + + Args: + jids: List of group JIDs to fetch overviews for. + + Returns: + List of GroupOverview objects. + + Raises: + RuntimeError: If the client is not running. + """ + ... + + async def fetch_metadata(self, jid: JID) -> GroupMetadata: + """Fetch full group metadata including participants from network. + + Args: + jid: Group JID. + + Returns: + GroupMetadata with full participant list. + + Raises: + RuntimeError: If the client is not running. """ ... async def get_metadata(self, jid: JID) -> GroupMetadata: """Get full group metadata including participants. + Alias for :meth:`fetch_metadata`. + Args: jid: Group JID. diff --git a/python/tryx/events.pyi b/python/tryx/events.pyi index 118179d..a55ac7e 100644 --- a/python/tryx/events.pyi +++ b/python/tryx/events.pyi @@ -108,11 +108,32 @@ class BusinessStatusUpdateType: Unknown: BusinessStatusUpdateType class GroupNotificationAction: - """Opaque group update action variant. + """Group update action variant. The runtime returns one action object that describes what changed in a group. + Access the variant type via ``isinstance`` checks, for example:: + + from tryx.events import GroupNotificationAction + + if isinstance(action, GroupNotificationAction.Subject): + print(action.subject) + print(action.subject_owner_pn) """ + class Subject: + """Group subject (name) was changed.""" + + subject: str + """New group name.""" + subject_owner: JID | None + """JID of the user who renamed the group (follows addressing mode).""" + subject_owner_pn: JID | None + """Phone-number JID of the renamer when ``subject_owner`` is a LID.""" + subject_owner_username: str | None + """Renamer's username, when username addressing is enabled.""" + subject_timestamp: datetime | None + """Server timestamp of the rename.""" + class EvConnected: """Emitted when the session becomes connected.""" diff --git a/src/backend/ffi_bridge.rs b/src/backend/ffi_bridge.rs index d4290d3..ce4b050 100644 --- a/src/backend/ffi_bridge.rs +++ b/src/backend/ffi_bridge.rs @@ -326,13 +326,22 @@ impl AppSyncStore for FfiBridgeStore { if res == 0 { Ok(()) } else { Err(make_err("set_sync_key failed")) } } - async fn get_version(&self, name: &str) -> StoreResult { + async fn get_version(&self, name: &str) -> StoreResult> { let cname = CString::new(name).unwrap(); let mut out = TryxBuffer { data: ptr::null_mut(), len: 0 }; let res = unsafe { (self.ffi.get_version)(self.ffi.handle, cname.as_ptr(), &mut out) }; if res == 0 { - Ok(bincode::deserialize(&self.take_buffer(out)).unwrap_or_default()) - } else { Ok(HashState::default()) } + let buf = self.take_buffer(out); + if buf.is_empty() { + Ok(None) + } else { + Ok(bincode::deserialize(&buf).ok()) + } + } else { Ok(None) } + } + + async fn delete_version(&self, _name: &str) -> StoreResult<()> { + Ok(()) } async fn set_version(&self, name: &str, state: HashState) -> StoreResult<()> { diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 80b03b5..7969de9 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -1,6 +1,6 @@ mod store; pub mod ffi_bridge; -pub use store::{BackendBase, SqliteStore, }; +pub use store::{BackendBase, SqliteStore, StoredDeviceSummary}; pub mod python_store; pub mod store_types; diff --git a/src/backend/python_store.rs b/src/backend/python_store.rs index f466097..74196cd 100644 --- a/src/backend/python_store.rs +++ b/src/backend/python_store.rs @@ -459,7 +459,7 @@ impl AppSyncStore for PythonStore { }) } - async fn get_version(&self, name: &str) -> StoreResult { + async fn get_version(&self, name: &str) -> StoreResult> { let name_c = name.to_string(); let fut = Python::attach(|_py| { let kwargs = pyo3::types::PyDict::new(_py); @@ -469,10 +469,35 @@ impl AppSyncStore for PythonStore { })?; let _res = fut.await.map_err(|e| make_err(e.to_string()))?; Python::attach(|_py| { - let v = _res.bind(_py).extract::>().map_err(|e| make_err(e.to_string()))?; serde_json::from_slice(&v).map_err(|e| make_err(e.to_string())) + let bound = _res.bind(_py); + if bound.is_none() { + Ok(None) + } else { + let v = bound.extract::>().map_err(|e| make_err(e.to_string()))?; + serde_json::from_slice(&v).map_err(|e| make_err(e.to_string())).map(Some) + } }) } + async fn delete_version(&self, name: &str) -> StoreResult<()> { + let name_c = name.to_string(); + let fut = Python::attach(|_py| -> StoreResult<_> { + let obj = self.py_obj.bind(_py); + if obj.hasattr("delete_version").unwrap_or(false) { + let kwargs = pyo3::types::PyDict::new(_py); + kwargs.set_item("name", name_c).unwrap(); + let coro = obj.call_method("delete_version", (), Some(&kwargs)).map_err(|e| make_err(e.to_string()))?; + Ok(Some(pyo3_async_runtimes::tokio::into_future(coro).map_err(|e| make_err(e.to_string()))?)) + } else { + Ok(None) + } + })?; + if let Some(f) = fut { + f.await.map_err(|e| make_err(e.to_string()))?; + } + Ok(()) + } + async fn set_version(&self, name: &str, state: HashState) -> StoreResult<()> { let name_c = name.to_string(); let state_c = serde_json::to_vec(&state).map_err(|e| make_err(e.to_string()))?; diff --git a/src/backend/store.rs b/src/backend/store.rs index 534b92c..896e5ad 100644 --- a/src/backend/store.rs +++ b/src/backend/store.rs @@ -1,36 +1,140 @@ -use whatsapp_rust_sqlite_storage::SqliteStore as SqliteStoreInner; +use whatsapp_rust_sqlite_storage::{ + SqliteStore as SqliteStoreInner, + StoredDeviceSummary as StoredDeviceSummaryInner, +}; use pyo3::prelude::*; use tracing::{error, info}; +use crate::types::JID; + #[pyclass(subclass)] pub struct BackendBase; +#[pyclass] +pub struct StoredDeviceSummary { + #[pyo3(get)] + pub id: i32, + #[pyo3(get)] + pub pn: Option>, + #[pyo3(get)] + pub lid: Option>, + #[pyo3(get)] + pub push_name: String, + #[pyo3(get)] + pub linked: bool, +} + +impl StoredDeviceSummary { + pub fn from_inner(py: Python<'_>, inner: StoredDeviceSummaryInner) -> PyResult { + Ok(Self { + id: inner.id, + pn: inner.pn.map(|j| Py::new(py, JID::from(j))).transpose()?, + lid: inner.lid.map(|j| Py::new(py, JID::from(j))).transpose()?, + push_name: inner.push_name, + linked: inner.linked, + }) + } +} + #[pyclass(extends=BackendBase)] pub struct SqliteStore { #[pyo3(get)] path: String, + #[pyo3(get)] + device_id: i32, } #[pymethods] impl SqliteStore { #[new] - fn new(path: String) -> (Self, BackendBase) { - (SqliteStore { path }, BackendBase) + #[pyo3(signature = (path, device_id=0))] + fn new(path: String, device_id: i32) -> (Self, BackendBase) { + (SqliteStore { path, device_id }, BackendBase) + } + + fn create_new_device<'py>(&self, py: Python<'py>) -> PyResult> { + let path = self.path.clone(); + let locals = pyo3_async_runtimes::tokio::get_current_locals(py)?; + pyo3_async_runtimes::tokio::future_into_py_with_locals::<_, i32>(py, locals, async move { + let store = SqliteStoreInner::new(&path) + .await + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + store + .create_new_device() + .await + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + }) + } + + fn list_devices<'py>(&self, py: Python<'py>) -> PyResult> { + let path = self.path.clone(); + let locals = pyo3_async_runtimes::tokio::get_current_locals(py)?; + pyo3_async_runtimes::tokio::future_into_py_with_locals::<_, Vec>>( + py, + locals, + async move { + let store = SqliteStoreInner::new(&path) + .await + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + let devices = store + .list_devices() + .await + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + Python::attach(|py| { + devices + .into_iter() + .map(|d| { + let summary = StoredDeviceSummary::from_inner(py, d)?; + Py::new(py, summary) + }) + .collect::>>() + }) + }, + ) + } + + fn remove_device<'py>(&self, py: Python<'py>, device_id: i32) -> PyResult> { + let path = self.path.clone(); + let locals = pyo3_async_runtimes::tokio::get_current_locals(py)?; + pyo3_async_runtimes::tokio::future_into_py_with_locals(py, locals, async move { + let store = SqliteStoreInner::new(&path) + .await + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + store + .remove_device(device_id) + .await + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + Ok(()) + }) + } + + fn device_exists<'py>(&self, py: Python<'py>, device_id: i32) -> PyResult> { + let path = self.path.clone(); + let locals = pyo3_async_runtimes::tokio::get_current_locals(py)?; + pyo3_async_runtimes::tokio::future_into_py_with_locals::<_, bool>(py, locals, async move { + let store = SqliteStoreInner::new(&path) + .await + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + store + .device_exists(device_id) + .await + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + }) } } impl SqliteStore { pub async fn connect(&self) -> Result { - info!(path = %self.path, "connecting sqlite backend"); - match SqliteStoreInner::new(&self.path).await { + info!(path = %self.path, device_id = self.device_id, "connecting sqlite backend"); + match SqliteStoreInner::new_for_device(&self.path, self.device_id).await { Ok(store) => { - info!(path = %self.path, "sqlite backend connected"); + info!(path = %self.path, device_id = self.device_id, "sqlite backend connected"); Ok(store) } Err(e) => { - error!(path = %self.path, error = %e, "sqlite backend connection failed"); - Err(e.to_string().into()) + error!(path = %self.path, device_id = self.device_id, error = %e, "sqlite backend connection failed"); + Err(e.to_string()) } } } diff --git a/src/clients/chat_actions.rs b/src/clients/chat_actions.rs index 9d18920..c9b30b1 100644 --- a/src/clients/chat_actions.rs +++ b/src/clients/chat_actions.rs @@ -437,6 +437,7 @@ impl ChatActionsClient { client .edit_message(chat_jid_value, original_id, message_value) .await + .map(|result| result.message_id) .map_err(|e| PyErr::new::(e.to_string())) }) } diff --git a/src/clients/contacts.rs b/src/clients/contacts.rs index 6b6622a..5724b00 100644 --- a/src/clients/contacts.rs +++ b/src/clients/contacts.rs @@ -13,7 +13,7 @@ use wacore::iq::usync::{ }; use crate::types::{JID, ProfilePicture}; -use crate::wacore::iq::usync::{ContactInfo, IsOnWhatsAppResult, UserInfo}; +use crate::wacore::iq::usync::{ContactInfo, IsOnWhatsAppResult, UserInfo, UsernameLookupUser}; #[pyclass] pub struct ContactClient { @@ -84,6 +84,7 @@ impl ContactClient { info.is_business, info.status, info.picture_id, + info.username.map(|u| u.to_string()), ); dict.set_item(JID::from(jid), contact_info)?; } @@ -92,6 +93,40 @@ impl ContactClient { }) } + #[pyo3(signature = (username, username_key=None))] + fn find_by_username<'py>( + &self, + py: Python<'py>, + username: String, + username_key: Option, + ) -> PyResult> { + let client = self.get_client()?; + let locals = get_current_locals(py)?; + + future_into_py_with_locals::<_, Option>>(py, locals, async move { + let result = client + .contacts() + .find_by_username(&username, username_key.as_deref()) + .await + .map_err(|e| PyErr::new::(e.to_string()))?; + + Python::attach(|py| match result { + wacore::iq::usync::UsernameLookup::Found(user) => { + let py_user = UsernameLookupUser { + jid: Py::new(py, JID::from(user.jid))?, + pn_jid: user.pn_jid.map(|j| Py::new(py, JID::from(j))).transpose()?, + username: user.username.map(|u| u.to_string()), + is_business: user.is_business, + }; + Ok(Some(Py::new(py, py_user)?)) + } + wacore::iq::usync::UsernameLookup::NotFound + | wacore::iq::usync::UsernameLookup::KeyRequired { .. } + | _ => Ok(None), + }) + }) + } + fn get_profile_picture<'py>( &self, py: Python<'py>, diff --git a/src/clients/groups.rs b/src/clients/groups.rs index 1c5b204..8afc467 100644 --- a/src/clients/groups.rs +++ b/src/clients/groups.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use pyo3::{Bound, Py, PyAny, PyErr, PyResult, Python, pyclass, pymethods}; -use pyo3::types::{PyDict, PyDictMethods}; use pyo3_async_runtimes::tokio::{future_into_py_with_locals, get_current_locals}; use tokio::sync::watch; use whatsapp_rust::Client; @@ -12,6 +11,7 @@ use crate::wacore::iq::groups::{ CreateGroupOptions, CreateGroupResult, GroupInfo, + GroupOverview, JoinGroupResult, MemberAddMode, MemberLinkMode, @@ -36,7 +36,7 @@ impl GroupsClient { #[pymethods] impl GroupsClient { - fn query_info<'py>(&self, py: Python<'py>, jid: Py) -> PyResult> { + fn routing_info<'py>(&self, py: Python<'py>, jid: Py) -> PyResult> { let client = self.get_client()?; let locals = get_current_locals(py)?; let jid_value = jid.bind(py).borrow().as_whatsapp_jid(); @@ -44,7 +44,7 @@ impl GroupsClient { future_into_py_with_locals::<_, Py>(py, locals, async move { let result = client .groups() - .query_info(&jid_value) + .routing_info(&jid_value) .await .map_err(|e| PyErr::new::(e.to_string()))?; Python::attach(|py| { @@ -54,29 +54,37 @@ impl GroupsClient { }) } - fn get_participating<'py>(&self, py: Python<'py>) -> PyResult> { + fn query_info<'py>(&self, py: Python<'py>, jid: Py) -> PyResult> { + self.routing_info(py, jid) + } + + fn list_participating<'py>(&self, py: Python<'py>) -> PyResult> { let client = self.get_client()?; let locals = get_current_locals(py)?; - future_into_py_with_locals::<_, Py>(py, locals, async move { + future_into_py_with_locals::<_, Vec>>(py, locals, async move { let result = client .groups() - .get_participating() + .list_participating() .await .map_err(|e| PyErr::new::(e.to_string()))?; Python::attach(|py| { - let dict = PyDict::new(py); - for (jid, meta) in result { - let py_meta = GroupMetadata::from_inner(py, meta)?; - let py_jid = Py::new(py, JID::from(jid))?; - dict.set_item(py_jid, Py::new(py, py_meta)?)?; - } - Ok(dict.unbind()) + result + .into_iter() + .map(|overview| { + let py_overview = GroupOverview::from_inner(py, overview)?; + Py::new(py, py_overview) + }) + .collect::>>() }) }) } - fn get_metadata<'py>(&self, py: Python<'py>, jid: Py) -> PyResult> { + fn get_participating<'py>(&self, py: Python<'py>) -> PyResult> { + self.list_participating(py) + } + + fn fetch_metadata<'py>(&self, py: Python<'py>, jid: Py) -> PyResult> { let client = self.get_client()?; let locals = get_current_locals(py)?; let jid_value = jid.bind(py).borrow().as_whatsapp_jid(); @@ -84,7 +92,7 @@ impl GroupsClient { future_into_py_with_locals::<_, Py>(py, locals, async move { let result = client .groups() - .get_metadata(&jid_value) + .fetch_metadata(&jid_value) .await .map_err(|e| PyErr::new::(e.to_string()))?; Python::attach(|py| { @@ -94,6 +102,10 @@ impl GroupsClient { }) } + fn get_metadata<'py>(&self, py: Python<'py>, jid: Py) -> PyResult> { + self.fetch_metadata(py, jid) + } + fn create_group<'py>( &self, py: Python<'py>, @@ -842,4 +854,44 @@ impl GroupsClient { Ok(()) }) } + + fn fetch_overviews<'py>(&self, py: Python<'py>, jids: Vec>) -> PyResult> { + let client = self.get_client()?; + let locals = get_current_locals(py)?; + let jids_value: Vec = jids.iter().map(|j| j.bind(py).borrow().as_whatsapp_jid()).collect(); + + future_into_py_with_locals::<_, Vec>>(py, locals, async move { + let result = client + .groups() + .fetch_overviews(&jids_value) + .await + .map_err(|e| PyErr::new::(e.to_string()))?; + Python::attach(|py| { + let overviews = result + .into_iter() + .filter_map(|res| match res { + whatsapp_rust::GroupOverviewResult::Found(overview) => { + let py_overview = GroupOverview::from_inner(py, overview).ok()?; + Py::new(py, py_overview).ok() + } + whatsapp_rust::GroupOverviewResult::Truncated { + id, + participant_count, + } => { + let py_overview = GroupOverview { + id: Py::new(py, JID::from(id)).ok()?, + subject: None, + hierarchy: "unknown".to_string(), + parent_jid: None, + participant_count: Some(participant_count), + }; + Py::new(py, py_overview).ok() + } + _ => None, + }) + .collect::>(); + Ok(overviews) + }) + }) + } } diff --git a/src/clients/tryx.rs b/src/clients/tryx.rs index 49eb52b..07f38ec 100644 --- a/src/clients/tryx.rs +++ b/src/clients/tryx.rs @@ -34,7 +34,7 @@ use super::status::StatusClient; use super::tryx_client::TryxClient; use crate::clients::chat_actions::ChatActionsClient; use crate::log::init_logging; -use crate::backend::{SqliteStore, BackendBase}; +use crate::backend::SqliteStore; use crate::events::types::{ EvArchiveUpdate, EvBusinessStatusUpdate, EvChatPresence, EvClientOutDated, EvConnectFailure, EvConnected, EvContactNumberChanged, EvContactSyncRequested, EvContactUpdate, EvContactUpdated, EvDeleteChatUpdate, EvDeleteMessageForMeUpdate, EvDeviceListUpdate, EvDisappearingModeChanged, EvDisconnected, EvGroupUpdate, EvHistorySync, EvLoggedOut, EvMarkChatAsReadUpdate, EvMessage, EvMuteUpdate, EvNewsletterLiveUpdate, EvNotification, EvOfflineSyncCompleted, EvOfflineSyncPreview, EvPairError, EvPairSuccess, EvPairingCode, EvPairingQrCode, EvPictureUpdate, EvPinUpdate, EvPresence, EvPushNameUpdate, EvQrScannedWithoutMultidevice, EvReceipt, EvSelfPushNameUpdated, EvStarUpdate, EvStreamError, EvStreamReplaced, EvTemporaryBan, EvUndecryptableMessage, EvUserAboutUpdate }; @@ -420,7 +420,7 @@ impl Tryx { async move { match event.as_ref() { Event::IncomingCall(incoming) => { - let incoming = incoming.clone(); + let incoming = (**incoming).clone(); let client = client.clone(); Self::emit_built_event(&tryx_client, &callbacks.incoming_call, locals.clone(), "IncomingCall", |py| { Py::new(py, crate::clients::voip::IncomingCallEvent { @@ -440,7 +440,7 @@ impl Tryx { }).await; } Event::LoggedOut(logout) => { - let logout = logout.clone(); + let logout = (**logout).clone(); Self::emit_built_event(&tryx_client, &callbacks.logout, locals.clone(), "LoggedOut", |py| { Py::new(py, EvLoggedOut::new(logout)).map(|event| event.into_any()) }).await; @@ -521,7 +521,7 @@ impl Tryx { |_py| { Ok(EvReceipt::new( receipt.source, - receipt.message_ids, + receipt.message_ids.into_iter().map(|id| id.to_string()).collect(), receipt.timestamp, receipt.r#type, ) diff --git a/src/clients/voip.rs b/src/clients/voip.rs index 4373a14..aa5dad3 100644 --- a/src/clients/voip.rs +++ b/src/clients/voip.rs @@ -113,15 +113,20 @@ impl CallHandle { self.inner.is_muted() } - fn set_muted(&self, muted: bool) { - self.inner.set_muted(muted); + fn set_muted<'py>(&self, py: Python<'py>, muted: bool) -> PyResult> { + let call = self.inner.clone(); + let locals = get_current_locals(py)?; + future_into_py_with_locals(py, locals, async move { + let _ = call.set_muted(muted).await; + Ok(()) + }) } fn hangup<'py>(&self, py: Python<'py>) -> PyResult> { let call = self.inner.clone(); let locals = get_current_locals(py)?; future_into_py_with_locals(py, locals, async move { - call.hangup().await; + call.hangup_local().await; Ok(()) }) } @@ -158,6 +163,47 @@ impl CallHandle { }) } + fn resume_video<'py>(&self, py: Python<'py>, video_source: Py, video_sink: Py) -> PyResult> { + let bridge = crate::voip::bridge_from_python(py, None, None, Some(video_source), Some(video_sink))?; + let source = bridge.video_source.ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("video source adapter is missing"))?; + let sink = bridge.video_sink.ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("video sink adapter is missing"))?; + let tasks = bridge.tasks; + self.bridge_tasks.lock().map_err(|_| pyo3::exceptions::PyRuntimeError::new_err("call task lock poisoned"))?.extend(tasks); + let call = self.inner.clone(); + let locals = get_current_locals(py)?; + future_into_py_with_locals(py, locals, async move { + call.resume_video(source, sink).await.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + Ok(()) + }) + } + + fn re_request_video_upgrade<'py>(&self, py: Python<'py>) -> PyResult> { + let call = self.inner.clone(); + let locals = get_current_locals(py)?; + future_into_py_with_locals(py, locals, async move { + call.re_request_video_upgrade().await.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + Ok(()) + }) + } + + fn announce_video_enabled<'py>(&self, py: Python<'py>) -> PyResult> { + let call = self.inner.clone(); + let locals = get_current_locals(py)?; + future_into_py_with_locals(py, locals, async move { + call.announce_video_enabled().await.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + Ok(()) + }) + } + + fn set_video_orientation<'py>(&self, py: Python<'py>, orientation: u8) -> PyResult> { + let call = self.inner.clone(); + let locals = get_current_locals(py)?; + future_into_py_with_locals(py, locals, async move { + call.set_video_orientation(orientation).await.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + Ok(()) + }) + } + fn invite_participant<'py>(&self, py: Python<'py>, target: Py) -> PyResult> { let call = self.inner.clone(); let target = target.bind(py).borrow().as_whatsapp_jid(); diff --git a/src/events/types/message_and_updates.rs b/src/events/types/message_and_updates.rs index 849c687..6a56f8d 100644 --- a/src/events/types/message_and_updates.rs +++ b/src/events/types/message_and_updates.rs @@ -41,6 +41,11 @@ impl From for EvTemporaryBan { EvTemporaryBan::new(event) } } +impl From> for EvTemporaryBan { + fn from(event: Box) -> Self { + EvTemporaryBan::new(*event) + } +} #[pymethods] impl EvTemporaryBan { #[getter] @@ -719,6 +724,8 @@ pub enum GroupNotificationAction { Subject { subject: String, subject_owner: Option>, + subject_owner_pn: Option>, + subject_owner_username: Option, subject_timestamp: Option>, }, Description { @@ -864,7 +871,7 @@ impl EvGroupUpdate { .collect::>() }; - let action = match &self.inner.action { + let action = match &*self.inner.action { wacore::stanza::groups::GroupNotificationAction::Add { participants, reason } => { let py_participants = py_group_participants(participants); GroupNotificationAction::Add { participants: py_participants, reason: reason.clone() } @@ -885,14 +892,17 @@ impl EvGroupUpdate { let py_participants = py_group_participants(participants); GroupNotificationAction::Modify { participants: py_participants } }, - wacore::stanza::groups::GroupNotificationAction::Subject { subject, subject_owner, subject_time } => { + wacore::stanza::groups::GroupNotificationAction::Subject { subject, subject_owner, subject_owner_pn, subject_owner_username, subject_time } => { let py_subject_owner = subject_owner .as_ref() .map(|o| Py::new(py, JID::from(o.clone())).unwrap()); + let py_subject_owner_pn = subject_owner_pn + .as_ref() + .map(|o| Py::new(py, JID::from(o.clone())).unwrap()); let py_subject_timestamp = subject_time.map(|t| { PyDateTime::from_timestamp(py, t as f64, None).unwrap().into() }); - GroupNotificationAction::Subject { subject: subject.clone(), subject_owner: py_subject_owner, subject_timestamp: py_subject_timestamp } + GroupNotificationAction::Subject { subject: subject.clone(), subject_owner: py_subject_owner, subject_owner_pn: py_subject_owner_pn, subject_owner_username: subject_owner_username.clone(), subject_timestamp: py_subject_timestamp } }, wacore::stanza::groups::GroupNotificationAction::Description { id, description } => { GroupNotificationAction::Description { id: id.clone(), description: description.clone() } diff --git a/src/events/types/profile_sync.rs b/src/events/types/profile_sync.rs index 5183874..0cd1ae7 100644 --- a/src/events/types/profile_sync.rs +++ b/src/events/types/profile_sync.rs @@ -12,14 +12,14 @@ pub struct EvPushNameUpdateData { #[pyclass] pub struct EvPushNameUpdate { - inner: Box, + _inner: Box, data_cache: OnceLock>, } impl EvPushNameUpdate { pub fn new(inner: wacore::types::events::RetiredPushNameUpdate) -> Self { Self { - inner: Box::new(inner), + _inner: Box::new(inner), data_cache: OnceLock::new(), } } diff --git a/src/lib.rs b/src/lib.rs index a0efae9..104048c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -114,13 +114,13 @@ use self::events::types::{ DeviceListUpdateData, EvPushNameUpdateData, }; -use self::backend::{BackendBase, SqliteStore, store_types::*}; +use self::backend::{BackendBase, SqliteStore, StoredDeviceSummary, store_types::*}; use self::exceptions::{EventDispatchError, FailedBuildClient, FailedToDecodeProto, PyPayloadBuildError, UnsupportedBackend, UnsupportedEventType}; use self::types::{DeviceSentMeta, JID, MediaReuploadResult, MessageInfo, MessageSource, MsgBotInfo, MsgMetaInfo, ProfilePicture, SendResult, UploadResponse}; use self::wacore::download::MediaType; use self::wacore::node::{Attrs, Node, NodeContent, NodeValue}; use self::wacore::stanza::{BusinessSubscription, KeyIndexInfo}; -use self::wacore::iq::usync::{ContactInfo, IsOnWhatsAppResult, UserInfo}; +use self::wacore::iq::usync::{ContactInfo, IsOnWhatsAppResult, UserInfo, UsernameLookupUser}; use self::wacore::iq::community::{ CommunitySubgroup, CreateCommunityOptions, @@ -146,6 +146,7 @@ use self::wacore::iq::groups::{ CreateGroupOptions, CreateGroupResult, GroupInfo, + GroupOverview, GroupParticipantOptions, JoinGroupResult, MemberAddMode, @@ -200,6 +201,7 @@ fn _tryx(_py: &Bound) -> PyResult<()> { client_module.add_class::()?; client_module.add_class::()?; client_module.add_class::()?; + client_module.add_class::()?; client_module.add_class::()?; client_module.add_class::()?; client_module.add_class::()?; @@ -227,6 +229,7 @@ fn _tryx(_py: &Bound) -> PyResult<()> { client_module.add_class::()?; client_module.add_class::()?; client_module.add_class::()?; + client_module.add_class::()?; client_module.add_class::()?; client_module.add_class::()?; client_module.add_class::()?; @@ -331,6 +334,7 @@ fn _tryx(_py: &Bound) -> PyResult<()> { let backend_module = PyModule::new(_py.py(), "backend")?; backend_module.add_class::()?; backend_module.add_class::()?; + backend_module.add_class::()?; backend_module.add_class::()?; backend_module.add_class::()?; backend_module.add_class::()?; diff --git a/src/types.rs b/src/types.rs index a1df1af..a830de2 100644 --- a/src/types.rs +++ b/src/types.rs @@ -194,9 +194,9 @@ impl From for MessageInfo { fn from(info: WhatsAppMessageInfo) -> Self { MessageInfo { inner: Arc::new(info.clone()), - id: info.id.clone(), + id: info.id.to_string(), r#type: info.r#type.map(|t| t.to_string()).unwrap_or_default(), - push_name: info.push_name.clone(), + push_name: info.push_name.to_string(), } } } @@ -253,37 +253,51 @@ impl MessageInfo { fn bot_info(&self) -> Option { match &self.inner.bot_info { Some(msg) => { - Some(MsgBotInfo { inner: Arc::new(msg.clone()), edit_target_id: match msg.edit_target_id { - Some(ref s) => Some(s.clone()), - None => None, - - } }) - }, + Some(MsgBotInfo { + inner: Arc::new((**msg).clone()), + edit_target_id: msg.edit_target_id.as_deref().map(String::from), + }) + } None => None, } } #[getter] - fn meta_info(&self, py: Python<'_>) -> MsgMetaInfo{ - MsgMetaInfo { - target_id: self.inner.meta_info.target_id.as_deref().map(String::from), - target_sender: match self.inner.meta_info.target_sender { - Some(ref jid) => Some(pyo3::Py::new(py, JID::from(jid.clone())).unwrap()), - None => None, - }, - target_chat: match self.inner.meta_info.target_chat { - Some(ref jid) => Some(pyo3::Py::new(py, JID::from(jid.clone())).unwrap()), - None => None, - }, - thread_message_id: self.inner.meta_info.thread_message_id.as_deref().map(String::from), - thread_message_sender_jid: match self.inner.meta_info.thread_message_sender_jid { - Some(ref jid) => Some(pyo3::Py::new(py, JID::from(jid.clone())).unwrap()), - None => None, - }, - content_type: self.inner.meta_info.content_type.as_deref().map(String::from), - appdata: self.inner.meta_info.appdata.as_deref().map(String::from), - reporting_tag: self.inner.meta_info.reporting_tag.as_deref().map(|b| b.to_vec()), - reporting_token: self.inner.meta_info.reporting_token.as_deref().map(|b| b.to_vec()), - reporting_token_version: self.inner.meta_info.reporting_token_version, + fn meta_info(&self, py: Python<'_>) -> MsgMetaInfo { + if let Some(meta) = &self.inner.meta_info { + MsgMetaInfo { + target_id: meta.target_id.as_deref().map(String::from), + target_sender: match meta.target_sender { + Some(ref jid) => Some(pyo3::Py::new(py, JID::from(jid.clone())).unwrap()), + None => None, + }, + target_chat: match meta.target_chat { + Some(ref jid) => Some(pyo3::Py::new(py, JID::from(jid.clone())).unwrap()), + None => None, + }, + thread_message_id: meta.thread_message_id.as_deref().map(String::from), + thread_message_sender_jid: match meta.thread_message_sender_jid { + Some(ref jid) => Some(pyo3::Py::new(py, JID::from(jid.clone())).unwrap()), + None => None, + }, + content_type: meta.content_type.as_deref().map(String::from), + appdata: meta.appdata.as_deref().map(String::from), + reporting_tag: meta.reporting_tag.as_deref().map(|b| b.to_vec()), + reporting_token: meta.reporting_token.as_deref().map(|b| b.to_vec()), + reporting_token_version: meta.reporting_token_version, + } + } else { + MsgMetaInfo { + target_id: None, + target_sender: None, + target_chat: None, + thread_message_id: None, + thread_message_sender_jid: None, + content_type: None, + appdata: None, + reporting_tag: None, + reporting_token: None, + reporting_token_version: None, + } } } #[getter] @@ -320,7 +334,7 @@ impl MessageInfo { } #[getter] fn ephemeral_expiration(&self) -> Option { - self.inner.ephemeral_expiration + None } #[getter] fn is_offline(&self) -> bool { diff --git a/src/wacore/iq/community.rs b/src/wacore/iq/community.rs index b6571c8..b6b20bd 100644 --- a/src/wacore/iq/community.rs +++ b/src/wacore/iq/community.rs @@ -214,7 +214,7 @@ pub struct GroupMetadata { #[pyo3(get)] pub id: Py, #[pyo3(get)] - pub subject: String, + pub subject: Option, #[pyo3(get)] pub participants: Vec>, #[pyo3(get)] diff --git a/src/wacore/iq/groups.rs b/src/wacore/iq/groups.rs index dd359e0..678ac18 100644 --- a/src/wacore/iq/groups.rs +++ b/src/wacore/iq/groups.rs @@ -300,7 +300,7 @@ pub struct GroupInfo { } impl GroupInfo { - pub fn from_inner(py: Python<'_>, value: &wacore::client::context::GroupInfo) -> PyResult { + pub fn from_inner(py: Python<'_>, value: &wacore::client::context::GroupRoutingInfo) -> PyResult { let lid_to_pn_map = Vec::new(); let participants = value @@ -321,3 +321,37 @@ impl GroupInfo { }) } } + +#[pyclass] +pub struct GroupOverview { + #[pyo3(get)] + pub id: Py, + #[pyo3(get)] + pub subject: Option, + #[pyo3(get)] + pub hierarchy: String, + #[pyo3(get)] + pub parent_jid: Option>, + #[pyo3(get)] + pub participant_count: Option, +} + +impl GroupOverview { + pub fn from_inner(py: Python<'_>, value: whatsapp_rust::GroupOverview) -> PyResult { + let (hierarchy, parent_jid) = match value.hierarchy { + whatsapp_rust::GroupHierarchy::Standalone => ("standalone".to_string(), None), + whatsapp_rust::GroupHierarchy::Community => ("community".to_string(), None), + whatsapp_rust::GroupHierarchy::Subgroup { parent, .. } => { + ("subgroup".to_string(), Some(Py::new(py, JID::from(parent))?)) + } + _ => ("unknown".to_string(), None), + }; + Ok(Self { + id: Py::new(py, JID::from(value.id))?, + subject: value.subject, + hierarchy, + parent_jid, + participant_count: value.participant_count, + }) + } +} diff --git a/src/wacore/iq/usync.rs b/src/wacore/iq/usync.rs index 2665aec..fdaa129 100644 --- a/src/wacore/iq/usync.rs +++ b/src/wacore/iq/usync.rs @@ -5,43 +5,53 @@ use crate::types::JID; #[pyclass] pub struct IsOnWhatsAppResult { #[pyo3(get)] - jid: Py, + pub jid: Py, #[pyo3(get)] - is_registered: bool, + pub is_registered: bool, + #[pyo3(get)] + pub pn_jid: Option>, + #[pyo3(get)] + pub username: Option, } impl IsOnWhatsAppResult { - pub fn new(jid: JID, is_registered: bool) -> Self { + pub fn new(jid: JID, is_registered: bool, pn_jid: Option, username: Option) -> Self { Python::attach(|py| { Self { jid: Py::new(py, JID::from(jid)).unwrap(), is_registered, + pn_jid: pn_jid.map(|j| Py::new(py, JID::from(j)).unwrap()), + username, } }) } } + impl From for IsOnWhatsAppResult { fn from(result: wacore::iq::usync::IsOnWhatsAppResult) -> Self { - IsOnWhatsAppResult::new(result.jid.into(), result.is_registered) + IsOnWhatsAppResult::new( + result.jid.into(), + result.is_registered, + result.pn_jid.map(Into::into), + result.username.map(|u| u.to_string()), + ) } } - - #[pyclass] pub struct ContactInfo { #[pyo3(get)] - jid: Py, + pub jid: Py, #[pyo3(get)] - lid: Option>, + pub lid: Option>, #[pyo3(get)] - is_registered: bool, + pub is_registered: bool, #[pyo3(get)] - is_business: bool, + pub is_business: bool, #[pyo3(get)] - status: Option, + pub status: Option, #[pyo3(get)] - picture_id: Option, + pub picture_id: Option, } impl ContactInfo { @@ -66,7 +76,6 @@ impl ContactInfo { } } - impl From for ContactInfo { fn from(info: wacore::iq::usync::IsOnWhatsAppResult) -> Self { ContactInfo::new( @@ -83,15 +92,17 @@ impl From for ContactInfo { #[pyclass] pub struct UserInfo { #[pyo3(get)] - jid: Py, + pub jid: Py, + #[pyo3(get)] + pub lid: Option>, #[pyo3(get)] - lid: Option>, + pub status: Option, #[pyo3(get)] - status: Option, + pub picture_id: Option, #[pyo3(get)] - picture_id: Option, + pub is_business: bool, #[pyo3(get)] - is_business: bool, + pub username: Option, } impl UserInfo { @@ -101,6 +112,7 @@ impl UserInfo { is_business: bool, status: Option, picture_id: Option, + username: Option, ) -> Self { Python::attach(|py| { Self { @@ -109,6 +121,7 @@ impl UserInfo { status, picture_id, is_business, + username, } }) } @@ -122,8 +135,20 @@ impl From for UserInfo { info.is_business, info.status, info.picture_id, - + info.username.map(|u| u.to_string()), ) } } +#[pyclass] +pub struct UsernameLookupUser { + #[pyo3(get)] + pub jid: Py, + #[pyo3(get)] + pub pn_jid: Option>, + #[pyo3(get)] + pub username: Option, + #[pyo3(get)] + pub is_business: bool, +} + diff --git a/tests/test_new_features.py b/tests/test_new_features.py new file mode 100644 index 0000000..985f8d5 --- /dev/null +++ b/tests/test_new_features.py @@ -0,0 +1,50 @@ +"""Tests for new features: multi-device SQLite store and new types.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from tryx.backend import SqliteStore, StoredDeviceSummary +from tryx.client import ( + ContactClient, + GroupsClient, + IsOnWhatsAppResult, + UserInfo, + UsernameLookupUser, +) + + +def test_sqlite_store_multidevice(tmp_path: Path) -> None: + async def run() -> None: + db_file = str(tmp_path / "devices.db") + store = SqliteStore(db_file, 0) + assert store.device_id == 0 + assert store.path == db_file + + dev_1 = await store.create_new_device() + assert dev_1 > 0 + assert await store.device_exists(dev_1) + + devices = await store.list_devices() + assert isinstance(devices, list) + assert any(d.id == dev_1 for d in devices) + for d in devices: + assert isinstance(d, StoredDeviceSummary) + assert isinstance(d.id, int) + assert isinstance(d.push_name, str) + assert isinstance(d.linked, bool) + + await store.remove_device(dev_1) + assert not await store.device_exists(dev_1) + + asyncio.run(run()) + + +def test_type_exports() -> None: + assert hasattr(UsernameLookupUser, "__doc__") + assert hasattr(IsOnWhatsAppResult, "__doc__") + assert hasattr(UserInfo, "__doc__") + assert hasattr(StoredDeviceSummary, "__doc__") + assert hasattr(ContactClient, "find_by_username") + assert hasattr(GroupsClient, "fetch_overviews")