Thumbnail

rani/matterbridge.git

Clone URL: https://git.buni.party/rani/matterbridge.git

Viewing file on branch master

1// Package transmitter provides functionality for transmitting
2// arbitrary webhook messages to Discord.
3//
4// The package provides the following functionality:
5//
6// - Creating new webhooks, whenever necessary
7// - Loading webhooks that we have previously created
8// - Sending new messages
9// - Editing messages, via message ID
10// - Deleting messages, via message ID
11//
12// The package has been designed for matterbridge, but with other
13// Go bots in mind. The public API should be matterbridge-agnostic.
14package transmitter
15
16import (
17 "errors"
18 "fmt"
19 "strings"
20 "sync"
21 "time"
22
23 "github.com/bwmarrin/discordgo"
24 log "github.com/sirupsen/logrus"
25)
26
27// A Transmitter represents a message manager for a single guild.
28type Transmitter struct {
29 session *discordgo.Session
30 guild string
31 title string
32 autoCreate bool
33
34 // channelWebhooks maps from a channel ID to a webhook instance
35 channelWebhooks map[string]*discordgo.Webhook
36
37 mutex sync.RWMutex
38
39 Log *log.Entry
40}
41
42// ErrWebhookNotFound is returned when a valid webhook for this channel/message combination does not exist
43var ErrWebhookNotFound = errors.New("webhook for this channel and message does not exist")
44
45// ErrPermissionDenied is returned if the bot does not have permission to manage webhooks.
46//
47// Bots can be granted a guild-wide permission and channel-specific permissions to manage webhooks.
48// Despite potentially having guild-wide permission, channel specific overrides could deny a bot's permission to manage webhooks.
49var ErrPermissionDenied = errors.New("missing 'Manage Webhooks' permission")
50
51// New returns a new Transmitter given a Discord session, guild ID, and title.
52func New(session *discordgo.Session, guild string, title string, autoCreate bool) *Transmitter {
53 return &Transmitter{
54 session: session,
55 guild: guild,
56 title: title,
57 autoCreate: autoCreate,
58
59 channelWebhooks: make(map[string]*discordgo.Webhook),
60
61 Log: log.NewEntry(log.StandardLogger()),
62 }
63}
64
65// Send transmits a message to the given channel with the provided webhook data, and waits until Discord responds with message data.
66func (t *Transmitter) Send(channelID string, params *discordgo.WebhookParams) (*discordgo.Message, error) {
67 wh, err := t.getOrCreateWebhook(channelID)
68 if err != nil {
69 return nil, err
70 }
71
72 msg, err := t.session.WebhookExecute(wh.ID, wh.Token, true, params)
73 if err != nil {
74 return nil, fmt.Errorf("execute failed: %w", err)
75 }
76
77 return msg, nil
78}
79
80// Edit will edit a message in a channel, if possible.
81func (t *Transmitter) Edit(channelID string, messageID string, params *discordgo.WebhookParams) error {
82 wh := t.getWebhook(channelID)
83
84 if wh == nil {
85 return ErrWebhookNotFound
86 }
87
88 uri := discordgo.EndpointWebhookToken(wh.ID, wh.Token) + "/messages/" + messageID
89 _, err := t.session.RequestWithBucketID("PATCH", uri, params, discordgo.EndpointWebhookToken("", ""))
90 if err != nil {
91 return err
92 }
93
94 return nil
95}
96
97// HasWebhook checks whether the transmitter is using a particular webhook.
98func (t *Transmitter) HasWebhook(id string) bool {
99 t.mutex.RLock()
100 defer t.mutex.RUnlock()
101
102 for _, wh := range t.channelWebhooks {
103 if wh.ID == id {
104 return true
105 }
106 }
107
108 return false
109}
110
111// AddWebhook allows you to register a channel's webhook with the transmitter.
112func (t *Transmitter) AddWebhook(channelID string, webhook *discordgo.Webhook) bool {
113 t.Log.Debugf("Manually added webhook %#v to channel %#v", webhook.ID, channelID)
114 t.mutex.Lock()
115 defer t.mutex.Unlock()
116
117 _, replaced := t.channelWebhooks[channelID]
118 t.channelWebhooks[channelID] = webhook
119 return replaced
120}
121
122// RefreshGuildWebhooks loads "relevant" webhooks into the transmitter, with careful permission handling.
123//
124// Notes:
125//
126// - A webhook is "relevant" if it was created by this bot -- the ApplicationID should match the bot's ID.
127// - The term "having permission" means having the "Manage Webhooks" permission. See ErrPermissionDenied for more information.
128// - This function is additive and will not unload previously loaded webhooks.
129// - A nil channelIDs slice is treated the same as an empty one.
130//
131// If the bot has guild-wide permission:
132//
133// 1. it will load any "relevant" webhooks from the entire guild
134// 2. the given slice is ignored
135//
136// If the bot does not have guild-wide permission:
137//
138// 1. it will load any "relevant" webhooks in each channel
139// 2. a single error will be returned if any error occurs (incl. if there is no permission for any of these channels)
140//
141// If any channel has more than one "relevant" webhook, it will randomly pick one.
142func (t *Transmitter) RefreshGuildWebhooks(channelIDs []string) error {
143 t.Log.Debugln("Refreshing guild webhooks")
144
145 botID, err := getDiscordUserID(t.session)
146 if err != nil {
147 return fmt.Errorf("could not get current user: %w", err)
148 }
149
150 // Get all existing webhooks
151 hooks, err := t.session.GuildWebhooks(t.guild)
152 if err != nil {
153 switch {
154 case isDiscordPermissionError(err):
155 // We fallback on manually fetching hooks from individual channels
156 // if we don't have the "Manage Webhooks" permission globally.
157 // We can only do this if we were provided channelIDs, though.
158 if len(channelIDs) == 0 {
159 return ErrPermissionDenied
160 }
161 t.Log.Debugln("Missing global 'Manage Webhooks' permission, falling back on per-channel permission")
162 return t.fetchChannelsHooks(channelIDs, botID)
163 default:
164 return fmt.Errorf("could not get webhooks: %w", err)
165 }
166 }
167
168 t.Log.Debugln("Refreshing guild webhooks using global permission")
169 t.assignHooksByAppID(hooks, botID, false)
170 return nil
171}
172
173// createWebhook creates a webhook for a specific channel.
174func (t *Transmitter) createWebhook(channel string) (*discordgo.Webhook, error) {
175 t.mutex.Lock()
176 defer t.mutex.Unlock()
177
178 wh, err := t.session.WebhookCreate(channel, t.title+time.Now().Format(" 3:04:05PM"), "")
179 if err != nil {
180 return nil, err
181 }
182
183 t.channelWebhooks[channel] = wh
184 return wh, nil
185}
186
187func (t *Transmitter) getWebhook(channel string) *discordgo.Webhook {
188 t.mutex.RLock()
189 defer t.mutex.RUnlock()
190
191 return t.channelWebhooks[channel]
192}
193
194func (t *Transmitter) getOrCreateWebhook(channelID string) (*discordgo.Webhook, error) {
195 // If we have a webhook for this channel, immediately return it
196 wh := t.getWebhook(channelID)
197 if wh != nil {
198 return wh, nil
199 }
200
201 // Early exit if we don't want to automatically create one
202 if !t.autoCreate {
203 return nil, ErrWebhookNotFound
204 }
205
206 t.Log.Infof("Creating a webhook for %s\n", channelID)
207 wh, err := t.createWebhook(channelID)
208 if err != nil {
209 return nil, fmt.Errorf("could not create webhook: %w", err)
210 }
211
212 return wh, nil
213}
214
215// fetchChannelsHooks fetches hooks for the given channelIDs and calls assignHooksByAppID for each channel's hooks
216func (t *Transmitter) fetchChannelsHooks(channelIDs []string, botID string) error {
217 // For each channel, search for relevant hooks
218 var failedHooks []string
219 for _, channelID := range channelIDs {
220 hooks, err := t.session.ChannelWebhooks(channelID)
221 if err != nil {
222 failedHooks = append(failedHooks, "\n- "+channelID+": "+err.Error())
223 continue
224 }
225 t.assignHooksByAppID(hooks, botID, true)
226 }
227
228 // Compose an error if any hooks failed
229 if len(failedHooks) > 0 {
230 return errors.New("failed to fetch hooks:" + strings.Join(failedHooks, ""))
231 }
232
233 return nil
234}
235
236func (t *Transmitter) assignHooksByAppID(hooks []*discordgo.Webhook, appID string, channelTargeted bool) {
237 logLine := "Picking up webhook"
238 if channelTargeted {
239 logLine += " (channel targeted)"
240 }
241
242 t.mutex.Lock()
243 defer t.mutex.Unlock()
244
245 for _, wh := range hooks {
246 if wh.ApplicationID != appID {
247 continue
248 }
249
250 t.channelWebhooks[wh.ChannelID] = wh
251 t.Log.WithFields(log.Fields{
252 "id": wh.ID,
253 "name": wh.Name,
254 "channel": wh.ChannelID,
255 }).Println(logLine)
256 }
257}
258