first commit

This commit is contained in:
Space-Banane
2026-01-21 22:55:58 +01:00
commit 66cf67d389
12 changed files with 788 additions and 0 deletions

71
commands/commands.go Normal file
View File

@@ -0,0 +1,71 @@
package commands
import (
"fmt"
"log"
"github.com/bwmarrin/discordgo"
)
type Cmd struct {
Command *discordgo.ApplicationCommand
Handler func(s *discordgo.Session, ic *discordgo.InteractionCreate)
AutocompleteHandler func(s *discordgo.Session, ic *discordgo.InteractionCreate)
}
var (
commandHandlers = map[string]func(s *discordgo.Session, ic *discordgo.InteractionCreate){}
autocompleteHandlers = map[string]func(s *discordgo.Session, ic *discordgo.InteractionCreate){}
)
func getAllCommands() []*Cmd {
return []*Cmd{
newSetupCommand(),
newLogoutCommand(),
newListCommand(),
newDeleteCommand(),
}
}
// RegisterAll registers the application commands with Discord and stores handlers.
func RegisterAll(s *discordgo.Session) error {
// Clean up old commands (optional, but good for dev)
// existing, _ := s.ApplicationCommands(s.State.User.ID, "")
// for _, v := range existing {
// s.ApplicationCommandDelete(s.State.User.ID, "", v.ID)
// }
for _, c := range getAllCommands() {
_, err := s.ApplicationCommandCreate(s.State.User.ID, "", c.Command)
if err != nil {
return fmt.Errorf("creating command %s: %w", c.Command.Name, err)
}
commandHandlers[c.Command.Name] = c.Handler
if c.AutocompleteHandler != nil {
autocompleteHandlers[c.Command.Name] = c.AutocompleteHandler
}
}
log.Println("Commands registered.")
return nil
}
// HandleInteraction routes incoming interaction events to the right handler.
func HandleInteraction(s *discordgo.Session, ic *discordgo.InteractionCreate) {
switch ic.Type {
case discordgo.InteractionApplicationCommand:
name := ic.ApplicationCommandData().Name
if h, ok := commandHandlers[name]; ok {
h(s, ic)
}
case discordgo.InteractionApplicationCommandAutocomplete:
name := ic.ApplicationCommandData().Name
if h, ok := autocompleteHandlers[name]; ok {
h(s, ic)
}
case discordgo.InteractionModalSubmit:
data := ic.ModalSubmitData()
if data.CustomID == "setup_modal" {
handleSetupSubmit(s, ic)
}
}
}

139
commands/delete.go Normal file
View File

@@ -0,0 +1,139 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/bwmarrin/discordgo"
)
func newDeleteCommand() *Cmd {
return &Cmd{
Command: &discordgo.ApplicationCommand{
Name: "delete",
Description: "Delete a Thoughtful idea",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "idea",
Description: "The idea to delete (search by title)",
Required: true,
Autocomplete: true,
},
},
},
Handler: handleDelete,
AutocompleteHandler: handleDeleteAutocomplete,
}
}
func handleDelete(s *discordgo.Session, ic *discordgo.InteractionCreate) {
user := ic.User
if user == nil {
user = ic.Member.User
}
cfg, ok, err := GetUserConfig(user.ID)
if err != nil {
respondError(s, ic, "Error retrieving configuration.")
return
}
if !ok {
respondError(s, ic, "You are not logged in. Use `/setup` first.")
return
}
options := ic.ApplicationCommandData().Options
ideaID := options[0].StringValue()
payload := map[string]string{"id": ideaID}
b, _ := json.Marshal(payload)
req, _ := http.NewRequest("DELETE", cfg.InstanceURL+"/api/ideas/delete", bytes.NewReader(b))
req.Header.Set("API-Authentication", cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
respondError(s, ic, "Failed to connect: "+err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode == 401 {
respondError(s, ic, "Unauthorized.")
return
}
// Assuming 200 is success
if resp.StatusCode == 200 {
s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Idea deleted successfully.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
} else {
respondError(s, ic, fmt.Sprintf("Failed to delete. Status: %d", resp.StatusCode))
}
}
func handleDeleteAutocomplete(s *discordgo.Session, ic *discordgo.InteractionCreate) {
user := ic.User
if user == nil {
user = ic.Member.User
}
cfg, ok, err := GetUserConfig(user.ID)
if err != nil || !ok {
return
}
// Fetch ideas
client := &http.Client{}
req, _ := http.NewRequest("GET", cfg.InstanceURL+"/api/ideas/list", nil)
req.Header.Set("API-Authentication", cfg.APIKey)
resp, err := client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
var result ideaListResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return
}
query := ""
data := ic.ApplicationCommandData()
for _, opt := range data.Options {
if opt.Name == "idea" && opt.Focused {
query = strings.ToLower(opt.StringValue())
break
}
}
choices := []*discordgo.ApplicationCommandOptionChoice{}
for _, idea := range result.Ideas {
if query == "" || strings.Contains(strings.ToLower(idea.Title), query) {
choices = append(choices, &discordgo.ApplicationCommandOptionChoice{
Name: idea.Title,
Value: idea.ID,
})
if len(choices) >= 25 {
break
}
}
}
s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionApplicationCommandAutocompleteResult,
Data: &discordgo.InteractionResponseData{
Choices: choices,
},
})
}

115
commands/list.go Normal file
View File

@@ -0,0 +1,115 @@
package commands
import (
"encoding/json"
"fmt"
"net/http"
"github.com/bwmarrin/discordgo"
)
func newListCommand() *Cmd {
return &Cmd{
Command: &discordgo.ApplicationCommand{
Name: "list",
Description: "List your Thoughtful ideas",
},
Handler: handleList,
}
}
type ideaListResponse struct {
Success bool `json:"success"`
Ideas []Idea `json:"ideas"`
}
type Idea struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
}
func handleList(s *discordgo.Session, ic *discordgo.InteractionCreate) {
user := ic.User
if user == nil {
user = ic.Member.User
}
cfg, ok, err := GetUserConfig(user.ID)
if err != nil {
respondError(s, ic, "Error retrieving configuration.")
return
}
if !ok {
respondError(s, ic, "You are not logged in. Use `/setup` first.")
return
}
client := &http.Client{}
req, _ := http.NewRequest("GET", cfg.InstanceURL+"/api/ideas/list", nil)
req.Header.Set("API-Authentication", cfg.APIKey)
resp, err := client.Do(req)
if err != nil {
respondError(s, ic, "Failed to connect to Thoughtful instance: "+err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode == 401 {
respondError(s, ic, "Unauthorized. Check your API Key.")
return
}
if resp.StatusCode != 200 {
respondError(s, ic, fmt.Sprintf("API Error: %d", resp.StatusCode))
return
}
var result ideaListResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
respondError(s, ic, "Failed to parse API response.")
return
}
if !result.Success {
respondError(s, ic, "API reported failure.")
return
}
if len(result.Ideas) == 0 {
s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "No ideas found.",
},
})
return
}
// Format output (simple list for now)
var content = "**Your Ideas:**\n"
for _, idea := range result.Ideas {
content += fmt.Sprintf("• **%s** (ID: `%s`)\n", idea.Title, idea.ID)
}
// Discord message limit check (simplified)
if len(content) > 2000 {
content = content[:1997] + "..."
}
s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: content,
},
})
}
func respondError(s *discordgo.Session, ic *discordgo.InteractionCreate, msg string) {
s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
},
})
}

35
commands/logout.go Normal file
View File

@@ -0,0 +1,35 @@
package commands
import (
"github.com/bwmarrin/discordgo"
)
func newLogoutCommand() *Cmd {
return &Cmd{
Command: &discordgo.ApplicationCommand{
Name: "logout",
Description: "Remove your stored Thoughtful credentials",
},
Handler: handleLogout,
}
}
func handleLogout(s *discordgo.Session, ic *discordgo.InteractionCreate) {
user := ic.User
if user == nil {
user = ic.Member.User
}
err := DeleteUserConfig(user.ID)
content := "Your credentials have been removed."
if err != nil {
content = "Error removing credentials: " + err.Error()
}
s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: content,
Flags: discordgo.MessageFlagsEphemeral,
},
})
}

112
commands/setup.go Normal file
View File

@@ -0,0 +1,112 @@
package commands
import (
"strings"
"github.com/bwmarrin/discordgo"
)
func newSetupCommand() *Cmd {
return &Cmd{
Command: &discordgo.ApplicationCommand{
Name: "setup",
Description: "Configure your Thoughtful instance",
},
Handler: handleSetup,
}
}
func handleSetup(s *discordgo.Session, ic *discordgo.InteractionCreate) {
err := s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: "setup_modal",
Title: "Thoughtful Setup",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "instance_url",
Label: "Instance URL",
Style: discordgo.TextInputShort,
Placeholder: "https://your-thoughtful-instance.com",
Required: true,
},
},
},
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "api_key",
Label: "API Key",
Style: discordgo.TextInputShort,
Placeholder: "Your API Key",
Required: true,
},
},
},
},
},
})
if err != nil {
// fallback if modal fails (rare)
}
}
func handleSetupSubmit(s *discordgo.Session, ic *discordgo.InteractionCreate) {
data := ic.ModalSubmitData()
url := ""
key := ""
for _, comp := range data.Components {
// ActionsRow -> TextInput
if row, ok := comp.(*discordgo.ActionsRow); ok {
for _, c := range row.Components {
if input, ok := c.(*discordgo.TextInput); ok {
if input.CustomID == "instance_url" {
url = strings.TrimSpace(input.Value)
} else if input.CustomID == "api_key" {
key = strings.TrimSpace(input.Value)
}
}
}
}
}
// Normalize URL
url = strings.TrimSuffix(url, "/")
if url == "" || key == "" {
s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Both URL and API Key are required.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
user := ic.User
if user == nil {
user = ic.Member.User
}
err := SaveUserConfig(user.ID, UserConfig{
InstanceURL: url,
APIKey: key,
})
content := "Configuration saved successfully!"
if err != nil {
content = "Error saving configuration: " + err.Error()
}
s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: content,
Flags: discordgo.MessageFlagsEphemeral,
},
})
}

84
commands/storage.go Normal file
View File

@@ -0,0 +1,84 @@
package commands
import (
"encoding/json"
"os"
"path/filepath"
"sync"
)
type UserConfig struct {
InstanceURL string `json:"instance_url"`
APIKey string `json:"api_key"`
}
var (
storePath = filepath.Join("users.json")
mu sync.Mutex
)
func ensureDir() error {
dir := filepath.Dir(storePath)
return os.MkdirAll(dir, 0o755)
}
func GetUserConfig(userID string) (UserConfig, bool, error) {
m, err := loadAll()
if err != nil {
return UserConfig{}, false, err
}
cfg, ok := m[userID]
return cfg, ok, nil
}
func SaveUserConfig(userID string, cfg UserConfig) error {
m, err := loadAll()
if err != nil {
// if load fails, maybe just start fresh?
m = make(map[string]UserConfig)
}
m[userID] = cfg
return saveAll(m)
}
func DeleteUserConfig(userID string) error {
m, err := loadAll()
if err != nil {
return err
}
delete(m, userID)
return saveAll(m)
}
func loadAll() (map[string]UserConfig, error) {
mu.Lock()
defer mu.Unlock()
if err := ensureDir(); err != nil {
return nil, err
}
if _, err := os.Stat(storePath); os.IsNotExist(err) {
return map[string]UserConfig{}, nil
}
b, err := os.ReadFile(storePath)
if err != nil {
return nil, err
}
var m map[string]UserConfig
if err := json.Unmarshal(b, &m); err != nil {
return map[string]UserConfig{}, nil // return empty on corrupt
}
return m, nil
}
func saveAll(m map[string]UserConfig) error {
mu.Lock()
defer mu.Unlock()
if err := ensureDir(); err != nil {
return err
}
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return os.WriteFile(storePath, b, 0o644)
}