package memory import ( "context" "sort" "strings" "time" "telesrv/internal/domain" "telesrv/internal/store" ) const paidMessageChannelCommissionPermille int64 = 850 // SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。 // 与 postgres 行为一致:复用 channel pts/事件;订阅者无需成员记录且只能写自己的 saved_peer, // 母频道管理员可以回复任意订阅者。 func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) { if req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 || req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" && req.Media == nil { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } var fingerprint []byte var err error if req.RandomID != 0 { fingerprint, err = store.MonoforumSendFingerprint(req) if err != nil { return domain.SendChannelMessageResult{}, err } req.IdempotencyFingerprint = fingerprint } s.mu.Lock() defer s.mu.Unlock() if req.RandomID != 0 { if replay, found, replayErr := s.lookupChannelSendReplayLocked(domain.ChannelSendReplayRequest{ ChannelID: req.MonoforumID, SenderUserID: req.SenderUserID, SavedPeer: req.SavedPeer, RandomID: req.RandomID, IdempotencyFingerprint: fingerprint, }); replayErr != nil || found { return replay, replayErr } } channel, ok := s.channels[req.MonoforumID] if !ok || channel.Deleted || !channel.Monoforum { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } parent, ok := s.channels[channel.LinkedMonoforumID] if !ok || parent.Deleted || !parent.BroadcastMessagesAllowed || parent.LinkedMonoforumID != channel.ID { return domain.SendChannelMessageResult{}, domain.ErrChannelPrivate } parentMember, parentMemberOK := s.members[parent.ID][req.SenderUserID] isAdmin := parentMemberOK && parentMember.CanManageDirectMessages() if req.SenderUserID != req.SavedPeer.ID && !isAdmin { return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired } if req.ReplyTo != nil { if req.ReplyTo.MessageID <= 0 || req.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}) { return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid } found := false for _, candidate := range s.messages[channel.ID] { if candidate.ID == req.ReplyTo.MessageID && !candidate.Deleted && candidate.SavedPeer == req.SavedPeer { found = true break } } if !found { return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid } } // telesrv has no Stars economy: Direct Messages are always free, so no // balance is ever checked or debited here regardless of any stale // per-channel price. paidMessageStars := int64(0) from := domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} if isAdmin { from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID} } if req.Date == 0 { req.Date = int(time.Now().Unix()) } pts := s.nextChannelPtsLocked(req.MonoforumID) msgID := s.nextChannelMessageIDLocked(req.MonoforumID) msg := domain.ChannelMessage{ ChannelID: req.MonoforumID, ID: msgID, RandomID: req.RandomID, SenderUserID: req.SenderUserID, From: from, SavedPeer: req.SavedPeer, SuggestedPost: req.SuggestedPost, PaidMessageStars: paidMessageStars, Date: req.Date, Silent: req.Silent, NoForwards: req.NoForwards, Body: req.Message, Entities: append([]domain.MessageEntity(nil), req.Entities...), Media: req.Media, ReplyTo: req.ReplyTo, Forward: req.Forward, Pts: pts, } // Store owns the persisted snapshot; callers must not be able to mutate it through // SuggestedPost/Media pointers after SendMonoforumMessage returns. msg = cloneChannelMessage(msg) var sendSnapshot []byte if req.RandomID != 0 { var snapshotErr error sendSnapshot, snapshotErr = store.EncodeChannelSendSnapshot(msg) if snapshotErr != nil { return domain.SendChannelMessageResult{}, snapshotErr } } event := domain.ChannelUpdateEvent{ ChannelID: req.MonoforumID, Type: domain.ChannelUpdateNewMessage, Pts: pts, PtsCount: 1, Date: req.Date, Message: cloneChannelMessage(msg), SenderUserID: req.SenderUserID, } s.messages[req.MonoforumID] = append(s.messages[req.MonoforumID], msg) if req.RandomID != 0 { replayKey := channelMessageReplayKey{channelID: req.MonoforumID, messageID: msg.ID} s.sendSnapshots[replayKey] = sendSnapshot s.sendFingerprints[replayKey] = append([]byte(nil), fingerprint...) } s.appendChannelEventLocked(event) channel.TopMessageID = msgID channel.Pts = pts s.channels[req.MonoforumID] = channel recipients := []int64{req.SavedPeer.ID} for userID, member := range s.members[parent.ID] { if member.CanManageDirectMessages() { recipients = append(recipients, userID) } } return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event), Recipients: uniqueNonZero(recipients, 0)}, nil } // findMonoforumDuplicateLocked 按 (sender, saved_peer, random_id) 查 monoforum 子会话内的重发消息。 func (s *ChannelStore) findMonoforumDuplicateLocked(monoforumID, senderUserID int64, savedPeer domain.Peer, randomID int64) (domain.ChannelMessage, bool) { if randomID == 0 { return domain.ChannelMessage{}, false } msgs := s.messages[monoforumID] for i := len(msgs) - 1; i >= 0; i-- { m := msgs[i] if m.RandomID == randomID && m.SenderUserID == senderUserID && m.SavedPeer == savedPeer { return m, true } } return domain.ChannelMessage{}, false } // ListMonoforumHistory 拉取某订阅者(saved_peer)在 monoforum 内的私信历史,id 倒序分页。 func (s *ChannelStore) ListMonoforumHistory(_ context.Context, filter domain.MonoforumHistoryFilter) (domain.ChannelHistory, error) { if filter.MonoforumID == 0 || filter.SavedPeer.ID == 0 { return domain.ChannelHistory{}, domain.ErrChannelInvalid } s.mu.RLock() defer s.mu.RUnlock() channel, ok := s.channels[filter.MonoforumID] if !ok || !channel.Monoforum { return domain.ChannelHistory{}, domain.ErrChannelInvalid } limit := filter.Limit if limit <= 0 || limit > 100 { limit = 100 } all := s.messages[filter.MonoforumID] var msgs []domain.ChannelMessage count := 0 for i := len(all) - 1; i >= 0; i-- { m := all[i] if m.Deleted || m.SavedPeer != filter.SavedPeer { continue } count++ if filter.OffsetID > 0 && m.ID >= filter.OffsetID { continue } if len(msgs) < limit { msgs = append(msgs, cloneChannelMessage(m)) } } return domain.ChannelHistory{Messages: msgs, Count: count, Channel: cloneChannel(channel)}, nil } // ResolveMonoforumSend 按 id 取 monoforum 频道(不要求调用者是 monoforum 成员——订阅者私信频道时 // 并非 monoforum 成员),并返回调用者是否可管理其母广播频道的 Direct Messages。非 monoforum/不存在 → ErrChannelInvalid。 func (s *ChannelStore) ResolveMonoforumSend(_ context.Context, viewerUserID, monoforumID int64) (domain.Channel, bool, error) { if viewerUserID == 0 || monoforumID == 0 { return domain.Channel{}, false, domain.ErrChannelInvalid } s.mu.RLock() defer s.mu.RUnlock() mono, ok := s.channels[monoforumID] if !ok || mono.Deleted || !mono.Monoforum || mono.LinkedMonoforumID == 0 { return domain.Channel{}, false, domain.ErrChannelInvalid } member, ok := s.members[mono.LinkedMonoforumID][viewerUserID] isAdmin := ok && member.CanManageDirectMessages() return cloneChannel(mono), isAdmin, nil } // ListMonoforumDialogs 列出 monoforum 的订阅者子会话(每个 saved_peer 一条,取其 top 消息), // 按 top 消息 id 倒序分页。 func (s *ChannelStore) ListMonoforumDialogs(_ context.Context, filter domain.MonoforumDialogsFilter) (domain.MonoforumDialogList, error) { if filter.MonoforumID == 0 { return domain.MonoforumDialogList{}, domain.ErrChannelInvalid } s.mu.RLock() defer s.mu.RUnlock() channel, ok := s.channels[filter.MonoforumID] if !ok || !channel.Monoforum { return domain.MonoforumDialogList{}, domain.ErrChannelInvalid } limit := filter.Limit if limit <= 0 || limit > 100 { limit = 100 } tops := map[domain.Peer]domain.ChannelMessage{} for _, m := range s.messages[filter.MonoforumID] { if m.Deleted || m.SavedPeer.ID == 0 { continue } if cur, ok := tops[m.SavedPeer]; !ok || m.ID > cur.ID { tops[m.SavedPeer] = m } } ordered := make([]domain.ChannelMessage, 0, len(tops)) for _, m := range tops { ordered = append(ordered, m) } sort.Slice(ordered, func(i, j int) bool { return ordered[i].ID > ordered[j].ID }) out := domain.MonoforumDialogList{MonoforumID: filter.MonoforumID, Channel: cloneChannel(channel), Count: len(ordered)} for _, m := range ordered { if filter.OffsetID > 0 && m.ID >= filter.OffsetID { continue } if len(out.Dialogs) >= limit { break } out.Dialogs = append(out.Dialogs, domain.MonoforumDialog{SavedPeer: m.SavedPeer, TopMessageID: m.ID, TopMessageDate: m.Date}) out.Messages = append(out.Messages, cloneChannelMessage(m)) } return out, nil }