Argus is a small Python service for receiving external events and pushing notifications. The first feature is a KKTIX webhook receiver that tracks event registrations and sends daily summary reports to Discord. The codebase is structured so that future features (different sources / different purposes) can be added as self-contained packages that share common infrastructure.
Argus uses a vertical slice layout: each feature owns its full stack (HTTP routing, business logic, persistence access, presentation), and shared infrastructure lives at the package root.
┌──────────────── feature packages ────────────────┐
│ kktix/ (future: github_monitor/, ...) │
│ router · handler · scraper · report │
└─────────────────────┬────────────────────────────┘
│ uses
┌─────────────────────▼────────────────────────────┐
│ shared infrastructure │
│ discord (transport) channels (resolution) │
│ database config │
│ timeutil health │
└──────────────────────────────────────────────────┘
discord.pyis a thin transport:post(url, content, embeds). It does not understand any feature's data model.channels.pyresolves a channel name to a Discord webhook URL via env vars. Source-agnostic.database.pyowns SQLite connection and the schema for all features (centralized to keep migrations simple).- A new feature creates its own package and uses these shared modules; it does not modify other features.
| Layer | Choice |
|---|---|
| Language | Python 3.11+ |
| Web framework | FastAPI |
| ASGI server | uvicorn |
| Database | SQLite |
| Container image | python:3.12-slim-bookworm with sqlite3 CLI |
| Scheduler | APScheduler |
| HTTP client | httpx |
| HTML parsing | beautifulsoup4 |
| Package manager | uv (setuptools build backend) |
| Deploy target | Railway |
| Method | Path | Auth | Description | Section |
|---|---|---|---|---|
POST |
/webhook/kktix/{channel} |
x-kktix-secret header |
Receive KKTIX registration / cancellation webhook | KKTIX Webhook |
GET |
/health |
— | Liveness + DB readiness check | Health Check |
GET |
/dashboard/login |
— | Start Google OAuth flow | Dashboard |
GET |
/dashboard/oauth/callback |
— | OAuth redirect target | Dashboard |
GET |
/dashboard/logout |
— | Clear session, redirect to login | Dashboard |
GET |
/dashboard |
session (HTML) | Event list page | Dashboard |
GET |
/dashboard/events/{slug} |
session (HTML) | Per-event chart page | Dashboard |
GET |
/dashboard/webhook-logs |
session (HTML) | Webhook log viewer page | Dashboard |
GET |
/dashboard/api/events |
session (401) | JSON: event list | Dashboard |
GET |
/dashboard/api/events/{slug}/timeseries |
session (401) | JSON: per-event time series | Dashboard |
DELETE |
/dashboard/api/events/{slug} |
session (401) | Permanently delete event + its tickets | Dashboard |
GET |
/dashboard/api/webhook-logs |
session (401) | JSON: paginated webhook log entries | Dashboard |
DELETE |
/dashboard/api/webhook-logs/{id} |
session (401) | Delete a single webhook log entry | Dashboard |
DELETE |
/dashboard/api/webhook-logs |
session (401) | Clear all webhook log entries | Dashboard |
POST |
/dashboard/api/report/trigger |
session (401) | Run the daily Discord report immediately | Dashboard |
Auth column legend:
x-kktix-secret header— request must include header matchingWEBHOOK_SECRET(constant-time compared)session (HTML)— protected by signed session cookie; missing/invalid → 302 to/dashboard/loginsession (401)— same protection but JSON routes return 401 instead of redirecting
argus/
├── src/
│ └── argus/
│ ├── __about__.py # version
│ ├── __init__.py
│ │
│ │ # ── feature packages ──
│ ├── kktix/
│ │ ├── __init__.py
│ │ ├── router.py # POST /webhook/kktix/{channel}
│ │ ├── handler.py # webhook payload → DB writes
│ │ ├── scraper.py # KKTIX page fetch + event enrichment
│ │ └── report.py # daily report query + Discord payload + send
│ ├── dashboard/
│ │ ├── __init__.py
│ │ ├── router.py # /dashboard/* routes
│ │ ├── queries.py # time series queries
│ │ └── templates/
│ │ ├── _base.html # shared layout
│ │ ├── index.html # event list
│ │ └── event.html # per-event chart
│ │
│ │ # ── shared infrastructure ──
│ ├── auth.py # OAuth client + require_login dependency (reusable)
│ ├── discord.py # generic Discord webhook client (post-only)
│ ├── channels.py # channel name validation + URL resolution
│ ├── config.py # Settings + Secrets dataclasses
│ ├── database.py # SQLite init + connection (all features' tables)
│ ├── timeutil.py # UTC datetime helpers
│ │
│ │ # ── system layer ──
│ ├── health.py # GET /health
│ ├── main.py # FastAPI app + uvicorn entrypoint
│ └── scheduler.py # APScheduler; dispatches each feature's scheduled jobs
│
├── tests/
│ ├── conftest.py
│ ├── test_*.py # automated tests
│ └── test_discord_format_manual.py # opt-in test that sends real Discord webhooks
├── .env.example
├── pyproject.toml
├── railway.json
└── README.md
- Create a new package under
src/argus/<feature>/(e.g.github_monitor/). - Inside, organize as needed (
router.py,handler.py,notifier.py, …) — the feature owns its own structure. - Use shared modules:
discord.post(),channels.resolve_webhook_url(),get_conn(),config.settings. - If the feature exposes HTTP endpoints, register its router in
main.py. - If the feature has scheduled work, register its job in
scheduler.py. - If the feature needs new tables, add them to
database.py's_CREATE_TABLES_SQL.
| Variable | Required | Description |
|---|---|---|
WEBHOOK_SECRET |
Yes | KKTIX auth header value |
DISCORD_WEBHOOK_<CHANNEL> |
Yes (≥1) | Discord webhook URL per channel, e.g. DISCORD_WEBHOOK_SPRINT |
GOOGLE_OAUTH_CLIENT_ID |
Yes | Google OAuth 2.0 client ID |
GOOGLE_OAUTH_CLIENT_SECRET |
Yes | Google OAuth 2.0 client secret |
SESSION_SECRET |
Yes | Random ≥32-byte hex string for signing session cookies |
| Variable | Default | Description |
|---|---|---|
KKTIX_ORGANIZATION |
— | KKTIX organizer subdomain (e.g. example for example.kktix.cc) |
REPORT_HOUR |
9 |
Daily report hour |
REPORT_MINUTE |
0 |
Daily report minute |
REPORT_TIMEZONE |
Asia/Taipei |
Daily report timezone |
DB_PATH |
argus.db |
SQLite file path |
HEALTHCHECK_DB_TIMEOUT |
1.0 |
Health check DB timeout in seconds |
LOG_LEVEL |
INFO |
Python application log level |
ALLOWED_EMAILS |
— | Comma-separated email allowlist for dashboard access |
ARGUS_HTTPS_ONLY |
0 |
Set to 1 to mark session cookies as Secure |
Config is loaded at startup via Settings.from_env() and Secrets.from_env() in config.py. Secret values are masked in __repr__.
| Column | Type | Description |
|---|---|---|
event_slug |
TEXT, PK | Unique identifier from KKTIX |
event_name |
TEXT | Event name |
channel |
TEXT | Discord channel (e.g. SPRINT) |
start_at |
TEXT | Event start time (UTC ISO 8601, no offset) |
capacity |
INTEGER | Total attendance cap |
created_at |
TEXT | Record creation time (UTC, set by SQLite) |
last_reported_at |
TEXT | Timestamp of last successful Discord report (UTC ISO 8601, no offset) |
start_at and capacity are populated automatically by scraping the KKTIX event page when the event is first created (see Event Enrichment).
| Column | Type | Description |
|---|---|---|
ticket_id |
INTEGER, PK | Unique identifier from KKTIX |
ticket_name |
TEXT | Ticket type name |
event_slug |
TEXT, FK → events | Associated event |
order_id |
INTEGER, index | Associated order |
order_state |
TEXT | activated or cancelled |
contact_name |
TEXT | Contact person name |
contact_email |
TEXT | Contact person email |
paid_at |
TEXT | Payment timestamp (UTC ISO 8601, no offset) |
cancelled_at |
TEXT | Cancellation timestamp (UTC ISO 8601, no offset) |
Indexes: event_slug, order_id, ticket_name.
| Column | Type | Description |
|---|---|---|
id |
INTEGER, PK | Auto-increment |
method |
TEXT | HTTP method |
channel |
TEXT | Normalized channel name (NULL if invalid) |
headers |
TEXT (JSON) | All request headers |
body |
TEXT (JSON) | Request body |
created_at |
TEXT | Record creation time (UTC, set by SQLite) |
All incoming webhook requests are logged before auth verification, including rejected ones.
All application-written timestamps (paid_at, cancelled_at, last_reported_at) are stored as UTC ISO 8601 strings without timezone offset or microseconds: YYYY-MM-DDTHH:MM:SS. Conversion is handled by timeutil.to_utc() and timeutil.utcnow_iso().
| Field | Value |
|---|---|
| Auth header name | x-kktix-secret |
| Auth header value | value of WEBHOOK_SECRET |
Secret comparison uses hmac.compare_digest to prevent timing attacks.
POST /webhook/kktix/{channel}
The channel path segment maps to a Discord channel. It is case-insensitive and normalized to uppercase. The corresponding DISCORD_WEBHOOK_<CHANNEL> env var must be set.
Error responses:
| Condition | Status |
|---|---|
| Invalid channel name format | 400 invalid_channel |
| Auth failure | 401 Unauthorized |
| Channel env var not configured | 503 channel_not_configured |
All requests are logged to webhook_logs before any validation.
{
"batch_id": "0000000000000001",
"notifications": [
{
"type": "order_activated_paid",
"event": {
"name": "Example Event",
"slug": "abcd1234"
},
"order": {
"id": 100000001,
"state": "activated",
"paid_at": "2026-04-18T14:00:54.442+08:00"
},
"contact": {
"name": "Test User",
"email": "test@example.com",
"mobile": ""
},
"tickets": [
{ "id": 200000001, "name": "一般票", "price_cents": 0, "price_currency": "TWD" },
{ "id": 200000002, "name": "早鳥票", "price_cents": 0, "price_currency": "TWD" }
]
}
]
}{
"batch_id": "0000000000000002",
"notifications": [
{
"type": "order_cancelled",
"event": {
"name": "Example Event",
"slug": "abcd1234"
},
"order": {
"id": 100000001,
"state": "cancelled",
"cancelled_at": "2026-04-18T14:03:50.949+08:00"
}
}
]
}order_activated_paid:- Upsert event into
eventswith channel (ON CONFLICT DO NOTHING) - Insert each ticket into
ticketswithorder_state = 'activated'(ON CONFLICT DO NOTHING) - If the event is newly inserted (first time seen), schedule Event Enrichment as a background task
- Upsert event into
order_cancelled:- Update all tickets matching
order_idtoorder_state = 'cancelled', setcancelled_at
- Update all tickets matching
- One order may contain multiple tickets
- One webhook payload may contain multiple notifications
- All timestamps converted to UTC before storage
When a new event is first inserted, a background task fetches start_at and capacity from the KKTIX event page.
URL: https://{KKTIX_ORGANIZATION}.kktix.cc/events/{slug}
Parsing:
start_at: extracted from JSON-LD structured data ("startDate"field), converted to UTCcapacity: extracted from<i class="fa fa-male"></i>{registered} / {capacity}pattern
The task is skipped if start_at is already populated. All exceptions are swallowed and logged; failures do not affect the webhook response.
Implemented in kktix/report.py. Uses the shared discord.post() transport.
Configured via REPORT_HOUR, REPORT_MINUTE, REPORT_TIMEZONE. Defaults to 09:00 Asia/Taipei.
send_report() queries the DB for channels with active events:
SELECT DISTINCT channel FROM events
WHERE channel IS NOT NULL
AND (start_at IS NULL OR start_at > <now_utc>)Only these channels receive a report. Channels with no active events are skipped entirely. Events with start_at IS NULL (not yet enriched) are included.
One Discord message per channel, containing one embed per event:
📊 Argus Daily Registration Summary 2026-04-29 09:00 (Asia/Taipei)
🎟️ Event Name
一般票 3 (+2)
早鳥票 2 (+0)
─────────────
**Total 5 (+2)**
- First report for an event (no
last_reported_at): counts shown without delta - Subsequent reports: delta shown as
(+N)or(-N)compared to last report - Embed border color: green (
0x1D9E75) for net increase, red (0xE24B4A) for net decrease, grey (0x888780) for no change or first report - Multiple events in the same channel appear as separate embeds in a single Discord message (Discord limit: 10 embeds per message)
- Discord failure: log status code and response body (up to 500 chars), do not update
last_reported_at, do not raise exception
Delta is computed from the tickets table using events.last_reported_at as the reference point — no separate snapshot table is maintained.
-- Current count (now)
SELECT ticket_name, COUNT(*) AS cnt
FROM tickets
WHERE event_slug = ? AND order_state = 'activated'
GROUP BY ticket_name;
-- Count at last report time
SELECT ticket_name, COUNT(*) AS cnt
FROM tickets
WHERE event_slug = ?
AND paid_at IS NOT NULL AND paid_at <= <last_reported_at>
AND (cancelled_at IS NULL OR cancelled_at > <last_reported_at>)
GROUP BY ticket_name;After each successful send, events.last_reported_at is updated to the current UTC time for all events in that channel.
A web UI that visualizes registration trends per event over time. Implemented as a separate feature package dashboard/. Protected by Google OAuth.
See API Reference for the canonical list. All routes under /dashboard/* (except login and oauth/callback) require an authenticated session. HTML routes redirect to /dashboard/login on failure; JSON API routes return 401.
Server-side OAuth 2.0 with Google as the identity provider. After successful OAuth, the user's email is checked against ALLOWED_EMAILS. If allowed, a signed session cookie is set.
Flow:
- Visit
/dashboard(or any protected route) without session → 302 to/dashboard/login /dashboard/login→ 302 to Google consent screen- Google →
/dashboard/oauth/callback?code=... - Backend exchanges code for
id_token, verifies email is inALLOWED_EMAILS - On success: session cookie written, 302 to original destination (or
/dashboard) - On rejection: 403 page
Session is signed using SESSION_SECRET via Starlette's SessionMiddleware.
No new DB table. Time series is derived from existing tickets data using the same logic as report delta calculation.
For each day D in range:
SELECT ticket_name, COUNT(*) AS cnt
FROM tickets
WHERE event_slug = ?
AND paid_at IS NOT NULL AND paid_at <= ? -- end of day D in UTC
AND (cancelled_at IS NULL OR cancelled_at > ?)
GROUP BY ticket_name;A ticket counts on day D if it was paid by end of D and either not cancelled or cancelled after D. This naturally reflects historical state.
Range: from date(min(paid_at)) through min(today, date(events.start_at)) in the configured display timezone. The chart never extends into the future — if start_at is upcoming, the chart stops at today and the start_marker_label field is null so the frontend suppresses the "Event start" annotation. Once start_at is reached, the chart extends to that date and the marker appears at the right edge.
GET /dashboard/api/events:
[
{
"event_slug": "test-event",
"event_name": "Test Event",
"channel": "SPRINT",
"start_at": "2026-04-25T01:00:00",
"capacity": 30
}
]GET /dashboard/api/events/{slug}/timeseries:
{
"event": {
"event_slug": "test-event",
"event_name": "Test Event",
"channel": "SPRINT",
"start_at": "2026-04-25T01:00:00",
"capacity": 30
},
"labels": ["2026-04-15", "2026-04-16", "..."],
"datasets": [
{ "name": "Total", "data": [1, 3, 5, ...] },
{ "name": "一般票", "data": [1, 2, 3, ...] },
{ "name": "早鳥票", "data": [0, 1, 2, ...] }
],
"start_marker_label": "2026-04-25"
}start_marker_label is null when start_at is unset or still in the future.
Single Jinja2 template per page; charts rendered client-side with Chart.js (CDN, no build step).
Per-event chart features:
- One line per ticket type, plus a "Total" line
- Horizontal dashed line at
capacity(when set) - Vertical dashed line at
start_at - Daily granularity on X axis
| Variable | Required | Description |
|---|---|---|
GOOGLE_OAUTH_CLIENT_ID |
Yes | Google OAuth 2.0 client ID |
GOOGLE_OAUTH_CLIENT_SECRET |
Yes | Google OAuth 2.0 client secret |
SESSION_SECRET |
Yes | Random ≥32-byte hex string for signing session cookies |
ALLOWED_EMAILS |
Yes | Comma-separated allowlist, e.g. alice@example.com,bob@example.com |
Google OAuth redirect URI to register in Google Cloud Console:
https://<your-domain>/dashboard/oauth/callback
authlib— OAuth 2.0 client integration with Googlejinja2— HTML templatingitsdangerous— session cookie signing (transitive via Starlette)
GET /health
Returns DB connectivity status and app version.
{
"status": "ok",
"version": "0.1.0",
"checks": {
"database": { "ok": true, "latency_ms": 0.42 }
}
}HTTP 200 when healthy, 503 when unhealthy.
uv sync --group dev # create .venv and install all dependencies
# Source env vars
set -a && source .env && set +a
uv run uvicorn argus.main:app --host 0.0.0.0 --port 8000 # start server
uv run pytest tests/ # run automated tests
uv run ruff check src tests # ruff check
uv run ruff format src tests # ruff format
# Visual inspection of Discord report (opt-in, sends a real webhook)
ARGUS_MANUAL_TEST=1 uv run pytest tests/test_discord_format_manual.py -v -s- Push repo to GitHub
- Create new Railway project → Deploy from GitHub repo
- Add a Volume, mount at
/data, setDB_PATH=/data/argus.db - Set all required environment variables in Railway dashboard
- Railway builds
Dockerfile, installs the package and SQLite CLI, then runs uvicorn againstargus.main:app. The exact start command (with$PORTinjection) comes fromrailway.json'sstartCommand, which overrides the Dockerfile'sCMD.
{
"deploy": {
"startCommand": "uvicorn argus.main:app --host 0.0.0.0 --port $PORT"
}
}Subsequent deploys trigger automatically on git push.