Combines all uncommitted D&D work into one checkpoint: Phases 1-8 (per gogobee_dnd_session_summary.md): - Race/class/stats system, !setup flow, !sheet, !respec - d20-vs-AC combat with race/class passives, auto-migration - XP/level-up, skill checks, NPC refund hooks - Equipment slot mapping, rarity, !rest short/long - Pre-arm active abilities, resource pools - Bestiary, !roll/!stats/!level, onboarding DM - Audit fixes A-I, flavor wiring under internal/flavor/ - Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix) Phase 9 SP1 — spell system foundations: - 79 SpellDefinitions (76 in-scope + 3 reaction-deferred) - dnd_known_spells, dnd_spell_slots tables - pending_cast / concentration_* columns on dnd_character - Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math - Long-rest refreshes slots; respec wipes spell state - Auto-grant default known list on !setup confirm and auto-migration Phase 9 SP2 — !cast / !spells / !prepare commands: - Out-of-combat HEAL and UTILITY resolve immediately - Damage/control/buff queue as pending_cast for next combat - Audit-style save-then-debit, slot refund on save failure - !cast --drop, concentration supersession, prep-cap enforcement - Reaction spells refused with Phase 11 note SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn tests) are next. Build clean, full test suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.0 KiB
GogoBee — forex Plugin
Design document for Claude Code integration.
Purpose
Tracks USD exchange rates against EUR and JPY using the Frankfurter API (ECB-sourced, no API key required). Stores daily history in bbolt, computes buy signals, and exposes Matrix commands for rate checks, full reports, and threshold alerts.
File Layout
Place all files under plugins/forex/ in the GogoBee module tree.
plugins/forex/
├── forex.go # Plugin struct, Start/Stop, HandleCommand, all Matrix command handlers
├── store.go # bbolt persistence (rates + alerts)
├── analyzer.go # Signal computation (30/90d moving averages, 52w range, buy score)
├── scheduler.go # Frankfurter HTTP client, backfill, daily poller goroutine
Matrix Commands
All commands use the !fx prefix. Currency arguments are optional where noted and accept any currency in TrackedCurrencies.
| Command | Args | Behavior |
|---|---|---|
!fx rate |
[currencies...] |
Current rate + one-line quick signal. No args = all tracked currencies. |
!fx report |
[currencies...] |
Full analysis block per currency: 30/90d averages, 52w range bar, buy score. |
!fx setalert |
<currency> <rate> |
Save a threshold alert for this room. Fires when 1 USD >= rate. |
!fx alerts |
— | List all active alerts in the current room, with last-fired time if applicable. |
!fx delalert |
<currency> <rate> |
Remove a specific alert. |
Example usage:
!fx rate → EUR and JPY quick signals
!fx rate JPY → JPY only
!fx report EUR JPY → full report for both
!fx setalert JPY 155 → alert when USD/JPY ≥ 155
!fx delalert JPY 155
Data Source
Frankfurter.app — wraps ECB reference rates. No authentication. Two endpoints used:
GET /latest?from=USD&to=EUR,JPY— current rates (called on-demand for!fx rate/reportand daily by the poller)GET /YYYY-MM-DD..YYYY-MM-DD?from=USD&to=EUR,JPY— date range (used for the one-time backfill)
Both return JSON. All currencies for a given date arrive in a single response, so there is only ever one HTTP call per operation regardless of how many currencies are tracked.
Storage (store.go)
Database: bbolt, single file, path configurable at New() call time (e.g. ./data/forex.db).
Two buckets:
rates
Key: "EUR:20240315" (currency + date in YYYYMMDD, lexicographically sortable per currency)
Value: JSON-encoded RateRecord{Date, Currency, Rate}
Range queries use bbolt cursor Seek + forward scan bounded by the end key. Each currency's history is naturally isolated by key prefix.
alerts
Key: "<roomID>:<userID>:<currency>:<threshold>" (float formatted to 6 decimal places)
Value: JSON-encoded AlertRecord{RoomID, UserID, Currency, Threshold, FiredAt}
FiredAt is a Unix timestamp (int64). Zero means never fired. Alerts are reset (set back to zero) if FiredAt is older than 24 hours — called automatically after each daily poll.
Signal Computation (analyzer.go)
Compute(store, currency, currentRate) returns a Signal struct.
Inputs gathered from store:
- 30-day rate slice (stored records + today's live rate appended)
- 90-day rate slice (same)
- 52-week rate slice (same)
Derived values:
| Field | Calculation |
|---|---|
Avg30 / Avg90 |
Arithmetic mean of respective slice |
High52w / Low52w |
Min/max of 52-week slice |
Percentile |
(current - low) / (high - low) * 100 — where current sits in the 52w range |
Score (1–10) |
percentile/100 * 10 * 0.6 + devScore * 0.4 |
devScore is derived from the average deviation above the 30d and 90d moving averages, mapped to a 0–10 scale where +5% above average = 10, −5% = 0.
Score labels:
| Score | Label | Emoji |
|---|---|---|
| ≥ 8 | Excellent | 🟢 |
| ≥ 6 | Good | 🟡 |
| ≥ 4 | Fair | 🟠 |
| < 4 | Poor | 🔴 |
Note on score direction: Higher score = dollar is stronger relative to history = good time to convert USD to a foreign currency. This is correct for both EUR (Portugal move) and JPY (Japan purchases).
Rate formatting: JPY prints to 2 decimal places (154.32); all other currencies to 4 (1.0842). Controlled by formatRate(currency, rate) in analyzer.go.
Daily Poller (scheduler.go)
StartDailyPoller(ctx, store, pollHourUTC, alertCallback) launches a goroutine that:
- Sleeps until
pollHourUTC:01 UTC(default:17— ECB publishes ~16:00 CET) - Calls
FetchCurrentRatesfor allTrackedCurrencies - Saves each rate to the store
- Calls
store.ResetFiredAlerts()(clears alerts fired >24h ago) - Calls
alertCallback(rates)soforex.gocan check thresholds and send Matrix notifications - Sleeps until next day
Backfill (Backfill(ctx, store)) is called once at Start(). It fetches the full trailing year in one request and saves all records. Safe to call repeatedly — bbolt upserts are idempotent.
Plugin Lifecycle
// Initialization (in GogoBee main/registry)
p, err := forex.New(matrixClient, "./data/forex.db")
p.Start() // triggers backfill + launches poller goroutine
defer p.Stop()
// Command dispatch (in message event handler)
// parts = strings.Fields(messageBody), e.g. ["!fx", "rate", "JPY"]
if parts[0] == "!fx" {
p.HandleCommand(evt, parts[1:])
}
New takes the mautrix *mautrix.Client and the bbolt file path.
HandleCommand takes the *event.Event and the argument slice (everything after !fx).
Adding Currencies
One change required: add the ISO 4217 code to TrackedCurrencies and a CurrencyMeta entry in store.go.
var TrackedCurrencies = []string{"EUR", "JPY", "GBP"}
var CurrencyMeta = map[string]struct {
Emoji string
Label string
}{
"EUR": {Emoji: "🇪🇺", Label: "Euro"},
"JPY": {Emoji: "🇯🇵", Label: "Japanese Yen"},
"GBP": {Emoji: "🇬🇧", Label: "British Pound"},
}
Frankfurter supports all major ISO currencies. The backfill and daily poll will automatically include any new entry on next run.
Dependencies
go.etcd.io/bbolt v1.3.9
maunium.net/go/mautrix v0.18.1 // already in GogoBee
gopkg.in/yaml.v3 // only if config file support is added later
No CGO. Builds cleanly on Linux/ARM (Hetzner CAX11 compatible).
Notes for Integration
- The
mdToHTMLfunction inforex.gois a minimal stub that handles**bold**and`code`only. Replace with GogoBee's existing markdown renderer if one is available — the function signature ismdToHTML(s string) string. - Alert
FiredAtuses a 24-hour cooldown. If the daily poll fires while an alert's threshold is still met the next day, it will re-notify. This is intentional — persistent high rates should keep notifying. - Live rate fetch has a 10-second timeout. On failure it falls back to the most recent stored rate per currency and logs a warning. Commands will still work, but the response will note staleness via the log (not surfaced to Matrix — add if desired).