diff --git a/bridge/bridge.go b/bridge/bridge.go index 047370be..45a0160a 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -6,6 +6,7 @@ import ( "github.com/42wim/matterbridge/bridge/gitter" "github.com/42wim/matterbridge/bridge/irc" "github.com/42wim/matterbridge/bridge/mattermost" + "github.com/42wim/matterbridge/bridge/slack" "github.com/42wim/matterbridge/bridge/xmpp" log "github.com/Sirupsen/logrus" "strings" @@ -42,6 +43,9 @@ func NewBridge(cfg *config.Config) error { if cfg.Gitter.Enable { b.Bridges = append(b.Bridges, bgitter.New(cfg, c)) } + if cfg.Slack.Enable { + b.Bridges = append(b.Bridges, bslack.New(cfg, c)) + } if len(b.Bridges) < 2 { log.Fatalf("only %d sections enabled. Need at least 2 sections enabled (eg [IRC] and [mattermost]", len(b.Bridges)) } @@ -72,6 +76,7 @@ func (b *Bridge) mapChannels() error { m["mattermost"] = val.Mattermost m["xmpp"] = val.Xmpp m["gitter"] = val.Gitter + m["slack"] = val.Slack b.Channels = append(b.Channels, m) } return nil @@ -83,6 +88,7 @@ func (b *Bridge) mapIgnores() { m["mattermost"] = strings.Fields(b.Config.Mattermost.IgnoreNicks) m["xmpp"] = strings.Fields(b.Config.Xmpp.IgnoreNicks) m["gitter"] = strings.Fields(b.Config.Gitter.IgnoreNicks) + m["slack"] = strings.Fields(b.Config.Slack.IgnoreNicks) b.ignoreNicks = m } @@ -105,6 +111,7 @@ func (b *Bridge) handleMessage(msg config.Message, dest Bridger) { return } b.modifyMessage(&msg, dest.Name()) + log.Debugf("sending %#v from %s to %s", msg, msg.Origin, dest.Name()) dest.Send(msg) } } @@ -138,5 +145,7 @@ func (b *Bridge) modifyMessage(msg *config.Message, dest string) { setNickFormat(msg, b.Config.Xmpp.RemoteNickFormat) case "mattermost": setNickFormat(msg, b.Config.Mattermost.RemoteNickFormat) + case "slack": + setNickFormat(msg, b.Config.Slack.RemoteNickFormat) } } diff --git a/bridge/config/config.go b/bridge/config/config.go index cd353fcb..48574e28 100644 --- a/bridge/config/config.go +++ b/bridge/config/config.go @@ -35,7 +35,6 @@ type Config struct { RemoteNickFormat string Token string } - Mattermost struct { URL string ShowJoinPart bool @@ -55,6 +54,19 @@ type Config struct { NoTLS bool Enable bool } + Slack struct { + BindAddress string + Enable bool + IconURL string + IgnoreNicks string + NickFormatter string + NicksPerRow int + PrefixMessagesWithNick bool + RemoteNickFormat string + Token string + URL string + UseAPI bool + } Xmpp struct { IgnoreNicks string Jid string @@ -70,6 +82,7 @@ type Config struct { Mattermost string Xmpp string Gitter string + Slack string } General struct { GiphyAPIKey string diff --git a/bridge/slack/slack.go b/bridge/slack/slack.go new file mode 100644 index 00000000..804eaea4 --- /dev/null +++ b/bridge/slack/slack.go @@ -0,0 +1,180 @@ +package bslack + +import ( + "github.com/42wim/matterbridge/bridge/config" + "github.com/42wim/matterbridge/matterhook" + log "github.com/Sirupsen/logrus" + "github.com/nlopes/slack" + "strings" + "time" +) + +type MMMessage struct { + Text string + Channel string + Username string +} + +type bslack struct { + mh *matterhook.Client + sc *slack.Client + // MMapi + *config.Config + rtm *slack.RTM + Plus bool + Remote chan config.Message + channels []slack.Channel +} + +var flog *log.Entry + +func init() { + flog = log.WithFields(log.Fields{"module": "slack"}) +} + +func New(cfg *config.Config, c chan config.Message) *bslack { + b := &bslack{} + b.Config = cfg + b.Remote = c + b.Plus = cfg.Slack.UseAPI + return b +} + +func (b *bslack) Command(cmd string) string { + return "" +} + +func (b *bslack) Connect() error { + if !b.Plus { + b.mh = matterhook.New(b.Config.Slack.URL, + matterhook.Config{BindAddress: b.Config.Slack.BindAddress}) + } else { + b.sc = slack.New(b.Config.Slack.Token) + flog.Infof("Trying login on slack with Token") + /* + if err != nil { + return err + } + */ + flog.Info("Login ok") + } + b.rtm = b.sc.NewRTM() + go b.rtm.ManageConnection() + go b.handleSlack() + return nil +} + +func (b *bslack) Name() string { + return "slack" +} + +func (b *bslack) Send(msg config.Message) error { + flog.Infof("slack send %#v", msg) + if msg.Origin != "slack" { + return b.SendType(msg.Username, msg.Text, msg.Channel, "") + } + return nil +} + +func (b *bslack) SendType(nick string, message string, channel string, mtype string) error { + if b.Config.Slack.PrefixMessagesWithNick { + message = nick + " " + message + } + if !b.Plus { + matterMessage := matterhook.OMessage{IconURL: b.Config.Slack.IconURL} + matterMessage.Channel = channel + matterMessage.UserName = nick + matterMessage.Type = mtype + matterMessage.Text = message + err := b.mh.Send(matterMessage) + if err != nil { + flog.Info(err) + return err + } + flog.Debug("->slack channel: ", channel, " ", message) + return nil + } + flog.Debugf("sent to slack channel API: %s %s", channel, message) + newmsg := b.rtm.NewOutgoingMessage(message, b.getChannelByName(channel).ID) + b.rtm.SendMessage(newmsg) + return nil +} + +func (b *bslack) getChannelByName(name string) *slack.Channel { + if b.channels == nil { + return nil + } + for _, channel := range b.channels { + if channel.Name == name { + return &channel + } + } + return nil +} + +func (b *bslack) handleSlack() { + flog.Infof("Choosing API based slack connection: %t", b.Plus) + mchan := make(chan *MMMessage) + if b.Plus { + go b.handleSlackClient(mchan) + } else { + go b.handleMatterHook(mchan) + } + time.Sleep(time.Second) + flog.Info("Start listening for Slack messages") + for message := range mchan { + texts := strings.Split(message.Text, "\n") + for _, text := range texts { + flog.Debug("Sending message from " + message.Username + " to " + message.Channel) + b.Remote <- config.Message{Text: text, Username: message.Username, Channel: message.Channel, Origin: "slack"} + } + } +} + +func (b *bslack) handleSlackClient(mchan chan *MMMessage) { + for msg := range b.rtm.IncomingEvents { + switch ev := msg.Data.(type) { + case *slack.MessageEvent: + flog.Debugf("%#v", ev) + channel, err := b.rtm.GetChannelInfo(ev.Channel) + if err != nil { + continue + } + user, err := b.rtm.GetUserInfo(ev.User) + if err != nil { + continue + } + m := &MMMessage{} + m.Username = user.Name + m.Channel = channel.Name + m.Text = ev.Text + mchan <- m + case *slack.OutgoingErrorEvent: + flog.Debugf("%#v", ev.Error()) + case *slack.ConnectedEvent: + b.channels = ev.Info.Channels + for _, val := range b.Config.Channel { + channel := b.getChannelByName(val.Slack) + if channel != nil && !channel.IsMember { + flog.Infof("Joining %s", val.Slack) + b.sc.JoinChannel(channel.ID) + } + } + case *slack.InvalidAuthEvent: + flog.Fatalf("Invalid Token %#v", ev) + default: + } + } +} + +func (b *bslack) handleMatterHook(mchan chan *MMMessage) { + for { + message := b.mh.Receive() + flog.Debugf("receiving from slack %#v", message) + m := &MMMessage{} + m.Username = message.UserName + m.Text = message.Text + m.Channel = message.ChannelName + mchan <- m + } +} diff --git a/matterhook/matterhook.go b/matterhook/matterhook.go index fc003cce..0880884b 100644 --- a/matterhook/matterhook.go +++ b/matterhook/matterhook.go @@ -27,6 +27,8 @@ type OMessage struct { // IMessage for mattermost outgoing webhook. (received from mattermost) type IMessage struct { + BotID string `schema:"bot_id"` + BotName string `schema:"bot_name"` Token string `schema:"token"` TeamID string `schema:"team_id"` TeamDomain string `schema:"team_domain"` @@ -36,6 +38,8 @@ type IMessage struct { UserID string `schema:"user_id"` UserName string `schema:"user_name"` PostId string `schema:"post_id"` + RawText string `schema:"raw_text"` + ServiceId string `schema:"service_id"` Text string `schema:"text"` TriggerWord string `schema:"trigger_word"` } diff --git a/vendor/github.com/nlopes/slack/LICENSE b/vendor/github.com/nlopes/slack/LICENSE new file mode 100644 index 00000000..5145171f --- /dev/null +++ b/vendor/github.com/nlopes/slack/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2015, Norberto Lopes +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/nlopes/slack/admin.go b/vendor/github.com/nlopes/slack/admin.go new file mode 100644 index 00000000..393b2880 --- /dev/null +++ b/vendor/github.com/nlopes/slack/admin.go @@ -0,0 +1,190 @@ +package slack + +import ( + "errors" + "fmt" + "net/url" +) + +type adminResponse struct { + OK bool `json:"ok"` + Error string `json:"error"` +} + +func adminRequest(method string, teamName string, values url.Values, debug bool) (*adminResponse, error) { + adminResponse := &adminResponse{} + err := parseAdminResponse(method, teamName, values, adminResponse, debug) + if err != nil { + return nil, err + } + + if !adminResponse.OK { + return nil, errors.New(adminResponse.Error) + } + + return adminResponse, nil +} + +// DisableUser disabled a user account, given a user ID +func (api *Client) DisableUser(teamName string, uid string) error { + values := url.Values{ + "user": {uid}, + "token": {api.config.token}, + "set_active": {"true"}, + "_attempts": {"1"}, + } + + _, err := adminRequest("setInactive", teamName, values, api.debug) + if err != nil { + return fmt.Errorf("Failed to disable user with id '%s': %s", uid, err) + } + + return nil +} + +// InviteGuest invites a user to Slack as a single-channel guest +func (api *Client) InviteGuest( + teamName string, + channel string, + firstName string, + lastName string, + emailAddress string, +) error { + values := url.Values{ + "email": {emailAddress}, + "channels": {channel}, + "first_name": {firstName}, + "last_name": {lastName}, + "ultra_restricted": {"1"}, + "token": {api.config.token}, + "set_active": {"true"}, + "_attempts": {"1"}, + } + + _, err := adminRequest("invite", teamName, values, api.debug) + if err != nil { + return fmt.Errorf("Failed to invite single-channel guest: %s", err) + } + + return nil +} + +// InviteRestricted invites a user to Slack as a restricted account +func (api *Client) InviteRestricted( + teamName string, + channel string, + firstName string, + lastName string, + emailAddress string, +) error { + values := url.Values{ + "email": {emailAddress}, + "channels": {channel}, + "first_name": {firstName}, + "last_name": {lastName}, + "restricted": {"1"}, + "token": {api.config.token}, + "set_active": {"true"}, + "_attempts": {"1"}, + } + + _, err := adminRequest("invite", teamName, values, api.debug) + if err != nil { + return fmt.Errorf("Failed to restricted account: %s", err) + } + + return nil +} + +// InviteToTeam invites a user to a Slack team +func (api *Client) InviteToTeam( + teamName string, + firstName string, + lastName string, + emailAddress string, +) error { + values := url.Values{ + "email": {emailAddress}, + "first_name": {firstName}, + "last_name": {lastName}, + "token": {api.config.token}, + "set_active": {"true"}, + "_attempts": {"1"}, + } + + _, err := adminRequest("invite", teamName, values, api.debug) + if err != nil { + return fmt.Errorf("Failed to invite to team: %s", err) + } + + return nil +} + +// SetRegular enables the specified user +func (api *Client) SetRegular(teamName string, user string) error { + values := url.Values{ + "user": {user}, + "token": {api.config.token}, + "set_active": {"true"}, + "_attempts": {"1"}, + } + + _, err := adminRequest("setRegular", teamName, values, api.debug) + if err != nil { + return fmt.Errorf("Failed to change the user (%s) to a regular user: %s", user, err) + } + + return nil +} + +// SendSSOBindingEmail sends an SSO binding email to the specified user +func (api *Client) SendSSOBindingEmail(teamName string, user string) error { + values := url.Values{ + "user": {user}, + "token": {api.config.token}, + "set_active": {"true"}, + "_attempts": {"1"}, + } + + _, err := adminRequest("sendSSOBind", teamName, values, api.debug) + if err != nil { + return fmt.Errorf("Failed to send SSO binding email for user (%s): %s", user, err) + } + + return nil +} + +// SetUltraRestricted converts a user into a single-channel guest +func (api *Client) SetUltraRestricted(teamName, uid, channel string) error { + values := url.Values{ + "user": {uid}, + "channel": {channel}, + "token": {api.config.token}, + "set_active": {"true"}, + "_attempts": {"1"}, + } + + _, err := adminRequest("setUltraRestricted", teamName, values, api.debug) + if err != nil { + return fmt.Errorf("Failed to ultra-restrict account: %s", err) + } + + return nil +} + +// SetRestricted converts a user into a restricted account +func (api *Client) SetRestricted(teamName, uid string) error { + values := url.Values{ + "user": {uid}, + "token": {api.config.token}, + "set_active": {"true"}, + "_attempts": {"1"}, + } + + _, err := adminRequest("setRestricted", teamName, values, api.debug) + if err != nil { + return fmt.Errorf("Failed to restrict account: %s", err) + } + + return nil +} diff --git a/vendor/github.com/nlopes/slack/attachments.go b/vendor/github.com/nlopes/slack/attachments.go new file mode 100644 index 00000000..4faf3e3f --- /dev/null +++ b/vendor/github.com/nlopes/slack/attachments.go @@ -0,0 +1,76 @@ +package slack + +// AttachmentField contains information for an attachment field +// An Attachment can contain multiple of these +type AttachmentField struct { + Title string `json:"title"` + Value string `json:"value"` + Short bool `json:"short"` +} + +// AttachmentAction is a button to be included in the attachment. Required when +// using message buttons and otherwise not useful. A maximum of 5 actions may be +// provided per attachment. +type AttachmentAction struct { + Name string `json:"name"` // Required. + Text string `json:"text"` // Required. + Style string `json:"style,omitempty"` // Optional. Allowed values: "default", "primary", "danger" + Type string `json:"type"` // Required. Must be set to "button" + Value string `json:"value,omitempty"` // Optional. + Confirm []ConfirmationField `json:"confirm,omitempty"` // Optional. +} + +// AttachmentActionCallback is sent from Slack when a user clicks a button in an interactive message (aka AttachmentAction) +type AttachmentActionCallback struct { + Actions []AttachmentAction `json:"actions"` + CallbackID string `json:"callback_id"` + Team Team `json:"team"` + Channel Channel `json:"channel"` + User User `json:"user"` + + OriginalMessage Message `json:"original_message"` + + ActionTs string `json:"action_ts"` + MessageTs string `json:"message_ts"` + AttachmentID string `json:"attachment_id"` + Token string `json:"token"` + ResponseURL string `json:"response_url"` +} + +// ConfirmationField are used to ask users to confirm actions +type ConfirmationField struct { + Title string `json:"title,omitempty"` // Optional. + Text string `json:"text"` // Required. + OkText string `json:"ok_text,omitempty"` // Optional. Defaults to "Okay" + DismissText string `json:"dismiss_text,omitempty"` // Optional. Defaults to "Cancel" +} + +// Attachment contains all the information for an attachment +type Attachment struct { + Color string `json:"color,omitempty"` + Fallback string `json:"fallback"` + + CallbackID string `json:"callback_id,omitempty"` + + AuthorName string `json:"author_name,omitempty"` + AuthorSubname string `json:"author_subname,omitempty"` + AuthorLink string `json:"author_link,omitempty"` + AuthorIcon string `json:"author_icon,omitempty"` + + Title string `json:"title,omitempty"` + TitleLink string `json:"title_link,omitempty"` + Pretext string `json:"pretext,omitempty"` + Text string `json:"text"` + + ImageURL string `json:"image_url,omitempty"` + ThumbURL string `json:"thumb_url,omitempty"` + + Fields []AttachmentField `json:"fields,omitempty"` + Actions []AttachmentAction `json:"actions,omitempty"` + MarkdownIn []string `json:"mrkdwn_in,omitempty"` + + Footer string `json:"footer,omitempty"` + FooterIcon string `json:"footer_icon,omitempty"` + + Ts int64 `json:"ts,omitempty"` +} diff --git a/vendor/github.com/nlopes/slack/backoff.go b/vendor/github.com/nlopes/slack/backoff.go new file mode 100644 index 00000000..e555a1ad --- /dev/null +++ b/vendor/github.com/nlopes/slack/backoff.go @@ -0,0 +1,57 @@ +package slack + +import ( + "math" + "math/rand" + "time" +) + +// This one was ripped from https://github.com/jpillora/backoff/blob/master/backoff.go + +// Backoff is a time.Duration counter. It starts at Min. After every +// call to Duration() it is multiplied by Factor. It is capped at +// Max. It returns to Min on every call to Reset(). Used in +// conjunction with the time package. +type backoff struct { + attempts int + //Factor is the multiplying factor for each increment step + Factor float64 + //Jitter eases contention by randomizing backoff steps + Jitter bool + //Min and Max are the minimum and maximum values of the counter + Min, Max time.Duration +} + +// Returns the current value of the counter and then multiplies it +// Factor +func (b *backoff) Duration() time.Duration { + //Zero-values are nonsensical, so we use + //them to apply defaults + if b.Min == 0 { + b.Min = 100 * time.Millisecond + } + if b.Max == 0 { + b.Max = 10 * time.Second + } + if b.Factor == 0 { + b.Factor = 2 + } + //calculate this duration + dur := float64(b.Min) * math.Pow(b.Factor, float64(b.attempts)) + if b.Jitter == true { + dur = rand.Float64()*(dur-float64(b.Min)) + float64(b.Min) + } + //cap! + if dur > float64(b.Max) { + return b.Max + } + //bump attempts count + b.attempts++ + //return as a time.Duration + return time.Duration(dur) +} + +//Resets the current value of the counter back to Min +func (b *backoff) Reset() { + b.attempts = 0 +} diff --git a/vendor/github.com/nlopes/slack/channels.go b/vendor/github.com/nlopes/slack/channels.go new file mode 100644 index 00000000..05cfbe1d --- /dev/null +++ b/vendor/github.com/nlopes/slack/channels.go @@ -0,0 +1,261 @@ +package slack + +import ( + "errors" + "net/url" + "strconv" +) + +type channelResponseFull struct { + Channel Channel `json:"channel"` + Channels []Channel `json:"channels"` + Purpose string `json:"purpose"` + Topic string `json:"topic"` + NotInChannel bool `json:"not_in_channel"` + History + SlackResponse +} + +// Channel contains information about the channel +type Channel struct { + groupConversation + IsChannel bool `json:"is_channel"` + IsGeneral bool `json:"is_general"` + IsMember bool `json:"is_member"` +} + +func channelRequest(path string, values url.Values, debug bool) (*channelResponseFull, error) { + response := &channelResponseFull{} + err := post(path, values, response, debug) + if err != nil { + return nil, err + } + if !response.Ok { + return nil, errors.New(response.Error) + } + return response, nil +} + +// ArchiveChannel archives the given channel +func (api *Client) ArchiveChannel(channel string) error { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + } + _, err := channelRequest("channels.archive", values, api.debug) + if err != nil { + return err + } + return nil +} + +// UnarchiveChannel unarchives the given channel +func (api *Client) UnarchiveChannel(channel string) error { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + } + _, err := channelRequest("channels.unarchive", values, api.debug) + if err != nil { + return err + } + return nil +} + +// CreateChannel creates a channel with the given name and returns a *Channel +func (api *Client) CreateChannel(channel string) (*Channel, error) { + values := url.Values{ + "token": {api.config.token}, + "name": {channel}, + } + response, err := channelRequest("channels.create", values, api.debug) + if err != nil { + return nil, err + } + return &response.Channel, nil +} + +// GetChannelHistory retrieves the channel history +func (api *Client) GetChannelHistory(channel string, params HistoryParameters) (*History, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + } + if params.Latest != DEFAULT_HISTORY_LATEST { + values.Add("latest", params.Latest) + } + if params.Oldest != DEFAULT_HISTORY_OLDEST { + values.Add("oldest", params.Oldest) + } + if params.Count != DEFAULT_HISTORY_COUNT { + values.Add("count", strconv.Itoa(params.Count)) + } + if params.Inclusive != DEFAULT_HISTORY_INCLUSIVE { + if params.Inclusive { + values.Add("inclusive", "1") + } else { + values.Add("inclusive", "0") + } + } + if params.Unreads != DEFAULT_HISTORY_UNREADS { + if params.Unreads { + values.Add("unreads", "1") + } else { + values.Add("unreads", "0") + } + } + response, err := channelRequest("channels.history", values, api.debug) + if err != nil { + return nil, err + } + return &response.History, nil +} + +// GetChannelInfo retrieves the given channel +func (api *Client) GetChannelInfo(channel string) (*Channel, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + } + response, err := channelRequest("channels.info", values, api.debug) + if err != nil { + return nil, err + } + return &response.Channel, nil +} + +// InviteUserToChannel invites a user to a given channel and returns a *Channel +func (api *Client) InviteUserToChannel(channel, user string) (*Channel, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + "user": {user}, + } + response, err := channelRequest("channels.invite", values, api.debug) + if err != nil { + return nil, err + } + return &response.Channel, nil +} + +// JoinChannel joins the currently authenticated user to a channel +func (api *Client) JoinChannel(channel string) (*Channel, error) { + values := url.Values{ + "token": {api.config.token}, + "name": {channel}, + } + response, err := channelRequest("channels.join", values, api.debug) + if err != nil { + return nil, err + } + return &response.Channel, nil +} + +// LeaveChannel makes the authenticated user leave the given channel +func (api *Client) LeaveChannel(channel string) (bool, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + } + response, err := channelRequest("channels.leave", values, api.debug) + if err != nil { + return false, err + } + if response.NotInChannel { + return response.NotInChannel, nil + } + return false, nil +} + +// KickUserFromChannel kicks a user from a given channel +func (api *Client) KickUserFromChannel(channel, user string) error { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + "user": {user}, + } + _, err := channelRequest("channels.kick", values, api.debug) + if err != nil { + return err + } + return nil +} + +// GetChannels retrieves all the channels +func (api *Client) GetChannels(excludeArchived bool) ([]Channel, error) { + values := url.Values{ + "token": {api.config.token}, + } + if excludeArchived { + values.Add("exclude_archived", "1") + } + response, err := channelRequest("channels.list", values, api.debug) + if err != nil { + return nil, err + } + return response.Channels, nil +} + +// SetChannelReadMark sets the read mark of a given channel to a specific point +// Clients should try to avoid making this call too often. When needing to mark a read position, a client should set a +// timer before making the call. In this way, any further updates needed during the timeout will not generate extra calls +// (just one per channel). This is useful for when reading scroll-back history, or following a busy live channel. A +// timeout of 5 seconds is a good starting point. Be sure to flush these calls on shutdown/logout. +func (api *Client) SetChannelReadMark(channel, ts string) error { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + "ts": {ts}, + } + _, err := channelRequest("channels.mark", values, api.debug) + if err != nil { + return err + } + return nil +} + +// RenameChannel renames a given channel +func (api *Client) RenameChannel(channel, name string) (*Channel, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + "name": {name}, + } + // XXX: the created entry in this call returns a string instead of a number + // so I may have to do some workaround to solve it. + response, err := channelRequest("channels.rename", values, api.debug) + if err != nil { + return nil, err + } + return &response.Channel, nil + +} + +// SetChannelPurpose sets the channel purpose and returns the purpose that was +// successfully set +func (api *Client) SetChannelPurpose(channel, purpose string) (string, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + "purpose": {purpose}, + } + response, err := channelRequest("channels.setPurpose", values, api.debug) + if err != nil { + return "", err + } + return response.Purpose, nil +} + +// SetChannelTopic sets the channel topic and returns the topic that was successfully set +func (api *Client) SetChannelTopic(channel, topic string) (string, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + "topic": {topic}, + } + response, err := channelRequest("channels.setTopic", values, api.debug) + if err != nil { + return "", err + } + return response.Topic, nil +} diff --git a/vendor/github.com/nlopes/slack/chat.go b/vendor/github.com/nlopes/slack/chat.go new file mode 100644 index 00000000..52a3420f --- /dev/null +++ b/vendor/github.com/nlopes/slack/chat.go @@ -0,0 +1,166 @@ +package slack + +import ( + "encoding/json" + "errors" + "net/url" + "strings" +) + +const ( + DEFAULT_MESSAGE_USERNAME = "" + DEFAULT_MESSAGE_ASUSER = false + DEFAULT_MESSAGE_PARSE = "" + DEFAULT_MESSAGE_LINK_NAMES = 0 + DEFAULT_MESSAGE_UNFURL_LINKS = false + DEFAULT_MESSAGE_UNFURL_MEDIA = true + DEFAULT_MESSAGE_ICON_URL = "" + DEFAULT_MESSAGE_ICON_EMOJI = "" + DEFAULT_MESSAGE_MARKDOWN = true + DEFAULT_MESSAGE_ESCAPE_TEXT = true +) + +type chatResponseFull struct { + Channel string `json:"channel"` + Timestamp string `json:"ts"` + Text string `json:"text"` + SlackResponse +} + +// PostMessageParameters contains all the parameters necessary (including the optional ones) for a PostMessage() request +type PostMessageParameters struct { + Text string + Username string + AsUser bool + Parse string + LinkNames int + Attachments []Attachment + UnfurlLinks bool + UnfurlMedia bool + IconURL string + IconEmoji string + Markdown bool `json:"mrkdwn,omitempty"` + EscapeText bool +} + +// NewPostMessageParameters provides an instance of PostMessageParameters with all the sane default values set +func NewPostMessageParameters() PostMessageParameters { + return PostMessageParameters{ + Username: DEFAULT_MESSAGE_USERNAME, + AsUser: DEFAULT_MESSAGE_ASUSER, + Parse: DEFAULT_MESSAGE_PARSE, + LinkNames: DEFAULT_MESSAGE_LINK_NAMES, + Attachments: nil, + UnfurlLinks: DEFAULT_MESSAGE_UNFURL_LINKS, + UnfurlMedia: DEFAULT_MESSAGE_UNFURL_MEDIA, + IconURL: DEFAULT_MESSAGE_ICON_URL, + IconEmoji: DEFAULT_MESSAGE_ICON_EMOJI, + Markdown: DEFAULT_MESSAGE_MARKDOWN, + EscapeText: DEFAULT_MESSAGE_ESCAPE_TEXT, + } +} + +func chatRequest(path string, values url.Values, debug bool) (*chatResponseFull, error) { + response := &chatResponseFull{} + err := post(path, values, response, debug) + if err != nil { + return nil, err + } + if !response.Ok { + return nil, errors.New(response.Error) + } + return response, nil +} + +// DeleteMessage deletes a message in a channel +func (api *Client) DeleteMessage(channel, messageTimestamp string) (string, string, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + "ts": {messageTimestamp}, + } + response, err := chatRequest("chat.delete", values, api.debug) + if err != nil { + return "", "", err + } + return response.Channel, response.Timestamp, nil +} + +func escapeMessage(message string) string { + replacer := strings.NewReplacer("&", "&", "<", "<", ">", ">") + return replacer.Replace(message) +} + +// PostMessage sends a message to a channel. +// Message is escaped by default according to https://api.slack.com/docs/formatting +// Use http://davestevens.github.io/slack-message-builder/ to help crafting your message. +func (api *Client) PostMessage(channel, text string, params PostMessageParameters) (string, string, error) { + if params.EscapeText { + text = escapeMessage(text) + } + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + "text": {text}, + } + if params.Username != DEFAULT_MESSAGE_USERNAME { + values.Set("username", string(params.Username)) + } + if params.AsUser != DEFAULT_MESSAGE_ASUSER { + values.Set("as_user", "true") + } + if params.Parse != DEFAULT_MESSAGE_PARSE { + values.Set("parse", string(params.Parse)) + } + if params.LinkNames != DEFAULT_MESSAGE_LINK_NAMES { + values.Set("link_names", "1") + } + if params.Attachments != nil { + attachments, err := json.Marshal(params.Attachments) + if err != nil { + return "", "", err + } + values.Set("attachments", string(attachments)) + } + if params.UnfurlLinks != DEFAULT_MESSAGE_UNFURL_LINKS { + values.Set("unfurl_links", "true") + } + // I want to send a message with explicit `as_user` `true` and `unfurl_links` `false` in request. + // Because setting `as_user` to `true` will change the default value for `unfurl_links` to `true` on Slack API side. + if params.AsUser != DEFAULT_MESSAGE_ASUSER && params.UnfurlLinks == DEFAULT_MESSAGE_UNFURL_LINKS { + values.Set("unfurl_links", "false") + } + if params.UnfurlMedia != DEFAULT_MESSAGE_UNFURL_MEDIA { + values.Set("unfurl_media", "false") + } + if params.IconURL != DEFAULT_MESSAGE_ICON_URL { + values.Set("icon_url", params.IconURL) + } + if params.IconEmoji != DEFAULT_MESSAGE_ICON_EMOJI { + values.Set("icon_emoji", params.IconEmoji) + } + if params.Markdown != DEFAULT_MESSAGE_MARKDOWN { + values.Set("mrkdwn", "false") + } + + response, err := chatRequest("chat.postMessage", values, api.debug) + if err != nil { + return "", "", err + } + return response.Channel, response.Timestamp, nil +} + +// UpdateMessage updates a message in a channel +func (api *Client) UpdateMessage(channel, timestamp, text string) (string, string, string, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + "text": {escapeMessage(text)}, + "ts": {timestamp}, + } + response, err := chatRequest("chat.update", values, api.debug) + if err != nil { + return "", "", "", err + } + return response.Channel, response.Timestamp, response.Text, nil +} diff --git a/vendor/github.com/nlopes/slack/comment.go b/vendor/github.com/nlopes/slack/comment.go new file mode 100644 index 00000000..7d1c0d4e --- /dev/null +++ b/vendor/github.com/nlopes/slack/comment.go @@ -0,0 +1,10 @@ +package slack + +// Comment contains all the information relative to a comment +type Comment struct { + ID string `json:"id,omitempty"` + Created JSONTime `json:"created,omitempty"` + Timestamp JSONTime `json:"timestamp,omitempty"` + User string `json:"user,omitempty"` + Comment string `json:"comment,omitempty"` +} diff --git a/vendor/github.com/nlopes/slack/conversation.go b/vendor/github.com/nlopes/slack/conversation.go new file mode 100644 index 00000000..51b993d1 --- /dev/null +++ b/vendor/github.com/nlopes/slack/conversation.go @@ -0,0 +1,38 @@ +package slack + +// Conversation is the foundation for IM and BaseGroupConversation +type conversation struct { + ID string `json:"id"` + Created JSONTime `json:"created"` + IsOpen bool `json:"is_open"` + LastRead string `json:"last_read,omitempty"` + Latest *Message `json:"latest,omitempty"` + UnreadCount int `json:"unread_count,omitempty"` + UnreadCountDisplay int `json:"unread_count_display,omitempty"` +} + +// GroupConversation is the foundation for Group and Channel +type groupConversation struct { + conversation + Name string `json:"name"` + Creator string `json:"creator"` + IsArchived bool `json:"is_archived"` + Members []string `json:"members"` + NumMembers int `json:"num_members,omitempty"` + Topic Topic `json:"topic"` + Purpose Purpose `json:"purpose"` +} + +// Topic contains information about the topic +type Topic struct { + Value string `json:"value"` + Creator string `json:"creator"` + LastSet JSONTime `json:"last_set"` +} + +// Purpose contains information about the purpose +type Purpose struct { + Value string `json:"value"` + Creator string `json:"creator"` + LastSet JSONTime `json:"last_set"` +} diff --git a/vendor/github.com/nlopes/slack/dnd.go b/vendor/github.com/nlopes/slack/dnd.go new file mode 100644 index 00000000..ac87758d --- /dev/null +++ b/vendor/github.com/nlopes/slack/dnd.go @@ -0,0 +1,123 @@ +package slack + +import ( + "errors" + "net/url" + "strconv" + "strings" +) + +type SnoozeDebug struct { + SnoozeEndDate string `json:"snooze_end_date"` +} + +type SnoozeInfo struct { + SnoozeEnabled bool `json:"snooze_enabled,omitempty"` + SnoozeEndTime int `json:"snooze_endtime,omitempty"` + SnoozeRemaining int `json:"snooze_remaining,omitempty"` + SnoozeDebug SnoozeDebug `json:"snooze_debug,omitempty"` +} + +type DNDStatus struct { + Enabled bool `json:"dnd_enabled"` + NextStartTimestamp int `json:"next_dnd_start_ts"` + NextEndTimestamp int `json:"next_dnd_end_ts"` + SnoozeInfo +} + +type dndResponseFull struct { + DNDStatus + SlackResponse +} + +type dndTeamInfoResponse struct { + Users map[string]DNDStatus `json:"users"` + SlackResponse +} + +func dndRequest(path string, values url.Values, debug bool) (*dndResponseFull, error) { + response := &dndResponseFull{} + err := post(path, values, response, debug) + if err != nil { + return nil, err + } + if !response.Ok { + return nil, errors.New(response.Error) + } + return response, nil +} + +// EndDND ends the user's scheduled Do Not Disturb session +func (api *Client) EndDND() error { + values := url.Values{ + "token": {api.config.token}, + } + + response := &SlackResponse{} + if err := post("dnd.endDnd", values, response, api.debug); err != nil { + return err + } + if !response.Ok { + return errors.New(response.Error) + } + return nil +} + +// EndSnooze ends the current user's snooze mode +func (api *Client) EndSnooze() (*DNDStatus, error) { + values := url.Values{ + "token": {api.config.token}, + } + + response, err := dndRequest("dnd.endSnooze", values, api.debug) + if err != nil { + return nil, err + } + return &response.DNDStatus, nil +} + +// GetDNDInfo provides information about a user's current Do Not Disturb settings. +func (api *Client) GetDNDInfo(user *string) (*DNDStatus, error) { + values := url.Values{ + "token": {api.config.token}, + } + if user != nil { + values.Set("user", *user) + } + response, err := dndRequest("dnd.info", values, api.debug) + if err != nil { + return nil, err + } + return &response.DNDStatus, nil +} + +// GetDNDTeamInfo provides information about a user's current Do Not Disturb settings. +func (api *Client) GetDNDTeamInfo(users []string) (map[string]DNDStatus, error) { + values := url.Values{ + "token": {api.config.token}, + "users": {strings.Join(users, ",")}, + } + response := &dndTeamInfoResponse{} + if err := post("dnd.teamInfo", values, response, api.debug); err != nil { + return nil, err + } + if !response.Ok { + return nil, errors.New(response.Error) + } + return response.Users, nil +} + +// SetSnooze adjusts the snooze duration for a user's Do Not Disturb +// settings. If a snooze session is not already active for the user, invoking +// this method will begin one for the specified duration. +func (api *Client) SetSnooze(minutes int) (*DNDStatus, error) { + values := url.Values{ + "token": {api.config.token}, + "num_minutes": {strconv.Itoa(minutes)}, + } + response, err := dndRequest("dnd.setSnooze", values, api.debug) + if err != nil { + return nil, err + } + return &response.DNDStatus, nil +} diff --git a/vendor/github.com/nlopes/slack/emoji.go b/vendor/github.com/nlopes/slack/emoji.go new file mode 100644 index 00000000..776c4a5f --- /dev/null +++ b/vendor/github.com/nlopes/slack/emoji.go @@ -0,0 +1,27 @@ +package slack + +import ( + "errors" + "net/url" +) + +type emojiResponseFull struct { + Emoji map[string]string `json:"emoji"` + SlackResponse +} + +// GetEmoji retrieves all the emojis +func (api *Client) GetEmoji() (map[string]string, error) { + values := url.Values{ + "token": {api.config.token}, + } + response := &emojiResponseFull{} + err := post("emoji.list", values, response, api.debug) + if err != nil { + return nil, err + } + if !response.Ok { + return nil, errors.New(response.Error) + } + return response.Emoji, nil +} diff --git a/vendor/github.com/nlopes/slack/examples/channels/channels.go b/vendor/github.com/nlopes/slack/examples/channels/channels.go new file mode 100644 index 00000000..a4465bdb --- /dev/null +++ b/vendor/github.com/nlopes/slack/examples/channels/channels.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + + "github.com/nlopes/slack" +) + +func main() { + api := slack.New("YOUR_TOKEN_HERE") + channels, err := api.GetChannels(false) + if err != nil { + fmt.Printf("%s\n", err) + return + } + for _, channel := range channels { + fmt.Println(channel.ID) + } +} diff --git a/vendor/github.com/nlopes/slack/examples/files/files.go b/vendor/github.com/nlopes/slack/examples/files/files.go new file mode 100644 index 00000000..bb7b4e50 --- /dev/null +++ b/vendor/github.com/nlopes/slack/examples/files/files.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + + "github.com/nlopes/slack" +) + +func main() { + api := slack.New("YOUR_TOKEN_HERE") + params := slack.FileUploadParameters{ + Title: "Batman Example", + //Filetype: "txt", + File: "example.txt", + //Content: "Nan Nan Nan Nan Nan Nan Nan Nan Batman", + } + file, err := api.UploadFile(params) + if err != nil { + fmt.Printf("%s\n", err) + return + } + fmt.Printf("Name: %s, URL: %s\n", file.Name, file.URL) + + err = api.DeleteFile(file.ID) + if err != nil { + fmt.Printf("%s\n", err) + return + } + fmt.Printf("File %s deleted successfully.\n", file.Name) +} diff --git a/vendor/github.com/nlopes/slack/examples/groups/groups.go b/vendor/github.com/nlopes/slack/examples/groups/groups.go new file mode 100644 index 00000000..2af215d1 --- /dev/null +++ b/vendor/github.com/nlopes/slack/examples/groups/groups.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + + "github.com/nlopes/slack" +) + +func main() { + api := slack.New("YOUR_TOKEN_HERE") + // If you set debugging, it will log all requests to the console + // Useful when encountering issues + // api.SetDebug(true) + groups, err := api.GetGroups(false) + if err != nil { + fmt.Printf("%s\n", err) + return + } + for _, group := range groups { + fmt.Printf("ID: %s, Name: %s\n", group.ID, group.Name) + } +} diff --git a/vendor/github.com/nlopes/slack/examples/messages/messages.go b/vendor/github.com/nlopes/slack/examples/messages/messages.go new file mode 100644 index 00000000..b3ea87f3 --- /dev/null +++ b/vendor/github.com/nlopes/slack/examples/messages/messages.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + + "github.com/nlopes/slack" +) + +func main() { + api := slack.New("YOUR_TOKEN_HERE") + params := slack.PostMessageParameters{} + attachment := slack.Attachment{ + Pretext: "some pretext", + Text: "some text", + // Uncomment the following part to send a field too + /* + Fields: []slack.AttachmentField{ + slack.AttachmentField{ + Title: "a", + Value: "no", + }, + }, + */ + } + params.Attachments = []slack.Attachment{attachment} + channelID, timestamp, err := api.PostMessage("CHANNEL_ID", "Some text", params) + if err != nil { + fmt.Printf("%s\n", err) + return + } + fmt.Printf("Message successfully sent to channel %s at %s", channelID, timestamp) +} diff --git a/vendor/github.com/nlopes/slack/examples/pins/pins.go b/vendor/github.com/nlopes/slack/examples/pins/pins.go new file mode 100644 index 00000000..d225184c --- /dev/null +++ b/vendor/github.com/nlopes/slack/examples/pins/pins.go @@ -0,0 +1,123 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/nlopes/slack" +) + +/* + WARNING: This example is destructive in the sense that it create a channel called testpinning +*/ +func main() { + var ( + apiToken string + debug bool + ) + + flag.StringVar(&apiToken, "token", "YOUR_TOKEN_HERE", "Your Slack API Token") + flag.BoolVar(&debug, "debug", false, "Show JSON output") + flag.Parse() + + api := slack.New(apiToken) + if debug { + api.SetDebug(true) + } + + var ( + postAsUserName string + postAsUserID string + postToChannelID string + ) + + // Find the user to post as. + authTest, err := api.AuthTest() + if err != nil { + fmt.Printf("Error getting channels: %s\n", err) + return + } + + channelName := "testpinning" + + // Post as the authenticated user. + postAsUserName = authTest.User + postAsUserID = authTest.UserID + + // Create a temporary channel + channel, err := api.CreateChannel(channelName) + + if err != nil { + // If the channel exists, that means we just need to unarchive it + if err.Error() == "name_taken" { + err = nil + channels, err := api.GetChannels(false) + if err != nil { + fmt.Println("Could not retrieve channels") + return + } + for _, archivedChannel := range channels { + if archivedChannel.Name == channelName { + if archivedChannel.IsArchived { + err = api.UnarchiveChannel(archivedChannel.ID) + if err != nil { + fmt.Printf("Could not unarchive %s: %s\n", archivedChannel.ID, err) + return + } + } + channel = &archivedChannel + break + } + } + } + if err != nil { + fmt.Printf("Error setting test channel for pinning: %s\n", err) + return + } + } + postToChannelID = channel.ID + + fmt.Printf("Posting as %s (%s) in channel %s\n", postAsUserName, postAsUserID, postToChannelID) + + // Post a message. + postParams := slack.PostMessageParameters{} + channelID, timestamp, err := api.PostMessage(postToChannelID, "Is this any good?", postParams) + if err != nil { + fmt.Printf("Error posting message: %s\n", err) + return + } + + // Grab a reference to the message. + msgRef := slack.NewRefToMessage(channelID, timestamp) + + // Add message pin to channel + if err := api.AddPin(channelID, msgRef); err != nil { + fmt.Printf("Error adding pin: %s\n", err) + return + } + + // List all of the users pins. + listPins, _, err := api.ListPins(channelID) + if err != nil { + fmt.Printf("Error listing pins: %s\n", err) + return + } + fmt.Printf("\n") + fmt.Printf("All pins by %s...\n", authTest.User) + for _, item := range listPins { + fmt.Printf(" > Item type: %s\n", item.Type) + } + + // Remove the pin. + err = api.RemovePin(channelID, msgRef) + if err != nil { + fmt.Printf("Error remove pin: %s\n", err) + return + } + + if err = api.ArchiveChannel(channelID); err != nil { + fmt.Printf("Error archiving channel: %s\n", err) + return + } + +} diff --git a/vendor/github.com/nlopes/slack/examples/reactions/reactions.go b/vendor/github.com/nlopes/slack/examples/reactions/reactions.go new file mode 100644 index 00000000..753f0d25 --- /dev/null +++ b/vendor/github.com/nlopes/slack/examples/reactions/reactions.go @@ -0,0 +1,126 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/nlopes/slack" +) + +func main() { + var ( + apiToken string + debug bool + ) + + flag.StringVar(&apiToken, "token", "YOUR_TOKEN_HERE", "Your Slack API Token") + flag.BoolVar(&debug, "debug", false, "Show JSON output") + flag.Parse() + + api := slack.New(apiToken) + if debug { + api.SetDebug(true) + } + + var ( + postAsUserName string + postAsUserID string + postToUserName string + postToUserID string + postToChannelID string + ) + + // Find the user to post as. + authTest, err := api.AuthTest() + if err != nil { + fmt.Printf("Error getting channels: %s\n", err) + return + } + + // Post as the authenticated user. + postAsUserName = authTest.User + postAsUserID = authTest.UserID + + // Posting to DM with self causes a conversation with slackbot. + postToUserName = authTest.User + postToUserID = authTest.UserID + + // Find the channel. + _, _, chanID, err := api.OpenIMChannel(postToUserID) + if err != nil { + fmt.Printf("Error opening IM: %s\n", err) + return + } + postToChannelID = chanID + + fmt.Printf("Posting as %s (%s) in DM with %s (%s), channel %s\n", postAsUserName, postAsUserID, postToUserName, postToUserID, postToChannelID) + + // Post a message. + postParams := slack.PostMessageParameters{} + channelID, timestamp, err := api.PostMessage(postToChannelID, "Is this any good?", postParams) + if err != nil { + fmt.Printf("Error posting message: %s\n", err) + return + } + + // Grab a reference to the message. + msgRef := slack.NewRefToMessage(channelID, timestamp) + + // React with :+1: + if err := api.AddReaction("+1", msgRef); err != nil { + fmt.Printf("Error adding reaction: %s\n", err) + return + } + + // React with :-1: + if err := api.AddReaction("cry", msgRef); err != nil { + fmt.Printf("Error adding reaction: %s\n", err) + return + } + + // Get all reactions on the message. + msgReactions, err := api.GetReactions(msgRef, slack.NewGetReactionsParameters()) + if err != nil { + fmt.Printf("Error getting reactions: %s\n", err) + return + } + fmt.Printf("\n") + fmt.Printf("%d reactions to message...\n", len(msgReactions)) + for _, r := range msgReactions { + fmt.Printf(" %d users say %s\n", r.Count, r.Name) + } + + // List all of the users reactions. + listReactions, _, err := api.ListReactions(slack.NewListReactionsParameters()) + if err != nil { + fmt.Printf("Error listing reactions: %s\n", err) + return + } + fmt.Printf("\n") + fmt.Printf("All reactions by %s...\n", authTest.User) + for _, item := range listReactions { + fmt.Printf("%d on a %s...\n", len(item.Reactions), item.Type) + for _, r := range item.Reactions { + fmt.Printf(" %s (along with %d others)\n", r.Name, r.Count-1) + } + } + + // Remove the :cry: reaction. + err = api.RemoveReaction("cry", msgRef) + if err != nil { + fmt.Printf("Error remove reaction: %s\n", err) + return + } + + // Get all reactions on the message. + msgReactions, err = api.GetReactions(msgRef, slack.NewGetReactionsParameters()) + if err != nil { + fmt.Printf("Error getting reactions: %s\n", err) + return + } + fmt.Printf("\n") + fmt.Printf("%d reactions to message after removing cry...\n", len(msgReactions)) + for _, r := range msgReactions { + fmt.Printf(" %d users say %s\n", r.Count, r.Name) + } +} diff --git a/vendor/github.com/nlopes/slack/examples/stars/stars.go b/vendor/github.com/nlopes/slack/examples/stars/stars.go new file mode 100644 index 00000000..d20c3dcf --- /dev/null +++ b/vendor/github.com/nlopes/slack/examples/stars/stars.go @@ -0,0 +1,46 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/nlopes/slack" +) + +func main() { + var ( + apiToken string + debug bool + ) + + flag.StringVar(&apiToken, "token", "YOUR_TOKEN_HERE", "Your Slack API Token") + flag.BoolVar(&debug, "debug", false, "Show JSON output") + flag.Parse() + + api := slack.New(apiToken) + if debug { + api.SetDebug(true) + } + + // Get all stars for the usr. + params := slack.NewStarsParameters() + starredItems, _, err := api.GetStarred(params) + if err != nil { + fmt.Printf("Error getting stars: %s\n", err) + return + } + for _, s := range starredItems { + var desc string + switch s.Type { + case slack.TYPE_MESSAGE: + desc = s.Message.Text + case slack.TYPE_FILE: + desc = s.File.Name + case slack.TYPE_FILE_COMMENT: + desc = s.File.Name + " - " + s.Comment.Comment + case slack.TYPE_CHANNEL, slack.TYPE_IM, slack.TYPE_GROUP: + desc = s.Channel + } + fmt.Printf("Starred %s: %s\n", s.Type, desc) + } +} diff --git a/vendor/github.com/nlopes/slack/examples/users/users.go b/vendor/github.com/nlopes/slack/examples/users/users.go new file mode 100644 index 00000000..9a6e1f6f --- /dev/null +++ b/vendor/github.com/nlopes/slack/examples/users/users.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + + "github.com/nlopes/slack" +) + +func main() { + api := slack.New("YOUR_TOKEN_HERE") + user, err := api.GetUserInfo("U023BECGF") + if err != nil { + fmt.Printf("%s\n", err) + return + } + fmt.Printf("ID: %s, Fullname: %s, Email: %s\n", user.ID, user.Profile.RealName, user.Profile.Email) +} diff --git a/vendor/github.com/nlopes/slack/examples/websocket/websocket.go b/vendor/github.com/nlopes/slack/examples/websocket/websocket.go new file mode 100644 index 00000000..612b97c0 --- /dev/null +++ b/vendor/github.com/nlopes/slack/examples/websocket/websocket.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/nlopes/slack" +) + +func main() { + api := slack.New("YOUR TOKEN HERE") + logger := log.New(os.Stdout, "slack-bot: ", log.Lshortfile|log.LstdFlags) + slack.SetLogger(logger) + api.SetDebug(true) + + rtm := api.NewRTM() + go rtm.ManageConnection() + +Loop: + for { + select { + case msg := <-rtm.IncomingEvents: + fmt.Print("Event Received: ") + switch ev := msg.Data.(type) { + case *slack.HelloEvent: + // Ignore hello + + case *slack.ConnectedEvent: + fmt.Println("Infos:", ev.Info) + fmt.Println("Connection counter:", ev.ConnectionCount) + // Replace #general with your Channel ID + rtm.SendMessage(rtm.NewOutgoingMessage("Hello world", "#general")) + + case *slack.MessageEvent: + fmt.Printf("Message: %v\n", ev) + + case *slack.PresenceChangeEvent: + fmt.Printf("Presence Change: %v\n", ev) + + case *slack.LatencyReport: + fmt.Printf("Current latency: %v\n", ev.Value) + + case *slack.RTMError: + fmt.Printf("Error: %s\n", ev.Error()) + + case *slack.InvalidAuthEvent: + fmt.Printf("Invalid credentials") + break Loop + + default: + + // Ignore other events.. + // fmt.Printf("Unexpected: %v\n", msg.Data) + } + } + } +} diff --git a/vendor/github.com/nlopes/slack/files.go b/vendor/github.com/nlopes/slack/files.go new file mode 100644 index 00000000..18fc9ba9 --- /dev/null +++ b/vendor/github.com/nlopes/slack/files.go @@ -0,0 +1,274 @@ +package slack + +import ( + "errors" + "net/url" + "strconv" + "strings" +) + +const ( + // Add here the defaults in the siten + DEFAULT_FILES_USER = "" + DEFAULT_FILES_CHANNEL = "" + DEFAULT_FILES_TS_FROM = 0 + DEFAULT_FILES_TS_TO = -1 + DEFAULT_FILES_TYPES = "all" + DEFAULT_FILES_COUNT = 100 + DEFAULT_FILES_PAGE = 1 +) + +// File contains all the information for a file +type File struct { + ID string `json:"id"` + Created JSONTime `json:"created"` + Timestamp JSONTime `json:"timestamp"` + + Name string `json:"name"` + Title string `json:"title"` + Mimetype string `json:"mimetype"` + ImageExifRotation int `json:"image_exif_rotation"` + Filetype string `json:"filetype"` + PrettyType string `json:"pretty_type"` + User string `json:"user"` + + Mode string `json:"mode"` + Editable bool `json:"editable"` + IsExternal bool `json:"is_external"` + ExternalType string `json:"external_type"` + + Size int `json:"size"` + + URL string `json:"url"` // Deprecated - never set + URLDownload string `json:"url_download"` // Deprecated - never set + URLPrivate string `json:"url_private"` + URLPrivateDownload string `json:"url_private_download"` + + OriginalH int `json:"original_h"` + OriginalW int `json:"original_w"` + Thumb64 string `json:"thumb_64"` + Thumb80 string `json:"thumb_80"` + Thumb160 string `json:"thumb_160"` + Thumb360 string `json:"thumb_360"` + Thumb360Gif string `json:"thumb_360_gif"` + Thumb360W int `json:"thumb_360_w"` + Thumb360H int `json:"thumb_360_h"` + Thumb480 string `json:"thumb_480"` + Thumb480W int `json:"thumb_480_w"` + Thumb480H int `json:"thumb_480_h"` + Thumb720 string `json:"thumb_720"` + Thumb720W int `json:"thumb_720_w"` + Thumb720H int `json:"thumb_720_h"` + Thumb960 string `json:"thumb_960"` + Thumb960W int `json:"thumb_960_w"` + Thumb960H int `json:"thumb_960_h"` + Thumb1024 string `json:"thumb_1024"` + Thumb1024W int `json:"thumb_1024_w"` + Thumb1024H int `json:"thumb_1024_h"` + + Permalink string `json:"permalink"` + PermalinkPublic string `json:"permalink_public"` + + EditLink string `json:"edit_link"` + Preview string `json:"preview"` + PreviewHighlight string `json:"preview_highlight"` + Lines int `json:"lines"` + LinesMore int `json:"lines_more"` + + IsPublic bool `json:"is_public"` + PublicURLShared bool `json:"public_url_shared"` + Channels []string `json:"channels"` + Groups []string `json:"groups"` + IMs []string `json:"ims"` + InitialComment Comment `json:"initial_comment"` + CommentsCount int `json:"comments_count"` + NumStars int `json:"num_stars"` + IsStarred bool `json:"is_starred"` +} + +// FileUploadParameters contains all the parameters necessary (including the optional ones) for an UploadFile() request +type FileUploadParameters struct { + File string + Content string + Filetype string + Filename string + Title string + InitialComment string + Channels []string +} + +// GetFilesParameters contains all the parameters necessary (including the optional ones) for a GetFiles() request +type GetFilesParameters struct { + User string + Channel string + TimestampFrom JSONTime + TimestampTo JSONTime + Types string + Count int + Page int +} + +type fileResponseFull struct { + File `json:"file"` + Paging `json:"paging"` + Comments []Comment `json:"comments"` + Files []File `json:"files"` + + SlackResponse +} + +// NewGetFilesParameters provides an instance of GetFilesParameters with all the sane default values set +func NewGetFilesParameters() GetFilesParameters { + return GetFilesParameters{ + User: DEFAULT_FILES_USER, + Channel: DEFAULT_FILES_CHANNEL, + TimestampFrom: DEFAULT_FILES_TS_FROM, + TimestampTo: DEFAULT_FILES_TS_TO, + Types: DEFAULT_FILES_TYPES, + Count: DEFAULT_FILES_COUNT, + Page: DEFAULT_FILES_PAGE, + } +} + +func fileRequest(path string, values url.Values, debug bool) (*fileResponseFull, error) { + response := &fileResponseFull{} + err := post(path, values, response, debug) + if err != nil { + return nil, err + } + if !response.Ok { + return nil, errors.New(response.Error) + } + return response, nil +} + +// GetFileInfo retrieves a file and related comments +func (api *Client) GetFileInfo(fileID string, count, page int) (*File, []Comment, *Paging, error) { + values := url.Values{ + "token": {api.config.token}, + "file": {fileID}, + "count": {strconv.Itoa(count)}, + "page": {strconv.Itoa(page)}, + } + response, err := fileRequest("files.info", values, api.debug) + if err != nil { + return nil, nil, nil, err + } + return &response.File, response.Comments, &response.Paging, nil +} + +// GetFiles retrieves all files according to the parameters given +func (api *Client) GetFiles(params GetFilesParameters) ([]File, *Paging, error) { + values := url.Values{ + "token": {api.config.token}, + } + if params.User != DEFAULT_FILES_USER { + values.Add("user", params.User) + } + if params.Channel != DEFAULT_FILES_CHANNEL { + values.Add("channel", params.Channel) + } + // XXX: this is broken. fix it with a proper unix timestamp + if params.TimestampFrom != DEFAULT_FILES_TS_FROM { + values.Add("ts_from", params.TimestampFrom.String()) + } + if params.TimestampTo != DEFAULT_FILES_TS_TO { + values.Add("ts_to", params.TimestampTo.String()) + } + if params.Types != DEFAULT_FILES_TYPES { + values.Add("types", params.Types) + } + if params.Count != DEFAULT_FILES_COUNT { + values.Add("count", strconv.Itoa(params.Count)) + } + if params.Page != DEFAULT_FILES_PAGE { + values.Add("page", strconv.Itoa(params.Page)) + } + response, err := fileRequest("files.list", values, api.debug) + if err != nil { + return nil, nil, err + } + return response.Files, &response.Paging, nil +} + +// UploadFile uploads a file +func (api *Client) UploadFile(params FileUploadParameters) (file *File, err error) { + // Test if user token is valid. This helps because client.Do doesn't like this for some reason. XXX: More + // investigation needed, but for now this will do. + _, err = api.AuthTest() + if err != nil { + return nil, err + } + response := &fileResponseFull{} + values := url.Values{ + "token": {api.config.token}, + } + if params.Filetype != "" { + values.Add("filetype", params.Filetype) + } + if params.Filename != "" { + values.Add("filename", params.Filename) + } + if params.Title != "" { + values.Add("title", params.Title) + } + if params.InitialComment != "" { + values.Add("initial_comment", params.InitialComment) + } + if len(params.Channels) != 0 { + values.Add("channels", strings.Join(params.Channels, ",")) + } + if params.Content != "" { + values.Add("content", params.Content) + err = post("files.upload", values, response, api.debug) + } else if params.File != "" { + err = postWithMultipartResponse("files.upload", params.File, values, response, api.debug) + } + if err != nil { + return nil, err + } + if !response.Ok { + return nil, errors.New(response.Error) + } + return &response.File, nil +} + +// DeleteFile deletes a file +func (api *Client) DeleteFile(fileID string) error { + values := url.Values{ + "token": {api.config.token}, + "file": {fileID}, + } + _, err := fileRequest("files.delete", values, api.debug) + if err != nil { + return err + } + return nil + +} + +// RevokeFilePublicURL disables public/external sharing for a file +func (api *Client) RevokeFilePublicURL(fileID string) (*File, error) { + values := url.Values{ + "token": {api.config.token}, + "file": {fileID}, + } + response, err := fileRequest("files.revokePublicURL", values, api.debug) + if err != nil { + return nil, err + } + return &response.File, nil +} + +// ShareFilePublicURL enabled public/external sharing for a file +func (api *Client) ShareFilePublicURL(fileID string) (*File, []Comment, *Paging, error) { + values := url.Values{ + "token": {api.config.token}, + "file": {fileID}, + } + response, err := fileRequest("files.sharedPublicURL", values, api.debug) + if err != nil { + return nil, nil, nil, err + } + return &response.File, response.Comments, &response.Paging, nil +} diff --git a/vendor/github.com/nlopes/slack/groups.go b/vendor/github.com/nlopes/slack/groups.go new file mode 100644 index 00000000..ec4a3b65 --- /dev/null +++ b/vendor/github.com/nlopes/slack/groups.go @@ -0,0 +1,293 @@ +package slack + +import ( + "errors" + "net/url" + "strconv" +) + +// Group contains all the information for a group +type Group struct { + groupConversation + IsGroup bool `json:"is_group"` +} + +type groupResponseFull struct { + Group Group `json:"group"` + Groups []Group `json:"groups"` + Purpose string `json:"purpose"` + Topic string `json:"topic"` + NotInGroup bool `json:"not_in_group"` + NoOp bool `json:"no_op"` + AlreadyClosed bool `json:"already_closed"` + AlreadyOpen bool `json:"already_open"` + AlreadyInGroup bool `json:"already_in_group"` + Channel Channel `json:"channel"` + History + SlackResponse +} + +func groupRequest(path string, values url.Values, debug bool) (*groupResponseFull, error) { + response := &groupResponseFull{} + err := post(path, values, response, debug) + if err != nil { + return nil, err + } + if !response.Ok { + return nil, errors.New(response.Error) + } + return response, nil +} + +// ArchiveGroup archives a private group +func (api *Client) ArchiveGroup(group string) error { + values := url.Values{ + "token": {api.config.token}, + "channel": {group}, + } + _, err := groupRequest("groups.archive", values, api.debug) + if err != nil { + return err + } + return nil +} + +// UnarchiveGroup unarchives a private group +func (api *Client) UnarchiveGroup(group string) error { + values := url.Values{ + "token": {api.config.token}, + "channel": {group}, + } + _, err := groupRequest("groups.unarchive", values, api.debug) + if err != nil { + return err + } + return nil +} + +// CreateGroup creates a private group +func (api *Client) CreateGroup(group string) (*Group, error) { + values := url.Values{ + "token": {api.config.token}, + "name": {group}, + } + response, err := groupRequest("groups.create", values, api.debug) + if err != nil { + return nil, err + } + return &response.Group, nil +} + +// CreateChildGroup creates a new private group archiving the old one +// This method takes an existing private group and performs the following steps: +// 1. Renames the existing group (from "example" to "example-archived"). +// 2. Archives the existing group. +// 3. Creates a new group with the name of the existing group. +// 4. Adds all members of the existing group to the new group. +func (api *Client) CreateChildGroup(group string) (*Group, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {group}, + } + response, err := groupRequest("groups.createChild", values, api.debug) + if err != nil { + return nil, err + } + return &response.Group, nil +} + +// CloseGroup closes a private group +func (api *Client) CloseGroup(group string) (bool, bool, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {group}, + } + response, err := imRequest("groups.close", values, api.debug) + if err != nil { + return false, false, err + } + return response.NoOp, response.AlreadyClosed, nil +} + +// GetGroupHistory fetches all the history for a private group +func (api *Client) GetGroupHistory(group string, params HistoryParameters) (*History, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {group}, + } + if params.Latest != DEFAULT_HISTORY_LATEST { + values.Add("latest", params.Latest) + } + if params.Oldest != DEFAULT_HISTORY_OLDEST { + values.Add("oldest", params.Oldest) + } + if params.Count != DEFAULT_HISTORY_COUNT { + values.Add("count", strconv.Itoa(params.Count)) + } + if params.Inclusive != DEFAULT_HISTORY_INCLUSIVE { + if params.Inclusive { + values.Add("inclusive", "1") + } else { + values.Add("inclusive", "0") + } + } + if params.Unreads != DEFAULT_HISTORY_UNREADS { + if params.Unreads { + values.Add("unreads", "1") + } else { + values.Add("unreads", "0") + } + } + response, err := groupRequest("groups.history", values, api.debug) + if err != nil { + return nil, err + } + return &response.History, nil +} + +// InviteUserToGroup invites a specific user to a private group +func (api *Client) InviteUserToGroup(group, user string) (*Group, bool, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {group}, + "user": {user}, + } + response, err := groupRequest("groups.invite", values, api.debug) + if err != nil { + return nil, false, err + } + return &response.Group, response.AlreadyInGroup, nil +} + +// LeaveGroup makes authenticated user leave the group +func (api *Client) LeaveGroup(group string) error { + values := url.Values{ + "token": {api.config.token}, + "channel": {group}, + } + _, err := groupRequest("groups.leave", values, api.debug) + if err != nil { + return err + } + return nil +} + +// KickUserFromGroup kicks a user from a group +func (api *Client) KickUserFromGroup(group, user string) error { + values := url.Values{ + "token": {api.config.token}, + "channel": {group}, + "user": {user}, + } + _, err := groupRequest("groups.kick", values, api.debug) + if err != nil { + return err + } + return nil +} + +// GetGroups retrieves all groups +func (api *Client) GetGroups(excludeArchived bool) ([]Group, error) { + values := url.Values{ + "token": {api.config.token}, + } + if excludeArchived { + values.Add("exclude_archived", "1") + } + response, err := groupRequest("groups.list", values, api.debug) + if err != nil { + return nil, err + } + return response.Groups, nil +} + +// GetGroupInfo retrieves the given group +func (api *Client) GetGroupInfo(group string) (*Group, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {group}, + } + response, err := groupRequest("groups.info", values, api.debug) + if err != nil { + return nil, err + } + return &response.Group, nil +} + +// SetGroupReadMark sets the read mark on a private group +// Clients should try to avoid making this call too often. When needing to mark a read position, a client should set a +// timer before making the call. In this way, any further updates needed during the timeout will not generate extra +// calls (just one per channel). This is useful for when reading scroll-back history, or following a busy live +// channel. A timeout of 5 seconds is a good starting point. Be sure to flush these calls on shutdown/logout. +func (api *Client) SetGroupReadMark(group, ts string) error { + values := url.Values{ + "token": {api.config.token}, + "channel": {group}, + "ts": {ts}, + } + _, err := groupRequest("groups.mark", values, api.debug) + if err != nil { + return err + } + return nil +} + +// OpenGroup opens a private group +func (api *Client) OpenGroup(group string) (bool, bool, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {group}, + } + response, err := groupRequest("groups.open", values, api.debug) + if err != nil { + return false, false, err + } + return response.NoOp, response.AlreadyOpen, nil +} + +// RenameGroup renames a group +// XXX: They return a channel, not a group. What is this crap? :( +// Inconsistent api it seems. +func (api *Client) RenameGroup(group, name string) (*Channel, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {group}, + "name": {name}, + } + // XXX: the created entry in this call returns a string instead of a number + // so I may have to do some workaround to solve it. + response, err := groupRequest("groups.rename", values, api.debug) + if err != nil { + return nil, err + } + return &response.Channel, nil + +} + +// SetGroupPurpose sets the group purpose +func (api *Client) SetGroupPurpose(group, purpose string) (string, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {group}, + "purpose": {purpose}, + } + response, err := groupRequest("groups.setPurpose", values, api.debug) + if err != nil { + return "", err + } + return response.Purpose, nil +} + +// SetGroupTopic sets the group topic +func (api *Client) SetGroupTopic(group, topic string) (string, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {group}, + "topic": {topic}, + } + response, err := groupRequest("groups.setTopic", values, api.debug) + if err != nil { + return "", err + } + return response.Topic, nil +} diff --git a/vendor/github.com/nlopes/slack/history.go b/vendor/github.com/nlopes/slack/history.go new file mode 100644 index 00000000..87b2e1ed --- /dev/null +++ b/vendor/github.com/nlopes/slack/history.go @@ -0,0 +1,36 @@ +package slack + +const ( + DEFAULT_HISTORY_LATEST = "" + DEFAULT_HISTORY_OLDEST = "0" + DEFAULT_HISTORY_COUNT = 100 + DEFAULT_HISTORY_INCLUSIVE = false + DEFAULT_HISTORY_UNREADS = false +) + +// HistoryParameters contains all the necessary information to help in the retrieval of history for Channels/Groups/DMs +type HistoryParameters struct { + Latest string + Oldest string + Count int + Inclusive bool + Unreads bool +} + +// History contains message history information needed to navigate a Channel / Group / DM history +type History struct { + Latest string `json:"latest"` + Messages []Message `json:"messages"` + HasMore bool `json:"has_more"` +} + +// NewHistoryParameters provides an instance of HistoryParameters with all the sane default values set +func NewHistoryParameters() HistoryParameters { + return HistoryParameters{ + Latest: DEFAULT_HISTORY_LATEST, + Oldest: DEFAULT_HISTORY_OLDEST, + Count: DEFAULT_HISTORY_COUNT, + Inclusive: DEFAULT_HISTORY_INCLUSIVE, + Unreads: DEFAULT_HISTORY_UNREADS, + } +} diff --git a/vendor/github.com/nlopes/slack/im.go b/vendor/github.com/nlopes/slack/im.go new file mode 100644 index 00000000..6fbc39e9 --- /dev/null +++ b/vendor/github.com/nlopes/slack/im.go @@ -0,0 +1,130 @@ +package slack + +import ( + "errors" + "net/url" + "strconv" +) + +type imChannel struct { + ID string `json:"id"` +} + +type imResponseFull struct { + NoOp bool `json:"no_op"` + AlreadyClosed bool `json:"already_closed"` + AlreadyOpen bool `json:"already_open"` + Channel imChannel `json:"channel"` + IMs []IM `json:"ims"` + History + SlackResponse +} + +// IM contains information related to the Direct Message channel +type IM struct { + conversation + IsIM bool `json:"is_im"` + User string `json:"user"` + IsUserDeleted bool `json:"is_user_deleted"` +} + +func imRequest(path string, values url.Values, debug bool) (*imResponseFull, error) { + response := &imResponseFull{} + err := post(path, values, response, debug) + if err != nil { + return nil, err + } + if !response.Ok { + return nil, errors.New(response.Error) + } + return response, nil +} + +// CloseIMChannel closes the direct message channel +func (api *Client) CloseIMChannel(channel string) (bool, bool, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + } + response, err := imRequest("im.close", values, api.debug) + if err != nil { + return false, false, err + } + return response.NoOp, response.AlreadyClosed, nil +} + +// OpenIMChannel opens a direct message channel to the user provided as argument +// Returns some status and the channel ID +func (api *Client) OpenIMChannel(user string) (bool, bool, string, error) { + values := url.Values{ + "token": {api.config.token}, + "user": {user}, + } + response, err := imRequest("im.open", values, api.debug) + if err != nil { + return false, false, "", err + } + return response.NoOp, response.AlreadyOpen, response.Channel.ID, nil +} + +// MarkIMChannel sets the read mark of a direct message channel to a specific point +func (api *Client) MarkIMChannel(channel, ts string) (err error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + "ts": {ts}, + } + _, err = imRequest("im.mark", values, api.debug) + if err != nil { + return err + } + return +} + +// GetIMHistory retrieves the direct message channel history +func (api *Client) GetIMHistory(channel string, params HistoryParameters) (*History, error) { + values := url.Values{ + "token": {api.config.token}, + "channel": {channel}, + } + if params.Latest != DEFAULT_HISTORY_LATEST { + values.Add("latest", params.Latest) + } + if params.Oldest != DEFAULT_HISTORY_OLDEST { + values.Add("oldest", params.Oldest) + } + if params.Count != DEFAULT_HISTORY_COUNT { + values.Add("count", strconv.Itoa(params.Count)) + } + if params.Inclusive != DEFAULT_HISTORY_INCLUSIVE { + if params.Inclusive { + values.Add("inclusive", "1") + } else { + values.Add("inclusive", "0") + } + } + if params.Unreads != DEFAULT_HISTORY_UNREADS { + if params.Unreads { + values.Add("unreads", "1") + } else { + values.Add("unreads", "0") + } + } + response, err := imRequest("im.history", values, api.debug) + if err != nil { + return nil, err + } + return &response.History, nil +} + +// GetIMChannels returns the list of direct message channels +func (api *Client) GetIMChannels() ([]IM, error) { + values := url.Values{ + "token": {api.config.token}, + } + response, err := imRequest("im.list", values, api.debug) + if err != nil { + return nil, err + } + return response.IMs, nil +} diff --git a/vendor/github.com/nlopes/slack/info.go b/vendor/github.com/nlopes/slack/info.go new file mode 100644 index 00000000..5f0e5414 --- /dev/null +++ b/vendor/github.com/nlopes/slack/info.go @@ -0,0 +1,206 @@ +package slack + +import ( + "fmt" + "time" +) + +// UserPrefs needs to be implemented +type UserPrefs struct { + // "highlight_words":"", + // "user_colors":"", + // "color_names_in_list":true, + // "growls_enabled":true, + // "tz":"Europe\/London", + // "push_dm_alert":true, + // "push_mention_alert":true, + // "push_everything":true, + // "push_idle_wait":2, + // "push_sound":"b2.mp3", + // "push_loud_channels":"", + // "push_mention_channels":"", + // "push_loud_channels_set":"", + // "email_alerts":"instant", + // "email_alerts_sleep_until":0, + // "email_misc":false, + // "email_weekly":true, + // "welcome_message_hidden":false, + // "all_channels_loud":true, + // "loud_channels":"", + // "never_channels":"", + // "loud_channels_set":"", + // "show_member_presence":true, + // "search_sort":"timestamp", + // "expand_inline_imgs":true, + // "expand_internal_inline_imgs":true, + // "expand_snippets":false, + // "posts_formatting_guide":true, + // "seen_welcome_2":true, + // "seen_ssb_prompt":false, + // "search_only_my_channels":false, + // "emoji_mode":"default", + // "has_invited":true, + // "has_uploaded":false, + // "has_created_channel":true, + // "search_exclude_channels":"", + // "messages_theme":"default", + // "webapp_spellcheck":true, + // "no_joined_overlays":false, + // "no_created_overlays":true, + // "dropbox_enabled":false, + // "seen_user_menu_tip_card":true, + // "seen_team_menu_tip_card":true, + // "seen_channel_menu_tip_card":true, + // "seen_message_input_tip_card":true, + // "seen_channels_tip_card":true, + // "seen_domain_invite_reminder":false, + // "seen_member_invite_reminder":false, + // "seen_flexpane_tip_card":true, + // "seen_search_input_tip_card":true, + // "mute_sounds":false, + // "arrow_history":false, + // "tab_ui_return_selects":true, + // "obey_inline_img_limit":true, + // "new_msg_snd":"knock_brush.mp3", + // "collapsible":false, + // "collapsible_by_click":true, + // "require_at":false, + // "mac_ssb_bounce":"", + // "mac_ssb_bullet":true, + // "win_ssb_bullet":true, + // "expand_non_media_attachments":true, + // "show_typing":true, + // "pagekeys_handled":true, + // "last_snippet_type":"", + // "display_real_names_override":0, + // "time24":false, + // "enter_is_special_in_tbt":false, + // "graphic_emoticons":false, + // "convert_emoticons":true, + // "autoplay_chat_sounds":true, + // "ss_emojis":true, + // "sidebar_behavior":"", + // "mark_msgs_read_immediately":true, + // "start_scroll_at_oldest":true, + // "snippet_editor_wrap_long_lines":false, + // "ls_disabled":false, + // "sidebar_theme":"default", + // "sidebar_theme_custom_values":"", + // "f_key_search":false, + // "k_key_omnibox":true, + // "speak_growls":false, + // "mac_speak_voice":"com.apple.speech.synthesis.voice.Alex", + // "mac_speak_speed":250, + // "comma_key_prefs":false, + // "at_channel_suppressed_channels":"", + // "push_at_channel_suppressed_channels":"", + // "prompted_for_email_disabling":false, + // "full_text_extracts":false, + // "no_text_in_notifications":false, + // "muted_channels":"", + // "no_macssb1_banner":false, + // "privacy_policy_seen":true, + // "search_exclude_bots":false, + // "fuzzy_matching":false +} + +// UserDetails contains user details coming in the initial response from StartRTM +type UserDetails struct { + ID string `json:"id"` + Name string `json:"name"` + Created JSONTime `json:"created"` + ManualPresence string `json:"manual_presence"` + Prefs UserPrefs `json:"prefs"` +} + +// JSONTime exists so that we can have a String method converting the date +type JSONTime int64 + +// String converts the unix timestamp into a string +func (t JSONTime) String() string { + tm := t.Time() + return fmt.Sprintf("\"%s\"", tm.Format("Mon Jan _2")) +} + +// Time returns a `time.Time` representation of this value. +func (t JSONTime) Time() time.Time { + return time.Unix(int64(t), 0) +} + +// Team contains details about a team +type Team struct { + ID string `json:"id"` + Name string `json:"name"` + Domain string `json:"domain"` +} + +// Icons XXX: needs further investigation +type Icons struct { + Image48 string `json:"image_48"` +} + +// Bot contains information about a bot +type Bot struct { + ID string `json:"id"` + Name string `json:"name"` + Deleted bool `json:"deleted"` + Icons Icons `json:"icons"` +} + +// Info contains various details about Users, Channels, Bots and the authenticated user. +// It is returned by StartRTM or included in the "ConnectedEvent" RTM event. +type Info struct { + URL string `json:"url,omitempty"` + User *UserDetails `json:"self,omitempty"` + Team *Team `json:"team,omitempty"` + Users []User `json:"users,omitempty"` + Channels []Channel `json:"channels,omitempty"` + Groups []Group `json:"groups,omitempty"` + Bots []Bot `json:"bots,omitempty"` + IMs []IM `json:"ims,omitempty"` +} + +type infoResponseFull struct { + Info + WebResponse +} + +// GetBotByID returns a bot given a bot id +func (info Info) GetBotByID(botID string) *Bot { + for _, bot := range info.Bots { + if bot.ID == botID { + return &bot + } + } + return nil +} + +// GetUserByID returns a user given a user id +func (info Info) GetUserByID(userID string) *User { + for _, user := range info.Users { + if user.ID == userID { + return &user + } + } + return nil +} + +// GetChannelByID returns a channel given a channel id +func (info Info) GetChannelByID(channelID string) *Channel { + for _, channel := range info.Channels { + if channel.ID == channelID { + return &channel + } + } + return nil +} + +// GetGroupByID returns a group given a group id +func (info Info) GetGroupByID(groupID string) *Group { + for _, group := range info.Groups { + if group.ID == groupID { + return &group + } + } + return nil +} diff --git a/vendor/github.com/nlopes/slack/item.go b/vendor/github.com/nlopes/slack/item.go new file mode 100644 index 00000000..89af4eb1 --- /dev/null +++ b/vendor/github.com/nlopes/slack/item.go @@ -0,0 +1,75 @@ +package slack + +const ( + TYPE_MESSAGE = "message" + TYPE_FILE = "file" + TYPE_FILE_COMMENT = "file_comment" + TYPE_CHANNEL = "channel" + TYPE_IM = "im" + TYPE_GROUP = "group" +) + +// Item is any type of slack message - message, file, or file comment. +type Item struct { + Type string `json:"type"` + Channel string `json:"channel,omitempty"` + Message *Message `json:"message,omitempty"` + File *File `json:"file,omitempty"` + Comment *Comment `json:"comment,omitempty"` + Timestamp string `json:"ts,omitempty"` +} + +// NewMessageItem turns a message on a channel into a typed message struct. +func NewMessageItem(ch string, m *Message) Item { + return Item{Type: TYPE_MESSAGE, Channel: ch, Message: m} +} + +// NewFileItem turns a file into a typed file struct. +func NewFileItem(f *File) Item { + return Item{Type: TYPE_FILE, File: f} +} + +// NewFileCommentItem turns a file and comment into a typed file_comment struct. +func NewFileCommentItem(f *File, c *Comment) Item { + return Item{Type: TYPE_FILE_COMMENT, File: f, Comment: c} +} + +// NewChannelItem turns a channel id into a typed channel struct. +func NewChannelItem(ch string) Item { + return Item{Type: TYPE_CHANNEL, Channel: ch} +} + +// NewIMItem turns a channel id into a typed im struct. +func NewIMItem(ch string) Item { + return Item{Type: TYPE_IM, Channel: ch} +} + +// NewGroupItem turns a channel id into a typed group struct. +func NewGroupItem(ch string) Item { + return Item{Type: TYPE_GROUP, Channel: ch} +} + +// ItemRef is a reference to a message of any type. One of FileID, +// CommentId, or the combination of ChannelId and Timestamp must be +// specified. +type ItemRef struct { + Channel string `json:"channel"` + Timestamp string `json:"timestamp"` + File string `json:"file"` + Comment string `json:"file_comment"` +} + +// NewRefToMessage initializes a reference to to a message. +func NewRefToMessage(channel, timestamp string) ItemRef { + return ItemRef{Channel: channel, Timestamp: timestamp} +} + +// NewRefToFile initializes a reference to a file. +func NewRefToFile(file string) ItemRef { + return ItemRef{File: file} +} + +// NewRefToComment initializes a reference to a file comment. +func NewRefToComment(comment string) ItemRef { + return ItemRef{Comment: comment} +} diff --git a/vendor/github.com/nlopes/slack/messageID.go b/vendor/github.com/nlopes/slack/messageID.go new file mode 100644 index 00000000..a17472b4 --- /dev/null +++ b/vendor/github.com/nlopes/slack/messageID.go @@ -0,0 +1,30 @@ +package slack + +import "sync" + +// IDGenerator provides an interface for generating integer ID values. +type IDGenerator interface { + Next() int +} + +// NewSafeID returns a new instance of an IDGenerator which is safe for +// concurrent use by multiple goroutines. +func NewSafeID(startID int) IDGenerator { + return &safeID{ + nextID: startID, + mutex: &sync.Mutex{}, + } +} + +type safeID struct { + nextID int + mutex *sync.Mutex +} + +func (s *safeID) Next() int { + s.mutex.Lock() + defer s.mutex.Unlock() + id := s.nextID + s.nextID++ + return id +} diff --git a/vendor/github.com/nlopes/slack/messages.go b/vendor/github.com/nlopes/slack/messages.go new file mode 100644 index 00000000..3433e06b --- /dev/null +++ b/vendor/github.com/nlopes/slack/messages.go @@ -0,0 +1,131 @@ +package slack + +// OutgoingMessage is used for the realtime API, and seems incomplete. +type OutgoingMessage struct { + ID int `json:"id"` + Channel string `json:"channel,omitempty"` + Text string `json:"text,omitempty"` + Type string `json:"type,omitempty"` +} + +// Message is an auxiliary type to allow us to have a message containing sub messages +type Message struct { + Msg + SubMessage *Msg `json:"message,omitempty"` +} + +// Msg contains information about a slack message +type Msg struct { + // Basic Message + Type string `json:"type,omitempty"` + Channel string `json:"channel,omitempty"` + User string `json:"user,omitempty"` + Text string `json:"text,omitempty"` + Timestamp string `json:"ts,omitempty"` + IsStarred bool `json:"is_starred,omitempty"` + PinnedTo []string `json:"pinned_to, omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` + Edited *Edited `json:"edited,omitempty"` + + // Message Subtypes + SubType string `json:"subtype,omitempty"` + + // Hidden Subtypes + Hidden bool `json:"hidden,omitempty"` // message_changed, message_deleted, unpinned_item + DeletedTimestamp string `json:"deleted_ts,omitempty"` // message_deleted + EventTimestamp string `json:"event_ts,omitempty"` + + // bot_message (https://api.slack.com/events/message/bot_message) + BotID string `json:"bot_id,omitempty"` + Username string `json:"username,omitempty"` + Icons *Icon `json:"icons,omitempty"` + + // channel_join, group_join + Inviter string `json:"inviter,omitempty"` + + // channel_topic, group_topic + Topic string `json:"topic,omitempty"` + + // channel_purpose, group_purpose + Purpose string `json:"purpose,omitempty"` + + // channel_name, group_name + Name string `json:"name,omitempty"` + OldName string `json:"old_name,omitempty"` + + // channel_archive, group_archive + Members []string `json:"members,omitempty"` + + // file_share, file_comment, file_mention + File *File `json:"file,omitempty"` + + // file_share + Upload bool `json:"upload,omitempty"` + + // file_comment + Comment *Comment `json:"comment,omitempty"` + + // pinned_item + ItemType string `json:"item_type,omitempty"` + + // https://api.slack.com/rtm + ReplyTo int `json:"reply_to,omitempty"` + Team string `json:"team,omitempty"` + + // reactions + Reactions []ItemReaction `json:"reactions,omitempty"` +} + +// Icon is used for bot messages +type Icon struct { + IconURL string `json:"icon_url,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` +} + +// Edited indicates that a message has been edited. +type Edited struct { + User string `json:"user,omitempty"` + Timestamp string `json:"ts,omitempty"` +} + +// Event contains the event type +type Event struct { + Type string `json:"type,omitempty"` +} + +// Ping contains information about a Ping Event +type Ping struct { + ID int `json:"id"` + Type string `json:"type"` +} + +// Pong contains information about a Pong Event +type Pong struct { + Type string `json:"type"` + ReplyTo int `json:"reply_to"` +} + +// NewOutgoingMessage prepares an OutgoingMessage that the user can +// use to send a message. Use this function to properly set the +// messageID. +func (rtm *RTM) NewOutgoingMessage(text string, channel string) *OutgoingMessage { + id := rtm.idGen.Next() + return &OutgoingMessage{ + ID: id, + Type: "message", + Channel: channel, + Text: text, + } +} + +// NewTypingMessage prepares an OutgoingMessage that the user can +// use to send as a typing indicator. Use this function to properly set the +// messageID. +func (rtm *RTM) NewTypingMessage(channel string) *OutgoingMessage { + id := rtm.idGen.Next() + return &OutgoingMessage{ + ID: id, + Type: "typing", + Channel: channel, + } +} diff --git a/vendor/github.com/nlopes/slack/misc.go b/vendor/github.com/nlopes/slack/misc.go new file mode 100644 index 00000000..c0f023cb --- /dev/null +++ b/vendor/github.com/nlopes/slack/misc.go @@ -0,0 +1,119 @@ +package slack + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "time" +) + +var HTTPClient = &http.Client{} + +type WebResponse struct { + Ok bool `json:"ok"` + Error *WebError `json:"error"` +} + +type WebError string + +func (s WebError) Error() string { + return string(s) +} + +func fileUploadReq(path, fpath string, values url.Values) (*http.Request, error) { + fullpath, err := filepath.Abs(fpath) + if err != nil { + return nil, err + } + file, err := os.Open(fullpath) + if err != nil { + return nil, err + } + defer file.Close() + + body := &bytes.Buffer{} + wr := multipart.NewWriter(body) + + ioWriter, err := wr.CreateFormFile("file", filepath.Base(fullpath)) + if err != nil { + wr.Close() + return nil, err + } + bytes, err := io.Copy(ioWriter, file) + if err != nil { + wr.Close() + return nil, err + } + // Close the multipart writer or the footer won't be written + wr.Close() + stat, err := file.Stat() + if err != nil { + return nil, err + } + if bytes != stat.Size() { + return nil, errors.New("could not read the whole file") + } + req, err := http.NewRequest("POST", path, body) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", wr.FormDataContentType()) + req.URL.RawQuery = (values).Encode() + return req, nil +} + +func parseResponseBody(body io.ReadCloser, intf *interface{}, debug bool) error { + response, err := ioutil.ReadAll(body) + if err != nil { + return err + } + + // FIXME: will be api.Debugf + if debug { + logger.Printf("parseResponseBody: %s\n", string(response)) + } + + err = json.Unmarshal(response, &intf) + if err != nil { + return err + } + + return nil +} + +func postWithMultipartResponse(path string, filepath string, values url.Values, intf interface{}, debug bool) error { + req, err := fileUploadReq(SLACK_API+path, filepath, values) + resp, err := HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + return parseResponseBody(resp.Body, &intf, debug) +} + +func postForm(endpoint string, values url.Values, intf interface{}, debug bool) error { + resp, err := HTTPClient.PostForm(endpoint, values) + if err != nil { + return err + } + defer resp.Body.Close() + + return parseResponseBody(resp.Body, &intf, debug) +} + +func post(path string, values url.Values, intf interface{}, debug bool) error { + return postForm(SLACK_API+path, values, intf, debug) +} + +func parseAdminResponse(method string, teamName string, values url.Values, intf interface{}, debug bool) error { + endpoint := fmt.Sprintf(SLACK_WEB_API_FORMAT, teamName, method, time.Now().Unix()) + return postForm(endpoint, values, intf, debug) +} diff --git a/vendor/github.com/nlopes/slack/oauth.go b/vendor/github.com/nlopes/slack/oauth.go new file mode 100644 index 00000000..33c3ed2d --- /dev/null +++ b/vendor/github.com/nlopes/slack/oauth.go @@ -0,0 +1,54 @@ +package slack + +import ( + "errors" + "net/url" +) + +type OAuthResponseIncomingWebhook struct { + URL string `json:"url"` + Channel string `json:"channel"` + ConfigurationURL string `json:"configuration_url"` +} + +type OAuthResponseBot struct { + BotUserID string `json:"bot_user_id"` + BotAccessToken string `json:"bot_access_token"` +} + +type OAuthResponse struct { + AccessToken string `json:"access_token"` + Scope string `json:"scope"` + TeamName string `json:"team_name"` + TeamID string `json:"team_id"` + IncomingWebhook OAuthResponseIncomingWebhook `json:"incoming_webhook"` + Bot OAuthResponseBot `json:"bot"` + SlackResponse +} + +// GetOAuthToken retrieves an AccessToken +func GetOAuthToken(clientID, clientSecret, code, redirectURI string, debug bool) (accessToken string, scope string, err error) { + response, err := GetOAuthResponse(clientID, clientSecret, code, redirectURI, debug) + if err != nil { + return "", "", err + } + return response.AccessToken, response.Scope, nil +} + +func GetOAuthResponse(clientID, clientSecret, code, redirectURI string, debug bool) (resp *OAuthResponse, err error) { + values := url.Values{ + "client_id": {clientID}, + "client_secret": {clientSecret}, + "code": {code}, + "redirect_uri": {redirectURI}, + } + response := &OAuthResponse{} + err = post("oauth.access", values, response, debug) + if err != nil { + return nil, err + } + if !response.Ok { + return nil, errors.New(response.Error) + } + return response, nil +} diff --git a/vendor/github.com/nlopes/slack/pagination.go b/vendor/github.com/nlopes/slack/pagination.go new file mode 100644 index 00000000..87dd136a --- /dev/null +++ b/vendor/github.com/nlopes/slack/pagination.go @@ -0,0 +1,20 @@ +package slack + +// Paging contains paging information +type Paging struct { + Count int `json:"count"` + Total int `json:"total"` + Page int `json:"page"` + Pages int `json:"pages"` +} + +// Pagination contains pagination information +// This is different from Paging in that it contains additional details +type Pagination struct { + TotalCount int `json:"total_count"` + Page int `json:"page"` + PerPage int `json:"per_page"` + PageCount int `json:"page_count"` + First int `json:"first"` + Last int `json:"last"` +} diff --git a/vendor/github.com/nlopes/slack/pins.go b/vendor/github.com/nlopes/slack/pins.go new file mode 100644 index 00000000..b95efbb6 --- /dev/null +++ b/vendor/github.com/nlopes/slack/pins.go @@ -0,0 +1,79 @@ +package slack + +import ( + "errors" + "net/url" +) + +type listPinsResponseFull struct { + Items []Item + Paging `json:"paging"` + SlackResponse +} + +// AddPin pins an item in a channel +func (api *Client) AddPin(channel string, item ItemRef) error { + values := url.Values{ + "channel": {channel}, + "token": {api.config.token}, + } + if item.Timestamp != "" { + values.Set("timestamp", string(item.Timestamp)) + } + if item.File != "" { + values.Set("file", string(item.File)) + } + if item.Comment != "" { + values.Set("file_comment", string(item.Comment)) + } + response := &SlackResponse{} + if err := post("pins.add", values, response, api.debug); err != nil { + return err + } + if !response.Ok { + return errors.New(response.Error) + } + return nil +} + +// RemovePin un-pins an item from a channel +func (api *Client) RemovePin(channel string, item ItemRef) error { + values := url.Values{ + "channel": {channel}, + "token": {api.config.token}, + } + if item.Timestamp != "" { + values.Set("timestamp", string(item.Timestamp)) + } + if item.File != "" { + values.Set("file", string(item.File)) + } + if item.Comment != "" { + values.Set("file_comment", string(item.Comment)) + } + response := &SlackResponse{} + if err := post("pins.remove", values, response, api.debug); err != nil { + return err + } + if !response.Ok { + return errors.New(response.Error) + } + return nil +} + +// ListPins returns information about the items a user reacted to. +func (api *Client) ListPins(channel string) ([]Item, *Paging, error) { + values := url.Values{ + "channel": {channel}, + "token": {api.config.token}, + } + response := &listPinsResponseFull{} + err := post("pins.list", values, response, api.debug) + if err != nil { + return nil, nil, err + } + if !response.Ok { + return nil, nil, errors.New(response.Error) + } + return response.Items, &response.Paging, nil +} diff --git a/vendor/github.com/nlopes/slack/reactions.go b/vendor/github.com/nlopes/slack/reactions.go new file mode 100644 index 00000000..8769543d --- /dev/null +++ b/vendor/github.com/nlopes/slack/reactions.go @@ -0,0 +1,246 @@ +package slack + +import ( + "errors" + "net/url" + "strconv" +) + +// ItemReaction is the reactions that have happened on an item. +type ItemReaction struct { + Name string `json:"name"` + Count int `json:"count"` + Users []string `json:"users"` +} + +// ReactedItem is an item that was reacted to, and the details of the +// reactions. +type ReactedItem struct { + Item + Reactions []ItemReaction +} + +// GetReactionsParameters is the inputs to get reactions to an item. +type GetReactionsParameters struct { + Full bool +} + +// NewGetReactionsParameters initializes the inputs to get reactions to an item. +func NewGetReactionsParameters() GetReactionsParameters { + return GetReactionsParameters{ + Full: false, + } +} + +type getReactionsResponseFull struct { + Type string + M struct { + Reactions []ItemReaction + } `json:"message"` + F struct { + Reactions []ItemReaction + } `json:"file"` + FC struct { + Reactions []ItemReaction + } `json:"comment"` + SlackResponse +} + +func (res getReactionsResponseFull) extractReactions() []ItemReaction { + switch res.Type { + case "message": + return res.M.Reactions + case "file": + return res.F.Reactions + case "file_comment": + return res.FC.Reactions + } + return []ItemReaction{} +} + +const ( + DEFAULT_REACTIONS_USER = "" + DEFAULT_REACTIONS_COUNT = 100 + DEFAULT_REACTIONS_PAGE = 1 + DEFAULT_REACTIONS_FULL = false +) + +// ListReactionsParameters is the inputs to find all reactions by a user. +type ListReactionsParameters struct { + User string + Count int + Page int + Full bool +} + +// NewListReactionsParameters initializes the inputs to find all reactions +// performed by a user. +func NewListReactionsParameters() ListReactionsParameters { + return ListReactionsParameters{ + User: DEFAULT_REACTIONS_USER, + Count: DEFAULT_REACTIONS_COUNT, + Page: DEFAULT_REACTIONS_PAGE, + Full: DEFAULT_REACTIONS_FULL, + } +} + +type listReactionsResponseFull struct { + Items []struct { + Type string + Channel string + M struct { + *Message + } `json:"message"` + F struct { + *File + Reactions []ItemReaction + } `json:"file"` + FC struct { + *Comment + Reactions []ItemReaction + } `json:"comment"` + } + Paging `json:"paging"` + SlackResponse +} + +func (res listReactionsResponseFull) extractReactedItems() []ReactedItem { + items := make([]ReactedItem, len(res.Items)) + for i, input := range res.Items { + item := ReactedItem{} + item.Type = input.Type + switch input.Type { + case "message": + item.Channel = input.Channel + item.Message = input.M.Message + item.Reactions = input.M.Reactions + case "file": + item.File = input.F.File + item.Reactions = input.F.Reactions + case "file_comment": + item.File = input.F.File + item.Comment = input.FC.Comment + item.Reactions = input.FC.Reactions + } + items[i] = item + } + return items +} + +// AddReaction adds a reaction emoji to a message, file or file comment. +func (api *Client) AddReaction(name string, item ItemRef) error { + values := url.Values{ + "token": {api.config.token}, + } + if name != "" { + values.Set("name", name) + } + if item.Channel != "" { + values.Set("channel", string(item.Channel)) + } + if item.Timestamp != "" { + values.Set("timestamp", string(item.Timestamp)) + } + if item.File != "" { + values.Set("file", string(item.File)) + } + if item.Comment != "" { + values.Set("file_comment", string(item.Comment)) + } + response := &SlackResponse{} + if err := post("reactions.add", values, response, api.debug); err != nil { + return err + } + if !response.Ok { + return errors.New(response.Error) + } + return nil +} + +// RemoveReaction removes a reaction emoji from a message, file or file comment. +func (api *Client) RemoveReaction(name string, item ItemRef) error { + values := url.Values{ + "token": {api.config.token}, + } + if name != "" { + values.Set("name", name) + } + if item.Channel != "" { + values.Set("channel", string(item.Channel)) + } + if item.Timestamp != "" { + values.Set("timestamp", string(item.Timestamp)) + } + if item.File != "" { + values.Set("file", string(item.File)) + } + if item.Comment != "" { + values.Set("file_comment", string(item.Comment)) + } + response := &SlackResponse{} + if err := post("reactions.remove", values, response, api.debug); err != nil { + return err + } + if !response.Ok { + return errors.New(response.Error) + } + return nil +} + +// GetReactions returns details about the reactions on an item. +func (api *Client) GetReactions(item ItemRef, params GetReactionsParameters) ([]ItemReaction, error) { + values := url.Values{ + "token": {api.config.token}, + } + if item.Channel != "" { + values.Set("channel", string(item.Channel)) + } + if item.Timestamp != "" { + values.Set("timestamp", string(item.Timestamp)) + } + if item.File != "" { + values.Set("file", string(item.File)) + } + if item.Comment != "" { + values.Set("file_comment", string(item.Comment)) + } + if params.Full != DEFAULT_REACTIONS_FULL { + values.Set("full", strconv.FormatBool(params.Full)) + } + response := &getReactionsResponseFull{} + if err := post("reactions.get", values, response, api.debug); err != nil { + return nil, err + } + if !response.Ok { + return nil, errors.New(response.Error) + } + return response.extractReactions(), nil +} + +// ListReactions returns information about the items a user reacted to. +func (api *Client) ListReactions(params ListReactionsParameters) ([]ReactedItem, *Paging, error) { + values := url.Values{ + "token": {api.config.token}, + } + if params.User != DEFAULT_REACTIONS_USER { + values.Add("user", params.User) + } + if params.Count != DEFAULT_REACTIONS_COUNT { + values.Add("count", strconv.Itoa(params.Count)) + } + if params.Page != DEFAULT_REACTIONS_PAGE { + values.Add("page", strconv.Itoa(params.Page)) + } + if params.Full != DEFAULT_REACTIONS_FULL { + values.Add("full", strconv.FormatBool(params.Full)) + } + response := &listReactionsResponseFull{} + err := post("reactions.list", values, response, api.debug) + if err != nil { + return nil, nil, err + } + if !response.Ok { + return nil, nil, errors.New(response.Error) + } + return response.extractReactedItems(), &response.Paging, nil +} diff --git a/vendor/github.com/nlopes/slack/rtm.go b/vendor/github.com/nlopes/slack/rtm.go new file mode 100644 index 00000000..f3552c53 --- /dev/null +++ b/vendor/github.com/nlopes/slack/rtm.go @@ -0,0 +1,39 @@ +package slack + +import ( + "fmt" + "net/url" +) + +// StartRTM calls the "rtm.start" endpoint and returns the provided URL and the full Info +// block. +// +// To have a fully managed Websocket connection, use `NewRTM`, and call `ManageConnection()` +// on it. +func (api *Client) StartRTM() (info *Info, websocketURL string, err error) { + response := &infoResponseFull{} + err = post("rtm.start", url.Values{"token": {api.config.token}}, response, api.debug) + if err != nil { + return nil, "", fmt.Errorf("post: %s", err) + } + if !response.Ok { + return nil, "", response.Error + } + + // websocket.Dial does not accept url without the port (yet) + // Fixed by: https://github.com/golang/net/commit/5058c78c3627b31e484a81463acd51c7cecc06f3 + // but slack returns the address with no port, so we have to fix it + api.Debugln("Using URL:", response.Info.URL) + websocketURL, err = websocketizeURLPort(response.Info.URL) + if err != nil { + return nil, "", fmt.Errorf("parsing response URL: %s", err) + } + + return &response.Info, websocketURL, nil +} + +// NewRTM returns a RTM, which provides a fully managed connection to +// Slack's websocket-based Real-Time Messaging protocol./ +func (api *Client) NewRTM() *RTM { + return newRTM(api) +} diff --git a/vendor/github.com/nlopes/slack/search.go b/vendor/github.com/nlopes/slack/search.go new file mode 100644 index 00000000..ab3c5dad --- /dev/null +++ b/vendor/github.com/nlopes/slack/search.go @@ -0,0 +1,137 @@ +package slack + +import ( + "errors" + "net/url" + "strconv" +) + +const ( + DEFAULT_SEARCH_SORT = "score" + DEFAULT_SEARCH_SORT_DIR = "desc" + DEFAULT_SEARCH_HIGHLIGHT = false + DEFAULT_SEARCH_COUNT = 100 + DEFAULT_SEARCH_PAGE = 1 +) + +type SearchParameters struct { + Sort string + SortDirection string + Highlight bool + Count int + Page int +} + +type CtxChannel struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type CtxMessage struct { + User string `json:"user"` + Username string `json:"username"` + Text string `json:"text"` + Timestamp string `json:"ts"` + Type string `json:"type"` +} + +type SearchMessage struct { + Type string `json:"type"` + Channel CtxChannel `json:"channel"` + User string `json:"user"` + Username string `json:"username"` + Timestamp string `json:"ts"` + Text string `json:"text"` + Permalink string `json:"permalink"` + Previous CtxMessage `json:"previous"` + Previous2 CtxMessage `json:"previous_2"` + Next CtxMessage `json:"next"` + Next2 CtxMessage `json:"next_2"` +} + +type SearchMessages struct { + Matches []SearchMessage `json:"matches"` + Paging `json:"paging"` + Pagination `json:"pagination"` + Total int `json:"total"` +} + +type SearchFiles struct { + Matches []File `json:"matches"` + Paging `json:"paging"` + Pagination `json:"pagination"` + Total int `json:"total"` +} + +type searchResponseFull struct { + Query string `json:"query"` + SearchMessages `json:"messages"` + SearchFiles `json:"files"` + SlackResponse +} + +func NewSearchParameters() SearchParameters { + return SearchParameters{ + Sort: DEFAULT_SEARCH_SORT, + SortDirection: DEFAULT_SEARCH_SORT_DIR, + Highlight: DEFAULT_SEARCH_HIGHLIGHT, + Count: DEFAULT_SEARCH_COUNT, + Page: DEFAULT_SEARCH_PAGE, + } +} + +func (api *Client) _search(path, query string, params SearchParameters, files, messages bool) (response *searchResponseFull, error error) { + values := url.Values{ + "token": {api.config.token}, + "query": {query}, + } + if params.Sort != DEFAULT_SEARCH_SORT { + values.Add("sort", params.Sort) + } + if params.SortDirection != DEFAULT_SEARCH_SORT_DIR { + values.Add("sort_dir", params.SortDirection) + } + if params.Highlight != DEFAULT_SEARCH_HIGHLIGHT { + values.Add("highlight", strconv.Itoa(1)) + } + if params.Count != DEFAULT_SEARCH_COUNT { + values.Add("count", strconv.Itoa(params.Count)) + } + if params.Page != DEFAULT_SEARCH_PAGE { + values.Add("page", strconv.Itoa(params.Page)) + } + response = &searchResponseFull{} + err := post(path, values, response, api.debug) + if err != nil { + return nil, err + } + if !response.Ok { + return nil, errors.New(response.Error) + } + return response, nil + +} + +func (api *Client) Search(query string, params SearchParameters) (*SearchMessages, *SearchFiles, error) { + response, err := api._search("search.all", query, params, true, true) + if err != nil { + return nil, nil, err + } + return &response.SearchMessages, &response.SearchFiles, nil +} + +func (api *Client) SearchFiles(query string, params SearchParameters) (*SearchFiles, error) { + response, err := api._search("search.files", query, params, true, false) + if err != nil { + return nil, err + } + return &response.SearchFiles, nil +} + +func (api *Client) SearchMessages(query string, params SearchParameters) (*SearchMessages, error) { + response, err := api._search("search.messages", query, params, false, true) + if err != nil { + return nil, err + } + return &response.SearchMessages, nil +} diff --git a/vendor/github.com/nlopes/slack/slack.go b/vendor/github.com/nlopes/slack/slack.go new file mode 100644 index 00000000..eb686354 --- /dev/null +++ b/vendor/github.com/nlopes/slack/slack.go @@ -0,0 +1,88 @@ +package slack + +import ( + "errors" + "log" + "net/url" + "os" +) + +var logger *log.Logger // A logger that can be set by consumers +/* + Added as a var so that we can change this for testing purposes +*/ +var SLACK_API string = "https://slack.com/api/" +var SLACK_WEB_API_FORMAT string = "https://%s.slack.com/api/users.admin.%s?t=%s" + +type SlackResponse struct { + Ok bool `json:"ok"` + Error string `json:"error"` +} + +type AuthTestResponse struct { + URL string `json:"url"` + Team string `json:"team"` + User string `json:"user"` + TeamID string `json:"team_id"` + UserID string `json:"user_id"` +} + +type authTestResponseFull struct { + SlackResponse + AuthTestResponse +} + +type Client struct { + config struct { + token string + } + info Info + debug bool +} + +// SetLogger let's library users supply a logger, so that api debugging +// can be logged along with the application's debugging info. +func SetLogger(l *log.Logger) { + logger = l +} + +func New(token string) *Client { + s := &Client{} + s.config.token = token + return s +} + +// AuthTest tests if the user is able to do authenticated requests or not +func (api *Client) AuthTest() (response *AuthTestResponse, error error) { + responseFull := &authTestResponseFull{} + err := post("auth.test", url.Values{"token": {api.config.token}}, responseFull, api.debug) + if err != nil { + return nil, err + } + if !responseFull.Ok { + return nil, errors.New(responseFull.Error) + } + return &responseFull.AuthTestResponse, nil +} + +// SetDebug switches the api into debug mode +// When in debug mode, it logs various info about what its doing +// If you ever use this in production, don't call SetDebug(true) +func (api *Client) SetDebug(debug bool) { + api.debug = debug + if debug && logger == nil { + logger = log.New(os.Stdout, "nlopes/slack", log.LstdFlags | log.Lshortfile) + } +} + +func (api *Client) Debugf(format string, v ...interface{}) { + if api.debug { + logger.Printf(format, v...) + } +} + +func (api *Client) Debugln(v ...interface{}) { + if api.debug { + logger.Println(v...) + } +} diff --git a/vendor/github.com/nlopes/slack/stars.go b/vendor/github.com/nlopes/slack/stars.go new file mode 100644 index 00000000..cc12e6ec --- /dev/null +++ b/vendor/github.com/nlopes/slack/stars.go @@ -0,0 +1,135 @@ +package slack + +import ( + "errors" + "net/url" + "strconv" +) + +const ( + DEFAULT_STARS_USER = "" + DEFAULT_STARS_COUNT = 100 + DEFAULT_STARS_PAGE = 1 +) + +type StarsParameters struct { + User string + Count int + Page int +} + +type StarredItem Item + +type listResponseFull struct { + Items []Item `json:"items"` + Paging `json:"paging"` + SlackResponse +} + +// NewStarsParameters initialises StarsParameters with default values +func NewStarsParameters() StarsParameters { + return StarsParameters{ + User: DEFAULT_STARS_USER, + Count: DEFAULT_STARS_COUNT, + Page: DEFAULT_STARS_PAGE, + } +} + +// AddStar stars an item in a channel +func (api *Client) AddStar(channel string, item ItemRef) error { + values := url.Values{ + "channel": {channel}, + "token": {api.config.token}, + } + if item.Timestamp != "" { + values.Set("timestamp", string(item.Timestamp)) + } + if item.File != "" { + values.Set("file", string(item.File)) + } + if item.Comment != "" { + values.Set("file_comment", string(item.Comment)) + } + response := &SlackResponse{} + if err := post("stars.add", values, response, api.debug); err != nil { + return err + } + if !response.Ok { + return errors.New(response.Error) + } + return nil +} + +// RemoveStar removes a starred item from a channel +func (api *Client) RemoveStar(channel string, item ItemRef) error { + values := url.Values{ + "channel": {channel}, + "token": {api.config.token}, + } + if item.Timestamp != "" { + values.Set("timestamp", string(item.Timestamp)) + } + if item.File != "" { + values.Set("file", string(item.File)) + } + if item.Comment != "" { + values.Set("file_comment", string(item.Comment)) + } + response := &SlackResponse{} + if err := post("stars.remove", values, response, api.debug); err != nil { + return err + } + if !response.Ok { + return errors.New(response.Error) + } + return nil +} + +// ListStars returns information about the stars a user added +func (api *Client) ListStars(params StarsParameters) ([]Item, *Paging, error) { + values := url.Values{ + "token": {api.config.token}, + } + if params.User != DEFAULT_STARS_USER { + values.Add("user", params.User) + } + if params.Count != DEFAULT_STARS_COUNT { + values.Add("count", strconv.Itoa(params.Count)) + } + if params.Page != DEFAULT_STARS_PAGE { + values.Add("page", strconv.Itoa(params.Page)) + } + response := &listResponseFull{} + err := post("stars.list", values, response, api.debug) + if err != nil { + return nil, nil, err + } + if !response.Ok { + return nil, nil, errors.New(response.Error) + } + return response.Items, &response.Paging, nil +} + +// GetStarred returns a list of StarredItem items. The user then has to iterate over them and figure out what they should +// be looking at according to what is in the Type. +// for _, item := range items { +// switch c.Type { +// case "file_comment": +// log.Println(c.Comment) +// case "file": +// ... +// +// } +// This function still exists to maintain backwards compatibility. +// I exposed it as returning []StarredItem, so it shall stay as StarredItem +func (api *Client) GetStarred(params StarsParameters) ([]StarredItem, *Paging, error) { + items, paging, err := api.ListStars(params) + if err != nil { + return nil, nil, err + } + starredItems := make([]StarredItem, len(items)) + for i, item := range items { + starredItems[i] = StarredItem(item) + } + return starredItems, paging, nil +} diff --git a/vendor/github.com/nlopes/slack/team.go b/vendor/github.com/nlopes/slack/team.go new file mode 100644 index 00000000..41507f68 --- /dev/null +++ b/vendor/github.com/nlopes/slack/team.go @@ -0,0 +1,46 @@ +package slack + +import ( + "errors" + "net/url" +) + +type TeamResponse struct { + Team TeamInfo `json:"team"` + SlackResponse +} + +type TeamInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Domain string `json:"domain"` + EmailDomain string `json:"email_domain"` + Icon map[string]interface{} `json:"icon"` +} + +func teamRequest(path string, values url.Values, debug bool) (*TeamResponse, error) { + response := &TeamResponse{} + err := post(path, values, response, debug) + if err != nil { + return nil, err + } + + if !response.Ok { + return nil, errors.New(response.Error) + } + + return response, nil +} + +// GetTeamInfo gets the Team Information of the user +func (api *Client) GetTeamInfo() (*TeamInfo, error) { + values := url.Values{ + "token": {api.config.token}, + } + + response, err := teamRequest("team.info", values, api.debug) + if err != nil { + return nil, err + } + return &response.Team, nil +} diff --git a/vendor/github.com/nlopes/slack/users.go b/vendor/github.com/nlopes/slack/users.go new file mode 100644 index 00000000..a0ed81e6 --- /dev/null +++ b/vendor/github.com/nlopes/slack/users.go @@ -0,0 +1,140 @@ +package slack + +import ( + "errors" + "net/url" +) + +// UserProfile contains all the information details of a given user +type UserProfile struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + RealName string `json:"real_name"` + RealNameNormalized string `json:"real_name_normalized"` + Email string `json:"email"` + Skype string `json:"skype"` + Phone string `json:"phone"` + Image24 string `json:"image_24"` + Image32 string `json:"image_32"` + Image48 string `json:"image_48"` + Image72 string `json:"image_72"` + Image192 string `json:"image_192"` + ImageOriginal string `json:"image_original"` + Title string `json:"title"` +} + +// User contains all the information of a user +type User struct { + ID string `json:"id"` + Name string `json:"name"` + Deleted bool `json:"deleted"` + Color string `json:"color"` + RealName string `json:"real_name"` + TZ string `json:"tz,omitempty"` + TZLabel string `json:"tz_label"` + TZOffset int `json:"tz_offset"` + Profile UserProfile `json:"profile"` + IsBot bool `json:"is_bot"` + IsAdmin bool `json:"is_admin"` + IsOwner bool `json:"is_owner"` + IsPrimaryOwner bool `json:"is_primary_owner"` + IsRestricted bool `json:"is_restricted"` + IsUltraRestricted bool `json:"is_ultra_restricted"` + Has2FA bool `json:"has_2fa"` + HasFiles bool `json:"has_files"` + Presence string `json:"presence"` +} + +// UserPresence contains details about a user online status +type UserPresence struct { + Presence string `json:"presence,omitempty"` + Online bool `json:"online,omitempty"` + AutoAway bool `json:"auto_away,omitempty"` + ManualAway bool `json:"manual_away,omitempty"` + ConnectionCount int `json:"connection_count,omitempty"` + LastActivity JSONTime `json:"last_activity,omitempty"` +} + +type userResponseFull struct { + Members []User `json:"members,omitempty"` // ListUsers + User `json:"user,omitempty"` // GetUserInfo + UserPresence // GetUserPresence + SlackResponse +} + +func userRequest(path string, values url.Values, debug bool) (*userResponseFull, error) { + response := &userResponseFull{} + err := post(path, values, response, debug) + if err != nil { + return nil, err + } + if !response.Ok { + return nil, errors.New(response.Error) + } + return response, nil +} + +// GetUserPresence will retrieve the current presence status of given user. +func (api *Client) GetUserPresence(user string) (*UserPresence, error) { + values := url.Values{ + "token": {api.config.token}, + "user": {user}, + } + response, err := userRequest("users.getPresence", values, api.debug) + if err != nil { + return nil, err + } + return &response.UserPresence, nil +} + +// GetUserInfo will retrive the complete user information +func (api *Client) GetUserInfo(user string) (*User, error) { + values := url.Values{ + "token": {api.config.token}, + "user": {user}, + } + response, err := userRequest("users.info", values, api.debug) + if err != nil { + return nil, err + } + return &response.User, nil +} + +// GetUsers returns the list of users (with their detailed information) +func (api *Client) GetUsers() ([]User, error) { + values := url.Values{ + "token": {api.config.token}, + "presence": {"1"}, + } + response, err := userRequest("users.list", values, api.debug) + if err != nil { + return nil, err + } + return response.Members, nil +} + +// SetUserAsActive marks the currently authenticated user as active +func (api *Client) SetUserAsActive() error { + values := url.Values{ + "token": {api.config.token}, + } + _, err := userRequest("users.setActive", values, api.debug) + if err != nil { + return err + } + return nil +} + +// SetUserPresence changes the currently authenticated user presence +func (api *Client) SetUserPresence(presence string) error { + values := url.Values{ + "token": {api.config.token}, + "presence": {presence}, + } + _, err := userRequest("users.setPresence", values, api.debug) + if err != nil { + return err + } + return nil + +} diff --git a/vendor/github.com/nlopes/slack/websocket.go b/vendor/github.com/nlopes/slack/websocket.go new file mode 100644 index 00000000..6eb09263 --- /dev/null +++ b/vendor/github.com/nlopes/slack/websocket.go @@ -0,0 +1,93 @@ +package slack + +import ( + "encoding/json" + "errors" + "time" + + "golang.org/x/net/websocket" +) + +const ( + // MaxMessageTextLength is the current maximum message length in number of characters as defined here + // https://api.slack.com/rtm#limits + MaxMessageTextLength = 4000 +) + +// RTM represents a managed websocket connection. It also supports +// all the methods of the `Client` type. +// +// Create this element with Client's NewRTM(). +type RTM struct { + idGen IDGenerator + pings map[int]time.Time + + // Connection life-cycle + conn *websocket.Conn + IncomingEvents chan RTMEvent + outgoingMessages chan OutgoingMessage + killChannel chan bool + forcePing chan bool + rawEvents chan json.RawMessage + wasIntentional bool + isConnected bool + + // Client is the main API, embedded + Client + websocketURL string + + // UserDetails upon connection + info *Info +} + +// NewRTM returns a RTM, which provides a fully managed connection to +// Slack's websocket-based Real-Time Messaging protocol. +func newRTM(api *Client) *RTM { + return &RTM{ + Client: *api, + IncomingEvents: make(chan RTMEvent, 50), + outgoingMessages: make(chan OutgoingMessage, 20), + pings: make(map[int]time.Time), + isConnected: false, + wasIntentional: true, + killChannel: make(chan bool), + forcePing: make(chan bool), + rawEvents: make(chan json.RawMessage), + idGen: NewSafeID(1), + } +} + +// Disconnect and wait, blocking until a successful disconnection. +func (rtm *RTM) Disconnect() error { + if !rtm.isConnected { + return errors.New("Invalid call to Disconnect - Slack API is already disconnected") + } + rtm.killChannel <- true + return nil +} + +// Reconnect only makes sense if you've successfully disconnectd with Disconnect(). +func (rtm *RTM) Reconnect() error { + logger.Println("RTM::Reconnect not implemented!") + return nil +} + +// GetInfo returns the info structure received when calling +// "startrtm", holding all channels, groups and other metadata needed +// to implement a full chat client. It will be non-nil after a call to +// StartRTM(). +func (rtm *RTM) GetInfo() *Info { + return rtm.info +} + +// SendMessage submits a simple message through the websocket. For +// more complicated messages, use `rtm.PostMessage` with a complete +// struct describing your attachments and all. +func (rtm *RTM) SendMessage(msg *OutgoingMessage) { + if msg == nil { + rtm.Debugln("Error: Attempted to SendMessage(nil)") + return + } + + rtm.outgoingMessages <- *msg +} diff --git a/vendor/github.com/nlopes/slack/websocket_channels.go b/vendor/github.com/nlopes/slack/websocket_channels.go new file mode 100644 index 00000000..7dd3319b --- /dev/null +++ b/vendor/github.com/nlopes/slack/websocket_channels.go @@ -0,0 +1,72 @@ +package slack + +// ChannelCreatedEvent represents the Channel created event +type ChannelCreatedEvent struct { + Type string `json:"type"` + Channel ChannelCreatedInfo `json:"channel"` + EventTimestamp string `json:"event_ts"` +} + +// ChannelCreatedInfo represents the information associated with the Channel created event +type ChannelCreatedInfo struct { + ID string `json:"id"` + IsChannel bool `json:"is_channel"` + Name string `json:"name"` + Created int `json:"created"` + Creator string `json:"creator"` +} + +// ChannelJoinedEvent represents the Channel joined event +type ChannelJoinedEvent struct { + Type string `json:"type"` + Channel Channel `json:"channel"` +} + +// ChannelInfoEvent represents the Channel info event +type ChannelInfoEvent struct { + // channel_left + // channel_deleted + // channel_archive + // channel_unarchive + Type string `json:"type"` + Channel string `json:"channel"` + User string `json:"user,omitempty"` + Timestamp string `json:"ts,omitempty"` +} + +// ChannelRenameEvent represents the Channel rename event +type ChannelRenameEvent struct { + Type string `json:"type"` + Channel ChannelRenameInfo `json:"channel"` + Timestamp string `json:"event_ts"` +} + +// ChannelRenameInfo represents the information associated with a Channel rename event +type ChannelRenameInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Created string `json:"created"` +} + +// ChannelHistoryChangedEvent represents the Channel history changed event +type ChannelHistoryChangedEvent struct { + Type string `json:"type"` + Latest string `json:"latest"` + Timestamp string `json:"ts"` + EventTimestamp string `json:"event_ts"` +} + +// ChannelMarkedEvent represents the Channel marked event +type ChannelMarkedEvent ChannelInfoEvent + +// ChannelLeftEvent represents the Channel left event +type ChannelLeftEvent ChannelInfoEvent + +// ChannelDeletedEvent represents the Channel deleted event +type ChannelDeletedEvent ChannelInfoEvent + +// ChannelArchiveEvent represents the Channel archive event +type ChannelArchiveEvent ChannelInfoEvent + +// ChannelUnarchiveEvent represents the Channel unarchive event +type ChannelUnarchiveEvent ChannelInfoEvent diff --git a/vendor/github.com/nlopes/slack/websocket_dm.go b/vendor/github.com/nlopes/slack/websocket_dm.go new file mode 100644 index 00000000..98bf6f88 --- /dev/null +++ b/vendor/github.com/nlopes/slack/websocket_dm.go @@ -0,0 +1,23 @@ +package slack + +// IMCreatedEvent represents the IM created event +type IMCreatedEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel ChannelCreatedInfo `json:"channel"` +} + +// IMHistoryChangedEvent represents the IM history changed event +type IMHistoryChangedEvent ChannelHistoryChangedEvent + +// IMOpenEvent represents the IM open event +type IMOpenEvent ChannelInfoEvent + +// IMCloseEvent represents the IM close event +type IMCloseEvent ChannelInfoEvent + +// IMMarkedEvent represents the IM marked event +type IMMarkedEvent ChannelInfoEvent + +// IMMarkedHistoryChanged represents the IM marked history changed event +type IMMarkedHistoryChanged ChannelInfoEvent diff --git a/vendor/github.com/nlopes/slack/websocket_dnd.go b/vendor/github.com/nlopes/slack/websocket_dnd.go new file mode 100644 index 00000000..62ddea3a --- /dev/null +++ b/vendor/github.com/nlopes/slack/websocket_dnd.go @@ -0,0 +1,8 @@ +package slack + +// DNDUpdatedEvent represents the update event for Do Not Disturb +type DNDUpdatedEvent struct { + Type string `json:"type"` + User string `json:"user"` + Status DNDStatus `json:"dnd_status"` +} diff --git a/vendor/github.com/nlopes/slack/websocket_files.go b/vendor/github.com/nlopes/slack/websocket_files.go new file mode 100644 index 00000000..8c5bd4f8 --- /dev/null +++ b/vendor/github.com/nlopes/slack/websocket_files.go @@ -0,0 +1,49 @@ +package slack + +// FileActionEvent represents the File action event +type fileActionEvent struct { + Type string `json:"type"` + EventTimestamp string `json:"event_ts"` + File File `json:"file"` + // FileID is used for FileDeletedEvent + FileID string `json:"file_id,omitempty"` +} + +// FileCreatedEvent represents the File created event +type FileCreatedEvent fileActionEvent + +// FileSharedEvent represents the File shared event +type FileSharedEvent fileActionEvent + +// FilePublicEvent represents the File public event +type FilePublicEvent fileActionEvent + +// FileUnsharedEvent represents the File unshared event +type FileUnsharedEvent fileActionEvent + +// FileChangeEvent represents the File change event +type FileChangeEvent fileActionEvent + +// FileDeletedEvent represents the File deleted event +type FileDeletedEvent fileActionEvent + +// FilePrivateEvent represents the File private event +type FilePrivateEvent fileActionEvent + +// FileCommentAddedEvent represents the File comment added event +type FileCommentAddedEvent struct { + fileActionEvent + Comment Comment `json:"comment"` +} + +// FileCommentEditedEvent represents the File comment edited event +type FileCommentEditedEvent struct { + fileActionEvent + Comment Comment `json:"comment"` +} + +// FileCommentDeletedEvent represents the File comment deleted event +type FileCommentDeletedEvent struct { + fileActionEvent + Comment string `json:"comment"` +} diff --git a/vendor/github.com/nlopes/slack/websocket_groups.go b/vendor/github.com/nlopes/slack/websocket_groups.go new file mode 100644 index 00000000..eb88985c --- /dev/null +++ b/vendor/github.com/nlopes/slack/websocket_groups.go @@ -0,0 +1,49 @@ +package slack + +// GroupCreatedEvent represents the Group created event +type GroupCreatedEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel ChannelCreatedInfo `json:"channel"` +} + +// XXX: Should we really do this? event.Group is probably nicer than event.Channel +// even though the api returns "channel" + +// GroupMarkedEvent represents the Group marked event +type GroupMarkedEvent ChannelInfoEvent + +// GroupOpenEvent represents the Group open event +type GroupOpenEvent ChannelInfoEvent + +// GroupCloseEvent represents the Group close event +type GroupCloseEvent ChannelInfoEvent + +// GroupArchiveEvent represents the Group archive event +type GroupArchiveEvent ChannelInfoEvent + +// GroupUnarchiveEvent represents the Group unarchive event +type GroupUnarchiveEvent ChannelInfoEvent + +// GroupLeftEvent represents the Group left event +type GroupLeftEvent ChannelInfoEvent + +// GroupJoinedEvent represents the Group joined event +type GroupJoinedEvent ChannelJoinedEvent + +// GroupRenameEvent represents the Group rename event +type GroupRenameEvent struct { + Type string `json:"type"` + Group GroupRenameInfo `json:"channel"` + Timestamp string `json:"ts"` +} + +// GroupRenameInfo represents the group info related to the renamed group +type GroupRenameInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Created string `json:"created"` +} + +// GroupHistoryChangedEvent represents the Group history changed event +type GroupHistoryChangedEvent ChannelHistoryChangedEvent diff --git a/vendor/github.com/nlopes/slack/websocket_internals.go b/vendor/github.com/nlopes/slack/websocket_internals.go new file mode 100644 index 00000000..2a8abe6e --- /dev/null +++ b/vendor/github.com/nlopes/slack/websocket_internals.go @@ -0,0 +1,92 @@ +package slack + +import ( + "fmt" + "time" +) + +/** + * Internal events, created by this lib and not mapped to Slack APIs. + */ + +// ConnectedEvent is used for when we connect to Slack +type ConnectedEvent struct { + ConnectionCount int // 1 = first time, 2 = second time + Info *Info +} + +// ConnectionErrorEvent contains information about a connection error +type ConnectionErrorEvent struct { + Attempt int + ErrorObj error +} + +func (c *ConnectionErrorEvent) Error() string { + return c.ErrorObj.Error() +} + +// ConnectingEvent contains information about our connection attempt +type ConnectingEvent struct { + Attempt int // 1 = first attempt, 2 = second attempt + ConnectionCount int +} + +// DisconnectedEvent contains information about how we disconnected +type DisconnectedEvent struct { + Intentional bool +} + +// LatencyReport contains information about connection latency +type LatencyReport struct { + Value time.Duration +} + +// InvalidAuthEvent is used in case we can't even authenticate with the API +type InvalidAuthEvent struct{} + +// UnmarshallingErrorEvent is used when there are issues deconstructing a response +type UnmarshallingErrorEvent struct { + ErrorObj error +} + +func (u UnmarshallingErrorEvent) Error() string { + return u.ErrorObj.Error() +} + +// MessageTooLongEvent is used when sending a message that is too long +type MessageTooLongEvent struct { + Message OutgoingMessage + MaxLength int +} + +func (m *MessageTooLongEvent) Error() string { + return fmt.Sprintf("Message too long (max %d characters)", m.MaxLength) +} + +// OutgoingErrorEvent contains information in case there were errors sending messages +type OutgoingErrorEvent struct { + Message OutgoingMessage + ErrorObj error +} + +func (o OutgoingErrorEvent) Error() string { + return o.ErrorObj.Error() +} + +// IncomingEventError contains information about an unexpected error receiving a websocket event +type IncomingEventError struct { + ErrorObj error +} + +func (i *IncomingEventError) Error() string { + return i.ErrorObj.Error() +} + +// AckErrorEvent i +type AckErrorEvent struct { + ErrorObj error +} + +func (a *AckErrorEvent) Error() string { + return a.ErrorObj.Error() +} diff --git a/vendor/github.com/nlopes/slack/websocket_managed_conn.go b/vendor/github.com/nlopes/slack/websocket_managed_conn.go new file mode 100644 index 00000000..db2df902 --- /dev/null +++ b/vendor/github.com/nlopes/slack/websocket_managed_conn.go @@ -0,0 +1,427 @@ +package slack + +import ( + "encoding/json" + "fmt" + "io" + "reflect" + "time" + + "golang.org/x/net/websocket" +) + +// ManageConnection can be called on a Slack RTM instance returned by the +// NewRTM method. It will connect to the slack RTM API and handle all incoming +// and outgoing events. If a connection fails then it will attempt to reconnect +// and will notify any listeners through an error event on the IncomingEvents +// channel. +// +// If the connection ends and the disconnect was unintentional then this will +// attempt to reconnect. +// +// This should only be called once per slack API! Otherwise expect undefined +// behavior. +// +// The defined error events are located in websocket_internals.go. +func (rtm *RTM) ManageConnection() { + var connectionCount int + for { + connectionCount++ + // start trying to connect + // the returned err is already passed onto the IncomingEvents channel + info, conn, err := rtm.connect(connectionCount) + // if err != nil then the connection is sucessful - otherwise it is + // fatal + if err != nil { + return + } + rtm.info = info + rtm.IncomingEvents <- RTMEvent{"connected", &ConnectedEvent{ + ConnectionCount: connectionCount, + Info: info, + }} + + rtm.conn = conn + rtm.isConnected = true + + keepRunning := make(chan bool) + // we're now connected (or have failed fatally) so we can set up + // listeners + go rtm.handleIncomingEvents(keepRunning) + + // this should be a blocking call until the connection has ended + rtm.handleEvents(keepRunning, 30*time.Second) + + // after being disconnected we need to check if it was intentional + // if not then we should try to reconnect + if rtm.wasIntentional { + return + } + // else continue and run the loop again to connect + } +} + +// connect attempts to connect to the slack websocket API. It handles any +// errors that occur while connecting and will return once a connection +// has been successfully opened. +func (rtm *RTM) connect(connectionCount int) (*Info, *websocket.Conn, error) { + // used to provide exponential backoff wait time with jitter before trying + // to connect to slack again + boff := &backoff{ + Min: 100 * time.Millisecond, + Max: 5 * time.Minute, + Factor: 2, + Jitter: true, + } + + for { + // send connecting event + rtm.IncomingEvents <- RTMEvent{"connecting", &ConnectingEvent{ + Attempt: boff.attempts + 1, + ConnectionCount: connectionCount, + }} + // attempt to start the connection + info, conn, err := rtm.startRTMAndDial() + if err == nil { + return info, conn, nil + } + // check for fatal errors - currently only invalid_auth + if sErr, ok := err.(*WebError); ok && (sErr.Error() == "invalid_auth" || sErr.Error() == "account_inactive") { + rtm.IncomingEvents <- RTMEvent{"invalid_auth", &InvalidAuthEvent{}} + return nil, nil, sErr + } + // any other errors are treated as recoverable and we try again after + // sending the event along the IncomingEvents channel + rtm.IncomingEvents <- RTMEvent{"connection_error", &ConnectionErrorEvent{ + Attempt: boff.attempts, + ErrorObj: err, + }} + // get time we should wait before attempting to connect again + dur := boff.Duration() + rtm.Debugf("reconnection %d failed: %s", boff.attempts+1, err) + rtm.Debugln(" -> reconnecting in", dur) + time.Sleep(dur) + } +} + +// startRTMAndDial attemps to connect to the slack websocket. It returns the +// full information returned by the "rtm.start" method on the slack API. +func (rtm *RTM) startRTMAndDial() (*Info, *websocket.Conn, error) { + info, url, err := rtm.StartRTM() + if err != nil { + return nil, nil, err + } + + conn, err := websocketProxyDial(url, "http://api.slack.com") + if err != nil { + return nil, nil, err + } + return info, conn, err +} + +// killConnection stops the websocket connection and signals to all goroutines +// that they should cease listening to the connection for events. +// +// This should not be called directly! Instead a boolean value (true for +// intentional, false otherwise) should be sent to the killChannel on the RTM. +func (rtm *RTM) killConnection(keepRunning chan bool, intentional bool) error { + rtm.Debugln("killing connection") + if rtm.isConnected { + close(keepRunning) + } + rtm.isConnected = false + rtm.wasIntentional = intentional + err := rtm.conn.Close() + rtm.IncomingEvents <- RTMEvent{"disconnected", &DisconnectedEvent{intentional}} + return err +} + +// handleEvents is a blocking function that handles all events. This sends +// pings when asked to (on rtm.forcePing) and upon every given elapsed +// interval. This also sends outgoing messages that are received from the RTM's +// outgoingMessages channel. This also handles incoming raw events from the RTM +// rawEvents channel. +func (rtm *RTM) handleEvents(keepRunning chan bool, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + // catch "stop" signal on channel close + case intentional := <-rtm.killChannel: + _ = rtm.killConnection(keepRunning, intentional) + return + // send pings on ticker interval + case <-ticker.C: + err := rtm.ping() + if err != nil { + _ = rtm.killConnection(keepRunning, false) + return + } + case <-rtm.forcePing: + err := rtm.ping() + if err != nil { + _ = rtm.killConnection(keepRunning, false) + return + } + // listen for messages that need to be sent + case msg := <-rtm.outgoingMessages: + rtm.sendOutgoingMessage(msg) + // listen for incoming messages that need to be parsed + case rawEvent := <-rtm.rawEvents: + rtm.handleRawEvent(rawEvent) + } + } +} + +// handleIncomingEvents monitors the RTM's opened websocket for any incoming +// events. It pushes the raw events onto the RTM channel rawEvents. +// +// This will stop executing once the RTM's keepRunning channel has been closed +// or has anything sent to it. +func (rtm *RTM) handleIncomingEvents(keepRunning <-chan bool) { + for { + // non-blocking listen to see if channel is closed + select { + // catch "stop" signal on channel close + case <-keepRunning: + return + default: + rtm.receiveIncomingEvent() + } + } +} + +// sendOutgoingMessage sends the given OutgoingMessage to the slack websocket. +// +// It does not currently detect if a outgoing message fails due to a disconnect +// and instead lets a future failed 'PING' detect the failed connection. +func (rtm *RTM) sendOutgoingMessage(msg OutgoingMessage) { + rtm.Debugln("Sending message:", msg) + if len(msg.Text) > MaxMessageTextLength { + rtm.IncomingEvents <- RTMEvent{"outgoing_error", &MessageTooLongEvent{ + Message: msg, + MaxLength: MaxMessageTextLength, + }} + return + } + err := websocket.JSON.Send(rtm.conn, msg) + if err != nil { + rtm.IncomingEvents <- RTMEvent{"outgoing_error", &OutgoingErrorEvent{ + Message: msg, + ErrorObj: err, + }} + // TODO force ping? + } +} + +// ping sends a 'PING' message to the RTM's websocket. If the 'PING' message +// fails to send then this returns an error signifying that the connection +// should be considered disconnected. +// +// This does not handle incoming 'PONG' responses but does store the time of +// each successful 'PING' send so latency can be detected upon a 'PONG' +// response. +func (rtm *RTM) ping() error { + id := rtm.idGen.Next() + rtm.Debugln("Sending PING ", id) + rtm.pings[id] = time.Now() + + msg := &Ping{ID: id, Type: "ping"} + err := websocket.JSON.Send(rtm.conn, msg) + if err != nil { + rtm.Debugf("RTM Error sending 'PING %d': %s", id, err.Error()) + return err + } + return nil +} + +// receiveIncomingEvent attempts to receive an event from the RTM's websocket. +// This will block until a frame is available from the websocket. +func (rtm *RTM) receiveIncomingEvent() { + event := json.RawMessage{} + err := websocket.JSON.Receive(rtm.conn, &event) + if err == io.EOF { + // EOF's don't seem to signify a failed connection so instead we ignore + // them here and detect a failed connection upon attempting to send a + // 'PING' message + + // trigger a 'PING' to detect pontential websocket disconnect + rtm.forcePing <- true + return + } else if err != nil { + rtm.IncomingEvents <- RTMEvent{"incoming_error", &IncomingEventError{ + ErrorObj: err, + }} + // force a ping here too? + return + } else if len(event) == 0 { + rtm.Debugln("Received empty event") + return + } + rtm.Debugln("Incoming Event:", string(event[:])) + rtm.rawEvents <- event +} + +// handleRawEvent takes a raw JSON message received from the slack websocket +// and handles the encoded event. +func (rtm *RTM) handleRawEvent(rawEvent json.RawMessage) { + event := &Event{} + err := json.Unmarshal(rawEvent, event) + if err != nil { + rtm.IncomingEvents <- RTMEvent{"unmarshalling_error", &UnmarshallingErrorEvent{err}} + return + } + switch event.Type { + case "": + rtm.handleAck(rawEvent) + case "hello": + rtm.IncomingEvents <- RTMEvent{"hello", &HelloEvent{}} + case "pong": + rtm.handlePong(rawEvent) + default: + rtm.handleEvent(event.Type, rawEvent) + } +} + +// handleAck handles an incoming 'ACK' message. +func (rtm *RTM) handleAck(event json.RawMessage) { + ack := &AckMessage{} + if err := json.Unmarshal(event, ack); err != nil { + rtm.Debugln("RTM Error unmarshalling 'ack' event:", err) + rtm.Debugln(" -> Erroneous 'ack' event:", string(event)) + return + } + if ack.Ok { + rtm.IncomingEvents <- RTMEvent{"ack", ack} + } else { + rtm.IncomingEvents <- RTMEvent{"ack_error", &AckErrorEvent{ack.Error}} + } +} + +// handlePong handles an incoming 'PONG' message which should be in response to +// a previously sent 'PING' message. This is then used to compute the +// connection's latency. +func (rtm *RTM) handlePong(event json.RawMessage) { + pong := &Pong{} + if err := json.Unmarshal(event, pong); err != nil { + rtm.Debugln("RTM Error unmarshalling 'pong' event:", err) + rtm.Debugln(" -> Erroneous 'ping' event:", string(event)) + return + } + if pingTime, exists := rtm.pings[pong.ReplyTo]; exists { + latency := time.Since(pingTime) + rtm.IncomingEvents <- RTMEvent{"latency_report", &LatencyReport{Value: latency}} + delete(rtm.pings, pong.ReplyTo) + } else { + rtm.Debugln("RTM Error - unmatched 'pong' event:", string(event)) + } +} + +// handleEvent is the "default" response to an event that does not have a +// special case. It matches the command's name to a mapping of defined events +// and then sends the corresponding event struct to the IncomingEvents channel. +// If the event type is not found or the event cannot be unmarshalled into the +// correct struct then this sends an UnmarshallingErrorEvent to the +// IncomingEvents channel. +func (rtm *RTM) handleEvent(typeStr string, event json.RawMessage) { + v, exists := eventMapping[typeStr] + if !exists { + rtm.Debugf("RTM Error, received unmapped event %q: %s\n", typeStr, string(event)) + err := fmt.Errorf("RTM Error: Received unmapped event %q: %s\n", typeStr, string(event)) + rtm.IncomingEvents <- RTMEvent{"unmarshalling_error", &UnmarshallingErrorEvent{err}} + return + } + t := reflect.TypeOf(v) + recvEvent := reflect.New(t).Interface() + err := json.Unmarshal(event, recvEvent) + if err != nil { + rtm.Debugf("RTM Error, could not unmarshall event %q: %s\n", typeStr, string(event)) + err := fmt.Errorf("RTM Error: Could not unmarshall event %q: %s\n", typeStr, string(event)) + rtm.IncomingEvents <- RTMEvent{"unmarshalling_error", &UnmarshallingErrorEvent{err}} + return + } + rtm.IncomingEvents <- RTMEvent{typeStr, recvEvent} +} + +// eventMapping holds a mapping of event names to their corresponding struct +// implementations. The structs should be instances of the unmarshalling +// target for the matching event type. +var eventMapping = map[string]interface{}{ + "message": MessageEvent{}, + "presence_change": PresenceChangeEvent{}, + "user_typing": UserTypingEvent{}, + + "channel_marked": ChannelMarkedEvent{}, + "channel_created": ChannelCreatedEvent{}, + "channel_joined": ChannelJoinedEvent{}, + "channel_left": ChannelLeftEvent{}, + "channel_deleted": ChannelDeletedEvent{}, + "channel_rename": ChannelRenameEvent{}, + "channel_archive": ChannelArchiveEvent{}, + "channel_unarchive": ChannelUnarchiveEvent{}, + "channel_history_changed": ChannelHistoryChangedEvent{}, + + "dnd_updated": DNDUpdatedEvent{}, + "dnd_updated_user": DNDUpdatedEvent{}, + + "im_created": IMCreatedEvent{}, + "im_open": IMOpenEvent{}, + "im_close": IMCloseEvent{}, + "im_marked": IMMarkedEvent{}, + "im_history_changed": IMHistoryChangedEvent{}, + + "group_marked": GroupMarkedEvent{}, + "group_open": GroupOpenEvent{}, + "group_joined": GroupJoinedEvent{}, + "group_left": GroupLeftEvent{}, + "group_close": GroupCloseEvent{}, + "group_rename": GroupRenameEvent{}, + "group_archive": GroupArchiveEvent{}, + "group_unarchive": GroupUnarchiveEvent{}, + "group_history_changed": GroupHistoryChangedEvent{}, + + "file_created": FileCreatedEvent{}, + "file_shared": FileSharedEvent{}, + "file_unshared": FileUnsharedEvent{}, + "file_public": FilePublicEvent{}, + "file_private": FilePrivateEvent{}, + "file_change": FileChangeEvent{}, + "file_deleted": FileDeletedEvent{}, + "file_comment_added": FileCommentAddedEvent{}, + "file_comment_edited": FileCommentEditedEvent{}, + "file_comment_deleted": FileCommentDeletedEvent{}, + + "pin_added": PinAddedEvent{}, + "pin_removed": PinRemovedEvent{}, + + "star_added": StarAddedEvent{}, + "star_removed": StarRemovedEvent{}, + + "reaction_added": ReactionAddedEvent{}, + "reaction_removed": ReactionRemovedEvent{}, + + "pref_change": PrefChangeEvent{}, + + "team_join": TeamJoinEvent{}, + "team_rename": TeamRenameEvent{}, + "team_pref_change": TeamPrefChangeEvent{}, + "team_domain_change": TeamDomainChangeEvent{}, + "team_migration_started": TeamMigrationStartedEvent{}, + + "manual_presence_change": ManualPresenceChangeEvent{}, + + "user_change": UserChangeEvent{}, + + "emoji_changed": EmojiChangedEvent{}, + + "commands_changed": CommandsChangedEvent{}, + + "email_domain_changed": EmailDomainChangedEvent{}, + + "bot_added": BotAddedEvent{}, + "bot_changed": BotChangedEvent{}, + + "accounts_changed": AccountsChangedEvent{}, + + "reconnect_url": ReconnectUrlEvent{}, +} diff --git a/vendor/github.com/nlopes/slack/websocket_misc.go b/vendor/github.com/nlopes/slack/websocket_misc.go new file mode 100644 index 00000000..6c2c6abf --- /dev/null +++ b/vendor/github.com/nlopes/slack/websocket_misc.go @@ -0,0 +1,117 @@ +package slack + +import ( + "encoding/json" + "fmt" +) + +// AckMessage is used for messages received in reply to other messages +type AckMessage struct { + ReplyTo int `json:"reply_to"` + Timestamp string `json:"ts"` + Text string `json:"text"` + RTMResponse +} + +// RTMResponse encapsulates response details as returned by the Slack API +type RTMResponse struct { + Ok bool `json:"ok"` + Error *RTMError `json:"error"` +} + +// RTMError encapsulates error information as returned by the Slack API +type RTMError struct { + Code int + Msg string +} + +func (s RTMError) Error() string { + return fmt.Sprintf("Code %d - %s", s.Code, s.Msg) +} + +// MessageEvent represents a Slack Message (used as the event type for an incoming message) +type MessageEvent Message + +// RTMEvent is the main wrapper. You will find all the other messages attached +type RTMEvent struct { + Type string + Data interface{} +} + +// HelloEvent represents the hello event +type HelloEvent struct{} + +// PresenceChangeEvent represents the presence change event +type PresenceChangeEvent struct { + Type string `json:"type"` + Presence string `json:"presence"` + User string `json:"user"` +} + +// UserTypingEvent represents the user typing event +type UserTypingEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` +} + +// PrefChangeEvent represents a user preferences change event +type PrefChangeEvent struct { + Type string `json:"type"` + Name string `json:"name"` + Value json.RawMessage `json:"value"` +} + +// ManualPresenceChangeEvent represents the manual presence change event +type ManualPresenceChangeEvent struct { + Type string `json:"type"` + Presence string `json:"presence"` +} + +// UserChangeEvent represents the user change event +type UserChangeEvent struct { + Type string `json:"type"` + User User `json:"user"` +} + +// EmojiChangedEvent represents the emoji changed event +type EmojiChangedEvent struct { + Type string `json:"type"` + EventTimestamp string `json:"event_ts"` +} + +// CommandsChangedEvent represents the commands changed event +type CommandsChangedEvent struct { + Type string `json:"type"` + EventTimestamp string `json:"event_ts"` +} + +// EmailDomainChangedEvent represents the email domain changed event +type EmailDomainChangedEvent struct { + Type string `json:"type"` + EventTimestamp string `json:"event_ts"` + EmailDomain string `json:"email_domain"` +} + +// BotAddedEvent represents the bot added event +type BotAddedEvent struct { + Type string `json:"type"` + Bot Bot `json:"bot"` +} + +// BotChangedEvent represents the bot changed event +type BotChangedEvent struct { + Type string `json:"type"` + Bot Bot `json:"bot"` +} + +// AccountsChangedEvent represents the accounts changed event +type AccountsChangedEvent struct { + Type string `json:"type"` +} + +// ReconnectUrlEvent represents the receiving reconnect url event +type ReconnectUrlEvent struct { + Type string `json:"type"` + URL string `json:"url"` +} diff --git a/vendor/github.com/nlopes/slack/websocket_pins.go b/vendor/github.com/nlopes/slack/websocket_pins.go new file mode 100644 index 00000000..95445e28 --- /dev/null +++ b/vendor/github.com/nlopes/slack/websocket_pins.go @@ -0,0 +1,16 @@ +package slack + +type pinEvent struct { + Type string `json:"type"` + User string `json:"user"` + Item Item `json:"item"` + Channel string `json:"channel_id"` + EventTimestamp string `json:"event_ts"` + HasPins bool `json:"has_pins,omitempty"` +} + +// PinAddedEvent represents the Pin added event +type PinAddedEvent pinEvent + +// PinRemovedEvent represents the Pin removed event +type PinRemovedEvent pinEvent diff --git a/vendor/github.com/nlopes/slack/websocket_proxy.go b/vendor/github.com/nlopes/slack/websocket_proxy.go new file mode 100644 index 00000000..440015d6 --- /dev/null +++ b/vendor/github.com/nlopes/slack/websocket_proxy.go @@ -0,0 +1,83 @@ +package slack + +import ( + "crypto/tls" + "errors" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" + + "golang.org/x/net/websocket" +) + +// Taken and reworked from: https://gist.github.com/madmo/8548738 +func websocketHTTPConnect(proxy, urlString string) (net.Conn, error) { + p, err := net.Dial("tcp", proxy) + if err != nil { + return nil, err + } + + turl, err := url.Parse(urlString) + if err != nil { + return nil, err + } + + req := http.Request{ + Method: "CONNECT", + URL: &url.URL{}, + Host: turl.Host, + } + + cc := httputil.NewProxyClientConn(p, nil) + cc.Do(&req) + if err != nil && err != httputil.ErrPersistEOF { + return nil, err + } + + rwc, _ := cc.Hijack() + + return rwc, nil +} + +func websocketProxyDial(urlString, origin string) (ws *websocket.Conn, err error) { + if os.Getenv("HTTP_PROXY") == "" { + return websocket.Dial(urlString, "", origin) + } + + purl, err := url.Parse(os.Getenv("HTTP_PROXY")) + if err != nil { + return nil, err + } + + config, err := websocket.NewConfig(urlString, origin) + if err != nil { + return nil, err + } + + client, err := websocketHTTPConnect(purl.Host, urlString) + if err != nil { + return nil, err + } + + switch config.Location.Scheme { + case "ws": + case "wss": + tlsClient := tls.Client(client, &tls.Config{ + ServerName: strings.Split(config.Location.Host, ":")[0], + }) + err := tlsClient.Handshake() + if err != nil { + tlsClient.Close() + return nil, err + } + client = tlsClient + + default: + return nil, errors.New("invalid websocket schema") + } + + return websocket.NewClient(config, client) +} diff --git a/vendor/github.com/nlopes/slack/websocket_reactions.go b/vendor/github.com/nlopes/slack/websocket_reactions.go new file mode 100644 index 00000000..e4973878 --- /dev/null +++ b/vendor/github.com/nlopes/slack/websocket_reactions.go @@ -0,0 +1,25 @@ +package slack + +// reactionItem is a lighter-weight item than is returned by the reactions list. +type reactionItem struct { + Type string `json:"type"` + Channel string `json:"channel,omitempty"` + File string `json:"file,omitempty"` + FileComment string `json:"file_comment,omitempty"` + Timestamp string `json:"ts,omitempty"` +} + +type reactionEvent struct { + Type string `json:"type"` + User string `json:"user"` + ItemUser string `json:"item_user"` + Item reactionItem `json:"item"` + Reaction string `json:"reaction"` + EventTimestamp string `json:"event_ts"` +} + +// ReactionAddedEvent represents the Reaction added event +type ReactionAddedEvent reactionEvent + +// ReactionRemovedEvent represents the Reaction removed event +type ReactionRemovedEvent reactionEvent diff --git a/vendor/github.com/nlopes/slack/websocket_stars.go b/vendor/github.com/nlopes/slack/websocket_stars.go new file mode 100644 index 00000000..e0f2dda3 --- /dev/null +++ b/vendor/github.com/nlopes/slack/websocket_stars.go @@ -0,0 +1,14 @@ +package slack + +type starEvent struct { + Type string `json:"type"` + User string `json:"user"` + Item StarredItem `json:"item"` + EventTimestamp string `json:"event_ts"` +} + +// StarAddedEvent represents the Star added event +type StarAddedEvent starEvent + +// StarRemovedEvent represents the Star removed event +type StarRemovedEvent starEvent diff --git a/vendor/github.com/nlopes/slack/websocket_teams.go b/vendor/github.com/nlopes/slack/websocket_teams.go new file mode 100644 index 00000000..3898c833 --- /dev/null +++ b/vendor/github.com/nlopes/slack/websocket_teams.go @@ -0,0 +1,33 @@ +package slack + +// TeamJoinEvent represents the Team join event +type TeamJoinEvent struct { + Type string `json:"type"` + User User `json:"user"` +} + +// TeamRenameEvent represents the Team rename event +type TeamRenameEvent struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + EventTimestamp string `json:"event_ts,omitempty"` +} + +// TeamPrefChangeEvent represents the Team preference change event +type TeamPrefChangeEvent struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Value []string `json:"value,omitempty"` +} + +// TeamDomainChangeEvent represents the Team domain change event +type TeamDomainChangeEvent struct { + Type string `json:"type"` + URL string `json:"url"` + Domain string `json:"domain"` +} + +// TeamMigrationStartedEvent represents the Team migration started event +type TeamMigrationStartedEvent struct { + Type string `json:"type"` +} diff --git a/vendor/github.com/nlopes/slack/websocket_utils.go b/vendor/github.com/nlopes/slack/websocket_utils.go new file mode 100644 index 00000000..b3d0ec89 --- /dev/null +++ b/vendor/github.com/nlopes/slack/websocket_utils.go @@ -0,0 +1,20 @@ +package slack + +import ( + "net" + "net/url" +) + +var portMapping = map[string]string{"ws": "80", "wss": "443"} + +func websocketizeURLPort(orig string) (string, error) { + urlObj, err := url.ParseRequestURI(orig) + if err != nil { + return "", err + } + _, _, err = net.SplitHostPort(urlObj.Host) + if err != nil { + return urlObj.Scheme + "://" + urlObj.Host + ":" + portMapping[urlObj.Scheme] + urlObj.Path, nil + } + return orig, nil +} diff --git a/vendor/golang.org/x/net/websocket/LICENSE b/vendor/golang.org/x/net/websocket/LICENSE new file mode 100644 index 00000000..6a66aea5 --- /dev/null +++ b/vendor/golang.org/x/net/websocket/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/golang.org/x/net/websocket/client.go b/vendor/golang.org/x/net/websocket/client.go new file mode 100644 index 00000000..20d1e1e3 --- /dev/null +++ b/vendor/golang.org/x/net/websocket/client.go @@ -0,0 +1,113 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "bufio" + "crypto/tls" + "io" + "net" + "net/http" + "net/url" +) + +// DialError is an error that occurs while dialling a websocket server. +type DialError struct { + *Config + Err error +} + +func (e *DialError) Error() string { + return "websocket.Dial " + e.Config.Location.String() + ": " + e.Err.Error() +} + +// NewConfig creates a new WebSocket config for client connection. +func NewConfig(server, origin string) (config *Config, err error) { + config = new(Config) + config.Version = ProtocolVersionHybi13 + config.Location, err = url.ParseRequestURI(server) + if err != nil { + return + } + config.Origin, err = url.ParseRequestURI(origin) + if err != nil { + return + } + config.Header = http.Header(make(map[string][]string)) + return +} + +// NewClient creates a new WebSocket client connection over rwc. +func NewClient(config *Config, rwc io.ReadWriteCloser) (ws *Conn, err error) { + br := bufio.NewReader(rwc) + bw := bufio.NewWriter(rwc) + err = hybiClientHandshake(config, br, bw) + if err != nil { + return + } + buf := bufio.NewReadWriter(br, bw) + ws = newHybiClientConn(config, buf, rwc) + return +} + +// Dial opens a new client connection to a WebSocket. +func Dial(url_, protocol, origin string) (ws *Conn, err error) { + config, err := NewConfig(url_, origin) + if err != nil { + return nil, err + } + if protocol != "" { + config.Protocol = []string{protocol} + } + return DialConfig(config) +} + +var portMap = map[string]string{ + "ws": "80", + "wss": "443", +} + +func parseAuthority(location *url.URL) string { + if _, ok := portMap[location.Scheme]; ok { + if _, _, err := net.SplitHostPort(location.Host); err != nil { + return net.JoinHostPort(location.Host, portMap[location.Scheme]) + } + } + return location.Host +} + +// DialConfig opens a new client connection to a WebSocket with a config. +func DialConfig(config *Config) (ws *Conn, err error) { + var client net.Conn + if config.Location == nil { + return nil, &DialError{config, ErrBadWebSocketLocation} + } + if config.Origin == nil { + return nil, &DialError{config, ErrBadWebSocketOrigin} + } + switch config.Location.Scheme { + case "ws": + client, err = net.Dial("tcp", parseAuthority(config.Location)) + + case "wss": + client, err = tls.Dial("tcp", parseAuthority(config.Location), config.TlsConfig) + + default: + err = ErrBadScheme + } + if err != nil { + goto Error + } + + ws, err = NewClient(config, client) + if err != nil { + client.Close() + goto Error + } + return + +Error: + return nil, &DialError{config, err} +} diff --git a/vendor/golang.org/x/net/websocket/hybi.go b/vendor/golang.org/x/net/websocket/hybi.go new file mode 100644 index 00000000..8cffdd16 --- /dev/null +++ b/vendor/golang.org/x/net/websocket/hybi.go @@ -0,0 +1,583 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +// This file implements a protocol of hybi draft. +// http://tools.ietf.org/html/draft-ietf-hybi-thewebsocketprotocol-17 + +import ( + "bufio" + "bytes" + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "encoding/binary" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "strings" +) + +const ( + websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + closeStatusNormal = 1000 + closeStatusGoingAway = 1001 + closeStatusProtocolError = 1002 + closeStatusUnsupportedData = 1003 + closeStatusFrameTooLarge = 1004 + closeStatusNoStatusRcvd = 1005 + closeStatusAbnormalClosure = 1006 + closeStatusBadMessageData = 1007 + closeStatusPolicyViolation = 1008 + closeStatusTooBigData = 1009 + closeStatusExtensionMismatch = 1010 + + maxControlFramePayloadLength = 125 +) + +var ( + ErrBadMaskingKey = &ProtocolError{"bad masking key"} + ErrBadPongMessage = &ProtocolError{"bad pong message"} + ErrBadClosingStatus = &ProtocolError{"bad closing status"} + ErrUnsupportedExtensions = &ProtocolError{"unsupported extensions"} + ErrNotImplemented = &ProtocolError{"not implemented"} + + handshakeHeader = map[string]bool{ + "Host": true, + "Upgrade": true, + "Connection": true, + "Sec-Websocket-Key": true, + "Sec-Websocket-Origin": true, + "Sec-Websocket-Version": true, + "Sec-Websocket-Protocol": true, + "Sec-Websocket-Accept": true, + } +) + +// A hybiFrameHeader is a frame header as defined in hybi draft. +type hybiFrameHeader struct { + Fin bool + Rsv [3]bool + OpCode byte + Length int64 + MaskingKey []byte + + data *bytes.Buffer +} + +// A hybiFrameReader is a reader for hybi frame. +type hybiFrameReader struct { + reader io.Reader + + header hybiFrameHeader + pos int64 + length int +} + +func (frame *hybiFrameReader) Read(msg []byte) (n int, err error) { + n, err = frame.reader.Read(msg) + if frame.header.MaskingKey != nil { + for i := 0; i < n; i++ { + msg[i] = msg[i] ^ frame.header.MaskingKey[frame.pos%4] + frame.pos++ + } + } + return n, err +} + +func (frame *hybiFrameReader) PayloadType() byte { return frame.header.OpCode } + +func (frame *hybiFrameReader) HeaderReader() io.Reader { + if frame.header.data == nil { + return nil + } + if frame.header.data.Len() == 0 { + return nil + } + return frame.header.data +} + +func (frame *hybiFrameReader) TrailerReader() io.Reader { return nil } + +func (frame *hybiFrameReader) Len() (n int) { return frame.length } + +// A hybiFrameReaderFactory creates new frame reader based on its frame type. +type hybiFrameReaderFactory struct { + *bufio.Reader +} + +// NewFrameReader reads a frame header from the connection, and creates new reader for the frame. +// See Section 5.2 Base Framing protocol for detail. +// http://tools.ietf.org/html/draft-ietf-hybi-thewebsocketprotocol-17#section-5.2 +func (buf hybiFrameReaderFactory) NewFrameReader() (frame frameReader, err error) { + hybiFrame := new(hybiFrameReader) + frame = hybiFrame + var header []byte + var b byte + // First byte. FIN/RSV1/RSV2/RSV3/OpCode(4bits) + b, err = buf.ReadByte() + if err != nil { + return + } + header = append(header, b) + hybiFrame.header.Fin = ((header[0] >> 7) & 1) != 0 + for i := 0; i < 3; i++ { + j := uint(6 - i) + hybiFrame.header.Rsv[i] = ((header[0] >> j) & 1) != 0 + } + hybiFrame.header.OpCode = header[0] & 0x0f + + // Second byte. Mask/Payload len(7bits) + b, err = buf.ReadByte() + if err != nil { + return + } + header = append(header, b) + mask := (b & 0x80) != 0 + b &= 0x7f + lengthFields := 0 + switch { + case b <= 125: // Payload length 7bits. + hybiFrame.header.Length = int64(b) + case b == 126: // Payload length 7+16bits + lengthFields = 2 + case b == 127: // Payload length 7+64bits + lengthFields = 8 + } + for i := 0; i < lengthFields; i++ { + b, err = buf.ReadByte() + if err != nil { + return + } + if lengthFields == 8 && i == 0 { // MSB must be zero when 7+64 bits + b &= 0x7f + } + header = append(header, b) + hybiFrame.header.Length = hybiFrame.header.Length*256 + int64(b) + } + if mask { + // Masking key. 4 bytes. + for i := 0; i < 4; i++ { + b, err = buf.ReadByte() + if err != nil { + return + } + header = append(header, b) + hybiFrame.header.MaskingKey = append(hybiFrame.header.MaskingKey, b) + } + } + hybiFrame.reader = io.LimitReader(buf.Reader, hybiFrame.header.Length) + hybiFrame.header.data = bytes.NewBuffer(header) + hybiFrame.length = len(header) + int(hybiFrame.header.Length) + return +} + +// A HybiFrameWriter is a writer for hybi frame. +type hybiFrameWriter struct { + writer *bufio.Writer + + header *hybiFrameHeader +} + +func (frame *hybiFrameWriter) Write(msg []byte) (n int, err error) { + var header []byte + var b byte + if frame.header.Fin { + b |= 0x80 + } + for i := 0; i < 3; i++ { + if frame.header.Rsv[i] { + j := uint(6 - i) + b |= 1 << j + } + } + b |= frame.header.OpCode + header = append(header, b) + if frame.header.MaskingKey != nil { + b = 0x80 + } else { + b = 0 + } + lengthFields := 0 + length := len(msg) + switch { + case length <= 125: + b |= byte(length) + case length < 65536: + b |= 126 + lengthFields = 2 + default: + b |= 127 + lengthFields = 8 + } + header = append(header, b) + for i := 0; i < lengthFields; i++ { + j := uint((lengthFields - i - 1) * 8) + b = byte((length >> j) & 0xff) + header = append(header, b) + } + if frame.header.MaskingKey != nil { + if len(frame.header.MaskingKey) != 4 { + return 0, ErrBadMaskingKey + } + header = append(header, frame.header.MaskingKey...) + frame.writer.Write(header) + data := make([]byte, length) + for i := range data { + data[i] = msg[i] ^ frame.header.MaskingKey[i%4] + } + frame.writer.Write(data) + err = frame.writer.Flush() + return length, err + } + frame.writer.Write(header) + frame.writer.Write(msg) + err = frame.writer.Flush() + return length, err +} + +func (frame *hybiFrameWriter) Close() error { return nil } + +type hybiFrameWriterFactory struct { + *bufio.Writer + needMaskingKey bool +} + +func (buf hybiFrameWriterFactory) NewFrameWriter(payloadType byte) (frame frameWriter, err error) { + frameHeader := &hybiFrameHeader{Fin: true, OpCode: payloadType} + if buf.needMaskingKey { + frameHeader.MaskingKey, err = generateMaskingKey() + if err != nil { + return nil, err + } + } + return &hybiFrameWriter{writer: buf.Writer, header: frameHeader}, nil +} + +type hybiFrameHandler struct { + conn *Conn + payloadType byte +} + +func (handler *hybiFrameHandler) HandleFrame(frame frameReader) (frameReader, error) { + if handler.conn.IsServerConn() { + // The client MUST mask all frames sent to the server. + if frame.(*hybiFrameReader).header.MaskingKey == nil { + handler.WriteClose(closeStatusProtocolError) + return nil, io.EOF + } + } else { + // The server MUST NOT mask all frames. + if frame.(*hybiFrameReader).header.MaskingKey != nil { + handler.WriteClose(closeStatusProtocolError) + return nil, io.EOF + } + } + if header := frame.HeaderReader(); header != nil { + io.Copy(ioutil.Discard, header) + } + switch frame.PayloadType() { + case ContinuationFrame: + frame.(*hybiFrameReader).header.OpCode = handler.payloadType + case TextFrame, BinaryFrame: + handler.payloadType = frame.PayloadType() + case CloseFrame: + return nil, io.EOF + case PingFrame, PongFrame: + b := make([]byte, maxControlFramePayloadLength) + n, err := io.ReadFull(frame, b) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return nil, err + } + io.Copy(ioutil.Discard, frame) + if frame.PayloadType() == PingFrame { + if _, err := handler.WritePong(b[:n]); err != nil { + return nil, err + } + } + return nil, nil + } + return frame, nil +} + +func (handler *hybiFrameHandler) WriteClose(status int) (err error) { + handler.conn.wio.Lock() + defer handler.conn.wio.Unlock() + w, err := handler.conn.frameWriterFactory.NewFrameWriter(CloseFrame) + if err != nil { + return err + } + msg := make([]byte, 2) + binary.BigEndian.PutUint16(msg, uint16(status)) + _, err = w.Write(msg) + w.Close() + return err +} + +func (handler *hybiFrameHandler) WritePong(msg []byte) (n int, err error) { + handler.conn.wio.Lock() + defer handler.conn.wio.Unlock() + w, err := handler.conn.frameWriterFactory.NewFrameWriter(PongFrame) + if err != nil { + return 0, err + } + n, err = w.Write(msg) + w.Close() + return n, err +} + +// newHybiConn creates a new WebSocket connection speaking hybi draft protocol. +func newHybiConn(config *Config, buf *bufio.ReadWriter, rwc io.ReadWriteCloser, request *http.Request) *Conn { + if buf == nil { + br := bufio.NewReader(rwc) + bw := bufio.NewWriter(rwc) + buf = bufio.NewReadWriter(br, bw) + } + ws := &Conn{config: config, request: request, buf: buf, rwc: rwc, + frameReaderFactory: hybiFrameReaderFactory{buf.Reader}, + frameWriterFactory: hybiFrameWriterFactory{ + buf.Writer, request == nil}, + PayloadType: TextFrame, + defaultCloseStatus: closeStatusNormal} + ws.frameHandler = &hybiFrameHandler{conn: ws} + return ws +} + +// generateMaskingKey generates a masking key for a frame. +func generateMaskingKey() (maskingKey []byte, err error) { + maskingKey = make([]byte, 4) + if _, err = io.ReadFull(rand.Reader, maskingKey); err != nil { + return + } + return +} + +// generateNonce generates a nonce consisting of a randomly selected 16-byte +// value that has been base64-encoded. +func generateNonce() (nonce []byte) { + key := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + panic(err) + } + nonce = make([]byte, 24) + base64.StdEncoding.Encode(nonce, key) + return +} + +// removeZone removes IPv6 zone identifer from host. +// E.g., "[fe80::1%en0]:8080" to "[fe80::1]:8080" +func removeZone(host string) string { + if !strings.HasPrefix(host, "[") { + return host + } + i := strings.LastIndex(host, "]") + if i < 0 { + return host + } + j := strings.LastIndex(host[:i], "%") + if j < 0 { + return host + } + return host[:j] + host[i:] +} + +// getNonceAccept computes the base64-encoded SHA-1 of the concatenation of +// the nonce ("Sec-WebSocket-Key" value) with the websocket GUID string. +func getNonceAccept(nonce []byte) (expected []byte, err error) { + h := sha1.New() + if _, err = h.Write(nonce); err != nil { + return + } + if _, err = h.Write([]byte(websocketGUID)); err != nil { + return + } + expected = make([]byte, 28) + base64.StdEncoding.Encode(expected, h.Sum(nil)) + return +} + +// Client handshake described in draft-ietf-hybi-thewebsocket-protocol-17 +func hybiClientHandshake(config *Config, br *bufio.Reader, bw *bufio.Writer) (err error) { + bw.WriteString("GET " + config.Location.RequestURI() + " HTTP/1.1\r\n") + + // According to RFC 6874, an HTTP client, proxy, or other + // intermediary must remove any IPv6 zone identifier attached + // to an outgoing URI. + bw.WriteString("Host: " + removeZone(config.Location.Host) + "\r\n") + bw.WriteString("Upgrade: websocket\r\n") + bw.WriteString("Connection: Upgrade\r\n") + nonce := generateNonce() + if config.handshakeData != nil { + nonce = []byte(config.handshakeData["key"]) + } + bw.WriteString("Sec-WebSocket-Key: " + string(nonce) + "\r\n") + bw.WriteString("Origin: " + strings.ToLower(config.Origin.String()) + "\r\n") + + if config.Version != ProtocolVersionHybi13 { + return ErrBadProtocolVersion + } + + bw.WriteString("Sec-WebSocket-Version: " + fmt.Sprintf("%d", config.Version) + "\r\n") + if len(config.Protocol) > 0 { + bw.WriteString("Sec-WebSocket-Protocol: " + strings.Join(config.Protocol, ", ") + "\r\n") + } + // TODO(ukai): send Sec-WebSocket-Extensions. + err = config.Header.WriteSubset(bw, handshakeHeader) + if err != nil { + return err + } + + bw.WriteString("\r\n") + if err = bw.Flush(); err != nil { + return err + } + + resp, err := http.ReadResponse(br, &http.Request{Method: "GET"}) + if err != nil { + return err + } + if resp.StatusCode != 101 { + return ErrBadStatus + } + if strings.ToLower(resp.Header.Get("Upgrade")) != "websocket" || + strings.ToLower(resp.Header.Get("Connection")) != "upgrade" { + return ErrBadUpgrade + } + expectedAccept, err := getNonceAccept(nonce) + if err != nil { + return err + } + if resp.Header.Get("Sec-WebSocket-Accept") != string(expectedAccept) { + return ErrChallengeResponse + } + if resp.Header.Get("Sec-WebSocket-Extensions") != "" { + return ErrUnsupportedExtensions + } + offeredProtocol := resp.Header.Get("Sec-WebSocket-Protocol") + if offeredProtocol != "" { + protocolMatched := false + for i := 0; i < len(config.Protocol); i++ { + if config.Protocol[i] == offeredProtocol { + protocolMatched = true + break + } + } + if !protocolMatched { + return ErrBadWebSocketProtocol + } + config.Protocol = []string{offeredProtocol} + } + + return nil +} + +// newHybiClientConn creates a client WebSocket connection after handshake. +func newHybiClientConn(config *Config, buf *bufio.ReadWriter, rwc io.ReadWriteCloser) *Conn { + return newHybiConn(config, buf, rwc, nil) +} + +// A HybiServerHandshaker performs a server handshake using hybi draft protocol. +type hybiServerHandshaker struct { + *Config + accept []byte +} + +func (c *hybiServerHandshaker) ReadHandshake(buf *bufio.Reader, req *http.Request) (code int, err error) { + c.Version = ProtocolVersionHybi13 + if req.Method != "GET" { + return http.StatusMethodNotAllowed, ErrBadRequestMethod + } + // HTTP version can be safely ignored. + + if strings.ToLower(req.Header.Get("Upgrade")) != "websocket" || + !strings.Contains(strings.ToLower(req.Header.Get("Connection")), "upgrade") { + return http.StatusBadRequest, ErrNotWebSocket + } + + key := req.Header.Get("Sec-Websocket-Key") + if key == "" { + return http.StatusBadRequest, ErrChallengeResponse + } + version := req.Header.Get("Sec-Websocket-Version") + switch version { + case "13": + c.Version = ProtocolVersionHybi13 + default: + return http.StatusBadRequest, ErrBadWebSocketVersion + } + var scheme string + if req.TLS != nil { + scheme = "wss" + } else { + scheme = "ws" + } + c.Location, err = url.ParseRequestURI(scheme + "://" + req.Host + req.URL.RequestURI()) + if err != nil { + return http.StatusBadRequest, err + } + protocol := strings.TrimSpace(req.Header.Get("Sec-Websocket-Protocol")) + if protocol != "" { + protocols := strings.Split(protocol, ",") + for i := 0; i < len(protocols); i++ { + c.Protocol = append(c.Protocol, strings.TrimSpace(protocols[i])) + } + } + c.accept, err = getNonceAccept([]byte(key)) + if err != nil { + return http.StatusInternalServerError, err + } + return http.StatusSwitchingProtocols, nil +} + +// Origin parses the Origin header in req. +// If the Origin header is not set, it returns nil and nil. +func Origin(config *Config, req *http.Request) (*url.URL, error) { + var origin string + switch config.Version { + case ProtocolVersionHybi13: + origin = req.Header.Get("Origin") + } + if origin == "" { + return nil, nil + } + return url.ParseRequestURI(origin) +} + +func (c *hybiServerHandshaker) AcceptHandshake(buf *bufio.Writer) (err error) { + if len(c.Protocol) > 0 { + if len(c.Protocol) != 1 { + // You need choose a Protocol in Handshake func in Server. + return ErrBadWebSocketProtocol + } + } + buf.WriteString("HTTP/1.1 101 Switching Protocols\r\n") + buf.WriteString("Upgrade: websocket\r\n") + buf.WriteString("Connection: Upgrade\r\n") + buf.WriteString("Sec-WebSocket-Accept: " + string(c.accept) + "\r\n") + if len(c.Protocol) > 0 { + buf.WriteString("Sec-WebSocket-Protocol: " + c.Protocol[0] + "\r\n") + } + // TODO(ukai): send Sec-WebSocket-Extensions. + if c.Header != nil { + err := c.Header.WriteSubset(buf, handshakeHeader) + if err != nil { + return err + } + } + buf.WriteString("\r\n") + return buf.Flush() +} + +func (c *hybiServerHandshaker) NewServerConn(buf *bufio.ReadWriter, rwc io.ReadWriteCloser, request *http.Request) *Conn { + return newHybiServerConn(c.Config, buf, rwc, request) +} + +// newHybiServerConn returns a new WebSocket connection speaking hybi draft protocol. +func newHybiServerConn(config *Config, buf *bufio.ReadWriter, rwc io.ReadWriteCloser, request *http.Request) *Conn { + return newHybiConn(config, buf, rwc, request) +} diff --git a/vendor/golang.org/x/net/websocket/server.go b/vendor/golang.org/x/net/websocket/server.go new file mode 100644 index 00000000..0895dea1 --- /dev/null +++ b/vendor/golang.org/x/net/websocket/server.go @@ -0,0 +1,113 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "bufio" + "fmt" + "io" + "net/http" +) + +func newServerConn(rwc io.ReadWriteCloser, buf *bufio.ReadWriter, req *http.Request, config *Config, handshake func(*Config, *http.Request) error) (conn *Conn, err error) { + var hs serverHandshaker = &hybiServerHandshaker{Config: config} + code, err := hs.ReadHandshake(buf.Reader, req) + if err == ErrBadWebSocketVersion { + fmt.Fprintf(buf, "HTTP/1.1 %03d %s\r\n", code, http.StatusText(code)) + fmt.Fprintf(buf, "Sec-WebSocket-Version: %s\r\n", SupportedProtocolVersion) + buf.WriteString("\r\n") + buf.WriteString(err.Error()) + buf.Flush() + return + } + if err != nil { + fmt.Fprintf(buf, "HTTP/1.1 %03d %s\r\n", code, http.StatusText(code)) + buf.WriteString("\r\n") + buf.WriteString(err.Error()) + buf.Flush() + return + } + if handshake != nil { + err = handshake(config, req) + if err != nil { + code = http.StatusForbidden + fmt.Fprintf(buf, "HTTP/1.1 %03d %s\r\n", code, http.StatusText(code)) + buf.WriteString("\r\n") + buf.Flush() + return + } + } + err = hs.AcceptHandshake(buf.Writer) + if err != nil { + code = http.StatusBadRequest + fmt.Fprintf(buf, "HTTP/1.1 %03d %s\r\n", code, http.StatusText(code)) + buf.WriteString("\r\n") + buf.Flush() + return + } + conn = hs.NewServerConn(buf, rwc, req) + return +} + +// Server represents a server of a WebSocket. +type Server struct { + // Config is a WebSocket configuration for new WebSocket connection. + Config + + // Handshake is an optional function in WebSocket handshake. + // For example, you can check, or don't check Origin header. + // Another example, you can select config.Protocol. + Handshake func(*Config, *http.Request) error + + // Handler handles a WebSocket connection. + Handler +} + +// ServeHTTP implements the http.Handler interface for a WebSocket +func (s Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { + s.serveWebSocket(w, req) +} + +func (s Server) serveWebSocket(w http.ResponseWriter, req *http.Request) { + rwc, buf, err := w.(http.Hijacker).Hijack() + if err != nil { + panic("Hijack failed: " + err.Error()) + } + // The server should abort the WebSocket connection if it finds + // the client did not send a handshake that matches with protocol + // specification. + defer rwc.Close() + conn, err := newServerConn(rwc, buf, req, &s.Config, s.Handshake) + if err != nil { + return + } + if conn == nil { + panic("unexpected nil conn") + } + s.Handler(conn) +} + +// Handler is a simple interface to a WebSocket browser client. +// It checks if Origin header is valid URL by default. +// You might want to verify websocket.Conn.Config().Origin in the func. +// If you use Server instead of Handler, you could call websocket.Origin and +// check the origin in your Handshake func. So, if you want to accept +// non-browser clients, which do not send an Origin header, set a +// Server.Handshake that does not check the origin. +type Handler func(*Conn) + +func checkOrigin(config *Config, req *http.Request) (err error) { + config.Origin, err = Origin(config, req) + if err == nil && config.Origin == nil { + return fmt.Errorf("null origin") + } + return err +} + +// ServeHTTP implements the http.Handler interface for a WebSocket +func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + s := Server{Handler: h, Handshake: checkOrigin} + s.serveWebSocket(w, req) +} diff --git a/vendor/golang.org/x/net/websocket/websocket.go b/vendor/golang.org/x/net/websocket/websocket.go new file mode 100644 index 00000000..9412191d --- /dev/null +++ b/vendor/golang.org/x/net/websocket/websocket.go @@ -0,0 +1,411 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package websocket implements a client and server for the WebSocket protocol +// as specified in RFC 6455. +package websocket // import "golang.org/x/net/websocket" + +import ( + "bufio" + "crypto/tls" + "encoding/json" + "errors" + "io" + "io/ioutil" + "net" + "net/http" + "net/url" + "sync" + "time" +) + +const ( + ProtocolVersionHybi13 = 13 + ProtocolVersionHybi = ProtocolVersionHybi13 + SupportedProtocolVersion = "13" + + ContinuationFrame = 0 + TextFrame = 1 + BinaryFrame = 2 + CloseFrame = 8 + PingFrame = 9 + PongFrame = 10 + UnknownFrame = 255 +) + +// ProtocolError represents WebSocket protocol errors. +type ProtocolError struct { + ErrorString string +} + +func (err *ProtocolError) Error() string { return err.ErrorString } + +var ( + ErrBadProtocolVersion = &ProtocolError{"bad protocol version"} + ErrBadScheme = &ProtocolError{"bad scheme"} + ErrBadStatus = &ProtocolError{"bad status"} + ErrBadUpgrade = &ProtocolError{"missing or bad upgrade"} + ErrBadWebSocketOrigin = &ProtocolError{"missing or bad WebSocket-Origin"} + ErrBadWebSocketLocation = &ProtocolError{"missing or bad WebSocket-Location"} + ErrBadWebSocketProtocol = &ProtocolError{"missing or bad WebSocket-Protocol"} + ErrBadWebSocketVersion = &ProtocolError{"missing or bad WebSocket Version"} + ErrChallengeResponse = &ProtocolError{"mismatch challenge/response"} + ErrBadFrame = &ProtocolError{"bad frame"} + ErrBadFrameBoundary = &ProtocolError{"not on frame boundary"} + ErrNotWebSocket = &ProtocolError{"not websocket protocol"} + ErrBadRequestMethod = &ProtocolError{"bad method"} + ErrNotSupported = &ProtocolError{"not supported"} +) + +// Addr is an implementation of net.Addr for WebSocket. +type Addr struct { + *url.URL +} + +// Network returns the network type for a WebSocket, "websocket". +func (addr *Addr) Network() string { return "websocket" } + +// Config is a WebSocket configuration +type Config struct { + // A WebSocket server address. + Location *url.URL + + // A Websocket client origin. + Origin *url.URL + + // WebSocket subprotocols. + Protocol []string + + // WebSocket protocol version. + Version int + + // TLS config for secure WebSocket (wss). + TlsConfig *tls.Config + + // Additional header fields to be sent in WebSocket opening handshake. + Header http.Header + + handshakeData map[string]string +} + +// serverHandshaker is an interface to handle WebSocket server side handshake. +type serverHandshaker interface { + // ReadHandshake reads handshake request message from client. + // Returns http response code and error if any. + ReadHandshake(buf *bufio.Reader, req *http.Request) (code int, err error) + + // AcceptHandshake accepts the client handshake request and sends + // handshake response back to client. + AcceptHandshake(buf *bufio.Writer) (err error) + + // NewServerConn creates a new WebSocket connection. + NewServerConn(buf *bufio.ReadWriter, rwc io.ReadWriteCloser, request *http.Request) (conn *Conn) +} + +// frameReader is an interface to read a WebSocket frame. +type frameReader interface { + // Reader is to read payload of the frame. + io.Reader + + // PayloadType returns payload type. + PayloadType() byte + + // HeaderReader returns a reader to read header of the frame. + HeaderReader() io.Reader + + // TrailerReader returns a reader to read trailer of the frame. + // If it returns nil, there is no trailer in the frame. + TrailerReader() io.Reader + + // Len returns total length of the frame, including header and trailer. + Len() int +} + +// frameReaderFactory is an interface to creates new frame reader. +type frameReaderFactory interface { + NewFrameReader() (r frameReader, err error) +} + +// frameWriter is an interface to write a WebSocket frame. +type frameWriter interface { + // Writer is to write payload of the frame. + io.WriteCloser +} + +// frameWriterFactory is an interface to create new frame writer. +type frameWriterFactory interface { + NewFrameWriter(payloadType byte) (w frameWriter, err error) +} + +type frameHandler interface { + HandleFrame(frame frameReader) (r frameReader, err error) + WriteClose(status int) (err error) +} + +// Conn represents a WebSocket connection. +// +// Multiple goroutines may invoke methods on a Conn simultaneously. +type Conn struct { + config *Config + request *http.Request + + buf *bufio.ReadWriter + rwc io.ReadWriteCloser + + rio sync.Mutex + frameReaderFactory + frameReader + + wio sync.Mutex + frameWriterFactory + + frameHandler + PayloadType byte + defaultCloseStatus int +} + +// Read implements the io.Reader interface: +// it reads data of a frame from the WebSocket connection. +// if msg is not large enough for the frame data, it fills the msg and next Read +// will read the rest of the frame data. +// it reads Text frame or Binary frame. +func (ws *Conn) Read(msg []byte) (n int, err error) { + ws.rio.Lock() + defer ws.rio.Unlock() +again: + if ws.frameReader == nil { + frame, err := ws.frameReaderFactory.NewFrameReader() + if err != nil { + return 0, err + } + ws.frameReader, err = ws.frameHandler.HandleFrame(frame) + if err != nil { + return 0, err + } + if ws.frameReader == nil { + goto again + } + } + n, err = ws.frameReader.Read(msg) + if err == io.EOF { + if trailer := ws.frameReader.TrailerReader(); trailer != nil { + io.Copy(ioutil.Discard, trailer) + } + ws.frameReader = nil + goto again + } + return n, err +} + +// Write implements the io.Writer interface: +// it writes data as a frame to the WebSocket connection. +func (ws *Conn) Write(msg []byte) (n int, err error) { + ws.wio.Lock() + defer ws.wio.Unlock() + w, err := ws.frameWriterFactory.NewFrameWriter(ws.PayloadType) + if err != nil { + return 0, err + } + n, err = w.Write(msg) + w.Close() + return n, err +} + +// Close implements the io.Closer interface. +func (ws *Conn) Close() error { + err := ws.frameHandler.WriteClose(ws.defaultCloseStatus) + err1 := ws.rwc.Close() + if err != nil { + return err + } + return err1 +} + +func (ws *Conn) IsClientConn() bool { return ws.request == nil } +func (ws *Conn) IsServerConn() bool { return ws.request != nil } + +// LocalAddr returns the WebSocket Origin for the connection for client, or +// the WebSocket location for server. +func (ws *Conn) LocalAddr() net.Addr { + if ws.IsClientConn() { + return &Addr{ws.config.Origin} + } + return &Addr{ws.config.Location} +} + +// RemoteAddr returns the WebSocket location for the connection for client, or +// the Websocket Origin for server. +func (ws *Conn) RemoteAddr() net.Addr { + if ws.IsClientConn() { + return &Addr{ws.config.Location} + } + return &Addr{ws.config.Origin} +} + +var errSetDeadline = errors.New("websocket: cannot set deadline: not using a net.Conn") + +// SetDeadline sets the connection's network read & write deadlines. +func (ws *Conn) SetDeadline(t time.Time) error { + if conn, ok := ws.rwc.(net.Conn); ok { + return conn.SetDeadline(t) + } + return errSetDeadline +} + +// SetReadDeadline sets the connection's network read deadline. +func (ws *Conn) SetReadDeadline(t time.Time) error { + if conn, ok := ws.rwc.(net.Conn); ok { + return conn.SetReadDeadline(t) + } + return errSetDeadline +} + +// SetWriteDeadline sets the connection's network write deadline. +func (ws *Conn) SetWriteDeadline(t time.Time) error { + if conn, ok := ws.rwc.(net.Conn); ok { + return conn.SetWriteDeadline(t) + } + return errSetDeadline +} + +// Config returns the WebSocket config. +func (ws *Conn) Config() *Config { return ws.config } + +// Request returns the http request upgraded to the WebSocket. +// It is nil for client side. +func (ws *Conn) Request() *http.Request { return ws.request } + +// Codec represents a symmetric pair of functions that implement a codec. +type Codec struct { + Marshal func(v interface{}) (data []byte, payloadType byte, err error) + Unmarshal func(data []byte, payloadType byte, v interface{}) (err error) +} + +// Send sends v marshaled by cd.Marshal as single frame to ws. +func (cd Codec) Send(ws *Conn, v interface{}) (err error) { + data, payloadType, err := cd.Marshal(v) + if err != nil { + return err + } + ws.wio.Lock() + defer ws.wio.Unlock() + w, err := ws.frameWriterFactory.NewFrameWriter(payloadType) + if err != nil { + return err + } + _, err = w.Write(data) + w.Close() + return err +} + +// Receive receives single frame from ws, unmarshaled by cd.Unmarshal and stores in v. +func (cd Codec) Receive(ws *Conn, v interface{}) (err error) { + ws.rio.Lock() + defer ws.rio.Unlock() + if ws.frameReader != nil { + _, err = io.Copy(ioutil.Discard, ws.frameReader) + if err != nil { + return err + } + ws.frameReader = nil + } +again: + frame, err := ws.frameReaderFactory.NewFrameReader() + if err != nil { + return err + } + frame, err = ws.frameHandler.HandleFrame(frame) + if err != nil { + return err + } + if frame == nil { + goto again + } + payloadType := frame.PayloadType() + data, err := ioutil.ReadAll(frame) + if err != nil { + return err + } + return cd.Unmarshal(data, payloadType, v) +} + +func marshal(v interface{}) (msg []byte, payloadType byte, err error) { + switch data := v.(type) { + case string: + return []byte(data), TextFrame, nil + case []byte: + return data, BinaryFrame, nil + } + return nil, UnknownFrame, ErrNotSupported +} + +func unmarshal(msg []byte, payloadType byte, v interface{}) (err error) { + switch data := v.(type) { + case *string: + *data = string(msg) + return nil + case *[]byte: + *data = msg + return nil + } + return ErrNotSupported +} + +/* +Message is a codec to send/receive text/binary data in a frame on WebSocket connection. +To send/receive text frame, use string type. +To send/receive binary frame, use []byte type. + +Trivial usage: + + import "websocket" + + // receive text frame + var message string + websocket.Message.Receive(ws, &message) + + // send text frame + message = "hello" + websocket.Message.Send(ws, message) + + // receive binary frame + var data []byte + websocket.Message.Receive(ws, &data) + + // send binary frame + data = []byte{0, 1, 2} + websocket.Message.Send(ws, data) + +*/ +var Message = Codec{marshal, unmarshal} + +func jsonMarshal(v interface{}) (msg []byte, payloadType byte, err error) { + msg, err = json.Marshal(v) + return msg, TextFrame, err +} + +func jsonUnmarshal(msg []byte, payloadType byte, v interface{}) (err error) { + return json.Unmarshal(msg, v) +} + +/* +JSON is a codec to send/receive JSON data in a frame from a WebSocket connection. + +Trivial usage: + + import "websocket" + + type T struct { + Msg string + Count int + } + + // receive JSON type T + var data T + websocket.JSON.Receive(ws, &data) + + // send JSON type T + websocket.JSON.Send(ws, data) +*/ +var JSON = Codec{jsonMarshal, jsonUnmarshal} diff --git a/vendor/manifest b/vendor/manifest index dc2f9253..c07f6e1b 100644 --- a/vendor/manifest +++ b/vendor/manifest @@ -110,6 +110,14 @@ "path": "/i18n", "notests": true }, + { + "importpath": "github.com/nlopes/slack", + "repository": "https://github.com/nlopes/slack", + "vcs": "git", + "revision": "4feee83bb2b31d790977ce727a028c6a542c72c7", + "branch": "HEAD", + "notests": true + }, { "importpath": "github.com/pborman/uuid", "repository": "https://github.com/pborman/uuid", @@ -186,6 +194,15 @@ "path": "/lex/httplex", "notests": true }, + { + "importpath": "golang.org/x/net/websocket", + "repository": "https://go.googlesource.com/net", + "vcs": "git", + "revision": "1358eff22f0dd0c54fc521042cc607f6ff4b531a", + "branch": "master", + "path": "/websocket", + "notests": true + }, { "importpath": "gopkg.in/gcfg.v1", "repository": "https://gopkg.in/gcfg.v1",