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 <blake@blakeridgway.com>
This commit is contained in:
79
Makefile
79
Makefile
@@ -1,19 +1,72 @@
|
|||||||
HOST = 172.16.0.214
|
HOST = 172.16.0.101
|
||||||
SSH_USER = root
|
SSH_USER = root
|
||||||
SSH = ssh $(SSH_USER)@$(HOST)
|
SSH = ssh $(SSH_USER)@$(HOST)
|
||||||
SCP = scp
|
SCP = scp
|
||||||
|
|
||||||
JAIL_NAME = cyclingbot
|
JAIL_NAME = cyclingbot
|
||||||
JAIL_ROOT = /jails/$(JAIL_NAME)
|
JAIL_ROOT = /jails/$(JAIL_NAME)
|
||||||
BINARY = cycling-bot
|
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
|
# Cross-compile for FreeBSD amd64
|
||||||
build:
|
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)
|
# Create required directories inside the jail (safe to run multiple times)
|
||||||
setup:
|
setup:
|
||||||
@@ -23,8 +76,8 @@ setup:
|
|||||||
$(JAIL_ROOT)/var/log \
|
$(JAIL_ROOT)/var/log \
|
||||||
$(JAIL_ROOT)/var/run"
|
$(JAIL_ROOT)/var/run"
|
||||||
|
|
||||||
# Copy binary and rc.d script into the jail (via /tmp to avoid jail dir permission issues)
|
# Copy binary and rc.d script into the jail
|
||||||
deploy: build setup
|
deploy: test vet build setup
|
||||||
$(SCP) $(BINARY) $(SSH_USER)@$(HOST):/tmp/$(BINARY)
|
$(SCP) $(BINARY) $(SSH_USER)@$(HOST):/tmp/$(BINARY)
|
||||||
$(SCP) rc.d/$(JAIL_NAME) $(SSH_USER)@$(HOST):/tmp/$(JAIL_NAME)-rcd
|
$(SCP) rc.d/$(JAIL_NAME) $(SSH_USER)@$(HOST):/tmp/$(JAIL_NAME)-rcd
|
||||||
$(SSH) "jexec $(JAIL_NAME) service $(JAIL_NAME) stop 2>/dev/null; \
|
$(SSH) "jexec $(JAIL_NAME) service $(JAIL_NAME) stop 2>/dev/null; \
|
||||||
@@ -61,6 +114,18 @@ logs:
|
|||||||
|
|
||||||
status:
|
status:
|
||||||
$(SSH) "jls && jexec $(JAIL_NAME) service $(JAIL_NAME) 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:
|
clean:
|
||||||
rm -f $(BINARY)
|
rm -f $(BINARY)
|
||||||
|
go clean
|
||||||
|
|||||||
22
bot/bot.go
22
bot/bot.go
@@ -25,19 +25,20 @@ type Bot struct {
|
|||||||
session *discordgo.Session
|
session *discordgo.Session
|
||||||
db *db.DB
|
db *db.DB
|
||||||
guildID string
|
guildID string
|
||||||
|
version string
|
||||||
|
|
||||||
topicMu sync.Mutex
|
topicMu sync.Mutex
|
||||||
topicTimer *time.Timer
|
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)
|
s, err := discordgo.New("Bot " + token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create session: %w", err)
|
return nil, fmt.Errorf("create session: %w", err)
|
||||||
}
|
}
|
||||||
s.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent
|
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.onMessageCreate)
|
||||||
s.AddHandler(b.onMessageDelete)
|
s.AddHandler(b.onMessageDelete)
|
||||||
s.AddHandler(b.onInteraction)
|
s.AddHandler(b.onInteraction)
|
||||||
@@ -115,6 +116,9 @@ func (b *Bot) RegisterCommands() error {
|
|||||||
{Name: "weeklyreport", Description: "Show distances logged this week"},
|
{Name: "weeklyreport", Description: "Show distances logged this week"},
|
||||||
{Name: "monthlyreport", Description: "Show distances logged this month"},
|
{Name: "monthlyreport", Description: "Show distances logged this month"},
|
||||||
|
|
||||||
|
// ── Utility ──────────────────────────────────────────────────────────
|
||||||
|
{Name: "version", Description: "Show the bot's build version"},
|
||||||
|
|
||||||
// ── Admin ────────────────────────────────────────────────────────────
|
// ── Admin ────────────────────────────────────────────────────────────
|
||||||
{Name: "addkm", Description: "[Admin] Manually add or subtract KM for a user",
|
{Name: "addkm", Description: "[Admin] Manually add or subtract KM for a user",
|
||||||
Options: []*discordgo.ApplicationCommandOption{
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
@@ -230,6 +234,11 @@ func (b *Bot) onMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate)
|
|||||||
if m.Author == nil || m.Author.Bot {
|
if m.Author == nil || m.Author.Bot {
|
||||||
return
|
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()
|
ctx := context.Background()
|
||||||
|
|
||||||
channelID, ok, err := b.db.GetSetting(ctx, m.GuildID, settingChannel)
|
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 "compare": b.handleCompare(ctx, s, i)
|
||||||
case "weeklyreport": b.handleWeeklyReport(ctx, s, i)
|
case "weeklyreport": b.handleWeeklyReport(ctx, s, i)
|
||||||
case "monthlyreport": b.handleMonthlyReport(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 "addkm": b.handleAddKM(ctx, s, i)
|
||||||
case "removelog": b.handleRemoveLog(ctx, s, i)
|
case "removelog": b.handleRemoveLog(ctx, s, i)
|
||||||
case "audit": b.handleAudit(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 ──────────────────────────────────────────────────────────────
|
// ── Topic update ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (b *Bot) scheduleTopicUpdate(guildID, channelID string) {
|
func (b *Bot) scheduleTopicUpdate(guildID, channelID string) {
|
||||||
|
|||||||
7
cyclingboot
Normal file
7
cyclingboot
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||||
|
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||||
|
QyNTUxOQAAACAvcWc5cl3rOERkrmUp4wiutUvzdo/tk6U2yiKwK0ilGQAAAJi12Uv7tdlL
|
||||||
|
+wAAAAtzc2gtZWQyNTUxOQAAACAvcWc5cl3rOERkrmUp4wiutUvzdo/tk6U2yiKwK0ilGQ
|
||||||
|
AAAEBKrlyeBnkQPdNn9/Anm7PLs6xqVCTUhFqAw4DMaNgFuC9xZzlyXes4RGSuZSnjCK61
|
||||||
|
S/N2j+2TpTbKIrArSKUZAAAAEWN5Y2xpbmdib3QtZGVwbG95AQIDBA==
|
||||||
|
-----END OPENSSH PRIVATE KEY-----
|
||||||
1
cyclingboot.pub
Normal file
1
cyclingboot.pub
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC9xZzlyXes4RGSuZSnjCK61S/N2j+2TpTbKIrArSKUZ cyclingbot-deploy
|
||||||
9
db/db.go
9
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)
|
return false, fmt.Errorf("insert log: %w", err)
|
||||||
}
|
}
|
||||||
rows, _ := res.RowsAffected()
|
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
|
return rows > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
db/migrations/002_fix_usernames.sql
Normal file
22
db/migrations/002_fix_usernames.sql
Normal file
@@ -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
|
||||||
|
);
|
||||||
|
|
||||||
7
main.go
7
main.go
@@ -13,6 +13,9 @@ import (
|
|||||||
"cycling-discord-bot/db"
|
"cycling-discord-bot/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Build version, set via ldflags at build time (see Makefile)
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
|
|
||||||
@@ -26,7 +29,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
b, err := bot.New(token, database, guildID)
|
b, err := bot.New(token, database, guildID, version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("create bot: %v", err)
|
log.Fatalf("create bot: %v", err)
|
||||||
}
|
}
|
||||||
@@ -39,6 +42,8 @@ func main() {
|
|||||||
// Give the session a moment to identify before registering commands
|
// Give the session a moment to identify before registering commands
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
log.Printf("starting cycling-discord-bot version %s", version)
|
||||||
|
|
||||||
if err := b.RegisterCommands(); err != nil {
|
if err := b.RegisterCommands(); err != nil {
|
||||||
log.Fatalf("register commands: %v", err)
|
log.Fatalf("register commands: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
32
scripts/fix-jail-dns.sh
Normal file
32
scripts/fix-jail-dns.sh
Normal file
@@ -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."
|
||||||
|
|
||||||
Reference in New Issue
Block a user