2026-02-20 15:25:44 +00:00
|
|
|
package slack
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
|
|
|
|
|
"github.com/slack-go/slack"
|
|
|
|
|
"github.com/slack-go/slack/slackevents"
|
|
|
|
|
"github.com/slack-go/slack/socketmode"
|
|
|
|
|
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
2026-02-22 22:56:48 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/identity"
|
2026-02-20 15:25:44 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
2026-02-22 15:27:55 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
2026-02-20 15:25:44 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type SlackChannel struct {
|
|
|
|
|
*channels.BaseChannel
|
|
|
|
|
config config.SlackConfig
|
|
|
|
|
api *slack.Client
|
|
|
|
|
socketClient *socketmode.Client
|
|
|
|
|
botUserID string
|
|
|
|
|
teamID string
|
|
|
|
|
ctx context.Context
|
|
|
|
|
cancel context.CancelFunc
|
|
|
|
|
pendingAcks sync.Map
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type slackMessageRef struct {
|
|
|
|
|
ChannelID string
|
|
|
|
|
Timestamp string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) {
|
2026-03-27 16:03:34 +00:00
|
|
|
if cfg.BotToken.String() == "" || cfg.AppToken.String() == "" {
|
2026-02-20 15:25:44 +00:00
|
|
|
return nil, fmt.Errorf("slack bot_token and app_token are required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
api := slack.New(
|
2026-03-27 16:03:34 +00:00
|
|
|
cfg.BotToken.String(),
|
|
|
|
|
slack.OptionAppLevelToken(cfg.AppToken.String()),
|
2026-02-20 15:25:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
socketClient := socketmode.New(api)
|
|
|
|
|
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom,
|
|
|
|
|
channels.WithMaxMessageLength(40000),
|
|
|
|
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
2026-02-26 05:24:51 +00:00
|
|
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
)
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
return &SlackChannel{
|
|
|
|
|
BaseChannel: base,
|
|
|
|
|
config: cfg,
|
|
|
|
|
api: api,
|
|
|
|
|
socketClient: socketClient,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *SlackChannel) Start(ctx context.Context) error {
|
|
|
|
|
logger.InfoC("slack", "Starting Slack channel (Socket Mode)")
|
|
|
|
|
|
|
|
|
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
|
|
|
|
|
|
|
|
|
authResp, err := c.api.AuthTest()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("slack auth test failed: %w", err)
|
|
|
|
|
}
|
|
|
|
|
c.botUserID = authResp.UserID
|
|
|
|
|
c.teamID = authResp.TeamID
|
|
|
|
|
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.InfoCF("slack", "Slack bot connected", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"bot_user_id": c.botUserID,
|
|
|
|
|
"team": authResp.Team,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
go c.eventLoop()
|
|
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
|
if err := c.socketClient.RunContext(c.ctx); err != nil {
|
|
|
|
|
if c.ctx.Err() == nil {
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.ErrorCF("slack", "Socket Mode connection error", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
c.SetRunning(true)
|
|
|
|
|
logger.InfoC("slack", "Slack channel started (Socket Mode)")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *SlackChannel) Stop(ctx context.Context) error {
|
|
|
|
|
logger.InfoC("slack", "Stopping Slack channel")
|
|
|
|
|
|
|
|
|
|
if c.cancel != nil {
|
|
|
|
|
c.cancel()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.SetRunning(false)
|
|
|
|
|
logger.InfoC("slack", "Slack channel stopped")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
2026-02-20 15:25:44 +00:00
|
|
|
if !c.IsRunning() {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, channels.ErrNotRunning
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
channelID, threadTS := parseSlackChatID(msg.ChatID)
|
|
|
|
|
if channelID == "" {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("invalid slack chat ID: %s", msg.ChatID)
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
opts := []slack.MsgOption{
|
|
|
|
|
slack.MsgOptionText(msg.Content, false),
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 08:33:01 +00:00
|
|
|
if msg.ReplyToMessageID != "" && threadTS == "" {
|
|
|
|
|
// Answer to the message by creating a Thread under it
|
|
|
|
|
opts = append(opts, slack.MsgOptionTS(msg.ReplyToMessageID))
|
|
|
|
|
} else if threadTS != "" {
|
|
|
|
|
// If we are already in a thread, continue in the thread
|
2026-02-20 15:25:44 +00:00
|
|
|
opts = append(opts, slack.MsgOptionTS(threadTS))
|
|
|
|
|
}
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
_, ts, err := c.api.PostMessageContext(ctx, channelID, opts...)
|
2026-02-20 15:25:44 +00:00
|
|
|
if err != nil {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("slack send: %w", channels.ErrTemporary)
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok {
|
|
|
|
|
msgRef := ref.(slackMessageRef)
|
|
|
|
|
c.api.AddReaction("white_check_mark", slack.ItemRef{
|
|
|
|
|
Channel: msgRef.ChannelID,
|
|
|
|
|
Timestamp: msgRef.Timestamp,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.DebugCF("slack", "Message sent", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"channel_id": channelID,
|
|
|
|
|
"thread_ts": threadTS,
|
|
|
|
|
})
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return []string{ts}, nil
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:10:57 +00:00
|
|
|
// SendMedia implements the channels.MediaSender interface.
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
|
2026-02-22 19:10:57 +00:00
|
|
|
if !c.IsRunning() {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, channels.ErrNotRunning
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
channelID, _ := parseSlackChatID(msg.ChatID)
|
|
|
|
|
if channelID == "" {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("invalid slack chat ID: %s", msg.ChatID)
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
store := c.GetMediaStore()
|
|
|
|
|
if store == nil {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, part := range msg.Parts {
|
|
|
|
|
localPath, err := store.Resolve(part.Ref)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.ErrorCF("slack", "Failed to resolve media ref", map[string]any{
|
|
|
|
|
"ref": part.Ref,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filename := part.Filename
|
|
|
|
|
if filename == "" {
|
|
|
|
|
filename = "file"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
title := part.Caption
|
|
|
|
|
if title == "" {
|
|
|
|
|
title = filename
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{
|
|
|
|
|
Channel: channelID,
|
|
|
|
|
File: localPath,
|
|
|
|
|
Filename: filename,
|
|
|
|
|
Title: title,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.ErrorCF("slack", "Failed to upload media", map[string]any{
|
|
|
|
|
"filename": filename,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("slack send media: %w", channels.ErrTemporary)
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
// UploadFileV2 does not expose the posted message timestamp in its
|
|
|
|
|
// response; returning nil avoids conflating file IDs with message IDs.
|
|
|
|
|
return nil, nil
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 19:02:40 +00:00
|
|
|
// ReactToMessage implements channels.ReactionCapable.
|
|
|
|
|
// It adds an "eyes" (👀) reaction to the inbound message and returns an undo function
|
|
|
|
|
// that removes the reaction.
|
|
|
|
|
func (c *SlackChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
|
|
|
|
channelID, _ := parseSlackChatID(chatID)
|
|
|
|
|
if channelID == "" {
|
|
|
|
|
return func() {}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.api.AddReaction("eyes", slack.ItemRef{
|
|
|
|
|
Channel: channelID,
|
|
|
|
|
Timestamp: messageID,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return func() {
|
|
|
|
|
c.api.RemoveReaction("eyes", slack.ItemRef{
|
|
|
|
|
Channel: channelID,
|
|
|
|
|
Timestamp: messageID,
|
|
|
|
|
})
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
func (c *SlackChannel) eventLoop() {
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-c.ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
case event, ok := <-c.socketClient.Events:
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
switch event.Type {
|
|
|
|
|
case socketmode.EventTypeEventsAPI:
|
|
|
|
|
c.handleEventsAPI(event)
|
|
|
|
|
case socketmode.EventTypeSlashCommand:
|
|
|
|
|
c.handleSlashCommand(event)
|
|
|
|
|
case socketmode.EventTypeInteractive:
|
|
|
|
|
if event.Request != nil {
|
|
|
|
|
c.socketClient.Ack(*event.Request)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *SlackChannel) handleEventsAPI(event socketmode.Event) {
|
|
|
|
|
if event.Request != nil {
|
|
|
|
|
c.socketClient.Ack(*event.Request)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
eventsAPIEvent, ok := event.Data.(slackevents.EventsAPIEvent)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch ev := eventsAPIEvent.InnerEvent.Data.(type) {
|
|
|
|
|
case *slackevents.MessageEvent:
|
|
|
|
|
c.handleMessageEvent(ev)
|
|
|
|
|
case *slackevents.AppMentionEvent:
|
|
|
|
|
c.handleAppMention(ev)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
|
|
|
|
if ev.User == c.botUserID || ev.User == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if ev.BotID != "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if ev.SubType != "" && ev.SubType != "file_share" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 04:17:11 +00:00
|
|
|
// check allowlist to avoid downloading attachments for rejected users
|
2026-02-22 22:56:48 +00:00
|
|
|
sender := bus.SenderInfo{
|
|
|
|
|
Platform: "slack",
|
|
|
|
|
PlatformID: ev.User,
|
|
|
|
|
CanonicalID: identity.BuildCanonicalID("slack", ev.User),
|
|
|
|
|
}
|
|
|
|
|
if !c.IsAllowedSender(sender) {
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"user_id": ev.User,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
senderID := ev.User
|
|
|
|
|
channelID := ev.Channel
|
|
|
|
|
threadTS := ev.ThreadTimeStamp
|
|
|
|
|
messageTS := ev.TimeStamp
|
|
|
|
|
|
|
|
|
|
chatID := channelID
|
|
|
|
|
if threadTS != "" {
|
|
|
|
|
chatID = channelID + "/" + threadTS
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.pendingAcks.Store(chatID, slackMessageRef{
|
|
|
|
|
ChannelID: channelID,
|
|
|
|
|
Timestamp: messageTS,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
content := ev.Text
|
|
|
|
|
content = c.stripBotMention(content)
|
|
|
|
|
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
// In non-DM channels, apply group trigger filtering
|
|
|
|
|
if !strings.HasPrefix(channelID, "D") {
|
|
|
|
|
respond, cleaned := c.ShouldRespondInGroup(false, content)
|
|
|
|
|
if !respond {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
content = cleaned
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
var mediaPaths []string
|
2026-02-22 15:27:55 +00:00
|
|
|
|
|
|
|
|
scope := channels.BuildMediaScope("slack", chatID, messageTS)
|
|
|
|
|
|
|
|
|
|
// Helper to register a local file with the media store
|
|
|
|
|
storeMedia := func(localPath, filename string) string {
|
|
|
|
|
if store := c.GetMediaStore(); store != nil {
|
|
|
|
|
ref, err := store.Store(localPath, media.MediaMeta{
|
2026-03-23 04:13:59 +00:00
|
|
|
Filename: filename,
|
|
|
|
|
Source: "slack",
|
|
|
|
|
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
2026-02-22 15:27:55 +00:00
|
|
|
}, scope)
|
|
|
|
|
if err == nil {
|
|
|
|
|
return ref
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-22 15:27:55 +00:00
|
|
|
return localPath // fallback
|
|
|
|
|
}
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
if ev.Message != nil && len(ev.Message.Files) > 0 {
|
|
|
|
|
for _, file := range ev.Message.Files {
|
|
|
|
|
localPath := c.downloadSlackFile(file)
|
|
|
|
|
if localPath == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-02-22 15:27:55 +00:00
|
|
|
mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name))
|
2026-02-22 19:47:12 +00:00
|
|
|
content += fmt.Sprintf("\n[file: %s]", file.Name)
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(content) == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
peerKind := "channel"
|
|
|
|
|
if strings.HasPrefix(channelID, "D") {
|
|
|
|
|
peerKind = "direct"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
metadata := map[string]string{
|
|
|
|
|
"message_ts": messageTS,
|
|
|
|
|
"channel_id": channelID,
|
|
|
|
|
"thread_ts": threadTS,
|
|
|
|
|
"platform": "slack",
|
|
|
|
|
"team_id": c.teamID,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.DebugCF("slack", "Received message", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"sender_id": senderID,
|
|
|
|
|
"chat_id": chatID,
|
|
|
|
|
"preview": utils.Truncate(content, 50),
|
|
|
|
|
"has_thread": threadTS != "",
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-01 05:50:24 +00:00
|
|
|
inboundCtx := bus.InboundContext{
|
|
|
|
|
Channel: c.Name(),
|
|
|
|
|
Account: c.teamID,
|
|
|
|
|
ChatID: channelID,
|
|
|
|
|
ChatType: peerKind,
|
|
|
|
|
SenderID: senderID,
|
|
|
|
|
MessageID: messageTS,
|
|
|
|
|
SpaceID: c.teamID,
|
|
|
|
|
SpaceType: "workspace",
|
|
|
|
|
Raw: metadata,
|
|
|
|
|
}
|
|
|
|
|
if threadTS != "" {
|
|
|
|
|
inboundCtx.TopicID = threadTS
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
c.HandleInboundContext(c.ctx, chatID, content, mediaPaths, inboundCtx, sender)
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
|
|
|
|
if ev.User == c.botUserID {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 22:56:48 +00:00
|
|
|
if !c.IsAllowedSender(bus.SenderInfo{
|
|
|
|
|
Platform: "slack",
|
|
|
|
|
PlatformID: ev.User,
|
|
|
|
|
CanonicalID: identity.BuildCanonicalID("slack", ev.User),
|
|
|
|
|
}) {
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"user_id": ev.User,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
senderID := ev.User
|
2026-02-22 22:56:48 +00:00
|
|
|
mentionSender := bus.SenderInfo{
|
|
|
|
|
Platform: "slack",
|
|
|
|
|
PlatformID: senderID,
|
|
|
|
|
CanonicalID: identity.BuildCanonicalID("slack", senderID),
|
|
|
|
|
}
|
2026-02-20 15:25:44 +00:00
|
|
|
channelID := ev.Channel
|
|
|
|
|
threadTS := ev.ThreadTimeStamp
|
|
|
|
|
messageTS := ev.TimeStamp
|
|
|
|
|
|
|
|
|
|
var chatID string
|
|
|
|
|
if threadTS != "" {
|
|
|
|
|
chatID = channelID + "/" + threadTS
|
|
|
|
|
} else {
|
|
|
|
|
chatID = channelID + "/" + messageTS
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.pendingAcks.Store(chatID, slackMessageRef{
|
|
|
|
|
ChannelID: channelID,
|
|
|
|
|
Timestamp: messageTS,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
content := c.stripBotMention(ev.Text)
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(content) == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mentionPeerKind := "channel"
|
|
|
|
|
if strings.HasPrefix(channelID, "D") {
|
|
|
|
|
mentionPeerKind = "direct"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
metadata := map[string]string{
|
|
|
|
|
"message_ts": messageTS,
|
|
|
|
|
"channel_id": channelID,
|
|
|
|
|
"thread_ts": threadTS,
|
|
|
|
|
"platform": "slack",
|
|
|
|
|
"is_mention": "true",
|
|
|
|
|
"team_id": c.teamID,
|
|
|
|
|
}
|
2026-04-01 05:50:24 +00:00
|
|
|
inboundCtx := bus.InboundContext{
|
|
|
|
|
Channel: c.Name(),
|
|
|
|
|
Account: c.teamID,
|
|
|
|
|
ChatID: channelID,
|
|
|
|
|
ChatType: mentionPeerKind,
|
|
|
|
|
TopicID: threadTS,
|
|
|
|
|
SenderID: senderID,
|
|
|
|
|
MessageID: messageTS,
|
|
|
|
|
SpaceID: c.teamID,
|
|
|
|
|
SpaceType: "workspace",
|
|
|
|
|
Mentioned: true,
|
|
|
|
|
Raw: metadata,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, mentionSender)
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
|
|
|
|
cmd, ok := event.Data.(slack.SlashCommand)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if event.Request != nil {
|
|
|
|
|
c.socketClient.Ack(*event.Request)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 22:56:48 +00:00
|
|
|
cmdSender := bus.SenderInfo{
|
|
|
|
|
Platform: "slack",
|
|
|
|
|
PlatformID: cmd.UserID,
|
|
|
|
|
CanonicalID: identity.BuildCanonicalID("slack", cmd.UserID),
|
|
|
|
|
}
|
|
|
|
|
if !c.IsAllowedSender(cmdSender) {
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"user_id": cmd.UserID,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
senderID := cmd.UserID
|
|
|
|
|
channelID := cmd.ChannelID
|
|
|
|
|
chatID := channelID
|
|
|
|
|
content := cmd.Text
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(content) == "" {
|
|
|
|
|
content = "help"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
metadata := map[string]string{
|
|
|
|
|
"channel_id": channelID,
|
|
|
|
|
"platform": "slack",
|
|
|
|
|
"is_command": "true",
|
|
|
|
|
"trigger_id": cmd.TriggerID,
|
|
|
|
|
"team_id": c.teamID,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.DebugCF("slack", "Slash command received", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"sender_id": senderID,
|
|
|
|
|
"command": cmd.Command,
|
|
|
|
|
"text": utils.Truncate(content, 50),
|
|
|
|
|
})
|
2026-04-01 05:50:24 +00:00
|
|
|
peerKind := "channel"
|
|
|
|
|
if strings.HasPrefix(channelID, "D") {
|
|
|
|
|
peerKind = "direct"
|
|
|
|
|
}
|
|
|
|
|
inboundCtx := bus.InboundContext{
|
|
|
|
|
Channel: c.Name(),
|
|
|
|
|
Account: c.teamID,
|
|
|
|
|
ChatID: channelID,
|
|
|
|
|
ChatType: peerKind,
|
|
|
|
|
SenderID: senderID,
|
|
|
|
|
SpaceID: c.teamID,
|
|
|
|
|
SpaceType: "workspace",
|
|
|
|
|
Raw: metadata,
|
|
|
|
|
}
|
2026-02-20 15:25:44 +00:00
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, cmdSender)
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *SlackChannel) downloadSlackFile(file slack.File) string {
|
|
|
|
|
downloadURL := file.URLPrivateDownload
|
|
|
|
|
if downloadURL == "" {
|
|
|
|
|
downloadURL = file.URLPrivate
|
|
|
|
|
}
|
|
|
|
|
if downloadURL == "" {
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.ErrorCF("slack", "No download URL for file", map[string]any{"file_id": file.ID})
|
2026-02-20 15:25:44 +00:00
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{
|
|
|
|
|
LoggerPrefix: "slack",
|
|
|
|
|
ExtraHeaders: map[string]string{
|
2026-03-27 16:03:34 +00:00
|
|
|
"Authorization": "Bearer " + c.config.BotToken.String(),
|
2026-02-20 15:25:44 +00:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *SlackChannel) stripBotMention(text string) string {
|
|
|
|
|
mention := fmt.Sprintf("<@%s>", c.botUserID)
|
|
|
|
|
text = strings.ReplaceAll(text, mention, "")
|
|
|
|
|
return strings.TrimSpace(text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseSlackChatID(chatID string) (channelID, threadTS string) {
|
|
|
|
|
parts := strings.SplitN(chatID, "/", 2)
|
|
|
|
|
channelID = parts[0]
|
|
|
|
|
if len(parts) > 1 {
|
|
|
|
|
threadTS = parts[1]
|
|
|
|
|
}
|
2026-02-26 15:24:35 +00:00
|
|
|
return channelID, threadTS
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|