Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1 +1,14 @@
ENV="staging"
BOT_TOKEN="your-bot-token"
DATABASE_URL= #"postgres://pen_bot:pen_bot_password@postgres:5432/pen_bot?sslmode=disable"
DB_NAME="pen_bot"
DB_HOST="localhost"
DB_PORT="5432"
DB_USER="pen_bot"
DB_PASSWORD="pen_bot_password"
DB_BOT_INSTANCE_ID="pen-bot-1"
DB_MAX_OPEN_CONNS="16"
DB_MAX_IDLE_CONNS="4"
DB_CONN_MAX_LIFETIME="30m"
DB_CACHE_CLEANUP_INTERVAL="15m"
POSTGRES_PASSWORD="pen_bot_password"
20 changes: 20 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,27 @@ services:
pull_policy: build
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
develop:
watch:
- action: rebuild
path: .

postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: pen_bot
POSTGRES_USER: pen_bot
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pen_bot_password}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5

volumes:
postgres_data:
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ go 1.26.2

require github.com/disgoorg/disgo v0.19.3

require github.com/lib/pq v1.12.3

require (
github.com/disgoorg/godave v0.1.0 // indirect
github.com/disgoorg/json/v2 v2.0.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad h1:qIQkSlF5vAUHxEmTbaqt1hkJ/t6skqEGYiMag343ucI=
Expand Down
64 changes: 62 additions & 2 deletions internal/core/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (
"strings"
"sync"
"syscall"
"time"

"github.com/Neon-Genesis-Linux/pen-bot/internal/db"
"github.com/disgoorg/disgo"
"github.com/disgoorg/disgo/bot"
"github.com/disgoorg/disgo/events"
Expand Down Expand Up @@ -78,10 +80,68 @@ func Start(ctx context.Context, token string, listener func(*events.MessageCreat
return err
}

cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
defer cleanupCancel()
cleanupInterval := db.ParseCleanupIntervalEnv("DB_CACHE_CLEANUP_INTERVAL", 15)
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("cache cleanup panicked", "recover", r)
}
}()
db.StartCleanup(cleanupCtx, cleanupInterval)
}()

go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("db connect panicked", "recover", r)
}
}()
connectDBWithRetry(ctx)
}()

defer db.CloseDB()

slog.Info("pen bot is now running. Press CTRL-C to exit.")
s := make(chan os.Signal, 1)
signal.Notify(s, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
<-s

return nil
select {
case <-ctx.Done():
return ctx.Err()
case <-s:
return nil
}
}

func connectDBWithRetry(ctx context.Context) {
var dbClient *db.DB
var err error

for attempt := range 10 {
dbClient, err = db.NewFromEnv()
if err == nil {
break
}
slog.Warn("db connection attempt failed", "attempt", attempt+1, "error", err)
select {
case <-ctx.Done():
return
case <-time.After(time.Duration(attempt+1) * 2 * time.Second):
}
}
if err != nil {
slog.Error("db unavailable after retries", "error", err)
return
}

if err := db.ApplyMigrations(ctx, dbClient); err != nil {
slog.Error("db migration failed", "error", err)
_ = dbClient.Close()
return
}

db.SetGlobalDB(dbClient)
slog.Info("db connected and ready")
}
99 changes: 99 additions & 0 deletions internal/core/start_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package core

import (
"testing"

"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
)

func TestDispatchExactMatch(t *testing.T) {
called := false
RegisterCommand("test-exact", func(e *events.MessageCreate) { called = true })
defer delete(commandRegistry, "test-exact")

DispatchCommand(&events.MessageCreate{
GenericMessage: &events.GenericMessage{
Message: discord.Message{
Content: "!test-exact",
Author: discord.User{Bot: false},
},
},
})

if !called {
t.Fatal("expected handler called")
}
}

func TestDispatchIgnoresBot(t *testing.T) {
called := false
RegisterCommand("test-bot", func(e *events.MessageCreate) { called = true })
defer delete(commandRegistry, "test-bot")

DispatchCommand(&events.MessageCreate{
GenericMessage: &events.GenericMessage{
Message: discord.Message{
Content: "!test-bot",
Author: discord.User{Bot: true},
},
},
})

if called {
t.Fatal("expected bot message ignored")
}
}

func TestDispatchNoPrefix(t *testing.T) {
called := false
RegisterCommand("test-noprefix", func(e *events.MessageCreate) { called = true })
defer delete(commandRegistry, "test-noprefix")

DispatchCommand(&events.MessageCreate{
GenericMessage: &events.GenericMessage{
Message: discord.Message{
Content: "test-noprefix",
Author: discord.User{Bot: false},
},
},
})

if called {
t.Fatal("expected no dispatch without prefix")
}
}

func TestDispatchUnknownCommand(t *testing.T) {
DispatchCommand(&events.MessageCreate{
GenericMessage: &events.GenericMessage{
Message: discord.Message{
Content: "!unknown-cmd",
Author: discord.User{Bot: false},
},
},
})
}

func TestCustomPrefix(t *testing.T) {
oldPrefix := botPrefix
botPrefix = "?"
t.Cleanup(func() { botPrefix = oldPrefix })

called := false
RegisterCommand("test-custom", func(e *events.MessageCreate) { called = true })
defer delete(commandRegistry, "test-custom")

DispatchCommand(&events.MessageCreate{
GenericMessage: &events.GenericMessage{
Message: discord.Message{
Content: "?test-custom",
Author: discord.User{Bot: false},
},
},
})

if !called {
t.Fatal("expected handler called with custom prefix")
}
}
70 changes: 70 additions & 0 deletions internal/db/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package db

import (
"context"
"sync"
"time"
)

type memCacheEntry struct {
data []byte
expiresAt time.Time
}

var (
mc = make(map[string]memCacheEntry)
mcMu sync.RWMutex
)

func SetCacheEntry(key string, valueJSON []byte, ttl time.Duration) {
mcMu.Lock()
defer mcMu.Unlock()
var expiresAt time.Time
if ttl > 0 {
expiresAt = time.Now().Add(ttl)
}
mc[key] = memCacheEntry{data: valueJSON, expiresAt: expiresAt}
}

func GetCacheEntry(key string) ([]byte, bool) {
mcMu.RLock()
e, ok := mc[key]
mcMu.RUnlock()
if !ok {
return nil, false
}
if !e.expiresAt.IsZero() && time.Now().After(e.expiresAt) {
mcMu.Lock()
delete(mc, key)
mcMu.Unlock()
return nil, false
}
return e.data, true
}

func DeleteCacheEntry(key string) {
mcMu.Lock()
defer mcMu.Unlock()
delete(mc, key)
}

func StartCleanup(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
return
case <-ticker.C:
mcMu.Lock()
now := time.Now()
for k, e := range mc {
if !e.expiresAt.IsZero() && now.After(e.expiresAt) {
delete(mc, k)
}
}
mcMu.Unlock()
}
}
}
Loading