Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion db/queries/channels.sql
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ FROM bot_channel_configs
WHERE bot_id = $1 AND channel_type = $2
LIMIT 1;

-- name: GetBotChannelConfigByID :one
SELECT id, bot_id, channel_type, credentials, external_identity, self_identity, routing, capabilities, disabled, verified_at, created_at, updated_at
FROM bot_channel_configs
WHERE id = $1
LIMIT 1;

-- name: GetBotChannelConfigByExternalIdentity :one
SELECT id, bot_id, channel_type, credentials, external_identity, self_identity, routing, capabilities, disabled, verified_at, created_at, updated_at
FROM bot_channel_configs
Expand Down Expand Up @@ -65,4 +71,3 @@ SELECT id, user_id, channel_type, config, created_at, updated_at
FROM user_channel_bindings
WHERE channel_type = $1
ORDER BY created_at DESC;

17 changes: 13 additions & 4 deletions internal/channel/adapters/feishu/bot_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,24 @@ func (a *FeishuAdapter) resolveBotOpenID(ctx context.Context, cfg channel.Channe
if openID := resolveConfiguredBotOpenID(cfg); openID != "" {
return openID
}
if cfg.ID != "" {
if v, ok := a.botOpenIDs.Load(cfg.ID); ok {
return v.(string)
}
}
discovered, externalID, err := a.DiscoverSelf(ctx, cfg.Credentials)
if err != nil {
if a != nil && a.logger != nil {
if 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
openID := strings.TrimSpace(channel.ReadString(discovered, "open_id", "openId"))
if openID == "" {
openID = resolveConfiguredBotOpenID(channel.ChannelConfig{ExternalIdentity: externalID})
}
if openID != "" && cfg.ID != "" {
a.botOpenIDs.Store(cfg.ID, openID)
}
return resolveConfiguredBotOpenID(channel.ChannelConfig{ExternalIdentity: externalID})
return openID
}
11 changes: 8 additions & 3 deletions internal/channel/adapters/feishu/feishu.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"log/slog"
"net/http"
"strings"
"sync"
"time"

"github.com/google/uuid"
Expand All @@ -31,8 +32,12 @@ type assetOpener interface {

// FeishuAdapter implements the channel.Adapter, channel.Sender, and channel.Receiver interfaces for Feishu.
type FeishuAdapter struct {
logger *slog.Logger
assets assetOpener
logger *slog.Logger
assets assetOpener
senderProfiles sync.Map
senderProfileSweepMu sync.Mutex
senderProfileSweepAt time.Time
botOpenIDs sync.Map
}

const processingBusyReactionType = "Typing"
Expand Down Expand Up @@ -615,7 +620,7 @@ func (a *FeishuAdapter) OpenStream(ctx context.Context, cfg channel.ChannelConfi
cfg: cfg,
target: target,
reply: opts.Reply,
client: client,
messageAPI: client.Im.Message,
receiveID: receiveID,
receiveType: receiveType,
patchInterval: feishuStreamPatchInterval,
Expand Down
173 changes: 127 additions & 46 deletions internal/channel/adapters/feishu/sender_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,37 @@ type feishuSenderProfile struct {
avatarURL string
}

const feishuChatMembersPageSize = 100
const (
feishuChatMembersPageSize = 100
senderProfileCacheTTL = 5 * time.Minute
senderProfileSweepWindow = 1 * time.Minute
)

type feishuSenderProfileLookup interface {
LookupContact(ctx context.Context, openID, userID string) (feishuSenderProfile, error)
LookupGroupMember(ctx context.Context, chatID, memberIDType, memberID string) (feishuSenderProfile, error)
}

type larkSenderProfileLookup struct {
client *lark.Client
}

func (l larkSenderProfileLookup) LookupContact(ctx context.Context, openID, userID string) (feishuSenderProfile, error) {
return lookupSenderProfileFromContact(ctx, l.client, openID, userID)
}

func (l larkSenderProfileLookup) LookupGroupMember(ctx context.Context, chatID, memberIDType, memberID string) (feishuSenderProfile, error) {
return lookupSenderProfileFromGroupMember(ctx, l.client, chatID, memberIDType, memberID)
}

type cachedSenderProfile struct {
profile feishuSenderProfile
expiresAt time.Time
}

// 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.
// In group chats it prefers chat-specific aliases, then falls back to the global
// contact profile when no group-scoped name is available.
func (a *FeishuAdapter) enrichSenderProfile(ctx context.Context, cfg channel.ChannelConfig, event *larkim.P2MessageReceiveV1, msg *channel.InboundMessage) {
if msg == nil {
return
Expand All @@ -49,70 +75,126 @@ func (a *FeishuAdapter) enrichSenderProfile(ctx context.Context, cfg channel.Cha
chatID = strings.TrimSpace(*event.Event.Message.ChatId)
}

cacheKey := strings.Join([]string{cfg.ID, strings.TrimPrefix(chatID, "chat_id:"), openID, userID}, "|")
if cached, ok := a.loadCachedSenderProfile(cacheKey); ok {
applySenderProfile(msg, cached)
return
}

lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

profile, err := a.lookupSenderProfile(lookupCtx, cfg, openID, userID, chatID)
feishuCfg, err := parseConfig(cfg.Credentials)
if err != nil {
if a.logger != nil {
a.logger.Debug("feishu sender profile lookup failed",
slog.String("config_id", cfg.ID),
slog.String("open_id", openID),
slog.String("user_id", userID),
slog.String("chat_id", chatID),
slog.Any("error", err),
)
}
applySenderProfile(msg, fallbackSenderProfile(openID, userID))
return
}
profile, err := lookupSenderProfileWithLookup(lookupCtx, larkSenderProfileLookup{client: feishuCfg.newClient()}, openID, userID, chatID)
if err != nil && a.logger != nil {
a.logger.Debug("feishu sender profile lookup failed",
slog.String("config_id", cfg.ID),
slog.String("open_id", openID),
slog.String("user_id", userID),
slog.String("chat_id", chatID),
slog.Any("error", err),
)
}
if strings.TrimSpace(profile.displayName) == "" && strings.TrimSpace(profile.username) == "" && strings.TrimSpace(profile.avatarURL) == "" {
profile = fallbackSenderProfile(openID, userID)
if profile.displayName != "" || profile.username != "" || profile.avatarURL != "" {
a.storeCachedSenderProfile(cacheKey, profile)
applySenderProfile(msg, profile)
} else {
applySenderProfile(msg, fallbackSenderProfile(openID, userID))
}
applySenderProfile(msg, profile)
}

func (*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
func lookupSenderProfileWithLookup(ctx context.Context, lookup feishuSenderProfileLookup, openID, userID, chatID string) (feishuSenderProfile, error) {
if lookup == nil {
return feishuSenderProfile{}, errors.New("sender profile lookup not configured")
}
client := feishuCfg.newClient()
chatID = strings.TrimPrefix(strings.TrimSpace(chatID), "chat_id:")

var lastErr error
chatID = strings.TrimSpace(chatID)
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
if chatID != "" {
if openID != "" {
if p, err := lookup.LookupGroupMember(ctx, chatID, "open_id", openID); err == nil {
if p.displayName != "" || p.username != "" || p.avatarURL != "" {
return p, nil
}
} else {
lastErr = err
}
} 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
if userID != "" {
if p, err := lookup.LookupGroupMember(ctx, chatID, "user_id", userID); err == nil {
if p.displayName != "" || p.username != "" || p.avatarURL != "" {
return p, nil
}
} else {
lastErr = err
}
} 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
if p, err := lookup.LookupContact(ctx, openID, userID); err == nil {
if p.displayName != "" || p.username != "" || p.avatarURL != "" {
return p, nil
}
} else {
lastErr = err
}

if lastErr != nil {
return feishuSenderProfile{}, lastErr
return feishuSenderProfile{}, lastErr
}

func (a *FeishuAdapter) loadCachedSenderProfile(key string) (feishuSenderProfile, bool) {
if a == nil || strings.TrimSpace(key) == "" {
return feishuSenderProfile{}, false
}
return feishuSenderProfile{}, nil
raw, ok := a.senderProfiles.Load(key)
if !ok {
return feishuSenderProfile{}, false
}
entry, ok := raw.(cachedSenderProfile)
if !ok {
a.senderProfiles.Delete(key)
return feishuSenderProfile{}, false
}
if time.Now().After(entry.expiresAt) {
a.senderProfiles.Delete(key)
return feishuSenderProfile{}, false
}
return entry.profile, true
}

func (a *FeishuAdapter) storeCachedSenderProfile(key string, profile feishuSenderProfile) {
if a == nil || strings.TrimSpace(key) == "" {
return
}
now := time.Now()
a.senderProfiles.Store(key, cachedSenderProfile{
profile: profile,
expiresAt: now.Add(senderProfileCacheTTL),
})
a.maybeSweepExpiredSenderProfiles(now)
}

func (a *FeishuAdapter) maybeSweepExpiredSenderProfiles(now time.Time) {
if a == nil {
return
}
a.senderProfileSweepMu.Lock()
defer a.senderProfileSweepMu.Unlock()
if !a.senderProfileSweepAt.IsZero() && now.Sub(a.senderProfileSweepAt) < senderProfileSweepWindow {
return
}
a.senderProfileSweepAt = now
a.senderProfiles.Range(func(key, value any) bool {
entry, ok := value.(cachedSenderProfile)
if !ok || now.After(entry.expiresAt) {
a.senderProfiles.Delete(key)
}
return true
})
}

func lookupSenderProfileFromContact(ctx context.Context, client *lark.Client, openID, userID string) (feishuSenderProfile, error) {
Expand Down Expand Up @@ -227,9 +309,8 @@ func fallbackSenderProfile(openID, userID string) feishuSenderProfile {
if username == "" {
return feishuSenderProfile{}
}
displayName := username
return feishuSenderProfile{
displayName: displayName,
displayName: username,
username: username,
}
}
Expand Down
Loading
Loading