├── LICENSE ├── README.md ├── chat ├── attachment.go ├── attachment_test.go ├── field.go ├── field_test.go ├── message.go └── message_test.go ├── cmd └── slackit │ ├── main.go │ └── main_test.go ├── errors.go ├── errors_test.go ├── interfaces.go ├── lrhook ├── hook.go └── hook_test.go ├── response.go ├── response_test.go ├── test ├── client.go ├── client_test.go └── consts.go └── webhook ├── client.go └── client_test.go /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2016, Multiplay 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slack [![Go Report Card](https://goreportcard.com/badge/github.com/multiplay/go-slack)](https://goreportcard.com/report/github.com/multiplay/go-slack) [![License](https://img.shields.io/badge/license-BSD-blue.svg)](https://github.com/multiplay/go-slack/blob/master/LICENSE) [![GoDoc](https://godoc.org/github.com/multiplay/go-slack?status.svg)](https://godoc.org/github.com/multiplay/go-slack) [![Build Status](https://travis-ci.org/multiplay/go-slack.svg?branch=master)](https://travis-ci.org/multiplay/go-slack) 2 | 3 | go-slack is a [Go](http://golang.org/) library for the [Slack API](https://api.slack.com/). 4 | 5 | Features 6 | -------- 7 | * [Slack Webhook](https://api.slack.com/incoming-webhooks) Support. 8 | * [Slack chat.postMessage](https://api.slack.com/methods/chat.postMessage) Support. 9 | * Client Interface - Use alternative implementations - currently webhook is the only client. 10 | * [Logrus Hook](https://github.com/sirupsen/logrus) Support - Automatically send messages to [Slack](https://slack.com) when using a [Logrus](https://github.com/sirupsen/logrus) logger. 11 | 12 | Installation 13 | ------------ 14 | ```sh 15 | go get -u github.com/multiplay/go-slack 16 | ``` 17 | 18 | Examples 19 | -------- 20 | 21 | The simplest way to use go-slack is to create a webhook client and send chat messages using it e.g. 22 | ```go 23 | package main 24 | 25 | import ( 26 | "github.com/multiplay/go-slack/chat" 27 | "github.com/multiplay/go-slack/webhook" 28 | ) 29 | 30 | func main() { 31 | c := webhook.New("https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX") 32 | m := &chat.Message{Text: "test message"} 33 | m.Send(c) 34 | } 35 | ``` 36 | 37 | If your using [logrus](https://github.com/sirupsen/logrus) you can use the webhook to post to slack based on your logging e.g. 38 | ```go 39 | package main 40 | 41 | import ( 42 | "github.com/multiplay/go-slack/lrhook" 43 | "github.com/multiplay/go-slack/chat" 44 | "github.com/sirupsen/logrus" 45 | ) 46 | 47 | func main() { 48 | cfg := lrhook.Config{ 49 | MinLevel: logrus.ErrorLevel, 50 | Message: chat.Message{ 51 | Channel: "#slack-testing", 52 | IconEmoji: ":ghost:", 53 | }, 54 | } 55 | h := lrhook.New(cfg, "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX") 56 | logrus.SetFormatter(&logrus.JSONFormatter{}) 57 | logrus.SetLevel(logrus.InfoLevel) 58 | logrus.AddHook(h) 59 | logrus.Error("my error") 60 | } 61 | ``` 62 | 63 | Documentation 64 | ------------- 65 | - [GoDoc API Reference](http://godoc.org/github.com/multiplay/go-slack). 66 | 67 | License 68 | ------- 69 | go-slack is available under the [BSD 2-Clause License](https://opensource.org/licenses/BSD-2-Clause). 70 | -------------------------------------------------------------------------------- /chat/attachment.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | // Attachment is a slack chat message attachment. 4 | // See: https://api.slack.com/docs/message-attachments 5 | type Attachment struct { 6 | // Fallback is the plain-text summary of the attachment. 7 | Fallback string `json:"fallback"` 8 | 9 | // Color is a color indicating the classification of the message. 10 | Color string `json:"color"` 11 | 12 | // PreText is optional text that appears above the message attachment block. 13 | PreText string `json:"pretext,omitempty"` 14 | 15 | // AuthorName is the small text used to display the author's name. 16 | AuthorName string `json:"author_name,omitempty"` 17 | 18 | // AuthorLink is a valid URL that will hyperlink the author_name text mentioned above. 19 | AuthorLink string `json:"author_link,omitempty"` 20 | 21 | // AuthorIcon is a valid URL that displays a small 16x16px image to the left of the author_name text. 22 | AuthorIcon string `json:"author_icon,omitempty"` 23 | 24 | // Title is displayed as larger, bold text near the top of a message attachment. 25 | Title string `json:"title,omitempty"` 26 | 27 | // TitleLink is the optional url of the hyperlink to be used for the title. 28 | TitleLink string `json:"title_link,omitempty"` 29 | 30 | // Text is the main text of the attachment. 31 | Text string `json:"text,omitempty"` 32 | 33 | // Fields contains optional fields to be displayed in the in a table inside the attachment. 34 | Fields []*Field `json:"fields,omitempty"` 35 | 36 | // MarkdownIn enables Markdown support. Valid values are ["pretext", "text", "fields"]. 37 | // Setting "fields" will enable markup formatting for the value of each field. 38 | MarkdownIn []string `json:"mrkdwn_in,omitempty"` 39 | 40 | // ImageURL is the URL to an image file that will be displayed inside the attachment. 41 | ImageURL string `json:"image_url"` 42 | 43 | // ThumbURL is the URL to an image file that will be displayed as a thumbnail on the right side of a attachment. 44 | ThumbURL string `json:"ThumbURL"` 45 | 46 | // Footer is optional text to help contextualize and identify an attachment (300 chars max). 47 | Footer string `json:"footer"` 48 | 49 | // FooterIcon is the URL to a small icon beside your footer text. 50 | FooterIcon string `json:"footer_icon"` 51 | 52 | // TimeStamp if set is the epoch time that will display as part of the attachment's footer. 53 | TimeStamp int `json:"ts,omitempty"` 54 | } 55 | 56 | // NewField creates a new field, adds it to the attachment and then returns it. 57 | func (a *Attachment) NewField(title, value string) *Field { 58 | f := NewField(title, value) 59 | a.AddField(f) 60 | 61 | return f 62 | } 63 | 64 | // AddField adds f to the attachments fields. 65 | func (a *Attachment) AddField(f *Field) { 66 | a.Fields = append(a.Fields, f) 67 | } 68 | -------------------------------------------------------------------------------- /chat/attachment_test.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAttachmentNewField(t *testing.T) { 10 | a := &Attachment{} 11 | title := "key" 12 | val := "val" 13 | f := a.NewField(title, val) 14 | 15 | assert.Equal(t, title, f.Title) 16 | assert.Equal(t, val, f.Value) 17 | assert.True(t, f.Short) 18 | assert.Equal(t, 1, len(a.Fields)) 19 | } 20 | 21 | func TestAttachmentNewFieldLong(t *testing.T) { 22 | a := &Attachment{} 23 | title := "key" 24 | val := "val which is longer than 20 characters" 25 | f := a.NewField(title, val) 26 | 27 | assert.Equal(t, title, f.Title) 28 | assert.Equal(t, val, f.Value) 29 | assert.False(t, f.Short) 30 | assert.Equal(t, 1, len(a.Fields)) 31 | } 32 | -------------------------------------------------------------------------------- /chat/field.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | var ( 4 | // ShortFieldLen is the length of a field value which is to be deemed short. 5 | ShortFieldLen = 20 6 | ) 7 | 8 | // Field will be displayed in a table inside the message attachment. 9 | type Field struct { 10 | // Title is shown as a bold heading above the value text. 11 | // It cannot contain markup and will be escaped for you. 12 | Title string `json:"title"` 13 | 14 | // Value is the text value of the field. 15 | // It may contain standard message markup and must be escaped as normal. 16 | // May be multi-line. 17 | Value string `json:"value"` 18 | 19 | // Short is an optional flag indicating whether the value is short enough to be displayed side-by-side with other values. 20 | Short bool `json:"short"` 21 | } 22 | 23 | // NewField returns a fully initialised field with Short set to true if the length of value is less than ShortFieldLen. 24 | func NewField(title, value string) *Field { 25 | return &Field{Title: title, Value: value, Short: len(value) < ShortFieldLen} 26 | } 27 | -------------------------------------------------------------------------------- /chat/field_test.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNewField(t *testing.T) { 10 | title := "key" 11 | val := "val" 12 | f := NewField(title, val) 13 | 14 | assert.Equal(t, title, f.Title) 15 | assert.Equal(t, val, f.Value) 16 | assert.True(t, f.Short) 17 | } 18 | 19 | func TestNewFieldLong(t *testing.T) { 20 | title := "key" 21 | val := "val which is longer than 20 characters" 22 | f := NewField(title, val) 23 | 24 | assert.Equal(t, title, f.Title) 25 | assert.Equal(t, val, f.Value) 26 | assert.False(t, f.Short) 27 | } 28 | -------------------------------------------------------------------------------- /chat/message.go: -------------------------------------------------------------------------------- 1 | // Package chat implements the types needed to post chat messages to slack. 2 | // 3 | // See: https://api.slack.com/methods/chat.postMessage 4 | package chat 5 | 6 | import ( 7 | "github.com/multiplay/go-slack" 8 | ) 9 | 10 | const ( 11 | // PostMessageEndpoint is the slack URL endpoint for chat post Message 12 | PostMessageEndpoint = "https://slack.com/api/chat.postMessage" 13 | ) 14 | 15 | // Message represents slack chat message. 16 | type Message struct { 17 | // Token is the Authentication token (Requires scope: chat:write:bot or chat:write:user). 18 | Token string `json:"token,omitempty"` 19 | 20 | // Channel is the channel, private group, or IM channel to send message to. 21 | Channel string `json:"channel,omitempty"` 22 | 23 | // Text of the message to send. 24 | Text string `json:"text,omitempty"` 25 | 26 | // Markdown enables Markdown support. 27 | Markdown bool `json:"mrkdwn,omitempty"` 28 | 29 | // Parse changes how messages are treated. 30 | Parse string `json:"parse,omitempty"` 31 | 32 | // LinkNames causes link channel names and usernames to be found and linked. 33 | LinkNames int `json:"link_name,omitempty"` 34 | 35 | // Attachments is structured message attachments 36 | Attachments []*Attachment `json:"attachments,omitempty"` 37 | 38 | // UnfurLinks enables unfurling of primarily text-based content. 39 | UnfurlLinks bool `json:"unfurl_links,omitempty"` 40 | 41 | // UnfurlMedia if set to false disables unfurling of media content. 42 | UnfurlMedia bool `json:"unfurl_media,omitempty"` 43 | 44 | // Username set your bot's user name. 45 | // Must be used in conjunction with AsUser set to false, otherwise ignored. 46 | Username string `json:"username,omitempty"` 47 | 48 | // AsUser pass true to post the message as the authed user, instead of as a bot. 49 | AsUser bool `json:"as_user"` 50 | 51 | // IconURL is the URL to an image to use as the icon for this message. 52 | // Must be used in conjunction with AsUser set to false, otherwise ignored. 53 | IconURL string `json:"icon_url,omitempty"` 54 | 55 | // IconEmoji is the emoji to use as the icon for this message. 56 | // Overrides IconURL. 57 | // Must be used in conjunction with AsUser set to false, otherwise ignored. 58 | IconEmoji string `json:"icon_emoji,omitempty"` 59 | 60 | // ThreadTS is the timestamp (ts) of the parent message to reply to a thread. 61 | ThreadTS string `json:"thread_ts,omitempty"` 62 | 63 | // ReplyBroadcast used in conjunction with thread_ts and indicates whether reply 64 | // should be made visible to everyone in the channel or conversation. 65 | ReplyBroadcast bool `json:"reply_broadcast,omitempty"` 66 | } 67 | 68 | // NewAttachment creates a new empty attachment adds it to the message and returns it. 69 | func (m *Message) NewAttachment() *Attachment { 70 | a := &Attachment{} 71 | m.AddAttachment(a) 72 | 73 | return a 74 | } 75 | 76 | // AddAttachment adds a to the message's attachments. 77 | func (m *Message) AddAttachment(a *Attachment) { 78 | m.Attachments = append(m.Attachments, a) 79 | } 80 | 81 | // Send sends the msg to slack using the client c. 82 | func (m *Message) Send(c slack.Client) (*MessageResponse, error) { 83 | resp := &MessageResponse{} 84 | if err := c.Send(PostMessageEndpoint, m, resp); err != nil { 85 | return nil, err 86 | } 87 | 88 | return resp, nil 89 | } 90 | 91 | // MessageResponse the response returned from the post message call. 92 | type MessageResponse struct { 93 | slack.Response 94 | Timestamp string `json:"ts,omitempty"` 95 | Channel string `json:"channel,omitempty"` 96 | Message *Message `json:"message,omitempty"` 97 | } 98 | -------------------------------------------------------------------------------- /chat/message_test.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/multiplay/go-slack/test" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestMessageNewAttachment(t *testing.T) { 12 | m := &Message{} 13 | a := m.NewAttachment() 14 | 15 | if !assert.Equal(t, 1, len(m.Attachments)) { 16 | return 17 | } 18 | assert.Equal(t, a, m.Attachments[0]) 19 | } 20 | 21 | func TestMessageSend(t *testing.T) { 22 | c := test.New() 23 | m := &Message{Text: "test message"} 24 | resp, err := m.Send(c) 25 | if !assert.NoError(t, err) { 26 | return 27 | } 28 | assert.True(t, resp.OK) 29 | } 30 | 31 | func TestMessageSendError(t *testing.T) { 32 | c := test.NewError("my error") 33 | m := &Message{Text: "test message"} 34 | _, err := m.Send(c) 35 | assert.Error(t, err) 36 | } 37 | 38 | func TestAddAttachment(t *testing.T) { 39 | m := &Message{} 40 | a := &Attachment{} 41 | m.AddAttachment(a) 42 | 43 | if !assert.Equal(t, 1, len(m.Attachments)) { 44 | return 45 | } 46 | assert.Equal(t, a, m.Attachments[0]) 47 | } 48 | -------------------------------------------------------------------------------- /cmd/slackit/main.go: -------------------------------------------------------------------------------- 1 | // slackit is a command line golang slack client. 2 | // 3 | // It reads a JSON formatted message from the file specified by -src 4 | // and posts it to slack using the webhook url specified by -hook . 5 | // 6 | // By default slackit reads the message from stdin. 7 | // 8 | // Example: 9 | // slackit -hook https://hooks.slack.com/services/T00/B00/XXX -src msg.json 10 | package main 11 | 12 | import ( 13 | "encoding/json" 14 | "flag" 15 | "log" 16 | "os" 17 | 18 | "github.com/multiplay/go-slack/chat" 19 | "github.com/multiplay/go-slack/webhook" 20 | ) 21 | 22 | // exit is the function used to exit on error its a variable so it can be easily overridden for tests. 23 | var exit = os.Exit 24 | 25 | func main() { 26 | var src = flag.String("src", "-", "reads the message from the specified file or stdin if '-'") 27 | var hook = flag.String("hook", "", "the hook url to use") 28 | 29 | flag.Usage = func() { 30 | log.Printf("usage %s -hook [-src ]\nflags:\n", os.Args[0]) 31 | flag.PrintDefaults() 32 | } 33 | 34 | flag.Parse() 35 | if *hook == "" { 36 | flag.Usage() 37 | exit(1) 38 | } 39 | 40 | var f *os.File 41 | 42 | if *src == "-" { 43 | f = os.Stdin 44 | } else { 45 | var err error 46 | if f, err = os.Open(*src); err != nil { 47 | log.Println("failed to open source: ", err) 48 | exit(1) 49 | } 50 | } 51 | 52 | dec := json.NewDecoder(f) 53 | m := &chat.Message{} 54 | if err := dec.Decode(m); err != nil { 55 | log.Println("failed to decode message: ", err) 56 | exit(1) 57 | } 58 | 59 | c := webhook.New(*hook) 60 | if _, err := m.Send(c); err != nil { 61 | log.Println("failed to send message:", err) 62 | exit(1) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /cmd/slackit/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | "testing" 11 | 12 | "github.com/multiplay/go-slack/test" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func testSlackit(t *testing.T, hook, msg string) ([]byte, error) { 17 | args := make([]string, 0, 2) 18 | if hook != "" { 19 | args = append(args, "-hook", hook) 20 | } 21 | 22 | switch { 23 | case msg == "invalid-file": 24 | args = append(args, "-src", "invalid-file") 25 | case msg != "": 26 | tmpfile, err := ioutil.TempFile("", "slackit-test") 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | 31 | defer os.Remove(tmpfile.Name()) 32 | 33 | if _, err := tmpfile.Write([]byte(msg)); err != nil { 34 | t.Fatal(err) 35 | } 36 | if err := tmpfile.Close(); err != nil { 37 | t.Fatal(err) 38 | } 39 | 40 | args = append(args, "-src", tmpfile.Name()) 41 | } 42 | 43 | var buf bytes.Buffer 44 | var err error 45 | 46 | exit = func(code int) { 47 | if code != 0 { 48 | err = fmt.Errorf("exit(%v)", code) 49 | } 50 | } 51 | os.Args = append([]string{"slackit"}, args...) 52 | flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) 53 | flag.CommandLine.SetOutput(&buf) 54 | log.SetOutput(&buf) 55 | main() 56 | 57 | return buf.Bytes(), err 58 | } 59 | 60 | func TestNoArgs(t *testing.T) { 61 | b, err := testSlackit(t, "", "") 62 | assert.Error(t, err) 63 | assert.Contains(t, string(b), "usage") 64 | } 65 | 66 | func TestMissingHook(t *testing.T) { 67 | b, err := testSlackit(t, "", "-") 68 | assert.Error(t, err) 69 | assert.Contains(t, string(b), "usage") 70 | } 71 | 72 | func TestInvalidMessage(t *testing.T) { 73 | b, err := testSlackit(t, test.Endpoint, "broken") 74 | assert.Error(t, err) 75 | assert.Contains(t, string(b), "failed to decode message") 76 | } 77 | 78 | func TestInvalidFile(t *testing.T) { 79 | b, err := testSlackit(t, test.Endpoint, "invalid-file") 80 | assert.Error(t, err) 81 | assert.Contains(t, string(b), "failed to decode message") 82 | } 83 | 84 | func TestSuccess(t *testing.T) { 85 | b, err := testSlackit(t, test.Endpoint, `{"text":"my message"}`) 86 | assert.NoError(t, err) 87 | assert.Empty(t, b) 88 | } 89 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Error represents an error from the Slack API. 8 | type Error struct { 9 | // StatusCode is the status code returned by the request. 10 | StatusCode int 11 | 12 | // Message is the message, if any, returned in the body. 13 | Message string 14 | } 15 | 16 | // NewError returns a new slack error with statuscode and msg. 17 | func NewError(statuscode int, msg string) *Error { 18 | return &Error{StatusCode: statuscode, Message: msg} 19 | } 20 | 21 | func (e *Error) Error() string { 22 | return fmt.Sprintf("slack: request failed statuscode: %v, message: %v", e.StatusCode, e.Message) 23 | } 24 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewError(t *testing.T) { 11 | errStr := "my error" 12 | code := http.StatusNotFound 13 | err := NewError(code, errStr) 14 | assert.Error(t, err) 15 | assert.Equal(t, code, err.StatusCode) 16 | assert.Contains(t, err.Error(), "failed") 17 | } 18 | -------------------------------------------------------------------------------- /interfaces.go: -------------------------------------------------------------------------------- 1 | // Package slack provides a generic interface for slack clients 2 | // and some basic types to enable the creation of slack clients. 3 | // 4 | // See the webhook sub directory for an example of such a client. 5 | package slack 6 | 7 | // Client represents a slack client. 8 | type Client interface { 9 | // Send sends the request to slack. 10 | Send(url string, message, response interface{}) error 11 | } 12 | 13 | // SendResponse is the interface that responses implement. 14 | type SendResponse interface { 15 | Ok() bool 16 | Err() string 17 | Warn() string 18 | } 19 | -------------------------------------------------------------------------------- /lrhook/hook.go: -------------------------------------------------------------------------------- 1 | // Package lrhook provides logrus hook for the Slack. 2 | // 3 | // It can post messages to slack based on the notification level of the 4 | // logrus entry including the ability to rate limit messages. 5 | // 6 | // See: https://godoc.org/github.com/sirupsen/logrus#Hook 7 | package lrhook 8 | 9 | import ( 10 | "fmt" 11 | 12 | "github.com/multiplay/go-slack" 13 | "github.com/multiplay/go-slack/chat" 14 | "github.com/multiplay/go-slack/webhook" 15 | 16 | "github.com/sirupsen/logrus" 17 | "golang.org/x/time/rate" 18 | ) 19 | 20 | var ( 21 | // DefaultLevelColors is the default level colors used if none are present in the configuration. 22 | DefaultLevelColors = map[string]string{ 23 | "trace": "#797979", 24 | "debug": "#9B30FF", 25 | "info": "good", 26 | "warning": "danger", 27 | "error": "danger", 28 | "fatal": "panic", 29 | "panic": "panic", 30 | } 31 | 32 | // DefaultUnknownColor is the default UnknownColor if one is not present in the configuration. 33 | DefaultUnknownColor = "warning" 34 | ) 35 | 36 | // Config is the configuration of a slack logrus.Hook. 37 | type Config struct { 38 | // MinLevel is the minimal level at which the hook will trigger. 39 | MinLevel logrus.Level 40 | 41 | // LevelColors is a hash of logrus level names to colors used for the attachment in the messages. 42 | LevelColors map[string]string 43 | 44 | // AttachmentText is the text message used for the attachment when fields are present in the log entry. 45 | AttachmentText string 46 | 47 | // UnknownColor is the color to use if there is no match for the log level in LevelColors. 48 | UnknownColor string 49 | 50 | // Async if true then messages are sent to slack asynchronously. 51 | // This means that Fire will never return an error. 52 | Async bool 53 | 54 | // Limit if none zero limits the number of messages to Limit posts per second. 55 | Limit rate.Limit 56 | 57 | // Burst sets the burst limit. 58 | // Ignored if Limit is zero. 59 | Burst int 60 | 61 | // Message defines the details of the messages sent from the hook. 62 | Message chat.Message 63 | 64 | // Attachment defines the details of the attachment sent from the hook. 65 | // Field Text - Will be set to that of log entry Message. 66 | // Field Fields - Will be created to match the log entry Fields. 67 | // Field Color - Will be set according to the LevelColors or UnknownColor if a match is not found.. 68 | Attachment chat.Attachment 69 | } 70 | 71 | // Hook is a logrus hook that sends messages to Slack. 72 | type Hook struct { 73 | Config 74 | client slack.Client 75 | limiter *rate.Limiter 76 | } 77 | 78 | // SetConfigDefaults sets defaults on the configuration if needed to ensure the cfg is valid. 79 | func SetConfigDefaults(cfg *Config) { 80 | if len(cfg.LevelColors) == 0 { 81 | cfg.LevelColors = DefaultLevelColors 82 | } 83 | if cfg.UnknownColor == "" { 84 | cfg.UnknownColor = DefaultUnknownColor 85 | } 86 | } 87 | 88 | // New returns a new Hook with the given configuration that posts messages using the webhook URL. 89 | // It ensures that the cfg is valid by calling SetConfigDefaults on the cfg. 90 | func New(cfg Config, url string) *Hook { 91 | return NewClient(cfg, webhook.New(url)) 92 | } 93 | 94 | // NewClient returns a new Hook with the given configuration using the slack.Client c. 95 | // It ensures that the cfg is valid by calling SetConfigDefaults on the cfg. 96 | func NewClient(cfg Config, client slack.Client) *Hook { 97 | SetConfigDefaults(&cfg) 98 | 99 | c := &Hook{Config: cfg, client: client} 100 | if cfg.Limit != 0 { 101 | c.limiter = rate.NewLimiter(cfg.Limit, cfg.Burst) 102 | } 103 | 104 | return c 105 | } 106 | 107 | // Levels implements logrus.Hook. 108 | // It returns the logrus.Level's that are lower or equal to that of MinLevel. 109 | // This means setting MinLevel to logrus.ErrorLevel will send slack messages for log entries at Error, Fatal and Panic. 110 | func (sh *Hook) Levels() []logrus.Level { 111 | lvls := make([]logrus.Level, 0, len(logrus.AllLevels)) 112 | for _, l := range logrus.AllLevels { 113 | if sh.MinLevel >= l { 114 | lvls = append(lvls, l) 115 | } 116 | } 117 | 118 | return lvls 119 | } 120 | 121 | // Fire implements logrus.Hook. 122 | // It sends a slack message for the log entry e. 123 | func (sh *Hook) Fire(e *logrus.Entry) error { 124 | if sh.limiter != nil && !sh.limiter.Allow() { 125 | // We've hit the configured limit, just ignore. 126 | return nil 127 | } 128 | 129 | m := sh.Message 130 | a := sh.Attachment 131 | m.AddAttachment(&a) 132 | a.Fallback = e.Message 133 | a.Color = sh.LevelColors[e.Level.String()] 134 | if a.Color == "" { 135 | a.Color = sh.UnknownColor 136 | } 137 | a.Text = e.Message 138 | for k, v := range e.Data { 139 | a.NewField(k, fmt.Sprint(v)) 140 | } 141 | 142 | if sh.Async { 143 | go m.Send(sh.client) 144 | return nil 145 | } 146 | 147 | _, err := m.Send(sh.client) 148 | return err 149 | } 150 | -------------------------------------------------------------------------------- /lrhook/hook_test.go: -------------------------------------------------------------------------------- 1 | package lrhook 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "strings" 8 | "sync" 9 | "testing" 10 | "time" 11 | 12 | "github.com/multiplay/go-slack/chat" 13 | "github.com/multiplay/go-slack/test" 14 | 15 | "github.com/sirupsen/logrus" 16 | "github.com/stretchr/testify/assert" 17 | ) 18 | 19 | // sbuf is a minimal locked bytes.Buffer so its thread safe. 20 | type sbuf struct { 21 | mtx sync.RWMutex 22 | buf bytes.Buffer 23 | } 24 | 25 | // Write implements io.Writer. 26 | func (b *sbuf) Write(p []byte) (n int, err error) { 27 | b.mtx.Lock() 28 | defer b.mtx.Unlock() 29 | 30 | return b.buf.Write(p) 31 | } 32 | 33 | // String implements Stringier. 34 | func (b *sbuf) String() string { 35 | b.mtx.RLock() 36 | defer b.mtx.RUnlock() 37 | 38 | return b.buf.String() 39 | } 40 | 41 | // Reset resets the buffer to be empty, but it retains the underlying storage for use by future writes. 42 | func (b *sbuf) Reset() { 43 | b.mtx.Lock() 44 | defer b.mtx.Unlock() 45 | 46 | b.buf.Reset() 47 | } 48 | 49 | var ( 50 | log = &sbuf{} 51 | stderr = &sbuf{} 52 | ) 53 | 54 | func init() { 55 | // Capture stderr here so logrus uses it to log fire failures. 56 | r, w, _ := os.Pipe() 57 | os.Stderr = w 58 | go io.Copy(stderr, r) 59 | } 60 | 61 | func resetBufs() { 62 | log.Reset() 63 | stderr.Reset() 64 | } 65 | 66 | func newHookedLogger(h logrus.Hook) *logrus.Logger { 67 | l := logrus.New() 68 | l.Out = log 69 | l.Hooks.Add(h) 70 | 71 | return l 72 | } 73 | 74 | // hookWait sleeps for a short period to ensure the hook has had chance to fire. 75 | func hookWait() { 76 | time.Sleep(time.Millisecond * 50) 77 | } 78 | 79 | func TestHookError(t *testing.T) { 80 | cfg := Config{MinLevel: logrus.InfoLevel} 81 | h := New(cfg, "hdds://broken") 82 | 83 | logger := newHookedLogger(h) 84 | logger.Info("my info") 85 | 86 | hookWait() 87 | 88 | assert.Contains(t, log.String(), "my info") 89 | assert.Contains(t, stderr.String(), "Failed to fire hook") 90 | 91 | resetBufs() 92 | } 93 | 94 | func TestNew(t *testing.T) { 95 | cfg := Config{MinLevel: logrus.InfoLevel} 96 | h := New(cfg, test.Endpoint) 97 | delete(h.LevelColors, "warning") 98 | 99 | logger := newHookedLogger(h) 100 | logger.Info("my info") 101 | logger.WithField("myfield", "some data").Warn("my warn") 102 | 103 | hookWait() 104 | 105 | assert.Contains(t, log.String(), "my info") 106 | assert.Contains(t, log.String(), "my warn") 107 | assert.Empty(t, stderr.String()) 108 | 109 | resetBufs() 110 | } 111 | 112 | func TestLimitPass(t *testing.T) { 113 | cfg := Config{ 114 | MinLevel: logrus.WarnLevel, 115 | Limit: 10, 116 | Burst: 20, 117 | } 118 | h := New(cfg, "hdds://broken") 119 | 120 | logger := newHookedLogger(h) 121 | logger.Warn("my warn") 122 | logger.Warn("my warn") 123 | 124 | hookWait() 125 | 126 | assert.Contains(t, log.String(), "my warn") 127 | assert.Equal(t, 2, strings.Count(stderr.String(), "Failed to fire hook")) 128 | 129 | resetBufs() 130 | } 131 | 132 | func TestLimitLimited(t *testing.T) { 133 | cfg := Config{ 134 | MinLevel: logrus.WarnLevel, 135 | Limit: 1, 136 | Burst: 1, 137 | } 138 | h := New(cfg, "hdds://broken") 139 | 140 | logger := newHookedLogger(h) 141 | logger.Warn("my warn") 142 | logger.Warn("my warn") 143 | 144 | hookWait() 145 | 146 | assert.Contains(t, log.String(), "my warn") 147 | assert.Equal(t, 1, strings.Count(stderr.String(), "Failed to fire hook")) 148 | 149 | resetBufs() 150 | } 151 | 152 | func TestAsync(t *testing.T) { 153 | cfg := Config{ 154 | Async: true, 155 | MinLevel: logrus.WarnLevel, 156 | Limit: 10, 157 | Burst: 20, 158 | } 159 | h := New(cfg, "hdds://broken") 160 | 161 | logger := newHookedLogger(h) 162 | logger.Warn("my warn") 163 | 164 | hookWait() 165 | 166 | assert.Contains(t, log.String(), "my warn") 167 | // Async doesn't return errors 168 | assert.Empty(t, stderr.String()) 169 | 170 | resetBufs() 171 | } 172 | 173 | func ExampleNew() { 174 | cfg := Config{ 175 | MinLevel: logrus.ErrorLevel, 176 | Message: chat.Message{ 177 | Username: "My App", 178 | Channel: "#slack-testing", 179 | IconEmoji: ":ghost:", 180 | }, 181 | } 182 | h := New(cfg, "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX") 183 | logrus.SetFormatter(&logrus.JSONFormatter{}) 184 | logrus.SetLevel(logrus.InfoLevel) 185 | logrus.AddHook(h) 186 | logrus.WithFields(logrus.Fields{"field1": "test field", "field2": 1}).Error("test error") 187 | } 188 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | // Response is a generic response from slack which implements SendResponse. 4 | type Response struct { 5 | OK bool `json:"ok"` 6 | Error string `json:"error,omitempty"` 7 | Warning string `json:"warning,omitempty"` 8 | } 9 | 10 | // Ok implements SendResponse.Ok. 11 | func (r Response) Ok() bool { 12 | return r.OK 13 | } 14 | 15 | // Err implements SendResponse.Err. 16 | func (r Response) Err() string { 17 | return r.Error 18 | } 19 | 20 | // Warn implements SendResponse.Warn. 21 | func (r Response) Warn() string { 22 | return r.Warning 23 | } 24 | -------------------------------------------------------------------------------- /response_test.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestResponse(t *testing.T) { 10 | ok, err, warn := true, "my error", "my warn" 11 | r := &Response{OK: ok, Error: err, Warning: warn} 12 | assert.Equal(t, ok, r.Ok()) 13 | assert.Equal(t, err, r.Err()) 14 | assert.Equal(t, warn, r.Warn()) 15 | } 16 | -------------------------------------------------------------------------------- /test/client.go: -------------------------------------------------------------------------------- 1 | // Package test provides a slack client implementation which uses the slack api.test endpoint 2 | // so is suitable to testing. 3 | // 4 | // See: https://api.slack.com/methods/api.test 5 | package test 6 | 7 | import ( 8 | "net/url" 9 | 10 | "github.com/multiplay/go-slack/webhook" 11 | ) 12 | 13 | // New returns a new slack.Client that can be used for testing. 14 | func New() *webhook.Client { 15 | return webhook.New(Endpoint) 16 | } 17 | 18 | // NewError returns a new slack.Client that can be used for testing which errors with err. 19 | func NewError(err string) *webhook.Client { 20 | v := url.Values{"error": {err}} 21 | return webhook.New(Endpoint + "?" + v.Encode()) 22 | } 23 | -------------------------------------------------------------------------------- /test/client_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "testing" 7 | 8 | "github.com/multiplay/go-slack" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestNew(t *testing.T) { 14 | c := New() 15 | assert.Equal(t, c.URL, Endpoint) 16 | } 17 | 18 | func TestNewError(t *testing.T) { 19 | err := "my error" 20 | c := NewError(err) 21 | assert.Contains(t, c.URL, Endpoint) 22 | assert.Contains(t, c.URL, url.Values{"error": {err}}.Encode()) 23 | } 24 | 25 | func ExampleNew() { 26 | c := New() 27 | msg := struct { 28 | Param1 string 29 | Param2 int 30 | }{Param1: "my value", Param2: 20} 31 | 32 | resp := struct { 33 | slack.Response 34 | Args struct { 35 | Param1 string 36 | Param2 int 37 | } 38 | }{} 39 | if err := c.Send("", msg, resp); err != nil { 40 | // No error is expected here. 41 | fmt.Println("error:", err) 42 | } 43 | fmt.Println("response:", resp) 44 | } 45 | 46 | func ExampleNewError() { 47 | c := NewError("my error") 48 | msg := struct { 49 | Param1 string 50 | Param2 int 51 | }{Param1: "my value", Param2: 20} 52 | 53 | resp := struct { 54 | slack.Response 55 | Args struct { 56 | Param1 string 57 | Param2 int 58 | } 59 | }{} 60 | if err := c.Send("", msg, resp); err != nil { 61 | // An error is expected here. 62 | fmt.Println("error:", err) 63 | } 64 | fmt.Println("response:", resp) 65 | } 66 | -------------------------------------------------------------------------------- /test/consts.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | const ( 4 | // Endpoint is the slack endpoint which can be used for testing calling code. 5 | Endpoint = "https://slack.com/api/api.test" 6 | ) 7 | -------------------------------------------------------------------------------- /webhook/client.go: -------------------------------------------------------------------------------- 1 | // Package webhook provides a slack webhook client implementation. 2 | // 3 | // See: https://api.slack.com/incoming-webhooks 4 | package webhook 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "io" 10 | "io/ioutil" 11 | "net/http" 12 | "strings" 13 | 14 | "github.com/multiplay/go-slack" 15 | ) 16 | 17 | // Client is a slack webhook client for posting messages using a webhook URL. 18 | type Client struct { 19 | // URL is the webhook URL to use 20 | URL string 21 | } 22 | 23 | // New returns a new Client which sends request using the webhook URL. 24 | func New(url string) *Client { 25 | return &Client{URL: url} 26 | } 27 | 28 | // Send sends the request to slack using the webhook protocol. 29 | // The url parameter only exists to satisfy the slack.Client interface 30 | // and is not used by the webhook Client. 31 | func (c *Client) Send(url string, msg, resp interface{}) error { 32 | b, err := json.Marshal(msg) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | r, err := http.Post(c.URL, "application/json; charset=utf-8", bytes.NewReader(b)) 38 | if err != nil { 39 | return err 40 | } 41 | defer r.Body.Close() 42 | 43 | if r.StatusCode != http.StatusOK { 44 | b, _ := ioutil.ReadAll(r.Body) 45 | return slack.NewError(r.StatusCode, string(b)) 46 | } 47 | 48 | var body io.Reader 49 | if strings.HasPrefix(c.URL, "https://hooks.slack.com") { 50 | // Work around webhooks not returning JSON as originally documented 51 | // by treating all StatusOK as success. 52 | body = bytes.NewReader([]byte(`{"ok":true}`)) 53 | } else { 54 | // This is required for compatibility with API test endpoint. 55 | body = r.Body 56 | } 57 | 58 | dec := json.NewDecoder(body) 59 | if err := dec.Decode(resp); err != nil { 60 | return slack.NewError(r.StatusCode, err.Error()) 61 | } 62 | 63 | if sr, ok := resp.(slack.SendResponse); !ok { 64 | return slack.NewError(r.StatusCode, "not a response") 65 | } else if !sr.Ok() { 66 | return slack.NewError(r.StatusCode, sr.Err()) 67 | } 68 | 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /webhook/client_test.go: -------------------------------------------------------------------------------- 1 | package webhook_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/multiplay/go-slack" 8 | "github.com/multiplay/go-slack/chat" 9 | "github.com/multiplay/go-slack/test" 10 | . "github.com/multiplay/go-slack/webhook" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestNew(t *testing.T) { 16 | c := New(test.Endpoint) 17 | assert.Equal(t, c.URL, test.Endpoint) 18 | } 19 | 20 | func TestSend(t *testing.T) { 21 | c := New(test.Endpoint) 22 | resp := &slack.Response{} 23 | err := c.Send("", nil, resp) 24 | if !assert.NoError(t, err) { 25 | return 26 | } 27 | assert.True(t, resp.OK) 28 | } 29 | 30 | func TestSendError(t *testing.T) { 31 | errStr := "no_text" 32 | c := New(test.Endpoint + "?error=" + errStr) 33 | resp := &slack.Response{} 34 | err := c.Send("", nil, resp) 35 | if !assert.Error(t, err) { 36 | return 37 | } 38 | serr := err.(*slack.Error) 39 | assert.Equal(t, http.StatusOK, serr.StatusCode) 40 | assert.Equal(t, errStr, serr.Message) 41 | assert.False(t, resp.OK) 42 | } 43 | 44 | func TestSendHttpError(t *testing.T) { 45 | errStr := "no text" 46 | c := New(test.Endpoint + "?error=" + errStr) 47 | resp := &slack.Response{} 48 | err := c.Send("", nil, resp) 49 | if !assert.Error(t, err) { 50 | return 51 | } 52 | serr := err.(*slack.Error) 53 | assert.Equal(t, http.StatusBadRequest, serr.StatusCode) 54 | assert.False(t, resp.OK) 55 | } 56 | 57 | func TestSendDecodeError(t *testing.T) { 58 | errStr := "no_text" 59 | c := New(test.Endpoint + "?error=" + errStr) 60 | resp := struct { 61 | OK bool 62 | }{} 63 | err := c.Send("", nil, resp) 64 | if !assert.Error(t, err) { 65 | return 66 | } 67 | serr := err.(*slack.Error) 68 | assert.Equal(t, http.StatusOK, serr.StatusCode) 69 | assert.False(t, resp.OK) 70 | } 71 | 72 | func TestSendMarshalError(t *testing.T) { 73 | errStr := "no_text" 74 | c := New(test.Endpoint + "?error=" + errStr) 75 | resp := struct { 76 | OK bool 77 | }{} 78 | err := c.Send("", New, &resp) 79 | if !assert.Error(t, err) { 80 | return 81 | } 82 | } 83 | 84 | func TestSendResponsError(t *testing.T) { 85 | errStr := "no_text" 86 | c := New(test.Endpoint + "?error=" + errStr) 87 | resp := struct { 88 | OK bool 89 | }{} 90 | err := c.Send("", nil, &resp) 91 | if !assert.Error(t, err) { 92 | return 93 | } 94 | serr := err.(*slack.Error) 95 | assert.Equal(t, http.StatusOK, serr.StatusCode) 96 | assert.False(t, resp.OK) 97 | } 98 | 99 | func TestSendPostError(t *testing.T) { 100 | c := New("hhc:/broken") 101 | resp := &slack.Response{} 102 | err := c.Send("", nil, resp) 103 | assert.Error(t, err) 104 | assert.False(t, resp.OK) 105 | } 106 | 107 | func ExampleNew() { 108 | c := New("https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX") 109 | m := &chat.Message{Text: "test message"} 110 | m.Send(c) 111 | } 112 | --------------------------------------------------------------------------------