matterbridge/bridge/discord/discord.go
Wim eaf92fca4d
Add an ID cache (discord). Fixes #1106 (#1111)
When a webhook "edits" a message, it does this by deleting the message
and creating a new one with the new content.

On creation of this new message, we'll get another ID then already is
know by the gateway in its id cache. So we add it in our own cache and
replace it whenever we want to edit/delete it again.
2020-04-21 23:35:46 +02:00

471 lines
12 KiB
Go

package bdiscord
import (
"bytes"
"errors"
"fmt"
"strings"
"sync"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/helper"
"github.com/matterbridge/discordgo"
)
const MessageLength = 1950
type Bdiscord struct {
*bridge.Config
c *discordgo.Session
nick string
userID string
guildID string
webhookID string
webhookToken string
canEditWebhooks bool
channelsMutex sync.RWMutex
channels []*discordgo.Channel
channelInfoMap map[string]*config.ChannelInfo
membersMutex sync.RWMutex
userMemberMap map[string]*discordgo.Member
nickMemberMap map[string]*discordgo.Member
webhookCache map[string]string
webhookMutex sync.RWMutex
}
func New(cfg *bridge.Config) bridge.Bridger {
b := &Bdiscord{Config: cfg}
b.userMemberMap = make(map[string]*discordgo.Member)
b.nickMemberMap = make(map[string]*discordgo.Member)
b.channelInfoMap = make(map[string]*config.ChannelInfo)
b.webhookCache = make(map[string]string)
if b.GetString("WebhookURL") != "" {
b.Log.Debug("Configuring Discord Incoming Webhook")
b.webhookID, b.webhookToken = b.splitURL(b.GetString("WebhookURL"))
}
return b
}
func (b *Bdiscord) Connect() error {
var err error
var guildFound bool
token := b.GetString("Token")
b.Log.Info("Connecting")
if b.GetString("WebhookURL") == "" {
b.Log.Info("Connecting using token")
} else {
b.Log.Info("Connecting using webhookurl (for posting) and token")
}
if !strings.HasPrefix(b.GetString("Token"), "Bot ") {
token = "Bot " + b.GetString("Token")
}
// if we have a User token, remove the `Bot` prefix
if strings.HasPrefix(b.GetString("Token"), "User ") {
token = strings.Replace(b.GetString("Token"), "User ", "", -1)
}
b.c, err = discordgo.New(token)
if err != nil {
return err
}
b.Log.Info("Connection succeeded")
b.c.AddHandler(b.messageCreate)
b.c.AddHandler(b.messageTyping)
b.c.AddHandler(b.memberUpdate)
b.c.AddHandler(b.messageUpdate)
b.c.AddHandler(b.messageDelete)
b.c.AddHandler(b.messageDeleteBulk)
b.c.AddHandler(b.memberAdd)
b.c.AddHandler(b.memberRemove)
err = b.c.Open()
if err != nil {
return err
}
guilds, err := b.c.UserGuilds(100, "", "")
if err != nil {
return err
}
userinfo, err := b.c.User("@me")
if err != nil {
return err
}
serverName := strings.Replace(b.GetString("Server"), "ID:", "", -1)
b.nick = userinfo.Username
b.userID = userinfo.ID
b.channelsMutex.Lock()
for _, guild := range guilds {
if guild.Name == serverName || guild.ID == serverName {
b.channels, err = b.c.GuildChannels(guild.ID)
if err != nil {
break
}
b.guildID = guild.ID
guildFound = true
}
}
b.channelsMutex.Unlock()
if !guildFound {
msg := fmt.Sprintf("Server \"%s\" not found", b.GetString("Server"))
err = errors.New(msg)
b.Log.Error(msg)
b.Log.Info("Possible values:")
for _, guild := range guilds {
b.Log.Infof("Server=\"%s\" # Server name", guild.Name)
b.Log.Infof("Server=\"%s\" # Server ID", guild.ID)
}
}
if err != nil {
return err
}
b.channelsMutex.RLock()
if b.GetString("WebhookURL") == "" {
for _, channel := range b.channels {
b.Log.Debugf("found channel %#v", channel)
}
} else {
manageWebhooks := discordgo.PermissionManageWebhooks
var channelsDenied []string
for _, info := range b.Channels {
id := b.getChannelID(info.Name) // note(qaisjp): this readlocks channelsMutex
b.Log.Debugf("Verifying PermissionManageWebhooks for %s with ID %s", info.ID, id)
perms, permsErr := b.c.UserChannelPermissions(userinfo.ID, id)
if permsErr != nil {
b.Log.Warnf("Failed to check PermissionManageWebhooks in channel \"%s\": %s", info.Name, permsErr.Error())
} else if perms&manageWebhooks == manageWebhooks {
continue
}
channelsDenied = append(channelsDenied, fmt.Sprintf("%#v", info.Name))
}
b.canEditWebhooks = len(channelsDenied) == 0
if b.canEditWebhooks {
b.Log.Info("Can manage webhooks; will edit channel for global webhook on send")
} else {
b.Log.Warn("Can't manage webhooks; won't edit channel for global webhook on send")
b.Log.Warn("Can't manage webhooks in channels: ", strings.Join(channelsDenied, ", "))
}
}
b.channelsMutex.RUnlock()
// Obtaining guild members and initializing nickname mapping.
b.membersMutex.Lock()
defer b.membersMutex.Unlock()
members, err := b.c.GuildMembers(b.guildID, "", 1000)
if err != nil {
b.Log.Error("Error obtaining server members: ", err)
return err
}
for _, member := range members {
if member == nil {
b.Log.Warnf("Skipping missing information for a user.")
continue
}
b.userMemberMap[member.User.ID] = member
b.nickMemberMap[member.User.Username] = member
if member.Nick != "" {
b.nickMemberMap[member.Nick] = member
}
}
return nil
}
func (b *Bdiscord) Disconnect() error {
return b.c.Close()
}
func (b *Bdiscord) JoinChannel(channel config.ChannelInfo) error {
b.channelsMutex.Lock()
defer b.channelsMutex.Unlock()
b.channelInfoMap[channel.ID] = &channel
return nil
}
func (b *Bdiscord) Send(msg config.Message) (string, error) {
b.Log.Debugf("=> Receiving %#v", msg)
origMsgID := msg.ID
channelID := b.getChannelID(msg.Channel)
if channelID == "" {
return "", fmt.Errorf("Could not find channelID for %v", msg.Channel)
}
if msg.Event == config.EventUserTyping {
if b.GetBool("ShowUserTyping") {
err := b.c.ChannelTyping(channelID)
return "", err
}
return "", nil
}
// Make a action /me of the message
if msg.Event == config.EventUserAction {
msg.Text = "_" + msg.Text + "_"
}
// use initial webhook configured for the entire Discord account
isGlobalWebhook := true
wID := b.webhookID
wToken := b.webhookToken
// check if have a channel specific webhook
b.channelsMutex.RLock()
if ci, ok := b.channelInfoMap[msg.Channel+b.Account]; ok {
if ci.Options.WebhookURL != "" {
wID, wToken = b.splitURL(ci.Options.WebhookURL)
isGlobalWebhook = false
}
}
b.channelsMutex.RUnlock()
// Use webhook to send the message
if wID != "" && msg.Event != config.EventMsgDelete {
// skip events
if msg.Event != "" && msg.Event != config.EventJoinLeave && msg.Event != config.EventTopicChange {
return "", nil
}
// If we are editing a message, delete the old message
if msg.ID != "" {
msg.ID = b.getCacheID(msg.ID)
b.Log.Debugf("Deleting edited webhook message")
err := b.c.ChannelMessageDelete(channelID, msg.ID)
if err != nil {
b.Log.Errorf("Could not delete edited webhook message: %s", err)
}
}
b.Log.Debugf("Broadcasting using Webhook")
// skip empty messages
if msg.Text == "" && (msg.Extra == nil || len(msg.Extra["file"]) == 0) {
b.Log.Debugf("Skipping empty message %#v", msg)
return "", nil
}
msg.Text = helper.ClipMessage(msg.Text, MessageLength)
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 we have a global webhook for this Discord account, and permission
// to modify webhooks (previously verified), then set its channel to
// the message channel before using it
// TODO: this isn't necessary if the last message from this webhook was
// sent to the current channel
if isGlobalWebhook && b.canEditWebhooks {
b.Log.Debugf("Setting webhook channel to \"%s\"", msg.Channel)
_, err := b.c.WebhookEdit(wID, "", "", channelID)
if err != nil {
b.Log.Errorf("Could not set webhook channel: %s", err)
return "", err
}
}
b.Log.Debugf("Processing webhook sending for message %#v", msg)
msg, err := b.webhookSend(&msg, wID, wToken)
if err != nil {
b.Log.Errorf("Could not broadcast via webook for message %#v: %s", msg, err)
return "", err
}
if msg == nil {
return "", nil
}
b.updateCacheID(origMsgID, msg.ID)
return msg.ID, nil
}
b.Log.Debugf("Broadcasting using token (API)")
// Delete message
if msg.Event == config.EventMsgDelete {
if msg.ID == "" {
return "", nil
}
msg.ID = b.getCacheID(msg.ID)
err := b.c.ChannelMessageDelete(channelID, msg.ID)
return "", err
}
// Upload a file if it exists
if msg.Extra != nil {
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
rmsg.Text = helper.ClipMessage(rmsg.Text, MessageLength)
if _, err := b.c.ChannelMessageSend(channelID, rmsg.Username+rmsg.Text); err != nil {
b.Log.Errorf("Could not send message %#v: %s", rmsg, err)
}
}
// check if we have files to upload (from slack, telegram or mattermost)
if len(msg.Extra["file"]) > 0 {
return b.handleUploadFile(&msg, channelID)
}
}
msg.Text = helper.ClipMessage(msg.Text, MessageLength)
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
}
// Post normal message
res, err := b.c.ChannelMessageSend(channelID, msg.Username+msg.Text)
if err != nil {
return "", err
}
return res.ID, nil
}
// useWebhook returns true if we have a webhook defined somewhere
func (b *Bdiscord) useWebhook() bool {
if b.GetString("WebhookURL") != "" {
return true
}
b.channelsMutex.RLock()
defer b.channelsMutex.RUnlock()
for _, channel := range b.channelInfoMap {
if channel.Options.WebhookURL != "" {
return true
}
}
return false
}
// isWebhookID returns true if the specified id is used in a defined webhook
func (b *Bdiscord) isWebhookID(id string) bool {
if b.GetString("WebhookURL") != "" {
wID, _ := b.splitURL(b.GetString("WebhookURL"))
if wID == id {
return true
}
}
b.channelsMutex.RLock()
defer b.channelsMutex.RUnlock()
for _, channel := range b.channelInfoMap {
if channel.Options.WebhookURL != "" {
wID, _ := b.splitURL(channel.Options.WebhookURL)
if wID == id {
return true
}
}
}
return false
}
// handleUploadFile handles native upload of files
func (b *Bdiscord) handleUploadFile(msg *config.Message, channelID string) (string, error) {
var err error
for _, f := range msg.Extra["file"] {
fi := f.(config.FileInfo)
file := discordgo.File{
Name: fi.Name,
ContentType: "",
Reader: bytes.NewReader(*fi.Data),
}
m := discordgo.MessageSend{
Content: msg.Username + fi.Comment,
Files: []*discordgo.File{&file},
}
_, err = b.c.ChannelMessageSendComplex(channelID, &m)
if err != nil {
return "", fmt.Errorf("file upload failed: %s", 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, webhookID, token string) (*discordgo.Message, error) {
var (
res *discordgo.Message
err error
)
// If avatar is unset, check if UseLocalAvatar contains the message's
// account or protocol, and if so, try to find a local avatar
if msg.Avatar == "" {
for _, val := range b.GetStringSlice("UseLocalAvatar") {
if msg.Protocol == val || msg.Account == val {
if avatar := b.findAvatar(msg); avatar != "" {
msg.Avatar = avatar
}
break
}
}
}
// WebhookParams can have either `Content` or `File`.
// We can't send empty messages.
if msg.Text != "" {
res, err = b.c.WebhookExecute(
webhookID,
token,
true,
&discordgo.WebhookParams{
Content: msg.Text,
Username: msg.Username,
AvatarURL: msg.Avatar,
},
)
if err != nil {
b.Log.Errorf("Could not send text (%s) for message %#v: %s", msg.Text, msg, err)
}
}
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 := ""
if msg.Text == "" {
content = fi.Comment
}
_, e2 := b.c.WebhookExecute(
webhookID,
token,
false,
&discordgo.WebhookParams{
Username: msg.Username,
AvatarURL: msg.Avatar,
File: &file,
Content: content,
},
)
if e2 != nil {
b.Log.Errorf("Could not send file %#v for message %#v: %s", file, msg, e2)
}
}
}
return res, err
}
func (b *Bdiscord) findAvatar(m *config.Message) string {
member, err := b.getGuildMemberByNick(m.Username)
if err != nil {
return ""
}
return member.User.AvatarURL("")
}