WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/slack-go/slack"
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
)
|
||||
|
||||
// Compile-time checks.
|
||||
var (
|
||||
_ im.Adapter = (*Adapter)(nil)
|
||||
_ im.StreamSender = (*Adapter)(nil)
|
||||
_ im.FileDownloader = (*Adapter)(nil)
|
||||
)
|
||||
|
||||
// Adapter implements im.Adapter and im.StreamSender for Slack.
|
||||
// It delegates to the Slack LongConnClient for Socket Mode.
|
||||
type Adapter struct {
|
||||
client *LongConnClient
|
||||
api *slack.Client
|
||||
signingSecret string
|
||||
}
|
||||
|
||||
// NewAdapter creates an adapter backed by a Slack long connection client.
|
||||
func NewAdapter(client *LongConnClient, api *slack.Client) *Adapter {
|
||||
return &Adapter{
|
||||
client: client,
|
||||
api: api,
|
||||
}
|
||||
}
|
||||
|
||||
// NewWebhookAdapter creates an adapter for Slack Events API via Webhook.
|
||||
func NewWebhookAdapter(api *slack.Client, signingSecret string) *Adapter {
|
||||
return &Adapter{
|
||||
api: api,
|
||||
signingSecret: signingSecret,
|
||||
}
|
||||
}
|
||||
|
||||
func parseIncomingMessage(user, channel, text, ts string, chatType im.ChatType, files []slack.File) *im.IncomingMessage {
|
||||
content := text
|
||||
if chatType == im.ChatTypeGroup {
|
||||
// Slack mentions are in the format <@U12345678>
|
||||
for strings.HasPrefix(content, "<@") {
|
||||
idx := strings.Index(content, ">")
|
||||
if idx >= 0 {
|
||||
content = strings.TrimSpace(content[idx+1:])
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
msg := &im.IncomingMessage{
|
||||
Platform: im.PlatformSlack,
|
||||
UserID: user,
|
||||
ChatID: channel,
|
||||
ChatType: chatType,
|
||||
Content: strings.TrimSpace(content),
|
||||
MessageID: ts,
|
||||
ThreadID: ts,
|
||||
}
|
||||
|
||||
if len(files) > 0 {
|
||||
file := files[0]
|
||||
msg.FileKey = file.ID
|
||||
msg.FileName = file.Name
|
||||
msg.FileSize = int64(file.Size)
|
||||
msg.Extra = map[string]string{
|
||||
"url_private_download": file.URLPrivateDownload,
|
||||
}
|
||||
if strings.HasPrefix(file.Mimetype, "image/") {
|
||||
msg.MessageType = im.MessageTypeImage
|
||||
} else {
|
||||
msg.MessageType = im.MessageTypeFile
|
||||
}
|
||||
} else {
|
||||
msg.MessageType = im.MessageTypeText
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
func (a *Adapter) Platform() im.Platform {
|
||||
return im.PlatformSlack
|
||||
}
|
||||
|
||||
func (a *Adapter) VerifyCallback(c *gin.Context) error {
|
||||
if a.signingSecret == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
sv, err := slack.NewSecretsVerifier(c.Request.Header, a.signingSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new secrets verifier: %w", err)
|
||||
}
|
||||
if _, err := sv.Write(bodyBytes); err != nil {
|
||||
return fmt.Errorf("write body to verifier: %w", err)
|
||||
}
|
||||
if err := sv.Ensure(); err != nil {
|
||||
return fmt.Errorf("verify signature: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(bodyBytes), slackevents.OptionNoVerifyToken())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse event: %w", err)
|
||||
}
|
||||
|
||||
if eventsAPIEvent.Type == slackevents.CallbackEvent {
|
||||
var rawEvent struct {
|
||||
Event struct {
|
||||
Files []slack.File `json:"files"`
|
||||
} `json:"event"`
|
||||
}
|
||||
_ = json.Unmarshal(bodyBytes, &rawEvent)
|
||||
files := rawEvent.Event.Files
|
||||
|
||||
innerEvent := eventsAPIEvent.InnerEvent
|
||||
switch ev := innerEvent.Data.(type) {
|
||||
case *slackevents.AppMentionEvent:
|
||||
threadTs := ev.ThreadTimeStamp
|
||||
if threadTs == "" {
|
||||
threadTs = ev.TimeStamp
|
||||
}
|
||||
return parseIncomingMessage(ev.User, ev.Channel, ev.Text, threadTs, im.ChatTypeGroup, files), nil
|
||||
case *slackevents.MessageEvent:
|
||||
if ev.BotID != "" || (ev.SubType != "" && ev.SubType != "file_share") {
|
||||
return nil, nil
|
||||
}
|
||||
chatType := im.ChatTypeDirect
|
||||
if ev.ChannelType == "channel" || ev.ChannelType == "group" {
|
||||
chatType = im.ChatTypeGroup
|
||||
}
|
||||
threadTs := ev.ThreadTimeStamp
|
||||
if threadTs == "" {
|
||||
threadTs = ev.TimeStamp
|
||||
}
|
||||
return parseIncomingMessage(ev.User, ev.Channel, ev.Text, threadTs, chatType, files), nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
var body struct {
|
||||
Type string `json:"type"`
|
||||
Challenge string `json:"challenge"`
|
||||
}
|
||||
if err := json.Unmarshal(bodyBytes, &body); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if body.Type == "url_verification" {
|
||||
c.JSON(http.StatusOK, gin.H{"challenge": body.Challenge})
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
|
||||
channelID := incoming.ChatID
|
||||
if channelID == "" {
|
||||
channelID = incoming.UserID
|
||||
}
|
||||
|
||||
options := []slack.MsgOption{slack.MsgOptionText(reply.Content, false)}
|
||||
if incoming.MessageID != "" {
|
||||
options = append(options, slack.MsgOptionTS(incoming.MessageID))
|
||||
}
|
||||
|
||||
_, _, err := a.api.PostMessageContext(ctx, channelID, options...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack post message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// slackStreamState tracks per-stream accumulated content.
|
||||
type slackStreamState struct {
|
||||
mu sync.Mutex
|
||||
content strings.Builder
|
||||
ts string // The timestamp of the message being updated
|
||||
channel string // The channel ID
|
||||
}
|
||||
|
||||
var (
|
||||
slackStreamsMu sync.Mutex
|
||||
slackStreams = map[string]*slackStreamState{}
|
||||
)
|
||||
|
||||
func (a *Adapter) StartStream(ctx context.Context, incoming *im.IncomingMessage) (string, error) {
|
||||
channelID := incoming.ChatID
|
||||
if channelID == "" {
|
||||
channelID = incoming.UserID
|
||||
}
|
||||
|
||||
options := []slack.MsgOption{slack.MsgOptionText("正在思考...", false)}
|
||||
if incoming.MessageID != "" {
|
||||
options = append(options, slack.MsgOptionTS(incoming.MessageID))
|
||||
}
|
||||
|
||||
// Send initial "Thinking..." message
|
||||
_, ts, err := a.api.PostMessageContext(ctx, channelID, options...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("slack start stream: %w", err)
|
||||
}
|
||||
|
||||
streamID := fmt.Sprintf("%s:%s", channelID, ts)
|
||||
|
||||
slackStreamsMu.Lock()
|
||||
slackStreams[streamID] = &slackStreamState{
|
||||
ts: ts,
|
||||
channel: channelID,
|
||||
}
|
||||
slackStreamsMu.Unlock()
|
||||
|
||||
logger.Infof(ctx, "[Slack] Streaming started: stream_id=%s", streamID)
|
||||
return streamID, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) UpdateStreamContent(ctx context.Context, incoming *im.IncomingMessage, streamID string, fullContent string) error {
|
||||
if fullContent == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
slackStreamsMu.Lock()
|
||||
state, ok := slackStreams[streamID]
|
||||
slackStreamsMu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown stream ID: %s", streamID)
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
state.content.Reset()
|
||||
state.content.WriteString(fullContent)
|
||||
channel := state.channel
|
||||
ts := state.ts
|
||||
state.mu.Unlock()
|
||||
|
||||
_, _, _, err := a.api.UpdateMessageContext(ctx, channel, ts, slack.MsgOptionText(fullContent, false))
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "[Slack] Failed to update stream content: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) FinalizeStream(ctx context.Context, incoming *im.IncomingMessage, streamID string, finalContent string) error {
|
||||
return a.UpdateStreamContent(ctx, incoming, streamID, finalContent)
|
||||
}
|
||||
|
||||
func (a *Adapter) SendStreamChunk(ctx context.Context, incoming *im.IncomingMessage, streamID string, content string) error {
|
||||
return a.UpdateStreamContent(ctx, incoming, streamID, content)
|
||||
}
|
||||
|
||||
func (a *Adapter) EndStream(ctx context.Context, incoming *im.IncomingMessage, streamID string) error {
|
||||
slackStreamsMu.Lock()
|
||||
state, ok := slackStreams[streamID]
|
||||
delete(slackStreams, streamID)
|
||||
slackStreamsMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
fullContent := state.content.String()
|
||||
state.mu.Unlock()
|
||||
|
||||
_, _, _, err := a.api.UpdateMessageContext(ctx, state.channel, state.ts, slack.MsgOptionText(fullContent, false))
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "[Slack] Failed to end stream: %v", err)
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "[Slack] Streaming ended: stream_id=%s", streamID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
|
||||
if msg.FileKey == "" {
|
||||
return nil, "", fmt.Errorf("file_key is required")
|
||||
}
|
||||
|
||||
downloadURL := ""
|
||||
if msg.Extra != nil {
|
||||
downloadURL = msg.Extra["url_private_download"]
|
||||
}
|
||||
|
||||
if downloadURL == "" {
|
||||
file, _, _, err := a.api.GetFileInfoContext(ctx, msg.FileKey, 0, 0)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get file info: %w", err)
|
||||
}
|
||||
downloadURL = file.URLPrivateDownload
|
||||
}
|
||||
|
||||
if downloadURL == "" {
|
||||
return nil, "", fmt.Errorf("no download URL available for file %s", msg.FileKey)
|
||||
}
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
err := a.api.GetFileContext(ctx, downloadURL, pw)
|
||||
pw.CloseWithError(err)
|
||||
}()
|
||||
|
||||
return pr, msg.FileName, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
slacklib "github.com/slack-go/slack"
|
||||
)
|
||||
|
||||
func TestParseIncomingMessage_ThreadID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ts string
|
||||
wantThreadID string
|
||||
wantMessageID string
|
||||
}{
|
||||
{
|
||||
name: "top-level message uses own timestamp",
|
||||
ts: "1234567890.123456",
|
||||
wantThreadID: "1234567890.123456",
|
||||
wantMessageID: "1234567890.123456",
|
||||
},
|
||||
{
|
||||
name: "threaded reply uses thread_ts",
|
||||
ts: "1234567890.000001",
|
||||
wantThreadID: "1234567890.000001",
|
||||
wantMessageID: "1234567890.000001",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
msg := parseIncomingMessage("U123", "C456", "hello", tt.ts, im.ChatTypeGroup, nil)
|
||||
|
||||
if msg.ThreadID != tt.wantThreadID {
|
||||
t.Errorf("ThreadID = %q, want %q", msg.ThreadID, tt.wantThreadID)
|
||||
}
|
||||
if msg.MessageID != tt.wantMessageID {
|
||||
t.Errorf("MessageID = %q, want %q", msg.MessageID, tt.wantMessageID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIncomingMessage_ThreadID_WithFiles(t *testing.T) {
|
||||
files := []slacklib.File{
|
||||
{ID: "F123", Name: "test.pdf", Size: 1024, Mimetype: "application/pdf"},
|
||||
}
|
||||
|
||||
msg := parseIncomingMessage("U123", "C456", "", "1234567890.999", im.ChatTypeDirect, files)
|
||||
|
||||
if msg.ThreadID != "1234567890.999" {
|
||||
t.Errorf("ThreadID = %q, want %q", msg.ThreadID, "1234567890.999")
|
||||
}
|
||||
if msg.MessageType != im.MessageTypeFile {
|
||||
t.Errorf("MessageType = %q, want %q", msg.MessageType, im.MessageTypeFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIncomingMessage_MentionStripping(t *testing.T) {
|
||||
// Ensure mention stripping doesn't affect ThreadID
|
||||
msg := parseIncomingMessage("U123", "C456", "<@U999> hello world", "ts-123", im.ChatTypeGroup, nil)
|
||||
|
||||
if msg.ThreadID != "ts-123" {
|
||||
t.Errorf("ThreadID = %q, want %q", msg.ThreadID, "ts-123")
|
||||
}
|
||||
if msg.Content != "hello world" {
|
||||
t.Errorf("Content = %q, want %q", msg.Content, "hello world")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
slackpkg "github.com/slack-go/slack"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
)
|
||||
|
||||
// NewFactory returns an im.AdapterFactory for Slack channels.
|
||||
// Supports "webhook" (Events API) and "websocket" (Socket Mode, default).
|
||||
func NewFactory() im.AdapterFactory {
|
||||
return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
|
||||
creds, err := im.ParseCredentials(channel.Credentials)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse slack credentials: %w", err)
|
||||
}
|
||||
|
||||
mode := im.ResolveMode(channel, "websocket")
|
||||
|
||||
switch mode {
|
||||
case "webhook":
|
||||
api := slackpkg.New(im.GetString(creds, "bot_token"))
|
||||
adapter := NewWebhookAdapter(api, im.GetString(creds, "signing_secret"))
|
||||
return adapter, func() {}, nil
|
||||
|
||||
case "websocket":
|
||||
client := NewLongConnClient(
|
||||
im.GetString(creds, "app_token"),
|
||||
im.GetString(creds, "bot_token"),
|
||||
msgHandler,
|
||||
)
|
||||
|
||||
adapter := NewAdapter(client, client.GetAPI())
|
||||
|
||||
wsCtx, wsCancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
if err := client.Start(wsCtx); err != nil && wsCtx.Err() == nil {
|
||||
logger.Errorf(context.Background(), "[IM] Slack long connection stopped for channel %s: %v", channel.ID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
return adapter, wsCancel, nil
|
||||
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported slack mode: %s", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/slack-go/slack"
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
"github.com/slack-go/slack/socketmode"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
)
|
||||
|
||||
// MessageHandler is called when an IM message is received via long connection.
|
||||
type MessageHandler func(ctx context.Context, msg *im.IncomingMessage) error
|
||||
|
||||
// LongConnClient manages a Slack Socket Mode long connection.
|
||||
type LongConnClient struct {
|
||||
appToken string
|
||||
botToken string
|
||||
handler MessageHandler
|
||||
|
||||
api *slack.Client
|
||||
client *socketmode.Client
|
||||
}
|
||||
|
||||
// NewLongConnClient creates a Slack long connection client.
|
||||
func NewLongConnClient(appToken, botToken string, handler MessageHandler) *LongConnClient {
|
||||
api := slack.New(
|
||||
botToken,
|
||||
slack.OptionAppLevelToken(appToken),
|
||||
)
|
||||
|
||||
client := socketmode.New(
|
||||
api,
|
||||
socketmode.OptionDebug(false), // true for debugging
|
||||
)
|
||||
|
||||
return &LongConnClient{
|
||||
appToken: appToken,
|
||||
botToken: botToken,
|
||||
handler: handler,
|
||||
api: api,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAPI returns the underlying slack API client.
|
||||
func (c *LongConnClient) GetAPI() *slack.Client {
|
||||
return c.api
|
||||
}
|
||||
|
||||
// Start begins the WebSocket long connection. It blocks until ctx is cancelled.
|
||||
func (c *LongConnClient) Start(ctx context.Context) error {
|
||||
logger.Infof(ctx, "[IM] Slack WebSocket connecting...")
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case evt := <-c.client.Events:
|
||||
switch evt.Type {
|
||||
case socketmode.EventTypeConnecting:
|
||||
logger.Infof(ctx, "[Slack] Connecting to Slack with Socket Mode...")
|
||||
case socketmode.EventTypeConnectionError:
|
||||
logger.Errorf(ctx, "[Slack] Connection failed. Retrying later...")
|
||||
case socketmode.EventTypeConnected:
|
||||
logger.Infof(ctx, "[IM] Slack WebSocket connected successfully")
|
||||
case socketmode.EventTypeEventsAPI:
|
||||
eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
|
||||
if !ok {
|
||||
logger.Warnf(ctx, "[Slack] Ignored %+v", evt)
|
||||
continue
|
||||
}
|
||||
|
||||
// Acknowledge the event
|
||||
c.client.Ack(*evt.Request)
|
||||
|
||||
// Handle the event
|
||||
c.handleEvent(ctx, eventsAPIEvent, evt.Request.Payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return c.client.RunContext(ctx)
|
||||
}
|
||||
|
||||
func (c *LongConnClient) handleEvent(ctx context.Context, eventsAPIEvent slackevents.EventsAPIEvent, rawPayload json.RawMessage) {
|
||||
logger.Infof(ctx, "[Slack] Received event type: %s", eventsAPIEvent.Type)
|
||||
switch eventsAPIEvent.Type {
|
||||
case slackevents.CallbackEvent:
|
||||
var rawEvent struct {
|
||||
Event struct {
|
||||
Files []slack.File `json:"files"`
|
||||
} `json:"event"`
|
||||
}
|
||||
_ = json.Unmarshal(rawPayload, &rawEvent)
|
||||
files := rawEvent.Event.Files
|
||||
|
||||
innerEvent := eventsAPIEvent.InnerEvent
|
||||
logger.Infof(ctx, "[Slack] Received inner event type: %s", innerEvent.Type)
|
||||
switch ev := innerEvent.Data.(type) {
|
||||
case *slackevents.AppMentionEvent:
|
||||
logger.Infof(ctx, "[Slack] AppMentionEvent: user=%s channel=%s text=%s", ev.User, ev.Channel, ev.Text)
|
||||
// Handle @bot mention in a channel
|
||||
threadTs := ev.ThreadTimeStamp
|
||||
if threadTs == "" {
|
||||
threadTs = ev.TimeStamp
|
||||
}
|
||||
c.processMessage(ctx, ev.User, ev.Channel, ev.Text, threadTs, im.ChatTypeGroup, files)
|
||||
case *slackevents.MessageEvent:
|
||||
logger.Infof(ctx, "[Slack] MessageEvent: user=%s channel=%s text=%s subtype=%s bot_id=%s", ev.User, ev.Channel, ev.Text, ev.SubType, ev.BotID)
|
||||
if ev.BotID != "" {
|
||||
return
|
||||
}
|
||||
if ev.SubType != "" && ev.SubType != "file_share" {
|
||||
return
|
||||
}
|
||||
|
||||
chatType := im.ChatTypeDirect
|
||||
if ev.ChannelType == "channel" || ev.ChannelType == "group" {
|
||||
chatType = im.ChatTypeGroup
|
||||
}
|
||||
|
||||
threadTs := ev.ThreadTimeStamp
|
||||
if threadTs == "" {
|
||||
threadTs = ev.TimeStamp
|
||||
}
|
||||
|
||||
c.processMessage(ctx, ev.User, ev.Channel, ev.Text, threadTs, chatType, files)
|
||||
default:
|
||||
logger.Warnf(ctx, "[Slack] Unhandled inner event type: %T", innerEvent.Data)
|
||||
}
|
||||
default:
|
||||
logger.Warnf(ctx, "[Slack] Unhandled event type: %s", eventsAPIEvent.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LongConnClient) processMessage(ctx context.Context, user, channel, text, ts string, chatType im.ChatType, files []slack.File) {
|
||||
incoming := parseIncomingMessage(user, channel, text, ts, chatType, files)
|
||||
|
||||
if err := c.handler(ctx, incoming); err != nil {
|
||||
logger.Errorf(ctx, "[Slack] Handle message error: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user