Metadata-Version: 2.5
Name: wawm
Version: 0.1.0
Summary: Developer-first Python SDK for WhatsApp: send, receive and manage groups from a linked number.
Project-URL: Homepage, https://github.com/SAFE-AI-Global/wawm
Project-URL: Documentation, https://github.com/SAFE-AI-Global/wawm/tree/main/docs
Project-URL: Source, https://github.com/SAFE-AI-Global/wawm
Project-URL: Issues, https://github.com/SAFE-AI-Global/wawm/issues
Project-URL: Changelog, https://github.com/SAFE-AI-Global/wawm/blob/main/CHANGELOG.md
Author-email: Safe AI Global <tech@safeai.global>
License: MIT
License-File: LICENSE
Keywords: api,automation,baileys,bot,chat,groups,messaging,sdk,whatsapp
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Chat
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.27
Provides-Extra: dev
Requires-Dist: cryptography>=42; extra == 'dev'
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest-cov>=5; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Requires-Dist: websockets<16,>=12; extra == 'dev'
Provides-Extra: events
Requires-Dist: websockets<16,>=12; extra == 'events'
Provides-Extra: webhooks
Requires-Dist: cryptography>=42; extra == 'webhooks'
Description-Content-Type: text/markdown

# wawm

A developer-first Python client for WhatsApp groups and messaging, driving a number you have
linked to your own WAWM deployment (the whatsapp-api-manager server).

Meta's official Cloud API has no group support. This one does, because it drives a real linked
device over the WhatsApp Web protocol.

```bash
pip install wawm           # add [events] for the live stream
```

```python
from wawm import WhatsApp

client = WhatsApp(api_key="wam_…", base_url="https://wa.example.com")

client.messages.send("447700900123", "Deploy finished ✅")

team = client.groups.find("Engineering")
client.group(team.id).send("standup in 5")
```

## Initialize once, use anywhere

If passing a client through every layer is not how your app is shaped, initialize once at
start-up and reach the same handle from anywhere:

```python
# main.py
import wawm
wawm.initialize()          # reads WAWM_API_KEY and WAWM_BASE_URL

# notifications.py — no plumbing, no imports of your own wiring
import wawm

def alert(text: str) -> None:
    wawm.messages.send("447700900123", text)
```

`wawm.initialize()` is idempotent: calling it twice returns the client you already have rather
than quietly opening a second connection pool. Pass `force=True` to replace it.

You never pass a number, either. An API key is bound to exactly one WhatsApp number for life, so
the SDK reads it off the key on first use. Nothing to configure, and nothing to get wrong.

## Grouped by what you are doing

```
client.session       is the number connected?
client.messages      send, read, edit, delete, react, pin, forward
client.chats         archive, pin, mute, mark read, typing indicators
client.groups        list, search, create, rename, members, invites, settings
client.contacts      search, validate numbers, block
client.profile       the account's own name, picture and privacy
client.communities   communities
client.channels      channels (broadcast)
client.activity      the durable trail of what happened
client.events        the live stream, over WebSocket
client.inbox         the durable message queue, each message read once
```

Type `client.groups.` and your editor lists everything a group can do. The package ships
`py.typed`, so the hints are real.

### Groups

```python
client.groups.list()                              # every group, from cache
client.groups.search("support")                   # name substring
client.groups.find("Engineering")                 # exactly one, or LookupError
client.groups.get(gid)                            # full, with participants

client.groups.create("Launch team", ["447700900123", "447700900124"])
client.groups.rename(gid, "Launch team (Q3)")
client.groups.set_description(gid, "Ship by Friday")
client.groups.settings(gid, who_can_post="admins", join_approval=True)

client.groups.invite(gid).url                     # https://chat.whatsapp.com/…
client.groups.revoke_invite(gid)
client.groups.leave(gid)
```

`find()` raises rather than guessing when several groups match. Picking the wrong one is how a
message reaches the wrong people, and that is not undoable.

### A group handle, when you are doing several things at once

```python
team = client.group("120363…@g.us")

team.send("standup in 5")
team.add(["447700900123"])
team.promote(["447700900123"])
team.settings(who_can_post="admins")
print(team.invite().url)
```

`client.chat(id)` gives the same thing for a one-to-one conversation.

### Partial success is never collapsed

WhatsApp answers membership changes **per person**, so adding five people can leave three in and
two out for different reasons. The result keeps all of it:

```python
result = client.groups.add(gid, ["447700900123", "447700900124"])

print(result)                     # "1 ok, 1 failed"
for entry in result.failed:
    print(entry.jid, entry.reason)   # "participant restricts who can add them to groups"

result.raise_for_failures()        # if partial success should be an error for you
```

Reporting `add()` as a single success or failure would be wrong, so the SDK will not let you.

### Receiving

There are two ways, and they are different guarantees rather than two flavours of one. Pick by
what happens while your process is *not* running.

**`client.inbox` — a durable queue, each message handled exactly once.** Use this for a worker.

```python
for message in client.inbox.listen():
    print(message.sender_name, message.text)
    if message.text == "ping":
        client.chat(message.chat_id).send("pong")
```

No cursor, and nothing to remember between runs: the server keeps your place. Every incoming
message is queued when it arrives and deleted only when you have it, so a backlog built up while
you were down is waiting on your next call and survives a server restart. Reading is destructive
and first-come-first-served, so two workers on one number split the traffic rather than both
seeing all of it. Needs only `messages:read` — no extras.

Reach for `batches()` when you want the counters as well as the messages:

```python
for batch in client.inbox.batches():
    if batch.dropped:      # expired (24h) or overflowed the queue before you read them
        backfill(client.messages.list(limit=100))
    if batch.gap:          # offsets that never reached you — a crash, or a second consumer
        alert(batch.gap)
    handle(batch.messages)
```

There is deliberately no "how many are unread": nothing can tell you what is queued without
taking it. `remaining` and `dropped` come back *with* a batch, after the fact.

**`client.events` — the live stream, everything the bus emits.** Use this to watch, not to
process.

```python
for event in client.events.listen(types=["group.participants"], since=last_seq):
    print(event.seq, event.type, event.data)
```

Every event carries a monotonic `seq`, and the stream resumes from the last one it saw after a
drop. Know that limit before you build a worker on it: the replay is a bounded in-memory ring on
the server that does not survive a restart, and the stream is a broadcast, so two listeners both
see everything and neither can tell the other it has handled one. It is the only way to see group
changes, receipts and connection state. Needs the `events` extra and `events:subscribe`.

### Sending media

```python
client.messages.send_media(chat, kind="image", url="https://example.com/chart.png",
                           caption="This week")
client.messages.send_media(chat, kind="document", file="report.pdf")
client.messages.send_poll(chat, "Ship today?", ["Yes", "No", "Needs review"])
client.messages.send_location(chat, 51.5308, -0.1238, name="King's Cross")
```

A URL is streamed by the server, so large files never pass through your process. URLs must be
publicly reachable — private and internal addresses are refused, deliberately.

### Before messaging a number you have not messaged before

```python
if client.contacts.exists("447700900123"):
    client.messages.send("447700900123", "hello")
```

Sending to numbers that are not on WhatsApp is one of the strongest signals used to ban an
account. This check is the cheap way to avoid it.

## Errors

Every failure raises a typed exception carrying the API's stable `code`. Branch on the class:

```python
from wawm import NotGroupAdmin, RateLimited, SessionNotConnected

try:
    client.groups.rename(gid, "New name")
except NotGroupAdmin:
    print("this number is not an admin of that group")   # retrying will not help
except SessionNotConnected:
    print("the device is offline; a person has to re-pair it")
except RateLimited as exc:
    time.sleep(exc.retry_after or 30)
```

| Exception | When |
| --- | --- |
| `AuthenticationError` | key missing, expired or revoked |
| `PermissionDenied` | the key lacks the permission — `.required`, `.granted` |
| `NotGroupAdmin` | WhatsApp refused; the number is not a group admin |
| `ValidationError` / `InvalidJID` | bad arguments; never retry as-is |
| `NotFound` / `MessageNotFound` / `SessionNotFound` | it does not exist |
| `SessionNotConnected` / `SessionLoggedOut` | the device is not usable |
| `RateLimited` | slow down — `.retry_after` |
| `UpstreamTimeout` / `ConnectionLost` | transient; retrying is reasonable |
| `UpstreamError` | WhatsApp said no; retrying will not change that |
| `MediaTooLarge` / `MediaFailed` / `NotSupported` | media and protocol limits |
| `TransportError` | the API could not be reached at all |

All descend from `WhatsAppError`, so one `except` catches everything.

**Reads are retried, writes are not.** A retried send double-posts, and a retried create leaves an
orphan group, so the SDK retries `GET` only — and only when the server marked the failure
retryable. This mirrors what the server does internally rather than second-guessing it.

## Permissions

A key carries an explicit permission list, and the SDK can tell you what it holds without a
round trip after the first call:

```python
client.can("messages:send")            # True / False
client.require("messages:send", "groups:manage")   # raise now, not mid-batch
print(client.describe())
```

```
wawm 0.1.0
  endpoint   https://wa.example.com
  number     whatsapp-biz-hp3y
  credential api-key "notifier"
  access     10 permissions
    client.chats        read, write
    client.groups       read
    client.messages     manage, read, send
    ...
```

`require()` at start-up is worth the two lines in a long-running service: better to refuse to boot
than to find out halfway through a batch that the key cannot send.

A key can never pair or unlink the device, read the pairing QR, manage API keys, or reach any
other number on the deployment — regardless of what it was granted.

## Configuration

| Argument | Environment | Default |
| --- | --- | --- |
| `api_key` | `WAWM_API_KEY` | — required |
| `base_url` | `WAWM_BASE_URL` | — required |
| `session` | `WAWM_SESSION` | discovered from the key |
| `timeout` | | 30s (media calls override) |
| `max_retries` | | 2, reads only |
| `allow_insecure_http` | | `False` — silences the clear-text warning |
| `trust_env` | | `True` — honour `HTTP_PROXY` and `SSL_CERT_FILE` |

```python
client = WhatsApp(timeout=60, max_retries=0)      # both from the environment
with WhatsApp() as client:                        # closes the pool on exit
    ...
```

## Security

The API key is the only secret this package holds, so it is treated like one:

- It travels in an `Authorization` header, never in a URL — including for the
  WebSocket, so it stays out of proxy and access logs.
- `repr()` of a client and every exception raised are asserted, in tests, not
  to contain it.
- Redirects are never followed; httpx would replay the header to whatever host
  a redirect names.
- TLS verification cannot be turned off. Plain `http` to a non-local host warns.
- A key with control characters is rejected before use — it would otherwise
  inject headers — and the error does not echo it back.
- Every path segment is percent-encoded, so an id cannot escape into another
  endpoint.
- Nothing is written to disk, nothing is logged, and no server response is ever
  evaluated or deserialized beyond `json.loads`.

One required dependency. Details and reporting in [SECURITY.md](SECURITY.md).

## Requirements

Python 3.10+, and a running whatsapp-api-manager deployment with a paired number. Create an API
key in the console under a number's **API keys** tab; the secret is shown once.

Only `httpx` is required. `websockets` comes with the `events` extra.

## A word of caution

This drives a real WhatsApp account through an unofficial protocol. Messages reach real people
instantly, group changes are visible to every member, and WhatsApp bans numbers that behave like
machines. The server rate-limits sends for that reason and the defaults are deliberately timid —
raise them knowing what you are trading.

## Documentation

| | |
|---|---|
| [docs/](docs/) | the map |
| [docs/production.md](docs/production.md) | deployment shapes, concurrency, delivery semantics, ban-risk controls, observability, a pre-flight checklist |
| [AGENTS.md](AGENTS.md) | the dense reference for AI agents — every signature, the hard rules, and what deliberately does not exist. Ships inside the installed package, readable offline via `importlib.resources` |
| [SECURITY.md](SECURITY.md) | threat model and reporting |
| [CHANGELOG.md](CHANGELOG.md) | what changed |

## Releasing

See [PUBLISHING.md](PUBLISHING.md).

## License

MIT
