初始化仓库
This commit is contained in:
@@ -0,0 +1,703 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
)
|
||||
|
||||
func TestStripAppMentionText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "strips bot mention prefix",
|
||||
in: "<@U0BOT123> run tests",
|
||||
want: "run tests",
|
||||
},
|
||||
{
|
||||
name: "empty mention becomes empty text",
|
||||
in: "<@U0BOT123> ",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "plain text remains unchanged",
|
||||
in: "run tests",
|
||||
want: "run tests",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := stripAppMentionText(tt.in); got != tt.want {
|
||||
t.Fatalf("stripAppMentionText(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSlackFile_HTMLDetection(t *testing.T) {
|
||||
// Test that we detect HTML responses (Slack login page) and return an error
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Simulate Slack returning HTML login page when auth is missing
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("<!DOCTYPE html><html><body>Please login</body></html>"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := &Platform{botToken: "xoxb-test-token"}
|
||||
_, err := p.downloadSlackFile(ts.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTML response, got nil")
|
||||
}
|
||||
// Should detect HTML prefix
|
||||
if err != nil && err.Error() == "" {
|
||||
t.Fatal("expected non-empty error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSlackFile_MissingAuth(t *testing.T) {
|
||||
// Test that we return an error for non-200 status codes
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("unauthorized"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := &Platform{botToken: "xoxb-test-token"}
|
||||
_, err := p.downloadSlackFile(ts.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 401 response, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSlackFile_Success(t *testing.T) {
|
||||
// Test successful binary download
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify Authorization header is set
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer xoxb-test-token" {
|
||||
t.Errorf("expected Authorization header 'Bearer xoxb-test-token', got %q", auth)
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("\x89PNG\r\n\x1a\n")) // PNG magic bytes
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := &Platform{botToken: "xoxb-test-token"}
|
||||
data, err := p.downloadSlackFile(ts.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(data) != 8 {
|
||||
t.Errorf("expected 8 bytes, got %d", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSlackFile_EmptyURL(t *testing.T) {
|
||||
p := &Platform{botToken: "xoxb-test-token"}
|
||||
_, err := p.downloadSlackFile("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty URL, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSlackInnerEventFiles(t *testing.T) {
|
||||
raw := json.RawMessage(`{"type":"app_mention","user":"U1","text":"<@B> hi","files":[{"id":"F1","name":"a.pdf","mimetype":"application/pdf","url_private_download":"http://example/f"}]}`)
|
||||
files := parseSlackInnerEventFiles(&raw)
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("len(files) = %d, want 1", len(files))
|
||||
}
|
||||
if files[0].Name != "a.pdf" || files[0].Mimetype != "application/pdf" {
|
||||
t.Fatalf("unexpected file: %+v", files[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSlackFileShares_GenericFile(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("%PDF-1.4 minimal"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := &Platform{botToken: "xoxb-test"}
|
||||
images, audio, docs := p.processSlackFileShares([]slackevents.File{
|
||||
{
|
||||
ID: "Fpdf",
|
||||
Name: "doc.pdf",
|
||||
Mimetype: "application/pdf",
|
||||
URLPrivateDownload: ts.URL,
|
||||
},
|
||||
})
|
||||
if len(images) != 0 || audio != nil {
|
||||
t.Fatalf("expected only doc file, got images=%d audio=%v", len(images), audio)
|
||||
}
|
||||
if len(docs) != 1 {
|
||||
t.Fatalf("len(docs) = %d, want 1", len(docs))
|
||||
}
|
||||
if docs[0].FileName != "doc.pdf" || docs[0].MimeType != "application/pdf" {
|
||||
t.Fatalf("unexpected doc: %+v", docs[0])
|
||||
}
|
||||
if string(docs[0].Data) != "%PDF-1.4 minimal" {
|
||||
t.Fatalf("unexpected data %q", docs[0].Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSlackFileShares_ImageVsDoc(t *testing.T) {
|
||||
imgSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("fakepng"))
|
||||
}))
|
||||
defer imgSrv.Close()
|
||||
txtSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("hello"))
|
||||
}))
|
||||
defer txtSrv.Close()
|
||||
|
||||
p := &Platform{botToken: "xoxb-test"}
|
||||
images, audio, docs := p.processSlackFileShares([]slackevents.File{
|
||||
{ID: "1", Name: "x.png", Mimetype: "image/png", URLPrivateDownload: imgSrv.URL},
|
||||
{ID: "2", Name: "n.txt", Mimetype: "text/plain", URLPrivateDownload: txtSrv.URL},
|
||||
})
|
||||
if audio != nil {
|
||||
t.Fatal("unexpected audio")
|
||||
}
|
||||
if len(images) != 1 || len(docs) != 1 {
|
||||
t.Fatalf("want 1 image 1 doc, got images=%d docs=%d", len(images), len(docs))
|
||||
}
|
||||
if images[0].MimeType != "image/png" {
|
||||
t.Errorf("image mime: %q", images[0].MimeType)
|
||||
}
|
||||
if docs[0].MimeType != "text/plain" || string(docs[0].Data) != "hello" {
|
||||
t.Errorf("unexpected text file: %+v", docs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSlackFileShares_EmptyMimeBecomesOctetStream(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte{0, 1, 2})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := &Platform{botToken: "xoxb-test"}
|
||||
_, _, docs := p.processSlackFileShares([]slackevents.File{
|
||||
{ID: "z", Name: "blob.bin", Mimetype: "", URLPrivateDownload: ts.URL},
|
||||
})
|
||||
if len(docs) != 1 || docs[0].MimeType != "application/octet-stream" {
|
||||
t.Fatalf("got %+v", docs)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user