Skip to content

Commit

Permalink
Split messages if necessary (discord) (#2124)
Browse files Browse the repository at this point in the history
* Implement and test byte-splitting helper function

* Implement discord botuser message splitting

* Implement discord webhooks message splitting
  • Loading branch information
BenWiederhake authored May 23, 2024
1 parent 0bb5215 commit 4bf1c04
Show file tree
Hide file tree
Showing 6 changed files with 262 additions and 81 deletions.
1 change: 1 addition & 0 deletions bridge/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ type Protocol struct {
MessageLength int // IRC, max length of a message allowed
MessageQueue int // IRC, size of message queue for flood control
MessageSplit bool // IRC, split long messages with newlines on MessageLength instead of clipping
MessageSplitMaxCount int // discord, split long messages into at most this many messages instead of clipping (MessageLength=1950 cannot be configured)
Muc string // xmpp
MxID string // matrix
Name string // all protocols
Expand Down
59 changes: 39 additions & 20 deletions bridge/discord/discord.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ func (b *Bdiscord) handleEventBotUser(msg *config.Message, channelID string) (st
// Upload a file if it exists
if msg.Extra != nil {
for _, rmsg := range helper.HandleExtra(msg, b.General) {
// TODO: Use ClipOrSplitMessage
rmsg.Text = helper.ClipMessage(rmsg.Text, MessageLength, b.GetString("MessageClipped"))
if _, err := b.c.ChannelMessageSend(channelID, rmsg.Username+rmsg.Text); err != nil {
b.Log.Errorf("Could not send message %#v: %s", rmsg, err)
Expand All @@ -327,35 +328,53 @@ func (b *Bdiscord) handleEventBotUser(msg *config.Message, channelID string) (st
}
}

msg.Text = helper.ClipMessage(msg.Text, MessageLength, b.GetString("MessageClipped"))
msg.Text = b.replaceUserMentions(msg.Text)

// Edit message
if msg.ID != "" {
_, err := b.c.ChannelMessageEdit(channelID, msg.ID, msg.Username+msg.Text)
return msg.ID, err
// Exploit that a discord message ID is actually just a large number, and we encode a list of IDs by separating them with ";".
var msgIds = strings.Split(msg.ID, ";")
msgParts := helper.ClipOrSplitMessage(b.replaceUserMentions(msg.Text), MessageLength, b.GetString("MessageClipped"), len(msgIds))
for len(msgParts) < len(msgIds) {
msgParts = append(msgParts, "((obsoleted by edit))")
}
for i := range msgParts {
// In case of split-messages where some parts remain the same (i.e. only a typo-fix in a huge message), this causes some noop-updates.
// TODO: Optimize away noop-updates of un-edited messages
// TODO: Use RemoteNickFormat instead of this broken concatenation
_, err := b.c.ChannelMessageEdit(channelID, msgIds[i], msg.Username+msgParts[i])
if err != nil {
return "", err
}
}
return msg.ID, nil
}

m := discordgo.MessageSend{
Content: msg.Username + msg.Text,
AllowedMentions: b.getAllowedMentions(),
}
msgParts := helper.ClipOrSplitMessage(b.replaceUserMentions(msg.Text), MessageLength, b.GetString("MessageClipped"), b.GetInt("MessageSplitMaxCount"))
var msgIds = []string{}

if msg.ParentValid() {
m.Reference = &discordgo.MessageReference{
MessageID: msg.ParentID,
ChannelID: channelID,
GuildID: b.guildID,
for _, msgPart := range msgParts {
m := discordgo.MessageSend{
Content: msg.Username + msgPart,
AllowedMentions: b.getAllowedMentions(),
}
}

// Post normal message
res, err := b.c.ChannelMessageSendComplex(channelID, &m)
if err != nil {
return "", err
if msg.ParentValid() {
m.Reference = &discordgo.MessageReference{
MessageID: msg.ParentID,
ChannelID: channelID,
GuildID: b.guildID,
}
}

// Post normal message
res, err := b.c.ChannelMessageSendComplex(channelID, &m)
if err != nil {
return "", err
}
msgIds = append(msgIds, res.ID)
}

return res.ID, nil
// Exploit that a discord message ID is actually just a large number, so we encode a list of IDs by separating them with ";".
return strings.Join(msgIds, ";"), nil
}

// handleUploadFile handles native upload of files
Expand Down
143 changes: 84 additions & 59 deletions bridge/discord/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package bdiscord

import (
"bytes"
"strings"

"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
Expand Down Expand Up @@ -42,13 +43,65 @@ func (b *Bdiscord) maybeGetLocalAvatar(msg *config.Message) string {
return ""
}

func (b *Bdiscord) webhookSendTextOnly(msg *config.Message, channelID string) (string, error) {
msgParts := helper.ClipOrSplitMessage(msg.Text, MessageLength, b.GetString("MessageClipped"), b.GetInt("MessageSplitMaxCount"))
var msgIds = []string{}
for _, msgPart := range msgParts {
res, err := b.transmitter.Send(
channelID,
&discordgo.WebhookParams{
Content: msgPart,
Username: msg.Username,
AvatarURL: msg.Avatar,
AllowedMentions: b.getAllowedMentions(),
},
)
if err != nil {
return "", err
} else {
msgIds = append(msgIds, res.ID)
}
}
// Exploit that a discord message ID is actually just a large number, so we encode a list of IDs by separating them with ";".
return strings.Join(msgIds, ";"), nil
}

func (b *Bdiscord) webhookSendFilesOnly(msg *config.Message, channelID string) error {
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
file := discordgo.File{
Name: fi.Name,
ContentType: "",
Reader: bytes.NewReader(*fi.Data),
}
content := fi.Comment

// Cannot use the resulting ID for any edits anyway, so throw it away.
// This has to be re-enabled when we implement message deletion.
_, err := b.transmitter.Send(
channelID,
&discordgo.WebhookParams{
Username: msg.Username,
AvatarURL: msg.Avatar,
Files: []*discordgo.File{&file},
Content: content,
AllowedMentions: b.getAllowedMentions(),
},
)
if err != nil {
b.Log.Errorf("Could not send file %#v for message %#v: %s", file, msg, err)
return err
}
}
return nil
}

// webhookSend send one or more message via webhook, taking care of file
// uploads (from slack, telegram or mattermost).
// Returns messageID and error.
func (b *Bdiscord) webhookSend(msg *config.Message, channelID string) (*discordgo.Message, error) {
func (b *Bdiscord) webhookSend(msg *config.Message, channelID string) (string, error) {
var (
res *discordgo.Message
res2 *discordgo.Message
res string
err error
)

Expand All @@ -61,48 +114,11 @@ func (b *Bdiscord) webhookSend(msg *config.Message, channelID string) (*discordg

// We can't send empty messages.
if msg.Text != "" {
res, err = b.transmitter.Send(
channelID,
&discordgo.WebhookParams{
Content: msg.Text,
Username: msg.Username,
AvatarURL: msg.Avatar,
AllowedMentions: b.getAllowedMentions(),
},
)
if err != nil {
b.Log.Errorf("Could not send text (%s) for message %#v: %s", msg.Text, msg, err)
}
res, err = b.webhookSendTextOnly(msg, channelID)
}

if msg.Extra != nil {
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
file := discordgo.File{
Name: fi.Name,
ContentType: "",
Reader: bytes.NewReader(*fi.Data),
}
content := fi.Comment

res2, err = b.transmitter.Send(
channelID,
&discordgo.WebhookParams{
Username: msg.Username,
AvatarURL: msg.Avatar,
Files: []*discordgo.File{&file},
Content: content,
AllowedMentions: b.getAllowedMentions(),
},
)
if err != nil {
b.Log.Errorf("Could not send file %#v for message %#v: %s", file, msg, err)
}
}
}

if msg.Text == "" {
res = res2
if err == nil && msg.Extra != nil {
err = b.webhookSendFilesOnly(msg, channelID)
}

return res, err
Expand All @@ -120,35 +136,44 @@ func (b *Bdiscord) handleEventWebhook(msg *config.Message, channelID string) (st
return "", nil
}

msg.Text = helper.ClipMessage(msg.Text, MessageLength, b.GetString("MessageClipped"))
msg.Text = b.replaceUserMentions(msg.Text)
// discord username must be [0..32] max
if len(msg.Username) > 32 {
msg.Username = msg.Username[0:32]
}

if msg.ID != "" {
// Exploit that a discord message ID is actually just a large number, and we encode a list of IDs by separating them with ";".
var msgIds = strings.Split(msg.ID, ";")
msgParts := helper.ClipOrSplitMessage(b.replaceUserMentions(msg.Text), MessageLength, b.GetString("MessageClipped"), len(msgIds))
for len(msgParts) < len(msgIds) {
msgParts = append(msgParts, "((obsoleted by edit))")
}
b.Log.Debugf("Editing webhook message")
err := b.transmitter.Edit(channelID, msg.ID, &discordgo.WebhookParams{
Content: msg.Text,
Username: msg.Username,
AllowedMentions: b.getAllowedMentions(),
})
if err == nil {
var edit_err error = nil
for i := range msgParts {
// In case of split-messages where some parts remain the same (i.e. only a typo-fix in a huge message), this causes some noop-updates.
// TODO: Optimize away noop-updates of un-edited messages
edit_err = b.transmitter.Edit(channelID, msgIds[i], &discordgo.WebhookParams{
Content: msgParts[i],
Username: msg.Username,
AllowedMentions: b.getAllowedMentions(),
})
if edit_err != nil {
break
}
}
if edit_err == nil {
return msg.ID, nil
}
b.Log.Errorf("Could not edit webhook message: %s", err)
b.Log.Errorf("Could not edit webhook message(s): %s; sending as new message(s) instead", edit_err)
}

b.Log.Debugf("Processing webhook sending for message %#v", msg)
discordMsg, err := b.webhookSend(msg, channelID)
msg.Text = b.replaceUserMentions(msg.Text)
msgId, err := b.webhookSend(msg, channelID)
if err != nil {
b.Log.Errorf("Could not broadcast via webhook for message %#v: %s", msg, err)
b.Log.Errorf("Could not broadcast via webhook for message %#v: %s", msgId, err)
return "", err
}
if discordMsg == nil {
return "", nil
}

return discordMsg.ID, nil
return msgId, nil
}
27 changes: 27 additions & 0 deletions bridge/helper/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,33 @@ func ClipMessage(text string, length int, clippingMessage string) string {
return text
}

func ClipOrSplitMessage(text string, length int, clippingMessage string, splitMax int) []string {
var msgParts []string
var remainingText = text
// Invariant of this splitting loop: No text is lost (msgParts+remainingText is the original text),
// and all parts is guaranteed to satisfy the length requirement.
for len(msgParts) < splitMax - 1 && len(remainingText) > length {
// Decision: The text needs to be split (again).
var chunk string
var wasted = 0
// The longest UTF-8 encoding of a valid rune is 4 bytes (0xF4 0x8F 0xBF 0xBF, encoding U+10FFFF),
// so we should never need to waste 4 or more bytes at a time.
for wasted < 4 && wasted < length {
chunk = remainingText[:length - wasted]
if r, _ := utf8.DecodeLastRuneInString(chunk); r == utf8.RuneError {
wasted += 1
} else {
break
}
}
// Note: At this point, "chunk" might still be invalid, if "text" is very broken.
msgParts = append(msgParts, chunk)
remainingText = remainingText[len(chunk):]
}
msgParts = append(msgParts, ClipMessage(remainingText, length, clippingMessage))
return msgParts
}

// ParseMarkdown takes in an input string as markdown and parses it to html
func ParseMarkdown(input string) string {
extensions := parser.HardLineBreak | parser.NoIntraEmphasis | parser.FencedCode
Expand Down
Loading

0 comments on commit 4bf1c04

Please sign in to comment.