feat(feishu): add webhook inbound mode, region support, and callback/attachment enhancements (#107)

This commit is contained in:
Ringo.Typowriter
2026-02-23 21:57:34 +08:00
committed by GitHub
parent df12d94171
commit 29e76322cc
19 changed files with 1474 additions and 66 deletions
@@ -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})
}
+55 -2
View File
@@ -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
}
@@ -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()
@@ -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)
}
}
@@ -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 {
@@ -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},
{"", "", ""},
}
+132 -44
View File
@@ -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
}
}
@@ -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()
@@ -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
}
}
@@ -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)
}
}
@@ -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")
}
@@ -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\":\"<at user_id=\\\"ou_other_user\\\"></at> 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))
}
}