mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 10:15:48 +00:00
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:
10 files changed
+288
-12
No files matched your search
@@ -184,6 +184,8 @@ func runGateway() {
|
||||
|
||||
// Message tool (send to channels)
|
||||
toolsReg.Register(tools.NewMessageTool(workspace, agentCfg.RestrictToWorkspace))
|
||||
// Group members tool (list members in group chats)
|
||||
toolsReg.Register(tools.NewListGroupMembersTool())
|
||||
slog.Info("session + message tools registered")
|
||||
|
||||
// Register legacy tool aliases (backward-compat names from policy.go).
|
||||
@@ -426,6 +428,12 @@ func runGateway() {
|
||||
cs.SetChannelSender(channelMgr.SendToChannel)
|
||||
}
|
||||
}
|
||||
// Wire group member lister on list_group_members tool
|
||||
if t, ok := toolsReg.Get("list_group_members"); ok {
|
||||
if gl, ok := t.(tools.GroupMemberListerAware); ok {
|
||||
gl.SetGroupMemberLister(channelMgr.ListGroupMembers)
|
||||
}
|
||||
}
|
||||
|
||||
// Load channel instances from DB.
|
||||
var instanceLoader *channels.InstanceLoader
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -628,6 +629,22 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
toolDefs = filtered
|
||||
}
|
||||
|
||||
// Hide channel-specific tools when channel type doesn't match.
|
||||
if req.ChannelType != "" {
|
||||
filtered := toolDefs[:0:0]
|
||||
for _, td := range toolDefs {
|
||||
if tool, ok := l.tools.Get(td.Function.Name); ok {
|
||||
if ca, ok := tool.(tools.ChannelAware); ok {
|
||||
if !slices.Contains(ca.RequiredChannelTypes(), req.ChannelType) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, td)
|
||||
}
|
||||
toolDefs = filtered
|
||||
}
|
||||
|
||||
// Use per-request model override if set (e.g. heartbeat uses cheaper model).
|
||||
model := l.model
|
||||
if req.ModelOverride != "" {
|
||||
|
||||
@@ -330,6 +330,17 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st
|
||||
c.bus.PublishInbound(msg)
|
||||
}
|
||||
|
||||
// GroupMember represents a member of a group chat.
|
||||
type GroupMember struct {
|
||||
MemberID string `json:"member_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// GroupMemberProvider is optionally implemented by channels that can list group members.
|
||||
type GroupMemberProvider interface {
|
||||
ListGroupMembers(ctx context.Context, chatID string) ([]GroupMember, error)
|
||||
}
|
||||
|
||||
// PendingCompactable is optionally implemented by channels that have a PendingHistory
|
||||
// supporting LLM-based compaction. InstanceLoader uses this to wire compaction config
|
||||
// after channel creation.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
@@ -190,6 +191,21 @@ func (m *Manager) ChannelTypeForName(name string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ListGroupMembers delegates to the channel's GroupMemberProvider if available.
|
||||
func (m *Manager) ListGroupMembers(ctx context.Context, channelName, chatID string) ([]GroupMember, error) {
|
||||
m.mu.RLock()
|
||||
ch, ok := m.channels[channelName]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("channel %q not found", channelName)
|
||||
}
|
||||
gmp, ok := ch.(GroupMemberProvider)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("channel %q does not support listing group members", channelName)
|
||||
}
|
||||
return gmp.ListGroupMembers(ctx, chatID)
|
||||
}
|
||||
|
||||
// UnregisterChannel removes a channel from the manager.
|
||||
func (m *Manager) UnregisterChannel(name string) {
|
||||
m.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
)
|
||||
|
||||
// GroupMemberLister abstracts listing members of a group chat.
|
||||
// Implemented by channels.Manager.ListGroupMembers.
|
||||
type GroupMemberLister func(ctx context.Context, channel, chatID string) ([]channels.GroupMember, error)
|
||||
|
||||
// GroupMemberListerAware tools can receive a group member lister function.
|
||||
type GroupMemberListerAware interface {
|
||||
SetGroupMemberLister(GroupMemberLister)
|
||||
}
|
||||
|
||||
// ListGroupMembersTool allows the agent to list members in a group chat.
|
||||
type ListGroupMembersTool struct {
|
||||
lister GroupMemberLister
|
||||
}
|
||||
|
||||
func NewListGroupMembersTool() *ListGroupMembersTool {
|
||||
return &ListGroupMembersTool{}
|
||||
}
|
||||
|
||||
func (t *ListGroupMembersTool) SetGroupMemberLister(l GroupMemberLister) { t.lister = l }
|
||||
|
||||
func (t *ListGroupMembersTool) RequiredChannelTypes() []string { return []string{"feishu"} }
|
||||
|
||||
func (t *ListGroupMembersTool) Name() string { return "list_group_members" }
|
||||
func (t *ListGroupMembersTool) Description() string {
|
||||
return "List all members of the current group chat. Returns member IDs (open_id) and display names. Only works in group conversations on supported channels (Lark/Feishu). Use this to find out who is in the group, check attendance, or identify members. To @mention a member in a message, use @member_id (e.g. @ou_abc123) — it will be converted to a native mention with notification."
|
||||
}
|
||||
|
||||
func (t *ListGroupMembersTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"channel": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Channel name (default: current channel from context)",
|
||||
},
|
||||
"chat_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Group chat ID (default: current chat from context)",
|
||||
},
|
||||
},
|
||||
"required": []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ListGroupMembersTool) Execute(ctx context.Context, args map[string]any) *Result {
|
||||
if t.lister == nil {
|
||||
return ErrorResult("list_group_members: no group member lister available")
|
||||
}
|
||||
|
||||
channel, _ := args["channel"].(string)
|
||||
if channel == "" {
|
||||
channel = ToolChannelFromCtx(ctx)
|
||||
}
|
||||
if channel == "" {
|
||||
return ErrorResult("channel is required (no current channel in context)")
|
||||
}
|
||||
|
||||
chatID, _ := args["chat_id"].(string)
|
||||
if chatID == "" {
|
||||
chatID = ToolChatIDFromCtx(ctx)
|
||||
}
|
||||
if chatID == "" {
|
||||
return ErrorResult("chat_id is required (no current chat in context)")
|
||||
}
|
||||
|
||||
members, err := t.lister(ctx, channel, chatID)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to list group members: %v", err))
|
||||
}
|
||||
|
||||
type memberOut struct {
|
||||
MemberID string `json:"member_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
out := make([]memberOut, len(members))
|
||||
for i, m := range members {
|
||||
out[i] = memberOut{MemberID: m.MemberID, Name: m.Name}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"channel": channel,
|
||||
"chat_id": chatID,
|
||||
"count": len(out),
|
||||
"members": out,
|
||||
})
|
||||
return NewResult(string(data))
|
||||
}
|
||||
@@ -21,7 +21,7 @@ var toolGroups = map[string][]string{
|
||||
"sessions": {"sessions_list", "sessions_history", "sessions_send", "spawn", "session_status"},
|
||||
"ui": {"browser"},
|
||||
"automation": {"cron"},
|
||||
"messaging": {"message", "create_forum_topic"},
|
||||
"messaging": {"message", "create_forum_topic", "list_group_members"},
|
||||
"team": {"team_tasks", "team_message"},
|
||||
// Composite group: all goclaw native tools (excludes MCP/custom plugins).
|
||||
"goclaw": {
|
||||
@@ -29,7 +29,7 @@ var toolGroups = map[string][]string{
|
||||
"web_search", "web_fetch", "browser",
|
||||
"memory_search", "memory_get",
|
||||
"sessions_list", "sessions_history", "sessions_send", "spawn", "session_status",
|
||||
"cron", "message", "create_forum_topic",
|
||||
"cron", "message", "create_forum_topic", "list_group_members",
|
||||
"read_image", "read_document", "read_audio", "read_video",
|
||||
"create_image", "create_video",
|
||||
"skill_search", "mcp_tool_search", "tts",
|
||||
|
||||
@@ -99,6 +99,12 @@ type ChannelSenderAware interface {
|
||||
SetChannelSender(ChannelSender)
|
||||
}
|
||||
|
||||
// ChannelAware is optionally implemented by tools that only work on specific channel types.
|
||||
// Tools implementing this are filtered out when the current channel type doesn't match.
|
||||
type ChannelAware interface {
|
||||
RequiredChannelTypes() []string
|
||||
}
|
||||
|
||||
// ToProviderDef converts a Tool to a providers.ToolDefinition for LLM APIs.
|
||||
func ToProviderDef(t Tool) providers.ToolDefinition {
|
||||
return providers.ToolDefinition{
|
||||
|
||||
@@ -182,6 +182,7 @@ export const requiredScopes: Partial<Record<string, ScopeEntry[]>> = {
|
||||
{ scope: "contact:user.employee_id:readonly", note: "Range: All members" },
|
||||
{ scope: "event:ip_list" },
|
||||
{ scope: "im:chat.members:bot_access" },
|
||||
{ scope: "im:chat.members:read", note: "Required for list_group_members tool" },
|
||||
{ scope: "im:message" },
|
||||
{ scope: "im:message.group_at_msg:readonly" },
|
||||
{ scope: "im:message.p2p_msg:readonly" },
|
||||
|
||||
Reference in new issue
Block a user