Skip to content

scbrown/rollcall

Repository files navigation

Rollcall — presence for wheels

Text one number. Your crew knows you're out. That's the whole thing.

A presence beacon for PEV riders — not a tracker.


status CI stack storage sms tests


Onewheel, EUC, esk8, and scooter crews coordinate through noisy group texts where "anyone riding today?" gets asked into the void and spontaneous rides go un-joined because nobody knew. Rollcall is one bit of state per riderriding / not riding — broadcast only to the people you chose. Think Discord status, for wheels.

You flip your status on by texting a number. Your crew gets a ping. Anyone who wants in shows up. Status auto-expires, so the board is never stale. No app to install — half of any crew never would — the phone number is the product.

  You  →  RIDING piedmont park
  🛞   →  You're live at piedmont park til ~9:15pm. Crew notified.

        …meanwhile, your crew gets:
        ┌─────────────────────────────────────────────┐
        │ 🛞 Stiwi is riding at piedmont park          │
        │    (til ~9:15pm)                             │
        │ Reply WHO to see everyone out, MUTE to hush. │
        └─────────────────────────────────────────────┘

  Riri →  WHERE stiwi
  🛞   →  Stiwi is at piedmont park (til ~9:15pm).

  You  →  DONE            (or just let it expire — crew isn't pinged either way)
  🛞   →  You're off the board. Ride safe. 🛞

✨ What it does

  • 📟 Zero-install presence — text riding, your crew gets an SMS. That's onboarding.
  • 📍 Optional spot, not a trail — drop riding piedmont park; friends text where for the pin. No breadcrumbs, no history feed.
  • Never stale — every session auto-expires (default 3h, +3h to extend). A beacon you can't trust is worse than none.
  • 🤫 Departures are silentdone and expiry never ping the crew. Arrivals are interesting; leaving is noise.
  • 👥 Crew-scoped, full stop — your status goes only to people you chose. No public map of strangers.
  • 🔕 Mute without leavingmute stops pings but keeps you a member; unmute any time.
  • 🖥️ Admin web panel — a password-gated /admin UI to seed crews, manage riders, watch who's live, and read the message log — no hand-edited JSON.
  • 🔒 Verified inbound — every webhook's X-Twilio-Signature is validated or rejected.
  • ♻️ Carrier-proof — duplicate webhooks deduped on MessageSid; STOP/START/HELP honored.
  • 🧪 Runs with no TwilioDRY_RUN mode logs SMS to the console so you can drive the whole grammar with curl.

💬 The grammar

Keywords are case-insensitive; the first word is the command.

Text Who Effect
riding rider Go live, no location; fan out to crew
riding <place> rider Go live with a location; fan out
done rider End your ride — crew not pinged
who anyone List who's out in your crew
where <name> anyone Get that rider's spot, if they're live
+3h / extend rider Push your expiry out
name <displayname> anyone Set your display name
mute / unmute anyone Stop / resume receiving fan-out (still a member)
help anyone Command list
stop / start anyone Carrier opt-out / opt-in (honored)

Warning

Ending a ride is done, not stop. Carriers reserve STOP for opting out of all messages — texting it unsubscribes you entirely.

🏗️ Architecture

Twilio number
   │  inbound SMS webhook (POST /webhooks/twilio)
   ▼
Rollcall service (Hono, single process)      SQLite (WAL)
   │  validate signature → parse → mutate ──► crews / riders / ride_sessions / message_log
   ├─► Twilio REST API: crew fan-out
   └─► expiry sweep (in-process, every minute)

TypeScript + Hono on Node ≥20, better-sqlite3, the official twilio SDK. SQLite is plenty — this is tens of writes a day. The domain layer carries no HTTP or Twilio concerns, so the command handler is unit-tested against an in-memory database.

📂 Project layout
src/
  index.ts            server + sweep bootstrap
  config.ts           env-driven config (one place, no scattered process.env)
  db/
    schema.sql        DDL
    index.ts          shared WAL connection + time helpers
    migrate.ts        `npm run migrate`
  domain/             state + business logic (no HTTP/Twilio here)
    riders.ts  sessions.ts  messages.ts  types.ts
  sms/
    parse.ts          text → Command
    commands.ts       Command → reply + fan-out (pure, unit-tested)
    format.ts         all outbound copy
  twilio/
    validate.ts       X-Twilio-Signature check
    client.ts         REST send (+ DRY_RUN console mode)
  admin/              password-gated /admin web panel (server-rendered)
    auth.ts           session cookie sign/verify + gate
    routes.ts         pages + form actions
    views.ts          HTML (no client build)
  routes/webhook.ts   POST /webhooks/twilio
  seed.ts             `npm run seed` — admin-seeds crews/riders from JSON
test/                 vitest: parser + command handler
docs/                 vision.md, mvp-spec.md, assets/

🚀 Getting started

Requires Node ≥ 20.

npm install
cp .env.example .env          # fill in Twilio creds (or set DRY_RUN=true)
npm run migrate               # create the SQLite database

Seed your crew. No self-serve signup in the MVP — the admin seeds riders:

cp seed.example.json seed.json   # add your riders (phones must be E.164)
npm run seed                     # idempotent; safe to re-run as the crew grows

Run it.

npm run dev      # tsx watch, reloads on change
# or
npm start

The service listens on PORT (default 8080):

  • POST /webhooks/twilio — point your Twilio number's inbound SMS webhook here
  • GET /health — liveness check

🧪 Try it without Twilio

Set DRY_RUN=true: signature checks are skipped and outbound SMS is printed to the console instead of sent — so you can exercise the whole grammar with curl.

Note

Send From with --data-urlencode so the leading + isn't decoded as a space.

DRY_RUN=true npm start

curl -X POST localhost:8080/webhooks/twilio \
  --data-urlencode "From=+14045551234" \
  --data-urlencode "Body=riding piedmont park" \
  --data-urlencode "MessageSid=SM_test_1"
# → TwiML reply; the crew fan-out prints as [DRY_RUN SMS] lines

✅ Test

npm test          # vitest: parser + command handler against an in-memory DB
npm run typecheck

🖥️ Admin panel

A server-rendered admin UI lives at /admin — no separate build, no SPA. Set ADMIN_PASSWORD to enable it (if unset, the panel refuses to serve). Log in with that password and you get a signed, HttpOnly, SameSite=Strict session cookie.

Rollcall admin — crew detail

From it you can:

  • 👥 Create crews and see rider counts and how many are out right now
  • 📇 Add / rename / re-crew / remove riders with inline E.164 validation (replaces seed.json)
  • 🔕 Mute or unmute any rider
  • 🛰️ Watch live sessions across every crew and end one (silently — the rider isn't texted)
  • 📜 Read the message log for debugging
ADMIN_PASSWORD='a-long-random-string' \
ADMIN_SESSION_SECRET="$(openssl rand -hex 32)" \
npm start
# → open http://localhost:8080/admin

Note

Set ADMIN_SESSION_SECRET to a stable value so sessions survive restarts; without it a fresh secret is generated each boot (you'll be logged out on restart). In production the session cookie is Secure (HTTPS only).

⚙️ Configuration

All via environment variables (see .env.example):

Variable Default Purpose
PORT 8080 HTTP port
DATABASE_PATH ./data/rollcall.db SQLite file (:memory: for tests)
TWILIO_ACCOUNT_SID Twilio account SID
TWILIO_AUTH_TOKEN Used to validate inbound signatures
TWILIO_FROM_NUMBER The Twilio number, E.164
PUBLIC_WEBHOOK_URL Exact webhook URL Twilio calls (signature is hashed over it)
DRY_RUN false Skip signature checks + real sends; log instead
DEFAULT_EXPIRY_HOURS 3 Ride session lifetime
SWEEP_INTERVAL_SECONDS 60 Expiry sweep cadence
LOG_RETENTION_DAYS 30 message_log prune horizon
ADMIN_PASSWORD (empty) Enables /admin; empty = panel disabled
ADMIN_SESSION_SECRET (random) Signs admin session cookies; set a stable value
ADMIN_SESSION_HOURS 24 Admin session lifetime

🌐 Deploying

Rollcall just needs a reachable HTTPS webhook and a writable disk for SQLite. There's a multi-stage Dockerfile (slim, non-root, prod deps only) and a fly.toml ready to go.

Fly.io:

fly launch --no-deploy                                   # match the app name in fly.toml
fly volumes create rollcall_data --size 1 --region atl   # SQLite lives here
fly secrets set TWILIO_ACCOUNT_SID=… TWILIO_AUTH_TOKEN=… \
                TWILIO_FROM_NUMBER=+1… \
                PUBLIC_WEBHOOK_URL=https://<app>.fly.dev/webhooks/twilio
fly deploy

Homelab (Proxmox + Cloudflare Tunnel): docker build -t rollcall . && docker run with /data mounted and the env vars set; point the Tunnel at PORT and set PUBLIC_WEBHOOK_URL to the public hostname.

Note

SQLite is single-writer, so Rollcall runs as exactly one instance — the fly.toml pins one always-on machine (no auto-stop) so the file isn't shared and the expiry sweep keeps ticking.

Set PUBLIC_WEBHOOK_URL to exactly what's in the Twilio console — signature validation hashes that URL, so it must match byte-for-byte.

Important

Twilio setup is the slow part — verification can take days, so start it first. Full walkthrough in docs/twilio-setup.md: choosing a toll-free vs 10DLC number, verification, wiring the webhook, and the secrets. In short: US application SMS must be registered (there's no hobby exemption), but costs are trivial once live — number ~$1–2/mo, ~$0.008 per segment, and fan-out copy stays under one segment.

🗺️ Roadmap

  • MVP (this repo) — SMS-only presence + crew fan-out.
  • v1.1 — PWA: live "who's riding" view, one-tap go-live, geocoded map pins (the lat/lng columns are already there, waiting).
  • v2 — auto-start via telemetry (VESC / BLE / phone motion), scheduled rides, multiple crews.
  • Someday — the fun stuff (streaks, group-ride stats) — only once the beacon is rock solid.

🧭 Principles

  1. Presence, not surveillance. Ephemeral status, coarse opt-in location. No breadcrumb trails.
  2. Crew-scoped. Your status goes only to people you chose.
  3. Stale state is death. Everything expires.
  4. Meet riders where they are. SMS first, PWA second, native maybe never.

📖 Vision  ·  MVP spec  ·  Twilio setup  ·  Contributing

🛞 Ride safe.

About

SMS-first ride presence for PEV riders — text to go live, your crew gets pinged. Presence beacon, not a tracker.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors