feat(channels): add MemberResolver interface + Telegram getChatMember impl

This commit is contained in:
viettranx committed 2026-04-18 21:35:44 +07:00
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.
+49
View File
@@ -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
}
+106
View File
@@ -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
+22
View File
@@ -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() {