diff options
Diffstat (limited to 'teleirc/matterbridge/bridge/discord')
| -rw-r--r-- | teleirc/matterbridge/bridge/discord/discord.go | 384 | ||||
| -rw-r--r-- | teleirc/matterbridge/bridge/discord/handlers.go | 281 | ||||
| -rw-r--r-- | teleirc/matterbridge/bridge/discord/handlers_test.go | 58 | ||||
| -rw-r--r-- | teleirc/matterbridge/bridge/discord/helpers.go | 269 | ||||
| -rw-r--r-- | teleirc/matterbridge/bridge/discord/helpers_test.go | 46 | ||||
| -rw-r--r-- | teleirc/matterbridge/bridge/discord/transmitter/transmitter.go | 257 | ||||
| -rw-r--r-- | teleirc/matterbridge/bridge/discord/transmitter/utils.go | 32 | ||||
| -rw-r--r-- | teleirc/matterbridge/bridge/discord/webhook.go | 148 |
8 files changed, 1475 insertions, 0 deletions
diff --git a/teleirc/matterbridge/bridge/discord/discord.go b/teleirc/matterbridge/bridge/discord/discord.go new file mode 100644 index 0000000..5ae6c57 --- /dev/null +++ b/teleirc/matterbridge/bridge/discord/discord.go @@ -0,0 +1,384 @@ +package bdiscord + +import ( + "bytes" + "fmt" + "strings" + "sync" + + "github.com/42wim/matterbridge/bridge" + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/discord/transmitter" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/bwmarrin/discordgo" + lru "github.com/hashicorp/golang-lru" +) + +const ( + MessageLength = 1950 + cFileUpload = "file_upload" +) + +type Bdiscord struct { + *bridge.Config + + c *discordgo.Session + + nick string + userID string + guildID 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 + + // Webhook specific logic + useAutoWebhooks bool + transmitter *transmitter.Transmitter + cache *lru.Cache +} + +func New(cfg *bridge.Config) bridge.Bridger { + newCache, err := lru.New(5000) + if err != nil { + cfg.Log.Fatalf("Could not create LRU cache: %v", err) + } + + b := &Bdiscord{ + Config: cfg, + cache: newCache, + } + + b.userMemberMap = make(map[string]*discordgo.Member) + b.nickMemberMap = make(map[string]*discordgo.Member) + b.channelInfoMap = make(map[string]*config.ChannelInfo) + + b.useAutoWebhooks = b.GetBool("AutoWebhooks") + if b.useAutoWebhooks { + b.Log.Debug("Using automatic webhooks") + } + return b +} + +func (b *Bdiscord) Connect() error { + var err error + token := b.GetString("Token") + b.Log.Info("Connecting") + 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.messageUpdate) + b.c.AddHandler(b.messageDelete) + b.c.AddHandler(b.messageDeleteBulk) + b.c.AddHandler(b.memberAdd) + b.c.AddHandler(b.memberRemove) + b.c.AddHandler(b.memberUpdate) + if b.GetInt("debuglevel") == 1 { + b.c.AddHandler(b.messageEvent) + } + // Add privileged intent for guild member tracking. This is needed to track nicks + // for display names and @mention translation + b.c.Identify.Intents = discordgo.MakeIntent(discordgo.IntentsAllWithoutPrivileged | + discordgo.IntentsGuildMembers) + + 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 + + // Try and find this account's guild, and populate channels + b.channelsMutex.Lock() + for _, guild := range guilds { + // Skip, if the server name does not match the visible name or the ID + if guild.Name != serverName && guild.ID != serverName { + continue + } + + // Complain about an ambiguous Server setting. Two Discord servers could have the same title! + // For IDs, practically this will never happen. It would only trigger if some server's name is also an ID. + if b.guildID != "" { + return fmt.Errorf("found multiple Discord servers with the same name %#v, expected to see only one", serverName) + } + + // Getting this guild's channel could result in a permission error + b.channels, err = b.c.GuildChannels(guild.ID) + if err != nil { + return fmt.Errorf("could not get %#v's channels: %w", b.GetString("Server"), err) + } + + b.guildID = guild.ID + } + b.channelsMutex.Unlock() + + // If we couldn't find a guild, we print extra debug information and return a nice error + if b.guildID == "" { + err = fmt.Errorf("could not find Discord server %#v", b.GetString("Server")) + b.Log.Error(err.Error()) + + // Print all of the possible server values + b.Log.Info("Possible server values:") + for _, guild := range guilds { + b.Log.Infof("\t- Server=%#v # by name", guild.Name) + b.Log.Infof("\t- Server=%#v # by ID", guild.ID) + } + + // If there are no results, we should say that + if len(guilds) == 0 { + b.Log.Info("\t- (none found)") + } + + return err + } + + // Legacy note: WebhookURL used to have an actual webhook URL that we would edit, + // but we stopped doing that due to Discord making rate limits more aggressive. + // + // Even older: the same WebhookURL used to be used by every channel, which is usually unexpected. + // This is no longer possible. + if b.GetString("WebhookURL") != "" { + message := "The global WebhookURL setting has been removed. " + message += "You can get similar \"webhook editing\" behaviour by replacing this line with `AutoWebhooks=true`. " + message += "If you rely on the old-OLD (non-editing) behaviour, can move the WebhookURL to specific channel sections." + b.Log.Errorln(message) + return fmt.Errorf("use of removed WebhookURL setting") + } + + if b.GetInt("debuglevel") == 2 { + b.Log.Debug("enabling even more discord debug") + b.c.Debug = true + } + + // Initialise webhook management + b.transmitter = transmitter.New(b.c, b.guildID, "matterbridge", b.useAutoWebhooks) + b.transmitter.Log = b.Log + + var webhookChannelIDs []string + for _, channel := range b.Channels { + channelID := b.getChannelID(channel.Name) // note(qaisjp): this readlocks channelsMutex + + // If a WebhookURL was not explicitly provided for this channel, + // there are two options: just a regular bot message (ugly) or this is should be webhook sent + if channel.Options.WebhookURL == "" { + // If it should be webhook sent, we should enforce this via the transmitter + if b.useAutoWebhooks { + webhookChannelIDs = append(webhookChannelIDs, channelID) + } + continue + } + + whID, whToken, ok := b.splitURL(channel.Options.WebhookURL) + if !ok { + return fmt.Errorf("failed to parse WebhookURL %#v for channel %#v", channel.Options.WebhookURL, channel.ID) + } + + b.transmitter.AddWebhook(channelID, &discordgo.Webhook{ + ID: whID, + Token: whToken, + GuildID: b.guildID, + ChannelID: channelID, + }) + } + + if b.useAutoWebhooks { + err = b.transmitter.RefreshGuildWebhooks(webhookChannelIDs) + if err != nil { + b.Log.WithError(err).Println("transmitter could not refresh guild webhooks") + return err + } + } + + // 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) + + 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 + "_" + } + + // Handle prefix hint for unthreaded messages. + if msg.ParentNotFound() { + msg.ParentID = "" + } + + // Use webhook to send the message + useWebhooks := b.shouldMessageUseWebhooks(&msg) + if useWebhooks && msg.Event != config.EventMsgDelete && msg.ParentID == "" { + return b.handleEventWebhook(&msg, channelID) + } + + return b.handleEventBotUser(&msg, channelID) +} + +// handleEventDirect handles events via the bot user +func (b *Bdiscord) handleEventBotUser(msg *config.Message, channelID string) (string, error) { + b.Log.Debugf("Broadcasting using token (API)") + + // Delete message + if msg.Event == config.EventMsgDelete { + if msg.ID == "" { + return "", nil + } + err := b.c.ChannelMessageDelete(channelID, msg.ID) + return "", err + } + + // Delete a file + if msg.Event == config.EventFileDelete { + if msg.ID == "" { + return "", nil + } + + if fi, ok := b.cache.Get(cFileUpload + msg.ID); ok { + err := b.c.ChannelMessageDelete(channelID, fi.(string)) // nolint:forcetypeassert + b.cache.Remove(cFileUpload + msg.ID) + return "", err + } + + return "", fmt.Errorf("file %s not found", msg.ID) + } + + // 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, 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) + } + } + // 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, 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 + } + + m := discordgo.MessageSend{ + Content: msg.Username + msg.Text, + AllowedMentions: b.getAllowedMentions(), + } + + 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 + } + + return res.ID, nil +} + +// handleUploadFile handles native upload of files +func (b *Bdiscord) handleUploadFile(msg *config.Message, channelID string) (string, 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}, + AllowedMentions: b.getAllowedMentions(), + } + res, err := b.c.ChannelMessageSendComplex(channelID, &m) + if err != nil { + return "", fmt.Errorf("file upload failed: %s", err) + } + + // link file_upload_nativeID (file ID from the original bridge) to our upload id + // so that we can remove this later when it eg needs to be deleted + b.cache.Add(cFileUpload+fi.NativeID, res.ID) + } + + return "", nil +} diff --git a/teleirc/matterbridge/bridge/discord/handlers.go b/teleirc/matterbridge/bridge/discord/handlers.go new file mode 100644 index 0000000..34cef55 --- /dev/null +++ b/teleirc/matterbridge/bridge/discord/handlers.go @@ -0,0 +1,281 @@ +package bdiscord + +import ( + "github.com/42wim/matterbridge/bridge/config" + "github.com/bwmarrin/discordgo" + "github.com/davecgh/go-spew/spew" +) + +func (b *Bdiscord) messageDelete(s *discordgo.Session, m *discordgo.MessageDelete) { //nolint:unparam + if m.GuildID != b.guildID { + b.Log.Debugf("Ignoring messageDelete because it originates from a different guild") + return + } + rmsg := config.Message{Account: b.Account, ID: m.ID, Event: config.EventMsgDelete, Text: config.EventMsgDelete} + rmsg.Channel = b.getChannelName(m.ChannelID) + + b.Log.Debugf("<= Sending message from %s to gateway", b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg +} + +// TODO(qaisjp): if other bridges support bulk deletions, it could be fanned out centrally +func (b *Bdiscord) messageDeleteBulk(s *discordgo.Session, m *discordgo.MessageDeleteBulk) { //nolint:unparam + if m.GuildID != b.guildID { + b.Log.Debugf("Ignoring messageDeleteBulk because it originates from a different guild") + return + } + for _, msgID := range m.Messages { + rmsg := config.Message{ + Account: b.Account, + ID: msgID, + Event: config.EventMsgDelete, + Text: config.EventMsgDelete, + Channel: b.getChannelName(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) messageEvent(s *discordgo.Session, m *discordgo.Event) { + b.Log.Debug(spew.Sdump(m.Struct)) +} + +func (b *Bdiscord) messageTyping(s *discordgo.Session, m *discordgo.TypingStart) { + if m.GuildID != b.guildID { + b.Log.Debugf("Ignoring messageTyping because it originates from a different guild") + return + } + if !b.GetBool("ShowUserTyping") { + return + } + + // Ignore our own typing messages + if m.UserID == b.userID { + return + } + + rmsg := config.Message{Account: b.Account, Event: config.EventUserTyping} + rmsg.Channel = b.getChannelName(m.ChannelID) + b.Remote <- rmsg +} + +func (b *Bdiscord) messageUpdate(s *discordgo.Session, m *discordgo.MessageUpdate) { //nolint:unparam + if m.GuildID != b.guildID { + b.Log.Debugf("Ignoring messageUpdate because it originates from a different guild") + return + } + if b.GetBool("EditDisable") { + return + } + // only when message is actually edited + if m.Message.EditedTimestamp != nil { + b.Log.Debugf("Sending edit message") + m.Content += b.GetString("EditSuffix") + msg := &discordgo.MessageCreate{ + Message: m.Message, + } + b.messageCreate(s, msg) + } +} + +func (b *Bdiscord) messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { //nolint:unparam + if m.GuildID != b.guildID { + b.Log.Debugf("Ignoring messageCreate because it originates from a different guild") + return + } + 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 m.Author.Bot && b.transmitter.HasWebhook(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} + + b.Log.Debugf("== Receiving event %#v", m.Message) + + if m.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) + + fromWebhook := m.WebhookID != "" + if !fromWebhook && !b.GetBool("UseUserName") { + rmsg.Username = b.getNick(m.Author, m.GuildID) + } else { + rmsg.Username = m.Author.Username + if !fromWebhook && b.GetBool("UseDiscriminator") { + rmsg.Username += "#" + m.Author.Discriminator + } + } + + // 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 += handleEmbed(embed) + } + } + + // 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 + } + + // Replace emotes + rmsg.Text = replaceEmotes(rmsg.Text) + + // Add our parent id if it exists, and if it's not referring to a message in another channel + if ref := m.MessageReference; ref != nil && ref.ChannelID == m.ChannelID { + rmsg.ParentID = ref.MessageID + } + + 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.GuildID != b.guildID { + b.Log.Debugf("Ignoring memberUpdate because it originates from a different guild") + return + } + 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 + } +} + +func (b *Bdiscord) memberAdd(s *discordgo.Session, m *discordgo.GuildMemberAdd) { + if m.GuildID != b.guildID { + b.Log.Debugf("Ignoring memberAdd because it originates from a different guild") + return + } + if b.GetBool("nosendjoinpart") { + return + } + if m.Member == nil { + b.Log.Warnf("Received member update with no member information: %#v", m) + return + } + username := m.Member.User.Username + if m.Member.Nick != "" { + username = m.Member.Nick + } + + rmsg := config.Message{ + Account: b.Account, + Event: config.EventJoinLeave, + Username: "system", + Text: username + " joins", + } + b.Log.Debugf("<= Sending message from %s to gateway", b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg +} + +func (b *Bdiscord) memberRemove(s *discordgo.Session, m *discordgo.GuildMemberRemove) { + if m.GuildID != b.guildID { + b.Log.Debugf("Ignoring memberRemove because it originates from a different guild") + return + } + if b.GetBool("nosendjoinpart") { + return + } + if m.Member == nil { + b.Log.Warnf("Received member update with no member information: %#v", m) + return + } + username := m.Member.User.Username + if m.Member.Nick != "" { + username = m.Member.Nick + } + + rmsg := config.Message{ + Account: b.Account, + Event: config.EventJoinLeave, + Username: "system", + Text: username + " leaves", + } + b.Log.Debugf("<= Sending message from %s to gateway", b.Account) + b.Log.Debugf("<= Message is %#v", rmsg) + b.Remote <- rmsg +} + +func handleEmbed(embed *discordgo.MessageEmbed) string { + var t []string + var result string + + t = append(t, embed.Title) + t = append(t, embed.Description) + t = append(t, embed.URL) + + i := 0 + for _, e := range t { + if e == "" { + continue + } + + i++ + if i == 1 { + result += " embed: " + e + continue + } + + result += " - " + e + } + + if result != "" { + result += "\n" + } + + return result +} diff --git a/teleirc/matterbridge/bridge/discord/handlers_test.go b/teleirc/matterbridge/bridge/discord/handlers_test.go new file mode 100644 index 0000000..915d9b1 --- /dev/null +++ b/teleirc/matterbridge/bridge/discord/handlers_test.go @@ -0,0 +1,58 @@ +package bdiscord + +import ( + "testing" + + "github.com/bwmarrin/discordgo" + "github.com/stretchr/testify/assert" +) + +func TestHandleEmbed(t *testing.T) { + testcases := map[string]struct { + embed *discordgo.MessageEmbed + result string + }{ + "allempty": { + embed: &discordgo.MessageEmbed{}, + result: "", + }, + "one": { + embed: &discordgo.MessageEmbed{ + Title: "blah", + }, + result: " embed: blah\n", + }, + "two": { + embed: &discordgo.MessageEmbed{ + Title: "blah", + Description: "blah2", + }, + result: " embed: blah - blah2\n", + }, + "three": { + embed: &discordgo.MessageEmbed{ + Title: "blah", + Description: "blah2", + URL: "blah3", + }, + result: " embed: blah - blah2 - blah3\n", + }, + "twob": { + embed: &discordgo.MessageEmbed{ + Description: "blah2", + URL: "blah3", + }, + result: " embed: blah2 - blah3\n", + }, + "oneb": { + embed: &discordgo.MessageEmbed{ + URL: "blah3", + }, + result: " embed: blah3\n", + }, + } + + for name, tc := range testcases { + assert.Equalf(t, tc.result, handleEmbed(tc.embed), "Testcases %s", name) + } +} diff --git a/teleirc/matterbridge/bridge/discord/helpers.go b/teleirc/matterbridge/bridge/discord/helpers.go new file mode 100644 index 0000000..2e18f46 --- /dev/null +++ b/teleirc/matterbridge/bridge/discord/helpers.go @@ -0,0 +1,269 @@ +package bdiscord + +import ( + "errors" + "regexp" + "strings" + "unicode" + + "github.com/bwmarrin/discordgo" +) + +func (b *Bdiscord) getAllowedMentions() *discordgo.MessageAllowedMentions { + // If AllowMention is not specified, then allow all mentions (default Discord behavior) + if !b.IsKeySet("AllowMention") { + return nil + } + + // Otherwise, allow only the mentions that are specified + allowedMentionTypes := make([]discordgo.AllowedMentionType, 0, 3) + for _, m := range b.GetStringSlice("AllowMention") { + switch m { + case "everyone": + allowedMentionTypes = append(allowedMentionTypes, discordgo.AllowedMentionTypeEveryone) + case "roles": + allowedMentionTypes = append(allowedMentionTypes, discordgo.AllowedMentionTypeRoles) + case "users": + allowedMentionTypes = append(allowedMentionTypes, discordgo.AllowedMentionTypeUsers) + } + } + + return &discordgo.MessageAllowedMentions{ + Parse: allowedMentionTypes, + } +} + +func (b *Bdiscord) getNick(user *discordgo.User, guildID string) 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(guildID, user.ID) + if err != nil { + b.Log.Warnf("Failed to fetch information for member %#v on guild %#v: %s", user, guildID, 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 { + if strings.Contains(name, "/") { + return b.getCategoryChannelID(name) + } + 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 && channel.Type == discordgo.ChannelTypeGuildText { + return channel.ID + } + } + return "" +} + +func (b *Bdiscord) getCategoryChannelID(name string) string { + b.channelsMutex.RLock() + defer b.channelsMutex.RUnlock() + res := strings.Split(name, "/") + // shouldn't happen because function should be only called from getChannelID + if len(res) != 2 { + return "" + } + catName, chanName := res[0], res[1] + for _, channel := range b.channels { + // if we have a parentID, lookup the name of that parent (category) + // and if it matches return it + if channel.Name == chanName && channel.ParentID != "" { + for _, cat := range b.channels { + if cat.ID == channel.ParentID && cat.Name == catName { + return channel.ID + } + } + } + } + return "" +} + +func (b *Bdiscord) getChannelName(id string) string { + b.channelsMutex.RLock() + defer b.channelsMutex.RUnlock() + + for _, c := range b.channelInfoMap { + if c.Name == "ID:"+id { + // if we have ID: specified in our gateway configuration return this + return c.Name + } + } + + for _, channel := range b.channels { + if channel.ID == id { + return b.getCategoryChannelName(channel.Name, channel.ParentID) + } + } + return "" +} + +func (b *Bdiscord) getCategoryChannelName(name, parentID string) string { + var usesCat bool + // do we have a category configuration in the channel config + for _, c := range b.channelInfoMap { + if strings.Contains(c.Name, "/") { + usesCat = true + break + } + } + // configuration without category, return the normal channel name + if !usesCat { + return name + } + // create a category/channel response + for _, c := range b.channels { + if c.ID == parentID { + name = c.Name + "/" + name + } + } + return name +} + +var ( + // See https://discordapp.com/developers/docs/reference#message-formatting. + channelMentionRE = regexp.MustCompile("<#[0-9]+>") + userMentionRE = regexp.MustCompile("@[^@\n]{1,32}") + emoteRE = regexp.MustCompile(`<a?(:\w+:)\d+>`) +) + +func (b *Bdiscord) replaceChannelMentions(text string) string { + replaceChannelMentionFunc := func(match string) string { + channelID := match[2 : len(match)-1] + channelName := b.getChannelName(channelID) + + // If we don't have the channel refresh our list. + if channelName == "" { + var err error + 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 replaceEmotes(text string) string { + return emoteRE.ReplaceAllString(text, "$1") +} + +func (b *Bdiscord) replaceAction(text string) (string, bool) { + length := len(text) + if length > 1 && text[0] == '_' && text[length-1] == '_' { + return text[1 : length-1], true + } + return text, false +} + +// splitURL splits a webhookURL and returns the ID and token. +func (b *Bdiscord) splitURL(url string) (string, string, bool) { + const ( + expectedWebhookSplitCount = 7 + webhookIdxID = 5 + webhookIdxToken = 6 + ) + webhookURLSplit := strings.Split(url, "/") + if len(webhookURLSplit) != expectedWebhookSplitCount { + return "", "", false + } + return webhookURLSplit[webhookIdxID], webhookURLSplit[webhookIdxToken], true +} + +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/teleirc/matterbridge/bridge/discord/helpers_test.go b/teleirc/matterbridge/bridge/discord/helpers_test.go new file mode 100644 index 0000000..0f196d9 --- /dev/null +++ b/teleirc/matterbridge/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) + } +} diff --git a/teleirc/matterbridge/bridge/discord/transmitter/transmitter.go b/teleirc/matterbridge/bridge/discord/transmitter/transmitter.go new file mode 100644 index 0000000..71407a1 --- /dev/null +++ b/teleirc/matterbridge/bridge/discord/transmitter/transmitter.go @@ -0,0 +1,257 @@ +// Package transmitter provides functionality for transmitting +// arbitrary webhook messages to Discord. +// +// The package provides the following functionality: +// +// - Creating new webhooks, whenever necessary +// - Loading webhooks that we have previously created +// - Sending new messages +// - Editing messages, via message ID +// - Deleting messages, via message ID +// +// The package has been designed for matterbridge, but with other +// Go bots in mind. The public API should be matterbridge-agnostic. +package transmitter + +import ( + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/bwmarrin/discordgo" + log "github.com/sirupsen/logrus" +) + +// A Transmitter represents a message manager for a single guild. +type Transmitter struct { + session *discordgo.Session + guild string + title string + autoCreate bool + + // channelWebhooks maps from a channel ID to a webhook instance + channelWebhooks map[string]*discordgo.Webhook + + mutex sync.RWMutex + + Log *log.Entry +} + +// ErrWebhookNotFound is returned when a valid webhook for this channel/message combination does not exist +var ErrWebhookNotFound = errors.New("webhook for this channel and message does not exist") + +// ErrPermissionDenied is returned if the bot does not have permission to manage webhooks. +// +// Bots can be granted a guild-wide permission and channel-specific permissions to manage webhooks. +// Despite potentially having guild-wide permission, channel specific overrides could deny a bot's permission to manage webhooks. +var ErrPermissionDenied = errors.New("missing 'Manage Webhooks' permission") + +// New returns a new Transmitter given a Discord session, guild ID, and title. +func New(session *discordgo.Session, guild string, title string, autoCreate bool) *Transmitter { + return &Transmitter{ + session: session, + guild: guild, + title: title, + autoCreate: autoCreate, + + channelWebhooks: make(map[string]*discordgo.Webhook), + + Log: log.NewEntry(log.StandardLogger()), + } +} + +// Send transmits a message to the given channel with the provided webhook data, and waits until Discord responds with message data. +func (t *Transmitter) Send(channelID string, params *discordgo.WebhookParams) (*discordgo.Message, error) { + wh, err := t.getOrCreateWebhook(channelID) + if err != nil { + return nil, err + } + + msg, err := t.session.WebhookExecute(wh.ID, wh.Token, true, params) + if err != nil { + return nil, fmt.Errorf("execute failed: %w", err) + } + + return msg, nil +} + +// Edit will edit a message in a channel, if possible. +func (t *Transmitter) Edit(channelID string, messageID string, params *discordgo.WebhookParams) error { + wh := t.getWebhook(channelID) + + if wh == nil { + return ErrWebhookNotFound + } + + uri := discordgo.EndpointWebhookToken(wh.ID, wh.Token) + "/messages/" + messageID + _, err := t.session.RequestWithBucketID("PATCH", uri, params, discordgo.EndpointWebhookToken("", "")) + if err != nil { + return err + } + + return nil +} + +// HasWebhook checks whether the transmitter is using a particular webhook. +func (t *Transmitter) HasWebhook(id string) bool { + t.mutex.RLock() + defer t.mutex.RUnlock() + + for _, wh := range t.channelWebhooks { + if wh.ID == id { + return true + } + } + + return false +} + +// AddWebhook allows you to register a channel's webhook with the transmitter. +func (t *Transmitter) AddWebhook(channelID string, webhook *discordgo.Webhook) bool { + t.Log.Debugf("Manually added webhook %#v to channel %#v", webhook.ID, channelID) + t.mutex.Lock() + defer t.mutex.Unlock() + + _, replaced := t.channelWebhooks[channelID] + t.channelWebhooks[channelID] = webhook + return replaced +} + +// RefreshGuildWebhooks loads "relevant" webhooks into the transmitter, with careful permission handling. +// +// Notes: +// +// - A webhook is "relevant" if it was created by this bot -- the ApplicationID should match the bot's ID. +// - The term "having permission" means having the "Manage Webhooks" permission. See ErrPermissionDenied for more information. +// - This function is additive and will not unload previously loaded webhooks. +// - A nil channelIDs slice is treated the same as an empty one. +// +// If the bot has guild-wide permission: +// +// 1. it will load any "relevant" webhooks from the entire guild +// 2. the given slice is ignored +// +// If the bot does not have guild-wide permission: +// +// 1. it will load any "relevant" webhooks in each channel +// 2. a single error will be returned if any error occurs (incl. if there is no permission for any of these channels) +// +// If any channel has more than one "relevant" webhook, it will randomly pick one. +func (t *Transmitter) RefreshGuildWebhooks(channelIDs []string) error { + t.Log.Debugln("Refreshing guild webhooks") + + botID, err := getDiscordUserID(t.session) + if err != nil { + return fmt.Errorf("could not get current user: %w", err) + } + + // Get all existing webhooks + hooks, err := t.session.GuildWebhooks(t.guild) + if err != nil { + switch { + case isDiscordPermissionError(err): + // We fallback on manually fetching hooks from individual channels + // if we don't have the "Manage Webhooks" permission globally. + // We can only do this if we were provided channelIDs, though. + if len(channelIDs) == 0 { + return ErrPermissionDenied + } + t.Log.Debugln("Missing global 'Manage Webhooks' permission, falling back on per-channel permission") + return t.fetchChannelsHooks(channelIDs, botID) + default: + return fmt.Errorf("could not get webhooks: %w", err) + } + } + + t.Log.Debugln("Refreshing guild webhooks using global permission") + t.assignHooksByAppID(hooks, botID, false) + return nil +} + +// createWebhook creates a webhook for a specific channel. +func (t *Transmitter) createWebhook(channel string) (*discordgo.Webhook, error) { + t.mutex.Lock() + defer t.mutex.Unlock() + + wh, err := t.session.WebhookCreate(channel, t.title+time.Now().Format(" 3:04:05PM"), "") + if err != nil { + return nil, err + } + + t.channelWebhooks[channel] = wh + return wh, nil +} + +func (t *Transmitter) getWebhook(channel string) *discordgo.Webhook { + t.mutex.RLock() + defer t.mutex.RUnlock() + + return t.channelWebhooks[channel] +} + +func (t *Transmitter) getOrCreateWebhook(channelID string) (*discordgo.Webhook, error) { + // If we have a webhook for this channel, immediately return it + wh := t.getWebhook(channelID) + if wh != nil { + return wh, nil + } + + // Early exit if we don't want to automatically create one + if !t.autoCreate { + return nil, ErrWebhookNotFound + } + + t.Log.Infof("Creating a webhook for %s\n", channelID) + wh, err := t.createWebhook(channelID) + if err != nil { + return nil, fmt.Errorf("could not create webhook: %w", err) + } + + return wh, nil +} + +// fetchChannelsHooks fetches hooks for the given channelIDs and calls assignHooksByAppID for each channel's hooks +func (t *Transmitter) fetchChannelsHooks(channelIDs []string, botID string) error { + // For each channel, search for relevant hooks + var failedHooks []string + for _, channelID := range channelIDs { + hooks, err := t.session.ChannelWebhooks(channelID) + if err != nil { + failedHooks = append(failedHooks, "\n- "+channelID+": "+err.Error()) + continue + } + t.assignHooksByAppID(hooks, botID, true) + } + + // Compose an error if any hooks failed + if len(failedHooks) > 0 { + return errors.New("failed to fetch hooks:" + strings.Join(failedHooks, "")) + } + + return nil +} + +func (t *Transmitter) assignHooksByAppID(hooks []*discordgo.Webhook, appID string, channelTargeted bool) { + logLine := "Picking up webhook" + if channelTargeted { + logLine += " (channel targeted)" + } + + t.mutex.Lock() + defer t.mutex.Unlock() + + for _, wh := range hooks { + if wh.ApplicationID != appID { + continue + } + + t.channelWebhooks[wh.ChannelID] = wh + t.Log.WithFields(log.Fields{ + "id": wh.ID, + "name": wh.Name, + "channel": wh.ChannelID, + }).Println(logLine) + } +} diff --git a/teleirc/matterbridge/bridge/discord/transmitter/utils.go b/teleirc/matterbridge/bridge/discord/transmitter/utils.go new file mode 100644 index 0000000..042aa50 --- /dev/null +++ b/teleirc/matterbridge/bridge/discord/transmitter/utils.go @@ -0,0 +1,32 @@ +package transmitter + +import ( + "github.com/bwmarrin/discordgo" +) + +// isDiscordPermissionError returns false for nil, and true if a Discord RESTError with code discordgo.ErrorCodeMissionPermissions +func isDiscordPermissionError(err error) bool { + if err == nil { + return false + } + + restErr, ok := err.(*discordgo.RESTError) + if !ok { + return false + } + + return restErr.Message != nil && restErr.Message.Code == discordgo.ErrCodeMissingPermissions +} + +// getDiscordUserID gets own user ID from state, and fallback on API request +func getDiscordUserID(session *discordgo.Session) (string, error) { + if user := session.State.User; user != nil { + return user.ID, nil + } + + user, err := session.User("@me") + if err != nil { + return "", err + } + return user.ID, nil +} diff --git a/teleirc/matterbridge/bridge/discord/webhook.go b/teleirc/matterbridge/bridge/discord/webhook.go new file mode 100644 index 0000000..c34fc94 --- /dev/null +++ b/teleirc/matterbridge/bridge/discord/webhook.go @@ -0,0 +1,148 @@ +package bdiscord + +import ( + "bytes" + + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/bridge/helper" + "github.com/bwmarrin/discordgo" +) + +// shouldMessageUseWebhooks checks if have a channel specific webhook, if we're not using auto webhooks +func (b *Bdiscord) shouldMessageUseWebhooks(msg *config.Message) bool { + if b.useAutoWebhooks { + return true + } + + b.channelsMutex.RLock() + defer b.channelsMutex.RUnlock() + if ci, ok := b.channelInfoMap[msg.Channel+b.Account]; ok { + if ci.Options.WebhookURL != "" { + return true + } + } + return false +} + +// maybeGetLocalAvatar checks if UseLocalAvatar contains the message's +// account or protocol, and if so, returns the Discord avatar (if exists) +func (b *Bdiscord) maybeGetLocalAvatar(msg *config.Message) string { + for _, val := range b.GetStringSlice("UseLocalAvatar") { + if msg.Protocol != val && msg.Account != val { + continue + } + + member, err := b.getGuildMemberByNick(msg.Username) + if err != nil { + return "" + } + + return member.User.AvatarURL("") + } + return "" +} + +// 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) { + var ( + res *discordgo.Message + err error + ) + + // If avatar is unset, mutate the message to include the local avatar (but only if settings say we should do this) + if msg.Avatar == "" { + msg.Avatar = b.maybeGetLocalAvatar(msg) + } + + // WebhookParams can have either `Content` or `File`. + + // 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) + } + } + + 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 + + _, e2 := b.transmitter.Send( + channelID, + &discordgo.WebhookParams{ + Username: msg.Username, + AvatarURL: msg.Avatar, + Files: []*discordgo.File{&file}, + Content: content, + AllowedMentions: b.getAllowedMentions(), + }, + ) + if e2 != nil { + b.Log.Errorf("Could not send file %#v for message %#v: %s", file, msg, e2) + } + } + } + return res, err +} + +func (b *Bdiscord) handleEventWebhook(msg *config.Message, channelID string) (string, error) { + // skip events + if msg.Event != "" && msg.Event != config.EventUserAction && msg.Event != config.EventJoinLeave && msg.Event != config.EventTopicChange { + return "", nil + } + + // 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, 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 != "" { + 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 { + return msg.ID, nil + } + b.Log.Errorf("Could not edit webhook message: %s", err) + } + + b.Log.Debugf("Processing webhook sending for message %#v", msg) + discordMsg, err := b.webhookSend(msg, channelID) + if err != nil { + b.Log.Errorf("Could not broadcast via webhook for message %#v: %s", msg, err) + return "", err + } + if discordMsg == nil { + return "", nil + } + + return discordMsg.ID, nil +} |
