diff --git a/.gitignore b/.gitignore index 2dda71a..25b0ec3 100644 --- a/.gitignore +++ b/.gitignore @@ -70,8 +70,6 @@ util/*/files .idea logs util/files -impact.ttf - gus.sh rathaus.sh run.sh diff --git a/bot/interfaces.go b/bot/interfaces.go index 8e38a81..af93915 100644 --- a/bot/interfaces.go +++ b/bot/interfaces.go @@ -29,11 +29,15 @@ const ( Help // SelfMessage triggers when the bot is sending a message SelfMessage + // Delete removes a message by ID + Delete ) type ImageAttachment struct { URL string AltTxt string + Width int + Height int } type Kind int diff --git a/bot/msg/message.go b/bot/msg/message.go index 6d3712c..9a13363 100644 --- a/bot/msg/message.go +++ b/bot/msg/message.go @@ -12,6 +12,7 @@ type Log Messages type Messages []Message type Message struct { + ID string User *user.User // With Slack, channel is the ID of a channel Channel string diff --git a/bot/user/users.go b/bot/user/users.go index 5a6e5f8..dd2efe3 100644 --- a/bot/user/users.go +++ b/bot/user/users.go @@ -2,14 +2,17 @@ package user +import "image" + // User type stores user history. This is a vehicle that will follow the user for the active // session type User struct { // Current nickname known - ID string - Name string - Admin bool - Icon string + ID string + Name string + Admin bool + Icon string + IconImg image.Image } func New(name string) User { diff --git a/connectors/discord/discord.go b/connectors/discord/discord.go new file mode 100644 index 0000000..19e8d55 --- /dev/null +++ b/connectors/discord/discord.go @@ -0,0 +1,235 @@ +package discord + +import ( + "errors" + "fmt" + "strings" + + "github.com/velour/catbase/bot/msg" + + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog/log" + "github.com/velour/catbase/bot" + "github.com/velour/catbase/bot/user" + "github.com/velour/catbase/config" +) + +type Discord struct { + config *config.Config + client *discordgo.Session + + event bot.Callback + + emojiCache map[string]string +} + +func New(config *config.Config) *Discord { + client, err := discordgo.New("Bot " + config.Get("DISCORDBOTTOKEN", "")) + if err != nil { + log.Fatal().Err(err).Msg("Could not connect to Discord") + } + d := &Discord{ + config: config, + client: client, + } + return d +} + +func (d *Discord) RegisterEvent(callback bot.Callback) { + d.event = callback +} + +func (d Discord) Send(kind bot.Kind, args ...interface{}) (string, error) { + + switch kind { + case bot.Message: + return d.sendMessage(args[0].(string), args[1].(string), false, args...) + case bot.Action: + return d.sendMessage(args[0].(string), args[1].(string), true, args...) + case bot.Edit: + st, err := d.client.ChannelMessageEdit(args[0].(string), args[1].(string), args[2].(string)) + return st.ID, err + case bot.Reply: + original, err := d.client.ChannelMessage(args[0].(string), args[1].(string)) + msg := args[2].(string) + if err != nil { + log.Error().Err(err).Msg("could not get original") + } else { + msg = fmt.Sprintf("> %v\n%s", original, msg) + } + return d.sendMessage(args[0].(string), msg, false, args...) + case bot.Reaction: + msg := args[2].(msg.Message) + err := d.client.MessageReactionAdd(args[0].(string), msg.ID, args[1].(string)) + return args[1].(string), err + case bot.Delete: + ch := args[0].(string) + id := args[1].(string) + err := d.client.ChannelMessageDelete(ch, id) + if err != nil { + log.Error().Err(err).Msg("cannot delete message") + } + return id, err + default: + log.Error().Msgf("discord.Send: unknown kind, %+v", kind) + return "", errors.New("unknown message type") + } +} + +func (d *Discord) sendMessage(channel, message string, meMessage bool, args ...interface{}) (string, error) { + if meMessage && !strings.HasPrefix(message, "_") && !strings.HasSuffix(message, "_") { + message = "_" + message + "_" + } + + var embeds *discordgo.MessageEmbed + + for _, arg := range args { + switch a := arg.(type) { + case bot.ImageAttachment: + //embeds.URL = a.URL + embeds = &discordgo.MessageEmbed{} + embeds.Description = a.AltTxt + embeds.Image = &discordgo.MessageEmbedImage{ + URL: a.URL, + Width: a.Width, + Height: a.Height, + } + } + } + + data := &discordgo.MessageSend{ + Content: message, + Embed: embeds, + } + + log.Debug(). + Interface("data", data). + Interface("args", args). + Msg("sending message") + + st, err := d.client.ChannelMessageSendComplex(channel, data) + + //st, err := d.client.ChannelMessageSend(channel, message) + if err != nil { + log.Error().Err(err).Msg("Error sending message") + return "", err + } + + return st.ID, err +} + +func (d *Discord) GetEmojiList() map[string]string { + if d.emojiCache != nil { + return d.emojiCache + } + guidID := d.config.Get("discord.guildid", "") + if guidID == "" { + } + e, err := d.client.GuildEmojis(guidID) + if err != nil { + log.Error().Err(err).Msg("could not retrieve emojis") + return map[string]string{} + } + emojis := map[string]string{} + for _, e := range e { + emojis[e.ID] = e.Name + } + return emojis +} + +func (d *Discord) Who(id string) []string { + ch, err := d.client.Channel(id) + if err != nil { + log.Error().Err(err).Msgf("Error getting users") + return []string{} + } + users := []string{} + for _, u := range ch.Recipients { + users = append(users, u.Username) + } + return users +} + +func (d *Discord) Profile(id string) (user.User, error) { + u, err := d.client.User(id) + if err != nil { + log.Error().Err(err).Msg("Error getting user") + return user.User{}, err + } + return *d.convertUser(u), nil +} + +func (d *Discord) convertUser(u *discordgo.User) *user.User { + img, err := d.client.UserAvatar(u.ID) + if err != nil { + log.Error().Err(err).Msg("error getting avatar") + } + return &user.User{ + ID: u.ID, + Name: u.Username, + Admin: false, + IconImg: img, + } +} + +func (d *Discord) Serve() error { + log.Debug().Msg("starting discord serve function") + + d.client.Identify.Intents = discordgo.MakeIntent(discordgo.IntentsGuilds | + discordgo.IntentsGuildMessages) + + err := d.client.Open() + if err != nil { + log.Debug().Err(err).Msg("error opening client") + return err + } + + log.Debug().Msg("discord connection open") + + d.client.AddHandler(d.messageCreate) + + return nil +} + +func (d *Discord) messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { + log.Debug().Msgf("discord message: %+v", m) + if m.Author.ID == s.State.User.ID { + return + } + + ch, err := s.Channel(m.ChannelID) + if err != nil { + log.Error().Err(err).Msg("error getting channel info") + } + + isCmd, text := bot.IsCmd(d.config, m.Content) + + tStamp, _ := m.Timestamp.Parse() + + msg := msg.Message{ + ID: m.ID, + User: d.convertUser(m.Author), + Channel: m.ChannelID, + ChannelName: ch.Name, + Body: text, + Command: isCmd, + Time: tStamp, + } + + log.Debug().Interface("m", m).Interface("msg", msg).Msg("message received") + + authorizedChannels := d.config.GetArray("channels", []string{}) + + if in(ch.Name, authorizedChannels) { + d.event(d, bot.Message, msg) + } +} + +func in(s string, lst []string) bool { + for _, i := range lst { + if s == i { + return true + } + } + return false +} diff --git a/connectors/slackapp/slackApp.go b/connectors/slackapp/slackApp.go index 3c73736..0ebc5c5 100644 --- a/connectors/slackapp/slackApp.go +++ b/connectors/slackapp/slackApp.go @@ -40,7 +40,6 @@ const defaultLogFormat = "[{{fixDate .Time \"2006-01-02 15:04:05\"}}] {{if .Topi // "User":{"Admin":false,"ID":"U0RLUDELD","Name":"flyngpngn"}} type SlackApp struct { - bot bot.Bot config *config.Config api *slack.Client @@ -448,6 +447,7 @@ func (s *SlackApp) buildMessage(m *slackevents.MessageEvent) msg.Message { tstamp := slackTStoTime(m.TimeStamp) return msg.Message{ + ID: m.TimeStamp, User: &user.User{ ID: m.User, Name: name, @@ -652,8 +652,8 @@ func (s *SlackApp) reactionReceived(event *slackevents.ReactionAddedEvent) error return s.log(msg, channel) } -func (s *SlackApp) Profile(name string) (user.User, error) { - log.Debug().Msgf("Getting profile for %s", name) +func (s *SlackApp) Profile(identifier string) (user.User, error) { + log.Debug().Msgf("Getting profile for %s", identifier) users, err := s.api.GetUsers() if err != nil { @@ -661,7 +661,7 @@ func (s *SlackApp) Profile(name string) (user.User, error) { } for _, u := range users { - if u.Name == name { + if u.Name == identifier || u.ID == identifier { return user.User{ ID: u.ID, Name: stringForUser(&u), diff --git a/go.mod b/go.mod index f5934d2..ac3df17 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/antchfx/xpath v1.1.1 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330 // indirect + github.com/bwmarrin/discordgo v0.22.0 github.com/cdipaolo/goml v0.0.0-20190412180403-e1f51f713598 github.com/chrissexton/gofuck v1.0.0 github.com/chrissexton/leftpad v0.0.0-20181207133115-1e93189d2fff diff --git a/go.sum b/go.sum index 2188f94..0220561 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330 h1:ekDALXAVvY/Ub1UtNta3inKQwZ/jMB/zpOtD8rAYh78= github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330/go.mod h1:nH+k0SvAt3HeiYyOlJpLLv1HG1p7KWP7qU9QPp2/pCo= +github.com/bwmarrin/discordgo v0.22.0 h1:uBxY1HmlVCsW1IuaPjpCGT6A2DBwRn0nvOguQIxDdFM= +github.com/bwmarrin/discordgo v0.22.0/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M= github.com/cdipaolo/goml v0.0.0-20190412180403-e1f51f713598 h1:j2XRGH5Y5uWtBYXGwmrjKeM/kfu/jh7ZcnrGvyN5Ttk= github.com/cdipaolo/goml v0.0.0-20190412180403-e1f51f713598/go.mod h1:sduMkaHcXDIWurl/Bd/z0rNEUHw5tr6LUA9IO8E9o0o= github.com/chrissexton/gofuck v1.0.0 h1:vxg/tIfI2HunJOErSotmMqMRNfLRVO+BTjSKpFoAizA= @@ -70,6 +72,7 @@ github.com/gonum/internal v0.0.0-20181124074243-f884aa714029 h1:8jtTdc+Nfj9AR+0s github.com/gonum/internal v0.0.0-20181124074243-f884aa714029/go.mod h1:Pu4dmpkhSyOzRwuXkOgAvijx4o+4YMUJJo9OvPYMkks= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 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/inconshreveable/log15 v0.0.0-20200109203555-b30bc20e4fd1 h1:KUDFlmBg2buRWNzIcwLlKvfcnujcHQRQ1As1LoaCLAM= @@ -145,7 +148,9 @@ github.com/velour/chat v0.0.0-20180713122344-fd1d1606cb89/go.mod h1:ejwOYCjnDMyO github.com/velour/velour v0.0.0-20160303155839-8e090e68d158 h1:p3rTUXxzuKsBOsHlkly7+rj9wagFBKeIsCDKkDII9sw= github.com/velour/velour v0.0.0-20160303155839-8e090e68d158/go.mod h1:Ojy3BTOiOTwpHpw7/HNi+TVTFppao191PQs+Qc3sqpE= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529 h1:iMGN4xG0cnqj3t+zOM8wUB0BiPKHEwSxEZCvzcbZuvk= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= diff --git a/main.go b/main.go index c94f592..03ef348 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,8 @@ import ( "os" "time" + "github.com/velour/catbase/connectors/discord" + "github.com/velour/catbase/plugins/gpt2" "github.com/velour/catbase/plugins/achievements" @@ -107,6 +109,8 @@ func main() { client = slack.New(c) case "slackapp": client = slackapp.New(c) + case "discord": + client = discord.New(c) default: log.Fatal().Msgf("Unknown connection type: %s", c.Get("type", "UNSET")) } @@ -159,5 +163,6 @@ func main() { } addr := c.Get("HttpAddr", "127.0.0.1:1337") - log.Fatal().Err(http.ListenAndServe(addr, nil)) + log.Debug().Msgf("starting web service at %s", addr) + log.Fatal().Err(http.ListenAndServe(addr, nil)).Msg("bot killed") } diff --git a/plugins/meme/meme.go b/plugins/meme/meme.go index bf51df0..c8f4b1a 100644 --- a/plugins/meme/meme.go +++ b/plugins/meme/meme.go @@ -12,6 +12,7 @@ import ( "net/http" "net/url" "path" + "regexp" "sort" "strings" "time" @@ -67,7 +68,19 @@ func New(b bot.Bot) *MemePlugin { return mp } +var cmdMatch = regexp.MustCompile(`(?i)meme (.+)`) + func (p *MemePlugin) message(c bot.Connector, kind bot.Kind, message msg.Message, args ...interface{}) bool { + if message.Command && cmdMatch.MatchString(message.Body) { + subs := cmdMatch.FindStringSubmatch(message.Body) + if len(subs) != 2 { + p.bot.Send(c, bot.Message, message.Channel, "Invalid meme request.") + return true + } + minusMeme := subs[1] + p.sendMeme(c, message.Channel, message.ChannelName, message.ID, message.User, minusMeme) + return true + } return false } @@ -202,6 +215,107 @@ func (p *MemePlugin) img(w http.ResponseWriter, r *http.Request) { p.images.cleanup() } +func (p *MemePlugin) bully(c bot.Connector, format, id string) image.Image { + bullyIcon := "" + + for _, bully := range p.c.GetArray("meme.bully", []string{}) { + if format == bully { + if u, err := c.Profile(bully); err == nil { + bullyIcon = u.Icon + } else { + log.Debug().Err(err).Msgf("could not get profile for %s", format) + } + formats := p.c.GetMap("meme.memes", defaultFormats) + format = randEntry(formats) + break + } + } + + if u, err := c.Profile(id); bullyIcon == "" && err == nil { + if u.IconImg != nil { + return u.IconImg + } + bullyIcon = u.Icon + } + + u, err := url.Parse(bullyIcon) + if err != nil { + log.Error().Err(err).Msg("error with bully URL") + } + bullyImg, err := DownloadTemplate(u) + if err != nil { + log.Error().Err(err).Msg("error downloading bully icon") + } + return bullyImg +} + +func (p *MemePlugin) sendMeme(c bot.Connector, channel, channelName, msgID string, from *user.User, text string) { + parts := strings.SplitN(text, " ", 2) + if len(parts) != 2 { + log.Debug().Msgf("Bad meme request: %v, %v", from, text) + p.bot.Send(c, bot.Message, channel, fmt.Sprintf("%v tried to send me a bad meme request.", from.Name)) + return + } + isCmd, message := bot.IsCmd(p.c, parts[1]) + format := parts[0] + + log.Debug().Strs("parts", parts).Msgf("Meme:\n%+v", text) + + go func() { + top, bottom := "", message + parts = strings.Split(message, "\n") + if len(parts) > 1 { + top, bottom = parts[0], parts[1] + } + + if top == "_" { + message = bottom + } else if bottom == "_" { + message = top + } + + bullyImg := p.bully(c, format, from.ID) + + id, w, h, err := p.genMeme(format, top, bottom, bullyImg) + if err != nil { + msg := fmt.Sprintf("Hey %v, I couldn't download that image you asked for.", from.Name) + p.bot.Send(c, bot.Message, channel, msg) + return + } + baseURL := p.c.Get("BaseURL", ``) + u, _ := url.Parse(baseURL) + u.Path = path.Join(u.Path, "meme", "img", id) + + log.Debug().Msgf("image is at %s", u.String()) + _, err = p.bot.Send(c, bot.Message, channel, "", bot.ImageAttachment{ + URL: u.String(), + AltTxt: fmt.Sprintf("%s: %s", from.Name, message), + Width: w, + Height: h, + }) + + if err == nil && msgID != "" { + p.bot.Send(c, bot.Delete, channel, msgID) + } + + m := msg.Message{ + User: &user.User{ + ID: from.ID, + Name: from.Name, + Admin: false, + }, + Channel: channel, + ChannelName: channelName, + Body: message, + Command: isCmd, + Time: time.Now(), + } + + p.bot.Receive(c, bot.Message, m) + }() + +} + func (p *MemePlugin) slashMeme(c bot.Connector) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { r.ParseForm() @@ -212,81 +326,15 @@ func (p *MemePlugin) slashMeme(c bot.Connector) http.HandlerFunc { text := r.PostForm.Get("text") log.Debug().Msgf("channel: %s", channel) - parts := strings.SplitN(text, " ", 2) - if len(parts) != 2 { - log.Debug().Msgf("Bad meme request: %s, %s", from, text) - p.bot.Send(c, bot.Message, channel, fmt.Sprintf("%s tried to send me a bad meme request.", from)) - return - } - isCmd, message := bot.IsCmd(p.c, parts[1]) - format := parts[0] - - bullyIcon := "" - - for _, bully := range p.c.GetArray("meme.bully", []string{}) { - if format == bully { - if u, err := c.Profile(bully); err == nil { - bullyIcon = u.Icon - } else { - log.Debug().Err(err).Msgf("could not get profile for %s", format) - } - formats := p.c.GetMap("meme.memes", defaultFormats) - format = randEntry(formats) - break - } + user := &user.User{ + ID: from, // HACK but should work fine + Name: from, } - if u, err := c.Profile(from); bullyIcon == "" && err == nil { - bullyIcon = u.Icon - } + p.sendMeme(c, channel, channelName, "", user, text) - log.Debug().Strs("parts", parts).Msgf("Meme:\n%+v", text) w.WriteHeader(200) w.Write(nil) - - go func() { - top, bottom := "", message - parts = strings.Split(message, "\n") - if len(parts) > 1 { - top, bottom = parts[0], parts[1] - } - - if top == "_" { - message = bottom - } else if bottom == "_" { - message = top - } - - id, err := p.genMeme(format, top, bottom, bullyIcon) - if err != nil { - msg := fmt.Sprintf("Hey %s, I couldn't download that image you asked for.", from) - p.bot.Send(c, bot.Message, channel, msg) - return - } - baseURL := p.c.Get("BaseURL", `https://catbase.velour.ninja`) - u, _ := url.Parse(baseURL) - u.Path = path.Join(u.Path, "meme", "img", id) - - log.Debug().Msgf("image is at %s", u.String()) - p.bot.Send(c, bot.Message, channel, "", bot.ImageAttachment{ - URL: u.String(), - AltTxt: fmt.Sprintf("%s: %s", from, message), - }) - m := msg.Message{ - User: &user.User{ - ID: from, - Name: from, - Admin: false, - }, - Channel: channel, - ChannelName: channelName, - Body: message, - Command: isCmd, - Time: time.Now(), - } - - p.bot.Receive(c, bot.Message, m) - }() } } @@ -322,7 +370,7 @@ var defaultFormats = map[string]string{ "raptor": "Philosoraptor.jpg", } -func (p *MemePlugin) genMeme(meme, top, bottom, bully string) (string, error) { +func (p *MemePlugin) genMeme(meme, top, bottom string, bully image.Image) (string, int, int, error) { fontSizes := []float64{48, 36, 24, 16, 12} fontSize := fontSizes[0] @@ -346,7 +394,7 @@ func (p *MemePlugin) genMeme(meme, top, bottom, bully string) (string, error) { img, err := DownloadTemplate(u) if err != nil { log.Debug().Msgf("failed to download image: %s", err) - return "", err + return "", 0, 0, err } r := img.Bounds() @@ -372,7 +420,7 @@ func (p *MemePlugin) genMeme(meme, top, bottom, bully string) (string, error) { h = r.Dy() log.Debug().Msgf("resized to %v, %v", w, h) - if bully != "" { + if bully != nil { img = p.applyBully(img, bully) } @@ -427,20 +475,11 @@ func (p *MemePlugin) genMeme(meme, top, bottom, bully string) (string, error) { log.Debug().Msgf("Saved to %s\n", path) - return path, nil + return path, w, h, nil } -func (p *MemePlugin) applyBully(img image.Image, bully string) image.Image { - log.Debug().Msgf("applying bully: %s", bully) +func (p *MemePlugin) applyBully(img, bullyImg image.Image) image.Image { dst := image.NewRGBA(img.Bounds()) - u, err := url.Parse(bully) - if err != nil { - return img - } - bullyImg, err := DownloadTemplate(u) - if err != nil { - return img - } scaleFactor := p.c.GetFloat64("meme.bullyScale", 0.1)