From 3a8d5186e6d6872d10a04bd7203e5dee30a6cbe0 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 3 Aug 2024 22:09:27 +0300 Subject: [PATCH] login: add initial support for logging in as slack app --- go.mod | 4 +- go.sum | 8 +- pkg/connector/backfill.go | 2 +- pkg/connector/chatinfo.go | 5 +- pkg/connector/client.go | 190 ++++++++++++++++---- pkg/connector/handlematrix.go | 57 ++++-- pkg/connector/handleslack.go | 53 +++++- pkg/connector/login-app.go | 97 ++++++++++ pkg/connector/{login.go => login-cookie.go} | 20 ++- pkg/connector/startchat.go | 6 +- pkg/msgconv/from-matrix.go | 12 +- pkg/slackid/dbmeta.go | 1 + 12 files changed, 372 insertions(+), 83 deletions(-) create mode 100644 pkg/connector/login-app.go rename pkg/connector/{login.go => login-cookie.go} (93%) diff --git a/go.mod b/go.mod index a7984d7..4447480 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( go.mau.fi/util v0.6.1-0.20240802175451-b430ebbffc98 golang.org/x/net v0.27.0 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.19.1-0.20240803150944-b71b32d0d6d6 + maunium.net/go/mautrix v0.19.1-0.20240803190639-956c13761ebb ) require ( @@ -38,4 +38,4 @@ require ( maunium.net/go/mauflag v1.0.0 // indirect ) -replace github.com/slack-go/slack => github.com/beeper/slackgo v0.0.0-20240803155237-c586cd47cbb5 +replace github.com/slack-go/slack => github.com/beeper/slackgo v0.0.0-20240803190730-362662f1319d diff --git a/go.sum b/go.sum index 345f47b..0ea8003 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/beeper/slackgo v0.0.0-20240803155237-c586cd47cbb5 h1:UgZR7wNGPl+kp54v6LktkRgThPFcrzIX3+Slpx5dy5E= -github.com/beeper/slackgo v0.0.0-20240803155237-c586cd47cbb5/go.mod h1:K+6JA6FP9/mILahVr6VH67l83p0sWkayPiDOBhzKWlo= +github.com/beeper/slackgo v0.0.0-20240803190730-362662f1319d h1:8wvWykAoc2uJX9MmYueb/vCSUcg3KYX3QvMknUed8xE= +github.com/beeper/slackgo v0.0.0-20240803190730-362662f1319d/go.mod h1:K+6JA6FP9/mILahVr6VH67l83p0sWkayPiDOBhzKWlo= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -71,5 +71,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= -maunium.net/go/mautrix v0.19.1-0.20240803150944-b71b32d0d6d6 h1:ZXMvdnZ/oZk4kFACRaApV4BpbUqueJCgalHZXHKpu0I= -maunium.net/go/mautrix v0.19.1-0.20240803150944-b71b32d0d6d6/go.mod h1:ZWyxoQxRTBxzWIMs0kQCVogZIY0clTu33h102veCT/Q= +maunium.net/go/mautrix v0.19.1-0.20240803190639-956c13761ebb h1:Vk9NX4DjDXbBjxw/A9DGKaCsI1VcQbATl2XY0LQC/gw= +maunium.net/go/mautrix v0.19.1-0.20240803190639-956c13761ebb/go.mod h1:ZWyxoQxRTBxzWIMs0kQCVogZIY0clTu33h102veCT/Q= diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index 3932377..bb6cbe4 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -35,7 +35,7 @@ var _ bridgev2.BackfillingNetworkAPI = (*SlackClient)(nil) func (s *SlackClient) FetchMessages(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) { if s.Client == nil { - return nil, fmt.Errorf("not logged in") + return nil, bridgev2.ErrNotLoggedIn } _, channelID := slackid.ParsePortalID(params.Portal.ID) if channelID == "" { diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index b89dedf..fd4dbc3 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -355,8 +355,7 @@ func (s *SlackClient) fetchUserInfo(ctx context.Context, userID string, lastUpda botInfo, err = s.Client.GetBotInfoContext(ctx, slack.GetBotInfoParameters{ Bot: userID, }) - } else { - //info, err = s.Client.GetUserInfoContext(ctx, userID) + } else if s.IsRealUser { var infos map[string]*slack.User infos, err = s.Client.GetUsersCacheContext(ctx, s.TeamID, slack.GetCachedUsersParameters{ CheckInteraction: true, @@ -372,6 +371,8 @@ func (s *SlackClient) fetchUserInfo(ctx context.Context, userID string, lastUpda return nil, nil } } + } else { + info, err = s.Client.GetUserInfoContext(ctx, userID) } if err != nil { return nil, fmt.Errorf("failed to get user info for %q: %w", userID, err) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index e4196bd..0329604 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -27,6 +27,7 @@ import ( "github.com/rs/zerolog" "github.com/slack-go/slack" + "github.com/slack-go/slack/socketmode" "maunium.net/go/mautrix/bridge/status" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" @@ -44,13 +45,15 @@ func init() { }) } -func makeSlackClient(log *zerolog.Logger, token, cookieToken string) *slack.Client { +func makeSlackClient(log *zerolog.Logger, token, cookieToken, appToken string) *slack.Client { options := []slack.Option{ slack.OptionLog(slackgoZerolog{Logger: log.With().Str("component", "slackgo").Logger()}), slack.OptionDebug(log.GetLevel() == zerolog.TraceLevel), } if cookieToken != "" { options = append(options, slack.OptionCookie("d", cookieToken)) + } else if appToken != "" { + options = append(options, slack.OptionAppLevelToken(appToken)) } return slack.New(token, options...) } @@ -62,18 +65,28 @@ func (s *SlackConnector) LoadUserLogin(ctx context.Context, login *bridgev2.User if meta.Token == "" { sc = &SlackClient{Main: s, UserLogin: login, UserID: userID, TeamID: teamID} } else { - client := makeSlackClient(&login.Log, meta.Token, meta.CookieToken) + client := makeSlackClient(&login.Log, meta.Token, meta.CookieToken, meta.AppToken) sc = &SlackClient{ - Main: s, - UserLogin: login, - Client: client, - RTM: client.NewRTM(), - UserID: userID, - TeamID: teamID, + Main: s, + UserLogin: login, + Client: client, + UserID: userID, + TeamID: teamID, + IsRealUser: strings.HasPrefix(meta.Token, "xoxs-") || strings.HasPrefix(meta.Token, "xoxc-"), chatInfoCache: make(map[string]chatInfoCacheEntry), lastReadCache: make(map[string]string), } + if sc.IsRealUser { + sc.RTM = client.NewRTM() + } else { + log := login.Log.With().Str("component", "slackgo socketmode").Logger() + sc.SocketMode = socketmode.New( + sc.Client, + socketmode.OptionLog(slackgoZerolog{Logger: log}), + socketmode.OptionDebug(log.GetLevel() == zerolog.TraceLevel), + ) + } } teamPortalKey := networkid.PortalKey{ID: slackid.MakeTeamPortalID(teamID)} var err error @@ -95,10 +108,14 @@ type SlackClient struct { UserLogin *bridgev2.UserLogin Client *slack.Client RTM *slack.RTM + SocketMode *socketmode.Client UserID string TeamID string BootResp *slack.ClientBootResponse TeamPortal *bridgev2.Portal + IsRealUser bool + + stopSocketMode context.CancelFunc chatInfoCache map[string]chatInfoCacheEntry chatInfoCacheLock sync.Mutex @@ -116,6 +133,21 @@ func (s *SlackClient) GetClient() *slack.Client { return s.Client } +func (s *SlackClient) handleBootError(ctx context.Context, err error) { + if err.Error() == "user_removed_from_team" || err.Error() == "invalid_auth" { + s.invalidateSession(ctx, status.BridgeState{ + StateEvent: status.StateBadCredentials, + Error: status.BridgeStateErrorCode(fmt.Sprintf("slack-%s", strings.ReplaceAll(err.Error(), "_", "-"))), + }) + } else { + s.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateUnknownError, + Error: "slack-unknown-fetch-error", + Message: fmt.Sprintf("Unknown error from Slack: %s", err.Error()), + }) + } +} + func (s *SlackClient) Connect(ctx context.Context) error { if s.Client == nil { s.UserLogin.BridgeState.Send(status.BridgeState{ @@ -124,21 +156,29 @@ func (s *SlackClient) Connect(ctx context.Context) error { }) return nil } - bootResp, err := s.Client.ClientBootContext(ctx) - if err != nil { - if err.Error() == "user_removed_from_team" || err.Error() == "invalid_auth" { - s.invalidateSession(ctx, status.BridgeState{ - StateEvent: status.StateBadCredentials, - Error: status.BridgeStateErrorCode(fmt.Sprintf("slack-%s", strings.ReplaceAll(err.Error(), "_", "-"))), - }) - } else { - s.UserLogin.BridgeState.Send(status.BridgeState{ - StateEvent: status.StateUnknownError, - Error: "slack-unknown-fetch-error", - Message: fmt.Sprintf("Unknown error from Slack: %s", err.Error()), - }) + var bootResp *slack.ClientBootResponse + if s.IsRealUser { + var err error + bootResp, err = s.Client.ClientBootContext(ctx) + if err != nil { + s.handleBootError(ctx, err) + return err + } + } else { + teamResp, err := s.Client.GetTeamInfoContext(ctx) + if err != nil { + s.handleBootError(ctx, err) + return fmt.Errorf("failed to fetch team info: %w", err) + } + userResp, err := s.Client.GetUserInfoContext(ctx, s.UserID) + if err != nil { + s.handleBootError(ctx, err) + return fmt.Errorf("failed to fetch user info: %w", err) + } + bootResp = &slack.ClientBootResponse{ + Self: *userResp, + Team: *teamResp, } - return err } return s.connect(ctx, bootResp) } @@ -149,13 +189,53 @@ func (s *SlackClient) connect(ctx context.Context, bootResp *slack.ClientBootRes if err != nil { return err } - go s.consumeEvents() - go s.RTM.ManageConnection() + if s.IsRealUser { + go s.consumeRTMEvents() + go s.RTM.ManageConnection() + } else { + go s.consumeSocketModeEvents() + go s.runSocketMode(ctx) + } go s.SyncEmojis(ctx) go s.SyncChannels(ctx) return nil } +func (s *SlackClient) consumeRTMEvents() { + for evt := range s.RTM.IncomingEvents { + s.HandleSlackEvent(evt.Data) + } +} + +func (s *SlackClient) consumeSocketModeEvents() { + for evt := range s.SocketMode.Events { + s.HandleSocketModeEvent(evt) + } +} + +func (s *SlackClient) runSocketMode(ctx context.Context) { + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + defer cancel() + s.stopSocketMode = cancel + log := zerolog.Ctx(ctx) + for ctx.Err() == nil { + err := s.SocketMode.RunContext(ctx) + if err != nil { + log.Err(err).Msg("Error in socket mode connection") + s.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateTransientDisconnect, + Error: "slack-socketmode-error", + Message: err.Error(), + }) + time.Sleep(10 * time.Second) + } else { + log.Info().Msg("Socket disconnected without error") + return + } + } +} + func (s *SlackClient) syncTeamPortal(ctx context.Context) error { info := s.getTeamInfo() if s.TeamPortal.MXID == "" { @@ -181,7 +261,10 @@ func (s *SlackClient) getLastReadCache(channelID string) string { return s.lastReadCache[channelID] } -func (s *SlackClient) SyncChannels(ctx context.Context) { +func (s *SlackClient) getLatestMessageIDs(ctx context.Context) map[string]string { + if !s.IsRealUser { + return nil + } log := zerolog.Ctx(ctx) clientCounts, err := s.Client.ClientCountsContext(ctx, &slack.ClientCountsParams{ ThreadCountsByChannel: true, @@ -190,7 +273,7 @@ func (s *SlackClient) SyncChannels(ctx context.Context) { }) if err != nil { log.Err(err).Msg("Failed to fetch client counts") - return + return nil } latestMessageIDs := make(map[string]string, len(clientCounts.Channels)+len(clientCounts.MpIMs)+len(clientCounts.IMs)) lastReadCache := make(map[string]string, len(clientCounts.Channels)+len(clientCounts.MpIMs)+len(clientCounts.IMs)) @@ -209,6 +292,12 @@ func (s *SlackClient) SyncChannels(ctx context.Context) { s.lastReadCacheLock.Lock() s.lastReadCache = lastReadCache s.lastReadCacheLock.Unlock() + return latestMessageIDs +} + +func (s *SlackClient) SyncChannels(ctx context.Context) { + log := zerolog.Ctx(ctx) + latestMessageIDs := s.getLatestMessageIDs(ctx) userPortals, err := s.UserLogin.Bridge.DB.UserPortal.GetAllForLogin(ctx, s.UserLogin.UserLogin) if err != nil { log.Err(err).Msg("Failed to fetch user portals") @@ -220,7 +309,7 @@ func (s *SlackClient) SyncChannels(ctx context.Context) { } var channels []*slack.Channel token := s.UserLogin.Metadata.(*slackid.UserLoginMetadata).Token - if strings.HasPrefix(token, "xoxs") || s.Main.Config.Backfill.ConversationCount == -1 { + if s.IsRealUser && (strings.HasPrefix(token, "xoxs-") || s.Main.Config.Backfill.ConversationCount == -1) { for _, ch := range s.BootResp.Channels { ch.IsMember = true channels = append(channels, &ch.Channel) @@ -232,6 +321,9 @@ func (s *SlackClient) SyncChannels(ctx context.Context) { log.Debug().Int("channel_count", len(channels)).Msg("Using channels from boot response for sync") } else { totalLimit := s.Main.Config.Backfill.ConversationCount + if totalLimit < 0 { + totalLimit = 50 + } var cursor string log.Debug().Int("total_limit", totalLimit).Msg("Fetching conversation list for sync") for totalLimit > 0 { @@ -259,18 +351,39 @@ func (s *SlackClient) SyncChannels(ctx context.Context) { cursor = nextCursor } } - slices.SortFunc(channels, func(a, b *slack.Channel) int { - return cmp.Compare(latestMessageIDs[a.ID], latestMessageIDs[b.ID]) - }) + if latestMessageIDs != nil { + slices.SortFunc(channels, func(a, b *slack.Channel) int { + return cmp.Compare(latestMessageIDs[a.ID], latestMessageIDs[b.ID]) + }) + } for _, ch := range channels { portalKey := s.makePortalKey(ch) delete(existingPortals, portalKey) - latestMessageID, hasCounts := latestMessageIDs[ch.ID] + var latestMessageID string + var hasCounts bool + if !s.IsRealUser { + ch, err = s.Client.GetConversationInfoContext(ctx, &slack.GetConversationInfoInput{ + ChannelID: ch.ID, + IncludeLocale: true, + IncludeNumMembers: true, + }) + if err != nil { + log.Err(err).Str("channel_id", ch.ID).Msg("Failed to fetch channel info") + continue + } + hasCounts = ch.Latest != nil + if hasCounts { + latestMessageID = ch.Latest.Timestamp + } + } else { + latestMessageID, hasCounts = latestMessageIDs[ch.ID] + } + // TODO fetch latest message from channel info when using bot account? s.Main.br.QueueRemoteEvent(s.UserLogin, &SlackChatResync{ SlackEventMeta: &SlackEventMeta{ Type: bridgev2.RemoteEventChatResync, PortalKey: portalKey, - CreatePortal: hasCounts || !(ch.IsIM || ch.IsMpIM), + CreatePortal: hasCounts || (!ch.IsIM && !ch.IsMpIM), LogContext: func(c zerolog.Context) zerolog.Context { return c. Object("portal_key", portalKey). @@ -303,17 +416,18 @@ func (s *SlackClient) SyncChannels(ctx context.Context) { } } -func (s *SlackClient) consumeEvents() { - for evt := range s.RTM.IncomingEvents { - s.HandleSlackEvent(evt.Data) - } -} - func (s *SlackClient) Disconnect() { s.disconnectRTM() + s.disconnectSocketMode() s.Client = nil } +func (s *SlackClient) disconnectSocketMode() { + if stop := s.stopSocketMode; stop != nil { + stop() + } +} + func (s *SlackClient) disconnectRTM() { if rtm := s.RTM; rtm != nil { err := rtm.Disconnect() diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 0ba4ba3..053d515 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -45,18 +45,27 @@ var ( ) func (s *SlackClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) { + if s.Client == nil { + return nil, bridgev2.ErrNotLoggedIn + } _, channelID := slackid.ParsePortalID(msg.Portal.ID) if channelID == "" { return nil, errors.New("invalid channel ID") } - conv, err := s.Main.MsgConv.ToSlack(ctx, s.Client, msg.Portal, msg.Content, msg.Event, msg.ThreadRoot, nil, msg.OrigSender) + conv, err := s.Main.MsgConv.ToSlack(ctx, s.Client, msg.Portal, msg.Content, msg.Event, msg.ThreadRoot, nil, msg.OrigSender, s.IsRealUser) if err != nil { return nil, err } - timestamp, err := s.sendToSlack(ctx, channelID, conv) + timestamp, fileID, err := s.sendToSlack(ctx, channelID, conv) if err != nil { return nil, err } + if timestamp == "" { + return &bridgev2.MatrixMessageResponse{ + DB: &database.Message{}, + Pending: networkid.TransactionID(fmt.Sprintf("%s:%s", s.UserID, fileID)), + }, nil + } return &bridgev2.MatrixMessageResponse{ DB: &database.Message{ ID: slackid.MakeMessageID(s.TeamID, channelID, timestamp), @@ -66,18 +75,18 @@ func (s *SlackClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat }, nil } -func (s *SlackClient) sendToSlack(ctx context.Context, channelID string, conv *msgconv.ConvertedSlackMessage) (string, error) { +func (s *SlackClient) sendToSlack(ctx context.Context, channelID string, conv *msgconv.ConvertedSlackMessage) (string, string, error) { log := zerolog.Ctx(ctx) if conv.SendReq != nil { log.Debug().Msg("Sending message to Slack") _, timestamp, err := s.Client.PostMessageContext(ctx, channelID, conv.SendReq) - return timestamp, err + return timestamp, "", err } else if conv.FileUpload != nil { log.Debug().Msg("Uploading attachment to Slack") - file, err := s.Client.UploadFileContext(ctx, *conv.FileUpload) + file, err := s.Client.UploadFileV2Context(ctx, *conv.FileUpload) if err != nil { log.Err(err).Msg("Failed to upload attachment to Slack") - return "", err + return "", "", err } var shareInfo slack.ShareFileInfo // Slack puts the channel message info after uploading a file in either file.shares.private or file.shares.public @@ -85,37 +94,41 @@ func (s *SlackClient) sendToSlack(ctx context.Context, channelID string, conv *m shareInfo = info[0] } else if info, found = file.Shares.Public[channelID]; found && len(info) > 0 { shareInfo = info[0] - } else { - return "", errors.New("failed to upload media to Slack") } - return shareInfo.Ts, nil + return shareInfo.Ts, file.ID, nil } else if conv.FileShare != nil { log.Debug().Msg("Sharing already uploaded attachment to Slack") resp, err := s.Client.ShareFile(ctx, *conv.FileShare) if err != nil { log.Err(err).Msg("Failed to share attachment to Slack") - return "", err + return "", "", err } - return resp.FileMsgTS, nil + return resp.FileMsgTS, "", nil } else { - return "", errors.New("no message or attachment to send") + return "", "", errors.New("no message or attachment to send") } } func (s *SlackClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error { + if s.Client == nil { + return bridgev2.ErrNotLoggedIn + } _, channelID := slackid.ParsePortalID(msg.Portal.ID) if channelID == "" { return errors.New("invalid channel ID") } - conv, err := s.Main.MsgConv.ToSlack(ctx, s.Client, msg.Portal, msg.Content, msg.Event, nil, msg.EditTarget, msg.OrigSender) + conv, err := s.Main.MsgConv.ToSlack(ctx, s.Client, msg.Portal, msg.Content, msg.Event, nil, msg.EditTarget, msg.OrigSender, s.IsRealUser) if err != nil { return err } - _, err = s.sendToSlack(ctx, channelID, conv) + _, _, err = s.sendToSlack(ctx, channelID, conv) return err } func (s *SlackClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error { + if s.Client == nil { + return bridgev2.ErrNotLoggedIn + } _, channelID, messageID, ok := slackid.ParseMessageID(msg.TargetMessage.ID) if !ok { return errors.New("invalid message ID") @@ -150,6 +163,9 @@ func (s *SlackClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2 } func (s *SlackClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (reaction *database.Reaction, err error) { + if s.Client == nil { + return nil, bridgev2.ErrNotLoggedIn + } _, channelID, messageID, ok := slackid.ParseMessageID(msg.TargetMessage.ID) if !ok { return nil, errors.New("invalid message ID") @@ -162,6 +178,9 @@ func (s *SlackClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.Ma } func (s *SlackClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error { + if s.Client == nil { + return bridgev2.ErrNotLoggedIn + } _, channelID, messageID, ok := slackid.ParseMessageID(msg.TargetReaction.MessageID) if !ok { return errors.New("invalid message ID") @@ -177,6 +196,11 @@ func (s *SlackClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridg } func (s *SlackClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error { + if s.Client == nil { + return bridgev2.ErrNotLoggedIn + } else if !s.IsRealUser { + return nil + } if msg.ExactMessage != nil { _, channelID, messageTS, ok := slackid.ParseMessageID(msg.ExactMessage.ID) if !ok { @@ -198,6 +222,11 @@ func (s *SlackClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2 } func (s *SlackClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error { + if s.Client == nil { + return bridgev2.ErrNotLoggedIn + } else if !s.IsRealUser { + return nil + } _, channelID := slackid.ParsePortalID(msg.Portal.ID) if channelID == "" { return nil diff --git a/pkg/connector/handleslack.go b/pkg/connector/handleslack.go index 226a264..2c0c959 100644 --- a/pkg/connector/handleslack.go +++ b/pkg/connector/handleslack.go @@ -23,6 +23,8 @@ import ( "github.com/rs/zerolog" "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" "maunium.net/go/mautrix/bridge/status" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" @@ -47,8 +49,8 @@ func (s *SlackClient) HandleSlackEvent(rawEvt any) { s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) case *slack.ConnectedEvent: s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) - //case *slack.DisconnectedEvent: - // TODO handle? + case *slack.DisconnectedEvent: + s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "slack-rtm-disconnected"}) case *slack.HelloEvent: // Ignored for now case *slack.InvalidAuthEvent: @@ -93,6 +95,33 @@ func (s *SlackClient) HandleSlackEvent(rawEvt any) { } } +func (s *SlackClient) HandleSocketModeEvent(evt socketmode.Event) { + switch evt.Type { + case socketmode.EventTypeConnecting: + s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) + case socketmode.EventTypeConnectionError: + s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "slack-socketmode-connection-error"}) + case socketmode.EventTypeConnected: + s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) + case socketmode.EventTypeEventsAPI: + eaEvt, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + s.UserLogin.Log.Warn().Type("data_type", evt.Data).Msg("Unexpected event type in socket mode") + return + } + if eaEvt.Type == slackevents.CallbackEvent { + s.HandleSlackEvent(eaEvt.InnerEvent.Data) + } + case socketmode.EventTypeInteractive: + //callback, ok := evt.Data.(slack.InteractionCallback) + case socketmode.EventTypeSlashCommand: + //cmd, ok := evt.Data.(slack.SlashCommand) + } + if evt.Request != nil && evt.Request.EnvelopeID != "" { + s.SocketMode.Ack(*evt.Request) + } +} + func (s *SlackClient) handleUserChange(ctx context.Context, user *slack.User) { ghost, err := s.Main.br.GetGhostByID(ctx, slackid.MakeUserID(s.TeamID, user.ID)) if err != nil { @@ -191,10 +220,10 @@ func (s *SlackClient) wrapEvent(ctx context.Context, rawEvt any) (bridgev2.Remot meta, metaErr = s.makeEventMeta(ctx, evt.Channel, nil, s.UserID, evt.Timestamp) wrapped = wrapMemberChange(&meta, meta.Sender, event.MembershipLeave, event.MembershipJoin) case *slack.MemberJoinedChannelEvent: - meta, metaErr = s.makeEventMeta(ctx, evt.Channel, nil, evt.User, "") + meta, metaErr = s.makeEventMeta(ctx, evt.Channel, nil, evt.User, evt.EventTimestamp) wrapped = wrapMemberChange(&meta, meta.Sender, event.MembershipJoin, "") case *slack.MemberLeftChannelEvent: - meta, metaErr = s.makeEventMeta(ctx, evt.Channel, nil, evt.User, "") + meta, metaErr = s.makeEventMeta(ctx, evt.Channel, nil, evt.User, evt.EventTimestamp) wrapped = wrapMemberChange(&meta, meta.Sender, event.MembershipLeave, event.MembershipJoin) case *slack.ChannelUpdateEvent: @@ -431,11 +460,19 @@ type SlackMessage struct { Client *SlackClient } +func (s *SlackMessage) GetTransactionID() networkid.TransactionID { + if len(s.Data.Files) != 1 { + return "" + } + return networkid.TransactionID(fmt.Sprintf("%s:%s", s.Data.User, s.Data.Files[0].ID)) +} + var ( - _ bridgev2.RemoteMessage = (*SlackMessage)(nil) - _ bridgev2.RemoteEdit = (*SlackMessage)(nil) - _ bridgev2.RemoteMessageRemove = (*SlackMessage)(nil) - _ bridgev2.RemoteChatResync = (*SlackMessage)(nil) + _ bridgev2.RemoteMessage = (*SlackMessage)(nil) + _ bridgev2.RemoteEdit = (*SlackMessage)(nil) + _ bridgev2.RemoteMessageRemove = (*SlackMessage)(nil) + _ bridgev2.RemoteChatResync = (*SlackMessage)(nil) + _ bridgev2.RemoteMessageWithTransactionID = (*SlackMessage)(nil) ) type SlackChatResync struct { diff --git a/pkg/connector/login-app.go b/pkg/connector/login-app.go new file mode 100644 index 0000000..c326d3b --- /dev/null +++ b/pkg/connector/login-app.go @@ -0,0 +1,97 @@ +// mautrix-slack - A Matrix-Slack puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + "fmt" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + + "go.mau.fi/mautrix-slack/pkg/slackid" +) + +const LoginFlowIDApp = "app" +const LoginStepIDAppToken = "fi.mau.slack.login.enter_app_tokens" + +type SlackAppLogin struct { + User *bridgev2.User +} + +var _ bridgev2.LoginProcessUserInput = (*SlackAppLogin)(nil) + +func (s *SlackAppLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: LoginStepIDAppToken, + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{{ + Type: bridgev2.LoginInputFieldTypeToken, + ID: "bot_token", + Name: "Bot token", + Description: "Slack bot token for the workspace (starts with `xoxb-`)", + Pattern: "^xoxb-.+$", + }, { + Type: bridgev2.LoginInputFieldTypeToken, + ID: "app_token", + Name: "App token", + Description: "Slack app-level token (starts with `xapp-`)", + Pattern: "^xapp-.+$", + }}, + }, + }, nil +} + +func (s *SlackAppLogin) Cancel() {} + +func (s *SlackAppLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + token, appToken := input["bot_token"], input["app_token"] + client := makeSlackClient(&s.User.Log, token, "", appToken) + info, err := client.AuthTestContext(ctx) + if err != nil { + return nil, fmt.Errorf("auth.test failed: %w", err) + } + ul, err := s.User.NewLogin(ctx, &database.UserLogin{ + ID: slackid.MakeUserLoginID(info.TeamID, info.UserID), + RemoteName: fmt.Sprintf("%s - %s", info.Team, info.User), + Metadata: &slackid.UserLoginMetadata{ + Token: token, + AppToken: appToken, + }, + }, &bridgev2.NewLoginParams{ + DeleteOnConflict: true, + DontReuseExisting: false, + }) + if err != nil { + return nil, err + } + sc := ul.Client.(*SlackClient) + err = sc.Connect(ul.Log.WithContext(context.Background())) + if err != nil { + return nil, fmt.Errorf("failed to connect after login: %w", err) + } + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeComplete, + StepID: LoginStepIDComplete, + Instructions: fmt.Sprintf("Successfully logged into %s as %s", info.Team, info.User), + CompleteParams: &bridgev2.LoginCompleteParams{ + UserLoginID: ul.ID, + UserLogin: ul, + }, + }, nil +} diff --git a/pkg/connector/login.go b/pkg/connector/login-cookie.go similarity index 93% rename from pkg/connector/login.go rename to pkg/connector/login-cookie.go index e8d93da..180fa30 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login-cookie.go @@ -35,16 +35,26 @@ func (s *SlackConnector) GetLoginFlows() []bridgev2.LoginFlow { Name: "Auth token & cookie", Description: "Log in with an auth token (and a cookie, if the token is from a browser)", ID: LoginFlowIDAuthToken, + }, { + Name: "Slack app", + Description: "Log in with a Slack app", + ID: LoginFlowIDApp, }} } func (s *SlackConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { - if flowID != LoginFlowIDAuthToken { + switch flowID { + case LoginFlowIDAuthToken: + return &SlackTokenLogin{ + User: user, + }, nil + case LoginFlowIDApp: + return &SlackAppLogin{ + User: user, + }, nil + default: return nil, fmt.Errorf("unknown login flow %s", flowID) } - return &SlackTokenLogin{ - User: user, - }, nil } type SlackTokenLogin struct { @@ -114,7 +124,7 @@ func (s *SlackTokenLogin) Cancel() {} func (s *SlackTokenLogin) SubmitCookies(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { token, cookieToken := input["auth_token"], input["cookie_token"] - client := makeSlackClient(&s.User.Log, token, cookieToken) + client := makeSlackClient(&s.User.Log, token, cookieToken, "") info, err := client.ClientBootContext(ctx) if err != nil { return nil, fmt.Errorf("client.boot failed: %w", err) diff --git a/pkg/connector/startchat.go b/pkg/connector/startchat.go index d52f0f0..539af52 100644 --- a/pkg/connector/startchat.go +++ b/pkg/connector/startchat.go @@ -36,7 +36,7 @@ var ( func (s *SlackClient) ResolveIdentifier(ctx context.Context, identifier string, createChat bool) (*bridgev2.ResolveIdentifierResponse, error) { if s.Client == nil { - return nil, fmt.Errorf("not logged in") + return nil, bridgev2.ErrNotLoggedIn } var userInfo *slack.User var err error @@ -89,7 +89,7 @@ func (s *SlackClient) ResolveIdentifier(ctx context.Context, identifier string, func (s *SlackClient) CreateGroup(ctx context.Context, name string, users ...networkid.UserID) (*bridgev2.CreateChatResponse, error) { if s.Client == nil { - return nil, fmt.Errorf("not logged in") + return nil, bridgev2.ErrNotLoggedIn } plainUsers := make([]string, len(users)) for i, user := range users { @@ -135,7 +135,7 @@ func (s *SlackClient) CreateGroup(ctx context.Context, name string, users ...net func (s *SlackClient) SearchUsers(ctx context.Context, query string) ([]*bridgev2.ResolveIdentifierResponse, error) { if s.Client == nil { - return nil, fmt.Errorf("not logged in") + return nil, bridgev2.ErrNotLoggedIn } resp, err := s.Client.SearchUsersCacheContext(ctx, s.TeamID, query) if err != nil { diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go index 91c225b..6c2f8e2 100644 --- a/pkg/msgconv/from-matrix.go +++ b/pkg/msgconv/from-matrix.go @@ -48,7 +48,7 @@ func isMediaMsgtype(msgType event.MessageType) bool { type ConvertedSlackMessage struct { SendReq slack.MsgOption - FileUpload *slack.FileUploadParameters + FileUpload *slack.UploadFileV2Parameters FileShare *slack.ShareFileParams } @@ -61,6 +61,7 @@ func (mc *MessageConverter) ToSlack( threadRoot *database.Message, editTarget *database.Message, origSender *bridgev2.OrigSender, + isRealUser bool, ) (conv *ConvertedSlackMessage, err error) { log := zerolog.Ctx(ctx) @@ -139,7 +140,6 @@ func (mc *MessageConverter) ToSlack( caption = content.Body captionHTML = content.FormattedBody } - useFileUpload := false if content.MSC3245Voice != nil && ffmpeg.Supported() { data, err = ffmpeg.ConvertBytes(ctx, data, ".webm", []string{}, []string{"-c:a", "copy"}, content.Info.MimeType) if err != nil { @@ -151,12 +151,12 @@ func (mc *MessageConverter) ToSlack( subtype = "slack_audio" } _, channelID := slackid.ParsePortalID(portal.ID) - if useFileUpload { - fileUpload := &slack.FileUploadParameters{ + if !isRealUser { + fileUpload := &slack.UploadFileV2Parameters{ Filename: filename, - Filetype: content.Info.MimeType, Reader: bytes.NewReader(data), - Channels: []string{channelID}, + FileSize: len(data), + Channel: channelID, ThreadTimestamp: threadRootID, } if caption != "" { diff --git a/pkg/slackid/dbmeta.go b/pkg/slackid/dbmeta.go index 85b5292..86f8ac3 100644 --- a/pkg/slackid/dbmeta.go +++ b/pkg/slackid/dbmeta.go @@ -34,6 +34,7 @@ type UserLoginMetadata struct { Email string `json:"email"` Token string `json:"token"` CookieToken string `json:"cookie_token,omitempty"` + AppToken string `json:"app_token,omitempty"` } type MessageMetadata struct {