package memory import ( "context" "errors" "fmt" "strings" "telesrv/internal/domain" "telesrv/internal/store" "time" ) func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } if strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero() { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } var fingerprint []byte var err error if req.RandomID != 0 { fingerprint, err = store.ChannelSendFingerprint(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.ChannelID, SenderUserID: req.UserID, RandomID: req.RandomID, IdempotencyFingerprint: fingerprint, }); replayErr != nil || found { return replay, replayErr } } channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) if errors.Is(err, domain.ErrChannelPrivate) { if candidate, ok := s.channels[req.ChannelID]; ok && !candidate.Deleted { guestMember, guest, guestErr := s.linkedDiscussionGuestLocked(req.UserID, candidate) switch { case guestErr != nil: err = guestErr case guest: channel = candidate member = guestMember err = nil default: // A clean "not a linked guest" result is not authorization. // Preserve the original private-member error and fail closed. err = domain.ErrChannelPrivate } } } if err != nil { return domain.SendChannelMessageResult{}, err } if member.Guest && channel.JoinToSend { return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden } if req.Date == 0 { req.Date = int(time.Now().Unix()) } fromBoostsApplied := 0 if channel.Megagroup { fromBoostsApplied = s.selfBoostsAppliedLocked(req.UserID, req.ChannelID, req.Date) } if domain.ChannelBannedRightsBlockMessage(req, channel, member, fromBoostsApplied) { return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden } if !canSendChannelMessageWithBoost(channel, member, fromBoostsApplied) { return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden } if wait := channelSlowModeWait(channel, member, req.Date); wait > 0 { return domain.SendChannelMessageResult{}, domain.NewSlowModeWaitError(wait) } replyTo, err := s.resolveChannelReplyLocked(req, member, channel, fromBoostsApplied) if err != nil { return domain.SendChannelMessageResult{}, err } var sendAs *domain.Peer if req.SendAs != nil { p := *req.SendAs sendAs = &p } pts := s.nextChannelPtsLocked(req.ChannelID) msgID := s.nextChannelMessageIDLocked(req.ChannelID) skipDelivery := channelDeliverySkipSet(req.SkipDeliveryUserIDs) var discussion *domain.SendChannelDiscussionResult var discussionRef *domain.ChannelDiscussionRef if channel.Broadcast && channel.LinkedChatID != 0 { if linked, ok := s.channels[channel.LinkedChatID]; ok && !linked.Deleted && linked.Megagroup { discussionPts := s.nextChannelPtsLocked(linked.ID) discussionMsgID := s.nextChannelMessageIDLocked(linked.ID) discussionRef = &domain.ChannelDiscussionRef{ChannelID: linked.ID, MessageID: discussionMsgID} discussionMsg := domain.ChannelMessage{ ChannelID: linked.ID, ID: discussionMsgID, SenderUserID: req.UserID, From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Date: req.Date, Silent: req.Silent, NoForwards: req.NoForwards || channel.NoForwards || linked.NoForwards, Body: req.Message, Entities: append([]domain.MessageEntity(nil), req.Entities...), RichMessage: cloneRichMessage(req.RichMessage), Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Date: req.Date, ChannelPost: msgID, SavedFrom: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, SavedFromMsgID: msgID}, ViaBotID: req.ViaBotID, GroupedID: req.GroupedID, ReplyMarkup: cloneReplyMarkup(req.ReplyMarkup), Pts: discussionPts, } discussionEvent := domain.ChannelUpdateEvent{ ChannelID: linked.ID, Type: domain.ChannelUpdateNewMessage, Pts: discussionPts, PtsCount: 1, Date: req.Date, Message: cloneChannelMessage(discussionMsg), } s.messages[linked.ID] = append(s.messages[linked.ID], discussionMsg) s.appendChannelEventLocked(discussionEvent) linked.TopMessageID = discussionMsgID linked.Pts = discussionPts s.channels[linked.ID] = linked s.addChannelUnreadMentionsLocked(linked.ID, discussionMsg, req.UserID, req.MentionUserIDs) for userID, member := range s.members[linked.ID] { if member.Status == domain.ChannelMemberActive { s.upsertChannelDialogLocked(userID, linked, discussionMsg, false) } } discussion = &domain.SendChannelDiscussionResult{ Channel: cloneChannel(linked), Message: cloneChannelMessage(discussionMsg), Event: cloneChannelEvent(discussionEvent), Recipients: s.activeMemberIDsLocked(linked.ID, 0, 0), MentionUserIDs: append([]int64(nil), req.MentionUserIDs...), } } } msg := domain.ChannelMessage{ ChannelID: req.ChannelID, ID: msgID, RandomID: req.RandomID, SenderUserID: req.UserID, From: domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}, Date: req.Date, Post: channel.Broadcast, PostAuthor: memoryChannelPostAuthor(channel, req.PostAuthor), Silent: req.Silent, NoForwards: req.NoForwards || channel.NoForwards, Body: req.Message, Entities: append([]domain.MessageEntity(nil), req.Entities...), Media: req.Media, RichMessage: cloneRichMessage(req.RichMessage), ReplyTo: replyTo, Forward: cloneMessageForward(req.Forward), ViaBotID: req.ViaBotID, GroupedID: req.GroupedID, ReplyMarkup: cloneReplyMarkup(req.ReplyMarkup), SendAs: sendAs, Discussion: discussionRef, Action: cloneChannelMessageAction(req.Action), FromBoostsApplied: fromBoostsApplied, Pts: pts, } msg.Replies = s.channelMessageRepliesLocked(req.UserID, req.ChannelID, msg) var sendSnapshot []byte if req.RandomID != 0 { sendSnapshot, err = store.EncodeChannelSendSnapshot(msg) if err != nil { return domain.SendChannelMessageResult{}, err } } event := domain.ChannelUpdateEvent{ ChannelID: req.ChannelID, Type: domain.ChannelUpdateNewMessage, Pts: pts, PtsCount: 1, Date: req.Date, Message: cloneChannelMessage(msg), SenderUserID: req.UserID, } s.messages[req.ChannelID] = append(s.messages[req.ChannelID], msg) s.appendChannelEventLocked(event) if !channel.Broadcast || channel.Megagroup { mentionTargets := req.MentionUserIDs if msg.ReplyTo != nil && msg.ReplyTo.MessageID > 0 { if target, ok := s.findMessageLocked(req.ChannelID, msg.ReplyTo.MessageID); ok && target.SenderUserID != 0 && target.SenderUserID != req.UserID { mentionTargets = append(append([]int64(nil), mentionTargets...), target.SenderUserID) } } s.addChannelUnreadMentionsLocked(req.ChannelID, msg, req.UserID, mentionTargets) } s.updateForumTopicTopMessageLocked(req.ChannelID, msg) if channel.Broadcast { s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ ChannelID: req.ChannelID, UserID: req.UserID, Date: req.Date, Type: domain.ChannelAdminLogSendMessage, Message: ptrChannelMessage(msg), Query: msg.Body, }) } if req.RandomID != 0 { key := channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID} s.randomToID[key] = msg.ID replayKey := channelMessageReplayKey{channelID: req.ChannelID, messageID: msg.ID} s.sendSnapshots[replayKey] = sendSnapshot s.sendFingerprints[replayKey] = append([]byte(nil), fingerprint...) } channel.TopMessageID = msg.ID channel.Pts = pts s.channels[req.ChannelID] = channel if !member.Guest { member.SlowmodeLastSendDate = req.Date s.members[req.ChannelID][req.UserID] = member } for userID, member := range s.members[req.ChannelID] { if member.Status == domain.ChannelMemberActive { if _, skip := skipDelivery[userID]; skip && userID != req.UserID { member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, msg.ID) member.UnreadMark = false s.members[req.ChannelID][userID] = member continue } s.upsertChannelDialogLocked(userID, channel, msg, userID == req.UserID) } } recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) recipients = filterSkippedChannelRecipients(recipients, skipDelivery) return domain.SendChannelMessageResult{ Channel: channel, Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event), Recipients: recipients, Discussion: discussion, MentionUserIDs: append([]int64(nil), req.MentionUserIDs...), SkipDeliveryUserIDs: append([]int64(nil), req.SkipDeliveryUserIDs...), }, nil } // LookupChannelSendReplay returns a committed regular-channel or monoforum send receipt without // evaluating current membership, write permissions, slow mode or any message allocation path. func (s *ChannelStore) LookupChannelSendReplay(_ context.Context, req domain.ChannelSendReplayRequest) (domain.SendChannelMessageResult, bool, error) { if req.ChannelID == 0 || req.SenderUserID == 0 || req.RandomID == 0 { return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory channel send replay: invalid scope") } if req.SavedPeer.ID == 0 { if req.SavedPeer.Type != "" { return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory channel send replay: incomplete saved peer scope") } } else if req.SavedPeer.Type != domain.PeerTypeUser { return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory channel send replay: invalid saved peer scope") } if err := store.ValidateSendFingerprint(req.IdempotencyFingerprint, "channel send replay"); err != nil { return domain.SendChannelMessageResult{}, false, err } s.mu.RLock() defer s.mu.RUnlock() return s.lookupChannelSendReplayLocked(req) } func (s *ChannelStore) lookupChannelSendReplayLocked(req domain.ChannelSendReplayRequest) (domain.SendChannelMessageResult, bool, error) { var id int if req.SavedPeer.ID == 0 { var found bool id, found = s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.SenderUserID, randomID: req.RandomID}] if !found { return domain.SendChannelMessageResult{}, false, nil } } else { msg, found := s.findMonoforumDuplicateLocked(req.ChannelID, req.SenderUserID, req.SavedPeer, req.RandomID) if !found { return domain.SendChannelMessageResult{}, false, nil } id = msg.ID } replayKey := channelMessageReplayKey{channelID: req.ChannelID, messageID: id} if !store.SameSendFingerprint(s.sendFingerprints[replayKey], req.IdempotencyFingerprint) { return domain.SendChannelMessageResult{}, false, domain.ErrMessageRandomIDDuplicate } first, err := store.DecodeChannelSendSnapshot(s.sendSnapshots[replayKey]) if err != nil { return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message snapshot: %w", err) } if first.ID != id || first.ChannelID != req.ChannelID || first.SenderUserID != req.SenderUserID || first.RandomID != req.RandomID || first.SavedPeer != req.SavedPeer { return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message snapshot disagrees with random_id receipt") } replay := first var replayDelete *domain.ChannelUpdateEvent if current, found := s.findMessageLocked(req.ChannelID, id); found && !current.Deleted { replay = cloneChannelMessage(current) } else if receipt := s.deleteReceipts[replayKey]; receipt != nil { cloned := cloneChannelEvent(*receipt) replayDelete = &cloned } else { return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message %d is absent without a durable delete receipt", id) } channel, ok := s.channels[req.ChannelID] if !ok { return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message %d has no channel", id) } event := domain.ChannelUpdateEvent{ ChannelID: first.ChannelID, Type: domain.ChannelUpdateNewMessage, Pts: first.Pts, PtsCount: 1, Date: first.Date, Message: cloneChannelMessage(replay), SenderUserID: first.SenderUserID, } result := domain.SendChannelMessageResult{ Channel: cloneChannel(channel), Message: cloneChannelMessage(replay), Event: event, Duplicate: true, ReplayDeleteEvent: replayDelete, } return result, true, nil } func channelDeliverySkipSet(ids []int64) map[int64]struct{} { if len(ids) == 0 { return nil } out := make(map[int64]struct{}, len(ids)) for _, id := range ids { if id != 0 { out[id] = struct{}{} } } return out } func filterSkippedChannelRecipients(recipients []int64, skip map[int64]struct{}) []int64 { if len(recipients) == 0 || len(skip) == 0 { return recipients } out := recipients[:0] for _, id := range recipients { if _, hidden := skip[id]; hidden { continue } out = append(out, id) } return out } func (s *ChannelStore) nextChannelMessageIDLocked(channelID int64) int { s.msgSeq[channelID]++ return s.msgSeq[channelID] } func (s *ChannelStore) appendChannelServiceMessageLocked(channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent) { channel := s.channels[channelID] msgID := s.nextChannelMessageIDLocked(channelID) pts := s.nextChannelPtsLocked(channelID) msg := domain.ChannelMessage{ ChannelID: channelID, ID: msgID, SenderUserID: senderUserID, From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, Date: date, Post: channel.Broadcast, Action: &action, Pts: pts, } event := domain.ChannelUpdateEvent{ ChannelID: channelID, Type: domain.ChannelUpdateNewMessage, Pts: pts, PtsCount: 1, Date: date, Message: cloneChannelMessage(msg), SenderUserID: senderUserID, UserIDs: append([]int64(nil), action.UserIDs...), } s.messages[channelID] = append(s.messages[channelID], msg) s.appendChannelEventLocked(event) return msg, event } func canSendChannelMessage(channel domain.Channel, member domain.ChannelMember) bool { return canSendChannelMessageWithBoost(channel, member, 0) } func canSendChannelMessageWithBoost(channel domain.Channel, member domain.ChannelMember, selfBoostsApplied int) bool { if channel.Broadcast { return canPostToBroadcast(member) } if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { return true } if member.BannedRights.SendMessages { return false } if !channel.DefaultBannedRights.SendMessages { return true } return channel.BoostsUnrestrict > 0 && selfBoostsApplied >= channel.BoostsUnrestrict } // memoryChannelPostAuthor 仅在 signatures 开启的 broadcast post 上保留签名。 func memoryChannelPostAuthor(channel domain.Channel, author string) string { if !channel.Broadcast || !channel.Signatures { return "" } return author }