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

679 lines
22 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"
"time"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/domain"
)
// registerPhotos 注册 photos.* RPC handler头像上传 / 切换 / 查询 / 删除)。
func (r *Router) registerPhotos(d *tlprofile.Dispatcher) {
registerRPC[*tg.PhotosUploadProfilePhotoRequest](d, tlprofile.SemanticMethodPhotosUploadProfilePhoto, func(ctx context.Context, layerRequest *tg.PhotosUploadProfilePhotoRequest) (any, error) {
return r.onPhotosUploadProfilePhoto(ctx, layerRequest)
})
registerRPC[*tg.PhotosUpdateProfilePhotoRequest](d, tlprofile.SemanticMethodPhotosUpdateProfilePhoto, func(ctx context.Context, layerRequest *tg.PhotosUpdateProfilePhotoRequest) (any, error) {
return r.onPhotosUpdateProfilePhoto(ctx, layerRequest)
})
registerRPC[*tg.PhotosUploadContactProfilePhotoRequest](d, tlprofile.SemanticMethodPhotosUploadContactProfilePhoto, func(ctx context.Context, layerRequest *tg.PhotosUploadContactProfilePhotoRequest) (any, error) {
return r.onPhotosUploadContactProfilePhoto(ctx, layerRequest)
})
registerRPC[*tg.PhotosGetUserPhotosRequest](d, tlprofile.SemanticMethodPhotosGetUserPhotos, func(ctx context.Context, layerRequest *tg.PhotosGetUserPhotosRequest) (any, error) {
return r.onPhotosGetUserPhotos(ctx, layerRequest)
})
registerRPC[*tg.PhotosDeletePhotosRequest](d, tlprofile.SemanticMethodPhotosDeletePhotos, func(ctx context.Context, layerRequest *tg.PhotosDeletePhotosRequest) (any, error) {
return r.onPhotosDeletePhotos(ctx, layerRequest.
ID)
})
}
func (r *Router) onPhotosUploadProfilePhoto(ctx context.Context, req *tg.PhotosUploadProfilePhotoRequest) (*tg.PhotosPhoto, error) {
if r.deps.Files == nil {
return nil, notImplementedErr()
}
userID, ok, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !ok || userID == 0 {
return nil, photoInvalidErr()
}
if bot, hasBot := req.GetBot(); hasBot && bot != nil {
return nil, inputConstructorInvalidErr()
}
upload, mediaFlags, err := parseProfilePhotoUpload(req)
if err != nil {
return nil, err
}
if mediaFlags == 0 {
return nil, photoInvalidErr()
}
kind := domain.ProfilePhotoKindProfile
if req.GetFallback() {
kind = domain.ProfilePhotoKindFallback
}
photo, err := r.createProfilePhoto(ctx, userID, upload)
if err != nil {
return nil, err
}
photo, found, err := r.deps.Files.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, kind, photo.ID, int(r.clock.Now().Unix()))
if err != nil {
return nil, internalErr()
}
if !found {
return nil, photoInvalidErr()
}
return r.photosPhotoForSelf(ctx, userID, photo, kind), nil
}
func (r *Router) onPhotosUpdateProfilePhoto(ctx context.Context, req *tg.PhotosUpdateProfilePhotoRequest) (*tg.PhotosPhoto, error) {
if r.deps.Files == nil {
return nil, notImplementedErr()
}
userID, ok, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !ok || userID == 0 {
return nil, photoInvalidErr()
}
if bot, hasBot := req.GetBot(); hasBot && bot != nil {
return nil, inputConstructorInvalidErr()
}
kind := domain.ProfilePhotoKindProfile
if req.GetFallback() {
kind = domain.ProfilePhotoKindFallback
}
switch in := req.ID.(type) {
case *tg.InputPhoto:
photo, found, err := r.deps.Files.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, kind, in.ID, int(r.clock.Now().Unix()))
if err != nil {
return nil, internalErr()
}
if !found {
return nil, photoInvalidErr()
}
return r.photosPhotoForSelf(ctx, userID, photo, kind), nil
default:
// InputPhotoEmpty移除当前头像停用现有当前照片
if cur, found, err := r.deps.Files.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, kind); err == nil && found {
_, _ = r.deps.Files.DeleteProfilePhotosKind(ctx, domain.PeerTypeUser, userID, kind, []int64{cur.ID})
}
return r.photosPhotoForSelf(ctx, userID, domain.Photo{}, kind), nil
}
}
func (r *Router) onPhotosUploadContactProfilePhoto(ctx context.Context, req *tg.PhotosUploadContactProfilePhotoRequest) (*tg.PhotosPhoto, error) {
if r.deps.Files == nil || r.deps.Users == nil {
return nil, notImplementedErr()
}
userID, ok, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !ok || userID == 0 {
return nil, photoInvalidErr()
}
target, found, err := r.userFromInput(ctx, userID, req.UserID)
if err != nil {
return nil, internalErr()
}
if !found || target.ID == 0 || target.ID == userID {
return nil, userIDInvalidErr()
}
upload, mediaFlags, err := parseProfilePhotoUpload(req)
if err != nil {
return nil, err
}
if mediaFlags == 0 && req.GetSave() {
if r.deps.Contacts == nil {
return nil, notImplementedErr()
}
if _, err := r.deps.Contacts.ClearPersonalPhoto(ctx, userID, target.ID, int(r.clock.Now().Unix())); err != nil {
return nil, contactErr(err)
}
r.invalidateRPCProjectionForPeer(userID, domain.Peer{Type: domain.PeerTypeUser, ID: target.ID})
return r.photosPhotoForUser(ctx, userID, target.ID, domain.Photo{}), nil
}
if mediaFlags == 0 {
return nil, photoInvalidErr()
}
photo, err := r.createProfilePhoto(ctx, userID, upload)
if err != nil {
return nil, err
}
if req.GetSuggest() {
if r.deps.Messages == nil {
return nil, notImplementedErr()
}
res, err := r.sendSuggestedProfilePhotoMessage(ctx, userID, target.ID, photo)
if err != nil {
return nil, err
}
r.pushSuggestedProfilePhotoCurrentSession(ctx, userID, res)
return r.photosPhotoForUser(ctx, userID, target.ID, photo), nil
}
if r.deps.Contacts == nil {
return nil, notImplementedErr()
}
if _, err := r.deps.Contacts.SetPersonalPhoto(ctx, userID, target.ID, photo, int(r.clock.Now().Unix())); err != nil {
return nil, contactErr(err)
}
r.invalidateRPCProjectionForPeer(userID, domain.Peer{Type: domain.PeerTypeUser, ID: target.ID})
return r.photosPhotoForUserWithPersonalPhoto(ctx, userID, target.ID, photo), nil
}
type profilePhotoUploadRequest interface {
GetFile() (tg.InputFileClass, bool)
GetVideo() (tg.InputFileClass, bool)
GetVideoStartTs() (float64, bool)
GetVideoEmojiMarkup() (tg.VideoSizeClass, bool)
}
type profilePhotoUpload struct {
file tg.InputFileClass
hasFile bool
video tg.InputFileClass
hasVideo bool
videoStartTs float64
markup tg.VideoSizeClass
hasMarkup bool
}
func parseProfilePhotoUpload(req profilePhotoUploadRequest) (profilePhotoUpload, int, error) {
file, hasFile := req.GetFile()
video, hasVideo := req.GetVideo()
videoStartTs, hasVideoStartTs := req.GetVideoStartTs()
markup, hasMarkup := req.GetVideoEmojiMarkup()
if hasFile && file == nil {
return profilePhotoUpload{}, 0, photoInvalidErr()
}
if hasVideo && video == nil {
return profilePhotoUpload{}, 0, photoInvalidErr()
}
if hasMarkup && markup == nil {
return profilePhotoUpload{}, 0, photoInvalidErr()
}
if hasFile && (hasVideo || hasMarkup) {
return profilePhotoUpload{}, 0, photoInvalidErr()
}
if hasVideoStartTs && !hasVideo {
return profilePhotoUpload{}, 0, photoInvalidErr()
}
mediaFlags := 0
if hasFile || hasVideo || hasMarkup {
mediaFlags = 1
}
return profilePhotoUpload{
file: file,
hasFile: hasFile,
video: video,
hasVideo: hasVideo,
videoStartTs: videoStartTs,
markup: markup,
hasMarkup: hasMarkup,
}, mediaFlags, nil
}
func (r *Router) createProfilePhoto(ctx context.Context, userID int64, upload profilePhotoUpload) (domain.Photo, error) {
switch {
case upload.hasFile:
ref, ok := uploadedFileRef(userID, upload.file)
if !ok {
return domain.Photo{}, fileReferenceInvalidErr()
}
photo, err := r.deps.Files.CreateAvatarFromUpload(ctx, ref)
if err != nil {
return domain.Photo{}, photoUploadErr(err)
}
return photo, nil
case upload.hasVideo && upload.hasMarkup:
ref, ok := uploadedFileRef(userID, upload.video)
if !ok {
return domain.Photo{}, fileReferenceInvalidErr()
}
size, ok := domainPhotoVideoMarkup(upload.markup)
if !ok {
return domain.Photo{}, photoInvalidErr()
}
photo, err := r.deps.Files.CreateAvatarVideoMarkupFromUpload(ctx, ref, upload.videoStartTs, size)
if err != nil {
return domain.Photo{}, photoUploadErr(err)
}
return photo, nil
case upload.hasVideo:
ref, ok := uploadedFileRef(userID, upload.video)
if !ok {
return domain.Photo{}, fileReferenceInvalidErr()
}
photo, err := r.deps.Files.CreateAvatarVideoFromUpload(ctx, ref, upload.videoStartTs)
if err != nil {
return domain.Photo{}, photoUploadErr(err)
}
return photo, nil
case upload.hasMarkup:
size, ok := domainPhotoVideoMarkup(upload.markup)
if !ok {
return domain.Photo{}, photoInvalidErr()
}
photo, err := r.deps.Files.CreateAvatarMarkup(ctx, size)
if err != nil {
return domain.Photo{}, photoUploadErr(err)
}
return photo, nil
default:
return domain.Photo{}, photoInvalidErr()
}
}
func domainPhotoVideoMarkup(markup tg.VideoSizeClass) (domain.PhotoSize, bool) {
switch v := markup.(type) {
case *tg.VideoSizeEmojiMarkup:
if v.EmojiID == 0 || len(v.BackgroundColors) == 0 {
return domain.PhotoSize{}, false
}
return domain.PhotoSize{
Kind: domain.PhotoSizeKindVideoEmojiMarkup,
EmojiID: v.EmojiID,
BackgroundColors: append([]int(nil), v.BackgroundColors...),
}, true
case *tg.VideoSizeStickerMarkup:
if v.StickerID == 0 || len(v.BackgroundColors) == 0 {
return domain.PhotoSize{}, false
}
size := domain.PhotoSize{
Kind: domain.PhotoSizeKindVideoStickerMarkup,
StickerID: v.StickerID,
BackgroundColors: append([]int(nil), v.BackgroundColors...),
}
setRef, ok := stickerSetRefFromInput(v.Stickerset)
if !ok {
return domain.PhotoSize{}, false
}
switch setRef.Kind {
case domain.StickerSetRefByID:
if setRef.ID == 0 {
return domain.PhotoSize{}, false
}
size.StickerSetID = setRef.ID
size.StickerSetAccessHash = setRef.AccessHash
case domain.StickerSetRefByShortName:
if setRef.ShortName == "" {
return domain.PhotoSize{}, false
}
size.StickerSetShortName = setRef.ShortName
case domain.StickerSetRefBySystem:
if setRef.SystemKey == "" {
return domain.PhotoSize{}, false
}
size.StickerSetSystemKey = setRef.SystemKey
default:
return domain.PhotoSize{}, false
}
return size, true
default:
return domain.PhotoSize{}, false
}
}
func (r *Router) sendSuggestedProfilePhotoMessage(ctx context.Context, userID, targetUserID int64, photo domain.Photo) (domain.SendPrivateTextResult, error) {
if photo.ID == 0 {
return domain.SendPrivateTextResult{}, photoInvalidErr()
}
recipientBlocked, err := r.peerBlocksUser(ctx, userID, targetUserID)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
sessionID, _ := SessionIDFrom(ctx)
photoCopy := photo
res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
SenderUserID: userID,
RecipientUserID: targetUserID,
RandomID: contactProfilePhotoSuggestRandomID(userID, targetUserID, photo.ID, r.clock.Now().UnixNano()),
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionSuggestProfilePhoto,
Photo: &photoCopy,
},
},
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
})
if err != nil {
return domain.SendPrivateTextResult{}, messageSendErr(err)
}
return res, nil
}
func contactProfilePhotoSuggestRandomID(userID, targetUserID, photoID, nowNano int64) int64 {
id := nowNano ^ (userID << 17) ^ (targetUserID << 7) ^ photoID
if id == 0 {
return photoID
}
return id
}
func (r *Router) pushSuggestedProfilePhotoCurrentSession(ctx context.Context, userID int64, res domain.SendPrivateTextResult) {
if res.SenderMessage.ID == 0 || res.SenderEvent.Pts == 0 {
return
}
updates := tgPrivateMessageUpdates(
res.SenderEvent,
res.SenderMessage,
0,
false,
r.usersForMessageUpdate(ctx, userID, res.SenderMessage),
r.chatsForMessageUpdate(ctx, userID, res.SenderMessage),
)
r.pushCurrentSessionMessage(ctx, "push suggested profile photo service message", updates)
}
func (r *Router) onPhotosGetUserPhotos(ctx context.Context, req *tg.PhotosGetUserPhotosRequest) (tg.PhotosPhotosClass, error) {
if r.deps.Files == nil {
return &tg.PhotosPhotos{}, nil
}
currentUserID, ok, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !ok || currentUserID == 0 {
return nil, userIDInvalidErr()
}
target, found, err := r.userFromInput(ctx, currentUserID, req.UserID)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, userIDInvalidErr()
}
offset := req.Offset
if offset < -1 {
offset = -1
}
if offset < 0 && req.MaxID <= 0 {
offset = 0
}
limit := req.Limit
if limit <= 0 || limit > 100 {
limit = 100
}
photos, total, err := r.deps.Files.GetProfilePhotos(ctx, domain.PeerTypeUser, target.ID, offset, limit, req.MaxID)
if err != nil {
return nil, internalErr()
}
tgPhotos := make([]tg.PhotoClass, 0, len(photos))
for _, p := range photos {
tgPhotos = append(tgPhotos, tgPhoto(p))
}
// 打开自己的头像相册inputUserSelf 或显式自己 ID时 target 即 viewer须带 self
// 标志,否则 self=false 的自己 user 会污染 DrKLO 账号缓存Saved Messages 变身)。
selfUser := r.tgUser(target)
if target.ID == currentUserID {
selfUser = r.tgSelfUser(target)
}
users := []tg.UserClass{selfUser}
r.applyPeerReadModels(ctx, currentUserID, users, nil)
countOffset := offset
if countOffset < 0 {
countOffset = 0
}
if total > len(photos)+countOffset {
return &tg.PhotosPhotosSlice{Count: total, Photos: tgPhotos, Users: users}, nil
}
return &tg.PhotosPhotos{Photos: tgPhotos, Users: users}, nil
}
func (r *Router) onPhotosDeletePhotos(ctx context.Context, id []tg.InputPhotoClass) ([]int64, error) {
if r.deps.Files == nil {
return []int64{}, nil
}
userID, ok, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !ok || userID == 0 {
return nil, photoInvalidErr()
}
ids := make([]int64, 0, len(id))
for _, in := range id {
if photo, isPhoto := in.(*tg.InputPhoto); isPhoto && photo.ID != 0 {
ids = append(ids, photo.ID)
}
}
if len(ids) == 0 {
return []int64{}, nil
}
deleted, err := r.deps.Files.DeleteProfilePhotos(ctx, domain.PeerTypeUser, userID, ids)
if err != nil {
return nil, internalErr()
}
r.invalidateRPCProjectionForUser(userID)
// 删除可能撤掉当前头像(回落到下一张或无头像):与 uploadProfilePhoto 同一纪律,
// 向全部在线 session含当前推 updateUser + fresh self对齐参考实现 deletePhotos 的
// SyncPushUpdates否则其它设备与当前设备DrKLO 本地删除逻辑同样不重建 has_video
// 头像停留在旧状态。
if deleted > 0 && r.deps.Users != nil {
if self, err := r.deps.Users.Self(ctx, userID); err == nil {
r.pushSelfPhotoUpdate(ctx, self)
}
}
return ids, nil
}
// photosPhotoForSelf 组装 photos.photo 响应(新照片 + 带头像的 self user并在头像变更后
// 向该账号其它在线设备推送,使其即时刷新头像。仅由 uploadProfilePhoto / updateProfilePhoto
// 等头像变更路径调用(只读路径不得使用,否则会误触发推送)。
func (r *Router) photosPhotoForSelf(ctx context.Context, userID int64, photo domain.Photo, kind domain.ProfilePhotoKind) *tg.PhotosPhoto {
out := &tg.PhotosPhoto{Photo: tgPhoto(photo), Users: []tg.UserClass{}}
r.invalidateRPCProjectionForUser(userID)
if r.deps.Users == nil {
return out
}
self, err := r.deps.Users.Self(ctx, userID)
if err != nil {
return out
}
if kind == domain.ProfilePhotoKindProfile {
applyProfilePhotoToUser(&self, photo)
}
out.Users = append(out.Users, r.tgSelfUser(self))
r.pushSelfPhotoUpdate(ctx, self)
return out
}
func (r *Router) photosPhotoForUser(ctx context.Context, viewerUserID, targetUserID int64, photo domain.Photo) *tg.PhotosPhoto {
out := &tg.PhotosPhoto{Photo: tgPhoto(photo), Users: []tg.UserClass{}}
if r.deps.Users == nil {
return out
}
user, found, err := r.deps.Users.ByID(ctx, viewerUserID, targetUserID)
if err != nil || !found {
return out
}
out.Users = append(out.Users, r.tgUser(user))
return out
}
func (r *Router) photosPhotoForUserWithPersonalPhoto(ctx context.Context, viewerUserID, targetUserID int64, photo domain.Photo) *tg.PhotosPhoto {
out := r.photosPhotoForUser(ctx, viewerUserID, targetUserID, photo)
if photo.ID == 0 || len(out.Users) == 0 {
return out
}
user, ok := out.Users[0].(*tg.User)
if !ok || user == nil {
return out
}
user.Photo = &tg.UserProfilePhoto{PhotoID: photo.ID, DCID: photo.DCID, Personal: true}
if domain.PhotoHasVideo(photo.Sizes) {
user.Photo.(*tg.UserProfilePhoto).SetHasVideo(true)
}
if stripped := domain.StrippedFromSizes(photo.Sizes); len(stripped) > 0 {
user.Photo.(*tg.UserProfilePhoto).SetStrippedThumb(stripped)
}
return out
}
func applyProfilePhotoToUser(user *domain.User, photo domain.Photo) {
if user == nil {
return
}
if photo.ID == 0 {
user.PhotoID = 0
user.PhotoDCID = 0
user.PhotoStripped = nil
user.PhotoPersonal = false
user.PhotoHasVideo = false
return
}
user.PhotoID = photo.ID
user.PhotoDCID = photo.DCID
user.PhotoStripped = domain.StrippedFromSizes(photo.Sizes)
user.PhotoPersonal = false
user.PhotoHasVideo = domain.PhotoHasVideo(photo.Sizes)
}
// pushSelfPhotoUpdate 向该账号全部在线设备(含当前 session推送头像变更对齐参考实现
// SyncPushUpdates 的全 session 语义。updateUserName 不含 photo 无法刷新头像updateUser 只是
// 「该 user 变了」的信号TDesktop 仅当 peer 已 full-loaded 时才 forceFull 重拉);最可靠是在
// Updates.Users 带上含新 userProfilePhoto 的完整 self userTDesktop 经
// processUser→setPhoto→peerUpdated(Photo) 即时刷新。
//
// 当前 session 不能只依赖 RPC 返回DrKLO Android 的 uploadProfilePhoto 响应回调
// ProfileActivity/SettingsActivity.didUploadPhoto、PhotoUtilities不消费
// photos.photo.users而是手工重建 userProfilePhoto——只填 photo_id/photo_small/photo_big
// 丢掉 has_video/stripped_thumbemoji/sticker markup 头像的本地渲染
// ImageReceiver.setForUserOrChat → VectorAvatarThumbDrawable要求
// user.photo.has_video==true缺推送时该设备要等到下一次拿到 fresh self user通常是重启
// 才会显示 emoji 头像。当前 session 的回显延迟发送,保证到达时客户端已处理完自己的
// RPC 响应回调(否则 Android 回调会把推送刚修好的 photo 再次覆盖回无 has_video 的形状)。
func (r *Router) pushSelfPhotoUpdate(ctx context.Context, self domain.User) {
if self.ID == 0 {
return
}
updates := selfPhotoUpdates(self, int(r.clock.Now().Unix()), r.tgSelfUser(self))
r.pushUserUpdates(ctx, self.ID, updates)
r.pushSelfPhotoUpdateToCurrentSession(ctx, updates)
}
// defaultSelfPhotoEchoPushDelay 是头像变更后向当前 session 回显 updateUser 的延迟:
// 必须晚于 RPC 结果写出与客户端响应回调,否则 DrKLO 的手工 photo 重建会覆盖回显内容。
// updateUser 无 pts晚到/丢失不影响 difference 正确性。
const defaultSelfPhotoEchoPushDelay = 500 * time.Millisecond
// pushSelfPhotoUpdateToCurrentSession 延迟向当前 session 回显头像变更 updates。
// ctx 值在调度前捕获(请求 ctx 在 handler 返回后即失效)。
func (r *Router) pushSelfPhotoUpdateToCurrentSession(ctx context.Context, updates *tg.Updates) {
if r.deps.Sessions == nil || updates == nil {
return
}
sessionID, ok := SessionIDFrom(ctx)
if !ok {
return
}
rawAuthKeyID := rawAuthKeyIDForOrigin(ctx)
push := func() {
pushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := r.deps.Sessions.PushToSessionForAuthKey(pushCtx, rawAuthKeyID, sessionID, proto.MessageFromServer, updates); err != nil {
r.log.Debug("push self photo update to current session", zap.Int64("session_id", sessionID), zap.Error(err))
}
}
if r.selfPhotoEchoPushDelay <= 0 {
push()
return
}
time.AfterFunc(r.selfPhotoEchoPushDelay, push)
}
func selfPhotoUpdates(self domain.User, date int, user tg.UserClass) *tg.Updates {
return &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: self.ID}},
Users: []tg.UserClass{user},
Date: date,
}
}
// uploadedFileRef 把 tg.InputFile / InputFileBig 转成 domain.UploadedFileRef。
func uploadedFileRef(ownerUserID int64, file tg.InputFileClass) (domain.UploadedFileRef, bool) {
switch f := file.(type) {
case *tg.InputFile:
if f.ID == 0 || f.Parts <= 0 {
return domain.UploadedFileRef{}, false
}
return domain.UploadedFileRef{OwnerUserID: ownerUserID, FileID: f.ID, Parts: f.Parts, Name: f.Name, MD5: f.MD5Checksum}, true
case *tg.InputFileBig:
if f.ID == 0 || f.Parts <= 0 {
return domain.UploadedFileRef{}, false
}
return domain.UploadedFileRef{OwnerUserID: ownerUserID, FileID: f.ID, Parts: f.Parts, Name: f.Name, Big: true}, true
default:
return domain.UploadedFileRef{}, false
}
}
// resolveInputChatPhoto 把 tg.InputChatPhoto 解析为 *domain.Photonil=清除头像)。
// 支持新上传InputChatUploadedPhoto与引用已有照片InputChatPhoto{InputPhoto})。
func (r *Router) resolveInputChatPhoto(ctx context.Context, userID int64, input tg.InputChatPhotoClass) (*domain.Photo, error) {
switch in := input.(type) {
case *tg.InputChatPhotoEmpty:
return nil, nil
case *tg.InputChatUploadedPhoto:
file, ok := in.GetFile()
if !ok {
return nil, photoInvalidErr()
}
if r.deps.Files == nil {
return nil, photoInvalidErr()
}
ref, ok := uploadedFileRef(userID, file)
if !ok {
return nil, fileReferenceInvalidErr()
}
// 频道/群头像用 avatar 尺寸('a'/'c'),匹配 InputPeerPhotoFileLocation 下载路径。
photo, err := r.deps.Files.CreateAvatarFromUpload(ctx, ref)
if err != nil {
return nil, photoUploadErr(err)
}
return &photo, nil
case *tg.InputChatPhoto:
switch id := in.ID.(type) {
case *tg.InputPhoto:
if r.deps.Files == nil {
return nil, photoInvalidErr()
}
photo, found, err := r.deps.Files.GetPhoto(ctx, id.ID)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, photoInvalidErr()
}
return &photo, nil
default:
return nil, nil // InputPhotoEmpty → 清除
}
default:
return nil, photoInvalidErr()
}
}
func photoUploadErr(err error) error {
switch {
case errors.Is(err, domain.ErrFilePartsInvalid):
return filePartsInvalidErr()
case errors.Is(err, domain.ErrPhotoInvalid):
return photoInvalidErr()
default:
return internalErr()
}
}