Files
2026-06-02 23:14:41 +08:00

704 lines
20 KiB
Go

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("<!DOCTYPE")) || bytes.HasPrefix(data, []byte("<html"))) {
return nil, fmt.Errorf("received HTML response (likely missing auth); first 100 bytes: %s", string(data[:min(100, len(data))]))
}
return data, nil
}
func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) {
// slack:{channel}:{user}
parts := strings.SplitN(sessionKey, ":", 3)
if len(parts) < 2 || parts[0] != "slack" {
return nil, fmt.Errorf("slack: invalid session key %q", sessionKey)
}
return replyContext{channel: parts[1]}, nil
}
func (p *Platform) resolveUserName(userID string) string {
if cached, ok := p.userNameCache.Load(userID); ok {
return cached.(string)
}
user, err := p.client.GetUserInfo(userID)
if err != nil {
slog.Debug("slack: resolve user name failed", "user", userID, "error", err)
return userID
}
name := user.RealName
if name == "" {
name = user.Profile.DisplayName
}
if name == "" {
name = userID
}
p.userNameCache.Store(userID, name)
return name
}
func (p *Platform) resolveChannelNameForMsg(channelID string) string {
name, err := p.ResolveChannelName(channelID)
if err != nil || name == "" {
return channelID
}
return name
}
func (p *Platform) ResolveChannelName(channelID string) (string, error) {
p.channelCacheMu.RLock()
if name, ok := p.channelNameCache[channelID]; ok {
p.channelCacheMu.RUnlock()
return name, nil
}
p.channelCacheMu.RUnlock()
info, err := p.client.GetConversationInfo(&slack.GetConversationInfoInput{
ChannelID: channelID,
})
if err != nil {
return "", fmt.Errorf("slack: resolve channel name for %s: %w", channelID, err)
}
p.channelCacheMu.Lock()
p.channelNameCache[channelID] = info.Name
p.channelCacheMu.Unlock()
return info.Name, nil
}
// FormattingInstructions returns Slack mrkdwn formatting guidance for the agent.
func (p *Platform) FormattingInstructions() string {
return `You are responding in Slack. Use Slack's mrkdwn format, NOT standard Markdown:
- Bold: *text* (single asterisks, not double)
- Italic: _text_
- Strikethrough: ~text~
- Code: ` + "`text`" + `
- Code block: ` + "```text```" + `
- Blockquote: > text
- Lists: use bullet (•) or numbered lists normally
- Links: <url|display text>
- 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
}