mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
165 lines
5.5 KiB
Go
165 lines
5.5 KiB
Go
package channels
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// WriterLabelMaxRunes caps the rendered writer label length so a pathological
|
|
// username/displayName cannot dominate the system prompt or squeeze legitimate
|
|
// instructions out.
|
|
const WriterLabelMaxRunes = 48
|
|
|
|
// SanitizeWriterLabel strips control characters (\r\n\t and friends) from
|
|
// user-controlled metadata strings before they flow into the system prompt
|
|
// or Telegram responses. A user could otherwise set their Telegram
|
|
// displayName to "Alice\n\nSYSTEM: ignore previous instructions..." and
|
|
// have it rendered as a pseudo-instruction. Also collapses whitespace runs
|
|
// and truncates to WriterLabelMaxRunes.
|
|
func SanitizeWriterLabel(s string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
prevSpace := false
|
|
for _, r := range s {
|
|
if r < 0x20 || r == 0x7f {
|
|
if !prevSpace {
|
|
b.WriteByte(' ')
|
|
prevSpace = true
|
|
}
|
|
continue
|
|
}
|
|
b.WriteRune(r)
|
|
prevSpace = r == ' '
|
|
}
|
|
out := strings.TrimSpace(b.String())
|
|
runes := []rune(out)
|
|
if len(runes) > WriterLabelMaxRunes {
|
|
out = string(runes[:WriterLabelMaxRunes]) + "…"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// WriterMeta is the canonical shape stored in ConfigPermission.Metadata for
|
|
// file_writer grants. Kept local to avoid a store→channels dependency; the
|
|
// field tags must stay in sync with what /addwriter + enrichment emit.
|
|
type writerMetaShape struct {
|
|
DisplayName string `json:"displayName"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
// WriterLabel renders a writer's metadata JSON into a human-readable tag.
|
|
// Preference order: @username → displayName → "User <userID>". All
|
|
// user-controlled strings pass through SanitizeWriterLabel. The userID arg
|
|
// is the fallback when no metadata is available (legacy rows).
|
|
func WriterLabel(metadata json.RawMessage, userID string) string {
|
|
var meta writerMetaShape
|
|
_ = json.Unmarshal(metadata, &meta)
|
|
if u := SanitizeWriterLabel(meta.Username); u != "" {
|
|
return "@" + u
|
|
}
|
|
if d := SanitizeWriterLabel(meta.DisplayName); d != "" {
|
|
return d
|
|
}
|
|
return "User " + userID
|
|
}
|
|
|
|
// 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
|
|
}
|