448 lines
16 KiB
Go
448 lines
16 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/store"
|
||
"time"
|
||
)
|
||
|
||
type privateSendDedupKey struct {
|
||
senderUserID int64
|
||
randomID int64
|
||
}
|
||
|
||
type privateSendDedupRecord struct {
|
||
recipientUserID int64
|
||
senderSnapshot []byte
|
||
recipientMessage domain.Message
|
||
fingerprint []byte
|
||
senderDeleteEvent *domain.UpdateEvent
|
||
}
|
||
|
||
func (s *MessageStore) Create(_ context.Context, msg domain.Message) (domain.Message, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
msg.ID = s.nextBoxIDLocked(msg.OwnerUserID)
|
||
msg.UID = s.nextUID
|
||
s.nextUID++
|
||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||
s.m[msg.OwnerUserID] = append(s.m[msg.OwnerUserID], msg)
|
||
if s.dialogs != nil {
|
||
s.dialogs.mu.Lock()
|
||
list := s.dialogs.m[msg.OwnerUserID]
|
||
list.Messages = append(list.Messages, msg)
|
||
if msg.Peer.Type == domain.PeerTypeUser && !hasUser(list.Users, msg.Peer.ID) {
|
||
if u, ok := domain.SystemUserByID(msg.Peer.ID); ok {
|
||
list.Users = append(list.Users, u)
|
||
}
|
||
}
|
||
s.dialogs.m[msg.OwnerUserID] = list
|
||
s.dialogs.mu.Unlock()
|
||
}
|
||
return msg, nil
|
||
}
|
||
|
||
func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
|
||
if !req.HasContent() {
|
||
return domain.SendPrivateTextResult{}, domain.ErrMessageEmpty
|
||
}
|
||
fingerprint, err := store.PrivateSendFingerprint(req)
|
||
if err != nil {
|
||
return domain.SendPrivateTextResult{}, err
|
||
}
|
||
// Match TogglePrivateNoForwards lock order; only external private-source
|
||
// replies read pair protection while holding the message mutation lock.
|
||
if r := req.ReplyTo; r != nil && r.Peer.Type == domain.PeerTypeUser && r.Peer.ID > 0 && r.Peer.ID != req.RecipientUserID {
|
||
s.noForwardsMu.Lock()
|
||
defer s.noForwardsMu.Unlock()
|
||
}
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return s.sendPrivateTextLocked(req, fingerprint, nil)
|
||
}
|
||
|
||
// A prefix stages related state events before the message. apply is infallible
|
||
// and only runs after all validation and receipt encoding have succeeded.
|
||
type memoryPrivateSendPrefix struct {
|
||
events []domain.UpdateEvent
|
||
apply func([]domain.UpdateEvent)
|
||
}
|
||
|
||
// Caller holds s.mu; this helper owns the dialog/event locks through commit.
|
||
func (s *MessageStore) sendPrivateTextLocked(req domain.SendPrivateTextRequest, fingerprint []byte, prefix *memoryPrivateSendPrefix) (domain.SendPrivateTextResult, error) {
|
||
if replay, found, err := s.lookupPrivateSendReplayLocked(domain.PrivateSendReplayRequest{
|
||
SenderUserID: req.SenderUserID,
|
||
RecipientUserID: req.RecipientUserID,
|
||
RandomID: req.RandomID,
|
||
IdempotencyFingerprint: fingerprint,
|
||
}); err != nil || found {
|
||
return replay, err
|
||
}
|
||
if req.Date == 0 {
|
||
req.Date = int(time.Now().Unix())
|
||
}
|
||
senderReply, recipientReply, err := s.resolveMemoryReplyLocked(req)
|
||
if err != nil {
|
||
return domain.SendPrivateTextResult{}, err
|
||
}
|
||
events := s.updateEvents
|
||
if events == nil {
|
||
return domain.SendPrivateTextResult{}, store.ErrDeliveryOutboxRequired
|
||
}
|
||
events.mu.Lock()
|
||
defer events.mu.Unlock()
|
||
// Stage counters as well as the message: failed encoding cannot consume PTS.
|
||
allocatedPTS := make(map[int64]int)
|
||
nextPTS := func(userID int64) int {
|
||
current, ok := allocatedPTS[userID]
|
||
if !ok {
|
||
current = s.nextPts[userID]
|
||
for _, event := range events.events[userID] {
|
||
if event.Pts > current {
|
||
current = event.Pts
|
||
}
|
||
}
|
||
}
|
||
allocatedPTS[userID] = current + 1
|
||
return current + 1
|
||
}
|
||
var prefixEvents []domain.UpdateEvent
|
||
if prefix != nil {
|
||
for _, event := range prefix.events {
|
||
event.Pts = nextPTS(event.UserID)
|
||
prefixEvents = append(prefixEvents, event)
|
||
}
|
||
}
|
||
uid := s.nextUID
|
||
sender := domain.Message{
|
||
ID: s.nextBox[req.SenderUserID] + 1,
|
||
UID: uid,
|
||
RandomID: req.RandomID,
|
||
OwnerUserID: req.SenderUserID,
|
||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID},
|
||
From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID},
|
||
Date: req.Date,
|
||
Out: true,
|
||
Silent: req.Silent,
|
||
NoForwards: req.NoForwards,
|
||
Body: req.Message,
|
||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||
Media: cloneRequestedPeerMedia(req.Media),
|
||
ViaBotID: req.ViaBotID,
|
||
GroupedID: req.GroupedID,
|
||
Effect: req.Effect,
|
||
ReplyMarkup: cloneReplyMarkup(req.ReplyMarkup),
|
||
RichMessage: cloneRichMessage(req.RichMessage),
|
||
ReplyTo: cloneMessageReply(senderReply),
|
||
Forward: cloneMessageForward(req.Forward),
|
||
Pts: nextPTS(req.SenderUserID),
|
||
// voice/round 在发送者副本上同样保持"未听",由对端内容已读清除。
|
||
MediaUnread: req.Media.HasUnreadPayload() && req.SenderUserID != req.RecipientUserID,
|
||
}
|
||
if req.SenderUserID == req.RecipientUserID {
|
||
sender.SavedPeer = domain.SavedPeerForSelfChat(req.SenderUserID, req.Forward)
|
||
}
|
||
recipient := domain.Message{}
|
||
if req.SenderUserID == req.RecipientUserID {
|
||
recipient = sender
|
||
}
|
||
if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked {
|
||
recipient = sender
|
||
recipient.ID = s.nextBox[req.RecipientUserID] + 1
|
||
recipient.OwnerUserID = req.RecipientUserID
|
||
recipient.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
|
||
recipient.Out = false
|
||
recipient.ReplyTo = cloneMessageReply(recipientReply)
|
||
recipient.Media = cloneRequestedPeerMedia(sender.Media)
|
||
// recipient = sender 是值拷贝,共享 sender.ReplyMarkup 指针/Data 切片——深拷
|
||
// 让双盒各持独立快照(与 postgres 每盒独立 decode 对齐,I3/I2)。
|
||
recipient.ReplyMarkup = cloneReplyMarkup(sender.ReplyMarkup)
|
||
recipient.RichMessage = cloneRichMessage(sender.RichMessage)
|
||
recipient.Pts = nextPTS(req.RecipientUserID)
|
||
recipient.MediaUnread = req.Media.HasUnreadPayload()
|
||
}
|
||
var senderSnapshot []byte
|
||
if req.RandomID != 0 {
|
||
senderSnapshot, err = store.EncodePrivateSendSnapshot(sender)
|
||
if err != nil {
|
||
return domain.SendPrivateTextResult{}, err
|
||
}
|
||
}
|
||
// No fallible work follows: publish the prefix and message under all locks.
|
||
if prefix != nil {
|
||
prefix.apply(prefixEvents)
|
||
}
|
||
for _, event := range prefixEvents {
|
||
auth, session := [8]byte{}, int64(0)
|
||
if event.UserID == req.SenderUserID {
|
||
auth, session = req.OriginAuthKeyID, req.OriginSessionID
|
||
}
|
||
appendMemorySendEventLocked(events, event.UserID, event, auth, session)
|
||
}
|
||
s.nextUID++
|
||
s.nextBox[req.SenderUserID] = sender.ID
|
||
if recipient.ID != 0 {
|
||
s.nextBox[recipient.OwnerUserID] = recipient.ID
|
||
}
|
||
for userID, pts := range allocatedPTS {
|
||
s.nextPts[userID] = pts
|
||
}
|
||
s.m[req.SenderUserID] = append(s.m[req.SenderUserID], sender)
|
||
if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked {
|
||
s.m[req.RecipientUserID] = append(s.m[req.RecipientUserID], recipient)
|
||
}
|
||
if req.RandomID != 0 {
|
||
s.privateSendDedup[privateSendDedupKey{senderUserID: req.SenderUserID, randomID: req.RandomID}] = privateSendDedupRecord{
|
||
recipientUserID: req.RecipientUserID,
|
||
senderSnapshot: senderSnapshot,
|
||
recipientMessage: immutablePrivateSendReceipt(recipient),
|
||
fingerprint: append([]byte(nil), fingerprint...),
|
||
}
|
||
}
|
||
if s.dialogs != nil {
|
||
if recipient.ID != 0 {
|
||
s.upsertMemoryDialogsLocked(sender, recipient)
|
||
} else {
|
||
s.upsertMemoryDialogsLocked(sender, sender)
|
||
}
|
||
}
|
||
originUserID := req.OriginUserID
|
||
if originUserID == 0 {
|
||
originUserID = req.SenderUserID
|
||
}
|
||
senderExcludeAuthKeyID, senderExcludeSessionID := [8]byte{}, int64(0)
|
||
if originUserID == req.SenderUserID {
|
||
senderExcludeAuthKeyID, senderExcludeSessionID = req.OriginAuthKeyID, req.OriginSessionID
|
||
}
|
||
appendMemorySendEventLocked(events, req.SenderUserID, newMessageEvent(sender), senderExcludeAuthKeyID, senderExcludeSessionID)
|
||
if recipient.ID != 0 && recipient.OwnerUserID != sender.OwnerUserID {
|
||
recipientExcludeAuthKeyID, recipientExcludeSessionID := [8]byte{}, int64(0)
|
||
if originUserID == req.RecipientUserID {
|
||
recipientExcludeAuthKeyID, recipientExcludeSessionID = req.OriginAuthKeyID, req.OriginSessionID
|
||
}
|
||
appendMemorySendEventLocked(events, req.RecipientUserID, newMessageEvent(recipient), recipientExcludeAuthKeyID, recipientExcludeSessionID)
|
||
}
|
||
return domain.SendPrivateTextResult{
|
||
SenderMessage: cloneMessage(sender),
|
||
RecipientMessage: cloneMessage(recipient),
|
||
SenderEvent: newMessageEvent(sender),
|
||
RecipientEvent: newMessageEvent(recipient),
|
||
}, nil
|
||
}
|
||
|
||
func (s *MessageStore) nextPtsWithEventsLocked(events *UpdateEventStore, userID int64) int {
|
||
current := s.nextPts[userID]
|
||
for _, event := range events.events[userID] {
|
||
if event.Pts > current {
|
||
current = event.Pts
|
||
}
|
||
}
|
||
current++
|
||
s.nextPts[userID] = current
|
||
return current
|
||
}
|
||
|
||
func appendMemorySendEventLocked(events *UpdateEventStore, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) {
|
||
event = events.appendLocked(userID, event, false)
|
||
events.dispatches[userID] = append(events.dispatches[userID], memoryUpdateDispatch{
|
||
Pts: event.Pts, ExcludeAuthKeyID: excludeAuthKeyID, ExcludeSessionID: excludeSessionID,
|
||
})
|
||
}
|
||
|
||
// LookupPrivateSendReplay returns an existing immutable/current replay receipt without running
|
||
// any send permission, reply resolution or allocation path.
|
||
func (s *MessageStore) LookupPrivateSendReplay(_ context.Context, req domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error) {
|
||
if req.SenderUserID == 0 || req.RecipientUserID == 0 || req.RandomID == 0 {
|
||
return domain.SendPrivateTextResult{}, false, fmt.Errorf("memory private send replay: invalid scope")
|
||
}
|
||
if err := store.ValidateSendFingerprint(req.IdempotencyFingerprint, "private send replay"); err != nil {
|
||
return domain.SendPrivateTextResult{}, false, err
|
||
}
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return s.lookupPrivateSendReplayLocked(req)
|
||
}
|
||
|
||
func (s *MessageStore) lookupPrivateSendReplayLocked(req domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error) {
|
||
record, ok := s.privateSendDedup[privateSendDedupKey{senderUserID: req.SenderUserID, randomID: req.RandomID}]
|
||
if !ok {
|
||
return domain.SendPrivateTextResult{}, false, nil
|
||
}
|
||
if record.recipientUserID != req.RecipientUserID || !store.SameSendFingerprint(record.fingerprint, req.IdempotencyFingerprint) {
|
||
return domain.SendPrivateTextResult{}, false, domain.ErrMessageRandomIDDuplicate
|
||
}
|
||
firstSender, err := store.DecodePrivateSendSnapshot(record.senderSnapshot)
|
||
if err != nil {
|
||
return domain.SendPrivateTextResult{}, false, fmt.Errorf("memory duplicate private message snapshot: %w", err)
|
||
}
|
||
sender := firstSender
|
||
visible := false
|
||
for _, current := range s.m[req.SenderUserID] {
|
||
if current.UID == firstSender.UID && current.ID == firstSender.ID {
|
||
sender = cloneMessage(current)
|
||
sender.RandomID = firstSender.RandomID
|
||
visible = true
|
||
break
|
||
}
|
||
}
|
||
if !visible && record.senderDeleteEvent == nil {
|
||
return domain.SendPrivateTextResult{}, false, fmt.Errorf("memory duplicate private message %d is absent without a durable sender delete receipt", firstSender.UID)
|
||
}
|
||
recipient := cloneMessage(record.recipientMessage)
|
||
var replayDelete *domain.UpdateEvent
|
||
if record.senderDeleteEvent != nil {
|
||
cloned := cloneUpdateEvent(*record.senderDeleteEvent)
|
||
replayDelete = &cloned
|
||
}
|
||
return domain.SendPrivateTextResult{
|
||
SenderMessage: sender,
|
||
RecipientMessage: recipient,
|
||
SenderEvent: newMessageEvent(firstSender),
|
||
RecipientEvent: newMessageEvent(recipient),
|
||
Duplicate: true,
|
||
ReplayDeleteEvent: replayDelete,
|
||
}, true, nil
|
||
}
|
||
|
||
// immutablePrivateSendReceipt keeps the recipient allocation facts used by
|
||
// store-level idempotency tests. The sender response snapshot is stored as a
|
||
// versioned JSON value above so all nested media/reply graphs are immutable.
|
||
func immutablePrivateSendReceipt(msg domain.Message) domain.Message {
|
||
if msg.ID == 0 {
|
||
return domain.Message{}
|
||
}
|
||
return domain.Message{
|
||
ID: msg.ID,
|
||
UID: msg.UID,
|
||
RandomID: msg.RandomID,
|
||
OwnerUserID: msg.OwnerUserID,
|
||
Peer: msg.Peer,
|
||
From: msg.From,
|
||
Date: msg.Date,
|
||
Out: msg.Out,
|
||
Pts: msg.Pts,
|
||
}
|
||
}
|
||
|
||
func (s *MessageStore) resolveMemoryReplyLocked(req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) {
|
||
if req.ReplyTo == nil {
|
||
return nil, nil, nil
|
||
}
|
||
if err := domain.ValidateMessageReplyBounds(req.ReplyTo); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if req.ReplyTo.StoryID > 0 {
|
||
// story 回复(评论):无源消息可查;story 作者就是会话对端,双盒同持。
|
||
reply := &domain.MessageReply{
|
||
StoryID: req.ReplyTo.StoryID,
|
||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID},
|
||
}
|
||
return cloneMessageReply(reply), cloneMessageReply(reply), nil
|
||
}
|
||
peer := req.ReplyTo.Peer
|
||
if peer.ID == 0 {
|
||
peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID}
|
||
}
|
||
if peer.Type != domain.PeerTypeUser || req.ReplyTo.External != nil {
|
||
return nil, nil, domain.ErrReplyMessageIDInvalid
|
||
}
|
||
var target domain.Message
|
||
for _, msg := range s.m[req.SenderUserID] {
|
||
if msg.Peer == peer && msg.ID == req.ReplyTo.MessageID {
|
||
target = msg
|
||
break
|
||
}
|
||
}
|
||
if target.ID == 0 {
|
||
return nil, nil, domain.ErrReplyMessageIDInvalid
|
||
}
|
||
senderReply := cloneMessageReply(req.ReplyTo)
|
||
senderReply.MessageID = target.ID
|
||
senderReply.Peer = peer
|
||
if peer.ID != req.RecipientUserID {
|
||
protected := target.NoForwards || (target.Media != nil && target.Media.TTLSeconds > 0)
|
||
if pair, ok := noForwardsPair(req.SenderUserID, peer.ID); ok {
|
||
protected = protected || s.privateNoForwards[pair].Enabled()
|
||
}
|
||
if protected {
|
||
return nil, nil, domain.ErrChatForwardsRestricted
|
||
}
|
||
if err := domain.ValidateExternalReplyQuote(req.ReplyTo, target.Body); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
var err error
|
||
senderReply.External, err = domain.NewMessageReplyExternal(target)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
recipientReply := cloneMessageReply(senderReply)
|
||
if req.SenderUserID != req.RecipientUserID {
|
||
recipientReply.MessageID = 0
|
||
recipientReply.TopMessageID = 0
|
||
}
|
||
return senderReply, recipientReply, nil
|
||
}
|
||
if req.SenderUserID == req.RecipientUserID {
|
||
return senderReply, cloneMessageReply(senderReply), nil
|
||
}
|
||
for _, msg := range s.m[req.RecipientUserID] {
|
||
if msg.UID == target.UID {
|
||
recipientReply := cloneMessageReply(senderReply)
|
||
recipientReply.MessageID = msg.ID
|
||
recipientReply.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
|
||
return senderReply, recipientReply, nil
|
||
}
|
||
}
|
||
return senderReply, nil, nil
|
||
}
|
||
|
||
func (s *MessageStore) upsertMemoryDialogsLocked(sender, recipient domain.Message) {
|
||
s.dialogs.mu.Lock()
|
||
defer s.dialogs.mu.Unlock()
|
||
list := s.dialogs.m[sender.OwnerUserID]
|
||
list = upsertMemoryDialog(list, domain.Dialog{Peer: sender.Peer, TopMessage: sender.ID, TopMessageDate: sender.Date})
|
||
// 发送方向清手动未读标记(对齐 postgres UpsertOutboxDialog 与
|
||
// channel 发送路径:向会话发出消息即视为已知晓内容)。
|
||
for i := range list.Dialogs {
|
||
if list.Dialogs[i].Peer == sender.Peer {
|
||
list.Dialogs[i].UnreadMark = false
|
||
break
|
||
}
|
||
}
|
||
list.Messages = append(list.Messages, sender)
|
||
s.dialogs.m[sender.OwnerUserID] = list
|
||
if recipient.OwnerUserID != sender.OwnerUserID {
|
||
peerList := s.dialogs.m[recipient.OwnerUserID]
|
||
peerList = upsertMemoryDialog(peerList, domain.Dialog{
|
||
Peer: recipient.Peer,
|
||
TopMessage: recipient.ID,
|
||
TopMessageDate: recipient.Date,
|
||
UnreadCount: s.privateUnreadCountLocked(recipient.OwnerUserID, recipient.Peer),
|
||
})
|
||
peerList.Messages = append(peerList.Messages, recipient)
|
||
s.dialogs.m[recipient.OwnerUserID] = peerList
|
||
}
|
||
}
|
||
|
||
func (s *MessageStore) privateUnreadCountLocked(ownerUserID int64, peer domain.Peer) int {
|
||
readMax := 0
|
||
if s.dialogs != nil {
|
||
if list, ok := s.dialogs.m[ownerUserID]; ok {
|
||
for _, dialog := range list.Dialogs {
|
||
if dialog.Peer == peer {
|
||
readMax = dialog.ReadInboxMaxID
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
unread := 0
|
||
for _, msg := range s.m[ownerUserID] {
|
||
if msg.Peer == peer && !msg.Out && msg.ID > readMax {
|
||
unread++
|
||
}
|
||
}
|
||
return unread
|
||
}
|