package feishu import ( "context" "fmt" "strings" 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" ) const ( defaultDirectoryPageSize = 20 maxDirectoryPageSize = 200 ) func directoryLimit(n int) int { if n <= 0 { return defaultDirectoryPageSize } if n > maxDirectoryPageSize { return maxDirectoryPageSize } return n } // ListPeers lists users (peers) from Feishu contact, optionally filtered by query. func (a *FeishuAdapter) ListPeers(ctx context.Context, cfg channel.ChannelConfig, query channel.DirectoryQuery) ([]channel.DirectoryEntry, error) { feishuCfg, err := parseConfig(cfg.Credentials) if err != nil { return nil, err } client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL())) pageSize := directoryLimit(query.Limit) req := larkcontact.NewListUserReqBuilder(). UserIdType(larkcontact.UserIdTypeOpenId). DepartmentIdType(larkcontact.DepartmentIdTypeOpenDepartmentId). DepartmentId("0"). PageSize(pageSize). Build() resp, err := client.Contact.User.List(ctx, req) if err != nil { return nil, fmt.Errorf("feishu list users: %w", err) } if !resp.Success() { return nil, fmt.Errorf("feishu list users: code=%d msg=%s", resp.Code, resp.Msg) } entries := make([]channel.DirectoryEntry, 0, len(resp.Data.Items)) for _, u := range resp.Data.Items { e := feishuUserToEntry(u) if query.Query != "" && !strings.Contains(strings.ToLower(e.Name+e.Handle), strings.ToLower(query.Query)) { continue } entries = append(entries, e) } return entries, nil } // ListGroups lists chat groups from Feishu IM, optionally filtered by query. func (a *FeishuAdapter) ListGroups(ctx context.Context, cfg channel.ChannelConfig, query channel.DirectoryQuery) ([]channel.DirectoryEntry, error) { feishuCfg, err := parseConfig(cfg.Credentials) if err != nil { return nil, err } client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL())) pageSize := directoryLimit(query.Limit) var items []*larkim.ListChat if strings.TrimSpace(query.Query) != "" { req := larkim.NewSearchChatReqBuilder(). UserIdType("open_id"). Query(strings.TrimSpace(query.Query)). PageSize(pageSize). Build() resp, err := client.Im.Chat.Search(ctx, req) if err != nil { return nil, fmt.Errorf("feishu search chats: %w", err) } if !resp.Success() { return nil, fmt.Errorf("feishu search chats: code=%d msg=%s", resp.Code, resp.Msg) } items = resp.Data.Items } else { req := larkim.NewListChatReqBuilder(). UserIdType("open_id"). PageSize(pageSize). Build() resp, err := client.Im.Chat.List(ctx, req) if err != nil { return nil, fmt.Errorf("feishu list chats: %w", err) } if !resp.Success() { return nil, fmt.Errorf("feishu list chats: code=%d msg=%s", resp.Code, resp.Msg) } items = resp.Data.Items } entries := make([]channel.DirectoryEntry, 0, len(items)) for _, c := range items { entries = append(entries, feishuChatToEntry(c)) } return entries, nil } // ListGroupMembers lists members of a Feishu chat group. func (a *FeishuAdapter) ListGroupMembers(ctx context.Context, cfg channel.ChannelConfig, groupID string, query channel.DirectoryQuery) ([]channel.DirectoryEntry, error) { feishuCfg, err := parseConfig(cfg.Credentials) if err != nil { return nil, err } chatID := strings.TrimSpace(groupID) if strings.HasPrefix(chatID, "chat_id:") { chatID = strings.TrimPrefix(chatID, "chat_id:") } if chatID == "" { return nil, fmt.Errorf("feishu list group members: empty group id") } client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL())) pageSize := directoryLimit(query.Limit) req := larkim.NewGetChatMembersReqBuilder(). ChatId(chatID). MemberIdType("open_id"). PageSize(pageSize). Build() resp, err := client.Im.ChatMembers.Get(ctx, req) if err != nil { return nil, fmt.Errorf("feishu get chat members: %w", err) } if !resp.Success() { return nil, fmt.Errorf("feishu get chat members: code=%d msg=%s", resp.Code, resp.Msg) } entries := make([]channel.DirectoryEntry, 0, len(resp.Data.Items)) for _, m := range resp.Data.Items { e := feishuMemberToEntry(m) if query.Query != "" && !strings.Contains(strings.ToLower(e.Name+e.Handle), strings.ToLower(query.Query)) { continue } entries = append(entries, e) } return entries, nil } // ResolveEntry resolves an input string to a user or group DirectoryEntry. func (a *FeishuAdapter) ResolveEntry(ctx context.Context, cfg channel.ChannelConfig, input string, kind channel.DirectoryEntryKind) (channel.DirectoryEntry, error) { feishuCfg, err := parseConfig(cfg.Credentials) if err != nil { return channel.DirectoryEntry{}, err } client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL())) input = strings.TrimSpace(input) switch kind { case channel.DirectoryEntryUser: return a.resolveUser(ctx, client, input) case channel.DirectoryEntryGroup: return a.resolveGroup(ctx, client, input) default: return channel.DirectoryEntry{}, fmt.Errorf("feishu resolve entry: unsupported kind %q", kind) } } func (a *FeishuAdapter) resolveUser(ctx context.Context, client *lark.Client, input string) (channel.DirectoryEntry, error) { userID, userIDType := parseFeishuUserInput(input) if userID == "" { return channel.DirectoryEntry{}, fmt.Errorf("feishu resolve entry user: invalid input %q", input) } req := larkcontact.NewGetUserReqBuilder(). UserId(userID). UserIdType(userIDType). Build() resp, err := client.Contact.User.Get(ctx, req) if err != nil { return channel.DirectoryEntry{}, fmt.Errorf("feishu get user: %w", err) } if !resp.Success() { return channel.DirectoryEntry{}, fmt.Errorf("feishu get user: code=%d msg=%s", resp.Code, resp.Msg) } if resp.Data == nil || resp.Data.User == nil { return channel.DirectoryEntry{}, fmt.Errorf("feishu get user: empty response") } return feishuUserToEntry(resp.Data.User), nil } func (a *FeishuAdapter) resolveGroup(ctx context.Context, client *lark.Client, input string) (channel.DirectoryEntry, error) { chatID := strings.TrimSpace(input) if strings.HasPrefix(chatID, "chat_id:") { chatID = strings.TrimPrefix(chatID, "chat_id:") } if chatID == "" { return channel.DirectoryEntry{}, fmt.Errorf("feishu resolve entry group: invalid input %q", input) } req := larkim.NewGetChatReqBuilder(). ChatId(chatID). UserIdType("open_id"). Build() resp, err := client.Im.Chat.Get(ctx, req) if err != nil { return channel.DirectoryEntry{}, fmt.Errorf("feishu get chat: %w", err) } if !resp.Success() { return channel.DirectoryEntry{}, fmt.Errorf("feishu get chat: code=%d msg=%s", resp.Code, resp.Msg) } return channel.DirectoryEntry{ Kind: channel.DirectoryEntryGroup, ID: "chat_id:" + chatID, Name: ptrStr(resp.Data.Name), AvatarURL: ptrStr(resp.Data.Avatar), Metadata: map[string]any{"chat_id": chatID}, }, nil } func parseFeishuUserInput(raw string) (userID, userIDType string) { raw = strings.TrimSpace(raw) if raw == "" { return "", "" } if strings.HasPrefix(raw, "open_id:") { return strings.TrimSpace(strings.TrimPrefix(raw, "open_id:")), larkcontact.UserIdTypeOpenId } if strings.HasPrefix(raw, "user_id:") { return strings.TrimSpace(strings.TrimPrefix(raw, "user_id:")), larkcontact.UserIdTypeUserId } if strings.HasPrefix(raw, "ou_") { return raw, larkcontact.UserIdTypeOpenId } if strings.HasPrefix(raw, "u_") || strings.HasPrefix(raw, "u-") { return raw, larkcontact.UserIdTypeUserId } // 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 { openID := ptrStr(u.OpenId) userID := ptrStr(u.UserId) id := "open_id:" + openID if openID == "" && userID != "" { id = "user_id:" + userID } meta := make(map[string]any) if u.OpenId != nil { meta["open_id"] = *u.OpenId } if u.UserId != nil { meta["user_id"] = *u.UserId } return channel.DirectoryEntry{ Kind: channel.DirectoryEntryUser, ID: id, Name: ptrStr(u.Name), Handle: ptrStr(u.Nickname), AvatarURL: feishuAvatarURL(u.Avatar), Metadata: meta, } } func feishuChatToEntry(c *larkim.ListChat) channel.DirectoryEntry { chatID := ptrStr(c.ChatId) meta := map[string]any{"chat_id": chatID} return channel.DirectoryEntry{ Kind: channel.DirectoryEntryGroup, ID: "chat_id:" + chatID, Name: ptrStr(c.Name), AvatarURL: ptrStr(c.Avatar), Metadata: meta, } } func feishuMemberToEntry(m *larkim.ListMember) channel.DirectoryEntry { id := ptrStr(m.MemberId) meta := make(map[string]any) if m.MemberIdType != nil { meta["member_id_type"] = *m.MemberIdType } prefix := "open_id:" if m.MemberIdType != nil && *m.MemberIdType == "user_id" { prefix = "user_id:" } return channel.DirectoryEntry{ Kind: channel.DirectoryEntryUser, ID: prefix + id, Name: ptrStr(m.Name), Metadata: meta, } } func ptrStr(s *string) string { if s == nil { return "" } return strings.TrimSpace(*s) } func feishuAvatarURL(avatar *larkcontact.AvatarInfo) string { if avatar == nil || avatar.Avatar72 == nil { return "" } return strings.TrimSpace(*avatar.Avatar72) }