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
File diff suppressed because it is too large Load Diff
+441
View File
@@ -0,0 +1,441 @@
package feishu
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestFeishuThreadID_ThreadedReply(t *testing.T) {
// Simulate: message is a reply in a thread (root_id is set)
msg := &feishuMessage{
MessageID: "msg-reply-1",
RootID: "msg-root-1",
ParentID: "msg-parent-1",
}
threadID := msg.RootID
if threadID == "" {
threadID = msg.MessageID
}
if threadID != "msg-root-1" {
t.Errorf("threadID = %q, want %q", threadID, "msg-root-1")
}
}
func TestFeishuThreadID_TopLevelMessage(t *testing.T) {
// Simulate: top-level message (root_id is empty)
msg := &feishuMessage{
MessageID: "msg-top-1",
RootID: "",
ParentID: "",
}
threadID := msg.RootID
if threadID == "" {
threadID = msg.MessageID
}
if threadID != "msg-top-1" {
t.Errorf("threadID = %q, want %q (should use MessageID as fallback)", threadID, "msg-top-1")
}
}
func TestFeishuMessageStruct_JSONFields(t *testing.T) {
// Verify the struct fields exist and have correct zero values
msg := feishuMessage{}
if msg.RootID != "" {
t.Errorf("RootID zero value = %q, want empty", msg.RootID)
}
if msg.ParentID != "" {
t.Errorf("ParentID zero value = %q, want empty", msg.ParentID)
}
if msg.MessageID != "" {
t.Errorf("MessageID zero value = %q, want empty", msg.MessageID)
}
}
func TestImageCacheKey_StripsQuery(t *testing.T) {
cases := map[string]string{
"https://host/a.png?sig=1&t=2": "https://host/a.png",
"https://host/a.png": "https://host/a.png",
}
for in, want := range cases {
if got := imageCacheKey(in); got != want {
t.Errorf("imageCacheKey(%q) = %q, want %q", in, got, want)
}
}
}
func TestResolveMarkdownImages_NoImageUnchanged(t *testing.T) {
a := &Adapter{region: RegionFeishu}
in := "hello **world** [link](https://example.com)"
if got := a.resolveMarkdownImages(context.Background(), "tok", in); got != in {
t.Errorf("content without image was modified: %q", got)
}
}
func TestResolveMarkdownImages_FallbackToLinkOnFailure(t *testing.T) {
a := &Adapter{region: RegionFeishu}
// A direct-IP loopback URL fails SSRF validation before any network call,
// so the image must degrade to a plain markdown link (never left as ![]()).
in := "see ![diagram](http://127.0.0.1/x.png) here"
got := a.resolveMarkdownImages(context.Background(), "tok", in)
if strings.Contains(got, "![") {
t.Errorf("failed image should not remain as image markdown: %q", got)
}
if !strings.Contains(got, "[diagram](http://127.0.0.1/x.png)") {
t.Errorf("expected link fallback with alt text, got: %q", got)
}
}
// An image with no alt text falls back to the region's own label, so Lark users
// do not get a Chinese link label.
func TestResolveMarkdownImages_FallbackLabelFollowsRegion(t *testing.T) {
for _, region := range []Region{RegionFeishu, RegionLark} {
a := &Adapter{region: region}
got := a.resolveMarkdownImages(context.Background(), "tok", "![](http://127.0.0.1/x.png)")
want := "[" + region.ImageFallbackLabel + "](http://127.0.0.1/x.png)"
if got != want {
t.Errorf("%s fallback = %q, want %q", region.Label, got, want)
}
}
}
func TestEndStreamFinalizesCardSummary(t *testing.T) {
useTestHTTPClient(t)
type recordedRequest struct {
method string
path string
authorization string
sequence int
content string
settings string
statePresent bool
}
var requests []recordedRequest
const cardID = "card-final-summary"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal" {
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0, "tenant_access_token": "test-token", "expire": 7200,
})
return
}
var body struct {
Sequence int `json:"sequence"`
Content string `json:"content"`
Settings string `json:"settings"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode %s: %v", r.URL.Path, err)
}
feishuStreamsMu.Lock()
_, statePresent := feishuStreams[cardID]
feishuStreamsMu.Unlock()
requests = append(requests, recordedRequest{
method: r.Method, path: r.URL.Path, authorization: r.Header.Get("Authorization"),
sequence: body.Sequence, content: body.Content, settings: body.Settings,
statePresent: statePresent,
})
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "ok"})
}))
defer srv.Close()
adapter, _ := NewAdapter(testRegion(srv.URL), "app", "secret", "", "", "")
feishuStreamsMu.Lock()
feishuStreams[cardID] = &feishuStreamState{}
feishuStreamsMu.Unlock()
t.Cleanup(func() {
feishuStreamsMu.Lock()
delete(feishuStreams, cardID)
feishuStreamsMu.Unlock()
})
ctx := context.Background()
if err := adapter.UpdateStreamContent(ctx, nil, cardID, "progress"); err != nil {
t.Fatalf("UpdateStreamContent: %v", err)
}
const finalContent = " 最终\n回答 ✅ "
if err := adapter.FinalizeStream(ctx, nil, cardID, finalContent); err != nil {
t.Fatalf("FinalizeStream: %v", err)
}
if err := adapter.EndStream(ctx, nil, cardID); err != nil {
t.Fatalf("EndStream: %v", err)
}
if len(requests) != 3 {
t.Fatalf("CardKit request count = %d, want 3", len(requests))
}
for i, request := range requests {
if request.authorization != "Bearer test-token" {
t.Errorf("request %d Authorization = %q", i, request.authorization)
}
if request.sequence != i+1 {
t.Errorf("request %d sequence = %d, want %d", i, request.sequence, i+1)
}
}
if requests[1].content != finalContent {
t.Errorf("final content = %q, want %q", requests[1].content, finalContent)
}
if requests[2].method != http.MethodPatch || requests[2].path != "/open-apis/cardkit/v1/cards/"+cardID+"/settings" {
t.Errorf("final request = %s %s", requests[2].method, requests[2].path)
}
if requests[2].statePresent {
t.Error("stream state still present when final settings were sent")
}
var settings struct {
Config *struct {
StreamingMode *bool `json:"streaming_mode"`
Summary struct {
Content string `json:"content"`
} `json:"summary"`
} `json:"config"`
}
if err := json.Unmarshal([]byte(requests[2].settings), &settings); err != nil {
t.Fatalf("decode final settings: %v", err)
}
if settings.Config == nil || settings.Config.StreamingMode == nil || *settings.Config.StreamingMode {
t.Fatalf("final settings did not disable streaming under config: %s", requests[2].settings)
}
if settings.Config.Summary.Content != "最终 回答 ✅" {
t.Errorf("summary = %q, want %q", settings.Config.Summary.Content, "最终 回答 ✅")
}
if err := adapter.EndStream(ctx, nil, "missing-card"); err != nil {
t.Fatalf("EndStream missing state: %v", err)
}
if len(requests) != 4 {
t.Fatalf("request count after missing state = %d, want 4", len(requests))
}
assertStreamingClosed(t, requests[3].settings, "")
if requests[3].sequence != 0 {
t.Errorf("missing-state sequence = %d, want 0", requests[3].sequence)
}
}
func TestEndStreamSummaryUsesLastSuccessfulContent(t *testing.T) {
useTestHTTPClient(t)
const cardID = "card-failed-final-content"
var finalSettings string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal" {
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0, "tenant_access_token": "test-token", "expire": 7200,
})
return
}
var body struct {
Content string `json:"content"`
Settings string `json:"settings"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode %s: %v", r.URL.Path, err)
return
}
if body.Content == "failed final content" {
_ = json.NewEncoder(w).Encode(map[string]any{"code": 1, "msg": "rejected"})
return
}
if r.Method == http.MethodPatch {
finalSettings = body.Settings
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "ok"})
}))
defer srv.Close()
adapter, _ := NewAdapter(testRegion(srv.URL), "app", "secret", "", "", "")
feishuStreamsMu.Lock()
feishuStreams[cardID] = &feishuStreamState{}
feishuStreamsMu.Unlock()
t.Cleanup(func() {
feishuStreamsMu.Lock()
delete(feishuStreams, cardID)
feishuStreamsMu.Unlock()
})
ctx := context.Background()
if err := adapter.UpdateStreamContent(ctx, nil, cardID, "visible progress"); err != nil {
t.Fatalf("UpdateStreamContent: %v", err)
}
if err := adapter.FinalizeStream(ctx, nil, cardID, "failed final content"); err == nil {
t.Fatal("FinalizeStream succeeded, want CardKit rejection")
}
if err := adapter.EndStream(ctx, nil, cardID); err != nil {
t.Fatalf("EndStream: %v", err)
}
var settings struct {
Config struct {
Summary struct {
Content string `json:"content"`
} `json:"summary"`
} `json:"config"`
}
if err := json.Unmarshal([]byte(finalSettings), &settings); err != nil {
t.Fatalf("decode final settings: %v", err)
}
if settings.Config.Summary.Content != "visible progress" {
t.Errorf("summary = %q, want last successful content", settings.Config.Summary.Content)
}
}
func TestEndStreamFinalizeOnlyUsesFinalContent(t *testing.T) {
useTestHTTPClient(t)
const cardID = "card-full-output"
var finalSettings string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal" {
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0, "tenant_access_token": "test-token", "expire": 7200,
})
return
}
var body struct {
Settings string `json:"settings"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode %s: %v", r.URL.Path, err)
return
}
if r.Method == http.MethodPatch {
finalSettings = body.Settings
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "ok"})
}))
defer srv.Close()
adapter, _ := NewAdapter(testRegion(srv.URL), "app", "secret", "", "", "")
feishuStreamsMu.Lock()
feishuStreams[cardID] = &feishuStreamState{}
feishuStreamsMu.Unlock()
t.Cleanup(func() {
feishuStreamsMu.Lock()
delete(feishuStreams, cardID)
feishuStreamsMu.Unlock()
})
ctx := context.Background()
if err := adapter.FinalizeStream(ctx, nil, cardID, "最终答案"); err != nil {
t.Fatalf("FinalizeStream: %v", err)
}
if err := adapter.EndStream(ctx, nil, cardID); err != nil {
t.Fatalf("EndStream: %v", err)
}
assertStreamingClosed(t, finalSettings, "最终答案")
}
func TestEndStreamOmitsEmptySummary(t *testing.T) {
useTestHTTPClient(t)
const cardID = "card-empty-summary"
var finalSettings string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal" {
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0, "tenant_access_token": "test-token", "expire": 7200,
})
return
}
var body struct {
Settings string `json:"settings"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode %s: %v", r.URL.Path, err)
return
}
if r.Method == http.MethodPatch {
finalSettings = body.Settings
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "ok"})
}))
defer srv.Close()
adapter, _ := NewAdapter(testRegion(srv.URL), "app", "secret", "", "", "")
feishuStreamsMu.Lock()
feishuStreams[cardID] = &feishuStreamState{}
feishuStreamsMu.Unlock()
t.Cleanup(func() {
feishuStreamsMu.Lock()
delete(feishuStreams, cardID)
feishuStreamsMu.Unlock()
})
if err := adapter.EndStream(context.Background(), nil, cardID); err != nil {
t.Fatalf("EndStream: %v", err)
}
assertStreamingClosed(t, finalSettings, "")
}
func TestCardSummaryPreview(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "collapse whitespace", in: " 最终\n回答\t✅ ", want: "最终 回答 ✅"},
{name: "image keeps alt only", in: "![架构图](https://cdn.example/a.png?sig=secret) 说明", want: "架构图 说明"},
{name: "link keeps text only", in: "见 [文档](https://cdn.example/a.png?sig=secret) 说明", want: "见 文档 说明"},
{name: "unicode limit", in: strings.Repeat("界", 121), want: strings.Repeat("界", 120)},
{name: "empty", in: " \n\t ", want: ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := cardSummaryPreview(test.in); got != test.want {
t.Errorf("cardSummaryPreview() = %q, want %q", got, test.want)
}
})
}
}
type cardSettings struct {
Config *struct {
StreamingMode *bool `json:"streaming_mode"`
Summary *struct {
Content string `json:"content"`
} `json:"summary"`
} `json:"config"`
}
func decodeCardSettings(t *testing.T, raw string) cardSettings {
t.Helper()
var settings cardSettings
if err := json.Unmarshal([]byte(raw), &settings); err != nil {
t.Fatalf("decode settings %q: %v", raw, err)
}
var top map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &top); err != nil {
t.Fatalf("decode settings map %q: %v", raw, err)
}
if _, ok := top["streaming_mode"]; ok {
t.Fatalf("streaming_mode must be nested under config: %s", raw)
}
return settings
}
func assertStreamingClosed(t *testing.T, raw, wantSummary string) {
t.Helper()
settings := decodeCardSettings(t, raw)
if settings.Config == nil || settings.Config.StreamingMode == nil || *settings.Config.StreamingMode {
t.Fatalf("settings did not disable streaming under config: %s", raw)
}
if wantSummary == "" {
if settings.Config.Summary != nil {
t.Fatalf("settings included empty summary: %s", raw)
}
return
}
if settings.Config.Summary == nil || settings.Config.Summary.Content != wantSummary {
t.Fatalf("summary = %v, want %q in %s", settings.Config.Summary, wantSummary, raw)
}
}
+69
View File
@@ -0,0 +1,69 @@
package feishu
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
)
// NewFactory returns an im.AdapterFactory for channels on the given region's
// cloud — RegionFeishu for 飞书, RegionLark for Lark. Both use the same
// credentials and modes; only the API host and tenant differ.
//
// The HTTP adapter is always created (needed for SendReply in both modes);
// "websocket" mode additionally runs a long-connection event stream.
func NewFactory(region Region) 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 %s credentials: %w", region.Platform, err)
}
appID := im.GetString(creds, "app_id")
appSecret := im.GetString(creds, "app_secret")
verificationToken := im.GetString(creds, "verification_token")
encryptKey := im.GetString(creds, "encrypt_key")
apiBaseURL := im.GetString(creds, "api_base_url")
// Always create the HTTP adapter (needed for SendReply in both modes).
// apiBaseURL is validated here; an invalid value fails the channel.
adapter, err := NewAdapter(region, appID, appSecret, verificationToken, encryptKey, apiBaseURL)
if err != nil {
return nil, nil, fmt.Errorf("create %s adapter: %w", region.Platform, err)
}
mode := im.ResolveMode(channel, "websocket")
switch mode {
case "webhook":
return adapter, nil, nil
case "websocket":
client := NewLongConnClient(region, appID, appSecret, apiBaseURL, msgHandler)
wsCtx, wsCancel := context.WithCancel(context.Background())
go func() {
if err := client.Start(wsCtx); err != nil && wsCtx.Err() == nil {
logger.Errorf(context.Background(), "[IM] %s long connection stopped for channel %s: %v",
region.Label, channel.ID, err)
}
}()
// stop tears down the connection for real. wsCancel alone is a
// no-op: the Feishu SDK's Start blocks on select{} and never
// observes ctx, so we must call Close() to actually close the
// socket and disable auto-reconnect. wsCancel is kept as a
// belt-and-braces fallback for the start goroutine.
stop := func() {
client.Close()
wsCancel()
}
return adapter, stop, nil
default:
return nil, nil, fmt.Errorf("unknown %s mode: %s", region.Platform, mode)
}
}
}
+353
View File
@@ -0,0 +1,353 @@
package feishu
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
)
// 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 Feishu/Lark WebSocket long connection.
type LongConnClient struct {
region Region
appID string
wsClient *larkws.Client
}
// NewLongConnClient creates a long connection client on the given region's cloud.
// apiBaseURL overrides the SDK's bootstrap domain (empty uses the region
// default); set it to a reverse-proxy origin for private/internal deployments.
// When a message arrives, it converts it to IncomingMessage and calls handler.
func NewLongConnClient(region Region, appID, appSecret, apiBaseURL string, handler MessageHandler) *LongConnClient {
// Long connection mode does not require verificationToken or encryptKey;
// those are only used for webhook signature verification and decryption.
eventHandler := dispatcher.NewEventDispatcher("", "").
OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
msg := convertEvent(region, event)
if msg == nil {
return nil
}
return handler(ctx, msg)
})
sdkLogger := &feishuLoggerAdapter{region: region, appID: appID}
apiBaseURL = strings.TrimRight(strings.TrimSpace(apiBaseURL), "/")
if apiBaseURL == "" {
apiBaseURL = region.OpenBaseURL
}
// WithDomain points the SDK at the region's cloud (open.feishu.cn by
// default). apiBaseURL overrides it for deployments that reach Feishu
// through a reverse proxy.
wsClient := larkws.NewClient(appID, appSecret,
larkws.WithEventHandler(eventHandler),
larkws.WithAutoReconnect(true),
larkws.WithLogger(sdkLogger),
larkws.WithDomain(apiBaseURL),
)
return &LongConnClient{region: region, appID: appID, wsClient: wsClient}
}
// Start begins the WebSocket long connection. It blocks until ctx is cancelled.
func (c *LongConnClient) Start(ctx context.Context) error {
logger.Infof(ctx, "[IM] %s WebSocket connecting (app_id=%s)...", c.region.Label, c.appID)
return c.wsClient.Start(ctx)
}
// Close tears down the WebSocket long connection.
//
// This is the only reliable way to stop a long connection: the SDK's
// Start blocks on a bare select{} and neither Start, pingLoop, nor
// receiveMessageLoop observe the passed context, so cancelling ctx alone leaves
// the underlying socket alive and the SDK auto-reconnecting. Client.Close()
// (added in oapi-sdk-go v3.9.7) flips autoReconnect off and calls disconnect,
// which actually closes the socket. Callers should still cancel the start ctx
// as a belt-and-braces fallback for the start goroutine.
func (c *LongConnClient) Close() {
c.wsClient.Close()
}
// feishuLoggerAdapter bridges the Feishu/Lark SDK logger to our unified logger,
// replacing raw SDK connection messages with a consistent format.
type feishuLoggerAdapter struct {
region Region
appID string
}
func (l *feishuLoggerAdapter) Debug(ctx context.Context, args ...interface{}) {
logger.Debugf(ctx, "[%s] %s", l.region.Label, fmt.Sprint(args...))
}
func (l *feishuLoggerAdapter) Info(ctx context.Context, args ...interface{}) {
msg := fmt.Sprint(args...)
if strings.HasPrefix(msg, "connected to ") {
logger.Infof(ctx, "[IM] %s WebSocket connected successfully (app_id=%s)", l.region.Label, l.appID)
return
}
logger.Infof(ctx, "[%s] %s", l.region.Label, msg)
}
func (l *feishuLoggerAdapter) Warn(ctx context.Context, args ...interface{}) {
logger.Warnf(ctx, "[%s] %s", l.region.Label, fmt.Sprint(args...))
}
func (l *feishuLoggerAdapter) Error(ctx context.Context, args ...interface{}) {
logger.Errorf(ctx, "[%s] %s", l.region.Label, fmt.Sprint(args...))
}
// convertEvent converts a Feishu/Lark SDK event to a unified IncomingMessage.
// Supports text, file, image and post messages. Returns nil for other types.
func convertEvent(region Region, event *larkim.P2MessageReceiveV1) *im.IncomingMessage {
if event == nil || event.Event == nil || event.Event.Message == nil {
logger.Warnf(context.Background(), "[%s][RX] event dropped: nil event/event/message", region.Label)
return nil
}
msg := event.Event.Message
// Debug: log every raw receive event so we can see exactly what the platform
// delivers (or doesn't). This is the single chokepoint for all message
// types — adding it here catches text/file/image/post uniformly.
logger.Infof(context.Background(),
"[%s][RX] msg_type=%q chat_type=%q chat_id=%q msg_id=%q root_id=%q parent_id=%q thread_id=%q content=%q",
region.Label,
ptrStr(msg.MessageType), ptrStr(msg.ChatType), ptrStr(msg.ChatId),
ptrStr(msg.MessageId), ptrStr(msg.RootId), ptrStr(msg.ParentId),
ptrStr(msg.ThreadId), ptrStr(msg.Content),
)
if msg.MessageType == nil {
return nil
}
msgType := *msg.MessageType
// Sender info
openID := ""
if event.Event.Sender != nil && event.Event.Sender.SenderId != nil && event.Event.Sender.SenderId.OpenId != nil {
openID = *event.Event.Sender.SenderId.OpenId
}
// Chat type
chatType := im.ChatTypeDirect
chatID := ""
if msg.ChatType != nil && *msg.ChatType == "group" {
chatType = im.ChatTypeGroup
if msg.ChatId != nil {
chatID = *msg.ChatId
}
}
// Message ID
messageID := ""
if msg.MessageId != nil {
messageID = *msg.MessageId
}
switch msgType {
case "text":
return convertTextEvent(region, msg, openID, chatID, chatType, messageID)
case "file":
return convertFileEvent(region, msg, openID, chatID, chatType, messageID)
case "image":
return convertImageEvent(region, msg, openID, chatID, chatType, messageID)
case "post":
return convertPostEvent(region, msg, openID, chatID, chatType, messageID)
default:
return nil
}
}
// convertTextEvent handles text message type.
func convertTextEvent(
region Region, msg *larkim.EventMessage,
openID, chatID string, chatType im.ChatType, messageID string,
) *im.IncomingMessage {
var textContent struct {
Text string `json:"text"`
}
if msg.Content == nil {
return nil
}
if err := json.Unmarshal([]byte(*msg.Content), &textContent); err != nil {
return nil
}
content := textContent.Text
if chatType == im.ChatTypeGroup {
for strings.HasPrefix(content, "@_user_") {
idx := strings.Index(content, " ")
if idx >= 0 {
content = content[idx+1:]
} else {
break
}
}
}
return &im.IncomingMessage{
Platform: region.Platform,
MessageType: im.MessageTypeText,
UserID: openID,
ChatID: chatID,
ChatType: chatType,
Content: strings.TrimSpace(content),
MessageID: messageID,
}
}
// convertFileEvent handles file message type.
func convertFileEvent(
region Region, msg *larkim.EventMessage,
openID, chatID string, chatType im.ChatType, messageID string,
) *im.IncomingMessage {
if msg.Content == nil {
return nil
}
var fileContent struct {
FileKey string `json:"file_key"`
FileName string `json:"file_name"`
}
if err := json.Unmarshal([]byte(*msg.Content), &fileContent); err != nil {
return nil
}
if fileContent.FileKey == "" {
return nil
}
return &im.IncomingMessage{
Platform: region.Platform,
MessageType: im.MessageTypeFile,
UserID: openID,
ChatID: chatID,
ChatType: chatType,
MessageID: messageID,
FileKey: fileContent.FileKey,
FileName: fileContent.FileName,
}
}
// convertImageEvent handles image message type.
// Downloads via GetMessageResource API with type=image.
func convertImageEvent(
region Region, msg *larkim.EventMessage,
openID, chatID string, chatType im.ChatType, messageID string,
) *im.IncomingMessage {
if msg.Content == nil {
return nil
}
var imageContent struct {
ImageKey string `json:"image_key"`
}
if err := json.Unmarshal([]byte(*msg.Content), &imageContent); err != nil {
return nil
}
if imageContent.ImageKey == "" {
return nil
}
return &im.IncomingMessage{
Platform: region.Platform,
MessageType: im.MessageTypeImage,
UserID: openID,
ChatID: chatID,
ChatType: chatType,
MessageID: messageID,
FileKey: imageContent.ImageKey,
FileName: imageContent.ImageKey + ".png",
}
}
// convertPostEvent handles rich-text (post) message type.
// Extracts all plain text content and treats it as a text query for QA.
func convertPostEvent(
region Region, msg *larkim.EventMessage,
openID, chatID string, chatType im.ChatType, messageID string,
) *im.IncomingMessage {
if msg.Content == nil {
return nil
}
// Post content structure: {"title":"...", "content":[[{"tag":"text","text":"..."},{"tag":"a","href":"...","text":"..."}]]}
var postContent struct {
Title string `json:"title"`
Content [][]json.RawMessage `json:"content"`
}
if err := json.Unmarshal([]byte(*msg.Content), &postContent); err != nil {
return nil
}
var textParts []string
if postContent.Title != "" {
textParts = append(textParts, postContent.Title)
}
for _, line := range postContent.Content {
var lineText strings.Builder
for _, elem := range line {
var tag struct {
Tag string `json:"tag"`
Text string `json:"text"`
}
if err := json.Unmarshal(elem, &tag); err != nil {
continue
}
switch tag.Tag {
case "text", "a":
lineText.WriteString(tag.Text)
case "at":
// Skip @mentions
}
}
if t := strings.TrimSpace(lineText.String()); t != "" {
textParts = append(textParts, t)
}
}
content := strings.Join(textParts, "\n")
if chatType == im.ChatTypeGroup {
for strings.HasPrefix(content, "@_user_") {
idx := strings.Index(content, " ")
if idx >= 0 {
content = content[idx+1:]
} else {
break
}
}
}
content = strings.TrimSpace(content)
if content == "" {
return nil
}
return &im.IncomingMessage{
Platform: region.Platform,
MessageType: im.MessageTypeText,
UserID: openID,
ChatID: chatID,
ChatType: chatType,
Content: content,
MessageID: messageID,
}
}
// ptrStr safely dereferences a *string for logging; returns "" for nil so the
// log line stays readable instead of printing <nil>.
func ptrStr(s *string) string {
if s == nil {
return ""
}
return *s
}
+49
View File
@@ -0,0 +1,49 @@
package feishu
import "github.com/Tencent/WeKnora/internal/im"
// Open Platform API origins. Feishu and Lark are the same product deployed on
// two isolated clouds; the API surface is identical, only the host differs.
const (
feishuOpenBaseURL = "https://open.feishu.cn"
larkOpenBaseURL = "https://open.larksuite.com"
)
// Region selects which cloud an adapter talks to. Apps, tenants, tokens and
// resource keys (image_key, file_key, card_id) are scoped to a single cloud and
// are never valid on the other, so a Region is fixed per channel at creation.
type Region struct {
// Platform is the im.Platform reported on messages parsed by this adapter.
Platform im.Platform
// OpenBaseURL is the Open Platform API origin, without a trailing slash.
OpenBaseURL string
// Label prefixes log lines so operators can tell the clouds apart.
Label string
// ThinkingText is the placeholder shown in a streaming card before the
// first answer chunk arrives.
ThinkingText string
// ImageFallbackLabel labels the plain link an image degrades to when it
// cannot be uploaded to the platform.
ImageFallbackLabel string
}
var (
// RegionFeishu is the Chinese mainland cloud (飞书). Its users are addressed
// in Chinese, matching the console and app language.
RegionFeishu = Region{
Platform: im.PlatformFeishu,
OpenBaseURL: feishuOpenBaseURL,
Label: "Feishu",
ThinkingText: "正在思考...",
ImageFallbackLabel: "图片",
}
// RegionLark is the international cloud (Lark), addressed in English.
RegionLark = Region{
Platform: im.PlatformLark,
OpenBaseURL: larkOpenBaseURL,
Label: "Lark",
ThinkingText: "Thinking...",
ImageFallbackLabel: "Image",
}
)
+179
View File
@@ -0,0 +1,179 @@
package feishu
import (
"encoding/json"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/im"
)
// The two regions must stay distinct in every dimension that reaches the
// network or the session store: sending a Lark request to open.feishu.cn fails
// authentication, and reporting the wrong platform merges the two clouds'
// sessions together.
func TestRegions_AreDistinct(t *testing.T) {
if RegionFeishu.OpenBaseURL == RegionLark.OpenBaseURL {
t.Errorf("regions share OpenBaseURL %q", RegionFeishu.OpenBaseURL)
}
if RegionFeishu.Platform == RegionLark.Platform {
t.Errorf("regions share Platform %q", RegionFeishu.Platform)
}
if RegionFeishu.Platform != im.PlatformFeishu {
t.Errorf("RegionFeishu.Platform = %q, want %q", RegionFeishu.Platform, im.PlatformFeishu)
}
if RegionLark.Platform != im.PlatformLark {
t.Errorf("RegionLark.Platform = %q, want %q", RegionLark.Platform, im.PlatformLark)
}
}
func TestRegions_BaseURLHosts(t *testing.T) {
cases := []struct {
region Region
want string
}{
{RegionFeishu, "https://open.feishu.cn"},
{RegionLark, "https://open.larksuite.com"},
}
for _, c := range cases {
if c.region.OpenBaseURL != c.want {
t.Errorf("%s OpenBaseURL = %q, want %q", c.region.Label, c.region.OpenBaseURL, c.want)
}
if strings.HasSuffix(c.region.OpenBaseURL, "/") {
t.Errorf("%s OpenBaseURL has a trailing slash: %q", c.region.Label, c.region.OpenBaseURL)
}
}
}
// Every API call must land on the region's own cloud.
func TestAdapterAPI_UsesRegionHost(t *testing.T) {
cases := []struct {
region Region
wantPrefix string
}{
{RegionFeishu, "https://open.feishu.cn/open-apis/"},
{RegionLark, "https://open.larksuite.com/open-apis/"},
}
for _, c := range cases {
a, _ := NewAdapter(c.region, "cli_app", "secret", "", "", "")
got := a.api("/open-apis/im/v1/messages/%s/reply", "om_1")
want := c.wantPrefix + "im/v1/messages/om_1/reply"
if got != want {
t.Errorf("%s api() = %q, want %q", c.region.Label, got, want)
}
// A path with no format verbs must survive unchanged.
if got := a.api("/open-apis/cardkit/v1/cards"); got != c.wantPrefix+"cardkit/v1/cards" {
t.Errorf("%s api() = %q, want %q", c.region.Label, got, c.wantPrefix+"cardkit/v1/cards")
}
}
}
func TestAdapterPlatform_FollowsRegion(t *testing.T) {
aFeishu, _ := NewAdapter(RegionFeishu, "a", "b", "", "", "")
if got := aFeishu.Platform(); got != im.PlatformFeishu {
t.Errorf("Feishu adapter Platform() = %q, want %q", got, im.PlatformFeishu)
}
aLark, _ := NewAdapter(RegionLark, "a", "b", "", "", "")
if got := aLark.Platform(); got != im.PlatformLark {
t.Errorf("Lark adapter Platform() = %q, want %q", got, im.PlatformLark)
}
}
// A custom api_base_url overrides the region's Open Platform host, so internal
// deployments can route Feishu API calls through a reverse proxy.
func TestAdapterAPI_BaseURLOverride(t *testing.T) {
a := &Adapter{apiBaseURL: "https://feishu-proxy.example.internal"}
got := a.api("/open-apis/im/v1/messages/%s/reply", "om_1")
want := "https://feishu-proxy.example.internal/open-apis/im/v1/messages/om_1/reply"
if got != want {
t.Errorf("override api() = %q, want %q", got, want)
}
}
// NewAdapter falls back to the region default when api_base_url is empty, and
// the region default itself passes validation (no SSRF check on the default).
func TestNewAdapter_FallbackAndTrim(t *testing.T) {
a, err := NewAdapter(RegionFeishu, "a", "b", "", "", "")
if err != nil {
t.Fatalf("NewAdapter empty api_base_url: %v", err)
}
if a.apiBaseURL != RegionFeishu.OpenBaseURL {
t.Errorf("fallback apiBaseURL = %q, want %q", a.apiBaseURL, RegionFeishu.OpenBaseURL)
}
// The region default value also passes (treated as "no override").
a2, err := NewAdapter(RegionFeishu, "a", "b", "", "", RegionFeishu.OpenBaseURL)
if err != nil {
t.Fatalf("NewAdapter default api_base_url: %v", err)
}
if a2.apiBaseURL != RegionFeishu.OpenBaseURL {
t.Errorf("default apiBaseURL = %q", a2.apiBaseURL)
}
}
// validateAPIBaseURL rejects non-http(s) schemes; empty and the region default
// are allowed without further checks.
func TestValidateAPIBaseURL(t *testing.T) {
defaults := RegionFeishu.OpenBaseURL
if err := validateAPIBaseURL("", defaults); err != nil {
t.Errorf("empty should be allowed: %v", err)
}
if err := validateAPIBaseURL(defaults, defaults); err != nil {
t.Errorf("default should be allowed: %v", err)
}
if err := validateAPIBaseURL("ftp://example.com", defaults); err == nil {
t.Error("ftp scheme should be rejected")
}
if err := validateAPIBaseURL("gopher://example.com", defaults); err == nil {
t.Error("gopher scheme should be rejected")
}
}
// The streaming card placeholder follows the region so Lark users are not shown
// Chinese copy.
func TestBuildStreamingCardJSON_PlaceholderFollowsRegion(t *testing.T) {
for _, region := range []Region{RegionFeishu, RegionLark} {
raw := buildStreamingCardJSON(region)
var card struct {
Config struct {
StreamingMode bool `json:"streaming_mode"`
Summary struct {
Content string `json:"content"`
} `json:"summary"`
} `json:"config"`
Body struct {
Elements []struct {
Content string `json:"content"`
ElementID string `json:"element_id"`
} `json:"elements"`
} `json:"body"`
}
if err := json.Unmarshal([]byte(raw), &card); err != nil {
t.Fatalf("%s card is not valid JSON: %v", region.Label, err)
}
if !card.Config.StreamingMode {
t.Errorf("%s card has streaming_mode disabled", region.Label)
}
if card.Config.Summary.Content != region.ThinkingText {
t.Errorf("%s summary = %q, want %q", region.Label, card.Config.Summary.Content, region.ThinkingText)
}
if len(card.Body.Elements) != 1 {
t.Fatalf("%s card has %d elements, want 1", region.Label, len(card.Body.Elements))
}
el := card.Body.Elements[0]
if el.ElementID != streamingElementID {
t.Errorf("%s element_id = %q, want %q", region.Label, el.ElementID, streamingElementID)
}
if !strings.Contains(el.Content, region.ThinkingText) {
t.Errorf("%s element content %q does not contain %q", region.Label, el.Content, region.ThinkingText)
}
}
// Guard the point of the whole exercise: the copy actually differs.
if RegionFeishu.ThinkingText == RegionLark.ThinkingText {
t.Error("Feishu and Lark share ThinkingText; Lark users would see Chinese copy")
}
}
+195
View File
@@ -0,0 +1,195 @@
package feishu
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/im"
)
func useTestHTTPClient(t *testing.T) {
t.Helper()
original := httpClient
httpClient = &http.Client{Timeout: 5 * time.Second}
t.Cleanup(func() { httpClient = original })
}
// testRegion returns a Lark-shaped region pointed at a fake Open Platform, so
// the whole send path can be driven without touching the real cloud. Region
// carries the host as a field precisely so this is possible.
func testRegion(baseURL string) Region {
r := RegionLark
r.OpenBaseURL = baseURL
return r
}
// fakeOpenPlatform records what the adapter asked for, and answers as the real
// Open Platform would.
type fakeOpenPlatform struct {
mu sync.Mutex
// paths records every request path in order.
paths []string
// replyAuth is the Authorization header seen on the reply call.
replyAuth string
// replyBody is the decoded JSON body of the reply call.
replyBody map[string]any
// sendBody is the decoded JSON body of the fallback send call.
sendBody map[string]any
// sendQuery is the raw query string of the fallback send call.
sendQuery string
// replyCode is returned from the reply endpoint (0 = success).
replyCode int
}
func (f *fakeOpenPlatform) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.paths = append(f.paths, r.URL.Path)
f.mu.Unlock()
switch {
case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal":
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0, "tenant_access_token": "t-lark-xyz", "expire": 7200,
})
case strings.HasSuffix(r.URL.Path, "/reply"):
f.mu.Lock()
f.replyAuth = r.Header.Get("Authorization")
_ = json.NewDecoder(r.Body).Decode(&f.replyBody)
code := f.replyCode
f.mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{"code": code, "msg": "mock"})
case r.URL.Path == "/open-apis/im/v1/messages":
f.mu.Lock()
f.sendQuery = r.URL.RawQuery
_ = json.NewDecoder(r.Body).Decode(&f.sendBody)
f.mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "ok"})
default:
http.NotFound(w, r)
}
})
}
func (f *fakeOpenPlatform) sawPath(want string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, p := range f.paths {
if p == want {
return true
}
}
return false
}
// SendReply must fetch a token from the region's cloud and reply under the
// original message, carrying that token as a bearer credential.
func TestSendReply_EndToEnd_UsesRegionCloud(t *testing.T) {
useTestHTTPClient(t)
fake := &fakeOpenPlatform{}
srv := httptest.NewServer(fake.handler())
defer srv.Close()
a, _ := NewAdapter(testRegion(srv.URL), "cli_app", "secret", "", "", "")
incoming := &im.IncomingMessage{
Platform: im.PlatformLark,
UserID: "ou_user1",
ChatType: im.ChatTypeDirect,
MessageID: "om_msg1",
}
if err := a.SendReply(context.Background(), incoming, &im.ReplyMessage{Content: "hello lark"}); err != nil {
t.Fatalf("SendReply: %v", err)
}
if !fake.sawPath("/open-apis/auth/v3/tenant_access_token/internal") {
t.Errorf("no token fetch; paths = %v", fake.paths)
}
if !fake.sawPath("/open-apis/im/v1/messages/om_msg1/reply") {
t.Errorf("reply did not target the message; paths = %v", fake.paths)
}
if fake.replyAuth != "Bearer t-lark-xyz" {
t.Errorf("reply Authorization = %q, want %q", fake.replyAuth, "Bearer t-lark-xyz")
}
if got := fake.replyBody["msg_type"]; got != "text" {
t.Errorf("msg_type = %v, want text", got)
}
// content is a JSON-encoded string, per the Open Platform contract.
var content struct {
Text string `json:"text"`
}
raw, _ := fake.replyBody["content"].(string)
if err := json.Unmarshal([]byte(raw), &content); err != nil {
t.Fatalf("content is not a JSON string: %q", raw)
}
if content.Text != "hello lark" {
t.Errorf("text = %q, want %q", content.Text, "hello lark")
}
}
// A group that rejects reply-in-thread (230071) must still receive the answer
// via the plain send-message API, addressed to the chat.
func TestSendReply_EndToEnd_FallsBackToSendAPI(t *testing.T) {
useTestHTTPClient(t)
fake := &fakeOpenPlatform{replyCode: 230071}
srv := httptest.NewServer(fake.handler())
defer srv.Close()
a, _ := NewAdapter(testRegion(srv.URL), "cli_app", "secret", "", "", "")
incoming := &im.IncomingMessage{
Platform: im.PlatformLark,
UserID: "ou_user1",
ChatID: "oc_chat1",
ChatType: im.ChatTypeGroup,
MessageID: "om_msg1",
}
if err := a.SendReply(context.Background(), incoming, &im.ReplyMessage{Content: "grouped"}); err != nil {
t.Fatalf("SendReply: %v", err)
}
if !fake.sawPath("/open-apis/im/v1/messages") {
t.Fatalf("no fallback to send-message API; paths = %v", fake.paths)
}
if fake.sendQuery != "receive_id_type=chat_id" {
t.Errorf("send query = %q, want receive_id_type=chat_id", fake.sendQuery)
}
if got := fake.sendBody["receive_id"]; got != "oc_chat1" {
t.Errorf("receive_id = %v, want oc_chat1", got)
}
}
// A non-fallback-eligible error must surface, not be silently swallowed.
func TestSendReply_EndToEnd_HardErrorSurfaces(t *testing.T) {
useTestHTTPClient(t)
fake := &fakeOpenPlatform{replyCode: 99991663} // app ticket invalid — not retryable
srv := httptest.NewServer(fake.handler())
defer srv.Close()
a, _ := NewAdapter(testRegion(srv.URL), "cli_app", "secret", "", "", "")
incoming := &im.IncomingMessage{
Platform: im.PlatformLark, UserID: "ou_user1",
ChatType: im.ChatTypeDirect, MessageID: "om_msg1",
}
err := a.SendReply(context.Background(), incoming, &im.ReplyMessage{Content: "x"})
if err == nil {
t.Fatal("SendReply returned nil for a non-retryable API error")
}
if !strings.Contains(err.Error(), "99991663") {
t.Errorf("error %q does not mention the API code", err)
}
// The plain send API must not be tried for a hard error.
if fake.sawPath("/open-apis/im/v1/messages") {
t.Errorf("fell back to send-message API on a non-retryable error; paths = %v", fake.paths)
}
}