Initial Commit:

Added wazero bin due to how extism requires it (not bundled ?)

Changes to be committed:
	modified:   .gitignore
	modified:   README.md
	new file:   bin/wazero
	new file:   go.mod
	new file:   go.sum
	new file:   main.go
This commit is contained in:
F9Alejandro 2025-04-26 00:10:18 -04:00
parent 1d57016d07
commit 7083387695
No known key found for this signature in database
6 changed files with 671 additions and 3 deletions

1
.gitignore vendored
View File

@ -23,5 +23,6 @@ go.work
go.work.sum
# env file
._*
.env

View File

@ -1,5 +1,14 @@
# DiscGoExtism-Host
DiscordGo + Go + Extism - For plugin additions to DiscordGo with help from Gemini Pro 2.5
Main Program (Host)
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.

BIN
bin/wazero Normal file

Binary file not shown.

22
go.mod Normal file
View File

@ -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
)

49
go.sum Normal file
View File

@ -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=

587
main.go Normal file
View File

@ -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 <handlerKey>.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) {}