package discordbotrus // Config handles discord bot connection configuration and logrus levels. type Config struct { // Disabled can disable hook form configuration file. Disabled bool `json:"disabled" yaml:"disabled"` // Token is bot token from discord developers applications. Token string `json:"token" yaml:"token"` // ChannelID is id of discord channel to log hooks. ChannelID string `json:"channel_id" yaml:"channel_id"` // Format specifies formatter to discord message. // Supported formats: text, json, embed. Format string `json:"format" yaml:"format"` // MinLevel is the minimum priority level to enable logging. MinLevel string `json:"min_level" yaml:"min_level"` // Levels is a list of levels to enable logging. Intersects with MinLevel. Levels []string `json:"levels" yaml:"levels"` } // NewDefaultConfig returns default configuration for hook. func NewDefaultConfig(token string, channelID string) *Config { return &Config{ Disabled: false, Token: token, ChannelID: channelID, MinLevel: "info", Format: EmbedFormatterCode, Levels: []string{ "error", "warning", "info", "trace", }, } } // Validate checks config for required fields. func (cfg *Config) Validate() error { // Do not validate disabled hook. if cfg.Disabled { return nil } if cfg.ChannelID == "" { return ErrEmptyChannelID } return nil }
package discordbotrus import ( "encoding/json" "fmt" "sort" "strings" "github.com/bwmarrin/discordgo" "github.com/sirupsen/logrus" ) const ( embedMaxFieldCount = 25 embedMaxFieldNameLen = 256 embedMaxFieldValueLen = 1024 embedMaxDescriptionLen = 2048 ) // EmbedFormatterCode formatter code to identify from config. const EmbedFormatterCode = "embed" // DefaultEmbedFormatter used as default EmbedFormatter. var DefaultEmbedFormatter = &EmbedFormatter{ Inline: true, TimestampFormat: DefaultTimeLayout, } // EmbedFormatter formats logs into parsable json. type EmbedFormatter struct { // TimestampFormat sets the format used for marshaling timestamps. TimestampFormat string // The keys sorting function, when uninitialized it uses sort.Strings. SortingFunc func([]string) // Inline causes fields to be displayed one per line // as opposed to being inline. Inline bool // DisableTimestamp allows disabling automatic timestamps in output. DisableTimestamp bool // The fields are sorted by default for a consistent output. For applications // that log extremely frequently and don't use the JSON formatter this may not // be desired. DisableSorting bool } // Format renders a single log entry. func (f *EmbedFormatter) Format(entry *logrus.Entry) ([]byte, error) { return json.Marshal(f.Embed(entry)) } // Embed creates discord embed message from logrus entry. func (f *EmbedFormatter) Embed(entry *logrus.Entry) *discordgo.MessageEmbed { title := strings.ToUpper(entry.Level.String()) if !f.DisableTimestamp { timestampFormat := f.TimestampFormat if timestampFormat == "" { timestampFormat = DefaultTimeLayout } title = entry.Time.Format(timestampFormat) + " " + title } // Truncate message if it is too long. if len([]rune(entry.Message)) > embedMaxDescriptionLen { entry.Message = string([]rune(entry.Message)[:embedMaxDescriptionLen]) } keys := make([]string, 0, len(entry.Data)) for k := range entry.Data { keys = append(keys, k) } if !f.DisableSorting { if f.SortingFunc == nil { sort.Strings(keys) } else { f.SortingFunc(keys) } } message := discordgo.MessageEmbed{ Title: title, Color: LevelColor(entry.Level), Description: entry.Message, Fields: f.getFields(keys, entry), } return &message } func (f *EmbedFormatter) getFields(keys []string, entry *logrus.Entry) []*discordgo.MessageEmbedField { fields := make([]*discordgo.MessageEmbedField, 0, len(keys)) for i, name := range keys { value := entry.Data[name] // Ensure that the maximum field number is not exceeded. if i >= embedMaxFieldCount { break } // Make value a string. valueStr := fmt.Sprintf("%v", value) if valueStr == "" { // Fix for discordgo bug with empty field value. Discord responses // `HTTP 400 Bad Request, {"embed": ["fields"]}` and nothing is clear. // Therefore must skip the value or add a fake value. continue } // Truncate names and values which are too long. if len([]rune(name)) > embedMaxFieldNameLen { name = string([]rune(name)[:embedMaxFieldNameLen]) } if len([]rune(valueStr)) > embedMaxFieldValueLen { valueStr = string([]rune(valueStr)[:embedMaxFieldValueLen]) } field := discordgo.MessageEmbedField{ Name: name, Value: valueStr, Inline: f.Inline, } fields = append(fields, &field) } return fields }
package discordbotrus import ( "errors" "fmt" "github.com/bwmarrin/discordgo" "github.com/sirupsen/logrus" ) const ( // DiscordMaxMessageLen max discord message length. DiscordMaxMessageLen = 2000 // DefaultTimeLayout default time layout to Formatter implementations. DefaultTimeLayout = "2006-01-02 15:04:05" ) var ( // ErrEmptyToken is returned when discord bot token is empty with enabled hook. ErrEmptyToken = errors.New("discord bot token 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") ) // Hook implements logrus.Hook and delivers logs to discord channel. type Hook struct { config *Config levels []logrus.Level session *discordgo.Session owner bool formatter logrus.Formatter } // New creates new logrus hook for discord. func New(cfg *Config, options ...Option) (*Hook, error) { hook := Hook{ config: cfg, } for _, option := range options { option(&hook) } // Allow use hook if it is deactivated. Necessary in order to simplify compatibility. if cfg.Disabled { return &hook, nil } if err := cfg.Validate(); err != nil { return &hook, err } if hook.session == nil && cfg.Token == "" { return &hook, ErrEmptyToken } // Add missed properties if hook is enabled. if err := hook.setDefaults(); err != nil { return &hook, err } return &hook, nil } // Fire implements logrus.Hook. func (hook *Hook) Fire(entry *logrus.Entry) error { // Do nothing if hook is disabled in config. if hook.config.Disabled { return nil } if hook.config.Format == EmbedFormatterCode || hook.config.Format == "" { embedFormatter, ok := hook.formatter.(*EmbedFormatter) if ok { msg := embedFormatter.Embed(entry) if _, err := hook.session.ChannelMessageSendEmbed(hook.config.ChannelID, msg); err != nil { return fmt.Errorf("discordbotrus: %w", err) } return nil } } raw, err := hook.formatter.Format(entry) if err != nil { return fmt.Errorf("discordbotrus: %w", err) } if _, err := hook.session.ChannelMessageSend(hook.config.ChannelID, string(raw)); err != nil { return fmt.Errorf("discordbotrus: %w", err) } return nil } // Levels implements logrus.Hook. func (hook *Hook) Levels() []logrus.Level { if hook.levels == nil { return logrus.AllLevels } return hook.levels } // Close implements io.Closer. // Closes connection to discord if hook is owner of it. func (hook *Hook) Close() error { // Do nothing if hook is disabled in config. if hook.config.Disabled { return nil } // Close discord connection only if it was opened during initialization. if hook.owner { if err := hook.session.Close(); err != nil { return fmt.Errorf("discordbotrus: %w", err) } } return nil } // setDefaults adds missed properties and sets default values to hook. func (hook *Hook) setDefaults() error { if hook.levels == nil { var err error if hook.levels, err = ParseLevels(hook.config.Levels, hook.config.MinLevel); err != nil { return err } } if hook.session == nil { session, err := discordgo.New("Bot " + hook.config.Token) if err != nil { return fmt.Errorf("create session: %w", err) } if err := session.Open(); err != nil { return fmt.Errorf("open session: %w", err) } hook.session = session hook.owner = true } if hook.formatter == nil { switch hook.config.Format { case TextFormatterCode: hook.formatter = DefaultTextFormatter case JSONFormatterCode: hook.formatter = DefaultJSONFormatter case EmbedFormatterCode: hook.formatter = DefaultEmbedFormatter default: hook.formatter = DefaultEmbedFormatter } } return nil }
package discordbotrus import ( "fmt" "github.com/sirupsen/logrus" ) // JSONFormatterCode formatter code to identify from config. const JSONFormatterCode = "json" // DefaultJSONFormatter used as default JSONFormatter. var DefaultJSONFormatter = &JSONFormatter{ JSONFormatter: logrus.JSONFormatter{ TimestampFormat: DefaultTimeLayout, }, Quoted: true, } // JSONFormatter formats logs into parsable json. type JSONFormatter struct { logrus.JSONFormatter // Quoted will quote string to discord tag json. Quoted bool } // Format renders a single log entry. func (f *JSONFormatter) Format(entry *logrus.Entry) ([]byte, error) { data, err := f.JSONFormatter.Format(entry) if err != nil { return data, fmt.Errorf("discordbotrus: %w", err) } if f.Quoted { data = []byte("```json\n" + string(data) + "```") } if len([]rune(string(data))) > DiscordMaxMessageLen { return data, ErrMessageTooLong } return data, nil }
package discordbotrus import ( "fmt" "github.com/sirupsen/logrus" ) // Colors for log levels. const ( ColorGreen = 0x0008000 ColorYellow = 0xffaa00 ColorRed = 0xff0000 ) const levelsCount = 7 // ParseLevels parses logging levels from the config. func ParseLevels(lvs []string, minLvl string) ([]logrus.Level, error) { levels := make([]logrus.Level, 0, levelsCount) if minLvl == "" { all := logrus.AllLevels minLvl = all[len(all)-1].String() } minLevel, err := logrus.ParseLevel(minLvl) if err != nil { return levels, fmt.Errorf("discordbotrus: %w", err) } if len(lvs) != 0 { for _, lvl := range lvs { level, err := logrus.ParseLevel(lvl) if err != nil { return levels, fmt.Errorf("discordbotrus: %w", err) } if minLevel >= level { levels = append(levels, level) } } return levels, nil } for _, level := range logrus.AllLevels { if minLevel >= level { levels = append(levels, level) } } return levels, nil } // LevelColor returns the respective color for the logrus level. func LevelColor(l logrus.Level) int { switch l { case logrus.PanicLevel: return ColorRed case logrus.FatalLevel: return ColorRed case logrus.ErrorLevel: return ColorRed case logrus.WarnLevel: return ColorYellow case logrus.InfoLevel: return ColorGreen case logrus.DebugLevel: return ColorGreen case logrus.TraceLevel: return ColorGreen default: return 0 } }
package discordbotrus import ( "github.com/bwmarrin/discordgo" "github.com/sirupsen/logrus" ) // Option can be used to create a customized connection. type Option func(client *Hook) // WithSession sets discordgo session to Hook. func WithSession(session *discordgo.Session) Option { return func(hook *Hook) { hook.session = session } } // WithFormatter sets custom formatter to Hook. func WithFormatter(formatter logrus.Formatter) Option { return func(hook *Hook) { hook.formatter = formatter } } // WithLevels sets logrus levels to Hook. func WithLevels(levels []logrus.Level) Option { return func(hook *Hook) { hook.levels = levels } }
package discordbotrus import ( "fmt" "github.com/sirupsen/logrus" ) // TextFormatterCode formatter code to identify from config. const TextFormatterCode = "text" // DefaultTextFormatter used as default TextFormatter. var DefaultTextFormatter = &TextFormatter{ TextFormatter: logrus.TextFormatter{ TimestampFormat: DefaultTimeLayout, QuoteEmptyFields: true, }, Quoted: true, } // TextFormatter formats logs into text. type TextFormatter struct { logrus.TextFormatter // Quoted will quote string to discord tag text. Quoted bool } // Format renders a single log entry. func (f *TextFormatter) Format(entry *logrus.Entry) ([]byte, error) { data, err := f.TextFormatter.Format(entry) if err != nil { return data, fmt.Errorf("discordbotrus: %w", err) } if f.Quoted { data = []byte("```text\n" + string(data) + "```") } if len([]rune(string(data))) > DiscordMaxMessageLen { return data, ErrMessageTooLong } return data, nil }