Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework Discord Webhooks with pictures #239

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 111 additions & 82 deletions discord.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,17 @@ import (
"crypto/sha1"
"encoding/base64"
"encoding/hex"
"github.com/acarl005/stripansi"
"github.com/bwmarrin/discordgo"
"github.com/leaanthony/go-ansi-parser"
"github.com/quackduck/term"
"golang.org/x/image/draw"
"image"
"image/color"
"image/png"
"os"
"strconv"
"strings"
"time"

"github.com/acarl005/stripansi"
"github.com/bwmarrin/discordgo"
"github.com/leaanthony/go-ansi-parser"
"github.com/quackduck/term"
"golang.org/x/image/draw"
)

var (
Expand All @@ -31,6 +29,8 @@ type DiscordMsg struct {
channel string
}

const maxWebhookCount = 13 // have buffer under the limit of 15 discord has

func discordInit() {
if Integrations.Discord == nil {
return
Expand All @@ -50,80 +50,97 @@ func discordInit() {
return
}

var webhook *discordgo.Webhook
var webhooks []*discordgo.Webhook
// get or create a webhook if we're not in compact mode
if !Integrations.Discord.CompactMode {
webhooks, err := sess.ChannelWebhooks(Integrations.Discord.ChannelID)
webhooks, err = sess.ChannelWebhooks(Integrations.Discord.ChannelID)
if err != nil {
Log.Println("Error getting Discord webhooks:", err)
return
}
for _, wh := range webhooks {
if wh.Name == "Devzat" {
webhook = wh
}
}
if webhook == nil {
webhook, err = sess.WebhookCreate(Integrations.Discord.ChannelID, "Devzat", "")
if err != nil {
Log.Println("Error creating a Discord webhook:", err)
return
}
webhooks = make([]*discordgo.Webhook, 0, maxWebhookCount)
}
}
DiscordChan = make(chan DiscordMsg, 100)
editsInLastMinute := 0 // discord allows for 30 webhook edits per minute: https://twitter.com/lolpython/status/967621046277820416
go func() {
overloading := false
nextMsg:
for msg := range DiscordChan {
sendingTimeStart := time.Now()
txt := strings.ReplaceAll(msg.msg, "@everyone", "@\\everyone")
if Integrations.Discord.CompactMode || overloading {
var toSend string
if msg.senderName == "" {
toSend = strings.ReplaceAll(stripansi.Strip("["+msg.channel+"] "+txt), `\n`, "\n")
} else {
toSend = strings.ReplaceAll(stripansi.Strip("["+msg.channel+"] **"+msg.senderName+"**: "+txt), `\n`, "\n")
if Integrations.Discord.CompactMode {
sendDiscordCompactMessage(msg, txt, sess)
} else {
avatar := createDiscordImage(msg.senderName)
avatarHash := shasum(avatar)
var webhook *discordgo.Webhook

// find the webhook for this user
for _, wh := range webhooks {
if wh.Name == avatarHash {
webhook = wh
break
}
}
_, err = sess.ChannelMessageSend(Integrations.Discord.ChannelID, toSend)
if err != nil {
Log.Println("Error sending Discord message:", err)
// delete unused webhooks if there are too many and a new one is needed
// (discord can only have 15 webhooks per channel)
if webhook == nil && len(webhooks) > maxWebhookCount {
// generate a list of all users in all rooms
users := make([]string, 0, maxWebhookCount)
for _, room := range Rooms {
for _, user := range room.users {
users = append(users, user.Name)
}
}
users = append(users, "", Devbot)
Comment on lines +85 to +91
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't feel right that this list is generated every message. Can we instead just update this list whenever users are added or removed?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, what happens when plugins send messages?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could have one webhook that handles all miscellaneous situations

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't feel right that this list is generated every message. Can we instead just update this list whenever users are added or removed?

This would need deeper changes outside of the discord bridge... i can try adding this in an expandable style or just for discord 🤷‍♀️

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could have one webhook that handles all miscellaneous situations

i could use the empy name webhook that is used for system commands, or add a difference between the max number of webhooks and the max number of users to use webhooks for so that webhooks are created for plugins, they might just get deleted after a user changes color or another plugin/the same plugin sends a message with different colors...

// if there are more users than webhooks we are recreating webhooks all the time which would get us
// rate limited by Discord, so just switch to compact mode
// TODO: AFK detection?
if len(users) >= maxWebhookCount {
sendDiscordCompactMessage(msg, txt, sess)
continue
}
// find a webhook that is not in use
for i, wh := range webhooks {
found := false
for _, user := range users {
if wh.Name == shasum(createDiscordImage(user)) { // generating all the images is cashed
found = true
break
}
}
if !found {
err = sess.WebhookDelete(wh.ID, discordgo.WithRetryOnRatelimit(false))
if err != nil {
Log.Println("Error deleting Discord webhook:", err)
sendDiscordCompactMessage(msg, txt, sess)
continue nextMsg
}
webhooks = append(webhooks[:i], webhooks[i+1:]...)
break
}
}
}
} else {
//Log.Println("edits in last minute", editsInLastMinute)
if len(DiscordChan) < 5 { // rate-limit the edits
avatarFor := msg.senderName
//if len(DiscordChan) == 9 { // blank out pfp if we're about to hit the limit
// avatarFor = ""
//}
//Log.Println("before edit")
//_, err = sess.WebhookEditWithToken(webhook.ID, webhook.Token, webhook.Name, createDiscordImage(avatarFor))
_, err = sess.WebhookEdit(webhook.ID, webhook.Name, createDiscordImage(avatarFor), webhook.ChannelID, discordgo.WithRetryOnRatelimit(true))
// create a new webhook if there isn't one for the users colors already
if webhook == nil {
webhook, err = sess.WebhookCreate(Integrations.Discord.ChannelID, avatarHash, avatar, discordgo.WithRetryOnRatelimit(false))
if err != nil {
Log.Println("Error modifying Discord webhook:", err)
Log.Println("Error creating Discord webhook:", err)
sendDiscordCompactMessage(msg, txt, sess)
continue
}
//Log.Println("after edit", msg.msg)
editsInLastMinute++
time.AfterFunc(time.Minute, func() { editsInLastMinute-- })
webhooks = append(webhooks, webhook)
}

_, err = sess.WebhookExecute(webhook.ID, webhook.Token, false,
&discordgo.WebhookParams{
Content: strings.ReplaceAll(stripansi.Strip(txt), `\n`, "\n"),
Username: stripansi.Strip("[" + msg.channel + "] " + msg.senderName),
},
discordgo.WithRetryOnRatelimit(true),
discordgo.WithRetryOnRatelimit(false),
)
if err != nil {
Log.Println("Error sending Discord message:", err)
sendDiscordCompactMessage(msg, txt, sess)
continue
}
}
elaspsedTime := time.Since(sendingTimeStart)
if elaspsedTime.Seconds() > 20 {
overloading = true
}
if len(DiscordChan) == 0 && elaspsedTime.Seconds() < 10 {
overloading = false
}
}
}()

Expand All @@ -134,6 +151,19 @@ func discordInit() {
Log.Println("Connected to Discord with bot ID", sess.State.User.ID, "as", sess.State.User.Username)
}

func sendDiscordCompactMessage(msg DiscordMsg, txt string, sess *discordgo.Session) {
var toSend string
if msg.senderName == "" {
toSend = strings.ReplaceAll(stripansi.Strip("["+msg.channel+"] "+txt), `\n`, "\n")
} else {
toSend = strings.ReplaceAll(stripansi.Strip("["+msg.channel+"] **"+msg.senderName+"**: "+txt), `\n`, "\n")
}
_, err := sess.ChannelMessageSend(Integrations.Discord.ChannelID, toSend)
if err != nil {
Log.Println("Error sending Discord message:", err)
}
}

func discordMessageHandler(_ *discordgo.Session, m *discordgo.MessageCreate) {
if m == nil || m.Author == nil || m.Author.Bot || m.ChannelID != Integrations.Discord.ChannelID { // ignore self and other channels
return
Expand All @@ -153,13 +183,22 @@ func discordMessageHandler(_ *discordgo.Session, m *discordgo.MessageCreate) {
runCommands(msgContent, DiscordUser)
}

var cacheSize = 20
const cacheSize = 20

// basic cache system
var imageCache = make([]struct {
user string
image string
}, cacheSize)
var imageCache = make(imgCache, cacheSize)

type imgCache map[string]string

func (i *imgCache) add(user, image string) {
if len(*i) >= cacheSize {
// remove the first value
for k := range *i {
delete(*i, k)
break
}
}
(*i)[user] = image
}
Comment on lines +192 to +201
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you need to delete the previous value in a map. You can set it directly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am trying to keep the map from getting to large, as the images themselves are stored in the map and users can easily change color or name and thus create extra entries in the cache. For that reason i believe there should be a limit as to not allow the ram usage to increase that much.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having the cache linked to a list of online members would be better but would also mean deeper changes into devzat's code that is not related to the discord bridge.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah nvm I see what the code does. we should make this an LRU cache.


func createDiscordImage(user string) string {
// a completely transparent one pixel png
Expand All @@ -168,16 +207,18 @@ func createDiscordImage(user string) string {
// make messages with no sender (eg. command outputs) look seamless
return fallback
}
for i := range imageCache {
if imageCache[i].user == user {
return imageCache[i].image
}
// Use image cache if possible
if img := imageCache[user]; img != "" {
return img
}
styledTexts, err := ansi.Parse(user)
if err != nil {
Log.Println("Error parsing ANSI from username while creating Discord avatar:", err)
return fallback
}
// Create an image with the colors of the username
// The image uses the width to display each color in the username
// and displays the background color on the top and bottom
img := image.NewNRGBA(image.Rect(0, 0, len(styledTexts), 3))

for i := 0; i < len(styledTexts); i++ {
Expand All @@ -190,16 +231,12 @@ func createDiscordImage(user string) string {
}
}

// Scale the image to 256x256
dst := image.NewNRGBA(image.Rect(0, 0, 256, 256))
//(&draw.Kernel{
// Support: 10,
// At: func(t float64) float64 {
// return math.Exp(-t * t * 2)
// },
//}).Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil)
//draw.BiLinear.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil)
draw.CatmullRom.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil)
//draw.NearestNeighbor.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil)

// Encode the image to base64
buff := new(bytes.Buffer)
err = png.Encode(buff, dst)
if err != nil {
Expand All @@ -208,14 +245,6 @@ func createDiscordImage(user string) string {
}
result := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buff.Bytes())

if len(imageCache) >= cacheSize {
// remove the first value
imageCache = imageCache[1:]
}
imageCache = append(imageCache, struct {
user string
image string
}{user: user, image: result})
//Log.Println("returned", result)
imageCache.add(user, result)
return result
}
Loading