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
}