├── .github ├── CODEOWNERS ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── ci.yml ├── .golangci.yml ├── LICENSE ├── README.md ├── email.go ├── email_test.go ├── go.mod ├── go.sum ├── interface.go ├── interface_test.go ├── slack.go ├── slack_test.go ├── telegram.go ├── telegram_test.go ├── webhook.go └── webhook_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in the repo. 2 | # Unless a later match takes precedence, @umputun will be requested for 3 | # review when someone opens a pull request. 4 | 5 | * @umputun 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [umputun] 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | open-pull-requests-limit: 0 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | tags: 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: set up go 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version: "1.23" 18 | id: go 19 | 20 | - name: checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: build and test 24 | run: | 25 | go get -v 26 | go test -race -timeout=60s -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov ./... 27 | go build -race 28 | env: 29 | TZ: "America/Chicago" 30 | 31 | - name: golangci-lint 32 | uses: golangci/golangci-lint-action@v7 33 | with: 34 | version: latest 35 | 36 | - name: submit coverage 37 | run: | 38 | go install github.com/mattn/goveralls@latest 39 | goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov 40 | env: 41 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - bodyclose 6 | - copyloopvar 7 | - dupl 8 | - gochecknoinits 9 | - gocognit 10 | - gocritic 11 | - gosec 12 | - govet 13 | - ineffassign 14 | - misspell 15 | - nakedret 16 | - nolintlint 17 | - prealloc 18 | - revive 19 | - staticcheck 20 | - testifylint 21 | - unconvert 22 | - unparam 23 | - unused 24 | settings: 25 | goconst: 26 | min-len: 2 27 | min-occurrences: 2 28 | revive: 29 | enable-all-rules: true 30 | rules: 31 | - name: unused-receiver 32 | disabled: true 33 | - name: line-length-limit 34 | disabled: true 35 | - name: add-constant 36 | disabled: true 37 | - name: cognitive-complexity 38 | disabled: true 39 | - name: function-length 40 | disabled: true 41 | - name: cyclomatic 42 | disabled: true 43 | - name: nested-structs 44 | disabled: true 45 | gocritic: 46 | disabled-checks: 47 | - hugeParam 48 | enabled-tags: 49 | - performance 50 | - style 51 | - experimental 52 | govet: 53 | enable: 54 | - shadow 55 | lll: 56 | line-length: 140 57 | misspell: 58 | locale: US 59 | formatters: 60 | enable: 61 | - gofmt 62 | - goimports 63 | exclusions: 64 | generated: lax 65 | paths: 66 | - third_party$ 67 | - builtin$ 68 | - examples$ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Umputun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notify 2 | 3 | [![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) 4 | 5 | This library provides ability to send notifications using multiple services: 6 | 7 | - Email 8 | - Telegram 9 | - Slack 10 | - Webhook 11 | 12 | ## Install 13 | 14 | `go get -u github.com/go-pkgz/notify` 15 | 16 | ## Usage 17 | 18 | All supported notification methods could adhere to the following interface. Example on how to use it: 19 | 20 | ```go 21 | package main 22 | 23 | import ( 24 | "context" 25 | "fmt" 26 | 27 | "github.com/go-pkgz/notify" 28 | ) 29 | 30 | func main() { 31 | // create notifiers 32 | notifiers := []notify.Notifier{ 33 | notify.NewWebhook(notify.WebhookParams{}), 34 | notify.NewEmail(notify.SMTPParams{}), 35 | notify.NewSlack("token"), 36 | } 37 | tg, err := notify.NewTelegram(notify.TelegramParams{Token: "token"}) 38 | if err == nil { 39 | notifiers = append(notifiers, tg) 40 | } 41 | err = notify.Send(context.Background(), notifiers, "https://example.com/webhook", "Hello, world!") 42 | if err != nil { 43 | fmt.Printf("Sent message error: %s", err)) 44 | } 45 | } 46 | ``` 47 | 48 | ### Email 49 | 50 | `mailto:` [scheme](https://datatracker.ietf.org/doc/html/rfc6068) is supported. Only `subject` and `from` query params are used. 51 | 52 | Examples: 53 | 54 | - `mailto:"John Wayne"?subject=test-subj&from="Notifier"` 55 | - `mailto:addr1@example.org,addr2@example.org?&subject=test-subj&from=notify@example.org` 56 | 57 | ```go 58 | package main 59 | 60 | import ( 61 | "context" 62 | "log" 63 | "time" 64 | 65 | "github.com/go-pkgz/notify" 66 | ) 67 | 68 | func main() { 69 | wh := notify.NewEmail(notify.SMTPParams{ 70 | Host: "localhost", // the only required field, others are optional 71 | Port: 25, 72 | TLS: false, // TLS, but not STARTTLS 73 | ContentType: "text/html", 74 | Charset: "UTF-8", 75 | Username: "username", 76 | Password: "password", 77 | TimeOut: time.Second * 10, // default is 30 seconds 78 | }) 79 | err := wh.Send( 80 | context.Background(), 81 | `mailto:"John Wayne"?subject=test-subj&from="Notifier"`, 82 | "Hello, World!", 83 | ) 84 | if err != nil { 85 | log.Fatalf("problem sending message using email, %v", err) 86 | } 87 | } 88 | ``` 89 | 90 | ### Telegram 91 | 92 | `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: 93 | 94 | - `telegram:channel` 95 | - `telegram:channelID` // channel ID is a number, like `-1001480738202`: use [that instruction](https://remark42.com/docs/configuration/telegram/#notifications-for-administrators) to obtain it 96 | - `telegram:userID` 97 | 98 | [Here](https://remark42.com/docs/configuration/telegram/#getting-bot-token-for-telegram) is an instruction on obtaining token for your notification bot. 99 | 100 | ```go 101 | package main 102 | 103 | import ( 104 | "context" 105 | "log" 106 | "time" 107 | 108 | "github.com/go-pkgz/notify" 109 | ) 110 | 111 | func main() { 112 | tg, err := notify.NewTelegram(notify.TelegramParams{ 113 | Token: "token", // required 114 | Timeout: time.Second * 10, // default is 5 seconds 115 | SuccessMsg: "Success", // optional, for auth, set by default 116 | ErrorMsg: "Error", // optional, for auth, unset by default 117 | }) 118 | if err != nil { 119 | log.Fatalf("problem creating telegram notifier, %v", err) 120 | } 121 | err = tg.Send(context.Background(), "telegram:-1001480738202", "Hello, World!") 122 | if err != nil { 123 | log.Fatalf("problem sending message using telegram, %v", err) 124 | } 125 | } 126 | ``` 127 | 128 | #### HTML Formatting 129 | 130 | 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 `` and `h4-h6` with `` to preserve formatting. 131 | 132 | If you want to post text into HTML tag like text, you can use `EscapeTelegramText` method to escape it (by replacing symbols `&`, `<`, `>` with `&`, `<`, `>`). 133 | 134 | #### Authorisation 135 | 136 | 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). 137 | 138 | Normal flow is following: 139 | 1. you run the `Run` goroutine 140 | 2. call `AddToken` and provide user with that token 141 | 3. user clicks on the link `https://t.me//?start=` 142 | 4. you call `CheckToken` to verify that the user clicked the link, and if so you will receive user's UserID 143 | 144 | 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). 145 | 146 | ### Slack 147 | 148 | `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: 149 | 150 | - `slack:channel` 151 | - `slack:channelID` 152 | - `slack:userID` 153 | - `slack:channel?title=title&attachmentText=test%20text&titleLink=https://example.org` 154 | 155 | ```go 156 | package main 157 | 158 | import ( 159 | "context" 160 | "log" 161 | 162 | "github.com/go-pkgz/notify" 163 | "github.com/slack-go/slack" 164 | ) 165 | 166 | func main() { 167 | wh := notify.NewSlack( 168 | "token", 169 | slack.OptionDebug(true), // optional, you can pass any slack.Options 170 | ) 171 | err := wh.Send(context.Background(), "slack:general", "Hello, World!") 172 | if err != nil { 173 | log.Fatalf("problem sending message using slack, %v", err) 174 | } 175 | } 176 | ``` 177 | 178 | ### Webhook 179 | 180 | `http://` and `https://` schemas are supported. 181 | 182 | ```go 183 | package main 184 | 185 | import ( 186 | "context" 187 | "log" 188 | "time" 189 | 190 | "github.com/go-pkgz/notify" 191 | ) 192 | 193 | func main() { 194 | wh := notify.NewWebhook(notify.WebhookParams{ 195 | Timeout: time.Second, // optional, default is 5 seconds 196 | Headers: []string{"Content-Type:application/json,text/plain"}, // optional 197 | }) 198 | err := wh.Send(context.Background(), "https://example.org/webhook", "Hello, World!") 199 | if err != nil { 200 | log.Fatalf("problem sending message using webhook, %v", err) 201 | } 202 | } 203 | ``` 204 | 205 | ## Status 206 | 207 | 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. 208 | 209 | `go-pkgz/notify` library still in development and until version 1 released some breaking changes possible. 210 | -------------------------------------------------------------------------------- /email.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/mail" 7 | "net/url" 8 | "strings" 9 | "time" 10 | 11 | "github.com/go-pkgz/email" 12 | ) 13 | 14 | // SMTPParams contain settings for smtp server connection 15 | type SMTPParams struct { 16 | Host string // SMTP host 17 | Port int // SMTP port 18 | TLS bool // TLS auth 19 | StartTLS bool // StartTLS auth 20 | InsecureSkipVerify bool // skip certificate verification 21 | ContentType string // Content type 22 | Charset string // Character set 23 | LoginAuth bool // LOGIN auth method instead of default PLAIN, needed for Office 365 and outlook.com 24 | Username string // username 25 | Password string // password 26 | TimeOut time.Duration // TCP connection timeout 27 | } 28 | 29 | // Email notifications client 30 | type Email struct { 31 | SMTPParams 32 | sender *email.Sender 33 | } 34 | 35 | // NewEmail makes new Email object 36 | func NewEmail(smtpParams SMTPParams) *Email { 37 | var opts []email.Option 38 | 39 | if smtpParams.Username != "" { 40 | opts = append(opts, email.Auth(smtpParams.Username, smtpParams.Password)) 41 | } 42 | 43 | if smtpParams.ContentType != "" { 44 | opts = append(opts, email.ContentType(smtpParams.ContentType)) 45 | } 46 | 47 | if smtpParams.Charset != "" { 48 | opts = append(opts, email.Charset(smtpParams.Charset)) 49 | } 50 | 51 | if smtpParams.LoginAuth { 52 | opts = append(opts, email.LoginAuth()) 53 | } 54 | 55 | if smtpParams.Port != 0 { 56 | opts = append(opts, email.Port(smtpParams.Port)) 57 | } 58 | 59 | if smtpParams.TimeOut != 0 { 60 | opts = append(opts, email.TimeOut(smtpParams.TimeOut)) 61 | } 62 | 63 | if smtpParams.TLS { 64 | opts = append(opts, email.TLS(true)) 65 | } 66 | 67 | if smtpParams.StartTLS { 68 | opts = append(opts, email.STARTTLS(true)) 69 | } 70 | 71 | if smtpParams.InsecureSkipVerify { 72 | opts = append(opts, email.InsecureSkipVerify(true)) 73 | } 74 | 75 | sender := email.NewSender(smtpParams.Host, opts...) 76 | 77 | return &Email{sender: sender, SMTPParams: smtpParams} 78 | } 79 | 80 | // Send sends the message over Email, with "from", "subject" and "unsubscribeLink" parsed from destination field 81 | // with "mailto:" schema. 82 | // "unsubscribeLink" passed as a header, https://support.google.com/mail/answer/81126 -> "Use one-click unsubscribe" 83 | // 84 | // Example: 85 | // 86 | // - mailto:"John Wayne"?subject=test-subj&from="Notifier" 87 | // - mailto:addr1@example.org,addr2@example.org?subject=test-subj&from=notify@example.org&unsubscribeLink=http://example.org/unsubscribe 88 | func (e *Email) Send(ctx context.Context, destination, text string) error { 89 | emailParams, err := e.parseDestination(destination) 90 | if err != nil { 91 | return fmt.Errorf("problem parsing destination: %w", err) 92 | } 93 | 94 | select { 95 | case <-ctx.Done(): 96 | return ctx.Err() 97 | default: 98 | return e.sender.Send(text, emailParams) 99 | } 100 | } 101 | 102 | // Schema returns schema prefix supported by this client 103 | func (e *Email) Schema() string { 104 | return "mailto" 105 | } 106 | 107 | // String representation of Email object 108 | func (e *Email) String() string { 109 | str := fmt.Sprintf("email: with username '%s' at server %s:%d", e.Username, e.Host, e.Port) 110 | if e.TLS { 111 | str += " with TLS" 112 | } 113 | if e.StartTLS { 114 | str += " with StartTLS" 115 | } 116 | return str 117 | } 118 | 119 | // parses "mailto:" URL and returns email parameters 120 | func (e *Email) parseDestination(destination string) (email.Params, error) { 121 | // parse URL 122 | u, err := url.Parse(destination) 123 | if err != nil { 124 | return email.Params{}, err 125 | } 126 | if u.Scheme != "mailto" { 127 | return email.Params{}, fmt.Errorf("unsupported scheme %s, should be mailto", u.Scheme) 128 | } 129 | 130 | // parse destination address(es) 131 | addresses, err := mail.ParseAddressList(u.Opaque) 132 | if err != nil { 133 | return email.Params{}, fmt.Errorf("problem parsing email recipients: %w", err) 134 | } 135 | destinations := []string{} 136 | for _, addr := range addresses { 137 | stringAddr := addr.String() 138 | // in case of mailgun, correct RFC5322 address with <> yield 501 error, so we need to remove brackets 139 | if strings.HasPrefix(stringAddr, "<") && strings.HasSuffix(stringAddr, ">") { 140 | stringAddr = stringAddr[1 : len(stringAddr)-1] 141 | } 142 | destinations = append(destinations, stringAddr) 143 | } 144 | 145 | return email.Params{ 146 | From: u.Query().Get("from"), 147 | To: destinations, 148 | Subject: u.Query().Get("subject"), 149 | UnsubscribeLink: u.Query().Get("unsubscribeLink"), 150 | }, nil 151 | } 152 | -------------------------------------------------------------------------------- /email_test.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestEmailNew(t *testing.T) { 13 | smtpParams := SMTPParams{ 14 | Host: "test@host", 15 | Port: 1000, 16 | TLS: true, 17 | Username: "test@username", 18 | Password: "test@password", 19 | LoginAuth: true, 20 | ContentType: "text/html", 21 | Charset: "UTF-8", 22 | TimeOut: time.Second, 23 | } 24 | 25 | email := NewEmail(smtpParams) 26 | 27 | assert.NotNil(t, email, "email returned") 28 | 29 | assert.Equal(t, "mailto", email.Schema()) 30 | assert.Equal(t, smtpParams.TimeOut, email.TimeOut, "SMTPParams.TimOut unchanged after creation") 31 | assert.Equal(t, smtpParams.Host, email.Host, "SMTPParams.Host unchanged after creation") 32 | assert.Equal(t, smtpParams.Username, email.Username, "SMTPParams.Username unchanged after creation") 33 | assert.Equal(t, smtpParams.Password, email.Password, "SMTPParams.Password unchanged after creation") 34 | assert.Equal(t, smtpParams.Port, email.Port, "SMTPParams.Port unchanged after creation") 35 | assert.Equal(t, smtpParams.TLS, email.TLS, "SMTPParams.TLS unchanged after creation") 36 | assert.Equal(t, smtpParams.ContentType, email.ContentType, "SMTPParams.ContentType unchanged after creation") 37 | assert.Equal(t, smtpParams.Charset, email.Charset, "SMTPParams.Charset unchanged after creation") 38 | assert.Equal(t, smtpParams.LoginAuth, email.LoginAuth, "SMTPParams.LoginAuth unchanged after creation") 39 | } 40 | 41 | func TestEmailSendClientError(t *testing.T) { 42 | email := NewEmail(SMTPParams{Host: "test@host", Username: "user", TLS: true}) 43 | 44 | assert.Equal(t, "email: with username 'user' at server test@host:0 with TLS", email.String()) 45 | 46 | // no destination set 47 | require.EqualError(t, email.Send(context.Background(), "", ""), 48 | "problem parsing destination: unsupported scheme , should be mailto") 49 | 50 | // wrong scheme 51 | require.EqualError(t, email.Send(context.Background(), "https://example.org", ""), 52 | "problem parsing destination: unsupported scheme https, should be mailto") 53 | 54 | // bad destination set 55 | require.EqualError(t, email.Send(context.Background(), "%", ""), 56 | `problem parsing destination: parse "%": invalid URL escape "%"`) 57 | 58 | // bad recipient 59 | require.EqualError(t, email.Send(context.Background(), "mailto:bad", ""), 60 | `problem parsing destination: problem parsing email recipients: mail: missing '@' or angle-addr`) 61 | 62 | // unable to find host, with advanced destination parsing test 63 | assert.Contains(t, 64 | email.Send( 65 | context.Background(), 66 | `mailto:addr1@example.org,"John Wayne"?subject=test-subj&from="Notifier"`, 67 | "test", 68 | ).Error(), 69 | "no such host", 70 | ) 71 | 72 | // canceled context 73 | ctx, cancel := context.WithCancel(context.Background()) 74 | cancel() 75 | assert.EqualError(t, email.Send(ctx, "mailto:test@example.org", ""), "context canceled") 76 | } 77 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-pkgz/notify 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/go-chi/chi/v5 v5.2.1 7 | github.com/go-pkgz/email v0.5.0 8 | github.com/go-pkgz/lgr v0.12.0 9 | github.com/go-pkgz/repeater v1.2.0 10 | github.com/microcosm-cc/bluemonday v1.0.27 11 | github.com/slack-go/slack v0.16.0 12 | github.com/stretchr/testify v1.10.0 13 | golang.org/x/net v0.39.0 14 | ) 15 | 16 | require ( 17 | github.com/aymerick/douceur v0.2.0 // indirect 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/gorilla/css v1.0.1 // indirect 20 | github.com/gorilla/websocket v1.5.3 // indirect 21 | github.com/pmezard/go-difflib v1.0.0 // indirect 22 | gopkg.in/yaml.v3 v3.0.1 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 2 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= 6 | github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 7 | github.com/go-pkgz/email v0.5.0 h1:fdtMDGJ8NwyBACLR0LYHaCIK/OeUwZHMhH7Q0+oty9U= 8 | github.com/go-pkgz/email v0.5.0/go.mod h1:BdxglsQnymzhfdbnncEE72a6DrucZHy6I+42LK2jLEc= 9 | github.com/go-pkgz/lgr v0.12.0 h1:uoSCLdiMocZDa+L66DavHG5UIkOJvWKOVqt6sNQllw0= 10 | github.com/go-pkgz/lgr v0.12.0/go.mod h1:A4AxjOthFVFK6jRnVYMeusno5SeDAxcLVHd0kI/lN/Y= 11 | github.com/go-pkgz/repeater v1.2.0 h1:oJFvjyKdTDd5RCzpzxlzYIZFFj6Zfl17rE1aUfu6UjQ= 12 | github.com/go-pkgz/repeater v1.2.0/go.mod h1:vypP6xamA53MFmafnGUucqOmALKk36xgKu2hSG73LHM= 13 | github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= 14 | github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 15 | github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= 16 | github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 17 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 18 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 19 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 20 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 21 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 22 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 23 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/slack-go/slack v0.16.0 h1:khp/WCFv+Hb/B/AJaAwvcxKun0hM6grN0bUZ8xG60P8= 27 | github.com/slack-go/slack v0.16.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= 28 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 29 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 30 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 31 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 32 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 33 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 35 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 36 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 37 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 38 | -------------------------------------------------------------------------------- /interface.go: -------------------------------------------------------------------------------- 1 | // Package notify provides notification functionality. 2 | package notify 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | // Notifier defines common interface among all notifiers 11 | type Notifier interface { 12 | fmt.Stringer 13 | Schema() string // returns schema prefix supported by this client 14 | Send(ctx context.Context, destination, text string) error // sends message to provided destination 15 | } 16 | 17 | // Send sends message to provided destination, picking the right one based on destination schema 18 | func Send(ctx context.Context, notifiers []Notifier, destination, text string) error { 19 | for _, n := range notifiers { 20 | if strings.HasPrefix(destination, n.Schema()) { 21 | return n.Send(ctx, destination, text) 22 | } 23 | } 24 | if strings.Contains(destination, ":") { 25 | return fmt.Errorf("unsupported destination schema: %s", strings.Split(destination, ":")[0]) 26 | } 27 | return fmt.Errorf("unsupported destination schema: %s", destination) 28 | } 29 | -------------------------------------------------------------------------------- /interface_test.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestSend(t *testing.T) { 12 | notifiers := []Notifier{NewWebhook(WebhookParams{})} 13 | 14 | ctx, cancel := context.WithCancel(context.Background()) 15 | require.EqualError(t, 16 | Send(ctx, notifiers, "mailto:addr@example.org", ""), 17 | "unsupported destination schema: mailto") 18 | require.EqualError(t, 19 | Send(ctx, notifiers, "bad destination", ""), 20 | "unsupported destination schema: bad destination") 21 | 22 | cancel() 23 | require.EqualError(t, 24 | Send(ctx, notifiers, "https://example.org/webhook", ""), 25 | `webhook request failed: Post "https://example.org/webhook": context canceled`) 26 | } 27 | 28 | func TestInterface(t *testing.T) { 29 | assert.Implements(t, (*Notifier)(nil), new(Email)) 30 | assert.Implements(t, (*Notifier)(nil), new(Webhook)) 31 | assert.Implements(t, (*Notifier)(nil), new(Slack)) 32 | assert.Implements(t, (*Notifier)(nil), new(Telegram)) 33 | } 34 | -------------------------------------------------------------------------------- /slack.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/slack-go/slack" 11 | ) 12 | 13 | // Slack notifications client 14 | type Slack struct { 15 | client *slack.Client 16 | } 17 | 18 | // NewSlack makes Slack client for notifications 19 | func NewSlack(token string, opts ...slack.Option) *Slack { 20 | return &Slack{client: slack.New(token, opts...)} 21 | } 22 | 23 | // Send sends the message over Slack, with "title", "titleLink" and "attachmentText" parsed from destination field 24 | // with "slack:" schema same way "mailto:" schema is constructed. 25 | // 26 | // Example: 27 | // 28 | // - slack:channelName 29 | // - slack:channelID 30 | // - slack:userID 31 | // - slack:channel?title=title&attachmentText=test%20text&titleLink=https://example.org 32 | func (s *Slack) Send(ctx context.Context, destination, text string) error { 33 | channelID, attachment, err := s.parseDestination(destination) 34 | if err != nil { 35 | return fmt.Errorf("problem parsing destination: %w", err) 36 | } 37 | options := []slack.MsgOption{slack.MsgOptionText(text, false)} 38 | if attachment.Title != "" { 39 | options = append(options, slack.MsgOptionAttachments(attachment)) 40 | } 41 | 42 | select { 43 | case <-ctx.Done(): 44 | return ctx.Err() 45 | default: 46 | _, _, err = s.client.PostMessageContext(ctx, channelID, options...) 47 | return err 48 | } 49 | } 50 | 51 | // Schema returns schema prefix supported by this client 52 | func (s *Slack) Schema() string { 53 | return "slack" 54 | } 55 | 56 | func (s *Slack) String() string { 57 | return "slack notifications destination" 58 | } 59 | 60 | // parses "slack:" in a manner "mailto:" URL is parsed url and returns channelID and attachment. 61 | // if channelID is channel name and not ID (starting with C for channel and with U for user), 62 | // then it will be resolved to ID. 63 | func (s *Slack) parseDestination(destination string) (string, slack.Attachment, error) { 64 | // parse URL 65 | u, err := url.Parse(destination) 66 | if err != nil { 67 | return "", slack.Attachment{}, err 68 | } 69 | if u.Scheme != "slack" { 70 | return "", slack.Attachment{}, fmt.Errorf("unsupported scheme %s, should be slack", u.Scheme) 71 | } 72 | channelID := u.Opaque 73 | if !strings.HasPrefix(u.Opaque, "C") && !strings.HasPrefix(u.Opaque, "U") { 74 | channelID, err = s.findChannelIDByName(u.Opaque) 75 | if err != nil { 76 | return "", slack.Attachment{}, fmt.Errorf("problem retrieving channel ID for #%s: %w", u.Opaque, err) 77 | } 78 | } 79 | 80 | return channelID, 81 | slack.Attachment{ 82 | Title: u.Query().Get("title"), 83 | TitleLink: u.Query().Get("titleLink"), 84 | Text: u.Query().Get("attachmentText"), 85 | }, nil 86 | } 87 | 88 | func (s *Slack) findChannelIDByName(name string) (string, error) { 89 | params := slack.GetConversationsParameters{} 90 | for { 91 | channels, next, err := s.client.GetConversations(¶ms) 92 | if err != nil { 93 | return "", err 94 | } 95 | 96 | for i := range channels { 97 | if channels[i].Name == name { 98 | return channels[i].ID, nil 99 | } 100 | } 101 | 102 | if next == "" { 103 | break 104 | } 105 | params.Cursor = next 106 | } 107 | return "", errors.New("no such channel") 108 | } 109 | -------------------------------------------------------------------------------- /slack_test.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/go-chi/chi/v5" 11 | "github.com/slack-go/slack" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestSlack_Send(t *testing.T) { 17 | ts := newMockSlackServer() 18 | defer ts.Close() 19 | 20 | tb := ts.newClient() 21 | assert.NotNil(t, tb) 22 | assert.Equal(t, "slack notifications destination", tb.String()) 23 | assert.Equal(t, "slack", tb.Schema()) 24 | 25 | err := tb.Send(context.Background(), "slack:general?title=title&attachmentText=test%20text&titleLink=https://example.org", "test text") 26 | require.NoError(t, err) 27 | 28 | ts.isServerDown = true 29 | err = tb.Send(context.Background(), "slack:general?title=title&attachmentText=test%20text&titleLink=https://example.org", "test text") 30 | assert.Contains(t, err.Error(), "slack server error", "send on broken client") 31 | } 32 | 33 | func TestSlackSendClientError(t *testing.T) { 34 | ts := newMockSlackServer() 35 | defer ts.Close() 36 | 37 | slck := ts.newClient() 38 | assert.NotNil(t, slck) 39 | assert.Equal(t, "slack notifications destination", slck.String()) 40 | 41 | // no destination set 42 | require.EqualError(t, slck.Send(context.Background(), "", ""), 43 | "problem parsing destination: unsupported scheme , should be slack") 44 | 45 | // wrong scheme 46 | require.EqualError(t, slck.Send(context.Background(), "https://example.org", ""), 47 | "problem parsing destination: unsupported scheme https, should be slack") 48 | 49 | // bad destination set 50 | require.EqualError(t, slck.Send(context.Background(), "%", ""), 51 | `problem parsing destination: parse "%": invalid URL escape "%"`) 52 | 53 | // can't retrieve channel ID 54 | ts.listingIsBroken = true 55 | require.EqualError(t, slck.Send(context.Background(), "slack:general", ""), 56 | "problem parsing destination: problem retrieving channel ID for #general:"+ 57 | " slack server error: 500 Internal Server Error") 58 | ts.listingIsBroken = false 59 | 60 | // non-existing channel 61 | require.EqualError(t, slck.Send(context.Background(), "slack:non-existent", ""), 62 | "problem parsing destination: problem retrieving channel ID for #non-existent: no such channel") 63 | 64 | // canceled context 65 | ctx, cancel := context.WithCancel(context.Background()) 66 | cancel() 67 | require.EqualError(t, slck.Send(ctx, "slack:general?title=test", ""), "context canceled") 68 | } 69 | 70 | type mockSlackServer struct { 71 | *httptest.Server 72 | isServerDown bool 73 | listingIsBroken bool 74 | } 75 | 76 | func (ts *mockSlackServer) newClient() *Slack { 77 | return NewSlack("any-token", slack.OptionAPIURL(ts.URL+"/")) 78 | } 79 | 80 | func newMockSlackServer() *mockSlackServer { 81 | mockServer := mockSlackServer{} 82 | router := chi.NewRouter() 83 | router.Post("/conversations.list", func(w http.ResponseWriter, _ *http.Request) { 84 | if mockServer.listingIsBroken { 85 | w.WriteHeader(500) 86 | } else { 87 | s := `{ 88 | "ok": true, 89 | "channels": [ 90 | { 91 | "id": "C12345678", 92 | "name": "general", 93 | "is_channel": true, 94 | "is_group": false, 95 | "is_im": false, 96 | "created": 1503888888, 97 | "is_archived": false, 98 | "is_general": false, 99 | "unlinked": 0, 100 | "name_normalized": "random", 101 | "is_shared": false, 102 | "parent_conversation": null, 103 | "creator": "U12345678", 104 | "is_ext_shared": false, 105 | "is_org_shared": false, 106 | "pending_shared": [], 107 | "pending_connected_team_ids": [], 108 | "is_pending_ext_shared": false, 109 | "is_member": false, 110 | "is_private": false, 111 | "is_mpim": false, 112 | "previous_names": [], 113 | "num_members": 1 114 | } 115 | ], 116 | "response_metadata": { 117 | "next_cursor": "" 118 | } 119 | }` 120 | _, _ = w.Write([]byte(s)) 121 | } 122 | }) 123 | 124 | router.Post("/chat.postMessage", func(w http.ResponseWriter, _ *http.Request) { 125 | if mockServer.isServerDown { 126 | w.WriteHeader(500) 127 | } else { 128 | s := `{ 129 | "ok": true, 130 | "channel": "C12345678", 131 | "ts": "1617008342.000100", 132 | "message": { 133 | "type": "message", 134 | "subtype": "bot_message", 135 | "text": "wowo", 136 | "ts": "1617008342.000100", 137 | "username": "slackbot", 138 | "bot_id": "B12345678" 139 | } 140 | }` 141 | _, _ = w.Write([]byte(s)) 142 | } 143 | }) 144 | 145 | router.NotFound(func(_ http.ResponseWriter, r *http.Request) { 146 | log.Printf("..... 404 for %s .....\n", r.URL) 147 | }) 148 | 149 | mockServer.Server = httptest.NewServer(router) 150 | return &mockServer 151 | } 152 | -------------------------------------------------------------------------------- /telegram.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | neturl "net/url" 12 | "strconv" 13 | "strings" 14 | "sync" 15 | "sync/atomic" 16 | "time" 17 | 18 | log "github.com/go-pkgz/lgr" 19 | "github.com/go-pkgz/repeater" 20 | "github.com/microcosm-cc/bluemonday" 21 | "golang.org/x/net/html" 22 | ) 23 | 24 | // TelegramParams contain settings for telegram notifications 25 | type TelegramParams struct { 26 | Token string // token for telegram bot API interactions 27 | Timeout time.Duration // http client timeout 28 | ErrorMsg, SuccessMsg string // messages for successful and unsuccessful subscription requests to bot 29 | 30 | apiPrefix string // changed only in tests 31 | } 32 | 33 | // Telegram notifications client 34 | type Telegram struct { 35 | TelegramParams 36 | 37 | // Identifier of the first update to be requested. 38 | // Should be equal to LastSeenUpdateID + 1 39 | // See https://core.telegram.org/bots/api#getupdates 40 | updateOffset int 41 | apiPollInterval time.Duration // interval to check updates from Telegram API and answer to users 42 | expiredCleanupInterval time.Duration // interval to check and clean up expired notification requests 43 | username string // bot username 44 | run int32 // non-zero if Run goroutine has started 45 | requests struct { 46 | sync.RWMutex 47 | data map[string]tgAuthRequest 48 | } 49 | } 50 | 51 | // telegramMsg is used to send message through Telegram bot API 52 | type telegramMsg struct { 53 | Text string `json:"text"` 54 | ParseMode string `json:"parse_mode,omitempty"` 55 | } 56 | 57 | type tgAuthRequest struct { 58 | confirmed bool // whether login request has been confirmed and user info set 59 | expires time.Time 60 | telegramID string 61 | user string 62 | site string 63 | } 64 | 65 | // TelegramBotInfo structure contains information about telegram bot, which is used from whole telegram API response 66 | type TelegramBotInfo struct { 67 | Username string `json:"username"` 68 | } 69 | 70 | const telegramTimeOut = 5000 * time.Millisecond 71 | const telegramAPIPrefix = "https://api.telegram.org/bot" 72 | const tgPollInterval = time.Second * 5 73 | const tgCleanupInterval = time.Minute * 5 74 | 75 | // NewTelegram makes telegram bot for notifications 76 | func NewTelegram(params TelegramParams) (*Telegram, error) { 77 | res := Telegram{TelegramParams: params} 78 | 79 | if res.apiPrefix == "" { 80 | res.apiPrefix = telegramAPIPrefix 81 | } 82 | if res.Timeout == 0 { 83 | res.Timeout = telegramTimeOut 84 | } 85 | 86 | if res.SuccessMsg == "" { 87 | res.SuccessMsg = "✅ You have successfully authenticated, check the web!" 88 | } 89 | 90 | res.apiPollInterval = tgPollInterval 91 | res.expiredCleanupInterval = tgCleanupInterval 92 | log.Printf("[DEBUG] create new telegram notifier for api=%s, timeout=%s", res.apiPrefix, res.Timeout) 93 | 94 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 95 | defer cancel() 96 | 97 | botInfo, err := res.botInfo(ctx) 98 | if err != nil { 99 | return nil, fmt.Errorf("can't retrieve bot info from Telegram API: %w", err) 100 | } 101 | res.username = botInfo.Username 102 | 103 | res.requests.data = make(map[string]tgAuthRequest) 104 | 105 | return &res, nil 106 | } 107 | 108 | // Send sends provided message to Telegram chat, with `parseMode` parsed from destination field (Markdown by default) 109 | // with "telegram:" schema same way "mailto:" schema is constructed. 110 | // 111 | // Example: 112 | // 113 | // - telegram:channel 114 | // - telegram:chatID // chatID is a number, like `-1001480738202` 115 | // - telegram:channel?parseMode=HTML 116 | func (t *Telegram) Send(ctx context.Context, destination, text string) error { 117 | chatID, parseMode, err := t.parseDestination(destination) 118 | if err != nil { 119 | return fmt.Errorf("problem parsing destination: %w", err) 120 | } 121 | 122 | body := telegramMsg{Text: text, ParseMode: parseMode} 123 | b, err := json.Marshal(body) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | url := fmt.Sprintf("sendMessage?chat_id=%s&disable_web_page_preview=true", chatID) 129 | return t.Request(ctx, url, b, &struct{}{}) 130 | } 131 | 132 | // TelegramSupportedHTML returns HTML with only tags allowed in Telegram HTML message payload, also trims ending newlines 133 | // 134 | // https://core.telegram.org/bots/api#html-style, https://core.telegram.org/api/entities#allowed-entities 135 | func TelegramSupportedHTML(htmlText string) string { 136 | adjustedHTMLText := adjustHTMLTags(htmlText) 137 | p := bluemonday.NewPolicy() 138 | p.AllowElements("b", "strong", "i", "em", "u", "ins", "s", "strike", "del", "a", "code", "pre", "tg-spoiler", "tg-emoji", "blockquote") 139 | p.AllowAttrs("href").OnElements("a") 140 | p.AllowAttrs("class").OnElements("code") 141 | p.AllowAttrs("title").OnElements("tg-spoiler") 142 | p.AllowAttrs("emoji-id").OnElements("tg-emoji") 143 | p.AllowAttrs("language").OnElements("pre") 144 | return strings.TrimRight(p.Sanitize(adjustedHTMLText), "\n") 145 | } 146 | 147 | // EscapeTelegramText returns text sanitized of symbols not allowed inside other HTML tags in Telegram HTML message payload 148 | // 149 | // https://core.telegram.org/bots/api#html-style 150 | func EscapeTelegramText(text string) string { 151 | // order is important 152 | text = strings.ReplaceAll(text, "&", "&") 153 | text = strings.ReplaceAll(text, "<", "<") 154 | text = strings.ReplaceAll(text, ">", ">") 155 | return text 156 | } 157 | 158 | // telegram not allow h1-h6 tags 159 | // replace these tags with a combination of and for visual distinction 160 | func adjustHTMLTags(htmlText string) string { 161 | buff := strings.Builder{} 162 | tokenizer := html.NewTokenizer(strings.NewReader(htmlText)) 163 | for { 164 | if tokenizer.Next() == html.ErrorToken { 165 | return buff.String() 166 | } 167 | token := tokenizer.Token() 168 | switch token.Type { 169 | case html.StartTagToken, html.EndTagToken: 170 | switch token.Data { 171 | case "h1", "h2", "h3": 172 | if token.Type == html.StartTagToken { 173 | _, _ = buff.WriteString("") 174 | } 175 | if token.Type == html.EndTagToken { 176 | _, _ = buff.WriteString("") 177 | } 178 | case "h4", "h5", "h6": 179 | if token.Type == html.StartTagToken { 180 | _, _ = buff.WriteString("") 181 | } 182 | if token.Type == html.EndTagToken { 183 | _, _ = buff.WriteString("") 184 | } 185 | default: 186 | _, _ = buff.WriteString(token.String()) 187 | } 188 | default: 189 | _, _ = buff.WriteString(token.String()) 190 | } 191 | } 192 | } 193 | 194 | // TelegramUpdate contains update information, which is used from whole telegram API response 195 | type TelegramUpdate struct { 196 | Result []struct { 197 | UpdateID int `json:"update_id"` 198 | Message struct { 199 | Chat struct { 200 | ID int `json:"id"` 201 | Name string `json:"first_name"` 202 | Type string `json:"type"` 203 | } `json:"chat"` 204 | Text string `json:"text"` 205 | } `json:"message"` 206 | } `json:"result"` 207 | } 208 | 209 | // GetBotUsername returns bot username 210 | func (t *Telegram) GetBotUsername() string { 211 | return t.username 212 | } 213 | 214 | // AddToken adds token 215 | func (t *Telegram) AddToken(token, user, site string, expires time.Time) { 216 | t.requests.Lock() 217 | t.requests.data[token] = tgAuthRequest{ 218 | expires: expires, 219 | user: user, 220 | site: site, 221 | } 222 | t.requests.Unlock() 223 | } 224 | 225 | // CheckToken verifies incoming token, returns the user address if it's confirmed and empty string otherwise 226 | func (t *Telegram) CheckToken(token, user string) (telegram, site string, err error) { 227 | t.requests.RLock() 228 | authRequest, ok := t.requests.data[token] 229 | t.requests.RUnlock() 230 | 231 | if !ok { 232 | return "", "", errors.New("request is not found") 233 | } 234 | 235 | if time.Now().After(authRequest.expires) { 236 | t.requests.Lock() 237 | delete(t.requests.data, token) 238 | t.requests.Unlock() 239 | return "", "", errors.New("request expired") 240 | } 241 | 242 | if !authRequest.confirmed { 243 | return "", "", errors.New("request is not verified yet") 244 | } 245 | 246 | if authRequest.user != user { 247 | return "", "", errors.New("user does not match original requester") 248 | } 249 | 250 | // Delete request 251 | t.requests.Lock() 252 | delete(t.requests.data, token) 253 | t.requests.Unlock() 254 | 255 | return authRequest.telegramID, authRequest.site, nil 256 | } 257 | 258 | // Run starts processing login requests sent in Telegram, required for user notifications to work 259 | // Blocks caller 260 | func (t *Telegram) Run(ctx context.Context) { 261 | atomic.AddInt32(&t.run, 1) 262 | processUpdatedTicker := time.NewTicker(t.apiPollInterval) 263 | cleanupTicker := time.NewTicker(t.expiredCleanupInterval) 264 | 265 | for { 266 | select { 267 | case <-ctx.Done(): 268 | processUpdatedTicker.Stop() 269 | cleanupTicker.Stop() 270 | atomic.AddInt32(&t.run, -1) 271 | return 272 | case <-processUpdatedTicker.C: 273 | updates, err := t.getUpdates(ctx) 274 | if err != nil { 275 | log.Printf("[WARN] Error while getting telegram updates: %v", err) 276 | continue 277 | } 278 | t.processUpdates(ctx, updates) 279 | case <-cleanupTicker.C: 280 | now := time.Now() 281 | t.requests.Lock() 282 | for key, req := range t.requests.data { 283 | if now.After(req.expires) { 284 | delete(t.requests.data, key) 285 | } 286 | } 287 | t.requests.Unlock() 288 | } 289 | } 290 | } 291 | 292 | // ProcessUpdate is alternative to Run, it processes provided plain text update from Telegram 293 | // so that caller could get updates and send it not only there but to multiple sources 294 | func (t *Telegram) ProcessUpdate(ctx context.Context, textUpdate string) error { 295 | if atomic.LoadInt32(&t.run) != 0 { 296 | return errors.New("the Run goroutine should not be used with ProcessUpdate") 297 | } 298 | defer func() { 299 | // as Run goroutine is not running, clean up old requests on each update 300 | // even if we hit json decode error 301 | now := time.Now() 302 | t.requests.Lock() 303 | for key, req := range t.requests.data { 304 | if now.After(req.expires) { 305 | delete(t.requests.data, key) 306 | } 307 | } 308 | t.requests.Unlock() 309 | }() 310 | var updates TelegramUpdate 311 | if err := json.Unmarshal([]byte(textUpdate), &updates); err != nil { 312 | return fmt.Errorf("failed to decode provided telegram update: %w", err) 313 | } 314 | t.processUpdates(ctx, &updates) 315 | return nil 316 | } 317 | 318 | // Schema returns schema prefix supported by this client 319 | func (t *Telegram) Schema() string { 320 | return "telegram" 321 | } 322 | 323 | func (t *Telegram) String() string { 324 | return "telegram notifications destination" 325 | } 326 | 327 | // parses "telegram:" in a manner "mailto:" URL is parsed url and returns chatID and parseMode. 328 | // if chatID is channel name and not a numerical ID, `@` will be added to it 329 | func (t *Telegram) parseDestination(destination string) (chatID, parseMode string, err error) { 330 | // parse URL 331 | u, err := neturl.Parse(destination) 332 | if err != nil { 333 | return "", "", err 334 | } 335 | if u.Scheme != "telegram" { 336 | return "", "", fmt.Errorf("unsupported scheme %s, should be telegram", u.Scheme) 337 | } 338 | 339 | chatID = u.Opaque 340 | if _, err := strconv.ParseInt(chatID, 10, 64); err != nil { 341 | chatID = "@" + chatID // if chatID not a number enforce @ prefix 342 | } 343 | 344 | parseMode = "Markdown" 345 | if u.Query().Get("parseMode") != "" { 346 | parseMode = u.Query().Get("parseMode") 347 | } 348 | 349 | return chatID, parseMode, nil 350 | } 351 | 352 | // getUpdates fetches incoming updates 353 | func (t *Telegram) getUpdates(ctx context.Context) (*TelegramUpdate, error) { 354 | url := `getUpdates?allowed_updates=["message"]` 355 | if t.updateOffset != 0 { 356 | url += fmt.Sprintf("&offset=%d", t.updateOffset) 357 | } 358 | 359 | var result TelegramUpdate 360 | 361 | err := t.Request(ctx, url, nil, &result) 362 | if err != nil { 363 | return nil, fmt.Errorf("failed to fetch updates: %w", err) 364 | } 365 | 366 | for _, u := range result.Result { 367 | if u.UpdateID >= t.updateOffset { 368 | t.updateOffset = u.UpdateID + 1 369 | } 370 | } 371 | 372 | return &result, nil 373 | } 374 | 375 | // processUpdates processes a batch of updates from telegram servers 376 | func (t *Telegram) processUpdates(ctx context.Context, updates *TelegramUpdate) { 377 | for _, update := range updates.Result { 378 | if update.Message.Chat.Type != "private" { 379 | continue 380 | } 381 | 382 | if !strings.HasPrefix(update.Message.Text, "/start ") { 383 | continue 384 | } 385 | 386 | token := strings.TrimPrefix(update.Message.Text, "/start ") 387 | 388 | t.requests.RLock() 389 | authRequest, ok := t.requests.data[token] 390 | if !ok { // No such token 391 | t.requests.RUnlock() 392 | if t.ErrorMsg != "" { 393 | if err := t.sendText(ctx, update.Message.Chat.ID, t.ErrorMsg); err != nil { 394 | log.Printf("[WARN] failed to notify telegram peer: %v", err) 395 | } 396 | } 397 | continue 398 | } 399 | t.requests.RUnlock() 400 | 401 | authRequest.confirmed = true 402 | authRequest.telegramID = strconv.Itoa(update.Message.Chat.ID) 403 | 404 | t.requests.Lock() 405 | t.requests.data[token] = authRequest 406 | t.requests.Unlock() 407 | 408 | if err := t.sendText(ctx, update.Message.Chat.ID, t.SuccessMsg); err != nil { 409 | log.Printf("[ERROR] failed to notify telegram peer: %v", err) 410 | } 411 | } 412 | } 413 | 414 | // sendText sends a plain text message to telegram peer 415 | func (t *Telegram) sendText(ctx context.Context, recipientID int, msg string) error { 416 | url := fmt.Sprintf("sendMessage?chat_id=%d&text=%s", recipientID, neturl.PathEscape(msg)) 417 | return t.Request(ctx, url, nil, &struct{}{}) 418 | } 419 | 420 | // botInfo returns info about configured bot 421 | func (t *Telegram) botInfo(ctx context.Context) (*TelegramBotInfo, error) { 422 | var resp = struct { 423 | Result *TelegramBotInfo `json:"result"` 424 | }{} 425 | 426 | err := t.Request(ctx, "getMe", nil, &resp) 427 | if err != nil { 428 | return nil, err 429 | } 430 | if resp.Result == nil { 431 | return nil, errors.New("received empty result") 432 | } 433 | 434 | return resp.Result, nil 435 | } 436 | 437 | // Request makes a request to the Telegram API and return the result 438 | func (t *Telegram) Request(ctx context.Context, method string, b []byte, data any) error { 439 | return repeater.NewDefault(3, time.Millisecond*250).Do(ctx, func() error { 440 | url := fmt.Sprintf("%s%s/%s", t.apiPrefix, t.Token, method) 441 | 442 | var req *http.Request 443 | var err error 444 | if b == nil { 445 | req, err = http.NewRequestWithContext(ctx, "GET", url, http.NoBody) 446 | } else { 447 | req, err = http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b)) 448 | req.Header.Set("Content-Type", "application/json; charset=utf-8") 449 | } 450 | if err != nil { 451 | return fmt.Errorf("failed to create request: %w", err) 452 | } 453 | 454 | client := http.Client{Timeout: t.Timeout} 455 | defer client.CloseIdleConnections() 456 | resp, err := client.Do(req) 457 | if err != nil { 458 | return fmt.Errorf("failed to send request: %w", err) 459 | } 460 | defer resp.Body.Close() 461 | 462 | if resp.StatusCode != http.StatusOK { 463 | return t.parseError(resp.Body, resp.StatusCode) 464 | } 465 | 466 | if err = json.NewDecoder(resp.Body).Decode(data); err != nil { 467 | return fmt.Errorf("failed to decode json response: %w", err) 468 | } 469 | 470 | return nil 471 | }) 472 | } 473 | 474 | func (t *Telegram) parseError(r io.Reader, statusCode int) error { 475 | tgErr := struct { 476 | Description string `json:"description"` 477 | }{} 478 | if err := json.NewDecoder(r).Decode(&tgErr); err != nil { 479 | return fmt.Errorf("unexpected telegram API status code %d", statusCode) 480 | } 481 | return fmt.Errorf("unexpected telegram API status code %d, error: %q", statusCode, tgErr.Description) 482 | } 483 | -------------------------------------------------------------------------------- /telegram_test.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/go-chi/chi/v5" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestTelegram_New(t *testing.T) { 17 | ts := mockTelegramServer(nil) 18 | defer ts.Close() 19 | 20 | tb, err := NewTelegram(TelegramParams{ 21 | Token: "good-token", 22 | apiPrefix: ts.URL + "/", 23 | }) 24 | require.NoError(t, err) 25 | assert.NotNil(t, tb) 26 | assert.Equal(t, time.Second*5, tb.Timeout) 27 | assert.Equal(t, "telegram", tb.Schema()) 28 | assert.Equal(t, "telegram notifications destination", tb.String()) 29 | 30 | _, err = NewTelegram(TelegramParams{ 31 | Token: "empty-json", 32 | apiPrefix: ts.URL + "/", 33 | }) 34 | require.EqualError(t, err, "can't retrieve bot info from Telegram API: received empty result") 35 | 36 | st := time.Now() 37 | _, err = NewTelegram(TelegramParams{ 38 | Token: "non-json-resp", 39 | Timeout: 2 * time.Second, 40 | apiPrefix: ts.URL + "/", 41 | }) 42 | require.Error(t, err) 43 | assert.Contains(t, err.Error(), "failed to decode json response:") 44 | assert.GreaterOrEqual(t, time.Since(st), 250*3*time.Millisecond) 45 | 46 | _, err = NewTelegram(TelegramParams{ 47 | Token: "404", 48 | Timeout: 2 * time.Second, 49 | apiPrefix: ts.URL + "/", 50 | }) 51 | require.EqualError(t, err, "can't retrieve bot info from Telegram API: unexpected telegram API status code 404") 52 | 53 | _, err = NewTelegram(TelegramParams{ 54 | Token: "no-such-thing", 55 | apiPrefix: "http://127.0.0.1:4321/", 56 | }) 57 | require.Error(t, err) 58 | assert.Contains(t, err.Error(), "can't retrieve bot info from Telegram API") 59 | assert.Contains(t, err.Error(), "dial tcp 127.0.0.1:4321: connect: connection refused") 60 | 61 | _, err = NewTelegram(TelegramParams{ 62 | Token: "", 63 | apiPrefix: "", 64 | }) 65 | require.Error(t, err, "empty api url not allowed") 66 | 67 | _, err = NewTelegram(TelegramParams{ 68 | Token: "good-token", 69 | Timeout: 2 * time.Second, 70 | apiPrefix: ts.URL + "/", 71 | }) 72 | require.NoError(t, err, "0 timeout allowed as default") 73 | } 74 | 75 | func TestTelegram_Send(t *testing.T) { 76 | ts := mockTelegramServer(nil) 77 | defer ts.Close() 78 | 79 | tb, err := NewTelegram(TelegramParams{ 80 | Token: "good-token", 81 | apiPrefix: ts.URL + "/", 82 | }) 83 | require.NoError(t, err) 84 | assert.NotNil(t, tb) 85 | 86 | err = tb.Send(context.Background(), "telegram:test_user_channel?parseMode=HTML", "test message") 87 | require.NoError(t, err) 88 | 89 | tb = &Telegram{ 90 | TelegramParams: TelegramParams{ 91 | Token: "non-json-resp", 92 | apiPrefix: ts.URL + "/", 93 | }} 94 | err = tb.Send(context.Background(), "telegram:test_user_channel", "test message") 95 | require.Error(t, err) 96 | assert.Contains(t, err.Error(), "unexpected telegram API status code 404", "send on broken tg") 97 | 98 | // bad API URL 99 | tb.apiPrefix = "http://non-existent" 100 | err = tb.Send(context.Background(), "telegram:test_user_channel", "test message") 101 | require.Error(t, err) 102 | } 103 | 104 | func TestTelegram_Formatting(t *testing.T) { 105 | text := `

Sample Markdown

106 |

This is some basic, sample markdown.

107 |

Second Heading

108 |
    109 |
  • Unordered lists, and:
      110 |
    1. One
    2. 111 |
    3. Two
    4. 112 |
    5. Three
    6. 113 |
    114 |
  • 115 |
  • More
  • 116 |
117 |
118 |

Blockquote

119 |
120 |

And bold, italics, and even italics and later bold. Even strikethrough. A link to somewhere.

121 |

And code highlighting:

122 |
var foo = 'bar';
123 | 
124 | function baz(s) {
125 |    return foo + ':' + s;
126 | }
127 | 
128 |

Fourth Heading

129 |

Or inline code like var foo = 'bar';.

130 |

Or an image of bears

131 |

bears

132 |

The end ...

133 | ` 134 | cleanText := `Sample Markdown 135 | This is some basic, sample markdown. 136 | Second Heading 137 | 138 | Unordered lists, and: 139 | One 140 | Two 141 | Three 142 | 143 | 144 | More 145 | 146 |
147 | Blockquote 148 |
149 | And bold, italics, and even italics and later bold. Even strikethrough. A link to somewhere. 150 | And code highlighting: 151 |
var foo = 'bar';
152 | 
153 | function baz(s) {
154 |    return foo + ':' + s;
155 | }
156 | 
157 | Fourth Heading 158 | Or inline code like var foo = 'bar';. 159 | Or an image of bears 160 | 161 | The end ...` 162 | 163 | assert.Equal(t, cleanText, TelegramSupportedHTML(text)) 164 | 165 | // taken from https://core.telegram.org/bots/api#html-style 166 | // `spoiler` for some reason doesn't work as expected, tag is stripped by bluemonday 167 | // also, there is no way to allow but not empty spans 168 | telegramExampleTest := `bold, bold 169 | italic, italic 170 | underline, underline 171 | strikethrough, strikethrough, strikethrough 172 | bold italic bold italic bold strikethrough italic bold strikethrough underline italic bold bold 173 | inline URL 174 | inline mention of a user 175 | 👍 176 | inline fixed-width code 177 |
pre-formatted fixed-width code block
178 |
pre-formatted fixed-width code block written in the Python programming language
179 |
Block quotation started\nBlock quotation continued\nThe last line of the block quotation
` 180 | assert.Equal(t, telegramExampleTest, TelegramSupportedHTML(telegramExampleTest)) 181 | 182 | username := "test" 183 | cleanUsername := "test<user>" 184 | assert.Equal(t, cleanUsername, EscapeTelegramText(username)) 185 | } 186 | 187 | func TestTelegramSendClientError(t *testing.T) { 188 | ts := mockTelegramServer(nil) 189 | defer ts.Close() 190 | 191 | tg, err := NewTelegram(TelegramParams{ 192 | Token: "good-token", 193 | apiPrefix: ts.URL + "/", 194 | }) 195 | require.NoError(t, err) 196 | assert.NotNil(t, tg) 197 | 198 | // no destination set 199 | require.EqualError(t, tg.Send(context.Background(), "", ""), 200 | "problem parsing destination: unsupported scheme , should be telegram") 201 | 202 | // wrong scheme 203 | require.EqualError(t, tg.Send(context.Background(), "https://example.org", ""), 204 | "problem parsing destination: unsupported scheme https, should be telegram") 205 | 206 | // bad destination set 207 | require.EqualError(t, tg.Send(context.Background(), "%", ""), 208 | `problem parsing destination: parse "%": invalid URL escape "%"`) 209 | 210 | // canceled context 211 | ctx, cancel := context.WithCancel(context.Background()) 212 | cancel() 213 | require.EqualError(t, tg.Send(ctx, "telegram:general?title=test", ""), "context canceled") 214 | } 215 | 216 | func TestTelegram_GetBotUsername(t *testing.T) { 217 | ts := mockTelegramServer(nil) 218 | defer ts.Close() 219 | 220 | tb, err := NewTelegram(TelegramParams{ 221 | Token: "good-token", 222 | apiPrefix: ts.URL + "/", 223 | }) 224 | require.NoError(t, err) 225 | assert.NotNil(t, tb) 226 | assert.Equal(t, "remark42_test_bot", tb.GetBotUsername()) 227 | } 228 | 229 | const getUpdatesResp = `{ 230 | "ok": true, 231 | "result": [ 232 | { 233 | "update_id": 998, 234 | "message": { 235 | "chat": { 236 | "type": "group" 237 | } 238 | } 239 | }, 240 | { 241 | "update_id": 999, 242 | "message": { 243 | "text": "not starting with /start", 244 | "chat": { 245 | "type": "private" 246 | } 247 | } 248 | }, 249 | { 250 | "update_id": 1000, 251 | "message": { 252 | "message_id": 4, 253 | "from": { 254 | "id": 313131313, 255 | "is_bot": false, 256 | "first_name": "Joe", 257 | "username": "joe123", 258 | "language_code": "en" 259 | }, 260 | "chat": { 261 | "id": 313131313, 262 | "first_name": "Joe", 263 | "username": "joe123", 264 | "type": "private" 265 | }, 266 | "date": 1601665548, 267 | "text": "/start token", 268 | "entities": [ 269 | { 270 | "offset": 0, 271 | "length": 6, 272 | "type": "bot_command" 273 | } 274 | ] 275 | } 276 | } 277 | ] 278 | }` 279 | 280 | func TestTelegram_GetUpdatesFlow(t *testing.T) { 281 | first := true 282 | ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) { 283 | if strings.Contains(r.URL.String(), "sendMessage") { 284 | // respond normally to processUpdates attempt to send message back to user 285 | _, _ = w.Write([]byte("{}")) 286 | return 287 | } 288 | // responses to get updates calls to API 289 | if first { 290 | assert.Empty(t, r.URL.Query().Get("offset")) 291 | first = false 292 | } else { 293 | assert.Equal(t, "1001", r.URL.Query().Get("offset")) 294 | } 295 | _, _ = w.Write([]byte(getUpdatesResp)) 296 | }) 297 | defer ts.Close() 298 | tb, err := NewTelegram(TelegramParams{ 299 | Token: "xxxsupersecretxxx", 300 | apiPrefix: ts.URL + "/", 301 | }) 302 | require.NoError(t, err) 303 | 304 | // send request with no offset 305 | upd, err := tb.getUpdates(context.Background()) 306 | require.NoError(t, err) 307 | 308 | assert.Len(t, upd.Result, 3) 309 | assert.Equal(t, 1001, tb.updateOffset) 310 | assert.Equal(t, "/start token", upd.Result[len(upd.Result)-1].Message.Text) 311 | 312 | tb.AddToken("token", "user", "site", time.Now().Add(time.Minute)) 313 | _, _, err = tb.CheckToken("token", "user") 314 | require.Error(t, err) 315 | tb.processUpdates(context.Background(), upd) 316 | tgID, site, err := tb.CheckToken("token", "user") 317 | require.NoError(t, err) 318 | assert.Equal(t, "313131313", tgID) 319 | assert.Equal(t, "site", site) 320 | 321 | // send request with offset 322 | _, err = tb.getUpdates(context.Background()) 323 | require.NoError(t, err) 324 | } 325 | 326 | func TestTelegram_ProcessUpdateFlow(t *testing.T) { 327 | ts := mockTelegramServer(func(w http.ResponseWriter, _ *http.Request) { 328 | // respond normally to processUpdates attempt to send message back to user 329 | _, _ = w.Write([]byte("{}")) 330 | }) 331 | defer ts.Close() 332 | tb, err := NewTelegram(TelegramParams{ 333 | Token: "xxxsupersecretxxx", 334 | apiPrefix: ts.URL + "/", 335 | }) 336 | require.NoError(t, err) 337 | 338 | tb.AddToken("token", "user", "site", time.Now().Add(time.Minute)) 339 | tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute)) 340 | assert.Len(t, tb.requests.data, 2) 341 | _, _, err = tb.CheckToken("token", "user") 342 | require.Error(t, err) 343 | require.NoError(t, tb.ProcessUpdate(context.Background(), getUpdatesResp)) 344 | assert.Len(t, tb.requests.data, 1, "expired token was cleaned up") 345 | tgID, site, err := tb.CheckToken("token", "user") 346 | require.NoError(t, err) 347 | assert.Empty(t, tb.requests.data, "token is deleted after successful check") 348 | assert.Equal(t, "313131313", tgID) 349 | assert.Equal(t, "site", site) 350 | 351 | tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute)) 352 | assert.Len(t, tb.requests.data, 1) 353 | require.EqualError(t, tb.ProcessUpdate(context.Background(), ""), "failed to decode provided telegram update: unexpected end of JSON input") 354 | assert.Empty(t, tb.requests.data, "expired token should be cleaned up despite the error") 355 | } 356 | 357 | const sendMessageResp = `{ 358 | "ok": true, 359 | "result": { 360 | "message_id": 100, 361 | "from": { 362 | "id": 666666666, 363 | "is_bot": true, 364 | "first_name": "Test auth bot", 365 | "username": "TestAuthBot" 366 | }, 367 | "chat": { 368 | "id": 313131313, 369 | "first_name": "Joe", 370 | "username": "joe123", 371 | "type": "private" 372 | }, 373 | "date": 1602430546, 374 | "text": "123" 375 | } 376 | }` 377 | 378 | func TestTelegram_SendText(t *testing.T) { 379 | ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) { 380 | assert.Equal(t, "123", r.URL.Query().Get("chat_id")) 381 | assert.Equal(t, "hello there", r.URL.Query().Get("text")) 382 | _, _ = w.Write([]byte(sendMessageResp)) 383 | }) 384 | defer ts.Close() 385 | tb, err := NewTelegram(TelegramParams{ 386 | Token: "xxxsupersecretxxx", 387 | apiPrefix: ts.URL + "/", 388 | }) 389 | require.NoError(t, err) 390 | 391 | err = tb.sendText(context.Background(), 123, "hello there") 392 | require.NoError(t, err) 393 | } 394 | 395 | const errorResp = `{"ok":false,"error_code":400,"description":"Very bad request"}` 396 | 397 | func TestTelegram_Error(t *testing.T) { 398 | ts := mockTelegramServer(func(w http.ResponseWriter, _ *http.Request) { 399 | w.WriteHeader(http.StatusBadRequest) 400 | _, _ = w.Write([]byte(errorResp)) 401 | }) 402 | defer ts.Close() 403 | tb, err := NewTelegram(TelegramParams{ 404 | Token: "xxxsupersecretxxx", 405 | apiPrefix: ts.URL + "/", 406 | }) 407 | require.NoError(t, err) 408 | 409 | _, err = tb.getUpdates(context.Background()) 410 | require.EqualError(t, err, "failed to fetch updates: unexpected telegram API status code 400, error: \"Very bad request\"") 411 | } 412 | 413 | func TestTelegram_TokenVerification(t *testing.T) { 414 | ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) { 415 | if strings.Contains(r.URL.String(), "sendMessage") { 416 | // respond normally to processUpdates attempt to send message back to user 417 | _, _ = w.Write([]byte("{}")) 418 | return 419 | } 420 | // responses to get updates calls to API 421 | _, _ = w.Write([]byte(getUpdatesResp)) 422 | }) 423 | defer ts.Close() 424 | 425 | tb, err := NewTelegram(TelegramParams{ 426 | Token: "good-token", 427 | apiPrefix: ts.URL + "/", 428 | }) 429 | require.NoError(t, err) 430 | assert.NotNil(t, tb) 431 | tb.AddToken("token", "user", "site", time.Now().Add(time.Minute)) 432 | assert.Len(t, tb.requests.data, 1) 433 | 434 | // wrong token 435 | tgID, site, err := tb.CheckToken("unknown token", "user") 436 | assert.Empty(t, tgID) 437 | assert.Empty(t, site) 438 | require.EqualError(t, err, "request is not found") 439 | 440 | // right token and user, not verified yet 441 | tgID, site, err = tb.CheckToken("token", "user") 442 | assert.Empty(t, tgID) 443 | assert.Empty(t, site) 444 | require.EqualError(t, err, "request is not verified yet") 445 | 446 | // confirm request 447 | authRequest, ok := tb.requests.data["token"] 448 | assert.True(t, ok) 449 | authRequest.confirmed = true 450 | authRequest.telegramID = "telegramID" 451 | tb.requests.data["token"] = authRequest 452 | 453 | // wrong user 454 | tgID, site, err = tb.CheckToken("token", "wrong user") 455 | assert.Empty(t, tgID) 456 | assert.Empty(t, site) 457 | require.EqualError(t, err, "user does not match original requester") 458 | 459 | // successful check 460 | tgID, site, err = tb.CheckToken("token", "user") 461 | require.NoError(t, err) 462 | assert.Equal(t, "telegramID", tgID) 463 | assert.Equal(t, "site", site) 464 | 465 | // expired token 466 | tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute)) 467 | tgID, site, err = tb.CheckToken("expired token", "user") 468 | assert.Empty(t, tgID) 469 | assert.Empty(t, site) 470 | require.EqualError(t, err, "request expired") 471 | assert.Empty(t, tb.requests.data) 472 | 473 | // expired token, cleaned up by the cleanup 474 | tb.apiPollInterval = time.Millisecond * 15 475 | tb.expiredCleanupInterval = time.Millisecond * 10 476 | ctx, cancel := context.WithCancel(context.Background()) 477 | go tb.Run(ctx) 478 | assert.Eventually(t, func() bool { 479 | return tb.ProcessUpdate(ctx, "").Error() == "the Run goroutine should not be used with ProcessUpdate" 480 | }, time.Millisecond*100, time.Millisecond*10, "ProcessUpdate should not work same time as Run") 481 | tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute)) 482 | tb.requests.RLock() 483 | assert.Len(t, tb.requests.data, 1) 484 | tb.requests.RUnlock() 485 | time.Sleep(tb.expiredCleanupInterval * 2) 486 | tb.requests.RLock() 487 | assert.Empty(t, tb.requests.data) 488 | tb.requests.RUnlock() 489 | cancel() 490 | // give enough time for Run() to finish 491 | time.Sleep(tb.expiredCleanupInterval) 492 | } 493 | 494 | const getMeResp = `{"ok": true, 495 | "result": { 496 | "first_name": "comments_test", 497 | "id": 707381019, 498 | "is_bot": true, 499 | "username": "remark42_test_bot" 500 | }}` 501 | 502 | func mockTelegramServer(h http.HandlerFunc) *httptest.Server { 503 | if h != nil { 504 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 505 | if strings.Contains(r.URL.String(), "getMe") { 506 | _, _ = w.Write([]byte(getMeResp)) 507 | return 508 | } 509 | h(w, r) 510 | })) 511 | } 512 | router := chi.NewRouter() 513 | router.Get("/good-token/getMe", func(w http.ResponseWriter, _ *http.Request) { 514 | _, _ = w.Write([]byte(getMeResp)) 515 | }) 516 | router.Get("/empty-json/getMe", func(w http.ResponseWriter, _ *http.Request) { 517 | _, _ = w.Write([]byte(`{}`)) 518 | }) 519 | router.Get("/non-json-resp/getMe", func(w http.ResponseWriter, _ *http.Request) { 520 | _, _ = w.Write([]byte(`not-a-json`)) 521 | }) 522 | router.Get("/404/getMe", func(w http.ResponseWriter, _ *http.Request) { 523 | w.WriteHeader(404) 524 | }) 525 | 526 | router.Post("/good-token/sendMessage", func(w http.ResponseWriter, _ *http.Request) { 527 | _, _ = w.Write([]byte(`{"ok": true}`)) 528 | }) 529 | 530 | return httptest.NewServer(router) 531 | } 532 | -------------------------------------------------------------------------------- /webhook.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | const webhookTimeOut = 5000 * time.Millisecond 15 | 16 | // WebhookParams contain settings for webhook notifications 17 | type WebhookParams struct { 18 | Timeout time.Duration 19 | Headers []string // headers in format "header:value" 20 | } 21 | 22 | // Webhook notifications client 23 | type Webhook struct { 24 | WebhookParams 25 | webhookClient webhookClient 26 | } 27 | 28 | // webhookClient defines an interface of client for webhook 29 | type webhookClient interface { 30 | Do(*http.Request) (*http.Response, error) 31 | } 32 | 33 | // NewWebhook makes Webhook 34 | func NewWebhook(params WebhookParams) *Webhook { 35 | res := &Webhook{WebhookParams: params} 36 | 37 | if res.Timeout == 0 { 38 | res.Timeout = webhookTimeOut 39 | } 40 | 41 | res.webhookClient = &http.Client{Timeout: res.Timeout} 42 | 43 | return res 44 | } 45 | 46 | // Send sends Webhook notification. Destination field is expected to have http:// or https:// schema. 47 | // 48 | // Example: 49 | // 50 | // - https://example.com/webhook 51 | func (wh *Webhook) Send(ctx context.Context, destination, text string) error { 52 | payload := bytes.NewBufferString(text) 53 | httpReq, err := http.NewRequestWithContext(ctx, "POST", destination, payload) 54 | if err != nil { 55 | return fmt.Errorf("unable to create webhook request: %w", err) 56 | } 57 | 58 | for _, h := range wh.Headers { 59 | elems := strings.Split(h, ":") 60 | if len(elems) != 2 { 61 | continue 62 | } 63 | httpReq.Header.Set(strings.TrimSpace(elems[0]), strings.TrimSpace(elems[1])) 64 | } 65 | 66 | resp, err := wh.webhookClient.Do(httpReq) 67 | if err != nil { 68 | return fmt.Errorf("webhook request failed: %w", err) 69 | } 70 | defer resp.Body.Close() 71 | 72 | if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { 73 | errMsg := fmt.Sprintf("webhook request failed with non-OK status code: %d", resp.StatusCode) 74 | respBody, e := io.ReadAll(resp.Body) 75 | if e != nil { 76 | return errors.New(errMsg) 77 | } 78 | return fmt.Errorf("%s, body: %s", errMsg, respBody) 79 | } 80 | 81 | return nil 82 | } 83 | 84 | // Schema returns schema prefix supported by this client 85 | func (wh *Webhook) Schema() string { 86 | return "http" 87 | } 88 | 89 | // String describes the webhook instance 90 | func (wh *Webhook) String() string { 91 | str := fmt.Sprintf("webhook notification with timeout %s", wh.Timeout) 92 | if wh.Headers != nil { 93 | str += fmt.Sprintf(" and headers %v", wh.Headers) 94 | } 95 | return str 96 | } 97 | -------------------------------------------------------------------------------- /webhook_test.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | type funcWebhookClient func(*http.Request) (*http.Response, error) 17 | 18 | func (c funcWebhookClient) Do(r *http.Request) (*http.Response, error) { 19 | return c(r) 20 | } 21 | 22 | type errReader struct { 23 | } 24 | 25 | func (errReader) Read(_ []byte) (n int, err error) { 26 | return 0, errors.New("test error") 27 | } 28 | 29 | func assertNoErrorWithStatus(t *testing.T, wh *Webhook, status int) { 30 | t.Run(fmt.Sprintf("HTTP-Status %d", status), func(t *testing.T) { 31 | wh.webhookClient = funcWebhookClient(func(*http.Request) (*http.Response, error) { 32 | return &http.Response{ 33 | StatusCode: status, 34 | Body: io.NopCloser(errReader{}), 35 | }, nil 36 | }) 37 | err := wh.Send(context.Background(), "http:/example.org/url", "") 38 | assert.NoError(t, err) 39 | }) 40 | } 41 | 42 | func assertErrorWithStatus(t *testing.T, wh *Webhook, status int) { 43 | t.Run(fmt.Sprintf("HTTP-Status %d", status), func(t *testing.T) { 44 | wh.webhookClient = funcWebhookClient(func(*http.Request) (*http.Response, error) { 45 | return &http.Response{ 46 | StatusCode: status, 47 | Body: io.NopCloser(errReader{}), 48 | }, nil 49 | }) 50 | err := wh.Send(context.Background(), "http:/example.org/url", "") 51 | assert.Error(t, err) 52 | }) 53 | } 54 | 55 | func TestWebhook_Send(t *testing.T) { 56 | // empty header to check wrong header handling case 57 | wh := NewWebhook(WebhookParams{Headers: []string{"Content-Type:application/json,text/plain", ""}}) 58 | assert.NotNil(t, wh) 59 | 60 | t.Run("OK with JSON response", func(t *testing.T) { 61 | wh.webhookClient = funcWebhookClient(func(r *http.Request) (*http.Response, error) { 62 | assert.Len(t, r.Header, 1) 63 | assert.Equal(t, "application/json,text/plain", r.Header.Get("Content-Type")) 64 | 65 | return &http.Response{ 66 | StatusCode: http.StatusOK, 67 | Body: io.NopCloser(bytes.NewBufferString("")), 68 | }, nil 69 | }) 70 | err := wh.Send(context.Background(), "https://example.org/webhook", "some_text") 71 | assert.NoError(t, err) 72 | }) 73 | 74 | t.Run("No context", func(t *testing.T) { 75 | err := wh.Send(nil, "https://example.org/webhook", "some_text") //nolint 76 | require.Error(t, err) 77 | assert.Contains(t, err.Error(), "unable to create webhook request") 78 | }) 79 | 80 | t.Run("Failed request", func(t *testing.T) { 81 | wh.webhookClient = funcWebhookClient(func(*http.Request) (*http.Response, error) { 82 | return nil, errors.New("request failed") 83 | }) 84 | err := wh.Send(context.Background(), "https://not-existing-url.net", "some_text") 85 | require.Error(t, err) 86 | assert.Contains(t, err.Error(), "webhook request failed") 87 | }) 88 | 89 | t.Run("Not found with json response", func(t *testing.T) { 90 | wh.webhookClient = funcWebhookClient(func(*http.Request) (*http.Response, error) { 91 | return &http.Response{ 92 | StatusCode: http.StatusNotFound, 93 | Body: io.NopCloser(bytes.NewBufferString("not found")), 94 | }, nil 95 | }) 96 | err := wh.Send(context.Background(), "http:/example.org/invalid-url", "some_text") 97 | require.Error(t, err) 98 | assert.Contains(t, err.Error(), "non-OK status code: 404, body: not found") 99 | }) 100 | 101 | t.Run("Not found with no response", func(t *testing.T) { 102 | wh.webhookClient = funcWebhookClient(func(*http.Request) (*http.Response, error) { 103 | return &http.Response{ 104 | StatusCode: http.StatusNotFound, 105 | Body: io.NopCloser(errReader{}), 106 | }, nil 107 | }) 108 | err := wh.Send(context.Background(), "http:/example.org/invalid-url", "some_text") 109 | require.Error(t, err) 110 | assert.Contains(t, err.Error(), "non-OK status code: 404") 111 | assert.NotContains(t, err.Error(), "body") 112 | }) 113 | 114 | assertErrorWithStatus(t, wh, http.StatusOK-1) 115 | assertNoErrorWithStatus(t, wh, http.StatusOK) 116 | assertNoErrorWithStatus(t, wh, http.StatusNoContent) 117 | assertNoErrorWithStatus(t, wh, http.StatusMultipleChoices-1) 118 | assertErrorWithStatus(t, wh, http.StatusMultipleChoices) 119 | assertErrorWithStatus(t, wh, http.StatusMultipleChoices+1) 120 | } 121 | 122 | func TestWebhook_String(t *testing.T) { 123 | wh := NewWebhook(WebhookParams{Headers: []string{"Content-Type:application/json,text/plain"}}) 124 | assert.NotNil(t, wh) 125 | 126 | str := wh.String() 127 | assert.Equal(t, "webhook notification with timeout 5s and headers [Content-Type:application/json,text/plain]", str) 128 | } 129 | --------------------------------------------------------------------------------