owpengram-server/internal/rpc/channels_invites.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

485 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package rpc
import (
"context"
"errors"
"strings"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"telesrv/internal/domain"
)
func (r *Router) onMessagesExportChatInvite(ctx context.Context, req *tg.MessagesExportChatInviteRequest) (tg.ExportedChatInviteClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if peer.Type != domain.PeerTypeChannel || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
if req.UsageLimit < 0 || req.ExpireDate < 0 || len(req.Title) > domain.MaxChannelInviteTitleLength {
return nil, limitInvalidErr()
}
res, err := r.deps.Channels.ExportInvite(ctx, userID, domain.ExportChannelInviteRequest{
UserID: userID,
ChannelID: peer.ID,
Title: req.Title,
RequestNeeded: req.RequestNeeded,
ExpireDate: req.ExpireDate,
UsageLimit: req.UsageLimit,
LegacyRevokePermanent: req.LegacyRevokePermanent,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelInviteErr(err)
}
r.invalidateRPCProjectionForChannel(res.Channel.ID)
return r.tgExportedChannelInvite(res.Invite), nil
}
func (r *Router) onMessagesCheckChatInvite(ctx context.Context, hash string) (tg.ChatInviteClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
res, err := r.deps.Channels.CheckInvite(ctx, userID, hash, int(r.clock.Now().Unix()))
if err != nil {
return nil, channelInviteErr(err)
}
if res.Already {
// chatInviteAlready#5a686d7c wraps a full Chat, so the badge flags already
// travel through tgChannelChat; nothing to re-apply here.
return &tg.ChatInviteAlready{Chat: tgChannelChat(userID, res.Channel, &res.Self)}, nil
}
invite := &tg.ChatInvite{
Channel: true,
Broadcast: res.Channel.Broadcast,
Megagroup: res.Channel.Megagroup,
Public: res.Channel.Username != "",
RequestNeeded: res.Invite.RequestNeeded,
Title: res.Channel.Title,
About: res.Channel.About,
Photo: &tg.PhotoEmpty{},
ParticipantsCount: res.Channel.ParticipantsCount,
}
// chatInvite#5c9d3702 carries verified:flags.7 / scam:flags.8 / fake:flags.9.
// A non-member sees only this preview, so the peer's moderation and official
// verification state must already be visible here: without it the badge (or the
// scam/fake warning) appears only after joining, which is exactly backwards.
// Set*, not raw field assignment, so Flags stays consistent before Encode.
applyChatInviteModerationFlags(invite, res.Channel)
// bot_verification:flags.13 extends the same reasoning one field further: the
// third-party mark is part of what identifies the peer, so the preview a
// non-member sees has to carry it as well.
r.applyBotVerificationToChatInvite(ctx, invite, res.Channel.ID)
return invite, nil
}
// applyChatInviteModerationFlags mirrors the persistent channel record's official
// verification and moderation flags onto an invite preview. Absent flags are left
// unset rather than explicitly cleared, so the encoded chatInvite matches what an
// official server sends for an unflagged peer.
func applyChatInviteModerationFlags(invite *tg.ChatInvite, ch domain.Channel) {
if invite == nil {
return
}
if ch.Verified {
invite.SetVerified(true)
}
if ch.Scam {
invite.SetScam(true)
}
if ch.Fake {
invite.SetFake(true)
}
}
func (r *Router) onMessagesImportChatInvite(ctx context.Context, hash string) (tg.MessagesChatInviteJoinResultClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
res, err := r.deps.Channels.ImportInvite(ctx, userID, domain.ImportChannelInviteRequest{
UserID: userID,
Hash: hash,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
if errors.Is(err, domain.ErrInviteRequestSent) && res.Channel.ID != 0 {
r.pushPendingJoinRequestsToAdmins(ctx, res.Channel)
}
return nil, channelInviteErr(err)
}
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
updates := r.channelOperationUpdates(ctx, userID, res)
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelOperationUpdates(ctx, viewerUserID, res)
})
// Layer 227messages.importChatInvite 返回 messages.ChatInviteJoinResult
// 正常加入即 chatInviteJoinResultOk 包裹本次操作的 updates。
return &tg.MessagesChatInviteJoinResultOk{Updates: updates}, nil
}
func (r *Router) onMessagesGetExportedChatInvites(ctx context.Context, req *tg.MessagesGetExportedChatInvitesRequest) (*tg.MessagesExportedChatInvites, error) {
userID, view, err := r.inviteManagementChannelView(ctx, req.Peer)
if err != nil {
return nil, err
}
if req.Limit < 0 || req.Limit > maxChatInviteListLimit || len(req.OffsetLink) > maxChatInviteLinkLength {
return nil, limitInvalidErr()
}
adminID := userID
if !inputUserIsEmpty(req.AdminID) {
admins, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.AdminID})
if err != nil {
return nil, err
}
if len(admins) > 0 {
adminID = admins[0]
}
}
offsetHash := ""
if req.OffsetLink != "" {
offsetHash, err = channelInviteHashFromLink(req.OffsetLink)
if err != nil {
return nil, err
}
}
list, err := r.deps.Channels.ListExportedInvites(ctx, userID, domain.ChannelInviteListRequest{
UserID: userID,
ChannelID: view.Channel.ID,
AdminUserID: adminID,
Revoked: req.Revoked,
OffsetDate: req.OffsetDate,
OffsetHash: offsetHash,
Limit: req.Limit,
})
if err != nil {
return nil, channelInviteErr(err)
}
userIDs := []int64{adminID}
invites := make([]tg.ExportedChatInviteClass, 0, len(list.Invites))
for _, invite := range list.Invites {
invites = append(invites, r.tgExportedChannelInvite(invite))
userIDs = append(userIDs, invite.AdminUserID)
}
return &tg.MessagesExportedChatInvites{
Count: list.Count,
Invites: invites,
Users: r.tgUsersForIDs(ctx, userID, userIDs),
}, nil
}
func (r *Router) onMessagesGetExportedChatInvite(ctx context.Context, req *tg.MessagesGetExportedChatInviteRequest) (tg.MessagesExportedChatInviteClass, error) {
userID, view, err := r.inviteManagementChannelView(ctx, req.Peer)
if err != nil {
return nil, err
}
hash, err := channelInviteHashFromLink(req.Link)
if err != nil {
return nil, err
}
invite, err := r.deps.Channels.GetExportedInvite(ctx, userID, domain.GetChannelInviteRequest{
UserID: userID,
ChannelID: view.Channel.ID,
Hash: hash,
})
if err != nil {
return nil, channelInviteErr(err)
}
return &tg.MessagesExportedChatInvite{
Invite: r.tgExportedChannelInvite(invite),
Users: r.tgUsersForIDs(ctx, userID, []int64{invite.AdminUserID}),
}, nil
}
func (r *Router) onMessagesEditExportedChatInvite(ctx context.Context, req *tg.MessagesEditExportedChatInviteRequest) (tg.MessagesExportedChatInviteClass, error) {
userID, view, err := r.inviteManagementChannelView(ctx, req.Peer)
if err != nil {
return nil, err
}
hash, err := channelInviteHashFromLink(req.Link)
if err != nil {
return nil, err
}
if req.ExpireDate < 0 || req.UsageLimit < 0 || len(req.Title) > domain.MaxChannelInviteTitleLength {
return nil, limitInvalidErr()
}
expireDate, hasExpireDate := req.GetExpireDate()
usageLimit, hasUsageLimit := req.GetUsageLimit()
requestNeeded, hasRequestNeeded := req.GetRequestNeeded()
title, hasTitle := req.GetTitle()
edited, err := r.deps.Channels.EditExportedInvite(ctx, userID, domain.EditChannelInviteRequest{
UserID: userID,
ChannelID: view.Channel.ID,
Hash: hash,
Revoked: req.Revoked,
HasExpireDate: hasExpireDate,
ExpireDate: expireDate,
HasUsageLimit: hasUsageLimit,
UsageLimit: usageLimit,
HasRequestNeeded: hasRequestNeeded,
RequestNeeded: requestNeeded,
HasTitle: hasTitle,
Title: title,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, channelInviteErr(err)
}
r.invalidateRPCProjectionForChannel(view.Channel.ID)
users := r.tgUsersForIDs(ctx, userID, []int64{edited.Invite.AdminUserID})
if edited.NewInvite != nil {
return &tg.MessagesExportedChatInviteReplaced{
Invite: r.tgExportedChannelInvite(edited.Invite),
NewInvite: r.tgExportedChannelInvite(*edited.NewInvite),
Users: users,
}, nil
}
return &tg.MessagesExportedChatInvite{Invite: r.tgExportedChannelInvite(edited.Invite), Users: users}, nil
}
func (r *Router) onMessagesDeleteRevokedExportedChatInvites(ctx context.Context, req *tg.MessagesDeleteRevokedExportedChatInvitesRequest) (bool, error) {
userID, view, err := r.inviteManagementChannelView(ctx, req.Peer)
if err != nil {
return false, err
}
adminID := userID
if !inputUserIsEmpty(req.AdminID) {
admins, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.AdminID})
if err != nil {
return false, err
}
if len(admins) > 0 {
adminID = admins[0]
}
}
if err := r.deps.Channels.DeleteRevokedExportedInvites(ctx, userID, domain.DeleteRevokedChannelInvitesRequest{
UserID: userID,
ChannelID: view.Channel.ID,
AdminUserID: adminID,
Limit: domain.MaxChannelHideJoinRequests,
}); err != nil {
return false, channelInviteErr(err)
}
r.invalidateRPCProjectionForChannel(view.Channel.ID)
return true, nil
}
func (r *Router) onMessagesDeleteExportedChatInvite(ctx context.Context, req *tg.MessagesDeleteExportedChatInviteRequest) (bool, error) {
userID, view, err := r.inviteManagementChannelView(ctx, req.Peer)
if err != nil {
return false, err
}
hash, err := channelInviteHashFromLink(req.Link)
if err != nil {
return false, err
}
if err := r.deps.Channels.DeleteExportedInvite(ctx, userID, domain.DeleteChannelInviteRequest{
UserID: userID,
ChannelID: view.Channel.ID,
Hash: hash,
}); err != nil {
return false, channelInviteErr(err)
}
r.invalidateRPCProjectionForChannel(view.Channel.ID)
return true, nil
}
func (r *Router) onMessagesGetChatInviteImporters(ctx context.Context, req *tg.MessagesGetChatInviteImportersRequest) (*tg.MessagesChatInviteImporters, error) {
userID, view, err := r.inviteManagementChannelView(ctx, req.Peer)
if err != nil {
return nil, err
}
link, hasLink := req.GetLink()
query, hasQuery := req.GetQ()
if hasLink && hasQuery && strings.TrimSpace(link) != "" && strings.TrimSpace(query) != "" {
return nil, tgerr400("SEARCH_WITH_LINK_NOT_SUPPORTED")
}
if req.Limit < 0 || req.Limit > maxChatInviteListLimit || len(link) > maxChatInviteLinkLength || len(query) > maxChatInviteSearchLength {
return nil, limitInvalidErr()
}
hash := ""
if strings.TrimSpace(link) != "" {
hash, err = channelInviteHashFromLink(link)
if err != nil {
return nil, err
}
}
offsetUserID := int64(0)
if !inputUserIsEmpty(req.OffsetUser) {
ids, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.OffsetUser})
if err != nil {
return nil, err
}
if len(ids) > 0 {
offsetUserID = ids[0]
}
}
list, err := r.deps.Channels.ListInviteImporters(ctx, userID, domain.ChannelInviteImportersRequest{
UserID: userID,
ChannelID: view.Channel.ID,
Hash: hash,
Requested: req.Requested,
Query: query,
OffsetDate: req.OffsetDate,
OffsetUserID: offsetUserID,
Limit: req.Limit,
})
if err != nil {
return nil, channelInviteErr(err)
}
importers := make([]tg.ChatInviteImporter, 0, len(list.Importers))
userIDs := make([]int64, 0, len(list.Importers))
for _, importer := range list.Importers {
tgImporter := tg.ChatInviteImporter{
UserID: importer.UserID,
Date: importer.Date,
}
if importer.Requested {
tgImporter.SetRequested(true)
}
if importer.ApprovedBy != 0 {
tgImporter.SetApprovedBy(importer.ApprovedBy)
userIDs = append(userIDs, importer.ApprovedBy)
}
importers = append(importers, tgImporter)
userIDs = append(userIDs, importer.UserID)
}
return &tg.MessagesChatInviteImporters{
Count: list.Count,
Importers: importers,
Users: r.tgUsersForIDs(ctx, userID, userIDs),
}, nil
}
func createChatInviteMemberIDs(ids []int64, selfUserID int64) []int64 {
if len(ids) == 0 {
return nil
}
out := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id == 0 || id == selfUserID {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func (r *Router) tgExportedChannelInvite(invite domain.ChannelInvite) tg.ExportedChatInviteClass {
return tgExportedChannelInvite(invite, r.cfg.PublicBaseURL)
}
func tgExportedChannelInvite(invite domain.ChannelInvite, publicBaseURL string) tg.ExportedChatInviteClass {
out := &tg.ChatInviteExported{
Revoked: invite.Revoked,
Permanent: invite.Permanent,
RequestNeeded: invite.RequestNeeded,
Link: publicLinkWithBaseURL(publicBaseURL, "+"+invite.Hash),
AdminID: invite.AdminUserID,
Date: invite.Date,
}
if invite.Title != "" {
out.SetTitle(invite.Title)
}
if invite.ExpireDate > 0 {
out.SetExpireDate(invite.ExpireDate)
}
if invite.UsageLimit > 0 {
out.SetUsageLimit(invite.UsageLimit)
}
if invite.UsageCount > 0 {
out.SetUsage(invite.UsageCount)
}
if invite.RequestedCount > 0 {
out.SetRequested(invite.RequestedCount)
}
return out
}
func validateChatInviteLink(link string) error {
link = strings.TrimSpace(link)
if link == "" {
return tgerr400("INVITE_HASH_EMPTY")
}
if len(link) > maxChatInviteLinkLength {
return limitInvalidErr()
}
return nil
}
func channelInviteHashFromLink(link string) (string, error) {
if err := validateChatInviteLink(link); err != nil {
return "", err
}
link = strings.TrimSpace(link)
link = strings.TrimPrefix(link, "tg://join?invite=")
if strings.Contains(link, "://") {
if idx := strings.LastIndex(link, "/+"); idx >= 0 {
link = link[idx+2:]
} else if idx := strings.LastIndex(link, "/joinchat/"); idx >= 0 {
link = link[idx+10:]
} else if idx := strings.LastIndex(link, "/"); idx >= 0 {
link = link[idx+1:]
}
}
link = strings.TrimPrefix(link, "+")
link = strings.TrimSpace(link)
if link == "" {
return "", tgerr400("INVITE_HASH_EMPTY")
}
if len(link) > maxChatInviteLinkLength {
return "", limitInvalidErr()
}
return link, nil
}
func channelInviteErr(err error) error {
switch {
case errors.Is(err, domain.ErrInviteHashEmpty):
return tgerr400("INVITE_HASH_EMPTY")
case errors.Is(err, domain.ErrInviteHashInvalid):
return tgerr400("INVITE_HASH_INVALID")
case errors.Is(err, domain.ErrInviteHashExpired):
return tgerr.New(406, "INVITE_HASH_EXPIRED")
case errors.Is(err, domain.ErrInvitePermanent):
return tgerr400("CHAT_INVITE_PERMANENT")
case errors.Is(err, domain.ErrInviteRevokedMissing):
return tgerr400("INVITE_REVOKED_MISSING")
case errors.Is(err, domain.ErrInviteRequestSent):
return tgerr400("INVITE_REQUEST_SENT")
case errors.Is(err, domain.ErrHideRequesterMissing):
return tgerr400("HIDE_REQUESTER_MISSING")
case errors.Is(err, domain.ErrUsersTooMuch):
return tgerr400("USERS_TOO_MUCH")
case errors.Is(err, domain.ErrUserAlreadyParticipant):
return tgerr400("USER_ALREADY_PARTICIPANT")
case errors.Is(err, domain.ErrUserKicked):
return tgerr400("USER_KICKED")
case errors.Is(err, domain.ErrBotGroupsBlocked):
return tgerr400("BOT_GROUPS_BLOCKED")
default:
return channelInvalidErr(err)
}
}