feat(feishu): add list_group_members tool + native @mention support (#280)

* feat(feishu): add list_group_members tool + native @mention support

Add agent tool to list all members in a Lark group chat via
GET /open-apis/im/v1/chats/{chat_id}/members with auto-pagination.

- New tool `list_group_members`: returns member IDs + display names
- Native @mention conversion: @ou_xxx in outbound messages becomes
  real Lark mentions (post: "at" element, card: <at> markdown tag)
- Auto-sync discovered members into ContactStore
- GroupMemberProvider interface on Channel for extensibility
- Add im:chat.members:read scope to channel setup UI

Requires Lark app scope: im:chat.members:read

* fix(feishu): URL-encode API params + channel-aware tool filtering

- URL-encode chatID (PathEscape) and pageToken (QueryEscape) in
  ListChatMembers to prevent path traversal and query corruption
- Add slog.Warn when ListGroupMembers fails for observability
- Add ChannelAware interface so tools can declare required channel types
- ListGroupMembersTool now declares RequiredChannelTypes=["feishu"]
- loop.go filters out channel-specific tools when channel type mismatches,
  so list_group_members only appears for Feishu conversations

---------

Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
Co-authored-by: viettranx <viettranx@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-03-19 17:21:38 +07:00
1 parent ec24b982f3
commit bb783b952c
10 files changed
+288 -12

No files matched your search

+78 -10
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"log/slog"
"net/http"
"regexp"
"strings"
"sync"
"time"
@@ -395,23 +396,67 @@ func resolveReceiveIDType(id string) string {
// --- Content builders ---
// mentionRe matches @ou_xxx patterns (Lark open_id) for outbound mention conversion.
var mentionRe = regexp.MustCompile(`@(ou_[a-zA-Z0-9_]+)`)
// hasMentions checks if text contains @ou_xxx patterns.
func hasMentions(text string) bool {
return mentionRe.MatchString(text)
}
// buildPostContent creates a Lark "post" message body.
// If the text contains @ou_xxx patterns, they are converted to native "at" elements
// so Lark renders real @mentions with notifications.
func buildPostContent(text string) string {
var elements []map[string]any
if hasMentions(text) {
// Split text around @ou_xxx patterns → alternating md + at elements.
matches := mentionRe.FindAllStringIndex(text, -1)
prev := 0
for _, loc := range matches {
// Text before the mention
if loc[0] > prev {
elements = append(elements, map[string]any{
"tag": "md",
"text": text[prev:loc[0]],
})
}
// The mention itself: extract ou_xxx from "@ou_xxx"
userID := text[loc[0]+1 : loc[1]] // skip "@"
elements = append(elements, map[string]any{
"tag": "at",
"user_id": userID,
})
prev = loc[1]
}
// Remaining text after last mention
if prev < len(text) {
elements = append(elements, map[string]any{
"tag": "md",
"text": text[prev:],
})
}
} else {
elements = []map[string]any{{"tag": "md", "text": text}}
}
content := map[string]any{
"zh_cn": map[string]any{
"content": [][]map[string]any{
{
{
"tag": "md",
"text": text,
},
},
},
"content": [][]map[string]any{elements},
},
}
data, _ := json.Marshal(content)
return string(data)
}
// convertMentionsForCard replaces @ou_xxx in text with Lark card markdown mention tags.
// e.g. "@ou_abc123" → "<at id=ou_abc123></at>"
// This syntax works in interactive card markdown content.
func convertMentionsForCard(text string) string {
return mentionRe.ReplaceAllString(text, `<at id=$1></at>`)
}
func buildMarkdownCard(text string) map[string]any {
return map[string]any{
"schema": "2.0",
@@ -422,7 +467,7 @@ func buildMarkdownCard(text string) map[string]any {
"elements": []map[string]any{
{
"tag": "markdown",
"content": text,
"content": convertMentionsForCard(text),
},
},
},
@@ -507,7 +552,30 @@ func (c *Channel) removeTypingReaction(ctx context.Context, chatID string) error
return nil
}
// Ensure Channel implements the channels.Channel, WebhookChannel, and ReactionChannel interfaces at compile time.
// ListGroupMembers returns all members of a Lark group chat.
// Also syncs discovered members into the contact store (if available).
func (c *Channel) ListGroupMembers(ctx context.Context, chatID string) ([]channels.GroupMember, error) {
members, err := c.client.ListChatMembers(ctx, chatID)
if err != nil {
slog.Warn("feishu.list_group_members", "chat_id", chatID, "error", err)
return nil, err
}
result := make([]channels.GroupMember, len(members))
for i, m := range members {
result[i] = channels.GroupMember{
MemberID: m.MemberID,
Name: m.Name,
}
// Auto-sync member into contact store
if cc := c.ContactCollector(); cc != nil {
cc.EnsureContact(ctx, channels.TypeFeishu, c.Name(), m.MemberID, m.MemberID, m.Name, "", "group")
}
}
return result, nil
}
// Ensure Channel implements the channels.Channel, WebhookChannel, ReactionChannel, and GroupMemberProvider interfaces at compile time.
var _ channels.Channel = (*Channel)(nil)
var _ channels.WebhookChannel = (*Channel)(nil)
var _ channels.ReactionChannel = (*Channel)(nil)
var _ channels.GroupMemberProvider = (*Channel)(nil)
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"log/slog"
"net/url"
"strconv"
)
@@ -252,6 +253,57 @@ func (c *LarkClient) GetBotInfo(ctx context.Context) (string, error) {
return result.Bot.OpenID, nil
}
// --- IM API: Chat Members ---
// ChatMember represents a member of a Lark group chat.
type ChatMember struct {
MemberID string `json:"member_id"`
MemberIDType string `json:"member_id_type"`
Name string `json:"name"`
TenantKey string `json:"tenant_key"`
}
// ListChatMembers returns all members of a group chat, handling pagination automatically.
// Lark API: GET /open-apis/im/v1/chats/{chat_id}/members
// Requires scope: im:chat.members:read
func (c *LarkClient) ListChatMembers(ctx context.Context, chatID string) ([]ChatMember, error) {
var all []ChatMember
pageToken := ""
for {
path := fmt.Sprintf("/open-apis/im/v1/chats/%s/members?member_id_type=open_id&page_size=100", url.PathEscape(chatID))
if pageToken != "" {
path += "&page_token=" + url.QueryEscape(pageToken)
}
resp, err := c.doJSON(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
if resp.Code != 0 {
return nil, fmt.Errorf("list chat members: code=%d msg=%s", resp.Code, resp.Msg)
}
var result struct {
Items []ChatMember `json:"items"`
PageToken string `json:"page_token"`
HasMore bool `json:"has_more"`
}
if err := json.Unmarshal(resp.Data, &result); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
all = append(all, result.Items...)
if !result.HasMore || result.PageToken == "" {
break
}
pageToken = result.PageToken
}
return all, nil
}
// --- Contact API ---
func (c *LarkClient) GetUser(ctx context.Context, userID, userIDType string) (string, error) {