owpengram-server/internal/rpc/messages_monoforum.go
2026-09-01 12:06:31 +03:00

493 lines
18 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"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
// resolveMonoforumForAdmin 解析 TDesktop messages.getSavedDialogs/getSavedHistory 的 parent_peer
// 并校验当前用户可管理母广播频道的 Direct Messages。TDesktop 的 SavedSublist 实际会把
// parentChat()->input() 作为 parent_peer根据客户端 materialize 路径,它既可能是 monoforum
// 虚拟频道,也可能是与之关联的母广播频道。因此这里把两种 wire peer 归一到同一个 monoforum
// 授权仍只认母频道的 creator / ManageDirectMessages绝不能把普通 admin 放进管理者视图。
//
// 返回 (monoforum频道, isMonoforum, err)parent 是有效但未关联 Direct Messages 的普通频道时
// 返回 (零, false, nil),由调用方保留良性空响应;关联频道的非管理者返回 CHAT_ADMIN_REQUIRED。
func (r *Router) resolveMonoforumForAdmin(ctx context.Context, userID int64, parent domain.Peer) (domain.Channel, bool, error) {
if r.deps.Channels == nil {
return domain.Channel{}, false, notImplementedErr()
}
if parent.Type != domain.PeerTypeChannel || parent.ID == 0 {
return domain.Channel{}, false, parentPeerInvalidErr()
}
mono, isAdmin, err := r.deps.Channels.ResolveMonoforumSend(ctx, userID, parent.ID)
if errors.Is(err, domain.ErrChannelInvalid) {
// TDesktop 当前的 Direct Messages subsection 会传母广播频道。只接受显式的
// linked_monoforum 关系,不能把任意普通频道猜成 monoforum。
views, viewErr := r.deps.Channels.GetChannels(ctx, userID, []int64{parent.ID})
if viewErr != nil {
return domain.Channel{}, false, internalErr()
}
if len(views) != 1 {
return domain.Channel{}, false, nil
}
parentChannel := views[0].Channel
if parentChannel.ID != parent.ID || parentChannel.Deleted || parentChannel.Monoforum || parentChannel.LinkedMonoforumID == 0 {
return domain.Channel{}, false, nil
}
mono, isAdmin, err = r.deps.Channels.ResolveMonoforumSend(ctx, userID, parentChannel.LinkedMonoforumID)
if err != nil {
// A visible parent that advertises linked_monoforum_id but cannot resolve
// that target violates the durable channel-link invariant. Do not disguise
// it as an ordinary channel probe.
return domain.Channel{}, false, internalErr()
}
}
if err != nil {
if errors.Is(err, domain.ErrChannelInvalid) {
return domain.Channel{}, false, nil
}
return domain.Channel{}, false, internalErr()
}
if !isAdmin {
// 是 monoforum,但调用者不是母频道管理员:读私信列表/历史仅限管理员。
return domain.Channel{}, false, tgerr400("CHAT_ADMIN_REQUIRED")
}
return mono, true, nil
}
// monoforumSavedDialogs 返回 monoforum 的订阅者子会话列表(管理员视角的私信列表)。mono 已解析+鉴权。
func (r *Router) monoforumSavedDialogs(ctx context.Context, userID int64, mono domain.Channel, limit, offsetID int) (tg.MessagesSavedDialogsClass, error) {
list, err := r.deps.Channels.ListMonoforumDialogs(ctx, domain.MonoforumDialogsFilter{
MonoforumID: mono.ID,
Limit: limit,
OffsetID: offsetID,
})
if err != nil {
return nil, internalErr()
}
dialogs := make([]tg.SavedDialogClass, 0, len(list.Dialogs))
for _, d := range list.Dialogs {
peer := tgPeer(d.SavedPeer)
if peer == nil {
continue
}
md := &tg.MonoForumDialog{
Peer: peer,
TopMessage: d.TopMessageID,
ReadInboxMaxID: d.ReadInboxMaxID,
ReadOutboxMaxID: d.ReadOutboxMaxID,
UnreadCount: d.UnreadCount,
}
dialogs = append(dialogs, md)
}
messages := make([]tg.MessageClass, 0, len(list.Messages))
for _, m := range list.Messages {
if item := tgChannelMessage(userID, m); item != nil {
messages = append(messages, item)
}
}
users, err := r.monoforumSubscriberUsersWithPeerCacheAndOverlaysStrict(ctx, userID, list.Dialogs, list.Messages, nil, nil)
if err != nil {
return nil, internalErr()
}
return &tg.MessagesSavedDialogs{
Dialogs: dialogs,
Messages: messages,
Chats: r.monoforumChats(ctx, userID, mono),
Users: users,
}, nil
}
// monoforumSavedHistory 返回某订阅者在 monoforum 内的私信历史。mono 已解析+鉴权。
func (r *Router) monoforumSavedHistory(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, limit, offsetID int) (tg.MessagesMessagesClass, error) {
if savedPeer.ID == 0 {
return nil, peerIDInvalidErr()
}
hist, err := r.deps.Channels.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{
MonoforumID: mono.ID,
SavedPeer: savedPeer,
Limit: limit,
OffsetID: offsetID,
})
if err != nil {
return nil, internalErr()
}
messages := make([]tg.MessageClass, 0, len(hist.Messages))
for _, m := range hist.Messages {
if item := tgChannelMessage(userID, m); item != nil {
messages = append(messages, item)
}
}
users, err := r.monoforumSubscriberUsersWithPeerCacheAndOverlaysStrict(ctx, userID, nil, hist.Messages, nil, nil)
if err != nil {
return nil, internalErr()
}
result := &tg.MessagesMessagesSlice{
Count: hist.Count,
Messages: messages,
Chats: r.monoforumChats(ctx, userID, mono),
Users: users,
}
r.applyPeerReadModelsToMessages(ctx, userID, result)
return result, nil
}
// monoforumChats 投影客户端 materialize monoforum 私信所需的频道:monoforum 自身直接投影,
// 再按 viewer 补母广播频道。订阅者没有 monoforum member row管理员身份也只来自母频道。
func (r *Router) monoforumChats(ctx context.Context, userID int64, mono domain.Channel) []tg.ChatClass {
chats := []tg.ChatClass{tgChannelChatForView(userID, domain.ChannelView{Channel: mono})}
if mono.LinkedMonoforumID != 0 && r.deps.Channels != nil {
if views, err := r.deps.Channels.GetChannels(ctx, userID, []int64{mono.LinkedMonoforumID}); err == nil {
for _, view := range views {
if view.Channel.ID != 0 {
chats = append(chats, tgChannelChatForView(userID, view))
}
}
}
}
return chats
}
type monoforumPeerOverlays struct {
usernames map[domain.Peer][]domain.Username
botProfiles map[int64]domain.BotProfile
botVerifications map[domain.Peer]domain.CustomVerification
}
func monoforumSubscriberUserIDs(dialogs []domain.MonoforumDialog, messages []domain.ChannelMessage) []int64 {
ids := make([]int64, 0, len(dialogs)+len(messages))
seen := map[int64]struct{}{}
add := func(id int64) {
if id == 0 {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
ids = append(ids, id)
}
for _, d := range dialogs {
if d.SavedPeer.Type == domain.PeerTypeUser {
add(d.SavedPeer.ID)
}
}
for _, m := range messages {
add(m.SenderUserID)
}
// tgChannelMessage can reference users beyond its sender (from/send_as,
// forward, via_bot, reply/quote mention, contact/poll/todo/action/reaction).
// Reuse the common channel-message closure so saved history, send echo and
// online fan-out all materialize the same complete Users envelope.
userIDs := make(map[int64]struct{})
channelIDs := make(map[int64]struct{})
for _, message := range messages {
collectChannelMessagePeerRefs(message, message.ChannelID, userIDs, channelIDs)
}
extra := peerIDMapKeys(userIDs)
sort.Slice(extra, func(i, j int) bool { return extra[i] < extra[j] })
for _, id := range extra {
add(id)
}
return ids
}
func monoforumProjectionPeers(monoforumID, parentID int64, userIDs []int64) []domain.Peer {
peers := make([]domain.Peer, 0, len(userIDs)+2)
seen := make(map[domain.Peer]struct{}, len(userIDs)+2)
add := func(peer domain.Peer) {
if peer.ID == 0 {
return
}
if _, ok := seen[peer]; ok {
return
}
seen[peer] = struct{}{}
peers = append(peers, peer)
}
for _, userID := range userIDs {
add(domain.Peer{Type: domain.PeerTypeUser, ID: userID})
}
add(domain.Peer{Type: domain.PeerTypeChannel, ID: monoforumID})
add(domain.Peer{Type: domain.PeerTypeChannel, ID: parentID})
return peers
}
// loadMonoforumPeerOverlays resolves peer-wide facts once before a fan-out. The
// returned snapshot is immutable for the lifetime of that job and can therefore
// be reused by every viewer builder without turning overlays into N+1 reads.
func (r *Router) loadMonoforumPeerOverlays(ctx context.Context, peers []domain.Peer) *monoforumPeerOverlays {
overlays := &monoforumPeerOverlays{
usernames: r.usernameRegistryMap(ctx, peers),
botVerifications: r.botVerificationMap(ctx, peers),
}
if r.deps.Bots == nil {
return overlays
}
userIDs := make([]int64, 0, len(peers))
for _, peer := range peers {
if peer.Type == domain.PeerTypeUser && peer.ID != 0 {
userIDs = append(userIDs, peer.ID)
}
}
if len(userIDs) == 0 {
return overlays
}
if batch, ok := r.deps.Bots.(botProfileBatchResolver); ok {
if profiles, err := batch.BotInfos(ctx, userIDs); err == nil {
overlays.botProfiles = profiles
return overlays
}
}
overlays.botProfiles = make(map[int64]domain.BotProfile)
for _, userID := range userIDs {
if profile, found, err := r.deps.Bots.BotInfo(ctx, userID); err == nil && found {
overlays.botProfiles[userID] = profile
}
}
return overlays
}
func applyMonoforumPeerOverlays(users []tg.UserClass, chats []tg.ChatClass, overlays *monoforumPeerOverlays) {
if overlays == nil {
return
}
applyUsernamesFromRegistry(users, chats, overlays.usernames)
for _, item := range users {
u, ok := item.(*tg.User)
if !ok || u == nil || u.Deleted {
continue
}
if u.Bot {
if profile, found := overlays.botProfiles[u.ID]; found {
applyBotProfileFlags(u, profile)
}
}
if mark, found := overlays.botVerifications[domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}]; found && mark.IconDocumentID > 0 {
u.SetBotVerificationIcon(mark.IconDocumentID)
}
}
for _, item := range chats {
ch, ok := item.(*tg.Channel)
if !ok || ch == nil {
continue
}
if mark, found := overlays.botVerifications[domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}]; found && mark.IconDocumentID > 0 {
ch.SetBotVerificationIcon(mark.IconDocumentID)
}
}
}
// monoforumSubscriberUsers projects subscriber users (saved_peer + message
// senders) for one viewer. Fan-out callers pass their preheated peer cache and
// overlay snapshot; single-viewer callers retain the same output shape through a
// one-shot local cache. The pure TL conversion is viewer-aware so self is never
// lost for a subscriber viewing their own envelope.
func (r *Router) monoforumSubscriberUsersWithPeerCacheAndOverlaysStrict(ctx context.Context, userID int64, dialogs []domain.MonoforumDialog, messages []domain.ChannelMessage, cache *viewerPeerCache, overlays *monoforumPeerOverlays) ([]tg.UserClass, error) {
ids := monoforumSubscriberUserIDs(dialogs, messages)
if len(ids) == 0 {
return []tg.UserClass{}, nil
}
if cache == nil {
cache = newViewerPeerCache(r)
}
projected, err := cache.usersForIDsStrict(ctx, userID, ids)
if err != nil {
return nil, err
}
if overlays == nil {
overlays = r.loadMonoforumPeerOverlays(ctx, monoforumProjectionPeers(0, 0, ids))
}
out := tgUsersForViewer(userID, projected)
applyMonoforumPeerOverlays(out, nil, overlays)
return out, nil
}
// monoforumReplyPresent 判断 sendMessage 的 reply_to 是否显式携带 monoforum_peer_id。
// 管理员回复必须带目标订阅者;普通订阅者按官方 TDesktop 行为不携带 reply_to目标由调用者推导。
func monoforumReplyPresent(input tg.InputReplyToClass) bool {
switch v := input.(type) {
case *tg.InputReplyToMonoForum:
return v != nil
case *tg.InputReplyToMessage:
if v == nil {
return false
}
_, ok := v.GetMonoforumPeerID()
return ok
default:
return false
}
}
// monoforumReplyTargetPeer 从 reply_to 的 monoforum_peer_id 解析订阅者子会话 peer。
func (r *Router) monoforumReplyTargetPeer(userID int64, input tg.InputReplyToClass) (domain.Peer, bool) {
var inputPeer tg.InputPeerClass
switch v := input.(type) {
case *tg.InputReplyToMonoForum:
if v != nil {
inputPeer = v.MonoforumPeerID
}
case *tg.InputReplyToMessage:
if v != nil {
if p, ok := v.GetMonoforumPeerID(); ok {
inputPeer = p
}
}
}
if inputPeer == nil {
return domain.Peer{}, false
}
return r.domainPeerFromInputPeer(userID, inputPeer)
}
func (r *Router) monoforumSavedPeerForSender(userID int64, isAdmin bool, replyTo tg.InputReplyToClass) (domain.Peer, error) {
savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
if monoforumReplyPresent(replyTo) {
var valid bool
savedPeer, valid = r.monoforumReplyTargetPeer(userID, replyTo)
if !valid || savedPeer.Type != domain.PeerTypeUser || savedPeer.ID == 0 {
return domain.Peer{}, replyToMonoforumPeerInvalidErr()
}
} else if isAdmin {
return domain.Peer{}, replyToMonoforumPeerInvalidErr()
}
if !isAdmin && savedPeer.ID != userID {
return domain.Peer{}, replyToMonoforumPeerInvalidErr()
}
return savedPeer, nil
}
// monoforumMessageReplyFromInput separates the sub-dialog selector from the actual message reply.
// InputReplyToMonoForum only selects a subscriber; InputReplyToMessage may carry both the selector
// and a real reply_to_msg_id, so clear flags.5 before reusing the common structural validator.
func (r *Router) monoforumMessageReplyFromInput(ctx context.Context, userID int64, peer domain.Peer, input tg.InputReplyToClass) (*domain.MessageReply, error) {
switch value := input.(type) {
case nil, *tg.InputReplyToMonoForum:
return nil, nil
case *tg.InputReplyToMessage:
if value == nil {
return nil, nil
}
clean := *value
clean.Flags.Unset(5)
clean.MonoforumPeerID = nil
return r.messageReplyFromInput(ctx, userID, peer, &clean)
default:
return r.messageReplyFromInput(ctx, userID, peer, input)
}
}
// sendMonoforumMessage 处理向频道私信(monoforum)发送:订阅者发到自己的子会话,管理员回复到目标订阅者。
// saved_peer 对订阅者由调用者推导、对管理员来自 reply_to;管理员可写任意订阅者子会话,订阅者只能写自己的。
func (r *Router) sendMonoforumMessage(ctx context.Context, userID int64, peer domain.Peer, mono domain.Channel, isAdmin bool, req domain.SendMonoforumMessageRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
if peer.Type != domain.PeerTypeChannel || peer.ID == 0 {
return nil, peerIDInvalidErr()
}
if mono.ID != peer.ID || !mono.Monoforum || req.SavedPeer.Type != domain.PeerTypeUser || req.SavedPeer.ID == 0 {
return nil, replyToMonoforumPeerInvalidErr()
}
if !isAdmin && req.SavedPeer.ID != userID {
// 普通订阅者只能写自己的子会话,不能写他人的。
return nil, replyToMonoforumPeerInvalidErr()
}
req.MonoforumID = mono.ID
req.SenderUserID = userID
if req.Date == 0 {
req.Date = int(r.clock.Now().Unix())
}
res, err := r.deps.Channels.SendMonoforumMessage(ctx, req)
if err != nil {
return nil, messageSendErr(err)
}
if req.ClearDraft {
r.clearDraftAfterSend(ctx, userID, peer, req.ReplyTo)
}
if !res.Duplicate {
r.enqueueMonoforumMessageFanout(ctx, userID, mono, req.SavedPeer, res)
}
return r.monoforumSendUpdatesStrict(ctx, userID, mono, req.SavedPeer, res)
}
// monoforumSendUpdates 给发送者构造回声 Updates:updateMessageID(关联 random_id)+ updateNewChannelMessage
// (monoforum 走 channel pts)。另一方经 monoforum 频道的 getChannelDifference 收取该 durable 事件。
func (r *Router) monoforumSendUpdatesStrict(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) (tg.UpdatesClass, error) {
return r.monoforumSendUpdatesWithPeerCacheAndOverlaysStrict(ctx, userID, mono, savedPeer, res, nil, nil)
}
func (r *Router) monoforumSendUpdatesWithPeerCacheAndOverlays(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult, cache *viewerPeerCache, overlays *monoforumPeerOverlays) tg.UpdatesClass {
updates, _ := r.monoforumSendUpdatesWithPeerCacheAndOverlaysStrict(ctx, userID, mono, savedPeer, res, cache, overlays)
return updates
}
func (r *Router) monoforumSendUpdatesWithPeerCacheAndOverlaysStrict(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult, cache *viewerPeerCache, overlays *monoforumPeerOverlays) (tg.UpdatesClass, error) {
updates := make([]tg.UpdateClass, 0, 3)
if res.Message.RandomID != 0 {
updates = append(updates, &tg.UpdateMessageID{ID: res.Message.ID, RandomID: res.Message.RandomID})
}
newMsg := &tg.UpdateNewChannelMessage{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount}
if channelMsg := tgChannelMessage(userID, res.Message); channelMsg != nil {
newMsg.Message = channelMsg
} else {
newMsg.Message = &tg.MessageEmpty{ID: res.Message.ID}
}
updates = append(updates, newMsg)
date := int(r.clock.Now().Unix())
if res.Duplicate && res.ReplayDeleteEvent != nil {
if deleted := tgChannelUpdate(userID, *res.ReplayDeleteEvent); deleted != nil {
updates = append(updates, deleted)
}
if res.ReplayDeleteEvent.Date > date {
date = res.ReplayDeleteEvent.Date
}
}
dialogs := []domain.MonoforumDialog{{SavedPeer: savedPeer}}
messages := []domain.ChannelMessage{res.Message}
if cache == nil {
cache = newViewerPeerCache(r)
}
if overlays == nil {
ids := monoforumSubscriberUserIDs(dialogs, messages)
overlays = r.loadMonoforumPeerOverlays(ctx, monoforumProjectionPeers(mono.ID, mono.LinkedMonoforumID, ids))
}
chats := r.monoforumChats(ctx, userID, mono)
users, err := r.monoforumSubscriberUsersWithPeerCacheAndOverlaysStrict(ctx, userID, dialogs, messages, cache, overlays)
if err != nil {
return nil, err
}
applyMonoforumPeerOverlays(nil, chats, overlays)
return &tg.Updates{
Updates: updates,
Chats: chats,
Users: users,
Date: date,
}, nil
}
func (r *Router) monoforumDeliveryUpdates(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) *tg.Updates {
return r.monoforumDeliveryUpdatesWithPeerCacheAndOverlays(ctx, userID, mono, savedPeer, res, nil, nil)
}
func (r *Router) monoforumDeliveryUpdatesWithPeerCacheAndOverlays(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult, cache *viewerPeerCache, overlays *monoforumPeerOverlays) *tg.Updates {
updates, _ := r.monoforumSendUpdatesWithPeerCacheAndOverlays(ctx, userID, mono, savedPeer, res, cache, overlays).(*tg.Updates)
if updates == nil {
return nil
}
filtered := make([]tg.UpdateClass, 0, len(updates.Updates))
for _, update := range updates.Updates {
if _, randomMapping := update.(*tg.UpdateMessageID); !randomMapping {
filtered = append(filtered, update)
}
}
updates.Updates = filtered
return updates
}