diff --git a/.gitignore b/.gitignore index e10e601..8b36234 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,8 @@ .env.test.local .env.production.local .env -himbot \ No newline at end of file +himbot +*.db +*.db-client_wal_index +*.db-shm +*.db-wal diff --git a/go.mod b/go.mod index f5a8e50..1f918f0 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,13 @@ module himbot go 1.23 +require github.com/tursodatabase/go-libsql v0.0.0-20241011135853-3effbb6dea5c + require ( + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/libsql/sqlite-antlr4-parser v0.0.0-20240721121621-c0bdc870f11c // indirect golang.org/x/crypto v0.28.0 // indirect + golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect golang.org/x/sys v0.26.0 // indirect ) diff --git a/go.sum b/go.sum index e495524..7dcb545 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,33 @@ +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4= github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/libsql/sqlite-antlr4-parser v0.0.0-20240721121621-c0bdc870f11c h1:WsJ6G+hkDXIMfQE8FIxnnziT26WmsRgZhdWQ0IQGlcc= +github.com/libsql/sqlite-antlr4-parser v0.0.0-20240721121621-c0bdc870f11c/go.mod h1:gIcFddvsvPcRCO6QDmWH9/zcFd5U26QWWRMgZh4ddyo= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/tursodatabase/go-libsql v0.0.0-20241011135853-3effbb6dea5c h1:a8TrFzP+zK+uYcMWuLQoNOR78SG/yISSnHwMIcyWa2Q= +github.com/tursodatabase/go-libsql v0.0.0-20241011135853-3effbb6dea5c/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= +golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/lib/command.go b/lib/command.go index f5f2fdb..66dddde 100644 --- a/lib/command.go +++ b/lib/command.go @@ -1,6 +1,7 @@ package lib import ( + "log" "time" "github.com/bwmarrin/discordgo" @@ -10,34 +11,54 @@ type CommandFunc func(s *discordgo.Session, i *discordgo.InteractionCreate) (str func HandleCommand(commandName string, cooldownDuration time.Duration, handler CommandFunc) func(s *discordgo.Session, i *discordgo.InteractionCreate) { return func(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Get user information first + user, userErr := GetUser(i) + if userErr != nil { + RespondWithError(s, i, "Error processing command: "+userErr.Error()) + return + } + + // Store user in database + dbErr := StoreUser(user.ID, user.Username) + if dbErr != nil { + // Log the error but don't stop command execution + log.Printf("Error storing user: %v", dbErr) + } + + // Rest of your existing HandleCommand logic... + if !CheckAndApplyCooldown(s, i, commandName, cooldownDuration) { + return + } + if !CheckAndApplyCooldown(s, i, commandName, cooldownDuration) { return } // Acknowledge the interaction immediately - err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + interactErr := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, }) - if err != nil { - ThrowWithError(commandName, "Error deferring response: "+err.Error()) + + if interactErr != nil { + ThrowWithError(commandName, "Error deferring response: "+interactErr.Error()) return } // Execute the command handler - response, err := handler(s, i) + response, handlerErr := handler(s, i) - if err != nil { - RespondWithError(s, i, "Error processing command: "+err.Error()) + if handlerErr != nil { + RespondWithError(s, i, "Error processing command: "+handlerErr.Error()) return } // Send the follow-up message with the response - _, err = s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ + _, followErr := s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ Content: response, }) - if err != nil { - ThrowWithError(commandName, "Error sending follow-up message: "+err.Error()) + if followErr != nil { + ThrowWithError(commandName, "Error sending follow-up message: "+followErr.Error()) } } } diff --git a/lib/db.go b/lib/db.go new file mode 100644 index 0000000..02f0b29 --- /dev/null +++ b/lib/db.go @@ -0,0 +1,169 @@ +package lib + +import ( + "database/sql" + "fmt" + "io/ioutil" + "log" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/tursodatabase/go-libsql" +) + +var DBClient *sql.DB + +func InitDB() error { + dbUrl := os.Getenv("DATABASE_URL") + dbToken := os.Getenv("DATABASE_AUTH_TOKEN") + + if dbUrl == "" || dbToken == "" { + return fmt.Errorf("database configuration missing") + } + + connector, err := libsql.NewEmbeddedReplicaConnector( + "./himbot.db", + dbUrl, + libsql.WithAuthToken(dbToken), + ) + + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + client := sql.OpenDB(connector) + DBClient = client + + return runMigrations() +} + +type Migration struct { + Version int + Up string + Down string +} + +func loadMigrations() ([]Migration, error) { + var migrations []Migration + migrationFiles, err := filepath.Glob("migrations/*.up.sql") + if err != nil { + return nil, fmt.Errorf("failed to read migration files: %w", err) + } + + for _, upFile := range migrationFiles { + // Extract version from filename (000001_create_users_table.up.sql -> 1) + baseName := filepath.Base(upFile) + version := 0 + fmt.Sscanf(baseName, "%d_", &version) + + downFile := strings.Replace(upFile, ".up.sql", ".down.sql", 1) + + upSQL, err := ioutil.ReadFile(upFile) + if err != nil { + return nil, fmt.Errorf("failed to read migration file %s: %w", upFile, err) + } + + downSQL, err := ioutil.ReadFile(downFile) + if err != nil { + return nil, fmt.Errorf("failed to read migration file %s: %w", downFile, err) + } + + migrations = append(migrations, Migration{ + Version: version, + Up: string(upSQL), + Down: string(downSQL), + }) + } + + sort.Slice(migrations, func(i, j int) bool { + return migrations[i].Version < migrations[j].Version + }) + + return migrations, nil +} + +func runMigrations() error { + // Create migrations table if it doesn't exist + _, err := DBClient.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`) + if err != nil { + return fmt.Errorf("failed to create schema_migrations table: %w", err) + } + + migrations, err := loadMigrations() + if err != nil { + return err + } + + for _, migration := range migrations { + var exists bool + err := DBClient.QueryRow( + "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = ?)", + migration.Version).Scan(&exists) + if err != nil { + return fmt.Errorf("failed to check migration status: %w", err) + } + + if !exists { + tx, err := DBClient.Begin() + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + + // Run migration + _, err = tx.Exec(migration.Up) + if err != nil { + tx.Rollback() + return fmt.Errorf("failed to apply migration %d: %w", migration.Version, err) + } + + // Record migration + _, err = tx.Exec( + "INSERT INTO schema_migrations (version) VALUES (?)", + migration.Version) + if err != nil { + tx.Rollback() + return fmt.Errorf("failed to record migration %d: %w", migration.Version, err) + } + + err = tx.Commit() + if err != nil { + return fmt.Errorf("failed to commit migration %d: %w", migration.Version, err) + } + + log.Printf("Applied migration %d", migration.Version) + } + } + + log.Println("Database migrations completed successfully") + return nil +} + +func StoreUser(discordID, username string) error { + // Check if user exists + var exists bool + err := DBClient.QueryRow( + "SELECT EXISTS(SELECT 1 FROM users WHERE discord_id = ?)", + discordID).Scan(&exists) + if err != nil { + return fmt.Errorf("failed to check user existence: %w", err) + } + + // If user doesn't exist, insert them + if !exists { + _, err = DBClient.Exec( + "INSERT INTO users (discord_id, username) VALUES (?, ?)", + discordID, username) + if err != nil { + return fmt.Errorf("failed to store user: %w", err) + } + log.Printf("New user stored: %s (%s)", username, discordID) + } + + return nil +} diff --git a/main.go b/main.go index df3b983..4545c4b 100644 --- a/main.go +++ b/main.go @@ -55,6 +55,11 @@ var ( func main() { godotenv.Load(".env") + err := lib.InitDB() + if err != nil { + log.Fatalf("Failed to initialize database: %v", err) + } + token := os.Getenv("DISCORD_TOKEN") if token == "" { @@ -83,6 +88,10 @@ func main() { signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) <-sc + if lib.DBClient != nil { + lib.DBClient.Close() + } + dg.Close() } diff --git a/migrations/000001_create_users_table.down.sql b/migrations/000001_create_users_table.down.sql new file mode 100644 index 0000000..c99ddcd --- /dev/null +++ b/migrations/000001_create_users_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/migrations/000001_create_users_table.up.sql b/migrations/000001_create_users_table.up.sql new file mode 100644 index 0000000..2b12777 --- /dev/null +++ b/migrations/000001_create_users_table.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + discord_id TEXT NOT NULL UNIQUE, + username TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/000002_create_schema_migrations.down.sql b/migrations/000002_create_schema_migrations.down.sql new file mode 100644 index 0000000..c6b4173 --- /dev/null +++ b/migrations/000002_create_schema_migrations.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS schema_migrations; diff --git a/migrations/000002_create_schema_migrations.up.sql b/migrations/000002_create_schema_migrations.up.sql new file mode 100644 index 0000000..3741469 --- /dev/null +++ b/migrations/000002_create_schema_migrations.up.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP +);