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) {}