diff --git a/bridge/discord/discord.go b/bridge/discord/discord.go index 3a11a504..16ce8c05 100644 --- a/bridge/discord/discord.go +++ b/bridge/discord/discord.go @@ -2,9 +2,7 @@ package bdiscord import ( "bytes" - "errors" "fmt" - "regexp" "strings" "sync" @@ -17,18 +15,23 @@ import ( const MessageLength = 1950 type Bdiscord struct { - c *discordgo.Session - Channels []*discordgo.Channel - Nick string - UseChannelID bool - userMemberMap map[string]*discordgo.Member - nickMemberMap map[string]*discordgo.Member - guildID string - webhookID string - webhookToken string - channelInfoMap map[string]*config.ChannelInfo - sync.RWMutex *bridge.Config + + c *discordgo.Session + + nick string + useChannelID bool + guildID string + webhookID string + webhookToken string + + channelsMutex sync.RWMutex + channels []*discordgo.Channel + channelInfoMap map[string]*config.ChannelInfo + + membersMutex sync.RWMutex + userMemberMap map[string]*discordgo.Member + nickMemberMap map[string]*discordgo.Member } func New(cfg *bridge.Config) bridge.Bridger { @@ -77,28 +80,40 @@ func (b *Bdiscord) Connect() error { return err } serverName := strings.Replace(b.GetString("Server"), "ID:", "", -1) - b.Nick = userinfo.Username + b.nick = userinfo.Username + b.channelsMutex.Lock() for _, guild := range guilds { if guild.Name == serverName || guild.ID == serverName { - b.Channels, err = b.c.GuildChannels(guild.ID) + b.channels, err = b.c.GuildChannels(guild.ID) b.guildID = guild.ID if err != nil { - return err + break } } } - for _, channel := range b.Channels { + b.channelsMutex.Unlock() + if err != nil { + return err + } + b.channelsMutex.RLock() + for _, channel := range b.channels { b.Log.Debugf("found channel %#v", channel) } - // obtaining guild members and initializing nickname mapping - b.Lock() - defer b.Unlock() + 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 guild 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 != "" { @@ -113,10 +128,13 @@ func (b *Bdiscord) Disconnect() error { } func (b *Bdiscord) JoinChannel(channel config.ChannelInfo) error { + b.channelsMutex.Lock() + defer b.channelsMutex.Unlock() + b.channelInfoMap[channel.ID] = &channel idcheck := strings.Split(channel.Name, "ID:") if len(idcheck) > 1 { - b.UseChannelID = true + b.useChannelID = true } return nil } @@ -139,11 +157,13 @@ func (b *Bdiscord) Send(msg config.Message) (string, error) { 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) } } + b.channelsMutex.RUnlock() // Use webhook to send the message if wID != "" { @@ -196,7 +216,9 @@ func (b *Bdiscord) Send(msg config.Message) (string, error) { if msg.Extra != nil { for _, rmsg := range helper.HandleExtra(&msg, b.General) { rmsg.Text = helper.ClipMessage(rmsg.Text, MessageLength) - b.c.ChannelMessageSend(channelID, rmsg.Username+rmsg.Text) + if _, err := b.c.ChannelMessageSend(channelID, rmsg.Username+rmsg.Text); err != nil { + b.Log.Errorf("Could not send message %#v: %v", rmsg, err) + } } // check if we have files to upload (from slack, telegram or mattermost) if len(msg.Extra["file"]) > 0 { @@ -221,246 +243,15 @@ func (b *Bdiscord) Send(msg config.Message) (string, error) { return res.ID, err } -func (b *Bdiscord) messageDelete(s *discordgo.Session, m *discordgo.MessageDelete) { - rmsg := config.Message{Account: b.Account, ID: m.ID, Event: config.EventMsgDelete, Text: config.EventMsgDelete} - rmsg.Channel = b.getChannelName(m.ChannelID) - if b.UseChannelID { - rmsg.Channel = "ID:" + m.ChannelID - } - b.Log.Debugf("<= Sending message from %s to gateway", b.Account) - b.Log.Debugf("<= Message is %#v", rmsg) - b.Remote <- rmsg -} - -func (b *Bdiscord) messageUpdate(s *discordgo.Session, m *discordgo.MessageUpdate) { - if b.GetBool("EditDisable") { - return - } - // only when message is actually edited - if m.Message.EditedTimestamp != "" { - b.Log.Debugf("Sending edit message") - m.Content += b.GetString("EditSuffix") - b.messageCreate(s, (*discordgo.MessageCreate)(m)) - } -} - -func (b *Bdiscord) messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { - var err error - - // not relay our own messages - if m.Author.Username == b.Nick { - return - } - // if using webhooks, do not relay if it's ours - if b.useWebhook() && m.Author.Bot && b.isWebhookID(m.Author.ID) { - return - } - - // add the url of the attachments to content - if len(m.Attachments) > 0 { - for _, attach := range m.Attachments { - m.Content = m.Content + "\n" + attach.URL - } - } - - rmsg := config.Message{Account: b.Account, Avatar: "https://cdn.discordapp.com/avatars/" + m.Author.ID + "/" + m.Author.Avatar + ".jpg", UserID: m.Author.ID, ID: m.ID} - - if m.Content != "" { - b.Log.Debugf("== Receiving event %#v", m.Message) - m.Message.Content = b.stripCustomoji(m.Message.Content) - m.Message.Content = b.replaceChannelMentions(m.Message.Content) - rmsg.Text, err = m.ContentWithMoreMentionsReplaced(b.c) - if err != nil { - b.Log.Errorf("ContentWithMoreMentionsReplaced failed: %s", err) - rmsg.Text = m.ContentWithMentionsReplaced() - } - } - - // set channel name - rmsg.Channel = b.getChannelName(m.ChannelID) - if b.UseChannelID { - rmsg.Channel = "ID:" + m.ChannelID - } - - // set username - if !b.GetBool("UseUserName") { - rmsg.Username = b.getNick(m.Author) - } else { - rmsg.Username = m.Author.Username - } - - // if we have embedded content add it to text - if b.GetBool("ShowEmbeds") && m.Message.Embeds != nil { - for _, embed := range m.Message.Embeds { - rmsg.Text = rmsg.Text + "embed: " + embed.Title + " - " + embed.Description + " - " + embed.URL + "\n" - } - } - - // no empty messages - if rmsg.Text == "" { - return - } - - // do we have a /me action - var ok bool - rmsg.Text, ok = b.replaceAction(rmsg.Text) - if ok { - rmsg.Event = config.EventUserAction - } - - b.Log.Debugf("<= Sending message from %s on %s to gateway", m.Author.Username, b.Account) - b.Log.Debugf("<= Message is %#v", rmsg) - b.Remote <- rmsg -} - -func (b *Bdiscord) memberUpdate(s *discordgo.Session, m *discordgo.GuildMemberUpdate) { - b.Lock() - if _, ok := b.userMemberMap[m.Member.User.ID]; ok { - b.Log.Debugf("%s: memberupdate: user %s (nick %s) changes nick to %s", b.Account, m.Member.User.Username, b.userMemberMap[m.Member.User.ID].Nick, m.Member.Nick) - } - b.userMemberMap[m.Member.User.ID] = m.Member - b.nickMemberMap[m.Member.User.Username] = m.Member - if m.Member.Nick != "" { - b.nickMemberMap[m.Member.Nick] = m.Member - } - b.Unlock() -} - -func (b *Bdiscord) getNick(user *discordgo.User) string { - var err error - b.Lock() - defer b.Unlock() - if _, ok := b.userMemberMap[user.ID]; ok { - if b.userMemberMap[user.ID] != nil { - if b.userMemberMap[user.ID].Nick != "" { - // only return if nick is set - return b.userMemberMap[user.ID].Nick - } - // otherwise return username - return user.Username - } - } - // if we didn't find nick, search for it - member, err := b.c.GuildMember(b.guildID, user.ID) - if err != nil { - return user.Username - } - b.userMemberMap[user.ID] = member - // only return if nick is set - if b.userMemberMap[user.ID].Nick != "" { - return b.userMemberMap[user.ID].Nick - } - return user.Username -} - -func (b *Bdiscord) getGuildMemberByNick(nick string) (*discordgo.Member, error) { - b.Lock() - defer b.Unlock() - if _, ok := b.nickMemberMap[nick]; ok { - if b.nickMemberMap[nick] != nil { - return b.nickMemberMap[nick], nil - } - } - - return nil, errors.New("Couldn't find guild member with nick " + nick) // This will most likely get ignored by the caller -} - -func (b *Bdiscord) getChannelID(name string) string { - idcheck := strings.Split(name, "ID:") - if len(idcheck) > 1 { - return idcheck[1] - } - for _, channel := range b.Channels { - if channel.Name == name { - return channel.ID - } - } - return "" -} - -func (b *Bdiscord) getChannelName(id string) string { - for _, channel := range b.Channels { - if channel.ID == id { - return channel.Name - } - } - return "" -} - -func (b *Bdiscord) replaceChannelMentions(text string) string { - var err error - re := regexp.MustCompile("<#[0-9]+>") - text = re.ReplaceAllStringFunc(text, func(m string) string { - channel := b.getChannelName(m[2 : len(m)-1]) - // if at first don't succeed, try again - if channel == "" { - b.Channels, err = b.c.GuildChannels(b.guildID) - if err != nil { - return "#unknownchannel" - } - channel = b.getChannelName(m[2 : len(m)-1]) - return "#" + channel - } - return "#" + channel - }) - return text -} - -func (b *Bdiscord) replaceUserMentions(text string) string { - re := regexp.MustCompile("@[^@]{1,32}") - text = re.ReplaceAllStringFunc(text, func(m string) string { - mention := strings.TrimSpace(m[1:]) - var member *discordgo.Member - var err error - for { - b.Log.Debugf("Testing mention: '%s'", mention) - member, err = b.getGuildMemberByNick(mention) - if err != nil { - lastSpace := strings.LastIndex(mention, " ") - if lastSpace == -1 { - break - } - mention = strings.TrimSpace(mention[0:lastSpace]) - } else { - break - } - } - if err != nil { - return m - } - return strings.Replace(m, "@"+mention, member.User.Mention(), -1) - }) - b.Log.Debugf("Message with mention replaced: %s", text) - return text -} - -func (b *Bdiscord) replaceAction(text string) (string, bool) { - if strings.HasPrefix(text, "_") && strings.HasSuffix(text, "_") { - return strings.Replace(text, "_", "", -1), true - } - return text, false -} - -func (b *Bdiscord) stripCustomoji(text string) string { - // <:doge:302803592035958784> - re := regexp.MustCompile("<(:.*?:)[0-9]+>") - return re.ReplaceAllString(text, `$1`) -} - -// splitURL splits a webhookURL and returns the id and token -func (b *Bdiscord) splitURL(url string) (string, string) { - webhookURLSplit := strings.Split(url, "/") - if len(webhookURLSplit) != 7 { - b.Log.Fatalf("%s is no correct discord WebhookURL", url) - } - return webhookURLSplit[len(webhookURLSplit)-2], webhookURLSplit[len(webhookURLSplit)-1] -} - // 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 @@ -477,6 +268,10 @@ func (b *Bdiscord) isWebhookID(id string) bool { return true } } + + b.channelsMutex.RLock() + defer b.channelsMutex.RUnlock() + for _, channel := range b.channelInfoMap { if channel.Options.WebhookURL != "" { wID, _ := b.splitURL(channel.Options.WebhookURL) diff --git a/bridge/discord/handlers.go b/bridge/discord/handlers.go new file mode 100644 index 00000000..79c32015 --- /dev/null +++ b/bridge/discord/handlers.go @@ -0,0 +1,125 @@ +package bdiscord + +import ( + "github.com/42wim/matterbridge/bridge/config" + "github.com/bwmarrin/discordgo" +) + +func (b *Bdiscord) messageDelete(s *discordgo.Session, m *discordgo.MessageDelete) { + rmsg := config.Message{Account: b.Account, ID: m.ID, Event: config.EventMsgDelete, Text: config.EventMsgDelete} + rmsg.Channel = b.getChannelName(m.ChannelID) + if b.useChannelID { + rmsg.Channel = "ID:" + m.ChannelID + } + b.Log.Debugf("<= Sending message from %s to gateway", b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg +} + +func (b *Bdiscord) messageUpdate(s *discordgo.Session, m *discordgo.MessageUpdate) { + if b.GetBool("EditDisable") { + return + } + // only when message is actually edited + if m.Message.EditedTimestamp != "" { + b.Log.Debugf("Sending edit message") + m.Content += b.GetString("EditSuffix") + b.messageCreate(s, (*discordgo.MessageCreate)(m)) + } +} + +func (b *Bdiscord) messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { + var err error + + // not relay our own messages + if m.Author.Username == b.nick { + return + } + // if using webhooks, do not relay if it's ours + if b.useWebhook() && m.Author.Bot && b.isWebhookID(m.Author.ID) { + return + } + + // add the url of the attachments to content + if len(m.Attachments) > 0 { + for _, attach := range m.Attachments { + m.Content = m.Content + "\n" + attach.URL + } + } + + rmsg := config.Message{Account: b.Account, Avatar: "https://cdn.discordapp.com/avatars/" + m.Author.ID + "/" + m.Author.Avatar + ".jpg", UserID: m.Author.ID, ID: m.ID} + + if m.Content != "" { + b.Log.Debugf("== Receiving event %#v", m.Message) + m.Message.Content = b.stripCustomoji(m.Message.Content) + m.Message.Content = b.replaceChannelMentions(m.Message.Content) + rmsg.Text, err = m.ContentWithMoreMentionsReplaced(b.c) + if err != nil { + b.Log.Errorf("ContentWithMoreMentionsReplaced failed: %s", err) + rmsg.Text = m.ContentWithMentionsReplaced() + } + } + + // set channel name + rmsg.Channel = b.getChannelName(m.ChannelID) + if b.useChannelID { + rmsg.Channel = "ID:" + m.ChannelID + } + + // set username + if !b.GetBool("UseUserName") { + rmsg.Username = b.getNick(m.Author) + } else { + rmsg.Username = m.Author.Username + } + + // if we have embedded content add it to text + if b.GetBool("ShowEmbeds") && m.Message.Embeds != nil { + for _, embed := range m.Message.Embeds { + rmsg.Text = rmsg.Text + "embed: " + embed.Title + " - " + embed.Description + " - " + embed.URL + "\n" + } + } + + // no empty messages + if rmsg.Text == "" { + return + } + + // do we have a /me action + var ok bool + rmsg.Text, ok = b.replaceAction(rmsg.Text) + if ok { + rmsg.Event = config.EventUserAction + } + + b.Log.Debugf("<= Sending message from %s on %s to gateway", m.Author.Username, b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg +} + +func (b *Bdiscord) memberUpdate(s *discordgo.Session, m *discordgo.GuildMemberUpdate) { + if m.Member == nil { + b.Log.Warnf("Received member update with no member information: %#v", m) + } + + b.membersMutex.Lock() + defer b.membersMutex.Unlock() + + if currMember, ok := b.userMemberMap[m.Member.User.ID]; ok { + b.Log.Debugf( + "%s: memberupdate: user %s (nick %s) changes nick to %s", + b.Account, + m.Member.User.Username, + b.userMemberMap[m.Member.User.ID].Nick, + m.Member.Nick, + ) + delete(b.nickMemberMap, currMember.User.Username) + delete(b.nickMemberMap, currMember.Nick) + delete(b.userMemberMap, m.Member.User.ID) + } + b.userMemberMap[m.Member.User.ID] = m.Member + b.nickMemberMap[m.Member.User.Username] = m.Member + if m.Member.Nick != "" { + b.nickMemberMap[m.Member.Nick] = m.Member + } +} diff --git a/bridge/discord/helpers.go b/bridge/discord/helpers.go new file mode 100644 index 00000000..e693498c --- /dev/null +++ b/bridge/discord/helpers.go @@ -0,0 +1,189 @@ +package bdiscord + +import ( + "errors" + "regexp" + "strings" + "unicode" + + "github.com/bwmarrin/discordgo" +) + +func (b *Bdiscord) getNick(user *discordgo.User) string { + b.membersMutex.RLock() + defer b.membersMutex.RUnlock() + + if member, ok := b.userMemberMap[user.ID]; ok { + if member.Nick != "" { + // Only return if nick is set. + return member.Nick + } + // Otherwise return username. + return user.Username + } + + // If we didn't find nick, search for it. + member, err := b.c.GuildMember(b.guildID, user.ID) + if err != nil { + b.Log.Warnf("Failed to fetch information for member %#v: %#v", user, err) + return user.Username + } else if member == nil { + b.Log.Warnf("Got no information for member %#v", user) + return user.Username + } + b.userMemberMap[user.ID] = member + b.nickMemberMap[member.User.Username] = member + if member.Nick != "" { + b.nickMemberMap[member.Nick] = member + return member.Nick + } + return user.Username +} + +func (b *Bdiscord) getGuildMemberByNick(nick string) (*discordgo.Member, error) { + b.membersMutex.RLock() + defer b.membersMutex.RUnlock() + + if member, ok := b.nickMemberMap[nick]; ok { + return member, nil + } + return nil, errors.New("Couldn't find guild member with nick " + nick) // This will most likely get ignored by the caller +} + +func (b *Bdiscord) getChannelID(name string) string { + b.channelsMutex.RLock() + defer b.channelsMutex.RUnlock() + + idcheck := strings.Split(name, "ID:") + if len(idcheck) > 1 { + return idcheck[1] + } + for _, channel := range b.channels { + if channel.Name == name { + return channel.ID + } + } + return "" +} + +func (b *Bdiscord) getChannelName(id string) string { + b.channelsMutex.RLock() + defer b.channelsMutex.RUnlock() + + for _, channel := range b.channels { + if channel.ID == id { + return channel.Name + } + } + return "" +} + +var ( + // See https://discordapp.com/developers/docs/reference#message-formatting. + channelMentionRE = regexp.MustCompile("<#[0-9]+>") + emojiRE = regexp.MustCompile("<(:.*?:)[0-9]+>") + userMentionRE = regexp.MustCompile("@[^@\n]{1,32}") +) + +func (b *Bdiscord) replaceChannelMentions(text string) string { + replaceChannelMentionFunc := func(match string) string { + var err error + channelID := match[2 : len(match)-1] + + channelName := b.getChannelName(channelID) + // If we don't have the channel refresh our list. + if channelName == "" { + b.channels, err = b.c.GuildChannels(b.guildID) + if err != nil { + return "#unknownchannel" + } + channelName = b.getChannelName(channelID) + } + return "#" + channelName + } + return channelMentionRE.ReplaceAllStringFunc(text, replaceChannelMentionFunc) +} + +func (b *Bdiscord) replaceUserMentions(text string) string { + replaceUserMentionFunc := func(match string) string { + var ( + err error + member *discordgo.Member + username string + ) + + usernames := enumerateUsernames(match[1:]) + for _, username = range usernames { + b.Log.Debugf("Testing mention: '%s'", username) + member, err = b.getGuildMemberByNick(username) + if err == nil { + break + } + } + if member == nil { + return match + } + return strings.Replace(match, "@"+username, member.User.Mention(), 1) + } + return userMentionRE.ReplaceAllStringFunc(text, replaceUserMentionFunc) +} + +func (b *Bdiscord) stripCustomoji(text string) string { + return emojiRE.ReplaceAllString(text, `$1`) +} + +func (b *Bdiscord) replaceAction(text string) (string, bool) { + if strings.HasPrefix(text, "_") && strings.HasSuffix(text, "_") { + return text[1:], true + } + return text, false +} + +// splitURL splits a webhookURL and returns the ID and token. +func (b *Bdiscord) splitURL(url string) (string, string) { + const ( + expectedWebhookSplitCount = 7 + webhookIdxID = 5 + webhookIdxToken = 6 + ) + webhookURLSplit := strings.Split(url, "/") + if len(webhookURLSplit) != expectedWebhookSplitCount { + b.Log.Fatalf("%s is no correct discord WebhookURL", url) + } + return webhookURLSplit[webhookIdxID], webhookURLSplit[webhookIdxToken] +} + +func enumerateUsernames(s string) []string { + onlySpace := true + for _, r := range s { + if !unicode.IsSpace(r) { + onlySpace = false + break + } + } + if onlySpace { + return nil + } + + var username, endSpace string + var usernames []string + skippingSpace := true + for _, r := range s { + if unicode.IsSpace(r) { + if !skippingSpace { + usernames = append(usernames, username) + skippingSpace = true + } + endSpace += string(r) + username += string(r) + } else { + endSpace = "" + username += string(r) + skippingSpace = false + } + } + if endSpace == "" { + usernames = append(usernames, username) + } + return usernames +} diff --git a/bridge/discord/helpers_test.go b/bridge/discord/helpers_test.go new file mode 100644 index 00000000..0f196d9a --- /dev/null +++ b/bridge/discord/helpers_test.go @@ -0,0 +1,46 @@ +package bdiscord + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEnumerateUsernames(t *testing.T) { + testcases := map[string]struct { + match string + expectedUsernames []string + }{ + "only space": { + match: " \t\n \t", + expectedUsernames: nil, + }, + "single word": { + match: "veni", + expectedUsernames: []string{"veni"}, + }, + "single word with preceeding space": { + match: " vidi", + expectedUsernames: []string{" vidi"}, + }, + "single word with suffixed space": { + match: "vici ", + expectedUsernames: []string{"vici"}, + }, + "multi-word with varying whitespace": { + match: "just me and\tmy friends \t", + expectedUsernames: []string{ + "just", + "just me", + "just me and", + "just me and\tmy", + "just me and\tmy friends", + }, + }, + } + + for testname, testcase := range testcases { + foundUsernames := enumerateUsernames(testcase.match) + assert.Equalf(t, testcase.expectedUsernames, foundUsernames, "Should have found the expected usernames for testcase %s", testname) + } +}