owpengram-server/internal/rpc/fragment.go
2026-09-01 12:06:31 +03:00

407 lines
14 KiB
Go

package rpc
import (
"context"
"errors"
"strings"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/domain"
)
// Collectible (Fragment-style) usernames on the protocol edge.
//
// This file owns three things:
//
// - fragment.getCollectibleInfo, the purchase-record lookup a client opens from
// the "this username was bought on Fragment" badge;
// - the projection overlay that turns the legacy single-username vector into
// the full username#b4073647 list (editable slot + collectibles);
// - the shared toggle/reorder/deactivate plumbing behind
// account.*, channels.* and bots.* username management.
//
// Every entry point degrades: with Deps.Usernames nil (or on any registry error)
// the wire shape is exactly what it was before collectibles existed.
// registerFragment 注册 fragment.* RPC handler。
func (r *Router) registerFragment(d *tlprofile.Dispatcher) {
registerRPC[*tg.FragmentGetCollectibleInfoRequest](d, tlprofile.SemanticMethodFragmentGetCollectibleInfo, func(ctx context.Context, layerRequest *tg.FragmentGetCollectibleInfoRequest) (any, error) {
return r.onFragmentGetCollectibleInfo(ctx, layerRequest)
})
}
// onFragmentGetCollectibleInfo answers fragment.getCollectibleInfo.
//
// Only inputCollectibleUsername is answerable here: this server has no
// collectible-phone registry. The method exposes only assets visible to the
// caller: their own inactive collectible, a collectible attached to a channel
// they own, or anybody's active collectible.
func (r *Router) onFragmentGetCollectibleInfo(ctx context.Context, req *tg.FragmentGetCollectibleInfoRequest) (*tg.FragmentCollectibleInfo, error) {
if req == nil {
return nil, collectibleInvalidErr()
}
// An authenticated caller is required, matching every other profile lookup.
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
switch collectible := req.Collectible.(type) {
case *tg.InputCollectibleUsername:
if collectible == nil {
return nil, collectibleInvalidErr()
}
return r.collectibleUsernameInfo(ctx, userID, collectible.Username)
case *tg.InputCollectiblePhone:
if collectible == nil || strings.TrimSpace(collectible.Phone) == "" {
return nil, collectibleInvalidErr()
}
return nil, collectibleNotFoundErr()
default:
return nil, collectibleInvalidErr()
}
}
func (r *Router) collectibleUsernameInfo(ctx context.Context, userID int64, username string) (*tg.FragmentCollectibleInfo, error) {
name := domain.NormalizeUsername(username)
// Syntax first: a name that cannot be a collectible is COLLECTIBLE_INVALID, and
// rejecting it here keeps malformed input off the registry.
if !domain.ValidCollectibleUsername(name) {
return nil, collectibleInvalidErr()
}
if r.deps.Usernames == nil {
return nil, collectibleNotFoundErr()
}
asset, err := r.deps.Usernames.Collectible(ctx, name)
if err != nil {
return nil, collectibleInfoErr(err)
}
if !asset.Owned() {
return nil, collectibleNotFoundErr()
}
visible, err := r.collectibleVisibleTo(ctx, userID, asset)
if err != nil {
return nil, internalErr()
}
if !visible {
// Do not reveal that an inactive collectible is associated with another
// peer. The official surface intentionally collapses that state to not found.
return nil, collectibleNotFoundErr()
}
info := asset.Info()
out := &tg.FragmentCollectibleInfo{
PurchaseDate: info.PurchaseDate,
Currency: info.Currency,
Amount: info.Amount,
CryptoCurrency: info.CryptoCurrency,
CryptoAmount: info.CryptoAmount,
URL: info.URL,
}
return out, nil
}
func (r *Router) collectibleVisibleTo(ctx context.Context, userID int64, asset domain.CollectibleUsername) (bool, error) {
if asset.Owner.Type == domain.PeerTypeUser && asset.Owner.ID == userID {
return true, nil
}
list, err := r.deps.Usernames.PeerUsernames(ctx, asset.Owner)
if err != nil {
return false, err
}
for _, item := range list {
if item.CollectibleID == asset.ID && item.Active {
return true, nil
}
}
if asset.Owner.Type != domain.PeerTypeChannel || r.deps.Channels == nil {
return false, nil
}
view, err := r.deps.Channels.ResolveChannel(ctx, userID, asset.Owner.ID)
if err != nil {
// Inaccessible channels are indistinguishable from an inactive asset owned
// by somebody else.
return false, nil
}
return view.Self.Role == domain.ChannelRoleCreator, nil
}
func collectibleInvalidErr() error { return tgerr400("COLLECTIBLE_INVALID") }
func collectibleNotFoundErr() error { return tgerr400("COLLECTIBLE_NOT_FOUND") }
// collectibleInfoErr maps registry lookup failures onto TL.
func collectibleInfoErr(err error) error {
switch {
case errors.Is(err, domain.ErrCollectibleUsernameNotFound),
errors.Is(err, domain.ErrUsernameNotOccupied),
errors.Is(err, domain.ErrCollectibleUsernameBurned),
errors.Is(err, domain.ErrCollectibleUsernameNotOwned):
return collectibleNotFoundErr()
case errors.Is(err, domain.ErrUsernameNotCollectible),
errors.Is(err, domain.ErrUsernameInvalid):
return collectibleInvalidErr()
default:
return internalErr()
}
}
// collectibleUsernameErr maps registry mutation failures onto TL.
//
// domain.ErrUsernameNotCollectible and domain.ErrUsernameOrderInvalid both mean
// "the client asked for something the collectible slots cannot express" -- moving
// or deactivating the editable slot, or an order that is not a permutation of the
// peer's collectibles -- so both are USERNAME_INVALID.
func collectibleUsernameErr(err error) error {
switch {
case errors.Is(err, domain.ErrUsernameNotCollectible),
errors.Is(err, domain.ErrUsernameOrderInvalid),
errors.Is(err, domain.ErrUsernameNotEditable),
errors.Is(err, domain.ErrUsernameInvalid):
return usernameInvalidErr()
case errors.Is(err, domain.ErrUsernameNotOccupied),
errors.Is(err, domain.ErrCollectibleUsernameNotFound),
errors.Is(err, domain.ErrCollectibleUsernameNotOwned),
errors.Is(err, domain.ErrCollectibleUsernameBurned):
return usernameNotOccupiedErr()
case errors.Is(err, domain.ErrCollectibleUsernameLimit):
return limitInvalidErr()
default:
return internalErr()
}
}
// toggleRegistryUsername is the shared body of account/channels/bots
// .toggleUsername. Callers do the permission check first.
func (r *Router) toggleRegistryUsername(ctx context.Context, peer domain.Peer, username string, active bool) error {
name := domain.NormalizeUsername(username)
if !domain.ValidCollectibleUsername(name) {
return usernameInvalidErr()
}
changed, err := r.deps.Usernames.ToggleUsername(ctx, peer, name, active)
if err != nil {
return collectibleUsernameErr(err)
}
if !changed {
return usernameNotModifiedErr()
}
r.invalidateRegistryProjection(peer)
return nil
}
// reorderRegistryUsernames is the shared body of account/channels/bots
// .reorderUsernames.
func (r *Router) reorderRegistryUsernames(ctx context.Context, peer domain.Peer, order []string) error {
normalized := make([]string, 0, len(order))
for _, name := range order {
normalized = append(normalized, domain.NormalizeUsername(name))
}
changed, err := r.deps.Usernames.ReorderUsernames(ctx, peer, normalized)
if err != nil {
if errors.Is(err, domain.ErrUsernameOrderInvalid) {
return tgerr400("ORDER_INVALID")
}
return collectibleUsernameErr(err)
}
if !changed {
return usernameNotModifiedErr()
}
r.invalidateRegistryProjection(peer)
return nil
}
// deactivateAllRegistryUsernames is the shared body of
// channels.deactivateAllUsernames: it hides every collectible username of a peer.
//
// Deactivating an empty set is success, not USERNAME_NOT_MODIFIED: Telegram
// Desktop calls channels.deactivateAllUsernames as a step of its "set the
// username" flow, so a peer that has no collectible usernames yet -- the common
// case for a freshly created channel -- would abort that flow on a 400. The
// no-op stub this replaced also answered true, and clients depend on it.
func (r *Router) deactivateAllRegistryUsernames(ctx context.Context, peer domain.Peer) error {
changed, err := r.deps.Usernames.DeactivateAllUsernames(ctx, peer)
if err != nil {
return collectibleUsernameErr(err)
}
if !changed {
return nil
}
r.invalidateRegistryProjection(peer)
return nil
}
// invalidateRegistryProjection drops the cached user/channel projections that
// embed the username vector, so the next getFullUser / getFullChannel rebuilds it.
func (r *Router) invalidateRegistryProjection(peer domain.Peer) {
r.InvalidatePeerIdentityReadModel(peer)
switch peer.Type {
case domain.PeerTypeUser:
r.invalidateRPCProjectionForUser(peer.ID)
case domain.PeerTypeChannel:
r.invalidateRPCProjectionForChannel(peer.ID)
}
}
// applyUsernamesToPeerObjects overlays the registry onto already-projected user
// and channel objects. Per the Fragment contract, the scalar username is cleared
// and the vector is set only when a collectible is associated with the peer.
//
// It mirrors applyStoryMaxIDsToPeerObjects: one batched read-model call per
// response instead of a per-peer query, and a silent no-op whenever the read
// model is unavailable. Overlaying after projection is what keeps the ~90
// pure tgUser/tgChannel call sites untouched -- they keep emitting the legacy
// scalar, and this pass upgrades it wherever a Router-level entry point runs.
func (r *Router) applyUsernamesToPeerObjects(ctx context.Context, users []tg.UserClass, chats []tg.ChatClass) {
if r.deps.Usernames == nil || len(users)+len(chats) == 0 {
return
}
peers := make([]domain.Peer, 0, len(users)+len(chats))
seen := make(map[domain.Peer]struct{}, len(users)+len(chats))
peers = appendUsernameProjectionPeers(peers, seen, users, chats)
if len(peers) == 0 {
return
}
byPeer := r.usernameRegistryMap(ctx, peers)
if len(byPeer) == 0 {
return
}
applyUsernamesFromRegistry(users, chats, byPeer)
}
// applyUsernamesToUpdatesBatch projects one username-registry snapshot over a
// whole outbox claim. A claim may contain repeated peer objects for several
// events and viewers; collecting the peer union first keeps the hot path at one
// registry round trip rather than one read per event or online session.
func (r *Router) applyUsernamesToUpdatesBatch(ctx context.Context, updates []*tg.Updates) {
if r.deps.Usernames == nil || len(updates) == 0 {
return
}
peerCapacity := 0
for _, update := range updates {
if update != nil {
peerCapacity += len(update.Users) + len(update.Chats)
}
}
if peerCapacity == 0 {
return
}
peers := make([]domain.Peer, 0, peerCapacity)
seen := make(map[domain.Peer]struct{}, peerCapacity)
for _, update := range updates {
if update == nil {
continue
}
peers = appendUsernameProjectionPeers(peers, seen, update.Users, update.Chats)
}
if len(peers) == 0 {
return
}
byPeer := r.usernameRegistryMap(ctx, peers)
if len(byPeer) == 0 {
return
}
for _, update := range updates {
if update != nil {
applyUsernamesFromRegistry(update.Users, update.Chats, byPeer)
}
}
}
func appendUsernameProjectionPeers(peers []domain.Peer, seen map[domain.Peer]struct{}, users []tg.UserClass, chats []tg.ChatClass) []domain.Peer {
addPeer := func(peer domain.Peer) {
if peer.ID == 0 {
return
}
if _, ok := seen[peer]; ok {
return
}
seen[peer] = struct{}{}
peers = append(peers, peer)
}
for _, item := range users {
if u, ok := item.(*tg.User); ok && u != nil && !u.Deleted {
addPeer(domain.Peer{Type: domain.PeerTypeUser, ID: u.ID})
}
}
for _, item := range chats {
if ch, ok := item.(*tg.Channel); ok && ch != nil {
addPeer(domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID})
}
}
return peers
}
// applyUsernamesFromRegistry applies a previously loaded registry snapshot.
// Notification fan-out uses this form so one peer-wide read does not become one
// database query per online viewer.
func applyUsernamesFromRegistry(users []tg.UserClass, chats []tg.ChatClass, byPeer map[domain.Peer][]domain.Username) {
if len(byPeer) == 0 {
return
}
for _, item := range users {
u, ok := item.(*tg.User)
if !ok || u == nil || u.Deleted {
continue
}
list, ok := byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}]
if !ok || !hasCollectibleUsername(list) {
continue
}
if vector := tgUsernamesFromRegistry(list, u.Username); len(vector) > 0 {
// Official clients treat the legacy scalar and the complete vector as
// alternative representations. TDLib rejects a User carrying both and
// discards the complete username set, while TDesktop and DrKLO derive
// the primary username from the first active vector entry.
u.Flags.Unset(3)
u.Username = ""
u.SetUsernames(vector)
}
}
for _, item := range chats {
ch, ok := item.(*tg.Channel)
if !ok || ch == nil {
continue
}
list, ok := byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}]
if !ok || !hasCollectibleUsername(list) {
continue
}
// ch.Username is the flagged scalar; GetUsername reports the empty string
// when unset, which is exactly the fallback tgUsernamesFromRegistry wants.
scalar, _ := ch.GetUsername()
if vector := tgUsernamesFromRegistry(list, scalar); len(vector) > 0 {
ch.Flags.Unset(6)
ch.Username = ""
ch.SetUsernames(vector)
}
}
}
// usernameRegistryMap loads the registry for the given peers. A single peer goes
// through PeerUsernames so a one-object projection does not pay for a batch
// round trip; anything larger goes through UsernamesBatch (no N+1). Any error
// yields an empty map, which the caller treats as "keep the legacy scalar".
func (r *Router) usernameRegistryMap(ctx context.Context, peers []domain.Peer) map[domain.Peer][]domain.Username {
if r.deps.Usernames == nil || len(peers) == 0 {
return nil
}
usernames, _ := r.peerIdentityMaps(ctx, peers, true, false)
return usernames
}
func (r *Router) loadUsernameRegistryMap(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
if len(peers) == 1 {
list, err := r.deps.Usernames.PeerUsernames(ctx, peers[0])
if err != nil {
return nil, err
}
if len(list) == 0 {
return map[domain.Peer][]domain.Username{}, nil
}
return map[domain.Peer][]domain.Username{peers[0]: list}, nil
}
byPeer, err := r.deps.Usernames.UsernamesBatch(ctx, peers)
if err != nil {
return nil, err
}
return byPeer, nil
}