You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Open-IM-Server/internal/rpc/group/pinned_msg.go

272 lines
8.4 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// Copyright © 2026 OpenIM. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package group
import (
"context"
"time"
"github.com/openimsdk/open-im-server/v3/pkg/authverify"
"github.com/openimsdk/open-im-server/v3/pkg/common/servererrs"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
"github.com/openimsdk/open-im-server/v3/pkg/msgprocessor"
"github.com/openimsdk/protocol/constant"
pbgroup "github.com/openimsdk/protocol/group"
"github.com/openimsdk/protocol/sdkws"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/mcontext"
)
// 群置顶消息相关 RPC 实现:
// - 自动滚动保留最近 N 条置顶消息N=model.GroupPinnedMsgMaxKeep默认为 3
// - 置顶时把整条消息内容做完整快照存档,避免后续消息删除/撤回影响展示
// - 每条置顶记录拥有唯一 pinID作为 unpin 时的精准删除凭据
// - 权限:默认全员可置顶;当 group.AllowPinMsg=1 时,仅群主/管理员可置顶或取消置顶
const (
groupPinnedActionPin = int32(1)
groupPinnedActionUnpin = int32(2)
)
// PinGroupMessage 群聊中置顶单条消息
func (s *groupServer) PinGroupMessage(ctx context.Context, req *pbgroup.PinGroupMessageReq) (*pbgroup.PinGroupMessageResp, error) {
if req.GroupID == "" {
return nil, errs.ErrArgs.WrapMsg("groupID empty")
}
if req.Seq <= 0 {
return nil, errs.ErrArgs.WrapMsg("seq must be positive")
}
group, err := s.db.TakeGroup(ctx, req.GroupID)
if err != nil {
return nil, err
}
if group.Status == constant.GroupStatusDismissed {
return nil, servererrs.ErrDismissedAlready.Wrap()
}
if err := s.checkPinPermission(ctx, group); err != nil {
return nil, err
}
conversationID := msgprocessor.GetConversationIDBySessionType(constant.ReadGroupChatType, req.GroupID)
msgData, err := s.msgClient.GetSingleMsgBySeq(ctx, conversationID, req.Seq)
if err != nil {
return nil, err
}
if msgData == nil {
return nil, servererrs.ErrRecordNotFound.WrapMsg("message not found by seq")
}
if msgData.GroupID != "" && msgData.GroupID != req.GroupID {
return nil, errs.ErrArgs.WrapMsg("seq does not belong to this group")
}
if msgData.Status >= constant.MsgStatusHasDeleted {
return nil, servererrs.ErrRecordNotFound.WrapMsg("message has been deleted")
}
pin := buildPinSnapshot(req.GroupID, conversationID, mcontext.GetOpUserID(ctx), msgData)
pinnedList, err := s.db.PinGroupMessage(ctx, req.GroupID, pin)
if err != nil {
return nil, err
}
pbPinned := pinnedMsgDB2PB(pin)
pbList := pinnedListDB2PB(pinnedList)
s.notification.GroupMessagePinnedNotification(ctx, req.GroupID, groupPinnedActionPin, pbPinned, pbList)
return &pbgroup.PinGroupMessageResp{
PinnedMsg: pbPinned,
PinnedList: pbList,
}, nil
}
// UnpinGroupMessage 群聊中取消置顶单条消息pinID 优先;为空则按 seq
func (s *groupServer) UnpinGroupMessage(ctx context.Context, req *pbgroup.UnpinGroupMessageReq) (*pbgroup.UnpinGroupMessageResp, error) {
if req.GroupID == "" {
return nil, errs.ErrArgs.WrapMsg("groupID empty")
}
if req.PinID == "" && req.Seq <= 0 {
return nil, errs.ErrArgs.WrapMsg("either pinID or seq must be provided")
}
group, err := s.db.TakeGroup(ctx, req.GroupID)
if err != nil {
return nil, err
}
if group.Status == constant.GroupStatusDismissed {
return nil, servererrs.ErrDismissedAlready.Wrap()
}
if err := s.checkPinPermission(ctx, group); err != nil {
return nil, err
}
current, err := s.db.GetGroupPinnedMessages(ctx, req.GroupID)
if err != nil {
return nil, err
}
var target *model.GroupPinnedMessage
for _, m := range current {
if req.PinID != "" {
if m.PinID == req.PinID {
target = m
break
}
} else if m.Seq == req.Seq {
target = m
break
}
}
if target == nil {
return nil, servererrs.ErrRecordNotFound.WrapMsg("pinned message not found")
}
pinnedList, err := s.db.UnpinGroupMessage(ctx, req.GroupID, req.PinID, req.Seq)
if err != nil {
return nil, err
}
pbPinned := pinnedMsgDB2PB(target)
pbList := pinnedListDB2PB(pinnedList)
s.notification.GroupMessagePinnedNotification(ctx, req.GroupID, groupPinnedActionUnpin, pbPinned, pbList)
return &pbgroup.UnpinGroupMessageResp{PinnedList: pbList}, nil
}
// GetGroupPinnedMessages 获取群置顶消息列表
func (s *groupServer) GetGroupPinnedMessages(ctx context.Context, req *pbgroup.GetGroupPinnedMessagesReq) (*pbgroup.GetGroupPinnedMessagesResp, error) {
if req.GroupID == "" {
return nil, errs.ErrArgs.WrapMsg("groupID empty")
}
if err := s.checkAdminOrInGroup(ctx, req.GroupID); err != nil {
return nil, err
}
pinnedList, err := s.db.GetGroupPinnedMessages(ctx, req.GroupID)
if err != nil {
return nil, err
}
return &pbgroup.GetGroupPinnedMessagesResp{
PinnedList: pinnedListDB2PB(pinnedList),
}, nil
}
// checkPinPermission 校验当前操作者是否具备群消息置顶权限
func (s *groupServer) checkPinPermission(ctx context.Context, group *model.Group) error {
if authverify.IsAppManagerUid(ctx, s.config.Share.IMAdminUserID) {
return nil
}
opUserID := mcontext.GetOpUserID(ctx)
if opUserID == "" {
return errs.ErrNoPermission.WrapMsg("op user id empty")
}
member, err := s.db.TakeGroupMember(ctx, group.GroupID, opUserID)
if err != nil {
return err
}
isOwnerOrAdmin := member.RoleLevel == constant.GroupOwner || member.RoleLevel == constant.GroupAdmin
if group.AllowPinMsg == model.GroupPermAdminOnly && !isOwnerOrAdmin {
return errs.ErrNoPermission.WrapMsg("only owner or admin can pin/unpin group message")
}
return nil
}
// buildPinSnapshot 把 sdkws.MsgData 完整快照成 GroupPinnedMessage
// PinID 在 mgo 层 Pin 时若为空会自动生成;这里留空交由存储层处理
func buildPinSnapshot(groupID, conversationID, opUserID string, m *sdkws.MsgData) *model.GroupPinnedMessage {
pin := &model.GroupPinnedMessage{
GroupID: groupID,
ConversationID: conversationID,
Seq: m.Seq,
ServerMsgID: m.ServerMsgID,
ClientMsgID: m.ClientMsgID,
SendID: m.SendID,
RecvID: m.RecvID,
SenderPlatformID: m.SenderPlatformID,
SenderNickname: m.SenderNickname,
SenderFaceURL: m.SenderFaceURL,
SessionType: m.SessionType,
MsgFrom: m.MsgFrom,
ContentType: m.ContentType,
Content: string(m.Content),
AtUserIDList: append([]string(nil), m.AtUserIDList...),
Options: copyOptions(m.Options),
AttachedInfo: m.AttachedInfo,
Ex: m.Ex,
SendTime: m.SendTime,
CreateTime: m.CreateTime,
Status: m.Status,
PinUserID: opUserID,
PinTime: time.Now().UnixMilli(),
}
if m.OfflinePushInfo != nil {
pin.OfflinePush = &model.GroupPinnedOfflinePush{
Title: m.OfflinePushInfo.Title,
Desc: m.OfflinePushInfo.Desc,
Ex: m.OfflinePushInfo.Ex,
IOSPushSound: m.OfflinePushInfo.IOSPushSound,
IOSBadgeCount: m.OfflinePushInfo.IOSBadgeCount,
SignalInfo: m.OfflinePushInfo.SignalInfo,
}
}
return pin
}
func copyOptions(src map[string]bool) map[string]bool {
if len(src) == 0 {
return nil
}
dst := make(map[string]bool, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func pinnedMsgDB2PB(m *model.GroupPinnedMessage) *sdkws.GroupPinnedMsgInfo {
if m == nil {
return nil
}
return &sdkws.GroupPinnedMsgInfo{
PinID: m.PinID,
GroupID: m.GroupID,
ConversationID: m.ConversationID,
Seq: m.Seq,
ServerMsgID: m.ServerMsgID,
ClientMsgID: m.ClientMsgID,
SendID: m.SendID,
RecvID: m.RecvID,
SenderPlatformID: m.SenderPlatformID,
SenderNickname: m.SenderNickname,
SenderFaceURL: m.SenderFaceURL,
SessionType: m.SessionType,
MsgFrom: m.MsgFrom,
ContentType: m.ContentType,
Content: m.Content,
AtUserIDList: append([]string(nil), m.AtUserIDList...),
Options: copyOptions(m.Options),
AttachedInfo: m.AttachedInfo,
Ex: m.Ex,
SendTime: m.SendTime,
CreateTime: m.CreateTime,
Status: m.Status,
PinUserID: m.PinUserID,
PinTime: m.PinTime,
}
}
func pinnedListDB2PB(list []*model.GroupPinnedMessage) []*sdkws.GroupPinnedMsgInfo {
if len(list) == 0 {
return nil
}
result := make([]*sdkws.GroupPinnedMsgInfo, 0, len(list))
for _, m := range list {
result = append(result, pinnedMsgDB2PB(m))
}
return result
}