mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 16:17:25 +00:00
feat(channels): add MemberResolver interface + Telegram getChatMember impl
This commit is contained in:
1 parent
9f68f3ee0d
commit
cadca23cab
7 files changed
+250
No files matched your search
@@ -73,6 +73,10 @@ func (f *fakeConfigPermStore) ListFileWriters(ctx context.Context, agentID uuid.
|
||||
return f.List(ctx, agentID, store.ConfigTypeFileWriter, scope)
|
||||
}
|
||||
|
||||
func (f *fakeConfigPermStore) ListFileWritersMissingMetadata(_ context.Context, _ string) ([]store.ConfigPermission, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// newTestChannel builds a minimal Feishu Channel suitable for writer-command
|
||||
// unit tests: BaseChannel with a UUID agent key, stub lark client pointing
|
||||
// at the provided httptest server, and the supplied permission store.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// MemberInfo carries the minimal chat-member profile used by the gateway
|
||||
// to enrich file_writer permission metadata on grant.
|
||||
type MemberInfo struct {
|
||||
Username string
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// ErrMemberResolveNotSupported indicates the channel does not implement
|
||||
// ChannelMemberResolver (e.g., Discord/Feishu in Phase 01). Callers should
|
||||
// treat this as a soft skip — proceed without enriched metadata.
|
||||
var ErrMemberResolveNotSupported = errors.New("member resolve not supported by this channel")
|
||||
|
||||
// MemberResolver is the facade consumed by gateway/HTTP handlers. Manager
|
||||
// implements it; handlers depend only on this interface (no channel-specific
|
||||
// imports) to avoid cycles.
|
||||
type MemberResolver interface {
|
||||
ResolveMember(ctx context.Context, channelName, chatID, userID string) (MemberInfo, error)
|
||||
}
|
||||
|
||||
// ChannelMemberResolver is the optional per-channel contract. Channels that
|
||||
// can look up a member by chat+user ID implement this; Manager dispatches
|
||||
// through it.
|
||||
type ChannelMemberResolver interface {
|
||||
ResolveMember(ctx context.Context, chatID, userID string) (MemberInfo, error)
|
||||
}
|
||||
|
||||
// ResolveMember looks up a channel by name and delegates to its
|
||||
// ChannelMemberResolver. Returns ErrMemberResolveNotSupported when the
|
||||
// channel is missing or does not implement the optional interface.
|
||||
func (m *Manager) ResolveMember(ctx context.Context, channelName, chatID, userID string) (MemberInfo, error) {
|
||||
m.mu.RLock()
|
||||
ch, ok := m.channels[channelName]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return MemberInfo{}, ErrMemberResolveNotSupported
|
||||
}
|
||||
resolver, ok := ch.(ChannelMemberResolver)
|
||||
if !ok {
|
||||
return MemberInfo{}, ErrMemberResolveNotSupported
|
||||
}
|
||||
return resolver.ResolveMember(ctx, chatID, userID)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
)
|
||||
|
||||
// ResolveMember implements channels.ChannelMemberResolver — fetches a
|
||||
// group member profile via Bot API `getChatMember`. Used by the gateway
|
||||
// to auto-enrich file_writer permission metadata when a caller grants
|
||||
// access without providing displayName/username (e.g. Web UI path).
|
||||
func (c *Channel) ResolveMember(ctx context.Context, chatID, userID string) (channels.MemberInfo, error) {
|
||||
if c.bot == nil {
|
||||
return channels.MemberInfo{}, fmt.Errorf("telegram bot not initialized")
|
||||
}
|
||||
chatIDInt, err := strconv.ParseInt(chatID, 10, 64)
|
||||
if err != nil {
|
||||
return channels.MemberInfo{}, fmt.Errorf("invalid chatID %q: %w", chatID, err)
|
||||
}
|
||||
userIDInt, err := strconv.ParseInt(userID, 10, 64)
|
||||
if err != nil {
|
||||
return channels.MemberInfo{}, fmt.Errorf("invalid userID %q: %w", userID, err)
|
||||
}
|
||||
member, err := c.bot.GetChatMember(ctx, &telego.GetChatMemberParams{
|
||||
ChatID: tu.ID(chatIDInt),
|
||||
UserID: userIDInt,
|
||||
})
|
||||
if err != nil {
|
||||
return channels.MemberInfo{}, err
|
||||
}
|
||||
user := member.MemberUser()
|
||||
return channels.MemberInfo{
|
||||
Username: user.Username,
|
||||
DisplayName: user.FirstName,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writerEnrichTimeout bounds each ResolveMember call so a slow channel
|
||||
// (network stall, Telegram API hang) never blocks the Grant path beyond
|
||||
// this budget.
|
||||
const writerEnrichTimeout = 3 * time.Second
|
||||
|
||||
// IsEmptyWriterMetadata reports whether the caller-supplied metadata is
|
||||
// effectively empty and therefore eligible for enrichment. Accepts
|
||||
// zero-length, "{}", "null", whitespace-only payloads, and the common
|
||||
// "both fields present but blank" shape `{"displayName":"","username":""}`
|
||||
// emitted by `/addwriter` when Telegram returns a user without Username
|
||||
// or FirstName.
|
||||
func IsEmptyWriterMetadata(m json.RawMessage) bool {
|
||||
if len(m) == 0 {
|
||||
return true
|
||||
}
|
||||
trimmed := bytes.TrimSpace(m)
|
||||
if len(trimmed) == 0 ||
|
||||
bytes.Equal(trimmed, []byte("{}")) ||
|
||||
bytes.Equal(trimmed, []byte("null")) {
|
||||
return true
|
||||
}
|
||||
// Structural check for `{"displayName":"","username":""}` regardless of
|
||||
// key order or whitespace. Callers that emit this shape still consider
|
||||
// the row unenriched.
|
||||
var parsed struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := json.Unmarshal(trimmed, &parsed); err == nil {
|
||||
return parsed.DisplayName == "" && parsed.Username == ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseGroupScope splits a file_writer scope into (channelName, chatID).
|
||||
// Returns ok=false for any shape other than "group:<name>:<chatID>".
|
||||
func ParseGroupScope(scope string) (channelName, chatID string, ok bool) {
|
||||
if !strings.HasPrefix(scope, "group:") {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.SplitN(scope, ":", 3)
|
||||
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[1], parts[2], true
|
||||
}
|
||||
|
||||
// BuildWriterMetadata returns canonical JSON `{"username":"...","displayName":"..."}`.
|
||||
func BuildWriterMetadata(info MemberInfo) (json.RawMessage, error) {
|
||||
return json.Marshal(map[string]string{
|
||||
"username": info.Username,
|
||||
"displayName": info.DisplayName,
|
||||
})
|
||||
}
|
||||
|
||||
// EnrichFileWriterMetadata resolves (scope,userID) to a writer-metadata JSON
|
||||
// payload. Returns (nil,false) when the resolver is missing, scope malformed,
|
||||
// channel lacks resolver support, or lookup fails — callers should proceed
|
||||
// with whatever metadata they already had. Enrichment is strictly best-effort
|
||||
// and never returns an error.
|
||||
func EnrichFileWriterMetadata(ctx context.Context, resolver MemberResolver, scope, userID string) (json.RawMessage, bool) {
|
||||
if resolver == nil {
|
||||
return nil, false
|
||||
}
|
||||
channelName, chatID, ok := ParseGroupScope(scope)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, writerEnrichTimeout)
|
||||
defer cancel()
|
||||
info, err := resolver.ResolveMember(ctx, channelName, chatID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrMemberResolveNotSupported) {
|
||||
slog.Debug("writer_metadata.enrich.skip", "channel", channelName, "reason", "not_supported")
|
||||
return nil, false
|
||||
}
|
||||
slog.Warn("writer_metadata.enrich_failed",
|
||||
"channel", channelName, "chat_id", chatID, "user_id", userID, "error", err)
|
||||
return nil, false
|
||||
}
|
||||
// Telegram can return a member whose Username and FirstName are both
|
||||
// empty (deleted accounts, banned members, legacy bots). Reporting
|
||||
// success here would poison the writerHealLastTry cache with a row
|
||||
// that still renders as a bare ID — treat it as a soft miss so the
|
||||
// next /writers refresh (after TTL) retries.
|
||||
if info.Username == "" && info.DisplayName == "" {
|
||||
return nil, false
|
||||
}
|
||||
meta, err := BuildWriterMetadata(info)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return meta, true
|
||||
}
|
||||
@@ -43,6 +43,11 @@ type ConfigPermissionStore interface {
|
||||
List(ctx context.Context, agentID uuid.UUID, configType, scope string) ([]ConfigPermission, error)
|
||||
// ListFileWriters returns cached file_writer allow permissions for a given agentID+scope (hot-path).
|
||||
ListFileWriters(ctx context.Context, agentID uuid.UUID, scope string) ([]ConfigPermission, error)
|
||||
// ListFileWritersMissingMetadata returns file_writer rows whose metadata is
|
||||
// empty (NULL, '', '{}', or 'null'). Used by the backfill subcommand to
|
||||
// re-enrich legacy rows. channelName="" matches all; otherwise filters on
|
||||
// scope prefix "group:<channelName>:".
|
||||
ListFileWritersMissingMetadata(ctx context.Context, channelName string) ([]ConfigPermission, error)
|
||||
}
|
||||
|
||||
// CheckFileWriterPermission returns an error if the caller is in a group context
|
||||
|
||||
@@ -276,6 +276,28 @@ func (s *PGConfigPermissionStore) ListFileWriters(ctx context.Context, agentID u
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
// ListFileWritersMissingMetadata returns file_writer rows with empty metadata
|
||||
// (NULL, '', '{}', or 'null'). Used by the backfill subcommand.
|
||||
func (s *PGConfigPermissionStore) ListFileWritersMissingMetadata(ctx context.Context, channelName string) ([]store.ConfigPermission, error) {
|
||||
query := `SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at
|
||||
FROM agent_config_permissions
|
||||
WHERE config_type = 'file_writer'
|
||||
AND (metadata IS NULL OR metadata::text IN ('{}', '', 'null'))`
|
||||
args := []any{}
|
||||
if channelName != "" {
|
||||
args = append(args, "group:"+channelName+":%")
|
||||
query += ` AND scope LIKE $1`
|
||||
}
|
||||
query += ` ORDER BY created_at`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanConfigPermissions(rows)
|
||||
}
|
||||
|
||||
func scanConfigPermissions(rows *sql.Rows) ([]store.ConfigPermission, error) {
|
||||
var perms []store.ConfigPermission
|
||||
for rows.Next() {
|
||||
|
||||
@@ -222,6 +222,28 @@ func (s *SQLiteConfigPermissionStore) ListFileWriters(ctx context.Context, agent
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
// ListFileWritersMissingMetadata returns file_writer rows with empty metadata
|
||||
// (NULL, '', '{}', or 'null'). Used by the backfill subcommand.
|
||||
func (s *SQLiteConfigPermissionStore) ListFileWritersMissingMetadata(ctx context.Context, channelName string) ([]store.ConfigPermission, error) {
|
||||
query := `SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at
|
||||
FROM agent_config_permissions
|
||||
WHERE config_type = 'file_writer'
|
||||
AND (metadata IS NULL OR metadata = '' OR metadata = '{}' OR metadata = 'null')`
|
||||
args := []any{}
|
||||
if channelName != "" {
|
||||
query += " AND scope LIKE ?"
|
||||
args = append(args, "group:"+channelName+":%")
|
||||
}
|
||||
query += " ORDER BY created_at"
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanConfigPermissions(rows)
|
||||
}
|
||||
|
||||
func scanConfigPermissions(rows *sql.Rows) ([]store.ConfigPermission, error) {
|
||||
var perms []store.ConfigPermission
|
||||
for rows.Next() {
|
||||
|
||||
Reference in new issue
Block a user