Skip to content

Repository files navigation

City Quest Bot

A WhatsApp bot that runs YAML-defined walking-tour quests. Authors write a quest in a single YAML file, send it to the bot, and players unlock stops by answering clues one at a time in a WhatsApp chat.

Built on FastAPI + Twilio's WhatsApp sandbox + SQLite. The game engine is pure Python — transport-free — so swapping WhatsApp for Telegram, SMS, or a web UI is a one-file adapter.

How it works

Player: /start sf
Bot:    Welcome to the SF demo quest! Two stops, no walking required.
Bot:    I stand on Telegraph Hill, 210 feet tall, shaped like the nozzle
        of a firefighter's hose. What am I?
Player: coit tower
Bot:    🔥 Yes! Lillie Hitchcock Coit left $125k to the city to build it in 1933.
Bot:    One block of my road has eight sharp hairpin turns down a steep hill.
        Tourists line up to drive me. What street am I?
Player: /hint
Bot:    💡 Hint: It's often called the crookedest street in the world.
Player: lombard
Bot:    🛣️ Exactly — the 600 block of Lombard between Hyde and Leavenworth.
Bot:    🎉 You finished the demo.

Features

  • Single-file quests. One YAML file describes the entire game — stops, clues, accepted answers, hints, and success messages. Validated against a published JSON Schema.
  • In-chat authoring. Authors send a YAML file (as .txt document or pasted into the chat body) and the bot stores it immediately. No redeploy.
  • Forgiving answer matching. Case-insensitive, punctuation-stripped membership against a list of accepted answers per stop.
  • Progressive hints. Manual (/hint) or auto-nudged after N wrong guesses per stop — configurable per stop in the YAML.
  • Resumable sessions. Players can walk away, come back hours later, and /start picks up where they left off.
  • Admin commands. An allowlist of phone numbers can /skip, /goto <stop_id>, /whereami, and /dumpstate for live debugging.
  • Webhook signature verification. Every Twilio request is validated with X-Twilio-Signature before the engine runs.
  • 62 tests, fully transport-agnostic engine. Engine never imports from transport; tests exercise the engine with an in-memory SQLite.

Quickstart

Requires Python 3.12+ and uv.

git clone https://github.com/noncuro/cityquest.git
cd cityquest
make install            # uv sync

cp .env.example .env    # then fill in Twilio credentials (see below)
make schema             # regenerate quest.schema.json from the Pydantic models
make test               # 62 passing

Configure Twilio

  1. Sign up for a free Twilio account and enable the WhatsApp Sandbox.
  2. Copy your Account SID and Auth Token into .env.
  3. Join your sandbox by sending the join <code> message from WhatsApp to the sandbox number (Twilio shows both).
  4. Put your own WhatsApp number in ADMIN_WA_NUMBERS (E.164 format, e.g. +14155551234) if you want access to admin commands.

Expose the webhook

Twilio needs a public HTTPS URL to POST player messages to. For local dev, ngrok is easiest:

# Terminal 1
make dev                                    # uvicorn on :8000

# Terminal 2
NGROK_DOMAIN=your-subdomain.ngrok-free.app make tunnel

Set PUBLIC_BASE_URL in .env to match your ngrok URL (signature verification uses it), then in the Twilio Sandbox settings set the incoming webhook to:

https://your-subdomain.ngrok-free.app/webhook/twilio

Play

From WhatsApp, send:

/quests         # list available quests
/start sf       # play the included San Francisco demo
/help           # full command list

Authoring a quest

A quest is one YAML file. The bot exposes its schema at GET /quest.schema.json, and quests/sf.yaml is a minimal working example.

Put this as the first line of your YAML so VS Code gives you autocomplete and live validation:

# yaml-language-server: $schema=https://your-subdomain.ngrok-free.app/quest.schema.json

Then the shape is:

id: sf                          # short slug used for /start <id>
title: "San Francisco Demo"
city: "San Francisco"
author: "Your Name"
description: "A tiny demo quest."

intro: |                        # sent when the player runs /start <id>
  Welcome to the quest!
  Type /hint if you get stuck.

completion: |                   # sent after the last stop is solved
  🎉 You finished.

stops:
  - id: coit
    name: "Coit Tower warmup"   # shown in /status, never to the player
    clue: |
      I stand on Telegraph Hill, 210 feet tall, shaped like the nozzle
      of a firefighter's hose. What am I?
    answers:                    # any one matches (case/punctuation-insensitive)
      - "coit tower"
      - "coit"
    hints:
      - "It was funded by a woman who loved firefighters."
      - "The first word rhymes with 'exploit'."
    auto_hint_after_wrong: 3    # nudge "Stuck? Try /hint." after 3 wrong guesses
    success_message: |          # optional — fallback is "✅ Correct!"
      🔥 Yes! Lillie Hitchcock Coit left $125k to build it in 1933.

Required fields: id, title, city, author, description, intro, completion, and stops[].{id,name,clue,answers}. Everything else has sensible defaults.

Uploading a quest

Three ways, any of them works:

  1. Paste the YAML into a WhatsApp message. If the body contains stops: and id: on their own lines, the bot parses it as a quest.
  2. Attach as a .txt document. WhatsApp's document picker rejects .yaml, so rename before sending. Twilio delivers it as media, the bot fetches and saves it.
  3. Seed from disk. Put the YAML in quests/ and run uv run python scripts/seed_quests.py to upsert every file into the DB.

The bot replies with ✅ Quest saved: <title>. Type /start <id> to play.

Commands

Player:

Command Effect
/start <quest_id> Begin a quest from its first stop
/start Resume your most recent incomplete quest
/hint Reveal the next hint for the current stop
/status Show current stop, wrong guesses, hints used
/reset Wipe your current session
/quests List every available quest
/schema Get the JSON Schema URL for authoring
/help Full command list (admin commands shown if authorized)

Admin (requires phone number in ADMIN_WA_NUMBERS):

Command Effect
/skip Skip the current stop
/goto <stop_id> Jump to a specific stop
/whereami Show current stop with accepted answers and hints
/dumpstate Full session state as JSON

Any other text is treated as an answer to the current stop.

Architecture

app/
├── engine/              # pure game logic, no transport imports
│   ├── models.py        # Pydantic Quest / Stop
│   ├── actions.py       # PlayerInput, BotAction (SendText, ...)
│   ├── matcher.py       # case-fold, strip-punct, membership match
│   ├── hints.py         # reveal next, auto-nudge
│   ├── runner.py        # QuestRunner: start, answer, commands, upload
│   ├── quests.py        # YAML -> Quest loader
│   └── export_schema.py # Pydantic -> quest.schema.json
├── store/               # SQLModel
│   ├── models.py        # QuestRow, Session, Event
│   └── db.py            # engine + session factory
├── transport/
│   └── twilio_whatsapp.py  # parse webhook, verify sig, dispatch
├── config.py            # pydantic-settings
└── main.py              # FastAPI app factory + webhook route

Key design choice: the engine operates on PlayerInput → list[BotAction] with no knowledge of HTTP or Twilio. Adding a new transport (Telegram, SMS, web UI) means writing one adapter file that parses incoming requests into PlayerInput and dispatches the returned actions. The TwilioDispatcher splits any SendText on \n\n into multiple sequential messages so authors can compose paragraph-style replies naturally.

Session IDs are opaque tokens — wa:+14155551234 for the Twilio adapter — which keeps the engine transport-agnostic while still allowing per-user state.

Development

make test       # run the test suite
make dev        # uvicorn with --reload on :8000
make schema     # regenerate quest.schema.json after changing engine/models.py

The full implementation plan used to build this (TDD, 17 tasks, subagent-driven) lives in docs/superpowers/plans/.

Roadmap

  • Fuzzy / semantic answer matching (currently exact-membership only)
  • Team sessions (shared state across multiple players)
  • Telegram transport
  • Production hosting guide (nginx + Let's Encrypt)
  • Author-facing authoring UI

License

MIT — see LICENSE.

About

WhatsApp bot that runs YAML-defined walking-tour quests. FastAPI + Twilio sandbox + SQLite.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages