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]>
  • Loading branch information
insomniacslk committed Jan 27, 2021
1 parent 3cfeb31 commit b32b461
Show file tree
Hide file tree
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
Expand Up @@ -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
Expand Down
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
Expand Up @@ -2,7 +2,7 @@ package ircslack

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

Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Loading

0 comments on commit b32b461

Please sign in to comment.