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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+343
View File
@@ -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
}
+70
View File
@@ -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")
}
}
+52
View File
@@ -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)
}
}
}
+148
View File
@@ -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)
}
}