From f78e33eca2fff5f77249a046a6b4d95c762b9d94 Mon Sep 17 00:00:00 2001 From: ION606 Date: Tue, 8 Apr 2025 15:21:23 -0400 Subject: [PATCH] added suggestion command --- .gitignore | 2 + Bot/Commands/reactionrole.go | 40 +---------- Bot/Commands/suggest.go | 130 +++++++++++++++++++++++++++++++++++ Bot/Helpers/suggestion_db.go | 70 +++++++++++++++++++ Bot/bot.go | 23 ++++--- Bot/register.go | 1 + Dockerfile | 11 ++- Makefile | 4 +- go.mod | 2 + go.sum | 2 + 10 files changed, 234 insertions(+), 51 deletions(-) create mode 100644 Bot/Commands/suggest.go create mode 100644 Bot/Helpers/suggestion_db.go diff --git a/.gitignore b/.gitignore index d43a39d..43175c3 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ go.work .env +*.xml +data/ diff --git a/Bot/Commands/reactionrole.go b/Bot/Commands/reactionrole.go index 655c0e0..edaebd5 100644 --- a/Bot/Commands/reactionrole.go +++ b/Bot/Commands/reactionrole.go @@ -1,47 +1,13 @@ package commands import ( - "encoding/json" "fmt" - "os" + "github.com/bwmarrin/discordgo" "strings" helpers "ion606_bot/Bot/Helpers" - - "github.com/bwmarrin/discordgo" ) -// reactionRoleTarget maps an interaction’s ID to its target message ID -var reactionRoleTarget = make(map[string]string) - -// SaveReactionRoleTarget saves the reactionRoleTarget map to a JSON file. -func SaveReactionRoleTarget(filename string) error { - data, err := json.Marshal(reactionRoleTarget) - - if err != nil { - return fmt.Errorf("failed to marshal reactionRoleTarget: %w", err) - } - - if err := os.WriteFile(filename, data, 0644); err != nil { - return fmt.Errorf("failed to write reactionRoleTarget file: %w", err) - } - return nil -} - -// LoadReactionRoleTarget loads the reactionRoleTarget map from a JSON file. -func LoadReactionRoleTarget(filename string) error { - data, err := os.ReadFile(filename) - - if err != nil { - return fmt.Errorf("failed to read reactionRoleTarget file: %w", err) - } - - if err := json.Unmarshal(data, &reactionRoleTarget); err != nil { - return fmt.Errorf("failed to unmarshal reactionRoleTarget: %w", err) - } - return nil -} - // Convert the manage roles permission into an int64 pointer for command registration. func manageRolesPerm() *int64 { fmt.Println("checking perms") @@ -214,8 +180,6 @@ func HandleReactionRole(s *discordgo.Session, i *discordgo.InteractionCreate) { if opt := helpers.GetOption(options, "message_id"); opt != nil { if val := opt.StringValue(); val != "" { targetMsgID = val - // Store the target message ID keyed by the user's ID. - reactionRoleTarget[i.Member.User.ID] = targetMsgID } } @@ -315,7 +279,7 @@ func HandleReactionRoleModalSubmit(s *discordgo.Session, i *discordgo.Interactio if !ok { continue } - + for _, innerComp := range row.Components { textInput, ok := innerComp.(*discordgo.TextInput) if !ok { diff --git a/Bot/Commands/suggest.go b/Bot/Commands/suggest.go new file mode 100644 index 0000000..2e688d2 --- /dev/null +++ b/Bot/Commands/suggest.go @@ -0,0 +1,130 @@ +package commands + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + helpers "ion606_bot/Bot/Helpers" + + "github.com/bwmarrin/discordgo" +) + +var SuggestCommand = &discordgo.ApplicationCommand{ + Name: "suggest", + Description: "Submit a suggestion to the server", +} + +func HandleSuggest(s *discordgo.Session, i *discordgo.InteractionCreate) { + userID := i.Member.User.ID + + // Check cooldown from DB + lastSubmission, err := helpers.GetLastSubmission(userID) + if err != nil { + helpers.HandleError(s, i, fmt.Errorf("failed to check cooldown: %w", err)) + return + } + + thirtyMins := time.Minute * time.Duration(30) + + if time.Since(lastSubmission) < (thirtyMins) { + remaining := time.Until(lastSubmission.Add(thirtyMins)).Round(time.Minute) + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: fmt.Sprintf("⏳ You can submit another suggestion in %d minutes!", int(remaining.Minutes())), + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Store new submission time + if err := helpers.SetLastSubmission(userID); err != nil { + helpers.HandleError(s, i, fmt.Errorf("failed to update cooldown: %w", err)) + return + } + + // Create modal with suggestion input + err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseModal, + Data: &discordgo.InteractionResponseData{ + CustomID: "suggestion_modal", + Title: "Submit Suggestion", + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "suggestion_content", + Label: "Your suggestion", + Style: discordgo.TextInputParagraph, + Placeholder: "Type your suggestion here...", + Required: true, + MaxLength: 1000, + }, + }, + }, + }, + }, + }) + + if err != nil { + helpers.HandleError(s, i, fmt.Errorf("failed to create modal: %w", err)) + } +} + +func HandleSuggestModal(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Extract suggestion from modal + data := i.ModalSubmitData() + content := data.Components[0].(*discordgo.ActionsRow).Components[0].(*discordgo.TextInput).Value + + // Get webhook URL from environment + webhookURL := os.Getenv("WEBHOOK_URL") + if webhookURL == "" { + helpers.HandleError(s, i, fmt.Errorf("WEBHOOK_URL environment variable not set")) + return + } + + // Create payload + payload := map[string]string{ + "content": fmt.Sprintf("**New Suggestion from <@%s> (%s)**\n```\n%s\n```", + i.Member.User.ID, + i.Member.User.Username, + content), + } + + jsonData, err := json.Marshal(payload) + if err != nil { + helpers.HandleError(s, i, fmt.Errorf("failed to marshal payload: %w", err)) + return + } + + // Send to webhook + resp, err := http.Post(webhookURL, "application/json", strings.NewReader(string(jsonData))) + if err != nil { + helpers.HandleError(s, i, fmt.Errorf("webhook request failed: %w", err)) + return + } + defer resp.Body.Close() + + // Verify successful response + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + helpers.HandleError(s, i, fmt.Errorf("webhook returned %d: %s", resp.StatusCode, string(body))) + return + } + + // Send confirmation to user + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "✅ Your suggestion has been submitted!", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} diff --git a/Bot/Helpers/suggestion_db.go b/Bot/Helpers/suggestion_db.go new file mode 100644 index 0000000..19a3e4b --- /dev/null +++ b/Bot/Helpers/suggestion_db.go @@ -0,0 +1,70 @@ +package helpers + +import ( + "database/sql" + "os" + "path/filepath" + "sync" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +var ( + db *sql.DB + dbOnce sync.Once +) + +func InitSuggestionDB() (*sql.DB, error) { + var err error + dbOnce.Do(func() { + // Create data directory if it doesn't exist + dataDir := "data" + if err = os.MkdirAll(dataDir, 0755); err != nil { + return + } + + dbPath := filepath.Join(dataDir, "suggestions.db") + db, err = sql.Open("sqlite3", dbPath) + if err != nil { + return + } + + // Create table if it doesn't exist + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS suggestion_cooldowns ( + user_id TEXT PRIMARY KEY, + last_submission TIMESTAMP + ) + `) + }) + return db, err +} + +func GetLastSubmission(userID string) (time.Time, error) { + var lastSubmission time.Time + err := db.QueryRow(` + SELECT last_submission FROM suggestion_cooldowns + WHERE user_id = ? + `, userID).Scan(&lastSubmission) + + if err == sql.ErrNoRows { + return time.Time{}, nil // No record exists + } + return lastSubmission, err +} + +func SetLastSubmission(userID string) error { + _, err := db.Exec(` + INSERT OR REPLACE INTO suggestion_cooldowns + (user_id, last_submission) VALUES (?, ?) + `, userID, time.Now()) + return err +} + +func CloseDB() error { + if db != nil { + return db.Close() + } + return nil +} diff --git a/Bot/bot.go b/Bot/bot.go index c44bb41..fc8ae1a 100644 --- a/Bot/bot.go +++ b/Bot/bot.go @@ -7,6 +7,7 @@ import ( "strings" commands "ion606_bot/Bot/Commands" + helpers "ion606_bot/Bot/Helpers" "github.com/bwmarrin/discordgo" ) @@ -25,6 +26,7 @@ var commandHandlers = map[string]func(*discordgo.Session, *discordgo.Interaction "animalgif": commands.HandleAnimalGif, "action": commands.HandleAction, "reactionrole": commands.HandleReactionRole, + "suggest": commands.HandleSuggest, } // Run starts the Discord bot session and listens for both message and slash command events. @@ -35,12 +37,6 @@ func Run() { log.Fatal("Error creating Discord session: ", err) } - // Load reaction role target map from file. - err = commands.LoadReactionRoleTarget("reactionRoleTarget.json") - if err != nil { - log.Printf("No reaction role target config loaded: %v", err) - } - // add event handlers for messages and interactions. discord.AddHandler(newMessage) discord.AddHandler(handleInteractionCreate) @@ -50,6 +46,11 @@ func Run() { log.Fatal("Error opening Discord session: ", err) } + _, err = helpers.InitSuggestionDB() + if err != nil { + log.Printf("Failed to initialize suggestion DB: %v", err) + } + RegisterCommands(discord, "") log.Println("Bot running....") @@ -59,10 +60,9 @@ func Run() { signal.Notify(c, os.Interrupt) <-c - // Save reaction role target map to file on shutdown. - err = commands.SaveReactionRoleTarget("reactionRoleTarget.json") + err = helpers.CloseDB() if err != nil { - log.Println("Error saving reaction role target:", err) + log.Println("Error closing suggestion DB:", err) } err = discord.Close() @@ -85,6 +85,11 @@ func handleInteractionCreate(s *discordgo.Session, i *discordgo.InteractionCreat commands.HandleReactionRoleModalSubmit(s, i) return } + + if i.ModalSubmitData().CustomID == "suggestion_modal" { + commands.HandleSuggestModal(s, i) + return + } } // Then check for message components (buttons). diff --git a/Bot/register.go b/Bot/register.go index a6382c6..19cbc7a 100644 --- a/Bot/register.go +++ b/Bot/register.go @@ -20,6 +20,7 @@ var commandsList = []*discordgo.ApplicationCommand{ Commands.AnimalGifCommand, Commands.ActionCommand, Commands.ReactionRoleCommand, + Commands.SuggestCommand, } func RegisterCommands(s *discordgo.Session, guildID string) { diff --git a/Dockerfile b/Dockerfile index 0393312..9f74f38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,23 @@ FROM golang:1.24-alpine AS builder WORKDIR /app +# for C stuff (CGO_ENABLED --> sqlite3) +RUN apk add --no-cache build-base + # Copy go.mod and go.sum to download dependencies COPY go.mod go.sum ./ RUN go mod download -# Copy the rest of the code and build the bot binary +# Copy the rest of the code COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -o bot . +RUN CGO_ENABLED=1 GOOS=linux go build -o bot . FROM alpine:latest WORKDIR /app COPY --from=builder /app/bot . +# DB stuff +VOLUME /app/data +RUN mkdir -p /app/data && chmod 755 /app/data + CMD ["./bot"] \ No newline at end of file diff --git a/Makefile b/Makefile index eca3021..e29c404 100644 --- a/Makefile +++ b/Makefile @@ -2,10 +2,10 @@ build: docker build -t ion606-bot . run: build - docker run --rm -d --env-file .env ion606-bot + docker run --rm -d --env-file .env -v "$(shell pwd)"/data:/app/data ion606-bot stop: docker stop $$(docker ps -q --filter ancestor=ion606-bot) dev: build - docker run --rm --env-file .env ion606-bot \ No newline at end of file + docker run --rm --env-file .env -v "$(shell pwd)"/data:/app/data ion606-bot \ No newline at end of file diff --git a/go.mod b/go.mod index 71554d0..1b02c1a 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module ion606_bot go 1.24.1 +require github.com/mattn/go-sqlite3 v1.14.27 + require ( github.com/bwmarrin/discordgo v0.28.1 // indirect github.com/gorilla/websocket v1.4.2 // indirect diff --git a/go.sum b/go.sum index 2039ab8..9d69d32 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= +github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad h1:qIQkSlF5vAUHxEmTbaqt1hkJ/t6skqEGYiMag343ucI= github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad/go.mod h1:/pA7k3zsXKdjjAiUhB5CjuKib9KJGCaLvZwtxGC8U0s= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=