switch to go-pkgz/notify package: modules

This commit is contained in:
Dmitry Verkhoturov
2022-04-29 13:32:15 -05:00
committed by Umputun
parent 598d767791
commit 0560a893cf
38 changed files with 2061 additions and 207 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ require (
github.com/stretchr/objx v0.3.0 // indirect
go.etcd.io/bbolt v1.3.6 // indirect
golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 // indirect
golang.org/x/net v0.0.0-20220412020605-290c469a71a5 // indirect
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 // indirect
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect
golang.org/x/time v0.0.0-20220411224347-583f2d630306 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
+2 -2
View File
@@ -88,8 +88,8 @@ golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9/go.mod h1:023OzeP/+EPmXeap
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220412020605-290c469a71a5 h1:bRb386wvrE+oBNdF1d/Xh9mQrfQ4ecYhW5qJ5GvTGT4=
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 h1:HVyaeDAYux4pnY+D/SiwmLOR36ewZ4iGQIIrtnuCjFA=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+2 -2
View File
@@ -1,7 +1,7 @@
package migrator
import (
"errors"
"fmt"
"io"
"strings"
)
@@ -42,7 +42,7 @@ func (u *URLMapper) loadRules(reader io.Reader) error {
row = strings.TrimSpace(row)
urls := strings.Split(row, " ")
if len(urls) != 2 {
return errors.New("bad row " + row)
return fmt.Errorf("bad row %s", row)
}
from, to := strings.TrimSpace(urls[0]), strings.TrimSpace(urls[1])
+5 -3
View File
@@ -15,6 +15,7 @@ require (
github.com/go-pkgz/jrpc v0.2.0
github.com/go-pkgz/lcw v0.8.1
github.com/go-pkgz/lgr v0.10.4
github.com/go-pkgz/notify v0.0.0-20220429105545-bc5c80a54199
github.com/go-pkgz/repeater v1.1.3
github.com/go-pkgz/rest v1.14.0
github.com/go-pkgz/syncs v1.2.0
@@ -25,18 +26,16 @@ require (
github.com/jessevdk/go-flags v1.5.0
github.com/kyokomi/emoji/v2 v2.2.9
github.com/microcosm-cc/bluemonday v1.0.18
github.com/pkg/errors v0.9.1
github.com/rakyll/statik v0.1.7
github.com/rs/xid v1.4.0
github.com/russross/blackfriday/v2 v2.1.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/slack-go/slack v0.10.2
github.com/stretchr/testify v1.7.1
go.etcd.io/bbolt v1.3.6
go.uber.org/goleak v1.1.12
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9
golang.org/x/net v0.0.0-20220412020605-290c469a71a5
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4
)
require (
@@ -48,6 +47,7 @@ require (
github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/go-chi/chi v4.1.1+incompatible // indirect
github.com/go-oauth2/oauth2/v4 v4.4.3 // indirect
github.com/go-pkgz/email v0.2.1-0.20220428155644-a7e5d837d02f // indirect
github.com/go-pkgz/expirable-cache v0.0.3 // indirect
github.com/go-redis/redis/v7 v7.4.1 // indirect
github.com/go-stack/stack v1.8.1 // indirect
@@ -59,7 +59,9 @@ require (
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/klauspost/compress v1.15.1 // indirect
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/slack-go/slack v0.10.3 // indirect
github.com/stretchr/objx v0.3.0 // indirect
github.com/valyala/fasthttp v1.35.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
+8 -4
View File
@@ -144,6 +144,8 @@ github.com/go-oauth2/oauth2/v4 v4.4.3 h1:IiU3iAK5NEeZ4mUf5HV9tN7iNlx4AGUkIJ5M1C4
github.com/go-oauth2/oauth2/v4 v4.4.3/go.mod h1:NR9Hugz5/Qe2OGxoPBhsTRNjnm/amC+z9+XTwt63rhs=
github.com/go-pkgz/auth v1.19.0 h1:TTfbQvlqsuEhRpYAiY/uF5UGP+HWMc5CN2RAhN06ik0=
github.com/go-pkgz/auth v1.19.0/go.mod h1:1pu95rx7tFfeIFq0pjQPwCeL6qGYH4+UxHJudhK0T7M=
github.com/go-pkgz/email v0.2.1-0.20220428155644-a7e5d837d02f h1:beMw0mHR3m7yh0tl2CwRTAg2Pa9z2LGFzHNDiD93i2Y=
github.com/go-pkgz/email v0.2.1-0.20220428155644-a7e5d837d02f/go.mod h1:TpnmSLkQW3FyICit2hn7WIhCUDrhCX6btzz5wS3wHRI=
github.com/go-pkgz/expirable-cache v0.0.3 h1:rTh6qNPp78z0bQE6HDhXBHUwqnV9i09Vm6dksJLXQDc=
github.com/go-pkgz/expirable-cache v0.0.3/go.mod h1:+IauqN00R2FqNRLCLA+X5YljQJrwB179PfiAoMPlTlQ=
github.com/go-pkgz/jrpc v0.2.0 h1:CLy/eZyekjraVrxZV18N2R1mYLMJ/nWrgdfyIOGPY/E=
@@ -152,6 +154,8 @@ github.com/go-pkgz/lcw v0.8.1 h1:Bpt2yYTE1J8hIhz8tjdm1WPOgH13eo5iTNsXyop7cMQ=
github.com/go-pkgz/lcw v0.8.1/go.mod h1:Xw0/ZfApATgbjVPYRZO4XHdWyxAjErDWDWJ7TLlw1Vc=
github.com/go-pkgz/lgr v0.10.4 h1:l7qyFjqEZgwRgaQQSEp6tve4A3OU80VrfzpvtEX8ngw=
github.com/go-pkgz/lgr v0.10.4/go.mod h1:CD0s1z6EFpIUplV067gitF77tn25JItzwHNKAPqeCF0=
github.com/go-pkgz/notify v0.0.0-20220429105545-bc5c80a54199 h1:1PE3B3RboNvAVL3V6b+GXNFHaTiIcTohsqh+HbUkpIg=
github.com/go-pkgz/notify v0.0.0-20220429105545-bc5c80a54199/go.mod h1:mSHXWImQev182O0gte/r6pT7NEzlTZozcOhOykjnVqk=
github.com/go-pkgz/repeater v1.1.3 h1:q6+JQF14ESSy28Dd7F+wRelY4F+41HJ0LEy/szNnMiE=
github.com/go-pkgz/repeater v1.1.3/go.mod h1:hVTavuO5x3Gxnu8zW7d6sQBfAneKV8X2FjU48kGfpKw=
github.com/go-pkgz/rest v1.5.0/go.mod h1:nQaM3RhSTUAmbBZWY4hfe4buyeC9VckvhoCktiQXJxI=
@@ -343,8 +347,8 @@ github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNX
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/slack-go/slack v0.10.2 h1:KMN/h2sgUninHXvQI8PrR/PHBUuWp2NPvz2Kr66tki4=
github.com/slack-go/slack v0.10.2/go.mod h1:5FLdBRv7VW/d9EBxx/eEktOptWygbA9K2QK/KW7ds1s=
github.com/slack-go/slack v0.10.3 h1:kKYwlKY73AfSrtAk9UHWCXXfitudkDztNI9GYBviLxw=
github.com/slack-go/slack v0.10.3/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
@@ -526,8 +530,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220412020605-290c469a71a5 h1:bRb386wvrE+oBNdF1d/Xh9mQrfQ4ecYhW5qJ5GvTGT4=
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 h1:HVyaeDAYux4pnY+D/SiwmLOR36ewZ4iGQIIrtnuCjFA=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+15
View File
@@ -0,0 +1,15 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
+61
View File
@@ -0,0 +1,61 @@
run:
timeout: 5m
output:
format: tab
skip-dirs:
- vendor
linters-settings:
govet:
check-shadowing: true
maligned:
suggest-new: true
goconst:
min-len: 2
min-occurrences: 2
misspell:
locale: US
lll:
line-length: 140
gocritic:
enabled-tags:
- performance
- style
- experimental
disabled-checks:
- wrapperFunc
- hugeParam
- rangeValCopy
- singleCaseSwitch
- ifElseChain
linters:
enable:
- megacheck
- revive
- govet
- unconvert
- megacheck
- structcheck
- gas
- gocyclo
- dupl
- misspell
- unparam
- varcheck
- deadcode
- typecheck
- ineffassign
- varcheck
- stylecheck
- gochecknoinits
- exportloopref
- gocritic
- nakedret
- gosimple
- prealloc
fast: false
disable-all: true
issues:
exclude-use-default: false
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Umputun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+70
View File
@@ -0,0 +1,70 @@
# email sending library
[![Build Status](https://github.com/go-pkgz/email/workflows/build/badge.svg)](https://github.com/go-pkgz/email/actions) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/email/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/email?branch=master) [![Go Reference](https://pkg.go.dev/badge/github.com/go-pkgz/email.svg)](https://pkg.go.dev/github.com/go-pkgz/email)
The library is a wrapper around the stdlib `net/smtp` simplifying email sending. It supports authentication, SSL/TLS,
user-specified SMTP servers, content-type, charset, multiple recipients and more.
Usage example:
```go
client := email.NewSender("localhost", email.ContentType("text/html"), email.Auth("user", "pass"))
err := client.Send("<html>some content, foo bar</html>",
email.Params{From: "me@example.com", To: []string{"to@example.com"}, Subject: "Hello world!",
Attachments: []string{"/path/to/file1.txt", "/path/to/file2.txt"},
InlineImages: []string{"/path/to/image1.png", "/path/to/image2.png"},
})
```
## options
`NewSender` accepts a number of options to configure the client:
- `Port`: SMTP port (default: 25)
- `TLS`: Use TLS SMTP (default: false)
- `STARTTLS`: Use STARTTLS (default: false)
- `Auth(user, password)`: Username and password for SMTP authentication (default: empty, no authentication)
- `ContentType`: Content type for the email (default: "text/plain")
- `Charset`: Charset for the email (default: "utf-8")
- `TimeOut`: Timeout for the SMTP connection (default: 30 seconds)
- `Log`: Logger to use (default: no logging)
- `SMTP`: Set custom smtp client (default: none)
See [go docs](https://pkg.go.dev/github.com/go-pkgz/email#Option) for `Option` functions.
_Options should be passed to `NewSender` after the mandatory first (host) parameter._
## sending email
To send email user need to create a sender first and then use `Send` method. The method accepts two parameters:
- email content (string)
- parameters (`email.Params`)
```go
type Params struct {
From string // From email field
To []string // From email field
Subject string // Email subject
UnsubscribeLink string // POST, https://support.google.com/mail/answer/81126 -> "Use one-click unsubscribe"
Attachments []string // Attachments path
InlineImages []string // Embedding directly to email body. Autogenerated Content-Id (cid) equals to file name
}
```
See [go docs](https://pkg.go.dev/github.com/go-pkgz/email#Sender.Send) for `Send` function.
## technical details
- Content-Transfer-Encoding set to `quoted-printable`
- Custom SMTP client (`smtp.Client` from stdlib) can be set by user with `SMTP` option. In this case it will be used instead of making a new smtp client internally.
- Logger can be set with `Log` option. It should implement `email.Logger` interface with a single `Logf(format string, args ...interface{})` method. By default, "no logging" internal logger is used. This interface is compatible with the `go-pkgz/lgr` logger.
- The library has no external dependencies, except for testing. It uses the stdlib `net/smtp` package.
- SSL/TLS supported with `TLS` option. Pls note: this is not the same as `STARTTLS` (not supported) which is usually on port 587 vs SSL/TLS on port 465.
## limitations
This library is not intended to be used for sending a lot of massive emails with
low latency requirements. The intended use case is sending simple messages, like alerts, notification and so on.
For example, sending alerts from a monitoring system, or for authentication-related emails, i.e. "password reset email",
"verification email", etc.
+347
View File
@@ -0,0 +1,347 @@
// Package email provides email sender
package email
import (
"bytes"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
"net/http"
"net/smtp"
"net/textproto"
"os"
"path/filepath"
"strings"
"time"
)
//go:generate moq -out mocks/smpt_client.go -pkg mocks -skip-ensure -fmt goimports . SMTPClient
//go:generate moq -out mocks/logger.go -pkg mocks -skip-ensure -fmt goimports . Logger
// Sender implements email sender
type Sender struct {
smtpClient SMTPClient
logger Logger
host string // SMTP host
port int // SMTP port
contentType string // Content type, optional. Will trigger MIME and Content-Type headers
tls bool // TLS auth
starttls bool // StartTLS
smtpUserName string // username
smtpPassword string // password
timeOut time.Duration
contentCharset string
timeNow func() time.Time
}
// Params contains all user-defined parameters to send emails
type Params struct {
From string // From email field
To []string // From email field
Subject string // Email subject
UnsubscribeLink string // POST, https://support.google.com/mail/answer/81126 -> "Use one-click unsubscribe"
Attachments []string // Attachments path
InlineImages []string // InlineImages images path
}
// Logger is used to log errors and debug messages
type Logger interface {
Logf(format string, args ...interface{})
}
// SMTPClient interface defines subset of net/smtp used by email client
type SMTPClient interface {
Mail(from string) error
Auth(auth smtp.Auth) error
Rcpt(to string) error
Data() (io.WriteCloser, error)
Quit() error
Close() error
}
// NewSender creates email client with prepared smtp
func NewSender(smtpHost string, options ...Option) *Sender {
res := Sender{
smtpClient: nil,
logger: nopLogger{},
host: smtpHost,
port: 25,
contentType: `text/plain`,
tls: false,
smtpUserName: "",
smtpPassword: "",
contentCharset: "UTF-8",
timeOut: time.Second * 30,
timeNow: time.Now,
}
for _, opt := range options {
opt(&res)
}
res.logger.Logf("[INFO] new email sender created with host: %s:%d, tls: %v, username: %q, timeout: %v, "+
"content type: %q, charset: %q", smtpHost,
res.port, res.tls, res.smtpUserName, res.timeOut, res.contentType, res.contentCharset)
return &res
}
// Send email with given text
// If SMTPClient defined in Email struct it will be used, if not - new smtp.Client on each send.
// Always closes client on completion or failure.
func (em *Sender) Send(text string, params Params) error {
em.logger.Logf("[DEBUG] send %q to %v", text, params.To)
client := em.smtpClient
if client == nil { // if client not set make new net/smtp
c, err := em.client()
if err != nil {
return fmt.Errorf("failed to make smtp client: %w", err)
}
client = c
}
var quit bool
defer func() {
if quit || client == nil { // quit set if Quit() call passed because it's closing connection as well.
return
}
if err := client.Close(); err != nil {
em.logger.Logf("[WARN] can't close smtp connection, %v", err)
}
}()
if len(params.To) == 0 {
return errors.New("no recipients")
}
if em.smtpUserName != "" && em.smtpPassword != "" {
auth := smtp.PlainAuth("", em.smtpUserName, em.smtpPassword, em.host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("failed to auth to smtp %s:%d, %w", em.host, em.port, err)
}
}
if err := client.Mail(params.From); err != nil {
return fmt.Errorf("bad from address %q: %w", params.From, err)
}
for _, rcpt := range params.To {
if err := client.Rcpt(rcpt); err != nil {
return fmt.Errorf("bad to address %q: %w", params.To, err)
}
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("can't make email writer: %w", err)
}
msg, err := em.buildMessage(text, params)
if err != nil {
return fmt.Errorf("can't make email message: %w", err)
}
buf := bytes.NewBufferString(msg)
if _, err = buf.WriteTo(writer); err != nil {
return fmt.Errorf("failed to send email body to %q: %w", params.To, err)
}
if err = writer.Close(); err != nil {
em.logger.Logf("[WARN] can't close smtp body writer, %v", err)
}
if err = client.Quit(); err != nil {
em.logger.Logf("[WARN] failed to send quit command to %s:%d, %v", em.host, em.port, err)
} else {
quit = true
}
return nil
}
func (em *Sender) String() string {
return fmt.Sprintf("smtp://%s:%d, auth:%v, tls:%v, starttls:%v, timeout:%v, content-type:%q, charset:%q",
em.host, em.port, em.smtpUserName != "", em.tls, em.starttls, em.timeOut, em.contentType, em.contentCharset)
}
func (em *Sender) client() (c *smtp.Client, err error) {
srvAddress := fmt.Sprintf("%s:%d", em.host, em.port)
tlsConf := &tls.Config{
InsecureSkipVerify: false,
ServerName: em.host,
MinVersion: tls.VersionTLS12,
}
if em.tls {
conn, e := tls.Dial("tcp", srvAddress, tlsConf)
if e != nil {
return nil, fmt.Errorf("failed to dial smtp tls to %s: %w", srvAddress, e)
}
if c, err = smtp.NewClient(conn, em.host); err != nil {
return nil, fmt.Errorf("failed to make smtp client for %s: %w", srvAddress, err)
}
return c, nil
}
conn, err := net.DialTimeout("tcp", srvAddress, em.timeOut)
if err != nil {
return nil, fmt.Errorf("timeout connecting to %s: %w", srvAddress, err)
}
c, err = smtp.NewClient(conn, srvAddress)
if err != nil {
return nil, fmt.Errorf("failed to dial: %w", err)
}
if em.starttls {
if err = c.StartTLS(tlsConf); err != nil {
return nil, fmt.Errorf("failed to start tls: %w", err)
}
}
return c, nil
}
func (em *Sender) buildMessage(text string, params Params) (message string, err error) {
addHeader := func(msg, h, v string) string {
msg += fmt.Sprintf("%s: %s\n", h, v)
return msg
}
message = addHeader(message, "From", params.From)
message = addHeader(message, "To", strings.Join(params.To, ","))
message = addHeader(message, "Subject", mime.BEncoding.Encode("utf-8", params.Subject))
if params.UnsubscribeLink != "" {
message = addHeader(message, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
message = addHeader(message, "List-Unsubscribe", "<"+params.UnsubscribeLink+">")
}
withAttachments := len(params.Attachments) > 0
withInlineImg := len(params.InlineImages) > 0
if em.contentType != "" || withAttachments || withInlineImg {
message = addHeader(message, "MIME-version", "1.0")
}
message = addHeader(message, "Date", em.timeNow().Format(time.RFC1123Z))
buff := &bytes.Buffer{}
qp := quotedprintable.NewWriter(buff)
mpMixed := multipart.NewWriter(buff)
boundaryMixed := mpMixed.Boundary()
mpRelated := multipart.NewWriter(buff)
boundaryRelated := mpRelated.Boundary()
if withAttachments {
message = addHeader(message, "Content-Type", fmt.Sprintf("multipart/mixed; boundary=%q\r\n\r\n%s\r",
boundaryMixed, "--"+boundaryMixed))
}
if withInlineImg {
message = addHeader(message, "Content-Type", fmt.Sprintf("multipart/related; boundary=%q\r\n\r\n%s\r",
boundaryRelated, "--"+boundaryRelated))
}
if em.contentType != "" {
message = addHeader(message, "Content-Transfer-Encoding", "quoted-printable")
message = addHeader(message, "Content-Type", fmt.Sprintf("%s; charset=%q", em.contentType, em.contentCharset))
}
if err := em.writeBody(qp, text); err != nil {
return "", fmt.Errorf("failed to write body: %w", err)
}
if withInlineImg {
buff.WriteString("\r\n\r\n")
if err := em.writeFiles(mpRelated, params.InlineImages, "inline"); err != nil {
return "", fmt.Errorf("failed to write inline images: %w", err)
}
}
if withAttachments {
buff.WriteString("\r\n\r\n")
if err := em.writeFiles(mpMixed, params.Attachments, "attachment"); err != nil {
return "", fmt.Errorf("failed to write attachments: %w", err)
}
}
m := buff.String()
message += "\n" + m
// returns base part of the file location
return message, nil
}
func (em *Sender) writeBody(wc io.WriteCloser, text string) error {
if _, err := wc.Write([]byte(text)); err != nil {
return err
}
if err := wc.Close(); err != nil {
return err
}
return nil
}
func (em *Sender) writeFiles(mp *multipart.Writer, files []string, disposition string) error {
for _, attachment := range files {
file, err := os.Open(filepath.Clean(attachment))
if err != nil {
return err
}
// we need first 512 bytes to detect file type
fTypeBuff := make([]byte, 512)
_, err = file.Read(fTypeBuff)
if err != nil {
return fmt.Errorf("failed to read file type %q: %w", attachment, err)
}
// remove null bytes in case file less than 512 bytes
fTypeBuff = bytes.Trim(fTypeBuff, "\x00")
fName := filepath.Base(attachment)
header := textproto.MIMEHeader{}
header.Set("Content-Type", http.DetectContentType(fTypeBuff)+"; name=\""+fName+"\"")
header.Set("Content-Transfer-Encoding", "base64")
switch disposition {
case "attachment":
header.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fName))
case "inline":
header.Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", fName))
header.Set("Content-ID", fmt.Sprintf("<%s>", fName))
}
writer, err := mp.CreatePart(header)
if err != nil {
return err
}
// set reader offset at the beginning of the file because we read first 512 bytes
_, err = file.Seek(0, io.SeekStart)
if err != nil {
return err
}
encoder := base64.NewEncoder(base64.StdEncoding, writer)
if _, err := io.Copy(encoder, file); err != nil {
return err
}
if err := encoder.Close(); err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
}
if err := mp.Close(); err != nil {
return err
}
return nil
}
type nopLogger struct{}
func (nopLogger) Logf(format string, args ...interface{}) {}
+70
View File
@@ -0,0 +1,70 @@
package email
import "time"
// Option func type
type Option func(s *Sender)
// SMTP sets SMTP client
func SMTP(smtp SMTPClient) Option {
return func(s *Sender) {
s.smtpClient = smtp
}
}
// Log sets the logger for the email package
func Log(l Logger) Option {
return func(s *Sender) {
s.logger = l
}
}
// Port sets SMTP port
func Port(port int) Option {
return func(s *Sender) {
s.port = port
}
}
// ContentType sets content type of the email
func ContentType(contentType string) Option {
return func(s *Sender) {
s.contentType = contentType
}
}
// Charset sets content charset of the email
func Charset(charset string) Option {
return func(s *Sender) {
s.contentCharset = charset
}
}
// TLS enables TLS support
func TLS(enabled bool) Option {
return func(s *Sender) {
s.tls = enabled
}
}
// STARTTLS enables STARTTLS support
func STARTTLS(enabled bool) Option {
return func(s *Sender) {
s.starttls = enabled
}
}
// Auth sets smtp username and password
func Auth(smtpUserName, smtpPasswd string) Option {
return func(s *Sender) {
s.smtpUserName = smtpUserName
s.smtpPassword = smtpPasswd
}
}
// TimeOut sets smtp timeout
func TimeOut(timeOut time.Duration) Option {
return func(s *Sender) {
s.timeOut = timeOut
}
}
+70
View File
@@ -0,0 +1,70 @@
run:
timeout: 5m
output:
format: tab
skip-dirs:
- vendor
linters-settings:
govet:
check-shadowing: true
maligned:
suggest-new: true
goconst:
min-len: 2
min-occurrences: 2
misspell:
locale: US
lll:
line-length: 140
gocritic:
enabled-tags:
- performance
- style
- experimental
disabled-checks:
- wrapperFunc
- hugeParam
- rangeValCopy
- singleCaseSwitch
- ifElseChain
linters:
enable:
- megacheck
- revive
- govet
- unconvert
- megacheck
- structcheck
- gas
- gocyclo
- dupl
- misspell
- unparam
- varcheck
- deadcode
- typecheck
- ineffassign
- varcheck
- stylecheck
- gochecknoinits
- exportloopref
- gocritic
- nakedret
- gosimple
- prealloc
- whitespace
fast: false
disable-all: true
issues:
exclude-rules:
- text: "at least one file in a package should have a package comment"
linters:
- stylecheck
- path: _test\.go
linters:
- gosec
- dupl
exclude-use-default: false
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Umputun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+202
View File
@@ -0,0 +1,202 @@
# Notify
[![Build Status](https://github.com/go-pkgz/notify/workflows/build/badge.svg)](https://github.com/go-pkgz/notify/actions) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/notify/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/notify?branch=master) [![Go Reference](https://pkg.go.dev/badge/github.com/go-pkgz/notify.svg)](https://pkg.go.dev/github.com/go-pkgz/notify)
This library provides ability to send notifications using multiple services:
- Email
- Telegram
- Slack
- Webhook
## Install
`go get -u github.com/go-pkgz/notify`
## Usage
All supported notification methods could adhere to the following interface. Example on how to use it:
```go
package main
import (
"context"
"fmt"
"github.com/go-pkgz/notify"
)
func main() {
// create notifiers
notifiers := []notify.Notifier{
notify.NewWebhook(notify.WebhookParams{}),
notify.NewEmail(notify.SMTPParams{}),
notify.NewSlack("token"),
notify.NewTelegram(notify.TelegramParams{token: "token"}),
}
err := notify.Send(context.Background(), notifiers, "https://example.com/webhook", "Hello, world!")
if err != nil {
fmt.Printf("Sent message error: %s", err))
}
}
```
### Email
`mailto:` [scheme](https://datatracker.ietf.org/doc/html/rfc6068) is supported. Only `subject` and `from` query params are used.
Examples:
- `mailto:"John Wayne"<john@example.org>?subject=test-subj&from="Notifier"<notify@example.org>`
- `mailto:addr1@example.org,addr2@example.org?&subject=test-subj&from=notify@example.org`
```go
package main
import (
"context"
"log"
"time"
"github.com/go-pkgz/notify"
)
func main() {
wh := notify.NewEmail(notify.SMTPParams{
Host: "localhost", // the only required field, others are optional
Port: 25,
TLS: false, // TLS, but not STARTTLS
ContentType: "text/html",
Charset: "UTF-8",
Username: "username",
Password: "password",
TimeOut: time.Second * 10, // default is 30 seconds
})
err := wh.Send(
context.Background(),
`mailto:"John Wayne"<john@example.org>?subject=test-subj&from="Notifier"<notify@example.org>`,
"Hello, World!",
)
if err != nil {
log.Fatalf("problem sending message using email, %v", err)
}
}
```
### Telegram
`telegram:` scheme akin to `mailto:` is supported. Query params `parseMode` ([doc](https://core.telegram.org/bots/api#formatting-options), legacy `Markdown` by default, preferable use `MarkdownV2` or `HTML` instead). Examples:
- `telegram:channel`
- `telegram:channelID` // channel ID is a number, like `-1001480738202`: use [that instruction](https://remark42.com/docs/configuration/telegram/#notifications-for-administrators) to obtain it
- `telegram:userID`
[Here](https://remark42.com/docs/configuration/telegram/#getting-bot-token-for-telegram) is an instruction on obtaining token for your notification bot.
```go
package main
import (
"context"
"log"
"github.com/go-pkgz/notify"
)
func main() {
tg := notify.NewTelegram(notify.TelegramParams{
Token: "token", // required
Timeout: time.Second * 10, // default is 5 seconds
SuccessMsg: // optional, for auth, set by default
ErrorMsg: // optional, for auth, unset by default
})
err := tg.Send(context.Background(), "telegram:-1001480738202", "Hello, World!")
if err != nil {
log.Fatalf("problem sending message using telegram, %v", err)
}
}
```
#### HTML Formatting
parseMode `HTML` supports [limited set of tags](https://core.telegram.org/bots/api#html-style), so `Telegram` provides `TelegramSupportedHTML` method which strips all unsupported tags and replaces `h1-h3` with `<b>` and `h4-h6` with `<i><b>` to preserve formatting.
If you want to post text into HTML tag like <a>text</a>, you can use `EscapeTelegramText` method to escape it (by replacing symbols `&`, `<`, `>` with `&amp;`, `&lt;`, `&gt;`).
#### Authorisation
You can use Telegram notifications as described above, just to send messages. But also, you can use `Telegram` to authorise users as a login method or to sign them up for notifications. Functions used for processing updates from users are `GetBotUsername`, `AddToken`, `CheckToken`, `Request`, and `Run` or `ProcessUpdate` (only one of two can be used at a time).
Normal flow is following:
1. you run the `Run` goroutine
2. call `AddToken` and provide user with that token
3. user clicks on the link `https://t.me/<BOT_USERNAME>/?start=<TOKEN>`
4. you call `CheckToken` to verify that the user clicked the link, and if so you will receive user's UserID
Alternative flow is the same, but instead of running the `Run` goroutine, you set up process update flow separately (to use with [auth](https://github.com/go-pkgz/auth/blob/master/provider/telegram.go) as well, for example) and run `ProcessUpdate` when update is received. Example of such a setup can be seen [in Remark42](https://github.com/umputun/remark42/blob/c027dcd/backend/app/providers/telegram.go).
### Slack
`slack:` scheme akin to `mailto:` is supported. `title`, `titleLink`, `attachmentText` and query params are used: if they are defined, message would be sent with a [text attachment](https://api.slack.com/reference/messaging/attachments). Examples:
- `slack:channel`
- `slack:channelID`
- `slack:userID`
- `slack:channel?title=title&attachmentText=test%20text&titleLink=https://example.org`
```go
package main
import (
"context"
"log"
"github.com/go-pkgz/notify"
"github.com/slack-go/slack"
)
func main() {
wh := notify.NewSlack(
"token",
slack.OptionDebug(true), // optional, you can pass any slack.Options
)
err := wh.Send(context.Background(), "slack:general", "Hello, World!")
if err != nil {
log.Fatalf("problem sending message using slack, %v", err)
}
}
```
### Webhook
`http://` and `https://` schemas are supported.
```go
package main
import (
"context"
"log"
"time"
"github.com/go-pkgz/notify"
)
func main() {
wh := notify.NewWebhook(notify.WebhookParams{
Timeout: time.Second, // optional, default is 5 seconds
Headers: []string{"Content-Type:application/json,text/plain"}, // optional
})
err := wh.Send(context.Background(), "https://example.org/webhook", "Hello, World!")
if err != nil {
log.Fatalf("problem sending message using webhook, %v", err)
}
}
```
## Status
The library extracted from [remark42](https://github.com/umputun/remark) project. The original code in production use on multiple sites and seems to work fine.
`go-pkgz/notify` library still in development and until version 1 released some breaking changes possible.
+132
View File
@@ -0,0 +1,132 @@
package notify
import (
"context"
"fmt"
"net/mail"
"net/url"
"time"
"github.com/go-pkgz/email"
)
// SMTPParams contain settings for smtp server connection
type SMTPParams struct {
Host string // SMTP host
Port int // SMTP port
TLS bool // TLS auth
StartTLS bool // StartTLS auth
ContentType string // Content type
Charset string // Character set
Username string // username
Password string // password
TimeOut time.Duration // TCP connection timeout
}
// Email notifications client
type Email struct {
SMTPParams
sender *email.Sender
}
// NewEmail makes new Email object
func NewEmail(smtpParams SMTPParams) *Email {
var opts []email.Option
if smtpParams.Username != "" {
opts = append(opts, email.Auth(smtpParams.Username, smtpParams.Password))
}
if smtpParams.ContentType != "" {
opts = append(opts, email.ContentType(smtpParams.ContentType))
}
if smtpParams.Charset != "" {
opts = append(opts, email.Charset(smtpParams.Charset))
}
if smtpParams.Port != 0 {
opts = append(opts, email.Port(smtpParams.Port))
}
if smtpParams.TimeOut != 0 {
opts = append(opts, email.TimeOut(smtpParams.TimeOut))
}
if smtpParams.TLS {
opts = append(opts, email.TLS(true))
}
if smtpParams.StartTLS {
opts = append(opts, email.STARTTLS(true))
}
sender := email.NewSender(smtpParams.Host, opts...)
return &Email{sender: sender, SMTPParams: smtpParams}
}
// Send sends the message over Email, with "from", "subject" and "unsubscribeLink" parsed from destination field
// with "mailto:" schema.
// "unsubscribeLink" passed as a header, https://support.google.com/mail/answer/81126 -> "Use one-click unsubscribe"
//
// Example:
//
// - mailto:"John Wayne"<john@example.org>?subject=test-subj&from="Notifier"<notify@example.org>
// - mailto:addr1@example.org,addr2@example.org?subject=test-subj&from=notify@example.org&unsubscribeLink=http://example.org/unsubscribe
func (e *Email) Send(ctx context.Context, destination, text string) error {
emailParams, err := e.parseDestination(destination)
if err != nil {
return fmt.Errorf("problem parsing destination: %w", err)
}
select {
case <-ctx.Done():
return ctx.Err()
default:
return e.sender.Send(text, emailParams)
}
}
// Schema returns schema prefix supported by this client
func (e *Email) Schema() string {
return "mailto"
}
// String representation of Email object
func (e *Email) String() string {
str := fmt.Sprintf("email: with username '%s' at server %s:%d", e.Username, e.Host, e.Port)
if e.TLS {
str += " with TLS"
}
return str
}
// parses "mailto:" URL and returns email parameters
func (e *Email) parseDestination(destination string) (email.Params, error) {
// parse URL
u, err := url.Parse(destination)
if err != nil {
return email.Params{}, err
}
if u.Scheme != "mailto" {
return email.Params{}, fmt.Errorf("unsupported scheme %s, should be mailto", u.Scheme)
}
// parse destination address(es)
addresses, err := mail.ParseAddressList(u.Opaque)
if err != nil {
return email.Params{}, fmt.Errorf("problem parsing email recipients: %w", err)
}
destinations := []string{}
for _, addr := range addresses {
destinations = append(destinations, addr.String())
}
return email.Params{
From: u.Query().Get("from"),
To: destinations,
Subject: u.Query().Get("subject"),
UnsubscribeLink: u.Query().Get("unsubscribeLink"),
}, nil
}
+28
View File
@@ -0,0 +1,28 @@
// Package notify provides notification functionality.
package notify
import (
"context"
"fmt"
"strings"
)
// Notifier defines common interface among all notifiers
type Notifier interface {
fmt.Stringer
Schema() string // returns schema prefix supported by this client
Send(ctx context.Context, destination, text string) error // sends message to provided destination
}
// Send sends message to provided destination, picking the right one based on destination schema
func Send(ctx context.Context, notifiers []Notifier, destination, text string) error {
for _, n := range notifiers {
if strings.HasPrefix(destination, n.Schema()) {
return n.Send(ctx, destination, text)
}
}
if strings.Contains(destination, ":") {
return fmt.Errorf("unsupported destination schema: %s", strings.Split(destination, ":")[0])
}
return fmt.Errorf("unsupported destination schema: %s", destination)
}
+108
View File
@@ -0,0 +1,108 @@
package notify
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"github.com/slack-go/slack"
)
// Slack notifications client
type Slack struct {
client *slack.Client
}
// NewSlack makes Slack client for notifications
func NewSlack(token string, opts ...slack.Option) *Slack {
return &Slack{client: slack.New(token, opts...)}
}
// Send sends the message over Slack, with "title", "titleLink" and "attachmentText" parsed from destination field
// with "slack:" schema same way "mailto:" schema is constructed.
//
// Example:
//
// - slack:channelName
// - slack:channelID
// - slack:userID
// - slack:channel?title=title&attachmentText=test%20text&titleLink=https://example.org
func (s *Slack) Send(ctx context.Context, destination, text string) error {
channelID, attachment, err := s.parseDestination(destination)
if err != nil {
return fmt.Errorf("problem parsing destination: %w", err)
}
options := []slack.MsgOption{slack.MsgOptionText(text, false)}
if attachment.Title != "" {
options = append(options, slack.MsgOptionAttachments(attachment))
}
select {
case <-ctx.Done():
return ctx.Err()
default:
_, _, err = s.client.PostMessageContext(ctx, channelID, options...)
return err
}
}
// Schema returns schema prefix supported by this client
func (s *Slack) Schema() string {
return "slack"
}
func (s *Slack) String() string {
return "slack notifications destination"
}
// parses "slack:" in a manner "mailto:" URL is parsed url and returns channelID and attachment.
// if channelID is channel name and not ID (starting with C for channel and with U for user),
// then it will be resolved to ID.
func (s *Slack) parseDestination(destination string) (string, slack.Attachment, error) {
// parse URL
u, err := url.Parse(destination)
if err != nil {
return "", slack.Attachment{}, err
}
if u.Scheme != "slack" {
return "", slack.Attachment{}, fmt.Errorf("unsupported scheme %s, should be slack", u.Scheme)
}
channelID := u.Opaque
if !strings.HasPrefix(u.Opaque, "C") && !strings.HasPrefix(u.Opaque, "U") {
channelID, err = s.findChannelIDByName(u.Opaque)
if err != nil {
return "", slack.Attachment{}, fmt.Errorf("problem retrieving channel ID for #%s: %w", u.Opaque, err)
}
}
return channelID,
slack.Attachment{
Title: u.Query().Get("title"),
TitleLink: u.Query().Get("titleLink"),
Text: u.Query().Get("attachmentText"),
}, nil
}
func (s *Slack) findChannelIDByName(name string) (string, error) {
params := slack.GetConversationsParameters{}
for {
channels, next, err := s.client.GetConversations(&params)
if err != nil {
return "", err
}
for _, channel := range channels {
if channel.Name == name {
return channel.ID, nil
}
}
if next == "" {
break
}
params.Cursor = next
}
return "", errors.New("no such channel")
}
+478
View File
@@ -0,0 +1,478 @@
package notify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
neturl "net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
log "github.com/go-pkgz/lgr"
"github.com/go-pkgz/repeater"
"github.com/microcosm-cc/bluemonday"
"golang.org/x/net/html"
)
// TelegramParams contain settings for telegram notifications
type TelegramParams struct {
Token string // token for telegram bot API interactions
Timeout time.Duration // http client timeout
ErrorMsg, SuccessMsg string // messages for successful and unsuccessful subscription requests to bot
apiPrefix string // changed only in tests
}
// Telegram notifications client
type Telegram struct {
TelegramParams
// Identifier of the first update to be requested.
// Should be equal to LastSeenUpdateID + 1
// See https://core.telegram.org/bots/api#getupdates
updateOffset int
apiPollInterval time.Duration // interval to check updates from Telegram API and answer to users
expiredCleanupInterval time.Duration // interval to check and clean up expired notification requests
username string // bot username
run int32 // non-zero if Run goroutine has started
requests struct {
sync.RWMutex
data map[string]tgAuthRequest
}
}
// telegramMsg is used to send message trough Telegram bot API
type telegramMsg struct {
Text string `json:"text"`
ParseMode string `json:"parse_mode,omitempty"`
}
type tgAuthRequest struct {
confirmed bool // whether login request has been confirmed and user info set
expires time.Time
telegramID string
user string
site string
}
// TelegramBotInfo structure contains information about telegram bot, which is used from whole telegram API response
type TelegramBotInfo struct {
Username string `json:"username"`
}
const telegramTimeOut = 5000 * time.Millisecond
const telegramAPIPrefix = "https://api.telegram.org/bot"
const tgPollInterval = time.Second * 5
const tgCleanupInterval = time.Minute * 5
// NewTelegram makes telegram bot for notifications
func NewTelegram(params TelegramParams) (*Telegram, error) {
res := Telegram{TelegramParams: params}
if res.apiPrefix == "" {
res.apiPrefix = telegramAPIPrefix
}
if res.Timeout == 0 {
res.Timeout = telegramTimeOut
}
if res.SuccessMsg == "" {
res.SuccessMsg = "✅ You have successfully authenticated, check the web!"
}
res.apiPollInterval = tgPollInterval
res.expiredCleanupInterval = tgCleanupInterval
log.Printf("[DEBUG] create new telegram notifier for api=%s, timeout=%s", res.apiPrefix, res.Timeout)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
botInfo, err := res.botInfo(ctx)
if err != nil {
return nil, fmt.Errorf("can't retrieve bot info from Telegram API: %w", err)
}
res.username = botInfo.Username
res.requests.data = make(map[string]tgAuthRequest)
return &res, nil
}
// Send sends provided message to Telegram chat, with `parseMode` parsed from destination field (Markdown by default)
// with "telegram:" schema same way "mailto:" schema is constructed.
//
// Example:
//
// - telegram:channel
// - telegram:chatID // chatID is a number, like `-1001480738202`
// - telegram:channel?parseMode=HTML
func (t *Telegram) Send(ctx context.Context, destination, text string) error {
chatID, parseMode, err := t.parseDestination(destination)
if err != nil {
return fmt.Errorf("problem parsing destination: %w", err)
}
body := telegramMsg{Text: text, ParseMode: parseMode}
b, err := json.Marshal(body)
if err != nil {
return err
}
url := fmt.Sprintf("sendMessage?chat_id=%s&disable_web_page_preview=true", chatID)
return t.Request(ctx, url, b, &struct{}{})
}
// TelegramSupportedHTML returns HTML with only tags allowed in Telegram HTML message payload, also trims ending newlines
//
// https://core.telegram.org/bots/api#html-style
func TelegramSupportedHTML(htmlText string) string {
adjustedHTMLText := adjustHTMLTags(htmlText)
p := bluemonday.NewPolicy()
p.AllowElements("b", "strong", "i", "em", "u", "ins", "s", "strike", "del", "a", "code", "pre")
p.AllowAttrs("href").OnElements("a")
p.AllowAttrs("class").OnElements("code")
return strings.TrimRight(p.Sanitize(adjustedHTMLText), "\n")
}
// EscapeTelegramText returns text sanitized of symbols not allowed inside other HTML tags in Telegram HTML message payload
//
// https://core.telegram.org/bots/api#html-style
func EscapeTelegramText(text string) string {
// order is important
text = strings.ReplaceAll(text, "&", "&amp;")
text = strings.ReplaceAll(text, "<", "&lt;")
text = strings.ReplaceAll(text, ">", "&gt;")
return text
}
// telegram not allow h1-h6 tags
// replace these tags with a combination of <b> and <i> for visual distinction
func adjustHTMLTags(htmlText string) string {
buff := strings.Builder{}
tokenizer := html.NewTokenizer(strings.NewReader(htmlText))
for {
if tokenizer.Next() == html.ErrorToken {
return buff.String()
}
token := tokenizer.Token()
switch token.Type {
case html.StartTagToken, html.EndTagToken:
switch token.Data {
case "h1", "h2", "h3":
if token.Type == html.StartTagToken {
buff.WriteString("<b>")
}
if token.Type == html.EndTagToken {
buff.WriteString("</b>")
}
case "h4", "h5", "h6":
if token.Type == html.StartTagToken {
buff.WriteString("<i><b>")
}
if token.Type == html.EndTagToken {
buff.WriteString("</b></i>")
}
default:
buff.WriteString(token.String())
}
default:
buff.WriteString(token.String())
}
}
}
// TelegramUpdate contains update information, which is used from whole telegram API response
type TelegramUpdate struct {
Result []struct {
UpdateID int `json:"update_id"`
Message struct {
Chat struct {
ID int `json:"id"`
Name string `json:"first_name"`
Type string `json:"type"`
} `json:"chat"`
Text string `json:"text"`
} `json:"message"`
} `json:"result"`
}
// GetBotUsername returns bot username
func (t *Telegram) GetBotUsername() string {
return t.username
}
// AddToken adds token
func (t *Telegram) AddToken(token, user, site string, expires time.Time) {
t.requests.Lock()
t.requests.data[token] = tgAuthRequest{
expires: expires,
user: user,
site: site,
}
t.requests.Unlock()
}
// CheckToken verifies incoming token, returns the user address if it's confirmed and empty string otherwise
func (t *Telegram) CheckToken(token, user string) (telegram, site string, err error) {
t.requests.RLock()
authRequest, ok := t.requests.data[token]
t.requests.RUnlock()
if !ok {
return "", "", errors.New("request is not found")
}
if time.Now().After(authRequest.expires) {
t.requests.Lock()
delete(t.requests.data, token)
t.requests.Unlock()
return "", "", errors.New("request expired")
}
if !authRequest.confirmed {
return "", "", errors.New("request is not verified yet")
}
if authRequest.user != user {
return "", "", errors.New("user does not match original requester")
}
// Delete request
t.requests.Lock()
delete(t.requests.data, token)
t.requests.Unlock()
return authRequest.telegramID, authRequest.site, nil
}
// Run starts processing login requests sent in Telegram, required for user notifications to work
// Blocks caller
func (t *Telegram) Run(ctx context.Context) {
atomic.AddInt32(&t.run, 1)
processUpdatedTicker := time.NewTicker(t.apiPollInterval)
cleanupTicker := time.NewTicker(t.expiredCleanupInterval)
for {
select {
case <-ctx.Done():
processUpdatedTicker.Stop()
cleanupTicker.Stop()
atomic.AddInt32(&t.run, -1)
return
case <-processUpdatedTicker.C:
updates, err := t.getUpdates(ctx)
if err != nil {
log.Printf("[WARN] Error while getting telegram updates: %v", err)
continue
}
t.processUpdates(ctx, updates)
case <-cleanupTicker.C:
now := time.Now()
t.requests.Lock()
for key, req := range t.requests.data {
if now.After(req.expires) {
delete(t.requests.data, key)
}
}
t.requests.Unlock()
}
}
}
// ProcessUpdate is alternative to Run, it processes provided plain text update from Telegram
// so that caller could get updates and send it not only there but to multiple sources
func (t *Telegram) ProcessUpdate(ctx context.Context, textUpdate string) error {
if atomic.LoadInt32(&t.run) != 0 {
return errors.New("the Run goroutine should not be used with ProcessUpdate")
}
defer func() {
// as Run goroutine is not running, clean up old requests on each update
// even if we hit json decode error
now := time.Now()
t.requests.Lock()
for key, req := range t.requests.data {
if now.After(req.expires) {
delete(t.requests.data, key)
}
}
t.requests.Unlock()
}()
var updates TelegramUpdate
if err := json.Unmarshal([]byte(textUpdate), &updates); err != nil {
return fmt.Errorf("failed to decode provided telegram update: %w", err)
}
t.processUpdates(ctx, &updates)
return nil
}
// Schema returns schema prefix supported by this client
func (t *Telegram) Schema() string {
return "telegram"
}
func (t *Telegram) String() string {
return "telegram notifications destination"
}
// parses "telegram:" in a manner "mailto:" URL is parsed url and returns chatID and parseMode.
// if chatID is channel name and not a numerical ID, `@` will be added to it
func (t *Telegram) parseDestination(destination string) (chatID, parseMode string, err error) {
// parse URL
u, err := neturl.Parse(destination)
if err != nil {
return "", "", err
}
if u.Scheme != "telegram" {
return "", "", fmt.Errorf("unsupported scheme %s, should be telegram", u.Scheme)
}
chatID = u.Opaque
if _, err := strconv.ParseInt(chatID, 10, 64); err != nil {
chatID = "@" + chatID // if chatID not a number enforce @ prefix
}
parseMode = "Markdown"
if u.Query().Get("parseMode") != "" {
parseMode = u.Query().Get("parseMode")
}
return chatID, parseMode, nil
}
// getUpdates fetches incoming updates
func (t *Telegram) getUpdates(ctx context.Context) (*TelegramUpdate, error) {
url := `getUpdates?allowed_updates=["message"]`
if t.updateOffset != 0 {
url += fmt.Sprintf("&offset=%d", t.updateOffset)
}
var result TelegramUpdate
err := t.Request(ctx, url, nil, &result)
if err != nil {
return nil, fmt.Errorf("failed to fetch updates: %w", err)
}
for _, u := range result.Result {
if u.UpdateID >= t.updateOffset {
t.updateOffset = u.UpdateID + 1
}
}
return &result, nil
}
// processUpdates processes a batch of updates from telegram servers
func (t *Telegram) processUpdates(ctx context.Context, updates *TelegramUpdate) {
for _, update := range updates.Result {
if update.Message.Chat.Type != "private" {
continue
}
if !strings.HasPrefix(update.Message.Text, "/start ") {
continue
}
token := strings.TrimPrefix(update.Message.Text, "/start ")
t.requests.RLock()
authRequest, ok := t.requests.data[token]
if !ok { // No such token
t.requests.RUnlock()
if t.ErrorMsg != "" {
if err := t.sendText(ctx, update.Message.Chat.ID, t.ErrorMsg); err != nil {
log.Printf("[WARN] failed to notify telegram peer: %v", err)
}
}
continue
}
t.requests.RUnlock()
authRequest.confirmed = true
authRequest.telegramID = strconv.Itoa(update.Message.Chat.ID)
t.requests.Lock()
t.requests.data[token] = authRequest
t.requests.Unlock()
if err := t.sendText(ctx, update.Message.Chat.ID, t.SuccessMsg); err != nil {
log.Printf("[ERROR] failed to notify telegram peer: %v", err)
}
}
}
// sendText sends a plain text message to telegram peer
func (t *Telegram) sendText(ctx context.Context, recipientID int, msg string) error {
url := fmt.Sprintf("sendMessage?chat_id=%d&text=%s", recipientID, neturl.PathEscape(msg))
return t.Request(ctx, url, nil, &struct{}{})
}
// botInfo returns info about configured bot
func (t *Telegram) botInfo(ctx context.Context) (*TelegramBotInfo, error) {
var resp = struct {
Result *TelegramBotInfo `json:"result"`
}{}
err := t.Request(ctx, "getMe", nil, &resp)
if err != nil {
return nil, err
}
if resp.Result == nil {
return nil, errors.New("received empty result")
}
return resp.Result, nil
}
// Request makes a request to the Telegram API and return the result
func (t *Telegram) Request(ctx context.Context, method string, b []byte, data interface{}) error {
return repeater.NewDefault(3, time.Millisecond*250).Do(ctx, func() error {
url := fmt.Sprintf("%s%s/%s", t.apiPrefix, t.Token, method)
var req *http.Request
var err error
if b == nil {
req, err = http.NewRequestWithContext(ctx, "GET", url, http.NoBody)
} else {
req, err = http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json; charset=utf-8")
}
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
client := http.Client{Timeout: t.Timeout}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return t.parseError(resp.Body, resp.StatusCode)
}
if err = json.NewDecoder(resp.Body).Decode(data); err != nil {
return fmt.Errorf("failed to decode json response: %w", err)
}
return nil
})
}
func (t *Telegram) parseError(r io.Reader, statusCode int) error {
tgErr := struct {
Description string `json:"description"`
}{}
if err := json.NewDecoder(r).Decode(&tgErr); err != nil {
return fmt.Errorf("unexpected telegram API status code %d", statusCode)
}
return fmt.Errorf("unexpected telegram API status code %d, error: %q", statusCode, tgErr.Description)
}
+96
View File
@@ -0,0 +1,96 @@
package notify
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const webhookTimeOut = 5000 * time.Millisecond
// WebhookParams contain settings for webhook notifications
type WebhookParams struct {
Timeout time.Duration
Headers []string // headers in format "header:value"
}
// Webhook notifications client
type Webhook struct {
WebhookParams
webhookClient webhookClient
}
// webhookClient defines an interface of client for webhook
type webhookClient interface {
Do(*http.Request) (*http.Response, error)
}
// NewWebhook makes Webhook
func NewWebhook(params WebhookParams) *Webhook {
res := &Webhook{WebhookParams: params}
if res.Timeout == 0 {
res.Timeout = webhookTimeOut
}
res.webhookClient = &http.Client{Timeout: res.Timeout}
return res
}
// Send sends Webhook notification. Destination field is expected to have http:// or https:// schema.
//
// Example:
//
// - https://example.com/webhook
func (wh *Webhook) Send(ctx context.Context, destination, text string) error {
payload := bytes.NewBufferString(text)
httpReq, err := http.NewRequestWithContext(ctx, "POST", destination, payload)
if err != nil {
return fmt.Errorf("unable to create webhook request: %w", err)
}
for _, h := range wh.Headers {
elems := strings.Split(h, ":")
if len(elems) != 2 {
continue
}
httpReq.Header.Set(strings.TrimSpace(elems[0]), strings.TrimSpace(elems[1]))
}
resp, err := wh.webhookClient.Do(httpReq)
if err != nil {
return fmt.Errorf("webhook request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errMsg := fmt.Sprintf("webhook request failed with non-OK status code: %d", resp.StatusCode)
respBody, e := io.ReadAll(resp.Body)
if e != nil {
return errors.New(errMsg)
}
return fmt.Errorf("%s, body: %s", errMsg, respBody)
}
return nil
}
// Schema returns schema prefix supported by this client
func (wh *Webhook) Schema() string {
return "http"
}
// String describes the webhook instance
func (wh *Webhook) String() string {
str := fmt.Sprintf("webhook notification with timeout %s", wh.Timeout)
if wh.Headers != nil {
str += fmt.Sprintf(" and headers %v", wh.Headers)
}
return str
}
+5 -1
View File
@@ -44,6 +44,10 @@ func (api *Client) ListEventAuthorizationsContext(ctx context.Context, eventCont
}
func (api *Client) UninstallApp(clientID, clientSecret string) error {
return api.UninstallAppContext(context.Background(), clientID, clientSecret)
}
func (api *Client) UninstallAppContext(ctx context.Context, clientID, clientSecret string) error {
values := url.Values{
"client_id": {clientID},
"client_secret": {clientSecret},
@@ -51,7 +55,7 @@ func (api *Client) UninstallApp(clientID, clientSecret string) error {
response := SlackResponse{}
err := api.getMethod(context.Background(), "apps.uninstall", api.token, values, &response)
err := api.getMethod(ctx, "apps.uninstall", api.token, values, &response)
if err != nil {
return err
}
+10
View File
@@ -39,6 +39,16 @@ type AuditEntry struct {
UA string `json:"ua"`
IPAddress string `json:"ip_address"`
} `json:"context"`
Details struct {
NewValue interface{} `json:"new_value"`
PreviousValue interface{} `json:"previous_value"`
MobileOnly bool `json:"mobile_only"`
WebOnly bool `json:"web_only"`
NonSSOOnly bool `json:"non_sso_only"`
ExportType string `json:"export_type"`
ExportStart string `json:"export_start_ts"`
ExportEnd string `json:"export_end_ts"`
} `json:"details"`
}
type AuditUser struct {
+6
View File
@@ -167,6 +167,12 @@ func (s *ButtonBlockElement) WithStyle(style Style) *ButtonBlockElement {
return s
}
// WithConfirm adds a confirmation dialogue to the button object and returns the modified ButtonBlockElement
func (s *ButtonBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *ButtonBlockElement {
s.Confirm = confirm
return s
}
// NewButtonBlockElement returns an instance of a new button element to be used within a block
func NewButtonBlockElement(actionID, value string, text *TextBlockObject) *ButtonBlockElement {
return &ButtonBlockElement{
+2 -1
View File
@@ -187,8 +187,9 @@ func (s ConfirmationBlockObject) validateType() MessageObjectType {
}
// WithStyle add styling to confirmation object
func (s *ConfirmationBlockObject) WithStyle(style Style) {
func (s *ConfirmationBlockObject) WithStyle(style Style) *ConfirmationBlockObject {
s.Style = style
return s
}
// NewConfirmationBlockObject returns an instance of a new Confirmation Block Object
+29 -31
View File
@@ -86,12 +86,7 @@ func NewPostMessageParameters() PostMessageParameters {
// DeleteMessage deletes a message in a channel
func (api *Client) DeleteMessage(channel, messageTimestamp string) (string, string, error) {
respChannel, respTimestamp, _, err := api.SendMessageContext(
context.Background(),
channel,
MsgOptionDelete(messageTimestamp),
)
return respChannel, respTimestamp, err
return api.DeleteMessageContext(context.Background(), channel, messageTimestamp)
}
// DeleteMessageContext deletes a message in a channel with a custom context
@@ -108,8 +103,15 @@ func (api *Client) DeleteMessageContext(ctx context.Context, channel, messageTim
// Message is escaped by default according to https://api.slack.com/docs/formatting
// Use http://davestevens.github.io/slack-message-builder/ to help crafting your message.
func (api *Client) ScheduleMessage(channelID, postAt string, options ...MsgOption) (string, string, error) {
return api.ScheduleMessageContext(context.Background(), channelID, postAt, options...)
}
// ScheduleMessageContext sends a message to a channel with a custom context
//
// For more details, see ScheduleMessage documentation.
func (api *Client) ScheduleMessageContext(ctx context.Context, channelID, postAt string, options ...MsgOption) (string, string, error) {
respChannel, respTimestamp, _, err := api.SendMessageContext(
context.Background(),
ctx,
channelID,
MsgOptionSchedule(postAt),
MsgOptionCompose(options...),
@@ -121,13 +123,7 @@ func (api *Client) ScheduleMessage(channelID, postAt string, options ...MsgOptio
// Message is escaped by default according to https://api.slack.com/docs/formatting
// Use http://davestevens.github.io/slack-message-builder/ to help crafting your message.
func (api *Client) PostMessage(channelID string, options ...MsgOption) (string, string, error) {
respChannel, respTimestamp, _, err := api.SendMessageContext(
context.Background(),
channelID,
MsgOptionPost(),
MsgOptionCompose(options...),
)
return respChannel, respTimestamp, err
return api.PostMessageContext(context.Background(), channelID, options...)
}
// PostMessageContext sends a message to a channel with a custom context
@@ -146,12 +142,7 @@ func (api *Client) PostMessageContext(ctx context.Context, channelID string, opt
// Message is escaped by default according to https://api.slack.com/docs/formatting
// Use http://davestevens.github.io/slack-message-builder/ to help crafting your message.
func (api *Client) PostEphemeral(channelID, userID string, options ...MsgOption) (string, error) {
return api.PostEphemeralContext(
context.Background(),
channelID,
userID,
options...,
)
return api.PostEphemeralContext(context.Background(), channelID, userID, options...)
}
// PostEphemeralContext sends an ephemeal message to a user in a channel with a custom context
@@ -168,12 +159,7 @@ func (api *Client) PostEphemeralContext(ctx context.Context, channelID, userID s
// UpdateMessage updates a message in a channel
func (api *Client) UpdateMessage(channelID, timestamp string, options ...MsgOption) (string, string, string, error) {
return api.SendMessageContext(
context.Background(),
channelID,
MsgOptionUpdate(timestamp),
MsgOptionCompose(options...),
)
return api.UpdateMessageContext(context.Background(), channelID, timestamp, options...)
}
// UpdateMessageContext updates a message in a channel
@@ -225,7 +211,7 @@ func (api *Client) SendMessageContext(ctx context.Context, channelID string, opt
response chatResponseFull
)
if req, parser, err = buildSender(api.endpoint, options...).BuildRequest(api.token, channelID); err != nil {
if req, parser, err = buildSender(api.endpoint, options...).BuildRequestContext(ctx, api.token, channelID); err != nil {
return "", "", "", err
}
@@ -306,6 +292,10 @@ type sendConfig struct {
}
func (t sendConfig) BuildRequest(token, channelID string) (req *http.Request, _ func(*chatResponseFull) responseParser, err error) {
return t.BuildRequestContext(context.Background(), token, channelID)
}
func (t sendConfig) BuildRequestContext(ctx context.Context, token, channelID string) (req *http.Request, _ func(*chatResponseFull) responseParser, err error) {
if t, err = applyMsgOptions(token, channelID, t.apiurl, t.options...); err != nil {
return nil, nil, err
}
@@ -320,9 +310,9 @@ func (t sendConfig) BuildRequest(token, channelID string) (req *http.Request, _
responseType: t.responseType,
replaceOriginal: t.replaceOriginal,
deleteOriginal: t.deleteOriginal,
}.BuildRequest()
}.BuildRequestContext(ctx)
default:
return formSender{endpoint: t.endpoint, values: t.values}.BuildRequest()
return formSender{endpoint: t.endpoint, values: t.values}.BuildRequestContext(ctx)
}
}
@@ -332,7 +322,11 @@ type formSender struct {
}
func (t formSender) BuildRequest() (*http.Request, func(*chatResponseFull) responseParser, error) {
req, err := formReq(t.endpoint, t.values)
return t.BuildRequestContext(context.Background())
}
func (t formSender) BuildRequestContext(ctx context.Context) (*http.Request, func(*chatResponseFull) responseParser, error) {
req, err := formReq(ctx, t.endpoint, t.values)
return req, func(resp *chatResponseFull) responseParser {
return newJSONParser(resp)
}, err
@@ -349,7 +343,11 @@ type responseURLSender struct {
}
func (t responseURLSender) BuildRequest() (*http.Request, func(*chatResponseFull) responseParser, error) {
req, err := jsonReq(t.endpoint, Msg{
return t.BuildRequestContext(context.Background())
}
func (t responseURLSender) BuildRequestContext(ctx context.Context) (*http.Request, func(*chatResponseFull) responseParser, error) {
req, err := jsonReq(ctx, t.endpoint, Msg{
Text: t.values.Get("text"),
Timestamp: t.values.Get("ts"),
Attachments: t.attachments,
+44 -35
View File
@@ -202,7 +202,14 @@ func (api *Client) GetFileInfoContext(ctx context.Context, fileID string, count,
// GetFile retreives a given file from its private download URL
func (api *Client) GetFile(downloadURL string, writer io.Writer) error {
return downloadFile(api.httpclient, api.token, downloadURL, writer, api)
return api.GetFileContext(context.Background(), downloadURL, writer)
}
// GetFileContext retreives a given file from its private download URL with a custom context
//
// For more details, see GetFile documentation.
func (api *Client) GetFileContext(ctx context.Context, downloadURL string, writer io.Writer) error {
return downloadFile(ctx, api.httpclient, api.token, downloadURL, writer, api)
}
// GetFiles retrieves all files according to the parameters given
@@ -210,40 +217,6 @@ func (api *Client) GetFiles(params GetFilesParameters) ([]File, *Paging, error)
return api.GetFilesContext(context.Background(), params)
}
// ListFiles retrieves all files according to the parameters given. Uses cursor based pagination.
func (api *Client) ListFiles(params ListFilesParameters) ([]File, *ListFilesParameters, error) {
return api.ListFilesContext(context.Background(), params)
}
// ListFilesContext retrieves all files according to the parameters given with a custom context. Uses cursor based pagination.
func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParameters) ([]File, *ListFilesParameters, error) {
values := url.Values{
"token": {api.token},
}
if params.User != DEFAULT_FILES_USER {
values.Add("user", params.User)
}
if params.Channel != DEFAULT_FILES_CHANNEL {
values.Add("channel", params.Channel)
}
if params.Limit != DEFAULT_FILES_COUNT {
values.Add("limit", strconv.Itoa(params.Limit))
}
if params.Cursor != "" {
values.Add("cursor", params.Cursor)
}
response, err := api.fileRequest(ctx, "files.list", values)
if err != nil {
return nil, nil, err
}
params.Cursor = response.Metadata.Cursor
return response.Files, &params, nil
}
// GetFilesContext retrieves all files according to the parameters given with a custom context
func (api *Client) GetFilesContext(ctx context.Context, params GetFilesParameters) ([]File, *Paging, error) {
values := url.Values{
@@ -281,6 +254,42 @@ func (api *Client) GetFilesContext(ctx context.Context, params GetFilesParameter
return response.Files, &response.Paging, nil
}
// ListFiles retrieves all files according to the parameters given. Uses cursor based pagination.
func (api *Client) ListFiles(params ListFilesParameters) ([]File, *ListFilesParameters, error) {
return api.ListFilesContext(context.Background(), params)
}
// ListFilesContext retrieves all files according to the parameters given with a custom context.
//
// For more details, see ListFiles documentation.
func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParameters) ([]File, *ListFilesParameters, error) {
values := url.Values{
"token": {api.token},
}
if params.User != DEFAULT_FILES_USER {
values.Add("user", params.User)
}
if params.Channel != DEFAULT_FILES_CHANNEL {
values.Add("channel", params.Channel)
}
if params.Limit != DEFAULT_FILES_COUNT {
values.Add("limit", strconv.Itoa(params.Limit))
}
if params.Cursor != "" {
values.Add("cursor", params.Cursor)
}
response, err := api.fileRequest(ctx, "files.list", values)
if err != nil {
return nil, nil, err
}
params.Cursor = response.Metadata.Cursor
return response.Files, &params, nil
}
// UploadFile uploads a file
func (api *Client) UploadFile(params FileUploadParameters) (file *File, err error) {
return api.UploadFileContext(context.Background(), params)
+5 -1
View File
@@ -321,9 +321,13 @@ type UserPrefs struct {
}
func (api *Client) GetUserPrefs() (*UserPrefsCarrier, error) {
return api.GetUserPrefsContext(context.Background())
}
func (api *Client) GetUserPrefsContext(ctx context.Context) (*UserPrefsCarrier, error) {
response := UserPrefsCarrier{}
err := api.getMethod(context.Background(), "users.prefs.get", api.token, url.Values{}, &response)
err := api.getMethod(ctx, "users.prefs.get", api.token, url.Values{}, &response)
if err != nil {
return nil, err
}
+32 -22
View File
@@ -28,32 +28,34 @@ const (
InteractionTypeViewSubmission = InteractionType("view_submission")
InteractionTypeViewClosed = InteractionType("view_closed")
InteractionTypeShortcut = InteractionType("shortcut")
InteractionTypeWorkflowStepEdit = InteractionType("workflow_step_edit")
)
// InteractionCallback is sent from slack when a user interactions with a button or dialog.
type InteractionCallback struct {
Type InteractionType `json:"type"`
Token string `json:"token"`
CallbackID string `json:"callback_id"`
ResponseURL string `json:"response_url"`
TriggerID string `json:"trigger_id"`
ActionTs string `json:"action_ts"`
Team Team `json:"team"`
Channel Channel `json:"channel"`
User User `json:"user"`
OriginalMessage Message `json:"original_message"`
Message Message `json:"message"`
Name string `json:"name"`
Value string `json:"value"`
MessageTs string `json:"message_ts"`
AttachmentID string `json:"attachment_id"`
ActionCallback ActionCallbacks `json:"actions"`
View View `json:"view"`
ActionID string `json:"action_id"`
APIAppID string `json:"api_app_id"`
BlockID string `json:"block_id"`
Container Container `json:"container"`
Enterprise Enterprise `json:"enterprise"`
Type InteractionType `json:"type"`
Token string `json:"token"`
CallbackID string `json:"callback_id"`
ResponseURL string `json:"response_url"`
TriggerID string `json:"trigger_id"`
ActionTs string `json:"action_ts"`
Team Team `json:"team"`
Channel Channel `json:"channel"`
User User `json:"user"`
OriginalMessage Message `json:"original_message"`
Message Message `json:"message"`
Name string `json:"name"`
Value string `json:"value"`
MessageTs string `json:"message_ts"`
AttachmentID string `json:"attachment_id"`
ActionCallback ActionCallbacks `json:"actions"`
View View `json:"view"`
ActionID string `json:"action_id"`
APIAppID string `json:"api_app_id"`
BlockID string `json:"block_id"`
Container Container `json:"container"`
Enterprise Enterprise `json:"enterprise"`
WorkflowStep InteractionWorkflowStep `json:"workflow_step"`
DialogSubmissionCallback
ViewSubmissionCallback
ViewClosedCallback
@@ -134,6 +136,14 @@ type Enterprise struct {
Name string `json:"name"`
}
type InteractionWorkflowStep struct {
WorkflowStepEditID string `json:"workflow_step_edit_id,omitempty"`
WorkflowID string `json:"workflow_id"`
StepID string `json:"step_id"`
Inputs *WorkflowStepInputs `json:"inputs,omitempty"`
Outputs *[]WorkflowStepOutput `json:"outputs,omitempty"`
}
// ActionCallback is a convenience struct defined to allow dynamic unmarshalling of
// the "actions" value in Slack's JSON response, which varies depending on block type
type ActionCallbacks struct {
+10 -10
View File
@@ -1,6 +1,6 @@
package slack
import "sync"
import "sync/atomic"
// IDGenerator provides an interface for generating integer ID values.
type IDGenerator interface {
@@ -11,20 +11,20 @@ type IDGenerator interface {
// concurrent use by multiple goroutines.
func NewSafeID(startID int) IDGenerator {
return &safeID{
nextID: startID,
mutex: &sync.Mutex{},
nextID: int64(startID),
}
}
type safeID struct {
nextID int
mutex *sync.Mutex
nextID int64
}
// make sure safeID implements the IDGenerator interface.
var _ IDGenerator = (*safeID)(nil)
// Next implements IDGenerator.Next.
func (s *safeID) Next() int {
s.mutex.Lock()
defer s.mutex.Unlock()
id := s.nextID
s.nextID++
return id
id := atomic.AddInt64(&s.nextID, 1)
return int(id)
}
+11 -15
View File
@@ -66,29 +66,27 @@ func (e *RateLimitedError) Retryable() bool {
}
func fileUploadReq(ctx context.Context, path string, values url.Values, r io.Reader) (*http.Request, error) {
req, err := http.NewRequest("POST", path, r)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, path, r)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.URL.RawQuery = (values).Encode()
req.URL.RawQuery = values.Encode()
return req, nil
}
func downloadFile(client httpClient, token string, downloadURL string, writer io.Writer, d Debug) error {
func downloadFile(ctx context.Context, client httpClient, token string, downloadURL string, writer io.Writer, d Debug) error {
if downloadURL == "" {
return fmt.Errorf("received empty download URL")
}
req, err := http.NewRequest("GET", downloadURL, &bytes.Buffer{})
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, &bytes.Buffer{})
if err != nil {
return err
}
var bearer = "Bearer " + token
req.Header.Add("Authorization", bearer)
req.WithContext(context.Background())
resp, err := client.Do(req)
if err != nil {
@@ -107,8 +105,8 @@ func downloadFile(client httpClient, token string, downloadURL string, writer io
return err
}
func formReq(endpoint string, values url.Values) (req *http.Request, err error) {
if req, err = http.NewRequest("POST", endpoint, strings.NewReader(values.Encode())); err != nil {
func formReq(ctx context.Context, endpoint string, values url.Values) (req *http.Request, err error) {
if req, err = http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(values.Encode())); err != nil {
return nil, err
}
@@ -116,13 +114,13 @@ func formReq(endpoint string, values url.Values) (req *http.Request, err error)
return req, nil
}
func jsonReq(endpoint string, body interface{}) (req *http.Request, err error) {
func jsonReq(ctx context.Context, endpoint string, body interface{}) (req *http.Request, err error) {
buffer := bytes.NewBuffer([]byte{})
if err = json.NewEncoder(buffer).Encode(body); err != nil {
return nil, err
}
if req, err = http.NewRequest("POST", endpoint, buffer); err != nil {
if req, err = http.NewRequestWithContext(ctx, http.MethodPost, endpoint, buffer); err != nil {
return nil, err
}
@@ -184,7 +182,6 @@ func postWithMultipartResponse(ctx context.Context, client httpClient, path, nam
}
req.Header.Add("Content-Type", wr.FormDataContentType())
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
@@ -206,7 +203,6 @@ func postWithMultipartResponse(ctx context.Context, client httpClient, path, nam
}
func doPost(ctx context.Context, client httpClient, req *http.Request, parser responseParser, d Debug) error {
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
return err
@@ -224,7 +220,7 @@ func doPost(ctx context.Context, client httpClient, req *http.Request, parser re
// post JSON.
func postJSON(ctx context.Context, client httpClient, endpoint, token string, json []byte, intf interface{}, d Debug) error {
reqBody := bytes.NewBuffer(json)
req, err := http.NewRequest("POST", endpoint, reqBody)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reqBody)
if err != nil {
return err
}
@@ -237,7 +233,7 @@ func postJSON(ctx context.Context, client httpClient, endpoint, token string, js
// post a url encoded form.
func postForm(ctx context.Context, client httpClient, endpoint string, values url.Values, intf interface{}, d Debug) error {
reqBody := strings.NewReader(values.Encode())
req, err := http.NewRequest("POST", endpoint, reqBody)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reqBody)
if err != nil {
return err
}
@@ -246,7 +242,7 @@ func postForm(ctx context.Context, client httpClient, endpoint string, values ur
}
func getResource(ctx context.Context, client httpClient, endpoint, token string, values url.Values, intf interface{}, d Debug) error {
req, err := http.NewRequest("GET", endpoint, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
+32 -4
View File
@@ -52,10 +52,17 @@ func (api *Client) doReminders(ctx context.Context, path string, values url.Valu
//
// See https://api.slack.com/methods/reminders.list
func (api *Client) ListReminders() ([]*Reminder, error) {
return api.ListRemindersContext(context.Background())
}
// ListRemindersContext lists all the reminders created by or for the authenticated user with a custom context
//
// For more details, see ListReminders documentation.
func (api *Client) ListRemindersContext(ctx context.Context) ([]*Reminder, error) {
values := url.Values{
"token": {api.token},
}
return api.doReminders(context.Background(), "reminders.list", values)
return api.doReminders(ctx, "reminders.list", values)
}
// AddChannelReminder adds a reminder for a channel.
@@ -64,13 +71,20 @@ func (api *Client) ListReminders() ([]*Reminder, error) {
// reminders on a channel is currently undocumented but has been tested to
// work)
func (api *Client) AddChannelReminder(channelID, text, time string) (*Reminder, error) {
return api.AddChannelReminderContext(context.Background(), channelID, text, time)
}
// AddChannelReminderContext adds a reminder for a channel with a custom context
//
// For more details, see AddChannelReminder documentation.
func (api *Client) AddChannelReminderContext(ctx context.Context, channelID, text, time string) (*Reminder, error) {
values := url.Values{
"token": {api.token},
"text": {text},
"time": {time},
"channel": {channelID},
}
return api.doReminder(context.Background(), "reminders.add", values)
return api.doReminder(ctx, "reminders.add", values)
}
// AddUserReminder adds a reminder for a user.
@@ -79,25 +93,39 @@ func (api *Client) AddChannelReminder(channelID, text, time string) (*Reminder,
// reminders on a channel is currently undocumented but has been tested to
// work)
func (api *Client) AddUserReminder(userID, text, time string) (*Reminder, error) {
return api.AddUserReminderContext(context.Background(), userID, text, time)
}
// AddUserReminderContext adds a reminder for a user with a custom context
//
// For more details, see AddUserReminder documentation.
func (api *Client) AddUserReminderContext(ctx context.Context, userID, text, time string) (*Reminder, error) {
values := url.Values{
"token": {api.token},
"text": {text},
"time": {time},
"user": {userID},
}
return api.doReminder(context.Background(), "reminders.add", values)
return api.doReminder(ctx, "reminders.add", values)
}
// DeleteReminder deletes an existing reminder.
//
// See https://api.slack.com/methods/reminders.delete
func (api *Client) DeleteReminder(id string) error {
return api.DeleteReminderContext(context.Background(), id)
}
// DeleteReminderContext deletes an existing reminder with a custom context
//
// For more details, see DeleteReminder documentation.
func (api *Client) DeleteReminderContext(ctx context.Context, id string) error {
values := url.Values{
"token": {api.token},
"reminder": {id},
}
response := &SlackResponse{}
if err := api.postMethod(context.Background(), "reminders.delete", values, response); err != nil {
if err := api.postMethod(ctx, "reminders.delete", values, response); err != nil {
return err
}
return response.Err()
+4 -2
View File
@@ -50,10 +50,12 @@ func DetectChannelType(channelID string) ChannelType {
}
}
// initialize replacer only once (if needed)
var escapeReplacer = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
// EscapeMessage text
func EscapeMessage(message string) string {
replacer := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
return replacer.Replace(message)
return escapeReplacer.Replace(message)
}
// Retryable errors return true.
+1 -1
View File
@@ -98,7 +98,7 @@ func NewErrorsViewSubmissionResponse(errors map[string]string) *ViewSubmissionRe
type ModalViewRequest struct {
Type ViewType `json:"type"`
Title *TextBlockObject `json:"title"`
Title *TextBlockObject `json:"title,omitempty"`
Blocks Blocks `json:"blocks"`
Close *TextBlockObject `json:"close,omitempty"`
Submit *TextBlockObject `json:"submit,omitempty"`
+24
View File
@@ -1,7 +1,10 @@
package slack
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
@@ -31,3 +34,24 @@ func PostWebhookContext(ctx context.Context, url string, msg *WebhookMessage) er
func PostWebhookCustomHTTP(url string, httpClient *http.Client, msg *WebhookMessage) error {
return PostWebhookCustomHTTPContext(context.Background(), url, httpClient, msg)
}
func PostWebhookCustomHTTPContext(ctx context.Context, url string, httpClient *http.Client, msg *WebhookMessage) error {
raw, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("marshal failed: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("failed new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to post webhook: %w", err)
}
defer resp.Body.Close()
return checkStatusCode(resp, discard{})
}
-34
View File
@@ -1,34 +0,0 @@
//go:build !go1.13
// +build !go1.13
package slack
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
func PostWebhookCustomHTTPContext(ctx context.Context, url string, httpClient *http.Client, msg *WebhookMessage) error {
raw, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("marshal failed: %v", err)
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("failed new request: %v", err)
}
req = req.WithContext(ctx)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to post webhook: %v", err)
}
defer resp.Body.Close()
return checkStatusCode(resp, discard{})
}
-33
View File
@@ -1,33 +0,0 @@
//go:build go1.13
// +build go1.13
package slack
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
func PostWebhookCustomHTTPContext(ctx context.Context, url string, httpClient *http.Client, msg *WebhookMessage) error {
raw, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("marshal failed: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("failed new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to post webhook: %w", err)
}
defer resp.Body.Close()
return checkStatusCode(resp, discard{})
}
+3 -3
View File
@@ -9,11 +9,11 @@ import (
"reflect"
"time"
"github.com/slack-go/slack/internal/backoff"
"github.com/slack-go/slack/internal/misc"
"github.com/gorilla/websocket"
"github.com/slack-go/slack/internal/backoff"
"github.com/slack-go/slack/internal/errorsx"
"github.com/slack-go/slack/internal/misc"
"github.com/slack-go/slack/internal/timex"
)
+98
View File
@@ -0,0 +1,98 @@
package slack
import (
"context"
"encoding/json"
)
const VTWorkflowStep ViewType = "workflow_step"
type (
ConfigurationModalRequest struct {
ModalViewRequest
}
WorkflowStepCompleteResponse struct {
WorkflowStepEditID string `json:"workflow_step_edit_id"`
Inputs *WorkflowStepInputs `json:"inputs,omitempty"`
Outputs *[]WorkflowStepOutput `json:"outputs,omitempty"`
}
WorkflowStepInputElement struct {
Value string `json:"value"`
SkipVariableReplacement bool `json:"skip_variable_replacement"`
}
WorkflowStepInputs map[string]WorkflowStepInputElement
WorkflowStepOutput struct {
Name string `json:"name"`
Type string `json:"type"`
Label string `json:"label"`
}
)
func NewConfigurationModalRequest(blocks Blocks, privateMetaData string, externalID string) *ConfigurationModalRequest {
return &ConfigurationModalRequest{
ModalViewRequest{
Type: VTWorkflowStep,
Title: nil, // slack configuration modal must not have a title!
Blocks: blocks,
PrivateMetadata: privateMetaData,
ExternalID: externalID,
},
}
}
func (api *Client) SaveWorkflowStepConfiguration(workflowStepEditID string, inputs *WorkflowStepInputs, outputs *[]WorkflowStepOutput) error {
return api.SaveWorkflowStepConfigurationContext(context.Background(), workflowStepEditID, inputs, outputs)
}
func (api *Client) SaveWorkflowStepConfigurationContext(ctx context.Context, workflowStepEditID string, inputs *WorkflowStepInputs, outputs *[]WorkflowStepOutput) error {
// More information: https://api.slack.com/methods/workflows.updateStep
wscr := WorkflowStepCompleteResponse{
WorkflowStepEditID: workflowStepEditID,
Inputs: inputs,
Outputs: outputs,
}
endpoint := api.endpoint + "workflows.updateStep"
jsonData, err := json.Marshal(wscr)
if err != nil {
return err
}
response := &SlackResponse{}
if err := postJSON(ctx, api.httpclient, endpoint, api.token, jsonData, response, api); err != nil {
return err
}
if !response.Ok {
return response.Err()
}
return nil
}
func GetInitialOptionFromWorkflowStepInput(selection *SelectBlockElement, inputs *WorkflowStepInputs, options []*OptionBlockObject) (*OptionBlockObject, bool) {
if len(*inputs) == 0 {
return &OptionBlockObject{}, false
}
if len(options) == 0 {
return &OptionBlockObject{}, false
}
if val, ok := (*inputs)[selection.ActionID]; ok {
if val.SkipVariableReplacement {
return &OptionBlockObject{}, false
}
for _, option := range options {
if option.Value == val.Value {
return option, true
}
}
}
return &OptionBlockObject{}, false
}
+8 -2
View File
@@ -95,6 +95,9 @@ github.com/go-pkgz/auth/middleware
github.com/go-pkgz/auth/provider
github.com/go-pkgz/auth/provider/sender
github.com/go-pkgz/auth/token
# github.com/go-pkgz/email v0.2.1-0.20220428155644-a7e5d837d02f
## explicit; go 1.17
github.com/go-pkgz/email
# github.com/go-pkgz/expirable-cache v0.0.3
## explicit; go 1.14
github.com/go-pkgz/expirable-cache
@@ -109,6 +112,9 @@ github.com/go-pkgz/lcw/internal/cache
# github.com/go-pkgz/lgr v0.10.4
## explicit; go 1.15
github.com/go-pkgz/lgr
# github.com/go-pkgz/notify v0.0.0-20220429105545-bc5c80a54199
## explicit; go 1.17
github.com/go-pkgz/notify
# github.com/go-pkgz/repeater v1.1.3
## explicit; go 1.12
github.com/go-pkgz/repeater
@@ -205,7 +211,7 @@ github.com/russross/blackfriday/v2
github.com/skip2/go-qrcode
github.com/skip2/go-qrcode/bitset
github.com/skip2/go-qrcode/reedsolomon
# github.com/slack-go/slack v0.10.2
# github.com/slack-go/slack v0.10.3
## explicit; go 1.16
github.com/slack-go/slack
github.com/slack-go/slack/internal/backoff
@@ -290,7 +296,7 @@ golang.org/x/crypto/scrypt
## explicit; go 1.12
golang.org/x/image/draw
golang.org/x/image/math/f64
# golang.org/x/net v0.0.0-20220412020605-290c469a71a5
# golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4
## explicit; go 1.17
golang.org/x/net/context
golang.org/x/net/context/ctxhttp