diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 7428dad..e9a5dda 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -51,6 +51,13 @@ jobs: python-version: "3.12" - uses: astral-sh/setup-uv@v5 - uses: dtolnay/rust-toolchain@stable + - name: Rust cache (tryx root) + uses: Swatinem/rust-cache@v2 + + - name: Rust cache (whatsapp-rust workspace) + uses: Swatinem/rust-cache@v2 + with: + workspaces: "libs/whatsapp-rust -> target" - name: Install dev dependencies run: uv sync --group dev --no-install-project - name: Build native extension diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..e4ee365 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,138 @@ +name: PR Check + +on: + pull_request: + branches: [master, main, dev] + +permissions: + contents: read + +jobs: + quality: + name: Quality Checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: astral-sh/setup-uv@v5 + + - uses: dtolnay/rust-toolchain@stable + + - name: Install dev dependencies + run: uv sync --group dev --no-install-project + + - name: Ruff lint + run: uv run --no-project ruff check . + + - name: Ruff format check + run: uv run --no-project ruff format --check . + + tests: + name: Python Tests + needs: quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: astral-sh/setup-uv@v5 + + - uses: dtolnay/rust-toolchain@stable + + - name: Rust cache (tryx root) + uses: Swatinem/rust-cache@v2 + + - name: Rust cache (whatsapp-rust workspace) + uses: Swatinem/rust-cache@v2 + with: + workspaces: "libs/whatsapp-rust -> target" + + - name: Install dev dependencies + run: uv sync --group dev --no-install-project + + - name: Build native extension + run: uv run maturin develop + + - name: Run pytest + run: uv run pytest -q + + stubs: + name: Type Stubs Check + needs: quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: astral-sh/setup-uv@v5 + + - uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: uv sync --group dev --group docs --no-install-project + + - name: Validate .pyi stubs with pyright + run: | + uv run --no-project pyright python/tryx/client.pyi \ + python/tryx/events.pyi \ + python/tryx/types.pyi \ + python/tryx/wacore.pyi \ + python/tryx/helpers.pyi \ + python/tryx/backend.pyi \ + python/tryx/exceptions.pyi \ + python/tryx/__init__.pyi \ + python/tryx/tryx.pyi \ + python/tryx/media.pyi + + - name: Validate .pyi stubs with mypy + run: | + uv run --no-project mypy python/tryx/client.pyi \ + python/tryx/events.pyi \ + python/tryx/types.pyi \ + python/tryx/wacore.pyi \ + python/tryx/helpers.pyi \ + python/tryx/backend.pyi \ + python/tryx/exceptions.pyi \ + python/tryx/__init__.pyi \ + python/tryx/tryx.pyi \ + python/tryx/media.pyi \ + --ignore-missing-imports + + docs: + name: Documentation Build + needs: quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: astral-sh/setup-uv@v5 + + - uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: uv sync --group dev --group docs --no-install-project + + - name: Build docs (validation only) + run: uv run --no-project zensical build -f mkdocs.yml -s diff --git a/SECURITY.md b/SECURITY.md index 121947c..651e382 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,7 @@ # Security Policy +> For security inquiries, please follow the disclosure process below. + ## Reporting a Vulnerability If you discover a security vulnerability in Tryx, please report it diff --git a/docs/api/backend.md b/docs/api/backend.md index 91a5645..3248988 100644 --- a/docs/api/backend.md +++ b/docs/api/backend.md @@ -47,6 +47,7 @@ exposing `lib_path` and `config_json` attributes satisfies this protocol. ```python import json + class PostgresStore: lib_path: str # path to compiled .so config_json: str diff --git a/docs/core-concepts/architecture.md b/docs/core-concepts/architecture.md index 8e262bc..4194aea 100644 --- a/docs/core-concepts/architecture.md +++ b/docs/core-concepts/architecture.md @@ -175,6 +175,7 @@ All WhatsApp interactions flow through typed events: ```python from tryx.events import EvMessage + @app.on(EvMessage) async def handle_message(client, event: EvMessage): # event.sender, event.text, event.media, etc. diff --git a/docs/core-concepts/event-model.md b/docs/core-concepts/event-model.md index 9b2ea53..f18709e 100644 --- a/docs/core-concepts/event-model.md +++ b/docs/core-concepts/event-model.md @@ -26,14 +26,17 @@ from tryx.events import EvMessage, EvConnected, EvDisconnected app = Tryx(store) + @app.on(EvConnected) async def on_connected(client): print("Connected to WhatsApp!") + @app.on(EvMessage) async def on_message(client, event): print(f"Message from {event.sender}: {event.text}") + @app.on(EvDisconnected) async def on_disconnected(client): print("Disconnected, will reconnect...") @@ -44,8 +47,7 @@ async def on_disconnected(client): Every handler receives two arguments: ```python -async def handler(client: TryxClient, event: EventType) -> None: - ... +async def handler(client: TryxClient, event: EventType) -> None: ... ``` - `client` — The `TryxClient` instance, ready to send messages and query state @@ -61,6 +63,7 @@ called for each event: async def log_message(client, event): logger.info(f"Received: {event.message_id}") + @app.on(EvMessage) async def process_message(client, event): if event.text: @@ -211,6 +214,7 @@ Build idempotent handlers using message identifiers: ```python processed = set() + @app.on(EvMessage) async def idempotent_handler(client, event): message_id = event.data.message_info.id diff --git a/docs/core-concepts/type-system.md b/docs/core-concepts/type-system.md index 5c8749b..cd37b2d 100644 --- a/docs/core-concepts/type-system.md +++ b/docs/core-concepts/type-system.md @@ -57,6 +57,7 @@ Contains identity, routing, and attribute information: ```python from tryx.events import EvMessage + @app.on(EvMessage) async def handle(client, event): info = event.data.message_info @@ -66,12 +67,12 @@ async def handle(client, event): timestamp = info.timestamp # Routing - sender = info.source.sender # JID - chat = info.source.chat # JID + sender = info.source.sender # JID + chat = info.source.chat # JID participant = info.source.participant # JID | None # Attributes - msg_type = info.message_type # "text", "image", etc. + msg_type = info.message_type # "text", "image", etc. is_from_me = info.is_from_me ``` @@ -82,9 +83,9 @@ Returned by all send methods: ```python result = await client.send_text(jid, "Hello") -result.message_id # str: unique message ID -result.timestamp # int: server timestamp -result.key # MessageKey: message key for tracking +result.message_id # str: unique message ID +result.timestamp # int: server timestamp +result.key # MessageKey: message key for tracking ``` ### MediaReuploadResult — Media Retry Output @@ -98,7 +99,7 @@ result = await client.request_media_reupload( media_key=b"...", ) -result.url # str: re-uploaded media URL +result.url # str: re-uploaded media URL result.direct_path # str: direct download path ``` @@ -109,9 +110,9 @@ Returned by media upload methods: ```python upload = await client.upload_photo(photo_bytes, jid) -upload.url # str: media URL +upload.url # str: media URL upload.direct_path # str: direct path -upload.media_key # bytes: encryption key +upload.media_key # bytes: encryption key upload.file_length # int: file size ``` @@ -120,10 +121,10 @@ upload.file_length # int: file size ```python picture = await client.contact.get_profile_picture(jid, preview=False) -picture.url # str: image URL -picture.direct_path # str: direct download path -picture.file_length # int: file size -picture.mimetype # str: image MIME type +picture.url # str: image URL +picture.direct_path # str: direct download path +picture.file_length # int: file size +picture.mimetype # str: image MIME type ``` --- @@ -134,15 +135,16 @@ Every event class has a defined payload contract: ```python from tryx.events import ( - EvMessage, # Incoming message - EvConnected, # WebSocket connected - EvDisconnected, # WebSocket lost - EvLoggedOut, # Session invalidated - EvPresence, # User typing/recording - EvGroupUpdate, # Group metadata changed - EvReceipt, # Delivery/read receipt + EvMessage, # Incoming message + EvConnected, # WebSocket connected + EvDisconnected, # WebSocket lost + EvLoggedOut, # Session invalidated + EvPresence, # User typing/recording + EvGroupUpdate, # Group metadata changed + EvReceipt, # Delivery/read receipt ) + @app.on(EvMessage) async def handle(client, event: EvMessage): # event.sender: JID @@ -195,9 +197,9 @@ StatusPrivacySetting.DenyList ```python from tryx.types import ChatStateType -ChatStateType.Composing # User is typing -ChatStateType.Recording # User is recording audio -ChatStateType.Paused # User stopped typing +ChatStateType.Composing # User is typing +ChatStateType.Recording # User is recording audio +ChatStateType.Paused # User stopped typing ``` ### Presence @@ -262,10 +264,12 @@ Keep type boundaries clean between layers: from tryx.events import EvMessage from tryx.types import JID + # Input boundary: accept typed events def extract_sender(event: EvMessage) -> JID: return event.data.message_info.source.sender + # Output boundary: return typed results async def forward_message(client, event: EvMessage, target: JID) -> SendResult: text = event.data.text or "" diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 38b64f9..374aa80 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -19,6 +19,7 @@ backend = SqliteStore("whatsapp.db") # Initialize the Tryx runtime app = Tryx(backend) + # Register an event handler @app.on(EvMessage) async def on_message(client, event): @@ -28,6 +29,7 @@ async def on_message(client, event): if text.lower() == "ping": await client.send_text(chat, "pong") + # Start the bot asyncio.run(app.run()) ``` @@ -113,8 +115,8 @@ with open("voice.ogg", "rb") as f: await client.send_audio( to=jid, audio_data=audio_data, - ptt=True, # Push-to-talk (voice note) - seconds=15, # Duration hint + ptt=True, # Push-to-talk (voice note) + seconds=15, # Duration hint ) ``` @@ -163,10 +165,12 @@ await client.send_sticker(to=jid, sticker_data=sticker_data) ```python from tryx.events import EvMessage, EvConnected, EvDisconnected + @app.on(EvConnected) async def on_connected(client): print("Connected to WhatsApp!") + @app.on(EvMessage) async def on_message(client, event): text = event.data.get_text() or "" @@ -182,6 +186,7 @@ async def on_message(client, event): names = [m.subject for m in groups.values()] await client.send_text(chat, f"Groups: {', '.join(names)}") + @app.on(EvDisconnected) async def on_disconnected(client): print("Disconnected, will reconnect...") @@ -229,6 +234,7 @@ import logging logger = logging.getLogger(__name__) + @app.on(EvMessage) async def safe_handler(client, event): try: diff --git a/docs/index.md b/docs/index.md index d634ed0..997a3ae 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,6 +49,7 @@ from tryx.backend import SqliteStore app = Tryx(SqliteStore("whatsapp.db")) client = app.get_client() + @app.on(EvMessage) async def on_message(client, event): text = event.data.get_text() @@ -56,6 +57,7 @@ async def on_message(client, event): if text: await client.send_text(chat, f"Echo: {text}") + app.run_blocking() ``` diff --git a/docs/operations/reliability.md b/docs/operations/reliability.md index 0b94904..45759b6 100644 --- a/docs/operations/reliability.md +++ b/docs/operations/reliability.md @@ -88,7 +88,7 @@ async def retry_with_backoff( last_exc = exc if attempt == max_attempts - 1: break - delay = min(base_delay * (2 ** attempt), max_delay) + delay = min(base_delay * (2**attempt), max_delay) if jitter: delay *= random.uniform(0.5, 1.0) await asyncio.sleep(delay) diff --git a/docs/reference/error-handling.md b/docs/reference/error-handling.md index db56b58..c57fafa 100644 --- a/docs/reference/error-handling.md +++ b/docs/reference/error-handling.md @@ -98,7 +98,9 @@ Persist context before recovery attempts: ```python # Store poll metadata first -poll_id, secret = await client.polls.create(to=chat_jid, name="Q", options=["A", "B"], selectable_count=1) +poll_id, secret = await client.polls.create( + to=chat_jid, name="Q", options=["A", "B"], selectable_count=1 +) # Now if vote fails, we have the poll_id and secret for recovery try: diff --git a/docs/tutorials/command-bot.md b/docs/tutorials/command-bot.md index f9b5df9..208ed90 100644 --- a/docs/tutorials/command-bot.md +++ b/docs/tutorials/command-bot.md @@ -234,12 +234,12 @@ async def cmd_ban(client, event, args): ```python COMMANDS = { "ping": cmd_ping, - "p": cmd_ping, # alias + "p": cmd_ping, # alias "echo": cmd_echo, - "e": cmd_echo, # alias + "e": cmd_echo, # alias "help": cmd_help, - "h": cmd_help, # alias - "?": cmd_help, # alias + "h": cmd_help, # alias + "?": cmd_help, # alias } ``` diff --git a/examples/basic_bot.py b/examples/basic_bot.py index 8cb7b4b..d960623 100644 --- a/examples/basic_bot.py +++ b/examples/basic_bot.py @@ -50,7 +50,9 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: sender_jid = source.sender text = (data.get_text() or "").strip() - print(f"[message] from={jid_to_text(sender_jid)} chat={jid_to_text(chat_jid)} text={text!r}") + sender = jid_to_text(sender_jid) + chat = jid_to_text(chat_jid) + print(f"[message] from={sender} chat={chat} text={text!r}") if not text: return @@ -83,7 +85,7 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: # ── /info ──────────────────────────────────────────────────────────── elif cmd == "info": info_lines = [ - f"*Your Info*", + "*Your Info*", f"• JID: {jid_to_text(sender_jid)}", f"• Chat: {jid_to_text(chat_jid)}", f"• Push name: {info.push_name or '(none)'}", @@ -93,6 +95,7 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: # ── Entry point ────────────────────────────────────────────────────────────── + async def main() -> None: print(f"Starting basic bot with DB: {DB_PATH}") await app.run() diff --git a/examples/group_bot.py b/examples/group_bot.py index 5448162..23c4a73 100644 --- a/examples/group_bot.py +++ b/examples/group_bot.py @@ -53,7 +53,9 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: sender_jid = source.sender text = (data.get_text() or "").strip() - print(f"[message] from={jid_to_text(sender_jid)} chat={jid_to_text(chat_jid)} text={text!r}") + sender = jid_to_text(sender_jid) + chat = jid_to_text(chat_jid) + print(f"[message] from={sender} chat={chat} text={text!r}") if not text or not source.is_group: return @@ -66,7 +68,7 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: try: metadata = await client.groups.get_metadata(chat_jid) lines = [ - f"*Group Info*", + "*Group Info*", f"• Name: {metadata.subject}", f"• Members: {metadata.size or len(metadata.participants)}", f"• Locked: {'Yes' if metadata.is_locked else 'No'}", @@ -114,15 +116,18 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: # ── /ephemeral N ───────────────────────────────────────────────────── elif cmd == "ephemeral": if len(cmd_parts) < 2: - await client.send_text(chat_jid, "Usage: /ephemeral \n0 = off", quoted=event) + msg = "Usage: /ephemeral \n0 = off" + await client.send_text(chat_jid, msg, quoted=event) return try: seconds = int(cmd_parts[1]) await client.groups.set_ephemeral(chat_jid, seconds) if seconds == 0: - await client.send_text(chat_jid, "Disabling disappearing messages", quoted=event) + msg = "Disabling disappearing messages" + await client.send_text(chat_jid, msg, quoted=event) else: - await client.send_text(chat_jid, f"Disappearing messages set to {seconds}s", quoted=event) + msg = f"Disappearing messages set to {seconds}s" + await client.send_text(chat_jid, msg, quoted=event) except ValueError: await client.send_text(chat_jid, "Invalid number", quoted=event) @@ -158,6 +163,7 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: # ── Entry point ────────────────────────────────────────────────────────────── + async def main() -> None: print(f"Starting group bot with DB: {DB_PATH}") await app.run() diff --git a/examples/media_bot.py b/examples/media_bot.py index 6c8dd13..8759d79 100644 --- a/examples/media_bot.py +++ b/examples/media_bot.py @@ -84,7 +84,9 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: sender_jid = source.sender text = (data.get_text() or "").strip().lower() - print(f"[message] from={jid_to_text(sender_jid)} chat={jid_to_text(chat_jid)} text={text!r}") + sender = jid_to_text(sender_jid) + chat = jid_to_text(chat_jid) + print(f"[message] from={sender} chat={chat} text={text!r}") if not text: return @@ -99,7 +101,8 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: ) print(f"[photo] sent: {result.message_id}") except Exception as exc: - await client.send_text(chat_jid, f"Failed to send photo: {exc}", quoted=event) + msg = f"Failed to send photo: {exc}" + await client.send_text(chat_jid, msg, quoted=event) # ── /document ──────────────────────────────────────────────────────── elif text == "document": @@ -107,11 +110,15 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: await client.chatstate.send_composing(chat_jid) doc_data = await download_bytes(SAMPLE_MEDIA["document"]["url"]) result = await client.send_document( - chat_jid, doc_data, file_name="sample.pdf", caption="Sample PDF document" + chat_jid, + doc_data, + file_name="sample.pdf", + caption="Sample PDF document", ) print(f"[document] sent: {result.message_id}") except Exception as exc: - await client.send_text(chat_jid, f"Failed to send document: {exc}", quoted=event) + msg = f"Failed to send document: {exc}" + await client.send_text(chat_jid, msg, quoted=event) # ── /audio ─────────────────────────────────────────────────────────── elif text == "audio": @@ -121,7 +128,8 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: result = await client.send_audio(chat_jid, audio_data) print(f"[audio] sent: {result.message_id}") except Exception as exc: - await client.send_text(chat_jid, f"Failed to send audio: {exc}", quoted=event) + msg = f"Failed to send audio: {exc}" + await client.send_text(chat_jid, msg, quoted=event) # ── /video ─────────────────────────────────────────────────────────── elif text == "video": @@ -133,7 +141,8 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: ) print(f"[video] sent: {result.message_id}") except Exception as exc: - await client.send_text(chat_jid, f"Failed to send video: {exc}", quoted=event) + msg = f"Failed to send video: {exc}" + await client.send_text(chat_jid, msg, quoted=event) # ── /help ──────────────────────────────────────────────────────────── elif text == "help": @@ -150,6 +159,7 @@ async def on_message(client: TryxClient, event: EvMessage) -> None: # ── Entry point ────────────────────────────────────────────────────────────── + async def main() -> None: print(f"Starting media bot with DB: {DB_PATH}") await app.run() diff --git a/tests/test_media_contracts.py b/tests/test_media_contracts.py index b5291f4..cc97b66 100644 --- a/tests/test_media_contracts.py +++ b/tests/test_media_contracts.py @@ -3,12 +3,12 @@ import pytest from tryx.media import ( - AudioPlayer, AudioSink, AudioSource, VideoFrame, validate_audio_frame, ) +from tryx.media import _PythonPrototypeAudioPlayer as AudioPlayer async def _frames(*frames: bytes):