- Python 99.8%
- Mako 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| src/gna | ||
| tests | ||
| .env.example | ||
| .gitignore | ||
| gna-design.md | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
Gná server
Self-hosted messaging hub for talking to bots and automated systems. See
gna-design.md for the design.
Running
uv sync
cp .env.example .env # then edit
uv run gna db upgrade
uv run gna serve # binds 127.0.0.1:8000; put Caddy/nginx with HTTPS in front
Run a single process: long-poll wake-ups and presence are in memory. The server refuses to start if the database schema is not at the latest migration.
Passkeys and universal links need a real domain with valid HTTPS (GNA_PUBLIC_URL).
Admin CLI
gna invite --name "Johan" # single-use, expiring account invite link
gna invites # open / used / expired / revoked
gna invite revoke inv_…
gna passkey-link --user johan # add a passkey / recover an existing account
gna conversation create --user johan --name Garage [--allow-internal-urls]
gna conversations --user johan
gna token create --user johan --conversation conv_… --name "garage script" [--send-only]
gna tokens --user johan --conversation conv_…
gna token revoke --user johan --conversation conv_… tok_…
--user takes an account ID or a display name (case-insensitive; must be unambiguous).
Integrations
Every request uses Authorization: Bearer gna_…. The conversation is implied by the token.
# Send (JSON)
curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"text": "Front door is unlocked. Lock?",
"buttons": [[{"label": "Lock", "data": "lock"}, {"label": "Ignore", "data": "ignore"}]]}' \
https://gna.example.com/messages
# Send an image and text in one call
curl -H "Authorization: Bearer $TOKEN" -F text="Motion at the door" -F image=@snap.jpg \
https://gna.example.com/messages
# Or let the server fetch it (internal addresses only if the conversation allows them)
curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"text": "Motion", "image_url": "http://192.168.1.50/snapshot.jpg"}' \
https://gna.example.com/messages
# Receive user messages and button presses (long-poll; pass `next` back as `after`)
curl -H "Authorization: Bearer $TOKEN" "https://gna.example.com/updates?after=0&timeout=30"
The OpenAPI spec is at /openapi.json (interactive docs at /docs).
API overview
| Endpoint | Who | Purpose |
|---|---|---|
POST /auth/invite |
anyone | Look up an invite/passkey link (name to greet with) |
POST /auth/register/options, /verify |
anyone | Passkey registration from a link |
POST /auth/login/options, /verify |
anyone | Passkey login (discoverable credentials) |
POST /auth/dev/login |
anyone | Only with DEV_AUTH=1 |
POST /auth/logout, GET/PATCH /me |
app / token | Session and account |
GET/POST /conversations, GET/PATCH /conversations/{id} |
app | Conversations, mute (muted_until) |
POST /conversations/{id}/read |
app | Read marker (unread counts) |
GET/POST/DELETE /conversations/{id}/tokens |
app | Integration tokens |
POST /conversations/{id}/messages |
app | Send as the user (reply_to, …) |
POST /conversations/{id}/messages/{mid}/press |
app | Press a callback button (409 once used) |
GET /messages?after=&conversation=&timeout= |
app / read token | The single stream: message and message_updated events |
POST /messages |
token | Send (JSON or multipart with image) |
GET /updates?after=&timeout= |
read token | User messages and callback events |
POST /attachments, GET /attachments/{id}[/thumb] |
app / token | Images |
POST /devices, DELETE /devices/{token} |
app | APNs device registration |
GET /.well-known/apple-app-site-association |
anyone | Associated domains |
Errors are {"error": "<code>", "detail": …}.
Notification categories for the app
The app registers these iOS categories. Action i presses button (row 0, col i).
notification_buttons |
Category | Actions |
|---|---|---|
| (none) | gna.message |
Reply (text input) |
ok |
gna.ok |
OK |
yes_no |
gna.yes_no |
Yes, No |
ok_cancel |
gna.ok_cancel |
OK, Cancel |
Push payloads carry message_id, conversation_id and, for images, attachment_url
(a thumbnail) for the notification service extension.
With DEV_AUTH=1 (and no GNA_PUSH_BACKEND set) each notification is printed to stdout
instead, once per notification and without needing a registered device. Mute and the
"app is connected" check still apply, so a notification only prints when a real push would
be sent.
Development
uv run pytest
uv run ruff check src tests && uv run black --check src tests && uv run isort --check src tests
After changing src/gna/store/sql/models.py, generate a migration against an up-to-date
database and review it:
uv run gna db upgrade && uv run gna db revision -m "add foo"
tests/test_infra.py::test_migrations_match_models fails if models and migrations diverge.