Adds a narrower spam sanction alongside the existing account freeze: a restricted account keeps every existing membership and conversation, but cannot join new channels/groups (public join or invite link) and cannot start a new conversation with a non-contact. Reachable both as a standalone admin action and as a decision on a reported user's moderation case, with the same idempotent-supersession and appeal wiring freeze already has.
1219 lines
42 KiB
Go
1219 lines
42 KiB
Go
package rpc
|
||
|
||
import (
|
||
"context"
|
||
cryptorand "crypto/rand"
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"github.com/iamxvbaba/td/tg"
|
||
"go.uber.org/zap"
|
||
|
||
"telesrv/internal/domain"
|
||
)
|
||
|
||
// 本文件实现 messages.uploadMedia / sendMedia / sendMultiMedia 的 photo/document/sticker 主路径,
|
||
// 并抽取 sendOutgoing 作为「已校验的一条出站消息(文本或媒体)落地」的共享实现,私聊与频道共用。
|
||
|
||
const maxContactVcardLength = 8192
|
||
|
||
// outgoingSend 是 sendOutgoing 的入参:一条已校验的出站消息。
|
||
type outgoingSend struct {
|
||
randomID int64
|
||
idempotencyFingerprint []byte
|
||
idempotencyPreflighted bool
|
||
message string
|
||
entities []tg.MessageEntityClass
|
||
media *domain.MessageMedia
|
||
silent bool
|
||
noforwards bool
|
||
replyToInput tg.InputReplyToClass
|
||
sendAsInput tg.InputPeerClass
|
||
replyTo *domain.MessageReply
|
||
replyToReady bool
|
||
sendAs *domain.Peer
|
||
sendAsReady bool
|
||
clearDraft bool
|
||
// replyMarkup 是 bot reply/inline keyboard(已解析+校验;非 bot 恒 nil)。
|
||
replyMarkup *domain.MessageReplyMarkup
|
||
viaBotID int64
|
||
// richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil)。
|
||
richMessage *domain.MessageRichMessage
|
||
// groupedID 是相册分组 id:sendMultiMedia 同组各条共享一个非零值(客户端据此渲染
|
||
// 成一个相册组);单条发送恒 0。
|
||
groupedID int64
|
||
// effect 是消息特效 id(私聊专属,0 表无特效)。调用方已对 catalog 校验合法性;
|
||
// 频道侧忽略(官方群/频道不渲染特效)。
|
||
effect int64
|
||
allowPaidStars int64
|
||
}
|
||
|
||
// sendOutgoing 把一条出站消息落地到私聊或频道,返回 *tg.Updates、是否重复、错误。
|
||
// media 为空即纯文本。校验(长度/random_id/限流)由调用方完成。
|
||
func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Peer, p outgoingSend) (tg.UpdatesClass, bool, error) {
|
||
sendAs := p.sendAs
|
||
if !p.sendAsReady {
|
||
resolved, err := r.resolveSendAsPeer(ctx, userID, peer, p.sendAsInput)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
sendAs = resolved
|
||
}
|
||
if peer.Type == domain.PeerTypeChannel {
|
||
if r.deps.Channels == nil {
|
||
return nil, false, peerIDInvalidErr()
|
||
}
|
||
replyTo := p.replyTo
|
||
if !p.replyToReady {
|
||
resolved, err := r.messageReplyFromInput(ctx, userID, peer, p.replyToInput)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
replyTo = resolved
|
||
}
|
||
mentionUserIDs, err := r.mentionedUserIDsFromMessage(ctx, userID, p.message, p.entities)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
res, err := r.deps.Channels.SendMessage(ctx, userID, domain.SendChannelMessageRequest{
|
||
UserID: userID,
|
||
ChannelID: peer.ID,
|
||
RandomID: p.randomID,
|
||
IdempotencyFingerprint: p.idempotencyFingerprint,
|
||
IdempotencyPreflighted: p.idempotencyPreflighted,
|
||
Message: p.message,
|
||
Entities: domainMessageEntitiesForViewer(userID, p.entities),
|
||
Media: p.media,
|
||
MentionUserIDs: mentionUserIDs,
|
||
SkipRecipientLookup: true,
|
||
PostAuthor: r.channelPostAuthorName(ctx, userID),
|
||
Silent: p.silent,
|
||
NoForwards: p.noforwards,
|
||
ReplyTo: replyTo,
|
||
ViaBotID: p.viaBotID,
|
||
GroupedID: p.groupedID,
|
||
ReplyMarkup: p.replyMarkup,
|
||
RichMessage: p.richMessage,
|
||
SendAs: sendAs,
|
||
Date: int(r.clock.Now().Unix()),
|
||
})
|
||
if err != nil {
|
||
return nil, false, channelInvalidErr(err)
|
||
}
|
||
// 发送者 echo 走 rpc_result(同步,用独立 cache);其余成员的 fan-out 异步化,
|
||
// 移出发送者 RPC 同步路径(设计 Phase 0)。echo 与 fan-out 必须用各自独立的
|
||
// viewerPeerCache——前者在 RPC goroutine、后者在 worker goroutine,共享会数据竞态。
|
||
// 发送者 echo 走 rpc_result(同步,用独立 cache);其余成员的 fan-out 异步化。
|
||
// echo 与 fan-out 必须用各自独立的 viewerPeerCache——前者在 RPC goroutine、后者在
|
||
// worker goroutine,共享会数据竞态。
|
||
echoCache := newViewerPeerCache(r)
|
||
updates := r.channelMessageUpdatesWithPeerCache(ctx, userID, res, p.randomID, echoCache)
|
||
if !res.Duplicate {
|
||
r.enqueueChannelMessageFanout(ctx, userID, res, nil)
|
||
r.pushChannelDiscussionUpdate(ctx, userID, res.Discussion)
|
||
// 频道链接预览 pending 占位:带外解析并就地替换(异步,不阻塞发送 echo)。
|
||
r.maybeEnqueueWebPageResolve(userID, peer, res.Message.ID, res.Message.Media)
|
||
}
|
||
if p.clearDraft && !res.Duplicate {
|
||
r.clearDraftAfterSend(ctx, userID, peer, replyTo)
|
||
}
|
||
return updates, res.Duplicate, nil
|
||
}
|
||
if peer.Type != domain.PeerTypeUser {
|
||
return nil, false, peerIDInvalidErr()
|
||
}
|
||
if r.deps.Messages == nil {
|
||
return nil, false, peerIDInvalidErr()
|
||
}
|
||
if err := r.ensurePrivateContactAllowed(ctx, userID, peer.ID, p.allowPaidStars, 1); err != nil {
|
||
return nil, false, err
|
||
}
|
||
if err := r.ensureNotRestrictedFromMessaging(ctx, userID, peer.ID); err != nil {
|
||
return nil, false, err
|
||
}
|
||
if err := r.ensureVoiceMessagesAllowed(ctx, userID, peer, p.media != nil && p.media.HasUnreadPayload()); err != nil {
|
||
return nil, false, err
|
||
}
|
||
var projectedUsers []domain.User
|
||
if r.deps.Users != nil {
|
||
loaded, err := r.deps.Users.ByIDs(ctx, userID, []int64{userID, peer.ID})
|
||
if err != nil {
|
||
return nil, false, internalErr()
|
||
}
|
||
projectedUsers = loaded
|
||
peerFound := peer.ID == userID
|
||
for _, user := range projectedUsers {
|
||
if user.ID == peer.ID {
|
||
peerFound = true
|
||
break
|
||
}
|
||
}
|
||
if !peerFound {
|
||
return nil, false, peerIDInvalidErr()
|
||
}
|
||
}
|
||
replyTo := p.replyTo
|
||
if !p.replyToReady {
|
||
resolved, err := r.messageReplyFromInput(ctx, userID, peer, p.replyToInput)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
replyTo = resolved
|
||
}
|
||
recipientBlocked, err := r.peerBlocksUser(ctx, userID, peer.ID)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
sessionID, _ := SessionIDFrom(ctx)
|
||
// outbox 的排除键定位的是发起 RPC 的物理连接;PFS temp key 绑定后
|
||
// AuthKeyIDFrom 是业务视角 perm key,不能用它代替连接实际 raw key。
|
||
authKeyID := rawAuthKeyIDForOrigin(ctx)
|
||
res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
|
||
SenderUserID: userID,
|
||
RecipientUserID: peer.ID,
|
||
RandomID: p.randomID,
|
||
Message: p.message,
|
||
Entities: domainMessageEntitiesForViewer(userID, p.entities),
|
||
Media: p.media,
|
||
Silent: p.silent,
|
||
NoForwards: p.noforwards,
|
||
ReplyTo: replyTo,
|
||
Date: int(r.clock.Now().Unix()),
|
||
OriginAuthKeyID: authKeyID,
|
||
OriginSessionID: sessionID,
|
||
OriginClientSession: clientSessionMetadataFromContext(ctx),
|
||
RecipientBlocked: recipientBlocked,
|
||
IdempotencyFingerprint: p.idempotencyFingerprint,
|
||
IdempotencyPreflighted: p.idempotencyPreflighted,
|
||
ReplyMarkup: p.replyMarkup,
|
||
RichMessage: p.richMessage,
|
||
ViaBotID: p.viaBotID,
|
||
GroupedID: p.groupedID,
|
||
Effect: p.effect,
|
||
})
|
||
if err != nil {
|
||
fields := append(r.contextLogFields(ctx),
|
||
zap.Error(err),
|
||
zap.Int64("user_id", userID),
|
||
zap.Int64("peer_user_id", peer.ID),
|
||
zap.Int64("random_id", p.randomID),
|
||
zap.Int("message_len", utf8.RuneCountInString(p.message)),
|
||
zap.Bool("has_media", p.media != nil && !p.media.IsZero()),
|
||
zap.Bool("has_rich_message", !p.richMessage.IsZero()),
|
||
zap.Bool("has_reply_to", replyTo != nil),
|
||
zap.Bool("clear_draft", p.clearDraft),
|
||
zap.Int64("via_bot_id", p.viaBotID),
|
||
)
|
||
r.log.Warn("messages.sendMessage private store failed", fields...)
|
||
return nil, false, messageSendErr(err)
|
||
}
|
||
var users []tg.UserClass
|
||
var chats []tg.ChatClass
|
||
if !res.Duplicate {
|
||
users = r.usersForMessageUpdateWithPreloaded(ctx, userID, res.SenderMessage, projectedUsers)
|
||
chats = r.chatsForMessageUpdate(ctx, userID, res.SenderMessage)
|
||
}
|
||
if p.clearDraft && !res.Duplicate {
|
||
r.clearDraftAfterSendWithPeerObjects(ctx, userID, peer, replyTo, users, chats)
|
||
}
|
||
if !res.Duplicate {
|
||
// 链接预览 pending 占位:带外解析并就地替换(异步,不阻塞发送 echo)。
|
||
r.maybeEnqueueWebPageResolve(userID, peer, res.SenderMessage.ID, res.SenderMessage.Media)
|
||
r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res)
|
||
}
|
||
return tgPrivateSendResultUpdates(res, p.randomID, true, users, chats), res.Duplicate, nil
|
||
}
|
||
|
||
// onMessagesUploadMedia 解析 InputMedia(上传或引用),返回可复用的 tg.MessageMedia。
|
||
func (r *Router) onMessagesUploadMedia(ctx context.Context, req *tg.MessagesUploadMediaRequest) (tg.MessageMediaClass, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if userID == 0 {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
if len(req.BusinessConnectionID) > maxBusinessConnIDLength {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
if _, ok := req.Peer.(*tg.InputPeerEmpty); !ok {
|
||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if _, ok := req.Media.(*tg.InputMediaEmpty); ok {
|
||
return &tg.MessageMediaEmpty{}, nil
|
||
}
|
||
media, err := r.resolveInputMedia(ctx, userID, req.Media)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if media == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
return tgMessageMedia(media), nil
|
||
}
|
||
|
||
// onMessagesSendMedia 发送一条带媒体的消息(photo/document/sticker),私聊与频道均支持。
|
||
func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMediaRequest) (tg.UpdatesClass, error) {
|
||
if req.RandomID == 0 {
|
||
return nil, randomIDEmptyErr()
|
||
}
|
||
if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength {
|
||
return nil, mediaCaptionTooLongErr()
|
||
}
|
||
if len(req.Entities) > maxMessageEntityCount {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
if req.ScheduleRepeatPeriod != 0 {
|
||
return nil, scheduleDateInvalidErr()
|
||
}
|
||
if req.Media == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
// InputMediaEmpty / WebPage:退化为纯文本发送(复用 sendMessage 校验与流程,含 url 实体补全)。
|
||
switch req.Media.(type) {
|
||
case *tg.InputMediaEmpty, *tg.InputMediaWebPage:
|
||
return r.onMessagesSendMessage(ctx, sendMessageRequestFromSendMedia(req))
|
||
}
|
||
// 指纹是幂等校验元数据,不能让一个本应由 media resolver 映射成
|
||
// MEDIA_INVALID 的畸形 input 因 Encode 失败提前变成 INTERNAL。合法请求会得到
|
||
// 原始 TL 指纹;编码失败则留空,由 store 使用 domain fallback。
|
||
idempotencyFingerprint, _ := sendMediaIdempotencyFingerprint(req)
|
||
// 媒体 caption 里的链接/@mention/#hashtag 等同样补自动高亮实体(客户端未带时)。
|
||
// 派生结果保持在局部变量中,不能回写原始 TL request,否则同一请求对象重试时指纹会
|
||
// 从“客户端输入”变成“服务端派生输入”,错误触发 RANDOM_ID_DUPLICATE。
|
||
entities := r.augmentAutoEntities(req.Message, req.Entities)
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if userID == 0 {
|
||
return nil, peerIDInvalidErr()
|
||
}
|
||
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer)
|
||
if !ok || peer.ID == 0 {
|
||
return nil, peerIDInvalidErr()
|
||
}
|
||
suggestedInput, hasSuggestedPost := req.GetSuggestedPost()
|
||
var mono domain.Channel
|
||
var monoforum, monoforumAdmin bool
|
||
if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil {
|
||
mono, monoforumAdmin, err = r.deps.Channels.ResolveMonoforumSend(ctx, userID, peer.ID)
|
||
switch {
|
||
case err == nil:
|
||
monoforum = true
|
||
case !errors.Is(err, domain.ErrChannelInvalid):
|
||
return nil, internalErr()
|
||
}
|
||
}
|
||
if hasSuggestedPost && !monoforum {
|
||
return nil, suggestedPostPeerInvalidErr()
|
||
}
|
||
if monoforum {
|
||
if req.AllowPaidStars < 0 {
|
||
return nil, starsAmountInvalidErr()
|
||
}
|
||
if req.AllowPaidFloodskip {
|
||
return nil, paymentUnsupportedErr()
|
||
}
|
||
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
|
||
return nil, scheduleDateInvalidErr()
|
||
}
|
||
suggestedPost, err := domainSuggestedPost(suggestedInput, hasSuggestedPost)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
savedPeer, err := r.monoforumSavedPeerForSender(userID, monoforumAdmin, req.ReplyTo)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
replyTo, err := r.monoforumMessageReplyFromInput(ctx, userID, peer, req.ReplyTo)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
replay, err := r.lookupChannelSendReplay(ctx, userID, peer.ID, savedPeer, req.RandomID, idempotencyFingerprint)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if replay.found {
|
||
if req.ClearDraft {
|
||
r.clearDraftAfterSend(ctx, userID, peer, replyTo)
|
||
}
|
||
return r.monoforumSendUpdatesStrict(ctx, userID, replay.channel.Channel, savedPeer, replay.channel)
|
||
}
|
||
if r.messageEffectInvalid(ctx, req.Effect) {
|
||
return nil, effectIDInvalidErr()
|
||
}
|
||
if err := r.checkSendRateLimit(ctx, userID, 1); err != nil {
|
||
return nil, err
|
||
}
|
||
checkedPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
media, err := r.resolveInputMedia(ctx, userID, req.Media)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if media == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
return r.sendMonoforumMessage(ctx, userID, checkedPeer, mono, monoforumAdmin, domain.SendMonoforumMessageRequest{
|
||
SavedPeer: savedPeer,
|
||
RandomID: req.RandomID,
|
||
IdempotencyFingerprint: idempotencyFingerprint,
|
||
IdempotencyPreflighted: replay.checked,
|
||
Message: req.Message,
|
||
Entities: domainMessageEntities(entities),
|
||
Media: media,
|
||
ReplyTo: replyTo,
|
||
Silent: req.Silent,
|
||
NoForwards: req.Noforwards,
|
||
SuggestedPost: suggestedPost,
|
||
AllowPaidStars: req.AllowPaidStars,
|
||
ClearDraft: req.ClearDraft,
|
||
})
|
||
}
|
||
if req.AllowPaidFloodskip {
|
||
return nil, paymentUnsupportedErr()
|
||
}
|
||
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if replay.found {
|
||
return r.outgoingReplayUpdates(ctx, userID, peer, req.RandomID, replay), nil
|
||
}
|
||
// 消息特效:仅接受 catalog 内的合法 effect id(非法 id → EFFECT_ID_INVALID,官方行为)。
|
||
if r.messageEffectInvalid(ctx, req.Effect) {
|
||
return nil, effectIDInvalidErr()
|
||
}
|
||
if err := r.checkSendRateLimit(ctx, userID, 1); err != nil {
|
||
return nil, err
|
||
}
|
||
peer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
voiceOrRound, err := r.preflightVoiceOrRound(ctx, []tg.InputMediaClass{req.Media})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := r.ensureVoiceMessagesAllowed(ctx, userID, peer, voiceOrRound); err != nil {
|
||
return nil, err
|
||
}
|
||
media, err := r.resolveInputMedia(ctx, userID, req.Media)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if media == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
// reply_markup:bot 可发送 inline keyboard 与普通 reply keyboard/hide/force;
|
||
// 非 bot 静默丢弃。
|
||
var replyMarkup *domain.MessageReplyMarkup
|
||
if req.ReplyMarkup != nil {
|
||
replyMarkup, err = domainOutgoingReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID))
|
||
if err != nil {
|
||
return nil, replyMarkupErr(err)
|
||
}
|
||
if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
|
||
return r.scheduleOutgoing(ctx, userID, peer, outgoingSend{
|
||
randomID: req.RandomID,
|
||
idempotencyFingerprint: idempotencyFingerprint,
|
||
idempotencyPreflighted: replay.checked,
|
||
message: req.Message,
|
||
entities: entities,
|
||
media: media,
|
||
silent: req.Silent,
|
||
noforwards: req.Noforwards,
|
||
replyToInput: req.ReplyTo,
|
||
sendAsInput: req.SendAs,
|
||
clearDraft: req.ClearDraft,
|
||
allowPaidStars: req.AllowPaidStars,
|
||
}, req.ScheduleDate, req.ScheduleRepeatPeriod)
|
||
}
|
||
updates, _, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{
|
||
randomID: req.RandomID,
|
||
idempotencyFingerprint: idempotencyFingerprint,
|
||
idempotencyPreflighted: replay.checked,
|
||
message: req.Message,
|
||
entities: entities,
|
||
media: media,
|
||
silent: req.Silent,
|
||
noforwards: req.Noforwards,
|
||
replyToInput: req.ReplyTo,
|
||
sendAsInput: req.SendAs,
|
||
clearDraft: req.ClearDraft,
|
||
replyMarkup: replyMarkup,
|
||
effect: req.Effect,
|
||
allowPaidStars: req.AllowPaidStars,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return updates, nil
|
||
}
|
||
|
||
// onMessagesSendMultiMedia 发送相册(多条媒体),并在解析媒体前持久预留 grouped_id。
|
||
func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesSendMultiMediaRequest) (tg.UpdatesClass, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if userID == 0 {
|
||
return nil, peerIDInvalidErr()
|
||
}
|
||
if len(req.MultiMedia) == 0 || len(req.MultiMedia) > maxSendMultiMediaItems {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
if req.AllowPaidStars < 0 {
|
||
return nil, starsAmountInvalidErr()
|
||
}
|
||
if req.AllowPaidFloodskip {
|
||
return nil, paymentUnsupportedErr()
|
||
}
|
||
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer)
|
||
if !ok || peer.ID == 0 {
|
||
return nil, peerIDInvalidErr()
|
||
}
|
||
randomIDs := make(map[int64]struct{}, len(req.MultiMedia))
|
||
reservationItems := make([]domain.AlbumGroupReservationItem, 0, len(req.MultiMedia))
|
||
for _, item := range req.MultiMedia {
|
||
if item.RandomID == 0 {
|
||
return nil, randomIDEmptyErr()
|
||
}
|
||
if _, duplicate := randomIDs[item.RandomID]; duplicate {
|
||
return nil, randomIDDuplicateErr()
|
||
}
|
||
randomIDs[item.RandomID] = struct{}{}
|
||
if utf8.RuneCountInString(item.Message) > maxSendMessageTextLength {
|
||
return nil, mediaCaptionTooLongErr()
|
||
}
|
||
if len(item.Entities) > maxMessageEntityCount {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
if item.Media == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
intentHash, fingerprintErr := sendMultiMediaItemIdempotencyFingerprint(req, item)
|
||
if fingerprintErr != nil {
|
||
// 畸形 InputMedia 的 TL 编码失败不能变成 INTERNAL;保持 media 输入错误语义。
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
reservationItems = append(reservationItems, domain.AlbumGroupReservationItem{
|
||
RandomID: item.RandomID,
|
||
IntentHash: intentHash,
|
||
})
|
||
}
|
||
replays := make([]outgoingReplayLookup, len(req.MultiMedia))
|
||
absentCount := 0
|
||
for i, item := range req.MultiMedia {
|
||
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, item.RandomID, reservationItems[i].IntentHash)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
replays[i] = replay
|
||
if !replay.found {
|
||
absentCount++
|
||
}
|
||
}
|
||
if absentCount == 0 {
|
||
results := make([]tg.UpdatesClass, 0, len(req.MultiMedia))
|
||
for i, item := range req.MultiMedia {
|
||
results = append(results, r.outgoingReplayUpdates(ctx, userID, peer, item.RandomID, replays[i]))
|
||
}
|
||
return combineSendUpdates(results), nil
|
||
}
|
||
if err := r.checkSendRateLimit(ctx, userID, absentCount); err != nil {
|
||
return nil, err
|
||
}
|
||
peer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if peer.Type == domain.PeerTypeUser {
|
||
if req.AllowPaidFloodskip {
|
||
return nil, paymentUnsupportedErr()
|
||
}
|
||
if err := r.ensurePrivateContactAllowed(ctx, userID, peer.ID, req.AllowPaidStars, absentCount); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := r.ensureNotRestrictedFromMessaging(ctx, userID, peer.ID); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
pendingMedia := make([]tg.InputMediaClass, 0, absentCount)
|
||
for i, item := range req.MultiMedia {
|
||
if !replays[i].found {
|
||
pendingMedia = append(pendingMedia, item.Media)
|
||
}
|
||
}
|
||
voiceOrRound, err := r.preflightVoiceOrRound(ctx, pendingMedia)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := r.ensureVoiceMessagesAllowed(ctx, userID, peer, voiceOrRound); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 必须在 resolveInputMedia 或发送任何 item 之前原子预留:首次请求若在第 N 条
|
||
// 失败,客户端只重试失败子集时仍从已绑定 random_id 恢复整包 grouped_id。
|
||
groupedID, err := r.reserveAlbumGroup(ctx, userID, peer, reservationItems)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, replay := range replays {
|
||
if replay.found {
|
||
messageGroupedID := replay.private.SenderMessage.GroupedID
|
||
if peer.Type == domain.PeerTypeChannel {
|
||
messageGroupedID = replay.channel.Message.GroupedID
|
||
}
|
||
if messageGroupedID != groupedID {
|
||
return nil, internalErr()
|
||
}
|
||
}
|
||
}
|
||
|
||
results := make([]tg.UpdatesClass, 0, len(req.MultiMedia))
|
||
clearDraftPending := req.ClearDraft
|
||
for i, item := range req.MultiMedia {
|
||
idempotencyFingerprint := reservationItems[i].IntentHash
|
||
if replays[i].found {
|
||
results = append(results, r.outgoingReplayUpdates(ctx, userID, peer, item.RandomID, replays[i]))
|
||
continue
|
||
}
|
||
media, err := r.resolveInputMedia(ctx, userID, item.Media)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if media == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
p := outgoingSend{
|
||
randomID: item.RandomID,
|
||
idempotencyFingerprint: idempotencyFingerprint,
|
||
idempotencyPreflighted: replays[i].checked,
|
||
message: item.Message,
|
||
entities: r.augmentAutoEntities(item.Message, item.Entities),
|
||
media: media,
|
||
silent: req.Silent,
|
||
noforwards: req.Noforwards,
|
||
replyToInput: req.ReplyTo,
|
||
sendAsInput: req.SendAs,
|
||
clearDraft: clearDraftPending,
|
||
groupedID: groupedID,
|
||
allowPaidStars: req.AllowPaidStars,
|
||
}
|
||
var result tg.UpdatesClass
|
||
duplicate := false
|
||
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
|
||
result, err = r.scheduleOutgoing(ctx, userID, peer, p, req.ScheduleDate, 0)
|
||
} else {
|
||
result, duplicate, err = r.sendOutgoing(ctx, userID, peer, p)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if p.clearDraft && !duplicate {
|
||
clearDraftPending = false
|
||
}
|
||
results = append(results, result)
|
||
}
|
||
return combineSendUpdates(results), nil
|
||
}
|
||
|
||
// resolveInputMedia 把 tg.InputMedia 解析为 domain.MessageMedia(上传则落库,引用则加载)。
|
||
// 返回 nil 表示 InputMediaEmpty(调用方退化为纯文本)。
|
||
func (r *Router) resolveInputMedia(ctx context.Context, userID int64, input tg.InputMediaClass) (*domain.MessageMedia, error) {
|
||
switch in := input.(type) {
|
||
case *tg.InputMediaEmpty:
|
||
return nil, nil
|
||
case *tg.InputMediaContact:
|
||
if !validContactInput(in.PhoneNumber, in.FirstName, in.LastName, "", 0) || utf8.RuneCountInString(in.Vcard) > maxContactVcardLength {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
if strings.TrimSpace(in.PhoneNumber) == "" && strings.TrimSpace(in.FirstName) == "" && strings.TrimSpace(in.LastName) == "" && strings.TrimSpace(in.Vcard) == "" {
|
||
return nil, mediaEmptyErr()
|
||
}
|
||
return &domain.MessageMedia{
|
||
Kind: domain.MessageMediaKindContact,
|
||
Contact: &domain.MessageContact{
|
||
PhoneNumber: in.PhoneNumber,
|
||
FirstName: in.FirstName,
|
||
LastName: in.LastName,
|
||
Vcard: in.Vcard,
|
||
UserID: r.messageContactUserID(ctx, userID, in.PhoneNumber),
|
||
},
|
||
}, nil
|
||
case *tg.InputMediaUploadedPhoto:
|
||
if r.deps.Files == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
if in.File == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
ref, ok := uploadedFileRef(userID, in.File)
|
||
if !ok {
|
||
return nil, fileReferenceInvalidErr()
|
||
}
|
||
photo, err := r.deps.Files.CreatePhotoFromUpload(ctx, ref)
|
||
if err != nil {
|
||
return nil, mediaUploadErr(err)
|
||
}
|
||
return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo, Spoiler: in.Spoiler, TTLSeconds: in.TTLSeconds}, nil
|
||
case *tg.InputMediaUploadedDocument:
|
||
if r.deps.Files == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
if in.File == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
ref, ok := uploadedFileRef(userID, in.File)
|
||
if !ok {
|
||
return nil, fileReferenceInvalidErr()
|
||
}
|
||
spec := domain.DocumentSpec{
|
||
MimeType: in.MimeType,
|
||
Attributes: domainDocumentAttributes(in.Attributes),
|
||
ForceFile: in.ForceFile,
|
||
NosoundVideo: in.NosoundVideo,
|
||
}
|
||
if thumb, ok := in.GetThumb(); ok {
|
||
if tref, ok := uploadedFileRef(userID, thumb); ok {
|
||
spec.Thumb = &tref
|
||
}
|
||
}
|
||
doc, err := r.deps.Files.CreateDocumentFromUpload(ctx, ref, spec)
|
||
if err != nil {
|
||
return nil, mediaUploadErr(err)
|
||
}
|
||
return messageMediaFromDocument(doc, in.Spoiler, in.TTLSeconds), nil
|
||
case *tg.InputMediaPhotoExternal:
|
||
// 外链图片:服务端 SSRF 安全抓取 URL 并铸造 Photo。未启用/拦截/上游失败统一 MEDIA_INVALID。
|
||
if r.deps.Files == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
photo, err := r.deps.Files.CreatePhotoFromURL(ctx, in.URL)
|
||
if err != nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo, Spoiler: in.Spoiler, TTLSeconds: in.TTLSeconds}, nil
|
||
case *tg.InputMediaDocumentExternal:
|
||
// 外链文档:抓取 URL,mime 取 Content-Type、文件名取 URL basename。video_cover/timestamp 暂忽略。
|
||
if r.deps.Files == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
doc, err := r.deps.Files.CreateDocumentFromURL(ctx, in.URL)
|
||
if err != nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
return messageMediaFromDocument(doc, in.Spoiler, in.TTLSeconds), nil
|
||
case *tg.InputMediaPhoto:
|
||
if r.deps.Files == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
photoID, ok := inputPhotoID(in.ID)
|
||
if !ok {
|
||
return nil, photoInvalidErr()
|
||
}
|
||
photo, found, err := r.deps.Files.GetPhoto(ctx, photoID)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if !found {
|
||
return nil, photoInvalidErr()
|
||
}
|
||
return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo, Spoiler: in.Spoiler, TTLSeconds: in.TTLSeconds}, nil
|
||
case *tg.InputMediaDocument:
|
||
if r.deps.Files == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
docIDs, ok := inputDocumentCandidateIDs(in.ID)
|
||
if !ok {
|
||
r.log.Warn("sendMedia InputMediaDocument unresolvable id", zap.String("id_type", fmt.Sprintf("%T", in.ID)))
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
var doc domain.Document
|
||
found := false
|
||
for _, docID := range docIDs {
|
||
var err error
|
||
doc, found, err = r.deps.Files.GetDocument(ctx, docID)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if found {
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
r.log.Warn("sendMedia references unknown document", zap.Int64s("doc_ids", docIDs), zap.Int64("user_id", userID))
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
return messageMediaFromDocument(doc, in.Spoiler, in.TTLSeconds), nil
|
||
case *tg.InputMediaGeoPoint:
|
||
geo, err := domainGeoPointFromInput(in.GeoPoint)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: geo}, nil
|
||
case *tg.InputMediaVenue:
|
||
geo, err := domainGeoPointFromInput(in.GeoPoint)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if strings.TrimSpace(in.Title) == "" {
|
||
return nil, mediaEmptyErr()
|
||
}
|
||
if utf8.RuneCountInString(in.Title) > maxVenueTitleLength ||
|
||
utf8.RuneCountInString(in.Address) > maxVenueAddressLength ||
|
||
utf8.RuneCountInString(in.Provider) > maxVenueProviderLength ||
|
||
utf8.RuneCountInString(in.VenueID) > maxVenueIDLength ||
|
||
utf8.RuneCountInString(in.VenueType) > maxVenueIDLength {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
return &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{
|
||
Geo: *geo,
|
||
Title: in.Title,
|
||
Address: in.Address,
|
||
Provider: in.Provider,
|
||
VenueID: in.VenueID,
|
||
VenueType: in.VenueType,
|
||
}}, nil
|
||
case *tg.InputMediaDice:
|
||
emoticon := normalizeDiceEmoticon(in.Emoticon)
|
||
sides, ok := diceValueSides(emoticon)
|
||
if !ok {
|
||
return nil, emoticonInvalidErr()
|
||
}
|
||
value, err := randomDiceValue(sides)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
return &domain.MessageMedia{Kind: domain.MessageMediaKindDice, Dice: &domain.MessageDice{
|
||
Emoticon: emoticon,
|
||
Value: value,
|
||
}}, nil
|
||
case *tg.InputMediaGeoLive:
|
||
if in.Stopped {
|
||
// stopped 只在 editMessage 停止共享时有意义,发送即停没有客户端路径。
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
geo, err := domainGeoPointFromInput(in.GeoPoint)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
live := &domain.MessageGeoLive{Geo: *geo, Period: minLiveLocationPeriod}
|
||
if period, ok := in.GetPeriod(); ok {
|
||
if period != foreverLiveLocationPeriod && (period < minLiveLocationPeriod || period > maxLiveLocationPeriod) {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
live.Period = period
|
||
}
|
||
if heading, ok := in.GetHeading(); ok {
|
||
if heading < 0 || heading > maxLiveLocationHeading {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
live.Heading = heading
|
||
}
|
||
if radius, ok := in.GetProximityNotificationRadius(); ok {
|
||
if radius < 0 || radius > maxProximityRadiusMeters {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
live.ProximityNotificationRadius = radius
|
||
}
|
||
return &domain.MessageMedia{Kind: domain.MessageMediaKindGeoLive, GeoLive: live}, nil
|
||
case *tg.InputMediaPoll:
|
||
if r.deps.Polls == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
pollID, err := randomPollID()
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
snapshot, def, err := r.domainPollFromInputMedia(ctx, in, userID, pollID, int(r.clock.Now().Unix()))
|
||
if err != nil {
|
||
// compat 诊断:客户端 poll 构造形状多变(flag 默认值/答案构造器随版本漂移),
|
||
// 拒绝时必须留痕,否则只有一个裸 4xx 无从对账。
|
||
answerTypes := make([]string, 0, len(in.Poll.Answers))
|
||
for _, answerClass := range in.Poll.Answers {
|
||
switch answer := answerClass.(type) {
|
||
case *tg.PollAnswer:
|
||
answerTypes = append(answerTypes, fmt.Sprintf("pollAnswer{flags:%#x,text:%d,option:%d,media:%T}",
|
||
uint32(answer.Flags), len(answer.Text.Text), len(answer.Option), answer.Media))
|
||
case *tg.InputPollAnswer:
|
||
media, hasMedia := answer.GetMedia()
|
||
answerTypes = append(answerTypes, fmt.Sprintf("inputPollAnswer{flags:%#x,text:%d,has_media:%v,media:%T}",
|
||
uint32(answer.Flags), len(answer.Text.Text), hasMedia, media))
|
||
default:
|
||
answerTypes = append(answerTypes, fmt.Sprintf("%T", answerClass))
|
||
}
|
||
}
|
||
closePeriod, hasPeriod := in.Poll.GetClosePeriod()
|
||
closeDate, hasDate := in.Poll.GetCloseDate()
|
||
correct, hasCorrect := in.GetCorrectAnswers()
|
||
solution, hasSolution := in.GetSolution()
|
||
r.log.Warn("sendMedia poll rejected",
|
||
zap.Error(err),
|
||
zap.Int64("user_id", userID),
|
||
zap.String("input_flags", fmt.Sprintf("%#x", uint32(in.Flags))),
|
||
zap.String("poll_flags", fmt.Sprintf("%#x", uint32(in.Poll.Flags))),
|
||
zap.Bool("quiz", in.Poll.Quiz),
|
||
zap.Bool("multiple_choice", in.Poll.MultipleChoice),
|
||
zap.Bool("public_voters", in.Poll.PublicVoters),
|
||
zap.Bool("open_answers", in.Poll.OpenAnswers),
|
||
zap.Bool("shuffle_answers", in.Poll.ShuffleAnswers),
|
||
zap.Bool("revoting_disabled", in.Poll.RevotingDisabled),
|
||
zap.Bool("hide_results", in.Poll.HideResultsUntilClose),
|
||
zap.Bool("subscribers_only", in.Poll.SubscribersOnly),
|
||
zap.Int("countries", len(in.Poll.CountriesISO2)),
|
||
zap.Int("question_len", len(in.Poll.Question.Text)),
|
||
zap.Int("question_entities", len(in.Poll.Question.Entities)),
|
||
zap.Strings("answer_types", answerTypes),
|
||
zap.Bool("has_close_period", hasPeriod), zap.Int("close_period", closePeriod),
|
||
zap.Bool("has_close_date", hasDate), zap.Int("close_date", closeDate),
|
||
zap.Bool("has_correct", hasCorrect), zap.Ints("correct", correct),
|
||
zap.Bool("has_solution", hasSolution), zap.Int("solution_len", len(solution)),
|
||
)
|
||
return nil, err
|
||
}
|
||
// 权威行先落库(消息发送失败产生的孤儿 poll 无害且可回收)。
|
||
if err := r.deps.Polls.CreatePoll(ctx, def); err != nil {
|
||
r.log.Warn("create poll failed", zap.Error(err))
|
||
return nil, internalErr()
|
||
}
|
||
return &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: snapshot}, nil
|
||
case *tg.InputMediaTodo:
|
||
todo, err := domainTodoFromInput(in.Todo)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &domain.MessageMedia{Kind: domain.MessageMediaKindTodo, Todo: todo}, nil
|
||
case *tg.InputMediaStory:
|
||
return r.domainMessageStoryFromInput(ctx, userID, in)
|
||
default:
|
||
// poll(独立链路见 messages_polls.go)/ geoLive / todo / game / invoice /
|
||
// paid media / external 等未接入;范围与 stub 决策见 docs/compatibility-matrix.md。
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
}
|
||
|
||
func (r *Router) domainMessageStoryFromInput(ctx context.Context, userID int64, in *tg.InputMediaStory) (*domain.MessageMedia, error) {
|
||
if in == nil || in.ID <= 0 || in.ID > domain.MaxStoryID {
|
||
return nil, storyIDInvalidErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, in.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if r.deps.Stories == nil {
|
||
return nil, storyIDInvalidErr()
|
||
}
|
||
now := int(r.clock.Now().Unix())
|
||
list, err := r.deps.Stories.GetStoriesByID(ctx, userID, peer, []int{in.ID}, now)
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
if len(list.Stories) != 1 || list.Stories[0].Owner != peer || list.Stories[0].ID != in.ID {
|
||
return nil, storyIDInvalidErr()
|
||
}
|
||
story := list.Stories[0]
|
||
if story.NoForwards {
|
||
return nil, chatForwardsRestrictedErr()
|
||
}
|
||
return &domain.MessageMedia{
|
||
Kind: domain.MessageMediaKindStory,
|
||
Story: &domain.MessageStory{
|
||
Peer: peer,
|
||
ID: in.ID,
|
||
Story: &story,
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
// domainGeoPointFromInput 校验并转换 InputGeoPoint;空点返回 MEDIA_EMPTY,越界返回 MEDIA_INVALID。
|
||
// access_hash 在此随机生成:客户端会把它原样带回 upload.getWebFile 的地图缩略请求,但
|
||
// 服务端地图渲染不依赖鉴权(详见 upload_webfile.go),故无需持久化校验。
|
||
func domainGeoPointFromInput(input tg.InputGeoPointClass) (*domain.MessageGeoPoint, error) {
|
||
point, ok := input.(*tg.InputGeoPoint)
|
||
if !ok || point == nil {
|
||
return nil, mediaEmptyErr()
|
||
}
|
||
if point.Lat < -90 || point.Lat > 90 || point.Long < -180 || point.Long > 180 {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
accuracy, _ := point.GetAccuracyRadius()
|
||
if accuracy < 0 || accuracy > maxGeoAccuracyRadiusMeters {
|
||
accuracy = 0
|
||
}
|
||
hash, err := randomGeoAccessHash()
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
return &domain.MessageGeoPoint{
|
||
Lat: point.Lat,
|
||
Long: point.Long,
|
||
AccessHash: hash,
|
||
AccuracyRadius: accuracy,
|
||
}, nil
|
||
}
|
||
|
||
func randomGeoAccessHash() (int64, error) {
|
||
var b [8]byte
|
||
if _, err := cryptorand.Read(b[:]); err != nil {
|
||
return 0, err
|
||
}
|
||
v := int64(binary.LittleEndian.Uint64(b[:]))
|
||
if v == 0 {
|
||
v = 1
|
||
}
|
||
return v, nil
|
||
}
|
||
|
||
// randomPollID 生成正的 63 位随机 poll id;polls 主键冲突概率可忽略(冲突时 INSERT 报错重试由客户端承担)。
|
||
func randomPollID() (int64, error) {
|
||
var b [8]byte
|
||
if _, err := cryptorand.Read(b[:]); err != nil {
|
||
return 0, err
|
||
}
|
||
v := int64(binary.LittleEndian.Uint64(b[:]) >> 1)
|
||
if v == 0 {
|
||
v = 1
|
||
}
|
||
return v, nil
|
||
}
|
||
|
||
// normalizeDiceEmoticon 去掉 emoji 变体选择符(U+FE0F):TDesktop 的 ⚽️ 与 ⚽ 都允许发送,
|
||
// 但 dice 贴纸系统集 key 与官方回包都是裸码点形态。
|
||
func normalizeDiceEmoticon(emoticon string) string {
|
||
return strings.ReplaceAll(emoticon, "️", "")
|
||
}
|
||
|
||
// diceValueSides 返回 emoticon 对应的取值上限(值域 [1, sides],与官方一致)。
|
||
// 列表必须与 appConfig 的 emojies_send_dice 保持同步(客户端据其决定单 emoji 是否转 dice)。
|
||
func diceValueSides(emoticon string) (int, bool) {
|
||
switch emoticon {
|
||
case "\U0001F3B2", "\U0001F3AF", "\U0001F3B3": // 🎲 🎯 🎳
|
||
return 6, true
|
||
case "\U0001F3C0", "⚽": // 🏀 ⚽
|
||
return 5, true
|
||
case "\U0001F3B0": // 🎰
|
||
return 64, true
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|
||
|
||
func randomDiceValue(sides int) (int, error) {
|
||
if sides <= 0 {
|
||
return 0, fmt.Errorf("dice sides must be positive")
|
||
}
|
||
// 拒绝采样消除模偏差;sides ≤ 64,单字节足够。
|
||
max := 256 - 256%sides
|
||
var b [1]byte
|
||
for {
|
||
if _, err := cryptorand.Read(b[:]); err != nil {
|
||
return 0, err
|
||
}
|
||
if int(b[0]) < max {
|
||
return 1 + int(b[0])%sides, nil
|
||
}
|
||
}
|
||
}
|
||
|
||
func (r *Router) messageContactUserID(ctx context.Context, userID int64, phone string) int64 {
|
||
if r.deps.Users == nil || strings.TrimSpace(phone) == "" {
|
||
return 0
|
||
}
|
||
identity, ok := r.deps.Users.(UserIdentityService)
|
||
if !ok {
|
||
return 0
|
||
}
|
||
u, found, err := identity.ResolvePhone(ctx, userID, phone)
|
||
if err != nil || !found {
|
||
return 0
|
||
}
|
||
return u.ID
|
||
}
|
||
|
||
// messageMediaFromDocument 由 Document 构造 MessageMedia,并从属性推导 Video/Round/Voice 标志。
|
||
func messageMediaFromDocument(doc domain.Document, spoiler bool, ttl int) *domain.MessageMedia {
|
||
media := &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &doc, Spoiler: spoiler, TTLSeconds: ttl}
|
||
if doc.IsSticker() {
|
||
media.Nopremium = true
|
||
}
|
||
for _, attr := range doc.Attributes {
|
||
switch attr.Kind {
|
||
case domain.DocAttrVideo:
|
||
media.Video = true
|
||
if attr.RoundMessage {
|
||
media.Round = true
|
||
}
|
||
case domain.DocAttrAudio:
|
||
if attr.Voice {
|
||
media.Voice = true
|
||
}
|
||
}
|
||
}
|
||
return media
|
||
}
|
||
|
||
func inputPhotoID(input tg.InputPhotoClass) (int64, bool) {
|
||
if p, ok := input.(*tg.InputPhoto); ok && p != nil && p.ID != 0 {
|
||
return p.ID, true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func inputDocumentID(input tg.InputDocumentClass) (int64, bool) {
|
||
if d, ok := input.(*tg.InputDocument); ok && d != nil && d.ID != 0 {
|
||
return d.ID, true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func inputDocumentCandidateIDs(input tg.InputDocumentClass) ([]int64, bool) {
|
||
if d, ok := input.(*tg.InputDocument); ok && d != nil && d.ID != 0 {
|
||
return []int64{d.ID}, true
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// domainDocumentAttributes 把 tg.DocumentAttribute 反向转为 domain(InputMediaUploadedDocument 用)。
|
||
func domainDocumentAttributes(attrs []tg.DocumentAttributeClass) []domain.DocumentAttribute {
|
||
out := make([]domain.DocumentAttribute, 0, len(attrs))
|
||
for _, a := range attrs {
|
||
switch v := a.(type) {
|
||
case *tg.DocumentAttributeImageSize:
|
||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrImageSize, W: v.W, H: v.H})
|
||
case *tg.DocumentAttributeAnimated:
|
||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAnimated})
|
||
case *tg.DocumentAttributeSticker:
|
||
attr := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: v.Alt, Mask: v.Mask}
|
||
if id, hash, ok := inputStickerSetIDs(v.Stickerset); ok {
|
||
attr.StickerSetID = id
|
||
attr.StickerSetAccessHash = hash
|
||
}
|
||
out = append(out, attr)
|
||
case *tg.DocumentAttributeVideo:
|
||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: v.W, H: v.H, Duration: v.Duration, RoundMessage: v.RoundMessage, SupportsStreaming: v.SupportsStreaming, NoSound: v.Nosound, VideoCodec: v.VideoCodec})
|
||
case *tg.DocumentAttributeAudio:
|
||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: v.Duration, Voice: v.Voice, Title: v.Title, Performer: v.Performer, Waveform: v.Waveform})
|
||
case *tg.DocumentAttributeFilename:
|
||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: v.FileName})
|
||
case *tg.DocumentAttributeCustomEmoji:
|
||
attr := domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: v.Alt, Free: v.Free, TextColor: v.TextColor}
|
||
if id, hash, ok := inputStickerSetIDs(v.Stickerset); ok {
|
||
attr.StickerSetID = id
|
||
attr.StickerSetAccessHash = hash
|
||
}
|
||
out = append(out, attr)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func inputStickerSetIDs(input tg.InputStickerSetClass) (int64, int64, bool) {
|
||
if s, ok := input.(*tg.InputStickerSetID); ok {
|
||
return s.ID, s.AccessHash, true
|
||
}
|
||
return 0, 0, false
|
||
}
|
||
|
||
// sendMessageRequestFromSendMedia 把 sendMedia(空媒体)的字段映射到 sendMessage 请求。
|
||
func sendMessageRequestFromSendMedia(req *tg.MessagesSendMediaRequest) *tg.MessagesSendMessageRequest {
|
||
return &tg.MessagesSendMessageRequest{
|
||
Silent: req.Silent,
|
||
Background: req.Background,
|
||
ClearDraft: req.ClearDraft,
|
||
Noforwards: req.Noforwards,
|
||
UpdateStickersetsOrder: req.UpdateStickersetsOrder,
|
||
InvertMedia: req.InvertMedia,
|
||
AllowPaidFloodskip: req.AllowPaidFloodskip,
|
||
Peer: req.Peer,
|
||
ReplyTo: req.ReplyTo,
|
||
Message: req.Message,
|
||
RandomID: req.RandomID,
|
||
ReplyMarkup: req.ReplyMarkup,
|
||
Entities: req.Entities,
|
||
ScheduleDate: req.ScheduleDate,
|
||
ScheduleRepeatPeriod: req.ScheduleRepeatPeriod,
|
||
SendAs: req.SendAs,
|
||
QuickReplyShortcut: req.QuickReplyShortcut,
|
||
Effect: req.Effect,
|
||
AllowPaidStars: req.AllowPaidStars,
|
||
SuggestedPost: req.SuggestedPost,
|
||
}
|
||
}
|
||
|
||
func mediaUploadErr(err error) error {
|
||
switch {
|
||
case errors.Is(err, domain.ErrFilePartsInvalid):
|
||
return filePartsInvalidErr()
|
||
case errors.Is(err, domain.ErrFileTooLarge):
|
||
return fileTooBigErr()
|
||
case errors.Is(err, domain.ErrPhotoInvalid):
|
||
return photoInvalidErr()
|
||
case errors.Is(err, domain.ErrDocumentInvalid):
|
||
return mediaInvalidErr()
|
||
case errors.Is(err, domain.ErrStorageFull):
|
||
return storageFullErr()
|
||
default:
|
||
return internalErr()
|
||
}
|
||
}
|
||
|
||
func userClassID(u tg.UserClass) int64 {
|
||
if v, ok := u.(*tg.User); ok {
|
||
return v.ID
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func chatClassID(c tg.ChatClass) int64 {
|
||
switch v := c.(type) {
|
||
case *tg.Channel:
|
||
return v.ID
|
||
case *tg.Chat:
|
||
return v.ID
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func mapValuesUsers(m map[int64]tg.UserClass) []tg.UserClass {
|
||
out := make([]tg.UserClass, 0, len(m))
|
||
for _, v := range m {
|
||
out = append(out, v)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func mapValuesChats(m map[int64]tg.ChatClass) []tg.ChatClass {
|
||
out := make([]tg.ChatClass, 0, len(m))
|
||
for _, v := range m {
|
||
out = append(out, v)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// channelPostAuthorName 取当前用户的展示名作为 broadcast post 签名快照;
|
||
// store 层只在 signatures 开启的 post 上落库。
|
||
func (r *Router) channelPostAuthorName(ctx context.Context, userID int64) string {
|
||
if r.deps.Users == nil || userID == 0 {
|
||
return ""
|
||
}
|
||
self, err := r.deps.Users.Self(ctx, userID)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
name := strings.TrimSpace(strings.TrimSpace(self.FirstName) + " " + strings.TrimSpace(self.LastName))
|
||
return name
|
||
}
|