package slack import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "strconv" "strings" "sync" "time" "github.com/chenhg5/cc-connect/core" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" "github.com/slack-go/slack/socketmode" ) func init() { core.RegisterPlatform("slack", New) } type replyContext struct { channel string timestamp string // thread_ts for threading replies } type Platform struct { botToken string appToken string allowFrom string shareSessionInChannel bool client *slack.Client socket *socketmode.Client handler core.MessageHandler cancel context.CancelFunc channelNameCache map[string]string channelCacheMu sync.RWMutex userNameCache sync.Map // userID -> display name } func New(opts map[string]any) (core.Platform, error) { botToken, _ := opts["bot_token"].(string) appToken, _ := opts["app_token"].(string) allowFrom, _ := opts["allow_from"].(string) core.CheckAllowFrom("slack", allowFrom) shareSessionInChannel, _ := opts["share_session_in_channel"].(bool) if botToken == "" || appToken == "" { return nil, fmt.Errorf("slack: bot_token and app_token are required") } return &Platform{ botToken: botToken, appToken: appToken, allowFrom: allowFrom, shareSessionInChannel: shareSessionInChannel, channelNameCache: make(map[string]string), }, nil } func (p *Platform) Name() string { return "slack" } func (p *Platform) Start(handler core.MessageHandler) error { p.handler = handler p.client = slack.New(p.botToken, slack.OptionAppLevelToken(p.appToken), ) p.socket = socketmode.New(p.client) ctx, cancel := context.WithCancel(context.Background()) p.cancel = cancel go func() { for { select { case <-ctx.Done(): return case evt := <-p.socket.Events: p.handleEvent(evt) } } }() go func() { if err := p.socket.RunContext(ctx); err != nil { slog.Error("slack: socket mode error", "error", err) } }() slog.Info("slack: socket mode connected") return nil } func (p *Platform) handleEvent(evt socketmode.Event) { slog.Debug("slack: raw event received", "type", evt.Type) switch evt.Type { case socketmode.EventTypeEventsAPI: data, ok := evt.Data.(slackevents.EventsAPIEvent) if !ok { slog.Debug("slack: EventsAPI type assertion failed") return } slog.Debug("slack: EventsAPI event", "outer_type", data.Type, "inner_type", data.InnerEvent.Type) if evt.Request != nil { p.socket.Ack(*evt.Request) } if data.Type == slackevents.CallbackEvent { switch ev := data.InnerEvent.Data.(type) { case *slackevents.AppMentionEvent: if ev.BotID != "" || ev.User == "" { return } if ts := ev.TimeStamp; ts != "" { if dotIdx := strings.IndexByte(ts, '.'); dotIdx > 0 { if sec, err := strconv.ParseInt(ts[:dotIdx], 10, 64); err == nil { if core.IsOldMessage(time.Unix(sec, 0)) { slog.Debug("slack: ignoring old app_mention after restart", "ts", ts) return } } } } slog.Debug("slack: app_mention received", "user", ev.User, "channel", ev.Channel) if !core.AllowList(p.allowFrom, ev.User) { slog.Debug("slack: app_mention from unauthorized user", "user", ev.User) return } var sessionKey string if p.shareSessionInChannel { sessionKey = fmt.Sprintf("slack:%s", ev.Channel) } else { sessionKey = fmt.Sprintf("slack:%s:%s", ev.Channel, ev.User) } var shareFiles []slackevents.File if cb, ok := data.Data.(*slackevents.EventsAPICallbackEvent); ok { shareFiles = parseSlackInnerEventFiles(cb.InnerEvent) } images, audio, docFiles := p.processSlackFileShares(shareFiles) content := stripAppMentionText(ev.Text) if content == "" && len(images) == 0 && audio == nil && len(docFiles) == 0 { return } msg := &core.Message{ SessionKey: sessionKey, Platform: "slack", UserID: ev.User, UserName: p.resolveUserName(ev.User), ChatName: p.resolveChannelNameForMsg(ev.Channel), Content: content, Images: images, Files: docFiles, Audio: audio, MessageID: ev.TimeStamp, ReplyCtx: replyContext{channel: ev.Channel, timestamp: ev.TimeStamp}, } p.handler(p, msg) case *slackevents.AssistantThreadStartedEvent: // User opened a Slack Assistant Chat thread for this app. // Subsequent messages arrive with ThreadTimeStamp set; // assistantOrThreadTS() routes replies into that thread (Chat tab UI). slog.Info("slack: assistant_thread_started", "user", ev.AssistantThread.UserID, "channel", ev.AssistantThread.ChannelID, "thread_ts", ev.AssistantThread.ThreadTimeStamp) _ = p.client.SetAssistantThreadsStatus(slack.AssistantThreadsSetStatusParameters{ ChannelID: ev.AssistantThread.ChannelID, ThreadTS: ev.AssistantThread.ThreadTimeStamp, Status: "", }) case *slackevents.MessageEvent: if ev.BotID != "" || ev.User == "" { return } if ts := ev.TimeStamp; ts != "" { if dotIdx := strings.IndexByte(ts, '.'); dotIdx > 0 { if sec, err := strconv.ParseInt(ts[:dotIdx], 10, 64); err == nil { if core.IsOldMessage(time.Unix(sec, 0)) { slog.Debug("slack: ignoring old message after restart", "ts", ts) return } } } } slog.Debug("slack: message received", "user", ev.User, "channel", ev.Channel) if !core.AllowList(p.allowFrom, ev.User) { slog.Debug("slack: message from unauthorized user", "user", ev.User) return } var sessionKey string if p.shareSessionInChannel { sessionKey = fmt.Sprintf("slack:%s", ev.Channel) } else { sessionKey = fmt.Sprintf("slack:%s:%s", ev.Channel, ev.User) } ts := ev.TimeStamp images, audio, docFiles := p.processSlackFileShares(ev.Files) if ev.Text == "" && len(images) == 0 && audio == nil && len(docFiles) == 0 { return } msg := &core.Message{ SessionKey: sessionKey, Platform: "slack", UserID: ev.User, UserName: p.resolveUserName(ev.User), ChatName: p.resolveChannelNameForMsg(ev.Channel), Content: ev.Text, Images: images, Files: docFiles, Audio: audio, MessageID: ts, ReplyCtx: replyContext{channel: ev.Channel, timestamp: assistantOrThreadTS(ev)}, } p.handler(p, msg) } } case socketmode.EventTypeSlashCommand: cmd, ok := evt.Data.(slack.SlashCommand) if !ok { slog.Debug("slack: slash command type assertion failed") return } if evt.Request != nil { p.socket.Ack(*evt.Request) } if !core.AllowList(p.allowFrom, cmd.UserID) { slog.Debug("slack: slash command from unauthorized user", "user", cmd.UserID) return } // Convert slash command to a regular message with / prefix so the // engine's command handling picks it up. cmdName := strings.TrimPrefix(cmd.Command, "/") content := "/" + cmdName if cmd.Text != "" { content += " " + cmd.Text } var sessionKey string if p.shareSessionInChannel { sessionKey = fmt.Sprintf("slack:%s", cmd.ChannelID) } else { sessionKey = fmt.Sprintf("slack:%s:%s", cmd.ChannelID, cmd.UserID) } msg := &core.Message{ SessionKey: sessionKey, Platform: "slack", UserID: cmd.UserID, UserName: cmd.UserName, Content: content, ReplyCtx: replyContext{channel: cmd.ChannelID}, } slog.Debug("slack: slash command", "command", cmd.Command, "text", cmd.Text, "user", cmd.UserID) p.handler(p, msg) case socketmode.EventTypeConnecting: slog.Debug("slack: connecting...") case socketmode.EventTypeConnected: slog.Info("slack: connected") case socketmode.EventTypeConnectionError: slog.Error("slack: connection error") } } func stripAppMentionText(text string) string { if idx := strings.Index(text, "> "); idx != -1 && strings.HasPrefix(text, "<@") { return strings.TrimSpace(text[idx+2:]) } return text } // parseSlackInnerEventFiles extracts the files array from a raw Events API inner // event. AppMentionEvent is unmarshaled without a Files field in slack-go, but // Slack still includes "files" in the JSON when a mention is sent with uploads. func parseSlackInnerEventFiles(raw *json.RawMessage) []slackevents.File { if raw == nil || len(*raw) == 0 { return nil } var wrapper struct { Files []slackevents.File `json:"files"` } if err := json.Unmarshal(*raw, &wrapper); err != nil { slog.Debug("slack: parse inner event files", "error", err) return nil } return wrapper.Files } // processSlackFileShares downloads Slack file shares and maps them to core // attachments. Non-audio/non-image types (e.g. PDF, text) become FileAttachment // so the engine can persist them and pass paths to the agent. func (p *Platform) processSlackFileShares(files []slackevents.File) (images []core.ImageAttachment, audio *core.AudioAttachment, docFiles []core.FileAttachment) { for _, f := range files { fileURL := f.URLPrivateDownload if fileURL == "" { fileURL = f.URLPrivate } if fileURL == "" { slog.Warn("slack: file has no download URL", "file_id", f.ID, "name", f.Name) continue } mt := strings.TrimSpace(strings.ToLower(f.Mimetype)) switch { case strings.HasPrefix(mt, "audio/"): data, err := p.downloadSlackFile(fileURL) if err != nil { slog.Error("slack: download audio failed", "error", err, "url", core.RedactToken(fileURL, p.botToken)) continue } format := "mp3" if parts := strings.SplitN(mt, "/", 2); len(parts) == 2 { format = parts[1] } audioMime := f.Mimetype if audioMime == "" { audioMime = mt } audio = &core.AudioAttachment{ MimeType: audioMime, Data: data, Format: format, } case strings.HasPrefix(mt, "image/"): imgData, err := p.downloadSlackFile(fileURL) if err != nil { slog.Error("slack: download image failed", "error", err, "url", core.RedactToken(fileURL, p.botToken)) continue } images = append(images, core.ImageAttachment{ MimeType: f.Mimetype, Data: imgData, FileName: slackFileDisplayName(f), }) default: data, err := p.downloadSlackFile(fileURL) if err != nil { slog.Error("slack: download file failed", "error", err, "url", core.RedactToken(fileURL, p.botToken)) continue } if mt == "" { mt = "application/octet-stream" } docFiles = append(docFiles, core.FileAttachment{ MimeType: mt, Data: data, FileName: slackFileDisplayName(f), }) } } return images, audio, docFiles } func slackFileDisplayName(f slackevents.File) string { if f.Name != "" { return f.Name } return f.Title } // assistantOrThreadTS returns the thread_ts to use for the bot's reply. // // For Slack Assistant apps (Agent toggle on), the user's "Chat" tab is a // dedicated thread. Messages typed there arrive as message.im events with // ThreadTimeStamp set to the assistant thread's root ts. The bot's reply // MUST include that thread_ts on chat.postMessage to land in the Chat tab // — without it, the reply goes to the DM root and surfaces in the History // tab feed instead, breaking the conversational UX. // // For regular channel messages (not DM, not already in a thread): use the // message's own TimeStamp so replies are threaded under the user's message, // preserving the old behavior of keeping conversations in threads. // // For DM messages (channel_type=im) that are not in an Assistant thread: // return empty so replies go top-level (natural 1-on-1 conversation). func assistantOrThreadTS(ev *slackevents.MessageEvent) string { if ev.ThreadTimeStamp != "" { // Already in a thread (Assistant Chat tab or regular thread reply). return ev.ThreadTimeStamp } // For non-DM channels, thread under the user's message. if ev.ChannelType != "im" { return ev.TimeStamp } // DM top-level: top-level reply is natural. return "" } func (p *Platform) Reply(ctx context.Context, rctx any, content string) error { rc, ok := rctx.(replyContext) if !ok { return fmt.Errorf("slack: invalid reply context type %T", rctx) } opts := []slack.MsgOption{ slack.MsgOptionText(core.MarkdownToSlackMrkdwn(content), false), } if rc.timestamp != "" { opts = append(opts, slack.MsgOptionPostMessageParameters(slack.PostMessageParameters{ThreadTimestamp: rc.timestamp})) } _, _, err := p.client.PostMessageContext(ctx, rc.channel, opts...) if err != nil { return fmt.Errorf("slack: send: %w", err) } return nil } // Send sends a new message (or threaded reply if rctx has timestamp). // Patched 2026-05-03: use thread_ts when present so replies in Slack Assistant // Chat tab land in the right thread (not the History tab feed). func (p *Platform) Send(ctx context.Context, rctx any, content string) error { rc, ok := rctx.(replyContext) if !ok { return fmt.Errorf("slack: invalid reply context type %T", rctx) } opts := []slack.MsgOption{ slack.MsgOptionText(core.MarkdownToSlackMrkdwn(content), false), } if rc.timestamp != "" { opts = append(opts, slack.MsgOptionPostMessageParameters(slack.PostMessageParameters{ThreadTimestamp: rc.timestamp})) } _, _, err := p.client.PostMessageContext(ctx, rc.channel, opts...) if err != nil { return fmt.Errorf("slack: send: %w", err) } return nil } // SendImage uploads and sends an image to the channel. // Implements core.ImageSender. func (p *Platform) SendImage(ctx context.Context, rctx any, img core.ImageAttachment) error { rc, ok := rctx.(replyContext) if !ok { return fmt.Errorf("slack: SendImage: invalid reply context type %T", rctx) } name := img.FileName if name == "" { name = "image.png" } _, err := p.client.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{ Reader: bytes.NewReader(img.Data), FileSize: len(img.Data), Filename: name, Channel: rc.channel, ThreadTimestamp: rc.timestamp, }) if err != nil { return fmt.Errorf("slack: send image: %w", err) } return nil } var _ core.ImageSender = (*Platform)(nil) var _ core.ObserverTarget = (*Platform)(nil) // SendObservation implements core.ObserverTarget for terminal session observation. func (p *Platform) SendObservation(ctx context.Context, channelID, text string) error { _, _, err := p.client.PostMessageContext(ctx, channelID, slack.MsgOptionText(text, false), slack.MsgOptionDisableLinkUnfurl(), ) if err != nil { return fmt.Errorf("slack: send observation: %w", err) } return nil } // SendFile uploads and sends a generic file to the channel. // Implements core.FileSender. func (p *Platform) SendFile(ctx context.Context, rctx any, file core.FileAttachment) error { rc, ok := rctx.(replyContext) if !ok { return fmt.Errorf("slack: SendFile: invalid reply context type %T", rctx) } name := file.FileName if name == "" { name = "attachment" } _, err := p.client.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{ Reader: bytes.NewReader(file.Data), FileSize: len(file.Data), Filename: name, Channel: rc.channel, ThreadTimestamp: rc.timestamp, }) if err != nil { return fmt.Errorf("slack: send file: %w", err) } return nil } var _ core.FileSender = (*Platform)(nil) func (p *Platform) downloadSlackFile(url string) ([]byte, error) { if url == "" { return nil, fmt.Errorf("empty URL") } req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+p.botToken) resp, err := core.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("%s", core.RedactToken(err.Error(), p.botToken)) } defer resp.Body.Close() // Check if we got an unexpected status code (e.g., redirect to login page) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return nil, fmt.Errorf("download failed with status %d: %s", resp.StatusCode, string(body)) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response body: %w", err) } // Basic sanity check: detect if we received HTML instead of binary data if len(data) > 0 && (bytes.HasPrefix(data, []byte(" text - Lists: use bullet (•) or numbered lists normally - Links: - Do NOT use ## headings — Slack does not render them. Use *bold text* on its own line instead.` } // StartTyping adds emoji reactions to the user's message as a heartbeat // indicator so the user knows the bot is still working. // // Timeline: // - Immediately: eyes // - After 2 minutes: clock // - Every 5 minutes after that: one more emoji (sequential from extras list) // // All reactions are removed when the returned stop function is called. func (p *Platform) StartTyping(ctx context.Context, rctx any) (stop func()) { rc, ok := rctx.(replyContext) if !ok || rc.channel == "" || rc.timestamp == "" { return func() {} } ref := slack.ItemRef{Channel: rc.channel, Timestamp: rc.timestamp} var mu sync.Mutex var added []string addReaction := func(emoji string) { if err := p.client.AddReaction(emoji, ref); err != nil { slog.Debug("slack: add reaction failed", "emoji", emoji, "error", err) return } mu.Lock() added = append(added, emoji) mu.Unlock() } // Immediately add eyes addReaction("eyes") extras := []string{ "hourglass_flowing_sand", "hourglass", "gear", "hammer_and_wrench", "mag", "bulb", "rocket", "zap", "fire", "sparkles", "brain", "crystal_ball", "jigsaw", "microscope", "satellite", } done := make(chan struct{}) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() // After 2 minutes, add clock timer := time.NewTimer(2 * time.Minute) defer timer.Stop() select { case <-timer.C: addReaction("clock1") case <-done: return } // Every 5 minutes, add a random extra emoji ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() idx := 0 for { select { case <-ticker.C: if idx < len(extras) { addReaction(extras[idx]) idx++ } case <-done: return } } }() return func() { close(done) wg.Wait() mu.Lock() emojis := make([]string, len(added)) copy(emojis, added) mu.Unlock() for _, emoji := range emojis { if err := p.client.RemoveReaction(emoji, ref); err != nil { slog.Debug("slack: remove reaction failed", "emoji", emoji, "error", err) } } } } func (p *Platform) Stop() error { if p.cancel != nil { p.cancel() } return nil }