mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
4c653c661d
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.
490 lines
11 KiB
Go
490 lines
11 KiB
Go
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"
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
|
"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) {
|
|
if cfg.BotToken == "" || cfg.AppToken == "" {
|
|
return nil, fmt.Errorf("slack bot_token and app_token are required")
|
|
}
|
|
|
|
api := slack.New(
|
|
cfg.BotToken,
|
|
slack.OptionAppLevelToken(cfg.AppToken),
|
|
)
|
|
|
|
socketClient := socketmode.New(api)
|
|
|
|
base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom,
|
|
channels.WithMaxMessageLength(40000),
|
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
|
)
|
|
|
|
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
|
|
|
|
logger.InfoCF("slack", "Slack bot connected", map[string]any{
|
|
"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 {
|
|
logger.ErrorCF("slack", "Socket Mode connection error", map[string]any{
|
|
"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
|
|
}
|
|
|
|
func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
|
if !c.IsRunning() {
|
|
return channels.ErrNotRunning
|
|
}
|
|
|
|
channelID, threadTS := parseSlackChatID(msg.ChatID)
|
|
if channelID == "" {
|
|
return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID)
|
|
}
|
|
|
|
opts := []slack.MsgOption{
|
|
slack.MsgOptionText(msg.Content, false),
|
|
}
|
|
|
|
if threadTS != "" {
|
|
opts = append(opts, slack.MsgOptionTS(threadTS))
|
|
}
|
|
|
|
_, _, err := c.api.PostMessageContext(ctx, channelID, opts...)
|
|
if err != nil {
|
|
return fmt.Errorf("slack send: %w", channels.ErrTemporary)
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
logger.DebugCF("slack", "Message sent", map[string]any{
|
|
"channel_id": channelID,
|
|
"thread_ts": threadTS,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// SendMedia implements the channels.MediaSender interface.
|
|
func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
|
if !c.IsRunning() {
|
|
return channels.ErrNotRunning
|
|
}
|
|
|
|
channelID, _ := parseSlackChatID(msg.ChatID)
|
|
if channelID == "" {
|
|
return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID)
|
|
}
|
|
|
|
store := c.GetMediaStore()
|
|
if store == nil {
|
|
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
|
}
|
|
|
|
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(),
|
|
})
|
|
return fmt.Errorf("slack send media: %w", channels.ErrTemporary)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 检查白名单,避免为被拒绝的用户下载附件
|
|
if !c.IsAllowed(ev.User) {
|
|
logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
|
|
"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.api.AddReaction("eyes", slack.ItemRef{
|
|
Channel: channelID,
|
|
Timestamp: messageTS,
|
|
})
|
|
|
|
c.pendingAcks.Store(chatID, slackMessageRef{
|
|
ChannelID: channelID,
|
|
Timestamp: messageTS,
|
|
})
|
|
|
|
content := ev.Text
|
|
content = c.stripBotMention(content)
|
|
|
|
// In non-DM channels, apply group trigger filtering
|
|
if !strings.HasPrefix(channelID, "D") {
|
|
respond, cleaned := c.ShouldRespondInGroup(false, content)
|
|
if !respond {
|
|
return
|
|
}
|
|
content = cleaned
|
|
}
|
|
|
|
var mediaPaths []string
|
|
|
|
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{
|
|
Filename: filename,
|
|
Source: "slack",
|
|
}, scope)
|
|
if err == nil {
|
|
return ref
|
|
}
|
|
}
|
|
return localPath // fallback
|
|
}
|
|
|
|
if ev.Message != nil && len(ev.Message.Files) > 0 {
|
|
for _, file := range ev.Message.Files {
|
|
localPath := c.downloadSlackFile(file)
|
|
if localPath == "" {
|
|
continue
|
|
}
|
|
mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name))
|
|
content += fmt.Sprintf("\n[file: %s]", file.Name)
|
|
}
|
|
}
|
|
|
|
if strings.TrimSpace(content) == "" {
|
|
return
|
|
}
|
|
|
|
peerKind := "channel"
|
|
peerID := channelID
|
|
if strings.HasPrefix(channelID, "D") {
|
|
peerKind = "direct"
|
|
peerID = senderID
|
|
}
|
|
|
|
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
|
|
|
metadata := map[string]string{
|
|
"message_ts": messageTS,
|
|
"channel_id": channelID,
|
|
"thread_ts": threadTS,
|
|
"platform": "slack",
|
|
"team_id": c.teamID,
|
|
}
|
|
|
|
logger.DebugCF("slack", "Received message", map[string]any{
|
|
"sender_id": senderID,
|
|
"chat_id": chatID,
|
|
"preview": utils.Truncate(content, 50),
|
|
"has_thread": threadTS != "",
|
|
})
|
|
|
|
c.HandleMessage(peer, messageTS, senderID, chatID, content, mediaPaths, metadata)
|
|
}
|
|
|
|
func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
|
if ev.User == c.botUserID {
|
|
return
|
|
}
|
|
|
|
if !c.IsAllowed(ev.User) {
|
|
logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{
|
|
"user_id": ev.User,
|
|
})
|
|
return
|
|
}
|
|
|
|
senderID := ev.User
|
|
channelID := ev.Channel
|
|
threadTS := ev.ThreadTimeStamp
|
|
messageTS := ev.TimeStamp
|
|
|
|
var chatID string
|
|
if threadTS != "" {
|
|
chatID = channelID + "/" + threadTS
|
|
} else {
|
|
chatID = channelID + "/" + messageTS
|
|
}
|
|
|
|
c.api.AddReaction("eyes", slack.ItemRef{
|
|
Channel: channelID,
|
|
Timestamp: messageTS,
|
|
})
|
|
|
|
c.pendingAcks.Store(chatID, slackMessageRef{
|
|
ChannelID: channelID,
|
|
Timestamp: messageTS,
|
|
})
|
|
|
|
content := c.stripBotMention(ev.Text)
|
|
|
|
if strings.TrimSpace(content) == "" {
|
|
return
|
|
}
|
|
|
|
mentionPeerKind := "channel"
|
|
mentionPeerID := channelID
|
|
if strings.HasPrefix(channelID, "D") {
|
|
mentionPeerKind = "direct"
|
|
mentionPeerID = senderID
|
|
}
|
|
|
|
mentionPeer := bus.Peer{Kind: mentionPeerKind, ID: mentionPeerID}
|
|
|
|
metadata := map[string]string{
|
|
"message_ts": messageTS,
|
|
"channel_id": channelID,
|
|
"thread_ts": threadTS,
|
|
"platform": "slack",
|
|
"is_mention": "true",
|
|
"team_id": c.teamID,
|
|
}
|
|
|
|
c.HandleMessage(mentionPeer, messageTS, senderID, chatID, content, nil, metadata)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
if !c.IsAllowed(cmd.UserID) {
|
|
logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{
|
|
"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,
|
|
}
|
|
|
|
logger.DebugCF("slack", "Slash command received", map[string]any{
|
|
"sender_id": senderID,
|
|
"command": cmd.Command,
|
|
"text": utils.Truncate(content, 50),
|
|
})
|
|
|
|
c.HandleMessage(bus.Peer{Kind: "channel", ID: channelID}, "", senderID, chatID, content, nil, metadata)
|
|
}
|
|
|
|
func (c *SlackChannel) downloadSlackFile(file slack.File) string {
|
|
downloadURL := file.URLPrivateDownload
|
|
if downloadURL == "" {
|
|
downloadURL = file.URLPrivate
|
|
}
|
|
if downloadURL == "" {
|
|
logger.ErrorCF("slack", "No download URL for file", map[string]any{"file_id": file.ID})
|
|
return ""
|
|
}
|
|
|
|
return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{
|
|
LoggerPrefix: "slack",
|
|
ExtraHeaders: map[string]string{
|
|
"Authorization": "Bearer " + c.config.BotToken,
|
|
},
|
|
})
|
|
}
|
|
|
|
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]
|
|
}
|
|
return
|
|
}
|