No description
  • Python 99.8%
  • Mako 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-24 13:49:22 +02:00
src/gna Intitial commit 2026-10-24 13:49:22 +02:00
tests Intitial commit 2026-10-24 13:49:22 +02:00
.env.example Intitial commit 2026-10-24 13:49:22 +02:00
.gitignore Intitial commit 2026-10-24 13:49:22 +02:00
gna-design.md Intitial commit 2026-10-24 13:49:22 +02:00
pyproject.toml Intitial commit 2026-10-24 13:49:22 +02:00
README.md Intitial commit 2026-10-24 13:49:22 +02:00
uv.lock Intitial commit 2026-10-24 13:49:22 +02:00

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.