Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/api/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/core-concepts/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions docs/core-concepts/event-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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...")
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
50 changes: 27 additions & 23 deletions docs/core-concepts/type-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```

Expand All @@ -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
Expand All @@ -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
```

Expand All @@ -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
```

Expand All @@ -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
```

---
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ""
Expand Down
10 changes: 8 additions & 2 deletions docs/getting-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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())
```
Expand Down Expand Up @@ -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
)
```

Expand Down Expand Up @@ -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 ""
Expand All @@ -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...")
Expand Down Expand Up @@ -229,6 +234,7 @@ import logging

logger = logging.getLogger(__name__)


@app.on(EvMessage)
async def safe_handler(client, event):
try:
Expand Down
Loading
Loading