diff --git a/.gitignore b/.gitignore index 5b90e79..b2f47cc 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,6 @@ go.work go.work.sum # env file +._* .env diff --git a/README.md b/README.md index cee64ba..23b532c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ # DiscGoExtism-Host -DiscordGo + Go + Extism - For plugin additions to DiscordGo with help from Gemini Pro 2.5 - -Main Program (Host) \ No newline at end of file +DiscordGo + Go + Extism - For plugin additions to DiscordGo with help from Gemini Pro 2.5 + +Main Program (Host) + +** Environment Variables ** + + - DISCORD_BOT_TOKEN + +** Building ** +Just build by running ``go build`` and it should output as DiscGoExtism-Host. You can also change how it is output as well using normal go commands. + +Feel free to make issue tickets and pull requests if you have any features, fixes, or general ideas. \ No newline at end of file diff --git a/bin/wazero b/bin/wazero new file mode 100644 index 0000000..68dac2b Binary files /dev/null and b/bin/wazero differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8908354 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module fg-sh.top/Testing_Grounds/DiscGoExtism-Host + +go 1.23.2 + +require ( + github.com/bwmarrin/discordgo v0.28.1 + github.com/extism/go-sdk v1.7.1 + github.com/sirupsen/logrus v1.9.3 + github.com/tetratelabs/wazero v1.9.0 +) + +require ( + github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // indirect + github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect + golang.org/x/sys v0.25.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..43946d8 --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4= +github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE= +github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= +github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= +github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1DU8ngI+aef9ZhAhNGQhcRTrWxVeG07F+c/Rw= +github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.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= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..66fbb96 --- /dev/null +++ b/main.go @@ -0,0 +1,587 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + discord "github.com/bwmarrin/discordgo" + extism "github.com/extism/go-sdk" + log "github.com/sirupsen/logrus" + "github.com/tetratelabs/wazero" +) + +// --- Configuration --- + +// discordTokenEnv is the name of the environment variable holding the bot token. +const discordTokenEnv = "DISCORD_BOT_TOKEN" + +// pluginDir is the directory where Wasm plugin files (.wasm) are stored. +// The application expects files named like .wasm (e.g., ping.wasm) +const pluginDir = "./plugins" + +// pluginFunctionName is the name of the function exported by the Wasm plugins +// that will handle the interaction logic. +const pluginFunctionName = "handle_interaction" + +// commandDefinitionFuncName is the name of the function exported by the Wasm plugins +// that will provide the command definition. +const commandDefinitionFuncName = "get_command_definition" // Function plugins must export + +// --- Structs --- + +// PluginResponse defines the structure of the JSON data expected *from* the Wasm plugin. +// The plugin should return JSON that can be unmarshaled into this struct. +type PluginResponse struct { + Content string `json:"content"` + Ephemeral bool `json:"ephemeral"` + + // NEW: Components to include in the message response (buttons, selects, etc.) + // Use a pointer to allow 'nil' when no components are needed. + // Documentation Reference (MessageComponent): https://pkg.go.dev/github.com/bwmarrin/discordgo@v0.28.1#MessageComponent + Components *[]json.RawMessage `json:"components,omitempty"` + + // TODO: Add fields for Modals later if needed. Requires sending a different InteractionResponse Type. + // Title string `json:"title,omitempty"` // For Modals + // ModalCustomID string `json:"modal_custom_id,omitempty"` // For Modals +} + +// --- Main Application Logic --- + +func main() { + f, err := os.OpenFile("latest_run.json", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + fmt.Println("Could not open file for logging.") + os.Exit(1) + } + defer f.Close() + multiWriter := io.MultiWriter(os.Stdout, f) + log.SetOutput(multiWriter) + log.SetLevel(log.InfoLevel) + log.SetFormatter(&log.JSONFormatter{}) + + token := os.Getenv(discordTokenEnv) + if token == "" { + log.Errorf(fmt.Sprintf("Bot token not found in environment variable %s", discordTokenEnv)) + os.Exit(1) + // Documentation Reference: Exiting if token is missing. + } + + dg, err := discord.New("Bot " + token) + if err != nil { + log.Errorf(fmt.Sprintf("Error creating Discord session: %s", err)) + return + } + + extism.SetLogLevel(extism.LogLevelWarn) + + log.Infoln("Adding interaction handler...") + // Add the central handler for all interaction types. + // Documentation Reference: https://pkg.go.dev/github.com/bwmarrin/discordgo@v0.28.1#Session.AddHandler + dg.AddHandler(interactionCreateHandler) + + log.Infoln("Opening Discord session...") + // Open a websocket connection to Discord and begin listening. + // Documentation Reference: https://pkg.go.dev/github.com/bwmarrin/discordgo@v0.28.1#Session.Open + err = dg.Open() + if err != nil { + log.Fatalf(fmt.Sprintf("Error opening connection: %v", err)) + } + + // Wait here until CTRL-C or other term signal is received. + log.Infof(fmt.Sprintf("Bot '%s' is now running. Press CTRL-C to exit.", dg.State.User.Username)) + + // !!! REGISTER COMMANDS HERE !!! + // Register for a specific guild for faster testing (replace "YourGuildID" or leave "" for global) + //registerCommands(dg, "") // "" for global, or "YourGuildID" + + // !!! CALL THE DYNAMIC COMMAND REGISTRATION !!! + // Pass the Guild ID for testing or "" for global commands. + registerCommandsFromPlugins(dg, "") // "" for global, or "YourGuildID" + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) + <-stop // Block until signal + + log.Infoln("Shutting down bot...") + // Cleanly close down the Discord session. + // Documentation Reference: https://pkg.go.dev/github.com/bwmarrin/discordgo@v0.28.1#Session.Close + if err := dg.Close(); err != nil { + log.Warnf(fmt.Sprintf("Error closing Discord session: %v", err)) + } +} + +// --- Interaction Handling --- + +// interactionCreateHandler is the core function that receives all interaction events from discordgo. +func interactionCreateHandler(s *discord.Session, i *discord.InteractionCreate) { + startTime := time.Now() // Track processing time + log.Debugf(fmt.Sprintf("Interaction received: Type=%v ID=%s User=%s Guild=%s Channel=%s", + i.Type, i.ID, formatUser(i), i.GuildID, i.ChannelID)) + + var handlerKey string + var interactionInput interface{} // Use interface{} to hold different data types easily for JSON marshaling + + // Determine the response type expected after plugin execution (can be overridden by plugin later) + expectedResponseType := discord.InteractionResponseChannelMessageWithSource + + // Determine the handler key and input data based on interaction type + // Documentation Reference (Interaction Types): https://pkg.go.dev/github.com/bwmarrin/discordgo@v0.28.1#InteractionType + switch i.Type { + case discord.InteractionApplicationCommand: + data := i.ApplicationCommandData() + handlerKey = data.Name // Use the command name (e.g., "ping") + interactionInput = i // Pass the whole interaction for now + expectedResponseType = discord.InteractionResponseChannelMessageWithSource // Default for slash commands + log.Debugf(fmt.Sprintf("-> Type=SlashCommand Name=/%s Options=%+v", data.Name, data.Options)) + + case discord.InteractionMessageComponent: + data := i.MessageComponentData() + fullCustomID := data.CustomID // CustomID pluginName:action:context + interactionInput = i // Pass the whole interaction + expectedResponseType = discord.InteractionResponseUpdateMessage // Components usually update the message they are on + log.Debugf(fmt.Sprintf("-> Type=Component CustomID=%s ComponentType=%v Values=%+v", data.CustomID, data.ComponentType, data.Values)) + + // --- CustomID Parsing --- + parts := strings.SplitN(fullCustomID, ":", 2) // Split into max 2 parts: "pluginName" and "action:context" + if len(parts) > 0 { + handlerKey = parts[0] // The first part is the plugin name + log.Debugf(fmt.Sprintf("Parsed handler key '%s' from CustomID '%s'", handlerKey, fullCustomID)) + } else { + log.Warnf(fmt.Sprintf("Could not parse handler key from CustomID: %s", fullCustomID)) + // Maybe fallback to using the full CustomID as key? Or handle as error. + // handlerKey = fullCustomID // Fallback (less ideal) + respondWithError(s, i.Interaction, "Invalid component ID format.", true) + return + } + // The plugin itself will handle the rest of the CustomID ("action:context") + + case discord.InteractionModalSubmit: + data := i.ModalSubmitData() + fullCustomID := data.CustomID + interactionInput = i + // Modals usually send a new message or update a previous one after submission. + expectedResponseType = discord.InteractionResponseChannelMessageWithSource // Can be overridden by plugin + log.Debugf("-> Type=ModalSubmit CustomID=%s", fullCustomID) + + // --- CustomID Parsing (same logic as components) --- + parts := strings.SplitN(fullCustomID, ":", 2) + if len(parts) > 0 { + handlerKey = parts[0] + log.Debugf(fmt.Sprintf("Parsed handler key '%s' from CustomID '%s'", handlerKey, fullCustomID)) + } else { + log.Warnf(fmt.Sprintf("Could not parse handler key from Modal CustomID: %s", fullCustomID)) + respondWithError(s, i.Interaction, "Invalid modal ID format.", true) + return + } + // Plugin handles the rest of the CustomID and submitted data (data.Components) + + // Optional: Handle Autocomplete interactions if needed + // case discordgo.InteractionApplicationCommandAutocomplete: + // data := i.ApplicationCommandData() + // handlerKey = data.Name // Or potentially a combination with focused option + // interactionInput = i + // log.Printf("INFO: -> Type=Autocomplete Name=/%s Focused=%s", data.Name, getFocusedOption(data.Options)) + // // Autocomplete requires a specific response type (InteractionResponseAutocompleteResult) + // // This might need a dedicated path or plugin convention. We'll skip detailed handling for now. + // handleAutocomplete(s, i) // Example separate function + // return // Return early for autocomplete + + default: + log.Warnf(fmt.Sprintf("Received unhandled interaction type: %d", i.Type)) + respondWithError(s, i.Interaction, "Sorry, I don't know how to handle this type of interaction yet.", true) + return + } + + if handlerKey == "" { + log.Warnln("Could not determine handler key for the interaction.") + respondWithError(s, i.Interaction, "Could not identify the action to perform.", true) + return + } + + // --- Plugin Lookup and Execution --- + pluginPath := filepath.Join(pluginDir, handlerKey+".wasm") + log.Printf(fmt.Sprintf("Attempting to load plugin for key '%s' at path: %s", handlerKey, pluginPath)) + + // Check if the plugin file exists before trying to load + // Documentation Reference (os.Stat): https://pkg.go.dev/os#Stat + // Documentation Reference (os.IsNotExist): https://pkg.go.dev/os#IsNotExist + if _, err := os.Stat(pluginPath); os.IsNotExist(err) { + log.Warnf(fmt.Sprintf("Plugin file not found: %s", pluginPath)) + respondWithError(s, i.Interaction, fmt.Sprintf("Sorry, the handler for '%s' is not available or implemented yet.", handlerKey), true) + return + } + + // Marshal the interaction data to JSON to pass to the plugin + // Note: Passing the entire discordgo.InteractionCreate struct can be large and includes sensitive info (token). + // Consider creating a more specific input struct derived from `i` for better security and efficiency later. + inputBytes, err := json.Marshal(interactionInput) + if err != nil { + log.Errorf(fmt.Sprintf("Failed to marshal interaction data for key '%s': %v", handlerKey, err)) + respondWithError(s, i.Interaction, "Error: Could not prepare data for the handler.", true) + return + } + // log.Printf("DEBUG: Marshaled Input Data (first 256 bytes): %s", truncateString(string(inputBytes), 256)) // Optional debug log + + // --- Extism Plugin Call --- + // Documentation Reference (Extism Concepts): https://extism.org/docs/concepts/overview + // Documentation Reference (Go SDK NewPlugin): https://pkg.go.dev/github.com/extism/go-sdk@v1.7.1#NewPlugin + ctx := context.Background() // Use background context for plugin execution + + // Wazero Cache + // Fixes issue with module[wasi_snapshot_preview1] not instantiated error + cache := wazero.NewCompilationCache() + defer cache.Close(ctx) + + // Define the source of the Wasm module + manifest := extism.Manifest{ + Wasm: []extism.Wasm{ + extism.WasmFile{Path: pluginPath}, + }, + // AllowedHosts can restrict network access if the plugin needs it. Example: + // AllowedHosts: []string{"discord.com", "api.example.com"}, + // Config allows passing static key-value pairs to the plugin on initialization. Example: + // Config: map[string]string{"log_level": "info"}, + } + + // Configure plugin options + pluginConfig := extism.PluginConfig{ + EnableWasi: true, + //ModuleConfig: wazero.NewModuleConfig(), + //RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(cache), + } + + log.Debugf(fmt.Sprintf("Initializing Extism plugin: %s", pluginPath)) + plugin, err := extism.NewPlugin(ctx, manifest, pluginConfig, nil) // `nil` for host functions for now + if err != nil { + log.Errorf(fmt.Sprintf("Failed to initialize Extism plugin for key '%s' from %s: %v", handlerKey, pluginPath, err)) + respondWithError(s, i.Interaction, "Error: Could not load the required handler module.", true) + return + } + defer plugin.Close(ctx) // IMPORTANT: Ensure plugin resources are freed + + log.Printf(fmt.Sprintf("Calling function '%s' in plugin '%s'", pluginFunctionName, handlerKey)) + // Call the exported function in the Wasm plugin + // Documentation Reference (Go SDK Call): https://pkg.go.dev/github.com/extism/go-sdk@v1.7.1#Plugin.Call + exitCode, outputBytes, err := plugin.Call(pluginFunctionName, inputBytes) + executionTime := time.Since(startTime) // Track execution time after call + + if err != nil { + log.Errorf(fmt.Sprintf("Extism plugin call failed for key '%s': %v. Execution time: %s", handlerKey, err, executionTime)) + respondWithError(s, i.Interaction, "Error: Failed to execute the handler module.", true) + return + } + + log.Debugf(fmt.Sprintf("Plugin '%s' finished. ExitCode=%d OutputLength=%d ExecutionTime=%s", handlerKey, exitCode, len(outputBytes), executionTime)) + + // Convention: A non-zero exit code from the plugin indicates an error during its execution. + if exitCode != 0 { + errorMsg := fmt.Sprintf("Handler Error (Code %d)", exitCode) + if len(outputBytes) > 0 { + // Try to use the output as a more specific error message + errorMsg = fmt.Sprintf("Handler Error (Code %d): %s", exitCode, string(outputBytes)) + } + log.Warnf(fmt.Sprintf("Plugin '%s' returned non-zero exit code. Message: %s", handlerKey, errorMsg)) + respondWithError(s, i.Interaction, errorMsg, true) // Show plugin error to user (maybe ephemeral) + return + } + + // Handle cases where the plugin successfully executes but returns no specific response data + // (e.g., acknowledging a button click without updating the message). + // --- Handle Empty Output --- + if len(outputBytes) == 0 { + log.Debugf(fmt.Sprintf("Plugin '%s' returned empty output.", handlerKey)) + // For components/modals, empty output might mean just acknowledge, do nothing visible. + // For slash commands, it's unexpected unless deferred. + // Send a simple acknowledgement based on the interaction type + ackType := discord.InteractionResponseDeferredChannelMessageWithSource + if i.Type == discord.InteractionMessageComponent { + ackType = discord.InteractionResponseDeferredMessageUpdate // Ack the component click + } + err = s.InteractionRespond(i.Interaction, &discord.InteractionResponse{Type: ackType}) + if err != nil { + log.Errorf(fmt.Sprintf("Failed to send acknowledgement for empty plugin output (key '%s'): %v", handlerKey, err)) + } else { + log.Debugf(fmt.Sprintf("Sent acknowledgement for key '%s'", handlerKey)) + } + return // Interaction acknowledged + } + + // --- Process Plugin Response --- + var pluginResp PluginResponse + err = json.Unmarshal(outputBytes, &pluginResp) + if err != nil { + log.Errorf(fmt.Sprintf("Failed to unmarshal plugin response JSON for key '%s': %v. Raw output: %s", handlerKey, err, truncateString(string(outputBytes), 256))) + respondWithError(s, i.Interaction, "Received an invalid response from the handler module.", true) + return + } + + var finalComponents []discord.MessageComponent + if pluginResp.Components != nil && len(*pluginResp.Components) > 0 { + finalComponents = make([]discord.MessageComponent, 0, len(*pluginResp.Components)) + for _, rawComponent := range *pluginResp.Components { + // Assuming top-level components are always Action Rows based on Discord structure + var actionRow discord.ActionsRow + if err := json.Unmarshal(rawComponent, &actionRow); err != nil { + log.Errorf(fmt.Sprintf("Failed to unmarshal component Action Row: %v. Raw: %s", err, string(rawComponent))) + // Decide how to handle: skip component, return error to user? + continue // Skip this component + } + finalComponents = append(finalComponents, actionRow) + } + } + + responseData := &discord.InteractionResponseData{ + Content: pluginResp.Content, + } + + // Assign to responseData (check if nil or empty first) + if len(finalComponents) > 0 { + responseData.Components = finalComponents // Assign the correctly typed slice + } else if pluginResp.Components != nil { + // This case means we had input components, but failed to parse all of them + log.Warnf("Plugin provided components, but failed to parse them into ActionRows.") + // Optionally send an error or proceed without components + } + + // --- Send Response to Discord --- + log.Debugf(fmt.Sprintf("Sending plugin response for '%s': Ephemeral=%t Content='%s' Components?=%t", + handlerKey, pluginResp.Ephemeral, truncateString(pluginResp.Content, 60), finalComponents != nil)) + + if pluginResp.Ephemeral { + responseData.Flags = discord.MessageFlagsEphemeral + } + + // Determine response type: Use the default determined earlier, unless plugin overrides (TODO) + responseType := expectedResponseType + // If the input was a component/modal and the plugin provided content/components, + // we usually want to update the message instead of sending a new one. + if (i.Type == discord.InteractionMessageComponent || i.Type == discord.InteractionModalSubmit) && (pluginResp.Content != "" || pluginResp.Components != nil) { + // Let's favor updating the original message for component/modal interactions if content is returned. + // A plugin could potentially signal wanting a *new* message instead. + if i.Type == discord.InteractionMessageComponent { + responseType = discord.InteractionResponseUpdateMessage + } else { + // Modal submits often create new messages, keep default ChannelMessageWithSource unless plugin specified update + // For now, let's keep it simple. + } + } + + // Send the response + err = s.InteractionRespond(i.Interaction, &discord.InteractionResponse{ + Type: responseType, + Data: responseData, + }) + + finalTime := time.Since(startTime) + if err != nil { + log.Errorf(fmt.Sprintf("Failed to send final interaction response for key '%s' (Type %d): %v. Total processing time: %s", handlerKey, responseType, err, finalTime)) + } else { + log.Printf(fmt.Sprintf("Successfully processed and responded to interaction for key '%s' (Type %d). Total processing time: %s", handlerKey, responseType, finalTime)) + } +} // End of interactionCreateHandler + +// --- DYNAMIC COMMAND REGISTRATION --- +// registerCommandsFromPlugins scans the plugin directory and registers commands based on plugin definitions. +func registerCommandsFromPlugins(s *discord.Session, guildID string) { + log.Infoln("Starting dynamic command registration from plugins...") + + // Read all files in the plugin directory + // Documentation Reference (os.ReadDir): https://pkg.go.dev/os#ReadDir + files, err := os.ReadDir(pluginDir) + if err != nil { + log.Errorf(fmt.Sprintf("Failed to read plugin directory '%s': %v", pluginDir, err)) + return + } + + registeredCount := 0 + for _, file := range files { + // Skip directories and non-wasm files + if file.IsDir() || !strings.HasSuffix(file.Name(), ".wasm") { + continue + } + + pluginPath := filepath.Join(pluginDir, file.Name()) + // Derive the expected command name from the filename (e.g., "ping.wasm" -> "ping") + expectedCommandName := strings.TrimSuffix(file.Name(), ".wasm") + + log.Debugf(fmt.Sprintf("Processing plugin: %s (expected command: '%s')", pluginPath, expectedCommandName)) + + // --- Load plugin temporarily to get definition --- + ctx := context.Background() + // Wazero Cache + // Fixes issue with module[wasi_snapshot_preview1] not instantiated error + cache := wazero.NewCompilationCache() + defer cache.Close(ctx) + + manifest := extism.Manifest{Wasm: []extism.Wasm{extism.WasmFile{Path: pluginPath}}} + + // WASI is likely not needed just for getting definition unless the function logs + pluginConfig := extism.PluginConfig{ + EnableWasi: true, + //ModuleConfig: wazero.NewModuleConfig(), + //RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(cache) + } + + plugin, err := extism.NewPlugin(ctx, manifest, pluginConfig, nil) + if err != nil { + log.Errorf(fmt.Sprintf("[%s] Failed to initialize plugin for definition check: %v", expectedCommandName, err)) + continue // Skip to next file + } + // Ensure plugin is closed even if errors occur later in the loop iteration + closePlugin := func() { + if err := plugin.Close(ctx); err != nil { + log.Warnf(fmt.Sprintf("[%s] Error closing plugin after definition check: %v", expectedCommandName, err)) + } + } + + // Check if the definition function exists + // Documentation Reference (Extism FunctionExists): https://pkg.go.dev/github.com/extism/go-sdk@v1.7.1#Plugin.FunctionExists + if !plugin.FunctionExists(commandDefinitionFuncName) { + log.Warnf(fmt.Sprintf("[%s] Plugin does not export the required function '%s'. Skipping registration.", expectedCommandName, commandDefinitionFuncName)) + closePlugin() + continue // Skip to next file + } + + // Call the function to get the command definition JSON + exitCode, outputBytes, err := plugin.Call(commandDefinitionFuncName, nil) // No input needed + if err != nil { + log.Errorf(fmt.Sprintf("[%s] Failed to call '%s': %v", expectedCommandName, commandDefinitionFuncName, err)) + closePlugin() + continue + } + if exitCode != 0 { + log.Errorf(fmt.Sprintf("[%s] Function '%s' returned non-zero exit code %d. Output: %s", expectedCommandName, commandDefinitionFuncName, exitCode, string(outputBytes))) + closePlugin() + continue + } + if len(outputBytes) == 0 { + log.Warnf(fmt.Sprintf("[%s] Function '%s' returned empty output. Skipping registration.", expectedCommandName, commandDefinitionFuncName)) + closePlugin() + continue + } + + // Unmarshal the JSON output into the discordgo struct + var cmdDef discord.ApplicationCommand + // Documentation Reference (json.Unmarshal): https://pkg.go.dev/encoding/json#Unmarshal + err = json.Unmarshal(outputBytes, &cmdDef) + if err != nil { + log.Errorf(fmt.Sprintf("[%s] Failed to unmarshal JSON definition from plugin: %v. Raw JSON: '%s'", expectedCommandName, err, string(outputBytes))) + closePlugin() + continue + } + + // --- Validation --- + if cmdDef.Name == "" { + log.Warnf(fmt.Sprintf("[%s] Plugin provided command definition with empty 'Name'. Skipping registration.", expectedCommandName)) + closePlugin() + continue + } + // Convention check: Does the name in the definition match the filename? + if cmdDef.Name != expectedCommandName { + log.Warnf(fmt.Sprintf("[%s] Plugin's defined command name ('%s') does not match filename convention ('%s'). Proceeding anyway.", expectedCommandName, cmdDef.Name, expectedCommandName)) + // You could choose to skip registration here if the convention is strict: + // closePlugin() + // continue + } + + // --- Register the Command with Discord --- + log.Infof(fmt.Sprintf("[%s] Registering command '%s' with Discord...", expectedCommandName, cmdDef.Name)) + cmd, err := s.ApplicationCommandCreate(s.State.User.ID, guildID, &cmdDef) + if err != nil { + // Check for specific errors like invalid characters if needed + log.Errorf(fmt.Sprintf("[%s] Failed to register command '%s': %v", expectedCommandName, cmdDef.Name, err)) + } else { + log.Infof(fmt.Sprintf("[%s] Registered command '%s' (ID: %s)", expectedCommandName, cmd.Name, cmd.ID)) + registeredCount++ + } + + // Close the plugin now that we're done with it for this loop iteration + closePlugin() + } + + log.Infof(fmt.Sprintf("Dynamic command registration finished. Registered %d commands.", registeredCount)) + + // Optional: Fetch all currently registered commands and compare against found plugins + // to identify and potentially delete commands corresponding to removed plugins. + // This requires storing/managing command IDs. +} + +// --- Helper Functions --- + +// respondWithError sends a standardized, usually ephemeral error message back to the user. +func respondWithError(s *discord.Session, interaction *discord.Interaction, message string, ephemeral bool) { + log.Errorf(fmt.Sprintf("RESPONSE_ERR: InteractionID=%s Message='%s'", interaction.ID, message)) + responseData := &discord.InteractionResponseData{ + Content: message, + } + if ephemeral { + responseData.Flags = discord.MessageFlagsEphemeral + } + err := s.InteractionRespond(interaction, &discord.InteractionResponse{ + Type: discord.InteractionResponseChannelMessageWithSource, + Data: responseData, + }) + if err != nil { + // Log the error in sending the error response, but don't try to respond again + log.Errorf(fmt.Sprintf("Failed to send error response for InteractionID=%s: %v", interaction.ID, err)) + } +} + +// formatUser returns a string representation of the user initiating the interaction. +func formatUser(i *discord.InteractionCreate) string { + if i.Member != nil && i.Member.User != nil { + return fmt.Sprintf("%s#%s (%s)", i.Member.User.Username, i.Member.User.Discriminator, i.Member.User.ID) + } + if i.User != nil { // Happens for interactions in DMs + return fmt.Sprintf("%s#%s (%s)", i.User.Username, i.User.Discriminator, i.User.ID) + } + return "Unknown User" +} + +// truncateString limits a string to a max length, adding "..." if truncated. +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen <= 3 { + return "..." + } + return s[:maxLen-3] + "..." +} + +// registerCommands registers application commands with Discord. +// Call this function in main() after the session is created but before waiting for the stop signal. +//func registerCommands(s *discord.Session, guildID string) { +// log.Println("INFO: Registering application commands...") +// +// // Define the /ping command +// pingCommand := &discord.ApplicationCommand{ +// Name: "ping", +// Description: "Checks the bot's responsiveness (handled by Wasm!)", +// // Add options here if needed in the future +// } +// +// // Register the command. +// // If guildID is empty "", it registers as a global command (can take ~1 hour to propagate). +// // If guildID is provided, it registers instantly for that specific server (good for testing). +// // Documentation Reference: https://pkg.go.dev/github.com/bwmarrin/discordgo@v0.28.1#Session.ApplicationCommandCreate +// cmd, err := s.ApplicationCommandCreate(s.State.User.ID, guildID, pingCommand) +// if err != nil { +// log.Errorf(fmt.Sprintf("Cannot create '/%s' command: %v", pingCommand.Name, err)) +// } else { +// log.Printf("Registered command: /%s (ID: %s)", cmd.Name, cmd.ID) +// } +// +// // Add other command registrations here... +//} + +// TODO: Add placeholder/example functions for command registration if desired +// func cleanupCommands(s *discordgo.Session) {}