package memory import ( "context" "sort" "strings" "telesrv/internal/domain" ) func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 { return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid } s.mu.Lock() defer s.mu.Unlock() channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) if err != nil { return domain.EditChannelMessageResult{}, err } member := s.members[req.ChannelID][req.UserID] idx, ok := s.findMessageIndexLocked(req.ChannelID, req.ID) if !ok || s.messages[req.ChannelID][idx].Deleted || s.messages[req.ChannelID][idx].Action != nil { return domain.EditChannelMessageResult{}, domain.ErrMessageIDInvalid } prevMsg := s.messages[req.ChannelID][idx] msg := prevMsg finalMedia := msg.Media if req.Media != nil { finalMedia = req.Media } finalRich := msg.RichMessage if req.SetRichMessage { finalRich = req.RichMessage } if strings.TrimSpace(req.Message) == "" && finalMedia.IsZero() && finalRich.IsZero() { return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid } // WebPageResolve:频道链接预览就地替换(服务端内部,幂等守卫即授权)。只换 media、 // 不碰 body/entities/edit_date,事件为 channel_web_page。 if req.WebPageResolve { if req.Media == nil || !domain.IsPendingWebPageMedia(msg.Media, req.ExpectedWebPageID) { return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified } pts := s.nextChannelPtsLocked(req.ChannelID) media := *req.Media msg.Media = &media msg.Pts = pts s.messages[req.ChannelID][idx] = msg channel.Pts = pts s.channels[req.ChannelID] = channel event := domain.ChannelUpdateEvent{ ChannelID: req.ChannelID, Type: domain.ChannelUpdateWebPage, Pts: pts, PtsCount: 1, Date: msg.Date, Message: cloneChannelMessage(msg), SenderUserID: req.UserID, } s.appendChannelEventLocked(event) return domain.EditChannelMessageResult{ Channel: channel, Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event), Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), }, nil } // participant todo 协作须满足与正常发消息相同的权限,禁言成员/无发帖权订阅者 // 不得借 OthersCanComplete/Append 绕过发言限制(与 postgres 对齐)。 participantTodoEdit := isChannelTodoParticipantEdit(req, msg) && canSendChannelMessage(channel, member) viaBotEditRequested := req.ViaBotEditBotID != 0 viaBotEdit := viaBotEditRequested && msg.ViaBotID == req.ViaBotEditBotID if viaBotEditRequested && !viaBotEdit { return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired } if !viaBotEdit && msg.SenderUserID != req.UserID && !canEditChannelMessage(member) && !participantTodoEdit { return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired } richChanged := req.SetRichMessage && !richMessagesEqual(msg.RichMessage, req.RichMessage) if req.Media == nil && !req.SetReplyMarkup && !richChanged && msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified } pts := s.nextChannelPtsLocked(req.ChannelID) msg.Body = req.Message msg.Entities = append([]domain.MessageEntity(nil), req.Entities...) if req.Media != nil { media := *req.Media msg.Media = &media } if req.SetReplyMarkup { msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup) } if req.SetRichMessage { msg.RichMessage = cloneRichMessage(req.RichMessage) } msg.EditDate = req.EditDate msg.Pts = pts s.messages[req.ChannelID][idx] = msg channel.Pts = pts s.channels[req.ChannelID] = channel event := domain.ChannelUpdateEvent{ ChannelID: req.ChannelID, Type: domain.ChannelUpdateEditMessage, Pts: pts, PtsCount: 1, Date: req.EditDate, Message: cloneChannelMessage(msg), SenderUserID: req.UserID, } s.appendChannelEventLocked(event) s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ ChannelID: req.ChannelID, UserID: req.UserID, Date: req.EditDate, Type: domain.ChannelAdminLogEditMessage, PrevMessage: ptrChannelMessage(prevMsg), NewMessage: ptrChannelMessage(msg), Query: msg.Body, }) var serviceMsg domain.ChannelMessage var serviceEvent domain.ChannelUpdateEvent if req.TodoServiceAction != nil { action := cloneChannelMessageAction(req.TodoServiceAction) if action == nil { return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid } replyTo := channelTodoServiceReply(msg) servicePts := s.nextChannelPtsLocked(req.ChannelID) serviceMsg = domain.ChannelMessage{ ChannelID: req.ChannelID, ID: s.nextChannelMessageIDLocked(req.ChannelID), SenderUserID: req.UserID, From: domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}, Date: req.EditDate, Post: channel.Broadcast, Silent: msg.Silent, NoForwards: msg.NoForwards || channel.NoForwards, ReplyTo: replyTo, Action: action, Pts: servicePts, } serviceEvent = domain.ChannelUpdateEvent{ ChannelID: req.ChannelID, Type: domain.ChannelUpdateNewMessage, Pts: servicePts, PtsCount: 1, Date: req.EditDate, Message: cloneChannelMessage(serviceMsg), SenderUserID: req.UserID, } s.messages[req.ChannelID] = append(s.messages[req.ChannelID], serviceMsg) s.appendChannelEventLocked(serviceEvent) s.updateForumTopicTopMessageLocked(req.ChannelID, serviceMsg) channel.TopMessageID = serviceMsg.ID channel.Pts = servicePts s.channels[req.ChannelID] = channel for userID, member := range s.members[req.ChannelID] { if member.Status == domain.ChannelMemberActive { s.upsertChannelDialogLocked(userID, channel, serviceMsg, userID == req.UserID) } } } // 编辑对账 @ 集合:新增者补提及,被移除的实体提及删除;reply 隐式提及保留。 // 仅在 body/entities 实际变化时执行:media-only 编辑(geolive/todo/poll)不带 // MentionUserIDs,无条件对账会误删 caption 里仍未读的 @ 提及(与 postgres 对齐)。 textChanged := prevMsg.Body != req.Message || !sameMessageEntities(prevMsg.Entities, req.Entities) if textChanged && (!channel.Broadcast || channel.Megagroup) { keep := make(map[int64]struct{}, len(req.MentionUserIDs)+1) for _, id := range req.MentionUserIDs { if id != 0 { keep[id] = struct{}{} } } if msg.ReplyTo != nil && msg.ReplyTo.MessageID > 0 { if target, ok := s.findMessageLocked(req.ChannelID, msg.ReplyTo.MessageID); ok && target.SenderUserID != 0 { keep[target.SenderUserID] = struct{}{} } } added := make([]int64, 0, len(req.MentionUserIDs)) for _, id := range req.MentionUserIDs { if id == 0 { continue } if _, ok := s.mentions[id][req.ChannelID][msg.ID]; !ok { added = append(added, id) } } for userID, byChannel := range s.mentions { if _, ok := byChannel[req.ChannelID][msg.ID]; !ok { continue } if _, ok := keep[userID]; ok { continue } delete(byChannel[req.ChannelID], msg.ID) if dialogs := s.dialogs[userID]; dialogs != nil { if dialog, ok := dialogs[req.ChannelID]; ok { dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(userID, req.ChannelID, 0) dialogs[req.ChannelID] = dialog } } } if len(added) > 0 { s.addChannelUnreadMentionsLocked(req.ChannelID, msg, req.UserID, added) for _, id := range added { if dialogs := s.dialogs[id]; dialogs != nil { if dialog, ok := dialogs[req.ChannelID]; ok { dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(id, req.ChannelID, 0) dialogs[req.ChannelID] = dialog } } } } } return domain.EditChannelMessageResult{ Channel: channel, Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event), ServiceMessage: cloneChannelMessage(serviceMsg), ServiceEvent: cloneChannelEvent(serviceEvent), Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), }, nil } func isChannelTodoParticipantEdit(req domain.EditChannelMessageRequest, msg domain.ChannelMessage) bool { if !req.AllowTodoParticipantMutation || req.SetReplyMarkup || req.Media == nil || req.Media.Kind != domain.MessageMediaKindTodo || req.Media.Todo == nil { return false } if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindTodo || msg.Media.Todo == nil { return false } if req.TodoServiceAction == nil { return false } switch req.TodoServiceAction.Type { case domain.ChannelActionTodoCompletions: if !msg.Media.Todo.OthersCanComplete { return false } case domain.ChannelActionTodoAppendTasks: if !msg.Media.Todo.OthersCanAppend { return false } default: return false } return msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) } func channelTodoServiceReply(msg domain.ChannelMessage) *domain.MessageReply { reply := &domain.MessageReply{ Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: msg.ChannelID}, MessageID: msg.ID, } if msg.ReplyTo != nil { reply.TopMessageID = msg.ReplyTo.TopMessageID reply.ForumTopic = msg.ReplyTo.ForumTopic } return reply } func (s *ChannelStore) UpdatePinnedMessage(_ context.Context, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid } s.mu.Lock() defer s.mu.Unlock() channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) if err != nil { return domain.UpdateChannelPinnedMessageResult{}, err } member := s.members[req.ChannelID][req.UserID] if !canPinChannelMessages(channel, member) { return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelAdminRequired } msg, ok := s.findMessageLocked(req.ChannelID, req.MessageID) if !ok || msg.Deleted { return domain.UpdateChannelPinnedMessageResult{}, domain.ErrMessageIDInvalid } // 多置顶模型:pin/unpin 只翻转目标消息自身的 pinned flag,不影响其它 // 置顶;与官方一致不设数量上限。 if msg.Pinned == req.Pinned { return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelNotModified } for i := range s.messages[req.ChannelID] { if s.messages[req.ChannelID][i].ID == req.MessageID { s.messages[req.ChannelID][i].Pinned = req.Pinned break } } pts := s.nextChannelPtsLocked(req.ChannelID) channel.PinnedMessageID = s.latestPinnedMessageIDLocked(req.ChannelID) channel.Pts = pts s.channels[req.ChannelID] = channel event := domain.ChannelUpdateEvent{ ChannelID: req.ChannelID, Type: domain.ChannelUpdatePinnedMessages, Pts: pts, PtsCount: 1, Date: req.Date, MessageIDs: []int{req.MessageID}, SenderUserID: req.UserID, Pinned: req.Pinned, } s.appendChannelEventLocked(event) logMsg := msg logMsg.Pinned = req.Pinned s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ ChannelID: req.ChannelID, UserID: req.UserID, Date: req.Date, Type: domain.ChannelAdminLogUpdatePinned, Message: ptrChannelMessage(logMsg), Query: msg.Body, }) return domain.UpdateChannelPinnedMessageResult{ Channel: channel, Event: cloneChannelEvent(event), Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), }, nil } // latestPinnedMessageIDLocked 返回最新置顶消息 id(channelFull.pinned_msg_id 缓存语义)。 func (s *ChannelStore) latestPinnedMessageIDLocked(channelID int64) int { latest := 0 for _, m := range s.messages[channelID] { if m.Pinned && !m.Deleted && m.ID > latest { latest = m.ID } } return latest } // UnpinAllChannelMessages 清空全部置顶并以单条 channel 事件携带全部 id。 func (s *ChannelStore) UnpinAllChannelMessages(_ context.Context, req domain.UnpinAllChannelMessagesRequest) (domain.UpdateChannelPinnedMessageResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid } s.mu.Lock() defer s.mu.Unlock() channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) if err != nil { return domain.UpdateChannelPinnedMessageResult{}, err } member := s.members[req.ChannelID][req.UserID] if !canPinChannelMessages(channel, member) { return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelAdminRequired } cleared := []int(nil) for i := range s.messages[req.ChannelID] { if s.messages[req.ChannelID][i].Pinned && !s.messages[req.ChannelID][i].Deleted { s.messages[req.ChannelID][i].Pinned = false cleared = append(cleared, s.messages[req.ChannelID][i].ID) } } if len(cleared) == 0 { return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelNotModified } sort.Ints(cleared) pts := s.nextChannelPtsLocked(req.ChannelID) channel.PinnedMessageID = 0 channel.Pts = pts s.channels[req.ChannelID] = channel event := domain.ChannelUpdateEvent{ ChannelID: req.ChannelID, Type: domain.ChannelUpdatePinnedMessages, Pts: pts, PtsCount: 1, Date: req.Date, MessageIDs: cleared, SenderUserID: req.UserID, Pinned: false, } s.appendChannelEventLocked(event) return domain.UpdateChannelPinnedMessageResult{ Channel: channel, Event: cloneChannelEvent(event), Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), }, nil } // ClearDanglingPinnedMessage 把指向已删除消息的置顶值清零(unpinAll 自愈)。 func (s *ChannelStore) ClearDanglingPinnedMessage(_ context.Context, channelID int64, messageID int) error { if channelID == 0 || messageID <= 0 { return domain.ErrChannelInvalid } s.mu.Lock() defer s.mu.Unlock() channel, ok := s.channels[channelID] if !ok { return domain.ErrChannelInvalid } if channel.PinnedMessageID == messageID { channel.PinnedMessageID = 0 s.channels[channelID] = channel } return nil } func canPinChannelMessages(channel domain.Channel, member domain.ChannelMember) bool { if member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) { return true } return channel.Megagroup && !channel.DefaultBannedRights.PinMessages && !member.BannedRights.PinMessages } func canEditChannelMessage(member domain.ChannelMember) bool { return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.EditMessages) }