owpengram-server/internal/rpc/verification_notify.go
Egor Egorov fff8de783a
feat: add NFT usernames and bot verification (#22)
Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review.

The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation.

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
2026-07-28 01:18:00 +08:00

117 lines
4.9 KiB
Go

package rpc
import (
"context"
"fmt"
"telesrv/internal/domain"
)
// verificationUserDirectory is the optional viewer-independent account reader.
// users.Service satisfies it; it is preferred over UsersService.ByID because
// "does this peer exist" must not depend on a viewer projection.
type verificationUserDirectory interface {
AdminUser(ctx context.Context, userID int64) (domain.User, bool, error)
}
// verificationChannelDirectory is the optional non-personalized channel base-row
// reader. channels.Service satisfies it (see the same assertion in communities.go);
// the badge hook has no viewer of its own, so it cannot use GetChannel.
type verificationChannelDirectory interface {
GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error)
}
// NotifyPeerVerified is the protocol-edge hook the official verification service
// invokes after an approve/reject/revoke decision has already committed. It makes
// the new user#b1b8cc83 verified:flags.17 / channel#d49f34c6 verified:flags.7 bit
// observable without waiting for a cache TTL:
//
// - the cached peer projections for the target are dropped, so the next
// users.getFullUser / channels.getFullChannel rebuilds from the committed row;
// - online clients that already know the peer are pushed the ordinary, non-PTS
// refresh update (updateUser / updateChannel) together with the re-projected
// peer object, which is what flips the badge in an official client live.
//
// It deliberately reuses the paths the scam/fake moderation flags already use
// rather than inventing a verification-specific update: the badge is one more
// boolean on the same peer record, and a second mechanism could only drift.
//
// Offline sessions are not pushed to and do not need to be: verified is part of
// the peer's base read model, whose version is bumped by the users/channels
// triggers in 0001_init, so updates.getDifference and any later getFullUser /
// getUsers answer already carries the new flag.
//
// A push failure never invalidates the committed decision, so the caller logs and
// swallows the returned error; this method therefore reports problems instead of
// panicking, and is safe on a nil receiver.
func (r *Router) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
if r == nil {
return nil
}
if peer.ID <= 0 {
return fmt.Errorf("notify peer verified: invalid peer id %d", peer.ID)
}
switch peer.Type {
case domain.PeerTypeUser:
return r.notifyUserVerified(ctx, peer.ID)
case domain.PeerTypeChannel:
return r.notifyChannelVerified(ctx, peer.ID)
default:
return fmt.Errorf("notify peer verified: unsupported peer type %q for peer %d", peer.Type, peer.ID)
}
}
// notifyUserVerified covers ordinary accounts and bots alike: a verified bot is a
// user#b1b8cc83 with verified:flags.17, so it takes the same audience-wide
// updateUser fan-out the moderation flags use (owner plus every online account
// that already sees the peer).
func (r *Router) notifyUserVerified(ctx context.Context, userID int64) error {
// Invalidate first and unconditionally: a decided application whose projection
// still says "not verified" would keep serving the stale badge state even if the
// push below cannot run.
r.invalidateRPCProjectionForUser(userID)
if r.deps.Users == nil {
return nil
}
user, found, err := r.verificationUser(ctx, userID)
if err != nil {
return fmt.Errorf("notify peer verified: load user %d: %w", userID, err)
}
if !found || user.ID == 0 {
return fmt.Errorf("notify peer verified: user %d not found", userID)
}
// NotifyUserModerationFlagsChanged re-projects the peer per recipient, so the
// snapshot handed in only carries identity; the pushed tg.User is always built
// from a fresh read.
return r.NotifyUserModerationFlagsChanged(ctx, user)
}
func (r *Router) verificationUser(ctx context.Context, userID int64) (domain.User, bool, error) {
if directory, ok := r.deps.Users.(verificationUserDirectory); ok {
return directory.AdminUser(ctx, userID)
}
return r.deps.Users.ByID(ctx, userID, userID)
}
// notifyChannelVerified reuses the channel state-mutation path, which invalidates
// the channel projections (plus a linked monoforum's) and pushes updateChannel
// with the refreshed chat object to the channel's members.
func (r *Router) notifyChannelVerified(ctx context.Context, channelID int64) error {
r.invalidateRPCProjectionForChannel(channelID)
if r.deps.Channels == nil {
return nil
}
directory, ok := r.deps.Channels.(verificationChannelDirectory)
if !ok {
return fmt.Errorf("notify peer verified: channel service does not expose GetChannelByID")
}
channel, err := directory.GetChannelByID(ctx, channelID)
if err != nil {
return fmt.Errorf("notify peer verified: load channel %d: %w", channelID, err)
}
if channel.ID == 0 {
return fmt.Errorf("notify peer verified: channel %d not found", channelID)
}
// Same hook the admin panel uses for any other channel base fact.
return r.NotifyChannelChanged(ctx, channel)
}