From 90d58c7f2dd67c5e4f8914489551a4aae4248288 Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Thu, 28 May 2026 14:07:21 -0500 Subject: [PATCH] Fix duplicate leaderboard entries, add /version command, fix jail DNS - db/db.go: Add write-time username sync in AddLog to prevent duplicate leaderboard entries when users change display names. Revert correlated subqueries back to GROUP BY user_id, username (simpler approach). - db/db.go: Early return in onMessageCreate if bot already reacted (prevents duplicate emoji reactions on Discord reconnection). - bot/bot.go: Add /version slash command with build version injection. - main.go: Add version variable with ldflags support. - Makefile: Add dns-fix, test, vet, build-native, pg-*, boot targets. Prepend test+vet to deploy pipeline. Add version ldflags to build. - db/migrations/002_fix_usernames.sql: One-time SQL to backfill old usernames. - scripts/fix-jail-dns.sh: Script to update jail resolv.conf from 8.8.8.8 to reachable nameservers (1.1.1.1, 9.9.9.9, 172.16.0.1). Signed-off-by: Blake Ridgway --- Makefile | 81 ++++++++++++++++++++++++++--- bot/bot.go | 22 +++++++- cyclingboot | 7 +++ cyclingboot.pub | 1 + db/db.go | 9 ++++ db/migrations/002_fix_usernames.sql | 22 ++++++++ main.go | 7 ++- scripts/fix-jail-dns.sh | 32 ++++++++++++ 8 files changed, 170 insertions(+), 11 deletions(-) create mode 100644 cyclingboot create mode 100644 cyclingboot.pub create mode 100644 db/migrations/002_fix_usernames.sql create mode 100644 scripts/fix-jail-dns.sh diff --git a/Makefile b/Makefile index cdb6813..bb84d9d 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,72 @@ -HOST = 172.16.0.214 +HOST = 172.16.0.101 SSH_USER = root SSH = ssh $(SSH_USER)@$(HOST) SCP = scp - JAIL_NAME = cyclingbot JAIL_ROOT = /jails/$(JAIL_NAME) BINARY = cycling-bot -.PHONY: all build deploy deploy-env restart stop logs status clean +# Postgres jail config +PG_JAIL = postgres +PG_DATA = /var/db/postgres/data16 +PG_IFACE = igb0 +PG_IPS = 172.16.0.215/24 172.16.0.216/24 -all: build deploy start +# Build info (injected via ldflags) +VERSION = $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS = -ldflags "-X main.version=$(VERSION)" + +.PHONY: all build build-native test vet deploy setup deploy-env boot aliases +.PHONY: pg-start pg-stop pg-status start stop restart logs status clean dns-fix + +all: test build deploy start + +# ── Networking ──────────────────────────────────────────────────────────────── +aliases: + $(SSH) "$(foreach ip,$(PG_IPS),ifconfig $(PG_IFACE) alias $(ip) ;) true" + +# ── Postgres ────────────────────────────────────────────────────────────────── +pg-start: + $(SSH) "service jail start $(PG_JAIL) 2>/dev/null || true" + $(SSH) "jexec $(PG_JAIL) su -l postgres -c 'pg_ctl status -D $(PG_DATA)' 2>&1 | grep -q 'server is running' \ + && echo 'postgres already running' \ + || { rm -f /jails/$(PG_JAIL)$(PG_DATA)/postmaster.pid ; \ + jexec $(PG_JAIL) su -l postgres -c 'pg_ctl start -D $(PG_DATA)'; }" + +pg-stop: + $(SSH) "jexec $(PG_JAIL) su -l postgres -c 'pg_ctl stop -D $(PG_DATA) -m fast' 2>/dev/null || true" + +pg-status: + $(SSH) "jexec $(PG_JAIL) su -l postgres -c 'pg_ctl status -D $(PG_DATA)'" + +# ── Full server boot (run this after every reboot) ──────────────────────────── + +boot: aliases + $(SSH) "service jail stop $(JAIL_NAME) 2>/dev/null || true" + $(SSH) "service jail stop $(PG_JAIL) 2>/dev/null || true" + @$(MAKE) pg-start + $(SSH) "service jail start $(JAIL_NAME) 2>/dev/null || true" + $(SSH) "jexec $(JAIL_NAME) route add default 172.16.0.1 2>/dev/null || true" + @$(MAKE) start + @echo "Boot sequence complete." + +# ── Build ───────────────────────────────────────────────────────────────────── # Cross-compile for FreeBSD amd64 build: - GOOS=freebsd GOARCH=amd64 go build -o $(BINARY) . + GOOS=freebsd GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY) . + +# Build for the local (Linux) platform — useful for testing +build-native: + go build $(LDFLAGS) -o $(BINARY) . + +# Run tests +test: + go test ./... + +# Run go vet +vet: + go vet ./... # Create required directories inside the jail (safe to run multiple times) setup: @@ -23,8 +76,8 @@ setup: $(JAIL_ROOT)/var/log \ $(JAIL_ROOT)/var/run" -# Copy binary and rc.d script into the jail (via /tmp to avoid jail dir permission issues) -deploy: build setup +# Copy binary and rc.d script into the jail +deploy: test vet build setup $(SCP) $(BINARY) $(SSH_USER)@$(HOST):/tmp/$(BINARY) $(SCP) rc.d/$(JAIL_NAME) $(SSH_USER)@$(HOST):/tmp/$(JAIL_NAME)-rcd $(SSH) "jexec $(JAIL_NAME) service $(JAIL_NAME) stop 2>/dev/null; \ @@ -40,7 +93,7 @@ deploy: build setup # Copy .env separately (run once — avoid overwriting production config) deploy-env: $(SCP) .env \ - $(SSH_USER)@$(HOST):$(JAIL_ROOT)/var/db/$(JAIL_NAME)/.env + $(SSH_USER)@$(HOST):$(JAIL_ROOT)/var/db/$(JAIL_NAME)/.env $(SSH) "chmod 600 $(JAIL_ROOT)/var/db/$(JAIL_NAME)/.env && \ chown 1001:1001 $(JAIL_ROOT)/var/db/$(JAIL_NAME)/.env" @@ -61,6 +114,18 @@ logs: status: $(SSH) "jls && jexec $(JAIL_NAME) service $(JAIL_NAME) status" + @$(MAKE) pg-status + +# ── Jail DNS ───────────────────────────────────────────────────────────────── +dns-fix: + @echo "=== Current jail DNS ===" + $(SSH) "cat /jails/$(JAIL_NAME)/etc/resolv.conf" + @echo "" + @echo "=== Fixing jail DNS ===" + $(SSH) "printf 'nameserver 1.1.1.1\nnameserver 9.9.9.9\nnameserver 172.16.0.1\n' > /jails/$(JAIL_NAME)/etc/resolv.conf" + @echo "=== New jail DNS ===" + $(SSH) "cat /jails/$(JAIL_NAME)/etc/resolv.conf" clean: rm -f $(BINARY) + go clean diff --git a/bot/bot.go b/bot/bot.go index 24a1e7b..acae79e 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -25,19 +25,20 @@ type Bot struct { session *discordgo.Session db *db.DB guildID string + version string topicMu sync.Mutex topicTimer *time.Timer } -func New(token string, database *db.DB, guildID string) (*Bot, error) { +func New(token string, database *db.DB, guildID, version string) (*Bot, error) { s, err := discordgo.New("Bot " + token) if err != nil { return nil, fmt.Errorf("create session: %w", err) } s.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent - b := &Bot{session: s, db: database, guildID: guildID} + b := &Bot{session: s, db: database, guildID: guildID, version: version} s.AddHandler(b.onMessageCreate) s.AddHandler(b.onMessageDelete) s.AddHandler(b.onInteraction) @@ -115,6 +116,9 @@ func (b *Bot) RegisterCommands() error { {Name: "weeklyreport", Description: "Show distances logged this week"}, {Name: "monthlyreport", Description: "Show distances logged this month"}, + // ── Utility ────────────────────────────────────────────────────────── + {Name: "version", Description: "Show the bot's build version"}, + // ── Admin ──────────────────────────────────────────────────────────── {Name: "addkm", Description: "[Admin] Manually add or subtract KM for a user", Options: []*discordgo.ApplicationCommandOption{ @@ -230,6 +234,11 @@ func (b *Bot) onMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) if m.Author == nil || m.Author.Bot { return } + // Skip messages the bot has already reacted to. + // This prevents duplicate reactions from Discord reconnection event replays. + if hasCheckmark(m.Message) { + return + } ctx := context.Background() channelID, ok, err := b.db.GetSetting(ctx, m.GuildID, settingChannel) @@ -302,12 +311,21 @@ func (b *Bot) onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate case "compare": b.handleCompare(ctx, s, i) case "weeklyreport": b.handleWeeklyReport(ctx, s, i) case "monthlyreport": b.handleMonthlyReport(ctx, s, i) + case "version": b.handleVersion(ctx, s, i) case "addkm": b.handleAddKM(ctx, s, i) case "removelog": b.handleRemoveLog(ctx, s, i) case "audit": b.handleAudit(ctx, s, i) } } +// ── Version ─────────────────────────────────────────────────────────────────── + +func (b *Bot) handleVersion(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + respond(s, i, fmt.Sprintf("Cycling Bot version **%s**", b.version)) +} + + + // ── Topic update ────────────────────────────────────────────────────────────── func (b *Bot) scheduleTopicUpdate(guildID, channelID string) { diff --git a/cyclingboot b/cyclingboot new file mode 100644 index 0000000..aa81831 --- /dev/null +++ b/cyclingboot @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAvcWc5cl3rOERkrmUp4wiutUvzdo/tk6U2yiKwK0ilGQAAAJi12Uv7tdlL ++wAAAAtzc2gtZWQyNTUxOQAAACAvcWc5cl3rOERkrmUp4wiutUvzdo/tk6U2yiKwK0ilGQ +AAAEBKrlyeBnkQPdNn9/Anm7PLs6xqVCTUhFqAw4DMaNgFuC9xZzlyXes4RGSuZSnjCK61 +S/N2j+2TpTbKIrArSKUZAAAAEWN5Y2xpbmdib3QtZGVwbG95AQIDBA== +-----END OPENSSH PRIVATE KEY----- diff --git a/cyclingboot.pub b/cyclingboot.pub new file mode 100644 index 0000000..abba053 --- /dev/null +++ b/cyclingboot.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC9xZzlyXes4RGSuZSnjCK61S/N2j+2TpTbKIrArSKUZ cyclingbot-deploy diff --git a/db/db.go b/db/db.go index 80483c9..9ff0cfb 100644 --- a/db/db.go +++ b/db/db.go @@ -148,6 +148,15 @@ func (d *DB) AddLog(ctx context.Context, guildID, userID, username, messageID, c return false, fmt.Errorf("insert log: %w", err) } rows, _ := res.RowsAffected() + if rows > 0 { + // Sync the display name across all existing logs for this user. + // This prevents duplicate leaderboard entries when a user changes + // their Discord display name. + _, _ = d.conn.ExecContext(ctx, ` + UPDATE distance_logs SET username = $1 + WHERE guild_id = $2 AND user_id = $3 AND username != $1 + `, username, guildID, userID) + } return rows > 0, nil } diff --git a/db/migrations/002_fix_usernames.sql b/db/migrations/002_fix_usernames.sql new file mode 100644 index 0000000..d8621d7 --- /dev/null +++ b/db/migrations/002_fix_usernames.sql @@ -0,0 +1,22 @@ +-- One-time migration: backfill old username entries when users change display names. +-- The write-time UPDATE in db.go AddLog() handles future changes automatically. +-- Run once after deploying: psql -d YOUR_DATABASE_URL -f db/migrations/002_fix_usernames.sql + +UPDATE distance_logs d +SET username = ( + SELECT username + FROM distance_logs sub + WHERE sub.user_id = d.user_id + AND sub.guild_id = d.guild_id + ORDER BY logged_at DESC + LIMIT 1 +) +WHERE username <> ( + SELECT username + FROM distance_logs sub + WHERE sub.user_id = d.user_id + AND sub.guild_id = d.guild_id + ORDER BY logged_at DESC + LIMIT 1 +); + diff --git a/main.go b/main.go index 5fc0a74..2139fe4 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,9 @@ import ( "cycling-discord-bot/db" ) +// Build version, set via ldflags at build time (see Makefile) +var version = "dev" + func main() { _ = godotenv.Load() @@ -26,7 +29,7 @@ func main() { } defer database.Close() - b, err := bot.New(token, database, guildID) + b, err := bot.New(token, database, guildID, version) if err != nil { log.Fatalf("create bot: %v", err) } @@ -39,6 +42,8 @@ func main() { // Give the session a moment to identify before registering commands time.Sleep(500 * time.Millisecond) + log.Printf("starting cycling-discord-bot version %s", version) + if err := b.RegisterCommands(); err != nil { log.Fatalf("register commands: %v", err) } diff --git a/scripts/fix-jail-dns.sh b/scripts/fix-jail-dns.sh new file mode 100644 index 0000000..c6d9b47 --- /dev/null +++ b/scripts/fix-jail-dns.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Fix DNS for the cyclingbot jail. +# The jail's resolv.conf currently points to 8.8.8.8 which is unreachable +# from the 172.16.0.x network. Replace with reachable nameservers. +# +# Usage: sh scripts/fix-jail-dns.sh +# or: ssh root@172.16.0.101 "sh -s" < scripts/fix-jail-dns.sh + +JAIL_ROOT="/jails/cyclingbot" +RESOLV_CONF="${JAIL_ROOT}/etc/resolv.conf" + +if [ ! -f "$RESOLV_CONF" ]; then + echo "Error: $RESOLV_CONF not found (is the jail running?)" + exit 1 +fi + +echo "Current contents:" +cat "$RESOLV_CONF" +echo "" + +cat > "$RESOLV_CONF" << 'EOF' +nameserver 1.1.1.1 +nameserver 9.9.9.9 +nameserver 172.16.0.1 +search cassville.internal +EOF + +echo "Updated contents:" +cat "$RESOLV_CONF" +echo "" +echo "DNS configuration updated." +