owpengram-server/internal/rpc/messages_send.go
Astra f33e25af8d admin: add account spam restriction (join/message gate)
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.
2026-09-16 14:03:15 +01:00

922 lines
27 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"
"sort"
"strings"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"telesrv/internal/domain"
)
func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSendMessageRequest) (tg.UpdatesClass, error) {
start := r.clock.Now()
var duplicate bool
var sendErr error
defer func() {
r.metrics().MessageSend(r.clock.Now().Sub(start), duplicate, sendErr)
}()
if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength {
sendErr = messageTooLongErr()
return nil, sendErr
}
if len(req.Entities) > maxMessageEntityCount {
sendErr = entitiesTooLongErr()
return nil, sendErr
}
if req.RandomID == 0 {
sendErr = randomIDEmptyErr()
return nil, sendErr
}
if req.QuickReplyShortcut != nil {
updates, err := r.onMessagesSaveQuickReplyText(ctx, req)
if err != nil {
sendErr = err
return nil, err
}
return updates, nil
}
if req.ScheduleRepeatPeriod != 0 {
sendErr = scheduleDateInvalidErr()
return nil, sendErr
}
if err := sendMessageUnsupportedOptionErr(req); err != nil {
sendErr = err
return nil, sendErr
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
sendErr = internalErr()
return nil, sendErr
}
if userID == 0 {
sendErr = peerIDInvalidErr()
return nil, sendErr
}
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer)
if !ok || peer.ID == 0 {
sendErr = peerIDInvalidErr()
return nil, sendErr
}
if peer.Type == domain.PeerTypeUser {
if err := r.ensureNotRestrictedFromMessaging(ctx, userID, peer.ID); err != nil {
sendErr = err
return nil, sendErr
}
}
idempotencyFingerprint, err := sendMessageIdempotencyFingerprint(req)
if err != nil {
sendErr = internalErr()
return nil, sendErr
}
// 自动实体必须在普通/频道/monoforum 分流前完成,保证所有文本写路径持久化同一份
// 服务端补全结果。指纹仍基于原始请求,派生实体不改变 random_id 幂等语义。
entities := req.Entities
if req.RichMessage == nil {
entities = r.augmentAutoEntities(req.Message, entities)
}
suggestedInput, hasSuggestedPost := req.GetSuggestedPost()
// monoforum 普通用户发送不带 reply_tosaved_peer 必须由服务端推导为自己;管理员回复才必须
// 显式携带 monoforum_peer_id。仅凭 reply_to 判路由会把用户请求误送进普通 megagroup 路径。
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):
sendErr = internalErr()
return nil, sendErr
}
}
if hasSuggestedPost && !monoforum {
sendErr = suggestedPostPeerInvalidErr()
return nil, sendErr
}
if monoforum {
suggestedPost, suggestedErr := domainSuggestedPost(suggestedInput, hasSuggestedPost)
if suggestedErr != nil {
sendErr = suggestedErr
return nil, sendErr
}
savedPeer, err := r.monoforumSavedPeerForSender(userID, monoforumAdmin, req.ReplyTo)
if err != nil {
sendErr = err
return nil, sendErr
}
replyTo, err := r.monoforumMessageReplyFromInput(ctx, userID, peer, req.ReplyTo)
if err != nil {
sendErr = err
return nil, sendErr
}
replay, err := r.lookupChannelSendReplay(ctx, userID, peer.ID, savedPeer, req.RandomID, idempotencyFingerprint)
if err != nil {
sendErr = err
return nil, err
}
if replay.found {
duplicate = true
if req.ClearDraft {
r.clearDraftAfterSend(ctx, userID, peer, replyTo)
}
updates, projectionErr := r.monoforumSendUpdatesStrict(ctx, userID, replay.channel.Channel, savedPeer, replay.channel)
if projectionErr != nil {
sendErr = projectionErr
return nil, projectionErr
}
return updates, nil
}
if err := r.checkSendRateLimit(ctx, userID, 1); err != nil {
sendErr = err
return nil, sendErr
}
checkedPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
sendErr = err
return nil, sendErr
}
updates, err := 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),
ReplyTo: replyTo,
Silent: req.Silent,
NoForwards: req.Noforwards,
SuggestedPost: suggestedPost,
AllowPaidStars: req.AllowPaidStars,
ClearDraft: req.ClearDraft,
})
if err != nil {
sendErr = err
return nil, sendErr
}
return updates, nil
}
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint)
if err != nil {
sendErr = err
return nil, err
}
if replay.found {
duplicate = true
return r.outgoingReplayUpdates(ctx, userID, peer, req.RandomID, replay), nil
}
// Mutable catalog state, rate accounting and access checks are intentionally after exact
// replay lookup: a committed send remains acknowledgeable after those states change.
if r.messageEffectInvalid(ctx, req.Effect) {
sendErr = effectIDInvalidErr()
return nil, sendErr
}
if err := r.checkSendRateLimit(ctx, userID, 1); err != nil {
sendErr = err
return nil, sendErr
}
peer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
sendErr = err
return nil, sendErr
}
// reply_markupbot 可发送 inline keyboard 与普通 reply keyboard/hide/force
// 非 bot 静默丢弃。仅请求携带 markup 时查询 is_bot。
// 仅在请求携带 markup 时才查 is_bot避免普通发送多打一次查询。
var replyMarkup *domain.MessageReplyMarkup
if req.ReplyMarkup != nil {
replyMarkup, err = domainOutgoingReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID))
if err != nil {
sendErr = replyMarkupErr(err)
return nil, sendErr
}
if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil {
sendErr = err
return nil, sendErr
}
}
// rich_messageLayer 228 富文本blocks、HTML、Markdown 均在边界归一为
// PageBlock + 内嵌媒体快照;普通消息恒 nil。
var richMessage *domain.MessageRichMessage
if req.RichMessage != nil {
richMessage, err = r.domainRichMessageFromInput(ctx, req.RichMessage)
if err != nil {
sendErr = err
return nil, sendErr
}
}
if req.Message != "" && richMessage != nil {
sendErr = mediaInvalidErr()
return nil, sendErr
}
if req.Message == "" && richMessage == nil {
sendErr = messageEmptyErr()
return nil, sendErr
}
// 链接预览:纯文本消息(私聊或频道)含可预览 URL 且未抑制时,挂 pending 占位,异步解析回填。
// 富文本消息有独立媒体语义,不叠加。
var previewMedia *domain.MessageMedia
if richMessage == nil {
previewMedia = r.webPageMediaFromText(ctx, req.Message, entities, req.NoWebpage, req.InvertMedia)
}
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
updates, err := r.scheduleOutgoing(ctx, userID, peer, outgoingSend{
randomID: req.RandomID,
idempotencyFingerprint: idempotencyFingerprint,
idempotencyPreflighted: replay.checked,
message: req.Message,
entities: entities,
media: previewMedia,
silent: req.Silent,
noforwards: req.Noforwards,
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
richMessage: richMessage,
allowPaidStars: req.AllowPaidStars,
}, req.ScheduleDate, req.ScheduleRepeatPeriod)
if err != nil {
sendErr = err
return nil, err
}
return updates, nil
}
updates, dup, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{
randomID: req.RandomID,
idempotencyFingerprint: idempotencyFingerprint,
idempotencyPreflighted: replay.checked,
message: req.Message,
entities: entities,
media: previewMedia,
silent: req.Silent,
noforwards: req.Noforwards,
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
replyMarkup: replyMarkup,
richMessage: richMessage,
effect: req.Effect,
allowPaidStars: req.AllowPaidStars,
})
duplicate = dup
if err != nil {
sendErr = err
return nil, sendErr
}
return updates, nil
}
func messageSendErr(err error) error {
switch {
case errors.Is(err, domain.ErrUserFrozen):
return frozenMethodInvalidErr()
case errors.Is(err, domain.ErrReplyMessageIDInvalid):
return replyMessageIDInvalidErr()
case errors.Is(err, domain.ErrMessageRandomIDDuplicate):
return randomIDDuplicateErr()
case errors.Is(err, domain.ErrMessageEmpty):
return messageEmptyErr()
case errors.Is(err, domain.ErrChatForwardsRestricted):
return chatForwardsRestrictedErr()
case errors.Is(err, domain.ErrQuoteTextInvalid):
return quoteTextInvalidErr()
default:
return internalErr()
}
}
func (r *Router) peerBlocksUser(ctx context.Context, userID, peerUserID int64) (bool, error) {
if userID == 0 || peerUserID == 0 || userID == peerUserID || r.deps.Contacts == nil {
return false, nil
}
blocked, err := r.deps.Contacts.IsBlocked(ctx, peerUserID, userID)
if err != nil {
return false, internalErr()
}
return blocked, nil
}
func (r *Router) messageReplyFromInput(ctx context.Context, userID int64, peer domain.Peer, input tg.InputReplyToClass) (*domain.MessageReply, error) {
if input == nil {
return nil, nil
}
reply, ok := input.(*tg.InputReplyToMessage)
if !ok {
switch st := input.(type) {
case *tg.InputReplyToStory:
// story 回复(评论):客户端发一条带 reply_to=inputReplyToStory 的私聊消息。
// 只支持回复会话对端story 作者)的 story投影为 messageReplyStoryHeader。
if st.StoryID <= 0 || st.StoryID > domain.MaxStoryID {
return nil, storyIDInvalidErr()
}
storyOwner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, st.Peer)
if err != nil {
return nil, err
}
if storyOwner != peer {
return nil, storyIDInvalidErr()
}
return &domain.MessageReply{Peer: storyOwner, StoryID: st.StoryID}, nil
case *tg.InputReplyToMonoForum:
return nil, replyToMonoforumPeerInvalidErr()
default:
return nil, inputConstructorInvalidErr()
}
}
if reply.Zero() {
return nil, nil
}
if _, ok := reply.GetMonoforumPeerID(); ok {
return nil, replyToMonoforumPeerInvalidErr()
}
if _, ok := reply.GetTodoItemID(); ok {
return nil, replyMessageIDInvalidErr()
}
if _, ok := reply.GetPollOption(); ok {
return nil, pollOptionInvalidErr()
}
replyPeer := peer
if inputPeer, ok := reply.GetReplyToPeerID(); ok {
parsed, err := r.checkedMessageReadPeer(ctx, userID, inputPeer, false)
if err != nil {
if tgerr.Is(err, "INTERNAL_SERVER_ERROR") {
return nil, err
}
return nil, replyMessageIDInvalidErr()
}
replyPeer = parsed
}
topMsgID, ok := reply.GetTopMsgID()
if ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) {
return nil, replyMessageIDInvalidErr()
}
if reply.ReplyToMsgID < 0 || reply.ReplyToMsgID > domain.MaxMessageBoxID {
return nil, replyMessageIDInvalidErr()
}
if reply.ReplyToMsgID == 0 && topMsgID == 0 {
return nil, replyMessageIDInvalidErr()
}
// inputReplyToMessage.reply_to_peer_id is explicitly allowed to point to a
// different dialog. Private-source existence is checked transactionally by
// MessageStore; channel sources are validated here because they live in the
// channel store rather than message_boxes.
if replyPeer.Type == domain.PeerTypeChannel && reply.ReplyToMsgID > 0 {
if r.deps.Channels == nil {
return nil, replyMessageIDInvalidErr()
}
history, err := r.deps.Channels.GetMessages(ctx, userID, replyPeer.ID, []int{reply.ReplyToMsgID})
if err != nil || len(history.Messages) != 1 || history.Messages[0].ID != reply.ReplyToMsgID {
return nil, replyMessageIDInvalidErr()
}
}
quoteText, _ := reply.GetQuoteText()
if utf8.RuneCountInString(quoteText) > maxReplyQuoteLength {
return nil, limitInvalidErr()
}
quoteEntities, _ := reply.GetQuoteEntities()
if len(quoteEntities) > maxMessageEntityCount {
return nil, limitInvalidErr()
}
quoteOffset, ok := reply.GetQuoteOffset()
if ok && (quoteOffset < 0 || quoteOffset > domain.MaxMessageReplyQuoteOffset) {
return nil, replyMessageIDInvalidErr()
}
return &domain.MessageReply{
MessageID: reply.ReplyToMsgID,
Peer: replyPeer,
TopMessageID: topMsgID,
QuoteText: quoteText,
QuoteEntities: domainMessageEntities(quoteEntities),
QuoteOffset: quoteOffset,
}, nil
}
func sendMessageUnsupportedOptionErr(req *tg.MessagesSendMessageRequest) error {
switch {
// reply_markup 不再一律拒绝bot inline keyboard 在 sendOutgoing 前单独解析+校验
// (非 bot 静默丢弃I1
case req.QuickReplyShortcut != nil:
return shortcutInvalidErr()
// req.Effect 不再一律拒绝:消息特效已实现,合法性在 messageEffectInvalid 单独校验。
case req.AllowPaidStars < 0:
return starsAmountInvalidErr()
case req.AllowPaidFloodskip:
return paymentUnsupportedErr()
default:
return nil
}
}
// messageEffectInvalid 校验消息特效 id0无特效恒合法非零必须命中 getAvailableEffects
// 目录(客户端只会从该目录选取 id否则视为非法 → EFFECT_ID_INVALID。effects 目录常驻
// 内存seed 时构建校验为内存线性扫描不查库effect==0 的常规发送零额外成本。
func (r *Router) messageEffectInvalid(ctx context.Context, effect int64) bool {
if effect == 0 {
return false
}
if r.deps.Files == nil {
return true
}
effects, _, err := r.deps.Files.AvailableEffects(ctx)
if err != nil {
return true
}
for _, e := range effects {
if e.ID == effect {
return false
}
}
return true
}
func (r *Router) mentionedUserIDsFromMessage(ctx context.Context, currentUserID int64, message string, entities []tg.MessageEntityClass) ([]int64, error) {
if r.deps.Users == nil {
return nil, nil
}
identity, _ := r.deps.Users.(UserIdentityService)
seen := make(map[int64]struct{})
out := make([]int64, 0)
add := func(id int64) {
if id == 0 {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
out = append(out, id)
}
for _, entity := range entities {
input, ok := entity.(*tg.InputMessageEntityMentionName)
if !ok || input.UserID == nil {
continue
}
user, found, err := r.userFromInput(ctx, currentUserID, input.UserID)
if err != nil {
return nil, internalErr()
}
if found {
add(user.ID)
}
if len(out) >= domain.MaxChannelMentionRecipients {
return out, nil
}
}
if identity != nil {
blocked := mentionScanBlockedSpansFromTGEntities(message, entities)
for _, username := range extractMentionUsernames(message, domain.MaxChannelMentionRecipients-len(out), blocked) {
user, found, err := identity.ResolveUsername(ctx, currentUserID, username)
if err != nil {
if isMentionResolveMiss(err) {
continue
}
return nil, internalErr()
}
if found {
add(user.ID)
}
if len(out) >= domain.MaxChannelMentionRecipients {
return out, nil
}
}
}
return out, nil
}
func isMentionResolveMiss(err error) bool {
return errors.Is(err, domain.ErrUsernameInvalid) || errors.Is(err, domain.ErrUsernameNotOccupied)
}
func extractMentionUsernames(message string, limit int, blocked []byteSpan) []string {
if limit <= 0 || message == "" {
return nil
}
blocked = mergeByteSpans(append(blocked, rawURLByteSpans(message)...))
blockIndex := 0
seen := make(map[string]struct{})
out := make([]string, 0)
for i := 0; i < len(message); i++ {
if message[i] != '@' {
continue
}
for blockIndex < len(blocked) && blocked[blockIndex].end <= i {
blockIndex++
}
if blockIndex < len(blocked) && blocked[blockIndex].start <= i && i < blocked[blockIndex].end {
i = blocked[blockIndex].end - 1
continue
}
if i > 0 && isUsernameByte(message[i-1]) {
continue
}
j := i + 1
for j < len(message) && isUsernameByte(message[j]) {
j++
}
if j == i+1 {
continue
}
username := strings.ToLower(message[i+1 : j])
if _, ok := seen[username]; ok {
continue
}
seen[username] = struct{}{}
out = append(out, username)
if len(out) == limit {
return out
}
i = j - 1
}
return out
}
func mergeByteSpans(spans []byteSpan) []byteSpan {
if len(spans) == 0 {
return nil
}
out := spans[:0]
for _, span := range spans {
if span.start < 0 || span.end <= span.start {
continue
}
inserted := false
for i := range out {
if span.start < out[i].start {
out = append(out, byteSpan{})
copy(out[i+1:], out[i:])
out[i] = span
inserted = true
break
}
}
if !inserted {
out = append(out, span)
}
}
if len(out) == 0 {
return nil
}
merged := out[:1]
for _, span := range out[1:] {
last := &merged[len(merged)-1]
if span.start <= last.end {
if span.end > last.end {
last.end = span.end
}
continue
}
merged = append(merged, span)
}
return merged
}
func mentionScanBlockedSpansFromTGEntities(message string, entities []tg.MessageEntityClass) []byteSpan {
if len(entities) == 0 {
return nil
}
bounds := utf16ByteBoundaries(message)
var out []byteSpan
for _, entity := range entities {
switch entity.(type) {
case *tg.MessageEntityURL, *tg.MessageEntityTextURL:
default:
continue
}
if span, ok := byteSpanFromUTF16Bounds(bounds, entity.GetOffset(), entity.GetLength()); ok {
out = append(out, span)
}
}
return out
}
func mentionScanBlockedSpansFromDomainEntities(message string, entities []domain.MessageEntity) []byteSpan {
if len(entities) == 0 {
return nil
}
bounds := utf16ByteBoundaries(message)
var out []byteSpan
for _, entity := range entities {
if entity.Type != domain.MessageEntityURL && entity.Type != domain.MessageEntityTextURL {
continue
}
if span, ok := byteSpanFromUTF16Bounds(bounds, entity.Offset, entity.Length); ok {
out = append(out, span)
}
}
return out
}
func utf16ByteBoundaries(message string) []int {
total := utf16CodeUnitLen(message)
bounds := make([]int, total+1)
for i := range bounds {
bounds[i] = -1
}
unit := 0
bounds[0] = 0
for i, r := range message {
bounds[unit] = i
if r <= 0xFFFF {
unit++
} else {
unit += 2
}
bounds[unit] = i + utf8.RuneLen(r)
}
return bounds
}
func byteSpanFromUTF16Bounds(bounds []int, offset, length int) (byteSpan, bool) {
end := offset + length
if offset < 0 || length <= 0 || end > len(bounds)-1 || bounds[offset] < 0 || bounds[end] < 0 {
return byteSpan{}, false
}
return byteSpan{start: bounds[offset], end: bounds[end]}, true
}
func isUsernameByte(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
}
func tgPrivateMessageUpdates(event domain.UpdateEvent, msg domain.Message, randomID int64, includeMessageID bool, users []tg.UserClass, chats []tg.ChatClass) *tg.Updates {
updates := make([]tg.UpdateClass, 0, 2)
if includeMessageID {
updates = append(updates, &tg.UpdateMessageID{ID: msg.ID, RandomID: randomID})
}
item := tgMessage(msg)
if item == nil {
item = &tg.MessageEmpty{ID: msg.ID}
}
updates = append(updates, &tg.UpdateNewMessage{
Message: item,
Pts: event.Pts,
PtsCount: event.PtsCount,
})
date := event.Date
if date == 0 {
date = msg.Date
}
return &tg.Updates{
Updates: updates,
Users: users,
Chats: chats,
Date: date,
Seq: 0, // 私聊不维护账号级 seq恒 0客户端仅靠 pts 同步)
}
}
// tgPrivateSendResultUpdates returns a complete send acknowledgement for exact
// random_id replays. DrKLO requires UpdateNewMessage in an Updates response to
// transition its local pending message to SENT. Visible edited messages use the
// current snapshot; deleted messages use the immutable first snapshot followed
// by the already-durable delete event, so the acknowledgement cannot become a
// permanent resurrection. No replay allocates pts or emits fan-out.
func tgPrivateSendResultUpdates(res domain.SendPrivateTextResult, randomID int64, includeMessageIDForNew bool, users []tg.UserClass, chats []tg.ChatClass) *tg.Updates {
if !res.Duplicate {
return tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, randomID, includeMessageIDForNew, users, chats)
}
if randomID == 0 {
randomID = res.SenderMessage.RandomID
}
out := tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, randomID, randomID != 0, users, chats)
if event := res.ReplayDeleteEvent; event != nil && event.Pts > 0 && len(event.MessageIDs) > 0 {
out.Updates = append(out.Updates, &tg.UpdateDeleteMessages{
Messages: append([]int(nil), event.MessageIDs...),
Pts: event.Pts,
PtsCount: event.PtsCount,
})
if event.Date > out.Date {
out.Date = event.Date
}
}
return out
}
func (r *Router) usersForMessageUpdate(ctx context.Context, ownerUserID int64, msg domain.Message) []tg.UserClass {
return r.usersForMessageUpdates(ctx, ownerUserID, []domain.Message{msg})
}
func (r *Router) usersForMessageUpdateWithPreloaded(ctx context.Context, ownerUserID int64, msg domain.Message, preloaded []domain.User) []tg.UserClass {
return r.usersForMessageUpdatesWithPreloaded(ctx, ownerUserID, []domain.Message{msg}, preloaded)
}
func (r *Router) usersForMessageUpdates(ctx context.Context, ownerUserID int64, messages []domain.Message) []tg.UserClass {
return r.usersForMessageUpdatesWithPreloaded(ctx, ownerUserID, messages, nil)
}
func (r *Router) usersForMessageUpdatesWithPreloaded(ctx context.Context, ownerUserID int64, messages []domain.Message, preloaded []domain.User) []tg.UserClass {
seen := make(map[int64]struct{}, len(messages)*2)
ids := make([]int64, 0, len(messages)*2)
addID := func(id int64) {
if id == 0 {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
ids = append(ids, id)
}
for _, msg := range messages {
for _, id := range appendMessageUserIDs(nil, make(map[int64]struct{}), msg) {
addID(id)
}
}
if len(ids) == 0 {
return nil
}
loaded := make(map[int64]domain.User, len(ids))
for _, user := range preloaded {
if user.ID != 0 {
loaded[user.ID] = user
}
}
missing := make([]int64, 0, len(ids))
for _, id := range ids {
if isSystemUserID(id) {
continue
}
if _, ok := loaded[id]; !ok {
missing = append(missing, id)
}
}
if r.deps.Users != nil && len(missing) > 0 {
if users, err := r.deps.Users.ByIDs(ctx, ownerUserID, missing); err == nil {
for _, user := range users {
loaded[user.ID] = user
}
}
}
users := make([]tg.UserClass, 0, len(ids))
for _, id := range ids {
switch {
case isSystemUserID(id):
if u, ok := domain.SystemUserByID(id); ok {
users = append(users, r.tgUser(u))
}
case id == ownerUserID:
if user, ok := loaded[id]; ok {
users = append(users, r.tgSelfUser(user))
}
default:
if user, ok := loaded[id]; ok {
users = append(users, r.tgUser(user))
}
}
}
r.applyUsernamesToPeerObjects(ctx, users, nil)
return users
}
func (r *Router) chatsForMessageUpdate(ctx context.Context, ownerUserID int64, msg domain.Message) []tg.ChatClass {
return r.chatsForMessageUpdates(ctx, ownerUserID, []domain.Message{msg})
}
func appendMessageUserIDs(ids []int64, seen map[int64]struct{}, msg domain.Message) []int64 {
add := func(id int64) {
if id == 0 {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
ids = append(ids, id)
}
for _, peer := range []domain.Peer{msg.From, msg.Peer} {
if peer.Type == domain.PeerTypeUser {
add(peer.ID)
}
}
if msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeUser {
add(msg.Forward.From.ID)
}
add(msg.ViaBotID)
if msg.ReplyTo != nil && msg.ReplyTo.Peer.Type == domain.PeerTypeUser {
add(msg.ReplyTo.Peer.ID)
}
if msg.Media != nil && msg.Media.Contact != nil {
add(msg.Media.Contact.UserID)
}
userRefs := make(map[int64]struct{})
channelRefs := make(map[int64]struct{})
collectMessagePeerRefs(msg, 0, userRefs, channelRefs)
extra := make([]int64, 0, len(userRefs))
for id := range userRefs {
extra = append(extra, id)
}
sort.Slice(extra, func(i, j int) bool { return extra[i] < extra[j] })
for _, id := range extra {
add(id)
}
return ids
}
func appendMessageChannelIDs(ids []int64, seen map[int64]struct{}, msg domain.Message) []int64 {
add := func(id int64) {
if id == 0 {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
ids = append(ids, id)
}
for _, peer := range []domain.Peer{msg.From, msg.Peer} {
if peer.Type == domain.PeerTypeChannel {
add(peer.ID)
}
}
if msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeChannel {
add(msg.Forward.From.ID)
}
if msg.ReplyTo != nil && msg.ReplyTo.Peer.Type == domain.PeerTypeChannel {
add(msg.ReplyTo.Peer.ID)
}
userRefs := make(map[int64]struct{})
channelRefs := make(map[int64]struct{})
collectMessagePeerRefs(msg, 0, userRefs, channelRefs)
extra := make([]int64, 0, len(channelRefs))
for id := range channelRefs {
extra = append(extra, id)
}
sort.Slice(extra, func(i, j int) bool { return extra[i] < extra[j] })
for _, id := range extra {
add(id)
}
return ids
}
func (r *Router) chatsForMessageUpdates(ctx context.Context, ownerUserID int64, messages []domain.Message) []tg.ChatClass {
if r.deps.Channels == nil || len(messages) == 0 {
return nil
}
seen := make(map[int64]struct{}, len(messages)*2)
ids := make([]int64, 0, len(messages))
for _, msg := range messages {
ids = appendMessageChannelIDs(ids, seen, msg)
}
if len(ids) == 0 {
return nil
}
views, err := r.deps.Channels.GetChannels(ctx, ownerUserID, ids)
if err != nil {
return nil
}
byID := make(map[int64]domain.ChannelView, len(views))
for _, view := range views {
if view.Channel.ID != 0 {
byID[view.Channel.ID] = view
}
}
chats := make([]tg.ChatClass, 0, len(ids))
for _, id := range ids {
if view, ok := byID[id]; ok {
chats = append(chats, tgChannelChatForView(ownerUserID, view))
}
}
return chats
}
// mentionUserIDsFromDomain 从 domain 实体与文本解析 @ 目标(转发/重放路径,
// mentionName 实体已携带解析好的 user_id
func (r *Router) mentionUserIDsFromDomain(ctx context.Context, currentUserID int64, message string, entities []domain.MessageEntity) []int64 {
seen := make(map[int64]struct{})
out := make([]int64, 0)
add := func(id int64) {
if id == 0 || len(out) >= domain.MaxChannelMentionRecipients {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
out = append(out, id)
}
for _, entity := range entities {
if entity.Type == domain.MessageEntityMentionName {
add(entity.UserID)
}
}
if identity, ok := r.deps.Users.(UserIdentityService); ok && identity != nil {
blocked := mentionScanBlockedSpansFromDomainEntities(message, entities)
for _, username := range extractMentionUsernames(message, domain.MaxChannelMentionRecipients-len(out), blocked) {
user, found, err := identity.ResolveUsername(ctx, currentUserID, username)
if err != nil {
if isMentionResolveMiss(err) {
continue
}
break
}
if found {
add(user.ID)
}
}
}
return out
}