diff --git a/cmd/gateway.go b/cmd/gateway.go index 55ddbc0a..f4a6c6b8 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -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 diff --git a/internal/agent/loop.go b/internal/agent/loop.go index be257b12..6e441d6e 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -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 != "" { diff --git a/internal/channels/channel.go b/internal/channels/channel.go index 28e350ba..dcfb3807 100644 --- a/internal/channels/channel.go +++ b/internal/channels/channel.go @@ -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. diff --git a/internal/channels/feishu/feishu.go b/internal/channels/feishu/feishu.go index 1c5e2d76..da87c40e 100644 --- a/internal/channels/feishu/feishu.go +++ b/internal/channels/feishu/feishu.go @@ -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" → "" +// This syntax works in interactive card markdown content. +func convertMentionsForCard(text string) string { + return mentionRe.ReplaceAllString(text, ``) +} + 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) diff --git a/internal/channels/feishu/larkclient_messaging.go b/internal/channels/feishu/larkclient_messaging.go index f5ecf300..3146ff5e 100644 --- a/internal/channels/feishu/larkclient_messaging.go +++ b/internal/channels/feishu/larkclient_messaging.go @@ -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) { diff --git a/internal/channels/manager.go b/internal/channels/manager.go index 82f1a486..33846bd8 100644 --- a/internal/channels/manager.go +++ b/internal/channels/manager.go @@ -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() diff --git a/internal/tools/group_members.go b/internal/tools/group_members.go new file mode 100644 index 00000000..ed4bc2d8 --- /dev/null +++ b/internal/tools/group_members.go @@ -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)) +} diff --git a/internal/tools/policy.go b/internal/tools/policy.go index c664280b..6b276754 100644 --- a/internal/tools/policy.go +++ b/internal/tools/policy.go @@ -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", diff --git a/internal/tools/types.go b/internal/tools/types.go index b291f16e..7c2615d3 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -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{ diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts index 6bba6521..f6d543b9 100644 --- a/ui/web/src/pages/channels/channel-schemas.ts +++ b/ui/web/src/pages/channels/channel-schemas.ts @@ -182,6 +182,7 @@ export const requiredScopes: Partial> = { { 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" },