forked from dasrick/go-teams-notify
-
Notifications
You must be signed in to change notification settings - Fork 17
/
send.go
665 lines (559 loc) · 21.7 KB
/
send.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
// Copyright 2020 Enrico Hoffmann
// Copyright 2021 Adam Chalkley
//
// https://github.com/atc0005/go-teams-notify
//
// Licensed under the MIT License. See LICENSE file in the project root for
// full license information.
package goteamsnotify
import (
"context"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
// logger is a package logger that can be enabled from client code to allow
// logging output from this package when desired/needed for troubleshooting
var logger *log.Logger
// Known webhook URL prefixes for submitting messages to Microsoft Teams
const (
WebhookURLOfficecomPrefix = "https://outlook.office.com"
WebhookURLOffice365Prefix = "https://outlook.office365.com"
WebhookURLOrgWebhookPrefix = "https://example.webhook.office.com"
)
// Known Workflow URL patterns for submitting messages to Microsoft Teams.
const (
WorkflowURLBaseDomain = `^https:\/\/(?:.*)(:?\.azure-api|logic\.azure)\.(?:com|net)`
)
// DisableWebhookURLValidation is a special keyword used to indicate to
// validation function(s) that webhook URL validation should be disabled.
//
// Deprecated: prefer using API.SkipWebhookURLValidationOnSend(bool) method instead
const DisableWebhookURLValidation string = "DISABLE_WEBHOOK_URL_VALIDATION"
// Regular Expression related constants that we can use to validate incoming
// webhook URLs provided by the user.
const (
// DefaultWebhookURLValidationPattern is a minimal regex for matching known valid
// webhook URL prefix patterns.
DefaultWebhookURLValidationPattern = `^https:\/\/(?:.*\.webhook|outlook)\.office(?:365)?\.com`
// Note: The regex allows for capital letters in the GUID patterns. This is
// allowed based on light testing which shows that mixed case works and the
// assumption that since Teams and Office 365 are Microsoft products case
// would be ignored (e.g., Windows, IIS do not consider 'A' and 'a' to be
// different).
// webhookURLRegex = `^https:\/\/(?:.*\.webhook|outlook)\.office(?:365)?\.com\/webhook(?:b2)?\/[-a-zA-Z0-9]{36}@[-a-zA-Z0-9]{36}\/IncomingWebhook\/[-a-zA-Z0-9]{32}\/[-a-zA-Z0-9]{36}$`
// webhookURLSubURIWebhookPrefix = "webhook"
// webhookURLSubURIWebhookb2Prefix = "webhookb2"
// webhookURLOfficialDocsSampleURI = "a1269812-6d10-44b1-abc5-b84f93580ba0@9e7b80c7-d1eb-4b52-8582-76f921e416d9/IncomingWebhook/3fdd6767bae44ac58e5995547d66a4e4/f332c8d9-3397-4ac5-957b-b8e3fc465a8c"
)
// ExpectedWebhookURLResponseText represents the expected response text
// provided by the remote webhook endpoint when submitting messages.
const ExpectedWebhookURLResponseText string = "1"
// DefaultWebhookSendTimeout specifies how long the message operation may take
// before it times out and is cancelled.
const DefaultWebhookSendTimeout = 5 * time.Second
// DefaultUserAgent is the project-specific user agent used when submitting
// messages unless overridden by client code. This replaces the Go default
// user agent value of "Go-http-client/1.1".
//
// The major.minor numbers reflect when this project first diverged from the
// "upstream" or parent project.
const DefaultUserAgent string = "go-teams-notify/2.2"
// ErrWebhookURLUnexpected is returned when a provided webhook URL does
// not match a set of confirmed webhook URL patterns.
var ErrWebhookURLUnexpected = errors.New("webhook URL does not match one of expected patterns")
// ErrWebhookURLUnexpectedPrefix is returned when a provided webhook URL does
// not match a set of confirmed webhook URL prefixes.
//
// Deprecated: Use ErrWebhookURLUnexpected instead.
var ErrWebhookURLUnexpectedPrefix = ErrWebhookURLUnexpected
// ErrInvalidWebhookURLResponseText is returned when the remote webhook
// endpoint indicates via response text that a message submission was
// unsuccessful.
var ErrInvalidWebhookURLResponseText = errors.New("invalid webhook URL response text")
// API is the legacy interface representing a client used to submit messages
// to a Microsoft Teams channel.
type API interface {
Send(webhookURL string, webhookMessage MessageCard) error
SendWithContext(ctx context.Context, webhookURL string, webhookMessage MessageCard) error
SendWithRetry(ctx context.Context, webhookURL string, webhookMessage MessageCard, retries int, retriesDelay int) error
SkipWebhookURLValidationOnSend(skip bool) API
AddWebhookURLValidationPatterns(patterns ...string) API
ValidateWebhook(webhookURL string) error
}
// MessageSender describes the behavior of a baseline Microsoft Teams client.
//
// An unexported method is used to prevent client code from implementing this
// interface in order to support future changes (and not violate backwards
// compatibility).
type MessageSender interface {
HTTPClient() *http.Client
UserAgent() string
ValidateWebhook(webhookURL string) error
// A private method to prevent client code from implementing the interface
// so that any future changes to it will not violate backwards
// compatibility.
private()
}
// messagePreparer is a message type that supports marshaling its fields
// as preparation for delivery to an endpoint.
type messagePreparer interface {
Prepare() error
}
// messageValidator is a message type that provides validation of its format.
type messageValidator interface {
Validate() error
}
// TeamsMessage is the interface shared by all supported message formats for
// submission to a Microsoft Teams channel.
type TeamsMessage interface {
messagePreparer
messageValidator
Payload() io.Reader
}
// teamsClient is the legacy client used for submitting messages to a
// Microsoft Teams channel.
type teamsClient struct {
httpClient *http.Client
userAgent string
webhookURLValidationPatterns []string
skipWebhookURLValidation bool
}
// TeamsClient provides functionality for submitting messages to a Microsoft
// Teams channel.
type TeamsClient struct {
httpClient *http.Client
userAgent string
webhookURLValidationPatterns []string
skipWebhookURLValidation bool
}
func init() {
// Disable logging output by default unless client code explicitly
// requests it
logger = log.New(os.Stderr, "[goteamsnotify] ", 0)
logger.SetOutput(ioutil.Discard)
}
// EnableLogging enables logging output from this package. Output is muted by
// default unless explicitly requested (by calling this function).
func EnableLogging() {
logger.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
logger.SetOutput(os.Stderr)
}
// DisableLogging reapplies default package-level logging settings of muting
// all logging output.
func DisableLogging() {
logger.SetFlags(0)
logger.SetOutput(ioutil.Discard)
}
// NewClient - create a brand new client for MS Teams notify
//
// Deprecated: use NewTeamsClient() function instead.
func NewClient() API {
client := teamsClient{
httpClient: &http.Client{
// We're using a context instead of setting this directly
// Timeout: DefaultWebhookSendTimeout,
},
skipWebhookURLValidation: false,
}
return &client
}
// NewTeamsClient constructs a minimal client for submitting messages to a
// Microsoft Teams channel.
func NewTeamsClient() *TeamsClient {
client := TeamsClient{
httpClient: &http.Client{
// We're using a context instead of setting this directly
// Timeout: DefaultWebhookSendTimeout,
},
skipWebhookURLValidation: false,
}
return &client
}
// private prevents client code from implementing the MessageSender interface
// so that any future changes to it will not violate backwards compatibility.
func (c *teamsClient) private() {}
// private prevents client code from implementing the MessageSender interface
// so that any future changes to it will not violate backwards compatibility.
func (c *TeamsClient) private() {}
// SetHTTPClient accepts a custom http.Client value which replaces the
// existing default http.Client.
func (c *TeamsClient) SetHTTPClient(httpClient *http.Client) *TeamsClient {
c.httpClient = httpClient
return c
}
// SetUserAgent accepts a custom user agent string. This custom user agent is
// used when submitting messages to Microsoft Teams.
func (c *TeamsClient) SetUserAgent(userAgent string) *TeamsClient {
c.userAgent = userAgent
return c
}
// UserAgent returns the configured user agent string for the client. If a
// custom value is not set the default package user agent is returned.
//
// Deprecated: use TeamsClient.UserAgent() method instead.
func (c *teamsClient) UserAgent() string {
switch {
case c.userAgent != "":
return c.userAgent
default:
return DefaultUserAgent
}
}
// UserAgent returns the configured user agent string for the client. If a
// custom value is not set the default package user agent is returned.
func (c *TeamsClient) UserAgent() string {
switch {
case c.userAgent != "":
return c.userAgent
default:
return DefaultUserAgent
}
}
// AddWebhookURLValidationPatterns collects given patterns for validation of
// the webhook URL.
//
// Deprecated: use TeamsClient.AddWebhookURLValidationPatterns() method instead.
func (c *teamsClient) AddWebhookURLValidationPatterns(patterns ...string) API {
c.webhookURLValidationPatterns = append(c.webhookURLValidationPatterns, patterns...)
return c
}
// AddWebhookURLValidationPatterns collects given patterns for validation of
// the webhook URL.
func (c *TeamsClient) AddWebhookURLValidationPatterns(patterns ...string) *TeamsClient {
c.webhookURLValidationPatterns = append(c.webhookURLValidationPatterns, patterns...)
return c
}
// HTTPClient returns the internal pointer to an http.Client. This can be used
// to further modify specific http.Client field values.
//
// Deprecated: use TeamsClient.HTTPClient() method instead.
func (c *teamsClient) HTTPClient() *http.Client {
return c.httpClient
}
// HTTPClient returns the internal pointer to an http.Client. This can be used
// to further modify specific http.Client field values.
func (c *TeamsClient) HTTPClient() *http.Client {
return c.httpClient
}
// Send is a wrapper function around the SendWithContext method in order to
// provide backwards compatibility.
//
// Deprecated: use TeamsClient.Send() method instead.
func (c *teamsClient) Send(webhookURL string, webhookMessage MessageCard) error {
// Create context that can be used to emulate existing timeout behavior.
ctx, cancel := context.WithTimeout(context.Background(), DefaultWebhookSendTimeout)
defer cancel()
return sendWithContext(ctx, c, webhookURL, &webhookMessage)
}
// Send is a wrapper function around the SendWithContext method in order to
// provide backwards compatibility.
func (c *TeamsClient) Send(webhookURL string, message TeamsMessage) error {
// Create context that can be used to emulate existing timeout behavior.
ctx, cancel := context.WithTimeout(context.Background(), DefaultWebhookSendTimeout)
defer cancel()
return sendWithContext(ctx, c, webhookURL, message)
}
// SendWithContext submits a given message to a Microsoft Teams channel using
// the provided webhook URL. The http client request honors the cancellation
// or timeout of the provided context.
//
// Deprecated: use TeamsClient.SendWithContext() method instead.
func (c *teamsClient) SendWithContext(ctx context.Context, webhookURL string, webhookMessage MessageCard) error {
return sendWithContext(ctx, c, webhookURL, &webhookMessage)
}
// SendWithContext submits a given message to a Microsoft Teams channel using
// the provided webhook URL. The http client request honors the cancellation
// or timeout of the provided context.
func (c *TeamsClient) SendWithContext(ctx context.Context, webhookURL string, message TeamsMessage) error {
return sendWithContext(ctx, c, webhookURL, message)
}
// SendWithRetry provides message retry support when submitting messages to a
// Microsoft Teams channel. The caller is responsible for providing the
// desired context timeout, the number of retries and retries delay.
//
// Deprecated: use TeamsClient.SendWithRetry() method instead.
func (c *teamsClient) SendWithRetry(ctx context.Context, webhookURL string, webhookMessage MessageCard, retries int, retriesDelay int) error {
return sendWithRetry(ctx, c, webhookURL, &webhookMessage, retries, retriesDelay)
}
// SendWithRetry provides message retry support when submitting messages to a
// Microsoft Teams channel. The caller is responsible for providing the
// desired context timeout, the number of retries and retries delay.
func (c *TeamsClient) SendWithRetry(ctx context.Context, webhookURL string, message TeamsMessage, retries int, retriesDelay int) error {
return sendWithRetry(ctx, c, webhookURL, message, retries, retriesDelay)
}
// SkipWebhookURLValidationOnSend allows the caller to optionally disable
// webhook URL validation.
//
// Deprecated: use TeamsClient.SkipWebhookURLValidationOnSend() method instead.
func (c *teamsClient) SkipWebhookURLValidationOnSend(skip bool) API {
c.skipWebhookURLValidation = skip
return c
}
// SkipWebhookURLValidationOnSend allows the caller to optionally disable
// webhook URL validation.
func (c *TeamsClient) SkipWebhookURLValidationOnSend(skip bool) *TeamsClient {
c.skipWebhookURLValidation = skip
return c
}
// prepareRequest is a helper function that prepares a http.Request (including
// all desired headers) in order to submit a given prepared message to an
// endpoint.
func prepareRequest(ctx context.Context, userAgent string, webhookURL string, preparedMessage io.Reader) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, preparedMessage)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/json;charset=utf-8")
req.Header.Set("User-Agent", userAgent)
return req, nil
}
// processResponse is a helper function responsible for validating a response
// from an endpoint after submitting a message.
func processResponse(response *http.Response) (string, error) {
// Get the response body, then convert to string for use with extended
// error messages
responseData, err := ioutil.ReadAll(response.Body)
if err != nil {
logger.Println(err)
return "", err
}
responseString := string(responseData)
// TODO: Refactor for v3 series once O365 connector support is dropped.
switch {
// 400 Bad Response is likely an indicator that we failed to provide a
// required field in our JSON payload. For example, when leaving out the
// top level MessageCard Summary or Text field, the remote API returns
// "Summary or Text is required." as a text string. We include that
// response text in the error message that we return to the caller.
case response.StatusCode >= 299:
err = fmt.Errorf("error on notification: %v, %q", response.Status, responseString)
logger.Println(err)
return "", err
case response.StatusCode == 202:
// 202 Accepted response is expected for Workflow connector URL
// submissions.
logger.Println("202 Accepted response received as expected for workflow connector")
return responseString, nil
// DEPRECATED
//
// See https://github.com/atc0005/go-teams-notify/issues/262
//
// Microsoft Teams developers have indicated that receiving a 200 status
// code when submitting payloads to O365 connectors is insufficient to
// confirm that a message was successfully submitted.
//
// Instead, clients should ensure that a specific response string was also
// returned along with a 200 status code to confirm that a message was
// sent successfully. Because there is a chance that unintentional
// whitespace could be included, we explicitly strip it out.
//
// See atc0005/go-teams-notify#59 for more information.
case responseString != strings.TrimSpace(ExpectedWebhookURLResponseText):
logger.Printf(
"StatusCode: %v, Status: %v\n", response.StatusCode, response.Status,
)
logger.Printf("ResponseString: %v\n", responseString)
err = fmt.Errorf(
"got %q, expected %q: %w",
responseString,
ExpectedWebhookURLResponseText,
ErrInvalidWebhookURLResponseText,
)
logger.Println(err)
return "", err
default:
return responseString, nil
}
}
// validateWebhook applies webhook URL validation unless explicitly disabled.
func validateWebhook(webhookURL string, skipWebhookValidation bool, patterns []string) error {
if skipWebhookValidation || webhookURL == DisableWebhookURLValidation {
logger.Printf("validateWebhook: Webhook URL will not be validated: %#v\n", webhookURL)
return nil
}
u, err := url.Parse(webhookURL)
if err != nil {
return fmt.Errorf("unable to parse webhook URL %q: %w", webhookURL, err)
}
if len(patterns) == 0 {
patterns = []string{
DefaultWebhookURLValidationPattern,
WorkflowURLBaseDomain,
}
}
// Indicate passing validation if at least one pattern matches.
for _, pat := range patterns {
matched, err := regexp.MatchString(pat, webhookURL)
if err != nil {
return err
}
if matched {
logger.Printf("Pattern %v matched", pat)
return nil
}
}
return fmt.Errorf(
"%w; got: %q, patterns: %s",
ErrWebhookURLUnexpected,
u.String(),
strings.Join(patterns, ","),
)
}
// ValidateWebhook applies webhook URL validation unless explicitly disabled.
//
// Deprecated: use TeamsClient.ValidateWebhook() method instead.
func (c *teamsClient) ValidateWebhook(webhookURL string) error {
return validateWebhook(webhookURL, c.skipWebhookURLValidation, c.webhookURLValidationPatterns)
}
// ValidateWebhook applies webhook URL validation unless explicitly disabled.
func (c *TeamsClient) ValidateWebhook(webhookURL string) error {
return validateWebhook(webhookURL, c.skipWebhookURLValidation, c.webhookURLValidationPatterns)
}
// sendWithContext submits a given message to a Microsoft Teams channel using
// the provided webhook URL and client. The http client request honors the
// cancellation or timeout of the provided context.
func sendWithContext(ctx context.Context, client MessageSender, webhookURL string, message TeamsMessage) error {
logger.Printf("sendWithContext: Webhook message received: %#v\n", message)
if err := client.ValidateWebhook(webhookURL); err != nil {
return fmt.Errorf(
"failed to validate webhook URL: %w",
err,
)
}
if err := message.Validate(); err != nil {
return fmt.Errorf(
"failed to validate message: %w",
err,
)
}
if err := message.Prepare(); err != nil {
return fmt.Errorf(
"failed to prepare message: %w",
err,
)
}
req, err := prepareRequest(ctx, client.UserAgent(), webhookURL, message.Payload())
if err != nil {
return fmt.Errorf(
"failed to prepare request: %w",
err,
)
}
// Submit message to endpoint.
res, err := client.HTTPClient().Do(req)
if err != nil {
return fmt.Errorf(
"failed to submit message: %w",
err,
)
}
// Make sure that we close the response body once we're done with it
defer func() {
if err := res.Body.Close(); err != nil {
log.Printf("error closing response body: %v", err)
}
}()
responseText, err := processResponse(res)
if err != nil {
return fmt.Errorf(
"failed to process response: %w",
err,
)
}
logger.Printf("sendWithContext: Response string from Microsoft Teams API: %v\n", responseText)
return nil
}
// sendWithRetry provides message retry support when submitting messages to a
// Microsoft Teams channel. The caller is responsible for providing the
// desired context timeout, the number of retries and retries delay.
func sendWithRetry(ctx context.Context, client MessageSender, webhookURL string, message TeamsMessage, retries int, retriesDelay int) error {
var result error
// initial attempt + number of specified retries
attemptsAllowed := 1 + retries
// attempt to send message to Microsoft Teams, retry specified number of
// times before giving up
for attempt := 1; attempt <= attemptsAllowed; attempt++ {
// the result from the last attempt is returned to the caller
result = sendWithContext(ctx, client, webhookURL, message)
switch {
case result != nil:
logger.Printf(
"sendWithRetry: Attempt %d of %d to send message failed: %v",
attempt,
attemptsAllowed,
result,
)
if ctx.Err() != nil {
errMsg := fmt.Errorf(
"sendWithRetry: context cancelled or expired: %v; "+
"aborting message submission after %d of %d attempts: %w",
ctx.Err().Error(),
attempt,
attemptsAllowed,
result,
)
logger.Println(errMsg)
return errMsg
}
ourRetryDelay := time.Duration(retriesDelay) * time.Second
logger.Printf(
"sendWithRetry: Context not cancelled yet, applying retry delay of %v",
ourRetryDelay,
)
time.Sleep(ourRetryDelay)
default:
logger.Printf(
"sendWithRetry: successfully sent message after %d of %d attempts\n",
attempt,
attemptsAllowed,
)
// No further retries needed
return nil
}
}
return result
}
// old deprecated helper functions --------------------------------------------------------------------------------------------------------------
// IsValidInput is a validation "wrapper" function. This function is intended
// to run current validation checks and offer easy extensibility for future
// validation requirements.
//
// Deprecated: use API.ValidateWebhook() and MessageCard.Validate()
// methods instead.
func IsValidInput(webhookMessage MessageCard, webhookURL string) (bool, error) {
// validate url
if valid, err := IsValidWebhookURL(webhookURL); !valid {
return false, err
}
// validate message
if valid, err := IsValidMessageCard(webhookMessage); !valid {
return false, err
}
return true, nil
}
// IsValidWebhookURL performs validation checks on the webhook URL used to
// submit messages to Microsoft Teams.
//
// Deprecated: use API.ValidateWebhook() method instead.
func IsValidWebhookURL(webhookURL string) (bool, error) {
c := teamsClient{}
err := c.ValidateWebhook(webhookURL)
return err == nil, err
}
// IsValidMessageCard performs validation/checks for known issues with
// MessardCard values.
//
// Deprecated: use MessageCard.Validate() instead.
func IsValidMessageCard(webhookMessage MessageCard) (bool, error) {
err := webhookMessage.Validate()
return err == nil, err
}