diff --git a/cmd/agent/main.go b/cmd/agent/main.go index c441f08d..58a0a1e3 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -41,13 +41,13 @@ import ( "github.com/memohai/memoh/internal/embeddings" "github.com/memohai/memoh/internal/handlers" "github.com/memohai/memoh/internal/healthcheck" - "github.com/memohai/memoh/internal/inbox" channelchecker "github.com/memohai/memoh/internal/healthcheck/checkers/channel" mcpchecker "github.com/memohai/memoh/internal/healthcheck/checkers/mcp" + "github.com/memohai/memoh/internal/inbox" "github.com/memohai/memoh/internal/logger" "github.com/memohai/memoh/internal/mcp" - mcpcontainer "github.com/memohai/memoh/internal/mcp/providers/container" mcpcontacts "github.com/memohai/memoh/internal/mcp/providers/contacts" + mcpcontainer "github.com/memohai/memoh/internal/mcp/providers/container" mcpinbox "github.com/memohai/memoh/internal/mcp/providers/inbox" mcpmemory "github.com/memohai/memoh/internal/mcp/providers/memory" mcpmessage "github.com/memohai/memoh/internal/mcp/providers/message" @@ -203,6 +203,7 @@ func runServe() { provideServerHandler(handlers.NewScheduleHandler), provideServerHandler(handlers.NewSubagentHandler), provideServerHandler(handlers.NewChannelHandler), + provideServerHandler(feishu.NewWebhookServerHandler), provideServerHandler(provideUsersHandler), provideServerHandler(handlers.NewMCPHandler), provideServerHandler(handlers.NewInboxHandler), @@ -395,8 +396,10 @@ func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub, mediaService tgAdapter := telegram.NewTelegramAdapter(log) tgAdapter.SetAssetOpener(mediaService) registry.MustRegister(tgAdapter) - registry.MustRegister(discord.NewDiscordAdapter(log)) - registry.MustRegister(feishu.NewFeishuAdapter(log)) + registry.MustRegister(discord.NewDiscordAdapter(log)) + feishuAdapter := feishu.NewFeishuAdapter(log) + feishuAdapter.SetAssetOpener(mediaService) + registry.MustRegister(feishuAdapter) registry.MustRegister(local.NewCLIAdapter(hub)) registry.MustRegister(local.NewWebAdapter(hub)) return registry diff --git a/internal/channel/adapters/feishu/bot_identity.go b/internal/channel/adapters/feishu/bot_identity.go new file mode 100644 index 00000000..16cc0db5 --- /dev/null +++ b/internal/channel/adapters/feishu/bot_identity.go @@ -0,0 +1,44 @@ +package feishu + +import ( + "context" + "log/slog" + "strings" + + "github.com/memohai/memoh/internal/channel" +) + +func resolveConfiguredBotOpenID(cfg channel.ChannelConfig) string { + if value := strings.TrimSpace(channel.ReadString(cfg.SelfIdentity, "open_id", "openId")); value != "" { + return value + } + external := strings.TrimSpace(cfg.ExternalIdentity) + if external == "" { + return "" + } + if strings.HasPrefix(external, "open_id:") { + return strings.TrimSpace(strings.TrimPrefix(external, "open_id:")) + } + // Legacy records may persist raw open_id without prefix. + if !strings.Contains(external, ":") { + return external + } + return "" +} + +func (a *FeishuAdapter) resolveBotOpenID(ctx context.Context, cfg channel.ChannelConfig) string { + if openID := resolveConfiguredBotOpenID(cfg); openID != "" { + return openID + } + discovered, externalID, err := a.DiscoverSelf(ctx, cfg.Credentials) + if err != nil { + if a != nil && a.logger != nil { + a.logger.Warn("discover self fallback failed", slog.String("config_id", cfg.ID), slog.Any("error", err)) + } + return "" + } + if discoveredOpenID := strings.TrimSpace(channel.ReadString(discovered, "open_id", "openId")); discoveredOpenID != "" { + return discoveredOpenID + } + return resolveConfiguredBotOpenID(channel.ChannelConfig{ExternalIdentity: externalID}) +} diff --git a/internal/channel/adapters/feishu/config.go b/internal/channel/adapters/feishu/config.go index 74133ed4..3ab38e8b 100644 --- a/internal/channel/adapters/feishu/config.go +++ b/internal/channel/adapters/feishu/config.go @@ -4,15 +4,27 @@ import ( "fmt" "strings" + lark "github.com/larksuite/oapi-sdk-go/v3" + "github.com/memohai/memoh/internal/channel" ) +const ( + regionFeishu = "feishu" + regionLark = "lark" + + inboundModeWebsocket = "websocket" + inboundModeWebhook = "webhook" +) + // Config holds the Feishu app credentials extracted from a channel configuration. type Config struct { AppID string AppSecret string EncryptKey string VerificationToken string + Region string + InboundMode string } // UserConfig holds the identifiers used to target a Feishu user. @@ -27,8 +39,10 @@ func normalizeConfig(raw map[string]any) (map[string]any, error) { return nil, err } result := map[string]any{ - "appId": cfg.AppID, - "appSecret": cfg.AppSecret, + "appId": cfg.AppID, + "appSecret": cfg.AppSecret, + "region": cfg.Region, + "inboundMode": cfg.InboundMode, } if cfg.EncryptKey != "" { result["encryptKey"] = cfg.EncryptKey @@ -103,6 +117,14 @@ func parseConfig(raw map[string]any) (Config, error) { appSecret := strings.TrimSpace(channel.ReadString(raw, "appSecret", "app_secret")) encryptKey := strings.TrimSpace(channel.ReadString(raw, "encryptKey", "encrypt_key")) verificationToken := strings.TrimSpace(channel.ReadString(raw, "verificationToken", "verification_token")) + region, err := normalizeRegion(channel.ReadString(raw, "region")) + if err != nil { + return Config{}, err + } + inboundMode, err := normalizeInboundMode(channel.ReadString(raw, "inboundMode", "inbound_mode")) + if err != nil { + return Config{}, err + } if appID == "" || appSecret == "" { return Config{}, fmt.Errorf("feishu appId and appSecret are required") } @@ -111,6 +133,8 @@ func parseConfig(raw map[string]any) (Config, error) { AppSecret: appSecret, EncryptKey: encryptKey, VerificationToken: verificationToken, + Region: region, + InboundMode: inboundMode, }, nil } @@ -139,3 +163,32 @@ func normalizeTarget(raw string) string { } return "open_id:" + value } + +func normalizeRegion(raw string) (string, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", regionFeishu, "cn", "china": + return regionFeishu, nil + case regionLark, "global", "intl", "international": + return regionLark, nil + default: + return "", fmt.Errorf("feishu region must be feishu or lark") + } +} + +func normalizeInboundMode(raw string) (string, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", inboundModeWebsocket: + return inboundModeWebsocket, nil + case inboundModeWebhook: + return inboundModeWebhook, nil + default: + return "", fmt.Errorf("feishu inbound_mode must be websocket or webhook") + } +} + +func (c Config) openBaseURL() string { + if c.Region == regionLark { + return lark.LarkBaseUrl + } + return lark.FeishuBaseUrl +} diff --git a/internal/channel/adapters/feishu/config_test.go b/internal/channel/adapters/feishu/config_test.go index 1b1feb60..3aa90509 100644 --- a/internal/channel/adapters/feishu/config_test.go +++ b/internal/channel/adapters/feishu/config_test.go @@ -20,6 +20,12 @@ func TestNormalizeConfig(t *testing.T) { if got["encryptKey"] != "enc" || got["verificationToken"] != "verify" { t.Fatalf("unexpected feishu security config: %#v", got) } + if got["region"] != regionFeishu { + t.Fatalf("unexpected default region: %#v", got["region"]) + } + if got["inboundMode"] != inboundModeWebsocket { + t.Fatalf("unexpected default inbound mode: %#v", got["inboundMode"]) + } } func TestNormalizeConfigRequiresApp(t *testing.T) { @@ -31,6 +37,52 @@ func TestNormalizeConfigRequiresApp(t *testing.T) { } } +func TestNormalizeConfigSupportsLarkAndWebhook(t *testing.T) { + t.Parallel() + + got, err := normalizeConfig(map[string]any{ + "app_id": "app", + "app_secret": "secret", + "region": "lark", + "inbound_mode": "webhook", + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got["region"] != regionLark { + t.Fatalf("unexpected region: %#v", got["region"]) + } + if got["inboundMode"] != inboundModeWebhook { + t.Fatalf("unexpected inbound mode: %#v", got["inboundMode"]) + } +} + +func TestNormalizeConfigRejectsInvalidRegion(t *testing.T) { + t.Parallel() + + _, err := normalizeConfig(map[string]any{ + "app_id": "app", + "app_secret": "secret", + "region": "unknown", + }) + if err == nil { + t.Fatal("expected invalid region error") + } +} + +func TestNormalizeConfigRejectsInvalidInboundMode(t *testing.T) { + t.Parallel() + + _, err := normalizeConfig(map[string]any{ + "app_id": "app", + "app_secret": "secret", + "inbound_mode": "invalid", + }) + if err == nil { + t.Fatal("expected invalid inbound_mode error") + } +} + func TestNormalizeUserConfig(t *testing.T) { t.Parallel() diff --git a/internal/channel/adapters/feishu/connect_mode_test.go b/internal/channel/adapters/feishu/connect_mode_test.go new file mode 100644 index 00000000..d47f1c7a --- /dev/null +++ b/internal/channel/adapters/feishu/connect_mode_test.go @@ -0,0 +1,39 @@ +package feishu + +import ( + "context" + "testing" + + "github.com/memohai/memoh/internal/channel" +) + +func TestConnectWebhookModeDoesNotStartWebsocket(t *testing.T) { + t.Parallel() + + adapter := NewFeishuAdapter(nil) + cfg := channel.ChannelConfig{ + ID: "cfg-1", + BotID: "bot-1", + ChannelType: Type, + Credentials: map[string]any{ + "app_id": "app", + "app_secret": "secret", + "inbound_mode": "webhook", + }, + } + conn, err := adapter.Connect(context.Background(), cfg, func(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage) error { + return nil + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if conn == nil { + t.Fatal("expected non-nil connection") + } + if !conn.Running() { + t.Fatal("expected connection to be running") + } + if err := conn.Stop(context.Background()); err != nil { + t.Fatalf("expected stop to succeed, got %v", err) + } +} diff --git a/internal/channel/adapters/feishu/directory.go b/internal/channel/adapters/feishu/directory.go index 8493eed9..4b6f0cde 100644 --- a/internal/channel/adapters/feishu/directory.go +++ b/internal/channel/adapters/feishu/directory.go @@ -33,7 +33,7 @@ func (a *FeishuAdapter) ListPeers(ctx context.Context, cfg channel.ChannelConfig if err != nil { return nil, err } - client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret) + client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL())) pageSize := directoryLimit(query.Limit) req := larkcontact.NewListUserReqBuilder(). UserIdType(larkcontact.UserIdTypeOpenId). @@ -65,7 +65,7 @@ func (a *FeishuAdapter) ListGroups(ctx context.Context, cfg channel.ChannelConfi if err != nil { return nil, err } - client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret) + client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL())) pageSize := directoryLimit(query.Limit) var items []*larkim.ListChat if strings.TrimSpace(query.Query) != "" { @@ -116,7 +116,7 @@ func (a *FeishuAdapter) ListGroupMembers(ctx context.Context, cfg channel.Channe if chatID == "" { return nil, fmt.Errorf("feishu list group members: empty group id") } - client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret) + client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL())) pageSize := directoryLimit(query.Limit) req := larkim.NewGetChatMembersReqBuilder(). ChatId(chatID). @@ -147,7 +147,7 @@ func (a *FeishuAdapter) ResolveEntry(ctx context.Context, cfg channel.ChannelCon if err != nil { return channel.DirectoryEntry{}, err } - client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret) + client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL())) input = strings.TrimSpace(input) switch kind { case channel.DirectoryEntryUser: @@ -226,7 +226,9 @@ func parseFeishuUserInput(raw string) (userID, userIDType string) { if strings.HasPrefix(raw, "u_") || strings.HasPrefix(raw, "u-") { return raw, larkcontact.UserIdTypeUserId } - return raw, larkcontact.UserIdTypeOpenId + // For raw IDs without explicit prefix, default to user_id. In practice + // open_id is usually "ou_*", while bare IDs are commonly user_id. + return raw, larkcontact.UserIdTypeUserId } func feishuUserToEntry(u *larkcontact.User) channel.DirectoryEntry { diff --git a/internal/channel/adapters/feishu/directory_test.go b/internal/channel/adapters/feishu/directory_test.go index caedadc0..2efc218d 100644 --- a/internal/channel/adapters/feishu/directory_test.go +++ b/internal/channel/adapters/feishu/directory_test.go @@ -38,6 +38,7 @@ func Test_parseFeishuUserInput(t *testing.T) { {"user_id:u_yyy", "u_yyy", larkcontact.UserIdTypeUserId}, {"ou_abc", "ou_abc", larkcontact.UserIdTypeOpenId}, {"u_123", "u_123", larkcontact.UserIdTypeUserId}, + {"b3f195f9", "b3f195f9", larkcontact.UserIdTypeUserId}, {" open_id: ou_zzz ", "ou_zzz", larkcontact.UserIdTypeOpenId}, {"", "", ""}, } diff --git a/internal/channel/adapters/feishu/feishu.go b/internal/channel/adapters/feishu/feishu.go index e4da2e34..4277522b 100644 --- a/internal/channel/adapters/feishu/feishu.go +++ b/internal/channel/adapters/feishu/feishu.go @@ -1,6 +1,7 @@ package feishu import ( + "bytes" "context" "encoding/json" "fmt" @@ -17,14 +18,20 @@ import ( larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" larkws "github.com/larksuite/oapi-sdk-go/v3/ws" + attachmentpkg "github.com/memohai/memoh/internal/attachment" "github.com/memohai/memoh/internal/channel" "github.com/memohai/memoh/internal/channel/adapters/common" "github.com/memohai/memoh/internal/media" ) +type assetOpener interface { + Open(ctx context.Context, botID, contentHash string) (io.ReadCloser, media.Asset, error) +} + // FeishuAdapter implements the channel.Adapter, channel.Sender, and channel.Receiver interfaces for Feishu. type FeishuAdapter struct { logger *slog.Logger + assets assetOpener } const processingBusyReactionType = "Typing" @@ -106,6 +113,11 @@ func NewFeishuAdapter(log *slog.Logger) *FeishuAdapter { } } +// SetAssetOpener injects media asset reader for content_hash attachment delivery. +func (a *FeishuAdapter) SetAssetOpener(opener assetOpener) { + a.assets = opener +} + // Type returns the Feishu channel type. func (a *FeishuAdapter) Type() channel.ChannelType { return Type @@ -127,7 +139,7 @@ func (a *FeishuAdapter) Descriptor() channel.Descriptor { BlockStreaming: true, }, ConfigSchema: channel.ConfigSchema{ - Version: 1, + Version: 2, Fields: map[string]channel.FieldSchema{ "appId": {Type: channel.FieldString, Required: true, Title: "App ID"}, "appSecret": {Type: channel.FieldSecret, Required: true, Title: "App Secret"}, @@ -139,6 +151,20 @@ func (a *FeishuAdapter) Descriptor() channel.Descriptor { Type: channel.FieldSecret, Title: "Verification Token", }, + "region": { + Type: channel.FieldEnum, + Title: "Region", + Description: "API endpoint region: feishu.cn or larksuite.com", + Enum: []string{regionFeishu, regionLark}, + Example: regionFeishu, + }, + "inboundMode": { + Type: channel.FieldEnum, + Title: "Inbound Mode", + Description: "Choose websocket long-connection or webhook callback for inbound messages", + Enum: []string{inboundModeWebsocket, inboundModeWebhook}, + Example: inboundModeWebsocket, + }, }, }, UserConfigSchema: channel.ConfigSchema{ @@ -200,7 +226,7 @@ func (a *FeishuAdapter) processingReactionGateway(cfg channel.ChannelConfig) (pr if err != nil { return nil, err } - client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret) + client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL())) gateway := &larkProcessingReactionGateway{api: client.Im.V1.MessageReaction} return gateway, nil } @@ -264,7 +290,7 @@ func (a *FeishuAdapter) DiscoverSelf(ctx context.Context, credentials map[string if err != nil { return nil, "", err } - client := lark.NewClient(cfg.AppID, cfg.AppSecret) + client := lark.NewClient(cfg.AppID, cfg.AppSecret, lark.WithOpenBaseUrl(cfg.openBaseURL())) resp, err := client.Get(ctx, "/open-apis/bot/v3/info", nil, larkcore.AccessTokenTypeTenant) if err != nil { return nil, "", fmt.Errorf("feishu discover self: %w", err) @@ -342,16 +368,13 @@ func (a *FeishuAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig, } return nil, err } - botOpenID := channel.ReadString(cfg.SelfIdentity, "open_id") - if botOpenID == "" { - if discovered, _, err := a.DiscoverSelf(ctx, cfg.Credentials); err == nil { - if id, ok := discovered["open_id"].(string); ok { - botOpenID = strings.TrimSpace(id) - } - } else if a.logger != nil { - a.logger.Warn("discover self fallback failed", slog.String("config_id", cfg.ID), slog.Any("error", err)) + if feishuCfg.InboundMode == inboundModeWebhook { + if a.logger != nil { + a.logger.Info("webhook mode enabled; websocket connect skipped", slog.String("config_id", cfg.ID)) } + return channel.NewConnection(cfg, func(context.Context) error { return nil }), nil } + botOpenID := a.resolveBotOpenID(ctx, cfg) if a.logger != nil { a.logger.Info("bot identity", slog.String("config_id", cfg.ID), slog.String("bot_open_id", botOpenID)) } @@ -403,6 +426,7 @@ func (a *FeishuAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig, } return nil } + a.enrichSenderProfile(connCtx, cfg, event, &msg) msg.BotID = cfg.BotID if a.logger != nil { isMentioned := false @@ -444,6 +468,7 @@ func (a *FeishuAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig, feishuCfg.AppID, feishuCfg.AppSecret, larkws.WithEventHandler(eventDispatcher), + larkws.WithDomain(feishuCfg.openBaseURL()), larkws.WithLogger(newLarkSlogLogger(a.logger)), larkws.WithLogLevel(larkcore.LogLevelDebug), ) @@ -499,11 +524,11 @@ func (a *FeishuAdapter) Send(ctx context.Context, cfg channel.ChannelConfig, msg return err } - client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret) + client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL())) if len(msg.Message.Attachments) > 0 { for _, att := range msg.Message.Attachments { - if err := a.sendAttachment(ctx, client, receiveID, receiveType, att, msg.Message.Text); err != nil { + if err := a.sendAttachment(ctx, client, receiveID, receiveType, cfg.BotID, att); err != nil { return err } } @@ -576,7 +601,7 @@ func (a *FeishuAdapter) OpenStream(ctx context.Context, cfg channel.ChannelConfi if err != nil { return nil, err } - client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret) + client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL())) select { case <-ctx.Done(): return nil, ctx.Err() @@ -644,7 +669,7 @@ func (a *FeishuAdapter) handleResponse(configID string, resp *larkim.CreateMessa return nil } -func (a *FeishuAdapter) sendAttachment(ctx context.Context, client *lark.Client, receiveID, receiveType string, att channel.Attachment, text string) error { +func (a *FeishuAdapter) sendAttachment(ctx context.Context, client *lark.Client, receiveID, receiveType, botID string, att channel.Attachment) error { var msgType string var contentMap map[string]string sourcePlatform := strings.TrimSpace(att.SourcePlatform) @@ -658,32 +683,22 @@ func (a *FeishuAdapter) sendAttachment(ctx context.Context, client *lark.Client, contentMap = map[string]string{"file_key": platformKey} } } else { - downloadURL := strings.TrimSpace(att.URL) - if downloadURL == "" { - return fmt.Errorf("failed to download attachment: url is required when platform key is unavailable") - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + reader, resolvedMime, resolvedName, err := a.resolveAttachmentUploadReader(ctx, att, botID) if err != nil { - return fmt.Errorf("failed to build download request: %w", err) + return err } - httpClient := &http.Client{Timeout: 60 * time.Second} - resp, err := httpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("failed to download attachment: %w", err) + defer func() { + _ = reader.Close() + }() + typeProbe := att + if strings.TrimSpace(typeProbe.Mime) == "" { + typeProbe.Mime = strings.TrimSpace(resolvedMime) } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to download attachment, status: %d", resp.StatusCode) - } - maxBytes := media.MaxAssetBytes - if resp.ContentLength > maxBytes { - return fmt.Errorf("%w: max %d bytes", media.ErrAssetTooLarge, maxBytes) - } - if strings.HasPrefix(att.Mime, "image/") || att.Type == channel.AttachmentImage { + if isFeishuImageAttachment(typeProbe) { uploadReq := larkim.NewCreateImageReqBuilder(). Body(larkim.NewCreateImageReqBodyBuilder(). ImageType(larkim.ImageTypeMessage). - Image(resp.Body). + Image(reader). Build()). Build() uploadResp, err := client.Im.V1.Image.Create(ctx, uploadReq) @@ -700,8 +715,8 @@ func (a *FeishuAdapter) sendAttachment(ctx context.Context, client *lark.Client, msgType = larkim.MsgTypeImage contentMap = map[string]string{"image_key": *uploadResp.Data.ImageKey} } else { - fileType := resolveFeishuFileType(att.Name, att.Mime) - fileName := strings.TrimSpace(att.Name) + fileType := resolveFeishuFileType(resolvedName, resolvedMime) + fileName := strings.TrimSpace(resolvedName) if fileName == "" { fileName = "attachment" } @@ -709,7 +724,7 @@ func (a *FeishuAdapter) sendAttachment(ctx context.Context, client *lark.Client, Body(larkim.NewCreateFileReqBodyBuilder(). FileType(fileType). FileName(fileName). - File(resp.Body). + File(reader). Build()). Build() uploadResp, err := client.Im.V1.File.Create(ctx, uploadReq) @@ -746,6 +761,76 @@ func (a *FeishuAdapter) sendAttachment(ctx context.Context, client *lark.Client, return a.handleResponse("", sendResp, err) } +func (a *FeishuAdapter) resolveAttachmentUploadReader(ctx context.Context, att channel.Attachment, fallbackBotID string) (io.ReadCloser, string, string, error) { + assetID := strings.TrimSpace(att.ContentHash) + botID := strings.TrimSpace(fallbackBotID) + if botID == "" && att.Metadata != nil { + if value, ok := att.Metadata["bot_id"].(string); ok { + botID = strings.TrimSpace(value) + } + } + if assetID != "" && botID != "" && a.assets != nil { + reader, asset, err := a.assets.Open(ctx, botID, assetID) + if err == nil { + resolvedMime := strings.TrimSpace(att.Mime) + if resolvedMime == "" { + resolvedMime = strings.TrimSpace(asset.Mime) + } + return reader, resolvedMime, strings.TrimSpace(att.Name), nil + } + if a.logger != nil { + a.logger.Debug("feishu attachment storage open failed", + slog.String("bot_id", botID), + slog.String("content_hash", assetID), + slog.Any("error", err), + ) + } + } + + rawBase64 := strings.TrimSpace(att.Base64) + downloadURL := strings.TrimSpace(att.URL) + if rawBase64 == "" && strings.HasPrefix(strings.ToLower(downloadURL), "data:") { + rawBase64 = downloadURL + } + if rawBase64 != "" { + decoded, err := attachmentpkg.DecodeBase64(rawBase64, media.MaxAssetBytes) + if err != nil { + return nil, "", "", fmt.Errorf("failed to decode attachment base64: %w", err) + } + data, err := media.ReadAllWithLimit(decoded, media.MaxAssetBytes) + if err != nil { + return nil, "", "", fmt.Errorf("failed to read attachment base64: %w", err) + } + resolvedMime := strings.TrimSpace(att.Mime) + if resolvedMime == "" { + resolvedMime = strings.TrimSpace(attachmentpkg.MimeFromDataURL(rawBase64)) + } + return io.NopCloser(bytes.NewReader(data)), resolvedMime, strings.TrimSpace(att.Name), nil + } + + if downloadURL == "" { + return nil, "", "", fmt.Errorf("attachment reference is required: provide platform_key/content_hash/base64/url") + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return nil, "", "", fmt.Errorf("failed to build download request: %w", err) + } + httpClient := &http.Client{Timeout: 60 * time.Second} + resp, err := httpClient.Do(httpReq) + if err != nil { + return nil, "", "", fmt.Errorf("failed to download attachment: %w", err) + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, "", "", fmt.Errorf("failed to download attachment, status: %d", resp.StatusCode) + } + if resp.ContentLength > media.MaxAssetBytes { + _ = resp.Body.Close() + return nil, "", "", fmt.Errorf("%w: max %d bytes", media.ErrAssetTooLarge, media.MaxAssetBytes) + } + return resp.Body, strings.TrimSpace(att.Mime), strings.TrimSpace(att.Name), nil +} + // ResolveAttachment resolves a Feishu attachment reference to a byte stream. // User-sent resources must be fetched via the message-resource API which // requires both message_id and file_key. The message_id is expected in @@ -768,7 +853,7 @@ func (a *FeishuAdapter) ResolveAttachment(ctx context.Context, cfg channel.Chann if err != nil { return channel.AttachmentPayload{}, err } - client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret) + client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL())) resourceType := "file" if isFeishuImageAttachment(attachment) { @@ -819,21 +904,24 @@ func isFeishuImageAttachment(att channel.Attachment) bool { // resolveFeishuFileType maps MIME type and filename to a Feishu file type constant. func resolveFeishuFileType(name, mime string) string { lower := strings.ToLower(mime) + lowerName := strings.ToLower(strings.TrimSpace(name)) switch { case strings.Contains(lower, "mp4") || strings.Contains(lower, "video"): return larkim.FileTypeMp4 case strings.Contains(lower, "pdf"): return larkim.FileTypePdf - case strings.Contains(lower, "word") || strings.Contains(lower, "msword") || strings.HasSuffix(strings.ToLower(name), ".doc") || strings.HasSuffix(strings.ToLower(name), ".docx"): + case strings.Contains(lower, "word") || strings.Contains(lower, "msword") || strings.HasSuffix(lowerName, ".doc") || strings.HasSuffix(lowerName, ".docx"): return larkim.FileTypeDoc - case strings.Contains(lower, "excel") || strings.Contains(lower, "spreadsheet") || strings.HasSuffix(strings.ToLower(name), ".xls") || strings.HasSuffix(strings.ToLower(name), ".xlsx"): + case strings.Contains(lower, "excel") || strings.Contains(lower, "spreadsheet") || strings.HasSuffix(lowerName, ".xls") || strings.HasSuffix(lowerName, ".xlsx"): return larkim.FileTypeXls - case strings.Contains(lower, "powerpoint") || strings.Contains(lower, "presentation") || strings.HasSuffix(strings.ToLower(name), ".ppt") || strings.HasSuffix(strings.ToLower(name), ".pptx"): + case strings.Contains(lower, "powerpoint") || strings.Contains(lower, "presentation") || strings.HasSuffix(lowerName, ".ppt") || strings.HasSuffix(lowerName, ".pptx"): return larkim.FileTypePpt case strings.Contains(lower, "zip") || strings.Contains(lower, "compressed") || strings.Contains(lower, "archive"): - return "zip" + return larkim.FileTypeStream + case strings.HasSuffix(lowerName, ".zip") || strings.HasSuffix(lowerName, ".tar") || strings.HasSuffix(lowerName, ".tgz") || strings.HasSuffix(lowerName, ".tar.gz") || strings.HasSuffix(lowerName, ".rar") || strings.HasSuffix(lowerName, ".7z") || strings.HasSuffix(lowerName, ".gz") || strings.HasSuffix(lowerName, ".bz2") || strings.HasSuffix(lowerName, ".xz"): + return larkim.FileTypeStream default: - return larkim.FileTypePdf + return larkim.FileTypeStream } } diff --git a/internal/channel/adapters/feishu/feishu_test.go b/internal/channel/adapters/feishu/feishu_test.go index 1d476108..6f6e5ad0 100644 --- a/internal/channel/adapters/feishu/feishu_test.go +++ b/internal/channel/adapters/feishu/feishu_test.go @@ -294,6 +294,34 @@ func TestIsFeishuImageAttachment(t *testing.T) { } } +func TestResolveFeishuFileType(t *testing.T) { + t.Parallel() + cases := []struct { + name string + file string + mime string + want string + }{ + {name: "video mime", file: "clip.bin", mime: "video/mp4", want: larkim.FileTypeMp4}, + {name: "pdf mime", file: "doc.bin", mime: "application/pdf", want: larkim.FileTypePdf}, + {name: "doc ext", file: "a.docx", mime: "application/octet-stream", want: larkim.FileTypeDoc}, + {name: "xls ext", file: "a.xlsx", mime: "application/octet-stream", want: larkim.FileTypeXls}, + {name: "ppt ext", file: "a.pptx", mime: "application/octet-stream", want: larkim.FileTypePpt}, + {name: "zip mime", file: "a.bin", mime: "application/zip", want: larkim.FileTypeStream}, + {name: "tar gz ext", file: "backup.tar.gz", mime: "application/octet-stream", want: larkim.FileTypeStream}, + {name: "default stream", file: "notes.txt", mime: "text/plain", want: larkim.FileTypeStream}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := resolveFeishuFileType(tc.file, tc.mime); got != tc.want { + t.Fatalf("resolveFeishuFileType(%q,%q)=%q want=%q", tc.file, tc.mime, got, tc.want) + } + }) + } +} + func TestBuildFeishuStreamCardContent(t *testing.T) { t.Parallel() @@ -560,6 +588,38 @@ func TestExtractFeishuInboundPostMentionOtherIgnored(t *testing.T) { } } +func TestResolveConfiguredBotOpenIDPrefersSelfIdentity(t *testing.T) { + t.Parallel() + + cfg := channel.ChannelConfig{ + SelfIdentity: map[string]any{ + "open_id": "ou_self_1", + }, + ExternalIdentity: "open_id:ou_external_1", + } + if got := resolveConfiguredBotOpenID(cfg); got != "ou_self_1" { + t.Fatalf("expected self identity open_id, got %q", got) + } +} + +func TestResolveConfiguredBotOpenIDFromExternalIdentity(t *testing.T) { + t.Parallel() + + cfg := channel.ChannelConfig{ExternalIdentity: "open_id:ou_external_2"} + if got := resolveConfiguredBotOpenID(cfg); got != "ou_external_2" { + t.Fatalf("expected external identity open_id, got %q", got) + } +} + +func TestResolveConfiguredBotOpenIDIgnoresNonOpenIDExternalIdentity(t *testing.T) { + t.Parallel() + + cfg := channel.ChannelConfig{ExternalIdentity: "chat_id:oc_group_1"} + if got := resolveConfiguredBotOpenID(cfg); got != "" { + t.Fatalf("expected empty open_id for non-open external identity, got %q", got) + } +} + func TestAddProcessingReactionFirstSuccess(t *testing.T) { t.Parallel() diff --git a/internal/channel/adapters/feishu/sender_profile.go b/internal/channel/adapters/feishu/sender_profile.go new file mode 100644 index 00000000..5fd14de6 --- /dev/null +++ b/internal/channel/adapters/feishu/sender_profile.go @@ -0,0 +1,276 @@ +package feishu + +import ( + "context" + "fmt" + "strings" + "time" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3" + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + + "github.com/memohai/memoh/internal/channel" +) + +type feishuSenderProfile struct { + displayName string + username string +} + +const feishuChatMembersPageSize = 100 + +// enrichSenderProfile fills sender display name / username for inbound messages. +// It first tries Contact.User.Get (open_id/user_id), then falls back to group member +// lookup when permissions are limited. +func (a *FeishuAdapter) enrichSenderProfile(ctx context.Context, cfg channel.ChannelConfig, event *larkim.P2MessageReceiveV1, msg *channel.InboundMessage) { + if msg == nil { + return + } + needDisplay := strings.TrimSpace(msg.Sender.DisplayName) == "" && + strings.TrimSpace(msg.Sender.Attribute("display_name")) == "" && + strings.TrimSpace(msg.Sender.Attribute("name")) == "" + needUsername := strings.TrimSpace(msg.Sender.Attribute("username")) == "" + if !needDisplay && !needUsername { + return + } + + openID := strings.TrimSpace(msg.Sender.Attribute("open_id")) + userID := strings.TrimSpace(msg.Sender.Attribute("user_id")) + if openID == "" && userID == "" { + return + } + + chatID := "" + if event != nil && event.Event != nil && event.Event.Message != nil && event.Event.Message.ChatId != nil { + chatID = strings.TrimSpace(*event.Event.Message.ChatId) + } + + if ctx == nil { + ctx = context.Background() + } + lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + profile, err := a.lookupSenderProfile(lookupCtx, cfg, openID, userID, chatID) + if err != nil { + if a.logger != nil { + a.logger.Debug("feishu sender profile lookup failed", + "config_id", cfg.ID, + "open_id", openID, + "user_id", userID, + "chat_id", chatID, + "error", err, + ) + } + } + if strings.TrimSpace(profile.displayName) == "" && strings.TrimSpace(profile.username) == "" { + profile = fallbackSenderProfile(openID, userID) + } + applySenderProfile(msg, profile) +} + +func (a *FeishuAdapter) lookupSenderProfile(ctx context.Context, cfg channel.ChannelConfig, openID, userID, chatID string) (feishuSenderProfile, error) { + feishuCfg, err := parseConfig(cfg.Credentials) + if err != nil { + return feishuSenderProfile{}, err + } + client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL())) + + var lastErr error + chatID = strings.TrimSpace(chatID) + if strings.HasPrefix(chatID, "chat_id:") { + chatID = strings.TrimPrefix(chatID, "chat_id:") + } + + // Group scene: chat members has the highest chance to return a human-readable name. + if chatID != "" && openID != "" { + if profile, err := lookupSenderProfileFromGroupMember(ctx, client, chatID, "open_id", openID); err == nil { + if strings.TrimSpace(profile.displayName) != "" || strings.TrimSpace(profile.username) != "" { + return profile, nil + } + } else { + lastErr = err + } + } + if chatID != "" && userID != "" { + if profile, err := lookupSenderProfileFromGroupMember(ctx, client, chatID, "user_id", userID); err == nil { + if strings.TrimSpace(profile.displayName) != "" || strings.TrimSpace(profile.username) != "" { + return profile, nil + } + } else { + lastErr = err + } + } + + if profile, err := lookupSenderProfileFromContact(ctx, client, openID, userID); err == nil { + if strings.TrimSpace(profile.displayName) != "" || strings.TrimSpace(profile.username) != "" { + return profile, nil + } + } else { + lastErr = err + } + + if lastErr != nil { + return feishuSenderProfile{}, lastErr + } + return feishuSenderProfile{}, nil +} + +func lookupSenderProfileFromContact(ctx context.Context, client *lark.Client, openID, userID string) (feishuSenderProfile, error) { + lookupID := strings.TrimSpace(openID) + idType := larkcontact.UserIdTypeOpenId + if lookupID == "" { + lookupID = strings.TrimSpace(userID) + idType = larkcontact.UserIdTypeUserId + } + if lookupID == "" { + return feishuSenderProfile{}, fmt.Errorf("empty sender id") + } + req := larkcontact.NewGetUserReqBuilder(). + UserIdType(idType). + UserId(lookupID). + Build() + resp, err := client.Contact.User.Get(ctx, req) + if err != nil { + return feishuSenderProfile{}, err + } + if resp == nil || !resp.Success() { + code := 0 + msg := "" + if resp != nil { + code = resp.Code + msg = strings.TrimSpace(resp.Msg) + } + return feishuSenderProfile{}, fmt.Errorf("feishu get user failed: code=%d msg=%s", code, msg) + } + if resp.Data == nil || resp.Data.User == nil { + return feishuSenderProfile{}, fmt.Errorf("feishu get user returned empty user") + } + displayName := ptrStr(resp.Data.User.Name) + username := ptrStr(resp.Data.User.Nickname) + if username == "" { + username = displayName + } + return feishuSenderProfile{ + displayName: displayName, + username: username, + }, nil +} + +func lookupSenderProfileFromGroupMember(ctx context.Context, client *lark.Client, chatID, memberIDType, memberID string) (feishuSenderProfile, error) { + memberIDType = strings.TrimSpace(memberIDType) + memberID = strings.TrimSpace(memberID) + if memberIDType == "" || memberID == "" { + return feishuSenderProfile{}, fmt.Errorf("empty member lookup input") + } + pageToken := "" + for page := 0; page < 5; page++ { + builder := larkim.NewGetChatMembersReqBuilder(). + ChatId(chatID). + MemberIdType(memberIDType). + PageSize(feishuChatMembersPageSize) + if pageToken != "" { + builder = builder.PageToken(pageToken) + } + resp, err := client.Im.ChatMembers.Get(ctx, builder.Build()) + if err != nil { + return feishuSenderProfile{}, err + } + if resp == nil || !resp.Success() { + code := 0 + msg := "" + if resp != nil { + code = resp.Code + msg = strings.TrimSpace(resp.Msg) + } + return feishuSenderProfile{}, fmt.Errorf("feishu get chat members failed: code=%d msg=%s", code, msg) + } + if resp.Data == nil { + return feishuSenderProfile{}, nil + } + for _, item := range resp.Data.Items { + if item == nil { + continue + } + if strings.TrimSpace(ptrStr(item.MemberId)) != memberID { + continue + } + name := ptrStr(item.Name) + username := firstNameFallback(name) + if username == "" { + username = name + } + return feishuSenderProfile{ + displayName: name, + username: username, + }, nil + } + hasMore := resp.Data.HasMore != nil && *resp.Data.HasMore + if !hasMore || resp.Data.PageToken == nil { + break + } + pageToken = strings.TrimSpace(*resp.Data.PageToken) + if pageToken == "" { + break + } + } + return feishuSenderProfile{}, nil +} + +func fallbackSenderProfile(openID, userID string) feishuSenderProfile { + openID = strings.TrimSpace(openID) + userID = strings.TrimSpace(userID) + username := userID + if username == "" { + username = openID + } + if username == "" { + return feishuSenderProfile{} + } + displayName := username + return feishuSenderProfile{ + displayName: displayName, + username: username, + } +} + +func firstNameFallback(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + parts := strings.Fields(name) + if len(parts) == 0 { + return "" + } + return strings.TrimSpace(parts[0]) +} + +func applySenderProfile(msg *channel.InboundMessage, profile feishuSenderProfile) { + if msg == nil { + return + } + displayName := strings.TrimSpace(profile.displayName) + username := strings.TrimSpace(profile.username) + if username == "" { + username = displayName + } + if msg.Sender.Attributes == nil { + msg.Sender.Attributes = map[string]string{} + } + if displayName != "" { + if strings.TrimSpace(msg.Sender.DisplayName) == "" { + msg.Sender.DisplayName = displayName + } + if strings.TrimSpace(msg.Sender.Attributes["display_name"]) == "" { + msg.Sender.Attributes["display_name"] = displayName + } + if strings.TrimSpace(msg.Sender.Attributes["name"]) == "" { + msg.Sender.Attributes["name"] = displayName + } + } + if username != "" && strings.TrimSpace(msg.Sender.Attributes["username"]) == "" { + msg.Sender.Attributes["username"] = username + } +} diff --git a/internal/channel/adapters/feishu/sender_profile_test.go b/internal/channel/adapters/feishu/sender_profile_test.go new file mode 100644 index 00000000..1b1993cc --- /dev/null +++ b/internal/channel/adapters/feishu/sender_profile_test.go @@ -0,0 +1,67 @@ +package feishu + +import ( + "testing" + + "github.com/memohai/memoh/internal/channel" +) + +func TestApplySenderProfileFillDisplayAndUsername(t *testing.T) { + msg := &channel.InboundMessage{ + Sender: channel.Identity{ + SubjectID: "ou_test", + Attributes: map[string]string{"open_id": "ou_test"}, + }, + } + + applySenderProfile(msg, feishuSenderProfile{ + displayName: "张三", + username: "zhangsan", + }) + + if got := msg.Sender.DisplayName; got != "张三" { + t.Fatalf("expected display name 张三, got %q", got) + } + if got := msg.Sender.Attribute("display_name"); got != "张三" { + t.Fatalf("expected attribute display_name 张三, got %q", got) + } + if got := msg.Sender.Attribute("name"); got != "张三" { + t.Fatalf("expected attribute name 张三, got %q", got) + } + if got := msg.Sender.Attribute("username"); got != "zhangsan" { + t.Fatalf("expected attribute username zhangsan, got %q", got) + } +} + +func TestApplySenderProfileKeepExistingIdentityFields(t *testing.T) { + msg := &channel.InboundMessage{ + Sender: channel.Identity{ + SubjectID: "ou_test", + DisplayName: "原名", + Attributes: map[string]string{ + "open_id": "ou_test", + "display_name": "原显示名", + "name": "原姓名", + "username": "old_user", + }, + }, + } + + applySenderProfile(msg, feishuSenderProfile{ + displayName: "新名", + username: "new_user", + }) + + if got := msg.Sender.DisplayName; got != "原名" { + t.Fatalf("expected original display name preserved, got %q", got) + } + if got := msg.Sender.Attribute("display_name"); got != "原显示名" { + t.Fatalf("expected original attribute display_name preserved, got %q", got) + } + if got := msg.Sender.Attribute("name"); got != "原姓名" { + t.Fatalf("expected original attribute name preserved, got %q", got) + } + if got := msg.Sender.Attribute("username"); got != "old_user" { + t.Fatalf("expected original attribute username preserved, got %q", got) + } +} diff --git a/internal/channel/adapters/feishu/webhook_handler.go b/internal/channel/adapters/feishu/webhook_handler.go new file mode 100644 index 00000000..79c272fb --- /dev/null +++ b/internal/channel/adapters/feishu/webhook_handler.go @@ -0,0 +1,174 @@ +package feishu + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + + "github.com/labstack/echo/v4" + larkevent "github.com/larksuite/oapi-sdk-go/v3/event" + "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + + "github.com/memohai/memoh/internal/channel" +) + +type webhookConfigStore interface { + ListConfigsByType(ctx context.Context, channelType channel.ChannelType) ([]channel.ChannelConfig, error) +} + +type webhookInboundManager interface { + HandleInbound(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage) error +} + +const webhookMaxBodyBytes int64 = 1 << 20 // 1 MiB + +// WebhookHandler receives Feishu/Lark event-subscription callbacks. +type WebhookHandler struct { + logger *slog.Logger + store webhookConfigStore + manager webhookInboundManager + adapter *FeishuAdapter +} + +// NewWebhookHandler creates a public webhook handler for Feishu/Lark callbacks. +func NewWebhookHandler(log *slog.Logger, store webhookConfigStore, manager webhookInboundManager) *WebhookHandler { + if log == nil { + log = slog.Default() + } + return &WebhookHandler{ + logger: log.With(slog.String("handler", "feishu_webhook")), + store: store, + manager: manager, + adapter: NewFeishuAdapter(log), + } +} + +// NewWebhookServerHandler is a DI-friendly constructor for fx/dig, using concrete +// channel types as parameters. +func NewWebhookServerHandler(log *slog.Logger, store *channel.Store, manager *channel.Manager) *WebhookHandler { + return NewWebhookHandler(log, store, manager) +} + +// Register registers webhook callback routes. +func (h *WebhookHandler) Register(e *echo.Echo) { + e.GET("/channels/feishu/webhook/:config_id", h.HandleProbe) + e.POST("/channels/feishu/webhook/:config_id", h.Handle) +} + +// HandleProbe responds to health/probe requests on the webhook URL. +func (h *WebhookHandler) HandleProbe(c echo.Context) error { + return c.String(http.StatusOK, "ok") +} + +// Handle processes Feishu/Lark event-subscription webhook requests. +func (h *WebhookHandler) Handle(c echo.Context) error { + if h.store == nil || h.manager == nil { + return echo.NewHTTPError(http.StatusInternalServerError, "feishu webhook dependencies not configured") + } + configID := strings.TrimSpace(c.Param("config_id")) + if configID == "" { + return echo.NewHTTPError(http.StatusBadRequest, "config id is required") + } + cfg, err := h.findConfigByID(c.Request().Context(), configID) + if err != nil { + return err + } + if cfg.Disabled { + return echo.NewHTTPError(http.StatusForbidden, "channel config is disabled") + } + feishuCfg, err := parseConfig(cfg.Credentials) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + if feishuCfg.InboundMode != inboundModeWebhook { + return echo.NewHTTPError(http.StatusBadRequest, "feishu inbound_mode is not webhook") + } + + payload, err := io.ReadAll(io.LimitReader(c.Request().Body, webhookMaxBodyBytes+1)) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("read body: %v", err)) + } + if int64(len(payload)) > webhookMaxBodyBytes { + return echo.NewHTTPError(http.StatusRequestEntityTooLarge, fmt.Sprintf("payload too large: max %d bytes", webhookMaxBodyBytes)) + } + if err := validateWebhookCallbackAuth(payload, feishuCfg); err != nil { + return err + } + + botOpenID := h.adapter.resolveBotOpenID(context.WithoutCancel(c.Request().Context()), cfg) + + eventDispatcher := dispatcher.NewEventDispatcher(feishuCfg.VerificationToken, feishuCfg.EncryptKey) + eventDispatcher.OnP2MessageReceiveV1(func(_ context.Context, event *larkim.P2MessageReceiveV1) error { + msg := extractFeishuInbound(event, botOpenID) + if strings.TrimSpace(msg.Message.PlainText()) == "" && len(msg.Message.Attachments) == 0 { + return nil + } + h.adapter.enrichSenderProfile(context.WithoutCancel(c.Request().Context()), cfg, event, &msg) + msg.BotID = cfg.BotID + return h.manager.HandleInbound(context.WithoutCancel(c.Request().Context()), cfg, msg) + }) + + resp := eventDispatcher.Handle(c.Request().Context(), &larkevent.EventReq{ + Header: c.Request().Header, + Body: payload, + RequestURI: c.Request().RequestURI, + }) + if resp == nil { + return c.NoContent(http.StatusOK) + } + for key, values := range resp.Header { + for _, value := range values { + c.Response().Header().Add(key, value) + } + } + c.Response().WriteHeader(resp.StatusCode) + if len(resp.Body) == 0 { + return nil + } + _, err = c.Response().Write(resp.Body) + return err +} + +func validateWebhookCallbackAuth(payload []byte, cfg Config) error { + if strings.TrimSpace(cfg.EncryptKey) != "" { + // Lark SDK signature verification is enabled only when encryptKey is configured. + return nil + } + var fuzzy larkevent.EventFuzzy + if err := json.Unmarshal(payload, &fuzzy); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid feishu webhook payload: %v", err)) + } + if larkevent.ReqType(strings.TrimSpace(fuzzy.Type)) == larkevent.ReqTypeChallenge { + return nil + } + expectedToken := strings.TrimSpace(cfg.VerificationToken) + if expectedToken == "" { + return echo.NewHTTPError(http.StatusForbidden, "feishu webhook requires verification_token when encrypt_key is empty") + } + requestToken := strings.TrimSpace(fuzzy.Token) + if fuzzy.Header != nil && strings.TrimSpace(fuzzy.Header.Token) != "" { + requestToken = strings.TrimSpace(fuzzy.Header.Token) + } + if requestToken == "" || requestToken != expectedToken { + return echo.NewHTTPError(http.StatusUnauthorized, "invalid feishu webhook token") + } + return nil +} + +func (h *WebhookHandler) findConfigByID(ctx context.Context, configID string) (channel.ChannelConfig, error) { + items, err := h.store.ListConfigsByType(ctx, Type) + if err != nil { + return channel.ChannelConfig{}, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + for _, item := range items { + if strings.TrimSpace(item.ID) == configID { + return item, nil + } + } + return channel.ChannelConfig{}, echo.NewHTTPError(http.StatusNotFound, "channel config not found") +} diff --git a/internal/channel/adapters/feishu/webhook_handler_test.go b/internal/channel/adapters/feishu/webhook_handler_test.go new file mode 100644 index 00000000..05da4375 --- /dev/null +++ b/internal/channel/adapters/feishu/webhook_handler_test.go @@ -0,0 +1,340 @@ +package feishu + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v4" + + "github.com/memohai/memoh/internal/channel" +) + +type fakeWebhookStore struct { + configs []channel.ChannelConfig + err error +} + +func (s *fakeWebhookStore) ListConfigsByType(ctx context.Context, channelType channel.ChannelType) ([]channel.ChannelConfig, error) { + if s.err != nil { + return nil, s.err + } + return s.configs, nil +} + +type fakeWebhookManager struct { + calls []struct { + cfg channel.ChannelConfig + msg channel.InboundMessage + } + err error +} + +func (m *fakeWebhookManager) HandleInbound(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage) error { + m.calls = append(m.calls, struct { + cfg channel.ChannelConfig + msg channel.InboundMessage + }{cfg: cfg, msg: msg}) + return m.err +} + +func TestWebhookHandler_URLVerification(t *testing.T) { + t.Parallel() + + store := &fakeWebhookStore{ + configs: []channel.ChannelConfig{ + { + ID: "cfg-1", + BotID: "bot-1", + ChannelType: Type, + Credentials: map[string]any{ + "app_id": "app", + "app_secret": "secret", + "verification_token": "verify-token", + "inbound_mode": "webhook", + }, + }, + }, + } + manager := &fakeWebhookManager{} + h := NewWebhookHandler(nil, store, manager) + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(`{"schema":"2.0","header":{"event_type":"im.message.receive_v1","token":"verify-token"},"type":"url_verification","challenge":"hello"}`)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("config_id") + c.SetParamValues("cfg-1") + + if err := h.Handle(c); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status code: %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), `"challenge":"hello"`) { + t.Fatalf("unexpected challenge response: %s", rec.Body.String()) + } + if len(manager.calls) != 0 { + t.Fatalf("expected no inbound calls, got %d", len(manager.calls)) + } +} + +func TestWebhookHandler_Probe(t *testing.T) { + t.Parallel() + + h := NewWebhookHandler(nil, &fakeWebhookStore{}, &fakeWebhookManager{}) + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/channels/feishu/webhook/cfg-1", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("config_id") + c.SetParamValues("cfg-1") + + if err := h.HandleProbe(c); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status code: %d", rec.Code) + } + if strings.TrimSpace(rec.Body.String()) != "ok" { + t.Fatalf("unexpected probe response: %q", rec.Body.String()) + } +} + +func TestWebhookHandler_EventCallbackDispatchesInbound(t *testing.T) { + t.Parallel() + + store := &fakeWebhookStore{ + configs: []channel.ChannelConfig{ + { + ID: "cfg-1", + BotID: "bot-1", + ChannelType: Type, + SelfIdentity: map[string]any{ + "open_id": "ou_bot_1", + }, + Credentials: map[string]any{ + "app_id": "app", + "app_secret": "secret", + "verification_token": "verify-token", + "inbound_mode": "webhook", + }, + }, + }, + } + manager := &fakeWebhookManager{} + h := NewWebhookHandler(nil, store, manager) + + e := echo.New() + body := `{"schema":"2.0","header":{"event_id":"evt_1","event_type":"im.message.receive_v1","token":"verify-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_1","user_id":"u_user_1"}},"message":{"message_id":"om_1","chat_id":"oc_1","chat_type":"p2p","message_type":"text","content":"{\"text\":\"hello\"}"}},"type":"event_callback"}` + req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("config_id") + c.SetParamValues("cfg-1") + + if err := h.Handle(c); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status code: %d", rec.Code) + } + if len(manager.calls) != 1 { + t.Fatalf("expected one inbound call, got %d", len(manager.calls)) + } + got := manager.calls[0].msg + if got.BotID != "bot-1" { + t.Fatalf("unexpected bot id: %s", got.BotID) + } + if got.Message.PlainText() != "hello" { + t.Fatalf("unexpected message text: %q", got.Message.PlainText()) + } +} + +func TestWebhookHandler_EventCallbackUsesExternalIdentityForMentionFilter(t *testing.T) { + t.Parallel() + + store := &fakeWebhookStore{ + configs: []channel.ChannelConfig{ + { + ID: "cfg-1", + BotID: "bot-1", + ChannelType: Type, + ExternalIdentity: "open_id:ou_bot_1", + Credentials: map[string]any{ + "app_id": "app", + "app_secret": "secret", + "verification_token": "verify-token", + "inbound_mode": "webhook", + }, + }, + }, + } + manager := &fakeWebhookManager{} + h := NewWebhookHandler(nil, store, manager) + + e := echo.New() + body := `{"schema":"2.0","header":{"event_id":"evt_2","event_type":"im.message.receive_v1","token":"verify-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_2","user_id":"u_user_2"}},"message":{"message_id":"om_2","chat_id":"oc_group_1","chat_type":"group","message_type":"text","content":"{\"text\":\" hello\"}"}},"type":"event_callback"}` + req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("config_id") + c.SetParamValues("cfg-1") + + if err := h.Handle(c); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status code: %d", rec.Code) + } + if len(manager.calls) != 1 { + t.Fatalf("expected one inbound call, got %d", len(manager.calls)) + } + mentioned, _ := manager.calls[0].msg.Metadata["is_mentioned"].(bool) + if mentioned { + t.Fatalf("expected mention flag=false when mentioning another user") + } +} + +func TestWebhookHandler_EventCallbackRejectsInvalidTokenWhenEncryptKeyMissing(t *testing.T) { + t.Parallel() + + store := &fakeWebhookStore{ + configs: []channel.ChannelConfig{ + { + ID: "cfg-1", + BotID: "bot-1", + ChannelType: Type, + Credentials: map[string]any{ + "app_id": "app", + "app_secret": "secret", + "verification_token": "verify-token", + "inbound_mode": "webhook", + }, + }, + }, + } + manager := &fakeWebhookManager{} + h := NewWebhookHandler(nil, store, manager) + + e := echo.New() + body := `{"schema":"2.0","header":{"event_id":"evt_1","event_type":"im.message.receive_v1","token":"forged-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_1"}},"message":{"message_id":"om_1","chat_id":"oc_1","chat_type":"p2p","message_type":"text","content":"{\"text\":\"hello\"}"}},"type":"event_callback"}` + req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("config_id") + c.SetParamValues("cfg-1") + + err := h.Handle(c) + if err == nil { + t.Fatal("expected unauthorized error") + } + he, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected HTTPError, got %T", err) + } + if he.Code != http.StatusUnauthorized { + t.Fatalf("unexpected status code: %d", he.Code) + } + if len(manager.calls) != 0 { + t.Fatalf("expected no inbound calls, got %d", len(manager.calls)) + } +} + +func TestWebhookHandler_EventCallbackRequiresVerificationTokenWhenEncryptKeyMissing(t *testing.T) { + t.Parallel() + + store := &fakeWebhookStore{ + configs: []channel.ChannelConfig{ + { + ID: "cfg-1", + BotID: "bot-1", + ChannelType: Type, + Credentials: map[string]any{ + "app_id": "app", + "app_secret": "secret", + "inbound_mode": "webhook", + }, + }, + }, + } + manager := &fakeWebhookManager{} + h := NewWebhookHandler(nil, store, manager) + + e := echo.New() + body := `{"schema":"2.0","header":{"event_id":"evt_1","event_type":"im.message.receive_v1","token":"verify-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_1"}},"message":{"message_id":"om_1","chat_id":"oc_1","chat_type":"p2p","message_type":"text","content":"{\"text\":\"hello\"}"}},"type":"event_callback"}` + req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("config_id") + c.SetParamValues("cfg-1") + + err := h.Handle(c) + if err == nil { + t.Fatal("expected forbidden error") + } + he, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected HTTPError, got %T", err) + } + if he.Code != http.StatusForbidden { + t.Fatalf("unexpected status code: %d", he.Code) + } + if len(manager.calls) != 0 { + t.Fatalf("expected no inbound calls, got %d", len(manager.calls)) + } +} + +func TestWebhookHandler_RejectsOversizedBody(t *testing.T) { + t.Parallel() + + store := &fakeWebhookStore{ + configs: []channel.ChannelConfig{ + { + ID: "cfg-1", + BotID: "bot-1", + ChannelType: Type, + Credentials: map[string]any{ + "app_id": "app", + "app_secret": "secret", + "verification_token": "verify-token", + "inbound_mode": "webhook", + }, + }, + }, + } + manager := &fakeWebhookManager{} + h := NewWebhookHandler(nil, store, manager) + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(strings.Repeat("x", int(webhookMaxBodyBytes)+1))) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("config_id") + c.SetParamValues("cfg-1") + + err := h.Handle(c) + if err == nil { + t.Fatal("expected payload-too-large error") + } + he, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected HTTPError, got %T", err) + } + if he.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("unexpected status code: %d", he.Code) + } + if len(manager.calls) != 0 { + t.Fatalf("expected no inbound calls, got %d", len(manager.calls)) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index f2e64937..68131bd8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -47,14 +47,7 @@ func NewServer(log *slog.Logger, addr string, jwtSecret string, }, })) e.Use(auth.JWTMiddleware(jwtSecret, func(c echo.Context) bool { - path := c.Request().URL.Path - if path == "/ping" || path == "/health" || path == "/api/swagger.json" || path == "/auth/login" { - return true - } - if strings.HasPrefix(path, "/api/docs") { - return true - } - return false + return shouldSkipJWT(c.Request().URL.Path) })) for _, h := range handlers { @@ -77,3 +70,16 @@ func (s *Server) Start() error { func (s *Server) Stop(ctx context.Context) error { return s.echo.Shutdown(ctx) } + +func shouldSkipJWT(path string) bool { + if path == "/ping" || path == "/health" || path == "/api/swagger.json" || path == "/auth/login" { + return true + } + if strings.HasPrefix(path, "/api/docs") { + return true + } + if strings.HasPrefix(path, "/channels/feishu/webhook/") { + return true + } + return false +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 00000000..fc8e6e96 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,23 @@ +package server + +import "testing" + +func TestShouldSkipJWT_FeishuWebhookPaths(t *testing.T) { + t.Parallel() + + cases := []struct { + path string + want bool + }{ + {path: "/channels/feishu/webhook/cfg-1", want: true}, + {path: "/channels/feishu/webhook", want: false}, + {path: "/api/channels/feishu/webhook", want: false}, + } + + for _, tc := range cases { + got := shouldSkipJWT(tc.path) + if got != tc.want { + t.Fatalf("path=%q want=%v got=%v", tc.path, tc.want, got) + } + } +} diff --git a/packages/cli/src/cli/channel.ts b/packages/cli/src/cli/channel.ts index 2b7bbd0f..386f5767 100644 --- a/packages/cli/src/cli/channel.ts +++ b/packages/cli/src/cli/channel.ts @@ -6,6 +6,7 @@ import { table } from 'table' import { apiRequest } from '../core/api' import { ensureAuth, getErrorMessage, resolveBotId } from './shared' +import { getBaseURL, readConfig } from '../utils/store' type ChannelFieldSchema = { type: 'string' | 'secret' | 'bool' | 'number' | 'enum' @@ -54,6 +55,28 @@ type ChannelConfig = { updated_at: string } +const readInboundMode = (credentials: Record) => { + const raw = credentials.inboundMode ?? credentials.inbound_mode + if (typeof raw !== 'string') return '' + return raw.trim().toLowerCase() +} + +const buildWebhookCallbackUrl = (configId: string) => { + const baseUrl = getBaseURL(readConfig()).replace(/\/+$/, '') + return `${baseUrl}/channels/feishu/webhook/${encodeURIComponent(configId)}` +} + +const printWebhookCallbackIfEnabled = (channelType: string, config: ChannelConfig) => { + if (channelType !== 'feishu') return + if (readInboundMode(config.credentials || {}) !== 'webhook') return + const configId = String(config.id || '').trim() + if (!configId) { + console.log(chalk.yellow('Webhook is enabled, but config id is missing so callback URL cannot be generated yet.')) + return + } + console.log(chalk.cyan(`Webhook callback URL: ${buildWebhookCallbackUrl(configId)}`)) +} + const renderChannelsTable = (items: ChannelMeta[]) => { const rows: string[][] = [['Type', 'Name', 'Configless']] for (const item of items) { @@ -100,6 +123,8 @@ const collectFeishuCredentials = async (opts: Record) => { let appSecret = typeof opts.app_secret === 'string' ? opts.app_secret : undefined let encryptKey = typeof opts.encrypt_key === 'string' ? opts.encrypt_key : undefined let verificationToken = typeof opts.verification_token === 'string' ? opts.verification_token : undefined + let region = typeof opts.region === 'string' ? opts.region : undefined + let inboundMode = typeof opts.inbound_mode === 'string' ? opts.inbound_mode : undefined const questions = [] if (!appId) questions.push({ type: 'input', name: 'appId', message: 'Feishu App ID:' }) @@ -110,16 +135,44 @@ const collectFeishuCredentials = async (opts: Record) => { if (!verificationToken) { questions.push({ type: 'input', name: 'verificationToken', message: 'Verification Token (optional):', default: '' }) } + if (!region) { + questions.push({ + type: 'list', + name: 'region', + message: 'Region:', + choices: [ + { name: 'Feishu (open.feishu.cn)', value: 'feishu' }, + { name: 'Lark (open.larksuite.com)', value: 'lark' }, + ], + default: 'feishu', + }) + } + if (!inboundMode) { + questions.push({ + type: 'list', + name: 'inboundMode', + message: 'Inbound mode:', + choices: [ + { name: 'WebSocket', value: 'websocket' }, + { name: 'Webhook', value: 'webhook' }, + ], + default: 'websocket', + }) + } const answers = questions.length ? await inquirer.prompt>(questions) : {} appId = appId ?? answers.appId appSecret = appSecret ?? answers.appSecret encryptKey = encryptKey ?? answers.encryptKey verificationToken = verificationToken ?? answers.verificationToken + region = region ?? answers.region + inboundMode = inboundMode ?? answers.inboundMode const payload: Record = { appId: String(appId).trim(), appSecret: String(appSecret).trim(), + region: String(region || 'feishu').trim(), + inboundMode: String(inboundMode || 'websocket').trim(), } if (String(encryptKey || '').trim()) payload.encryptKey = String(encryptKey).trim() if (String(verificationToken || '').trim()) payload.verificationToken = String(verificationToken).trim() @@ -200,6 +253,7 @@ export const registerChannelCommands = (program: Command) => { const channelType = await resolveChannelType(token, opts.type) const resp = await apiRequest(`/bots/${encodeURIComponent(resolvedBotId)}/channel/${encodeURIComponent(channelType)}`, {}, token) console.log(JSON.stringify(resp, null, 2)) + printWebhookCallbackIfEnabled(channelType, resp) }) config @@ -211,6 +265,8 @@ export const registerChannelCommands = (program: Command) => { .option('--app_secret ') .option('--encrypt_key ') .option('--verification_token ') + .option('--region ', 'feishu|lark') + .option('--inbound_mode ', 'websocket|webhook') .action(async (botId, opts) => { const token = ensureAuth() const resolvedBotId = await resolveBotId(token, botId) @@ -222,11 +278,12 @@ export const registerChannelCommands = (program: Command) => { const credentials = await collectFeishuCredentials(opts) const spinner = ora('Updating channel config...').start() try { - await apiRequest(`/bots/${encodeURIComponent(resolvedBotId)}/channel/${encodeURIComponent(channelType)}`, { + const resp = await apiRequest(`/bots/${encodeURIComponent(resolvedBotId)}/channel/${encodeURIComponent(channelType)}`, { method: 'PUT', body: JSON.stringify({ credentials }), }, token) spinner.succeed('Channel config updated') + printWebhookCallbackIfEnabled(channelType, resp) } catch (err: unknown) { spinner.fail(getErrorMessage(err) || 'Failed to update channel config') process.exit(1) @@ -273,4 +330,3 @@ export const registerChannelCommands = (program: Command) => { } }) } - diff --git a/packages/web/src/i18n/locales/en.json b/packages/web/src/i18n/locales/en.json index 4b159595..8272f4b8 100644 --- a/packages/web/src/i18n/locales/en.json +++ b/packages/web/src/i18n/locales/en.json @@ -403,9 +403,13 @@ "actionDisable": "Disable", "saveOnly": "Save only", "saveAndEnable": "Save and enable", + "copyFailed": "Copy failed", "deleteConfirm": "Are you sure you want to remove this platform?", "deleteSuccess": "Platform removed", "deleteFailed": "Failed to remove platform", + "webhookCallback": "WebHook Callback URL", + "webhookCallbackHint": "Use this URL as the event subscription request URL in Feishu/Lark.", + "webhookCallbackPending": "Save this platform configuration to generate the callback URL.", "noAvailableTypes": "All platform types have been configured", "types": { "feishu": "Feishu", diff --git a/packages/web/src/i18n/locales/zh.json b/packages/web/src/i18n/locales/zh.json index bc698cf7..68da7674 100644 --- a/packages/web/src/i18n/locales/zh.json +++ b/packages/web/src/i18n/locales/zh.json @@ -399,9 +399,13 @@ "actionDisable": "停用", "saveOnly": "仅保存", "saveAndEnable": "立即启用", + "copyFailed": "复制失败", "deleteConfirm": "确定要移除这个平台吗?", "deleteSuccess": "平台已移除", "deleteFailed": "移除平台失败", + "webhookCallback": "WebHook 回调地址", + "webhookCallbackHint": "将该地址配置到飞书/Lark 事件订阅的请求 URL。", + "webhookCallbackPending": "保存平台配置后会生成回调地址。", "noAvailableTypes": "所有平台类型均已配置", "types": { "feishu": "飞书", diff --git a/packages/web/src/pages/bots/components/channel-settings-panel.vue b/packages/web/src/pages/bots/components/channel-settings-panel.vue index 75c6f91b..3876f33b 100644 --- a/packages/web/src/pages/bots/components/channel-settings-panel.vue +++ b/packages/web/src/pages/bots/components/channel-settings-panel.vue @@ -47,6 +47,40 @@ + + + {{ $t('bots.channels.webhookCallback') }} + + + {{ $t('bots.channels.webhookCallbackHint') }} + + + + + + {{ $t('common.copy') }} + + + + + {{ $t('bots.channels.webhookCallbackPending') }} + + + @@ -254,6 +288,7 @@ const { mutateAsync: updateChannelStatus, isLoading: isStatusLoading } = useMuta const action = ref<'save' | 'toggle' | 'delete' | ''>('') const isBusy = computed(() => isLoading.value || isStatusLoading.value || action.value !== '') const isEditMode = computed(() => props.channelItem.configured) +const lastSavedConfigId = ref('') // ---- Form state ---- @@ -279,6 +314,23 @@ const orderedFields = computed(() => { return Object.fromEntries(entries) as Record }) +const currentInboundMode = computed(() => { + const value = form.credentials.inboundMode ?? form.credentials.inbound_mode + if (typeof value !== 'string') return '' + return value.trim().toLowerCase() +}) + +const showWebhookCallback = computed(() => { + return props.channelItem.meta.type === 'feishu' && currentInboundMode.value === 'webhook' +}) + +const webhookCallbackUrl = computed(() => { + if (!showWebhookCallback.value) return '' + const configId = String(props.channelItem.config?.id || lastSavedConfigId.value || '').trim() + if (!configId) return '' + return buildWebhookCallbackUrl(configId) +}) + function initForm() { const schema = props.channelItem.meta.config_schema?.fields ?? {} const existingCredentials = props.channelItem.config?.credentials ?? {} @@ -289,6 +341,7 @@ function initForm() { } form.credentials = creds form.disabled = props.channelItem.config?.disabled ?? false + lastSavedConfigId.value = String(props.channelItem.config?.id || '').trim() } watch( @@ -325,13 +378,14 @@ async function saveChannel(disabled: boolean, nextAction: 'save' | 'toggle') { if (!validateRequired()) return action.value = nextAction try { - await upsertChannel({ + const result = await upsertChannel({ platform: props.channelItem.meta.type, data: { credentials: buildCredentials(), disabled, }, }) + lastSavedConfigId.value = String(result?.id || lastSavedConfigId.value || '').trim() form.disabled = disabled toast.success(t('bots.channels.saveSuccess')) emit('saved') @@ -384,6 +438,7 @@ async function handleDelete() { url: `/bots/${botIdRef.value}/channel/${props.channelItem.meta.type}`, throwOnError: true, }) + lastSavedConfigId.value = '' toast.success(t('bots.channels.deleteSuccess')) emit('saved') } catch (err) { @@ -393,4 +448,65 @@ async function handleDelete() { action.value = '' } } + +function buildWebhookCallbackUrl(configId: string): string { + const normalizedBase = resolveWebhookCallbackBaseUrl() + if (!normalizedBase) return '' + if (typeof window !== 'undefined') { + const baseUrl = new URL(normalizedBase, window.location.origin) + baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/, '')}/channels/feishu/webhook/${encodeURIComponent(configId)}` + baseUrl.search = '' + baseUrl.hash = '' + return baseUrl.toString() + } + const base = normalizedBase.replace(/\/+$/, '') + return `${base}/channels/feishu/webhook/${encodeURIComponent(configId)}` +} + +function resolveWebhookCallbackBaseUrl(): string { + const explicitRaw = String( + import.meta.env.VITE_WEBHOOK_PUBLIC_BASE_URL?.trim() || + import.meta.env.VITE_API_PUBLIC_URL?.trim() || + '', + ).trim() + if (isAbsoluteHttpUrl(explicitRaw)) { + return explicitRaw + } + + const apiBase = String(client.getConfig().baseUrl || import.meta.env.VITE_API_URL?.trim() || '').trim() + if (isAbsoluteHttpUrl(apiBase)) { + return apiBase + } + + if (typeof window === 'undefined') { + return '' + } + const fallbackApiPort = String(import.meta.env.VITE_API_PORT || '8080').trim() + const fallback = new URL(window.location.origin) + if (fallbackApiPort) { + fallback.port = fallbackApiPort + } + fallback.search = '' + fallback.hash = '' + return fallback.toString().replace(/\/+$/, '') +} + +function isAbsoluteHttpUrl(value: string): boolean { + return /^https?:\/\//i.test(value) +} + +async function copyWebhookCallback() { + if (!webhookCallbackUrl.value) return + try { + if (typeof navigator !== 'undefined' && navigator.clipboard) { + await navigator.clipboard.writeText(webhookCallbackUrl.value) + toast.success(t('common.copied')) + return + } + toast.error(t('bots.channels.copyFailed')) + } catch (err) { + const detail = err instanceof Error ? err.message : '' + toast.error(detail ? `${t('bots.channels.copyFailed')}: ${detail}` : t('bots.channels.copyFailed')) + } +}
+ {{ $t('bots.channels.webhookCallbackHint') }} +
+ {{ $t('bots.channels.webhookCallbackPending') }} +