Skip to content

Commit 92f2e23

Browse files
authored
feat(setup): add gordie init wizard (#33)
* feat(setup): add gordie init wizard * test(setup): cover setup cli edge cases * style(setup): format setup cli * test(setup): use pytest raises for setup errors * chore(setup): declare scripts package exports * feat(setup): reuse existing env values * feat(setup): default chat media to discord * feat(setup): link discord credential setup * fix(setup): link discord bot token page * fix(setup): run migrations on server startup * feat(discord): add gateway mode * fix(setup): start docker services after init * fix(setup): derive discord mode from hosted flag * fix(setup): rebuild docker services during init * refactor(agent): move response delivery to transport handlers
1 parent bea7326 commit 92f2e23

36 files changed

Lines changed: 2840 additions & 296 deletions

.env.example

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,26 @@ LLM_MODEL=gpt-4o-mini # provider-specific model name
2424
YAHOO_CLIENT_ID=
2525
YAHOO_CLIENT_SECRET=
2626

27+
# ----------------------------------------------------------------------------
28+
# Chat media
29+
# ----------------------------------------------------------------------------
30+
CHAT_MEDIA= # comma-separated; telegram, discord, email, sms
31+
32+
# ----------------------------------------------------------------------------
33+
# Telegram (optional; only needed when CHAT_MEDIA includes telegram)
34+
# ----------------------------------------------------------------------------
35+
TELEGRAM_BOT_TOKEN=
36+
37+
# ----------------------------------------------------------------------------
38+
# Discord (optional; only needed when CHAT_MEDIA includes discord)
39+
# ----------------------------------------------------------------------------
40+
DISCORD_MODE=gateway # gateway for local bot websocket; interactions for public HTTPS endpoint
41+
DISCORD_APPLICATION_ID=
42+
DISCORD_PUBLIC_KEY= # interactions mode only
43+
DISCORD_BOT_TOKEN= # gateway mode only
44+
DISCORD_ALLOWED_USER_IDS= # gateway mode only; comma-separated Discord user IDs
45+
DISCORD_REQUIRE_MENTION=true # gateway mode only; ignored in direct messages
46+
2747
# ----------------------------------------------------------------------------
2848
# Email — Mailgun (optional; server runs without it but cannot send email)
2949
# ----------------------------------------------------------------------------

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ COPY --from=builder --chown=app:app /app /app
4343
ENV PATH="/app/.venv/bin:${PATH}" \
4444
PYTHONUNBUFFERED=1 \
4545
PYTHONDONTWRITEBYTECODE=1 \
46+
GORDIE_LOG_FILE=stderr \
4647
SERVER_HOST=0.0.0.0 \
4748
SERVER_PORT=8000
4849

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ Real conversations with Gordie.
4545
┌──────────────────────────────────────────┐
4646
Email ──► │ Quart HTTP server (server/server.py) │
4747
SMS ──► │ /email/webhook /sms/webhook /callback │
48-
Discord ─► │ /discord/interactions
48+
Discord ─► │ /discord/interactions or Gateway client
4949
└────────────────┬─────────────────────────┘
5050
5151

agent/graph_builder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def build_agent_graph(graph_checkpointer: object | None = checkpointer):
1818
workflow.add_node("supervisor", supervisor_node)
1919
workflow.add_node("data_quality", data_quality_node)
2020
workflow.add_node("voice_rewrite", make_voice_rewrite_node(registry)) # pyright: ignore[reportArgumentType]
21-
workflow.add_node("response", make_response_node(registry)) # pyright: ignore[reportArgumentType]
21+
workflow.add_node("response", make_response_node()) # pyright: ignore[reportArgumentType]
2222

2323
workflow.set_entry_point("context")
2424
workflow.add_edge("context", "supervisor")

agent/response_node.py

Lines changed: 3 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
"""Response node for the fantasy sports assistant graph.
2-
3-
Dispatches responses through the configured channel adapter.
4-
"""
1+
"""Response node for the fantasy sports assistant graph."""
52

63
from collections.abc import Callable, Mapping
74
from typing import Literal, cast
@@ -10,9 +7,7 @@
107

118
from agent.agent_state import AgentState
129
from agent.memory_store import get_memory_store, summarize_and_store_conversation
13-
from data.models import Medium
1410
from module.logger import get_logger
15-
from server.adapters.base import AdapterRegistry
1611

1712
logger = get_logger(__name__)
1813

@@ -54,47 +49,16 @@ def _store_conversation_memory(state: AgentState, messages: list[object]) -> Non
5449
logger.error(f"Failed to store conversation memory: {e}")
5550

5651

57-
def _coerce_medium(channel: object) -> Medium | None:
58-
if isinstance(channel, Medium):
59-
return channel
60-
if isinstance(channel, str):
61-
try:
62-
return Medium(channel)
63-
except ValueError:
64-
return None
65-
return None
66-
67-
68-
def make_response_node(
69-
registry: AdapterRegistry,
70-
) -> Callable[[AgentState], Command[Literal["__end__"]]]:
52+
def make_response_node() -> Callable[[AgentState], Command[Literal["__end__"]]]:
7153
def response_node(state: AgentState) -> Command[Literal["__end__"]]:
72-
"""Dispatch the agent response to the appropriate channel and end the flow."""
54+
"""Finalize the agent response and end the flow."""
7355
messages = state.get("messages", [])
74-
channel = cast(object, state.get("channel"))
75-
external_id = state.get("external_id")
7656

7757
message_content, _ = _get_last_ai_message(messages)
7858
if not message_content:
7959
logger.warning("No AI message found to send")
8060
return Command(goto=END_NODE, update=state)
8161

82-
if channel == "cli":
83-
_store_conversation_memory(state, messages)
84-
return Command(goto=END_NODE, update=state)
85-
86-
medium = _coerce_medium(channel)
87-
if medium is None:
88-
logger.error(f"No valid channel found in state: {channel}")
89-
elif not external_id:
90-
logger.error("No external_id found in state")
91-
else:
92-
adapter = registry.get(medium)
93-
if adapter:
94-
adapter.send(external_id, message_content, state)
95-
else:
96-
logger.error(f"No adapter configured for channel: {medium.value}")
97-
9862
_store_conversation_memory(state, messages)
9963
return Command(goto=END_NODE, update=state)
10064

data/alembic/env.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77

88
from data.models import Base
99

10-
load_dotenv()
10+
_ = load_dotenv()
1111

1212
config = context.config
1313

1414
if config.config_file_name is not None:
15-
fileConfig(config.config_file_name)
15+
fileConfig(config.config_file_name, disable_existing_loggers=False)
1616

1717
# Override sqlalchemy.url from environment
1818
_raw_url = os.environ.get("DATABASE_URL", "")

docker-compose.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ services:
3636
container_name: gordie-server
3737
env_file: .env
3838
environment:
39-
DATABASE_URL: ${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/fantasy_agent}
39+
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-fantasy_agent}
40+
GORDIE_LOG_FILE: stderr
4041
SERVER_HOST: 0.0.0.0
4142
SERVER_PORT: 8000
4243
depends_on:

docs/message-flow.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ How user messages reach Gordie and how Gordie responds, from webhook to delivery
44

55
## Entry Points
66

7-
Three webhooks accept inbound messages. Each returns immediately and processes the message in a background thread.
7+
Three webhooks accept inbound messages. Each returns immediately and processes the message in a background thread. Discord can also run through an outbound Gateway bot for local installs.
88

99
| Channel | Endpoint | Provider | Route File |
1010
|---------|----------|----------|------------|
1111
| Email | `POST /email/webhook` | Mailgun | `server/routes/email_routes.py` |
1212
| SMS | `POST /sms/webhook` | Sinch | `server/routes/sms_routes.py` |
13-
| Discord | `POST /discord/interactions` | Discord interactions | `server/routes/discord_routes.py` |
13+
| Discord interactions | `POST /discord/interactions` | Discord interactions | `server/routes/discord_routes.py` |
14+
| Discord gateway | Outbound websocket | Discord Gateway | `server/discord_gateway.py` |
1415

1516
## Webhook Validation
1617

@@ -22,7 +23,8 @@ Inbound webhooks run the same guard sequence before processing. Verification log
2223
4. **SMS-only: rate limiting** — in-memory sliding window (5 messages per 60 seconds per phone number)
2324
5. **SMS-only: opt-out/opt-in** — STOP/START keywords (and variants like UNSUBSCRIBE, CANCEL, END, QUIT) are handled inline and short-circuit before reaching the agent. Opted-out users receive no responses until they send START
2425
6. **SMS-only: cold start** — if the phone number has no registered user, a pending user record is created and an OAuth link is sent via SMS instead of invoking the agent
25-
7. **Discord-only: deferred response** — slash commands return Discord response type `5`, then Gordie edits the original interaction response after the agent finishes
26+
7. **Discord interactions-only: deferred response** — slash commands return Discord response type `5`, then Gordie edits the original interaction response after the agent finishes
27+
8. **Discord gateway-only: allowlist + mention filter** — direct messages are accepted from `DISCORD_ALLOWED_USER_IDS`; server messages require an @mention unless `DISCORD_REQUIRE_MENTION=false`
2628

2729
## Billing Enforcement
2830

@@ -40,7 +42,7 @@ Each channel resolves a `thread_id` to maintain conversation continuity through
4042

4143
**SMS** maps each phone number to one `conversation_threads` row for the SMS medium.
4244

43-
**Discord** maps each Discord user ID to one `conversation_threads` row for the Discord medium. The latest Discord interaction token for that thread is stored in `discord_interaction_targets` so the response adapter can edit the deferred original response.
45+
**Discord** maps each Discord user ID to one `conversation_threads` row for the Discord medium. Interactions mode stores the latest Discord interaction token for that thread in `discord_interaction_targets` so the response adapter can edit the deferred original response. Gateway mode sends the captured response directly back to the Discord channel.
4446

4547
## Agent Processing
4648

@@ -131,12 +133,18 @@ The response node (`agent/response_node.py`) dispatches the final message throug
131133
1. Strips markdown from the response
132134
2. Sends as a single SMS via Sinch
133135

134-
**Discord dispatch** (`server/adapters/discord_adapter.py`):
136+
**Discord interactions dispatch** (`server/adapters/discord_adapter.py`):
135137

136138
1. Looks up the latest `discord_interaction_targets` row for the thread
137139
2. Truncates content to Discord's 2000-character response limit
138140
3. Edits the original deferred Discord interaction response
139141

142+
**Discord gateway dispatch** (`server/discord_gateway.py`):
143+
144+
1. Receives messages from Discord over the bot websocket
145+
2. Runs the same Discord message processor with adapter dispatch disabled
146+
3. Sends the returned response directly to the originating Discord channel
147+
140148
**Conversation memory** (`agent/memory_store.py`):
141149

142150
After dispatch, the response node calls `summarize_and_store_conversation()` which uses GPT-4o-mini to extract a summary, key topics, players mentioned, and decisions made from the last 10 messages. Summaries are stored in both:
@@ -147,14 +155,15 @@ After dispatch, the response node calls `summarize_and_store_conversation()` whi
147155
## Sequence Overview
148156

149157
```
150-
User sends SMS/Email/Discord command
158+
User sends SMS/Email/Discord message
151159
152160
153161
Webhook handler
154162
(verify signature, deduplicate, timestamp check)
155163
156164
├── SMS-only: rate limit, opt-out, cold-start checks
157-
├── Discord-only: defer response + store interaction target
165+
├── Discord interactions: defer response + store interaction target
166+
├── Discord gateway: allowlist + mention filter
158167
159168
├── Billing tier enforcement
160169

docs/setup/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ See per-service setup docs:
3232
- `yahoo-oauth.md``YAHOO_CLIENT_ID`, `YAHOO_CLIENT_SECRET`
3333
- `email-mailgun.md``MAILGUN_API_KEY`, `MAILGUN_DOMAIN`, `MAILGUN_FROM_EMAIL`, `MAILGUN_WEBHOOK_SIGNING_KEY`
3434
- `sms-sinch.md``SINCH_SERVICE_PLAN_ID`, `SINCH_API_TOKEN`, `SINCH_FROM_NUMBER`, `SINCH_WEBHOOK_TOKEN`
35-
- `discord.md``DISCORD_PUBLIC_KEY`, `DISCORD_APPLICATION_ID`
35+
- `discord.md``DISCORD_MODE`, `DISCORD_APPLICATION_ID`, `DISCORD_PUBLIC_KEY`, `DISCORD_BOT_TOKEN`
3636

3737
## Billing (Creem)
3838

docs/setup/database.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ Gordie uses Postgres for application state (users, OAuth tokens, subscriptions,
55
## Local Postgres via Docker
66

77
```bash
8-
docker compose up -d postgres
9-
docker compose exec server uv run alembic upgrade head
8+
docker compose up -d
109
```
1110

11+
The server applies Alembic migrations automatically before it starts accepting requests.
12+
1213
`docker-compose.yml` defaults to:
1314
- DB name: `fantasy_agent`
1415
- User: `postgres`
@@ -34,8 +35,7 @@ LangGraph's PostgresSaver auto-creates its own tables on first import of `agent.
3435

3536
```bash
3637
docker compose down -v # drops the postgres volume
37-
docker compose up -d postgres
38-
docker compose exec server uv run alembic upgrade head
38+
docker compose up -d
3939
```
4040

4141
There's a helper script: `scripts/reset_databases.sh` (assumes `gordie-postgres` container).

0 commit comments

Comments
 (0)