Skip to content

Commit

Permalink
Support private channels
Browse files Browse the repository at this point in the history
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 <[email protected]>
insomniacslk committed Jan 27, 2021
1 parent 3cfeb31 commit 3e14d5a
Showing 10 changed files with 443 additions and 343 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
140 changes: 140 additions & 0 deletions pkg/ircslack/channel.go
Original file line number Diff line number Diff line change
@@ -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 "<unknow-channel-type>"
}
}

// SlackName returns the slack.Channel.Name field.
func (c *Channel) SlackName() string {
return c.Name
}
105 changes: 88 additions & 17 deletions pkg/ircslack/channels.go
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@ package ircslack

import (
"context"
"strings"
"fmt"
"sync"
"time"

@@ -11,29 +11,99 @@ 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
}

// 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, &params)
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
}
174 changes: 76 additions & 98 deletions pkg/ircslack/event_handler.go
Original file line number Diff line number Diff line change
@@ -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:
40 changes: 0 additions & 40 deletions pkg/ircslack/irc_channel.go

This file was deleted.

27 changes: 0 additions & 27 deletions pkg/ircslack/irc_channel_test.go

This file was deleted.

10 changes: 10 additions & 0 deletions pkg/ircslack/irc_context.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
207 changes: 66 additions & 141 deletions pkg/ircslack/irc_server.go
Original file line number Diff line number Diff line change
@@ -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(&params)
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,18 +592,15 @@ 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 <nickname|channel>"); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
ctx.SendUnknownError("Invalid WHO command. Syntax: WHO <nickname|channel>")
}
if len(args) != 1 && len(args) != 2 {
sendErr()
return
}
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 <username>"); err != nil {
log.Warningf("Failed to send IRC message: %v", err)
}
ctx.SendUnknownError("Invalid WHOIS command. Syntax: WHOIS <username>")
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
65 changes: 49 additions & 16 deletions pkg/ircslack/users.go
Original file line number Diff line number Diff line change
@@ -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,13 +106,15 @@ 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 {
log.Debugf("Retrieved %d users (current total is %d)", len(up.Users), len(users))
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`.
9 changes: 5 additions & 4 deletions pkg/ircslack/users_test.go
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 3e14d5a

Please sign in to comment.