Merge pull request #34 from corylanou/issue-31-feat-add-slack-channel-integration-with-socket-mode-threads-reactions-and-slash-commands
feat(channels): add Slack channel integration with Socket Mode
This commit is contained in:
@@ -664,6 +664,12 @@ func gatewayCmd() {
|
|||||||
logger.InfoC("voice", "Groq transcription attached to Discord channel")
|
logger.InfoC("voice", "Groq transcription attached to Discord channel")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if slackChannel, ok := channelManager.GetChannel("slack"); ok {
|
||||||
|
if sc, ok := slackChannel.(*channels.SlackChannel); ok {
|
||||||
|
sc.SetTranscriber(transcriber)
|
||||||
|
logger.InfoC("voice", "Groq transcription attached to Slack channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enabledChannels := channelManager.GetEnabledChannels()
|
enabledChannels := channelManager.GetEnabledChannels()
|
||||||
|
|||||||
@@ -43,6 +43,12 @@
|
|||||||
"client_id": "YOUR_CLIENT_ID",
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
"client_secret": "YOUR_CLIENT_SECRET",
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
},
|
||||||
|
"slack": {
|
||||||
|
"enabled": false,
|
||||||
|
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
||||||
|
"app_token": "xapp-YOUR-APP-TOKEN",
|
||||||
|
"allow_from": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -12,6 +12,7 @@ require (
|
|||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||||
|
github.com/slack-go/slack v0.17.3
|
||||||
github.com/openai/openai-go/v3 v3.21.0
|
github.com/openai/openai-go/v3 v3.21.0
|
||||||
github.com/tencent-connect/botgo v0.2.1
|
github.com/tencent-connect/botgo v0.2.1
|
||||||
golang.org/x/oauth2 v0.35.0
|
golang.org/x/oauth2 v0.35.0
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -29,6 +29,8 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m
|
|||||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||||
|
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
|
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
@@ -81,6 +83,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||||
|
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -88,8 +92,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
|
|||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk=
|
github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk=
|
||||||
github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI=
|
github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI=
|
||||||
github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
|||||||
@@ -136,6 +136,19 @@ func (m *Manager) initChannels() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.Slack.Enabled && m.config.Channels.Slack.BotToken != "" {
|
||||||
|
logger.DebugC("channels", "Attempting to initialize Slack channel")
|
||||||
|
slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.bus)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
m.channels["slack"] = slackCh
|
||||||
|
logger.InfoC("channels", "Slack channel enabled successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|||||||
446
pkg/channels/slack.go
Normal file
446
pkg/channels/slack.go
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SlackChannel struct {
|
||||||
|
*BaseChannel
|
||||||
|
config config.SlackConfig
|
||||||
|
api *slack.Client
|
||||||
|
socketClient *socketmode.Client
|
||||||
|
botUserID string
|
||||||
|
transcriber *voice.GroqTranscriber
|
||||||
|
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 := NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom)
|
||||||
|
|
||||||
|
return &SlackChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
api: api,
|
||||||
|
socketClient: socketClient,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SlackChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
||||||
|
c.transcriber = transcriber
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
logger.InfoCF("slack", "Slack bot connected", map[string]interface{}{
|
||||||
|
"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]interface{}{
|
||||||
|
"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 fmt.Errorf("slack channel not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
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("failed to send slack message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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]interface{}{
|
||||||
|
"channel_id": channelID,
|
||||||
|
"thread_ts": threadTS,
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
case *slackevents.ReactionAddedEvent:
|
||||||
|
c.handleReactionAdded(ev)
|
||||||
|
case *slackevents.ReactionRemovedEvent:
|
||||||
|
c.handleReactionRemoved(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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
var mediaPaths []string
|
||||||
|
|
||||||
|
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, localPath)
|
||||||
|
|
||||||
|
if isAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
result, err := c.transcriber.Transcribe(ctx, localPath)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("slack", "Voice transcription failed", map[string]interface{}{"error": err.Error()})
|
||||||
|
content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name)
|
||||||
|
} else {
|
||||||
|
content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content += fmt.Sprintf("\n[file: %s]", file.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := map[string]string{
|
||||||
|
"message_ts": messageTS,
|
||||||
|
"channel_id": channelID,
|
||||||
|
"thread_ts": threadTS,
|
||||||
|
"platform": "slack",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("slack", "Received message", map[string]interface{}{
|
||||||
|
"sender_id": senderID,
|
||||||
|
"chat_id": chatID,
|
||||||
|
"preview": truncateStringSlack(content, 50),
|
||||||
|
"has_thread": threadTS != "",
|
||||||
|
})
|
||||||
|
|
||||||
|
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
||||||
|
if ev.User == c.botUserID {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := map[string]string{
|
||||||
|
"message_ts": messageTS,
|
||||||
|
"channel_id": channelID,
|
||||||
|
"thread_ts": threadTS,
|
||||||
|
"platform": "slack",
|
||||||
|
"is_mention": "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("slack", "Slash command received", map[string]interface{}{
|
||||||
|
"sender_id": senderID,
|
||||||
|
"command": cmd.Command,
|
||||||
|
"text": truncateStringSlack(content, 50),
|
||||||
|
})
|
||||||
|
|
||||||
|
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SlackChannel) handleReactionAdded(ev *slackevents.ReactionAddedEvent) {
|
||||||
|
logger.DebugCF("slack", "Reaction added", map[string]interface{}{
|
||||||
|
"reaction": ev.Reaction,
|
||||||
|
"user": ev.User,
|
||||||
|
"item_ts": ev.Item.Timestamp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SlackChannel) handleReactionRemoved(ev *slackevents.ReactionRemovedEvent) {
|
||||||
|
logger.DebugCF("slack", "Reaction removed", map[string]interface{}{
|
||||||
|
"reaction": ev.Reaction,
|
||||||
|
"user": ev.User,
|
||||||
|
"item_ts": ev.Item.Timestamp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SlackChannel) downloadSlackFile(file slack.File) string {
|
||||||
|
mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
|
||||||
|
if err := os.MkdirAll(mediaDir, 0755); err != nil {
|
||||||
|
logger.ErrorCF("slack", "Failed to create media directory", map[string]interface{}{"error": err.Error()})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL := file.URLPrivateDownload
|
||||||
|
if downloadURL == "" {
|
||||||
|
downloadURL = file.URLPrivate
|
||||||
|
}
|
||||||
|
if downloadURL == "" {
|
||||||
|
logger.ErrorCF("slack", "No download URL for file", map[string]interface{}{"file_id": file.ID})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath := filepath.Join(mediaDir, file.Name)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("slack", "Failed to create download request", map[string]interface{}{"error": err.Error()})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.config.BotToken)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("slack", "Failed to download file", map[string]interface{}{"error": err.Error()})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
logger.ErrorCF("slack", "File download returned non-200 status", map[string]interface{}{"status": resp.StatusCode})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(localPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("slack", "Failed to create local file", map[string]interface{}{"error": err.Error()})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||||
|
logger.ErrorCF("slack", "Failed to write file", map[string]interface{}{"error": err.Error()})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("slack", "File downloaded", map[string]interface{}{"path": localPath, "name": file.Name})
|
||||||
|
return localPath
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateStringSlack(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen]
|
||||||
|
}
|
||||||
193
pkg/channels/slack_test.go
Normal file
193
pkg/channels/slack_test.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseSlackChatID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
chatID string
|
||||||
|
wantChanID string
|
||||||
|
wantThread string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "channel only",
|
||||||
|
chatID: "C123456",
|
||||||
|
wantChanID: "C123456",
|
||||||
|
wantThread: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "channel with thread",
|
||||||
|
chatID: "C123456/1234567890.123456",
|
||||||
|
wantChanID: "C123456",
|
||||||
|
wantThread: "1234567890.123456",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DM channel",
|
||||||
|
chatID: "D987654",
|
||||||
|
wantChanID: "D987654",
|
||||||
|
wantThread: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
chatID: "",
|
||||||
|
wantChanID: "",
|
||||||
|
wantThread: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
chanID, threadTS := parseSlackChatID(tt.chatID)
|
||||||
|
if chanID != tt.wantChanID {
|
||||||
|
t.Errorf("parseSlackChatID(%q) channelID = %q, want %q", tt.chatID, chanID, tt.wantChanID)
|
||||||
|
}
|
||||||
|
if threadTS != tt.wantThread {
|
||||||
|
t.Errorf("parseSlackChatID(%q) threadTS = %q, want %q", tt.chatID, threadTS, tt.wantThread)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripBotMention(t *testing.T) {
|
||||||
|
ch := &SlackChannel{botUserID: "U12345BOT"}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "mention at start",
|
||||||
|
input: "<@U12345BOT> hello there",
|
||||||
|
want: "hello there",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mention in middle",
|
||||||
|
input: "hey <@U12345BOT> can you help",
|
||||||
|
want: "hey can you help",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no mention",
|
||||||
|
input: "hello world",
|
||||||
|
want: "hello world",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only mention",
|
||||||
|
input: "<@U12345BOT>",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := ch.stripBotMention(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("stripBotMention(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSlackChannel(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
|
||||||
|
t.Run("missing bot token", func(t *testing.T) {
|
||||||
|
cfg := config.SlackConfig{
|
||||||
|
BotToken: "",
|
||||||
|
AppToken: "xapp-test",
|
||||||
|
}
|
||||||
|
_, err := NewSlackChannel(cfg, msgBus)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing bot_token, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing app token", func(t *testing.T) {
|
||||||
|
cfg := config.SlackConfig{
|
||||||
|
BotToken: "xoxb-test",
|
||||||
|
AppToken: "",
|
||||||
|
}
|
||||||
|
_, err := NewSlackChannel(cfg, msgBus)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing app_token, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid config", func(t *testing.T) {
|
||||||
|
cfg := config.SlackConfig{
|
||||||
|
BotToken: "xoxb-test",
|
||||||
|
AppToken: "xapp-test",
|
||||||
|
AllowFrom: []string{"U123"},
|
||||||
|
}
|
||||||
|
ch, err := NewSlackChannel(cfg, msgBus)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if ch.Name() != "slack" {
|
||||||
|
t.Errorf("Name() = %q, want %q", ch.Name(), "slack")
|
||||||
|
}
|
||||||
|
if ch.IsRunning() {
|
||||||
|
t.Error("new channel should not be running")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlackChannelIsAllowed(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
|
||||||
|
t.Run("empty allowlist allows all", func(t *testing.T) {
|
||||||
|
cfg := config.SlackConfig{
|
||||||
|
BotToken: "xoxb-test",
|
||||||
|
AppToken: "xapp-test",
|
||||||
|
AllowFrom: []string{},
|
||||||
|
}
|
||||||
|
ch, _ := NewSlackChannel(cfg, msgBus)
|
||||||
|
if !ch.IsAllowed("U_ANYONE") {
|
||||||
|
t.Error("empty allowlist should allow all users")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allowlist restricts users", func(t *testing.T) {
|
||||||
|
cfg := config.SlackConfig{
|
||||||
|
BotToken: "xoxb-test",
|
||||||
|
AppToken: "xapp-test",
|
||||||
|
AllowFrom: []string{"U_ALLOWED"},
|
||||||
|
}
|
||||||
|
ch, _ := NewSlackChannel(cfg, msgBus)
|
||||||
|
if !ch.IsAllowed("U_ALLOWED") {
|
||||||
|
t.Error("allowed user should pass allowlist check")
|
||||||
|
}
|
||||||
|
if ch.IsAllowed("U_BLOCKED") {
|
||||||
|
t.Error("non-allowed user should be blocked")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateStringSlack(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
maxLen int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"hello", 10, "hello"},
|
||||||
|
{"hello world", 5, "hello"},
|
||||||
|
{"", 5, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := truncateStringSlack(tt.input, tt.maxLen)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("truncateStringSlack(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ type ChannelsConfig struct {
|
|||||||
MaixCam MaixCamConfig `json:"maixcam"`
|
MaixCam MaixCamConfig `json:"maixcam"`
|
||||||
QQ QQConfig `json:"qq"`
|
QQ QQConfig `json:"qq"`
|
||||||
DingTalk DingTalkConfig `json:"dingtalk"`
|
DingTalk DingTalkConfig `json:"dingtalk"`
|
||||||
|
Slack SlackConfig `json:"slack"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WhatsAppConfig struct {
|
type WhatsAppConfig struct {
|
||||||
@@ -88,6 +89,13 @@ type DingTalkConfig struct {
|
|||||||
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
|
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SlackConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
|
||||||
|
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
|
||||||
|
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
|
||||||
|
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProvidersConfig struct {
|
type ProvidersConfig struct {
|
||||||
Anthropic ProviderConfig `json:"anthropic"`
|
Anthropic ProviderConfig `json:"anthropic"`
|
||||||
OpenAI ProviderConfig `json:"openai"`
|
OpenAI ProviderConfig `json:"openai"`
|
||||||
@@ -175,6 +183,12 @@ func DefaultConfig() *Config {
|
|||||||
ClientSecret: "",
|
ClientSecret: "",
|
||||||
AllowFrom: []string{},
|
AllowFrom: []string{},
|
||||||
},
|
},
|
||||||
|
Slack: SlackConfig{
|
||||||
|
Enabled: false,
|
||||||
|
BotToken: "",
|
||||||
|
AppToken: "",
|
||||||
|
AllowFrom: []string{},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: ProvidersConfig{
|
||||||
Anthropic: ProviderConfig{},
|
Anthropic: ProviderConfig{},
|
||||||
|
|||||||
Reference in New Issue
Block a user