From cadca23cab849f3deb2865f782abc4b3e3c14dbf Mon Sep 17 00:00:00 2001 From: viettranx Date: Sat, 18 Apr 2026 21:35:44 +0700 Subject: [PATCH] feat(channels): add MemberResolver interface + Telegram getChatMember impl --- .../channels/feishu/commands_writers_test.go | 4 + internal/channels/member_resolver.go | 49 ++++++++ internal/channels/telegram/member_resolver.go | 42 +++++++ internal/channels/writer_metadata.go | 106 ++++++++++++++++++ internal/store/config_permission_store.go | 5 + internal/store/pg/config_permissions.go | 22 ++++ .../store/sqlitestore/config_permissions.go | 22 ++++ 7 files changed, 250 insertions(+) create mode 100644 internal/channels/member_resolver.go create mode 100644 internal/channels/telegram/member_resolver.go create mode 100644 internal/channels/writer_metadata.go diff --git a/internal/channels/feishu/commands_writers_test.go b/internal/channels/feishu/commands_writers_test.go index 58c91c0e..18d27e74 100644 --- a/internal/channels/feishu/commands_writers_test.go +++ b/internal/channels/feishu/commands_writers_test.go @@ -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. diff --git a/internal/channels/member_resolver.go b/internal/channels/member_resolver.go new file mode 100644 index 00000000..8debf336 --- /dev/null +++ b/internal/channels/member_resolver.go @@ -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) +} diff --git a/internal/channels/telegram/member_resolver.go b/internal/channels/telegram/member_resolver.go new file mode 100644 index 00000000..527a5996 --- /dev/null +++ b/internal/channels/telegram/member_resolver.go @@ -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 +} diff --git a/internal/channels/writer_metadata.go b/internal/channels/writer_metadata.go new file mode 100644 index 00000000..0152e706 --- /dev/null +++ b/internal/channels/writer_metadata.go @@ -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::". +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 +} diff --git a/internal/store/config_permission_store.go b/internal/store/config_permission_store.go index 56f5ccbb..c1547e0f 100644 --- a/internal/store/config_permission_store.go +++ b/internal/store/config_permission_store.go @@ -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::". + ListFileWritersMissingMetadata(ctx context.Context, channelName string) ([]ConfigPermission, error) } // CheckFileWriterPermission returns an error if the caller is in a group context diff --git a/internal/store/pg/config_permissions.go b/internal/store/pg/config_permissions.go index 7eb470c5..f18d77d8 100644 --- a/internal/store/pg/config_permissions.go +++ b/internal/store/pg/config_permissions.go @@ -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() { diff --git a/internal/store/sqlitestore/config_permissions.go b/internal/store/sqlitestore/config_permissions.go index 2dfe9b2d..c27ecdaa 100644 --- a/internal/store/sqlitestore/config_permissions.go +++ b/internal/store/sqlitestore/config_permissions.go @@ -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() {