a discord bot for the tootsies server. ask, recap, discuss, ship features by typing.
A play-money sportsbook inside a Discord bot. It settles real games off live data — and never pays the wrong team.
Bet coins on who wins a live or upcoming game with /bet. The bot freezes the line, watches the game end, and pays out in front of the whole room. It looks like a toy. The hard part hides inside “watches the game end and settles correctly”: there’s no single source of truth for a sports result, and the dropdown you bet from has a 3-second deadline.
The hard problems, in one breath:
Three feeds describe the same match differently — flipped home/away, different spellings (BiH vs Bosnia & Herzegovina), even different ideas of whether it’s still live. So we don’t trust any provider’s ID. We mint our own: sport + the two team names, accent-folded, order-independent, alias-collapsed.
match_key + canonical_teamdef match_key_parts(sport, home, away, title=""):
# sorted() → order-independent (a home/away flip can't split it)
# canonical_team → folds "BiH" and "Bosnia & Herzegovina" onto one name
teams = "@".join(sorted(t for t in (_canonical_team(home), _canonical_team(away)) if t))
return f"{_norm(sport)}:{teams}" if teams else f"{_norm(sport)}:{_norm(title)}"
An unresolved name passes through as plain norm, so a miss is at worst a missed merge (caught later by a stranded-bet detector that flags it for an alias add) — never a wrong one. No date component, on purpose: only open bets settle, so a rematch can’t double-settle, and a stale key clears via the 96h void.
A bet stores the team it backed, not a side. Settlement matches by name — so a provider flipping orientation can’t pay the wrong team — and if a name can’t be reconciled, the bet refunds. The house eats ambiguity; a player never loses a stake to a spelling gap.
def bookie_bet_outcome(side, side_label, winner, home, away, stake, decimal_odds):
if winner is None: # no result
return "void", stake # → refund
if winner == "draw":
return ("won", round(stake * decimal_odds)) if side == "draw" else ("lost", 0)
if side == "draw":
return "lost", 0
sl = _canonical_team(side_label)
if sl and sl == _canonical_team(winner):
return "won", round(stake * decimal_odds) # backed the winner
if sl and sl in (_canonical_team(home), _canonical_team(away)):
return "lost", 0 # backed the loser
return "void", stake # can't reconcile → refund, never wrongly lose
Two settlers, one payout. The 5-minute settle loop and the live commentator can both reach a finished game. One transaction that only touches status='open' rows, FOR UPDATE, makes whoever loses the race a clean no-op — so each bet pays exactly once.
async def settle_bookie_game(self, game_key, winner, home="", away=""):
async with self._pool().acquire() as conn, conn.transaction():
await conn.execute("INSERT INTO bookie_locked_games (game_key) VALUES ($1) "
"ON CONFLICT DO NOTHING", game_key) # lock market, same txn
rows = await conn.fetch("SELECT ... WHERE game_key=$1 AND status='open' FOR UPDATE", game_key)
for r in rows:
status, payout = bookie_bet_outcome(...) # pure decision
await conn.execute("UPDATE bookie_bets SET status=$2, payout=$3, settled_at=NOW() ...")
if payout:
await conn.execute("UPDATE bookie_balances SET balance = balance + $3 ...")
return settled
The commentator deliberately reads the settled rows instead of trusting its own return value: the loop usually wins the race, so the commentator’s call comes back empty — and roasting off that empty result silently blanked the postgame beat (a real bug). Settle, then read who got settled. db.py:2414
Discord kills a slash interaction after ~3 seconds. A cold six-league odds fan-out is ~5. So the picker is never allowed to block on a live fetch — it serves a warm cache and refreshes behind.
/bet.defer() before any await, so resolving the pick can’t blow the 3s window either.Each outbound feed sits behind its own circuit breaker — a crater trips it and stops the hammering instead of stalling every request. The Odds API is the dual backstop (it fills the slate and settles bets), budget-guarded so a metered key can’t be drained.
| Feed | Healthy | Craters → |
|---|---|---|
| SportsGameOdds | primary odds + slate | The Odds API fills the picker |
| API-Sports | live scores + finals | The Odds API settles open bets |
| The Odds API | idle when others are healthy | becomes the only source — 5,000-credit reserve, spends only when a primary is down |
One strategy call falls out of this: only offer what we can settle. Settlement is API-Sports-only (soccer + NBA), so an SGO-only sport could be priced and bet but would never settle a win — so the picker never shows it.
Placement is one transaction — ensure row → daily top-up → check funds → debit → insert — so a balance can’t go negative and a double-spend can’t slip through. If the invariant above ever stops holding, coins were conjured: it’s the single check that proves the ledger.
A sportsbook line and a prediction-market price reduce to the same decimal multiplier, so they compare and pay on one scale — and the multiplier is frozen at placement.
| Native price | → decimal | $100 pays | implied |
|---|---|---|---|
+130 American |
2.30 |
230 (profit 130) |
43% |
-150 American |
1.667 |
167 (profit 67) |
60% |
0.40 market YES |
2.50 |
250 (profit 150) |
40% |
The bot doesn’t just take bets — it calls the games, live, in a sharp bartender voice, and folds the Bookie into the broadcast on the bookend beats:
It settles the game with that same idempotent transaction, then hands the results to the commentary as grounded context (names and numbers only, never invented). It’s the moment the feature stops feeling like a slash command and starts feeling like a sportsbook with a live announcer who knows your name.
| Concern | Location |
|---|---|
| Cog: commands, picker, settle loop | cogs/bookie.py |
| Ledger + settlement transactions | db.py |
| Settlement decision (pure) | db.py:39 bookie_bet_outcome |
| Game identity | utils/sportsdata/models.py, utils/sportsdata/names.py |
| Odds math (pure) | utils/odds_compare.py |
| Feeds + circuit breakers | utils/markets.py, utils/the_odds_api.py, utils/api_sports.py |
Part of the Tootsies engineering series. Source: github.com/mejasonmejason/tootsies.