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" },