diff --git a/README.md b/README.md index a8c62f2..676893f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ It combines: - Documentation: http://krypton-byte.tech/tryx/ - Contributing Guide: [CONTRIBUTING.md](CONTRIBUTING.md) -- Command Bot Example: [examples/command_bot.py](examples/command_bot.py) +- Command Automation Example: [examples/command_bot.py](examples/command_bot.py) ## Installation @@ -64,16 +64,16 @@ from tryx.events import EvMessage from tryx.waproto.whatsapp_pb2 import Message backend = SqliteBackend("whatsapp.db") -bot = Tryx(backend) +app = Tryx(backend) -@bot.on(EvMessage) +@app.on(EvMessage) async def on_message(client: TryxClient, event: EvMessage) -> None: text = event.data.get_text() or "" chat = event.data.message_info.source.chat await client.send_message(chat, Message(conversation=f"Echo: {text}")) async def main() -> None: - await bot.run() + await app.run() if __name__ == "__main__": asyncio.run(main()) @@ -81,7 +81,7 @@ if __name__ == "__main__": ## Feature Overview -- Event-based handlers via `@bot.on(...)` +- Event-based handlers via `@app.on(...)` - Runtime client namespaces: - `contact`, `chat_actions`, `community`, `newsletter`, `groups` - `status`, `chatstate`, `blocking`, `polls`, `presence`, `privacy`, `profile` @@ -132,9 +132,9 @@ If you see `ModuleNotFoundError: No module named 'tryx._tryx'`: uv run maturin develop --release ``` -### Bot Not Running +### Client Not Running -Ensure the bot runtime is started (`run` or `run_blocking`) before calling runtime client methods. +Ensure the client runtime is started (`run` or `run_blocking`) before calling runtime client methods. ## Contributing diff --git a/docs/api/blocking.md b/docs/api/blocking.md index 49d69d6..c126317 100644 --- a/docs/api/blocking.md +++ b/docs/api/blocking.md @@ -18,7 +18,7 @@ spam_hits: dict[str, int] = {} LIMIT = 5 -@bot.on(EvMessage) +@app.on(EvMessage) async def anti_spam(client, event): sender = event.data.message_info.source.sender key = sender.user diff --git a/docs/api/chat-actions.md b/docs/api/chat-actions.md index 5010328..4f6c878 100644 --- a/docs/api/chat-actions.md +++ b/docs/api/chat-actions.md @@ -3,7 +3,7 @@ `client.chat_actions` manages chat state transitions and message-level actions (edit, revoke, react, star, archive, mute). !!! note "Why this namespace matters" - Many WhatsApp state changes are synchronization events. If your bot writes chat state, subscribe to related events so your local view stays consistent. + Many WhatsApp state changes are synchronization events. If your client writes chat state, subscribe to related events so your local view stays consistent. ## Builder Helpers @@ -40,7 +40,7 @@ Build `SyncActionMessageRange` for operations that need explicit message windows from tryx.events import EvMessage -@bot.on(EvMessage) +@app.on(EvMessage) async def moderation_actions(client, event): text = (event.data.get_text() or "").strip() chat = event.data.message_info.source.chat @@ -58,7 +58,7 @@ async def moderation_actions(client, event): ```python async def resolve_ticket(client, chat_jid, msg_id): await client.chat_actions.react_message(chat_jid, msg_id, "✅") - # Optional follow-up: revoke a stale bot message + # Optional follow-up: revoke a stale client message await client.chat_actions.revoke_message(chat_jid, msg_id) ``` diff --git a/docs/api/chatstate.md b/docs/api/chatstate.md index b07d719..56b3a9e 100644 --- a/docs/api/chatstate.md +++ b/docs/api/chatstate.md @@ -20,7 +20,7 @@ Use chatstate signals to indicate user-facing activity such as typing or recordi ## Runnable Example: Latency-Hiding Reply ```python -@bot.on(EvMessage) +@app.on(EvMessage) async def smart_reply(client, event): chat = event.data.message_info.source.chat @@ -50,4 +50,4 @@ async def send_voice(client, chat, audio_bytes): - [Presence Namespace](presence.md) - [Events API](events.md) -- [Tutorial: Command Bot](../tutorials/command-bot.md) +- [Tutorial: Command Automation](../tutorials/command-bot.md) diff --git a/docs/api/client.md b/docs/api/client.md index b9af126..a828996 100644 --- a/docs/api/client.md +++ b/docs/api/client.md @@ -90,7 +90,7 @@ These methods stay on `TryxClient` directly because they are cross-domain primit | `upload(data, media_type) -> UploadResponse` | Upload in-memory bytes | Transform pipelines | | `send_message(to, message) -> SendResult` | Raw protobuf message send | Advanced custom payloads | | `send_text(...) -> SendResult` | Text helper | Most command handlers | -| `send_photo(...) -> SendResult` | Image helper | Bot replies with screenshots/posters | +| `send_photo(...) -> SendResult` | Image helper | Client replies with screenshots/posters | | `send_document(...) -> SendResult` | File helper | Reports, exports, invoices | | `send_audio(...) -> SendResult` | Audio helper | Voice notes / TTS | | `send_video(...) -> SendResult` | Video helper | Clips, demos | @@ -104,7 +104,7 @@ These methods stay on `TryxClient` directly because they are cross-domain primit ## Practical Flow By Goal -=== "Message Bot" +=== "Message Client" Use root send methods + `chat_actions` + `chatstate`. 1. Parse incoming event. @@ -112,14 +112,14 @@ These methods stay on `TryxClient` directly because they are cross-domain primit 3. Send reply with `client.send_text(...)`. 4. Optional message edit/revoke via `client.chat_actions`. -=== "Moderation Bot" +=== "Moderation Client" Use `groups`, `blocking`, `privacy`. 1. Resolve sender via [Types API](types.md). 2. Apply participant actions (`promote`, `remove`, `approve request`). 3. Enforce policy with blocklist/privacy settings. -=== "Broadcast/Channel Bot" +=== "Broadcast/Channel Client" Use `status`, `newsletter`, `polls`. 1. Upload content or build text payload. @@ -131,4 +131,4 @@ These methods stay on `TryxClient` directly because they are cross-domain primit - Event contracts: [Events API](events.md) - Shared value objects: [Types API](types.md) - Builders and utility helpers: [Helpers API](helpers.md) -- End-to-end bot composition: [Tutorial: Command Bot](../tutorials/command-bot.md) +- End-to-end client composition: [Tutorial: Command Automation](../tutorials/command-bot.md) diff --git a/docs/api/contact.md b/docs/api/contact.md index 20639a7..492abac 100644 --- a/docs/api/contact.md +++ b/docs/api/contact.md @@ -33,7 +33,7 @@ from tryx.events import EvMessage from tryx.types import JID -@bot.on(EvMessage) +@app.on(EvMessage) async def on_lookup(client, event): text = (event.data.get_text() or "").strip() if not text.startswith("/check "): @@ -73,4 +73,4 @@ async def enrich_contacts(client, phones: list[str]) -> dict[str, str]: - [Types API](types.md) - [Profile Namespace](profile.md) -- [Tutorial: Command Bot](../tutorials/command-bot.md) +- [Tutorial: Command Automation](../tutorials/command-bot.md) diff --git a/docs/api/events.md b/docs/api/events.md index e3d10a9..24286c0 100644 --- a/docs/api/events.md +++ b/docs/api/events.md @@ -4,10 +4,10 @@ This page maps event classes in `tryx.events` to practical handler strategies. ## Dispatcher Contract -`Dispatcher` is used internally by `Tryx` and by `@bot.on(EventClass)` registration. +`Dispatcher` is used internally by `Tryx` and by `@app.on(EventClass)` registration. ```python -@bot.on(EvMessage) +@app.on(EvMessage) async def on_message(client, event): ... ``` @@ -102,7 +102,7 @@ async def on_message(client, event): from tryx.events import EvMessage, EvPresence -@bot.on(EvMessage) +@app.on(EvMessage) async def on_message(client, event): chat = event.data.message_info.source.chat text = event.data.get_text() or "" @@ -110,7 +110,7 @@ async def on_message(client, event): await client.send_text(chat, "pong", quoted=event) -@bot.on(EvPresence) +@app.on(EvPresence) async def on_presence(client, event): # keep side effects minimal; enqueue heavy processing pass diff --git a/docs/api/presence.md b/docs/api/presence.md index 4d81c71..7f0df3d 100644 --- a/docs/api/presence.md +++ b/docs/api/presence.md @@ -24,7 +24,7 @@ from tryx.events import EvMessage from tryx.types import JID -@bot.on(EvMessage) +@app.on(EvMessage) async def monitor_presence(client, event): text = (event.data.get_text() or "").strip() chat = event.data.message_info.source.chat diff --git a/docs/api/profile.md b/docs/api/profile.md index 0c1eacd..a659813 100644 --- a/docs/api/profile.md +++ b/docs/api/profile.md @@ -17,7 +17,7 @@ from tryx.events import EvMessage -@bot.on(EvMessage) +@app.on(EvMessage) async def profile_admin(client, event): text = (event.data.get_text() or "").strip() chat = event.data.message_info.source.chat @@ -53,7 +53,7 @@ async def rotate_profile_picture(client, image_bytes): - Log profile mutations with operator identity. !!! tip "Consistency" - Couple profile changes with privacy policy reviews if your bot identity is user-facing. + Couple profile changes with privacy policy reviews if your client identity is user-facing. ## Related Docs diff --git a/docs/api/wacore.md b/docs/api/wacore.md index 93fedaf..2652461 100644 --- a/docs/api/wacore.md +++ b/docs/api/wacore.md @@ -25,4 +25,4 @@ These appear in device/business sync event payloads. ## When to Use WACore Types Use these types only when high-level client/event abstractions are not enough. -For normal bot logic, prefer typed event payload objects and client namespace methods. +For normal client logic, prefer typed event payload objects and client namespace methods. diff --git a/docs/core-concepts/event-model.md b/docs/core-concepts/event-model.md index 630bcb0..8c8a7e3 100644 --- a/docs/core-concepts/event-model.md +++ b/docs/core-concepts/event-model.md @@ -12,7 +12,7 @@ Handlers are registered by event class and receive `(client, event)`. ## Handler Registration ```python -@bot.on(EvMessage) +@app.on(EvMessage) async def on_message(client: TryxClient, event: EvMessage) -> None: ... ``` diff --git a/docs/faq/qna.md b/docs/faq/qna.md index fdceb2f..fb330dc 100644 --- a/docs/faq/qna.md +++ b/docs/faq/qna.md @@ -11,7 +11,7 @@ Tryx is a Rust-powered Python SDK for event-driven WhatsApp automation. Rust handles protocol-heavy runtime work for better throughput and lower overhead, while Python keeps app logic easy to write. ### Is Tryx synchronous or asynchronous? -Both: async-first (`await bot.run()`), plus blocking convenience (`bot.run_blocking()`). +Both: async-first (`await app.run()`), plus blocking convenience (`app.run_blocking()`). See [Quick Start](../getting-started/quickstart.md). @@ -91,7 +91,7 @@ Yes, the package includes `py.typed` for static analysis integration. ### How should I handle temporary bans? Listen to `EvTemporaryBan`, pause high-frequency operations, and avoid aggressive retries. -### How can I make my bot idempotent? +### How can I make my client idempotent? Store processed message IDs and guard side effects before calling external systems. See [Reliability](../operations/reliability.md). diff --git a/docs/getting-started/authentication.md b/docs/getting-started/authentication.md index a118261..b486ff6 100644 --- a/docs/getting-started/authentication.md +++ b/docs/getting-started/authentication.md @@ -14,7 +14,7 @@ Tryx follows the WhatsApp multi-device pairing flow. The first run links a sessi ## Typical First-Run Sequence -1. Start bot runtime. +1. Start client runtime. 2. Wait for `EvPairingQrCode` or `EvPairingCode`. 3. Complete pairing from your WhatsApp mobile app. 4. Receive `EvPairSuccess`. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 800dcc3..c3eed54 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -43,8 +43,8 @@ from tryx.client import Tryx, TryxClient from tryx.backend import SqliteBackend backend = SqliteBackend("whatsapp.db") -bot = Tryx(backend) -client = bot.get_client() +app = Tryx(backend) +client = app.get_client() print(type(client).__name__) ``` diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index f953f32..8e54346 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -1,11 +1,11 @@ # Quick Start -Build and run a minimal echo bot, then expand it safely. +Build and run a minimal echo client, then expand it safely. !!! tip "Expected outcome" You should receive incoming text and reply with an echo message in the same chat. -## Minimal Bot +## Minimal Client ```python import asyncio @@ -16,10 +16,10 @@ from tryx.events import EvMessage from tryx.waproto.whatsapp_pb2 import Message backend = SqliteBackend("whatsapp.db") -bot = Tryx(backend) +app = Tryx(backend) -@bot.on(EvMessage) +@app.on(EvMessage) async def on_message(client: TryxClient, event: EvMessage) -> None: text = event.data.get_text() or "" chat = event.data.message_info.source.chat @@ -27,7 +27,7 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: async def main() -> None: - await bot.run() + await app.run() if __name__ == "__main__": @@ -38,15 +38,15 @@ if __name__ == "__main__": 1. backend persists pairing/session state 2. `Tryx` runtime wires event dispatcher -3. `@bot.on(EvMessage)` registers handler +3. `@app.on(EvMessage)` registers handler 4. `TryxClient` executes namespace/root API calls ## Runtime Flow 1. Create backend storage. -2. Create `Tryx` bot instance. -3. Register handlers with `@bot.on(EventClass)`. -4. Start runtime with `await bot.run()`. +2. Create `Tryx` client instance. +3. Register handlers with `@app.on(EventClass)`. +4. Start runtime with `await app.run()`. 5. Use `TryxClient` inside handlers for API calls. ## First Production Hardening @@ -71,8 +71,8 @@ For quick scripts without manual event loop management: from tryx.backend import SqliteBackend from tryx.client import Tryx -bot = Tryx(SqliteBackend("whatsapp.db")) -bot.run_blocking() +app = Tryx(SqliteBackend("whatsapp.db")) +app.run_blocking() ``` !!! warning @@ -83,4 +83,4 @@ bot.run_blocking() - Read [Authentication Flow](authentication.md) to understand pairing and session persistence. - Explore [Client API Gateway](../api/client.md) for all namespace methods. - Review [Event Model](../core-concepts/event-model.md) before building complex logic. -- Continue with [Tutorial: Command Bot](../tutorials/command-bot.md). +- Continue with [Tutorial: Command Automation](../tutorials/command-bot.md). diff --git a/docs/index.md b/docs/index.md index b21668c..65e93b0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -58,7 +58,7 @@ ## Recommended Reading Path 1. Start with [Installation](getting-started/installation.md). -2. Follow [Quick Start](getting-started/quickstart.md) to build your first running bot. +2. Follow [Quick Start](getting-started/quickstart.md) to build your first running client. 3. Understand pairing in [Authentication Flow](getting-started/authentication.md). 4. Learn internals in [Architecture](core-concepts/architecture.md) and [Event Model](core-concepts/event-model.md). 5. Jump into [Client API Gateway](api/client.md) and namespace deep dives. @@ -70,7 +70,7 @@ This documentation set focuses on the Python SDK experience first: -- Runtime setup and bot lifecycle +- Runtime setup and client lifecycle - Event payload model and handler patterns - API reference for client/events/types/helpers/wacore - Performance, reliability, and security operations diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index 9c37823..e9bd46a 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -9,13 +9,13 @@ Deploy Tryx bots safely in production with stable session storage and predictabl ```ini [Unit] - Description=Tryx Bot + Description=Tryx Automation After=network.target [Service] WorkingDirectory=/srv/tryx Environment=PYTHONUNBUFFERED=1 - ExecStart=/srv/tryx/.venv/bin/python bot.py + ExecStart=/srv/tryx/.venv/bin/python app.py Restart=always RestartSec=5 User=tryx @@ -35,7 +35,7 @@ Deploy Tryx bots safely in production with stable session storage and predictabl RUN uv sync --group dev RUN uv run maturin develop - CMD ["uv", "run", "python", "bot.py"] + CMD ["uv", "run", "python", "app.py"] ``` ## Session Persistence Requirements diff --git a/docs/operations/reliability.md b/docs/operations/reliability.md index a569d74..2b1243c 100644 --- a/docs/operations/reliability.md +++ b/docs/operations/reliability.md @@ -17,7 +17,7 @@ This page focuses on idempotency, retry strategy, and safe handler design. processed: set[str] = set() -@bot.on(EvMessage) +@app.on(EvMessage) async def reliable_handler(client, event): msg_id = event.data.message_info.id if msg_id in processed: @@ -51,6 +51,6 @@ async def retry(coro_factory, attempts=3): ## Related Docs -- [Command Bot Tutorial](../tutorials/command-bot.md) +- [Command Automation Tutorial](../tutorials/command-bot.md) - [Performance Guide](performance.md) - [Error Handling](../reference/error-handling.md) diff --git a/docs/operations/security.md b/docs/operations/security.md index 349f6ec..642e73a 100644 --- a/docs/operations/security.md +++ b/docs/operations/security.md @@ -1,6 +1,6 @@ # Security Practices -Secure your bot around four risk zones: session state, operator controls, user input, and outbound behavior. +Secure your client around four risk zones: session state, operator controls, user input, and outbound behavior. ## Threat Model Snapshot @@ -30,7 +30,7 @@ Secure your bot around four risk zones: session state, operator controls, user i - avoid repetitive high-frequency outbound sends - enforce explicit user intent for auto-responses -- add anti-loop guardrails for bot-to-bot conversations +- add anti-loop guardrails for client-to-client conversations ## Input Validation diff --git a/docs/operations/troubleshooting.md b/docs/operations/troubleshooting.md index 2bf3cde..196976c 100644 --- a/docs/operations/troubleshooting.md +++ b/docs/operations/troubleshooting.md @@ -6,7 +6,7 @@ Use this page as a decision tree first, checklist second. ```mermaid flowchart TD - A[Bot cannot connect] --> B{Network reachable?} + A[Client cannot connect] --> B{Network reachable?} B -- No --> B1[Fix outbound network/DNS/TLS] B -- Yes --> C{Backend path writable?} C -- No --> C1[Fix storage permissions] diff --git a/docs/tutorials/command-bot.md b/docs/tutorials/command-bot.md index 8a9ecd7..2ee5cc1 100644 --- a/docs/tutorials/command-bot.md +++ b/docs/tutorials/command-bot.md @@ -1,6 +1,6 @@ -# Tutorial: Command Bot +# Tutorial: Command Automation -Build a command-driven bot that stays maintainable as command count grows. +Build a command-driven automation that stays maintainable as command count grows. !!! tip "Outcome" At the end of this tutorial you will have: @@ -18,14 +18,14 @@ from tryx.client import Tryx, TryxClient from tryx.events import EvMessage backend = SqliteBackend("whatsapp.db") -bot = Tryx(backend) +app = Tryx(backend) def normalize(text: str | None) -> str: return (text or "").strip().lower() -@bot.on(EvMessage) +@app.on(EvMessage) async def on_message(client: TryxClient, event: EvMessage) -> None: text = normalize(event.data.get_text()) chat = event.data.message_info.source.chat @@ -36,7 +36,7 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: await client.send_text(chat, "commands: ping, help", quoted=event) -asyncio.run(bot.run()) +asyncio.run(app.run()) ``` ## Level 2: Table-driven Commands @@ -63,7 +63,7 @@ COMMANDS: dict[str, CommandHandler] = { } -@bot.on(EvMessage) +@app.on(EvMessage) async def on_command(client: TryxClient, event: EvMessage) -> None: text = (event.data.get_text() or "").strip() if not text.startswith("/"): @@ -100,7 +100,7 @@ async def on_command(client: TryxClient, event: EvMessage) -> None: seen_ids: set[str] = set() -@bot.on(EvMessage) +@app.on(EvMessage) async def on_idempotent(client: TryxClient, event: EvMessage) -> None: message_id = event.data.message_info.id if message_id in seen_ids: diff --git a/docs/tutorials/group-automation.md b/docs/tutorials/group-automation.md index 05eb612..ffc38da 100644 --- a/docs/tutorials/group-automation.md +++ b/docs/tutorials/group-automation.md @@ -55,7 +55,7 @@ async def reconcile_group(client, group_jid): ## Operational Safety Rules !!! warning - Always verify the bot has required admin privileges before participant mutation. + Always verify the client has required admin privileges before participant mutation. !!! tip Write audit logs for every moderator action: actor, target group, participant, action, timestamp. diff --git a/docs/tutorials/profile-privacy.md b/docs/tutorials/profile-privacy.md index e7a2d08..3c80d12 100644 --- a/docs/tutorials/profile-privacy.md +++ b/docs/tutorials/profile-privacy.md @@ -13,7 +13,7 @@ from tryx.events import EvMessage ADMIN = "1234567890" -@bot.on(EvMessage) +@app.on(EvMessage) async def profile_commands(client, event): sender = event.data.message_info.source.sender.user if sender != ADMIN: @@ -37,7 +37,7 @@ async def profile_commands(client, event): from tryx.client import PrivacyCategory, PrivacyValue -@bot.on(EvMessage) +@app.on(EvMessage) async def privacy_commands(client, event): text = (event.data.get_text() or "").strip() chat = event.data.message_info.source.chat diff --git a/examples.py b/examples.py index c61e2a8..a004b6b 100644 --- a/examples.py +++ b/examples.py @@ -13,21 +13,22 @@ # print("Handling event with data:", event) backend = SqliteBackend(DB_PATH) -client = Tryx(backend) +app = Tryx(backend) -@client.on(EvMessage) +@app.on(EvMessage) async def on_message(client: TryxClient, event: EvMessage) -> None: - info = event.message_info + data = event.data + info = data.message_info source = info.source sender = source.sender - text = event.get_text() or event.caption or "" + text = data.get_text() or data.caption or "" sender_jid = f"{sender.user}@{sender.server}" chat = source.chat - print(event.raw_proto) + print(data.raw_proto) print(f"[{info.id}] {sender_jid}: {text}") - print("text:", event.get_text()) + print("text:", data.get_text()) print("client:", client) print("chat:", chat, dir(chat)) await client.send_message(chat, msg(conversation="Hello!")) @@ -40,9 +41,8 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: async def main() -> None: - await client.run() + await app.run() if __name__ == "__main__": - loop = asyncio.new_event_loop() - loop.run_until_complete(main()) + asyncio.run(main()) diff --git a/examples/README.md b/examples/README.md index 3983afd..9b85fd3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,7 +2,7 @@ ## command_bot.py -Contoh bot command sederhana dengan alur: +Contoh automation command sederhana dengan alur: - menerima pesan masuk (`EvMessage`) - parsing command dari teks pesan @@ -13,10 +13,10 @@ Contoh bot command sederhana dengan alur: ### Command yang didukung -- `ping` -> bot membalas `pong` -- `pp` -> bot download profile picture pengirim dan kirim balik ke chat -- `pushname` -> bot membalas pushname pengirim -- `bio` -> bot membalas bio/about pengirim +- `ping` -> client membalas `pong` +- `pp` -> client download profile picture pengirim dan kirim balik ke chat +- `pushname` -> client membalas pushname pengirim +- `bio` -> client membalas bio/about pengirim - `help` / `menu` -> menampilkan daftar command ### Menjalankan diff --git a/examples/command_bot.py b/examples/command_bot.py index bc67160..73535fe 100644 --- a/examples/command_bot.py +++ b/examples/command_bot.py @@ -1,4 +1,4 @@ -"""Command bot example for Tryx. +"""Command automation example for Tryx. Commands: - ping -> reply pong @@ -28,18 +28,22 @@ def jid_to_text(jid: object) -> str: async def download_bytes(url: str) -> bytes: + """Download bytes from an HTTPS URL. Rejects non-HTTPS schemes for safety.""" + if not url.startswith("https://"): + raise ValueError(f"Refusing to download from non-HTTPS URL: {url}") + def _download() -> bytes: - with urlopen(url, timeout=20) as response: + with urlopen(url, timeout=20) as response: # noqa: S310 return response.read() return await asyncio.to_thread(_download) backend = SqliteBackend(DB_PATH) -bot = Tryx(backend) +app = Tryx(backend) -@bot.on(EvPushNameUpdate) +@app.on(EvPushNameUpdate) async def on_push_name_update(_client: TryxClient, event: EvPushNameUpdate) -> None: data = event.data print( @@ -50,13 +54,13 @@ async def on_push_name_update(_client: TryxClient, event: EvPushNameUpdate) -> N ) -@bot.on(EvUserAboutUpdate) +@app.on(EvUserAboutUpdate) async def on_user_about_update(_client: TryxClient, event: EvUserAboutUpdate) -> None: data = event.data print("[bio-update]", jid_to_text(data.jid), "=>", repr(data.status)) -@bot.on(EvMessage) +@app.on(EvMessage) async def on_message(client: TryxClient, event: EvMessage) -> None: data = event.data info = data.message_info @@ -144,7 +148,7 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: async def main() -> None: print(f"Starting command bot with DB: {DB_PATH}") - await bot.run() + await app.run() if __name__ == "__main__": diff --git a/mkdocs.yml b/mkdocs.yml index e3a007c..defd829 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -94,6 +94,8 @@ theme: plugins: - search + - minify: + minify_html: true markdown_extensions: - admonition diff --git a/pyproject.toml b/pyproject.toml index 89f1699..d5d1b41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ dev = [ docs = [ "mkdocs>=1.6.0", "mkdocs-material>=9.6.0", + "mkdocs-minify-plugin>=0.8.0", ] # include = [ # { path = "python/tryx/__init__.py", format = ["sdist", "wheel"] }, @@ -80,6 +81,9 @@ line-length = 88 target-version = "py38" extend-exclude = ["target", "site", "libs", "python/tryx/waproto/whatsapp_pb2.py", "python/tryx/waproto/whatsapp_pb2.pyi"] +[tool.pytest.ini_options] +testpaths = ["tests"] + [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B"] diff --git a/python/tryx/client.pyi b/python/tryx/client.pyi index fef21ce..0c642a9 100644 --- a/python/tryx/client.pyi +++ b/python/tryx/client.pyi @@ -45,9 +45,9 @@ DownloadableMedia = ( ) class Tryx: - """Main bot runtime controller. + """Main automation runtime controller. - Use this class to register handlers and start the connection lifecycle. + Use this class to register event handlers and start the connection lifecycle. """ handlers: Any diff --git a/python/tryx/exceptions.py b/python/tryx/exceptions.py index b08a51a..491b74b 100644 --- a/python/tryx/exceptions.py +++ b/python/tryx/exceptions.py @@ -6,7 +6,11 @@ globals()[name] = obj # Prefer modern names, but gracefully fall back to legacy names when needed. -FailedBuildBot = globals().get("FailedBuildBot") or globals().get("BuildBotError") +FailedBuildClient = ( + globals().get("FailedBuildClient") + or globals().get("FailedBuildBot") + or globals().get("BuildBotError") +) UnsupportedEventType = globals().get("UnsupportedEventType") or globals().get( "UnsupportedEventTypeError" ) @@ -15,8 +19,9 @@ ) # Backward-compatible aliases for older Python API names. -if isinstance(FailedBuildBot, type): - BuildBotError = FailedBuildBot +if isinstance(FailedBuildClient, type): + FailedBuildBot = FailedBuildClient # backward compat + BuildBotError = FailedBuildClient # backward compat if isinstance(UnsupportedEventType, type): UnsupportedEventTypeError = UnsupportedEventType diff --git a/python/tryx/exceptions.pyi b/python/tryx/exceptions.pyi index 7314526..288e0e9 100644 --- a/python/tryx/exceptions.pyi +++ b/python/tryx/exceptions.pyi @@ -1,7 +1,10 @@ """Typed exception hierarchy for Tryx.""" -class FailedBuildBot(Exception): - """Raised when the bot runtime cannot be initialized.""" +class FailedBuildClient(Exception): + """Raised when the automation client cannot be initialized.""" + +# Backward-compatible alias. +FailedBuildBot = FailedBuildClient class FailedToDecodeProto(Exception): """Raised when protobuf payload decoding fails.""" @@ -19,8 +22,8 @@ class UnsupportedEventType(Exception): """Raised when registering or dispatching an unknown event class.""" # Backward-compatible aliases. -class BuildBotError(FailedBuildBot): - """Backward-compatible alias of FailedBuildBot.""" +class BuildBotError(FailedBuildClient): + """Backward-compatible alias of FailedBuildClient.""" class UnsupportedEventTypeError(UnsupportedEventType): """Backward-compatible alias of UnsupportedEventType.""" diff --git a/src/clients/blocking.rs b/src/clients/blocking.rs index 61c22d1..ea87d6c 100644 --- a/src/clients/blocking.rs +++ b/src/clients/blocking.rs @@ -18,7 +18,7 @@ impl BlockingClient { self.client_rx .borrow() .clone() - .ok_or_else(|| PyErr::new::("Bot is not running")) + .ok_or_else(|| PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.")) } } diff --git a/src/clients/chat_actions.rs b/src/clients/chat_actions.rs index 52ae788..a381491 100644 --- a/src/clients/chat_actions.rs +++ b/src/clients/chat_actions.rs @@ -25,7 +25,7 @@ impl ChatActionsClient { self.client_rx .borrow() .clone() - .ok_or_else(|| PyErr::new::("Bot is not running")) + .ok_or_else(|| PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.")) } fn decode_sync_action_message_range( @@ -57,7 +57,7 @@ impl ChatActionsClient { } fn encode_message_key(py: Python<'_>, key: wa::MessageKey) -> PyResult> { - let mut bytes = Vec::new(); + let mut bytes = Vec::with_capacity(key.encoded_len()); key.encode(&mut bytes).map_err(|e| { PyErr::new::( format!("Failed to encode MessageKey: {}", e), @@ -70,7 +70,7 @@ impl ChatActionsClient { py: Python<'_>, range: SyncActionMessageRange, ) -> PyResult> { - let mut bytes = Vec::new(); + let mut bytes = Vec::with_capacity(range.encoded_len()); range.encode(&mut bytes).map_err(|e| { PyErr::new::( format!("Failed to encode SyncActionMessageRange: {}", e), diff --git a/src/clients/chatstate.rs b/src/clients/chatstate.rs index f857362..299064c 100644 --- a/src/clients/chatstate.rs +++ b/src/clients/chatstate.rs @@ -18,7 +18,7 @@ impl ChatstateClient { self.client_rx .borrow() .clone() - .ok_or_else(|| PyErr::new::("Bot is not running")) + .ok_or_else(|| PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.")) } } diff --git a/src/clients/community.rs b/src/clients/community.rs index b8c46ea..a57f553 100644 --- a/src/clients/community.rs +++ b/src/clients/community.rs @@ -27,7 +27,7 @@ impl CommunityClient { self.client_rx .borrow() .clone() - .ok_or_else(|| PyErr::new::("Bot is not running")) + .ok_or_else(|| PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.")) } } diff --git a/src/clients/contacts.rs b/src/clients/contacts.rs index 0483cf9..6b6622a 100644 --- a/src/clients/contacts.rs +++ b/src/clients/contacts.rs @@ -25,7 +25,7 @@ impl ContactClient { self.client_rx .borrow() .clone() - .ok_or_else(|| PyErr::new::("Bot is not running")) + .ok_or_else(|| PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.")) } } diff --git a/src/clients/groups.rs b/src/clients/groups.rs index 7df0569..d7d7607 100644 --- a/src/clients/groups.rs +++ b/src/clients/groups.rs @@ -29,7 +29,7 @@ impl GroupsClient { self.client_rx .borrow() .clone() - .ok_or_else(|| PyErr::new::("Bot is not running")) + .ok_or_else(|| PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.")) } } diff --git a/src/clients/newsletter.rs b/src/clients/newsletter.rs index d7e1360..1067a8b 100644 --- a/src/clients/newsletter.rs +++ b/src/clients/newsletter.rs @@ -24,7 +24,7 @@ impl NewsletterClient { self.client_rx .borrow() .clone() - .ok_or_else(|| PyErr::new::("Bot is not running")) + .ok_or_else(|| PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.")) } } diff --git a/src/clients/polls.rs b/src/clients/polls.rs index f05a925..41f71bf 100644 --- a/src/clients/polls.rs +++ b/src/clients/polls.rs @@ -18,7 +18,7 @@ impl PollsClient { self.client_rx .borrow() .clone() - .ok_or_else(|| PyErr::new::("Bot is not running")) + .ok_or_else(|| PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.")) } } diff --git a/src/clients/presence.rs b/src/clients/presence.rs index 4e27d53..d0f4aa2 100644 --- a/src/clients/presence.rs +++ b/src/clients/presence.rs @@ -18,7 +18,7 @@ impl PresenceClient { self.client_rx .borrow() .clone() - .ok_or_else(|| PyErr::new::("Bot is not running")) + .ok_or_else(|| PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.")) } } diff --git a/src/clients/privacy.rs b/src/clients/privacy.rs index 2210951..5b4684b 100644 --- a/src/clients/privacy.rs +++ b/src/clients/privacy.rs @@ -17,7 +17,7 @@ impl PrivacyClient { self.client_rx .borrow() .clone() - .ok_or_else(|| PyErr::new::("Bot is not running")) + .ok_or_else(|| PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.")) } } diff --git a/src/clients/profile.rs b/src/clients/profile.rs index 8e9033c..ad23674 100644 --- a/src/clients/profile.rs +++ b/src/clients/profile.rs @@ -15,7 +15,7 @@ impl ProfileClient { self.client_rx .borrow() .clone() - .ok_or_else(|| PyErr::new::("Bot is not running")) + .ok_or_else(|| PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.")) } } diff --git a/src/clients/status.rs b/src/clients/status.rs index 1a88e47..68f81a5 100644 --- a/src/clients/status.rs +++ b/src/clients/status.rs @@ -28,7 +28,7 @@ impl StatusClient { self.client_rx .borrow() .clone() - .ok_or_else(|| PyErr::new::("Bot is not running")) + .ok_or_else(|| PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.")) } } diff --git a/src/clients/tryx.rs b/src/clients/tryx.rs index b287b7a..72f7d23 100644 --- a/src/clients/tryx.rs +++ b/src/clients/tryx.rs @@ -21,6 +21,7 @@ use super::contacts::ContactClient; use super::chatstate::ChatstateClient; use super::blocking::BlockingClient; use super::groups::GroupsClient; +use super::newsletter::NewsletterClient; use super::polls::PollsClient; use super::presence::PresenceClient; use super::privacy::PrivacyClient; @@ -33,10 +34,17 @@ use crate::backend::{SqliteBackend, BackendBase}; use crate::events::types::{ EvArchiveUpdate, EvBusinessStatusUpdate, EvChatPresence, EvClientOutDated, EvConnectFailure, EvConnected, EvContactNumberChanged, EvContactSyncRequested, EvContactUpdate, EvContactUpdated, EvDeleteChatUpdate, EvDeleteMessageForMeUpdate, EvDeviceListUpdate, EvDisappearingModeChanged, EvDisconnected, EvGroupUpdate, EvHistorySync, EvJoinedGroup, 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 }; -use crate::exceptions::{EventDispatchError, FailedBuildBot, UnsupportedBackend}; +use crate::exceptions::{EventDispatchError, FailedBuildClient, UnsupportedBackend}; use crate::events::dispatcher::Dispatcher; use super::event_callbacks::EventCallbacks; +/// Creates a `Py` namespace client that shares the `client_rx` watch channel. +macro_rules! new_namespace_client { + ($py:expr, $rx:expr, $ty:ident) => { + Py::new($py, $ty { client_rx: $rx.clone() })? + }; +} + type PyCallbackFuture = Pin>> + Send>>; @@ -65,94 +73,22 @@ impl Tryx { .block_on(backends.connect()) .map_err(|e| PyErr::new::(e))?; let (client_tx, client_rx) = watch::channel(None); - let contact_client = Py::new( - py, - ContactClient { - client_rx: client_rx.clone(), - }, - )?; - let chat_actions_client = Py::new( - py, - ChatActionsClient { - client_rx: client_rx.clone(), - }, - )?; - let community_client = Py::new( - py, - CommunityClient { - client_rx: client_rx.clone(), - }, - )?; - let newsletter_client = Py::new( - py, - crate::clients::newsletter::NewsletterClient { - client_rx: client_rx.clone(), - }, - )?; - let groups_client = Py::new( - py, - GroupsClient { - client_rx: client_rx.clone(), - }, - )?; - let status_client = Py::new( - py, - StatusClient { - client_rx: client_rx.clone(), - }, - )?; - let chatstate_client = Py::new( - py, - ChatstateClient { - client_rx: client_rx.clone(), - }, - )?; - let blocking_client = Py::new( - py, - BlockingClient { - client_rx: client_rx.clone(), - }, - )?; - let polls_client = Py::new( - py, - PollsClient { - client_rx: client_rx.clone(), - }, - )?; - let presence_client = Py::new( - py, - PresenceClient { - client_rx: client_rx.clone(), - }, - )?; - let privacy_client = Py::new( - py, - PrivacyClient { - client_rx: client_rx.clone(), - }, - )?; - let profile_client = Py::new( - py, - ProfileClient { - client_rx: client_rx.clone(), - }, - )?; let tryx_client = Py::new( py, TryxClient { - client_rx, - contact: contact_client, - chat_actions: chat_actions_client, - community: community_client, - newsletter: newsletter_client, - groups: groups_client, - status: status_client, - chatstate: chatstate_client, - blocking: blocking_client, - polls: polls_client, - presence: presence_client, - privacy: privacy_client, - profile: profile_client, + client_rx: client_rx.clone(), + contact: new_namespace_client!(py, client_rx, ContactClient), + chat_actions: new_namespace_client!(py, client_rx, ChatActionsClient), + community: new_namespace_client!(py, client_rx, CommunityClient), + newsletter: new_namespace_client!(py, client_rx, NewsletterClient), + groups: new_namespace_client!(py, client_rx, GroupsClient), + status: new_namespace_client!(py, client_rx, StatusClient), + chatstate: new_namespace_client!(py, client_rx, ChatstateClient), + blocking: new_namespace_client!(py, client_rx, BlockingClient), + polls: new_namespace_client!(py, client_rx, PollsClient), + presence: new_namespace_client!(py, client_rx, PresenceClient), + privacy: new_namespace_client!(py, client_rx, PrivacyClient), + profile: new_namespace_client!(py, client_rx, ProfileClient), } )?; @@ -185,25 +121,25 @@ impl Tryx { fn run<'py>(&'py self, py: Python<'py>) -> Result, PyErr> { init_logging(); - info!("starting bot in async mode via Tryx.run"); + info!("starting client in async mode via Tryx.run"); let backend = self.backend.clone(); let handlers = self.handlers.clone_ref(py); let tryx_client = self.tryx_client.clone_ref(py); let client_tx = self.client_tx.clone(); let locals = get_current_locals(py)?; future_into_py_with_locals(py, locals.clone(), async move { - Self::run_bot(backend, handlers, Some(locals), tryx_client, client_tx).await + Self::run_automation(backend, handlers, Some(locals), tryx_client, client_tx).await }) } - /// Starts the bot and blocks until it exits. + /// Starts the client and blocks until it exits. /// /// Python usage: /// client.run_blocking() fn run_blocking(&self, py: Python<'_>) -> PyResult<()> { init_logging(); - info!("starting bot in blocking mode via Tryx.run_blocking"); + info!("starting client in blocking mode via Tryx.run_blocking"); let backend = self.backend.clone(); let handlers = self.handlers.clone_ref(py); let tryx_client = self.tryx_client.clone_ref(py); @@ -216,14 +152,14 @@ impl Tryx { })?; rt.block_on(async { - let mut bot_task = tokio::spawn(Self::run_bot(backend, handlers, None, tryx_client, client_tx)); + let mut task = tokio::spawn(Self::run_automation(backend, handlers, None, tryx_client, client_tx)); let mut signal_tick = interval(Duration::from_millis(200)); loop { tokio::select! { _ = signal::ctrl_c() => { - warn!("SIGINT received via tokio::signal, stopping bot task"); - bot_task.abort(); + warn!("SIGINT received via tokio::signal, stopping task"); + task.abort(); break; } _ = signal_tick.tick() => { @@ -238,27 +174,27 @@ impl Tryx { }); if let Err((err, is_keyboard_interrupt)) = signal_result { if is_keyboard_interrupt { - warn!("KeyboardInterrupt detected from Python, stopping bot task"); - bot_task.abort(); + warn!("KeyboardInterrupt detected from Python, stopping task"); + task.abort(); break; } error!(error = %err, "non-keyboard Python signal error while polling"); - bot_task.abort(); + task.abort(); return Err(err); } } - result = &mut bot_task => { + result = &mut task => { match result { Ok(inner) => { - info!("bot task finished in blocking mode"); + info!("task finished in blocking mode"); inner?; } Err(err) if err.is_cancelled() => { - info!("bot task cancelled"); + info!("task cancelled"); } Err(err) => { - error!(error = %err, "bot task join failed"); + error!(error = %err, "task join failed"); return Err(PyErr::new::(err.to_string())); } } @@ -268,12 +204,12 @@ impl Tryx { } } - match bot_task.await { - Ok(Ok(())) => info!("bot finished after interrupt"), + match task.await { + Ok(Ok(())) => info!("client finished after interrupt"), Ok(Err(err)) => return Err(err), - Err(join_err) if join_err.is_cancelled() => info!("bot task cancelled successfully"), + Err(join_err) if join_err.is_cancelled() => info!("task cancelled successfully"), Err(join_err) => { - error!(error = %join_err, "bot task join failed after interrupt"); + error!(error = %join_err, "task join failed after interrupt"); return Err(PyErr::new::(join_err.to_string())); } } @@ -360,11 +296,14 @@ impl Tryx { ) where F: FnOnce(Python<'_>) -> PyResult>, { + if callbacks.is_empty() { + return; + } let payload = Python::attach(build_payload); Self::emit_event(callbacks, tryx_client, payload, locals, event_name).await; } - async fn run_bot( + async fn run_automation( backend: Arc, handlers: Py, locals: Option, @@ -375,8 +314,8 @@ impl Tryx { let dispatcher = handlers.bind(py).borrow(); EventCallbacks::from_dispatcher(py, &dispatcher) })); - info!("building WhatsApp bot"); - let mut bot = Bot::builder() + info!("building WhatsApp automation client"); + let mut automation = Bot::builder() .with_backend(backend) .with_transport_factory(TokioWebSocketTransportFactory::new()) .with_http_client(UreqHttpClient::new()) @@ -475,7 +414,7 @@ impl Tryx { } Event::UndecryptableMessage(undecryptable_message) => { Self::emit_built_event(&tryx_client, &callbacks.undecryptable_message, locals.clone(), "UndecryptableMessage", |py| { - Py::new(py, EvUndecryptableMessage::new(undecryptable_message.info.clone(), undecryptable_message.is_unavailable, undecryptable_message.unavailable_type, undecryptable_message.decrypt_fail_mode)).map(|event| event.into_any()) + Py::new(py, EvUndecryptableMessage::new(undecryptable_message.info, undecryptable_message.is_unavailable, undecryptable_message.unavailable_type, undecryptable_message.decrypt_fail_mode)).map(|event| event.into_any()) }).await; } Event::Notification(notification) => { @@ -643,29 +582,29 @@ impl Tryx { .build() .await .map_err(|e| { - error!(error = %e, "failed to build bot"); - PyErr::new::(e.to_string()) + error!(error = %e, "failed to build client"); + PyErr::new::(e.to_string()) })?; - let client = bot.client(); + let client = automation.client(); client_tx .send(Some(client)) .map_err(|e| PyErr::new::(e.to_string()))?; - info!("bot built successfully, starting run loop"); - bot.run() + info!("client built successfully, starting run loop"); + automation.run() .await .map_err(|e| { - error!(error = %e, "failed to start bot run stream"); + error!(error = %e, "failed to start run stream"); PyErr::new::(e.to_string()) })? .await .map_err(|e| { - error!(error = %e, "bot run stream failed"); + error!(error = %e, "run stream failed"); PyErr::new::(e.to_string()) })?; - info!("bot run loop finished"); + info!("run loop finished"); Ok(()) } diff --git a/src/clients/tryx_client.rs b/src/clients/tryx_client.rs index 8e6518c..bd3bd07 100644 --- a/src/clients/tryx_client.rs +++ b/src/clients/tryx_client.rs @@ -23,6 +23,18 @@ use crate::clients::status::StatusClient; use crate::events::types::{EvMessage}; use crate::types::{JID, MediaReuploadResult, SendResult, UploadResponse}; use crate::wacore::download::MediaType; + +/// Reduces the repeated decode-then-download pattern for each media type. +macro_rules! decode_and_download { + ($client:expr, $data:expr, $msg_type:ty, $label:expr) => {{ + let media = <$msg_type>::decode($data).map_err(|e| { + PyErr::new::( + format!("Failed to decode {}: {}", $label, e), + ) + })?; + $client.download(&media).await + }}; +} #[pyclass] pub struct TryxClient { pub client_rx: watch::Receiver>>, @@ -84,7 +96,7 @@ impl TryxClient { } // fn download_media_to_writter<'py>(&self, py: Python<'py>, message: Py, path: String) -> PyResult> { // let client = self.client_rx.borrow().clone().ok_or_else(|| { - // PyErr::new::("Bot is not running") + // PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.") // })?; // let message_type_name = message // .getattr(py, "DESCRIPTOR") @@ -159,7 +171,7 @@ impl TryxClient { // }.map_err(|e| PyErr::new::(e.to_string())) fn download_media<'py>(&self, py: Python<'py>, message: Py) -> PyResult> { let client = self.client_rx.borrow().clone().ok_or_else(|| { - PyErr::new::("Bot is not running") + PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.") })?; let message_type_name = message .getattr(py, "DESCRIPTOR") @@ -173,48 +185,13 @@ impl TryxClient { let locals = get_current_locals(py)?; future_into_py_with_locals::<_, Vec>(py, locals, async move { let download = match message_type_name.as_str() { - "ImageMessage" => { - let media = wa::ImageMessage::decode(serialized.as_slice()).map_err(|e| { - PyErr::new::( - format!("Failed to decode ImageMessage: {}", e), - ) - })?; - client.download(&media).await - } - "VideoMessage" => { - let media = wa::VideoMessage::decode(serialized.as_slice()).map_err(|e| { - PyErr::new::( - format!("Failed to decode VideoMessage: {}", e), - ) - })?; - client.download(&media).await - } - "DocumentMessage" => { - let media = wa::DocumentMessage::decode(serialized.as_slice()).map_err(|e| { - PyErr::new::( - format!("Failed to decode DocumentMessage: {}", e), - ) - })?; - client.download(&media).await - } - "AudioMessage" => { - let media = wa::AudioMessage::decode(serialized.as_slice()).map_err(|e| { - PyErr::new::( - format!("Failed to decode AudioMessage: {}", e), - ) - })?; - client.download(&media).await - } - "StickerMessage" => { - let media = wa::StickerMessage::decode(serialized.as_slice()).map_err(|e| { - PyErr::new::( - format!("Failed to decode StickerMessage: {}", e), - ) - })?; - client.download(&media).await - } + "ImageMessage" => decode_and_download!(client, serialized.as_slice(), wa::ImageMessage, "ImageMessage"), + "VideoMessage" => decode_and_download!(client, serialized.as_slice(), wa::VideoMessage, "VideoMessage"), + "DocumentMessage" => decode_and_download!(client, serialized.as_slice(), wa::DocumentMessage, "DocumentMessage"), + "AudioMessage" => decode_and_download!(client, serialized.as_slice(), wa::AudioMessage, "AudioMessage"), + "StickerMessage" => decode_and_download!(client, serialized.as_slice(), wa::StickerMessage, "StickerMessage"), _ => { - // Fallback path for unknown wrappers from Python side. + // Fallback: try each media type in order. if let Ok(media) = wa::ImageMessage::decode(serialized.as_slice()) { client.download(&media).await } else if let Ok(media) = wa::VideoMessage::decode(serialized.as_slice()) { @@ -227,7 +204,7 @@ impl TryxClient { client.download(&media).await } else { return Err(PyErr::new::( - "Failed to decode message as supported media message", + "Failed to decode message as supported media type. Expected one of: ImageMessage, VideoMessage, DocumentMessage, AudioMessage, StickerMessage", )); } } @@ -238,7 +215,7 @@ impl TryxClient { } fn upload_file<'py>(&self, py: Python<'py>, path: String, media_type: Py) -> PyResult> { let client = self.client_rx.borrow().clone().ok_or_else(|| { - PyErr::new::("Bot is not running") + PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.") })?; let media_type_enum = media_type.bind(py).borrow_mut().to_wacore_enum(); let locals = get_current_locals(py)?; @@ -262,7 +239,7 @@ impl TryxClient { } fn upload<'py>(&self, py: Python<'py>, data: &[u8], media_type: Py) -> PyResult> { let client = self.client_rx.borrow().clone().ok_or_else(|| { - PyErr::new::("Bot is not running") + PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.") })?; let data_vec = data.to_vec(); let mtype = media_type.bind(py).borrow_mut().to_wacore_enum(); @@ -287,7 +264,7 @@ impl TryxClient { } fn send_message<'py>(&self, py: Python<'py>, to: Py, message: Py) -> PyResult> { let client = self.client_rx.borrow().clone().ok_or_else(|| { - PyErr::new::("Bot is not running") + PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.") })?; let jid = to.bind(py).borrow().as_whatsapp_jid(); @@ -315,7 +292,7 @@ impl TryxClient { #[pyo3(signature = (to, text, quoted=None))] fn send_text<'py>(&self, py: Python<'py>, to: Py, text: String, quoted: Option>) -> PyResult> { let client = self.client_rx.borrow().clone().ok_or_else(|| { - PyErr::new::("Bot is not running") + PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.") })?; let jid = to.bind(py).borrow().as_whatsapp_jid(); let locals = get_current_locals(py)?; @@ -354,7 +331,7 @@ impl TryxClient { #[pyo3(signature = (to, photo_data, caption=None, quoted=None))] fn send_photo<'py>(&self, py: Python<'py>, to: Py, photo_data: &[u8], caption: Option, quoted: Option>) -> PyResult> { let client = self.client_rx.borrow().clone().ok_or_else(|| { - PyErr::new::("Bot is not running") + PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.") })?; let jid = to.bind(py).borrow().as_whatsapp_jid(); let photo_clone = photo_data.to_vec(); @@ -399,7 +376,7 @@ impl TryxClient { quoted: Option>, ) -> PyResult> { let client = self.client_rx.borrow().clone().ok_or_else(|| { - PyErr::new::("Bot is not running") + PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.") })?; let jid = to.bind(py).borrow().as_whatsapp_jid(); let data = document_data.to_vec(); @@ -450,7 +427,7 @@ impl TryxClient { quoted: Option>, ) -> PyResult> { let client = self.client_rx.borrow().clone().ok_or_else(|| { - PyErr::new::("Bot is not running") + PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.") })?; let jid = to.bind(py).borrow().as_whatsapp_jid(); let data = audio_data.to_vec(); @@ -501,7 +478,7 @@ impl TryxClient { quoted: Option>, ) -> PyResult> { let client = self.client_rx.borrow().clone().ok_or_else(|| { - PyErr::new::("Bot is not running") + PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.") })?; let jid = to.bind(py).borrow().as_whatsapp_jid(); let data = video_data.to_vec(); @@ -563,7 +540,7 @@ impl TryxClient { quoted: Option>, ) -> PyResult> { let client = self.client_rx.borrow().clone().ok_or_else(|| { - PyErr::new::("Bot is not running") + PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.") })?; let jid = to.bind(py).borrow().as_whatsapp_jid(); let data = sticker_data.to_vec(); @@ -617,7 +594,7 @@ impl TryxClient { } let client = self.client_rx.borrow().clone().ok_or_else(|| { - PyErr::new::("Bot is not running") + PyErr::new::("Client is not running. Call Tryx.run() or Tryx.run_blocking() first.") })?; let chat_jid_value = chat_jid.bind(py).borrow().as_whatsapp_jid(); let participant_value = participant diff --git a/src/events/dispatcher.rs b/src/events/dispatcher.rs index 4c40898..3f88fd1 100644 --- a/src/events/dispatcher.rs +++ b/src/events/dispatcher.rs @@ -1,7 +1,7 @@ use pyo3::prelude::*; use tracing::{debug, info}; -use pyo3::types::{PyType}; -use pyo3::{PyTypeInfo}; +use pyo3::types::PyType; +use pyo3::PyTypeInfo; use super::types::{ EvArchiveUpdate, EvBusinessStatusUpdate, @@ -9,8 +9,14 @@ use super::types::{ EvClientOutDated, EvConnectFailure, EvConnected, + EvContactNumberChanged, + EvContactSyncRequested, EvContactUpdate, + EvContactUpdated, + EvDeleteChatUpdate, + EvDeleteMessageForMeUpdate, EvDeviceListUpdate, + EvDisappearingModeChanged, EvDisconnected, EvGroupInfoUpdate, EvHistorySync, @@ -19,6 +25,7 @@ use super::types::{ EvMarkChatAsReadUpdate, EvMessage, EvMuteUpdate, + EvNewsletterLiveUpdate, EvNotification, EvOfflineSyncCompleted, EvOfflineSyncPreview, @@ -33,689 +40,176 @@ use super::types::{ EvQrScannedWithoutMultidevice, EvReceipt, EvSelfPushNameUpdated, + EvStarUpdate, EvStreamError, EvStreamReplaced, EvTemporaryBan, EvUndecryptableMessage, EvUserAboutUpdate, }; -use crate::events::types::{EvContactSyncRequested, EvContactUpdated, EvDeleteChatUpdate, EvDeleteMessageForMeUpdate, EvDisappearingModeChanged, EvNewsletterLiveUpdate, EvStarUpdate}; use crate::exceptions::UnsupportedEventType; -#[pyclass] -pub struct Dispatcher { - connected: Vec>, - disconnected: Vec>, - logged_out: Vec>, - pair_success: Vec>, - pair_error: Vec>, - pairing_qr_code: Vec>, - pairing_code: Vec>, - qr_scanned_without_multidevice: Vec>, - client_outdated: Vec>, - message: Vec>, - receipt: Vec>, - undecryptable_message: Vec>, - notification: Vec>, - chat_presence: Vec>, - presence: Vec>, - picture_update: Vec>, - user_about_update: Vec>, - joined_group: Vec>, - group_info_update: Vec>, - contact_update: Vec>, - push_name_update: Vec>, - self_push_name_update: Vec>, - pin_update: Vec>, - mute_update: Vec>, - archive_update: Vec>, - mark_chat_as_read_update: Vec>, - history_sync: Vec>, - offline_sync_preview: Vec>, - offline_sync_completed: Vec>, - device_list_update: Vec>, - business_status_update: Vec>, - stream_replaced: Vec>, - temporary_ban: Vec>, - connect_failure: Vec>, - stream_error: Vec>, - pending_event: Option, - disappearing_mode_changed: Vec>, - contact_sync_requested: Vec>, - contact_updated: Vec>, - star_update: Vec>, - newsletter_live_update: Vec>, - delete_chat_update: Vec>, - delete_message_for_me_update: Vec>, -} - -#[derive(Clone, Copy)] -enum DispatchEvent { - Connected, - Disconnected, - LoggedOut, - PairSuccess, - PairError, - PairingQrCode, - PairingCode, - QrScannedWithoutMultidevice, - ClientOutDated, - Message, - Receipt, - UndecryptableMessage, - Notification, - ChatPresence, - Presence, - PictureUpdate, - UserAboutUpdate, - JoinedGroup, - GroupInfoUpdate, - ContactUpdate, - PushNameUpdate, - SelfPushNameUpdated, - PinUpdate, - MuteUpdate, - ArchiveUpdate, - MarkChatAsReadUpdate, - HistorySync, - OfflineSyncPreview, - OfflineSyncCompleted, - DeviceListUpdate, - BusinessStatusUpdate, - StreamReplaced, - TemporaryBan, - ConnectFailure, - StreamError, - DisappearingModeChanged, - ContactSyncRequested, - ContactUpdated, - StarUpdate, - NewsletterLiveUpdate, - DeleteChatUpdate, - DeleteMessageForMeUpdate, -} - -impl Dispatcher { - fn cloned_handlers(py: Python<'_>, handlers: &Vec>) -> Vec> { - handlers - .iter() - .map(|handler| handler.clone_ref(py)) - .collect::>() - } - - pub fn empty() -> Self { - Self { - connected: Vec::new(), - disconnected: Vec::new(), - logged_out: Vec::new(), - pair_success: Vec::new(), - pair_error: Vec::new(), - pairing_qr_code: Vec::new(), - pairing_code: Vec::new(), - qr_scanned_without_multidevice: Vec::new(), - client_outdated: Vec::new(), - message: Vec::new(), - receipt: Vec::new(), - undecryptable_message: Vec::new(), - notification: Vec::new(), - chat_presence: Vec::new(), - presence: Vec::new(), - picture_update: Vec::new(), - user_about_update: Vec::new(), - joined_group: Vec::new(), - group_info_update: Vec::new(), - contact_update: Vec::new(), - push_name_update: Vec::new(), - self_push_name_update: Vec::new(), - pin_update: Vec::new(), - mute_update: Vec::new(), - archive_update: Vec::new(), - mark_chat_as_read_update: Vec::new(), - history_sync: Vec::new(), - offline_sync_preview: Vec::new(), - offline_sync_completed: Vec::new(), - device_list_update: Vec::new(), - business_status_update: Vec::new(), - stream_replaced: Vec::new(), - temporary_ban: Vec::new(), - connect_failure: Vec::new(), - stream_error: Vec::new(), - disappearing_mode_changed: Vec::new(), - pending_event: None, - contact_sync_requested: Vec::new(), - contact_updated: Vec::new(), - star_update: Vec::new(), - newsletter_live_update: Vec::new(), - delete_chat_update: Vec::new(), - delete_message_for_me_update: Vec::new(), +/// Generates the `Dispatcher` struct, `DispatchEvent` enum, and all associated +/// boilerplate from a single declarative list. +/// +/// Each entry maps: +/// field, EnumVariant, "wire_name", EvPyClass, "log label", handler_fn +macro_rules! define_dispatcher { + ( + $( + $field:ident, $variant:ident, $name:expr, $ev_type:ty, $log_label:expr, $handler_fn:ident + );+ $(;)? + ) => { + #[pyclass] + pub struct Dispatcher { + $( $field: Vec>, )+ + pending_event: Option, } - } - - pub fn pairing_qr_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.pairing_qr_code); - debug!(handlers = handlers.len(), "collected pairing QR handlers"); - handlers - } - - pub fn message_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.message); - debug!(handlers = handlers.len(), "collected message handlers"); - handlers - } - - pub fn logout_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.logged_out); - debug!(handlers = handlers.len(), "collected logged out handlers"); - handlers - } - - pub fn logged_out_handlers(&self, py: Python<'_>) -> Vec> { - self.logout_handlers(py) - } - - pub fn connected_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.connected); - debug!(handlers = handlers.len(), "collected connected handlers"); - handlers - } - - pub fn disconnected_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.disconnected); - debug!(handlers = handlers.len(), "collected disconnected handlers"); - handlers - } - - pub fn pair_success_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.pair_success); - debug!(handlers = handlers.len(), "collected pair success handlers"); - handlers - } - - pub fn pair_error_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.pair_error); - debug!(handlers = handlers.len(), "collected pair error handlers"); - handlers - } - - pub fn pairing_code_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.pairing_code); - debug!(handlers = handlers.len(), "collected pairing code handlers"); - handlers - } - - pub fn qr_scanned_without_multidevice_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.qr_scanned_without_multidevice); - debug!(handlers = handlers.len(), "collected QR scanned without multidevice handlers"); - handlers - } - - pub fn client_outdated_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.client_outdated); - debug!(handlers = handlers.len(), "collected client outdated handlers"); - handlers - } - - pub fn newsletter_live_update_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.newsletter_live_update); - debug!(handlers = handlers.len(), "collected newsletter live update handlers"); - handlers - } - pub fn receipt_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.receipt); - debug!(handlers = handlers.len(), "collected receipt handlers"); - handlers - } - - pub fn undecryptable_message_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.undecryptable_message); - debug!(handlers = handlers.len(), "collected undecryptable message handlers"); - handlers - } - - pub fn notification_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.notification); - debug!(handlers = handlers.len(), "collected notification handlers"); - handlers - } - - pub fn chat_presence_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.chat_presence); - debug!(handlers = handlers.len(), "collected chat presence handlers"); - handlers - } - - pub fn presence_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.presence); - debug!(handlers = handlers.len(), "collected presence handlers"); - handlers - } - - pub fn picture_update_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.picture_update); - debug!(handlers = handlers.len(), "collected picture update handlers"); - handlers - } - - pub fn user_about_update_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.user_about_update); - debug!(handlers = handlers.len(), "collected user about update handlers"); - handlers - } - - pub fn joined_group_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.joined_group); - debug!(handlers = handlers.len(), "collected joined group handlers"); - handlers - } - - pub fn group_info_update_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.group_info_update); - debug!(handlers = handlers.len(), "collected group info update handlers"); - handlers - } - - pub fn contact_update_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.contact_update); - debug!(handlers = handlers.len(), "collected contact update handlers"); - handlers - } - - pub fn push_name_update_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.push_name_update); - debug!(handlers = handlers.len(), "collected push name update handlers"); - handlers - } - pub fn self_push_name_updated_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.self_push_name_update); - debug!(handlers = handlers.len(), "collected self push name updated handlers"); - handlers - } - - pub fn pin_update_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.pin_update); - debug!(handlers = handlers.len(), "collected pin update handlers"); - handlers - } - - pub fn mute_update_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.mute_update); - debug!(handlers = handlers.len(), "collected mute update handlers"); - handlers - } - pub fn archive_update_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.archive_update); - debug!(handlers = handlers.len(), "collected archive update handlers"); - handlers - } - - pub fn mark_chat_as_read_update_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.mark_chat_as_read_update); - debug!(handlers = handlers.len(), "collected mark chat as read handlers"); - handlers - } - - pub fn history_sync_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.history_sync); - debug!(handlers = handlers.len(), "collected history sync handlers"); - handlers - } - - pub fn offline_sync_preview_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.offline_sync_preview); - debug!(handlers = handlers.len(), "collected offline sync preview handlers"); - handlers - } - - pub fn offline_sync_completed_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.offline_sync_completed); - debug!(handlers = handlers.len(), "collected offline sync completed handlers"); - handlers - } - - pub fn device_list_update_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.device_list_update); - debug!(handlers = handlers.len(), "collected device list update handlers"); - handlers - } - - pub fn business_status_update_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.business_status_update); - debug!(handlers = handlers.len(), "collected business status update handlers"); - handlers - } - - pub fn stream_replaced_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.stream_replaced); - debug!(handlers = handlers.len(), "collected stream replaced handlers"); - handlers - } - - pub fn temporary_ban_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.temporary_ban); - debug!(handlers = handlers.len(), "collected temporary ban handlers"); - handlers - } - - pub fn connect_failure_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.connect_failure); - debug!(handlers = handlers.len(), "collected connect failure handlers"); - handlers - } - - pub fn stream_error_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.stream_error); - debug!(handlers = handlers.len(), "collected stream error handlers"); - handlers - } - pub fn disappearing_mode_changed_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.disappearing_mode_changed); - debug!(handlers = handlers.len(), "collected disappearing mode changed handlers"); - handlers - } - pub fn contact_number_changed_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.contact_update); - debug!(handlers = handlers.len(), "collected contact number changed handlers"); - handlers - } - fn handlers_for_event(&self, event: DispatchEvent) -> &Vec> { - match event { - DispatchEvent::Connected => &self.connected, - DispatchEvent::Disconnected => &self.disconnected, - DispatchEvent::LoggedOut => &self.logged_out, - DispatchEvent::PairSuccess => &self.pair_success, - DispatchEvent::PairError => &self.pair_error, - DispatchEvent::PairingQrCode => &self.pairing_qr_code, - DispatchEvent::PairingCode => &self.pairing_code, - DispatchEvent::QrScannedWithoutMultidevice => &self.qr_scanned_without_multidevice, - DispatchEvent::ClientOutDated => &self.client_outdated, - DispatchEvent::Message => &self.message, - DispatchEvent::Receipt => &self.receipt, - DispatchEvent::UndecryptableMessage => &self.undecryptable_message, - DispatchEvent::Notification => &self.notification, - DispatchEvent::ChatPresence => &self.chat_presence, - DispatchEvent::Presence => &self.presence, - DispatchEvent::PictureUpdate => &self.picture_update, - DispatchEvent::UserAboutUpdate => &self.user_about_update, - DispatchEvent::JoinedGroup => &self.joined_group, - DispatchEvent::GroupInfoUpdate => &self.group_info_update, - DispatchEvent::ContactUpdate => &self.contact_update, - DispatchEvent::PushNameUpdate => &self.push_name_update, - DispatchEvent::SelfPushNameUpdated => &self.self_push_name_update, - DispatchEvent::PinUpdate => &self.pin_update, - DispatchEvent::MuteUpdate => &self.mute_update, - DispatchEvent::ArchiveUpdate => &self.archive_update, - DispatchEvent::MarkChatAsReadUpdate => &self.mark_chat_as_read_update, - DispatchEvent::HistorySync => &self.history_sync, - DispatchEvent::OfflineSyncPreview => &self.offline_sync_preview, - DispatchEvent::OfflineSyncCompleted => &self.offline_sync_completed, - DispatchEvent::DeviceListUpdate => &self.device_list_update, - DispatchEvent::BusinessStatusUpdate => &self.business_status_update, - DispatchEvent::StreamReplaced => &self.stream_replaced, - DispatchEvent::TemporaryBan => &self.temporary_ban, - DispatchEvent::ConnectFailure => &self.connect_failure, - DispatchEvent::StreamError => &self.stream_error, - DispatchEvent::DisappearingModeChanged => &self.disappearing_mode_changed, - DispatchEvent::ContactSyncRequested => &self.contact_sync_requested, - DispatchEvent::ContactUpdated => &self.contact_updated, - DispatchEvent::StarUpdate => &self.star_update, - DispatchEvent::NewsletterLiveUpdate => &self.newsletter_live_update, - DispatchEvent::DeleteChatUpdate => &self.delete_chat_update, - DispatchEvent::DeleteMessageForMeUpdate => &self.delete_message_for_me_update, + #[derive(Clone, Copy)] + enum DispatchEvent { + $( $variant, )+ } - } -} - -fn dispatch_event_name(event: DispatchEvent) -> &'static str { - match event { - DispatchEvent::Connected => "connected", - DispatchEvent::Disconnected => "disconnected", - DispatchEvent::LoggedOut => "logged_out", - DispatchEvent::PairSuccess => "pair_success", - DispatchEvent::PairError => "pair_error", - DispatchEvent::PairingQrCode => "pairing_qr_code", - DispatchEvent::PairingCode => "pairing_code", - DispatchEvent::QrScannedWithoutMultidevice => "qr_scanned_without_multidevice", - DispatchEvent::ClientOutDated => "client_outdated", - DispatchEvent::Message => "message", - DispatchEvent::Receipt => "receipt", - DispatchEvent::UndecryptableMessage => "undecryptable_message", - DispatchEvent::Notification => "notification", - DispatchEvent::ChatPresence => "chat_presence", - DispatchEvent::Presence => "presence", - DispatchEvent::PictureUpdate => "picture_update", - DispatchEvent::UserAboutUpdate => "user_about_update", - DispatchEvent::JoinedGroup => "joined_group", - DispatchEvent::GroupInfoUpdate => "group_info_update", - DispatchEvent::ContactUpdate => "contact_update", - DispatchEvent::PushNameUpdate => "push_name_update", - DispatchEvent::SelfPushNameUpdated => "self_push_name_updated", - DispatchEvent::PinUpdate => "pin_update", - DispatchEvent::MuteUpdate => "mute_update", - DispatchEvent::ArchiveUpdate => "archive_update", - DispatchEvent::MarkChatAsReadUpdate => "mark_chat_as_read_update", - DispatchEvent::HistorySync => "history_sync", - DispatchEvent::OfflineSyncPreview => "offline_sync_preview", - DispatchEvent::OfflineSyncCompleted => "offline_sync_completed", - DispatchEvent::DeviceListUpdate => "device_list_update", - DispatchEvent::BusinessStatusUpdate => "business_status_update", - DispatchEvent::StreamReplaced => "stream_replaced", - DispatchEvent::TemporaryBan => "temporary_ban", - DispatchEvent::ConnectFailure => "connect_failure", - DispatchEvent::StreamError => "stream_error", - DispatchEvent::DisappearingModeChanged => "disappearing_mode_changed", - DispatchEvent::ContactSyncRequested => "contact_sync_requested", - DispatchEvent::ContactUpdated => "contact_updated", - DispatchEvent::StarUpdate => "star_update", - DispatchEvent::NewsletterLiveUpdate => "newsletter_live_update", - DispatchEvent::DeleteChatUpdate => "delete_chat_update", - DispatchEvent::DeleteMessageForMeUpdate => "delete_message_for_me_update", - } -} - -/// Maps a Python event class into the internal dispatcher event enum. -fn dispatch_event_from_type(py: Python, event_type: &Bound) -> PyResult { - let event_type = event_type.cast::()?; - - if event_type.is_subclass(&EvConnected::type_object(py))? { - Ok(DispatchEvent::Connected) - } else if event_type.is_subclass(&EvDisconnected::type_object(py))? { - Ok(DispatchEvent::Disconnected) - } else if event_type.is_subclass(&EvLoggedOut::type_object(py))? { - Ok(DispatchEvent::LoggedOut) - } else if event_type.is_subclass(&EvPairSuccess::type_object(py))? { - Ok(DispatchEvent::PairSuccess) - } else if event_type.is_subclass(&EvPairError::type_object(py))? { - Ok(DispatchEvent::PairError) - } else if event_type.is_subclass(&EvPairingQrCode::type_object(py))? { - Ok(DispatchEvent::PairingQrCode) - } else if event_type.is_subclass(&EvPairingCode::type_object(py))? { - Ok(DispatchEvent::PairingCode) - } else if event_type.is_subclass(&EvQrScannedWithoutMultidevice::type_object(py))? { - Ok(DispatchEvent::QrScannedWithoutMultidevice) - } else if event_type.is_subclass(&EvClientOutDated::type_object(py))? { - Ok(DispatchEvent::ClientOutDated) - } else if event_type.is_subclass(&EvMessage::type_object(py))? { - Ok(DispatchEvent::Message) - } else if event_type.is_subclass(&EvReceipt::type_object(py))? { - Ok(DispatchEvent::Receipt) - } else if event_type.is_subclass(&EvUndecryptableMessage::type_object(py))? { - Ok(DispatchEvent::UndecryptableMessage) - } else if event_type.is_subclass(&EvNotification::type_object(py))? { - Ok(DispatchEvent::Notification) - } else if event_type.is_subclass(&EvChatPresence::type_object(py))? { - Ok(DispatchEvent::ChatPresence) - } else if event_type.is_subclass(&EvPresence::type_object(py))? { - Ok(DispatchEvent::Presence) - } else if event_type.is_subclass(&EvPictureUpdate::type_object(py))? { - Ok(DispatchEvent::PictureUpdate) - } else if event_type.is_subclass(&EvUserAboutUpdate::type_object(py))? { - Ok(DispatchEvent::UserAboutUpdate) - } else if event_type.is_subclass(&EvJoinedGroup::type_object(py))? { - Ok(DispatchEvent::JoinedGroup) - } else if event_type.is_subclass(&EvGroupInfoUpdate::type_object(py))? { - Ok(DispatchEvent::GroupInfoUpdate) - } else if event_type.is_subclass(&EvContactUpdate::type_object(py))? { - Ok(DispatchEvent::ContactUpdate) - } else if event_type.is_subclass(&EvPushNameUpdate::type_object(py))? { - Ok(DispatchEvent::PushNameUpdate) - } else if event_type.is_subclass(&EvSelfPushNameUpdated::type_object(py))? { - Ok(DispatchEvent::SelfPushNameUpdated) - } else if event_type.is_subclass(&EvPinUpdate::type_object(py))? { - Ok(DispatchEvent::PinUpdate) - } else if event_type.is_subclass(&EvMuteUpdate::type_object(py))? { - Ok(DispatchEvent::MuteUpdate) - } else if event_type.is_subclass(&EvArchiveUpdate::type_object(py))? { - Ok(DispatchEvent::ArchiveUpdate) - } else if event_type.is_subclass(&EvMarkChatAsReadUpdate::type_object(py))? { - Ok(DispatchEvent::MarkChatAsReadUpdate) - } else if event_type.is_subclass(&EvHistorySync::type_object(py))? { - Ok(DispatchEvent::HistorySync) - } else if event_type.is_subclass(&EvOfflineSyncPreview::type_object(py))? { - Ok(DispatchEvent::OfflineSyncPreview) - } else if event_type.is_subclass(&EvOfflineSyncCompleted::type_object(py))? { - Ok(DispatchEvent::OfflineSyncCompleted) - } else if event_type.is_subclass(&EvDeviceListUpdate::type_object(py))? { - Ok(DispatchEvent::DeviceListUpdate) - } else if event_type.is_subclass(&EvBusinessStatusUpdate::type_object(py))? { - Ok(DispatchEvent::BusinessStatusUpdate) - } else if event_type.is_subclass(&EvStreamReplaced::type_object(py))? { - Ok(DispatchEvent::StreamReplaced) - } else if event_type.is_subclass(&EvTemporaryBan::type_object(py))? { - Ok(DispatchEvent::TemporaryBan) - } else if event_type.is_subclass(&EvConnectFailure::type_object(py))? { - Ok(DispatchEvent::ConnectFailure) - } else if event_type.is_subclass(&EvStreamError::type_object(py))? { - Ok(DispatchEvent::StreamError) - }else if event_type.is_subclass(&EvDisappearingModeChanged::type_object(py))? { - Ok(DispatchEvent::DisappearingModeChanged) - } else if event_type.is_subclass(&EvContactSyncRequested::type_object(py))? { - Ok(DispatchEvent::ContactSyncRequested) - } else if event_type.is_subclass(&EvContactUpdated::type_object(py))? { - Ok(DispatchEvent::ContactUpdated) - } else if event_type.is_subclass(&EvStarUpdate::type_object(py))? { - Ok(DispatchEvent::StarUpdate) - } else if event_type.is_subclass(&EvNewsletterLiveUpdate::type_object(py))? { - Ok(DispatchEvent::NewsletterLiveUpdate) - }else if event_type.is_subclass(&EvDeleteChatUpdate::type_object(py))? { - Ok(DispatchEvent::DeleteChatUpdate) - } else if event_type.is_subclass(&EvDeleteMessageForMeUpdate::type_object(py))? { - Ok(DispatchEvent::DeleteMessageForMeUpdate) - } else { - Err(PyErr::new::("Unsupported event type")) - } -} - -#[pymethods] -impl Dispatcher { - #[new] - fn new() -> Self { - Self::empty() - } - - /// Selects an event class and returns a callable decorator. - /// - /// Python usage: - /// @dispatcher.on(Connected) - /// def handler(client, event): - /// ... - fn on(slf: Py, py: Python, event_type: &Bound) -> PyResult> { - let event = dispatch_event_from_type(py, event_type)?; - info!(event = dispatch_event_name(event), "selected event for next callback registration"); - { - let mut this = slf.borrow_mut(py); - this.pending_event = Some(event); + impl Dispatcher { + fn cloned_handlers(py: Python<'_>, handlers: &[Py]) -> Vec> { + handlers.iter().map(|h| h.clone_ref(py)).collect() + } + + pub fn empty() -> Self { + Self { + $( $field: Vec::new(), )+ + pending_event: None, + } + } + + $( + pub fn $handler_fn(&self, py: Python<'_>) -> Vec> { + let handlers = Self::cloned_handlers(py, &self.$field); + debug!(handlers = handlers.len(), concat!("collected ", $log_label, " handlers")); + handlers + } + )+ + + /// Alias kept for backward compatibility. + pub fn logout_handlers(&self, py: Python<'_>) -> Vec> { + self.logged_out_handlers(py) + } + + fn handlers_for_event(&self, event: DispatchEvent) -> &Vec> { + match event { + $( DispatchEvent::$variant => &self.$field, )+ + } + } } - Ok(slf.into_any()) - } - - /// Registers the function produced by the decorator call and returns it. - fn __call__(&mut self, py: Python, func: Py) -> PyResult> { - let event = self - .pending_event - .take() - .ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("on(event_type) must be called before registering callback"))?; - match event { - DispatchEvent::Connected => self.connected.push(func.clone_ref(py)), - DispatchEvent::Disconnected => self.disconnected.push(func.clone_ref(py)), - DispatchEvent::LoggedOut => self.logged_out.push(func.clone_ref(py)), - DispatchEvent::PairSuccess => self.pair_success.push(func.clone_ref(py)), - DispatchEvent::PairError => self.pair_error.push(func.clone_ref(py)), - DispatchEvent::PairingQrCode => self.pairing_qr_code.push(func.clone_ref(py)), - DispatchEvent::PairingCode => self.pairing_code.push(func.clone_ref(py)), - DispatchEvent::QrScannedWithoutMultidevice => self.qr_scanned_without_multidevice.push(func.clone_ref(py)), - DispatchEvent::ClientOutDated => self.client_outdated.push(func.clone_ref(py)), - DispatchEvent::Message => self.message.push(func.clone_ref(py)), - DispatchEvent::Receipt => self.receipt.push(func.clone_ref(py)), - DispatchEvent::UndecryptableMessage => self.undecryptable_message.push(func.clone_ref(py)), - DispatchEvent::Notification => self.notification.push(func.clone_ref(py)), - DispatchEvent::ChatPresence => self.chat_presence.push(func.clone_ref(py)), - DispatchEvent::Presence => self.presence.push(func.clone_ref(py)), - DispatchEvent::PictureUpdate => self.picture_update.push(func.clone_ref(py)), - DispatchEvent::UserAboutUpdate => self.user_about_update.push(func.clone_ref(py)), - DispatchEvent::JoinedGroup => self.joined_group.push(func.clone_ref(py)), - DispatchEvent::GroupInfoUpdate => self.group_info_update.push(func.clone_ref(py)), - DispatchEvent::ContactUpdate => self.contact_update.push(func.clone_ref(py)), - DispatchEvent::PushNameUpdate => self.push_name_update.push(func.clone_ref(py)), - DispatchEvent::SelfPushNameUpdated => self.self_push_name_update.push(func.clone_ref(py)), - DispatchEvent::PinUpdate => self.pin_update.push(func.clone_ref(py)), - DispatchEvent::MuteUpdate => self.mute_update.push(func.clone_ref(py)), - DispatchEvent::ArchiveUpdate => self.archive_update.push(func.clone_ref(py)), - DispatchEvent::MarkChatAsReadUpdate => self.mark_chat_as_read_update.push(func.clone_ref(py)), - DispatchEvent::HistorySync => self.history_sync.push(func.clone_ref(py)), - DispatchEvent::OfflineSyncPreview => self.offline_sync_preview.push(func.clone_ref(py)), - DispatchEvent::OfflineSyncCompleted => self.offline_sync_completed.push(func.clone_ref(py)), - DispatchEvent::DeviceListUpdate => self.device_list_update.push(func.clone_ref(py)), - DispatchEvent::BusinessStatusUpdate => self.business_status_update.push(func.clone_ref(py)), - DispatchEvent::StreamReplaced => self.stream_replaced.push(func.clone_ref(py)), - DispatchEvent::TemporaryBan => self.temporary_ban.push(func.clone_ref(py)), - DispatchEvent::ConnectFailure => self.connect_failure.push(func.clone_ref(py)), - DispatchEvent::StreamError => self.stream_error.push(func.clone_ref(py)), - DispatchEvent::DisappearingModeChanged => self.disappearing_mode_changed.push(func.clone_ref(py)), - DispatchEvent::ContactSyncRequested => self.contact_sync_requested.push(func.clone_ref(py)), - DispatchEvent::ContactUpdated => self.contact_updated.push(func.clone_ref(py)), - DispatchEvent::StarUpdate => self.star_update.push(func.clone_ref(py)), - DispatchEvent::NewsletterLiveUpdate => self.newsletter_live_update.push(func.clone_ref(py)), - DispatchEvent::DeleteChatUpdate => self.delete_chat_update.push(func.clone_ref(py)), - DispatchEvent::DeleteMessageForMeUpdate => self.delete_message_for_me_update.push(func.clone_ref(py)), + fn dispatch_event_name(event: DispatchEvent) -> &'static str { + match event { + $( DispatchEvent::$variant => $name, )+ + } } - let total_handlers = self.handlers_for_event(event).len(); - info!(event = dispatch_event_name(event), handlers = total_handlers, "registered Python callback"); + fn dispatch_event_from_type(py: Python, event_type: &Bound) -> PyResult { + let event_type = event_type.cast::()?; + $( + if event_type.is_subclass(&<$ev_type>::type_object(py))? { + return Ok(DispatchEvent::$variant); + } + )+ + Err(PyErr::new::("Unsupported event type")) + } - Ok(func) - } + #[pymethods] + impl Dispatcher { + #[new] + fn new() -> Self { + Self::empty() + } + + /// Returns a callable decorator for the given event class. + /// + /// ```python + /// @dispatcher.on(EvMessage) + /// async def handler(client, event): ... + /// ``` + fn on(slf: Py, py: Python, event_type: &Bound) -> PyResult> { + let event = dispatch_event_from_type(py, event_type)?; + info!(event = dispatch_event_name(event), "selected event for next callback registration"); + { + let mut this = slf.borrow_mut(py); + this.pending_event = Some(event); + } + Ok(slf.into_any()) + } + + /// Registers the decorated function and returns it unchanged. + fn __call__(&mut self, py: Python, func: Py) -> PyResult> { + let event = self + .pending_event + .take() + .ok_or_else(|| { + pyo3::exceptions::PyRuntimeError::new_err( + "on(event_type) must be called before registering callback", + ) + })?; + + match event { + $( DispatchEvent::$variant => self.$field.push(func.clone_ref(py)), )+ + } + + let total_handlers = self.handlers_for_event(event).len(); + info!(event = dispatch_event_name(event), handlers = total_handlers, "registered Python callback"); + + Ok(func) + } + } + }; } -impl Dispatcher { - pub fn contact_sync_requested_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.contact_sync_requested); - debug!(handlers = handlers.len(), "collected contact sync requested handlers"); - handlers - } - pub fn delete_message_for_me_update_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.delete_message_for_me_update); - debug!(handlers = handlers.len(), "collected delete message for me update handlers"); - handlers - } - pub fn contact_updated_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.contact_updated); - debug!(handlers = handlers.len(), "collected contact updated handlers"); - handlers - } - - pub fn star_update_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.star_update); - debug!(handlers = handlers.len(), "collected star update handlers"); - handlers - } - - pub fn delete_chat_update_handlers(&self, py: Python<'_>) -> Vec> { - let handlers = Self::cloned_handlers(py, &self.delete_chat_update); - debug!(handlers = handlers.len(), "collected delete chat update handlers"); - handlers - } - -} \ No newline at end of file +// ── Single source of truth for all dispatcher events ───────────────────── +// field, EnumVariant, "wire_name", EvPyClass, "log label", handler_method +define_dispatcher! { + connected, Connected, "connected", EvConnected, "connected", connected_handlers; + disconnected, Disconnected, "disconnected", EvDisconnected, "disconnected", disconnected_handlers; + logged_out, LoggedOut, "logged_out", EvLoggedOut, "logged out", logged_out_handlers; + pair_success, PairSuccess, "pair_success", EvPairSuccess, "pair success", pair_success_handlers; + pair_error, PairError, "pair_error", EvPairError, "pair error", pair_error_handlers; + pairing_qr_code, PairingQrCode, "pairing_qr_code", EvPairingQrCode, "pairing QR", pairing_qr_handlers; + pairing_code, PairingCode, "pairing_code", EvPairingCode, "pairing code", pairing_code_handlers; + qr_scanned_without_multidevice, QrScannedWithoutMultidevice, "qr_scanned_without_multidevice", EvQrScannedWithoutMultidevice, "QR scanned without multidevice", qr_scanned_without_multidevice_handlers; + client_outdated, ClientOutDated, "client_outdated", EvClientOutDated, "client outdated", client_outdated_handlers; + message, Message, "message", EvMessage, "message", message_handlers; + receipt, Receipt, "receipt", EvReceipt, "receipt", receipt_handlers; + undecryptable_message, UndecryptableMessage, "undecryptable_message", EvUndecryptableMessage, "undecryptable message", undecryptable_message_handlers; + notification, Notification, "notification", EvNotification, "notification", notification_handlers; + chat_presence, ChatPresence, "chat_presence", EvChatPresence, "chat presence", chat_presence_handlers; + presence, Presence, "presence", EvPresence, "presence", presence_handlers; + picture_update, PictureUpdate, "picture_update", EvPictureUpdate, "picture update", picture_update_handlers; + user_about_update, UserAboutUpdate, "user_about_update", EvUserAboutUpdate, "user about update", user_about_update_handlers; + joined_group, JoinedGroup, "joined_group", EvJoinedGroup, "joined group", joined_group_handlers; + group_info_update, GroupInfoUpdate, "group_info_update", EvGroupInfoUpdate, "group info update", group_info_update_handlers; + contact_update, ContactUpdate, "contact_update", EvContactUpdate, "contact update", contact_update_handlers; + push_name_update, PushNameUpdate, "push_name_update", EvPushNameUpdate, "push name update", push_name_update_handlers; + self_push_name_update, SelfPushNameUpdated, "self_push_name_updated", EvSelfPushNameUpdated, "self push name updated", self_push_name_updated_handlers; + pin_update, PinUpdate, "pin_update", EvPinUpdate, "pin update", pin_update_handlers; + mute_update, MuteUpdate, "mute_update", EvMuteUpdate, "mute update", mute_update_handlers; + archive_update, ArchiveUpdate, "archive_update", EvArchiveUpdate, "archive update", archive_update_handlers; + mark_chat_as_read_update, MarkChatAsReadUpdate, "mark_chat_as_read_update", EvMarkChatAsReadUpdate, "mark chat as read", mark_chat_as_read_update_handlers; + history_sync, HistorySync, "history_sync", EvHistorySync, "history sync", history_sync_handlers; + offline_sync_preview, OfflineSyncPreview, "offline_sync_preview", EvOfflineSyncPreview, "offline sync preview", offline_sync_preview_handlers; + offline_sync_completed, OfflineSyncCompleted, "offline_sync_completed", EvOfflineSyncCompleted, "offline sync completed", offline_sync_completed_handlers; + device_list_update, DeviceListUpdate, "device_list_update", EvDeviceListUpdate, "device list update", device_list_update_handlers; + business_status_update, BusinessStatusUpdate, "business_status_update", EvBusinessStatusUpdate, "business status update", business_status_update_handlers; + stream_replaced, StreamReplaced, "stream_replaced", EvStreamReplaced, "stream replaced", stream_replaced_handlers; + temporary_ban, TemporaryBan, "temporary_ban", EvTemporaryBan, "temporary ban", temporary_ban_handlers; + connect_failure, ConnectFailure, "connect_failure", EvConnectFailure, "connect failure", connect_failure_handlers; + stream_error, StreamError, "stream_error", EvStreamError, "stream error", stream_error_handlers; + contact_number_changed, ContactNumberChanged, "contact_number_changed", EvContactNumberChanged, "contact number changed", contact_number_changed_handlers; + disappearing_mode_changed, DisappearingModeChanged, "disappearing_mode_changed", EvDisappearingModeChanged, "disappearing mode changed", disappearing_mode_changed_handlers; + contact_sync_requested, ContactSyncRequested, "contact_sync_requested", EvContactSyncRequested, "contact sync requested", contact_sync_requested_handlers; + contact_updated, ContactUpdated, "contact_updated", EvContactUpdated, "contact updated", contact_updated_handlers; + star_update, StarUpdate, "star_update", EvStarUpdate, "star update", star_update_handlers; + newsletter_live_update, NewsletterLiveUpdate, "newsletter_live_update", EvNewsletterLiveUpdate, "newsletter live update", newsletter_live_update_handlers; + delete_chat_update, DeleteChatUpdate, "delete_chat_update", EvDeleteChatUpdate, "delete chat update", delete_chat_update_handlers; + delete_message_for_me_update, DeleteMessageForMeUpdate, "delete_message_for_me_update", EvDeleteMessageForMeUpdate, "delete message for me update", delete_message_for_me_update_handlers; +} diff --git a/src/events/types.rs b/src/events/types.rs index 7bf6df1..a4a96f1 100644 --- a/src/events/types.rs +++ b/src/events/types.rs @@ -553,7 +553,7 @@ impl LazyConversation { } else { let proto_type = get_lazy_conversation_proto_type(py)?; let proto = self.inner.get().ok_or_else(|| PyErr::new::("LazyConversation does not contain conversation data"))?; - let mut proto_bytes = Vec::new(); + let mut proto_bytes = Vec::with_capacity(proto.encoded_len()); proto.encode(&mut proto_bytes).map_err(|e| PyErr::new::(format!("Failed to encode conversation proto: {}", e)))?; let parsed_proto = from_string_to_python_proto(py, proto_type, &proto_bytes)?; self.parsed = Some(parsed_proto.clone_ref(py)); diff --git a/src/events/types/message_and_updates.rs b/src/events/types/message_and_updates.rs index c2f08a1..49afda6 100644 --- a/src/events/types/message_and_updates.rs +++ b/src/events/types/message_and_updates.rs @@ -270,7 +270,7 @@ impl MessageData { match self.message_proto.get() { Some(ref proto) => Ok(proto.clone_ref(py)), None => { - let mut buffer = Vec::new(); + let mut buffer = Vec::with_capacity(self.inner.as_ref().encoded_len()); self.inner .as_ref() .encode(&mut buffer) diff --git a/src/events/types/profile_sync.rs b/src/events/types/profile_sync.rs index e69c894..c29631c 100644 --- a/src/events/types/profile_sync.rs +++ b/src/events/types/profile_sync.rs @@ -158,7 +158,7 @@ impl MuteUpdateData { Ok(cached.clone_ref(py)) } else { let proto_type = get_proto_mute_action_proto_type(py)?; - let mut proto_bytes = Vec::new(); + let mut proto_bytes = Vec::with_capacity(self.action.encoded_len()); self.action.encode(&mut proto_bytes).map_err(|e| PyErr::new::(format!("Failed to encode MuteAction proto: {}", e)))?; let parsed_proto = from_string_to_python_proto(py, proto_type, &proto_bytes)?; self.action_cached.set(parsed_proto.clone_ref(py)).ok(); @@ -229,7 +229,7 @@ impl MarkChatAsReadUpdateData { Ok(cached.clone_ref(py)) } else { let proto_type = get_proto_mark_chat_as_read_action_proto_type(py)?; - let mut proto_bytes = Vec::new(); + let mut proto_bytes = Vec::with_capacity(self.action.encoded_len()); self.action.encode(&mut proto_bytes).map_err(|e| PyErr::new::(format!("Failed to encode MarkChatAsReadAction proto: {}", e)))?; let parsed_proto = from_string_to_python_proto(py, proto_type, &proto_bytes)?; self.action_cached.set(parsed_proto.clone_ref(py)).ok(); @@ -292,7 +292,7 @@ impl EvHistorySync { Ok(proto.clone_ref(py)) } else { let proto_type = get_proto_history_sync_proto_type(py)?; - let mut proto_bytes = Vec::new(); + let mut proto_bytes = Vec::with_capacity(self.inner.encoded_len()); self.inner.encode(&mut proto_bytes).map_err(|e| PyErr::new::(format!("Failed to encode HistorySync proto: {}", e)))?; let parsed_proto = from_string_to_python_proto(py, proto_type, &proto_bytes)?; self.proto_cached.set(parsed_proto.clone_ref(py)).ok(); diff --git a/src/exceptions/exceptions.rs b/src/exceptions/exceptions.rs index d921ed7..6ef3d50 100644 --- a/src/exceptions/exceptions.rs +++ b/src/exceptions/exceptions.rs @@ -1,21 +1,21 @@ use pyo3::{pyclass, pymethods}; use pyo3::exceptions::PyException; #[pyclass(extends=PyException)] -pub struct FailedBuildBot { +pub struct FailedBuildClient { message: String, } #[pymethods] -impl FailedBuildBot { +impl FailedBuildClient { #[new] fn new(message: String) -> Self { - FailedBuildBot { message } + FailedBuildClient { message } } fn __str__(&self) -> String { self.message.clone() } fn __repr__(&self) -> String { - format!("FailedBuildBot(message='{}')", self.message) + format!("FailedBuildClient(message='{}')", self.message) } } diff --git a/src/exceptions/mod.rs b/src/exceptions/mod.rs index 44c23cd..b723bae 100644 --- a/src/exceptions/mod.rs +++ b/src/exceptions/mod.rs @@ -2,7 +2,7 @@ mod exceptions; pub use exceptions::{ EventDispatchError, - FailedBuildBot, + FailedBuildClient, FailedToDecodeProto, PyPayloadBuildError, UnsupportedBackend, diff --git a/src/lib.rs b/src/lib.rs index 30d8a36..519d0ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -107,7 +107,7 @@ use self::events::types::{ EvPushNameUpdateData, }; use self::backend::{BackendBase, SqliteBackend}; -use self::exceptions::{EventDispatchError, FailedBuildBot, FailedToDecodeProto, PyPayloadBuildError, UnsupportedBackend, UnsupportedEventType}; +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}; @@ -310,7 +310,7 @@ fn _tryx(_py: &Bound) -> PyResult<()> { _py.add_submodule(&backend_module)?; let exceptions_module = PyModule::new(_py.py(), "exceptions")?; - exceptions_module.add_class::()?; + exceptions_module.add_class::()?; exceptions_module.add_class::()?; exceptions_module.add_class::()?; exceptions_module.add_class::()?; diff --git a/src/main.rs b/src/main.rs index 0fd1d04..cddaea4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ async fn main() -> Result<(), Box> { let backend = Arc::new(SqliteStore::new("whatsapp.db").await?); // Build the bot - let bot = Bot::builder() + let app = Bot::builder() .with_backend(backend) .with_transport_factory(TokioWebSocketTransportFactory::new()) .with_http_client(UreqHttpClient::new()) @@ -32,10 +32,10 @@ async fn main() -> Result<(), Box> { }) .with_runtime(TokioRuntime) .build(); - let mut bot2 = bot.await?; - let _g = bot2.client(); + let mut app = app.await?; + let _g = app.client(); // Start the bot - bot2.run().await?.await?; + app.run().await?.await?; Ok(()) } \ No newline at end of file diff --git a/src/types.rs b/src/types.rs index 5449914..be56539 100644 --- a/src/types.rs +++ b/src/types.rs @@ -111,10 +111,12 @@ impl MessageSource { } impl From for MessageSource { fn from(source: WhatsAppMessageSource) -> Self { + let chat = Arc::new(source.chat.clone()); + let sender = Arc::new(source.sender.clone()); MessageSource { - inner: Arc::new(source.clone()), - chat: Arc::new(source.chat.clone()), - sender: Arc::new(source.sender.clone()), + inner: Arc::new(source), + chat, + sender, } } } @@ -272,15 +274,15 @@ impl MessageInfo { fn verified_name(&self, py: Python<'_>) -> PyResult>> { match self.inner.verified_name { Some(ref name) => { - let mut buffer = Vec::new(); + let mut buffer = Vec::with_capacity(name.encoded_len()); name.encode(&mut buffer).map_err(|e| { PyErr::new::( format!("Failed to encode VerifiedNameCertificate: {}", e), ) })?; - let verified_proto = py.import("waproto.whatsapp_pb2")?; - let proto_type = verified_proto.getattr("attr_name")?; + let verified_proto = py.import("tryx.waproto.whatsapp_pb2")?; + let proto_type = verified_proto.getattr("VerifiedNameCertificate")?; let proto_instance = proto_type.call0()?; proto_instance.call_method1("ParseFromString", (PyBytes::new(py, &buffer),))?; Ok(Some(proto_instance.into())) diff --git a/test_a.py b/test_a.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9a82813 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +"""Pytest configuration for Tryx integration tests.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(scope="session") +def db_path(tmp_path_factory: pytest.TempPathFactory) -> str: + """Provide a temporary SQLite database path for tests.""" + return str(tmp_path_factory.mktemp("tryx") / "test.db") diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..2a9f40b --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,112 @@ +"""Unit tests for Tryx event and client initialization.""" + +from __future__ import annotations + +import tryx.events as events +from tryx.backend import SqliteBackend +from tryx.client import Tryx + + +class TestEventClassesExist: + """Verify that all expected event classes are importable.""" + + EVENT_NAMES = [ + "EvConnected", + "EvDisconnected", + "EvLoggedOut", + "EvPairSuccess", + "EvPairError", + "EvPairingQrCode", + "EvPairingCode", + "EvMessage", + "EvReceipt", + "EvUndecryptableMessage", + "EvNotification", + "EvChatPresence", + "EvPresence", + "EvPictureUpdate", + "EvUserAboutUpdate", + "EvJoinedGroup", + "EvGroupInfoUpdate", + "EvGroupUpdate", + "EvContactUpdate", + "EvPushNameUpdate", + "EvSelfPushNameUpdated", + "EvPinUpdate", + "EvMuteUpdate", + "EvArchiveUpdate", + "EvMarkChatAsReadUpdate", + "EvHistorySync", + "EvOfflineSyncPreview", + "EvOfflineSyncCompleted", + "EvDeviceListUpdate", + "EvBusinessStatusUpdate", + "EvStreamReplaced", + "EvTemporaryBan", + "EvConnectFailure", + "EvStreamError", + "EvContactNumberChanged", + "EvDisappearingModeChanged", + "EvContactSyncRequested", + "EvContactUpdated", + "EvStarUpdate", + "EvNewsletterLiveUpdate", + "EvDeleteChatUpdate", + "EvDeleteMessageForMeUpdate", + ] + + def test_all_event_classes_importable(self) -> None: + for name in self.EVENT_NAMES: + assert hasattr(events, name), f"Missing event class: {name}" + cls = getattr(events, name) + assert isinstance(cls, type), f"{name} is not a class" + + +class TestTryxInitialization: + """Verify that Tryx can be initialised with a SQLite backend.""" + + def test_basic_init(self, tmp_path: object) -> None: + import pathlib + + db = str(pathlib.Path(str(tmp_path)) / "test.db") + backend = SqliteBackend(db) + app = Tryx(backend) + client = app.get_client() + assert client is not None + assert not client.is_connected() + + def test_client_namespaces_exist(self, tmp_path: object) -> None: + import pathlib + + db = str(pathlib.Path(str(tmp_path)) / "test2.db") + backend = SqliteBackend(db) + app = Tryx(backend) + client = app.get_client() + for ns in [ + "contact", + "chat_actions", + "community", + "newsletter", + "groups", + "status", + "chatstate", + "blocking", + "polls", + "presence", + "privacy", + "profile", + ]: + assert hasattr(client, ns), f"Missing namespace: {ns}" + + def test_handler_registration(self, tmp_path: object) -> None: + import pathlib + + db = str(pathlib.Path(str(tmp_path)) / "test3.db") + backend = SqliteBackend(db) + app = Tryx(backend) + + @app.on(events.EvMessage) + async def handler(client: object, event: object) -> None: + pass + + assert handler is not None diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..6314096 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,30 @@ +"""Unit tests for Tryx type bindings.""" + +from __future__ import annotations + +from tryx.types import JID + + +class TestJID: + """Tests for the JID type.""" + + def test_construction(self) -> None: + jid = JID("1234567890", "s.whatsapp.net") + assert jid.user == "1234567890" + assert jid.server == "s.whatsapp.net" + + def test_repr(self) -> None: + jid = JID("1234567890", "s.whatsapp.net") + r = repr(jid) + assert "1234567890" in r + assert "s.whatsapp.net" in r + + def test_different_servers(self) -> None: + jid_user = JID("123", "s.whatsapp.net") + jid_group = JID("123456", "g.us") + assert jid_user.server == "s.whatsapp.net" + assert jid_group.server == "g.us" + + def test_empty_user(self) -> None: + jid = JID("", "s.whatsapp.net") + assert jid.user == "" diff --git a/uv.lock b/uv.lock index 6048ee8..9d541c4 100644 --- a/uv.lock +++ b/uv.lock @@ -267,6 +267,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "csscompressor" +version = "0.9.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/2a/8c3ac3d8bc94e6de8d7ae270bb5bc437b210bb9d6d9e46630c98f4abd20c/csscompressor-0.9.5.tar.gz", hash = "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05", size = 237808, upload-time = "2017-11-26T21:13:08.238Z" } + [[package]] name = "deprecated" version = "1.3.1" @@ -373,6 +379,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, ] +[[package]] +name = "htmlmin2" +version = "0.1.13" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/31/a76f4bfa885f93b8167cb4c85cf32b54d1f64384d0b897d45bc6d19b7b45/htmlmin2-0.1.13-py3-none-any.whl", hash = "sha256:75609f2a42e64f7ce57dbff28a39890363bde9e7e5885db633317efbdf8c79a2", size = 34486, upload-time = "2023-03-14T21:28:30.388Z" }, +] + [[package]] name = "identify" version = "2.6.15" @@ -505,6 +519,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsmin" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/73/e01e4c5e11ad0494f4407a3f623ad4d87714909f50b17a06ed121034ff6e/jsmin-3.0.1.tar.gz", hash = "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc", size = 13925, upload-time = "2022-01-16T20:35:59.13Z" } + [[package]] name = "librt" version = "0.8.1" @@ -987,6 +1007,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] +[[package]] +name = "mkdocs-minify-plugin" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "csscompressor" }, + { name = "htmlmin2" }, + { name = "jsmin" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/67/fe4b77e7a8ae7628392e28b14122588beaf6078b53eb91c7ed000fd158ac/mkdocs-minify-plugin-0.8.0.tar.gz", hash = "sha256:bc11b78b8120d79e817308e2b11539d790d21445eb63df831e393f76e52e753d", size = 8366, upload-time = "2024-01-29T16:11:32.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/cd/2e8d0d92421916e2ea4ff97f10a544a9bd5588eb747556701c983581df13/mkdocs_minify_plugin-0.8.0-py3-none-any.whl", hash = "sha256:5fba1a3f7bd9a2142c9954a6559a57e946587b21f133165ece30ea145c66aee6", size = 6723, upload-time = "2024-01-29T16:11:31.851Z" }, +] + [[package]] name = "mypy" version = "1.14.1" @@ -2246,6 +2281,7 @@ dev = [ docs = [ { name = "mkdocs" }, { name = "mkdocs-material" }, + { name = "mkdocs-minify-plugin" }, ] [package.metadata] @@ -2266,6 +2302,7 @@ dev = [ docs = [ { name = "mkdocs", specifier = ">=1.6.0" }, { name = "mkdocs-material", specifier = ">=9.6.0" }, + { name = "mkdocs-minify-plugin", specifier = ">=0.8.0" }, ] [[package]]