package discordant // Command is the Discord command. type Command struct { Name string `json:"name"` Arg string `json:"arg"` Description string `json:"description"` Help string `json:"help"` Access []string `json:"access"` action HandlerFunc } // CommandOption describes command option func. type CommandOption func(*Command) // MiddlewareAccess adds access levels. func MiddlewareAccess(access ...string) CommandOption { return func(c *Command) { c.Access = append(c.Access, access...) } } // MiddlewareDescription adds description to command. func MiddlewareDescription(description string) CommandOption { return func(c *Command) { c.Description = description } }
package discordant // Config contains credentials for Discord server. type Config struct { Token string `json:"token" yaml:"token"` Prefix string `json:"prefix" yaml:"prefix"` Safemode bool `json:"safemode" yaml:"safemode"` Channels map[string]string `json:"channels" yaml:"channels"` AccessOrder []string `json:"access_order" yaml:"access_order"` } // Validate checks required fields and validates for allowed values. func (cfg *Config) Validate() error { if cfg.Prefix == "" { return ErrEmptyPrefix } return nil }
package discordant import ( "bufio" "bytes" ctx "context" "encoding/json" "fmt" "io" "net/http" "strings" "github.com/bwmarrin/discordgo" ) const ( stateStart = "start" stateQuotes = "quotes" stateArg = "arg" ) // Context is an interface represents the context of the current Discord command. type Context interface { Command() *Command Discordant() *Discordant Request() *discordgo.MessageCreate ChannelID() string QueryString() string QuerySlice() ([]string, error) QueryAttachmentBodyFirst() (string, error) Send(msg string, params ...string) error Success() error Fail() error JSON(rawmsg any, params ...string) error JSONPretty(rawmsg any, params ...string) error Embed(msg *discordgo.MessageEmbed) error Embeds(msgs []discordgo.MessageEmbed) error } type context struct { command *Command discordant *Discordant request *discordgo.MessageCreate } // Command returns received command. func (c *context) Command() *Command { return c.command } // Discordant returns Discordant instance. func (c *context) Discordant() *Discordant { return c.discordant } // Request returns the data for a MessageCreate event from request query. func (c *context) Request() *discordgo.MessageCreate { return c.request } // ChannelID returns the ID of the channel in which the message was sent. func (c *context) ChannelID() string { return c.request.ChannelID } // QueryString returns the URL query string. func (c *context) QueryString() string { return c.Command().Arg } // QuerySlice parses the command query string and returns a slice of arguments. // It handles quoted arguments (both single and double quotes) and escape characters. // // The function implements a simple state machine to parse the query string with these rules: // 1. Spaces separate arguments unless they're within quotes // 2. Both single (') and double (") quotes are supported // 3. Backslash (\) can be used to escape special characters // 4. Unclosed quotes will return an error // // Returns: // - []string: slice of parsed arguments // - error: if there's an unclosed quote in the input // // Note: The function is marked with nolint for cyclop and funlen as the state machine // logic is inherently complex but intentionally kept as a single unit for clarity. func (c *context) QuerySlice() ([]string, error) { //nolint: cyclop, funlen // indivisible query := c.Command().Arg var args []string // State machine states state := stateStart // Initial parsing state current := "" // Current argument being built quote := "\"" // Type of quote we're currently in (if in quotes) escapeNext := true // Whether next character should be escaped for i, command := range query { // Special case: first character is a quote (disable escaping) if i == 0 && string(command) == `"` { escapeNext = false } // When inside quotes, accept all characters until closing quote if state == stateQuotes { if string(command) != quote { current += string(command) } else { args = append(args, current) current = "" state = stateStart } continue } // Handle escaped characters if escapeNext { current += string(command) escapeNext = false continue } // Detect escape character if command == '\\' { escapeNext = true continue } // Detect quote start if command == '"' || command == '\'' { state = stateQuotes quote = string(command) continue } // When in argument (not in quotes) if state == stateArg { if command == ' ' || command == '\t' { // Space ends current argument args = append(args, current) current = "" state = stateStart } else { current += string(command) } continue } // Detect start of new argument if command != ' ' && command != '\t' { state = stateArg current += string(command) } } // Error if we ended while still inside quotes if state == stateQuotes { return []string{}, fmt.Errorf("%w: %s", ErrUnclosedQuote, query) } // Add any remaining argument if current != "" { args = append(args, current) } return args, nil } // QueryAttachmentBodyFirst retrieves the content of the first attachment from a message. // It performs the following operations: // 1. Checks if there are any attachments - returns empty string if none exist // 2. Fetches the content from the URL of the first attachment // 3. Returns the content as a string // // This is typically used to process message attachments where only the first attachment's // content is needed (e.g., processing a single file upload). // // Returns: // - string: The content of the first attachment as a string // - error: Any error that occurred during the HTTP request or content reading // (network errors, invalid URL, read errors, etc.) // // The function automatically closes the response body after reading. func (c *context) QueryAttachmentBodyFirst() (string, error) { if len(c.Request().Message.Attachments) == 0 { return "", ErrNoAttachment } uri := c.Request().Message.Attachments[0].URL req, err := http.NewRequestWithContext(ctx.Background(), http.MethodGet, uri, http.NoBody) if err != nil { return "", err } resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() buf := &bytes.Buffer{} if _, err := io.Copy(buf, resp.Body); err != nil { return "", err } return buf.String(), nil } // Send sends message to discord channel. // That is, what to do with messages that more than 2000 characters. // 1. Send as file. // 2. Send as multiple messages. func (c *context) Send(msg string, params ...string) error { // Send normal message. if len([]rune(msg)) <= DiscordMaxMessageLenValidate { if _, err := c.discordant.session.ChannelMessageSend(c.request.ChannelID, msg); err != nil { return fmt.Errorf("discordant send: %w", err) } return nil } // Message is too big. Attach as file. msg = strings.TrimPrefix(msg, "```json\n") msg = strings.TrimSuffix(msg, "\n```") var buf bytes.Buffer fileName := "message.txt" if len(params) > 0 { fileName = params[0] } if _, err := buf.Write([]byte(msg)); err != nil { return fmt.Errorf("discordant send: %w", err) } ms := &discordgo.MessageSend{Files: []*discordgo.File{ {Name: fileName, Reader: bufio.NewReader(&buf)}, }} if _, err := c.discordant.session.ChannelMessageSendComplex(c.request.ChannelID, ms); err != nil { return fmt.Errorf("discordant send: %w", err) } return nil } // Success sends a success response message. func (c *context) Success() error { return c.Send(ResponseMessageSuccess) } // Fail sends a fail response message. func (c *context) Fail() error { return c.Send(ResponseMessageFail) } // JSON sends a JSON response with the given message/object. // It converts the input to JSON format and sends it as a string response. // The input can be a string, error, or any type that can be marshaled to JSON. // Optional parameters can be provided for additional response configuration. func (c *context) JSON(rawmsg any, params ...string) error { return c.json(rawmsg, false, params...) } // JSONPretty sends a JSON response with pretty-printed formatting (indented). // Similar to JSON() but outputs human-readable formatted JSON. // The input can be a string, error, or any type that can be marshaled to JSON. // Optional parameters can be provided for additional response configuration. func (c *context) JSONPretty(rawmsg any, params ...string) error { return c.json(rawmsg, true, params...) } // Embed sends a message with embedded data. func (c *context) Embed(msg *discordgo.MessageEmbed) error { if _, err := c.discordant.session.ChannelMessageSendEmbed(c.ChannelID(), msg); err != nil { return err } return nil } // Embeds sends messages with embedded data. func (c *context) Embeds(msgs []discordgo.MessageEmbed) error { if len(msgs) == 0 { return ErrEmptyResponseMessage } for i := range msgs { msg := msgs[i] if err := c.Embed(&msg); err != nil { return err } } return nil } // json is the internal implementation for JSON response handling. // It handles the conversion of different input types to JSON format and sends the response. // The pretty parameter controls whether the JSON output is formatted with indentation. func (c *context) json(rawmsg any, pretty bool, params ...string) error { var msg string switch val := rawmsg.(type) { case string: msg = fmt.Sprintf(ResponseMessageFormatJSON, val) case error: msg = fmt.Sprintf(ResponseMessageFormatJSON, val.Error()) default: var ( rawjson []byte err error ) if pretty { rawjson, err = json.MarshalIndent(val, "", " ") } else { rawjson, err = json.Marshal(val) } if err != nil { return fmt.Errorf("%w: %w", ErrInvalidResponseMessageType, err) } msg = fmt.Sprintf(ResponseMessageFormatJSON, string(rawjson)) } return c.Send(msg, params...) }
package discordant import ( "errors" "fmt" "strings" "github.com/bwmarrin/discordgo" "github.com/outdead/discordant/internal/session" ) // Validation. const ( // DiscordMaxMessageLen max discord message length. DiscordMaxMessageLen = 2000 // DiscordMaxMessageLenValidate max discord message length for internal validation. DiscordMaxMessageLenValidate = 1990 ) // Channel types. const ( ChannelAdmin = "admin" ChannelGeneral = "general" ) // Defaults. const ( DefaultCommandPrefix = "!" DefaultCommandDelimiter = " " ) // Response massage layouts. const ( ResponseMessageFail = "```fail```" ResponseMessageSuccess = "```success```" ResponseMessageFormatJSON = "```json\n%s\n```" ) // Colors. const ( ColorGreen = 0x0008000 ColorYellow = 0xffaa00 ColorRed = 0xff0000 ) var ( // ErrEmptyToken is returned when discord bot token is empty with enabled hook. ErrEmptyToken = errors.New("discord bot token is empty") // ErrEmptyPrefix is returned when discord bot prefix is empty. ErrEmptyPrefix = errors.New("discord bot prefix is empty") // ErrEmptyChannelID is returned when discord channel id is empty with enabled hook. ErrEmptyChannelID = errors.New("discord channel id is empty") // ErrMessageTooLong is returned when message that has been sent to discord longer // than 2000 characters. ErrMessageTooLong = errors.New("discord message too long") // ErrCommandNotFound is returned if an unknown command was received. ErrCommandNotFound = errors.New("command not found") // ErrUnclosedQuote is returned if args has unclosed quote. ErrUnclosedQuote = errors.New("unclosed quote in command line") // ErrNoAttachment is returned when attempting to access attachments on a message that has none. ErrNoAttachment = errors.New("no attachment") // ErrInvalidResponseMessageType is returned when trying to send unknown message type. ErrInvalidResponseMessageType = errors.New("invalid response message type") // ErrEmptyResponseMessage is returned when trying to send empty message. ErrEmptyResponseMessage = errors.New("empty response message") ) // HandlerFunc defines a function to serve HTTP requests. type HandlerFunc func(Context) error // Discordant represents a connection to the Discord API. type Discordant struct { config *Config id string session *session.Session logger Logger commands map[string]Command commandsAccessOrder []string } // New creates a new Discord session and will automate some startup // tasks if given enough information to do so. Currently, you can pass zero // arguments, and it will return an empty Discord session. func New(cfg *Config, options ...Option) (*Discordant, error) { d := Discordant{ config: cfg, logger: NewDefaultLog(), commands: make(map[string]Command), commandsAccessOrder: make([]string, len(cfg.AccessOrder)), } if err := cfg.Validate(); err != nil { return nil, err } for _, option := range options { option(&d) } if d.session == nil && cfg.Token == "" { return nil, ErrEmptyToken } if d.session == nil { var err error if d.session, err = session.New(cfg.Token); err != nil { return nil, fmt.Errorf("discordant: %w", err) } } user, err := d.session.User("@me") if err != nil { _ = d.Close() return nil, fmt.Errorf("discordant: retrieve bot account: %w", err) } d.id = user.ID if len(d.config.AccessOrder) == 0 { d.commandsAccessOrder = []string{ChannelGeneral, ChannelAdmin} } else { copy(d.commandsAccessOrder, d.config.AccessOrder) } return &d, nil } // Close closes discord connection. func (d *Discordant) Close() error { if d.session != nil { if err := d.session.Close(); err != nil { return fmt.Errorf("discordant: close connection: %w", err) } } return nil } // Run runs discord bot handlers. func (d *Discordant) Run() { for name, command := range d.commands { command.Name = name d.commands[name] = command } d.AddHandler(d.commandHandler) } // ID returns stored bot id. func (d *Discordant) ID() string { return d.id } // Session returns discord Session. func (d *Discordant) Session() *discordgo.Session { return d.session.Session } // Commands returns commands list. func (d *Discordant) Commands() map[string]Command { return d.commands } // AddHandler allows you to add an event handler that will be fired anytime // the Discord WSAPI event that matches the function fires. func (d *Discordant) AddHandler(handler interface{}) func() { return d.session.AddHandler(handler) } // NewContext creates new Context. func (d *Discordant) NewContext(message *discordgo.MessageCreate, command *Command) Context { return &context{ command: command, request: message, discordant: d, } } // ADMIN adds route handler to admin channel. func (d *Discordant) ADMIN(name string, handler HandlerFunc, options ...CommandOption) { options = append(options, MiddlewareAccess(ChannelAdmin)) d.Add(name, handler, options...) } // GENERAL adds route handler to general channel. func (d *Discordant) GENERAL(name string, handler HandlerFunc, options ...CommandOption) { options = append(options, MiddlewareAccess(ChannelGeneral)) d.Add(name, handler, options...) } // ALL adds route handler to any channel. func (d *Discordant) ALL(name string, handler HandlerFunc, options ...CommandOption) { options = append(options, MiddlewareAccess(d.commandsAccessOrder...)) d.Add(name, handler, options...) } // Add adds route handler. func (d *Discordant) Add(name string, handler HandlerFunc, options ...CommandOption) { command := Command{ action: handler, } for _, option := range options { option(&command) } d.fixCommandAccess(&command) d.commands[name] = command } // GetCommand returns command by received message. func (d *Discordant) GetCommand(message string) (*Command, error) { // Command without args. if command, ok := d.commands[message]; ok { return &command, nil } // Find commands with args. for name, command := range d.commands { if strings.Index(message, name+DefaultCommandDelimiter) == 0 { // Workaround for intersecting commands such as `rules` and `rules set` or `help` and `help set`. // TODO: Come up with a better solution. if strings.Index(message, name+DefaultCommandDelimiter+"set ") == 0 { continue } command.Arg = strings.Replace(message, name, "", 1) command.Arg = strings.TrimSpace(command.Arg) return &command, nil } } return nil, ErrCommandNotFound } // CheckAccess returns true if access is allowed. func (d *Discordant) CheckAccess(id string, channels ...string) bool { if len(channels) == 0 { return true } for _, channel := range channels { if id == d.config.Channels[channel] { return true } } return false } func (d *Discordant) commandHandler(_ *discordgo.Session, message *discordgo.MessageCreate) { // Do nothing because the bot is talking. if message.Author.Bot || message.Author.ID == d.id { return } // Not bot command. Do nothing. if strings.Index(message.Content, d.config.Prefix) != 0 { return } if d.config.Safemode { // Unknown channel. Do nothing. if !d.CheckAccess(message.ChannelID, ChannelGeneral, ChannelAdmin) { d.logger.Debugf("discordant: unknown channel %s", message.ChannelID) return } } // Remove prefix from discord message. content := strings.TrimPrefix(message.Content, d.config.Prefix) command, err := d.GetCommand(content) if err != nil { d.logger.Debug(err) return } if ok := d.CheckAccess(message.ChannelID, command.Access...); !ok { d.logger.Debugf("discordant: access to command \"%s\" denied", command.Name) return } ctx := d.NewContext(message, command) if err := command.action(ctx); err != nil { d.logger.Errorf("discordant action: %s", err) if err := ctx.Fail(); err != nil { d.logger.Errorf("send fail response: %s", err) } } } func (d *Discordant) fixCommandAccess(command *Command) { buf := make(map[string]struct{}, len(d.commandsAccessOrder)) access := make([]string, 0, len(d.commandsAccessOrder)) for _, channel := range command.Access { if _, ok := buf[channel]; !ok { buf[channel] = struct{}{} } } for _, channel := range d.commandsAccessOrder { if _, ok := buf[channel]; ok { access = append(access, channel) } } command.Access = access }
package session import ( "fmt" "github.com/bwmarrin/discordgo" ) // A Session represents a connection to the Discord API. type Session struct { *discordgo.Session owner bool } // New creates a new Discord session and will automate some startup // tasks if given enough information to do so. func New(token string) (*Session, error) { session, err := discordgo.New("Bot " + token) if err != nil { return nil, fmt.Errorf("discord: create session: %w", err) } session.Identify.Intents = discordgo.MakeIntent(discordgo.IntentsAll) if err := session.Open(); err != nil { return nil, fmt.Errorf("discord: open connection: %w", err) } return &Session{session, true}, nil } // Close closes a websocket and stops all listening/heartbeat goroutines. func (s *Session) Close() error { if s.owner { return s.Session.Close() } return nil }
package discordant import ( "log" "os" ) // Logger is implemented by any logging system that is used for standard logs. type Logger interface { Debugf(format string, args ...interface{}) Infof(format string, args ...interface{}) Warningf(format string, args ...interface{}) Errorf(format string, args ...interface{}) Debug(args ...interface{}) Info(args ...interface{}) Warning(args ...interface{}) Error(args ...interface{}) Debugln(args ...interface{}) Infoln(args ...interface{}) Warningln(args ...interface{}) Errorln(args ...interface{}) } type defaultLog struct { *log.Logger } // NewDefaultLog creates and returns default logger to stderr. func NewDefaultLog() Logger { return &defaultLog{Logger: log.New(os.Stderr, "discordant ", log.LstdFlags)} } func (l *defaultLog) Debugf(f string, v ...interface{}) { l.printf("DEBUG: "+f, v...) } func (l *defaultLog) Debug(args ...interface{}) { l.printf("DEBUG: %v", args...) } func (l *defaultLog) Debugln(args ...interface{}) { l.printf("DEBUG: %v\n", args...) } func (l *defaultLog) Infof(f string, v ...interface{}) { l.printf("INFO: "+f, v...) } func (l *defaultLog) Info(args ...interface{}) { l.printf("INFO: %v", args...) } func (l *defaultLog) Infoln(args ...interface{}) { l.printf("INFO: %v\n", args...) } func (l *defaultLog) Warningf(f string, v ...interface{}) { l.printf("WARNING: "+f, v...) } func (l *defaultLog) Warning(args ...interface{}) { l.printf("WARNING: %v", args...) } func (l *defaultLog) Warningln(args ...interface{}) { l.printf("WARNING: %v\n", args...) } func (l *defaultLog) Errorf(f string, v ...interface{}) { l.printf("ERROR: "+f, v...) } func (l *defaultLog) Error(args ...interface{}) { l.printf("ERROR: %v", args...) } func (l *defaultLog) Errorln(args ...interface{}) { l.printf("ERROR: %v\n", args...) } func (l *defaultLog) printf(f string, v ...interface{}) { l.Logger.Printf(f, v...) }
package discordant import ( "github.com/bwmarrin/discordgo" "github.com/outdead/discordant/internal/session" ) // Option can be used to create a customized connections. type Option func(d *Discordant) // SetSession sets discordgo session to Discordant. func SetSession(ses *discordgo.Session) Option { return func(d *Discordant) { d.session = &session.Session{Session: ses} } } // SetLogger sets logger to Discordant. func SetLogger(logger Logger) Option { return func(d *Discordant) { d.logger = logger } }