Initial open source release
This commit is contained in:
commit
74992e893f
377 changed files with 118084 additions and 0 deletions
3
internal/app/dialogs/doc.go
Normal file
3
internal/app/dialogs/doc.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Package dialogs 是会话应用服务:会话列表、未读数、置顶、草稿。
|
||||
// 第一阶段实现 PG-backed 空账号会话摘要查询;真实消息闭环留第二阶段。
|
||||
package dialogs
|
||||
593
internal/app/dialogs/service.go
Normal file
593
internal/app/dialogs/service.go
Normal file
|
|
@ -0,0 +1,593 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service 提供会话列表查询。
|
||||
type Service struct {
|
||||
dialogs store.DialogStore
|
||||
channels store.ChannelStore
|
||||
}
|
||||
|
||||
// NewService 创建 dialogs 服务。
|
||||
func NewService(dialogs store.DialogStore, channels ...store.ChannelStore) *Service {
|
||||
s := &Service{dialogs: dialogs}
|
||||
if len(channels) > 0 {
|
||||
s.channels = channels[0]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GetDialogs 返回当前登录账号的会话摘要。未登录或无持久化实现时按空账号处理。
|
||||
func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
|
||||
if s == nil || userID == 0 {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
if filter.HasFolderID && filter.FolderID >= domain.DialogCustomFolderMinID && filter.Folder == nil {
|
||||
if s.dialogs == nil {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
folder, found, err := s.dialogs.GetFolder(ctx, userID, filter.FolderID)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
filter.Folder = &folder
|
||||
}
|
||||
var out domain.DialogList
|
||||
if s.dialogs != nil {
|
||||
list, err := s.dialogs.ListByUser(ctx, userID, filter)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeDialogLists(out, list)
|
||||
}
|
||||
if s.channels != nil {
|
||||
list, err := s.channels.ListChannelDialogs(ctx, userID, filter)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeChannelDialogs(out, list)
|
||||
}
|
||||
sortDialogList(out.Dialogs)
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
if len(out.Dialogs) > limit {
|
||||
keep := make(map[domain.Peer]struct{}, limit)
|
||||
for _, d := range out.Dialogs[:limit] {
|
||||
keep[d.Peer] = struct{}{}
|
||||
}
|
||||
out.Dialogs = out.Dialogs[:limit]
|
||||
out.Messages = filterPrivateMessagesByPeer(out.Messages, keep)
|
||||
out.ChannelMessages = filterChannelMessagesByPeer(out.ChannelMessages, keep)
|
||||
out.Channels = filterChannelsByPeer(out.Channels, keep)
|
||||
}
|
||||
if out.Count == 0 {
|
||||
out.Count = len(out.Dialogs)
|
||||
}
|
||||
if err := s.attachDrafts(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetPeerDialogs 返回指定 peer 的会话摘要。缺失的 peer 由 store 按空会话占位返回。
|
||||
func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
|
||||
if s == nil || userID == 0 || len(peers) == 0 {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
if len(peers) > domain.MaxDialogFolderPeers {
|
||||
return domain.DialogList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
userPeers := make([]domain.Peer, 0, len(peers))
|
||||
channelIDs := make([]int64, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
userPeers = append(userPeers, peer)
|
||||
case domain.PeerTypeChannel:
|
||||
channelIDs = append(channelIDs, peer.ID)
|
||||
}
|
||||
}
|
||||
var out domain.DialogList
|
||||
if len(userPeers) > 0 && s.dialogs != nil {
|
||||
list, err := s.dialogs.ListByPeers(ctx, userID, userPeers)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeDialogLists(out, list)
|
||||
}
|
||||
if len(channelIDs) > 0 && s.channels != nil {
|
||||
list, err := s.channels.GetChannelDialogs(ctx, userID, channelIDs)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeChannelDialogs(out, list)
|
||||
out, err = s.appendMissingChannelPeerPreviews(ctx, userID, channelIDs, out)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
}
|
||||
if err := s.attachDrafts(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID int64, channelIDs []int64, out domain.DialogList) (domain.DialogList, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || len(channelIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
present := make(map[int64]struct{}, len(out.Dialogs))
|
||||
for _, dialog := range out.Dialogs {
|
||||
if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID != 0 {
|
||||
present[dialog.Peer.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(channelIDs))
|
||||
for _, channelID := range channelIDs {
|
||||
if channelID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[channelID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[channelID] = struct{}{}
|
||||
if _, ok := present[channelID]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
view, err := s.channels.GetChannel(ctx, userID, channelID)
|
||||
if err != nil {
|
||||
if isChannelPreviewAccessError(err) {
|
||||
continue
|
||||
}
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
history, err := s.channels.ListChannelHistory(ctx, userID, domain.ChannelHistoryFilter{
|
||||
ChannelID: channelID,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
if isChannelPreviewAccessError(err) {
|
||||
continue
|
||||
}
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
|
||||
dialog := dialogFromChannelView(view)
|
||||
if len(history.Messages) > 0 {
|
||||
top := history.Messages[0]
|
||||
dialog.TopMessage = top.ID
|
||||
dialog.TopMessageDate = top.Date
|
||||
out.ChannelMessages = append(out.ChannelMessages, top)
|
||||
}
|
||||
out.Dialogs = append(out.Dialogs, dialog)
|
||||
out.Channels = append(out.Channels, view.Channel)
|
||||
out.Channels = append(out.Channels, history.Channels...)
|
||||
out.Users = append(out.Users, history.Users...)
|
||||
out.Count++
|
||||
present[channelID] = struct{}{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func isChannelPreviewAccessError(err error) bool {
|
||||
return errors.Is(err, domain.ErrChannelPrivate) ||
|
||||
errors.Is(err, domain.ErrChannelUserBanned) ||
|
||||
errors.Is(err, domain.ErrChannelInvalid)
|
||||
}
|
||||
|
||||
func dialogFromChannelView(view domain.ChannelView) domain.Dialog {
|
||||
dialog := view.Dialog
|
||||
return domain.Dialog{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
||||
ChannelLeft: view.Self.Status == domain.ChannelMemberLeft,
|
||||
FolderID: dialog.FolderID,
|
||||
TopMessage: dialog.TopMessageID,
|
||||
TopMessageDate: dialog.TopMessageDate,
|
||||
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
||||
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
||||
UnreadCount: dialog.UnreadCount,
|
||||
UnreadMentions: dialog.UnreadMentions,
|
||||
Pinned: dialog.Pinned,
|
||||
PinnedOrder: dialog.PinnedOrder,
|
||||
UnreadMark: dialog.UnreadMark,
|
||||
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
||||
}
|
||||
}
|
||||
|
||||
// SaveDraft stores or clears a cloud draft for one peer/topic.
|
||||
func (s *Service) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := validateDraft(draft); err != nil {
|
||||
return err
|
||||
}
|
||||
if draft.Empty() {
|
||||
_, err := s.dialogs.DeleteDraft(ctx, userID, draft.Peer, draft.TopMessageID)
|
||||
return err
|
||||
}
|
||||
return s.dialogs.SaveDraft(ctx, userID, draft)
|
||||
}
|
||||
|
||||
// DeleteDraft clears one cloud draft.
|
||||
func (s *Service) DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if err := validateDraftKey(peer, topMessageID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.dialogs.DeleteDraft(ctx, userID, peer, topMessageID)
|
||||
}
|
||||
|
||||
// ListDrafts returns bounded cloud drafts for messages.getAllDrafts.
|
||||
func (s *Service) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.dialogs.ListDrafts(ctx, userID, clampDraftLimit(limit))
|
||||
}
|
||||
|
||||
// ClearDrafts deletes bounded cloud drafts for messages.clearAllDrafts.
|
||||
func (s *Service) ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.dialogs.ClearDrafts(ctx, userID, clampDraftLimit(limit))
|
||||
}
|
||||
|
||||
func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) {
|
||||
if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeChannel:
|
||||
if s.channels == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.channels.SetChannelDialogPinned(ctx, userID, peer.ID, pinned)
|
||||
default:
|
||||
if s.dialogs == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.dialogs.SetPinned(ctx, userID, peer, pinned)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error {
|
||||
if s == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
if s.dialogs != nil {
|
||||
if err := s.dialogs.ReorderPinned(ctx, userID, order, force); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if s.channels != nil {
|
||||
if err := s.channels.ReorderChannelPinnedDialogs(ctx, userID, order, force); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) MarkUnread(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) {
|
||||
if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeChannel:
|
||||
if s.channels == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.channels.SetChannelDialogUnreadMark(ctx, userID, peer.ID, unread)
|
||||
default:
|
||||
if s.dialogs == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.dialogs.SetUnreadMark(ctx, userID, peer, unread)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) UnreadMarks(ctx context.Context, userID int64) ([]domain.Peer, error) {
|
||||
if s == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var out []domain.Peer
|
||||
if s.dialogs != nil {
|
||||
peers, err := s.dialogs.ListUnreadMarked(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, peers...)
|
||||
}
|
||||
if s.channels != nil {
|
||||
peers, err := s.channels.ListChannelUnreadMarked(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, peers...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) HidePeerSettingsBar(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return s.dialogs.SetPeerSettingsBarHidden(ctx, userID, peer)
|
||||
}
|
||||
|
||||
func (s *Service) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 || peer.Type == "" || peer.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return s.dialogs.PeerSettingsBarHidden(ctx, userID, peer)
|
||||
}
|
||||
|
||||
func (s *Service) GetDialogFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return domain.DialogFolderList{}, nil
|
||||
}
|
||||
return s.dialogs.ListFolders(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) SaveDialogFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.dialogs.UpsertFolder(ctx, userID, folder)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteDialogFolder(ctx context.Context, userID int64, folderID int) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.dialogs.DeleteFolder(ctx, userID, folderID)
|
||||
}
|
||||
|
||||
func (s *Service) ReorderDialogFolders(ctx context.Context, userID int64, order []int) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.dialogs.ReorderFolders(ctx, userID, order)
|
||||
}
|
||||
|
||||
func (s *Service) ToggleDialogFolderTags(ctx context.Context, userID int64, enabled bool) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.dialogs.SetFolderTagsEnabled(ctx, userID, enabled)
|
||||
}
|
||||
|
||||
func (s *Service) EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error {
|
||||
if s == nil || userID == 0 || len(peers) == 0 {
|
||||
return nil
|
||||
}
|
||||
privatePeers := make([]domain.FolderPeerUpdate, 0, len(peers))
|
||||
channelPeers := make([]domain.FolderPeerUpdate, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.Peer.Type == domain.PeerTypeChannel {
|
||||
channelPeers = append(channelPeers, peer)
|
||||
} else {
|
||||
privatePeers = append(privatePeers, peer)
|
||||
}
|
||||
}
|
||||
if len(privatePeers) > 0 && s.dialogs != nil {
|
||||
if err := s.dialogs.EditPeerFolders(ctx, userID, privatePeers); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(channelPeers) > 0 && s.channels != nil {
|
||||
if err := s.channels.EditChannelPeerFolders(ctx, userID, channelPeers); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) attachDrafts(ctx context.Context, userID int64, list *domain.DialogList) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 || list == nil || len(list.Dialogs) == 0 {
|
||||
return nil
|
||||
}
|
||||
drafts, err := s.dialogs.ListDrafts(ctx, userID, domain.MaxDialogDraftsPerUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(drafts) == 0 {
|
||||
return nil
|
||||
}
|
||||
byPeer := make(map[domain.Peer]domain.DialogDraft, len(drafts))
|
||||
for _, draft := range drafts {
|
||||
if draft.TopMessageID != 0 {
|
||||
continue
|
||||
}
|
||||
byPeer[draft.Peer] = cloneDraft(draft)
|
||||
}
|
||||
if len(byPeer) == 0 {
|
||||
return nil
|
||||
}
|
||||
attached := false
|
||||
for i := range list.Dialogs {
|
||||
draft, ok := byPeer[list.Dialogs[i].Peer]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
d := cloneDraft(draft)
|
||||
list.Dialogs[i].Draft = &d
|
||||
attached = true
|
||||
}
|
||||
if attached {
|
||||
list.Hash = dialogHashWithDrafts(list.Hash, list.Dialogs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDraft(draft domain.DialogDraft) error {
|
||||
if err := validateDraftKey(draft.Peer, draft.TopMessageID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(draft.Entities) > domain.MaxMessageEntityCount {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(draft.Message) > domain.MaxMessageTextLength {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
if domain.ValidateMessageReplyBounds(draft.ReplyTo) != nil {
|
||||
return domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDraftKey(peer domain.Peer, topMessageID int) error {
|
||||
if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
if topMessageID < 0 || topMessageID > domain.MaxMessageBoxID {
|
||||
return domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clampDraftLimit(limit int) int {
|
||||
if limit <= 0 || limit > domain.MaxDialogDraftsPerUser {
|
||||
return domain.MaxDialogDraftsPerUser
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func cloneDraft(draft domain.DialogDraft) domain.DialogDraft {
|
||||
draft.Entities = append([]domain.MessageEntity(nil), draft.Entities...)
|
||||
if draft.ReplyTo != nil {
|
||||
reply := *draft.ReplyTo
|
||||
reply.QuoteEntities = append([]domain.MessageEntity(nil), draft.ReplyTo.QuoteEntities...)
|
||||
draft.ReplyTo = &reply
|
||||
}
|
||||
if draft.WebPage != nil {
|
||||
webpage := *draft.WebPage
|
||||
draft.WebPage = &webpage
|
||||
}
|
||||
return draft
|
||||
}
|
||||
|
||||
func dialogHashWithDrafts(base int64, dialogs []domain.Dialog) int64 {
|
||||
if len(dialogs) == 0 {
|
||||
return base
|
||||
}
|
||||
h := fnv.New64a()
|
||||
var buf [48]byte
|
||||
binary.LittleEndian.PutUint64(buf[:8], uint64(base))
|
||||
_, _ = h.Write(buf[:8])
|
||||
for _, d := range dialogs {
|
||||
if d.Draft == nil {
|
||||
continue
|
||||
}
|
||||
binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID))
|
||||
binary.LittleEndian.PutUint32(buf[8:12], uint32(d.Draft.TopMessageID))
|
||||
binary.LittleEndian.PutUint32(buf[12:16], uint32(d.Draft.Date))
|
||||
binary.LittleEndian.PutUint64(buf[16:24], uint64(len(d.Draft.Message)))
|
||||
if d.Draft.NoWebpage {
|
||||
buf[24] = 1
|
||||
} else {
|
||||
buf[24] = 0
|
||||
}
|
||||
if d.Draft.InvertMedia {
|
||||
buf[25] = 1
|
||||
} else {
|
||||
buf[25] = 0
|
||||
}
|
||||
binary.LittleEndian.PutUint64(buf[26:34], uint64(len(d.Draft.Entities)))
|
||||
binary.LittleEndian.PutUint64(buf[34:42], uint64(d.Draft.Effect))
|
||||
_, _ = h.Write(buf[:])
|
||||
_, _ = h.Write([]byte(d.Draft.Message))
|
||||
if d.Draft.WebPage != nil {
|
||||
_, _ = h.Write([]byte(d.Draft.WebPage.URL))
|
||||
}
|
||||
}
|
||||
return int64(h.Sum64())
|
||||
}
|
||||
|
||||
func mergeDialogLists(out, in domain.DialogList) domain.DialogList {
|
||||
out.Dialogs = append(out.Dialogs, in.Dialogs...)
|
||||
out.Messages = append(out.Messages, in.Messages...)
|
||||
out.Users = append(out.Users, in.Users...)
|
||||
out.Count += in.Count
|
||||
out.Hash ^= in.Hash
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeChannelDialogs(out domain.DialogList, in domain.ChannelDialogList) domain.DialogList {
|
||||
out.Dialogs = append(out.Dialogs, in.Dialogs...)
|
||||
out.ChannelMessages = append(out.ChannelMessages, in.Messages...)
|
||||
out.Channels = append(out.Channels, in.Channels...)
|
||||
out.Users = append(out.Users, in.Users...)
|
||||
out.Count += in.Count
|
||||
out.Hash ^= in.Hash
|
||||
return out
|
||||
}
|
||||
|
||||
func sortDialogList(dialogs []domain.Dialog) {
|
||||
sort.SliceStable(dialogs, func(i, j int) bool {
|
||||
if dialogs[i].Pinned != dialogs[j].Pinned {
|
||||
return dialogs[i].Pinned
|
||||
}
|
||||
if dialogs[i].PinnedOrder != dialogs[j].PinnedOrder {
|
||||
return dialogs[i].PinnedOrder > dialogs[j].PinnedOrder
|
||||
}
|
||||
if dialogs[i].TopMessageDate != dialogs[j].TopMessageDate {
|
||||
return dialogs[i].TopMessageDate > dialogs[j].TopMessageDate
|
||||
}
|
||||
if dialogs[i].TopMessage != dialogs[j].TopMessage {
|
||||
return dialogs[i].TopMessage > dialogs[j].TopMessage
|
||||
}
|
||||
return dialogs[i].Peer.ID > dialogs[j].Peer.ID
|
||||
})
|
||||
}
|
||||
|
||||
func filterPrivateMessagesByPeer(messages []domain.Message, keep map[domain.Peer]struct{}) []domain.Message {
|
||||
out := messages[:0]
|
||||
for _, msg := range messages {
|
||||
if _, ok := keep[msg.Peer]; ok {
|
||||
out = append(out, msg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterChannelMessagesByPeer(messages []domain.ChannelMessage, keep map[domain.Peer]struct{}) []domain.ChannelMessage {
|
||||
out := messages[:0]
|
||||
for _, msg := range messages {
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: msg.ChannelID}
|
||||
if _, ok := keep[peer]; ok {
|
||||
out = append(out, msg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterChannelsByPeer(channels []domain.Channel, keep map[domain.Peer]struct{}) []domain.Channel {
|
||||
out := channels[:0]
|
||||
for _, ch := range channels {
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}
|
||||
if _, ok := keep[peer]; ok {
|
||||
out = append(out, ch)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
282
internal/app/dialogs/service_test.go
Normal file
282
internal/app/dialogs/service_test.go
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestGetDialogsIncludesChannelReadOutboxAfterOfflineRead(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
dialogs := NewService(nil, channelStore)
|
||||
|
||||
created, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Offline Read",
|
||||
MemberUserIDs: []int64{1002},
|
||||
Date: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat: %v", err)
|
||||
}
|
||||
sent, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
RandomID: 42,
|
||||
Message: "restore read outbox",
|
||||
Date: 11,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
if _, err := channels.ReadHistory(ctx, 1002, domain.ReadChannelHistoryRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
MaxID: sent.Message.ID,
|
||||
Date: 12,
|
||||
}); err != nil {
|
||||
t.Fatalf("ReadHistory: %v", err)
|
||||
}
|
||||
|
||||
list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs: %v", err)
|
||||
}
|
||||
dialog := findChannelDialog(t, list, created.Channel.ID)
|
||||
if dialog.ReadOutboxMaxID != sent.Message.ID {
|
||||
t.Fatalf("getDialogs read_outbox = %d, want %d", dialog.ReadOutboxMaxID, sent.Message.ID)
|
||||
}
|
||||
|
||||
peerList, err := dialogs.GetPeerDialogs(ctx, 1001, []domain.Peer{
|
||||
{Type: domain.PeerTypeChannel, ID: created.Channel.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetPeerDialogs: %v", err)
|
||||
}
|
||||
peerDialog := findChannelDialog(t, peerList, created.Channel.ID)
|
||||
if peerDialog.ReadOutboxMaxID != sent.Message.ID {
|
||||
t.Fatalf("getPeerDialogs read_outbox = %d, want %d", peerDialog.ReadOutboxMaxID, sent.Message.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
dialogs := NewService(nil, channelStore)
|
||||
|
||||
first, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Pinned One",
|
||||
Date: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat first: %v", err)
|
||||
}
|
||||
second, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Pinned Two",
|
||||
Date: 21,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat second: %v", err)
|
||||
}
|
||||
firstPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: first.Channel.ID}
|
||||
secondPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: second.Channel.ID}
|
||||
|
||||
if changed, err := dialogs.TogglePinned(ctx, 1001, firstPeer, true); err != nil || !changed {
|
||||
t.Fatalf("TogglePinned first = changed %v err %v, want changed", changed, err)
|
||||
}
|
||||
if changed, err := dialogs.TogglePinned(ctx, 1001, secondPeer, true); err != nil || !changed {
|
||||
t.Fatalf("TogglePinned second = changed %v err %v, want changed", changed, err)
|
||||
}
|
||||
if err := dialogs.ReorderPinned(ctx, 1001, []domain.Peer{secondPeer, firstPeer}, true); err != nil {
|
||||
t.Fatalf("ReorderPinned: %v", err)
|
||||
}
|
||||
if changed, err := dialogs.MarkUnread(ctx, 1001, firstPeer, true); err != nil || !changed {
|
||||
t.Fatalf("MarkUnread = changed %v err %v, want changed", changed, err)
|
||||
}
|
||||
if err := dialogs.EditPeerFolders(ctx, 1001, []domain.FolderPeerUpdate{
|
||||
{Peer: firstPeer, FolderID: domain.DialogArchiveFolderID},
|
||||
}); err != nil {
|
||||
t.Fatalf("EditPeerFolders: %v", err)
|
||||
}
|
||||
|
||||
list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs: %v", err)
|
||||
}
|
||||
firstDialog := findChannelDialog(t, list, first.Channel.ID)
|
||||
if !firstDialog.Pinned || firstDialog.PinnedOrder != 1 || !firstDialog.UnreadMark || firstDialog.FolderID != domain.DialogArchiveFolderID {
|
||||
t.Fatalf("first dialog = %+v, want pinned order 1, unread mark, archived", firstDialog)
|
||||
}
|
||||
secondDialog := findChannelDialog(t, list, second.Channel.ID)
|
||||
if !secondDialog.Pinned || secondDialog.PinnedOrder != 2 {
|
||||
t.Fatalf("second dialog = %+v, want pinned order 2", secondDialog)
|
||||
}
|
||||
marks, err := dialogs.UnreadMarks(ctx, 1001)
|
||||
if err != nil {
|
||||
t.Fatalf("UnreadMarks: %v", err)
|
||||
}
|
||||
if len(marks) != 1 || marks[0] != firstPeer {
|
||||
t.Fatalf("unread marks = %+v, want first channel", marks)
|
||||
}
|
||||
archived, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
|
||||
HasFolderID: true,
|
||||
FolderID: domain.DialogArchiveFolderID,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs archive: %v", err)
|
||||
}
|
||||
if got := findChannelDialog(t, archived, first.Channel.ID); got.FolderID != domain.DialogArchiveFolderID {
|
||||
t.Fatalf("archived dialog = %+v, want archive folder", got)
|
||||
}
|
||||
|
||||
custom, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
|
||||
HasFolderID: true,
|
||||
FolderID: domain.DialogCustomFolderMinID,
|
||||
Folder: &domain.DialogFolder{ID: domain.DialogCustomFolderMinID, Groups: true},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs custom groups: %v", err)
|
||||
}
|
||||
if got := findChannelDialog(t, custom, first.Channel.ID); got.Peer.ID != first.Channel.ID {
|
||||
t.Fatalf("custom group dialog = %+v, want first channel", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsAppliesChannelDialogOffset(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
dialogs := NewService(nil, channelStore)
|
||||
|
||||
old, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Older Channel",
|
||||
Date: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat old: %v", err)
|
||||
}
|
||||
newer, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Newer Channel",
|
||||
Date: 30,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat newer: %v", err)
|
||||
}
|
||||
|
||||
first, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs first: %v", err)
|
||||
}
|
||||
if len(first.Dialogs) != 1 || first.Dialogs[0].Peer.ID != newer.Channel.ID {
|
||||
t.Fatalf("first page dialogs = %+v, want newer channel", first.Dialogs)
|
||||
}
|
||||
|
||||
next, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{
|
||||
OffsetDate: first.Dialogs[0].TopMessageDate,
|
||||
OffsetID: first.Dialogs[0].TopMessage,
|
||||
HasOffsetPeer: true,
|
||||
OffsetPeer: first.Dialogs[0].Peer,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs next: %v", err)
|
||||
}
|
||||
if len(next.Dialogs) != 1 || next.Dialogs[0].Peer.ID != old.Channel.ID {
|
||||
t.Fatalf("next page dialogs = %+v, want only older channel", next.Dialogs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPeerDialogsRejectsHugeVector(t *testing.T) {
|
||||
dialogs := NewService(nil, memory.NewChannelStore())
|
||||
peers := make([]domain.Peer, domain.MaxDialogFolderPeers+1)
|
||||
for i := range peers {
|
||||
peers[i] = domain.Peer{Type: domain.PeerTypeChannel, ID: int64(i + 1)}
|
||||
}
|
||||
if _, err := dialogs.GetPeerDialogs(context.Background(), 1001, peers); !errors.Is(err, domain.ErrChannelInvalid) {
|
||||
t.Fatalf("GetPeerDialogs huge vector err = %v, want ErrChannelInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPeerDialogsIncludesPublicChannelPreviewForNonMember(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelStore)
|
||||
dialogs := NewService(nil, channelStore)
|
||||
|
||||
public, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Public Peer Dialog",
|
||||
Broadcast: true,
|
||||
Date: 1700002000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel public: %v", err)
|
||||
}
|
||||
if _, err := channels.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{
|
||||
UserID: 1001,
|
||||
ChannelID: public.Channel.ID,
|
||||
Username: "public_peer_dialog",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateUsername public: %v", err)
|
||||
}
|
||||
sent, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
|
||||
ChannelID: public.Channel.ID,
|
||||
RandomID: 99,
|
||||
Message: "public peer dialog top",
|
||||
Date: 1700002010,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage public: %v", err)
|
||||
}
|
||||
private, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Private Peer Dialog",
|
||||
Broadcast: true,
|
||||
Date: 1700002020,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel private: %v", err)
|
||||
}
|
||||
|
||||
list, err := dialogs.GetPeerDialogs(ctx, 1002, []domain.Peer{
|
||||
{Type: domain.PeerTypeChannel, ID: public.Channel.ID},
|
||||
{Type: domain.PeerTypeChannel, ID: private.Channel.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetPeerDialogs public preview: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 1 {
|
||||
t.Fatalf("dialogs = %+v, want only public preview dialog", list.Dialogs)
|
||||
}
|
||||
dialog := findChannelDialog(t, list, public.Channel.ID)
|
||||
if dialog.TopMessage != sent.Message.ID || dialog.TopMessageDate != sent.Message.Date {
|
||||
t.Fatalf("preview dialog top = id %d date %d, want %d/%d", dialog.TopMessage, dialog.TopMessageDate, sent.Message.ID, sent.Message.Date)
|
||||
}
|
||||
if !dialog.ChannelLeft {
|
||||
t.Fatalf("preview dialog ChannelLeft = false, want read-only left preview")
|
||||
}
|
||||
if dialog.UnreadCount != 0 || dialog.ReadInboxMaxID < sent.Message.ID || dialog.ReadOutboxMaxID < sent.Message.ID {
|
||||
t.Fatalf("preview dialog read/unread = %+v, want read through top and no unread", dialog)
|
||||
}
|
||||
if len(list.ChannelMessages) != 1 || list.ChannelMessages[0].Body != "public peer dialog top" {
|
||||
t.Fatalf("channel messages = %+v, want public top message", list.ChannelMessages)
|
||||
}
|
||||
if len(list.Channels) != 1 || list.Channels[0].ID != public.Channel.ID {
|
||||
t.Fatalf("channels = %+v, want public channel shell", list.Channels)
|
||||
}
|
||||
}
|
||||
|
||||
func findChannelDialog(t *testing.T, list domain.DialogList, channelID int64) domain.Dialog {
|
||||
t.Helper()
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID == channelID {
|
||||
return dialog
|
||||
}
|
||||
}
|
||||
t.Fatalf("channel dialog %d not found in %+v", channelID, list.Dialogs)
|
||||
return domain.Dialog{}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue