Files
Memoh/internal/channel/adapters/slack/slack.go
T
Fodesu 8e013ad1ad feat(platform): add slack platform support (#385)
* feat(platform): add slack platform support

* docs: add slack channel setup guide

* feat: normalize slack unicode reactions

* chore(docs): remove unsupport feature

* fix(slack): harden adapter stream and identity handling

- ignore reaction and speech stream events in Slack outbound
  streams
  - normalize Slack conversation types to framework-standard values
  - route DiscoverSelf through the adapter API factory
  - add config-scoped Slack user display-name caching
  - expand adapter interface assertions and add regression coverage
  - add ChannelTypeSlack to well-known channel constants
2026-04-19 14:17:05 +08:00

1212 lines
32 KiB
Go

package slack
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/slack-go/slack/socketmode"
"github.com/memohai/memoh/internal/channel"
"github.com/memohai/memoh/internal/channel/common"
"github.com/memohai/memoh/internal/media"
)
const (
inboundDedupTTL = time.Minute
slackMaxLength = 40000
channelNameTTL = 5 * time.Minute
)
// assetOpener reads stored asset bytes by content hash.
type assetOpener interface {
Open(ctx context.Context, botID, contentHash string) (io.ReadCloser, media.Asset, error)
}
type slackConnection struct {
api *slack.Client
sm *socketmode.Client
cancel context.CancelFunc
}
type cachedSlackChannelName struct {
name string
chatType string
cachedAt time.Time
}
type cachedSlackUserName struct {
displayName string
cachedAt time.Time
}
type SlackAdapter struct {
logger *slog.Logger
mu sync.RWMutex
connections map[string]*slackConnection // keyed by config ID
seenMessages map[string]time.Time // keyed by configID:messageTS
channelNames map[string]cachedSlackChannelName // keyed by configID:channelID
userNames map[string]cachedSlackUserName // keyed by configID:userID
assets assetOpener
apiFactory func(Config, ...slack.Option) *slack.Client
authTest func(*slack.Client) (*slack.AuthTestResponse, error)
openConversation func(context.Context, *slack.Client, *slack.OpenConversationParameters) (*slack.Channel, bool, bool, error)
socketOpen func(Config) (*slack.Client, *socketmode.Client)
socketRun func(context.Context, *socketmode.Client) error
historyFetch func(context.Context, *slack.Client, *slack.GetConversationHistoryParameters) (*slack.GetConversationHistoryResponse, error)
}
var (
_ channel.Sender = (*SlackAdapter)(nil)
_ channel.StreamSender = (*SlackAdapter)(nil)
_ channel.Reactor = (*SlackAdapter)(nil)
_ channel.Receiver = (*SlackAdapter)(nil)
_ channel.AttachmentResolver = (*SlackAdapter)(nil)
_ channel.SelfDiscoverer = (*SlackAdapter)(nil)
_ channel.ConfigNormalizer = (*SlackAdapter)(nil)
_ channel.TargetResolver = (*SlackAdapter)(nil)
_ channel.BindingMatcher = (*SlackAdapter)(nil)
_ channel.ProcessingStatusNotifier = (*SlackAdapter)(nil)
)
func NewSlackAdapter(log *slog.Logger) *SlackAdapter {
if log == nil {
log = slog.Default()
}
return &SlackAdapter{
logger: log.With(slog.String("adapter", "slack")),
connections: make(map[string]*slackConnection),
seenMessages: make(map[string]time.Time),
channelNames: make(map[string]cachedSlackChannelName),
userNames: make(map[string]cachedSlackUserName),
apiFactory: func(cfg Config, options ...slack.Option) *slack.Client {
opts := []slack.Option{
slack.OptionRetry(3),
}
opts = append(opts, options...)
return slack.New(cfg.BotToken, opts...)
},
authTest: func(api *slack.Client) (*slack.AuthTestResponse, error) {
return api.AuthTest()
},
openConversation: func(ctx context.Context, api *slack.Client, params *slack.OpenConversationParameters) (*slack.Channel, bool, bool, error) {
return api.OpenConversationContext(ctx, params)
},
socketOpen: newSocketModeClient,
socketRun: func(ctx context.Context, sm *socketmode.Client) error {
return sm.RunContext(ctx)
},
historyFetch: func(ctx context.Context, api *slack.Client, params *slack.GetConversationHistoryParameters) (*slack.GetConversationHistoryResponse, error) {
return api.GetConversationHistoryContext(ctx, params)
},
}
}
// SetAssetOpener configures the asset opener for reading stored attachments by content hash.
func (a *SlackAdapter) SetAssetOpener(opener assetOpener) {
a.mu.Lock()
defer a.mu.Unlock()
a.assets = opener
}
func (*SlackAdapter) Type() channel.ChannelType {
return Type
}
func (*SlackAdapter) Descriptor() channel.Descriptor {
return channel.Descriptor{
Type: Type,
DisplayName: "Slack",
Capabilities: channel.ChannelCapabilities{
Text: true,
Markdown: true,
Reply: true,
Attachments: true,
Media: true,
Streaming: true,
BlockStreaming: true,
Reactions: true,
Threads: true,
},
ConfigSchema: channel.ConfigSchema{
Version: 1,
Fields: map[string]channel.FieldSchema{
"botToken": {
Type: channel.FieldSecret,
Required: true,
Title: "Bot Token",
Description: "Slack Bot User OAuth Token (xoxb-...)",
},
"appToken": {
Type: channel.FieldSecret,
Required: true,
Title: "App-Level Token",
Description: "Slack App-Level Token for Socket Mode (xapp-...)",
},
},
},
UserConfigSchema: channel.ConfigSchema{
Version: 1,
Fields: map[string]channel.FieldSchema{
"user_id": {Type: channel.FieldString},
"channel_id": {Type: channel.FieldString},
"username": {Type: channel.FieldString},
},
},
TargetSpec: channel.TargetSpec{
Format: "channel_id | user_id",
Hints: []channel.TargetHint{
{Label: "Channel ID", Example: "C0123456789"},
{Label: "User ID", Example: "U0123456789"},
},
},
}
}
func (a *SlackAdapter) newAPIClient(cfg Config, options ...slack.Option) *slack.Client {
if a != nil && a.apiFactory != nil {
return a.apiFactory(cfg, options...)
}
opts := []slack.Option{
slack.OptionRetry(3),
}
opts = append(opts, options...)
return slack.New(cfg.BotToken, opts...)
}
func newSocketModeClient(cfg Config) (*slack.Client, *socketmode.Client) {
api := slack.New(
cfg.BotToken,
slack.OptionRetry(3),
slack.OptionAppLevelToken(cfg.AppToken),
)
return api, socketmode.New(api)
}
func (a *SlackAdapter) getOrCreateConnection(channelCfg channel.ChannelConfig, cfg Config) (*slackConnection, error) {
a.mu.RLock()
conn, ok := a.connections[channelCfg.ID]
a.mu.RUnlock()
if ok {
return conn, nil
}
a.mu.Lock()
defer a.mu.Unlock()
if c, ok := a.connections[channelCfg.ID]; ok {
return c, nil
}
socketOpen := a.socketOpen
if socketOpen == nil {
socketOpen = newSocketModeClient
}
api, sm := socketOpen(cfg)
conn = &slackConnection{
api: api,
sm: sm,
}
a.connections[channelCfg.ID] = conn
return conn, nil
}
func (a *SlackAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig, handler channel.InboundHandler) (channel.Connection, error) {
if a.logger != nil {
a.logger.Info("start", slog.String("config_id", cfg.ID))
}
slackCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return nil, err
}
conn, err := a.getOrCreateConnection(cfg, slackCfg)
if err != nil {
return nil, err
}
// Discover self identity for filtering bot's own messages
authTest := a.authTest
if authTest == nil {
authTest = func(api *slack.Client) (*slack.AuthTestResponse, error) {
return api.AuthTest()
}
}
authResp, err := authTest(conn.api)
if err != nil {
a.clearConnection(cfg.ID)
return nil, fmt.Errorf("slack auth test: %w", err)
}
selfUserID := authResp.UserID
smCtx, cancel := context.WithCancel(ctx)
conn.cancel = cancel
connectedCh := make(chan struct{})
startErrCh := make(chan error, 1)
var startupOnce sync.Once
signalConnected := func() {
startupOnce.Do(func() {
close(connectedCh)
})
}
signalStartupError := func(err error) {
if err == nil {
err = errors.New("slack socket mode startup failed")
}
select {
case startErrCh <- err:
default:
}
}
go func() {
for {
select {
case <-smCtx.Done():
return
case evt, ok := <-conn.sm.Events:
if !ok {
return
}
switch evt.Type {
case socketmode.EventTypeConnected:
signalConnected()
case socketmode.EventTypeInvalidAuth:
signalStartupError(errors.New("slack socket mode invalid auth"))
case socketmode.EventTypeConnectionError:
if connErr, ok := evt.Data.(*slack.ConnectionErrorEvent); ok && connErr != nil && connErr.ErrorObj != nil {
signalStartupError(fmt.Errorf("slack socket mode connect: %w", connErr.ErrorObj))
} else {
signalStartupError(errors.New("slack socket mode connect failed"))
}
}
a.handleSocketModeEvent(smCtx, conn, evt, cfg, handler, selfUserID)
}
}
}()
go func() {
socketRun := a.socketRun
if socketRun == nil {
socketRun = func(ctx context.Context, sm *socketmode.Client) error {
return sm.RunContext(ctx)
}
}
if err := socketRun(smCtx, conn.sm); err != nil {
if !errors.Is(err, context.Canceled) {
signalStartupError(fmt.Errorf("slack socket mode run: %w", err))
}
if a.logger != nil && !errors.Is(err, context.Canceled) {
a.logger.Error("socket mode run error", slog.String("config_id", cfg.ID), slog.Any("error", err))
}
}
}()
select {
case <-connectedCh:
case err := <-startErrCh:
cancel()
a.clearConnection(cfg.ID)
return nil, err
case <-ctx.Done():
cancel()
a.clearConnection(cfg.ID)
return nil, ctx.Err()
}
stop := func(_ context.Context) error {
if a.logger != nil {
a.logger.Info("stop", slog.String("config_id", cfg.ID))
}
cancel()
a.clearConnection(cfg.ID)
return nil
}
return channel.NewConnection(cfg, stop), nil
}
func (a *SlackAdapter) handleSocketModeEvent(
ctx context.Context,
conn *slackConnection,
evt socketmode.Event,
cfg channel.ChannelConfig,
handler channel.InboundHandler,
selfUserID string,
) {
switch evt.Type {
case socketmode.EventTypeEventsAPI:
eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
if !ok {
return
}
if evt.Request != nil {
conn.sm.Ack(*evt.Request)
}
if eventsAPIEvent.Type != slackevents.CallbackEvent {
return
}
switch ev := eventsAPIEvent.InnerEvent.Data.(type) {
case *slackevents.MessageEvent:
a.handleMessageEvent(ctx, conn, ev, cfg, handler, selfUserID)
case *slackevents.AppMentionEvent:
a.handleAppMentionEvent(ctx, conn, ev, cfg, handler)
}
case socketmode.EventTypeConnecting:
if a.logger != nil {
a.logger.Info("connecting to Slack Socket Mode", slog.String("config_id", cfg.ID))
}
case socketmode.EventTypeConnected:
if a.logger != nil {
a.logger.Info("connected to Slack Socket Mode", slog.String("config_id", cfg.ID))
}
case socketmode.EventTypeConnectionError:
if a.logger != nil {
a.logger.Error("Slack Socket Mode connection error", slog.String("config_id", cfg.ID))
}
case socketmode.EventTypeInteractive:
if evt.Request != nil {
conn.sm.Ack(*evt.Request)
}
case socketmode.EventTypeSlashCommand:
if evt.Request != nil {
conn.sm.Ack(*evt.Request)
}
}
}
func (a *SlackAdapter) handleMessageEvent(
ctx context.Context,
conn *slackConnection,
ev *slackevents.MessageEvent,
cfg channel.ChannelConfig,
handler channel.InboundHandler,
selfUserID string,
) {
if ev.BotID != "" || ev.User == "" || ev.User == selfUserID {
return
}
// Skip message subtypes that aren't regular messages
if ev.SubType != "" && ev.SubType != "file_share" {
return
}
text := strings.TrimSpace(ev.Text)
attachments := a.collectAttachments(ev.Message)
if text == "" && len(attachments) == 0 {
return
}
if a.isDuplicateInbound(cfg.ID, ev.TimeStamp) {
return
}
chatType := channel.ConversationTypeGroup
switch ev.ChannelType {
case "im":
chatType = channel.ConversationTypePrivate
case "mpim":
chatType = channel.ConversationTypeGroup
case "group":
chatType = channel.ConversationTypeGroup
}
// Resolve user display name
displayName := a.resolveUserDisplayName(conn.api, cfg.ID, ev.User)
isMentioned := strings.Contains(ev.Text, "<@"+selfUserID+">")
threadID := ev.ThreadTimeStamp
if ev.Message != nil && strings.TrimSpace(ev.Message.ThreadTimestamp) != "" {
threadID = strings.TrimSpace(ev.Message.ThreadTimestamp)
}
conversationName, _ := a.lookupConversationInfo(ctx, conn.api, cfg.ID, ev.Channel)
msg := channel.InboundMessage{
Channel: Type,
Message: channel.Message{
ID: ev.TimeStamp,
Format: channel.MessageFormatPlain,
Text: text,
Attachments: attachments,
},
BotID: cfg.BotID,
ReplyTarget: ev.Channel,
Sender: channel.Identity{
SubjectID: ev.User,
DisplayName: displayName,
Attributes: slackIdentityAttributes(ev.User, "", ev.ChannelType, ev.Channel),
},
Conversation: channel.Conversation{
ID: ev.Channel,
Type: chatType,
Name: conversationName,
ThreadID: threadID,
},
ReceivedAt: time.Now().UTC(),
Source: "slack",
Metadata: map[string]any{
"channel_type": ev.ChannelType,
"channel_name": conversationName,
"is_mentioned": isMentioned,
"thread_ts": threadID,
"subtype": ev.SubType,
},
}
if a.logger != nil {
a.logger.Info("inbound received",
slog.String("config_id", cfg.ID),
slog.String("chat_type", chatType),
slog.String("user_id", ev.User),
slog.String("text", common.SummarizeText(text)),
)
}
go func() {
if err := handler(ctx, cfg, msg); err != nil && a.logger != nil {
a.logger.Error("handle inbound failed", slog.String("config_id", cfg.ID), slog.Any("error", err))
}
}()
}
func (a *SlackAdapter) handleAppMentionEvent(
ctx context.Context,
conn *slackConnection,
ev *slackevents.AppMentionEvent,
cfg channel.ChannelConfig,
handler channel.InboundHandler,
) {
if ev.BotID != "" || ev.User == "" {
return
}
text := strings.TrimSpace(ev.Text)
if text == "" {
return
}
attachments := a.fetchMessageAttachments(ctx, conn.api, ev.Channel, ev.TimeStamp)
if a.isDuplicateInbound(cfg.ID, ev.TimeStamp) {
return
}
displayName := a.resolveUserDisplayName(conn.api, cfg.ID, ev.User)
threadID := ev.ThreadTimeStamp
conversationName, conversationType := a.lookupConversationInfo(ctx, conn.api, cfg.ID, ev.Channel)
if conversationType == "" {
conversationType = channel.ConversationTypeGroup
}
msg := channel.InboundMessage{
Channel: Type,
Message: channel.Message{
ID: ev.TimeStamp,
Format: channel.MessageFormatPlain,
Text: text,
Attachments: attachments,
},
BotID: cfg.BotID,
ReplyTarget: ev.Channel,
Sender: channel.Identity{
SubjectID: ev.User,
DisplayName: displayName,
Attributes: map[string]string{
"user_id": ev.User,
},
},
Conversation: channel.Conversation{
ID: ev.Channel,
Type: conversationType,
Name: conversationName,
ThreadID: threadID,
},
ReceivedAt: time.Now().UTC(),
Source: "slack",
Metadata: map[string]any{
"channel_name": conversationName,
"is_mentioned": true,
"thread_ts": threadID,
},
}
if a.logger != nil {
a.logger.Info("app mention received",
slog.String("config_id", cfg.ID),
slog.String("user_id", ev.User),
slog.String("text", common.SummarizeText(text)),
)
}
go func() {
if err := handler(ctx, cfg, msg); err != nil && a.logger != nil {
a.logger.Error("handle inbound failed", slog.String("config_id", cfg.ID), slog.Any("error", err))
}
}()
}
func (a *SlackAdapter) Send(ctx context.Context, cfg channel.ChannelConfig, msg channel.PreparedOutboundMessage) error {
slackCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return err
}
api := a.newAPIClient(slackCfg)
target, err := a.resolveOutboundTarget(ctx, api, msg.Target)
if err != nil {
return err
}
return a.sendSlackMessage(ctx, api, target, msg)
}
func (a *SlackAdapter) sendSlackMessage(ctx context.Context, api *slack.Client, channelID string, msg channel.PreparedOutboundMessage) error {
text := truncateSlackText(msg.Message.Message.PlainText())
threadTS := ""
if msg.Message.Message.Reply != nil && msg.Message.Message.Reply.MessageID != "" {
threadTS = msg.Message.Message.Reply.MessageID
}
opts := []slack.MsgOption{
slack.MsgOptionText(text, false),
}
if threadTS != "" {
opts = append(opts, slack.MsgOptionTS(threadTS))
}
if len(msg.Message.Attachments) > 0 {
for _, att := range msg.Message.Attachments {
if err := a.uploadPreparedAttachment(ctx, api, channelID, threadTS, att); err != nil {
if a.logger != nil {
a.logger.Error("upload attachment failed", slog.Any("error", err))
}
return err
}
}
}
if text == "" && len(msg.Message.Attachments) > 0 {
return nil
}
if text == "" {
return errors.New("cannot send empty message")
}
_, _, err := api.PostMessageContext(ctx, channelID, opts...)
return err
}
func (*SlackAdapter) uploadPreparedAttachment(ctx context.Context, api *slack.Client, channelID string, threadTS string, att channel.PreparedAttachment) error {
if att.Kind != channel.PreparedAttachmentUpload {
return fmt.Errorf("slack attachment requires upload source, got %s", att.Kind)
}
if att.Open == nil {
return errors.New("slack attachment upload is not openable")
}
reader, err := att.Open(ctx)
if err != nil {
return err
}
defer func() { _ = reader.Close() }()
data, err := media.ReadAllWithLimit(reader, media.MaxAssetBytes)
if err != nil {
return err
}
name := strings.TrimSpace(att.Name)
if name == "" {
name = "attachment"
if ext := mimeExtension(strings.TrimSpace(att.Mime)); ext != "" {
name += ext
}
}
_, err = api.UploadFileContext(ctx, slack.UploadFileParameters{
Channel: channelID,
ThreadTimestamp: threadTS,
Filename: name,
Reader: bytes.NewReader(data),
FileSize: len(data),
})
return err
}
func (a *SlackAdapter) ResolveAttachment(ctx context.Context, cfg channel.ChannelConfig, attachment channel.Attachment) (channel.AttachmentPayload, error) {
slackCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return channel.AttachmentPayload{}, err
}
return a.resolveAttachmentWithClient(ctx, a.newAPIClient(slackCfg), attachment)
}
func (*SlackAdapter) resolveAttachmentWithClient(ctx context.Context, api *slack.Client, attachment channel.Attachment) (channel.AttachmentPayload, error) {
downloadURL := strings.TrimSpace(attachment.URL)
if attachment.Size > media.MaxAssetBytes {
return channel.AttachmentPayload{}, fmt.Errorf("%w: max %d bytes", media.ErrAssetTooLarge, media.MaxAssetBytes)
}
if downloadURL == "" {
fileID := strings.TrimSpace(attachment.PlatformKey)
if fileID == "" {
return channel.AttachmentPayload{}, errors.New("slack attachment requires url or platform_key")
}
file, _, _, err := api.GetFileInfoContext(ctx, fileID, 0, 0)
if err != nil {
return channel.AttachmentPayload{}, fmt.Errorf("slack get file info: %w", err)
}
if file == nil {
return channel.AttachmentPayload{}, errors.New("slack file info response is empty")
}
downloadURL = strings.TrimSpace(file.URLPrivateDownload)
if downloadURL == "" {
downloadURL = strings.TrimSpace(file.URLPrivate)
}
if strings.TrimSpace(attachment.Name) == "" {
attachment.Name = strings.TrimSpace(file.Name)
}
if strings.TrimSpace(attachment.Mime) == "" {
attachment.Mime = strings.TrimSpace(file.Mimetype)
}
if attachment.Size <= 0 {
attachment.Size = int64(file.Size)
}
if attachment.Size > media.MaxAssetBytes {
return channel.AttachmentPayload{}, fmt.Errorf("%w: max %d bytes", media.ErrAssetTooLarge, media.MaxAssetBytes)
}
}
if downloadURL == "" {
return channel.AttachmentPayload{}, errors.New("slack attachment download URL is empty")
}
reader, err := streamSlackAttachment(ctx, api, downloadURL, media.MaxAssetBytes)
if err != nil {
return channel.AttachmentPayload{}, err
}
return channel.AttachmentPayload{
Reader: reader,
Mime: strings.TrimSpace(attachment.Mime),
Name: strings.TrimSpace(attachment.Name),
Size: attachment.Size,
}, nil
}
func truncateSlackText(text string) string {
if utf8.RuneCountInString(text) <= slackMaxLength {
return text
}
runes := []rune(text)
return string(runes[:slackMaxLength-3]) + "..."
}
func mimeExtension(mime string) string {
switch mime {
case "image/jpeg", "image/jpg":
return ".jpg"
case "image/png":
return ".png"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
case "video/mp4":
return ".mp4"
case "video/webm":
return ".webm"
case "audio/mpeg", "audio/mp3":
return ".mp3"
case "audio/ogg":
return ".ogg"
case "audio/wav":
return ".wav"
case "application/pdf":
return ".pdf"
case "text/plain":
return ".txt"
default:
return ""
}
}
func (a *SlackAdapter) OpenStream(ctx context.Context, cfg channel.ChannelConfig, target string, opts channel.StreamOptions) (channel.PreparedOutboundStream, error) {
target = strings.TrimSpace(target)
slackCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return nil, err
}
api := a.newAPIClient(slackCfg)
target, err = a.resolveOutboundTarget(ctx, api, target)
if err != nil {
return nil, err
}
reply := opts.Reply
if reply == nil && strings.TrimSpace(opts.SourceMessageID) != "" {
reply = &channel.ReplyRef{
Target: target,
MessageID: strings.TrimSpace(opts.SourceMessageID),
}
}
return &slackOutboundStream{
adapter: a,
cfg: cfg,
target: target,
reply: reply,
api: api,
}, nil
}
func (*SlackAdapter) ProcessingStarted(_ context.Context, _ channel.ChannelConfig, _ channel.InboundMessage, _ channel.ProcessingStatusInfo) (channel.ProcessingStatusHandle, error) {
// Slack does not have a public typing indicator API for bots
return channel.ProcessingStatusHandle{}, nil
}
func (*SlackAdapter) ProcessingCompleted(_ context.Context, _ channel.ChannelConfig, _ channel.InboundMessage, _ channel.ProcessingStatusInfo, _ channel.ProcessingStatusHandle) error {
return nil
}
func (*SlackAdapter) ProcessingFailed(_ context.Context, _ channel.ChannelConfig, _ channel.InboundMessage, _ channel.ProcessingStatusInfo, _ channel.ProcessingStatusHandle, _ error) error {
return nil
}
func (a *SlackAdapter) React(ctx context.Context, cfg channel.ChannelConfig, target string, messageID string, emoji string) error {
slackCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return err
}
api := a.newAPIClient(slackCfg)
target, err = a.resolveOutboundTarget(ctx, api, target)
if err != nil {
return err
}
emoji = resolveSlackEmoji(emoji)
return api.AddReaction(emoji, slack.ItemRef{
Channel: target,
Timestamp: messageID,
})
}
func (a *SlackAdapter) Unreact(ctx context.Context, cfg channel.ChannelConfig, target string, messageID string, emoji string) error {
slackCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return err
}
api := a.newAPIClient(slackCfg)
target, err = a.resolveOutboundTarget(ctx, api, target)
if err != nil {
return err
}
emoji = resolveSlackEmoji(emoji)
return api.RemoveReaction(emoji, slack.ItemRef{
Channel: target,
Timestamp: messageID,
})
}
func (a *SlackAdapter) resolveOutboundTarget(ctx context.Context, api *slack.Client, target string) (string, error) {
target = strings.TrimSpace(target)
if target == "" {
return "", errors.New("slack target is required")
}
if !strings.HasPrefix(target, "U") {
return target, nil
}
openConversation := a.openConversation
if openConversation == nil {
openConversation = func(ctx context.Context, api *slack.Client, params *slack.OpenConversationParameters) (*slack.Channel, bool, bool, error) {
return api.OpenConversationContext(ctx, params)
}
}
conversation, _, _, err := openConversation(ctx, api, &slack.OpenConversationParameters{
Users: []string{target},
ReturnIM: true,
})
if err != nil {
return "", fmt.Errorf("slack open dm conversation: %w", err)
}
if conversation == nil || strings.TrimSpace(conversation.ID) == "" {
return "", errors.New("slack open dm conversation returned empty channel")
}
return strings.TrimSpace(conversation.ID), nil
}
func (a *SlackAdapter) DiscoverSelf(_ context.Context, credentials map[string]any) (map[string]any, string, error) {
cfg, err := parseConfig(credentials)
if err != nil {
return nil, "", err
}
api := a.newAPIClient(cfg)
resp, err := api.AuthTest()
if err != nil {
return nil, "", fmt.Errorf("slack auth test: %w", err)
}
identity := map[string]any{
"user_id": resp.UserID,
"bot_id": resp.BotID,
"team_id": resp.TeamID,
"username": resp.User,
"team": resp.Team,
}
return identity, resp.UserID, nil
}
func (*SlackAdapter) NormalizeConfig(raw map[string]any) (map[string]any, error) {
return normalizeConfig(raw)
}
func (*SlackAdapter) NormalizeUserConfig(raw map[string]any) (map[string]any, error) {
return normalizeUserConfig(raw)
}
func (*SlackAdapter) NormalizeTarget(raw string) string {
return normalizeTarget(raw)
}
func (*SlackAdapter) ResolveTarget(userConfig map[string]any) (string, error) {
return resolveTarget(userConfig)
}
func (*SlackAdapter) MatchBinding(config map[string]any, criteria channel.BindingCriteria) bool {
return matchBinding(config, criteria)
}
func (*SlackAdapter) BuildUserConfig(identity channel.Identity) map[string]any {
return buildUserConfig(identity)
}
func (a *SlackAdapter) isDuplicateInbound(token, messageTS string) bool {
if strings.TrimSpace(token) == "" || strings.TrimSpace(messageTS) == "" {
return false
}
now := time.Now().UTC()
expireBefore := now.Add(-inboundDedupTTL)
a.mu.Lock()
defer a.mu.Unlock()
for key, seenAt := range a.seenMessages {
if seenAt.Before(expireBefore) {
delete(a.seenMessages, key)
}
}
seenKey := token + ":" + messageTS
if _, ok := a.seenMessages[seenKey]; ok {
return true
}
a.seenMessages[seenKey] = now
return false
}
func (a *SlackAdapter) clearConnection(appToken string) {
a.mu.Lock()
defer a.mu.Unlock()
if conn, ok := a.connections[appToken]; ok {
if conn.cancel != nil {
conn.cancel()
}
delete(a.connections, appToken)
}
}
func (a *SlackAdapter) resolveUserDisplayName(api *slack.Client, configID, userID string) string {
configID = strings.TrimSpace(configID)
userID = strings.TrimSpace(userID)
if api == nil || configID == "" || userID == "" {
return userID
}
cacheKey := configID + ":" + userID
expireBefore := time.Now().UTC().Add(-channelNameTTL)
a.mu.RLock()
cached, ok := a.userNames[cacheKey]
a.mu.RUnlock()
if ok && cached.cachedAt.After(expireBefore) {
return cached.displayName
}
userInfo, err := api.GetUserInfo(userID)
if err != nil {
return userID
}
displayName := strings.TrimSpace(userInfo.Profile.DisplayName)
if displayName == "" {
displayName = strings.TrimSpace(userInfo.RealName)
}
if displayName == "" {
displayName = strings.TrimSpace(userInfo.Name)
}
if displayName == "" {
displayName = userID
}
a.mu.Lock()
a.userNames[cacheKey] = cachedSlackUserName{displayName: displayName, cachedAt: time.Now().UTC()}
a.mu.Unlock()
return displayName
}
func (*SlackAdapter) collectAttachments(msg *slack.Msg) []channel.Attachment {
if msg == nil || len(msg.Files) == 0 {
return nil
}
attachments := make([]channel.Attachment, 0, len(msg.Files))
for _, file := range msg.Files {
attachment := channel.Attachment{
Type: channel.AttachmentFile,
PlatformKey: strings.TrimSpace(file.ID),
SourcePlatform: Type.String(),
Name: strings.TrimSpace(file.Name),
Size: int64(file.Size),
Mime: strings.TrimSpace(file.Mimetype),
}
switch {
case strings.HasPrefix(file.Mimetype, "image/"):
attachment.Type = channel.AttachmentImage
case strings.HasPrefix(file.Mimetype, "video/"):
attachment.Type = channel.AttachmentVideo
case strings.HasPrefix(file.Mimetype, "audio/"):
attachment.Type = channel.AttachmentAudio
}
attachments = append(attachments, attachment)
}
return attachments
}
func (a *SlackAdapter) fetchMessageAttachments(ctx context.Context, api *slack.Client, channelID string, ts string) []channel.Attachment {
if api == nil || strings.TrimSpace(channelID) == "" || strings.TrimSpace(ts) == "" {
return nil
}
historyFetch := a.historyFetch
if historyFetch == nil {
historyFetch = func(ctx context.Context, api *slack.Client, params *slack.GetConversationHistoryParameters) (*slack.GetConversationHistoryResponse, error) {
return api.GetConversationHistoryContext(ctx, params)
}
}
resp, err := historyFetch(ctx, api, &slack.GetConversationHistoryParameters{
ChannelID: channelID,
Oldest: ts,
Latest: ts,
Inclusive: true,
Limit: 1,
})
if err != nil || resp == nil || len(resp.Messages) == 0 {
return nil
}
msg := resp.Messages[0].Msg
return a.collectAttachments(&msg)
}
func streamSlackAttachment(ctx context.Context, api *slack.Client, downloadURL string, maxBytes int64) (io.ReadCloser, error) {
if api == nil {
return nil, errors.New("slack client is required")
}
if maxBytes <= 0 {
return nil, fmt.Errorf("%w: max %d bytes", media.ErrAssetTooLarge, maxBytes)
}
streamCtx, cancel := context.WithCancel(ctx)
pr, pw := io.Pipe()
go func() {
writer := &limitedSlackPipeWriter{
pipe: pw,
maxBytes: maxBytes,
}
err := api.GetFileContext(streamCtx, downloadURL, writer)
if err == nil && writer.err != nil {
err = writer.err
}
if err != nil {
if errors.Is(err, media.ErrAssetTooLarge) {
err = fmt.Errorf("%w: max %d bytes", media.ErrAssetTooLarge, maxBytes)
} else {
err = fmt.Errorf("slack download file: %w", err)
}
_ = pw.CloseWithError(err)
return
}
_ = pw.Close()
}()
return &slackAttachmentStream{
ReadCloser: pr,
cancel: cancel,
}, nil
}
type slackAttachmentStream struct {
io.ReadCloser
cancel context.CancelFunc
}
func (s *slackAttachmentStream) Close() error {
if s == nil {
return nil
}
if s.cancel != nil {
s.cancel()
}
if s.ReadCloser == nil {
return nil
}
return s.ReadCloser.Close()
}
type limitedSlackPipeWriter struct {
pipe *io.PipeWriter
maxBytes int64
written int64
err error
}
func (w *limitedSlackPipeWriter) Write(p []byte) (int, error) {
if w == nil || w.pipe == nil {
return 0, errors.New("pipe writer is required")
}
if w.maxBytes <= 0 {
w.err = media.ErrAssetTooLarge
return 0, w.err
}
remaining := w.maxBytes - w.written
if remaining <= 0 {
w.err = media.ErrAssetTooLarge
return 0, w.err
}
if int64(len(p)) <= remaining {
n, err := w.pipe.Write(p)
w.written += int64(n)
return n, err
}
allowed := p[:remaining]
n, err := w.pipe.Write(allowed)
w.written += int64(n)
if err != nil {
return n, err
}
w.err = media.ErrAssetTooLarge
return n, w.err
}
func slackIdentityAttributes(userID, username, channelType, channelID string) map[string]string {
attrs := map[string]string{}
if value := strings.TrimSpace(userID); value != "" {
attrs["user_id"] = value
}
if value := strings.TrimSpace(username); value != "" {
attrs["username"] = value
}
if strings.TrimSpace(channelType) == "im" {
if value := strings.TrimSpace(channelID); value != "" {
attrs["channel_id"] = value
}
}
return attrs
}
func (a *SlackAdapter) lookupConversationName(ctx context.Context, api *slack.Client, configID, channelID string) string {
name, _ := a.lookupConversationInfo(ctx, api, configID, channelID)
return name
}
func (a *SlackAdapter) lookupConversationInfo(ctx context.Context, api *slack.Client, configID, channelID string) (string, string) {
configID = strings.TrimSpace(configID)
channelID = strings.TrimSpace(channelID)
if api == nil || configID == "" || channelID == "" {
return "", ""
}
cacheKey := configID + ":" + channelID
expireBefore := time.Now().UTC().Add(-channelNameTTL)
a.mu.RLock()
cached, ok := a.channelNames[cacheKey]
a.mu.RUnlock()
if ok && cached.cachedAt.After(expireBefore) {
return cached.name, cached.chatType
}
name, chatType, err := a.fetchConversationInfo(ctx, api, channelID)
if err != nil {
if a.logger != nil {
a.logger.Debug("resolve slack conversation name failed",
slog.String("channel_id", channelID),
slog.Any("error", err),
)
}
return "", ""
}
if name == "" && chatType == "" {
return "", ""
}
a.mu.Lock()
a.channelNames[cacheKey] = cachedSlackChannelName{name: name, chatType: chatType, cachedAt: time.Now().UTC()}
a.mu.Unlock()
return name, chatType
}
func (*SlackAdapter) fetchConversationInfo(ctx context.Context, api *slack.Client, channelID string) (string, string, error) {
info, err := api.GetConversationInfoContext(ctx, &slack.GetConversationInfoInput{
ChannelID: channelID,
})
if err != nil {
return "", "", err
}
if info == nil {
return "", "", nil
}
name := strings.TrimSpace(info.Name)
if name == "" {
name = strings.TrimSpace(info.NameNormalized)
}
chatType := channel.ConversationTypeGroup
switch {
case info.IsIM:
chatType = channel.ConversationTypePrivate
case info.IsMpIM:
chatType = channel.ConversationTypeGroup
case info.IsPrivate:
chatType = channel.ConversationTypeGroup
}
return name, chatType, nil
}