From 71a468485a7df79565f654219897ab0d49cba6cf Mon Sep 17 00:00:00 2001 From: Andrea Barberio Date: Tue, 26 Jan 2021 19:15:13 +0000 Subject: [PATCH] Support private channels This patch adds support to private channels. Previously, private channels were read-only, while now it's bidirectional like public channels and IMs. This required a refactoring of the channels API, and a few bug fixes. Now anything channel-related goes through the Channel and Channels structures, and should not use the Slack conversations API directly anymore. Signed-off-by: Andrea Barberio --- README.md | 9 ++ pkg/ircslack/channel.go | 140 +++++++++++++++++++++ pkg/ircslack/channels.go | 105 +++++++++++++--- pkg/ircslack/event_handler.go | 174 ++++++++++++-------------- pkg/ircslack/irc_channel.go | 40 ------ pkg/ircslack/irc_channel_test.go | 27 ---- pkg/ircslack/irc_context.go | 10 ++ pkg/ircslack/irc_server.go | 207 ++++++++++--------------------- pkg/ircslack/users.go | 65 +++++++--- pkg/ircslack/users_test.go | 9 +- 10 files changed, 443 insertions(+), 343 deletions(-) create mode 100644 pkg/ircslack/channel.go delete mode 100644 pkg/ircslack/irc_channel.go delete mode 100644 pkg/ircslack/irc_channel_test.go diff --git a/README.md b/README.md index 4763630..69aec69 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,15 @@ Then configure your IRC client to connect to localhost:6666 and use one of the m You can also [run it with Docker](#run-it-with-docker). +## Feature matrix + +| | public channel | private channel | multiparty IM | IM | +| --- | --- | --- | --- | --- | +| from me | works | works | doesn't work ([#168](https://github.com/insomniacslk/irc-slack/issues/168)) | works | +| to me | works | works | works | works | +| thread from me | doesn't work ([#168](https://github.com/insomniacslk/irc-slack/issues/168)) | doesn't work ([#168](https://github.com/insomniacslk/irc-slack/issues/168)) | untested | doesn't work ([#166](https://github.com/insomniacslk/irc-slack/issues/166)) | +| thread to me | works | works | untested | works but sends in the IM chat ([#167](https://github.com/insomniacslk/irc-slack/issues/167)) | + ## Encryption `irc-slack` by default does not use encryption when communicating with your IRC diff --git a/pkg/ircslack/channel.go b/pkg/ircslack/channel.go new file mode 100644 index 0000000..09c04ec --- /dev/null +++ b/pkg/ircslack/channel.go @@ -0,0 +1,140 @@ +package ircslack + +import ( + "fmt" + "strings" + "time" + + "github.com/slack-go/slack" +) + +// Constants for public, private, and multi-party conversation prefixes. +// Channel threads are prefixed with "+" but they are not conversation types +// so they do not belong here. A thread is just a message whose destination +// is within another message in a public, private, or multi-party conversation. +const ( + ChannelPrefixPublicChannel = "#" + ChannelPrefixPrivateChannel = "@" + ChannelPrefixMpIM = "&" + // NOTE: a thread is not a channel type + ChannelPrefixThread = "+" +) + +// HasChannelPrefix returns true if the channel name starts with one of the +// supproted channel prefixes. +func HasChannelPrefix(name string) bool { + if len(name) == 0 { + return false + } + switch string(name[0]) { + case ChannelPrefixPublicChannel, ChannelPrefixPrivateChannel, ChannelPrefixMpIM, ChannelPrefixThread: + return true + default: + return false + } +} + +// StripChannelPrefix returns a channel name without its channel prefix. If no +// channel prefix is present, the string is returned unchanged. +func StripChannelPrefix(name string) string { + if HasChannelPrefix(name) { + return name[1:] + } + return name +} + +// ChannelMembers returns a list of users in the given conversation. +func ChannelMembers(ctx *IrcContext, channelID string) ([]slack.User, error) { + var ( + members, m []string + nextCursor string + err error + page int + ) + for { + attempt := 0 + for { + // retry if rate-limited, no more than MaxSlackAPIAttempts times + if attempt >= MaxSlackAPIAttempts { + return nil, fmt.Errorf("ChannelMembers: exceeded the maximum number of attempts (%d) with the Slack API", MaxSlackAPIAttempts) + } + log.Debugf("ChannelMembers: page %d attempt #%d nextCursor=%s", page, attempt, nextCursor) + m, nextCursor, err = ctx.SlackClient.GetUsersInConversation(&slack.GetUsersInConversationParameters{ChannelID: channelID, Cursor: nextCursor, Limit: 1000}) + if err != nil { + log.Errorf("Failed to get users in conversation '%s': %v", channelID, err) + if rlErr, ok := err.(*slack.RateLimitedError); ok { + // we were rate-limited. Let's wait as much as Slack + // instructs us to do + log.Warningf("Hit Slack API rate limiter. Waiting %v", rlErr.RetryAfter) + time.Sleep(rlErr.RetryAfter) + attempt++ + continue + } + return nil, fmt.Errorf("Cannot get member list for conversation %s: %v", channelID, err) + } + break + } + members = append(members, m...) + log.Debugf("Fetched %d user IDs for channel %s (fetched so far: %d)", len(m), channelID, len(members)) + // TODO call ctx.Users.FetchByID here in a goroutine to see if this + // speeds up + if nextCursor == "" { + break + } + page++ + } + log.Debugf("Retrieving user information for %d users", len(members)) + users, err := ctx.Users.FetchByIDs(ctx.SlackClient, false, members...) + if err != nil { + return nil, fmt.Errorf("Failed to fetch users by their IDs: %v", err) + } + return users, nil +} + +// Channel wraps a Slack conversation with a few utility functions. +type Channel slack.Channel + +// IsPublicChannel returns true if the channel is public. +func (c *Channel) IsPublicChannel() bool { + return c.IsChannel && !c.IsPrivate +} + +// IsPrivateChannel returns true if the channel is private. +func (c *Channel) IsPrivateChannel() bool { + return c.IsGroup && c.IsPrivate +} + +// IsMP returns true if it is a multi-party conversation. +func (c *Channel) IsMP() bool { + return c.IsMpIM +} + +// IRCName returns the channel name as it would appear on IRC. +// Examples: +// * #channel for public groups +// * @channel for private groups +// * &Gxxxx|nick1-nick2-nick3 for multi-party IMs +func (c *Channel) IRCName() string { + switch { + case c.IsPublicChannel(): + return ChannelPrefixPublicChannel + c.Name + case c.IsPrivateChannel(): + return ChannelPrefixPrivateChannel + c.Name + case c.IsMP(): + name := ChannelPrefixMpIM + c.ID + "|" + c.Name + name = strings.Replace(name, "mpdm-", "", -1) + name = strings.Replace(name, "--", "-", -1) + if len(name) >= 30 { + return name[:29] + "…" + } + return name + default: + log.Warningf("Unknown channel type for channel %+v", c) + return "" + } +} + +// SlackName returns the slack.Channel.Name field. +func (c *Channel) SlackName() string { + return c.Name +} diff --git a/pkg/ircslack/channels.go b/pkg/ircslack/channels.go index a5b98eb..51f2aff 100644 --- a/pkg/ircslack/channels.go +++ b/pkg/ircslack/channels.go @@ -2,7 +2,7 @@ package ircslack import ( "context" - "strings" + "fmt" "sync" "time" @@ -11,7 +11,7 @@ import ( // Channels wraps the channel list with convenient operations and cache. type Channels struct { - channels map[string]slack.Channel + channels map[string]Channel Pagination int mu sync.Mutex } @@ -19,21 +19,91 @@ type Channels struct { // NewChannels creates a new Channels object. func NewChannels(pagination int) *Channels { return &Channels{ - channels: make(map[string]slack.Channel), + channels: make(map[string]Channel), Pagination: pagination, } } +// SupportedChannelPrefixes returns a list of supported channel prefixes. +func SupportedChannelPrefixes() []string { + return []string{ + ChannelPrefixPublicChannel, + ChannelPrefixPrivateChannel, + ChannelPrefixMpIM, + ChannelPrefixThread, + } + +} + // AsMap returns the channels as a map of name -> channel. The map is copied to // avoid data races -func (c *Channels) AsMap() map[string]slack.Channel { - var ret map[string]slack.Channel +func (c *Channels) AsMap() map[string]Channel { + c.mu.Lock() + defer c.mu.Unlock() + ret := make(map[string]Channel, len(c.channels)) for k, v := range c.channels { ret[k] = v } return ret } +// FetchByIDs fetches the channels with the specified IDs and updates the +// internal channel mapping. +func (c *Channels) FetchByIDs(client *slack.Client, skipCache bool, channelIDs ...string) ([]Channel, error) { + var ( + toRetrieve []string + alreadyRetrieved []Channel + ) + + if !skipCache { + c.mu.Lock() + for _, cid := range channelIDs { + if ch, ok := c.channels[cid]; !ok { + toRetrieve = append(toRetrieve, cid) + } else { + alreadyRetrieved = append(alreadyRetrieved, ch) + } + } + c.mu.Unlock() + log.Debugf("Fetching information for %d channels out of %d (%d already in cache)", len(toRetrieve), len(channelIDs), len(channelIDs)-len(toRetrieve)) + } else { + toRetrieve = channelIDs + } + allFetchedChannels := make([]Channel, 0, len(channelIDs)) + for i := 0; i < len(toRetrieve); i++ { + for { + attempt := 0 + if attempt >= MaxSlackAPIAttempts { + return nil, fmt.Errorf("Channels.FetchByIDs: exceeded the maximum number of attempts (%d) with the Slack API", MaxSlackAPIAttempts) + } + log.Debugf("Fetching %d channels of %d, attempt %d of %d", len(toRetrieve), len(channelIDs), attempt+1, MaxSlackAPIAttempts) + slackChannel, err := client.GetConversationInfo(toRetrieve[i], true) + if err != nil { + if rlErr, ok := err.(*slack.RateLimitedError); ok { + // we were rate-limited. Let's wait the recommended delay + log.Warningf("Hit Slack API rate limiter. Waiting %v", rlErr.RetryAfter) + time.Sleep(rlErr.RetryAfter) + attempt++ + continue + } + return nil, err + } + ch := Channel(*slackChannel) + allFetchedChannels = append(allFetchedChannels, ch) + // also update the local users map + c.mu.Lock() + c.channels[ch.ID] = ch + c.mu.Unlock() + break + } + } + allChannels := append(alreadyRetrieved, allFetchedChannels...) + if len(channelIDs) != len(allChannels) { + return allFetchedChannels, fmt.Errorf("Found %d users but %d were requested", len(allChannels), len(channelIDs)) + } + return allChannels, nil +} + // Fetch retrieves all the channels on a given Slack team. The Slack client has // to be valid and connected. func (c *Channels) Fetch(client *slack.Client) error { @@ -43,20 +113,22 @@ func (c *Channels) Fetch(client *slack.Client) error { var ( err error ctx = context.Background() - channels = make(map[string]slack.Channel) + channels = make(map[string]Channel) ) start := time.Now() params := slack.GetConversationsParameters{ + Types: []string{"public_channel", "private_channel"}, Limit: c.Pagination, } for err == nil { chans, nextCursor, err := client.GetConversationsContext(ctx, ¶ms) if err == nil { log.Debugf("Retrieved %d channels (current total is %d)", len(chans), len(channels)) - for _, c := range chans { + for _, sch := range chans { // WARNING WARNING WARNING: channels are internally mapped by - // name, while users are mapped by ID. - channels[c.Name] = c + // the Slack name, while users are mapped by Slack ID. + ch := Channel(sch) + channels[ch.SlackName()] = ch } } else if rateLimitedError, ok := err.(*slack.RateLimitedError); ok { select { @@ -88,7 +160,7 @@ func (c *Channels) Count() int { } // ByID retrieves a channel by its Slack ID. -func (c *Channels) ByID(id string) *slack.Channel { +func (c *Channels) ByID(id string) *Channel { c.mu.Lock() defer c.mu.Unlock() for _, c := range c.channels { @@ -99,17 +171,16 @@ func (c *Channels) ByID(id string) *slack.Channel { return nil } -// ByName retrieves a channel by its Slack name. -func (c *Channels) ByName(name string) *slack.Channel { - if strings.HasPrefix(name, "#") { +// ByName retrieves a channel by its Slack or IRC name. +func (c *Channels) ByName(name string) *Channel { + if HasChannelPrefix(name) { + // without prefix, the channel now has the form of a Slack name name = name[1:] } c.mu.Lock() defer c.mu.Unlock() - for _, c := range c.channels { - if c.Name == name { - return &c - } + if ch, ok := c.channels[name]; ok { + return &ch } return nil } diff --git a/pkg/ircslack/event_handler.go b/pkg/ircslack/event_handler.go index bcd63ec..ddc45a4 100644 --- a/pkg/ircslack/event_handler.go +++ b/pkg/ircslack/event_handler.go @@ -8,147 +8,125 @@ import ( "github.com/slack-go/slack" ) -func joinText(first string, second string, divider string) string { +func joinText(first string, second string, separator string) string { if first == "" { return second } if second == "" { return first } - return first + divider + second + return first + separator + second } -func formatMultipartyChannelName(slackChannelID string, slackChannelName string) string { - name := "&" + slackChannelID + "|" + slackChannelName - name = strings.Replace(name, "mpdm-", "", -1) - name = strings.Replace(name, "--", "-", -1) - if len(name) >= 30 { - return name[:29] + "…" - } - return name -} - -func formatThreadChannelName(threadTimestamp string, channel *slack.Channel) string { - return "+" + channel.Name + "-" + threadTimestamp +func formatThreadChannelName(threadTimestamp string, channel *Channel) string { + return ChannelPrefixThread + channel.Name + "-" + threadTimestamp } func resolveChannelName(ctx *IrcContext, msgChannel, threadTimestamp string) string { - // channame := "" if strings.HasPrefix(msgChannel, "C") || strings.HasPrefix(msgChannel, "G") { // Channel message - channel, err := ctx.GetConversationInfo(msgChannel) + channel := ctx.Channels.ByID(msgChannel) + if channel == nil { + // try fetching it, in case it's a new channel + channels, err := ctx.Channels.FetchByIDs(ctx.SlackClient, false, msgChannel) + if err != nil || len(channels) == 0 { + ctx.SendUnknownError("Failed to fetch channel with ID `%s`: %v", msgChannel, err) + return "" + } + channel = &channels[0] + } - if err != nil { - log.Warningf("Failed to get channel info for %v: %v", msgChannel, err) + if channel == nil { + ctx.SendUnknownError("Unknown channel ID `%s` when resolving channel name", msgChannel) return "" } else if threadTimestamp != "" { channame := formatThreadChannelName(threadTimestamp, channel) - if ctx.Channels.ByName(channame) == nil { - openingText, err := ctx.GetThreadOpener(msgChannel, threadTimestamp) - if err == nil { - IrcSendChanInfoAfterJoin( - ctx, - channame, - msgChannel, - openingText.Text, - []string{}, - true, - ) - } else { - log.Warningf("Didn't find thread channel %v", err) - } - - user := ctx.GetUserInfo(openingText.User) - name := "" - if user == nil { - log.Warningf("Error getting user info for %v", openingText.User) - name = openingText.User - } else { - name = user.Name - } + openingText, err := ctx.GetThreadOpener(msgChannel, threadTimestamp) + if err != nil { + ctx.SendUnknownError("Failed to get thread opener for `%s`: %v", msgChannel, err) + return "" + } + IrcSendChanInfoAfterJoinCustom( + ctx, + channame, + msgChannel, + openingText.Text, + []slack.User{}, + ) - privmsg := fmt.Sprintf(":%v!%v@%v PRIVMSG %v :%s%s%s\r\n", - name, openingText.User, ctx.ServerName, - channame, "", openingText.Text, "", - ) - if _, err := ctx.Conn.Write([]byte(privmsg)); err != nil { - log.Warningf("Failed to send IRC message: %v", err) - } + privmsg := fmt.Sprintf(":%v!%v@%v PRIVMSG %v :%s%s%s\r\n", + channame, openingText.User, ctx.ServerName, + channame, "", openingText.Text, "", + ) + if _, err := ctx.Conn.Write([]byte(privmsg)); err != nil { + log.Warningf("Failed to send IRC message: %v", err) } return channame } else if channel.IsMpIM { - channame := formatMultipartyChannelName(msgChannel, channel.Name) - if ctx.Channels.ByName(channame) == nil { - IrcSendChanInfoAfterJoin( - ctx, - channame, - msgChannel, - channel.Purpose.Value, - []string{}, - true, - ) + if ctx.Channels.ByName(channel.IRCName()) == nil { + members, err := ChannelMembers(ctx, channel.ID) + if err != nil { + log.Warningf("Failed to fetch channel members for `%s`: %v", channel.Name, err) + } else { + IrcSendChanInfoAfterJoin(ctx, channel, members) + } } - return channame + return channel.IRCName() } - return "#" + channel.Name + return channel.IRCName() } else if strings.HasPrefix(msgChannel, "D") { // Direct message to me - users, err := usersInConversation(ctx, msgChannel) - if err != nil { - // ERR_UNKNOWNERROR - if err := SendIrcNumeric(ctx, 400, ctx.Nick(), fmt.Sprintf("Cannot get conversation info for %s", msgChannel)); err != nil { - log.Warningf("Failed to send IRC message: %v", err) + channel := ctx.Channels.ByID(msgChannel) + if channel == nil { + // not found locally, try to get it via Slack API + channels, err := ctx.Channels.FetchByIDs(ctx.SlackClient, false, msgChannel) + if err != nil || len(channels) == 0 { + ctx.SendUnknownError("Failed to fetch IM chat with ID `%s`: %v", msgChannel, err) + return "" } + channel = &channels[0] + } + members, err := ChannelMembers(ctx, channel.ID) + if err != nil { + ctx.SendUnknownError("Failed to fetch channel members for `%s`: %v", channel.Name, err) return "" } // we expect only two members in a direct message. Raise an // error if not. - if len(users) == 0 || len(users) > 2 { - // ERR_UNKNOWNERROR - if err := SendIrcNumeric(ctx, 400, ctx.Nick(), fmt.Sprintf("Want 1 or 2 users in conversation, got %d (conversation ID: %s)", len(users), msgChannel)); err != nil { - log.Warningf("Failed to send IRC message: %v", err) - } + if len(members) == 0 || len(members) > 2 { + ctx.SendUnknownError("Want 1 or 2 users in conversation, got %d (conversation ID: %s)", len(members), msgChannel) return "" - } // of the two users, one is me. Otherwise fail if ctx.UserID() == "" { - // ERR_UNKNOWNERROR - if err := SendIrcNumeric(ctx, 400, ctx.UserID(), "Cannot get my own user ID"); err != nil { - log.Warningf("Failed to send IRC message: %v", err) - } + ctx.SendUnknownError("Cannot get my own user ID") return "" } - user1 := users[0] - var user2 string - if len(users) == 2 { - user2 = users[1] + user1 := members[0] + var user2 slack.User + if len(members) == 2 { + user2 = members[1] } else { // len is 1. Sending a message to myself user2 = user1 } - if user1 != ctx.UserID() && user2 != ctx.UserID() { - // ERR_UNKNOWNERROR - if err := SendIrcNumeric(ctx, 400, ctx.UserID(), fmt.Sprintf("Got a direct message where I am not part of the members list (members: %s)", strings.Join(users, ", "))); err != nil { - log.Warningf("Failed to send IRC message: %v", err) - } + if user1.ID != ctx.UserID() && user2.ID != ctx.UserID() { + ctx.SendUnknownError("Got a direct message where I am not part of the members list (conversation: %s)", msgChannel) return "" } var recipientID string - if user1 == ctx.UserID() { + if user1.ID == ctx.UserID() { // then it's the other user - recipientID = user2 + recipientID = user2.ID } else { - recipientID = user1 + recipientID = user1.ID } // now resolve the ID to the user's nickname nickname := ctx.GetUserInfo(recipientID) if nickname == nil { // ERR_UNKNOWNERROR - if err := SendIrcNumeric(ctx, 400, ctx.UserID(), fmt.Sprintf("Unknown destination user ID %s for direct message %s", recipientID, msgChannel)); err != nil { - log.Warningf("Failed to send IRC message: %v", err) - } + ctx.SendUnknownError("Unknown destination user ID %s for direct message %s", recipientID, msgChannel) return "" } return nickname.Name @@ -301,11 +279,11 @@ func eventHandler(ctx *IrcContext, rtm *slack.RTM) { case "channel_topic": // https://api.slack.com/events/message/channel_topic // Send out new topic - channel, err := ctx.SlackClient.GetChannelInfo(message.Channel) - if err != nil { + channel := ctx.Channels.ByID(message.Channel) + if channel == nil { log.Warningf("Cannot get channel name for %v", message.Channel) } else { - newTopic := fmt.Sprintf(":%v TOPIC #%v :%v\r\n", ctx.Mask(), channel.Name, message.Topic) + newTopic := fmt.Sprintf(":%v TOPIC %s :%v\r\n", ctx.Mask(), channel.IRCName(), message.Topic) log.Infof("Got new topic: %v", newTopic) if _, err := ctx.Conn.Write([]byte(newTopic)); err != nil { log.Warningf("Failed to send IRC message: %v", err) @@ -339,8 +317,8 @@ func eventHandler(ctx *IrcContext, rtm *slack.RTM) { log.Warningf("Unknown channel: %s", ev.Channel) continue } - if _, err := ctx.Conn.Write([]byte(fmt.Sprintf(":%v JOIN #%v\r\n", ctx.Mask(), ch.Name))); err != nil { - log.Warningf("Failed to send IRC message: %v", err) + if _, err := ctx.Conn.Write([]byte(fmt.Sprintf(":%s JOIN %s\r\n", ctx.Mask(), ch.IRCName()))); err != nil { + log.Warningf("Failed to send IRC JOIN message for `%s`: %v", ch.IRCName(), err) } case *slack.MemberLeftChannelEvent: // This is the currently preferred way to notify when a user leaves a @@ -352,19 +330,19 @@ func eventHandler(ctx *IrcContext, rtm *slack.RTM) { log.Warningf("Unknown channel: %s", ev.Channel) continue } - if _, err := ctx.Conn.Write([]byte(fmt.Sprintf(":%v PART #%v\r\n", ctx.Mask(), ch.Name))); err != nil { + if _, err := ctx.Conn.Write([]byte(fmt.Sprintf(":%v PART %s\r\n", ctx.Mask(), ch.IRCName()))); err != nil { log.Warningf("Failed to send IRC message: %v", err) } case *slack.TeamJoinEvent: // https://api.slack.com/events/team_join // update the users list - if err := ctx.Users.FetchByIDs(ctx.SlackClient, false, ev.User.ID); err != nil { + if _, err := ctx.Users.FetchByIDs(ctx.SlackClient, false, ev.User.ID); err != nil { log.Warningf("Failed to fetch users: %v", err) } case *slack.UserChangeEvent: // https://api.slack.com/events/user_change // update the user list - if err := ctx.Users.FetchByIDs(ctx.SlackClient, false, ev.User.ID); err != nil { + if _, err := ctx.Users.FetchByIDs(ctx.SlackClient, false, ev.User.ID); err != nil { log.Warningf("Failed to fetch users: %v", err) } case *slack.ChannelJoinedEvent, *slack.ChannelLeftEvent: diff --git a/pkg/ircslack/irc_channel.go b/pkg/ircslack/irc_channel.go deleted file mode 100644 index 022b78b..0000000 --- a/pkg/ircslack/irc_channel.go +++ /dev/null @@ -1,40 +0,0 @@ -package ircslack - -// Channel represents an IRC channel. It maps to Slack's groups and channels. -// Private messages are handled differently. -type Channel struct { - Members []string - Topic string - ID string - // Slack groups are different from channels. Here I try to uniform them for - // IRC, but I still need to know which is which to use the right API calls. - IsGroup bool -} - -// MembersDiff compares the members of this channel with another members list -// and return a slice of members who joined and a slice of members who left. -func (c Channel) MembersDiff(otherMembers []string) ([]string, []string) { - var membersMap = map[string]bool{} - for _, m := range c.Members { - membersMap[m] = true - } - var otherMembersMap = map[string]bool{} - for _, m := range otherMembers { - otherMembersMap[m] = true - } - - added := make([]string, 0) - for _, m := range otherMembers { - if _, ok := membersMap[m]; !ok { - added = append(added, m) - } - } - - removed := make([]string, 0) - for _, m := range c.Members { - if _, ok := otherMembersMap[m]; !ok { - removed = append(removed, m) - } - } - return added, removed -} diff --git a/pkg/ircslack/irc_channel_test.go b/pkg/ircslack/irc_channel_test.go deleted file mode 100644 index 794e45e..0000000 --- a/pkg/ircslack/irc_channel_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package ircslack - -import ( - "testing" -) - -func TestMembersDiffEmpty(t *testing.T) { - c := Channel{Members: []string{}} - a, r := c.MembersDiff([]string{}) - if len(a) != 0 { - t.Fatalf("Added members: %v; want empty list", a) - } - if len(r) != 0 { - t.Fatalf("Removed members: %v; want empty list", r) - } -} - -func TestMembersDiffNonEmpty(t *testing.T) { - c := Channel{Members: []string{"removed1"}} - a, r := c.MembersDiff([]string{"added1"}) - if !(len(a) == 1 && a[0] == "added1") { - t.Fatalf("Added members: %v; want: %v", a, []string{"added1"}) - } - if !(len(r) == 1 && r[0] == "removed1") { - t.Fatalf("Removed members: %v; want: %v", a, []string{"removed1"}) - } -} diff --git a/pkg/ircslack/irc_context.go b/pkg/ircslack/irc_context.go index e3cf448..76562ff 100644 --- a/pkg/ircslack/irc_context.go +++ b/pkg/ircslack/irc_context.go @@ -168,3 +168,13 @@ func (ic IrcContext) GetConversationInfo(conversation string) (*slack.Channel, e var ( UserContexts = map[net.Addr]*IrcContext{} ) + +// SendUnknownError sends an IRC 400 (ERR_UNKNOWNERROR) message to the client +// and prints a warning about it. +func (ic *IrcContext) SendUnknownError(fmtstr string, args ...interface{}) { + msg := fmt.Sprintf(fmtstr, args...) + log.Warningf("Sending ERR_UNKNOWNERROR (400) to client with message: %s", msg) + if err := SendIrcNumeric(ic, 400, ic.Nick(), msg); err != nil { + log.Warningf("Failed to send ERR_UNKNOWNERROR (400) to client: %v", err) + } +} diff --git a/pkg/ircslack/irc_server.go b/pkg/ircslack/irc_server.go index d788535..06905f7 100644 --- a/pkg/ircslack/irc_server.go +++ b/pkg/ircslack/irc_server.go @@ -151,131 +151,65 @@ func SendIrcNumeric(ctx *IrcContext, code int, args, desc string) error { // IrcSendChanInfoAfterJoin sends channel information to the user about a joined // channel. -func IrcSendChanInfoAfterJoin(ctx *IrcContext, name, id, topic string, members []string, isGroup bool) { +func IrcSendChanInfoAfterJoin(ctx *IrcContext, ch *Channel, members []slack.User) { + IrcSendChanInfoAfterJoinCustom(ctx, ch.IRCName(), ch.ID, ch.Purpose.Value, members) +} + +// IrcSendChanInfoAfterJoinCustom sends channel information to the user about a joined +// channel. It can be used as an alternative to IrcSendChanInfoAfterJoin when +// you need to specify custom chan name, id, and topic. +func IrcSendChanInfoAfterJoinCustom(ctx *IrcContext, chanName, chanID, topic string, members []slack.User) { + memberNames := make([]string, 0, len(members)) + for _, m := range members { + memberNames = append(memberNames, m.Name) + } // TODO wrap all these Conn.Write into a function - if _, err := ctx.Conn.Write([]byte(fmt.Sprintf(":%v JOIN %v\r\n", ctx.Mask(), name))); err != nil { - log.Warningf("Failed to send IRC message: %v", err) + if _, err := ctx.Conn.Write([]byte(fmt.Sprintf(":%s JOIN %s\r\n", ctx.Mask(), chanName))); err != nil { + log.Warningf("Failed to send IRC JOIN message: %v", err) } // RPL_TOPIC - if err := SendIrcNumeric(ctx, 332, fmt.Sprintf("%s %s", ctx.Nick(), name), topic); err != nil { - log.Warningf("Failed to send IRC message: %v", err) + if err := SendIrcNumeric(ctx, 332, fmt.Sprintf("%s %s", ctx.Nick(), chanName), topic); err != nil { + log.Warningf("Failed to send IRC TOPIC message: %v", err) } // RPL_NAMREPLY - if err := SendIrcNumeric(ctx, 353, fmt.Sprintf("%s = %s", ctx.Nick(), name), strings.Join(ctx.Users.IDsToNames(members...), " ")); err != nil { - log.Warningf("Failed to send IRC message: %v", err) - } - // RPL_ENDOFNAMES - if err := SendIrcNumeric(ctx, 366, fmt.Sprintf("%s %s", ctx.Nick(), name), "End of NAMES list"); err != nil { - log.Warningf("Failed to send IRC message: %v", err) - } - log.Infof("Joined channel %s: %+v", name, ctx.Channels.ByName(name)) -} - -func usersInConversation(ctx *IrcContext, conversation string) ([]string, error) { - var ( - members, m []string - nextCursor string - err error - page int - ) - for { - attempt := 0 - for { - // retry if rate-limited, no more than MaxSlackAPIAttempts times - if attempt >= MaxSlackAPIAttempts { - return nil, fmt.Errorf("GetUsersInConversation: exceeded the maximum number of attempts (%d) with the Slack API", MaxSlackAPIAttempts) - } - log.Debugf("GetUsersInConversation: page %d attempt #%d nextCursor=%s", page, attempt, nextCursor) - m, nextCursor, err = ctx.SlackClient.GetUsersInConversation(&slack.GetUsersInConversationParameters{ChannelID: conversation, Cursor: nextCursor, Limit: 1000}) - if err != nil { - log.Errorf("Failed to get users in conversation '%s': %v", conversation, err) - if rlErr, ok := err.(*slack.RateLimitedError); ok { - // we were rate-limited. Let's wait as much as Slack - // instructs us to do - log.Warningf("Hit Slack API rate limiter. Waiting %v", rlErr.RetryAfter) - time.Sleep(rlErr.RetryAfter) - attempt++ - continue - } - return nil, fmt.Errorf("Cannot get member list for conversation %s: %v", conversation, err) - } - break + if len(members) > 0 { + if err := SendIrcNumeric(ctx, 353, fmt.Sprintf("%s = %s", ctx.Nick(), chanName), strings.Join(memberNames, " ")); err != nil { + log.Warningf("Failed to send IRC NAMREPLY message: %v", err) } - log.Debugf("Fetched %d user IDs for channel %s (fetched so far: %d)", len(m), conversation, len(members)) - members = append(members, m...) - // TODO call ctx.Users.FetchByID here in a goroutine to see if this - // speeds up - if nextCursor == "" { - break - } - page++ } - log.Debugf("Retrieving user information for %d users", len(members)) - if err := ctx.Users.FetchByIDs(ctx.SlackClient, false, members...); err != nil { - return nil, fmt.Errorf("Failed to fetch users by their IDs: %v", err) + // RPL_ENDOFNAMES + if err := SendIrcNumeric(ctx, 366, fmt.Sprintf("%s %s", ctx.Nick(), chanName), "End of NAMES list"); err != nil { + log.Warningf("Failed to send IRC ENDOFNAMES message: %v", err) } - return members, nil + log.Infof("Joined channel %s", chanName) } -// join will join the channel with the given ID, name and topic, and send back a +// joinChannel will join the channel with the given ID, name and topic, and send back a // response to the IRC client -func join(ctx *IrcContext, id, name, topic string) error { - members, err := usersInConversation(ctx, id) - if err != nil { - return err - } - info := fmt.Sprintf("#%s topic=%s members=%d", name, topic, len(members)) - log.Infof(info) +func joinChannel(ctx *IrcContext, ch *Channel) error { + log.Infof(fmt.Sprintf("%s topic=%s members=%d", ch.IRCName(), ch.Purpose.Value, ch.NumMembers)) // the channels are already joined, notify the IRC client of their // existence - go IrcSendChanInfoAfterJoin(ctx, name, id, topic, members, false) + members, err := ChannelMembers(ctx, ch.ID) + if err != nil { + jErr := fmt.Errorf("Failed to fetch users in channel `%s (channel ID: %s): %v", ch.Name, ch.ID, err) + ctx.SendUnknownError(jErr.Error()) + return jErr + } + go IrcSendChanInfoAfterJoin(ctx, ch, members) return nil } // joinChannels gets all the available Slack channels and sends an IRC JOIN message // for each of the joined channels on Slack func joinChannels(ctx *IrcContext) error { - log.Info("Channel list:") - var ( - channels, chans []slack.Channel - nextCursor string - err error - ) - for { - attempt := 0 - for { - // retry if rate-limited, no more than MaxSlackAPIAttempts times - if attempt >= MaxSlackAPIAttempts { - return fmt.Errorf("GetConversations: exceeded the maximum number of attempts (%d) with the Slack API", MaxSlackAPIAttempts) - } - log.Infof("GetConversations: attempt #%d, nextCursor=%s", attempt, nextCursor) - params := slack.GetConversationsParameters{ - Types: []string{"public_channel", "private_channel"}, - Cursor: nextCursor, - } - chans, nextCursor, err = ctx.SlackClient.GetConversations(¶ms) - if err != nil { - log.Warningf("Failed to get conversations: %v", err) - if rlErr, ok := err.(*slack.RateLimitedError); ok { - // we were rate-limited. Let's wait as much as Slack - // instructs us to do - log.Warningf("Hit Slack API rate limiter. Waiting %v", rlErr.RetryAfter) - time.Sleep(rlErr.RetryAfter) - attempt++ - continue - } - return fmt.Errorf("Cannot get slack channels: %v", err) - } - break - } - channels = append(channels, chans...) - if nextCursor == "" { - break + for _, sch := range ctx.Channels.AsMap() { + ch := Channel(sch) + if !ch.IsPublicChannel() && !ch.IsPrivateChannel() { + continue } - } - for _, ch := range channels { if ch.IsMember { - if err := join(ctx, ch.ID, "#"+ch.Name, ch.Purpose.Value); err != nil { + if err := joinChannel(ctx, &ch); err != nil { return err } } @@ -307,7 +241,7 @@ func IrcAfterLoggingIn(ctx *IrcContext, rtm *slack.RTM) error { } } // RPL_ISUPPORT - if err := SendIrcNumeric(ctx, 005, ctx.Nick(), "CHANTYPES=#+&"); err != nil { + if err := SendIrcNumeric(ctx, 005, ctx.Nick(), "CHANTYPES="+strings.Join(SupportedChannelPrefixes(), "")); err != nil { log.Warningf("Failed to send IRC message: %v", err) } motd(fmt.Sprintf("This is an IRC-to-Slack gateway, written by %s <%s>.", ProjectAuthor, ProjectAuthorEmail)) @@ -373,14 +307,16 @@ func getTargetTs(channelName string) string { // IrcPrivMsgHandler is called when a PRIVMSG command is sent func IrcPrivMsgHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) { - channelParameter := "" - text := "" - if len(args) == 1 { + var channelParameter, text string + switch len(args) { + case 1: channelParameter = args[0] text = trailing - } else if len(args) == 2 { + case 2: channelParameter = args[0] text = args[1] + default: + log.Warningf("Invalid number of parameters for PRIVMSG, want 1 or 2, got %d", len(args)) } if channelParameter == "" || text == "" { log.Warningf("Invalid PRIVMSG command args: %v %v", args, trailing) @@ -389,8 +325,10 @@ func IrcPrivMsgHandler(ctx *IrcContext, prefix, cmd string, args []string, trail channel := ctx.Channels.ByName(channelParameter) target := "" if channel != nil { - target = channel.ID + // known channel + target = channel.SlackName() } else { + // assume private message target = "@" + channelParameter } @@ -406,7 +344,7 @@ func IrcPrivMsgHandler(ctx *IrcContext, prefix, cmd string, args []string, trail log.Warningf("Unknown channel ID for %s", key) return } - target = ch.ID + target = ch.SlackName() // this is a MeMessage // strip off the ACTION and \x01 wrapper @@ -654,10 +592,7 @@ func IrcPassHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing // IrcWhoHandler is called when a WHO command is sent func IrcWhoHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) { sendErr := func() { - // ERR_UNKNOWNERROR - if err := SendIrcNumeric(ctx, 400, ctx.Nick(), "Invalid WHO command. Syntax: WHO "); err != nil { - log.Warningf("Failed to send IRC message: %v", err) - } + ctx.SendUnknownError("Invalid WHO command. Syntax: WHO ") } if len(args) != 1 && len(args) != 2 { sendErr() @@ -665,7 +600,7 @@ func IrcWhoHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing } target := args[0] var rargs, desc string - if strings.HasPrefix(target, "#") { + if HasChannelPrefix(target) { ch := ctx.Channels.ByName(target) if ch == nil { // ERR_NOSUCHCHANNEL @@ -676,7 +611,7 @@ func IrcWhoHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing } for _, un := range ch.Members { // FIXME can we use the cached users? - u := ctx.GetUserInfo(un) + u := ctx.Users.ByID(un) if u == nil { log.Warningf("Failed to get info for user name '%s'", un) continue @@ -720,10 +655,7 @@ func IrcWhoHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing // IrcWhoisHandler is called when a WHOIS command is sent func IrcWhoisHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) { if len(args) != 1 && len(args) != 2 { - // ERR_UNKNOWNERROR - if err := SendIrcNumeric(ctx, 400, ctx.Nick(), "Invalid WHOIS command. Syntax: WHOIS "); err != nil { - log.Warningf("Failed to send IRC message: %v", err) - } + ctx.SendUnknownError("Invalid WHOIS command. Syntax: WHOIS ") return } username := args[0] @@ -789,10 +721,7 @@ func IrcWhoisHandler(ctx *IrcContext, prefix, cmd string, args []string, trailin // IrcJoinHandler is called when a JOIN command is sent func IrcJoinHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) { if len(args) != 1 { - // ERR_UNKNOWNERROR - if err := SendIrcNumeric(ctx, 400, ctx.Nick(), "Invalid JOIN command"); err != nil { - log.Warningf("Failed to send IRC message: %v", err) - } + ctx.SendUnknownError("Invalid JOIN command") return } // Because it is possible for an IRC Client to join multiple channels @@ -801,39 +730,38 @@ func IrcJoinHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing // separately. channames := strings.Split(args[0], ",") for _, channame := range channames { - if strings.HasPrefix(channame, "&") || strings.HasPrefix(channame, "+") { + if strings.HasPrefix(channame, ChannelPrefixMpIM) || strings.HasPrefix(channame, ChannelPrefixThread) { + log.Debugf("JOIN: ignoring channel `%s`, cannot join multi-party IMs or threads", channame) continue } - ch, err := ctx.SlackClient.JoinChannel(channame) + sch, err := ctx.SlackClient.JoinChannel(channame) if err != nil { log.Warningf("Cannot join channel %s: %v", channame, err) continue } log.Infof("Joined channel %s", channame) - go IrcSendChanInfoAfterJoin(ctx, channame, ch.ID, ch.Purpose.Value, ch.Members, true) + ch := Channel(*sch) + if err := joinChannel(ctx, &ch); err != nil { + log.Warningf("Failed to join channel `%s`: %v", ch.Name, err) + continue + } } } // IrcPartHandler is called when a PART command is sent func IrcPartHandler(ctx *IrcContext, prefix, cmd string, args []string, trailing string) { if len(args) != 1 { - // ERR_UNKNOWNERROR - if err := SendIrcNumeric(ctx, 400, ctx.Nick(), "Invalid PART command"); err != nil { - log.Warningf("Failed to send IRC message: %v", err) - } + ctx.SendUnknownError("Invalid PART command") return } - channame := strings.TrimPrefix(args[0], "#") + channame := StripChannelPrefix(args[0]) // Slack needs the channel ID to leave it, not the channel name. The only // way to get the channel ID from the name is retrieving the whole channel // list and finding the one whose name is the one we want to leave chanlist, err := ctx.SlackClient.GetChannels(true) if err != nil { log.Warningf("Cannot leave channel %s: %v", channame, err) - // ERR_UNKNOWNERROR - if err := SendIrcNumeric(ctx, 400, ctx.Nick(), fmt.Sprintf("Cannot leave channel: %v", err)); err != nil { - log.Warningf("Failed to send IRC message: %v", err) - } + ctx.SendUnknownError("Cannot leave channel: %v", err) return } var chanID string @@ -887,10 +815,7 @@ func IrcTopicHandler(ctx *IrcContext, prefix, cmd string, args []string, trailin } newTopic, err := ctx.SlackClient.SetPurposeOfConversation(channel.ID, topic) if err != nil { - // ERR_UNKNOWNERROR - if err := SendIrcNumeric(ctx, 400, ctx.Nick(), fmt.Sprintf("%s :Cannot set topic: %v", channame, err)); err != nil { - log.Warningf("Failed to send IRC message: %v", err) - } + ctx.SendUnknownError("%s :Cannot set topic: %v", channame, err) return } // RPL_TOPIC diff --git a/pkg/ircslack/users.go b/pkg/ircslack/users.go index 43693e3..e4ea232 100644 --- a/pkg/ircslack/users.go +++ b/pkg/ircslack/users.go @@ -2,6 +2,7 @@ package ircslack import ( "context" + "fmt" "sync" "time" @@ -23,15 +24,21 @@ func NewUsers(pagination int) *Users { } } -// FetchByIDs fetches the users from the specified IDs and updates the internal +// FetchByIDs fetches the users with the specified IDs and updates the internal // user mapping. -func (u *Users) FetchByIDs(client *slack.Client, skipCache bool, userIDs ...string) error { - var toRetrieve []string +func (u *Users) FetchByIDs(client *slack.Client, skipCache bool, userIDs ...string) ([]slack.User, error) { + var ( + toRetrieve []string + alreadyRetrieved []slack.User + ) + if !skipCache { u.mu.Lock() for _, uid := range userIDs { - if _, ok := u.users[uid]; !ok { + if u, ok := u.users[uid]; !ok { toRetrieve = append(toRetrieve, uid) + } else { + alreadyRetrieved = append(alreadyRetrieved, u) } } u.mu.Unlock() @@ -40,28 +47,52 @@ func (u *Users) FetchByIDs(client *slack.Client, skipCache bool, userIDs ...stri toRetrieve = userIDs } chunkSize := 1000 + allFetchedUsers := make([]slack.User, 0, len(userIDs)) for i := 0; i < len(toRetrieve); i += chunkSize { upperLimit := i + chunkSize if upperLimit > len(toRetrieve) { upperLimit = len(toRetrieve) } - slackUsers, err := client.GetUsersInfo(toRetrieve[i:upperLimit]...) - if err != nil { - return err - } - // also update the local users map - u.mu.Lock() - for _, user := range *slackUsers { - u.users[user.ID] = user + for { + attempt := 0 + if attempt >= MaxSlackAPIAttempts { + return nil, fmt.Errorf("Users.FetchByIDs: exceeded the maximum number of attempts (%d) with the Slack API", MaxSlackAPIAttempts) + } + log.Debugf("Fetching %d users of %d, attempt %d of %d", len(toRetrieve), len(userIDs), attempt+1, MaxSlackAPIAttempts) + slackUsers, err := client.GetUsersInfo(toRetrieve[i:upperLimit]...) + if err != nil { + if rlErr, ok := err.(*slack.RateLimitedError); ok { + // we were rate-limited. Let's wait the recommended delay + log.Warningf("Hit Slack API rate limiter. Waiting %v", rlErr.RetryAfter) + time.Sleep(rlErr.RetryAfter) + attempt++ + continue + } + return nil, err + } + if len(*slackUsers) != len(toRetrieve[i:upperLimit]) { + log.Warningf("Tried to fetch %d users but only got %d", len(toRetrieve[i:upperLimit]), len(*slackUsers)) + } + allFetchedUsers = append(allFetchedUsers, *slackUsers...) + // also update the local users map + u.mu.Lock() + for _, user := range *slackUsers { + u.users[user.ID] = user + } + u.mu.Unlock() + break } - u.mu.Unlock() } - return nil + allUsers := append(alreadyRetrieved, allFetchedUsers...) + if len(userIDs) != len(allUsers) { + return allFetchedUsers, fmt.Errorf("Found %d users but %d were requested", len(allUsers), len(userIDs)) + } + return allUsers, nil } // Fetch retrieves all the users on a given Slack team. The Slack client has to // be valid and connected. -func (u *Users) Fetch(client *slack.Client) error { +func (u *Users) Fetch(client *slack.Client) ([]slack.User, error) { log.Infof("Fetching all users, might take a while on large Slack teams") var opts []slack.GetUsersOption if u.pagination > 0 { @@ -75,6 +106,7 @@ func (u *Users) Fetch(client *slack.Client) error { users = make(map[string]slack.User) ) start := time.Now() + var allFetchedUsers []slack.User for err == nil { up, err = up.Next(ctx) if err == nil { @@ -82,6 +114,7 @@ func (u *Users) Fetch(client *slack.Client) error { for _, u := range up.Users { users[u.ID] = u } + allFetchedUsers = append(allFetchedUsers, up.Users...) } else if rateLimitedError, ok := err.(*slack.RateLimitedError); ok { select { case <-ctx.Done(): @@ -99,7 +132,7 @@ func (u *Users) Fetch(client *slack.Client) error { u.mu.Lock() u.users = users u.mu.Unlock() - return nil + return allFetchedUsers, nil } // Count returns the number of users. This method must be called after `Fetch`. diff --git a/pkg/ircslack/users_test.go b/pkg/ircslack/users_test.go index 9ed7417..20d84ab 100644 --- a/pkg/ircslack/users_test.go +++ b/pkg/ircslack/users_test.go @@ -58,15 +58,16 @@ func (c fakeSlackHTTPClient) Do(req *http.Request) (*http.Response, error) { func TestUsersFetch(t *testing.T) { client := slack.New("test-token", slack.OptionHTTPClient(fakeSlackHTTPClient{})) users := NewUsers(10) - err := users.Fetch(client) + fetched, err := users.Fetch(client) require.NoError(t, err) assert.Equal(t, 1, users.Count()) + assert.Equal(t, 1, len(fetched)) } func TestUsersById(t *testing.T) { client := slack.New("test-token", slack.OptionHTTPClient(fakeSlackHTTPClient{})) users := NewUsers(10) - err := users.Fetch(client) + _, err := users.Fetch(client) require.NoError(t, err) u := users.ByID("UABCD") require.NotNil(t, u) @@ -77,7 +78,7 @@ func TestUsersById(t *testing.T) { func TestUsersByName(t *testing.T) { client := slack.New("test-token", slack.OptionHTTPClient(fakeSlackHTTPClient{})) users := NewUsers(10) - err := users.Fetch(client) + _, err := users.Fetch(client) require.NoError(t, err) u := users.ByName("insomniac") require.NotNil(t, u) @@ -88,7 +89,7 @@ func TestUsersByName(t *testing.T) { func TestUsersIDsToNames(t *testing.T) { client := slack.New("test-token", slack.OptionHTTPClient(fakeSlackHTTPClient{})) users := NewUsers(10) - err := users.Fetch(client) + _, err := users.Fetch(client) require.NoError(t, err) names := users.IDsToNames("UABCD") assert.Equal(t, []string{"insomniac"}, names)