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

875 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package rpc
import (
"context"
"errors"
"strings"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestMonoforumSendUpdatesIncludesCompleteMessageUserEnvelope(t *testing.T) {
const (
viewerID = int64(1000000201)
savedPeerID = int64(1000000202)
viaBotID = int64(1000000203)
replyPeerID = int64(1000000204)
quoteUserID = int64(1000000205)
monoforumID = int64(1000000291)
messageID = 41
messagePts = 51
)
users := map[int64]domain.User{}
for _, id := range []int64{viewerID, savedPeerID, viaBotID, replyPeerID, quoteUserID} {
users[id] = domain.User{ID: id, FirstName: "projected"}
}
router := New(Config{}, Deps{Users: mapUsersService{users: users}}, zaptest.NewLogger(t), clock.System)
savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: savedPeerID}
message := domain.ChannelMessage{
ChannelID: monoforumID, ID: messageID, SenderUserID: viewerID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: viewerID}, SavedPeer: savedPeer,
ViaBotID: viaBotID, Date: 1700000600,
ReplyTo: &domain.MessageReply{
MessageID: 40,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: replyPeerID},
QuoteEntities: []domain.MessageEntity{{
Type: domain.MessageEntityMentionName, UserID: quoteUserID,
}},
},
}
result := domain.SendChannelMessageResult{
Channel: domain.Channel{ID: monoforumID, Monoforum: true},
Message: message,
Event: domain.ChannelUpdateEvent{
ChannelID: monoforumID, Type: domain.ChannelUpdateNewMessage,
Pts: messagePts, PtsCount: 1, Message: message,
},
}
updatesClass, err := router.monoforumSendUpdatesStrict(context.Background(), viewerID, result.Channel, savedPeer, result)
if err != nil {
t.Fatalf("monoforumSendUpdatesStrict: %v", err)
}
updates, ok := updatesClass.(*tg.Updates)
if !ok || updates == nil {
t.Fatalf("monoforumSendUpdates = %T, want *tg.Updates", updates)
}
got := make(map[int64]bool, len(updates.Users))
for _, item := range updates.Users {
if user, ok := item.(*tg.User); ok {
got[user.ID] = true
}
}
for _, id := range []int64{viewerID, savedPeerID, viaBotID, replyPeerID, quoteUserID} {
if !got[id] {
t.Fatalf("Users = %+v, missing referenced user %d", got, id)
}
}
}
func TestMonoforumSendUpdatesFailsClosedOnIncompleteUserEnvelope(t *testing.T) {
const (
viewerID = int64(1000000301)
savedPeerID = int64(1000000302)
missingBot = int64(1000000303)
monoforumID = int64(1000000391)
)
router := New(Config{}, Deps{Users: mapUsersService{users: map[int64]domain.User{
viewerID: {ID: viewerID, FirstName: "viewer"},
savedPeerID: {ID: savedPeerID, FirstName: "saved peer"},
}}}, zaptest.NewLogger(t), clock.System)
savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: savedPeerID}
message := domain.ChannelMessage{
ChannelID: monoforumID, ID: 1, SenderUserID: viewerID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: viewerID}, SavedPeer: savedPeer,
ViaBotID: missingBot, Date: 1700000700,
}
result := domain.SendChannelMessageResult{
Channel: domain.Channel{ID: monoforumID, Monoforum: true}, Message: message,
Event: domain.ChannelUpdateEvent{ChannelID: monoforumID, Type: domain.ChannelUpdateNewMessage, Pts: 2, PtsCount: 1, Message: message},
}
updates, err := router.monoforumSendUpdatesStrict(context.Background(), viewerID, result.Channel, savedPeer, result)
if !errors.Is(err, ErrDurableUserProjectionIncomplete) || updates != nil {
t.Fatalf("monoforum strict envelope = %T, %v; want nil ErrDurableUserProjectionIncomplete", updates, err)
}
}
// TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC管理员经
// getSavedDialogs/getSavedHistory 看订阅者子会话parent_peer 同时兼容 TDesktop 实际发送的
// 母广播频道和虚拟 monoforum订阅者经普通 getHistory 只看自己的子会话。
func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
sub, err := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550002002", FirstName: "Sub"})
if err != nil {
t.Fatalf("create sub: %v", err)
}
channelStore := memory.NewChannelStore()
channelSvc := appchannels.NewService(channelStore)
verify := newFakeBotVerifications()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelSvc,
BotVerifications: verify,
}, zaptest.NewLogger(t), clock.System)
created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "DM Broadcast", Broadcast: true, Date: 1000})
if err != nil {
t.Fatalf("create channel: %v", err)
}
enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
if err != nil {
t.Fatalf("enable DM: %v", err)
}
monoID := enabled.Channel.LinkedMonoforumID
if monoID == 0 {
t.Fatalf("no monoforum created")
}
subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID}
if _, err := channelStore.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 1, Message: "hello channel", Date: 1100,
}); err != nil {
t.Fatalf("seed monoforum message: %v", err)
}
mono, err := channelStore.GetChannelByID(ctx, monoID)
if err != nil {
t.Fatalf("get monoforum: %v", err)
}
monoInput := &tg.InputPeerChannel{ChannelID: monoID, AccessHash: mono.AccessHash}
parentInput := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
const monoforumIcon = int64(8800025)
monoforumPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}
verify.marks[monoforumPeer] = domain.CustomVerification{
VerifierBotID: 777000123,
Peer: monoforumPeer,
IconDocumentID: monoforumIcon,
Description: "Verified monoforum peer",
}
// TDesktop 点 Direct Messages 入口会先按 monoforum peer 拉普通 channel history。
// 主历史只应返回 monoforum 自身的 service messages,不能混入 saved_peer 子会话消息。
var raw bin.Buffer
if err := (&tg.MessagesGetHistoryRequest{Peer: monoInput, Limit: 20}).Encode(&raw); err != nil {
t.Fatalf("encode getHistory(monoforum): %v", err)
}
mainEnc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &raw)
if err != nil {
t.Fatalf("dispatch getHistory(monoforum): %v", err)
}
mainHistory, ok := mainEnc.(*tg.MessagesChannelMessages)
if !ok {
t.Fatalf("getHistory(monoforum) = %T, want *tg.MessagesChannelMessages", mainEnc)
}
if len(mainHistory.Messages) != 1 {
t.Fatalf("main monoforum history = %d msgs, want only the creation service", len(mainHistory.Messages))
}
assertMessagesEnvelopeBotVerificationIcon(t, mainHistory, monoforumPeer, monoforumIcon)
service, ok := mainHistory.Messages[0].(*tg.MessageService)
if !ok {
t.Fatalf("main monoforum message = %T, want MessageService", mainHistory.Messages[0])
}
// monoforum 的首条服务消息是创建消息;TDesktop 对 monoforum 渲染为 "Direct messages were
// enabled in this channel."(lng_action_created_monoforum)。paid_messages_price 只进母广播频道。
if _, ok := service.Action.(*tg.MessageActionChannelCreate); !ok {
t.Fatalf("main monoforum action = %T %+v, want MessageActionChannelCreate", service.Action, service.Action)
}
seenChats := map[int64]bool{}
for _, chat := range mainHistory.Chats {
if ch, ok := chat.(*tg.Channel); ok {
seenChats[ch.ID] = true
}
}
if !seenChats[monoID] || !seenChats[created.Channel.ID] {
t.Fatalf("main monoforum chats = %+v, want monoforum %d and parent %d", seenChats, monoID, created.Channel.ID)
}
var subscriberRaw bin.Buffer
if err := (&tg.MessagesGetHistoryRequest{Peer: monoInput, Limit: 20}).Encode(&subscriberRaw); err != nil {
t.Fatalf("encode non-admin getHistory(monoforum): %v", err)
}
subscriberEnc, err := r.Dispatch(WithUserID(ctx, sub.ID), [8]byte{}, 0, &subscriberRaw)
if err != nil {
t.Fatalf("non-admin getHistory(monoforum): %v", err)
}
subscriberHistory, ok := subscriberEnc.(*tg.MessagesChannelMessages)
if !ok {
t.Fatalf("non-admin getHistory(monoforum) = %T, want *tg.MessagesChannelMessages", subscriberEnc)
}
if len(subscriberHistory.Messages) != 1 {
t.Fatalf("non-admin getHistory(monoforum) = %d msgs, want own sublist message", len(subscriberHistory.Messages))
}
subscriberMessage, ok := subscriberHistory.Messages[0].(*tg.Message)
if !ok || subscriberMessage.Message != "hello channel" {
t.Fatalf("non-admin history[0] = %#v, want own 'hello channel'", subscriberHistory.Messages[0])
}
subscriberSavedPeer, ok := subscriberMessage.GetSavedPeerID()
if !ok {
t.Fatalf("non-admin history message missing saved_peer_id")
}
if peer, ok := subscriberSavedPeer.(*tg.PeerUser); !ok || peer.UserID != sub.ID {
t.Fatalf("non-admin history saved_peer_id = %#v, want self %d", subscriberSavedPeer, sub.ID)
}
// 管理员看私信列表。
dreq := &tg.MessagesGetSavedDialogsRequest{}
// TDesktop SavedSublist::loadAround() 的 parentChat()->input() 是母广播频道。
dreq.SetParentPeer(parentInput)
dres, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, owner.ID), dreq)
if err != nil {
t.Fatalf("getSavedDialogs(monoforum): %v", err)
}
sd, ok := dres.(*tg.MessagesSavedDialogs)
if !ok {
t.Fatalf("getSavedDialogs = %T, want *tg.MessagesSavedDialogs", dres)
}
if len(sd.Dialogs) != 1 {
t.Fatalf("dialogs = %d, want 1 subscriber sublist", len(sd.Dialogs))
}
md, ok := sd.Dialogs[0].(*tg.MonoForumDialog)
if !ok {
t.Fatalf("dialog = %T, want *tg.MonoForumDialog", sd.Dialogs[0])
}
if pu, ok := md.Peer.(*tg.PeerUser); !ok || pu.UserID != sub.ID {
t.Fatalf("dialog peer = %#v, want PeerUser %d", md.Peer, sub.ID)
}
foundSub := false
for _, u := range sd.Users {
if usr, ok := u.(*tg.User); ok && usr.ID == sub.ID {
foundSub = true
}
}
if !foundSub {
t.Fatalf("getSavedDialogs users missing subscriber %d", sub.ID)
}
// 管理员看某订阅者会话历史。
hreq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}}
hreq.SetParentPeer(parentInput)
hres, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), hreq)
if err != nil {
t.Fatalf("getSavedHistory(monoforum): %v", err)
}
assertMessagesEnvelopeBotVerificationIcon(t, hres, monoforumPeer, monoforumIcon)
var gotMsgs []tg.MessageClass
switch m := hres.(type) {
case *tg.MessagesMessages:
gotMsgs = m.Messages
case *tg.MessagesMessagesSlice:
gotMsgs = m.Messages
case *tg.MessagesChannelMessages:
gotMsgs = m.Messages
default:
t.Fatalf("getSavedHistory = %T, want messages", hres)
}
if len(gotMsgs) != 1 {
t.Fatalf("history = %d msgs, want 1", len(gotMsgs))
}
msg, ok := gotMsgs[0].(*tg.Message)
if !ok {
t.Fatalf("msg = %T, want *tg.Message", gotMsgs[0])
}
if msg.Message != "hello channel" {
t.Fatalf("body = %q, want 'hello channel'", msg.Message)
}
sp, ok := msg.GetSavedPeerID()
if !ok {
t.Fatalf("message missing saved_peer_id (client can't group into subscriber sublist)")
}
if pu, ok := sp.(*tg.PeerUser); !ok || pu.UserID != sub.ID {
t.Fatalf("saved_peer_id = %#v, want sub %d", sp, sub.ID)
}
// 虚拟 monoforum peer 仍是合法的等价入口,两个 parent 不能落到不同数据集。
directMonoReq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}}
directMonoReq.SetParentPeer(monoInput)
directMonoRes, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), directMonoReq)
if err != nil {
t.Fatalf("getSavedHistory(direct monoforum): %v", err)
}
directMonoSlice, ok := directMonoRes.(*tg.MessagesMessagesSlice)
if !ok || len(directMonoSlice.Messages) != 1 {
t.Fatalf("getSavedHistory(direct monoforum) = %#v, want same single-message topic", directMonoRes)
}
// 非管理员(订阅者本人)经管理员入口看列表被拒。
if _, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, sub.ID), dreq); err == nil {
t.Fatalf("non-admin getSavedDialogs(monoforum) = nil err, want denied")
}
}
// TestMonoforumSendMessageWritePath 验证写侧:订阅者按 TDesktop 实际请求仅以
// peer=monoforum 发到自己的子会话;管理员必须显式指定目标订阅者;suggested_post 被持久化返回;
// 订阅者不能写他人子会话。
func TestMonoforumSendMessageWritePath(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550003001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
sub, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550003002", FirstName: "Sub"})
if err != nil {
t.Fatalf("create sub: %v", err)
}
channelStore := memory.NewChannelStore()
channelSvc := appchannels.NewService(channelStore)
dialogSvc := appdialogs.NewService(memory.NewDialogStore(), channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelSvc,
Dialogs: dialogSvc,
}, zaptest.NewLogger(t), clock.System)
created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "DM Broadcast", Broadcast: true, Date: 1000})
if err != nil {
t.Fatalf("create channel: %v", err)
}
enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 10, true)
if err != nil {
t.Fatalf("enable DM: %v", err)
}
monoID := enabled.Channel.LinkedMonoforumID
mono, err := channelStore.GetChannelByID(ctx, monoID)
if err != nil {
t.Fatalf("get monoforum: %v", err)
}
monoInput := &tg.InputPeerChannel{ChannelID: monoID, AccessHash: mono.AccessHash}
monoChannelInput := &tg.InputChannel{ChannelID: monoID, AccessHash: mono.AccessHash}
// 订阅者不是 monoforum 成员,但 TDesktop 打开会话时必须能读取 full channel shell。
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, sub.ID), monoChannelInput)
if err != nil {
t.Fatalf("subscriber getFullChannel(monoforum): %v", err)
}
if full == nil || full.FullChat == nil {
t.Fatalf("subscriber getFullChannel(monoforum) = %#v, want full chat", full)
}
// monoforum 永远不能通过 join 变成普通频道成员,否则会生成错误的 joined service message。
if _, err := r.onChannelsJoinChannel(WithUserID(ctx, sub.ID), monoChannelInput); err == nil || !strings.Contains(err.Error(), "CHANNEL_MONOFORUM_UNSUPPORTED") {
t.Fatalf("subscriber joinChannel(monoforum) err = %v, want CHANNEL_MONOFORUM_UNSUPPORTED", err)
}
// TDesktop 在发送前保存相同 suggested_post 草稿;它必须可写、可恢复,不能变成 CHANNEL_PRIVATE。
draftSuggested := tg.SuggestedPost{}
draftSuggested.SetPrice(&tg.StarsAmount{Amount: 10})
draftSuggested.SetScheduleDate(1_700_100_000)
draftReq := &tg.MessagesSaveDraftRequest{Peer: monoInput, Message: "pending suggested post"}
draftReq.SetSuggestedPost(draftSuggested)
if ok, err := r.onMessagesSaveDraft(WithUserID(ctx, sub.ID), draftReq); err != nil || !ok {
t.Fatalf("subscriber saveDraft(monoforum) = %v, %v; want true, nil", ok, err)
}
storedDraft, found, err := dialogSvc.GetDraft(ctx, sub.ID, domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, 0)
if err != nil || !found {
t.Fatalf("get persisted monoforum draft = %+v, %v, %v; want found", storedDraft, found, err)
}
if storedDraft.Message != "pending suggested post" || storedDraft.SuggestedPost == nil || storedDraft.SuggestedPost.Price == nil || storedDraft.SuggestedPost.Price.Amount != 10 || storedDraft.SuggestedPost.ScheduleDate != 1_700_100_000 {
t.Fatalf("persisted monoforum draft = %+v, want suggested post content", storedDraft)
}
// TDesktop 的订阅者请求不携带 InputReplyToMonoForum;服务端必须从调用者推导 saved_peer=self。
subReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "hi from sub telesrv://resolve?domain=Owner", RandomID: 555}
subReq.ClearDraft = true
subReq.SetAllowPaidStars(20)
suggestedInput := tg.SuggestedPost{}
suggestedInput.SetPrice(&tg.StarsAmount{Amount: 10})
suggestedInput.SetScheduleDate(1_700_100_000)
subReq.SetSuggestedPost(suggestedInput)
subUpd, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), subReq)
if err != nil {
t.Fatalf("subscriber sendMessage(monoforum): %v", err)
}
subUpdates, ok := subUpd.(*tg.Updates)
if !ok {
t.Fatalf("subscriber send updates = %T, want *tg.Updates", subUpd)
}
var subMessageID int
var subPaidStars int64
for _, update := range subUpdates.Updates {
if newMessage, ok := update.(*tg.UpdateNewChannelMessage); ok {
if message, ok := newMessage.Message.(*tg.Message); ok {
subMessageID = message.ID
subPaidStars, _ = message.GetPaidMessageStars()
var hasAppLink bool
for _, entity := range message.Entities {
if url, ok := entity.(*tg.MessageEntityURL); ok && url.Offset == utf16CodeUnitLen("hi from sub ") && url.Length == utf16CodeUnitLen("telesrv://resolve?domain=Owner") {
hasAppLink = true
}
}
if !hasAppLink {
t.Fatalf("monoforum message missing configured app-link entity: %+v", message.Entities)
}
}
}
}
// telesrv has no Stars economy: DM sends are always free, so no
// UpdateStarsBalance is ever emitted regardless of allow_paid_stars.
if subMessageID == 0 || subPaidStars != 0 {
t.Fatalf("subscriber send updates id/paid = %d/%d, want id>0/0: %#v", subMessageID, subPaidStars, subUpdates.Updates)
}
if _, found, err := dialogSvc.GetDraft(ctx, sub.ID, domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, 0); err != nil || found {
t.Fatalf("clear_draft after send found/err = %v/%v, want false/nil", found, err)
}
duplicateUpd, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), subReq)
if err != nil {
t.Fatalf("subscriber replay: %v", err)
}
if _, ok := duplicateUpd.(*tg.Updates); !ok {
t.Fatalf("subscriber replay = %T, want *tg.Updates", duplicateUpd)
}
// 管理员回复到该订阅者的子会话:同一个 inputReplyToMessage 同时携带真实 reply id
// 和 monoforum target两部分都必须保留。
adminReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "admin reply", RandomID: 556}
adminReply := &tg.InputReplyToMessage{ReplyToMsgID: subMessageID}
adminReply.SetMonoforumPeerID(&tg.InputPeerUser{UserID: sub.ID})
adminReq.SetReplyTo(adminReply)
adminReq.SetAllowPaidStars(100)
adminUpd, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), adminReq)
if err != nil {
t.Fatalf("admin reply sendMessage(monoforum): %v", err)
}
if updates, ok := adminUpd.(*tg.Updates); ok {
for _, update := range updates.Updates {
if _, balance := update.(*tg.UpdateStarsBalance); balance {
t.Fatalf("admin free reply emitted a balance debit: %#v", updates.Updates)
}
if newMessage, ok := update.(*tg.UpdateNewChannelMessage); ok {
if message, ok := newMessage.Message.(*tg.Message); ok {
if stars, paid := message.GetPaidMessageStars(); paid || stars != 0 {
t.Fatalf("admin reply paid_message_stars = %d/%v, want 0/false", stars, paid)
}
}
}
}
}
// 带媒体的 suggested post 必须走同一 monoforum 子会话,不能退化为普通频道消息或丢 flags。
mediaSuggested := tg.SuggestedPost{}
mediaSuggested.SetPrice(&tg.StarsAmount{Amount: 15})
mediaReq := &tg.MessagesSendMediaRequest{
Peer: monoInput, RandomID: 558, Message: "media suggestion",
Media: &tg.InputMediaContact{PhoneNumber: "+15550003003", FirstName: "Media", LastName: "Contact", Vcard: ""},
}
mediaReq.SetSuggestedPost(mediaSuggested)
mediaReq.SetAllowPaidStars(15)
if _, err := r.onMessagesSendMedia(WithUserID(ctx, sub.ID), mediaReq); err != nil {
t.Fatalf("subscriber sendMedia(monoforum): %v", err)
}
// 订阅者不能写他人(owner)的子会话。
sneaky := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "sneaky", RandomID: 557}
sneaky.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerUser{UserID: owner.ID}})
if _, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), sneaky); err == nil {
t.Fatalf("subscriber writing another's sublist = nil err, want REPLY_TO_MONOFORUM_PEER_INVALID")
}
// 经管理员读历史:子会话含三条(订阅者文本 + 管理员回复 + 订阅者媒体),倒序。
hreq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}}
hreq.SetParentPeer(monoInput)
hres, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), hreq)
if err != nil {
t.Fatalf("getSavedHistory after sends: %v", err)
}
slice, ok := hres.(*tg.MessagesMessagesSlice)
if !ok {
t.Fatalf("getSavedHistory = %T, want *tg.MessagesMessagesSlice", hres)
}
if len(slice.Messages) != 3 {
t.Fatalf("history = %d msgs, want 3 (sub text + admin + sub media)", len(slice.Messages))
}
top, ok := slice.Messages[0].(*tg.Message)
if !ok || top.Message != "media suggestion" {
t.Fatalf("history[0] = %#v, want newest media suggestion", slice.Messages[0])
}
if _, ok := top.Media.(*tg.MessageMediaContact); !ok {
t.Fatalf("history[0] media = %T, want MessageMediaContact", top.Media)
}
// telesrv has no Stars economy: sends are always free regardless of the
// channel's configured price, so paid_message_stars is never set.
if paid, ok := top.GetPaidMessageStars(); ok || paid != 0 {
t.Fatalf("media paid_message_stars = %d/%v, want 0/false", paid, ok)
}
topSuggested, ok := top.GetSuggestedPost()
if !ok {
t.Fatalf("media message missing suggested_post")
}
topPrice, ok := topSuggested.GetPrice()
if !ok {
t.Fatalf("media suggested_post missing price")
}
if stars, ok := topPrice.(*tg.StarsAmount); !ok || stars.Amount != 15 {
t.Fatalf("media suggested_post price = %#v, want 15 Stars", topPrice)
}
adminMessage, ok := slice.Messages[1].(*tg.Message)
if !ok || adminMessage.Message != "admin reply" {
t.Fatalf("history[1] = %#v, want admin reply", slice.Messages[1])
}
if header, ok := adminMessage.ReplyTo.(*tg.MessageReplyHeader); !ok || header.ReplyToMsgID != subMessageID {
t.Fatalf("admin reply header = %#v, want reply_to_msg_id %d", adminMessage.ReplyTo, subMessageID)
}
suggestedMessage, ok := slice.Messages[2].(*tg.Message)
if !ok {
t.Fatalf("history[2] = %T, want *tg.Message", slice.Messages[2])
}
suggested, ok := suggestedMessage.GetSuggestedPost()
if !ok {
t.Fatalf("subscriber message missing suggested_post")
}
price, ok := suggested.GetPrice()
if !ok {
t.Fatalf("suggested_post missing price")
}
stars, ok := price.(*tg.StarsAmount)
if !ok || stars.Amount != 10 {
t.Fatalf("suggested_post price = %#v, want 10 Stars", price)
}
if scheduleDate, ok := suggested.GetScheduleDate(); !ok || scheduleDate != 1_700_100_000 {
t.Fatalf("suggested_post schedule = %d/%v, want 1700100000/true", scheduleDate, ok)
}
}
// TestMonoforumForwardSuggestedPostAndMessageMetadataPaths 回归 DrKLO 的四条真实路径:
// Add Offer 用 forwardMessages+suggested_post 新建建议Edit Price/Edit Time 再回复原建议;
// getMessagesViews 与 get/send reaction 必须允许无 member 行的订阅者访问自己的 saved_peer
// 且不能按猜测 id 跨到另一订阅者的子会话。
func TestMonoforumForwardSuggestedPostAndMessageMetadataPaths(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550004001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
sub, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550004002", FirstName: "Sub"})
if err != nil {
t.Fatalf("create sub: %v", err)
}
other, err := userStore.Create(ctx, domain.User{AccessHash: 33, Phone: "15550004003", FirstName: "Other"})
if err != nil {
t.Fatalf("create other: %v", err)
}
channelStore := memory.NewChannelStore()
channelSvc := appchannels.NewService(channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelSvc,
}, zaptest.NewLogger(t), clock.System)
created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "DM Suggested", Broadcast: true, Date: 2000})
if err != nil {
t.Fatalf("create channel: %v", err)
}
enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
if err != nil {
t.Fatalf("enable DM: %v", err)
}
monoID := enabled.Channel.LinkedMonoforumID
mono, err := channelStore.GetChannelByID(ctx, monoID)
if err != nil {
t.Fatalf("get monoforum: %v", err)
}
monoInput := &tg.InputPeerChannel{ChannelID: monoID, AccessHash: mono.AccessHash}
subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID}
original, err := channelStore.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer,
RandomID: 7001, Message: "please publish this", Date: 2001,
})
if err != nil {
t.Fatalf("seed subscriber message: %v", err)
}
otherMessage, err := channelStore.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: other.ID,
SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: other.ID},
RandomID: 7002, Message: "another subscriber", Date: 2002,
})
if err != nil {
t.Fatalf("seed other subscriber message: %v", err)
}
// DrKLO 会为带 views/replies 的可见消息每 5 秒批量刷新一次。返回向量必须
// 保持请求位置,同时不能因 synthetic viewer 无 channel_members 行而报 CHANNEL_PRIVATE
// 也不能让猜测到的其它 saved_peer 消息被读取或递增。
messageViews, err := r.onMessagesGetMessagesViews(WithUserID(ctx, sub.ID), &tg.MessagesGetMessagesViewsRequest{
Peer: monoInput,
ID: []int{original.Message.ID, otherMessage.Message.ID},
Increment: true,
})
if err != nil {
t.Fatalf("subscriber getMessagesViews(monoforum): %v", err)
}
if len(messageViews.Views) != 2 || len(messageViews.Chats) == 0 {
t.Fatalf("subscriber getMessagesViews = %+v, want two positional views with channel context", messageViews)
}
if got, ok := messageViews.Views[0].GetViews(); !ok || got != 1 {
t.Fatalf("subscriber own message views = %d/%v, want 1/true", got, ok)
}
if got, ok := messageViews.Views[1].GetViews(); ok || got != 0 {
t.Fatalf("subscriber cross-saved-peer views = %d/%v, want 0/false", got, ok)
}
repeatedMessageViews, err := r.onMessagesGetMessagesViews(WithUserID(ctx, sub.ID), &tg.MessagesGetMessagesViewsRequest{
Peer: monoInput, ID: []int{original.Message.ID}, Increment: true,
})
if err != nil {
t.Fatalf("subscriber repeated getMessagesViews(monoforum): %v", err)
}
if got, ok := repeatedMessageViews.Views[0].GetViews(); !ok || got != 1 {
t.Fatalf("subscriber repeated message views = %d/%v, want idempotent 1/true", got, ok)
}
adminMessageViews, err := r.onMessagesGetMessagesViews(WithUserID(ctx, owner.ID), &tg.MessagesGetMessagesViewsRequest{
Peer: monoInput,
ID: []int{original.Message.ID, otherMessage.Message.ID},
Increment: true,
})
if err != nil {
t.Fatalf("admin getMessagesViews(monoforum): %v", err)
}
if got, ok := adminMessageViews.Views[0].GetViews(); !ok || got != 2 {
t.Fatalf("admin first saved-peer views = %d/%v, want 2/true", got, ok)
}
if got, ok := adminMessageViews.Views[1].GetViews(); !ok || got != 1 {
t.Fatalf("admin second saved-peer views = %d/%v, want 1/true", got, ok)
}
// Android 打开 reaction 状态时会先发 getMessagesReactions。订阅者没有 channel_members
// 行,但自己的 saved_peer 消息必须正常返回,并携带 saved_peer_id 供客户端归组。
reactionState, err := r.onMessagesGetMessagesReactions(WithUserID(ctx, sub.ID), &tg.MessagesGetMessagesReactionsRequest{
Peer: monoInput,
ID: []int{original.Message.ID},
})
if err != nil {
t.Fatalf("subscriber getMessagesReactions(monoforum): %v", err)
}
reactionUpdates, ok := reactionState.(*tg.Updates)
if !ok || len(reactionUpdates.Updates) != 1 {
t.Fatalf("getMessagesReactions = %#v, want one update", reactionState)
}
reactionUpdate, ok := reactionUpdates.Updates[0].(*tg.UpdateMessageReactions)
if !ok {
t.Fatalf("getMessagesReactions update = %T, want UpdateMessageReactions", reactionUpdates.Updates[0])
}
savedPeer, ok := reactionUpdate.GetSavedPeerID()
if !ok {
t.Fatalf("getMessagesReactions update missing saved_peer_id")
}
if peer, ok := savedPeer.(*tg.PeerUser); !ok || peer.UserID != sub.ID {
t.Fatalf("reaction saved_peer_id = %#v, want subscriber %d", savedPeer, sub.ID)
}
sendReaction := &tg.MessagesSendReactionRequest{Peer: monoInput, MsgID: original.Message.ID}
sendReaction.SetReaction([]tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}})
sentReaction, err := r.onMessagesSendReaction(WithUserID(ctx, sub.ID), sendReaction)
if err != nil {
t.Fatalf("subscriber sendReaction(monoforum): %v", err)
}
sentReactionUpdates, ok := sentReaction.(*tg.Updates)
if !ok || len(sentReactionUpdates.Updates) != 1 {
t.Fatalf("sendReaction = %#v, want one update", sentReaction)
}
sentReactionUpdate, ok := sentReactionUpdates.Updates[0].(*tg.UpdateMessageReactions)
if !ok {
t.Fatalf("sendReaction update = %T, want UpdateMessageReactions", sentReactionUpdates.Updates[0])
}
if saved, ok := sentReactionUpdate.GetSavedPeerID(); !ok {
t.Fatalf("sendReaction update missing saved_peer_id")
} else if peer, ok := saved.(*tg.PeerUser); !ok || peer.UserID != sub.ID {
t.Fatalf("sendReaction saved_peer_id = %#v, want subscriber %d", saved, sub.ID)
}
if _, err := r.onMessagesSendReaction(WithUserID(ctx, sub.ID), &tg.MessagesSendReactionRequest{
Peer: monoInput, MsgID: otherMessage.Message.ID,
}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("cross-saved-peer sendReaction err = %v, want MESSAGE_ID_INVALID", err)
}
// Add Offer源消息和目标会话都是 monoforum自身 topic 由
// inputReplyToMonoForum(self) 选择;这不是普通 forum reply root。
addOffer := &tg.MessagesForwardMessagesRequest{
FromPeer: monoInput,
ID: []int{original.Message.ID},
RandomID: []int64{8001},
ToPeer: monoInput,
}
addOffer.SetDropAuthor(true)
addOffer.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerSelf{}})
offer := tg.SuggestedPost{}
offer.SetPrice(&tg.StarsAmount{Amount: 25})
offer.SetScheduleDate(2_000_000_000)
addOffer.SetSuggestedPost(offer)
addOfferResult, err := r.onMessagesForwardMessages(WithUserID(ctx, sub.ID), addOffer)
if err != nil {
t.Fatalf("subscriber Add Offer forwardMessages(monoforum): %v", err)
}
proposal, proposalUpdate := requireMonoforumNewChannelMessage(t, addOfferResult)
if proposal.Message != original.Message.Body {
t.Fatalf("proposal body = %q, want copied source %q", proposal.Message, original.Message.Body)
}
if proposalUpdate.PtsCount != 1 || proposalUpdate.Pts <= original.Message.Pts {
t.Fatalf("proposal pts/count = %d/%d, want one real channel event after %d", proposalUpdate.Pts, proposalUpdate.PtsCount, original.Message.Pts)
}
if _, ok := proposal.GetFwdFrom(); ok {
t.Fatalf("drop_author proposal unexpectedly has fwd_from: %#v", proposal)
}
if saved, ok := proposal.GetSavedPeerID(); !ok {
t.Fatalf("proposal missing saved_peer_id")
} else if peer, ok := saved.(*tg.PeerUser); !ok || peer.UserID != sub.ID {
t.Fatalf("proposal saved_peer_id = %#v, want subscriber %d", saved, sub.ID)
}
proposalSuggested, ok := proposal.GetSuggestedPost()
if !ok {
t.Fatalf("proposal missing suggested_post")
}
if price, ok := proposalSuggested.GetPrice(); !ok {
t.Fatalf("proposal suggested_post missing price")
} else if stars, ok := price.(*tg.StarsAmount); !ok || stars.Amount != 25 {
t.Fatalf("proposal price = %#v, want 25 Stars", price)
}
// Edit Price/Edit TimeAndroid 同时带 reply_to、monoforum_peer_id 和冗余
// top_msg_id服务端保留真实 reply接受相等的冗余值但不制造 forum topic root。
editOffer := &tg.MessagesForwardMessagesRequest{
FromPeer: monoInput,
ID: []int{proposal.ID},
RandomID: []int64{8002},
ToPeer: monoInput,
}
editOffer.SetDropAuthor(true)
editReply := &tg.InputReplyToMessage{ReplyToMsgID: proposal.ID}
editReply.SetMonoforumPeerID(&tg.InputPeerSelf{})
editOffer.SetReplyTo(editReply)
editOffer.SetTopMsgID(proposal.ID)
edited := tg.SuggestedPost{}
edited.SetPrice(&tg.StarsAmount{Amount: 40})
edited.SetScheduleDate(2_000_100_000)
editOffer.SetSuggestedPost(edited)
editResult, err := r.onMessagesForwardMessages(WithUserID(ctx, sub.ID), editOffer)
if err != nil {
t.Fatalf("subscriber Edit Offer forwardMessages(monoforum): %v", err)
}
editedProposal, editedUpdate := requireMonoforumNewChannelMessage(t, editResult)
if editedUpdate.PtsCount != 1 || editedUpdate.Pts != proposalUpdate.Pts+1 {
t.Fatalf("edited proposal pts/count = %d/%d, want %d/1", editedUpdate.Pts, editedUpdate.PtsCount, proposalUpdate.Pts+1)
}
replyHeader, ok := editedProposal.ReplyTo.(*tg.MessageReplyHeader)
if !ok || replyHeader.ReplyToMsgID != proposal.ID {
t.Fatalf("edited proposal reply = %#v, want message %d", editedProposal.ReplyTo, proposal.ID)
}
if topID, ok := replyHeader.GetReplyToTopID(); ok || topID != 0 {
t.Fatalf("edited proposal acquired forum top_msg_id = %d/%v, want absent", topID, ok)
}
editedSuggested, ok := editedProposal.GetSuggestedPost()
if !ok {
t.Fatalf("edited proposal missing suggested_post")
}
if price, ok := editedSuggested.GetPrice(); !ok {
t.Fatalf("edited proposal missing price")
} else if stars, ok := price.(*tg.StarsAmount); !ok || stars.Amount != 40 {
t.Fatalf("edited proposal price = %#v, want 40 Stars", price)
}
if schedule, ok := editedSuggested.GetScheduleDate(); !ok || schedule != 2_000_100_000 {
t.Fatalf("edited proposal schedule = %d/%v, want 2000100000/true", schedule, ok)
}
beforeReplay, err := channelStore.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{
MonoforumID: monoID, SavedPeer: subPeer, Limit: 100,
})
if err != nil {
t.Fatalf("history before replay: %v", err)
}
replayResult, err := r.onMessagesForwardMessages(WithUserID(ctx, sub.ID), editOffer)
if err != nil {
t.Fatalf("exact Edit Offer replay: %v", err)
}
replayedProposal, replayedUpdate := requireMonoforumNewChannelMessage(t, replayResult)
afterReplay, err := channelStore.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{
MonoforumID: monoID, SavedPeer: subPeer, Limit: 100,
})
if err != nil {
t.Fatalf("history after replay: %v", err)
}
if replayedProposal.ID != editedProposal.ID || replayedUpdate.Pts != editedUpdate.Pts || afterReplay.Count != beforeReplay.Count {
t.Fatalf("exact replay id/pts/count = %d/%d/%d, want %d/%d/%d",
replayedProposal.ID, replayedUpdate.Pts, afterReplay.Count,
editedProposal.ID, editedUpdate.Pts, beforeReplay.Count)
}
conflict := *editOffer
conflictingSuggested := tg.SuggestedPost{}
conflictingSuggested.SetPrice(&tg.StarsAmount{Amount: 41})
conflict.SetSuggestedPost(conflictingSuggested)
if _, err := r.onMessagesForwardMessages(WithUserID(ctx, sub.ID), &conflict); err == nil || !strings.Contains(err.Error(), "RANDOM_ID_DUPLICATE") {
t.Fatalf("conflicting Edit Offer replay err = %v, want RANDOM_ID_DUPLICATE", err)
}
// 精确 id 查询必须服从 saved_peer 过滤,不能把其它订阅者的消息作为转发源。
crossSource := &tg.MessagesForwardMessagesRequest{
FromPeer: monoInput,
ID: []int{otherMessage.Message.ID},
RandomID: []int64{8003},
ToPeer: monoInput,
}
crossSource.SetDropAuthor(true)
crossSource.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerSelf{}})
crossSource.SetSuggestedPost(offer)
if _, err := r.onMessagesForwardMessages(WithUserID(ctx, sub.ID), crossSource); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("cross-saved-peer forward source err = %v, want MESSAGE_ID_INVALID", err)
}
// 管理员可见全部子会话,但目标仍必须显式指定,写入同一 subscriber saved_peer。
adminOffer := &tg.MessagesForwardMessagesRequest{
FromPeer: monoInput,
ID: []int{original.Message.ID},
RandomID: []int64{8004},
ToPeer: monoInput,
}
adminOffer.SetDropAuthor(true)
adminOffer.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerUser{UserID: sub.ID}})
adminOffer.SetSuggestedPost(offer)
adminResult, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), adminOffer)
if err != nil {
t.Fatalf("admin Add Offer forwardMessages(monoforum): %v", err)
}
adminProposal, _ := requireMonoforumNewChannelMessage(t, adminResult)
if saved, ok := adminProposal.GetSavedPeerID(); !ok {
t.Fatalf("admin proposal missing saved_peer_id")
} else if peer, ok := saved.(*tg.PeerUser); !ok || peer.UserID != sub.ID {
t.Fatalf("admin proposal saved_peer_id = %#v, want subscriber %d", saved, sub.ID)
}
}
func requireMonoforumNewChannelMessage(t *testing.T, updatesClass tg.UpdatesClass) (*tg.Message, *tg.UpdateNewChannelMessage) {
t.Helper()
updates, ok := updatesClass.(*tg.Updates)
if !ok {
t.Fatalf("updates = %T, want *tg.Updates", updatesClass)
}
for _, update := range updates.Updates {
newMessage, ok := update.(*tg.UpdateNewChannelMessage)
if !ok {
continue
}
message, ok := newMessage.Message.(*tg.Message)
if !ok {
t.Fatalf("new channel message = %T, want *tg.Message", newMessage.Message)
}
return message, newMessage
}
t.Fatalf("updates missing UpdateNewChannelMessage: %#v", updates.Updates)
return nil, nil
}