package files import ( "context" "errors" "fmt" "go.uber.org/zap" "telesrv/internal/domain" ) // RetentionPurgeMessageEditor is the private-message capability the storage // retention sweep needs to turn a hard-retention/eviction blob purge into a // visible messageActionCustomAction notice. Satisfied by // internal/app/messages.Service (GetMessages resolves the box's peer -- // EditMessageRequest requires it, and message_box ref_key only encodes // owner_user_id+box_id -- then EditMessage performs the actual in-place // edit with RetentionPurge set). type RetentionPurgeMessageEditor interface { GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) EditMessage(ctx context.Context, userID int64, req domain.EditMessageRequest) (domain.EditMessageResult, error) } // RetentionPurgeChannelEditor is the channel-message counterpart of // RetentionPurgeMessageEditor. Satisfied by internal/app/channels.Service. // Unlike private messages, EditChannelMessageRequest needs no peer lookup -- // channelMessageRefKey already encodes the channel id directly. Uses // EditChannelMessageInternal (not the ordinary EditMessage) since this is a // server-internal edit with no acting user/channel member behind it. type RetentionPurgeChannelEditor interface { EditChannelMessageInternal(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) } // SetRetentionPurgeNotifier wires the private-message/channel-message edit // capability the storage retention sweep needs to turn a hard-retention/ // eviction blob purge into a visible messageActionCustomAction notice on // every message still embedding the purged document/photo. Both app-layer // services are constructed after this Service in cmd/telesrv/main.go (and // the background retention sweep goroutine is started before either exists), // so this is a post-construction setter rather than a NewService option: a // sweep tick that races ahead of this call simply finds both fields nil and // skips the notice for that tick, same as any other per-reference failure // below (best-effort -- the underlying blob purge has already committed and // must never be undone or retried because of a notice failure). func (s *Service) SetRetentionPurgeNotifier(messages RetentionPurgeMessageEditor, channels RetentionPurgeChannelEditor) { s.retentionNotifyMu.Lock() defer s.retentionNotifyMu.Unlock() s.retentionMessages = messages s.retentionChannels = channels } func (s *Service) retentionNotifier() (RetentionPurgeMessageEditor, RetentionPurgeChannelEditor) { s.retentionNotifyMu.RLock() defer s.retentionNotifyMu.RUnlock() return s.retentionMessages, s.retentionChannels } // notifyRetentionPurge turns a just-completed hard-retention/eviction blob // purge of a document/photo into a visible service-message notice on every // message that still embeds it. profile_photo/sticker_set/gift references // have no message to edit and are skipped. Best-effort throughout: a lookup // or edit failure is logged and never propagated -- the underlying blob purge // has already committed and must not be undone or retried because of this. func (s *Service) notifyRetentionPurge(ctx context.Context, kind domain.MediaKind, mediaID int64) { messages, channels := s.retentionNotifier() if messages == nil && channels == nil { return } store, ok := s.media.(mediaRetentionStore) if !ok { return } refs, err := store.ListMediaReferences(ctx, kind, mediaID) if err != nil { s.log.Warn("list media references for retention purge notice failed", zap.String("media_kind", string(kind)), zap.Int64("media_id", mediaID), zap.Error(err)) return } for _, ref := range refs { switch ref.RefKind { case domain.MediaRefKindMessageBox: s.notifyRetentionPurgeMessageBox(ctx, messages, ref.RefKey) case domain.MediaRefKindChannelMessage: s.notifyRetentionPurgeChannelMessage(ctx, channels, ref.RefKey) case domain.MediaRefKindProfilePhoto, domain.MediaRefKindStickerSet, domain.MediaRefKindGift: // No message to edit for these ref kinds. } } } // notifyRetentionPurgeMessageBox parses a message_box ref_key (format // "user::box:", see postgres.messageBoxRefKey -- kept // in sync by convention, not a shared symbol, since the ref_key encoding is // an internal storage detail of that package) and edits the box in place. func (s *Service) notifyRetentionPurgeMessageBox(ctx context.Context, messages RetentionPurgeMessageEditor, refKey string) { if messages == nil { return } ownerUserID, boxID, ok := parseMessageBoxRefKey(refKey) if !ok { s.log.Warn("unparseable message_box retention purge ref_key", zap.String("ref_key", refKey)) return } // EditMessageRequest requires the peer explicitly (an ordinary edit's RPC // caller always supplies it) -- resolve it from the box row itself since // this is a server-internal edit with no client request behind it. list, err := messages.GetMessages(ctx, ownerUserID, []int{boxID}) if err != nil { s.log.Warn("resolve message_box peer for retention purge notice failed", zap.Int64("owner_user_id", ownerUserID), zap.Int("box_id", boxID), zap.Error(err)) return } if len(list.Messages) == 0 { // Box no longer visible to its owner (deleted) -- nothing to notice. return } peer := list.Messages[0].Peer _, err = messages.EditMessage(ctx, ownerUserID, domain.EditMessageRequest{ OwnerUserID: ownerUserID, Peer: peer, ID: boxID, SetRichMessage: true, RichMessage: nil, Media: retentionPurgeNoticeMedia(), RetentionPurge: true, }) if err != nil && !errors.Is(err, domain.ErrMessageNotModified) && !errors.Is(err, domain.ErrMessageIDInvalid) { s.log.Warn("retention purge notice edit failed (message_box)", zap.Int64("owner_user_id", ownerUserID), zap.Int("box_id", boxID), zap.Error(err)) } } // notifyRetentionPurgeChannelMessage parses a channel_message ref_key // (format "channel::msg:", see // postgres.channelMessageRefKey) and edits the message in place. Unlike // private messages, no peer lookup is needed: EditChannelMessageRequest only // needs the channel id, already encoded in the ref_key. func (s *Service) notifyRetentionPurgeChannelMessage(ctx context.Context, channels RetentionPurgeChannelEditor, refKey string) { if channels == nil { return } channelID, messageID, ok := parseChannelMessageRefKey(refKey) if !ok { s.log.Warn("unparseable channel_message retention purge ref_key", zap.String("ref_key", refKey)) return } _, err := channels.EditChannelMessageInternal(ctx, domain.EditChannelMessageRequest{ ChannelID: channelID, ID: messageID, RetentionPurge: true, RetentionPurgeAction: &domain.ChannelMessageAction{Type: domain.ChannelActionCustomText, Text: domain.RetentionPurgeNoticeText}, }) if err != nil && !errors.Is(err, domain.ErrMessageNotModified) && !errors.Is(err, domain.ErrMessageIDInvalid) { s.log.Warn("retention purge notice edit failed (channel_message)", zap.Int64("channel_id", channelID), zap.Int("message_id", messageID), zap.Error(err)) } } // retentionPurgeNoticeMedia builds the messageActionCustomAction service // media payload private-message edits replace a purged message's media with. func retentionPurgeNoticeMedia() *domain.MessageMedia { return &domain.MessageMedia{ Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ Kind: domain.MessageServiceActionCustomText, Text: domain.RetentionPurgeNoticeText, }, } } // parseMessageBoxRefKey parses "user::box:". // fmt.Sscanf reports success and silently ignores unconsumed trailing input // (e.g. "user:1:box:2garbage" would still scan fine), so a bare cnt/err check // isn't enough -- round-trip the parsed ids back through the same format // (messageBoxRefKey's own layout) and require an exact match to reject any // malformed/truncated/trailing-garbage key. Defensive only: ref_key is // server-written and never externally supplied, but a bug or future // encoding change should fail closed here, not silently misdirect an edit at // the wrong box. func parseMessageBoxRefKey(refKey string) (ownerUserID int64, boxID int, ok bool) { cnt, err := fmt.Sscanf(refKey, "user:%d:box:%d", &ownerUserID, &boxID) if err != nil || cnt != 2 || ownerUserID == 0 || boxID == 0 { return 0, 0, false } if fmt.Sprintf("user:%d:box:%d", ownerUserID, boxID) != refKey { return 0, 0, false } return ownerUserID, boxID, true } // parseChannelMessageRefKey parses "channel::msg:". // See parseMessageBoxRefKey's doc comment -- same round-trip defense against // trailing-garbage input Sscanf alone would silently accept. func parseChannelMessageRefKey(refKey string) (channelID int64, messageID int, ok bool) { cnt, err := fmt.Sscanf(refKey, "channel:%d:msg:%d", &channelID, &messageID) if err != nil || cnt != 2 || channelID == 0 || messageID == 0 { return 0, 0, false } if fmt.Sprintf("channel:%d:msg:%d", channelID, messageID) != refKey { return 0, 0, false } return channelID, messageID, true }