chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
250
internal/store/memory/auth.go
Normal file
250
internal/store/memory/auth.go
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthKeyStore 是 store.AuthKeyStore 的内存实现。
|
||||
type AuthKeyStore struct {
|
||||
mu sync.RWMutex
|
||||
keys map[[8]byte]store.AuthKeyData
|
||||
}
|
||||
|
||||
// NewAuthKeyStore 创建内存 AuthKeyStore。
|
||||
func NewAuthKeyStore() *AuthKeyStore {
|
||||
return &AuthKeyStore{keys: make(map[[8]byte]store.AuthKeyData)}
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) Save(_ context.Context, k store.AuthKeyData) error {
|
||||
s.mu.Lock()
|
||||
s.keys[k.ID] = k
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) Get(_ context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
|
||||
s.mu.RLock()
|
||||
k, ok := s.keys[id]
|
||||
s.mu.RUnlock()
|
||||
return k, ok, nil
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) Delete(_ context.Context, id [8]byte) error {
|
||||
s.mu.Lock()
|
||||
delete(s.keys, id)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SessionStore 是 store.SessionStore 的内存实现。
|
||||
type SessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[int64]store.SessionData
|
||||
}
|
||||
|
||||
// NewSessionStore 创建内存 SessionStore。
|
||||
func NewSessionStore() *SessionStore {
|
||||
return &SessionStore{sessions: make(map[int64]store.SessionData)}
|
||||
}
|
||||
|
||||
func (s *SessionStore) Save(_ context.Context, d store.SessionData) error {
|
||||
s.mu.Lock()
|
||||
s.sessions[d.ID] = d
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SessionStore) Get(_ context.Context, id int64) (store.SessionData, bool, error) {
|
||||
s.mu.RLock()
|
||||
d, ok := s.sessions[id]
|
||||
s.mu.RUnlock()
|
||||
return d, ok, nil
|
||||
}
|
||||
|
||||
func (s *SessionStore) Delete(_ context.Context, id int64) error {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, id)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// TempAuthKeyBindingStore 是 store.TempAuthKeyBindingStore 的内存实现。
|
||||
type TempAuthKeyBindingStore struct {
|
||||
mu sync.RWMutex
|
||||
m map[[8]byte]domain.TempAuthKeyBinding
|
||||
}
|
||||
|
||||
// NewTempAuthKeyBindingStore 创建内存 TempAuthKeyBindingStore。
|
||||
func NewTempAuthKeyBindingStore() *TempAuthKeyBindingStore {
|
||||
return &TempAuthKeyBindingStore{m: make(map[[8]byte]domain.TempAuthKeyBinding)}
|
||||
}
|
||||
|
||||
func (s *TempAuthKeyBindingStore) Save(_ context.Context, b domain.TempAuthKeyBinding) error {
|
||||
b.EncryptedMessage = append([]byte(nil), b.EncryptedMessage...)
|
||||
s.mu.Lock()
|
||||
s.m[b.TempAuthKeyID] = b
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TempAuthKeyBindingStore) GetByTemp(_ context.Context, tempAuthKeyID [8]byte) (domain.TempAuthKeyBinding, bool, error) {
|
||||
s.mu.RLock()
|
||||
b, ok := s.m[tempAuthKeyID]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return domain.TempAuthKeyBinding{}, false, nil
|
||||
}
|
||||
b.EncryptedMessage = append([]byte(nil), b.EncryptedMessage...)
|
||||
return b, true, nil
|
||||
}
|
||||
|
||||
func (s *TempAuthKeyBindingStore) DeleteExpired(_ context.Context, expiredBefore int64, limit int) (int, error) {
|
||||
if limit <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
deleted := 0
|
||||
for id, b := range s.m {
|
||||
if deleted >= limit {
|
||||
break
|
||||
}
|
||||
if int64(b.ExpiresAt) < expiredBefore {
|
||||
delete(s.m, id)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// AuthorizationStore 是 store.AuthorizationStore 的内存实现。
|
||||
type AuthorizationStore struct {
|
||||
mu sync.RWMutex
|
||||
m map[[8]byte]domain.Authorization
|
||||
}
|
||||
|
||||
// NewAuthorizationStore 创建内存 AuthorizationStore。
|
||||
func NewAuthorizationStore() *AuthorizationStore {
|
||||
return &AuthorizationStore{m: make(map[[8]byte]domain.Authorization)}
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) Bind(_ context.Context, a domain.Authorization) error {
|
||||
now := time.Now()
|
||||
if a.Hash == 0 {
|
||||
a.Hash = int64(binary.LittleEndian.Uint64(a.AuthKeyID[:]))
|
||||
}
|
||||
if a.CreatedAt.IsZero() {
|
||||
a.CreatedAt = now
|
||||
}
|
||||
a.ActiveAt = now
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.m[a.AuthKeyID]; ok && !existing.CreatedAt.IsZero() {
|
||||
a.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
s.m[a.AuthKeyID] = a
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) ByAuthKey(_ context.Context, id [8]byte) (domain.Authorization, bool, error) {
|
||||
s.mu.RLock()
|
||||
a, ok := s.m[id]
|
||||
s.mu.RUnlock()
|
||||
return a, ok, nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) MarkPasswordPassed(_ context.Context, id [8]byte) error {
|
||||
s.mu.Lock()
|
||||
if a, ok := s.m[id]; ok {
|
||||
a.PasswordPending = false
|
||||
a.ActiveAt = time.Now()
|
||||
s.m[id] = a
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) ListByUser(_ context.Context, userID int64) ([]domain.Authorization, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]domain.Authorization, 0)
|
||||
for _, a := range s.m {
|
||||
if a.UserID == userID {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) Delete(_ context.Context, id [8]byte) error {
|
||||
s.mu.Lock()
|
||||
delete(s.m, id)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) DeleteByHash(_ context.Context, userID, hash int64) (domain.Authorization, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for id, a := range s.m {
|
||||
if a.UserID == userID && a.Hash == hash {
|
||||
delete(s.m, id)
|
||||
return a, true, nil
|
||||
}
|
||||
}
|
||||
return domain.Authorization{}, false, nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) DeleteByUserExcept(_ context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domain.Authorization, 0)
|
||||
for id, a := range s.m {
|
||||
if a.UserID != userID || id == keepAuthKeyID {
|
||||
continue
|
||||
}
|
||||
delete(s.m, id)
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CodeStore 是 store.CodeStore 的内存实现(带 TTL)。
|
||||
type CodeStore struct {
|
||||
mu sync.Mutex
|
||||
m map[string]codeEntry
|
||||
}
|
||||
|
||||
// NewCodeStore 创建内存 CodeStore。
|
||||
func NewCodeStore() *CodeStore {
|
||||
return &CodeStore{m: make(map[string]codeEntry)}
|
||||
}
|
||||
|
||||
func (s *CodeStore) Set(_ context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
|
||||
s.mu.Lock()
|
||||
s.m[hash] = codeEntry{code: code, expires: time.Now().Add(ttl)}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CodeStore) Get(_ context.Context, hash string) (store.PhoneCode, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, ok := s.m[hash]
|
||||
if !ok || time.Now().After(e.expires) {
|
||||
return store.PhoneCode{}, false, nil
|
||||
}
|
||||
return e.code, true, nil
|
||||
}
|
||||
|
||||
func (s *CodeStore) Del(_ context.Context, hash string) error {
|
||||
s.mu.Lock()
|
||||
delete(s.m, hash)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
769
internal/store/memory/bot.go
Normal file
769
internal/store/memory/bot.go
Normal file
|
|
@ -0,0 +1,769 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// BotStore 是 store.BotStore 的内存实现。bot 的 users 行经注入的 UserStore 创建,
|
||||
// 与 postgres 实现(单事务建 users+bots 两行)保持可见性一致。
|
||||
// 内置 BotFather 的 bots 行预置(token 为空 = 不可登录),对齐迁移 0090 种子。
|
||||
type BotStore struct {
|
||||
mu sync.RWMutex
|
||||
users *UserStore
|
||||
byID map[int64]domain.BotProfile
|
||||
states map[[2]int64]domain.BotChatState
|
||||
permissions map[[2]int64]bool
|
||||
appsByID map[int64]domain.BotApp
|
||||
appShortNames map[botAppShortKey]int64
|
||||
appSettings map[int64]domain.BotAppSettings
|
||||
previewMedia map[int64]map[int64]domain.BotAppPreviewMedia
|
||||
previewSeq int64
|
||||
attachMenu map[int64]domain.BotAttachMenuBot
|
||||
attachStates map[[2]int64]domain.BotAttachMenuState
|
||||
requestButtons map[requestedButtonKey]domain.BotRequestedWebViewButton
|
||||
emojiPerms map[[2]int64]bool
|
||||
customMethod map[string]domain.BotWebViewCustomMethodQuery
|
||||
}
|
||||
|
||||
type botAppShortKey struct {
|
||||
botUserID int64
|
||||
shortName string
|
||||
}
|
||||
|
||||
type requestedButtonKey struct {
|
||||
botUserID int64
|
||||
userID int64
|
||||
reqID string
|
||||
}
|
||||
|
||||
// NewBotStore 创建内存 BotStore。
|
||||
func NewBotStore(users *UserStore) *BotStore {
|
||||
s := &BotStore{
|
||||
users: users,
|
||||
byID: make(map[int64]domain.BotProfile),
|
||||
states: make(map[[2]int64]domain.BotChatState),
|
||||
permissions: make(map[[2]int64]bool),
|
||||
appsByID: make(map[int64]domain.BotApp),
|
||||
appShortNames: make(map[botAppShortKey]int64),
|
||||
appSettings: make(map[int64]domain.BotAppSettings),
|
||||
previewMedia: make(map[int64]map[int64]domain.BotAppPreviewMedia),
|
||||
attachMenu: make(map[int64]domain.BotAttachMenuBot),
|
||||
attachStates: make(map[[2]int64]domain.BotAttachMenuState),
|
||||
requestButtons: make(map[requestedButtonKey]domain.BotRequestedWebViewButton),
|
||||
emojiPerms: make(map[[2]int64]bool),
|
||||
customMethod: make(map[string]domain.BotWebViewCustomMethodQuery),
|
||||
}
|
||||
s.byID[domain.BotFatherUserID] = domain.BotProfile{
|
||||
BotUserID: domain.BotFatherUserID,
|
||||
OwnerUserID: domain.BotFatherUserID,
|
||||
Description: "BotFather is the one bot to rule them all. Use it to create new bot accounts and manage your existing bots.",
|
||||
Commands: []domain.BotCommand{
|
||||
{Command: "newbot", Description: "create a new bot"},
|
||||
{Command: "mybots", Description: "list your bots"},
|
||||
{Command: "token", Description: "show a bot's token"},
|
||||
{Command: "revoke", Description: "revoke a bot's token"},
|
||||
{Command: "cancel", Description: "cancel the current operation"},
|
||||
{Command: "help", Description: "show help"},
|
||||
},
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profile domain.BotProfile) (domain.User, domain.BotProfile, error) {
|
||||
user.Phone = ""
|
||||
user.Username = strings.TrimSpace(strings.TrimPrefix(user.Username, "@"))
|
||||
user.Bot = true
|
||||
if user.BotInfoVersion < 1 {
|
||||
user.BotInfoVersion = 1
|
||||
}
|
||||
// 持 BotStore.mu 跨「复核计数 → 建 users 行 → 写 profile」,与 postgres 的
|
||||
// advisory lock + 事务内复核对齐,封死 count-then-insert TOCTOU。锁顺序
|
||||
// BotStore.mu → UserStore.mu(UserStore 不反向引用 BotStore,无死锁)。
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
owned := 0
|
||||
for _, p := range s.byID {
|
||||
if p.OwnerUserID == profile.OwnerUserID && p.BotUserID != p.OwnerUserID {
|
||||
owned++
|
||||
}
|
||||
}
|
||||
if owned >= domain.MaxBotsPerOwner {
|
||||
return domain.User{}, domain.BotProfile{}, domain.ErrBotsTooMany
|
||||
}
|
||||
created, err := s.users.Create(ctx, user)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.BotProfile{}, err
|
||||
}
|
||||
profile.BotUserID = created.ID
|
||||
s.byID[created.ID] = cloneBotProfile(profile)
|
||||
return created, profile, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) GetBot(_ context.Context, botUserID int64) (domain.BotProfile, bool, error) {
|
||||
s.mu.RLock()
|
||||
p, ok := s.byID[botUserID]
|
||||
if ok {
|
||||
p = s.enrichBotProfileLocked(p)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return domain.BotProfile{}, false, nil
|
||||
}
|
||||
return cloneBotProfile(p), true, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) GetBots(_ context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) {
|
||||
if len(botUserIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make(map[int64]domain.BotProfile, len(botUserIDs))
|
||||
for _, id := range botUserIDs {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := out[id]; ok {
|
||||
continue
|
||||
}
|
||||
if p, ok := s.byID[id]; ok {
|
||||
out[id] = cloneBotProfile(s.enrichBotProfileLocked(p))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) ListBotsByOwner(_ context.Context, ownerUserID int64) ([]domain.BotProfile, error) {
|
||||
if ownerUserID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]domain.BotProfile, 0)
|
||||
for _, p := range s.byID {
|
||||
if p.OwnerUserID == ownerUserID && p.BotUserID != p.OwnerUserID {
|
||||
out = append(out, cloneBotProfile(s.enrichBotProfileLocked(p)))
|
||||
}
|
||||
}
|
||||
sortBotProfiles(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) CountBotsByOwner(_ context.Context, ownerUserID int64) (int, error) {
|
||||
if ownerUserID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
n := 0
|
||||
for _, p := range s.byID {
|
||||
if p.OwnerUserID == ownerUserID && p.BotUserID != p.OwnerUserID {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) UpdateBotTokenSecret(_ context.Context, botUserID int64, secret string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p, ok := s.byID[botUserID]
|
||||
if !ok {
|
||||
return domain.ErrBotNotFound
|
||||
}
|
||||
p.TokenSecret = secret
|
||||
s.byID[botUserID] = p
|
||||
return nil
|
||||
}
|
||||
|
||||
// editProfile 在 BotStore.mu 内改 bots 行字段,随后 bump users 的 bot_info_version。
|
||||
// 与 postgres 不同(非单事务),但 memory 仅测试替身、元数据更新低频,可接受。
|
||||
func (s *BotStore) editProfile(botUserID int64, fn func(p *domain.BotProfile)) (int, error) {
|
||||
s.mu.Lock()
|
||||
p, ok := s.byID[botUserID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return 0, domain.ErrBotNotFound
|
||||
}
|
||||
fn(&p)
|
||||
s.byID[botUserID] = p
|
||||
s.mu.Unlock()
|
||||
ver, ok := s.users.bumpBotInfoVersion(botUserID)
|
||||
if !ok {
|
||||
return 0, domain.ErrBotNotFound
|
||||
}
|
||||
return ver, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) UpdateBotCommands(_ context.Context, botUserID int64, commands []domain.BotCommand) (int, error) {
|
||||
return s.editProfile(botUserID, func(p *domain.BotProfile) {
|
||||
p.Commands = append([]domain.BotCommand(nil), commands...)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BotStore) UpdateBotInfo(_ context.Context, botUserID int64, upd domain.BotInfoUpdate) (int, error) {
|
||||
if _, ok := s.GetBotProfile(botUserID); !ok {
|
||||
return 0, domain.ErrBotNotFound
|
||||
}
|
||||
if upd.SetName || upd.SetAbout {
|
||||
if !s.users.updateBotProfile(botUserID, upd.SetName, upd.Name, upd.SetAbout, upd.About) {
|
||||
return 0, domain.ErrBotNotFound
|
||||
}
|
||||
}
|
||||
return s.editProfile(botUserID, func(p *domain.BotProfile) {
|
||||
if upd.SetDescription {
|
||||
p.Description = upd.Description
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BotStore) UpdateBotMenuButton(_ context.Context, botUserID int64, button domain.BotMenuButton) (int, error) {
|
||||
return s.editProfile(botUserID, func(p *domain.BotProfile) {
|
||||
p.MenuButton = button
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BotStore) SetBotInlinePlaceholder(_ context.Context, botUserID int64, placeholder string) (int, error) {
|
||||
return s.editProfile(botUserID, func(p *domain.BotProfile) {
|
||||
p.InlinePlaceholder = placeholder
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BotStore) SetBotInlineGeo(_ context.Context, botUserID int64, inlineGeo bool) (int, error) {
|
||||
return s.editProfile(botUserID, func(p *domain.BotProfile) { p.InlineGeo = inlineGeo })
|
||||
}
|
||||
|
||||
func (s *BotStore) SetBotNochats(_ context.Context, botUserID int64, nochats bool) (int, error) {
|
||||
return s.editProfile(botUserID, func(p *domain.BotProfile) { p.Nochats = nochats })
|
||||
}
|
||||
|
||||
func (s *BotStore) SetBotChatHistory(_ context.Context, botUserID int64, chatHistory bool) (int, error) {
|
||||
return s.editProfile(botUserID, func(p *domain.BotProfile) { p.ChatHistory = chatHistory })
|
||||
}
|
||||
|
||||
func (s *BotStore) CanBotSendMessage(_ context.Context, botUserID, userID int64) (bool, error) {
|
||||
if botUserID == 0 || userID == 0 || botUserID == userID {
|
||||
return false, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
allowed := s.permissions[[2]int64{botUserID, userID}]
|
||||
s.mu.RUnlock()
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) AllowBotSendMessage(_ context.Context, botUserID, userID int64, _ bool) (bool, error) {
|
||||
if botUserID == 0 || userID == 0 || botUserID == userID {
|
||||
return false, domain.ErrBotNotFound
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.byID[botUserID]; !ok {
|
||||
return false, domain.ErrBotNotFound
|
||||
}
|
||||
key := [2]int64{botUserID, userID}
|
||||
if s.permissions[key] {
|
||||
return false, nil
|
||||
}
|
||||
s.permissions[key] = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) UpsertBotApp(_ context.Context, app domain.BotApp) (domain.BotApp, int, error) {
|
||||
if app.BotUserID == 0 || app.ID == 0 || app.AccessHash == 0 || app.ShortName == "" || app.URL == "" {
|
||||
return domain.BotApp{}, 0, domain.ErrBotAppInvalid
|
||||
}
|
||||
app.ShortName = strings.ToLower(strings.TrimSpace(app.ShortName))
|
||||
s.mu.Lock()
|
||||
if _, ok := s.byID[app.BotUserID]; !ok {
|
||||
s.mu.Unlock()
|
||||
return domain.BotApp{}, 0, domain.ErrBotNotFound
|
||||
}
|
||||
if existing, ok := s.appShortNames[botAppShortKey{botUserID: app.BotUserID, shortName: app.ShortName}]; ok && existing != app.ID {
|
||||
s.mu.Unlock()
|
||||
return domain.BotApp{}, 0, domain.ErrBotAppInvalid
|
||||
}
|
||||
if old, ok := s.appsByID[app.ID]; ok && old.BotUserID != app.BotUserID {
|
||||
s.mu.Unlock()
|
||||
return domain.BotApp{}, 0, domain.ErrBotAppInvalid
|
||||
}
|
||||
s.appsByID[app.ID] = app
|
||||
s.appShortNames[botAppShortKey{botUserID: app.BotUserID, shortName: app.ShortName}] = app.ID
|
||||
p := s.byID[app.BotUserID]
|
||||
if app.Main {
|
||||
for id, item := range s.appsByID {
|
||||
if item.BotUserID == app.BotUserID && id != app.ID && item.Main {
|
||||
item.Main = false
|
||||
s.appsByID[id] = item
|
||||
}
|
||||
}
|
||||
p.HasMainApp = true
|
||||
}
|
||||
if app.HasSettings {
|
||||
p.HasMainApp = p.HasMainApp || app.Main
|
||||
}
|
||||
s.byID[app.BotUserID] = p
|
||||
s.mu.Unlock()
|
||||
ver, ok := s.users.bumpBotInfoVersion(app.BotUserID)
|
||||
if !ok {
|
||||
return domain.BotApp{}, 0, domain.ErrBotNotFound
|
||||
}
|
||||
return cloneBotApp(app), ver, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) GetBotAppByID(_ context.Context, appID, accessHash int64) (domain.BotApp, bool, error) {
|
||||
if appID == 0 || accessHash == 0 {
|
||||
return domain.BotApp{}, false, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
app, ok := s.appsByID[appID]
|
||||
s.mu.RUnlock()
|
||||
if !ok || app.AccessHash != accessHash {
|
||||
return domain.BotApp{}, false, nil
|
||||
}
|
||||
return cloneBotApp(app), true, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) GetBotAppByShortName(_ context.Context, botUserID int64, shortName string) (domain.BotApp, bool, error) {
|
||||
key := botAppShortKey{botUserID: botUserID, shortName: strings.ToLower(strings.TrimSpace(shortName))}
|
||||
s.mu.RLock()
|
||||
id, ok := s.appShortNames[key]
|
||||
var app domain.BotApp
|
||||
if ok {
|
||||
app, ok = s.appsByID[id]
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return domain.BotApp{}, false, nil
|
||||
}
|
||||
return cloneBotApp(app), true, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) GetMainBotApp(_ context.Context, botUserID int64) (domain.BotApp, bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, app := range s.appsByID {
|
||||
if app.BotUserID == botUserID && app.Main {
|
||||
return cloneBotApp(app), true, nil
|
||||
}
|
||||
}
|
||||
return domain.BotApp{}, false, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) ListBotApps(_ context.Context, botUserID int64) ([]domain.BotApp, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]domain.BotApp, 0)
|
||||
for _, app := range s.appsByID {
|
||||
if app.BotUserID == botUserID {
|
||||
out = append(out, cloneBotApp(app))
|
||||
}
|
||||
}
|
||||
sortBotApps(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) GetBotAppSettings(_ context.Context, botUserID int64) (domain.BotAppSettings, bool, error) {
|
||||
s.mu.RLock()
|
||||
settings, ok := s.appSettings[botUserID]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return domain.BotAppSettings{}, false, nil
|
||||
}
|
||||
return cloneBotAppSettings(settings), true, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) UpsertBotAppSettings(_ context.Context, botUserID int64, settings domain.BotAppSettings) (int, error) {
|
||||
s.mu.Lock()
|
||||
if _, ok := s.byID[botUserID]; !ok {
|
||||
s.mu.Unlock()
|
||||
return 0, domain.ErrBotNotFound
|
||||
}
|
||||
s.appSettings[botUserID] = cloneBotAppSettings(settings)
|
||||
p := s.byID[botUserID]
|
||||
c := cloneBotAppSettings(settings)
|
||||
p.AppSettings = &c
|
||||
s.byID[botUserID] = p
|
||||
s.mu.Unlock()
|
||||
ver, ok := s.users.bumpBotInfoVersion(botUserID)
|
||||
if !ok {
|
||||
return 0, domain.ErrBotNotFound
|
||||
}
|
||||
return ver, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) ListBotAppPreviewMedia(_ context.Context, botUserID, appID int64) ([]domain.BotAppPreviewMedia, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
items := s.previewMedia[appID]
|
||||
out := make([]domain.BotAppPreviewMedia, 0, len(items))
|
||||
for _, media := range items {
|
||||
if media.BotUserID == botUserID {
|
||||
out = append(out, media)
|
||||
}
|
||||
}
|
||||
sortBotPreviewMedia(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) UpsertBotAppPreviewMedia(_ context.Context, media domain.BotAppPreviewMedia) (domain.BotAppPreviewMedia, int, error) {
|
||||
if media.BotUserID == 0 || media.AppID == 0 || (media.PhotoID == 0 && media.DocumentID == 0) {
|
||||
return domain.BotAppPreviewMedia{}, 0, domain.ErrBotAppInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
if app, ok := s.appsByID[media.AppID]; !ok || app.BotUserID != media.BotUserID {
|
||||
s.mu.Unlock()
|
||||
return domain.BotAppPreviewMedia{}, 0, domain.ErrBotAppInvalid
|
||||
}
|
||||
if s.previewMedia[media.AppID] == nil {
|
||||
s.previewMedia[media.AppID] = make(map[int64]domain.BotAppPreviewMedia)
|
||||
}
|
||||
if media.ID == 0 {
|
||||
s.previewSeq++
|
||||
media.ID = s.previewSeq
|
||||
}
|
||||
if media.Position <= 0 {
|
||||
media.Position = len(s.previewMedia[media.AppID]) + 1
|
||||
}
|
||||
s.previewMedia[media.AppID][media.ID] = media
|
||||
p := s.byID[media.BotUserID]
|
||||
p.HasPreviewMedias = true
|
||||
s.byID[media.BotUserID] = p
|
||||
s.mu.Unlock()
|
||||
ver, ok := s.users.bumpBotInfoVersion(media.BotUserID)
|
||||
if !ok {
|
||||
return domain.BotAppPreviewMedia{}, 0, domain.ErrBotNotFound
|
||||
}
|
||||
return media, ver, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) DeleteBotAppPreviewMedia(_ context.Context, botUserID, appID, mediaID int64) (int, error) {
|
||||
s.mu.Lock()
|
||||
items := s.previewMedia[appID]
|
||||
if mediaID == 0 || items == nil {
|
||||
s.mu.Unlock()
|
||||
return 0, domain.ErrBotAppInvalid
|
||||
}
|
||||
media, ok := items[mediaID]
|
||||
if !ok || media.BotUserID != botUserID {
|
||||
s.mu.Unlock()
|
||||
return 0, domain.ErrBotAppInvalid
|
||||
}
|
||||
delete(items, mediaID)
|
||||
has := false
|
||||
for _, items := range s.previewMedia {
|
||||
for _, media := range items {
|
||||
if media.BotUserID == botUserID {
|
||||
has = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
p := s.byID[botUserID]
|
||||
p.HasPreviewMedias = has
|
||||
s.byID[botUserID] = p
|
||||
s.mu.Unlock()
|
||||
ver, ok := s.users.bumpBotInfoVersion(botUserID)
|
||||
if !ok {
|
||||
return 0, domain.ErrBotNotFound
|
||||
}
|
||||
return ver, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) ReorderBotAppPreviewMedia(_ context.Context, botUserID, appID int64, mediaIDs []int64) (int, error) {
|
||||
s.mu.Lock()
|
||||
items := s.previewMedia[appID]
|
||||
if items == nil {
|
||||
s.mu.Unlock()
|
||||
return 0, domain.ErrBotAppInvalid
|
||||
}
|
||||
for pos, id := range mediaIDs {
|
||||
media, ok := items[id]
|
||||
if !ok || media.BotUserID != botUserID {
|
||||
s.mu.Unlock()
|
||||
return 0, domain.ErrBotAppInvalid
|
||||
}
|
||||
media.Position = pos + 1
|
||||
items[id] = media
|
||||
}
|
||||
s.mu.Unlock()
|
||||
ver, ok := s.users.bumpBotInfoVersion(botUserID)
|
||||
if !ok {
|
||||
return 0, domain.ErrBotNotFound
|
||||
}
|
||||
return ver, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) UpsertAttachMenuBot(_ context.Context, bot domain.BotAttachMenuBot) (int, error) {
|
||||
if bot.BotUserID == 0 || bot.ShortName == "" {
|
||||
return 0, domain.ErrBotAttachMenuInvalid
|
||||
}
|
||||
bot.ShortName = strings.ToLower(strings.TrimSpace(bot.ShortName))
|
||||
s.mu.Lock()
|
||||
if _, ok := s.byID[bot.BotUserID]; !ok {
|
||||
s.mu.Unlock()
|
||||
return 0, domain.ErrBotNotFound
|
||||
}
|
||||
s.attachMenu[bot.BotUserID] = cloneAttachMenuBot(bot)
|
||||
p := s.byID[bot.BotUserID]
|
||||
p.HasAttachMenu = true
|
||||
s.byID[bot.BotUserID] = p
|
||||
s.mu.Unlock()
|
||||
ver, ok := s.users.bumpBotInfoVersion(bot.BotUserID)
|
||||
if !ok {
|
||||
return 0, domain.ErrBotNotFound
|
||||
}
|
||||
return ver, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) GetAttachMenuBot(_ context.Context, botUserID int64) (domain.BotAttachMenuBot, bool, error) {
|
||||
s.mu.RLock()
|
||||
bot, ok := s.attachMenu[botUserID]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return domain.BotAttachMenuBot{}, false, nil
|
||||
}
|
||||
return cloneAttachMenuBot(bot), true, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) ListAttachMenuBots(_ context.Context) ([]domain.BotAttachMenuBot, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]domain.BotAttachMenuBot, 0, len(s.attachMenu))
|
||||
for _, bot := range s.attachMenu {
|
||||
out = append(out, cloneAttachMenuBot(bot))
|
||||
}
|
||||
sortAttachMenuBots(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) GetAttachMenuState(_ context.Context, userID, botUserID int64) (domain.BotAttachMenuState, bool, error) {
|
||||
s.mu.RLock()
|
||||
state, ok := s.attachStates[[2]int64{userID, botUserID}]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return domain.BotAttachMenuState{}, false, nil
|
||||
}
|
||||
return state, true, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) SetAttachMenuState(_ context.Context, state domain.BotAttachMenuState) (domain.BotAttachMenuState, error) {
|
||||
if state.UserID == 0 || state.BotUserID == 0 || state.UserID == state.BotUserID {
|
||||
return domain.BotAttachMenuState{}, domain.ErrBotAttachMenuInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
if _, ok := s.attachMenu[state.BotUserID]; !ok {
|
||||
s.mu.Unlock()
|
||||
return domain.BotAttachMenuState{}, domain.ErrBotAttachMenuInvalid
|
||||
}
|
||||
s.attachStates[[2]int64{state.UserID, state.BotUserID}] = state
|
||||
s.mu.Unlock()
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) SaveRequestedWebViewButton(_ context.Context, button domain.BotRequestedWebViewButton) error {
|
||||
if button.BotUserID == 0 || button.UserID == 0 || button.WebAppReqID == "" || button.ButtonID == 0 {
|
||||
return domain.ErrBotRequestedButtonInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.requestButtons[requestedButtonKey{botUserID: button.BotUserID, userID: button.UserID, reqID: button.WebAppReqID}] = button
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotStore) GetRequestedWebViewButton(_ context.Context, botUserID, userID int64, webAppReqID string) (domain.BotRequestedWebViewButton, bool, error) {
|
||||
key := requestedButtonKey{botUserID: botUserID, userID: userID, reqID: webAppReqID}
|
||||
s.mu.Lock()
|
||||
button, ok := s.requestButtons[key]
|
||||
if ok && !button.ExpiresAt.IsZero() && !button.ExpiresAt.After(time.Now()) {
|
||||
delete(s.requestButtons, key)
|
||||
ok = false
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return domain.BotRequestedWebViewButton{}, false, nil
|
||||
}
|
||||
return button, true, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) DeleteRequestedWebViewButton(_ context.Context, botUserID, userID int64, webAppReqID string) error {
|
||||
s.mu.Lock()
|
||||
delete(s.requestButtons, requestedButtonKey{botUserID: botUserID, userID: userID, reqID: webAppReqID})
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotStore) SetBotEmojiStatusPermission(_ context.Context, botUserID, userID int64, allowed bool) error {
|
||||
if botUserID == 0 || userID == 0 || botUserID == userID {
|
||||
return domain.ErrBotNotFound
|
||||
}
|
||||
s.mu.Lock()
|
||||
if _, ok := s.byID[botUserID]; !ok {
|
||||
s.mu.Unlock()
|
||||
return domain.ErrBotNotFound
|
||||
}
|
||||
if allowed {
|
||||
s.emojiPerms[[2]int64{botUserID, userID}] = true
|
||||
} else {
|
||||
delete(s.emojiPerms, [2]int64{botUserID, userID})
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotStore) BotEmojiStatusPermission(_ context.Context, botUserID, userID int64) (bool, error) {
|
||||
s.mu.RLock()
|
||||
allowed := s.emojiPerms[[2]int64{botUserID, userID}]
|
||||
s.mu.RUnlock()
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) PutWebViewCustomMethodQuery(_ context.Context, query domain.BotWebViewCustomMethodQuery) error {
|
||||
if query.ID == "" || query.BotUserID == 0 || query.UserID == 0 || query.CustomMethod == "" {
|
||||
return domain.ErrBotCustomMethodUnavailable
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.customMethod[query.ID] = query
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBotProfile 是 GetBot 的同步只读快照(editProfile 前置存在性检查用)。
|
||||
func (s *BotStore) GetBotProfile(botUserID int64) (domain.BotProfile, bool) {
|
||||
s.mu.RLock()
|
||||
p, ok := s.byID[botUserID]
|
||||
s.mu.RUnlock()
|
||||
return p, ok
|
||||
}
|
||||
|
||||
func (s *BotStore) GetBotChatState(_ context.Context, botUserID, userID int64) (domain.BotChatState, bool, error) {
|
||||
s.mu.RLock()
|
||||
state, ok := s.states[[2]int64{botUserID, userID}]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return domain.BotChatState{}, false, nil
|
||||
}
|
||||
return cloneBotChatState(state), true, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) UpsertBotChatState(_ context.Context, state domain.BotChatState) error {
|
||||
s.mu.Lock()
|
||||
s.states[[2]int64{state.BotUserID, state.UserID}] = cloneBotChatState(state)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotStore) DeleteBotChatState(_ context.Context, botUserID, userID int64) error {
|
||||
s.mu.Lock()
|
||||
delete(s.states, [2]int64{botUserID, userID})
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneBotProfile(p domain.BotProfile) domain.BotProfile {
|
||||
out := p
|
||||
out.Commands = append([]domain.BotCommand(nil), p.Commands...)
|
||||
if p.AppSettings != nil {
|
||||
settings := cloneBotAppSettings(*p.AppSettings)
|
||||
out.AppSettings = &settings
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *BotStore) enrichBotProfileLocked(p domain.BotProfile) domain.BotProfile {
|
||||
if _, ok := s.appSettings[p.BotUserID]; ok {
|
||||
settings := cloneBotAppSettings(s.appSettings[p.BotUserID])
|
||||
p.AppSettings = &settings
|
||||
}
|
||||
for _, app := range s.appsByID {
|
||||
if app.BotUserID == p.BotUserID && app.Main {
|
||||
p.HasMainApp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, ok := s.attachMenu[p.BotUserID]; ok {
|
||||
p.HasAttachMenu = true
|
||||
}
|
||||
for _, items := range s.previewMedia {
|
||||
for _, media := range items {
|
||||
if media.BotUserID == p.BotUserID {
|
||||
p.HasPreviewMedias = true
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func cloneBotApp(app domain.BotApp) domain.BotApp {
|
||||
return app
|
||||
}
|
||||
|
||||
func cloneBotAppSettings(settings domain.BotAppSettings) domain.BotAppSettings {
|
||||
out := settings
|
||||
out.PlaceholderPath = append([]byte(nil), settings.PlaceholderPath...)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneAttachMenuBot(bot domain.BotAttachMenuBot) domain.BotAttachMenuBot {
|
||||
out := bot
|
||||
out.PeerTypes = append([]string(nil), bot.PeerTypes...)
|
||||
out.Icons = make([]domain.BotAttachMenuIcon, len(bot.Icons))
|
||||
for i, icon := range bot.Icons {
|
||||
out.Icons[i] = icon
|
||||
out.Icons[i].Colors = append([]domain.BotAttachMenuIconColor(nil), icon.Colors...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneBotChatState(state domain.BotChatState) domain.BotChatState {
|
||||
out := state
|
||||
if state.Draft != nil {
|
||||
out.Draft = make(map[string]string, len(state.Draft))
|
||||
for k, v := range state.Draft {
|
||||
out.Draft[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortBotProfiles(list []domain.BotProfile) {
|
||||
for i := 1; i < len(list); i++ {
|
||||
for j := i; j > 0 && list[j].BotUserID < list[j-1].BotUserID; j-- {
|
||||
list[j], list[j-1] = list[j-1], list[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sortBotApps(list []domain.BotApp) {
|
||||
for i := 1; i < len(list); i++ {
|
||||
for j := i; j > 0 && appSortKey(list[j]) < appSortKey(list[j-1]); j-- {
|
||||
list[j], list[j-1] = list[j-1], list[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appSortKey(app domain.BotApp) string {
|
||||
return strconv.FormatInt(app.BotUserID, 10) + ":" + app.ShortName
|
||||
}
|
||||
|
||||
func sortBotPreviewMedia(list []domain.BotAppPreviewMedia) {
|
||||
for i := 1; i < len(list); i++ {
|
||||
for j := i; j > 0 && (list[j].Position < list[j-1].Position || (list[j].Position == list[j-1].Position && list[j].ID < list[j-1].ID)); j-- {
|
||||
list[j], list[j-1] = list[j-1], list[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sortAttachMenuBots(list []domain.BotAttachMenuBot) {
|
||||
for i := 1; i < len(list); i++ {
|
||||
for j := i; j > 0 && list[j].BotUserID < list[j-1].BotUserID; j-- {
|
||||
list[j], list[j-1] = list[j-1], list[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
593
internal/store/memory/business.go
Normal file
593
internal/store/memory/business.go
Normal file
|
|
@ -0,0 +1,593 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *PasswordStore) GetBusinessProfile(_ context.Context, userID int64) (domain.BusinessProfile, bool, error) {
|
||||
s.mu.RLock()
|
||||
profile, ok := s.businessProfiles[userID]
|
||||
s.mu.RUnlock()
|
||||
return cloneBusinessProfile(profile), ok, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) SaveBusinessProfile(_ context.Context, profile domain.BusinessProfile) error {
|
||||
if profile.UserID == 0 {
|
||||
return domain.ErrBusinessProfileInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.businessProfiles[profile.UserID] = cloneBusinessProfile(profile)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) ListBusinessChatLinks(_ context.Context, ownerUserID int64) ([]domain.BusinessChatLink, error) {
|
||||
s.mu.RLock()
|
||||
slugs := append([]string(nil), s.businessChatLinkSlugs[ownerUserID]...)
|
||||
out := make([]domain.BusinessChatLink, 0, len(slugs))
|
||||
for _, slug := range slugs {
|
||||
if link, ok := s.businessChatLinks[slug]; ok {
|
||||
out = append(out, cloneBusinessChatLink(link))
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].CreatedAt == out[j].CreatedAt {
|
||||
return out[i].Slug < out[j].Slug
|
||||
}
|
||||
return out[i].CreatedAt < out[j].CreatedAt
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) CreateBusinessChatLink(_ context.Context, link domain.BusinessChatLink) (domain.BusinessChatLink, error) {
|
||||
if link.OwnerUserID == 0 || link.Slug == "" || link.Link == "" || link.Message == "" {
|
||||
return domain.BusinessChatLink{}, domain.ErrBusinessChatLinkInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.businessChatLinks[link.Slug]; exists {
|
||||
return domain.BusinessChatLink{}, domain.ErrBusinessChatLinkInvalid
|
||||
}
|
||||
if len(s.businessChatLinkSlugs[link.OwnerUserID]) >= domain.MaxBusinessChatLinks {
|
||||
return domain.BusinessChatLink{}, domain.ErrBusinessChatLinksTooMuch
|
||||
}
|
||||
link = cloneBusinessChatLink(link)
|
||||
s.businessChatLinks[link.Slug] = link
|
||||
s.businessChatLinkSlugs[link.OwnerUserID] = append(s.businessChatLinkSlugs[link.OwnerUserID], link.Slug)
|
||||
return cloneBusinessChatLink(link), nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) UpdateBusinessChatLink(_ context.Context, ownerUserID int64, slug string, input domain.BusinessChatLinkInput) (domain.BusinessChatLink, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
link, ok := s.businessChatLinks[slug]
|
||||
if !ok || link.OwnerUserID != ownerUserID {
|
||||
return domain.BusinessChatLink{}, domain.ErrBusinessChatLinkNotFound
|
||||
}
|
||||
link.Message = input.Message
|
||||
link.Entities = append([]domain.MessageEntity(nil), input.Entities...)
|
||||
link.Title = input.Title
|
||||
s.businessChatLinks[slug] = cloneBusinessChatLink(link)
|
||||
return cloneBusinessChatLink(link), nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) DeleteBusinessChatLink(_ context.Context, ownerUserID int64, slug string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
link, ok := s.businessChatLinks[slug]
|
||||
if !ok || link.OwnerUserID != ownerUserID {
|
||||
return false, nil
|
||||
}
|
||||
delete(s.businessChatLinks, slug)
|
||||
slugs := s.businessChatLinkSlugs[ownerUserID]
|
||||
next := slugs[:0]
|
||||
for _, item := range slugs {
|
||||
if item != slug {
|
||||
next = append(next, item)
|
||||
}
|
||||
}
|
||||
s.businessChatLinkSlugs[ownerUserID] = next
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) ResolveBusinessChatLink(_ context.Context, slug string, bumpViews bool) (domain.BusinessChatLink, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
link, ok := s.businessChatLinks[slug]
|
||||
if !ok {
|
||||
return domain.BusinessChatLink{}, false, nil
|
||||
}
|
||||
if bumpViews {
|
||||
link.Views++
|
||||
s.businessChatLinks[slug] = link
|
||||
}
|
||||
return cloneBusinessChatLink(link), true, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) ListQuickReplies(_ context.Context, ownerUserID int64, includeTopMessages bool) (domain.QuickReplyList, error) {
|
||||
s.mu.RLock()
|
||||
out := s.quickReplyListLocked(ownerUserID, includeTopMessages)
|
||||
s.mu.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) CheckQuickReplyShortcut(_ context.Context, ownerUserID int64, shortcut string) (bool, error) {
|
||||
shortcut, err := domain.NormalizeQuickReplyShortcut(shortcut)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
_, ok := s.quickReplyByShortcut[ownerUserID][quickReplyShortcutKey(shortcut)]
|
||||
s.mu.RUnlock()
|
||||
return !ok, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) SaveQuickReplyText(_ context.Context, ownerUserID int64, shortcut string, msg domain.QuickReplyMessage) (domain.QuickReplyMutation, error) {
|
||||
shortcut, err := domain.NormalizeQuickReplyShortcut(shortcut)
|
||||
if err != nil {
|
||||
return domain.QuickReplyMutation{}, err
|
||||
}
|
||||
if msg.Message == "" || len(msg.Entities) > domain.MaxMessageEntityCount {
|
||||
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.ensureQuickReplyMapsLocked(ownerUserID)
|
||||
key := quickReplyShortcutKey(shortcut)
|
||||
replyID, found := s.quickReplyByShortcut[ownerUserID][key]
|
||||
created := false
|
||||
if !found {
|
||||
if len(s.quickReplies[ownerUserID]) >= domain.MaxQuickReplies {
|
||||
return domain.QuickReplyMutation{}, domain.ErrQuickRepliesTooMuch
|
||||
}
|
||||
replyID = s.nextQuickReplyID[ownerUserID] + 1
|
||||
s.nextQuickReplyID[ownerUserID] = replyID
|
||||
s.quickReplyByShortcut[ownerUserID][key] = replyID
|
||||
s.quickReplies[ownerUserID][replyID] = domain.QuickReply{
|
||||
OwnerUserID: ownerUserID,
|
||||
ID: replyID,
|
||||
Shortcut: shortcut,
|
||||
SortOrder: len(s.quickReplies[ownerUserID]) + 1,
|
||||
}
|
||||
s.quickReplyMessages[ownerUserID][replyID] = make(map[int]domain.QuickReplyMessage)
|
||||
created = true
|
||||
}
|
||||
if len(s.quickReplyMessages[ownerUserID][replyID]) >= domain.MaxQuickReplyMessages {
|
||||
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
|
||||
}
|
||||
msg.OwnerUserID = ownerUserID
|
||||
msg.ShortcutID = replyID
|
||||
msg.ID = s.nextQuickReplyMessageID[ownerUserID] + 1
|
||||
s.nextQuickReplyMessageID[ownerUserID] = msg.ID
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
s.quickReplyMessages[ownerUserID][replyID][msg.ID] = msg
|
||||
s.refreshQuickReplyLocked(ownerUserID, replyID)
|
||||
reply := cloneQuickReply(s.quickReplies[ownerUserID][replyID])
|
||||
kind := domain.QuickReplyMutationMessage
|
||||
if created {
|
||||
kind = domain.QuickReplyMutationNew
|
||||
}
|
||||
return domain.QuickReplyMutation{
|
||||
Kind: kind,
|
||||
List: s.quickReplyListLocked(ownerUserID, true),
|
||||
QuickReply: reply,
|
||||
ShortcutID: replyID,
|
||||
Message: cloneQuickReplyMessage(msg),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) GetQuickReplyMessages(_ context.Context, ownerUserID int64, shortcutID int, ids []int) (domain.QuickReplyMessages, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, ok := s.quickReplies[ownerUserID][shortcutID]; !ok {
|
||||
return domain.QuickReplyMessages{}, domain.ErrShortcutInvalid
|
||||
}
|
||||
msgs := s.quickReplyMessages[ownerUserID][shortcutID]
|
||||
selected := make([]domain.QuickReplyMessage, 0, len(msgs))
|
||||
if len(ids) == 0 {
|
||||
for _, msg := range msgs {
|
||||
selected = append(selected, cloneQuickReplyMessage(msg))
|
||||
}
|
||||
} else {
|
||||
for _, id := range ids {
|
||||
msg, ok := msgs[id]
|
||||
if !ok {
|
||||
return domain.QuickReplyMessages{}, domain.ErrShortcutInvalid
|
||||
}
|
||||
selected = append(selected, cloneQuickReplyMessage(msg))
|
||||
}
|
||||
}
|
||||
sortQuickReplyMessages(selected)
|
||||
return domain.QuickReplyMessages{
|
||||
OwnerUserID: ownerUserID,
|
||||
ShortcutID: shortcutID,
|
||||
Messages: selected,
|
||||
Count: len(msgs),
|
||||
Hash: quickReplyMessagesHash(selected),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) RenameQuickReplyShortcut(_ context.Context, ownerUserID int64, shortcutID int, shortcut string) (domain.QuickReplyMutation, error) {
|
||||
shortcut, err := domain.NormalizeQuickReplyShortcut(shortcut)
|
||||
if err != nil {
|
||||
return domain.QuickReplyMutation{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
reply, ok := s.quickReplies[ownerUserID][shortcutID]
|
||||
if !ok {
|
||||
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
|
||||
}
|
||||
key := quickReplyShortcutKey(shortcut)
|
||||
if existingID, exists := s.quickReplyByShortcut[ownerUserID][key]; exists && existingID != shortcutID {
|
||||
return domain.QuickReplyMutation{}, domain.ErrShortcutOccupied
|
||||
}
|
||||
delete(s.quickReplyByShortcut[ownerUserID], quickReplyShortcutKey(reply.Shortcut))
|
||||
reply.Shortcut = shortcut
|
||||
s.quickReplyByShortcut[ownerUserID][key] = shortcutID
|
||||
s.quickReplies[ownerUserID][shortcutID] = reply
|
||||
return domain.QuickReplyMutation{Kind: domain.QuickReplyMutationList, List: s.quickReplyListLocked(ownerUserID, true)}, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) ReorderQuickReplies(_ context.Context, ownerUserID int64, order []int) (domain.QuickReplyMutation, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
replies := s.quickReplies[ownerUserID]
|
||||
if len(order) != len(replies) {
|
||||
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
|
||||
}
|
||||
seen := make(map[int]struct{}, len(order))
|
||||
for i, id := range order {
|
||||
reply, ok := replies[id]
|
||||
if !ok {
|
||||
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
|
||||
}
|
||||
if _, dup := seen[id]; dup {
|
||||
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
reply.SortOrder = i + 1
|
||||
replies[id] = reply
|
||||
}
|
||||
return domain.QuickReplyMutation{Kind: domain.QuickReplyMutationList, List: s.quickReplyListLocked(ownerUserID, true)}, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) DeleteQuickReplyShortcut(_ context.Context, ownerUserID int64, shortcutID int) (domain.QuickReplyMutation, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
reply, ok := s.quickReplies[ownerUserID][shortcutID]
|
||||
if !ok {
|
||||
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
|
||||
}
|
||||
delete(s.quickReplyByShortcut[ownerUserID], quickReplyShortcutKey(reply.Shortcut))
|
||||
delete(s.quickReplies[ownerUserID], shortcutID)
|
||||
delete(s.quickReplyMessages[ownerUserID], shortcutID)
|
||||
normalizeQuickReplyOrderLocked(s.quickReplies[ownerUserID])
|
||||
return domain.QuickReplyMutation{
|
||||
Kind: domain.QuickReplyMutationDelete,
|
||||
List: s.quickReplyListLocked(ownerUserID, true),
|
||||
ShortcutID: shortcutID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) DeleteQuickReplyMessages(_ context.Context, ownerUserID int64, shortcutID int, ids []int) (domain.QuickReplyMutation, error) {
|
||||
if len(ids) == 0 {
|
||||
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.quickReplies[ownerUserID][shortcutID]; !ok {
|
||||
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
|
||||
}
|
||||
msgs := s.quickReplyMessages[ownerUserID][shortcutID]
|
||||
deleted := make([]int, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if _, ok := msgs[id]; !ok {
|
||||
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
|
||||
}
|
||||
delete(msgs, id)
|
||||
deleted = append(deleted, id)
|
||||
}
|
||||
s.refreshQuickReplyLocked(ownerUserID, shortcutID)
|
||||
return domain.QuickReplyMutation{
|
||||
Kind: domain.QuickReplyMutationIDs,
|
||||
List: s.quickReplyListLocked(ownerUserID, true),
|
||||
ShortcutID: shortcutID,
|
||||
MessageIDs: deleted,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) ReserveBusinessAutomationDelivery(_ context.Context, delivery domain.BusinessAutomationDelivery) (bool, error) {
|
||||
if delivery.OwnerUserID == 0 || delivery.PeerUserID == 0 || delivery.Kind == "" || delivery.TriggerMessageID == 0 {
|
||||
return false, domain.ErrBusinessProfileInvalid
|
||||
}
|
||||
key := businessAutomationDeliveryKey{
|
||||
ownerUserID: delivery.OwnerUserID,
|
||||
peerUserID: delivery.PeerUserID,
|
||||
kind: delivery.Kind,
|
||||
triggerMessageID: delivery.TriggerMessageID,
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.businessDeliveries[key]; ok {
|
||||
return false, nil
|
||||
}
|
||||
s.businessDeliveries[key] = delivery
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) LastBusinessAutomationDelivery(_ context.Context, ownerUserID, peerUserID int64, kind domain.BusinessAutomationKind) (domain.BusinessAutomationDelivery, bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var out domain.BusinessAutomationDelivery
|
||||
found := false
|
||||
for _, delivery := range s.businessDeliveries {
|
||||
if delivery.OwnerUserID != ownerUserID || delivery.PeerUserID != peerUserID || delivery.Kind != kind {
|
||||
continue
|
||||
}
|
||||
if !found || delivery.SentAt > out.SentAt || delivery.SentAt == out.SentAt && delivery.TriggerMessageID > out.TriggerMessageID {
|
||||
out = delivery
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return out, found, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) GetConnectedBusinessBot(_ context.Context, ownerUserID int64) (domain.ConnectedBusinessBot, bool, error) {
|
||||
s.mu.RLock()
|
||||
bot, ok := s.connectedBusinessBots[ownerUserID]
|
||||
s.mu.RUnlock()
|
||||
return cloneConnectedBusinessBot(bot), ok, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) SaveConnectedBusinessBot(_ context.Context, bot domain.ConnectedBusinessBot) (domain.ConnectedBusinessBot, error) {
|
||||
if bot.OwnerUserID == 0 || bot.BotUserID == 0 {
|
||||
return domain.ConnectedBusinessBot{}, domain.ErrBotBusinessMissing
|
||||
}
|
||||
s.mu.Lock()
|
||||
if prev, ok := s.connectedBusinessBots[bot.OwnerUserID]; ok && bot.CreatedAtUnix == 0 {
|
||||
bot.CreatedAtUnix = prev.CreatedAtUnix
|
||||
}
|
||||
s.connectedBusinessBots[bot.OwnerUserID] = cloneConnectedBusinessBot(bot)
|
||||
s.mu.Unlock()
|
||||
return cloneConnectedBusinessBot(bot), nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) DeleteConnectedBusinessBot(_ context.Context, ownerUserID, botUserID int64) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
bot, ok := s.connectedBusinessBots[ownerUserID]
|
||||
if !ok || bot.BotUserID != botUserID {
|
||||
return false, nil
|
||||
}
|
||||
delete(s.connectedBusinessBots, ownerUserID)
|
||||
for key := range s.connectedBusinessBotPeerStates {
|
||||
if key.ownerUserID == ownerUserID {
|
||||
delete(s.connectedBusinessBotPeerStates, key)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) SetConnectedBusinessBotPaused(_ context.Context, ownerUserID, peerUserID int64, paused bool) (domain.ConnectedBusinessBotPeerState, error) {
|
||||
if ownerUserID == 0 || peerUserID == 0 {
|
||||
return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing
|
||||
}
|
||||
key := connectedBusinessBotPeerKey{ownerUserID: ownerUserID, peerUserID: peerUserID}
|
||||
s.mu.Lock()
|
||||
state := s.connectedBusinessBotPeerStates[key]
|
||||
state.OwnerUserID = ownerUserID
|
||||
state.PeerUserID = peerUserID
|
||||
state.Paused = paused
|
||||
s.connectedBusinessBotPeerStates[key] = state
|
||||
s.mu.Unlock()
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) DisableConnectedBusinessBotForPeer(_ context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, error) {
|
||||
if ownerUserID == 0 || peerUserID == 0 {
|
||||
return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing
|
||||
}
|
||||
key := connectedBusinessBotPeerKey{ownerUserID: ownerUserID, peerUserID: peerUserID}
|
||||
s.mu.Lock()
|
||||
state := s.connectedBusinessBotPeerStates[key]
|
||||
state.OwnerUserID = ownerUserID
|
||||
state.PeerUserID = peerUserID
|
||||
state.Paused = false
|
||||
state.Disabled = true
|
||||
s.connectedBusinessBotPeerStates[key] = state
|
||||
s.mu.Unlock()
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) GetConnectedBusinessBotPeerState(_ context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, bool, error) {
|
||||
key := connectedBusinessBotPeerKey{ownerUserID: ownerUserID, peerUserID: peerUserID}
|
||||
s.mu.RLock()
|
||||
state, ok := s.connectedBusinessBotPeerStates[key]
|
||||
s.mu.RUnlock()
|
||||
return state, ok, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) ensureQuickReplyMapsLocked(ownerUserID int64) {
|
||||
if s.quickReplies[ownerUserID] == nil {
|
||||
s.quickReplies[ownerUserID] = make(map[int]domain.QuickReply)
|
||||
}
|
||||
if s.quickReplyByShortcut[ownerUserID] == nil {
|
||||
s.quickReplyByShortcut[ownerUserID] = make(map[string]int)
|
||||
}
|
||||
if s.quickReplyMessages[ownerUserID] == nil {
|
||||
s.quickReplyMessages[ownerUserID] = make(map[int]map[int]domain.QuickReplyMessage)
|
||||
}
|
||||
for id := range s.quickReplies[ownerUserID] {
|
||||
if s.quickReplyMessages[ownerUserID][id] == nil {
|
||||
s.quickReplyMessages[ownerUserID][id] = make(map[int]domain.QuickReplyMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PasswordStore) refreshQuickReplyLocked(ownerUserID int64, shortcutID int) {
|
||||
s.ensureQuickReplyMapsLocked(ownerUserID)
|
||||
reply := s.quickReplies[ownerUserID][shortcutID]
|
||||
reply.Count = len(s.quickReplyMessages[ownerUserID][shortcutID])
|
||||
reply.TopMessage = 0
|
||||
for id := range s.quickReplyMessages[ownerUserID][shortcutID] {
|
||||
if id > reply.TopMessage {
|
||||
reply.TopMessage = id
|
||||
}
|
||||
}
|
||||
s.quickReplies[ownerUserID][shortcutID] = reply
|
||||
}
|
||||
|
||||
func (s *PasswordStore) quickReplyListLocked(ownerUserID int64, includeTopMessages bool) domain.QuickReplyList {
|
||||
replies := make([]domain.QuickReply, 0, len(s.quickReplies[ownerUserID]))
|
||||
for id := range s.quickReplies[ownerUserID] {
|
||||
s.refreshQuickReplyLocked(ownerUserID, id)
|
||||
replies = append(replies, cloneQuickReply(s.quickReplies[ownerUserID][id]))
|
||||
}
|
||||
sortQuickReplies(replies)
|
||||
messages := make([]domain.QuickReplyMessage, 0, len(replies))
|
||||
if includeTopMessages {
|
||||
for _, reply := range replies {
|
||||
if reply.TopMessage == 0 {
|
||||
continue
|
||||
}
|
||||
if msg, ok := s.quickReplyMessages[ownerUserID][reply.ID][reply.TopMessage]; ok {
|
||||
messages = append(messages, cloneQuickReplyMessage(msg))
|
||||
}
|
||||
}
|
||||
sortQuickReplyMessages(messages)
|
||||
}
|
||||
return domain.QuickReplyList{
|
||||
OwnerUserID: ownerUserID,
|
||||
QuickReplies: replies,
|
||||
Messages: messages,
|
||||
Hash: quickReplyListHash(replies),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeQuickReplyOrderLocked(replies map[int]domain.QuickReply) {
|
||||
list := make([]domain.QuickReply, 0, len(replies))
|
||||
for _, reply := range replies {
|
||||
list = append(list, reply)
|
||||
}
|
||||
sortQuickReplies(list)
|
||||
for i, reply := range list {
|
||||
reply.SortOrder = i + 1
|
||||
replies[reply.ID] = reply
|
||||
}
|
||||
}
|
||||
|
||||
func sortQuickReplies(items []domain.QuickReply) {
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].SortOrder == items[j].SortOrder {
|
||||
return items[i].ID < items[j].ID
|
||||
}
|
||||
return items[i].SortOrder < items[j].SortOrder
|
||||
})
|
||||
}
|
||||
|
||||
func sortQuickReplyMessages(items []domain.QuickReplyMessage) {
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
}
|
||||
|
||||
func quickReplyShortcutKey(shortcut string) string {
|
||||
return strings.ToLower(shortcut)
|
||||
}
|
||||
|
||||
func quickReplyListHash(items []domain.QuickReply) int64 {
|
||||
h := fnv.New64a()
|
||||
for _, item := range items {
|
||||
writeHashInt(h, item.ID)
|
||||
writeHashString(h, item.Shortcut)
|
||||
writeHashInt(h, item.TopMessage)
|
||||
writeHashInt(h, item.Count)
|
||||
}
|
||||
return int64(h.Sum64() & 0x7fffffffffffffff)
|
||||
}
|
||||
|
||||
func quickReplyMessagesHash(items []domain.QuickReplyMessage) int64 {
|
||||
h := fnv.New64a()
|
||||
for _, item := range items {
|
||||
writeHashInt(h, item.ID)
|
||||
writeHashString(h, item.Message)
|
||||
writeHashInt(h, item.Date)
|
||||
}
|
||||
return int64(h.Sum64() & 0x7fffffffffffffff)
|
||||
}
|
||||
|
||||
func writeHashInt(h interface{ Write([]byte) (int, error) }, v int) {
|
||||
_, _ = h.Write([]byte{
|
||||
byte(v >> 24),
|
||||
byte(v >> 16),
|
||||
byte(v >> 8),
|
||||
byte(v),
|
||||
})
|
||||
}
|
||||
|
||||
func writeHashString(h interface{ Write([]byte) (int, error) }, v string) {
|
||||
_, _ = h.Write([]byte(v))
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
|
||||
func cloneBusinessProfile(in domain.BusinessProfile) domain.BusinessProfile {
|
||||
out := in
|
||||
if in.WorkHours != nil {
|
||||
v := *in.WorkHours
|
||||
v.WeeklyOpen = append([]domain.BusinessWeeklyOpen(nil), in.WorkHours.WeeklyOpen...)
|
||||
out.WorkHours = &v
|
||||
}
|
||||
if in.Location != nil {
|
||||
v := *in.Location
|
||||
if in.Location.Geo != nil {
|
||||
geo := *in.Location.Geo
|
||||
v.Geo = &geo
|
||||
}
|
||||
out.Location = &v
|
||||
}
|
||||
if in.Intro != nil {
|
||||
v := *in.Intro
|
||||
out.Intro = &v
|
||||
}
|
||||
if in.Greeting != nil {
|
||||
v := *in.Greeting
|
||||
v.Recipients.Users = append([]int64(nil), in.Greeting.Recipients.Users...)
|
||||
out.Greeting = &v
|
||||
}
|
||||
if in.Away != nil {
|
||||
v := *in.Away
|
||||
v.Recipients.Users = append([]int64(nil), in.Away.Recipients.Users...)
|
||||
out.Away = &v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneBusinessChatLink(in domain.BusinessChatLink) domain.BusinessChatLink {
|
||||
out := in
|
||||
out.Entities = append([]domain.MessageEntity(nil), in.Entities...)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneQuickReply(in domain.QuickReply) domain.QuickReply {
|
||||
return in
|
||||
}
|
||||
|
||||
func cloneQuickReplyMessage(in domain.QuickReplyMessage) domain.QuickReplyMessage {
|
||||
out := in
|
||||
out.Entities = append([]domain.MessageEntity(nil), in.Entities...)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneConnectedBusinessBot(in domain.ConnectedBusinessBot) domain.ConnectedBusinessBot {
|
||||
out := in
|
||||
out.Recipients.Users = append([]int64(nil), in.Recipients.Users...)
|
||||
out.Recipients.ExcludeUsers = append([]int64(nil), in.Recipients.ExcludeUsers...)
|
||||
return out
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
246
internal/store/memory/channel_boosts.go
Normal file
246
internal/store/memory/channel_boosts.go
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) GetPremiumBoostStatus(_ context.Context, viewerUserID, channelID int64, now int) (domain.PremiumBoostStatus, error) {
|
||||
if viewerUserID == 0 || channelID == 0 {
|
||||
return domain.PremiumBoostStatus{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, _, _, err := s.channelForViewerLocked(viewerUserID, channelID); err != nil {
|
||||
return domain.PremiumBoostStatus{}, err
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
|
||||
total := 0
|
||||
my := make([]domain.PremiumBoostSlot, 0, 1)
|
||||
for _, slot := range s.boostSlots {
|
||||
if slot.Peer != peer {
|
||||
continue
|
||||
}
|
||||
total += slot.Weight(now)
|
||||
if slot.UserID == viewerUserID && slot.Assigned(now) {
|
||||
my = append(my, clonePremiumBoostSlot(slot))
|
||||
}
|
||||
}
|
||||
sortPremiumBoostSlots(my)
|
||||
return domain.PremiumBoostStatusForCount(peer, total, my), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListPremiumBoosts(_ context.Context, viewerUserID, channelID int64, gifts bool, offset string, limit, now int) (domain.PremiumBoostList, error) {
|
||||
if viewerUserID == 0 || channelID == 0 {
|
||||
return domain.PremiumBoostList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxPremiumBoostsListLimit {
|
||||
limit = domain.MaxPremiumBoostsListLimit
|
||||
}
|
||||
start, err := boostOffsetIndex(offset)
|
||||
if err != nil {
|
||||
return domain.PremiumBoostList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.PremiumBoostList{}, err
|
||||
}
|
||||
if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin {
|
||||
return domain.PremiumBoostList{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
items := s.activeBoostSlotsForPeerLocked(domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, now, func(slot domain.PremiumBoostSlot) bool {
|
||||
return !gifts || slot.Gift || slot.Giveaway
|
||||
})
|
||||
return premiumBoostPage(items, start, limit), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetPremiumMyBoosts(_ context.Context, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error) {
|
||||
if userID == 0 {
|
||||
return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.premiumMyBoostsLocked(userID, now, premiumUntil), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ApplyPremiumBoost(_ context.Context, userID, channelID int64, slots []int, now, premiumUntil int) (domain.PremiumMyBoosts, error) {
|
||||
if userID == 0 || channelID == 0 || len(slots) == 0 || premiumUntil <= now {
|
||||
if premiumUntil <= now {
|
||||
return domain.PremiumMyBoosts{}, domain.ErrPremiumRequired
|
||||
}
|
||||
return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, _, err := s.channelAndMemberLocked(userID, channelID); err != nil {
|
||||
return domain.PremiumMyBoosts{}, err
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
|
||||
for _, slotID := range slots {
|
||||
if slotID != domain.DefaultPremiumBoostSlotID {
|
||||
return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid
|
||||
}
|
||||
key := boostSlotKey{userID: userID, slot: slotID}
|
||||
slot, changed, err := domain.ApplyPremiumBoostSlot(
|
||||
s.boostSlots[key],
|
||||
userID,
|
||||
slotID,
|
||||
peer,
|
||||
now,
|
||||
premiumUntil,
|
||||
domain.DefaultPremiumBoostReassignCooldownSeconds,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.PremiumMyBoosts{}, err
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
s.boostSlots[key] = slot
|
||||
}
|
||||
return s.premiumMyBoostsLocked(userID, now, premiumUntil), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetPremiumUserBoosts(_ context.Context, viewerUserID, channelID, targetUserID int64, now int) (domain.PremiumBoostList, error) {
|
||||
if viewerUserID == 0 || channelID == 0 || targetUserID == 0 {
|
||||
return domain.PremiumBoostList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.PremiumBoostList{}, err
|
||||
}
|
||||
if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin {
|
||||
return domain.PremiumBoostList{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
items := s.activeBoostSlotsForPeerLocked(domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, now, func(slot domain.PremiumBoostSlot) bool {
|
||||
return slot.UserID == targetUserID
|
||||
})
|
||||
return premiumBoostPage(items, 0, len(items)), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) activeBoostSlotsForPeerLocked(peer domain.Peer, now int, keep func(domain.PremiumBoostSlot) bool) []domain.PremiumBoostSlot {
|
||||
items := make([]domain.PremiumBoostSlot, 0)
|
||||
for _, slot := range s.boostSlots {
|
||||
if slot.Peer != peer || !slot.Assigned(now) {
|
||||
continue
|
||||
}
|
||||
if keep != nil && !keep(slot) {
|
||||
continue
|
||||
}
|
||||
items = append(items, clonePremiumBoostSlot(slot))
|
||||
}
|
||||
sortPremiumBoostSlots(items)
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *ChannelStore) selfBoostsAppliedLocked(userID, channelID int64, now int) int {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return 0
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
|
||||
total := 0
|
||||
for _, slot := range s.boostSlots {
|
||||
if slot.UserID == userID && slot.Peer == peer {
|
||||
total += slot.Weight(now)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (s *ChannelStore) premiumMyBoostsLocked(userID int64, now, premiumUntil int) domain.PremiumMyBoosts {
|
||||
out := domain.PremiumMyBoosts{}
|
||||
if premiumUntil <= now {
|
||||
return out
|
||||
}
|
||||
foundBase := false
|
||||
for _, slot := range s.boostSlots {
|
||||
if slot.UserID != userID || !slot.Active(now) {
|
||||
continue
|
||||
}
|
||||
if slot.Slot == domain.DefaultPremiumBoostSlotID {
|
||||
foundBase = true
|
||||
if slot.Expires < premiumUntil {
|
||||
slot.Expires = premiumUntil
|
||||
}
|
||||
}
|
||||
out.Slots = append(out.Slots, clonePremiumBoostSlot(slot))
|
||||
}
|
||||
if !foundBase {
|
||||
out.Slots = append(out.Slots, domain.PremiumBoostSlot{
|
||||
UserID: userID,
|
||||
Slot: domain.DefaultPremiumBoostSlotID,
|
||||
Multiplier: 1,
|
||||
})
|
||||
}
|
||||
sortPremiumBoostSlots(out.Slots)
|
||||
channelIDs := make(map[int64]struct{})
|
||||
for _, slot := range out.Slots {
|
||||
if slot.Peer.Type == domain.PeerTypeChannel && slot.Peer.ID != 0 {
|
||||
channelIDs[slot.Peer.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for channelID := range channelIDs {
|
||||
if channel, ok := s.channels[channelID]; ok && !channel.Deleted {
|
||||
out.Channels = append(out.Channels, cloneChannel(channel))
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Channels, func(i, j int) bool { return out.Channels[i].ID < out.Channels[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
func premiumBoostPage(items []domain.PremiumBoostSlot, start, limit int) domain.PremiumBoostList {
|
||||
if start < 0 || start > len(items) {
|
||||
start = len(items)
|
||||
}
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
out := domain.PremiumBoostList{
|
||||
Count: len(items),
|
||||
Boosts: append([]domain.PremiumBoostSlot(nil), items[start:end]...),
|
||||
}
|
||||
if end < len(items) {
|
||||
out.NextOffset = strconv.Itoa(end)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func boostOffsetIndex(offset string) (int, error) {
|
||||
if offset == "" {
|
||||
return 0, nil
|
||||
}
|
||||
n, err := strconv.Atoi(offset)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n < 0 {
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func sortPremiumBoostSlots(items []domain.PremiumBoostSlot) {
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].Date != items[j].Date {
|
||||
return items[i].Date > items[j].Date
|
||||
}
|
||||
if items[i].UserID != items[j].UserID {
|
||||
return items[i].UserID < items[j].UserID
|
||||
}
|
||||
return items[i].Slot < items[j].Slot
|
||||
})
|
||||
}
|
||||
|
||||
func clonePremiumBoostSlot(in domain.PremiumBoostSlot) domain.PremiumBoostSlot {
|
||||
return in
|
||||
}
|
||||
260
internal/store/memory/channel_core.go
Normal file
260
internal/store/memory/channel_core.go
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) CreateChannel(_ context.Context, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) {
|
||||
if req.CreatorUserID == 0 || strings.TrimSpace(req.Title) == "" {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
channelID := s.nextChannelIDLocked()
|
||||
channel := domain.Channel{
|
||||
ID: channelID,
|
||||
AccessHash: s.nextAccessHashLocked(),
|
||||
CreatorUserID: req.CreatorUserID,
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
About: req.About,
|
||||
Broadcast: req.Broadcast,
|
||||
Megagroup: req.Megagroup,
|
||||
Forum: req.Forum,
|
||||
ForumTabs: req.ForumTabs,
|
||||
ParticipantsCount: 1,
|
||||
AdminsCount: 1,
|
||||
TTLPeriod: req.TTLPeriod,
|
||||
Date: req.Date,
|
||||
}
|
||||
if !channel.Broadcast && !channel.Megagroup {
|
||||
channel.Broadcast = true
|
||||
}
|
||||
inviteID, err := randomMemoryPositiveInt64()
|
||||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
inviteHash, err := randomMemoryInviteHash()
|
||||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
inviteDate := channel.Date
|
||||
if inviteDate == 0 {
|
||||
inviteDate = int(time.Now().Unix())
|
||||
}
|
||||
channel.HasLink = true
|
||||
creator := domain.ChannelMember{
|
||||
ChannelID: channelID,
|
||||
UserID: req.CreatorUserID,
|
||||
Role: domain.ChannelRoleCreator,
|
||||
Status: domain.ChannelMemberActive,
|
||||
JoinedAt: req.Date,
|
||||
AdminRights: domain.ChannelAdminRights{
|
||||
ChangeInfo: true,
|
||||
PostMessages: true,
|
||||
EditMessages: true,
|
||||
DeleteMessages: true,
|
||||
PostStories: true,
|
||||
EditStories: true,
|
||||
DeleteStories: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
},
|
||||
}
|
||||
s.channels[channelID] = channel
|
||||
s.invites[inviteHash] = domain.ChannelInvite{
|
||||
ChannelID: channelID,
|
||||
InviteID: inviteID,
|
||||
Hash: inviteHash,
|
||||
AdminUserID: req.CreatorUserID,
|
||||
Permanent: true,
|
||||
Date: inviteDate,
|
||||
}
|
||||
s.members[channelID] = map[int64]domain.ChannelMember{creator.UserID: creator}
|
||||
members := []domain.ChannelMember{creator}
|
||||
for _, userID := range uniqueNonZero(req.MemberUserIDs, req.CreatorUserID) {
|
||||
member := domain.ChannelMember{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
InviterUserID: req.CreatorUserID,
|
||||
Role: domain.ChannelRoleMember,
|
||||
Status: domain.ChannelMemberActive,
|
||||
JoinedAt: req.Date,
|
||||
}
|
||||
s.members[channelID][userID] = member
|
||||
members = append(members, member)
|
||||
channel.ParticipantsCount++
|
||||
}
|
||||
msg, event := s.appendChannelServiceMessageLocked(channelID, req.CreatorUserID, req.Date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionCreate,
|
||||
Title: channel.Title,
|
||||
})
|
||||
channel.TopMessageID = msg.ID
|
||||
channel.Pts = event.Pts
|
||||
s.channels[channelID] = channel
|
||||
for _, member := range members {
|
||||
s.upsertChannelDialogLocked(member.UserID, channel, msg, member.UserID == req.CreatorUserID)
|
||||
}
|
||||
return domain.CreateChannelResult{
|
||||
Channel: channel,
|
||||
Members: cloneChannelMembers(members),
|
||||
Message: cloneChannelMessage(msg),
|
||||
Event: cloneChannelEvent(event),
|
||||
Recipients: s.activeMemberIDsLocked(channelID, 0, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetChannel(_ context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, preview, err := s.channelForViewerLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelView{}, err
|
||||
}
|
||||
dialog := s.dialogForUserLocked(viewerUserID, channel)
|
||||
if preview {
|
||||
dialog = previewChannelDialog(viewerUserID, channel, member)
|
||||
}
|
||||
var exportedInvite *domain.ChannelInvite
|
||||
if !preview && canExportChannelInvite(member) {
|
||||
if invite, ok := s.permanentInviteForAdminLocked(channelID, viewerUserID); ok {
|
||||
exportedInvite = &invite
|
||||
}
|
||||
}
|
||||
return domain.ChannelView{
|
||||
Channel: cloneChannel(channel),
|
||||
Self: member,
|
||||
Dialog: dialog,
|
||||
SelfBoostsApplied: s.selfBoostsAppliedLocked(viewerUserID, channelID, int(time.Now().Unix())),
|
||||
ExportedInvite: exportedInvite,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveChannel 是 GetChannel 的轻量版:只做访问校验并返回 Channel+Self,跳过 dialog/boost。
|
||||
// 与 postgres 实现语义一致(内存侧 dialog/boost 本就便宜,但保持接口行为对齐)。
|
||||
func (s *ChannelStore) ResolveChannel(_ context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, preview, err := s.channelForViewerLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelView{}, err
|
||||
}
|
||||
view := domain.ChannelView{Channel: cloneChannel(channel), Self: member}
|
||||
if preview {
|
||||
view.Dialog = previewChannelDialog(viewerUserID, channel, member)
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetChannels(_ context.Context, viewerUserID int64, channelIDs []int64) ([]domain.ChannelView, error) {
|
||||
if viewerUserID == 0 || len(channelIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]domain.ChannelView, 0, len(channelIDs))
|
||||
seen := make(map[int64]struct{}, len(channelIDs))
|
||||
for _, channelID := range channelIDs {
|
||||
if channelID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[channelID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[channelID] = struct{}{}
|
||||
channel, member, preview, err := s.channelForViewerLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelUserBanned) {
|
||||
if banned, ok := s.channels[channelID]; ok && !banned.Deleted {
|
||||
out = append(out, domain.ChannelView{
|
||||
Channel: cloneChannel(banned),
|
||||
Self: s.members[channelID][viewerUserID],
|
||||
Forbidden: true,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, domain.ErrChannelInvalid) || errors.Is(err, domain.ErrChannelPrivate) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
dialog := s.dialogForUserLocked(viewerUserID, channel)
|
||||
if preview {
|
||||
dialog = previewChannelDialog(viewerUserID, channel, member)
|
||||
}
|
||||
out = append(out, domain.ChannelView{
|
||||
Channel: cloneChannel(channel),
|
||||
Self: member,
|
||||
Dialog: dialog,
|
||||
SelfBoostsApplied: s.selfBoostsAppliedLocked(viewerUserID, channelID, int(time.Now().Unix())),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetChannelByID(_ context.Context, channelID int64) (domain.Channel, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func publicPreviewableChannel(channel domain.Channel) bool {
|
||||
return publicSearchableChannel(channel)
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func uniqueNonZero(ids []int64, exclude int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
out := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 || id == exclude {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniqueNonZeroInt64s(items ...int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(items))
|
||||
out := make([]int64, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item] = struct{}{}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneChannel(in domain.Channel) domain.Channel {
|
||||
in.ReactionPolicy = copyChannelReactionPolicy(in.ReactionPolicy)
|
||||
in.Wallpaper = domain.CloneWallpaperPtr(in.Wallpaper)
|
||||
return in
|
||||
}
|
||||
75
internal/store/memory/channel_delete_monoforum_test.go
Normal file
75
internal/store/memory/channel_delete_monoforum_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TestDeleteChannelCascadesLinkedMonoforum 验证删除开启了 Direct Messages 的母广播频道时,关联的
|
||||
// monoforum 虚拟频道被一并软删并随结果返回。不级联会留下 monoforum=true 但 linked_monoforum_id 指向
|
||||
// 已删父频道的孤儿(web 客户端渲染崩 + DB 垃圾随删除累积)。
|
||||
func TestDeleteChannelCascadesLinkedMonoforum(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1, Title: "DM Broadcast", Broadcast: true, Date: 1_700_000_900,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create broadcast: %v", err)
|
||||
}
|
||||
parentID := broadcast.Channel.ID
|
||||
enabled, err := store.SetPaidMessagesPrice(ctx, 1, parentID, 0, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enable DM: %v", err)
|
||||
}
|
||||
monoID := enabled.Channel.LinkedMonoforumID
|
||||
if monoID == 0 {
|
||||
t.Fatalf("no monoforum created")
|
||||
}
|
||||
|
||||
res, err := store.DeleteChannel(ctx, domain.DeleteChannelRequest{UserID: 1, ChannelID: parentID, Date: 1_700_001_000})
|
||||
if err != nil {
|
||||
t.Fatalf("delete parent: %v", err)
|
||||
}
|
||||
if !res.Channel.Deleted {
|
||||
t.Fatalf("parent not marked deleted: %+v", res.Channel)
|
||||
}
|
||||
if res.LinkedMonoforum == nil || res.LinkedMonoforum.ID != monoID || !res.LinkedMonoforum.Deleted {
|
||||
t.Fatalf("result LinkedMonoforum = %+v, want deleted mono %d", res.LinkedMonoforum, monoID)
|
||||
}
|
||||
if mono := store.channels[monoID]; !mono.Deleted {
|
||||
t.Fatalf("monoforum %d not soft-deleted in store: %+v", monoID, mono)
|
||||
}
|
||||
// 删后 getDialogs 不应再返回母频道或 mono。
|
||||
dialogs, err := store.ListChannelDialogs(ctx, 1, domain.DialogFilter{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("list dialogs after delete: %v", err)
|
||||
}
|
||||
for _, d := range dialogs.Dialogs {
|
||||
if d.Peer.ID == monoID || d.Peer.ID == parentID {
|
||||
t.Fatalf("dialogs after delete still include parent/mono: %+v", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteChannelNoMonoforumNoCascade 锁定不开私信的普通频道删除不受影响:LinkedMonoforum 为 nil,
|
||||
// 不会误触级联。
|
||||
func TestDeleteChannelNoMonoforumNoCascade(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
plain, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1, Title: "Plain Group", Megagroup: true, Date: 1_700_000_900,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
res, err := store.DeleteChannel(ctx, domain.DeleteChannelRequest{UserID: 1, ChannelID: plain.Channel.ID, Date: 1_700_001_000})
|
||||
if err != nil {
|
||||
t.Fatalf("delete channel: %v", err)
|
||||
}
|
||||
if res.LinkedMonoforum != nil {
|
||||
t.Fatalf("plain channel delete returned LinkedMonoforum %+v, want nil", res.LinkedMonoforum)
|
||||
}
|
||||
}
|
||||
577
internal/store/memory/channel_dialogs.go
Normal file
577
internal/store/memory/channel_dialogs.go
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) ListChannelDialogs(_ context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) {
|
||||
if viewerUserID == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
}
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
channelIDs := make([]int64, 0, len(s.dialogs[viewerUserID]))
|
||||
seen := make(map[int64]struct{}, len(s.dialogs[viewerUserID]))
|
||||
for channelID := range s.dialogs[viewerUserID] {
|
||||
channelIDs = append(channelIDs, channelID)
|
||||
seen[channelID] = struct{}{}
|
||||
}
|
||||
for channelID, members := range s.members {
|
||||
if _, ok := seen[channelID]; ok {
|
||||
continue
|
||||
}
|
||||
if member, ok := members[viewerUserID]; ok && member.Status == domain.ChannelMemberActive {
|
||||
channelIDs = append(channelIDs, channelID)
|
||||
seen[channelID] = struct{}{}
|
||||
}
|
||||
}
|
||||
syntheticMembers := make(map[int64]domain.ChannelMember)
|
||||
for channelID, channel := range s.channels {
|
||||
if _, ok := seen[channelID]; ok {
|
||||
continue
|
||||
}
|
||||
if !channel.Monoforum || channel.LinkedMonoforumID == 0 || channel.Deleted {
|
||||
continue
|
||||
}
|
||||
parentMember, ok := s.members[channel.LinkedMonoforumID][viewerUserID]
|
||||
if !ok || parentMember.Status != domain.ChannelMemberActive || !isChannelAdmin(parentMember) {
|
||||
continue
|
||||
}
|
||||
channelIDs = append(channelIDs, channelID)
|
||||
seen[channelID] = struct{}{}
|
||||
syntheticMembers[channelID] = syntheticMonoforumAdminMember(channel, parentMember)
|
||||
}
|
||||
|
||||
items := make([]domain.Dialog, 0, len(channelIDs))
|
||||
for _, channelID := range channelIDs {
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted {
|
||||
continue
|
||||
}
|
||||
member, synthetic := syntheticMembers[channelID]
|
||||
if !synthetic {
|
||||
if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil {
|
||||
continue
|
||||
}
|
||||
member = s.members[channelID][viewerUserID]
|
||||
}
|
||||
if member.Status != domain.ChannelMemberActive {
|
||||
continue
|
||||
}
|
||||
item := channelDialogToDialog(s.dialogForMemberLocked(viewerUserID, channel, member), channel.Pts, member.Status)
|
||||
if !channelDialogMatchesFilter(item, channel, filter) {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].Pinned != items[j].Pinned {
|
||||
return items[i].Pinned
|
||||
}
|
||||
if items[i].PinnedOrder != items[j].PinnedOrder {
|
||||
return items[i].PinnedOrder > items[j].PinnedOrder
|
||||
}
|
||||
if items[i].TopMessageDate != items[j].TopMessageDate {
|
||||
return items[i].TopMessageDate > items[j].TopMessageDate
|
||||
}
|
||||
if items[i].TopMessage != items[j].TopMessage {
|
||||
return items[i].TopMessage > items[j].TopMessage
|
||||
}
|
||||
return items[i].Peer.ID > items[j].Peer.ID
|
||||
})
|
||||
out := domain.ChannelDialogList{Count: len(items)}
|
||||
for _, dialog := range items {
|
||||
if len(out.Dialogs) >= limit {
|
||||
break
|
||||
}
|
||||
out.Dialogs = append(out.Dialogs, dialog)
|
||||
channel := s.channels[dialog.Peer.ID]
|
||||
out.Channels = append(out.Channels, channel)
|
||||
if msg, ok := s.findMessageLocked(dialog.Peer.ID, dialog.TopMessage); ok && !msg.Deleted {
|
||||
out.Messages = append(out.Messages, cloneChannelMessage(msg))
|
||||
}
|
||||
}
|
||||
// 与 PG 同因:getDialogs top message 按 viewer 补未读提及标志。
|
||||
s.populateChannelMessageUnreadFlagsLocked(viewerUserID, out.Messages)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetChannelDialogs(_ context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) {
|
||||
if viewerUserID == 0 || len(channelIDs) == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := domain.ChannelDialogList{}
|
||||
seen := make(map[int64]struct{}, len(channelIDs))
|
||||
for _, channelID := range channelIDs {
|
||||
if channelID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[channelID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[channelID] = struct{}{}
|
||||
channel, member, _, err := s.channelForViewerLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dialog := channelDialogToDialog(s.dialogForMemberLocked(viewerUserID, channel, member), channel.Pts, member.Status)
|
||||
out.Dialogs = append(out.Dialogs, dialog)
|
||||
out.Channels = append(out.Channels, channel)
|
||||
// 与 postgres GetChannelDialogs 一致:monoforum 私信会话须同批下发母广播频道,客户端
|
||||
// 才能 resolve linked_monoforum_id 并派生 MonoforumAdmin 渲染 Direct-Messages 容器;否则
|
||||
// 退化成普通 megagroup。到达此处的 monoforum 必为管理员预览(非管理员在
|
||||
// channelForViewerLocked 已返 ErrChannelPrivate 被 skip),不泄漏给无关用户。
|
||||
if channel.Monoforum && channel.LinkedMonoforumID != 0 {
|
||||
if parent, ok := s.channels[channel.LinkedMonoforumID]; ok && !parent.Deleted {
|
||||
out.Channels = append(out.Channels, cloneChannel(parent))
|
||||
}
|
||||
}
|
||||
if msg, ok := s.findMessageLocked(channelID, dialog.TopMessage); ok && !msg.Deleted {
|
||||
out.Messages = append(out.Messages, cloneChannelMessage(msg))
|
||||
}
|
||||
}
|
||||
out.Count = len(out.Dialogs)
|
||||
s.populateChannelMessageUnreadFlagsLocked(viewerUserID, out.Messages)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListCommonChannels(_ context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) {
|
||||
if req.UserID == 0 || req.TargetUserID == 0 || req.UserID == req.TargetUserID || req.MaxID < 0 {
|
||||
return domain.CommonChannelsResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 || limit > domain.MaxCommonChannelsLimit {
|
||||
limit = domain.MaxCommonChannelsLimit
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
ids := make([]int64, 0)
|
||||
for channelID, members := range s.members {
|
||||
self, selfOK := members[req.UserID]
|
||||
target, targetOK := members[req.TargetUserID]
|
||||
if !selfOK || !targetOK || self.Status != domain.ChannelMemberActive || target.Status != domain.ChannelMemberActive {
|
||||
continue
|
||||
}
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted || !channel.Megagroup || channel.Broadcast {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, channelID)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
out := domain.CommonChannelsResult{Count: len(ids)}
|
||||
if req.CountOnly {
|
||||
return out, nil
|
||||
}
|
||||
for _, channelID := range ids {
|
||||
if req.MaxID > 0 && channelID <= req.MaxID {
|
||||
continue
|
||||
}
|
||||
out.Channels = append(out.Channels, cloneChannel(s.channels[channelID]))
|
||||
if len(out.Channels) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListLeftChannels(_ context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) {
|
||||
if userID == 0 || offset < 0 || offset > domain.MaxLeftChannelsOffset {
|
||||
return domain.LeftChannelsResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxLeftChannelsLimit {
|
||||
limit = domain.MaxLeftChannelsLimit
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
all := make([]domain.LeftChannel, 0)
|
||||
for channelID, members := range s.members {
|
||||
member, ok := members[userID]
|
||||
if !ok || member.Status != domain.ChannelMemberLeft {
|
||||
continue
|
||||
}
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted || (!channel.Broadcast && !channel.Megagroup) {
|
||||
continue
|
||||
}
|
||||
all = append(all, domain.LeftChannel{
|
||||
Channel: cloneChannel(channel),
|
||||
Self: member,
|
||||
})
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].Self.LeftAt != all[j].Self.LeftAt {
|
||||
return all[i].Self.LeftAt > all[j].Self.LeftAt
|
||||
}
|
||||
return all[i].Channel.ID > all[j].Channel.ID
|
||||
})
|
||||
|
||||
out := domain.LeftChannelsResult{Count: len(all)}
|
||||
if offset >= len(all) {
|
||||
return out, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
out.Channels = append(out.Channels, all[offset:end]...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListInactiveChannels(_ context.Context, userID int64, limit int) (domain.ChannelDialogList, error) {
|
||||
if userID == 0 {
|
||||
return domain.ChannelDialogList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxInactiveChannelsLimit {
|
||||
limit = domain.MaxInactiveChannelsLimit
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
type item struct {
|
||||
channel domain.Channel
|
||||
dialog domain.Dialog
|
||||
}
|
||||
items := make([]item, 0, limit)
|
||||
for channelID, members := range s.members {
|
||||
member, ok := members[userID]
|
||||
if !ok || member.Status != domain.ChannelMemberActive {
|
||||
continue
|
||||
}
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted || (!channel.Broadcast && !channel.Megagroup) {
|
||||
continue
|
||||
}
|
||||
if _, err := s.channelForMemberLocked(userID, channelID); err != nil {
|
||||
continue
|
||||
}
|
||||
dialog := channelDialogToDialog(s.dialogForUserLocked(userID, channel), channel.Pts, member.Status)
|
||||
dialog.TopMessageDate = inactiveChannelDate(dialog, channel, member)
|
||||
items = append(items, item{channel: cloneChannel(channel), dialog: dialog})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].dialog.TopMessageDate != items[j].dialog.TopMessageDate {
|
||||
return items[i].dialog.TopMessageDate < items[j].dialog.TopMessageDate
|
||||
}
|
||||
if items[i].dialog.TopMessage != items[j].dialog.TopMessage {
|
||||
return items[i].dialog.TopMessage < items[j].dialog.TopMessage
|
||||
}
|
||||
return items[i].channel.ID < items[j].channel.ID
|
||||
})
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
out := domain.ChannelDialogList{Count: len(items)}
|
||||
for _, item := range items {
|
||||
out.Dialogs = append(out.Dialogs, item.dialog)
|
||||
out.Channels = append(out.Channels, item.channel)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetChannelDialogPinned(_ context.Context, userID, channelID int64, pinned bool) (bool, int, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return false, 0, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return false, 0, nil
|
||||
}
|
||||
dialog := s.dialogForUserLocked(userID, channel)
|
||||
targetFolderID := dialog.FolderID
|
||||
// order 仅在目标会话所在 folder 内分配;memory 双 store 互不可见,
|
||||
// 与 postgres 跨表统一 order 空间相比此处只看 channel 表(reorder 会统一重排)。
|
||||
nextOrder := 1
|
||||
for _, d := range s.dialogs[userID] {
|
||||
if d.Pinned && d.FolderID == targetFolderID && d.PinnedOrder >= nextOrder {
|
||||
nextOrder = d.PinnedOrder + 1
|
||||
}
|
||||
}
|
||||
changed := dialog.Pinned != pinned || (pinned && dialog.PinnedOrder == 0)
|
||||
dialog.Pinned = pinned
|
||||
if pinned {
|
||||
if dialog.PinnedOrder == 0 {
|
||||
dialog.PinnedOrder = nextOrder
|
||||
}
|
||||
} else {
|
||||
dialog.PinnedOrder = 0
|
||||
}
|
||||
if s.dialogs[userID] == nil {
|
||||
s.dialogs[userID] = make(map[int64]domain.ChannelDialog)
|
||||
}
|
||||
s.dialogs[userID][channelID] = dialog
|
||||
return changed, targetFolderID, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ReorderChannelPinnedDialogs(_ context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) {
|
||||
if userID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
positions := make(map[int64]int, len(order))
|
||||
for i, peer := range order {
|
||||
if peer.Type != domain.PeerTypeChannel || peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := positions[peer.ID]; ok {
|
||||
continue
|
||||
}
|
||||
positions[peer.ID] = len(order) - i
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
changed := false
|
||||
for channelID, dialog := range s.dialogs[userID] {
|
||||
if dialog.FolderID != folderID {
|
||||
continue
|
||||
}
|
||||
if pos, ok := positions[channelID]; ok {
|
||||
if !dialog.Pinned || dialog.PinnedOrder != pos {
|
||||
changed = true
|
||||
}
|
||||
dialog.Pinned = true
|
||||
dialog.PinnedOrder = pos
|
||||
s.dialogs[userID][channelID] = dialog
|
||||
continue
|
||||
}
|
||||
if force && dialog.Pinned {
|
||||
changed = true
|
||||
dialog.Pinned = false
|
||||
dialog.PinnedOrder = 0
|
||||
s.dialogs[userID][channelID] = dialog
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) EditChannelPeerFolders(_ context.Context, userID int64, peers []domain.FolderPeerUpdate) error {
|
||||
if userID == 0 || len(peers) == 0 {
|
||||
return nil
|
||||
}
|
||||
updates := make(map[int64]int, len(peers))
|
||||
for _, item := range peers {
|
||||
if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if item.FolderID != domain.DialogMainFolderID && item.FolderID != domain.DialogArchiveFolderID {
|
||||
continue
|
||||
}
|
||||
updates[item.Peer.ID] = item.FolderID
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for channelID, folderID := range updates {
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dialog := s.dialogForUserLocked(userID, channel)
|
||||
// 换 folder 时清 pinned:与私聊 EditPeerFolders 一致,TDesktop
|
||||
// 在归档/还原时本地无条件 unpin,服务端保留旧 pin 会造成状态漂移。
|
||||
if dialog.FolderID != folderID {
|
||||
dialog.Pinned = false
|
||||
dialog.PinnedOrder = 0
|
||||
}
|
||||
dialog.FolderID = folderID
|
||||
if s.dialogs[userID] == nil {
|
||||
s.dialogs[userID] = make(map[int64]domain.ChannelDialog)
|
||||
}
|
||||
s.dialogs[userID][channelID] = dialog
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) CountChannelArchiveUnread(_ context.Context, userID int64) (int, int, error) {
|
||||
if userID == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
peers, messages := 0, 0
|
||||
for channelID, dialog := range s.dialogs[userID] {
|
||||
if dialog.FolderID != domain.DialogArchiveFolderID {
|
||||
continue
|
||||
}
|
||||
// 与 postgres 一致:仅统计 active 成员的会话。
|
||||
if _, err := s.channelForMemberLocked(userID, channelID); err != nil {
|
||||
continue
|
||||
}
|
||||
if dialog.UnreadCount > 0 || dialog.UnreadMark {
|
||||
peers++
|
||||
}
|
||||
messages += dialog.UnreadCount
|
||||
}
|
||||
return peers, messages, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) upsertChannelDialogLocked(userID int64, channel domain.Channel, top domain.ChannelMessage, selfAction bool) {
|
||||
if s.dialogs[userID] == nil {
|
||||
s.dialogs[userID] = make(map[int64]domain.ChannelDialog)
|
||||
}
|
||||
dialog := s.dialogs[userID][channel.ID]
|
||||
dialog.UserID = userID
|
||||
dialog.ChannelID = channel.ID
|
||||
dialog.TopMessageID = s.visibleTopMessageIDLocked(userID, channel)
|
||||
if top.ID != 0 {
|
||||
dialog.TopMessageDate = top.Date
|
||||
}
|
||||
member := s.members[channel.ID][userID]
|
||||
if member.ReadInboxMaxID > dialog.ReadInboxMaxID {
|
||||
dialog.ReadInboxMaxID = member.ReadInboxMaxID
|
||||
}
|
||||
if selfAction {
|
||||
// 只推进 inbox:自己动作产生的消息不算自己的未读。outbox 回执水位
|
||||
// 表示"已被其他成员读到的最大 ID",绝不随自己的发送/动作推进,
|
||||
// 否则同账号另一设备会把自己的消息渲染成对端已读。
|
||||
if channel.TopMessageID > dialog.ReadInboxMaxID {
|
||||
dialog.ReadInboxMaxID = channel.TopMessageID
|
||||
}
|
||||
// 发送方向清手动未读标记,对齐 postgres 发送路径双表清除。
|
||||
dialog.UnreadMark = false
|
||||
}
|
||||
dialog.UnreadCount = s.channelUnreadCountLocked(userID, channel.ID, dialog.ReadInboxMaxID, dialog.TopMessageID)
|
||||
dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(userID, channel.ID, 0)
|
||||
dialog.UnreadReactions = s.countChannelUnreadReactionsLocked(userID, channel.ID, 0)
|
||||
s.dialogs[userID][channel.ID] = dialog
|
||||
}
|
||||
|
||||
func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int, memberStatus domain.ChannelMemberStatus) domain.Dialog {
|
||||
return domain.Dialog{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
||||
// 非成员预览(publicPreviewMember/被踢)须标记 ChannelLeft,客户端据此把频道渲染为只读 left 预览。
|
||||
ChannelLeft: memberStatus == domain.ChannelMemberLeft,
|
||||
FolderID: dialog.FolderID,
|
||||
TopMessage: dialog.TopMessageID,
|
||||
TopMessageDate: dialog.TopMessageDate,
|
||||
ReadInboxMaxID: dialog.ReadInboxMaxID,
|
||||
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
|
||||
UnreadCount: dialog.UnreadCount,
|
||||
UnreadMentions: dialog.UnreadMentions,
|
||||
UnreadReactions: dialog.UnreadReactions,
|
||||
Pinned: dialog.Pinned,
|
||||
PinnedOrder: dialog.PinnedOrder,
|
||||
UnreadMark: dialog.UnreadMark,
|
||||
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
||||
HasScheduled: dialog.HasScheduled,
|
||||
Pts: channelPts,
|
||||
}
|
||||
}
|
||||
|
||||
func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog {
|
||||
topMessageID := channel.TopMessageID
|
||||
if topMessageID <= member.AvailableMinID {
|
||||
topMessageID = 0
|
||||
}
|
||||
return domain.ChannelDialog{
|
||||
UserID: userID,
|
||||
ChannelID: channel.ID,
|
||||
TopMessageID: topMessageID,
|
||||
TopMessageDate: channel.Date,
|
||||
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
|
||||
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
|
||||
}
|
||||
}
|
||||
|
||||
func channelDialogMatchesFilter(dialog domain.Dialog, channel domain.Channel, filter domain.DialogFilter) bool {
|
||||
if filter.HasFolderID {
|
||||
if filter.FolderID < domain.DialogCustomFolderMinID {
|
||||
if dialog.FolderID != filter.FolderID {
|
||||
return false
|
||||
}
|
||||
} else if filter.Folder == nil {
|
||||
return false
|
||||
}
|
||||
} else if dialog.FolderID != domain.DialogMainFolderID {
|
||||
// 不带 folder_id 视为主列表(folder 0),与私聊侧/官方语义一致。
|
||||
return false
|
||||
}
|
||||
if filter.PinnedOnly && !dialog.Pinned {
|
||||
return false
|
||||
}
|
||||
if filter.ExcludePinned && dialog.Pinned {
|
||||
return false
|
||||
}
|
||||
if !channelDialogAfterOffset(dialog, filter) {
|
||||
return false
|
||||
}
|
||||
if filter.Folder == nil {
|
||||
return true
|
||||
}
|
||||
folder := filter.Folder
|
||||
if peerInFolderList(dialog.Peer, folder.ExcludePeers) {
|
||||
return false
|
||||
}
|
||||
if folder.ExcludeRead && dialog.UnreadCount == 0 && !dialog.UnreadMark {
|
||||
return false
|
||||
}
|
||||
if folder.ExcludeArchived && dialog.FolderID == domain.DialogArchiveFolderID {
|
||||
return false
|
||||
}
|
||||
if peerInFolderList(dialog.Peer, folder.PinnedPeers) || peerInFolderList(dialog.Peer, folder.IncludePeers) {
|
||||
return true
|
||||
}
|
||||
if channel.Megagroup && folder.Groups {
|
||||
return true
|
||||
}
|
||||
if channel.Broadcast && folder.Broadcasts {
|
||||
return true
|
||||
}
|
||||
// 群/频道只能经 Groups/Broadcasts 开关或显式 include/pinned 进入自定义文件夹;
|
||||
// 仅勾 Contacts/NonContacts/Bots 的文件夹不含任何群/频道(与 postgres 对齐)。
|
||||
return false
|
||||
}
|
||||
|
||||
func channelDialogAfterOffset(dialog domain.Dialog, filter domain.DialogFilter) bool {
|
||||
if filter.OffsetDate <= 0 && filter.OffsetID <= 0 {
|
||||
if filter.HasOffsetPeer && filter.OffsetPeer == dialog.Peer {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if filter.OffsetDate > 0 {
|
||||
if dialog.TopMessageDate != filter.OffsetDate {
|
||||
return dialog.TopMessageDate < filter.OffsetDate
|
||||
}
|
||||
if filter.OffsetID <= 0 {
|
||||
return false
|
||||
}
|
||||
if dialog.TopMessage != filter.OffsetID {
|
||||
return dialog.TopMessage < filter.OffsetID
|
||||
}
|
||||
if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type {
|
||||
return dialog.Peer.ID < filter.OffsetPeer.ID
|
||||
}
|
||||
return false
|
||||
}
|
||||
if dialog.TopMessage != filter.OffsetID {
|
||||
return dialog.TopMessage < filter.OffsetID
|
||||
}
|
||||
if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type {
|
||||
return dialog.Peer.ID < filter.OffsetPeer.ID
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func peerInFolderList(peer domain.Peer, items []domain.DialogFolderPeer) bool {
|
||||
for _, item := range items {
|
||||
if item.Peer == peer {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
83
internal/store/memory/channel_groupcall.go
Normal file
83
internal/store/memory/channel_groupcall.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// SetActiveCall 写入/清除 channel 行上的活跃群通话关联。
|
||||
func (s *ChannelStore) SetActiveCall(_ context.Context, channelID, callID, callAccessHash int64, notEmpty bool) (domain.Channel, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ch, ok := s.channels[channelID]
|
||||
if !ok || ch.Deleted {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
ch.ActiveCallID = callID
|
||||
ch.ActiveCallAccessHash = callAccessHash
|
||||
ch.ActiveCallNotEmpty = notEmpty && callID != 0
|
||||
s.channels[channelID] = ch
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// AppendCallServiceMessage 生成群通话服务消息(带频道 pts)。
|
||||
func (s *ChannelStore) AppendCallServiceMessage(_ context.Context, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) {
|
||||
return s.appendServiceMessageLocked(channelID, senderUserID, date, action)
|
||||
}
|
||||
|
||||
// AppendStarGiftAdminLog 记录频道 Star gift 到 Recent Actions,不进入频道消息历史。
|
||||
func (s *ChannelStore) AppendStarGiftAdminLog(_ context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error {
|
||||
if channelID == 0 || senderUserID == 0 || savedID <= 0 {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ch, ok := s.channels[channelID]
|
||||
if !ok || ch.Deleted {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
messageID := int(savedID)
|
||||
if savedID > int64(domain.MaxMessageBoxID) {
|
||||
messageID = domain.MaxMessageBoxID
|
||||
}
|
||||
action = channelServiceActionForMessage(channelID, messageID, action)
|
||||
msg := domain.ChannelMessage{
|
||||
ChannelID: channelID,
|
||||
ID: messageID,
|
||||
SenderUserID: senderUserID,
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID},
|
||||
Date: date,
|
||||
Post: ch.Broadcast,
|
||||
Action: &action,
|
||||
Pts: ch.Pts,
|
||||
}
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: channelID,
|
||||
UserID: senderUserID,
|
||||
Date: date,
|
||||
Type: domain.ChannelAdminLogSendMessage,
|
||||
Message: &msg,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) appendServiceMessageLocked(channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ch, ok := s.channels[channelID]
|
||||
if !ok || ch.Deleted {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
msg, event := s.appendChannelServiceMessageLocked(channelID, senderUserID, date, action)
|
||||
ch = s.channels[channelID]
|
||||
ch.TopMessageID = msg.ID
|
||||
ch.Pts = event.Pts
|
||||
s.channels[channelID] = ch
|
||||
return domain.SendChannelMessageResult{
|
||||
Channel: ch,
|
||||
Message: msg,
|
||||
Event: event,
|
||||
Recipients: s.activeMemberIDsLocked(channelID, 0, 0),
|
||||
}, nil
|
||||
}
|
||||
703
internal/store/memory/channel_helpers.go
Normal file
703
internal/store/memory/channel_helpers.go
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) SaveChannelDefaultSendAs(_ context.Context, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
return domain.ChannelView{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.SendAs != nil && req.SendAs.Type != domain.PeerTypeUser && req.SendAs.Type != domain.PeerTypeChannel {
|
||||
return domain.ChannelView{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelView{}, err
|
||||
}
|
||||
dialog := s.dialogForUserLocked(req.UserID, channel)
|
||||
if req.SendAs != nil {
|
||||
p := *req.SendAs
|
||||
dialog.DefaultSendAs = &p
|
||||
} else {
|
||||
dialog.DefaultSendAs = nil
|
||||
}
|
||||
if s.dialogs[req.UserID] == nil {
|
||||
s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog)
|
||||
}
|
||||
s.dialogs[req.UserID][req.ChannelID] = dialog
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
return domain.ChannelView{Channel: cloneChannel(channel), Self: member, Dialog: dialog}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) DeleteChannel(_ context.Context, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
return domain.DeleteChannelResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.DeleteChannelResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if member.Role != domain.ChannelRoleCreator {
|
||||
return domain.DeleteChannelResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0)
|
||||
channel.Deleted = true
|
||||
channel.Username = ""
|
||||
s.channels[req.ChannelID] = channel
|
||||
// 连带软删关联 monoforum(频道私信容器),与 PG store 等价:仅当 counterpart 是 monoforum 时级联,
|
||||
// 防止删 mono 反向误删真实母频道。不级联会留下指向已删父频道的孤儿 mono。
|
||||
var linkedMono *domain.Channel
|
||||
if channel.LinkedMonoforumID != 0 {
|
||||
if mono, ok := s.channels[channel.LinkedMonoforumID]; ok && mono.Monoforum && !mono.Deleted {
|
||||
mono.Deleted = true
|
||||
mono.Username = ""
|
||||
s.channels[mono.ID] = mono
|
||||
clone := cloneChannel(mono)
|
||||
linkedMono = &clone
|
||||
}
|
||||
}
|
||||
return domain.DeleteChannelResult{Channel: channel, Recipients: recipients, LinkedMonoforum: linkedMono}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error) {
|
||||
if viewerUserID == 0 {
|
||||
return domain.PublicChannelSearchResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxPublicChannelSearchLimit {
|
||||
limit = domain.MaxPublicChannelSearchLimit
|
||||
}
|
||||
query = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(query, "@")))
|
||||
if query == "" {
|
||||
return domain.PublicChannelSearchResult{}, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
type item struct {
|
||||
channel domain.Channel
|
||||
joined bool
|
||||
rank int
|
||||
}
|
||||
items := make([]item, 0, limit)
|
||||
for channelID, channel := range s.channels {
|
||||
rank, ok := publicChannelSearchRank(channel, query)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
member, joined := s.members[channelID][viewerUserID]
|
||||
joined = joined && member.Status == domain.ChannelMemberActive
|
||||
items = append(items, item{
|
||||
channel: cloneChannel(channel),
|
||||
joined: joined,
|
||||
rank: rank,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].rank != items[j].rank {
|
||||
return items[i].rank < items[j].rank
|
||||
}
|
||||
if items[i].joined != items[j].joined {
|
||||
return items[i].joined
|
||||
}
|
||||
if items[i].channel.ParticipantsCount != items[j].channel.ParticipantsCount {
|
||||
return items[i].channel.ParticipantsCount > items[j].channel.ParticipantsCount
|
||||
}
|
||||
if items[i].channel.Date != items[j].channel.Date {
|
||||
return items[i].channel.Date > items[j].channel.Date
|
||||
}
|
||||
return items[i].channel.ID > items[j].channel.ID
|
||||
})
|
||||
|
||||
out := domain.PublicChannelSearchResult{}
|
||||
for _, item := range items {
|
||||
if len(out.MyResults)+len(out.Results) >= limit {
|
||||
break
|
||||
}
|
||||
if item.joined {
|
||||
out.MyResults = append(out.MyResults, item.channel)
|
||||
} else {
|
||||
out.Results = append(out.Results, item.channel)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) inviteByChannelHashLocked(channelID int64, hash string) (domain.ChannelInvite, error) {
|
||||
hash = strings.TrimSpace(hash)
|
||||
if hash == "" {
|
||||
return domain.ChannelInvite{}, domain.ErrInviteHashEmpty
|
||||
}
|
||||
invite, ok := s.invites[hash]
|
||||
if !ok || invite.ChannelID != channelID {
|
||||
return domain.ChannelInvite{}, domain.ErrInviteHashInvalid
|
||||
}
|
||||
return invite, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) inviteByIDLocked(channelID, inviteID int64) (domain.ChannelInvite, error) {
|
||||
for _, invite := range s.invites {
|
||||
if invite.ChannelID == channelID && invite.InviteID == inviteID {
|
||||
return invite, nil
|
||||
}
|
||||
}
|
||||
return domain.ChannelInvite{}, domain.ErrInviteHashInvalid
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SearchPublicPosts(_ context.Context, viewerUserID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) {
|
||||
query := strings.ToLower(strings.TrimSpace(req.Query))
|
||||
hashtag := strings.ToLower(strings.TrimSpace(req.Hashtag))
|
||||
if (query == "") == (hashtag == "") {
|
||||
return domain.ChannelHistory{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.Limit <= 0 || req.Limit > domain.MaxChannelSearchPostsLimit {
|
||||
req.Limit = domain.MaxChannelSearchPostsLimit
|
||||
}
|
||||
type hit struct {
|
||||
channel domain.Channel
|
||||
message domain.ChannelMessage
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
hits := make([]hit, 0, req.Limit+1)
|
||||
for channelID, channel := range s.channels {
|
||||
if channel.Deleted || strings.TrimSpace(channel.Username) == "" {
|
||||
continue
|
||||
}
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if msg.Deleted || strings.TrimSpace(msg.Body) == "" {
|
||||
continue
|
||||
}
|
||||
if !channelSearchPostAfterCursor(msg, req) {
|
||||
continue
|
||||
}
|
||||
body := strings.ToLower(msg.Body)
|
||||
if query != "" && !strings.Contains(body, query) {
|
||||
continue
|
||||
}
|
||||
if hashtag != "" && !strings.Contains(body, "#"+hashtag) {
|
||||
continue
|
||||
}
|
||||
hits = append(hits, hit{channel: channel, message: cloneChannelMessage(msg)})
|
||||
}
|
||||
}
|
||||
sort.Slice(hits, func(i, j int) bool {
|
||||
a, b := hits[i].message, hits[j].message
|
||||
if a.Date != b.Date {
|
||||
return a.Date > b.Date
|
||||
}
|
||||
if a.ChannelID != b.ChannelID {
|
||||
return a.ChannelID > b.ChannelID
|
||||
}
|
||||
return a.ID > b.ID
|
||||
})
|
||||
out := domain.ChannelHistory{Count: len(hits)}
|
||||
if out.Count > req.Limit {
|
||||
out.Count = req.Limit + 1
|
||||
hits = hits[:req.Limit]
|
||||
}
|
||||
channelSeen := make(map[int64]struct{}, len(hits))
|
||||
for _, h := range hits {
|
||||
out.Messages = append(out.Messages, h.message)
|
||||
if _, ok := channelSeen[h.channel.ID]; ok {
|
||||
continue
|
||||
}
|
||||
channelSeen[h.channel.ID] = struct{}{}
|
||||
out.Channels = append(out.Channels, h.channel)
|
||||
}
|
||||
s.populateChannelMessagesReactionsLocked(viewerUserID, out.Channels, out.Messages)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func channelSearchPostAfterCursor(msg domain.ChannelMessage, req domain.ChannelSearchPostsRequest) bool {
|
||||
if req.OffsetRate <= 0 && req.OffsetChannelID <= 0 && req.OffsetID <= 0 {
|
||||
return true
|
||||
}
|
||||
if req.OffsetRate > 0 {
|
||||
if msg.Date < req.OffsetRate {
|
||||
return true
|
||||
}
|
||||
if msg.Date > req.OffsetRate {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if req.OffsetChannelID > 0 {
|
||||
if msg.ChannelID < req.OffsetChannelID {
|
||||
return true
|
||||
}
|
||||
if msg.ChannelID > req.OffsetChannelID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if req.OffsetID > 0 {
|
||||
return msg.ID < req.OffsetID
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func channelGlobalSearchAfterCursor(msg domain.ChannelMessage, req domain.ChannelGlobalSearchRequest) bool {
|
||||
if req.OffsetRate <= 0 && req.OffsetChannelID <= 0 && req.OffsetID <= 0 {
|
||||
return true
|
||||
}
|
||||
if req.OffsetRate > 0 {
|
||||
if msg.Date < req.OffsetRate {
|
||||
return true
|
||||
}
|
||||
if msg.Date > req.OffsetRate {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if req.OffsetChannelID > 0 {
|
||||
if msg.ChannelID < req.OffsetChannelID {
|
||||
return true
|
||||
}
|
||||
if msg.ChannelID > req.OffsetChannelID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if req.OffsetID > 0 {
|
||||
return msg.ID < req.OffsetID
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListActiveChannelIDsForUser(_ context.Context, userID, afterChannelID int64, limit int) ([]int64, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if userID == 0 || afterChannelID < 0 {
|
||||
return nil, domain.ErrChannelInvalid
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout {
|
||||
limit = domain.MaxSynchronousChannelDialogFanout
|
||||
}
|
||||
out := make([]int64, 0, limit)
|
||||
for channelID, members := range s.members {
|
||||
if channelID <= afterChannelID {
|
||||
continue
|
||||
}
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted {
|
||||
continue
|
||||
}
|
||||
member, ok := members[userID]
|
||||
if !ok || member.Status != domain.ChannelMemberActive {
|
||||
continue
|
||||
}
|
||||
out = append(out, channelID)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if userID == 0 || sinceDate <= 0 || afterChannelID < 0 {
|
||||
return nil, domain.ErrChannelInvalid
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxChannelDifferenceLimit {
|
||||
limit = domain.MaxChannelDifferenceLimit
|
||||
}
|
||||
out := make([]domain.DirtyChannel, 0, limit)
|
||||
for channelID, members := range s.members {
|
||||
if channelID <= afterChannelID {
|
||||
continue
|
||||
}
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted {
|
||||
continue
|
||||
}
|
||||
member, ok := members[userID]
|
||||
if !ok || member.Status != domain.ChannelMemberActive {
|
||||
continue
|
||||
}
|
||||
dirty := false
|
||||
for _, event := range s.events[channelID] {
|
||||
if event.Date > sinceDate {
|
||||
dirty = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if dirty {
|
||||
out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) nextChannelIDLocked() int64 {
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
return id
|
||||
}
|
||||
|
||||
func (s *ChannelStore) nextAccessHashLocked() int64 {
|
||||
hash := s.nextHash
|
||||
s.nextHash += 17
|
||||
return hash
|
||||
}
|
||||
|
||||
func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.Channel, domain.ChannelMember, bool, error) {
|
||||
channel, member, err := s.channelAndMemberLocked(userID, channelID)
|
||||
if err == nil {
|
||||
return channel, member, false, nil
|
||||
}
|
||||
if !errors.Is(err, domain.ErrChannelPrivate) {
|
||||
return domain.Channel{}, domain.ChannelMember{}, false, err
|
||||
}
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted {
|
||||
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelInvalid
|
||||
}
|
||||
existing, found := s.members[channelID][userID]
|
||||
if found && (existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages) {
|
||||
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelUserBanned
|
||||
}
|
||||
if channel.Monoforum && channel.LinkedMonoforumID != 0 {
|
||||
parentMember, ok := s.members[channel.LinkedMonoforumID][userID]
|
||||
if ok && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember) {
|
||||
return channel, syntheticMonoforumAdminMember(channel, parentMember), true, nil
|
||||
}
|
||||
}
|
||||
if !publicPreviewableChannel(channel) {
|
||||
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate
|
||||
}
|
||||
return channel, publicPreviewMember(channel, userID, existing, found), true, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) dialogForUserLocked(userID int64, channel domain.Channel) domain.ChannelDialog {
|
||||
return s.dialogForMemberLocked(userID, channel, s.members[channel.ID][userID])
|
||||
}
|
||||
|
||||
func (s *ChannelStore) dialogForMemberLocked(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog {
|
||||
dialog := s.dialogs[userID][channel.ID]
|
||||
dialog.UserID = userID
|
||||
dialog.ChannelID = channel.ID
|
||||
dialog.TopMessageID = s.visibleTopMessageIDForMemberLocked(channel, member)
|
||||
// TopMessageDate 必须从可见 top 消息派生(不能继承空缓存的 0),否则会话排序/分页与预览
|
||||
// dialog 的日期全错。与 postgres GetChannelDialogs 用 getChannelMessage 设 date 对齐。
|
||||
dialog.TopMessageDate = 0
|
||||
if dialog.TopMessageID > 0 {
|
||||
if top, ok := s.findMessageLocked(channel.ID, dialog.TopMessageID); ok {
|
||||
dialog.TopMessageDate = top.Date
|
||||
}
|
||||
}
|
||||
if member.ReadInboxMaxID > dialog.ReadInboxMaxID {
|
||||
dialog.ReadInboxMaxID = member.ReadInboxMaxID
|
||||
}
|
||||
if member.ReadOutboxMaxID > dialog.ReadOutboxMaxID {
|
||||
dialog.ReadOutboxMaxID = member.ReadOutboxMaxID
|
||||
}
|
||||
// 公共已读水位派生:即使实时 fanout 被截断,read_outbox 真值仍前进。
|
||||
if derived := s.readMarks[channel.ID].forSender(userID); derived > dialog.ReadOutboxMaxID {
|
||||
dialog.ReadOutboxMaxID = derived
|
||||
}
|
||||
if top := channel.TopMessageID; dialog.ReadOutboxMaxID > top {
|
||||
dialog.ReadOutboxMaxID = top
|
||||
}
|
||||
dialog.UnreadCount = s.channelUnreadCountLocked(userID, channel.ID, dialog.ReadInboxMaxID, dialog.TopMessageID)
|
||||
dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(userID, channel.ID, 0)
|
||||
dialog.UnreadReactions = s.countChannelUnreadReactionsLocked(userID, channel.ID, 0)
|
||||
return dialog
|
||||
}
|
||||
|
||||
func channelReplyBelongsToRoot(msg domain.ChannelMessage, channelID int64, rootID int) bool {
|
||||
if msg.ReplyTo == nil || rootID <= 0 {
|
||||
return false
|
||||
}
|
||||
if msg.ReplyTo.Peer.ID != 0 && msg.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) {
|
||||
return false
|
||||
}
|
||||
return msg.ReplyTo.TopMessageID == rootID || (msg.ReplyTo.TopMessageID == 0 && msg.ReplyTo.MessageID == rootID)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRequest, member domain.ChannelMember, channel domain.Channel) (*domain.MessageReply, error) {
|
||||
if req.ReplyTo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err := domain.ValidateMessageReplyBounds(req.ReplyTo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
peer := req.ReplyTo.Peer
|
||||
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
||||
if peer.ID == 0 {
|
||||
peer = channelPeer
|
||||
}
|
||||
if peer != channelPeer {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if req.ReplyTo.MessageID == 0 {
|
||||
if req.ReplyTo.TopMessageID <= 0 || !channel.Forum {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
topic, ok := s.topics[req.ChannelID][req.ReplyTo.TopMessageID]
|
||||
if !ok || topic.Hidden {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) {
|
||||
return nil, domain.ErrChannelWriteForbidden
|
||||
}
|
||||
reply := cloneMessageReply(req.ReplyTo)
|
||||
reply.MessageID = 0
|
||||
reply.Peer = channelPeer
|
||||
reply.TopMessageID = topic.TopicID
|
||||
reply.ForumTopic = true
|
||||
return reply, nil
|
||||
}
|
||||
target, ok := s.findMessageLocked(req.ChannelID, req.ReplyTo.MessageID)
|
||||
if !ok || target.Deleted || target.ID <= member.AvailableMinID {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
reply := cloneMessageReply(req.ReplyTo)
|
||||
reply.MessageID = target.ID
|
||||
reply.Peer = channelPeer
|
||||
reply.TopMessageID = target.ID
|
||||
if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 {
|
||||
reply.TopMessageID = target.ReplyTo.TopMessageID
|
||||
}
|
||||
if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != reply.TopMessageID {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if channel.Forum && reply.TopMessageID > 0 {
|
||||
if topic, ok := s.topics[req.ChannelID][reply.TopMessageID]; ok && !topic.Hidden {
|
||||
if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) {
|
||||
return nil, domain.ErrChannelWriteForbidden
|
||||
}
|
||||
reply.ForumTopic = true
|
||||
}
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func inactiveChannelDate(dialog domain.Dialog, channel domain.Channel, member domain.ChannelMember) int {
|
||||
if dialog.TopMessageDate > 0 {
|
||||
return dialog.TopMessageDate
|
||||
}
|
||||
date := channel.Date
|
||||
if member.JoinedAt > date {
|
||||
date = member.JoinedAt
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func recommendableChannel(channel domain.Channel) bool {
|
||||
return !channel.Deleted &&
|
||||
channel.Broadcast &&
|
||||
!channel.Megagroup &&
|
||||
strings.TrimSpace(channel.Username) != ""
|
||||
}
|
||||
|
||||
func publicSearchableChannel(channel domain.Channel) bool {
|
||||
return !channel.Deleted &&
|
||||
(channel.Broadcast || channel.Megagroup) &&
|
||||
strings.TrimSpace(channel.Username) != ""
|
||||
}
|
||||
|
||||
func channelRoleOrder(role domain.ChannelMemberRole) int {
|
||||
switch role {
|
||||
case domain.ChannelRoleCreator:
|
||||
return 0
|
||||
case domain.ChannelRoleAdmin:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func canChangeChannelInfo(member domain.ChannelMember) bool {
|
||||
return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.ChangeInfo)
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
func channelInitialAvailableMinID(channel domain.Channel) int {
|
||||
if channel.PreHistoryHidden {
|
||||
return channel.TopMessageID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func adminRightsSubset(want, have domain.ChannelAdminRights) bool {
|
||||
return (!want.ChangeInfo || have.ChangeInfo) &&
|
||||
(!want.PostMessages || have.PostMessages) &&
|
||||
(!want.EditMessages || have.EditMessages) &&
|
||||
(!want.DeleteMessages || have.DeleteMessages) &&
|
||||
(!want.PostStories || have.PostStories) &&
|
||||
(!want.EditStories || have.EditStories) &&
|
||||
(!want.DeleteStories || have.DeleteStories) &&
|
||||
(!want.BanUsers || have.BanUsers) &&
|
||||
(!want.InviteUsers || have.InviteUsers) &&
|
||||
(!want.PinMessages || have.PinMessages) &&
|
||||
(!want.AddAdmins || have.AddAdmins) &&
|
||||
(!want.ManageCall || have.ManageCall) &&
|
||||
(!want.Anonymous || have.Anonymous) &&
|
||||
(!want.ManageRanks || have.ManageRanks)
|
||||
}
|
||||
|
||||
// checkEditMemberRank validates a rank-only (member tag) edit: creator edits
|
||||
// anyone but no one else edits the creator; admins always edit their own tag,
|
||||
// and with ManageRanks edit plain members plus admins they promoted; plain
|
||||
// members edit only their own tag and only while neither the channel default
|
||||
// nor their personal banned rights set edit_rank. Member tags exist only in
|
||||
// megagroups: broadcast participants must keep an empty rank so the admins
|
||||
// participant filter stays a pure admin list there.
|
||||
func checkEditMemberRank(channel domain.Channel, actor, target domain.ChannelMember) error {
|
||||
if !channel.Megagroup {
|
||||
return domain.ErrMegagroupIDInvalid
|
||||
}
|
||||
if actor.UserID == target.UserID {
|
||||
if actor.Role == domain.ChannelRoleCreator || actor.Role == domain.ChannelRoleAdmin {
|
||||
return nil
|
||||
}
|
||||
if channel.DefaultBannedRights.EditRank || actor.BannedRights.EditRank {
|
||||
return domain.ErrChannelRightForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if target.Role == domain.ChannelRoleCreator {
|
||||
return domain.ErrChannelUserCreator
|
||||
}
|
||||
if actor.Role == domain.ChannelRoleCreator {
|
||||
return nil
|
||||
}
|
||||
if actor.Role != domain.ChannelRoleAdmin || !actor.AdminRights.ManageRanks {
|
||||
return domain.ErrChannelAdminRequired
|
||||
}
|
||||
if target.Role == domain.ChannelRoleAdmin && target.InviterUserID != actor.UserID {
|
||||
return domain.ErrChannelRightForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func adminLogBanType(previous, next domain.ChannelMember) domain.ChannelAdminLogEventType {
|
||||
if next.Status == domain.ChannelMemberKicked || next.BannedRights.ViewMessages {
|
||||
return domain.ChannelAdminLogParticipantKick
|
||||
}
|
||||
if previous.Status == domain.ChannelMemberKicked || previous.BannedRights.ViewMessages {
|
||||
return domain.ChannelAdminLogParticipantUnkick
|
||||
}
|
||||
if !zeroChannelBannedRights(next.BannedRights) {
|
||||
return domain.ChannelAdminLogParticipantBan
|
||||
}
|
||||
return domain.ChannelAdminLogParticipantUnban
|
||||
}
|
||||
|
||||
func adminLogSearchText(event domain.ChannelAdminLogEvent) string {
|
||||
parts := []string{
|
||||
event.Query,
|
||||
event.PrevString,
|
||||
event.NewString,
|
||||
}
|
||||
for _, msg := range []*domain.ChannelMessage{event.Message, event.PrevMessage, event.NewMessage} {
|
||||
if msg != nil {
|
||||
parts = append(parts, msg.Body)
|
||||
}
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(strings.Join(parts, " ")))
|
||||
}
|
||||
|
||||
func int64Set(items []int64) map[int64]struct{} {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[int64]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
if item != 0 {
|
||||
out[item] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *ChannelStore) refreshChannelCountsLocked(channelID int64) {
|
||||
channel := s.channels[channelID]
|
||||
var participants, admins, kicked, banned int
|
||||
for _, member := range s.members[channelID] {
|
||||
if member.Status == domain.ChannelMemberKicked {
|
||||
kicked++
|
||||
}
|
||||
if member.Status != domain.ChannelMemberActive {
|
||||
continue
|
||||
}
|
||||
participants++
|
||||
if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin {
|
||||
admins++
|
||||
}
|
||||
if !zeroChannelBannedRights(member.BannedRights) {
|
||||
banned++
|
||||
}
|
||||
}
|
||||
channel.ParticipantsCount = participants
|
||||
channel.AdminsCount = admins
|
||||
channel.KickedCount = kicked
|
||||
channel.BannedCount = banned
|
||||
s.channels[channelID] = channel
|
||||
}
|
||||
|
||||
func diffFinal(returned, all []domain.ChannelUpdateEvent) bool {
|
||||
if len(returned) == 0 {
|
||||
return true
|
||||
}
|
||||
return returned[len(returned)-1].Pts >= all[len(all)-1].Pts
|
||||
}
|
||||
|
||||
func randomMemoryPositiveInt64() (int64, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(binary.LittleEndian.Uint64(b[:]) & ((1 << 63) - 1)), nil
|
||||
}
|
||||
|
||||
func discussionGroupUpdateResult(changed map[int64]domain.Channel) domain.DiscussionGroupUpdateResult {
|
||||
ids := make([]int64, 0, len(changed))
|
||||
for id := range changed {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool {
|
||||
return ids[i] < ids[j]
|
||||
})
|
||||
out := domain.DiscussionGroupUpdateResult{Channels: make([]domain.Channel, 0, len(ids))}
|
||||
for _, id := range ids {
|
||||
out.Channels = append(out.Channels, cloneChannel(changed[id]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *ChannelStore) topicWithViewerCountersLocked(viewerUserID, channelID int64, topic domain.ChannelForumTopic, member domain.ChannelMember) domain.ChannelForumTopic {
|
||||
out := cloneChannelForumTopic(topic)
|
||||
water := s.channelTopicReadInboxLocked(channelID, viewerUserID, topic.TopicID, member.AvailableMinID)
|
||||
out.UnreadCount = s.channelTopicUnreadCountLocked(viewerUserID, channelID, topic.TopicID, water)
|
||||
out.ReadInboxMaxID = water
|
||||
out.ReadOutboxMaxID = s.channelTopicReadOutboxLocked(channelID, viewerUserID, topic.TopicID)
|
||||
out.UnreadMentionsCount = s.countChannelUnreadMentionsLocked(viewerUserID, channelID, topic.TopicID)
|
||||
out.UnreadReactionsCount = s.countChannelUnreadReactionsLocked(viewerUserID, channelID, topic.TopicID)
|
||||
return out
|
||||
}
|
||||
988
internal/store/memory/channel_invites.go
Normal file
988
internal/store/memory/channel_invites.go
Normal file
|
|
@ -0,0 +1,988 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"sort"
|
||||
"strings"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) InviteToChannel(_ context.Context, channelID, inviterUserID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(inviterUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
inviter := s.members[channelID][inviterUserID]
|
||||
if !canInviteToChannel(channel, inviter) {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
requested := uniqueNonZero(userIDs, 0)
|
||||
inviteOne := len(requested) == 1
|
||||
canRestoreKicked := canBanChannelUsers(inviter)
|
||||
added := make([]int64, 0, len(requested))
|
||||
members := make([]domain.ChannelMember, 0, len(requested))
|
||||
restoredKicked := 0
|
||||
for _, userID := range requested {
|
||||
if existing, ok := s.members[channelID][userID]; ok {
|
||||
if existing.Status == domain.ChannelMemberActive {
|
||||
if inviteOne {
|
||||
return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant
|
||||
}
|
||||
continue
|
||||
}
|
||||
if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages {
|
||||
if !canRestoreKicked {
|
||||
if inviteOne {
|
||||
return domain.CreateChannelResult{}, domain.ErrUserKicked
|
||||
}
|
||||
continue
|
||||
}
|
||||
if existing.Status == domain.ChannelMemberKicked {
|
||||
restoredKicked++
|
||||
}
|
||||
}
|
||||
}
|
||||
member := domain.ChannelMember{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
InviterUserID: inviterUserID,
|
||||
Role: domain.ChannelRoleMember,
|
||||
Status: domain.ChannelMemberActive,
|
||||
JoinedAt: date,
|
||||
AvailableMinID: channelInitialAvailableMinID(channel),
|
||||
AvailableMinPts: channelInitialAvailableMinPts(channel),
|
||||
ReadInboxMaxID: channel.TopMessageID,
|
||||
}
|
||||
s.members[channelID][userID] = member
|
||||
members = append(members, member)
|
||||
added = append(added, userID)
|
||||
channel.ParticipantsCount++
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: channelID,
|
||||
UserID: inviterUserID,
|
||||
Date: date,
|
||||
Type: domain.ChannelAdminLogParticipantInvite,
|
||||
Participant: ptrChannelMember(member),
|
||||
})
|
||||
}
|
||||
if restoredKicked > 0 {
|
||||
channel.KickedCount = maxInt(channel.KickedCount-restoredKicked, 0)
|
||||
}
|
||||
s.channels[channelID] = channel
|
||||
var msg domain.ChannelMessage
|
||||
var event domain.ChannelUpdateEvent
|
||||
if len(added) > 0 && channel.Megagroup {
|
||||
msg, event = s.appendChannelServiceMessageLocked(channelID, inviterUserID, date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionChatAddUser,
|
||||
UserIDs: append([]int64(nil), added...),
|
||||
})
|
||||
channel.TopMessageID = msg.ID
|
||||
channel.Pts = event.Pts
|
||||
s.channels[channelID] = channel
|
||||
}
|
||||
for _, member := range members {
|
||||
s.upsertChannelDialogLocked(member.UserID, channel, msg, false)
|
||||
}
|
||||
return domain.CreateChannelResult{
|
||||
Channel: channel,
|
||||
Members: cloneChannelMembers(members),
|
||||
Message: cloneChannelMessage(msg),
|
||||
Event: cloneChannelEvent(event),
|
||||
Recipients: s.activeMemberIDsLocked(channelID, 0, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListAdminLog(_ context.Context, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.MaxID < 0 || req.MinID < 0 {
|
||||
return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelAdminLogResult{}, err
|
||||
}
|
||||
if !isChannelAdmin(s.members[req.ChannelID][req.UserID]) {
|
||||
return domain.ChannelAdminLogResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 || limit > domain.MaxChannelAdminLogLimit {
|
||||
limit = domain.MaxChannelAdminLogLimit
|
||||
}
|
||||
admins := int64Set(req.AdminUserIDs)
|
||||
query := strings.ToLower(strings.TrimSpace(req.Query))
|
||||
out := make([]domain.ChannelAdminLogEvent, 0, limit)
|
||||
events := s.adminLogs[req.ChannelID]
|
||||
for i := len(events) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
event := events[i]
|
||||
if req.MaxID > 0 && event.ID >= req.MaxID {
|
||||
continue
|
||||
}
|
||||
if req.MinID > 0 && event.ID <= req.MinID {
|
||||
continue
|
||||
}
|
||||
if len(admins) > 0 {
|
||||
if _, ok := admins[event.UserID]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !adminLogEventMatchesFilter(event.Type, req.Filter) {
|
||||
continue
|
||||
}
|
||||
if query != "" && !adminLogEventMatchesQuery(event, query) {
|
||||
continue
|
||||
}
|
||||
out = append(out, cloneChannelAdminLogEvent(event))
|
||||
}
|
||||
return domain.ChannelAdminLogResult{Channel: channel, Events: out}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ExportInvite(_ context.Context, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
return domain.ExportChannelInviteResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ExportChannelInviteResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !canExportChannelInvite(member) {
|
||||
return domain.ExportChannelInviteResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
if req.LegacyRevokePermanent {
|
||||
for hash, invite := range s.invites {
|
||||
if invite.ChannelID == req.ChannelID && invite.AdminUserID == req.UserID && invite.Permanent {
|
||||
invite.Revoked = true
|
||||
s.invites[hash] = invite
|
||||
}
|
||||
}
|
||||
}
|
||||
inviteID, err := randomMemoryPositiveInt64()
|
||||
if err != nil {
|
||||
return domain.ExportChannelInviteResult{}, err
|
||||
}
|
||||
hash, err := randomMemoryInviteHash()
|
||||
if err != nil {
|
||||
return domain.ExportChannelInviteResult{}, err
|
||||
}
|
||||
invite := domain.ChannelInvite{
|
||||
ChannelID: req.ChannelID,
|
||||
InviteID: inviteID,
|
||||
Hash: hash,
|
||||
AdminUserID: req.UserID,
|
||||
Title: req.Title,
|
||||
Permanent: req.ExpireDate == 0 && req.UsageLimit == 0 && !req.RequestNeeded && req.Title == "",
|
||||
RequestNeeded: req.RequestNeeded,
|
||||
ExpireDate: req.ExpireDate,
|
||||
UsageLimit: req.UsageLimit,
|
||||
Date: req.Date,
|
||||
}
|
||||
s.invites[hash] = invite
|
||||
channel = s.refreshChannelHasLinkLocked(req.ChannelID)
|
||||
return domain.ExportChannelInviteResult{Channel: channel, Invite: invite}, nil
|
||||
}
|
||||
|
||||
// EnsurePermanentInvite 幂等返回 (channel, admin) 当前未撤销的永久邀请;缺失则创建。
|
||||
// 与 postgres 实现语义对齐(官方语义:邀请权限管理员必有主链接)。
|
||||
func (s *ChannelStore) EnsurePermanentInvite(_ context.Context, channelID, adminUserID int64, date int) (domain.ChannelInvite, error) {
|
||||
if channelID == 0 || adminUserID == 0 {
|
||||
return domain.ChannelInvite{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, err := s.channelForMemberLocked(adminUserID, channelID); err != nil {
|
||||
return domain.ChannelInvite{}, err
|
||||
}
|
||||
member := s.members[channelID][adminUserID]
|
||||
if !canExportChannelInvite(member) {
|
||||
return domain.ChannelInvite{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
var existing *domain.ChannelInvite
|
||||
for hash := range s.invites {
|
||||
invite := s.invites[hash]
|
||||
if invite.ChannelID != channelID || invite.AdminUserID != adminUserID || !invite.Permanent || invite.Revoked {
|
||||
continue
|
||||
}
|
||||
// map 遍历无序:取最早创建的一条,对齐 postgres ORDER BY created_at ASC。
|
||||
if existing == nil || invite.Date < existing.Date || (invite.Date == existing.Date && invite.Hash < existing.Hash) {
|
||||
copied := invite
|
||||
existing = &copied
|
||||
}
|
||||
}
|
||||
if existing != nil {
|
||||
s.setChannelHasLinkLocked(channelID, true)
|
||||
return *existing, nil
|
||||
}
|
||||
inviteID, err := randomMemoryPositiveInt64()
|
||||
if err != nil {
|
||||
return domain.ChannelInvite{}, err
|
||||
}
|
||||
hash, err := randomMemoryInviteHash()
|
||||
if err != nil {
|
||||
return domain.ChannelInvite{}, err
|
||||
}
|
||||
invite := domain.ChannelInvite{
|
||||
ChannelID: channelID,
|
||||
InviteID: inviteID,
|
||||
Hash: hash,
|
||||
AdminUserID: adminUserID,
|
||||
Permanent: true,
|
||||
Date: date,
|
||||
}
|
||||
s.invites[hash] = invite
|
||||
s.setChannelHasLinkLocked(channelID, true)
|
||||
return invite, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) CheckInvite(_ context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error) {
|
||||
if userID == 0 || strings.TrimSpace(hash) == "" {
|
||||
return domain.CheckChannelInviteResult{}, domain.ErrInviteHashEmpty
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
invite, ok := s.invites[strings.TrimSpace(hash)]
|
||||
if !ok || invite.Revoked {
|
||||
return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid
|
||||
}
|
||||
if invite.ExpireDate > 0 && invite.ExpireDate < date {
|
||||
return domain.CheckChannelInviteResult{}, domain.ErrInviteHashExpired
|
||||
}
|
||||
channel, ok := s.channels[invite.ChannelID]
|
||||
if !ok || channel.Deleted {
|
||||
return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid
|
||||
}
|
||||
member := s.members[invite.ChannelID][userID]
|
||||
if member.Status == domain.ChannelMemberKicked || member.Status == domain.ChannelMemberBanned || member.BannedRights.ViewMessages {
|
||||
return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid
|
||||
}
|
||||
return domain.CheckChannelInviteResult{
|
||||
Channel: channel,
|
||||
Invite: invite,
|
||||
Already: member.Status == domain.ChannelMemberActive,
|
||||
Self: member,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ImportInvite(_ context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) {
|
||||
if req.UserID == 0 || strings.TrimSpace(req.Hash) == "" {
|
||||
return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
invite, ok := s.invites[strings.TrimSpace(req.Hash)]
|
||||
if !ok || invite.Revoked {
|
||||
return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid
|
||||
}
|
||||
if invite.ExpireDate > 0 && invite.ExpireDate < req.Date {
|
||||
return domain.CreateChannelResult{}, domain.ErrInviteHashExpired
|
||||
}
|
||||
channel, ok := s.channels[invite.ChannelID]
|
||||
if !ok || channel.Deleted {
|
||||
return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid
|
||||
}
|
||||
if invite.RequestNeeded {
|
||||
if err := s.recordPendingInviteRequestLocked(invite, req.UserID, req.Date); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent
|
||||
}
|
||||
return s.approveInviteImporterLocked(channel, invite, req.UserID, 0, req.Date)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListExportedInvites(_ context.Context, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 {
|
||||
return domain.ChannelInviteList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil {
|
||||
return domain.ChannelInviteList{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !canExportChannelInvite(member) {
|
||||
return domain.ChannelInviteList{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
all := make([]domain.ChannelInvite, 0)
|
||||
for _, invite := range s.invites {
|
||||
if invite.ChannelID == req.ChannelID && invite.AdminUserID == req.AdminUserID && invite.Revoked == req.Revoked {
|
||||
all = append(all, invite)
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].Date != all[j].Date {
|
||||
return all[i].Date > all[j].Date
|
||||
}
|
||||
return all[i].Hash > all[j].Hash
|
||||
})
|
||||
total := len(all)
|
||||
start := 0
|
||||
if req.OffsetDate > 0 || req.OffsetHash != "" {
|
||||
start = len(all)
|
||||
for i, invite := range all {
|
||||
if invite.Date == req.OffsetDate && invite.Hash == req.OffsetHash {
|
||||
start = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 || limit > domain.MaxChannelInviteListLimit {
|
||||
limit = domain.MaxChannelInviteListLimit
|
||||
}
|
||||
if start > len(all) {
|
||||
start = len(all)
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
return domain.ChannelInviteList{Count: total, Invites: cloneChannelInvites(all[start:end])}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) permanentInviteForAdminLocked(channelID, adminUserID int64) (domain.ChannelInvite, bool) {
|
||||
var (
|
||||
best domain.ChannelInvite
|
||||
ok bool
|
||||
)
|
||||
for _, invite := range s.invites {
|
||||
if invite.ChannelID != channelID || invite.AdminUserID != adminUserID || !invite.Permanent || invite.Revoked {
|
||||
continue
|
||||
}
|
||||
if !ok || invite.Date < best.Date || (invite.Date == best.Date && invite.Hash < best.Hash) {
|
||||
best = invite
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
return best, ok
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetExportedInvite(_ context.Context, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" {
|
||||
return domain.ChannelInvite{}, domain.ErrInviteHashEmpty
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil {
|
||||
return domain.ChannelInvite{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !canExportChannelInvite(member) {
|
||||
return domain.ChannelInvite{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
return s.inviteByChannelHashLocked(req.ChannelID, req.Hash)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) EditExportedInvite(_ context.Context, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" {
|
||||
return domain.EditChannelInviteResult{}, domain.ErrInviteHashEmpty
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil {
|
||||
return domain.EditChannelInviteResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !canExportChannelInvite(member) {
|
||||
return domain.EditChannelInviteResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash)
|
||||
if err != nil {
|
||||
return domain.EditChannelInviteResult{}, err
|
||||
}
|
||||
if req.Revoked {
|
||||
if invite.Revoked {
|
||||
return domain.EditChannelInviteResult{}, domain.ErrInviteRevokedMissing
|
||||
}
|
||||
invite.Revoked = true
|
||||
s.invites[invite.Hash] = invite
|
||||
if !invite.Permanent {
|
||||
s.refreshChannelHasLinkLocked(req.ChannelID)
|
||||
return domain.EditChannelInviteResult{Invite: invite}, nil
|
||||
}
|
||||
newInvite, err := s.newReplacementInviteLocked(invite, req.Date)
|
||||
if err != nil {
|
||||
return domain.EditChannelInviteResult{}, err
|
||||
}
|
||||
s.invites[newInvite.Hash] = newInvite
|
||||
s.refreshChannelHasLinkLocked(req.ChannelID)
|
||||
return domain.EditChannelInviteResult{Invite: invite, NewInvite: &newInvite}, nil
|
||||
}
|
||||
if invite.Permanent && ((req.HasExpireDate && req.ExpireDate > 0) || (req.HasUsageLimit && req.UsageLimit > 0) || (req.HasRequestNeeded && req.RequestNeeded)) {
|
||||
return domain.EditChannelInviteResult{}, domain.ErrInvitePermanent
|
||||
}
|
||||
if req.HasExpireDate {
|
||||
invite.ExpireDate = req.ExpireDate
|
||||
}
|
||||
if req.HasUsageLimit {
|
||||
invite.UsageLimit = req.UsageLimit
|
||||
}
|
||||
if req.HasRequestNeeded {
|
||||
invite.RequestNeeded = req.RequestNeeded
|
||||
}
|
||||
if req.HasTitle {
|
||||
invite.Title = req.Title
|
||||
}
|
||||
invite.Permanent = invite.ExpireDate == 0 && invite.UsageLimit == 0 && !invite.RequestNeeded && invite.Title == ""
|
||||
s.invites[invite.Hash] = invite
|
||||
s.refreshChannelHasLinkLocked(req.ChannelID)
|
||||
return domain.EditChannelInviteResult{Invite: invite}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) DeleteExportedInvite(_ context.Context, req domain.DeleteChannelInviteRequest) error {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" {
|
||||
return domain.ErrInviteHashEmpty
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil {
|
||||
return err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !canExportChannelInvite(member) {
|
||||
return domain.ErrChannelAdminRequired
|
||||
}
|
||||
invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delete(s.invites, invite.Hash)
|
||||
s.refreshChannelHasLinkLocked(req.ChannelID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) DeleteRevokedExportedInvites(_ context.Context, req domain.DeleteRevokedChannelInvitesRequest) error {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil {
|
||||
return err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !canExportChannelInvite(member) {
|
||||
return domain.ErrChannelAdminRequired
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 || limit > domain.MaxChannelHideJoinRequests {
|
||||
limit = domain.MaxChannelHideJoinRequests
|
||||
}
|
||||
deleted := 0
|
||||
for hash, invite := range s.invites {
|
||||
if invite.ChannelID == req.ChannelID && invite.AdminUserID == req.AdminUserID && invite.Revoked {
|
||||
delete(s.invites, hash)
|
||||
deleted++
|
||||
if deleted >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListAdminsWithInvites(_ context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return nil, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, err := s.channelForMemberLocked(userID, channelID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !canExportChannelInvite(member) {
|
||||
return nil, domain.ErrChannelAdminRequired
|
||||
}
|
||||
byAdmin := map[int64]*domain.ChannelAdminInviteCount{}
|
||||
for _, invite := range s.invites {
|
||||
if invite.ChannelID != channelID {
|
||||
continue
|
||||
}
|
||||
count := byAdmin[invite.AdminUserID]
|
||||
if count == nil {
|
||||
count = &domain.ChannelAdminInviteCount{AdminUserID: invite.AdminUserID}
|
||||
byAdmin[invite.AdminUserID] = count
|
||||
}
|
||||
if invite.Revoked {
|
||||
count.RevokedInvitesCount++
|
||||
} else {
|
||||
count.InvitesCount++
|
||||
}
|
||||
}
|
||||
out := make([]domain.ChannelAdminInviteCount, 0, len(byAdmin))
|
||||
for _, count := range byAdmin {
|
||||
out = append(out, *count)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].AdminUserID < out[j].AdminUserID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListInviteImporters(_ context.Context, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil {
|
||||
return domain.ChannelInviteImporterList{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !canExportChannelInvite(member) {
|
||||
return domain.ChannelInviteImporterList{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
var inviteID int64
|
||||
if req.Hash != "" {
|
||||
invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash)
|
||||
if err != nil {
|
||||
return domain.ChannelInviteImporterList{}, err
|
||||
}
|
||||
inviteID = invite.InviteID
|
||||
}
|
||||
if req.Query != "" {
|
||||
return domain.ChannelInviteImporterList{}, nil
|
||||
}
|
||||
all := make([]domain.ChannelInviteImporter, 0)
|
||||
for _, importer := range s.importers[req.ChannelID] {
|
||||
if importer.Requested != req.Requested {
|
||||
continue
|
||||
}
|
||||
if inviteID != 0 && importer.InviteID != inviteID {
|
||||
continue
|
||||
}
|
||||
all = append(all, importer)
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].Date != all[j].Date {
|
||||
return all[i].Date > all[j].Date
|
||||
}
|
||||
return all[i].UserID > all[j].UserID
|
||||
})
|
||||
total := len(all)
|
||||
start := 0
|
||||
if req.OffsetDate > 0 || req.OffsetUserID != 0 {
|
||||
start = len(all)
|
||||
for i, importer := range all {
|
||||
if importer.Date == req.OffsetDate && importer.UserID == req.OffsetUserID {
|
||||
start = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 || limit > domain.MaxChannelInviteListLimit {
|
||||
limit = domain.MaxChannelInviteListLimit
|
||||
}
|
||||
if start > len(all) {
|
||||
start = len(all)
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
return domain.ChannelInviteImporterList{Count: total, Importers: cloneChannelInviteImporters(all[start:end])}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) PendingJoinRequests(_ context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error) {
|
||||
if channelID == 0 {
|
||||
return domain.ChannelPendingJoinRequests{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted {
|
||||
return domain.ChannelPendingJoinRequests{}, domain.ErrChannelInvalid
|
||||
}
|
||||
all := make([]domain.ChannelInviteImporter, 0)
|
||||
for _, importer := range s.importers[channelID] {
|
||||
if importer.Requested {
|
||||
all = append(all, importer)
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].Date != all[j].Date {
|
||||
return all[i].Date > all[j].Date
|
||||
}
|
||||
return all[i].UserID > all[j].UserID
|
||||
})
|
||||
if limit <= 0 || limit > domain.MaxChannelPendingJoinRecentRequesters {
|
||||
limit = domain.MaxChannelPendingJoinRecentRequesters
|
||||
}
|
||||
if len(all) < limit {
|
||||
limit = len(all)
|
||||
}
|
||||
recent := make([]int64, 0, limit)
|
||||
for _, importer := range all[:limit] {
|
||||
recent = append(recent, importer.UserID)
|
||||
}
|
||||
return domain.ChannelPendingJoinRequests{
|
||||
ChannelID: channelID,
|
||||
Count: len(all),
|
||||
RecentRequesters: recent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) HideAllChatJoinRequests(_ context.Context, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !canExportChannelInvite(member) {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
var inviteID int64
|
||||
if req.Hash != "" {
|
||||
invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash)
|
||||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
inviteID = invite.InviteID
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 || limit > domain.MaxChannelHideJoinRequests {
|
||||
limit = domain.MaxChannelHideJoinRequests
|
||||
}
|
||||
targets := make([]domain.ChannelInviteImporter, 0, limit)
|
||||
for _, importer := range s.importers[req.ChannelID] {
|
||||
if !importer.Requested {
|
||||
continue
|
||||
}
|
||||
if inviteID != 0 && importer.InviteID != inviteID {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, importer)
|
||||
if len(targets) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
var result domain.CreateChannelResult
|
||||
for _, importer := range targets {
|
||||
invite := domain.ChannelInvite{ChannelID: req.ChannelID, AdminUserID: req.UserID}
|
||||
if importer.InviteID != 0 {
|
||||
var err error
|
||||
invite, err = s.inviteByIDLocked(req.ChannelID, importer.InviteID)
|
||||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
}
|
||||
if !req.Approved {
|
||||
s.deletePendingInviteImporterLocked(invite, importer.UserID)
|
||||
result = domain.CreateChannelResult{Channel: channel, Recipients: s.activeMemberIDsLocked(req.ChannelID, importer.UserID, 0)}
|
||||
continue
|
||||
}
|
||||
result, err = s.approveInviteImporterLocked(channel, invite, importer.UserID, req.UserID, req.Date)
|
||||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
channel = result.Channel
|
||||
}
|
||||
if result.Channel.ID == 0 {
|
||||
result = domain.CreateChannelResult{Channel: channel, Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0)}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) approveInviteImporterLocked(channel domain.Channel, invite domain.ChannelInvite, userID, approvedBy int64, date int) (domain.CreateChannelResult, error) {
|
||||
if invite.InviteID != 0 && invite.UsageLimit > 0 && invite.UsageCount >= invite.UsageLimit {
|
||||
return domain.CreateChannelResult{}, domain.ErrUsersTooMuch
|
||||
}
|
||||
channelID := channel.ID
|
||||
if channelID == 0 {
|
||||
channelID = invite.ChannelID
|
||||
}
|
||||
if existing, ok := s.members[channelID][userID]; ok {
|
||||
if existing.Status == domain.ChannelMemberActive {
|
||||
return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant
|
||||
}
|
||||
if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages {
|
||||
return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid
|
||||
}
|
||||
}
|
||||
preJoinTopID := channel.TopMessageID
|
||||
minID := channelInitialAvailableMinID(channel)
|
||||
inviterID := invite.AdminUserID
|
||||
if inviterID == 0 {
|
||||
inviterID = approvedBy
|
||||
}
|
||||
member := domain.ChannelMember{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
InviterUserID: inviterID,
|
||||
Role: domain.ChannelRoleMember,
|
||||
Status: domain.ChannelMemberActive,
|
||||
JoinedAt: date,
|
||||
AvailableMinID: minID,
|
||||
AvailableMinPts: channelInitialAvailableMinPts(channel),
|
||||
ReadInboxMaxID: maxInt(minID, preJoinTopID),
|
||||
}
|
||||
if s.members[channelID] == nil {
|
||||
s.members[channelID] = make(map[int64]domain.ChannelMember)
|
||||
}
|
||||
s.members[channelID][userID] = member
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Date: date,
|
||||
Type: domain.ChannelAdminLogParticipantJoin,
|
||||
})
|
||||
if importer, ok := s.importers[channelID][userID]; ok && importer.Requested {
|
||||
if importer.InviteID == invite.InviteID {
|
||||
if invite.InviteID != 0 && invite.RequestedCount > 0 {
|
||||
invite.RequestedCount--
|
||||
}
|
||||
} else if importer.InviteID != 0 {
|
||||
if pendingInvite, err := s.inviteByIDLocked(channelID, importer.InviteID); err == nil && pendingInvite.RequestedCount > 0 {
|
||||
pendingInvite.RequestedCount--
|
||||
s.invites[pendingInvite.Hash] = pendingInvite
|
||||
}
|
||||
}
|
||||
}
|
||||
if invite.InviteID != 0 && invite.Hash != "" {
|
||||
invite.UsageCount++
|
||||
s.invites[invite.Hash] = invite
|
||||
}
|
||||
s.refreshChannelCountsLocked(channelID)
|
||||
channel = s.channels[channelID]
|
||||
var msg domain.ChannelMessage
|
||||
var event domain.ChannelUpdateEvent
|
||||
if channel.Megagroup {
|
||||
msg, event = s.appendChannelServiceMessageLocked(channelID, userID, date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionChatJoinedByLink,
|
||||
InviterUserID: invite.AdminUserID,
|
||||
UserIDs: []int64{userID},
|
||||
})
|
||||
channel.TopMessageID = msg.ID
|
||||
channel.Pts = event.Pts
|
||||
s.channels[channelID] = channel
|
||||
}
|
||||
member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID)
|
||||
if msg.ID != 0 {
|
||||
member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID)
|
||||
}
|
||||
s.members[channelID][userID] = member
|
||||
s.upsertChannelDialogLocked(userID, channel, msg, true)
|
||||
if s.importers[channelID] == nil {
|
||||
s.importers[channelID] = make(map[int64]domain.ChannelInviteImporter)
|
||||
}
|
||||
s.importers[channelID][userID] = domain.ChannelInviteImporter{
|
||||
ChannelID: channelID,
|
||||
InviteID: invite.InviteID,
|
||||
UserID: userID,
|
||||
Date: date,
|
||||
ApprovedBy: approvedBy,
|
||||
}
|
||||
return domain.CreateChannelResult{
|
||||
Channel: channel,
|
||||
Members: []domain.ChannelMember{member},
|
||||
Message: cloneChannelMessage(msg),
|
||||
Event: cloneChannelEvent(event),
|
||||
Recipients: s.activeMemberIDsLocked(channelID, 0, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) recordPendingInviteRequestLocked(invite domain.ChannelInvite, userID int64, date int) error {
|
||||
if existing, ok := s.members[invite.ChannelID][userID]; ok {
|
||||
if existing.Status == domain.ChannelMemberActive {
|
||||
return domain.ErrUserAlreadyParticipant
|
||||
}
|
||||
if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages {
|
||||
return domain.ErrInviteHashInvalid
|
||||
}
|
||||
}
|
||||
if s.importers[invite.ChannelID] == nil {
|
||||
s.importers[invite.ChannelID] = make(map[int64]domain.ChannelInviteImporter)
|
||||
}
|
||||
if existing, ok := s.importers[invite.ChannelID][userID]; ok && existing.Requested {
|
||||
return domain.ErrInviteRequestSent
|
||||
}
|
||||
s.importers[invite.ChannelID][userID] = domain.ChannelInviteImporter{
|
||||
ChannelID: invite.ChannelID,
|
||||
InviteID: invite.InviteID,
|
||||
UserID: userID,
|
||||
Date: date,
|
||||
Requested: true,
|
||||
}
|
||||
invite.RequestedCount++
|
||||
s.invites[invite.Hash] = invite
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) deletePendingInviteImporterLocked(invite domain.ChannelInvite, userID int64) {
|
||||
if existing, ok := s.importers[invite.ChannelID][userID]; ok && existing.Requested {
|
||||
delete(s.importers[invite.ChannelID], userID)
|
||||
if invite.InviteID != 0 && invite.Hash != "" && invite.RequestedCount > 0 {
|
||||
invite.RequestedCount--
|
||||
s.invites[invite.Hash] = invite
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelStore) newReplacementInviteLocked(old domain.ChannelInvite, date int) (domain.ChannelInvite, error) {
|
||||
inviteID, err := randomMemoryPositiveInt64()
|
||||
if err != nil {
|
||||
return domain.ChannelInvite{}, err
|
||||
}
|
||||
hash, err := randomMemoryInviteHash()
|
||||
if err != nil {
|
||||
return domain.ChannelInvite{}, err
|
||||
}
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
return domain.ChannelInvite{
|
||||
ChannelID: old.ChannelID,
|
||||
InviteID: inviteID,
|
||||
Hash: hash,
|
||||
AdminUserID: old.AdminUserID,
|
||||
Permanent: old.Permanent,
|
||||
Date: date,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) setChannelHasLinkLocked(channelID int64, hasLink bool) {
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
channel.HasLink = hasLink
|
||||
s.channels[channelID] = channel
|
||||
}
|
||||
|
||||
func (s *ChannelStore) refreshChannelHasLinkLocked(channelID int64) domain.Channel {
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok {
|
||||
return domain.Channel{}
|
||||
}
|
||||
channel.HasLink = s.channelHasNonRevokedInviteLocked(channelID)
|
||||
s.channels[channelID] = channel
|
||||
return channel
|
||||
}
|
||||
|
||||
func (s *ChannelStore) channelHasNonRevokedInviteLocked(channelID int64) bool {
|
||||
for _, invite := range s.invites {
|
||||
if invite.ChannelID == channelID && !invite.Revoked {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloneChannelInvites(in []domain.ChannelInvite) []domain.ChannelInvite {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]domain.ChannelInvite, len(in))
|
||||
copy(out, in)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneChannelInviteImporters(in []domain.ChannelInviteImporter) []domain.ChannelInviteImporter {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]domain.ChannelInviteImporter, len(in))
|
||||
copy(out, in)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListChannelInviteAdminMemberIDs(_ context.Context, channelID int64, limit int) ([]int64, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, ok := s.channels[channelID]
|
||||
if channelID == 0 || !ok || channel.Deleted {
|
||||
return nil, domain.ErrChannelInvalid
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxChannelRealtimeFanout {
|
||||
limit = domain.MaxChannelRealtimeFanout
|
||||
}
|
||||
members := s.members[channelID]
|
||||
out := make([]int64, 0, minInt(len(members), limit))
|
||||
for _, member := range members {
|
||||
if member.Status != domain.ChannelMemberActive {
|
||||
continue
|
||||
}
|
||||
if member.Role == domain.ChannelRoleCreator {
|
||||
out = append(out, member.UserID)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo) {
|
||||
out = append(out, member.UserID)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) appendChannelAdminLogLocked(event domain.ChannelAdminLogEvent) {
|
||||
if event.ChannelID == 0 || event.UserID == 0 || event.Type == "" {
|
||||
return
|
||||
}
|
||||
s.logSeq[event.ChannelID]++
|
||||
event.ID = s.logSeq[event.ChannelID]
|
||||
event.Query = adminLogSearchText(event)
|
||||
s.adminLogs[event.ChannelID] = append(s.adminLogs[event.ChannelID], cloneChannelAdminLogEvent(event))
|
||||
}
|
||||
|
||||
func canInviteToChannel(channel domain.Channel, member domain.ChannelMember) bool {
|
||||
if member.Role == domain.ChannelRoleCreator ||
|
||||
(member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) {
|
||||
return true
|
||||
}
|
||||
return channel.Megagroup && !channel.DefaultBannedRights.InviteUsers && !member.BannedRights.InviteUsers
|
||||
}
|
||||
|
||||
func canExportChannelInvite(member domain.ChannelMember) bool {
|
||||
return member.Role == domain.ChannelRoleCreator ||
|
||||
(member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo))
|
||||
}
|
||||
|
||||
func randomMemoryInviteHash() (string, error) {
|
||||
var b [18]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
func cloneChannelAdminLogEvent(in domain.ChannelAdminLogEvent) domain.ChannelAdminLogEvent {
|
||||
if in.PrevParticipant != nil {
|
||||
in.PrevParticipant = ptrChannelMember(*in.PrevParticipant)
|
||||
}
|
||||
if in.NewParticipant != nil {
|
||||
in.NewParticipant = ptrChannelMember(*in.NewParticipant)
|
||||
}
|
||||
if in.Participant != nil {
|
||||
in.Participant = ptrChannelMember(*in.Participant)
|
||||
}
|
||||
if in.Message != nil {
|
||||
in.Message = ptrChannelMessage(*in.Message)
|
||||
}
|
||||
if in.PrevMessage != nil {
|
||||
in.PrevMessage = ptrChannelMessage(*in.PrevMessage)
|
||||
}
|
||||
if in.NewMessage != nil {
|
||||
in.NewMessage = ptrChannelMessage(*in.NewMessage)
|
||||
}
|
||||
return in
|
||||
}
|
||||
992
internal/store/memory/channel_members.go
Normal file
992
internal/store/memory/channel_members.go
Normal file
|
|
@ -0,0 +1,992 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) GetParticipants(_ context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, err := s.channelForMemberLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelParticipantList{}, err
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxChannelParticipantsLimit {
|
||||
limit = domain.MaxChannelParticipantsLimit
|
||||
}
|
||||
viewer := s.members[channelID][viewerUserID]
|
||||
// 广播频道订阅者列表仅管理员可枚举(与隐藏成员同一门控):admins filter 仍放行(徽章数据源)。
|
||||
if channel.MembersListAdminOnly() && !isChannelAdmin(viewer) {
|
||||
switch filter.Kind {
|
||||
case domain.ChannelParticipantsAdmins:
|
||||
case domain.ChannelParticipantsBots:
|
||||
return domain.ChannelParticipantList{Channel: channel, Count: 0}, nil
|
||||
default:
|
||||
return domain.ChannelParticipantList{Channel: channel, Count: channel.ParticipantsCount}, nil
|
||||
}
|
||||
}
|
||||
if (filter.Kind == domain.ChannelParticipantsBanned || filter.Kind == domain.ChannelParticipantsKicked) && !isChannelAdmin(viewer) {
|
||||
return domain.ChannelParticipantList{Channel: channel}, nil
|
||||
}
|
||||
query := strings.ToLower(strings.TrimSpace(filter.Query))
|
||||
items := make([]domain.ChannelMember, 0, len(s.members[channelID]))
|
||||
for _, member := range s.members[channelID] {
|
||||
if !channelParticipantMatchesFilter(member, filter.Kind, query) {
|
||||
continue
|
||||
}
|
||||
if shouldHideAnonymousAdminFromParticipantList(viewer, member) {
|
||||
continue
|
||||
}
|
||||
items = append(items, member)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].Role != items[j].Role {
|
||||
return channelRoleOrder(items[i].Role) < channelRoleOrder(items[j].Role)
|
||||
}
|
||||
return items[i].UserID < items[j].UserID
|
||||
})
|
||||
count := len(items)
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset > domain.MaxChannelParticipantsOffset {
|
||||
offset = domain.MaxChannelParticipantsOffset
|
||||
}
|
||||
if offset >= len(items) {
|
||||
items = nil
|
||||
} else {
|
||||
end := offset + limit
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
items = items[offset:end]
|
||||
}
|
||||
return domain.ChannelParticipantList{
|
||||
Channel: channel,
|
||||
Participants: cloneChannelMembers(items),
|
||||
Count: count,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetParticipant(_ context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil {
|
||||
return domain.ChannelMember{}, err
|
||||
}
|
||||
member, ok := s.members[channelID][participantUserID]
|
||||
if !ok {
|
||||
return domain.ChannelMember{}, domain.ErrChannelPrivate
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) FutureCreatorAfterLeave(_ context.Context, channelID, userID int64) (domain.ChannelMember, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, err := s.channelAndMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelMember{}, err
|
||||
}
|
||||
if channel.CreatorUserID != userID || member.Role != domain.ChannelRoleCreator {
|
||||
return domain.ChannelMember{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
return s.futureCreatorAfterLeaveLocked(channelID, userID)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) JoinChannel(_ context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
preJoinTopID := channel.TopMessageID
|
||||
if existing, ok := s.members[channelID][userID]; ok {
|
||||
if existing.Status == domain.ChannelMemberActive {
|
||||
return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant
|
||||
}
|
||||
if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelUserBanned
|
||||
}
|
||||
}
|
||||
if channel.JoinRequest {
|
||||
if err := s.recordPublicJoinRequestLocked(channel, userID, date); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent
|
||||
}
|
||||
member := domain.ChannelMember{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
// 自加入:inviter 即本人(对齐官方 channelParticipantSelf.inviter_id == user_id),
|
||||
// 客户端据此生成本地「您加入了此频道」服务消息把广播频道补进会话列表。见 postgres 同处注释。
|
||||
InviterUserID: userID,
|
||||
Role: domain.ChannelRoleMember,
|
||||
Status: domain.ChannelMemberActive,
|
||||
JoinedAt: date,
|
||||
}
|
||||
if existing, ok := s.members[channelID][userID]; ok {
|
||||
member = existing
|
||||
member.Status = domain.ChannelMemberActive
|
||||
member.LeftAt = 0
|
||||
// 重进是全新 participant:不保留旧的角色/管理权/Tag;inviter 重置为本人(自加入)。
|
||||
// 只有 channel 当前 owner 仍是该账号时,才保留 creator 身份。
|
||||
if existing.Role != domain.ChannelRoleCreator || channel.CreatorUserID != userID {
|
||||
member.Role = domain.ChannelRoleMember
|
||||
member.AdminRights = domain.ChannelAdminRights{}
|
||||
member.Rank = ""
|
||||
member.InviterUserID = userID
|
||||
member.JoinedAt = date
|
||||
}
|
||||
if minID := channelInitialAvailableMinID(channel); minID > member.AvailableMinID {
|
||||
member.AvailableMinID = minID
|
||||
member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, minID)
|
||||
}
|
||||
member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, preJoinTopID)
|
||||
if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts {
|
||||
member.AvailableMinPts = minPts
|
||||
}
|
||||
channel.ParticipantsCount++
|
||||
} else {
|
||||
member.AvailableMinID = channelInitialAvailableMinID(channel)
|
||||
member.AvailableMinPts = channelInitialAvailableMinPts(channel)
|
||||
member.ReadInboxMaxID = maxInt(member.AvailableMinID, preJoinTopID)
|
||||
channel.ParticipantsCount++
|
||||
}
|
||||
if s.members[channelID] == nil {
|
||||
s.members[channelID] = make(map[int64]domain.ChannelMember)
|
||||
}
|
||||
s.members[channelID][userID] = member
|
||||
s.channels[channelID] = channel
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Date: date,
|
||||
Type: domain.ChannelAdminLogParticipantJoin,
|
||||
})
|
||||
var msg domain.ChannelMessage
|
||||
var event domain.ChannelUpdateEvent
|
||||
if channel.Megagroup {
|
||||
msg, event = s.appendChannelServiceMessageLocked(channelID, userID, date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionChatJoined,
|
||||
UserIDs: []int64{userID},
|
||||
})
|
||||
channel.TopMessageID = msg.ID
|
||||
channel.Pts = event.Pts
|
||||
s.channels[channelID] = channel
|
||||
}
|
||||
member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID)
|
||||
if msg.ID != 0 && msg.SenderUserID == userID {
|
||||
member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID)
|
||||
}
|
||||
s.members[channelID][userID] = member
|
||||
s.upsertChannelDialogLocked(userID, channel, msg, true)
|
||||
return domain.CreateChannelResult{
|
||||
Channel: channel,
|
||||
Members: []domain.ChannelMember{member},
|
||||
Message: cloneChannelMessage(msg),
|
||||
Event: cloneChannelEvent(event),
|
||||
Recipients: s.activeMemberIDsLocked(channelID, 0, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) LeaveChannel(_ context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, member, err := s.channelAndMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
members := []domain.ChannelMember{}
|
||||
adminsDelta := 0
|
||||
if isChannelAdmin(member) {
|
||||
adminsDelta--
|
||||
}
|
||||
if channel.CreatorUserID == userID || member.Role == domain.ChannelRoleCreator {
|
||||
if channel.CreatorUserID != userID || member.Role != domain.ChannelRoleCreator {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelUserCreator
|
||||
}
|
||||
future, err := s.futureCreatorAfterLeaveLocked(channelID, userID)
|
||||
if err != nil {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelUserCreator
|
||||
}
|
||||
if !isChannelAdmin(future) {
|
||||
adminsDelta++
|
||||
}
|
||||
future.Role = domain.ChannelRoleCreator
|
||||
future.AdminRights = creatorChannelAdminRights()
|
||||
future.Rank = ""
|
||||
future.Status = domain.ChannelMemberActive
|
||||
future.LeftAt = 0
|
||||
s.members[channelID][future.UserID] = future
|
||||
channel.CreatorUserID = future.UserID
|
||||
members = append(members, future)
|
||||
member.Role = domain.ChannelRoleMember
|
||||
member.AdminRights = domain.ChannelAdminRights{}
|
||||
member.Rank = ""
|
||||
}
|
||||
member.Status = domain.ChannelMemberLeft
|
||||
member.LeftAt = date
|
||||
s.members[channelID][userID] = member
|
||||
members = append([]domain.ChannelMember{member}, members...)
|
||||
s.clearChannelMentionsForUserLocked(channelID, userID)
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Date: date,
|
||||
Type: domain.ChannelAdminLogParticipantLeave,
|
||||
})
|
||||
if channel.ParticipantsCount > 0 {
|
||||
channel.ParticipantsCount--
|
||||
}
|
||||
channel.AdminsCount += adminsDelta
|
||||
if channel.AdminsCount < 0 {
|
||||
channel.AdminsCount = 0
|
||||
}
|
||||
var msg domain.ChannelMessage
|
||||
var event domain.ChannelUpdateEvent
|
||||
if channel.Megagroup {
|
||||
msg, event = s.appendChannelServiceMessageLocked(channelID, userID, date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionChatDelete,
|
||||
UserIDs: []int64{userID},
|
||||
})
|
||||
channel.TopMessageID = msg.ID
|
||||
channel.Pts = event.Pts
|
||||
}
|
||||
s.channels[channelID] = channel
|
||||
return domain.CreateChannelResult{
|
||||
Channel: channel,
|
||||
Members: cloneChannelMembers(members),
|
||||
Message: cloneChannelMessage(msg),
|
||||
Event: cloneChannelEvent(event),
|
||||
Recipients: append(s.activeMemberIDsLocked(channelID, 0, 0), userID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 {
|
||||
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.EditChannelAdminResult{}, err
|
||||
}
|
||||
actor := s.members[req.ChannelID][req.UserID]
|
||||
if !canAddChannelAdmins(actor) {
|
||||
return domain.EditChannelAdminResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
if actor.Role != domain.ChannelRoleCreator && !adminRightsSubset(req.AdminRights, actor.AdminRights) {
|
||||
return domain.EditChannelAdminResult{}, domain.ErrChannelRightForbidden
|
||||
}
|
||||
previous, ok := s.members[req.ChannelID][req.MemberID]
|
||||
if !ok {
|
||||
previous = domain.ChannelMember{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.MemberID,
|
||||
InviterUserID: req.UserID,
|
||||
Role: domain.ChannelRoleMember,
|
||||
Status: domain.ChannelMemberActive,
|
||||
JoinedAt: req.Date,
|
||||
AvailableMinID: channelInitialAvailableMinID(channel),
|
||||
AvailableMinPts: channelInitialAvailableMinPts(channel),
|
||||
ReadInboxMaxID: channel.TopMessageID,
|
||||
}
|
||||
}
|
||||
if previous.Role == domain.ChannelRoleCreator {
|
||||
return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator
|
||||
}
|
||||
member := previous
|
||||
member.InviterUserID = req.UserID
|
||||
member.Status = domain.ChannelMemberActive
|
||||
member.LeftAt = 0
|
||||
if previous.Status != domain.ChannelMemberActive {
|
||||
if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts {
|
||||
member.AvailableMinPts = minPts
|
||||
}
|
||||
}
|
||||
member.AdminRights = req.AdminRights
|
||||
member.Rank = req.Rank
|
||||
if zeroChannelAdminRights(req.AdminRights) {
|
||||
member.Role = domain.ChannelRoleMember
|
||||
member.Rank = ""
|
||||
} else {
|
||||
member.Role = domain.ChannelRoleAdmin
|
||||
}
|
||||
s.members[req.ChannelID][req.MemberID] = member
|
||||
logType := domain.ChannelAdminLogParticipantPromote
|
||||
if member.Role != domain.ChannelRoleAdmin {
|
||||
logType = domain.ChannelAdminLogParticipantDemote
|
||||
}
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: req.Date,
|
||||
Type: logType,
|
||||
PrevParticipant: ptrChannelMember(previous),
|
||||
NewParticipant: ptrChannelMember(member),
|
||||
})
|
||||
s.refreshChannelCountsLocked(req.ChannelID)
|
||||
channel = s.channels[req.ChannelID]
|
||||
event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date)
|
||||
if msg, ok := s.findMessageLocked(req.ChannelID, channel.TopMessageID); ok {
|
||||
s.upsertChannelDialogLocked(member.UserID, channel, msg, false)
|
||||
}
|
||||
recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0)
|
||||
recipients = append(recipients, req.MemberID)
|
||||
return domain.EditChannelAdminResult{
|
||||
Channel: channel,
|
||||
Previous: previous,
|
||||
Participant: member,
|
||||
Event: event,
|
||||
Recipients: recipients,
|
||||
Date: req.Date,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) EditChannelMemberRank(_ context.Context, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 {
|
||||
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.EditChannelAdminResult{}, err
|
||||
}
|
||||
actor := s.members[req.ChannelID][req.UserID]
|
||||
previous, ok := s.members[req.ChannelID][req.MemberID]
|
||||
if !ok || previous.Status != domain.ChannelMemberActive {
|
||||
return domain.EditChannelAdminResult{}, domain.ErrUserNotParticipant
|
||||
}
|
||||
if err := checkEditMemberRank(channel, actor, previous); err != nil {
|
||||
return domain.EditChannelAdminResult{}, err
|
||||
}
|
||||
member := previous
|
||||
member.Rank = req.Rank
|
||||
s.members[req.ChannelID][req.MemberID] = member
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: req.Date,
|
||||
Type: domain.ChannelAdminLogParticipantEditRank,
|
||||
PrevString: previous.Rank,
|
||||
NewString: member.Rank,
|
||||
Participant: ptrChannelMember(member),
|
||||
})
|
||||
event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date)
|
||||
recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0)
|
||||
recipients = append(recipients, req.MemberID)
|
||||
return domain.EditChannelAdminResult{
|
||||
Channel: channel,
|
||||
Previous: previous,
|
||||
Participant: member,
|
||||
Event: event,
|
||||
Recipients: recipients,
|
||||
Date: req.Date,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) EditChannelBanned(_ context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.Participant.Type != domain.PeerTypeUser || req.Participant.ID == 0 {
|
||||
return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.EditChannelBannedResult{}, err
|
||||
}
|
||||
actor := s.members[req.ChannelID][req.UserID]
|
||||
if !canBanChannelUsers(actor) {
|
||||
return domain.EditChannelBannedResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
previous, ok := s.members[req.ChannelID][req.Participant.ID]
|
||||
if !ok {
|
||||
previous = domain.ChannelMember{ChannelID: req.ChannelID, UserID: req.Participant.ID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberLeft}
|
||||
}
|
||||
if previous.Role == domain.ChannelRoleCreator {
|
||||
return domain.EditChannelBannedResult{}, domain.ErrChannelUserCreator
|
||||
}
|
||||
member := previous
|
||||
member.Role = domain.ChannelRoleMember
|
||||
member.BannedRights = req.BannedRights
|
||||
switch {
|
||||
case req.BannedRights.ViewMessages:
|
||||
member.InviterUserID = req.UserID
|
||||
member.Status = domain.ChannelMemberKicked
|
||||
member.LeftAt = req.Date
|
||||
case zeroChannelBannedRights(req.BannedRights):
|
||||
if previous.Status == domain.ChannelMemberActive {
|
||||
member.Status = domain.ChannelMemberActive
|
||||
} else {
|
||||
member.Status = domain.ChannelMemberLeft
|
||||
}
|
||||
member.LeftAt = 0
|
||||
default:
|
||||
member.InviterUserID = req.UserID
|
||||
if previous.Status == domain.ChannelMemberActive {
|
||||
member.Status = domain.ChannelMemberActive
|
||||
} else {
|
||||
member.Status = domain.ChannelMemberBanned
|
||||
}
|
||||
}
|
||||
if member.JoinedAt == 0 && member.Status == domain.ChannelMemberActive {
|
||||
member.JoinedAt = req.Date
|
||||
}
|
||||
s.members[req.ChannelID][req.Participant.ID] = member
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: req.Date,
|
||||
Type: adminLogBanType(previous, member),
|
||||
PrevParticipant: ptrChannelMember(previous),
|
||||
NewParticipant: ptrChannelMember(member),
|
||||
})
|
||||
s.refreshChannelCountsLocked(req.ChannelID)
|
||||
channel = s.channels[req.ChannelID]
|
||||
event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date)
|
||||
if member.Status == domain.ChannelMemberActive {
|
||||
if msg, ok := s.findMessageLocked(req.ChannelID, channel.TopMessageID); ok {
|
||||
s.upsertChannelDialogLocked(member.UserID, channel, msg, false)
|
||||
}
|
||||
}
|
||||
if member.Status == domain.ChannelMemberKicked && previous.Status == domain.ChannelMemberActive {
|
||||
s.clearChannelMentionsForUserLocked(req.ChannelID, req.Participant.ID)
|
||||
}
|
||||
var serviceMsg domain.ChannelMessage
|
||||
var serviceEvent domain.ChannelUpdateEvent
|
||||
if channel.Megagroup && previous.Status == domain.ChannelMemberActive && member.Status == domain.ChannelMemberKicked {
|
||||
serviceMsg, serviceEvent = s.appendChannelServiceMessageLocked(req.ChannelID, req.UserID, req.Date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionChatDelete,
|
||||
UserIDs: []int64{req.Participant.ID},
|
||||
})
|
||||
channel.TopMessageID = serviceMsg.ID
|
||||
channel.Pts = serviceEvent.Pts
|
||||
s.channels[req.ChannelID] = channel
|
||||
}
|
||||
recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0)
|
||||
recipients = append(recipients, req.Participant.ID)
|
||||
return domain.EditChannelBannedResult{
|
||||
Channel: channel,
|
||||
Previous: previous,
|
||||
Participant: member,
|
||||
Event: event,
|
||||
Recipients: recipients,
|
||||
Date: req.Date,
|
||||
Message: cloneChannelMessage(serviceMsg),
|
||||
ServiceEvent: cloneChannelEvent(serviceEvent),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) EditChannelDefaultBannedRights(_ context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
actor := s.members[req.ChannelID][req.UserID]
|
||||
if !canBanChannelUsers(actor) {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
if channel.DefaultBannedRights == req.BannedRights {
|
||||
return domain.Channel{}, domain.ErrChannelNotModified
|
||||
}
|
||||
channel.DefaultBannedRights = req.BannedRights
|
||||
s.channels[req.ChannelID] = channel
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListAdminedPublicChannels(_ context.Context, userID int64) ([]domain.Channel, error) {
|
||||
if userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]domain.Channel, 0)
|
||||
for channelID, members := range s.members {
|
||||
member := members[userID]
|
||||
if member.Status != domain.ChannelMemberActive || !isChannelAdmin(member) {
|
||||
continue
|
||||
}
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted || channel.Username == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, channel)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
if len(out) > domain.MaxAdminedPublicChannels {
|
||||
out = out[:domain.MaxAdminedPublicChannels]
|
||||
}
|
||||
return append([]domain.Channel(nil), out...), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListStoryPostableChannels(_ context.Context, userID int64) ([]domain.Channel, error) {
|
||||
if userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]domain.Channel, 0)
|
||||
for channelID, members := range s.members {
|
||||
member := members[userID]
|
||||
if member.Status != domain.ChannelMemberActive {
|
||||
continue
|
||||
}
|
||||
if member.Role != domain.ChannelRoleCreator && !(member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostStories) {
|
||||
continue
|
||||
}
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted || (!channel.Broadcast && !channel.Megagroup) {
|
||||
continue
|
||||
}
|
||||
out = append(out, channel)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
if len(out) > domain.MaxStorySendAsChannels {
|
||||
out = out[:domain.MaxStorySendAsChannels]
|
||||
}
|
||||
return append([]domain.Channel(nil), out...), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListSendAsChannels(_ context.Context, userID int64) ([]domain.Channel, error) {
|
||||
if userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]domain.Channel, 0)
|
||||
for channelID, members := range s.members {
|
||||
member := members[userID]
|
||||
if member.Status != domain.ChannelMemberActive {
|
||||
continue
|
||||
}
|
||||
if member.Role != domain.ChannelRoleCreator && !(member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostMessages) {
|
||||
continue
|
||||
}
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted || !channel.Broadcast {
|
||||
continue
|
||||
}
|
||||
out = append(out, channel)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
if len(out) > domain.MaxSendAsChannels {
|
||||
out = out[:domain.MaxSendAsChannels]
|
||||
}
|
||||
return append([]domain.Channel(nil), out...), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetParticipantsHidden(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !channel.Megagroup || !canBanChannelUsers(member) {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
channel.ParticipantsHidden = enabled
|
||||
s.channels[channelID] = channel
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetJoinToSend(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !channel.Megagroup || !canExportChannelInvite(member) {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
channel.JoinToSend = enabled
|
||||
s.channels[channelID] = channel
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetJoinRequest(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !channel.Megagroup || !canExportChannelInvite(member) {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
if enabled && strings.TrimSpace(channel.Username) == "" {
|
||||
return domain.Channel{}, domain.ErrChatPublicRequired
|
||||
}
|
||||
channel.JoinRequest = enabled
|
||||
s.channels[channelID] = channel
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) HideChatJoinRequest(_ context.Context, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.TargetUserID == 0 {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !canExportChannelInvite(member) {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
importer, ok := s.importers[req.ChannelID][req.TargetUserID]
|
||||
if !ok || !importer.Requested {
|
||||
return domain.CreateChannelResult{}, domain.ErrHideRequesterMissing
|
||||
}
|
||||
invite := domain.ChannelInvite{ChannelID: req.ChannelID, AdminUserID: req.UserID}
|
||||
if importer.InviteID != 0 {
|
||||
var err error
|
||||
invite, err = s.inviteByIDLocked(req.ChannelID, importer.InviteID)
|
||||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
}
|
||||
if !req.Approved {
|
||||
s.deletePendingInviteImporterLocked(invite, req.TargetUserID)
|
||||
return domain.CreateChannelResult{Channel: channel, Recipients: s.activeMemberIDsLocked(req.ChannelID, req.TargetUserID, 0)}, nil
|
||||
}
|
||||
return s.approveInviteImporterLocked(channel, invite, req.TargetUserID, req.UserID, req.Date)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) recordPublicJoinRequestLocked(channel domain.Channel, userID int64, date int) error {
|
||||
if existing, ok := s.members[channel.ID][userID]; ok {
|
||||
if existing.Status == domain.ChannelMemberActive {
|
||||
return domain.ErrUserAlreadyParticipant
|
||||
}
|
||||
if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages {
|
||||
return domain.ErrInviteHashInvalid
|
||||
}
|
||||
}
|
||||
if s.importers[channel.ID] == nil {
|
||||
s.importers[channel.ID] = make(map[int64]domain.ChannelInviteImporter)
|
||||
}
|
||||
if existing, ok := s.importers[channel.ID][userID]; ok && existing.Requested {
|
||||
return domain.ErrInviteRequestSent
|
||||
}
|
||||
s.importers[channel.ID][userID] = domain.ChannelInviteImporter{
|
||||
ChannelID: channel.ID,
|
||||
UserID: userID,
|
||||
Date: date,
|
||||
Requested: true,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListActiveChannelMemberIDs(_ context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.activeMemberIDsLocked(channelID, 0, limit), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) FilterActiveChannelMemberIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if channelID == 0 || len(userIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
members := s.members[channelID]
|
||||
if len(members) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]int64, 0, len(userIDs))
|
||||
seen := make(map[int64]struct{}, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if userID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[userID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
member, ok := members[userID]
|
||||
if !ok || member.Status != domain.ChannelMemberActive {
|
||||
continue
|
||||
}
|
||||
out = append(out, userID)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListActiveChannelMembers(_ context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, viewer, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, domain.ChannelMember{}, nil, err
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout {
|
||||
limit = domain.MaxSynchronousChannelDialogFanout
|
||||
}
|
||||
members := s.members[channelID]
|
||||
out := make([]domain.ChannelMember, 0, minInt(limit, len(members)))
|
||||
for _, member := range members {
|
||||
if member.Status != domain.ChannelMemberActive {
|
||||
continue
|
||||
}
|
||||
out = append(out, member)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].UserID < out[j].UserID })
|
||||
return channel, viewer, cloneChannelMembers(out), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) channelForMemberLocked(userID, channelID int64) (domain.Channel, error) {
|
||||
channel, _, err := s.channelAndMemberLocked(userID, channelID)
|
||||
return channel, err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) channelAndMemberLocked(userID, channelID int64) (domain.Channel, domain.ChannelMember, error) {
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted {
|
||||
return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelInvalid
|
||||
}
|
||||
member, ok := s.members[channelID][userID]
|
||||
if !ok || member.Status == domain.ChannelMemberLeft {
|
||||
return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelPrivate
|
||||
}
|
||||
if member.Status == domain.ChannelMemberBanned || member.Status == domain.ChannelMemberKicked || member.BannedRights.ViewMessages {
|
||||
return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelUserBanned
|
||||
}
|
||||
return channel, member, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) activeMemberIDsLocked(channelID, excludeUserID int64, limit int) []int64 {
|
||||
members := s.members[channelID]
|
||||
if limit <= 0 || limit > domain.MaxChannelRealtimeFanout {
|
||||
limit = domain.MaxChannelRealtimeFanout
|
||||
}
|
||||
capacity := limit
|
||||
if len(members) < capacity {
|
||||
capacity = len(members)
|
||||
}
|
||||
out := make([]int64, 0, capacity)
|
||||
for userID, member := range members {
|
||||
if userID == excludeUserID || member.Status != domain.ChannelMemberActive {
|
||||
continue
|
||||
}
|
||||
out = append(out, userID)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *ChannelStore) futureCreatorAfterLeaveLocked(channelID, userID int64) (domain.ChannelMember, error) {
|
||||
members := s.members[channelID]
|
||||
var selected domain.ChannelMember
|
||||
found := false
|
||||
for _, member := range members {
|
||||
if member.UserID == userID || member.Status != domain.ChannelMemberActive || member.Role == domain.ChannelRoleCreator || member.BannedRights.ViewMessages {
|
||||
continue
|
||||
}
|
||||
if !found || futureCreatorCandidateLess(member, selected) {
|
||||
selected = member
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return domain.ChannelMember{}, domain.ErrUserNotParticipant
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func futureCreatorCandidateLess(a, b domain.ChannelMember) bool {
|
||||
if channelRoleOrder(a.Role) != channelRoleOrder(b.Role) {
|
||||
return channelRoleOrder(a.Role) < channelRoleOrder(b.Role)
|
||||
}
|
||||
return a.UserID < b.UserID
|
||||
}
|
||||
|
||||
func publicPreviewMember(channel domain.Channel, userID int64, existing domain.ChannelMember, found bool) domain.ChannelMember {
|
||||
member := domain.ChannelMember{
|
||||
ChannelID: channel.ID,
|
||||
UserID: userID,
|
||||
Role: domain.ChannelRoleMember,
|
||||
Status: domain.ChannelMemberLeft,
|
||||
AvailableMinID: channelInitialAvailableMinID(channel),
|
||||
AvailableMinPts: channelInitialAvailableMinPts(channel),
|
||||
ReadInboxMaxID: channel.TopMessageID,
|
||||
ReadOutboxMaxID: channel.TopMessageID,
|
||||
}
|
||||
if found {
|
||||
member.InviterUserID = existing.InviterUserID
|
||||
member.JoinedAt = existing.JoinedAt
|
||||
member.LeftAt = existing.LeftAt
|
||||
member.AvailableMinID = maxInt(member.AvailableMinID, existing.AvailableMinID)
|
||||
member.AvailableMinPts = maxInt(member.AvailableMinPts, existing.AvailableMinPts)
|
||||
member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, existing.ReadInboxMaxID)
|
||||
member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, existing.ReadOutboxMaxID)
|
||||
}
|
||||
return member
|
||||
}
|
||||
|
||||
func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.ChannelMember) domain.ChannelMember {
|
||||
member := parentMember
|
||||
member.ChannelID = mono.ID
|
||||
member.Status = domain.ChannelMemberActive
|
||||
if mono.CreatorUserID == parentMember.UserID {
|
||||
member.Role = domain.ChannelRoleCreator
|
||||
} else {
|
||||
member.Role = domain.ChannelRoleAdmin
|
||||
}
|
||||
member.AvailableMinID = 0
|
||||
member.AvailableMinPts = 0
|
||||
member.ReadInboxMaxID = mono.TopMessageID
|
||||
member.ReadOutboxMaxID = mono.TopMessageID
|
||||
member.UnreadMark = false
|
||||
member.SlowmodeLastSendDate = 0
|
||||
return member
|
||||
}
|
||||
|
||||
func publicChannelSearchRank(channel domain.Channel, queryLower string) (int, bool) {
|
||||
if !publicSearchableChannel(channel) {
|
||||
return 0, false
|
||||
}
|
||||
username := strings.ToLower(strings.TrimSpace(channel.Username))
|
||||
title := strings.ToLower(strings.TrimSpace(channel.Title))
|
||||
switch {
|
||||
case username == queryLower:
|
||||
return 0, true
|
||||
case strings.HasPrefix(username, queryLower):
|
||||
return 1, true
|
||||
case strings.Contains(username, queryLower):
|
||||
return 2, true
|
||||
case strings.HasPrefix(title, queryLower):
|
||||
return 3, true
|
||||
case strings.Contains(title, queryLower):
|
||||
return 4, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func canPostToBroadcast(member domain.ChannelMember) bool {
|
||||
return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostMessages)
|
||||
}
|
||||
|
||||
func isChannelAdmin(member domain.ChannelMember) bool {
|
||||
return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin
|
||||
}
|
||||
|
||||
func shouldHideAnonymousAdminFromParticipantList(viewer, member domain.ChannelMember) bool {
|
||||
if isChannelAdmin(viewer) {
|
||||
return false
|
||||
}
|
||||
if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin {
|
||||
return false
|
||||
}
|
||||
return member.AdminRights.Anonymous
|
||||
}
|
||||
|
||||
func channelParticipantMatchesFilter(member domain.ChannelMember, kind domain.ChannelParticipantsFilterKind, query string) bool {
|
||||
if query != "" && !strings.Contains(strconv.FormatInt(member.UserID, 10), query) {
|
||||
return false
|
||||
}
|
||||
switch kind {
|
||||
case "", domain.ChannelParticipantsRecent, domain.ChannelParticipantsContacts, domain.ChannelParticipantsMentions, domain.ChannelParticipantsSearch:
|
||||
return member.Status == domain.ChannelMemberActive
|
||||
case domain.ChannelParticipantsAdmins:
|
||||
// Layer 225 起 admins filter 同时是消息徽章数据源:客户端把整个返回
|
||||
// (含 rank)灌进 badge 缓存,因此带成员 Tag 的普通成员也必须返回。
|
||||
return member.Status == domain.ChannelMemberActive && (isChannelAdmin(member) || member.Rank != "")
|
||||
case domain.ChannelParticipantsKicked:
|
||||
return member.Status == domain.ChannelMemberKicked || member.BannedRights.ViewMessages
|
||||
case domain.ChannelParticipantsBanned:
|
||||
return member.Status != domain.ChannelMemberKicked && !zeroChannelBannedRights(member.BannedRights)
|
||||
case domain.ChannelParticipantsBots:
|
||||
return false
|
||||
default:
|
||||
return member.Status == domain.ChannelMemberActive
|
||||
}
|
||||
}
|
||||
|
||||
func canManageDiscussionBroadcast(member domain.ChannelMember) bool {
|
||||
return canChangeChannelInfo(member)
|
||||
}
|
||||
|
||||
func canManageDiscussionGroup(member domain.ChannelMember) bool {
|
||||
return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages)
|
||||
}
|
||||
|
||||
func canAddChannelAdmins(member domain.ChannelMember) bool {
|
||||
return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.AddAdmins)
|
||||
}
|
||||
|
||||
func canBanChannelUsers(member domain.ChannelMember) bool {
|
||||
return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.BanUsers)
|
||||
}
|
||||
|
||||
func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool {
|
||||
return rights == domain.ChannelAdminRights{}
|
||||
}
|
||||
|
||||
func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool {
|
||||
return rights == domain.ChannelBannedRights{}
|
||||
}
|
||||
|
||||
func creatorChannelAdminRights() domain.ChannelAdminRights {
|
||||
return domain.ChannelAdminRights{
|
||||
ChangeInfo: true,
|
||||
PostMessages: true,
|
||||
EditMessages: true,
|
||||
DeleteMessages: true,
|
||||
PostStories: true,
|
||||
EditStories: true,
|
||||
DeleteStories: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneChannelMembers(in []domain.ChannelMember) []domain.ChannelMember {
|
||||
return append([]domain.ChannelMember(nil), in...)
|
||||
}
|
||||
|
||||
func ptrChannelMember(in domain.ChannelMember) *domain.ChannelMember {
|
||||
out := in
|
||||
return &out
|
||||
}
|
||||
258
internal/store/memory/channel_message_delete.go
Normal file
258
internal/store/memory/channel_message_delete.go
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) DeleteChannelMessages(_ context.Context, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || len(req.IDs) == 0 {
|
||||
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if len(req.IDs) > domain.MaxDeleteMessageIDs {
|
||||
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.DeleteChannelMessagesResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
refs := make(map[int]domain.ChannelDiscussionRef, len(req.IDs))
|
||||
for _, id := range req.IDs {
|
||||
if msg, ok := s.findMessageLocked(req.ChannelID, id); ok && !msg.Deleted && msg.Discussion != nil {
|
||||
refs[id] = *msg.Discussion
|
||||
}
|
||||
}
|
||||
deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, req.IDs, req.UserID, req.Date)
|
||||
if err != nil {
|
||||
return domain.DeleteChannelMessagesResult{}, err
|
||||
}
|
||||
// 被删 broadcast post 的讨论组转发根级联删除(服务端动作,creator 权限)。
|
||||
var cascades []domain.ChannelCascadeDelete
|
||||
byChannel := make(map[int64][]int)
|
||||
for _, id := range deleted {
|
||||
if ref, ok := refs[id]; ok && ref.ChannelID != 0 && ref.MessageID != 0 {
|
||||
byChannel[ref.ChannelID] = append(byChannel[ref.ChannelID], ref.MessageID)
|
||||
}
|
||||
}
|
||||
for groupID, rootIDs := range byChannel {
|
||||
group, ok := s.channels[groupID]
|
||||
if !ok || group.Deleted {
|
||||
continue
|
||||
}
|
||||
systemMember := domain.ChannelMember{ChannelID: groupID, UserID: req.UserID, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive}
|
||||
groupDeleted, groupEvent, group, err := s.deleteChannelMessagesLocked(group, systemMember, rootIDs, req.UserID, req.Date)
|
||||
if err != nil || len(groupDeleted) == 0 {
|
||||
continue
|
||||
}
|
||||
cascades = append(cascades, domain.ChannelCascadeDelete{
|
||||
Channel: group,
|
||||
Event: cloneChannelEvent(groupEvent),
|
||||
Recipients: s.activeMemberIDsLocked(groupID, 0, 0),
|
||||
})
|
||||
}
|
||||
return domain.DeleteChannelMessagesResult{
|
||||
Channel: channel,
|
||||
Event: cloneChannelEvent(event),
|
||||
DeletedIDs: append([]int(nil), deleted...),
|
||||
Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0),
|
||||
DiscussionDeletes: cascades,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) DeleteChannelHistory(_ context.Context, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.DeleteChannelHistoryResult{}, err
|
||||
}
|
||||
maxID := req.MaxID
|
||||
if maxID <= 0 || maxID > channel.TopMessageID {
|
||||
maxID = channel.TopMessageID
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !req.ForEveryone {
|
||||
appliedMinID := maxInt(member.AvailableMinID, maxID)
|
||||
member.AvailableMinID = appliedMinID
|
||||
member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, appliedMinID)
|
||||
member.UnreadMark = false
|
||||
s.members[req.ChannelID][req.UserID] = member
|
||||
s.deleteChannelUnreadMentionsUpToLocked(req.UserID, req.ChannelID, appliedMinID)
|
||||
if dialog, ok := s.dialogs[req.UserID][req.ChannelID]; ok {
|
||||
dialog.UnreadReactions = s.countChannelUnreadReactionsLocked(req.UserID, req.ChannelID, 0)
|
||||
s.dialogs[req.UserID][req.ChannelID] = dialog
|
||||
}
|
||||
if s.dialogs[req.UserID] == nil {
|
||||
s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog)
|
||||
}
|
||||
s.dialogs[req.UserID][req.ChannelID] = s.dialogForUserLocked(req.UserID, channel)
|
||||
return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil
|
||||
}
|
||||
if !canDeleteAnyChannelMessage(member) {
|
||||
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
// id=1 是建群服务消息,全员清空必须保留:它是清空后会话仅剩的
|
||||
// top message,没有它客户端会把 lastMessage 视为空并从聊天列表
|
||||
// 隐藏该会话(成员资格仍在,但会话条目对全员消失)。
|
||||
ids := make([]int, 0, domain.MaxDeleteHistoryBatch)
|
||||
for i := len(s.messages[req.ChannelID]) - 1; i >= 0; i-- {
|
||||
msg := s.messages[req.ChannelID][i]
|
||||
if msg.Deleted || msg.ID > maxID || msg.ID <= 1 {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, msg.ID)
|
||||
if len(ids) >= domain.MaxDeleteHistoryBatch {
|
||||
break
|
||||
}
|
||||
}
|
||||
deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, ids, req.UserID, req.Date)
|
||||
if err != nil {
|
||||
return domain.DeleteChannelHistoryResult{}, err
|
||||
}
|
||||
offset := 0
|
||||
if len(deleted) == domain.MaxDeleteHistoryBatch {
|
||||
offset = 1
|
||||
}
|
||||
return domain.DeleteChannelHistoryResult{
|
||||
Channel: channel,
|
||||
Event: cloneChannelEvent(event),
|
||||
DeletedIDs: append([]int(nil), deleted...),
|
||||
Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0),
|
||||
Offset: offset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) DeleteChannelParticipantHistory(_ context.Context, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 {
|
||||
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.DeleteChannelHistoryResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !canDeleteAnyChannelMessage(member) {
|
||||
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
// 同全员清空:id=1 建群服务消息不随发送者(创建者)历史一起删除,
|
||||
// 否则会话会因 lastMessage 为空从全员聊天列表隐藏。
|
||||
ids := make([]int, 0, domain.MaxDeleteHistoryBatch)
|
||||
for i := len(s.messages[req.ChannelID]) - 1; i >= 0; i-- {
|
||||
msg := s.messages[req.ChannelID][i]
|
||||
if msg.Deleted || msg.SenderUserID != req.ParticipantUserID || msg.ID <= 1 {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, msg.ID)
|
||||
if len(ids) >= domain.MaxDeleteHistoryBatch {
|
||||
break
|
||||
}
|
||||
}
|
||||
deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, ids, req.UserID, req.Date)
|
||||
if err != nil {
|
||||
return domain.DeleteChannelHistoryResult{}, err
|
||||
}
|
||||
offset := 0
|
||||
if len(deleted) == domain.MaxDeleteHistoryBatch {
|
||||
offset = 1
|
||||
}
|
||||
return domain.DeleteChannelHistoryResult{
|
||||
Channel: channel,
|
||||
Event: cloneChannelEvent(event),
|
||||
DeletedIDs: append([]int(nil), deleted...),
|
||||
Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0),
|
||||
Offset: offset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) deleteChannelMessagesLocked(channel domain.Channel, member domain.ChannelMember, ids []int, actorUserID int64, date int) ([]int, domain.ChannelUpdateEvent, domain.Channel, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, domain.ChannelUpdateEvent{}, channel, nil
|
||||
}
|
||||
seen := make(map[int]struct{}, len(ids))
|
||||
deleted := make([]int, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return nil, domain.ChannelUpdateEvent{}, channel, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
idx, ok := s.findMessageIndexLocked(channel.ID, id)
|
||||
if !ok || s.messages[channel.ID][idx].Deleted {
|
||||
continue
|
||||
}
|
||||
msg := s.messages[channel.ID][idx]
|
||||
if msg.SenderUserID != actorUserID && !canDeleteAnyChannelMessage(member) {
|
||||
return nil, domain.ChannelUpdateEvent{}, channel, domain.ErrChannelAdminRequired
|
||||
}
|
||||
if id <= 1 {
|
||||
// id=1 建群服务消息是清空后会话仅剩的兜底 top message,所有
|
||||
// 删除入口统一静默跳过(官方客户端对它禁用删除)。
|
||||
continue
|
||||
}
|
||||
msg.Deleted = true
|
||||
s.messages[channel.ID][idx] = msg
|
||||
deleted = append(deleted, id)
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: channel.ID,
|
||||
UserID: actorUserID,
|
||||
Date: date,
|
||||
Type: domain.ChannelAdminLogDeleteMessage,
|
||||
Message: ptrChannelMessage(msg),
|
||||
Query: msg.Body,
|
||||
})
|
||||
}
|
||||
if len(deleted) == 0 {
|
||||
return nil, domain.ChannelUpdateEvent{}, channel, nil
|
||||
}
|
||||
s.deleteChannelUnreadMentionsLocked(channel.ID, deleted)
|
||||
pts := s.nextChannelPtsNLocked(channel.ID, len(deleted))
|
||||
channel.Pts = pts
|
||||
channel.TopMessageID = s.topNonDeletedMessageIDLocked(channel.ID)
|
||||
// 删除即从置顶集合移除(读路径过滤 NOT deleted),重算最新置顶缓存。
|
||||
channel.PinnedMessageID = s.latestPinnedMessageIDLocked(channel.ID)
|
||||
s.channels[channel.ID] = channel
|
||||
for userID, member := range s.members[channel.ID] {
|
||||
if member.Status != domain.ChannelMemberActive {
|
||||
continue
|
||||
}
|
||||
if s.dialogs[userID] == nil {
|
||||
s.dialogs[userID] = make(map[int64]domain.ChannelDialog)
|
||||
}
|
||||
dialog := s.dialogForUserLocked(userID, channel)
|
||||
s.dialogs[userID][channel.ID] = dialog
|
||||
}
|
||||
event := domain.ChannelUpdateEvent{
|
||||
ChannelID: channel.ID,
|
||||
Type: domain.ChannelUpdateDeleteMessages,
|
||||
Pts: pts,
|
||||
PtsCount: len(deleted),
|
||||
Date: date,
|
||||
MessageIDs: append([]int(nil), deleted...),
|
||||
SenderUserID: actorUserID,
|
||||
}
|
||||
s.events[channel.ID] = append(s.events[channel.ID], event)
|
||||
return deleted, event, channel, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) topNonDeletedMessageIDLocked(channelID int64) int {
|
||||
for i := len(s.messages[channelID]) - 1; i >= 0; i-- {
|
||||
if !s.messages[channelID][i].Deleted {
|
||||
return s.messages[channelID][i].ID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func canDeleteAnyChannelMessage(member domain.ChannelMember) bool {
|
||||
return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.DeleteMessages)
|
||||
}
|
||||
396
internal/store/memory/channel_message_edit.go
Normal file
396
internal/store/memory/channel_message_edit.go
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) {
|
||||
// 空文本只在媒体替换(live location 续报/停止)时合法。
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 || (strings.TrimSpace(req.Message) == "" && req.Media == nil) {
|
||||
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
|
||||
// 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.events[req.ChannelID] = append(s.events[req.ChannelID], 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
|
||||
}
|
||||
if req.Media == nil && !req.SetReplyMarkup && 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)
|
||||
}
|
||||
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.events[req.ChannelID] = append(s.events[req.ChannelID], 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.events[req.ChannelID] = append(s.events[req.ChannelID], 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.events[req.ChannelID] = append(s.events[req.ChannelID], 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.events[req.ChannelID] = append(s.events[req.ChannelID], 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)
|
||||
}
|
||||
78
internal/store/memory/channel_message_helpers.go
Normal file
78
internal/store/memory/channel_message_helpers.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) eventForMessageLocked(channelID int64, id int) domain.ChannelUpdateEvent {
|
||||
for _, event := range s.events[channelID] {
|
||||
if event.Message.ID == id {
|
||||
return cloneChannelEvent(event)
|
||||
}
|
||||
}
|
||||
return domain.ChannelUpdateEvent{}
|
||||
}
|
||||
|
||||
func sameMessageEntities(a, b []domain.MessageEntity) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cloneChannelMessage(in domain.ChannelMessage) domain.ChannelMessage {
|
||||
in.Entities = append([]domain.MessageEntity(nil), in.Entities...)
|
||||
in.ReplyTo = cloneMessageReply(in.ReplyTo)
|
||||
in.Forward = cloneMessageForward(in.Forward)
|
||||
in.ReplyMarkup = cloneReplyMarkup(in.ReplyMarkup)
|
||||
in.Discussion = cloneChannelDiscussionRef(in.Discussion)
|
||||
in.Replies = cloneChannelMessageReplies(in.Replies)
|
||||
in.Reactions = cloneChannelMessageReactionsPtr(in.Reactions)
|
||||
if in.SendAs != nil {
|
||||
p := *in.SendAs
|
||||
in.SendAs = &p
|
||||
}
|
||||
if in.Action != nil {
|
||||
in.Action = cloneChannelMessageAction(in.Action)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func cloneChannelMessageAction(in *domain.ChannelMessageAction) *domain.ChannelMessageAction {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
out.UserIDs = append([]int64(nil), in.UserIDs...)
|
||||
out.Completed = append([]int(nil), in.Completed...)
|
||||
out.Incompleted = append([]int(nil), in.Incompleted...)
|
||||
out.TodoItems = append([]domain.MessageTodoItem(nil), in.TodoItems...)
|
||||
if in.Closed != nil {
|
||||
v := *in.Closed
|
||||
out.Closed = &v
|
||||
}
|
||||
if in.Hidden != nil {
|
||||
v := *in.Hidden
|
||||
out.Hidden = &v
|
||||
}
|
||||
if in.StarGift != nil {
|
||||
g := *in.StarGift
|
||||
if in.StarGift.Sticker != nil {
|
||||
sticker := *in.StarGift.Sticker
|
||||
g.Sticker = &sticker
|
||||
}
|
||||
out.StarGift = &g
|
||||
}
|
||||
out.Wallpaper = domain.CloneWallpaperPtr(in.Wallpaper)
|
||||
return &out
|
||||
}
|
||||
|
||||
func ptrChannelMessage(in domain.ChannelMessage) *domain.ChannelMessage {
|
||||
out := cloneChannelMessage(in)
|
||||
return &out
|
||||
}
|
||||
561
internal/store/memory/channel_message_history.go
Normal file
561
internal/store/memory/channel_message_history.go
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, _, err := s.channelForViewerLocked(viewerUserID, filter.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
items := append([]domain.ChannelMessage(nil), s.messages[filter.ChannelID]...)
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].ID > items[j].ID })
|
||||
// 静态过滤(不含 offset 锚点的方向条件),结果保持 id 降序。
|
||||
query := strings.ToLower(strings.TrimSpace(filter.Query))
|
||||
matched := make([]domain.ChannelMessage, 0, len(items))
|
||||
for _, msg := range items {
|
||||
if msg.Deleted {
|
||||
continue
|
||||
}
|
||||
if channel.Monoforum && msg.SavedPeer.ID != 0 {
|
||||
continue
|
||||
}
|
||||
if msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
if filter.PinnedOnly && !msg.Pinned {
|
||||
continue
|
||||
}
|
||||
if filter.MusicOnly && !msg.Media.IsMusic() {
|
||||
continue
|
||||
}
|
||||
if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) {
|
||||
continue
|
||||
}
|
||||
if filter.SenderUserID != 0 && msg.SenderUserID != filter.SenderUserID {
|
||||
continue
|
||||
}
|
||||
if filter.MinDate > 0 && msg.Date <= filter.MinDate {
|
||||
continue
|
||||
}
|
||||
if filter.MaxDate > 0 && msg.Date >= filter.MaxDate {
|
||||
continue
|
||||
}
|
||||
if filter.MaxID > 0 && msg.ID > filter.MaxID {
|
||||
continue
|
||||
}
|
||||
if filter.MinID > 0 && msg.ID <= filter.MinID {
|
||||
continue
|
||||
}
|
||||
matched = append(matched, msg)
|
||||
}
|
||||
// add_offset 决定加载方向(对齐 postgres ListChannelHistory):
|
||||
// >= 0 backward:锚点更旧方向(不含锚点),先跳过 add_offset 条
|
||||
// < 0 且 +limit>0 around:以锚点为中心,向更新取 -add_offset 条 + 向更旧(含锚点)取 limit+add_offset 条
|
||||
// 否则 forward:仅锚点更新方向
|
||||
newerThanAnchor := func(msg domain.ChannelMessage) bool {
|
||||
if filter.OffsetDate > 0 {
|
||||
return msg.Date >= filter.OffsetDate
|
||||
}
|
||||
if filter.OffsetID > 0 {
|
||||
return msg.ID > filter.OffsetID
|
||||
}
|
||||
return false
|
||||
}
|
||||
olderThanAnchor := func(msg domain.ChannelMessage, includeAnchor bool) bool {
|
||||
if filter.OffsetDate > 0 {
|
||||
return msg.Date < filter.OffsetDate
|
||||
}
|
||||
if filter.OffsetID > 0 {
|
||||
if includeAnchor {
|
||||
return msg.ID <= filter.OffsetID
|
||||
}
|
||||
return msg.ID < filter.OffsetID
|
||||
}
|
||||
return true
|
||||
}
|
||||
takeNewer := func(limit int) []domain.ChannelMessage {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
// 升序收集锚点更新方向最近的 limit 条,再反转回降序。
|
||||
asc := make([]domain.ChannelMessage, 0, limit)
|
||||
for i := len(matched) - 1; i >= 0; i-- {
|
||||
if !newerThanAnchor(matched[i]) {
|
||||
continue
|
||||
}
|
||||
asc = append(asc, matched[i])
|
||||
if len(asc) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
out := make([]domain.ChannelMessage, 0, len(asc))
|
||||
for i := len(asc) - 1; i >= 0; i-- {
|
||||
out = append(out, asc[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
takeOlder := func(skip, limit int, includeAnchor bool) ([]domain.ChannelMessage, bool) {
|
||||
if limit <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]domain.ChannelMessage, 0, limit)
|
||||
skipped := 0
|
||||
for _, msg := range matched {
|
||||
if !olderThanAnchor(msg, includeAnchor) {
|
||||
continue
|
||||
}
|
||||
if skipped < skip {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if len(out) == limit {
|
||||
return out, true
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out, false
|
||||
}
|
||||
addOffset := filter.AddOffset
|
||||
var page []domain.ChannelMessage
|
||||
hasMoreOlder := false
|
||||
switch {
|
||||
case addOffset < 0 && addOffset+limit > 0:
|
||||
fwdLimit := -addOffset
|
||||
if fwdLimit > limit {
|
||||
fwdLimit = limit
|
||||
}
|
||||
bwdLimit := limit + addOffset
|
||||
page = takeNewer(fwdLimit)
|
||||
older, more := takeOlder(0, bwdLimit, true)
|
||||
page = append(page, older...)
|
||||
hasMoreOlder = more
|
||||
case addOffset < 0:
|
||||
page = takeNewer(limit)
|
||||
default:
|
||||
page, hasMoreOlder = takeOlder(addOffset, limit, false)
|
||||
}
|
||||
out := make([]domain.ChannelMessage, 0, len(page))
|
||||
for _, msg := range page {
|
||||
out = append(out, cloneChannelMessage(msg))
|
||||
}
|
||||
count := len(out)
|
||||
if hasMoreOlder {
|
||||
count = len(out) + 1
|
||||
}
|
||||
s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out)
|
||||
s.populateChannelMessageReactionsLocked(viewerUserID, channel, out)
|
||||
extraChannels := []domain.Channel(nil)
|
||||
if channel.Monoforum && channel.LinkedMonoforumID != 0 {
|
||||
if parent, ok := s.channels[channel.LinkedMonoforumID]; ok && !parent.Deleted {
|
||||
extraChannels = append(extraChannels, cloneChannel(parent))
|
||||
}
|
||||
}
|
||||
return domain.ChannelHistory{
|
||||
Channel: channel,
|
||||
Self: member,
|
||||
Channels: extraChannels,
|
||||
Messages: out,
|
||||
Count: count,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) {
|
||||
query := strings.ToLower(strings.TrimSpace(req.Query))
|
||||
if viewerUserID == 0 || (query == "" && !req.MusicOnly) {
|
||||
return domain.ChannelHistory{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.Limit <= 0 || req.Limit > domain.MaxChannelGlobalSearchLimit {
|
||||
req.Limit = domain.MaxChannelGlobalSearchLimit
|
||||
}
|
||||
type hit struct {
|
||||
channel domain.Channel
|
||||
message domain.ChannelMessage
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
hits := make([]hit, 0, req.Limit+1)
|
||||
for channelID, channel := range s.channels {
|
||||
if channel.Deleted {
|
||||
continue
|
||||
}
|
||||
if req.BroadcastsOnly && (!channel.Broadcast || channel.Megagroup) {
|
||||
continue
|
||||
}
|
||||
if req.GroupsOnly && !channel.Megagroup {
|
||||
continue
|
||||
}
|
||||
member, ok := s.members[channelID][viewerUserID]
|
||||
if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages {
|
||||
continue
|
||||
}
|
||||
if req.HasFolderID {
|
||||
dialog, ok := s.dialogs[viewerUserID][channelID]
|
||||
if !ok || dialog.FolderID != req.FolderID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if msg.Deleted {
|
||||
continue
|
||||
}
|
||||
if query == "" && !req.MusicOnly || query != "" && strings.TrimSpace(msg.Body) == "" {
|
||||
continue
|
||||
}
|
||||
if member.AvailableMinID > 0 && msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
if req.MinDate > 0 && msg.Date <= req.MinDate {
|
||||
continue
|
||||
}
|
||||
if req.MaxDate > 0 && msg.Date >= req.MaxDate {
|
||||
continue
|
||||
}
|
||||
if !channelGlobalSearchAfterCursor(msg, req) {
|
||||
continue
|
||||
}
|
||||
if req.MusicOnly && !msg.Media.IsMusic() {
|
||||
continue
|
||||
}
|
||||
if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) {
|
||||
continue
|
||||
}
|
||||
hits = append(hits, hit{channel: channel, message: cloneChannelMessage(msg)})
|
||||
}
|
||||
}
|
||||
sort.Slice(hits, func(i, j int) bool {
|
||||
a, b := hits[i].message, hits[j].message
|
||||
if a.Date != b.Date {
|
||||
return a.Date > b.Date
|
||||
}
|
||||
if a.ChannelID != b.ChannelID {
|
||||
return a.ChannelID > b.ChannelID
|
||||
}
|
||||
return a.ID > b.ID
|
||||
})
|
||||
out := domain.ChannelHistory{Count: len(hits)}
|
||||
if out.Count > req.Limit {
|
||||
out.Count = req.Limit + 1
|
||||
hits = hits[:req.Limit]
|
||||
}
|
||||
channelSeen := make(map[int64]struct{}, len(hits))
|
||||
for _, h := range hits {
|
||||
out.Messages = append(out.Messages, h.message)
|
||||
if _, ok := channelSeen[h.channel.ID]; ok {
|
||||
continue
|
||||
}
|
||||
channelSeen[h.channel.ID] = struct{}{}
|
||||
out.Channels = append(out.Channels, h.channel)
|
||||
}
|
||||
s.populateChannelMessagesReactionsLocked(viewerUserID, out.Channels, out.Messages)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetChannelMessages(_ context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelHistory, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
// viewer 口径:公开频道非成员可预览读取(与 ListChannelHistory 一致 + 与 PG 实现对齐),
|
||||
// 否则查看他人公开「个人频道」时 channels.getMessages 被拒、资料页整块不显示。
|
||||
channel, member, _, err := s.channelForViewerLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return domain.ChannelHistory{Channel: channel, Self: member}, nil
|
||||
}
|
||||
if len(ids) > domain.MaxGetMessageIDs {
|
||||
return domain.ChannelHistory{}, domain.ErrChannelInvalid
|
||||
}
|
||||
wanted := make(map[int]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return domain.ChannelHistory{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
wanted[id] = struct{}{}
|
||||
}
|
||||
messages := make([]domain.ChannelMessage, 0, len(wanted))
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if _, ok := wanted[msg.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
if msg.Deleted || msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, cloneChannelMessage(msg))
|
||||
}
|
||||
sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID })
|
||||
s.populateChannelMessageRepliesLocked(viewerUserID, channelID, messages)
|
||||
s.populateChannelMessageReactionsLocked(viewerUserID, channel, messages)
|
||||
return domain.ChannelHistory{Channel: channel, Self: member, Messages: messages, Count: len(messages)}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListStoryMessageForwards(_ context.Context, req domain.StoryMessageForwardListRequest) (domain.StoryMessageForwardList, error) {
|
||||
if req.ViewerUserID == 0 || req.Owner.ID == 0 || req.StoryID <= 0 || req.StoryID > domain.MaxStoryID {
|
||||
return domain.StoryMessageForwardList{}, domain.ErrStoryIDInvalid
|
||||
}
|
||||
if req.Owner.Type != domain.PeerTypeUser && req.Owner.Type != domain.PeerTypeChannel {
|
||||
return domain.StoryMessageForwardList{}, domain.ErrStoryPeerInvalid
|
||||
}
|
||||
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
|
||||
return domain.StoryMessageForwardList{}, err
|
||||
}
|
||||
limit := clampStoryInteractionLimit(req.Limit)
|
||||
cursor := parseStoryInteractionCursor(req.Offset)
|
||||
s.mu.RLock()
|
||||
views := make([]domain.StoryView, 0)
|
||||
for channelID, channel := range s.channels {
|
||||
if channel.Deleted || strings.TrimSpace(channel.Username) == "" || (!channel.Broadcast && !channel.Megagroup) {
|
||||
continue
|
||||
}
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if !messageSharesStory(msg, req.Owner, req.StoryID) {
|
||||
continue
|
||||
}
|
||||
views = append(views, domain.StoryView{
|
||||
Owner: req.Owner,
|
||||
StoryID: req.StoryID,
|
||||
Date: msg.Date,
|
||||
PublicForward: &domain.StoryPublicForward{
|
||||
Message: cloneChannelMessage(msg),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
sortStoryViewsForList(views, req.ReactionsFirst, req.ForwardsFirst)
|
||||
page, nextOffset := pageStoryViews(views, limit, cursor, req.ReactionsFirst, req.ForwardsFirst)
|
||||
return domain.StoryMessageForwardList{Count: len(views), Forwards: page, NextOffset: nextOffset}, nil
|
||||
}
|
||||
|
||||
func messageSharesStory(msg domain.ChannelMessage, owner domain.Peer, storyID int) bool {
|
||||
return !msg.Deleted &&
|
||||
msg.Media != nil &&
|
||||
msg.Media.Kind == domain.MessageMediaKindStory &&
|
||||
msg.Media.Story != nil &&
|
||||
msg.Media.Story.Peer == owner &&
|
||||
msg.Media.Story.ID == storyID
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetChannelMessageForInlineBot(_ context.Context, botID, channelID int64, id int) (domain.Channel, domain.ChannelMessage, bool, error) {
|
||||
if botID == 0 || channelID == 0 || id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return domain.Channel{}, domain.ChannelMessage{}, false, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted {
|
||||
return domain.Channel{}, domain.ChannelMessage{}, false, nil
|
||||
}
|
||||
msg, ok := s.findMessageLocked(channelID, id)
|
||||
if !ok || msg.Deleted || msg.Action != nil || msg.ViaBotID != botID {
|
||||
return domain.Channel{}, domain.ChannelMessage{}, false, nil
|
||||
}
|
||||
return channel, cloneChannelMessage(msg), true, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetDiscussionMessage(_ context.Context, viewerUserID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
source, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelDiscussionMessage{}, err
|
||||
}
|
||||
msg, ok := s.findMessageLocked(channelID, msgID)
|
||||
if !ok || msg.Deleted || msg.ID <= member.AvailableMinID {
|
||||
return domain.ChannelDiscussionMessage{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
result := domain.ChannelDiscussionMessage{PostChannel: source, DiscussionChannel: source, Channels: []domain.Channel{source}}
|
||||
targetChannel := source
|
||||
targetMsg := msg
|
||||
targetMember := member
|
||||
if source.Broadcast {
|
||||
if msg.Discussion == nil || msg.Discussion.ChannelID == 0 || msg.Discussion.MessageID == 0 {
|
||||
return result, nil
|
||||
}
|
||||
linked, ok := s.channels[msg.Discussion.ChannelID]
|
||||
if !ok || linked.Deleted {
|
||||
return result, nil
|
||||
}
|
||||
linkedMsg, ok := s.findMessageLocked(linked.ID, msg.Discussion.MessageID)
|
||||
if !ok || linkedMsg.Deleted {
|
||||
return result, nil
|
||||
}
|
||||
targetChannel = linked
|
||||
targetMsg = linkedMsg
|
||||
if linkedMember, ok := s.members[linked.ID][viewerUserID]; ok {
|
||||
targetMember = linkedMember
|
||||
} else {
|
||||
targetMember = domain.ChannelMember{}
|
||||
}
|
||||
result.DiscussionChannel = linked
|
||||
result.Channels = []domain.Channel{source, linked}
|
||||
}
|
||||
items := []domain.ChannelMessage{cloneChannelMessage(targetMsg)}
|
||||
s.populateChannelMessageRepliesLocked(viewerUserID, targetChannel.ID, items)
|
||||
s.populateChannelMessageReactionsLocked(viewerUserID, targetChannel, items)
|
||||
if stats := s.channelMessageRepliesLocked(viewerUserID, targetChannel.ID, targetMsg); stats != nil {
|
||||
result.MaxID = stats.MaxID
|
||||
}
|
||||
result.ReadInboxMaxID = targetMember.ReadInboxMaxID
|
||||
result.ReadOutboxMaxID = targetMember.ReadOutboxMaxID
|
||||
result.UnreadCount = s.channelThreadUnreadCountLocked(viewerUserID, targetChannel.ID, targetMsg.ID, targetMember.ReadInboxMaxID)
|
||||
result.Messages = items
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) findMessageLocked(channelID int64, id int) (domain.ChannelMessage, bool) {
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if msg.ID == id {
|
||||
return msg, true
|
||||
}
|
||||
}
|
||||
return domain.ChannelMessage{}, false
|
||||
}
|
||||
|
||||
func (s *ChannelStore) findMessageIndexLocked(channelID int64, id int) (int, bool) {
|
||||
for i, msg := range s.messages[channelID] {
|
||||
if msg.ID == id {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func pageChannelMessageHistory(base []domain.ChannelMessage, filter domain.ChannelRepliesFilter, limit int) []domain.ChannelMessage {
|
||||
if limit <= 0 || len(base) == 0 {
|
||||
return nil
|
||||
}
|
||||
switch messageHistoryLoadType(filter.AddOffset, limit) {
|
||||
case messageHistoryLoadForward:
|
||||
return forwardChannelMessageHistory(base, filter, limit)
|
||||
case messageHistoryLoadAround:
|
||||
forwardLimit := -filter.AddOffset
|
||||
if forwardLimit > limit {
|
||||
forwardLimit = limit
|
||||
}
|
||||
backwardLimit := limit + filter.AddOffset
|
||||
if backwardLimit < 0 {
|
||||
backwardLimit = 0
|
||||
}
|
||||
page := make([]domain.ChannelMessage, 0, limit)
|
||||
page = append(page, forwardChannelMessageHistory(base, filter, forwardLimit)...)
|
||||
page = append(page, backwardChannelMessageHistory(base, filter, backwardLimit, true)...)
|
||||
sort.SliceStable(page, func(i, j int) bool { return channelMessageLess(page[i], page[j]) })
|
||||
return page
|
||||
default:
|
||||
start := filter.AddOffset
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
candidates := backwardChannelMessageHistory(base, filter, limit+start, false)
|
||||
if start >= len(candidates) {
|
||||
return nil
|
||||
}
|
||||
return candidates[start:]
|
||||
}
|
||||
}
|
||||
|
||||
func backwardChannelMessageHistory(base []domain.ChannelMessage, filter domain.ChannelRepliesFilter, limit int, includeOffset bool) []domain.ChannelMessage {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]domain.ChannelMessage, 0, limit)
|
||||
for _, msg := range base {
|
||||
if !channelMessageBeforeHistoryOffset(msg, filter, includeOffset) {
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func forwardChannelMessageHistory(base []domain.ChannelMessage, filter domain.ChannelRepliesFilter, limit int) []domain.ChannelMessage {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]domain.ChannelMessage, 0, limit)
|
||||
for i := len(base) - 1; i >= 0; i-- {
|
||||
msg := base[i]
|
||||
if !channelMessageAfterHistoryOffset(msg, filter) {
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) })
|
||||
return out
|
||||
}
|
||||
|
||||
func channelMessageBeforeHistoryOffset(msg domain.ChannelMessage, filter domain.ChannelRepliesFilter, includeOffset bool) bool {
|
||||
if filter.OffsetDate > 0 {
|
||||
if includeOffset {
|
||||
return msg.Date <= filter.OffsetDate
|
||||
}
|
||||
return msg.Date < filter.OffsetDate
|
||||
}
|
||||
if filter.OffsetID <= 0 {
|
||||
return true
|
||||
}
|
||||
if includeOffset {
|
||||
return msg.ID <= filter.OffsetID
|
||||
}
|
||||
return msg.ID < filter.OffsetID
|
||||
}
|
||||
|
||||
func channelMessageAfterHistoryOffset(msg domain.ChannelMessage, filter domain.ChannelRepliesFilter) bool {
|
||||
if filter.OffsetDate > 0 {
|
||||
return msg.Date >= filter.OffsetDate
|
||||
}
|
||||
if filter.OffsetID <= 0 {
|
||||
return false
|
||||
}
|
||||
return msg.ID > filter.OffsetID
|
||||
}
|
||||
|
||||
func channelMessageLess(a, b domain.ChannelMessage) bool {
|
||||
if a.Date != b.Date {
|
||||
return a.Date > b.Date
|
||||
}
|
||||
return a.ID > b.ID
|
||||
}
|
||||
|
||||
func (s *ChannelStore) visibleTopMessageIDLocked(userID int64, channel domain.Channel) int {
|
||||
return s.visibleTopMessageIDForMemberLocked(channel, s.members[channel.ID][userID])
|
||||
}
|
||||
|
||||
func (s *ChannelStore) visibleTopMessageIDForMemberLocked(channel domain.Channel, member domain.ChannelMember) int {
|
||||
for i := len(s.messages[channel.ID]) - 1; i >= 0; i-- {
|
||||
msg := s.messages[channel.ID][i]
|
||||
if !msg.Deleted && msg.ID > member.AvailableMinID {
|
||||
return msg.ID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *ChannelStore) topicHasVisibleMessagesLocked(channelID int64, topicID int) bool {
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if msg.Deleted {
|
||||
continue
|
||||
}
|
||||
if msg.ID == topicID || (msg.ReplyTo != nil && msg.ReplyTo.TopMessageID == topicID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
309
internal/store/memory/channel_message_send.go
Normal file
309
internal/store/memory/channel_message_send.go
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"telesrv/internal/domain"
|
||||
"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() {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
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 !canSendChannelMessageWithBoost(channel, member, fromBoostsApplied) {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden
|
||||
}
|
||||
if req.RandomID != 0 {
|
||||
if id, ok := s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}]; ok {
|
||||
msg, ok := s.findMessageLocked(req.ChannelID, id)
|
||||
if ok {
|
||||
event := s.eventForMessageLocked(req.ChannelID, id)
|
||||
if event.Message.ID != 0 {
|
||||
msg = event.Message
|
||||
}
|
||||
return domain.SendChannelMessageResult{
|
||||
Channel: channel,
|
||||
Message: cloneChannelMessage(msg),
|
||||
Event: event,
|
||||
Duplicate: true,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if wait := channelSlowModeWait(channel, member, req.Date); wait > 0 {
|
||||
return domain.SendChannelMessageResult{}, domain.NewSlowModeWaitError(wait)
|
||||
}
|
||||
replyTo, err := s.resolveChannelReplyLocked(req, member, channel)
|
||||
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...),
|
||||
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.events[linked.ID] = append(s.events[linked.ID], 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,
|
||||
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)
|
||||
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.events[req.ChannelID] = append(s.events[req.ChannelID], 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 {
|
||||
s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}] = msg.ID
|
||||
}
|
||||
channel.TopMessageID = msg.ID
|
||||
channel.Pts = pts
|
||||
s.channels[req.ChannelID] = channel
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
action = channelServiceActionForMessage(channelID, msgID, action)
|
||||
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.events[channelID] = append(s.events[channelID], event)
|
||||
return msg, event
|
||||
}
|
||||
|
||||
func channelServiceActionForMessage(channelID int64, msgID int, action domain.ChannelMessageAction) domain.ChannelMessageAction {
|
||||
if action.Type == domain.ChannelActionStarGift && action.StarGift != nil {
|
||||
g := *action.StarGift
|
||||
if g.PeerChannelID == 0 {
|
||||
g.PeerChannelID = channelID
|
||||
}
|
||||
if g.SavedID == 0 {
|
||||
g.SavedID = int64(msgID)
|
||||
}
|
||||
action.StarGift = &g
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
125
internal/store/memory/channel_message_settings.go
Normal file
125
internal/store/memory/channel_message_settings.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) SetPreHistoryHidden(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if member.Role != domain.ChannelRoleCreator {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
prev := channel.PreHistoryHidden
|
||||
channel.PreHistoryHidden = enabled
|
||||
s.channels[channelID] = channel
|
||||
if prev != enabled {
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Date: int(time.Now().Unix()),
|
||||
Type: domain.ChannelAdminLogTogglePreHistoryHidden,
|
||||
PrevBool: prev,
|
||||
NewBool: enabled,
|
||||
})
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetPaidMessagesPrice(_ context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.ChannelPaidMessagesPriceResult, error) {
|
||||
if userID == 0 || channelID == 0 || stars < 0 {
|
||||
return domain.ChannelPaidMessagesPriceResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelPaidMessagesPriceResult{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !canChangeChannelInfo(member) {
|
||||
return domain.ChannelPaidMessagesPriceResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
out := domain.ChannelPaidMessagesPriceResult{}
|
||||
prevAllowed := channel.BroadcastMessagesAllowed
|
||||
prevStars := channel.SendPaidMessagesStars
|
||||
prevLinked := channel.LinkedMonoforumID
|
||||
channel.SendPaidMessagesStars = stars
|
||||
channel.BroadcastMessagesAllowed = channel.Broadcast && broadcastMessagesAllowed
|
||||
changed := channel.Broadcast && (prevAllowed != channel.BroadcastMessagesAllowed || prevStars != stars || (channel.BroadcastMessagesAllowed && prevLinked == 0))
|
||||
// 开启频道私信时关联 monoforum 虚拟频道(与 postgres 行为一致);复用既有则只同步价格。
|
||||
if channel.BroadcastMessagesAllowed {
|
||||
if channel.LinkedMonoforumID == 0 {
|
||||
monoID := s.nextChannelIDLocked()
|
||||
s.channels[monoID] = domain.Channel{
|
||||
ID: monoID,
|
||||
AccessHash: s.nextAccessHashLocked(),
|
||||
CreatorUserID: channel.CreatorUserID,
|
||||
Title: channel.Title,
|
||||
// Keep monoforum as a megagroup-like saved sublist container. TDesktop hangs when
|
||||
// a monoforum peer is projected as both broadcast and megagroup.
|
||||
Megagroup: true,
|
||||
Monoforum: true,
|
||||
LinkedMonoforumID: channelID,
|
||||
SendPaidMessagesStars: stars,
|
||||
Date: int(time.Now().Unix()),
|
||||
}
|
||||
channel.LinkedMonoforumID = monoID
|
||||
} else if mono, ok := s.channels[channel.LinkedMonoforumID]; ok {
|
||||
// 复用既有 monoforum 只同步价格。mono 恒以 megagroup 形态创建、无路径会翻成 broadcast/forum,
|
||||
// 故无需再规范化 kind(那是对从未发布的 broadcast+monoforum 实验形态的历史数据修复死代码)。
|
||||
// 与 postgres 保持一致。
|
||||
mono.SendPaidMessagesStars = stars
|
||||
s.channels[channel.LinkedMonoforumID] = mono
|
||||
}
|
||||
// monoforum 首条(也是唯一)服务消息=创建消息(messageActionChannelCreate),客户端对
|
||||
// monoforum 渲染为 "Direct messages were enabled in this channel."。开关/价格变更的
|
||||
// paid_messages_price 只进母广播频道(渲染 "Channel enabled/disabled Direct Messages"),
|
||||
// **绝不进 mono**——mono 是 megagroup(非 broadcast),同一 action 会被渲染成"消息免费/
|
||||
// 设为 N 星"的错误文案;关闭只靠 monoforumDisabled 状态显示停用页脚。与 postgres 一致。
|
||||
if existing := s.channels[channel.LinkedMonoforumID]; existing.ID != 0 && existing.TopMessageID == 0 {
|
||||
createMsg, createEvent := s.appendChannelServiceMessageLocked(channel.LinkedMonoforumID, userID, int(time.Now().Unix()), domain.ChannelMessageAction{Type: domain.ChannelActionCreate, Title: existing.Title})
|
||||
mono := s.channels[channel.LinkedMonoforumID]
|
||||
mono.TopMessageID = createMsg.ID
|
||||
mono.Pts = createEvent.Pts
|
||||
s.channels[channel.LinkedMonoforumID] = mono
|
||||
out.ServiceMessages = append(out.ServiceMessages, domain.SendChannelMessageResult{Channel: cloneChannel(mono), Message: cloneChannelMessage(createMsg), Event: cloneChannelEvent(createEvent)})
|
||||
}
|
||||
}
|
||||
// monoforum 镜像母频道 DM 启用状态到自己的 BroadcastMessagesAllowed(与 postgres 一致):投影时据此
|
||||
// 决定是否下发 mono 的 linked_monoforum_id,关闭时双方都隐藏 link → 打开 monoforum 重拉不会清掉
|
||||
// MonoforumDisabled,停用页脚保持。内部关联行仍保留以便重新开启复用。
|
||||
if channel.LinkedMonoforumID != 0 {
|
||||
if mono, ok := s.channels[channel.LinkedMonoforumID]; ok {
|
||||
mono.BroadcastMessagesAllowed = channel.BroadcastMessagesAllowed
|
||||
s.channels[channel.LinkedMonoforumID] = mono
|
||||
}
|
||||
}
|
||||
if changed && channel.LinkedMonoforumID != 0 {
|
||||
action := domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionPaidMessagesPrice,
|
||||
BroadcastMessagesAllowed: channel.BroadcastMessagesAllowed,
|
||||
Stars: stars,
|
||||
}
|
||||
parentMsg, parentEvent := s.appendChannelServiceMessageLocked(channelID, userID, int(time.Now().Unix()), action)
|
||||
channel.TopMessageID = parentMsg.ID
|
||||
channel.Pts = parentEvent.Pts
|
||||
out.ServiceMessages = append(out.ServiceMessages, domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(parentMsg), Event: cloneChannelEvent(parentEvent)})
|
||||
}
|
||||
if len(out.ServiceMessages) > 0 {
|
||||
out.ServiceMessage = &out.ServiceMessages[0]
|
||||
}
|
||||
s.channels[channelID] = channel
|
||||
out.Channel = cloneChannel(channel)
|
||||
return out, nil
|
||||
}
|
||||
84
internal/store/memory/channel_message_views.go
Normal file
84
internal/store/memory/channel_message_views.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) GetChannelMessageViews(_ context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
return domain.ChannelMessageViewsResult{Views: map[int]int{}, Replies: map[int]*domain.ChannelMessageReplies{}}, nil
|
||||
}
|
||||
if len(req.IDs) > domain.MaxGetMessageIDs {
|
||||
return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessageViewsResult{}, err
|
||||
}
|
||||
wanted := make(map[int]struct{}, len(req.IDs))
|
||||
for _, id := range req.IDs {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return domain.ChannelMessageViewsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
wanted[id] = struct{}{}
|
||||
}
|
||||
visible := make(map[int]struct{}, len(wanted))
|
||||
for _, msg := range s.messages[req.ChannelID] {
|
||||
if _, ok := wanted[msg.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
if msg.Deleted || msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
visible[msg.ID] = struct{}{}
|
||||
}
|
||||
if s.msgViews[req.ChannelID] == nil {
|
||||
s.msgViews[req.ChannelID] = make(map[int]int)
|
||||
}
|
||||
if s.msgViewers[req.ChannelID] == nil {
|
||||
s.msgViewers[req.ChannelID] = make(map[int]map[int64]struct{})
|
||||
}
|
||||
for id := range visible {
|
||||
if req.Increment {
|
||||
if s.msgViewers[req.ChannelID][id] == nil {
|
||||
s.msgViewers[req.ChannelID][id] = make(map[int64]struct{})
|
||||
}
|
||||
if _, seen := s.msgViewers[req.ChannelID][id][req.UserID]; !seen {
|
||||
s.msgViewers[req.ChannelID][id][req.UserID] = struct{}{}
|
||||
s.msgViews[req.ChannelID][id]++
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make(map[int]int, len(visible))
|
||||
replies := make(map[int]*domain.ChannelMessageReplies, len(visible))
|
||||
peerSeen := make(map[domain.Peer]struct{}, len(visible))
|
||||
peers := make([]domain.Peer, 0, len(visible))
|
||||
for id := range visible {
|
||||
out[id] = s.msgViews[req.ChannelID][id]
|
||||
}
|
||||
for _, msg := range s.messages[req.ChannelID] {
|
||||
if _, ok := visible[msg.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
peer := msg.From
|
||||
if peer.ID == 0 && msg.SenderUserID != 0 {
|
||||
peer = domain.Peer{Type: domain.PeerTypeUser, ID: msg.SenderUserID}
|
||||
}
|
||||
if peer.ID != 0 {
|
||||
if _, ok := peerSeen[peer]; !ok {
|
||||
peerSeen[peer] = struct{}{}
|
||||
peers = append(peers, peer)
|
||||
}
|
||||
}
|
||||
if reply := s.channelMessageRepliesLocked(req.UserID, req.ChannelID, msg); reply != nil {
|
||||
replies[msg.ID] = reply
|
||||
}
|
||||
}
|
||||
return domain.ChannelMessageViewsResult{Channel: channel, Views: out, Replies: replies, Peers: peers}, nil
|
||||
}
|
||||
179
internal/store/memory/channel_monoforum.go
Normal file
179
internal/store/memory/channel_monoforum.go
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。
|
||||
// 与 postgres 行为一致:复用 channel pts/事件;只校验 monoforum 存在,不要求发件人是成员。
|
||||
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) == "" {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, ok := s.channels[req.MonoforumID]
|
||||
if !ok || channel.Deleted || !channel.Monoforum {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
if req.RandomID != 0 {
|
||||
// 去重维度 = (sender, saved_peer, random_id),与 postgres 迁移 0022 的唯一索引一致;
|
||||
// 不复用账号级 randomToID(其按 channel+sender+random_id 三元组,会被跨子会话同 random_id 互相覆盖)。
|
||||
if dup, ok := s.findMonoforumDuplicateLocked(req.MonoforumID, req.SenderUserID, req.SavedPeer, req.RandomID); ok {
|
||||
event := s.eventForMessageLocked(req.MonoforumID, dup.ID)
|
||||
if event.Message.ID != 0 {
|
||||
dup = event.Message
|
||||
}
|
||||
return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(dup), Event: event, Duplicate: true}, nil
|
||||
}
|
||||
}
|
||||
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: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID},
|
||||
SavedPeer: req.SavedPeer,
|
||||
Date: req.Date,
|
||||
Body: req.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Pts: pts,
|
||||
}
|
||||
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)
|
||||
s.events[req.MonoforumID] = append(s.events[req.MonoforumID], event)
|
||||
channel.TopMessageID = msgID
|
||||
channel.Pts = pts
|
||||
s.channels[req.MonoforumID] = channel
|
||||
return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event)}, 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.Deleted && 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 成员),并返回调用者是否为其母广播频道的创建者/管理员。非 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.Status == domain.ChannelMemberActive &&
|
||||
(member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin)
|
||||
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
|
||||
}
|
||||
134
internal/store/memory/channel_monoforum_send_test.go
Normal file
134
internal/store/memory/channel_monoforum_send_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TestSendMonoforumMessageAndHistory 验证频道私信(monoforum)发送+按订阅者读历史:
|
||||
// 订阅者发、管理员回复同进一个 saved_peer 子会话;幂等;不同订阅者互不串会话。
|
||||
func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: 1, Title: "DM", Broadcast: true, Date: 1_700_001_000})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
enabled, err := store.SetPaidMessagesPrice(ctx, 1, broadcast.Channel.ID, 0, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enable DM: %v", err)
|
||||
}
|
||||
monoID := enabled.Channel.LinkedMonoforumID
|
||||
if monoID == 0 {
|
||||
t.Fatalf("no monoforum created")
|
||||
}
|
||||
|
||||
sub := domain.Peer{Type: domain.PeerTypeUser, ID: 42}
|
||||
|
||||
m1, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Date: 1_700_001_001})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber send 1: %v", err)
|
||||
}
|
||||
if m1.Message.SavedPeer != sub || m1.Message.ChannelID != monoID || m1.Message.Pts == 0 {
|
||||
t.Fatalf("m1 = %+v, want saved_peer sub + channel mono + pts>0", m1.Message)
|
||||
}
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 112, Message: "again", Date: 1_700_001_002}); err != nil {
|
||||
t.Fatalf("subscriber send 2: %v", err)
|
||||
}
|
||||
// 管理员回复:发件人是 creator,saved_peer 仍是该订阅者(同一子会话)。
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 113, Message: "reply", Date: 1_700_001_003}); err != nil {
|
||||
t.Fatalf("admin reply: %v", err)
|
||||
}
|
||||
|
||||
mainHist, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("main monoforum history: %v", err)
|
||||
}
|
||||
if mainHist.Count != 1 || len(mainHist.Messages) != 1 {
|
||||
t.Fatalf("main monoforum history count=%d len=%d, want only service message", mainHist.Count, len(mainHist.Messages))
|
||||
}
|
||||
// monoforum 的服务消息是创建消息(渲染 "Direct messages were enabled in this channel."),
|
||||
// paid_messages_price 只进母广播频道。
|
||||
if action := mainHist.Messages[0].Action; action == nil || action.Type != domain.ChannelActionCreate {
|
||||
t.Fatalf("main monoforum action = %+v, want channel_create", action)
|
||||
}
|
||||
if len(mainHist.Channels) != 1 || mainHist.Channels[0].ID != broadcast.Channel.ID {
|
||||
t.Fatalf("main monoforum extra channels = %+v, want parent %d", mainHist.Channels, broadcast.Channel.ID)
|
||||
}
|
||||
if _, err := store.ListChannelHistory(ctx, 42, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}); err == nil {
|
||||
t.Fatalf("subscriber main monoforum history = nil err, want denied")
|
||||
}
|
||||
|
||||
// 幂等:相同 randomID 返回原消息、不重复。
|
||||
dup, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Date: 1_700_001_004})
|
||||
if err != nil {
|
||||
t.Fatalf("dup send: %v", err)
|
||||
}
|
||||
if !dup.Duplicate || dup.Message.ID != m1.Message.ID {
|
||||
t.Fatalf("dup = %+v, want duplicate of m1 id %d", dup.Message, m1.Message.ID)
|
||||
}
|
||||
|
||||
hist, err := store.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: sub, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
if hist.Count != 3 || len(hist.Messages) != 3 {
|
||||
t.Fatalf("history count=%d len=%d, want 3", hist.Count, len(hist.Messages))
|
||||
}
|
||||
if hist.Messages[0].Body != "reply" {
|
||||
t.Fatalf("history[0] = %q, want newest 'reply'", hist.Messages[0].Body)
|
||||
}
|
||||
for _, m := range hist.Messages {
|
||||
if m.SavedPeer != sub {
|
||||
t.Fatalf("history msg saved_peer = %+v, want sub", m.SavedPeer)
|
||||
}
|
||||
}
|
||||
|
||||
// 另一个订阅者的私信不串会话。
|
||||
other := domain.Peer{Type: domain.PeerTypeUser, ID: 99}
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 99, SavedPeer: other, RandomID: 201, Message: "other", Date: 1_700_001_005}); err != nil {
|
||||
t.Fatalf("other subscriber send: %v", err)
|
||||
}
|
||||
subHist, _ := store.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: sub, Limit: 10})
|
||||
if subHist.Count != 3 {
|
||||
t.Fatalf("sub history after other subscriber = %d, want still 3 (no cross-talk)", subHist.Count)
|
||||
}
|
||||
|
||||
// 去重按订阅者子会话维度:同一发件人(此处管理员)用相同 random_id 向两个不同订阅者发,不得互相去重。
|
||||
a, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 9001, Message: "to sub", Date: 1_700_001_010})
|
||||
if err != nil {
|
||||
t.Fatalf("dedup send A: %v", err)
|
||||
}
|
||||
b, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: other, RandomID: 9001, Message: "to other", Date: 1_700_001_011})
|
||||
if err != nil {
|
||||
t.Fatalf("dedup send B: %v", err)
|
||||
}
|
||||
if b.Duplicate || b.Message.ID == a.Message.ID {
|
||||
t.Fatalf("cross-sublist same random_id wrongly deduped: a=%d b=%d dup=%v", a.Message.ID, b.Message.ID, b.Duplicate)
|
||||
}
|
||||
// 同一子会话真重发(相同 random_id)仍去重。
|
||||
again, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 9001, Message: "to sub", Date: 1_700_001_012})
|
||||
if err != nil {
|
||||
t.Fatalf("dedup resend A: %v", err)
|
||||
}
|
||||
if !again.Duplicate || again.Message.ID != a.Message.ID {
|
||||
t.Fatalf("same-sublist retry not deduped: again=%+v want dup of %d", again.Message, a.Message.ID)
|
||||
}
|
||||
|
||||
// 订阅者子会话列表:两个订阅者,按 top 消息 id 倒序(other 最后发,排首)。
|
||||
dialogs, err := store.ListMonoforumDialogs(ctx, domain.MonoforumDialogsFilter{MonoforumID: monoID, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list dialogs: %v", err)
|
||||
}
|
||||
if dialogs.Count != 2 || len(dialogs.Dialogs) != 2 {
|
||||
t.Fatalf("dialogs count=%d len=%d, want 2 subscribers", dialogs.Count, len(dialogs.Dialogs))
|
||||
}
|
||||
if dialogs.Dialogs[0].SavedPeer != other {
|
||||
t.Fatalf("dialogs[0] saved_peer = %+v, want newest 'other'", dialogs.Dialogs[0].SavedPeer)
|
||||
}
|
||||
if dialogs.Dialogs[1].SavedPeer != sub || dialogs.Dialogs[1].TopMessageID == 0 {
|
||||
t.Fatalf("dialogs[1] = %+v, want sub with top message", dialogs.Dialogs[1])
|
||||
}
|
||||
}
|
||||
205
internal/store/memory/channel_monoforum_test.go
Normal file
205
internal/store/memory/channel_monoforum_test.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TestSetPaidMessagesPriceCreatesAndReusesMonoforum 验证开启频道私信(Direct Messages)时
|
||||
// 为广播频道关联一个 monoforum 虚拟频道:首次建、再次复用并同步价格;超级群(非广播)不建。
|
||||
// 与 postgres 实现行为对齐。
|
||||
func TestSetPaidMessagesPriceCreatesAndReusesMonoforum(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1, Title: "DM Broadcast", Broadcast: true, Date: 1_700_000_900,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create broadcast: %v", err)
|
||||
}
|
||||
channelID := broadcast.Channel.ID
|
||||
|
||||
updatedResult, err := store.SetPaidMessagesPrice(ctx, 1, channelID, 5, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enable DM: %v", err)
|
||||
}
|
||||
updated := updatedResult.Channel
|
||||
if updated.LinkedMonoforumID == 0 || !updated.BroadcastMessagesAllowed || updated.SendPaidMessagesStars != 5 {
|
||||
t.Fatalf("after enable = %+v, want linked monoforum + broadcast allowed + 5 stars", updated)
|
||||
}
|
||||
monoID := updated.LinkedMonoforumID
|
||||
|
||||
mono, ok := store.channels[monoID]
|
||||
if !ok {
|
||||
t.Fatalf("monoforum channel %d not created", monoID)
|
||||
}
|
||||
if !mono.Monoforum || mono.Broadcast || !mono.Megagroup || mono.LinkedMonoforumID != channelID || mono.SendPaidMessagesStars != 5 || mono.CreatorUserID != 1 {
|
||||
t.Fatalf("monoforum channel = %+v, want megagroup monoforum + back-link %d + 5 stars + creator 1", mono, channelID)
|
||||
}
|
||||
if mono.TopMessageID == 0 || mono.Pts == 0 {
|
||||
t.Fatalf("monoforum top/pts = %d/%d, want service top message", mono.TopMessageID, mono.Pts)
|
||||
}
|
||||
dialogs, err := store.ListChannelDialogs(ctx, 1, domain.DialogFilter{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("list dialogs after enable: %v", err)
|
||||
}
|
||||
var foundMono bool
|
||||
for _, dialog := range dialogs.Dialogs {
|
||||
if dialog.Peer == (domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}) {
|
||||
foundMono = true
|
||||
if dialog.TopMessage != mono.TopMessageID || dialog.UnreadCount != 0 || dialog.ReadInboxMaxID < mono.TopMessageID {
|
||||
t.Fatalf("monoforum dialog = %+v, want top %d read/no-unread", dialog, mono.TopMessageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundMono {
|
||||
t.Fatalf("dialogs after enable do not include admin-visible monoforum %d: %+v", monoID, dialogs.Dialogs)
|
||||
}
|
||||
msgs := store.messages[monoID]
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("monoforum messages = %d, want exactly one creation service message", len(msgs))
|
||||
}
|
||||
// monoforum 的服务消息是创建消息(渲染 "Direct messages were enabled in this channel."),
|
||||
// 不是 paid_messages_price(后者只进母频道,在 megagroup 里会渲染成错误的"消息免费"文案)。
|
||||
if action := msgs[0].Action; action == nil || action.Type != domain.ChannelActionCreate {
|
||||
t.Fatalf("monoforum service action = %+v, want channel_create", action)
|
||||
}
|
||||
parentMsgs := store.messages[channelID]
|
||||
if len(parentMsgs) != 2 {
|
||||
t.Fatalf("parent channel messages = %d, want create + paid_messages_price service", len(parentMsgs))
|
||||
}
|
||||
if action := parentMsgs[1].Action; action == nil || action.Type != domain.ChannelActionPaidMessagesPrice || !action.BroadcastMessagesAllowed || action.Stars != 5 {
|
||||
t.Fatalf("parent service action = %+v, want paid_messages_price allowed stars=5", action)
|
||||
}
|
||||
mono.TopMessageID = 0
|
||||
mono.Pts = 0
|
||||
store.channels[monoID] = mono
|
||||
store.messages[monoID] = nil
|
||||
repairedResult, err := store.SetPaidMessagesPrice(ctx, 1, channelID, 5, true)
|
||||
if err != nil {
|
||||
t.Fatalf("repair legacy DM: %v", err)
|
||||
}
|
||||
repaired := repairedResult.Channel
|
||||
if repaired.LinkedMonoforumID != monoID {
|
||||
t.Fatalf("repair linked = %d, want stable %d", repaired.LinkedMonoforumID, monoID)
|
||||
}
|
||||
msgs = store.messages[monoID]
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("monoforum messages after legacy repair = %d, want one creation service message", len(msgs))
|
||||
}
|
||||
if action := msgs[0].Action; action == nil || action.Type != domain.ChannelActionCreate {
|
||||
t.Fatalf("monoforum repaired service action = %+v, want channel_create", action)
|
||||
}
|
||||
|
||||
// 幂等:再次开启不新建,仅同步价格。
|
||||
againResult, err := store.SetPaidMessagesPrice(ctx, 1, channelID, 8, true)
|
||||
if err != nil {
|
||||
t.Fatalf("re-enable: %v", err)
|
||||
}
|
||||
again := againResult.Channel
|
||||
if again.LinkedMonoforumID != monoID {
|
||||
t.Fatalf("re-enable linked = %d, want stable %d (no second monoforum)", again.LinkedMonoforumID, monoID)
|
||||
}
|
||||
if got := store.channels[monoID].SendPaidMessagesStars; got != 8 {
|
||||
t.Fatalf("monoforum price = %d, want synced 8", got)
|
||||
}
|
||||
// 价格变更只进母广播频道;monoforum 仍只有创建消息那一条。
|
||||
if msgs = store.messages[monoID]; len(msgs) != 1 {
|
||||
t.Fatalf("monoforum messages after price update = %d, want still one (price change goes to parent only)", len(msgs))
|
||||
}
|
||||
if pm := store.messages[channelID]; len(pm) == 0 || pm[len(pm)-1].Action == nil || pm[len(pm)-1].Action.Type != domain.ChannelActionPaidMessagesPrice || !pm[len(pm)-1].Action.BroadcastMessagesAllowed || pm[len(pm)-1].Action.Stars != 8 {
|
||||
t.Fatalf("parent latest action = %+v, want paid_messages_price allowed stars=8", store.messages[channelID])
|
||||
}
|
||||
disabledResult, err := store.SetPaidMessagesPrice(ctx, 1, channelID, 0, false)
|
||||
if err != nil {
|
||||
t.Fatalf("disable DM: %v", err)
|
||||
}
|
||||
disabled := disabledResult.Channel
|
||||
if disabled.LinkedMonoforumID != monoID || disabled.BroadcastMessagesAllowed {
|
||||
t.Fatalf("disable linked/allowed = %d/%v, want stable %d and disabled", disabled.LinkedMonoforumID, disabled.BroadcastMessagesAllowed, monoID)
|
||||
}
|
||||
if disabledResult.ServiceMessage == nil || disabledResult.ServiceMessage.Event.Pts == 0 {
|
||||
t.Fatalf("disable service result = %+v, want paid_messages_price event", disabledResult.ServiceMessage)
|
||||
}
|
||||
// 关闭只进母广播频道(+monoforumDisabled 状态显示停用页脚);monoforum 仍只有创建消息那一条。
|
||||
if msgs = store.messages[monoID]; len(msgs) != 1 {
|
||||
t.Fatalf("monoforum messages after disable = %d, want still one (disable goes to parent only)", len(msgs))
|
||||
}
|
||||
if pm := store.messages[channelID]; len(pm) == 0 || pm[len(pm)-1].Action == nil || pm[len(pm)-1].Action.Type != domain.ChannelActionPaidMessagesPrice || pm[len(pm)-1].Action.BroadcastMessagesAllowed || pm[len(pm)-1].Action.Stars != 0 {
|
||||
t.Fatalf("parent latest action = %+v, want paid_messages_price disabled stars=0", store.messages[channelID])
|
||||
}
|
||||
|
||||
// 超级群(非广播)即便 broadcastMessagesAllowed=true 也不建 monoforum:私信是广播频道专属。
|
||||
group, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1, Title: "SG", Megagroup: true, Date: 1_700_000_901,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create group: %v", err)
|
||||
}
|
||||
sgResult, err := store.SetPaidMessagesPrice(ctx, 1, group.Channel.ID, 3, true)
|
||||
if err != nil {
|
||||
t.Fatalf("group paid: %v", err)
|
||||
}
|
||||
sg := sgResult.Channel
|
||||
if sg.LinkedMonoforumID != 0 || sg.BroadcastMessagesAllowed {
|
||||
t.Fatalf("megagroup = linked %d broadcastAllowed %v, want no monoforum", sg.LinkedMonoforumID, sg.BroadcastMessagesAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetChannelDialogsCoDeliversMonoforumParent 锁定 Direct-Messages 渲染的"同批下发"不变量:
|
||||
// 管理员按 id 拉取 monoforum 私信会话时,响应必须同时带母广播频道(作为 chats[] 附带,而非额外
|
||||
// dialog),客户端才能 resolve linked_monoforum_id 并派生 MonoforumAdmin;缺父对象会让 mono 退化为
|
||||
// 普通 megagroup。非管理员既拿不到 monoforum 也不会被泄漏母频道。
|
||||
func TestGetChannelDialogsCoDeliversMonoforumParent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: 1, Title: "DM Broadcast", Broadcast: true, Date: 1_700_000_900})
|
||||
if err != nil {
|
||||
t.Fatalf("create broadcast: %v", err)
|
||||
}
|
||||
parentID := broadcast.Channel.ID
|
||||
enabled, err := store.SetPaidMessagesPrice(ctx, 1, parentID, 0, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enable DM: %v", err)
|
||||
}
|
||||
monoID := enabled.Channel.LinkedMonoforumID
|
||||
if monoID == 0 {
|
||||
t.Fatalf("no monoforum created")
|
||||
}
|
||||
|
||||
list, err := store.GetChannelDialogs(ctx, 1, []int64{monoID})
|
||||
if err != nil {
|
||||
t.Fatalf("get channel dialogs: %v", err)
|
||||
}
|
||||
var hasMono, hasParent bool
|
||||
for _, ch := range list.Channels {
|
||||
switch ch.ID {
|
||||
case monoID:
|
||||
hasMono = true
|
||||
case parentID:
|
||||
hasParent = true
|
||||
}
|
||||
}
|
||||
if !hasMono {
|
||||
t.Fatalf("GetChannelDialogs([mono]) channels missing mono %d: %+v", monoID, list.Channels)
|
||||
}
|
||||
if !hasParent {
|
||||
t.Fatalf("GetChannelDialogs([mono]) did NOT co-deliver parent broadcast %d (TDesktop can't derive MonoforumAdmin): %+v", parentID, list.Channels)
|
||||
}
|
||||
if len(list.Dialogs) != 1 || list.Dialogs[0].Peer.ID != monoID {
|
||||
t.Fatalf("dialogs = %+v, want exactly the mono dialog (parent only as chats[])", list.Dialogs)
|
||||
}
|
||||
|
||||
// 非管理员(非母频道成员)拿不到 monoforum,也不会被泄漏母频道。
|
||||
deniedList, err := store.GetChannelDialogs(ctx, 999, []int64{monoID})
|
||||
if err != nil {
|
||||
t.Fatalf("get channel dialogs (non-admin): %v", err)
|
||||
}
|
||||
for _, ch := range deniedList.Channels {
|
||||
if ch.ID == monoID || ch.ID == parentID {
|
||||
t.Fatalf("non-admin leaked channel %d: %+v", ch.ID, deniedList.Channels)
|
||||
}
|
||||
}
|
||||
}
|
||||
139
internal/store/memory/channel_poll_fanout_test.go
Normal file
139
internal/store/memory/channel_poll_fanout_test.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TestChannelPollFanoutViewsByteEquivalentToGetMessages 锁定 Phase 4 poll 模板化的安全网:批量
|
||||
// ChannelPollFanoutViews 对每个 viewer 产出的 enrich poll,必须与逐 viewer GetChannelMessages(旧
|
||||
// fan-out 路径)字节等价;且可见性判定一致(不可见 viewer 在批量里为 nil、在旧路径里取不到消息)。
|
||||
// 覆盖:作者(创建者)、已投票非作者、未投票非作者、hide_results 期非创建者、late-joiner(AvailableMinID)、
|
||||
// 被封成员、ViewMessages 受限成员、非成员。
|
||||
func TestChannelPollFanoutViewsByteEquivalentToGetMessages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
channelID = int64(8000000001)
|
||||
msgID = 50
|
||||
pollID = int64(9000000001)
|
||||
creator = int64(7001)
|
||||
votedA = int64(7002)
|
||||
votedB = int64(7003)
|
||||
unvoted = int64(7004)
|
||||
late = int64(7005) // AvailableMinID >= msgID(pre-history 隐藏)
|
||||
banned = int64(7006)
|
||||
restricted = int64(7007) // active 但 BannedRights.ViewMessages
|
||||
nonMember = int64(7008)
|
||||
)
|
||||
opt1, opt2, opt3 := []byte{1}, []byte{2}, []byte{3}
|
||||
|
||||
for _, hideResults := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "visible_counts", true: "hide_results"}[hideResults], func(t *testing.T) {
|
||||
channels := NewChannelStore()
|
||||
polls := NewPollStore()
|
||||
channels.AttachPollStore(polls)
|
||||
|
||||
channels.channels[channelID] = domain.Channel{ID: channelID, CreatorUserID: creator, Megagroup: true, PreHistoryHidden: true}
|
||||
channels.members[channelID] = map[int64]domain.ChannelMember{
|
||||
creator: {UserID: creator, Status: domain.ChannelMemberActive, Role: domain.ChannelRoleCreator},
|
||||
votedA: {UserID: votedA, Status: domain.ChannelMemberActive},
|
||||
votedB: {UserID: votedB, Status: domain.ChannelMemberActive},
|
||||
unvoted: {UserID: unvoted, Status: domain.ChannelMemberActive},
|
||||
late: {UserID: late, Status: domain.ChannelMemberActive, AvailableMinID: msgID + 10},
|
||||
banned: {UserID: banned, Status: domain.ChannelMemberBanned},
|
||||
restricted: {UserID: restricted, Status: domain.ChannelMemberActive, BannedRights: domain.ChannelBannedRights{ViewMessages: true}},
|
||||
}
|
||||
|
||||
def := domain.PollDefinition{
|
||||
ID: pollID,
|
||||
CreatorUserID: creator,
|
||||
Options: [][]byte{opt1, opt2, opt3},
|
||||
PublicVoters: true,
|
||||
MultipleChoice: false,
|
||||
HideResultsUntilClose: hideResults,
|
||||
}
|
||||
if err := polls.CreatePoll(ctx, def); err != nil {
|
||||
t.Fatalf("create poll: %v", err)
|
||||
}
|
||||
basePoll := &domain.MessagePoll{
|
||||
ID: pollID,
|
||||
Question: "Q?",
|
||||
Answers: []domain.MessagePollAnswer{{Text: "A", Option: opt1}, {Text: "B", Option: opt2}, {Text: "C", Option: opt3}},
|
||||
PublicVoters: true,
|
||||
HideResultsUntilClose: hideResults,
|
||||
}
|
||||
channels.messages[channelID] = []domain.ChannelMessage{{
|
||||
ChannelID: channelID,
|
||||
ID: msgID,
|
||||
SenderUserID: creator,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: basePoll},
|
||||
}}
|
||||
if err := polls.Vote(pollID, votedA, [][]byte{opt1}, 100); err != nil {
|
||||
t.Fatalf("vote A: %v", err)
|
||||
}
|
||||
if err := polls.Vote(pollID, votedB, [][]byte{opt2}, 101); err != nil {
|
||||
t.Fatalf("vote B: %v", err)
|
||||
}
|
||||
|
||||
viewers := []int64{creator, votedA, votedB, unvoted, late, banned, restricted, nonMember}
|
||||
views, err := channels.ChannelPollFanoutViews(ctx, channelID, msgID, viewers, 200)
|
||||
if err != nil {
|
||||
t.Fatalf("ChannelPollFanoutViews: %v", err)
|
||||
}
|
||||
if !views.Found {
|
||||
t.Fatal("poll fan-out views Found=false, want true")
|
||||
}
|
||||
|
||||
for _, viewer := range viewers {
|
||||
got, evaluated := views.Polls[viewer]
|
||||
if !evaluated {
|
||||
t.Fatalf("viewer %d not evaluated in batch (all passed viewers must be evaluated)", viewer)
|
||||
}
|
||||
// 旧路径参考:GetChannelMessages 按 viewer 取消息(含可见性 + per-viewer poll enrich)。
|
||||
ref, err := channels.GetChannelMessages(ctx, viewer, channelID, []int{msgID})
|
||||
var refPoll *domain.MessagePoll
|
||||
if err == nil {
|
||||
for _, m := range ref.Messages {
|
||||
if m.ID == msgID && m.Media != nil {
|
||||
refPoll = m.Media.Poll
|
||||
}
|
||||
}
|
||||
}
|
||||
if (got != nil) != (refPoll != nil) {
|
||||
t.Fatalf("viewer %d visibility mismatch: batch nil=%v, GetMessages nil=%v (err=%v)", viewer, got == nil, refPoll == nil, err)
|
||||
}
|
||||
if got != nil && !reflect.DeepEqual(got.Results, refPoll.Results) {
|
||||
t.Fatalf("viewer %d poll Results mismatch:\n batch=%+v\n old=%+v", viewer, got.Results, refPoll.Results)
|
||||
}
|
||||
}
|
||||
|
||||
// 显式断言可见性分类,防 GetMessages 与批量同时漏判。
|
||||
if views.Polls[creator] == nil || views.Polls[votedA] == nil || views.Polls[votedB] == nil || views.Polls[unvoted] == nil {
|
||||
t.Fatalf("active members should be visible: creator/votedA/votedB/unvoted")
|
||||
}
|
||||
if views.Polls[late] != nil || views.Polls[banned] != nil || views.Polls[restricted] != nil || views.Polls[nonMember] != nil {
|
||||
t.Fatalf("late/banned/restricted/nonMember must be invisible (nil)")
|
||||
}
|
||||
// hide_results 期:非创建者已投票者计数应被隐藏(Voters=0),创建者可见真实计数。
|
||||
if hideResults {
|
||||
for _, v := range views.Polls[votedA].Results.Voters {
|
||||
if v.Voters != 0 {
|
||||
t.Fatalf("hide_results: non-creator votedA should see Voters=0, got %d", v.Voters)
|
||||
}
|
||||
}
|
||||
sawCount := false
|
||||
for _, v := range views.Polls[creator].Results.Voters {
|
||||
if v.Voters > 0 {
|
||||
sawCount = true
|
||||
}
|
||||
}
|
||||
if !sawCount {
|
||||
t.Fatal("hide_results: creator should see real counts")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
116
internal/store/memory/channel_polls.go
Normal file
116
internal/store/memory/channel_polls.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// 频道/超级群消息 poll 投票与关闭:成员资格与消息可见性沿用 reaction 同款校验,
|
||||
// poll 级语义委托共享 PollStore。
|
||||
|
||||
func (s *ChannelStore) VoteChannelMessagePoll(_ context.Context, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) {
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
channel, msg, err := s.pollChannelMessageTarget(req.UserID, req.ChannelID, req.MessageID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessagePollResult{}, err
|
||||
}
|
||||
if err := s.polls.Vote(msg.Media.Poll.ID, req.UserID, req.Options, req.Date); err != nil {
|
||||
return domain.ChannelMessagePollResult{}, err
|
||||
}
|
||||
return s.channelPollResult(channel, msg, req.UserID, req.Date), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) CloseChannelMessagePoll(_ context.Context, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) {
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
channel, msg, err := s.pollChannelMessageTarget(req.UserID, req.ChannelID, req.MessageID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessagePollResult{}, err
|
||||
}
|
||||
if err := s.polls.Close(msg.Media.Poll.ID, req.UserID); err != nil {
|
||||
return domain.ChannelMessagePollResult{}, err
|
||||
}
|
||||
return s.channelPollResult(channel, msg, req.UserID, req.Date), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) pollChannelMessageTarget(userID, channelID int64, messageID int) (domain.Channel, domain.ChannelMessage, error) {
|
||||
if s == nil || s.polls == nil {
|
||||
return domain.Channel{}, domain.ChannelMessage{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if userID == 0 || channelID == 0 || messageID <= 0 || messageID > domain.MaxMessageBoxID {
|
||||
return domain.Channel{}, domain.ChannelMessage{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, err := s.channelAndMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, domain.ChannelMessage{}, err
|
||||
}
|
||||
msg, ok := s.findMessageLocked(channelID, messageID)
|
||||
if !ok || msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID {
|
||||
return domain.Channel{}, domain.ChannelMessage{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindPoll || msg.Media.Poll == nil || msg.Media.Poll.ID == 0 {
|
||||
return domain.Channel{}, domain.ChannelMessage{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
return cloneChannel(channel), cloneChannelMessage(msg), nil
|
||||
}
|
||||
|
||||
// ChannelPollFanoutViews 批量加载一条 poll 消息对一组 viewer 的 per-viewer enrich(与 postgres 同口径,
|
||||
// 消除 fan-out 逐 viewer 重载 N+1):成员/AvailableMinID 可见性复刻 channelAndMemberLocked +
|
||||
// pollChannelMessageTarget;poll enrich 委托 PollStore.EnrichPollForViewers(模板一次)。bot 历史过滤
|
||||
// 在 app 层叠加。Polls:key 存在=已评估;nil=不可见;非 nil=可见 enrich poll。
|
||||
func (s *ChannelStore) ChannelPollFanoutViews(_ context.Context, channelID int64, msgID int, viewers []int64, now int) (domain.ChannelPollFanoutViews, error) {
|
||||
out := domain.ChannelPollFanoutViews{Polls: map[int64]*domain.MessagePoll{}}
|
||||
if s == nil || s.polls == nil || channelID == 0 || msgID <= 0 || len(viewers) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if now == 0 {
|
||||
now = int(time.Now().Unix())
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted {
|
||||
return out, nil
|
||||
}
|
||||
msg, ok := s.findMessageLocked(channelID, msgID)
|
||||
if !ok || msg.Deleted || msg.Action != nil || msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindPoll || msg.Media.Poll == nil || msg.Media.Poll.ID == 0 {
|
||||
return out, nil
|
||||
}
|
||||
out.Found = true
|
||||
out.Message = cloneChannelMessage(msg)
|
||||
visible := make([]int64, 0, len(viewers))
|
||||
for _, viewer := range viewers {
|
||||
if viewer == 0 {
|
||||
continue
|
||||
}
|
||||
member, ok := s.members[channelID][viewer]
|
||||
if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages || (member.AvailableMinID > 0 && msgID <= member.AvailableMinID) {
|
||||
out.Polls[viewer] = nil // 已评估但不可见
|
||||
continue
|
||||
}
|
||||
visible = append(visible, viewer)
|
||||
}
|
||||
enriched := s.polls.EnrichPollForViewers(msg.Media.Poll, visible, now)
|
||||
for viewer, poll := range enriched {
|
||||
out.Polls[viewer] = poll
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// channelPollResult 为投票者视角组装结果;实时 fan-out 与 reaction 同款由 rpc 层按 viewer 重建。
|
||||
func (s *ChannelStore) channelPollResult(channel domain.Channel, msg domain.ChannelMessage, viewerUserID int64, now int) domain.ChannelMessagePollResult {
|
||||
msg.Media = enrichPollMediaForViewer(s.polls, msg.Media, viewerUserID, now)
|
||||
return domain.ChannelMessagePollResult{
|
||||
PollID: msg.Media.Poll.ID,
|
||||
Channel: channel,
|
||||
Message: msg,
|
||||
Recipients: []int64{viewerUserID, msg.SenderUserID},
|
||||
}
|
||||
}
|
||||
1030
internal/store/memory/channel_reactions.go
Normal file
1030
internal/store/memory/channel_reactions.go
Normal file
File diff suppressed because it is too large
Load diff
573
internal/store/memory/channel_read.go
Normal file
573
internal/store/memory/channel_read.go
Normal file
|
|
@ -0,0 +1,573 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) SetChannelDialogUnreadMark(_ context.Context, userID, channelID int64, unread bool) (bool, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
dialog := s.dialogForUserLocked(userID, channel)
|
||||
changed := dialog.UnreadMark != unread
|
||||
dialog.UnreadMark = unread
|
||||
member := s.members[channelID][userID]
|
||||
member.UnreadMark = unread
|
||||
s.members[channelID][userID] = member
|
||||
if s.dialogs[userID] == nil {
|
||||
s.dialogs[userID] = make(map[int64]domain.ChannelDialog)
|
||||
}
|
||||
s.dialogs[userID][channelID] = dialog
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListChannelUnreadMarked(_ context.Context, userID int64) ([]domain.Peer, error) {
|
||||
if userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]domain.Peer, 0, len(s.dialogs[userID]))
|
||||
for channelID, dialog := range s.dialogs[userID] {
|
||||
if !dialog.UnreadMark {
|
||||
continue
|
||||
}
|
||||
if _, err := s.channelForMemberLocked(userID, channelID); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ReadChannelMessageContents(_ context.Context, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ReadChannelMessageContentsResult{}, err
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
return domain.ReadChannelMessageContentsResult{Channel: channel}, nil
|
||||
}
|
||||
if len(req.IDs) > domain.MaxGetMessageIDs {
|
||||
return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
wanted := make(map[int]struct{}, len(req.IDs))
|
||||
for _, id := range req.IDs {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return domain.ReadChannelMessageContentsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
wanted[id] = struct{}{}
|
||||
}
|
||||
messages := make([]domain.ChannelMessage, 0, len(wanted))
|
||||
for _, msg := range s.messages[req.ChannelID] {
|
||||
if _, ok := wanted[msg.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
if msg.Deleted || msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, cloneChannelMessage(msg))
|
||||
}
|
||||
sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID })
|
||||
clearedSet := make(map[int]struct{})
|
||||
for _, msg := range messages {
|
||||
byUser := s.reactions[req.ChannelID][msg.ID]
|
||||
if len(byUser) == 0 {
|
||||
continue
|
||||
}
|
||||
for reactedUserID, rows := range byUser {
|
||||
changed := false
|
||||
for i := range rows {
|
||||
if rows[i].SenderUserID == req.UserID && rows[i].UserID != req.UserID && rows[i].Unread {
|
||||
rows[i].Unread = false
|
||||
changed = true
|
||||
clearedSet[msg.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
byUser[reactedUserID] = rows
|
||||
}
|
||||
}
|
||||
}
|
||||
cleared := make([]int, 0, len(clearedSet))
|
||||
for id := range clearedSet {
|
||||
cleared = append(cleared, id)
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(cleared)))
|
||||
if len(cleared) > 0 {
|
||||
s.refreshChannelUnreadReactionsDialogLocked(req.UserID, req.ChannelID)
|
||||
}
|
||||
// 视口内容已读同步翻转 mention 未读:客户端本地已减计数,服务端
|
||||
// 不落库角标会在下一次 getDialogs 复活。
|
||||
clearedMentions := make([]int, 0, len(messages))
|
||||
mentionFlipped := false
|
||||
for _, msg := range messages {
|
||||
mention, ok := s.mentions[req.UserID][req.ChannelID][msg.ID]
|
||||
if !ok || !mention.unread {
|
||||
continue
|
||||
}
|
||||
mention.unread = false
|
||||
s.mentions[req.UserID][req.ChannelID][msg.ID] = mention
|
||||
clearedMentions = append(clearedMentions, msg.ID)
|
||||
mentionFlipped = true
|
||||
}
|
||||
sort.Ints(clearedMentions)
|
||||
if mentionFlipped {
|
||||
if dialogs := s.dialogs[req.UserID]; dialogs != nil {
|
||||
dialog := dialogs[req.ChannelID]
|
||||
dialog.UserID = req.UserID
|
||||
dialog.ChannelID = req.ChannelID
|
||||
dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(req.UserID, req.ChannelID, 0)
|
||||
dialogs[req.ChannelID] = dialog
|
||||
}
|
||||
}
|
||||
s.populateChannelMessageRepliesLocked(req.UserID, req.ChannelID, messages)
|
||||
s.populateChannelMessageReactionsLocked(req.UserID, channel, messages)
|
||||
return domain.ReadChannelMessageContentsResult{
|
||||
Channel: channel,
|
||||
Messages: messages,
|
||||
ClearedUnreadReactionMessageIDs: cleared,
|
||||
ClearedUnreadMentionMessageIDs: clearedMentions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListChannelUnreadMentions(_ context.Context, viewerUserID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > domain.MaxChannelUnreadMentionsLimit {
|
||||
limit = domain.MaxChannelUnreadMentionsLimit
|
||||
}
|
||||
filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset)
|
||||
base := make([]domain.ChannelMessage, 0, limit)
|
||||
for msgID, mention := range s.mentions[viewerUserID][filter.ChannelID] {
|
||||
if !mention.unread {
|
||||
continue
|
||||
}
|
||||
if filter.TopMsgID > 0 && mention.topID != filter.TopMsgID && !(filter.TopMsgID == 1 && mention.topID == 0) {
|
||||
continue
|
||||
}
|
||||
msg, ok := s.findMessageLocked(filter.ChannelID, msgID)
|
||||
if !ok || msg.Deleted || msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
if filter.MaxID > 0 && msg.ID >= filter.MaxID {
|
||||
continue
|
||||
}
|
||||
if filter.MinID > 0 && msg.ID <= filter.MinID {
|
||||
continue
|
||||
}
|
||||
base = append(base, msg)
|
||||
}
|
||||
sort.SliceStable(base, func(i, j int) bool { return channelMessageLess(base[i], base[j]) })
|
||||
page := pageChannelMessageHistory(base, domain.ChannelRepliesFilter{
|
||||
OffsetID: filter.OffsetID,
|
||||
OffsetDate: filter.OffsetDate,
|
||||
AddOffset: filter.AddOffset,
|
||||
Limit: limit,
|
||||
MaxID: filter.MaxID,
|
||||
MinID: filter.MinID,
|
||||
}, limit)
|
||||
out := make([]domain.ChannelMessage, 0, len(page))
|
||||
for _, msg := range page {
|
||||
out = append(out, cloneChannelMessage(msg))
|
||||
}
|
||||
s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out)
|
||||
s.populateChannelMessageReactionsLocked(viewerUserID, channel, out)
|
||||
return domain.ChannelHistory{Channel: channel, Messages: out, Count: len(base)}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ReadChannelMentions(_ context.Context, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ReadChannelMentionsResult{}, err
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 || limit > domain.MaxChannelReadMentionsBatch {
|
||||
limit = domain.MaxChannelReadMentionsBatch
|
||||
}
|
||||
msgIDs := make([]int, 0, limit)
|
||||
for msgID, mention := range s.mentions[req.UserID][req.ChannelID] {
|
||||
if !mention.unread {
|
||||
continue
|
||||
}
|
||||
if req.TopMsgID > 0 && mention.topID != req.TopMsgID && !(req.TopMsgID == 1 && mention.topID == 0) {
|
||||
continue
|
||||
}
|
||||
msgIDs = append(msgIDs, msgID)
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(msgIDs)))
|
||||
if len(msgIDs) > limit {
|
||||
msgIDs = msgIDs[:limit]
|
||||
}
|
||||
for _, msgID := range msgIDs {
|
||||
mention := s.mentions[req.UserID][req.ChannelID][msgID]
|
||||
mention.unread = false
|
||||
s.mentions[req.UserID][req.ChannelID][msgID] = mention
|
||||
}
|
||||
remaining := s.countChannelUnreadMentionsLocked(req.UserID, req.ChannelID, req.TopMsgID)
|
||||
if dialogs := s.dialogs[req.UserID]; dialogs != nil {
|
||||
dialog := dialogs[req.ChannelID]
|
||||
dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(req.UserID, req.ChannelID, 0)
|
||||
dialog.UserID = req.UserID
|
||||
dialog.ChannelID = req.ChannelID
|
||||
dialogs[req.ChannelID] = dialog
|
||||
}
|
||||
offset := 0
|
||||
if remaining > 0 {
|
||||
offset = 1
|
||||
}
|
||||
return domain.ReadChannelMentionsResult{
|
||||
Channel: channel,
|
||||
Cleared: len(msgIDs),
|
||||
Remaining: remaining,
|
||||
Offset: offset,
|
||||
ChannelPts: channel.Pts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ReadChannelHistory(_ context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ReadChannelHistoryResult{}, err
|
||||
}
|
||||
maxID := req.MaxID
|
||||
if maxID <= 0 || maxID > channel.TopMessageID {
|
||||
maxID = channel.TopMessageID
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
previous := member.ReadInboxMaxID
|
||||
changed := maxID > member.ReadInboxMaxID
|
||||
var outboxUpdates []domain.ChannelReadOutboxUpdate
|
||||
if changed {
|
||||
member.ReadInboxMaxID = maxID
|
||||
member.ReadInboxDate = req.Date
|
||||
member.UnreadMark = false
|
||||
s.members[req.ChannelID][req.UserID] = member
|
||||
s.readMarks[req.ChannelID] = s.readMarks[req.ChannelID].advance(req.UserID, maxID)
|
||||
outboxUpdates = s.advanceChannelReadOutboxLocked(req.ChannelID, req.UserID, previous, maxID)
|
||||
}
|
||||
dialog := s.dialogForUserLocked(req.UserID, channel)
|
||||
dialog.ReadInboxMaxID = member.ReadInboxMaxID
|
||||
dialog.UnreadCount = s.channelUnreadCountLocked(req.UserID, channel.ID, member.ReadInboxMaxID, dialog.TopMessageID)
|
||||
dialog.UnreadMark = false
|
||||
if s.dialogs[req.UserID] == nil {
|
||||
s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog)
|
||||
}
|
||||
s.dialogs[req.UserID][req.ChannelID] = dialog
|
||||
return domain.ReadChannelHistoryResult{
|
||||
ChannelID: req.ChannelID,
|
||||
MaxID: maxID,
|
||||
StillUnreadCount: dialog.UnreadCount,
|
||||
Changed: changed,
|
||||
Pts: channel.Pts,
|
||||
Forum: channel.Forum,
|
||||
Dialog: dialog,
|
||||
OutboxUpdates: outboxUpdates,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) advanceChannelReadOutboxLocked(channelID, readerUserID int64, previous, maxID int) []domain.ChannelReadOutboxUpdate {
|
||||
if maxID <= previous {
|
||||
return nil
|
||||
}
|
||||
lowerID := previous
|
||||
if maxID-lowerID > domain.MaxChannelReadOutboxScanMessages {
|
||||
lowerID = maxID - domain.MaxChannelReadOutboxScanMessages
|
||||
}
|
||||
bySender := make(map[int64]int, domain.MaxChannelReadOutboxFanout)
|
||||
messages := s.messages[channelID]
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
if msg.ID <= lowerID {
|
||||
break
|
||||
}
|
||||
if msg.ID > maxID || msg.Deleted || msg.SenderUserID == 0 || msg.SenderUserID == readerUserID {
|
||||
continue
|
||||
}
|
||||
if _, ok := bySender[msg.SenderUserID]; ok {
|
||||
continue
|
||||
}
|
||||
bySender[msg.SenderUserID] = msg.ID
|
||||
if len(bySender) >= domain.MaxChannelReadOutboxFanout {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(bySender) == 0 {
|
||||
return nil
|
||||
}
|
||||
senderIDs := make([]int64, 0, len(bySender))
|
||||
for userID := range bySender {
|
||||
senderIDs = append(senderIDs, userID)
|
||||
}
|
||||
sort.Slice(senderIDs, func(i, j int) bool { return senderIDs[i] < senderIDs[j] })
|
||||
channel := s.channels[channelID]
|
||||
out := make([]domain.ChannelReadOutboxUpdate, 0, len(senderIDs))
|
||||
for _, userID := range senderIDs {
|
||||
maxForSender := bySender[userID]
|
||||
member, ok := s.members[channelID][userID]
|
||||
if !ok || member.Status != domain.ChannelMemberActive || maxForSender <= member.ReadOutboxMaxID {
|
||||
continue
|
||||
}
|
||||
member.ReadOutboxMaxID = maxForSender
|
||||
s.members[channelID][userID] = member
|
||||
dialog := s.dialogForUserLocked(userID, channel)
|
||||
if dialog.ReadOutboxMaxID < maxForSender {
|
||||
dialog.ReadOutboxMaxID = maxForSender
|
||||
}
|
||||
if s.dialogs[userID] == nil {
|
||||
s.dialogs[userID] = make(map[int64]domain.ChannelDialog)
|
||||
}
|
||||
s.dialogs[userID][channelID] = dialog
|
||||
out = append(out, domain.ChannelReadOutboxUpdate{UserID: userID, MaxID: maxForSender})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListMessageReadParticipants(_ context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelReadParticipantsResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
msg, found := s.findMessageLocked(req.ChannelID, req.MessageID)
|
||||
if !found || msg.Deleted || msg.ID <= member.AvailableMinID {
|
||||
return domain.ChannelReadParticipantsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
result := domain.ChannelReadParticipantsResult{
|
||||
Channel: channel,
|
||||
Message: cloneChannelMessage(msg),
|
||||
}
|
||||
if !channel.Megagroup || channel.ParticipantsHidden || channel.ParticipantsCount > domain.MaxChannelReadParticipants {
|
||||
return result, nil
|
||||
}
|
||||
now := req.Date
|
||||
if now > 0 && msg.Date+domain.ChannelReadMarkExpirePeriod <= now {
|
||||
return result, nil
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 || limit > domain.MaxChannelReadParticipants {
|
||||
limit = domain.MaxChannelReadParticipants
|
||||
}
|
||||
for _, reader := range s.members[req.ChannelID] {
|
||||
if reader.UserID == req.UserID || reader.Status != domain.ChannelMemberActive || reader.BannedRights.ViewMessages {
|
||||
continue
|
||||
}
|
||||
if reader.ReadInboxDate <= 0 {
|
||||
continue
|
||||
}
|
||||
if reader.AvailableMinID >= req.MessageID || reader.ReadInboxMaxID < req.MessageID {
|
||||
continue
|
||||
}
|
||||
result.Participants = append(result.Participants, domain.ChannelReadParticipant{
|
||||
UserID: reader.UserID,
|
||||
Date: reader.ReadInboxDate,
|
||||
})
|
||||
if len(result.Participants) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.Slice(result.Participants, func(i, j int) bool {
|
||||
if result.Participants[i].Date == result.Participants[j].Date {
|
||||
return result.Participants[i].UserID < result.Participants[j].UserID
|
||||
}
|
||||
return result.Participants[i].Date < result.Participants[j].Date
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) channelThreadUnreadCountLocked(viewerUserID, channelID int64, rootID, readMaxID int) int {
|
||||
unread := 0
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if msg.Deleted || msg.ID <= readMaxID || msg.SenderUserID == viewerUserID {
|
||||
continue
|
||||
}
|
||||
if channelReplyBelongsToRoot(msg, channelID, rootID) {
|
||||
unread++
|
||||
// 钳到 MaxDialogUnreadCount(P1-v),与 postgres 的 LIMIT 子查询同 min(actual,cap) 语义。
|
||||
if unread >= domain.MaxDialogUnreadCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return unread
|
||||
}
|
||||
|
||||
func (s *ChannelStore) channelUnreadCountLocked(viewerUserID, channelID int64, readMaxID, topID int) int {
|
||||
if viewerUserID == 0 || channelID == 0 || topID <= readMaxID {
|
||||
return 0
|
||||
}
|
||||
unread := 0
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if msg.Deleted || msg.ID <= readMaxID || msg.ID > topID || msg.SenderUserID == viewerUserID {
|
||||
continue
|
||||
}
|
||||
unread++
|
||||
// 钳到 MaxDialogUnreadCount(P1-v),与 postgres 的 LIMIT 子查询同 min(actual,cap) 语义。
|
||||
if unread >= domain.MaxDialogUnreadCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
return unread
|
||||
}
|
||||
|
||||
func (s *ChannelStore) addChannelUnreadMentionsLocked(channelID int64, msg domain.ChannelMessage, senderUserID int64, userIDs []int64) {
|
||||
if len(userIDs) == 0 || msg.ID == 0 {
|
||||
return
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(userIDs))
|
||||
written := 0
|
||||
topID := channelMentionTopID(msg)
|
||||
for _, userID := range userIDs {
|
||||
if userID == 0 || userID == senderUserID {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[userID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
member, ok := s.members[channelID][userID]
|
||||
if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages {
|
||||
continue
|
||||
}
|
||||
if msg.ID <= member.AvailableMinID || msg.ID <= member.ReadInboxMaxID {
|
||||
continue
|
||||
}
|
||||
if s.mentions[userID] == nil {
|
||||
s.mentions[userID] = make(map[int64]map[int]memoryMention)
|
||||
}
|
||||
if s.mentions[userID][channelID] == nil {
|
||||
s.mentions[userID][channelID] = make(map[int]memoryMention)
|
||||
}
|
||||
s.mentions[userID][channelID][msg.ID] = memoryMention{topID: topID, unread: true}
|
||||
written++
|
||||
if written == domain.MaxChannelMentionRecipients {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelStore) countChannelUnreadMentionsLocked(userID, channelID int64, topMsgID int) int {
|
||||
count := 0
|
||||
for _, mention := range s.mentions[userID][channelID] {
|
||||
if !mention.unread {
|
||||
continue
|
||||
}
|
||||
if topMsgID == 0 || mention.topID == topMsgID || (topMsgID == 1 && mention.topID == 0) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *ChannelStore) deleteChannelUnreadMentionsLocked(channelID int64, ids []int) {
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
set := make(map[int]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
set[id] = struct{}{}
|
||||
}
|
||||
for userID, byChannel := range s.mentions {
|
||||
mentions := byChannel[channelID]
|
||||
if len(mentions) == 0 {
|
||||
continue
|
||||
}
|
||||
for id := range set {
|
||||
delete(mentions, id)
|
||||
}
|
||||
if len(mentions) == 0 {
|
||||
delete(byChannel, channelID)
|
||||
}
|
||||
if len(byChannel) == 0 {
|
||||
delete(s.mentions, userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelStore) deleteChannelUnreadMentionsUpToLocked(userID, channelID int64, maxID int) {
|
||||
if maxID <= 0 || len(s.mentions[userID][channelID]) == 0 {
|
||||
return
|
||||
}
|
||||
for id := range s.mentions[userID][channelID] {
|
||||
if id <= maxID {
|
||||
delete(s.mentions[userID][channelID], id)
|
||||
}
|
||||
}
|
||||
if len(s.mentions[userID][channelID]) == 0 {
|
||||
delete(s.mentions[userID], channelID)
|
||||
}
|
||||
if len(s.mentions[userID]) == 0 {
|
||||
delete(s.mentions, userID)
|
||||
}
|
||||
}
|
||||
|
||||
func channelMentionTopID(msg domain.ChannelMessage) int {
|
||||
if msg.ReplyTo == nil {
|
||||
return 0
|
||||
}
|
||||
if msg.ReplyTo.TopMessageID > 0 {
|
||||
return msg.ReplyTo.TopMessageID
|
||||
}
|
||||
return msg.ReplyTo.MessageID
|
||||
}
|
||||
|
||||
func (s *ChannelStore) populateChannelMessageUnreadFlagsLocked(viewerUserID int64, messages []domain.ChannelMessage) {
|
||||
if viewerUserID == 0 || len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range messages {
|
||||
if messages[i].ChannelID == 0 || messages[i].ID <= 0 {
|
||||
continue
|
||||
}
|
||||
mention, ok := s.mentions[viewerUserID][messages[i].ChannelID][messages[i].ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
messages[i].Mentioned = true
|
||||
messages[i].MediaUnread = mention.unread
|
||||
}
|
||||
}
|
||||
|
||||
// clearChannelMentionsForUserLocked 在离开/被踢时清空该用户的提及状态。
|
||||
func (s *ChannelStore) clearChannelMentionsForUserLocked(channelID, userID int64) {
|
||||
if byChannel := s.mentions[userID]; byChannel != nil {
|
||||
delete(byChannel, channelID)
|
||||
if len(byChannel) == 0 {
|
||||
delete(s.mentions, userID)
|
||||
}
|
||||
}
|
||||
if dialogs := s.dialogs[userID]; dialogs != nil {
|
||||
if dialog, ok := dialogs[channelID]; ok {
|
||||
dialog.UnreadMentions = 0
|
||||
dialogs[channelID] = dialog
|
||||
}
|
||||
}
|
||||
}
|
||||
49
internal/store/memory/channel_read_clamp_test.go
Normal file
49
internal/store/memory/channel_read_clamp_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TestChannelUnreadCountClampedToMax 锁定 P1-v:未读 COUNT 钳到 MaxDialogUnreadCount(min(actual,cap)),
|
||||
// 与 postgres 的 LIMIT 子查询同语义。直接喂 s.messages 避免 1000+ 次 send 建链路。
|
||||
func TestChannelUnreadCountClampedToMax(t *testing.T) {
|
||||
const (
|
||||
channelID = int64(7000000001)
|
||||
viewer = int64(5001)
|
||||
sender = int64(5002)
|
||||
)
|
||||
s := NewChannelStore()
|
||||
|
||||
over := domain.MaxDialogUnreadCount + 25
|
||||
msgs := make([]domain.ChannelMessage, 0, over)
|
||||
for i := 1; i <= over; i++ {
|
||||
msgs = append(msgs, domain.ChannelMessage{ChannelID: channelID, ID: i, SenderUserID: sender, ReplyTo: &domain.MessageReply{TopMessageID: 100}})
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.messages[channelID] = msgs
|
||||
s.mu.Unlock()
|
||||
|
||||
// 全量未读(readMaxID=0, topID 覆盖全部)→ 应钳到 cap,而非 over。
|
||||
if got := s.channelUnreadCountLocked(viewer, channelID, 0, over); got != domain.MaxDialogUnreadCount {
|
||||
t.Fatalf("channelUnreadCountLocked = %d, want clamped %d", got, domain.MaxDialogUnreadCount)
|
||||
}
|
||||
// thread 未读同样钳到 cap(全部消息挂在 root=100 线程)。
|
||||
if got := s.channelThreadUnreadCountLocked(viewer, channelID, 100, 0); got != domain.MaxDialogUnreadCount {
|
||||
t.Fatalf("channelThreadUnreadCountLocked = %d, want clamped %d", got, domain.MaxDialogUnreadCount)
|
||||
}
|
||||
|
||||
// 未超上界时返回精确值(cap 内不被钳)。
|
||||
const fewChannel = int64(7000000002)
|
||||
few := make([]domain.ChannelMessage, 0, 3)
|
||||
for i := 1; i <= 3; i++ {
|
||||
few = append(few, domain.ChannelMessage{ChannelID: fewChannel, ID: i, SenderUserID: sender})
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.messages[fewChannel] = few
|
||||
s.mu.Unlock()
|
||||
if got := s.channelUnreadCountLocked(viewer, fewChannel, 0, 3); got != 3 {
|
||||
t.Fatalf("channelUnreadCountLocked(few) = %d, want exact 3 (below cap unaffected)", got)
|
||||
}
|
||||
}
|
||||
690
internal/store/memory/channel_settings.go
Normal file
690
internal/store/memory/channel_settings.go
Normal file
|
|
@ -0,0 +1,690 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) EditChannelTitle(_ context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Title) == "" {
|
||||
return domain.EditChannelTitleResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.EditChannelTitleResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !canChangeChannelInfo(member) {
|
||||
return domain.EditChannelTitleResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
title := strings.TrimSpace(req.Title)
|
||||
if channel.Title == title {
|
||||
return domain.EditChannelTitleResult{}, domain.ErrChannelNotModified
|
||||
}
|
||||
prevTitle := channel.Title
|
||||
channel.Title = title
|
||||
msg, event := s.appendChannelServiceMessageLocked(req.ChannelID, req.UserID, req.Date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionEditTitle,
|
||||
Title: title,
|
||||
})
|
||||
channel.TopMessageID = msg.ID
|
||||
channel.Pts = event.Pts
|
||||
s.channels[req.ChannelID] = channel
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: req.Date,
|
||||
Type: domain.ChannelAdminLogChangeTitle,
|
||||
PrevString: prevTitle,
|
||||
NewString: title,
|
||||
})
|
||||
s.upsertChannelDialogLocked(req.UserID, channel, msg, true)
|
||||
return domain.EditChannelTitleResult{
|
||||
Channel: channel,
|
||||
Message: cloneChannelMessage(msg),
|
||||
Event: cloneChannelEvent(event),
|
||||
Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetChannelWallpaper(_ context.Context, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
return domain.SetChannelWallpaperResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
wallpaper := domain.CloneWallpaperPtr(req.Wallpaper)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.SetChannelWallpaperResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !canChangeChannelInfo(member) {
|
||||
return domain.SetChannelWallpaperResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
if domain.WallpaperEqual(channel.Wallpaper, wallpaper) {
|
||||
return domain.SetChannelWallpaperResult{Channel: cloneChannel(channel)}, nil
|
||||
}
|
||||
channel.Wallpaper = domain.CloneWallpaperPtr(wallpaper)
|
||||
if wallpaper == nil {
|
||||
s.channels[req.ChannelID] = channel
|
||||
return domain.SetChannelWallpaperResult{
|
||||
Channel: cloneChannel(channel),
|
||||
Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0),
|
||||
Changed: true,
|
||||
}, nil
|
||||
}
|
||||
msg, event := s.appendChannelServiceMessageLocked(req.ChannelID, req.UserID, req.Date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionSetChatWallpaper,
|
||||
Wallpaper: domain.CloneWallpaperPtr(wallpaper),
|
||||
})
|
||||
channel.TopMessageID = msg.ID
|
||||
channel.Pts = event.Pts
|
||||
s.channels[req.ChannelID] = channel
|
||||
s.upsertChannelDialogLocked(req.UserID, channel, msg, true)
|
||||
return domain.SetChannelWallpaperResult{
|
||||
Channel: cloneChannel(channel),
|
||||
Message: cloneChannelMessage(msg),
|
||||
Event: cloneChannelEvent(event),
|
||||
Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0),
|
||||
Changed: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) EditChannelAbout(_ context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !canChangeChannelInfo(member) {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
channel.About = req.About
|
||||
s.channels[req.ChannelID] = channel
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) CheckUsername(_ context.Context, userID, channelID int64, username string) (bool, error) {
|
||||
if userID == 0 || channelID == 0 || strings.TrimSpace(username) == "" {
|
||||
return false, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, err := s.channelForMemberLocked(userID, channelID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
||||
for id, channel := range s.channels {
|
||||
if channel.Deleted || channel.Username == "" {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(channel.Username) == usernameLower && id != channelID {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) UpdateUsername(_ context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if member.Role != domain.ChannelRoleCreator {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
|
||||
usernameLower := strings.ToLower(username)
|
||||
if strings.EqualFold(channel.Username, username) {
|
||||
return domain.Channel{}, domain.ErrChannelNotModified
|
||||
}
|
||||
if usernameLower != "" {
|
||||
for id, existing := range s.channels {
|
||||
if existing.Deleted || existing.Username == "" {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(existing.Username) == usernameLower && id != req.ChannelID {
|
||||
return domain.Channel{}, domain.ErrUsernameOccupied
|
||||
}
|
||||
}
|
||||
}
|
||||
prevUsername := channel.Username
|
||||
channel.Username = username
|
||||
s.channels[req.ChannelID] = channel
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: int(time.Now().Unix()),
|
||||
Type: domain.ChannelAdminLogChangeUsername,
|
||||
PrevString: prevUsername,
|
||||
NewString: username,
|
||||
})
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetChannelVerified(_ context.Context, channelID int64, verified bool) (domain.Channel, error) {
|
||||
if channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
channel.Verified = verified
|
||||
s.channels[channelID] = channel
|
||||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ResolvePublicChannelUsername(_ context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) {
|
||||
if viewerUserID == 0 {
|
||||
return domain.Channel{}, false, domain.ErrChannelInvalid
|
||||
}
|
||||
username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
||||
if username == "" {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, channel := range s.channels {
|
||||
if !publicSearchableChannel(channel) {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(channel.Username) == username {
|
||||
return cloneChannel(channel), true, nil
|
||||
}
|
||||
}
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetChannelPhoto(_ context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !canChangeChannelInfo(member) {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
if photo != nil && photo.ID != 0 {
|
||||
channel.PhotoID = photo.ID
|
||||
channel.PhotoDCID = photo.DCID
|
||||
channel.PhotoStripped = domain.StrippedFromSizes(photo.Sizes)
|
||||
} else {
|
||||
channel.PhotoID = 0
|
||||
channel.PhotoDCID = 0
|
||||
channel.PhotoStripped = nil
|
||||
}
|
||||
s.channels[channelID] = channel
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetSignatures(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !canChangeChannelInfo(member) {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
prev := channel.Signatures
|
||||
channel.Signatures = enabled
|
||||
s.channels[channelID] = channel
|
||||
if prev != enabled {
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Date: int(time.Now().Unix()),
|
||||
Type: domain.ChannelAdminLogToggleSignatures,
|
||||
PrevBool: prev,
|
||||
NewBool: enabled,
|
||||
})
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetAutotranslation(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !canChangeChannelInfo(member) {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
prev := channel.Autotranslation
|
||||
channel.Autotranslation = enabled
|
||||
s.channels[channelID] = channel
|
||||
if prev != enabled {
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Date: int(time.Now().Unix()),
|
||||
Type: domain.ChannelAdminLogToggleAutotranslation,
|
||||
PrevBool: prev,
|
||||
NewBool: enabled,
|
||||
})
|
||||
}
|
||||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetRestrictedSponsored(_ context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !canChangeChannelInfo(member) {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
channel.RestrictedSponsored = restricted
|
||||
s.channels[channelID] = channel
|
||||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetAntiSpam(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !channel.Megagroup || !canChangeChannelInfo(member) {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
prev := channel.AntiSpam
|
||||
channel.AntiSpam = enabled
|
||||
s.channels[channelID] = channel
|
||||
if prev != enabled {
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Date: int(time.Now().Unix()),
|
||||
Type: domain.ChannelAdminLogToggleAntiSpam,
|
||||
PrevBool: prev,
|
||||
NewBool: enabled,
|
||||
})
|
||||
}
|
||||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetSlowMode(_ context.Context, userID, channelID int64, seconds int) (domain.Channel, error) {
|
||||
if userID == 0 || channelID == 0 || !domain.ValidChannelSlowModeSeconds(seconds) {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !canChangeChannelInfo(member) {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
prev := channel.SlowmodeSeconds
|
||||
channel.SlowmodeSeconds = seconds
|
||||
s.channels[channelID] = channel
|
||||
if prev != seconds {
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Date: int(time.Now().Unix()),
|
||||
Type: domain.ChannelAdminLogToggleSlowMode,
|
||||
PrevInt: prev,
|
||||
NewInt: seconds,
|
||||
})
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetBoostsToUnblockRestrictions(_ context.Context, userID, channelID int64, boosts int) (domain.Channel, error) {
|
||||
if userID == 0 || channelID == 0 || boosts < 0 || boosts > domain.MaxChannelBoostsToUnblockRestrictions {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !canChangeChannelInfo(member) {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
channel.BoostsUnrestrict = boosts
|
||||
s.channels[channelID] = channel
|
||||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetNoForwards(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !canChangeChannelInfo(member) {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
channel.NoForwards = enabled
|
||||
s.channels[channelID] = channel
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetColor(_ context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !canChangeChannelInfo(member) {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
if forProfile {
|
||||
channel.ProfileColor = color
|
||||
} else {
|
||||
channel.Color = color
|
||||
}
|
||||
s.channels[channelID] = channel
|
||||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetEmojiStatus(_ context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !canChangeChannelInfo(member) {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
channel.EmojiStatus = status
|
||||
s.channels[channelID] = channel
|
||||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListChannelRecommendations(_ context.Context, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) {
|
||||
if req.UserID == 0 || req.SourceChannelID < 0 {
|
||||
return domain.ChannelRecommendationsResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 || limit > domain.MaxChannelRecommendationsLimit {
|
||||
limit = domain.DefaultChannelRecommendationsLimit
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
items := make([]domain.Channel, 0, limit)
|
||||
for channelID, channel := range s.channels {
|
||||
if !recommendableChannel(channel) || channelID == req.SourceChannelID {
|
||||
continue
|
||||
}
|
||||
if req.SourceChannelID == 0 {
|
||||
if member, ok := s.members[channelID][req.UserID]; ok && member.Status == domain.ChannelMemberActive {
|
||||
continue
|
||||
}
|
||||
}
|
||||
items = append(items, cloneChannel(channel))
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].ParticipantsCount != items[j].ParticipantsCount {
|
||||
return items[i].ParticipantsCount > items[j].ParticipantsCount
|
||||
}
|
||||
if items[i].Date != items[j].Date {
|
||||
return items[i].Date > items[j].Date
|
||||
}
|
||||
return items[i].ID > items[j].ID
|
||||
})
|
||||
out := domain.ChannelRecommendationsResult{Count: len(items)}
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
out.Channels = append(out.Channels, items...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListDiscussionGroups(_ context.Context, userID int64, limit int) ([]domain.Channel, error) {
|
||||
if userID == 0 {
|
||||
return nil, domain.ErrChannelInvalid
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxDiscussionGroupsLimit {
|
||||
limit = domain.MaxDiscussionGroupsLimit
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
items := make([]domain.Channel, 0, limit)
|
||||
for channelID, channel := range s.channels {
|
||||
if !validDiscussionGroup(channel) || channel.Deleted {
|
||||
continue
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if member.Status != domain.ChannelMemberActive || !canManageDiscussionGroup(member) {
|
||||
continue
|
||||
}
|
||||
items = append(items, cloneChannel(channel))
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].ID > items[j].ID
|
||||
})
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetDiscussionGroup(_ context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) {
|
||||
if userID == 0 {
|
||||
return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if broadcastID == 0 && groupID == 0 {
|
||||
return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
changed := make(map[int64]domain.Channel)
|
||||
markChanged := func(channel domain.Channel) {
|
||||
if channel.ID != 0 {
|
||||
changed[channel.ID] = cloneChannel(channel)
|
||||
}
|
||||
}
|
||||
setLinked := func(channelID, linkedID int64) (domain.Channel, bool) {
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted {
|
||||
return domain.Channel{}, false
|
||||
}
|
||||
if channel.LinkedChatID == linkedID {
|
||||
return channel, true
|
||||
}
|
||||
channel.LinkedChatID = linkedID
|
||||
s.channels[channelID] = channel
|
||||
markChanged(channel)
|
||||
return channel, true
|
||||
}
|
||||
|
||||
if broadcastID == 0 {
|
||||
group, groupMember, err := s.channelAndMemberLocked(userID, groupID)
|
||||
if err != nil || !validDiscussionGroup(group) {
|
||||
return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid
|
||||
}
|
||||
if !canManageDiscussionGroup(groupMember) {
|
||||
return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
oldBroadcastID := group.LinkedChatID
|
||||
if oldBroadcastID == 0 {
|
||||
return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified
|
||||
}
|
||||
if oldBroadcast, ok := s.channels[oldBroadcastID]; ok && oldBroadcast.LinkedChatID == groupID {
|
||||
if updated, ok := setLinked(oldBroadcastID, 0); ok {
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: updated.ID,
|
||||
UserID: userID,
|
||||
Date: int(time.Now().Unix()),
|
||||
Type: domain.ChannelAdminLogChangeLinkedChat,
|
||||
PrevInt: int(groupID),
|
||||
NewInt: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
setLinked(groupID, 0)
|
||||
return discussionGroupUpdateResult(changed), nil
|
||||
}
|
||||
|
||||
broadcast, broadcastMember, err := s.channelAndMemberLocked(userID, broadcastID)
|
||||
if err != nil || !broadcast.Broadcast || broadcast.Megagroup {
|
||||
return domain.DiscussionGroupUpdateResult{}, domain.ErrBroadcastIDInvalid
|
||||
}
|
||||
if !canManageDiscussionBroadcast(broadcastMember) {
|
||||
return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
oldGroupID := broadcast.LinkedChatID
|
||||
if groupID == 0 {
|
||||
if oldGroupID == 0 {
|
||||
return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified
|
||||
}
|
||||
updated, _ := setLinked(broadcastID, 0)
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: updated.ID,
|
||||
UserID: userID,
|
||||
Date: int(time.Now().Unix()),
|
||||
Type: domain.ChannelAdminLogChangeLinkedChat,
|
||||
PrevInt: int(oldGroupID),
|
||||
NewInt: 0,
|
||||
})
|
||||
if oldGroup, ok := s.channels[oldGroupID]; ok && oldGroup.LinkedChatID == broadcastID {
|
||||
setLinked(oldGroupID, 0)
|
||||
}
|
||||
return discussionGroupUpdateResult(changed), nil
|
||||
}
|
||||
|
||||
group, groupMember, err := s.channelAndMemberLocked(userID, groupID)
|
||||
if err != nil || !validDiscussionGroup(group) {
|
||||
return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid
|
||||
}
|
||||
if group.PreHistoryHidden {
|
||||
return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupPrehistoryHidden
|
||||
}
|
||||
if !canManageDiscussionGroup(groupMember) {
|
||||
return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
if oldGroupID == groupID && group.LinkedChatID == broadcastID {
|
||||
return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified
|
||||
}
|
||||
oldBroadcastID := group.LinkedChatID
|
||||
if oldGroupID != 0 && oldGroupID != groupID {
|
||||
if oldGroup, ok := s.channels[oldGroupID]; ok && oldGroup.LinkedChatID == broadcastID {
|
||||
setLinked(oldGroupID, 0)
|
||||
}
|
||||
}
|
||||
if oldBroadcastID != 0 && oldBroadcastID != broadcastID {
|
||||
if oldBroadcast, ok := s.channels[oldBroadcastID]; ok && oldBroadcast.LinkedChatID == groupID {
|
||||
if updated, ok := setLinked(oldBroadcastID, 0); ok {
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: updated.ID,
|
||||
UserID: userID,
|
||||
Date: int(time.Now().Unix()),
|
||||
Type: domain.ChannelAdminLogChangeLinkedChat,
|
||||
PrevInt: int(groupID),
|
||||
NewInt: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
updatedBroadcast, _ := setLinked(broadcastID, groupID)
|
||||
setLinked(groupID, broadcastID)
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: updatedBroadcast.ID,
|
||||
UserID: userID,
|
||||
Date: int(time.Now().Unix()),
|
||||
Type: domain.ChannelAdminLogChangeLinkedChat,
|
||||
PrevInt: int(oldGroupID),
|
||||
NewInt: int(groupID),
|
||||
})
|
||||
return discussionGroupUpdateResult(changed), nil
|
||||
}
|
||||
|
||||
func validDiscussionGroup(channel domain.Channel) bool {
|
||||
return channel.Megagroup && !channel.Broadcast && !channel.Forum && !channel.Deleted
|
||||
}
|
||||
|
||||
func channelSlowModeWait(channel domain.Channel, member domain.ChannelMember, now int) int {
|
||||
if channel.SlowmodeSeconds <= 0 || member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin {
|
||||
return 0
|
||||
}
|
||||
next := member.SlowmodeLastSendDate + channel.SlowmodeSeconds
|
||||
if now >= next {
|
||||
return 0
|
||||
}
|
||||
return next - now
|
||||
}
|
||||
|
||||
func cloneChannelDiscussionRef(in *domain.ChannelDiscussionRef) *domain.ChannelDiscussionRef {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
return &out
|
||||
}
|
||||
129
internal/store/memory/channel_store.go
Normal file
129
internal/store/memory/channel_store.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const firstMemoryChannelID int64 = 2000000000
|
||||
|
||||
type channelRandomKey struct {
|
||||
channelID int64
|
||||
userID int64
|
||||
randomID int64
|
||||
}
|
||||
|
||||
type boostSlotKey struct {
|
||||
userID int64
|
||||
slot int
|
||||
}
|
||||
|
||||
// channelReadWatermark 是 channel 级公共已读水位:任一成员推进过的最高两个
|
||||
// read_inbox。sender 的 read_outbox 由它派生(top1 持有者本人取 top2)。
|
||||
// memoryMention 是 owner 视角一条 mention 的状态:topID 支持 topic 过滤,
|
||||
// unread 翻转为 false 表示已读但 mentioned 高亮永久保留。
|
||||
type memoryMention struct {
|
||||
topID int
|
||||
unread bool
|
||||
}
|
||||
|
||||
type channelReadWatermark struct {
|
||||
top1User int64
|
||||
top1 int
|
||||
top2 int
|
||||
}
|
||||
|
||||
func (w channelReadWatermark) forSender(userID int64) int {
|
||||
if w.top1User == userID {
|
||||
return w.top2
|
||||
}
|
||||
return w.top1
|
||||
}
|
||||
|
||||
func (w channelReadWatermark) advance(userID int64, maxID int) channelReadWatermark {
|
||||
switch {
|
||||
case w.top1User == userID:
|
||||
if maxID > w.top1 {
|
||||
w.top1 = maxID
|
||||
}
|
||||
case maxID >= w.top1:
|
||||
w.top2 = w.top1
|
||||
w.top1User = userID
|
||||
w.top1 = maxID
|
||||
case maxID > w.top2:
|
||||
w.top2 = maxID
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// ChannelStore is an in-memory channel/supergroup store for tests and local development.
|
||||
type ChannelStore struct {
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
nextHash int64
|
||||
channels map[int64]domain.Channel
|
||||
members map[int64]map[int64]domain.ChannelMember
|
||||
dialogs map[int64]map[int64]domain.ChannelDialog
|
||||
topics map[int64]map[int]domain.ChannelForumTopic
|
||||
messages map[int64][]domain.ChannelMessage
|
||||
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
|
||||
// paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。
|
||||
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
|
||||
top map[int64]map[string]domain.TopMessageReaction
|
||||
recent map[int64]map[string]domain.RecentMessageReaction
|
||||
savedTags map[int64]map[string]domain.SavedReactionTag
|
||||
mentions map[int64]map[int64]map[int]memoryMention
|
||||
msgViews map[int64]map[int]int
|
||||
msgViewers map[int64]map[int]map[int64]struct{}
|
||||
events map[int64][]domain.ChannelUpdateEvent
|
||||
adminLogs map[int64][]domain.ChannelAdminLogEvent
|
||||
invites map[string]domain.ChannelInvite
|
||||
importers map[int64]map[int64]domain.ChannelInviteImporter
|
||||
msgSeq map[int64]int
|
||||
ptsSeq map[int64]int
|
||||
logSeq map[int64]int64
|
||||
randomToID map[channelRandomKey]int
|
||||
boostSlots map[boostSlotKey]domain.PremiumBoostSlot
|
||||
readMarks map[int64]channelReadWatermark
|
||||
// topicReads 是 per-(channel,user,topic) 已读水位(forum 话题独立已读,不碰频道级 member 水位)。
|
||||
topicReads map[int64]map[int64]map[int]memoryTopicRead
|
||||
// polls 是共享 poll 权威(与 MessageStore 同一实例);nil 时 poll 链路按未接入处理。
|
||||
polls *PollStore
|
||||
}
|
||||
|
||||
// AttachPollStore 注入共享 poll 权威。
|
||||
func (s *ChannelStore) AttachPollStore(polls *PollStore) {
|
||||
s.polls = polls
|
||||
}
|
||||
|
||||
// NewChannelStore creates an in-memory ChannelStore.
|
||||
func NewChannelStore() *ChannelStore {
|
||||
return &ChannelStore{
|
||||
nextID: firstMemoryChannelID,
|
||||
nextHash: 900000000000,
|
||||
channels: make(map[int64]domain.Channel),
|
||||
members: make(map[int64]map[int64]domain.ChannelMember),
|
||||
dialogs: make(map[int64]map[int64]domain.ChannelDialog),
|
||||
topics: make(map[int64]map[int]domain.ChannelForumTopic),
|
||||
messages: make(map[int64][]domain.ChannelMessage),
|
||||
reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction),
|
||||
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
|
||||
top: make(map[int64]map[string]domain.TopMessageReaction),
|
||||
recent: make(map[int64]map[string]domain.RecentMessageReaction),
|
||||
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
|
||||
mentions: make(map[int64]map[int64]map[int]memoryMention),
|
||||
msgViews: make(map[int64]map[int]int),
|
||||
msgViewers: make(map[int64]map[int]map[int64]struct{}),
|
||||
events: make(map[int64][]domain.ChannelUpdateEvent),
|
||||
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
|
||||
invites: make(map[string]domain.ChannelInvite),
|
||||
importers: make(map[int64]map[int64]domain.ChannelInviteImporter),
|
||||
msgSeq: make(map[int64]int),
|
||||
ptsSeq: make(map[int64]int),
|
||||
logSeq: make(map[int64]int64),
|
||||
randomToID: make(map[channelRandomKey]int),
|
||||
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
|
||||
readMarks: make(map[int64]channelReadWatermark),
|
||||
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
|
|
@ -9,6 +10,74 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestChannelCreateCreatesPermanentInviteAndHasLink(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1,
|
||||
Title: "private group with main link",
|
||||
Megagroup: true,
|
||||
Date: 1_700_000_090,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
if !created.Channel.HasLink {
|
||||
t.Fatalf("created channel HasLink = false, want true")
|
||||
}
|
||||
view, err := store.GetChannel(ctx, 1, created.Channel.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get channel: %v", err)
|
||||
}
|
||||
if !view.Channel.HasLink {
|
||||
t.Fatalf("stored channel HasLink = false, want true")
|
||||
}
|
||||
if view.ExportedInvite == nil || !view.ExportedInvite.Permanent || view.ExportedInvite.Revoked || view.ExportedInvite.AdminUserID != 1 {
|
||||
t.Fatalf("owner view exported invite = %+v, want creator permanent main link", view.ExportedInvite)
|
||||
}
|
||||
invites, err := store.ListExportedInvites(ctx, domain.ChannelInviteListRequest{
|
||||
UserID: 1,
|
||||
ChannelID: created.Channel.ID,
|
||||
AdminUserID: 1,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list exported invites: %v", err)
|
||||
}
|
||||
if invites.Count != 1 || len(invites.Invites) != 1 || !invites.Invites[0].Permanent || invites.Invites[0].Revoked {
|
||||
t.Fatalf("invites = %+v, want one active permanent main link", invites)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelViewExportedInviteIsAdminOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1,
|
||||
Title: "private group with regular member",
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{2},
|
||||
Date: 1_700_000_091,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
ownerView, err := store.GetChannel(ctx, 1, created.Channel.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get owner channel: %v", err)
|
||||
}
|
||||
if ownerView.ExportedInvite == nil || !ownerView.ExportedInvite.Permanent {
|
||||
t.Fatalf("owner exported invite = %+v, want permanent main link", ownerView.ExportedInvite)
|
||||
}
|
||||
memberView, err := store.GetChannel(ctx, 2, created.Channel.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get member channel: %v", err)
|
||||
}
|
||||
if memberView.ExportedInvite != nil {
|
||||
t.Fatalf("member exported invite = %+v, want nil", memberView.ExportedInvite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelRealtimeRecipientsAreCapped(t *testing.T) {
|
||||
store := NewChannelStore()
|
||||
memberIDs := make([]int64, domain.MaxChannelRealtimeFanout+25)
|
||||
|
|
@ -39,6 +108,71 @@ func TestChannelRealtimeRecipientsAreCapped(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChannelCreatorLeaveTransfersOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1,
|
||||
Title: "creator transfer",
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{2, 3},
|
||||
Date: 1_700_000_110,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{
|
||||
UserID: 1,
|
||||
ChannelID: created.Channel.ID,
|
||||
MemberID: 3,
|
||||
AdminRights: domain.ChannelAdminRights{
|
||||
ChangeInfo: true,
|
||||
AddAdmins: true,
|
||||
},
|
||||
Date: 1_700_000_111,
|
||||
}); err != nil {
|
||||
t.Fatalf("promote admin: %v", err)
|
||||
}
|
||||
future, err := store.FutureCreatorAfterLeave(ctx, created.Channel.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("future creator: %v", err)
|
||||
}
|
||||
if future.UserID != 3 {
|
||||
t.Fatalf("future creator = %+v, want admin user 3", future)
|
||||
}
|
||||
left, err := store.LeaveChannel(ctx, created.Channel.ID, 1, 1_700_000_112)
|
||||
if err != nil {
|
||||
t.Fatalf("creator leave: %v", err)
|
||||
}
|
||||
if left.Channel.CreatorUserID != 3 || left.Channel.ParticipantsCount != 2 || left.Channel.AdminsCount != 1 {
|
||||
t.Fatalf("channel after creator leave = %+v, want owner=3 participants=2 admins=1", left.Channel)
|
||||
}
|
||||
newOwner, err := store.GetParticipant(ctx, 3, created.Channel.ID, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("get new owner: %v", err)
|
||||
}
|
||||
if newOwner.Role != domain.ChannelRoleCreator || newOwner.Status != domain.ChannelMemberActive {
|
||||
t.Fatalf("new owner = %+v, want active creator", newOwner)
|
||||
}
|
||||
oldOwner, err := store.GetParticipant(ctx, 3, created.Channel.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("get old owner: %v", err)
|
||||
}
|
||||
if oldOwner.Status != domain.ChannelMemberLeft || oldOwner.Role == domain.ChannelRoleCreator {
|
||||
t.Fatalf("old owner = %+v, want left non-creator", oldOwner)
|
||||
}
|
||||
if _, err := store.JoinChannel(ctx, created.Channel.ID, 1, 1_700_000_113); err != nil {
|
||||
t.Fatalf("old owner rejoin: %v", err)
|
||||
}
|
||||
rejoined, err := store.GetParticipant(ctx, 3, created.Channel.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("get rejoined old owner: %v", err)
|
||||
}
|
||||
if rejoined.Role != domain.ChannelRoleMember || rejoined.Status != domain.ChannelMemberActive || rejoined.Rank != "" {
|
||||
t.Fatalf("rejoined old owner = %+v, want active plain member", rejoined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelAdminAndBanDoNotAdvanceChannelPts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
|
|
@ -71,7 +205,24 @@ func TestChannelAdminAndBanDoNotAdvanceChannelPts(t *testing.T) {
|
|||
t.Fatalf("edit admin pts = event(%d,%d) channel %d, want unchanged %d", promoted.Event.Pts, promoted.Event.PtsCount, promoted.Channel.Pts, ptsFloor)
|
||||
}
|
||||
|
||||
banned, err := store.EditChannelBanned(ctx, domain.EditChannelBannedRequest{
|
||||
muted, err := store.EditChannelBanned(ctx, domain.EditChannelBannedRequest{
|
||||
UserID: 1,
|
||||
ChannelID: channelID,
|
||||
Participant: domain.Peer{Type: domain.PeerTypeUser, ID: 2},
|
||||
BannedRights: domain.ChannelBannedRights{
|
||||
SendMessages: true,
|
||||
UntilDate: 1_700_001_121,
|
||||
},
|
||||
Date: 1_700_000_122,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("edit banned mute: %v", err)
|
||||
}
|
||||
if muted.Event.Pts != 0 || muted.Event.PtsCount != 0 || muted.Channel.Pts != ptsFloor || muted.ServiceEvent.Pts != 0 {
|
||||
t.Fatalf("mute pts = event(%d,%d) channel %d, want unchanged %d without service message", muted.Event.Pts, muted.Event.PtsCount, muted.Channel.Pts, ptsFloor)
|
||||
}
|
||||
|
||||
kicked, err := store.EditChannelBanned(ctx, domain.EditChannelBannedRequest{
|
||||
UserID: 1,
|
||||
ChannelID: channelID,
|
||||
Participant: domain.Peer{Type: domain.PeerTypeUser, ID: 2},
|
||||
|
|
@ -79,13 +230,21 @@ func TestChannelAdminAndBanDoNotAdvanceChannelPts(t *testing.T) {
|
|||
ViewMessages: true,
|
||||
UntilDate: 1_700_001_121,
|
||||
},
|
||||
Date: 1_700_000_122,
|
||||
Date: 1_700_000_123,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("edit banned: %v", err)
|
||||
t.Fatalf("edit banned kick: %v", err)
|
||||
}
|
||||
if banned.Event.Pts != 0 || banned.Event.PtsCount != 0 || banned.Channel.Pts != ptsFloor {
|
||||
t.Fatalf("edit banned pts = event(%d,%d) channel %d, want unchanged %d", banned.Event.Pts, banned.Event.PtsCount, banned.Channel.Pts, ptsFloor)
|
||||
// megagroup 踢人产生可见 "X removed Y" 服务消息并占 channel pts;
|
||||
// participant update 本身仍不占 pts。
|
||||
if kicked.Event.Pts != 0 || kicked.Event.PtsCount != 0 {
|
||||
t.Fatalf("kick participant event = (%d,%d), must stay transient", kicked.Event.Pts, kicked.Event.PtsCount)
|
||||
}
|
||||
if kicked.ServiceEvent.Pts != ptsFloor+1 || kicked.Message.Action == nil || kicked.Message.Action.Type != domain.ChannelActionChatDelete {
|
||||
t.Fatalf("kick service = event %+v message %+v, want ChatDelete service message at pts %d", kicked.ServiceEvent, kicked.Message, ptsFloor+1)
|
||||
}
|
||||
if kicked.Channel.Pts != ptsFloor+1 {
|
||||
t.Fatalf("kick channel pts = %d, want %d", kicked.Channel.Pts, ptsFloor+1)
|
||||
}
|
||||
|
||||
diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
|
|
@ -97,8 +256,145 @@ func TestChannelAdminAndBanDoNotAdvanceChannelPts(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("list difference: %v", err)
|
||||
}
|
||||
if len(diff.Events) != 0 || diff.Pts != ptsFloor {
|
||||
t.Fatalf("difference after participant state change = %+v, want no durable events at pts %d", diff, ptsFloor)
|
||||
if len(diff.Events) != 1 || diff.Pts != ptsFloor+1 {
|
||||
t.Fatalf("difference after kick = %+v, want only the kick service message at pts %d", diff, ptsFloor+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelStoreGetMessagesNonMemberPublicPreview(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
const owner, outsider int64 = 1, 99
|
||||
|
||||
// 公开广播频道:非成员可读取消息(查看他人资料里的公开「个人频道」时,DrKLO 经
|
||||
// channels.getMessages 拉最新一帖依赖此;否则资料页个人频道整块不显示)。
|
||||
pub, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner, Title: "public", Broadcast: true, Date: 1_700_000_000})
|
||||
if err != nil {
|
||||
t.Fatalf("create public: %v", err)
|
||||
}
|
||||
if _, err := store.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{UserID: owner, ChannelID: pub.Channel.ID, Username: "pub_preview_mem"}); err != nil {
|
||||
t.Fatalf("make public: %v", err)
|
||||
}
|
||||
sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{UserID: owner, ChannelID: pub.Channel.ID, RandomID: 111, Message: "hello", Date: 1_700_000_001})
|
||||
if err != nil {
|
||||
t.Fatalf("send public: %v", err)
|
||||
}
|
||||
got, err := store.GetChannelMessages(ctx, outsider, pub.Channel.ID, []int{sent.Message.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("non-member getMessages on public channel: %v", err)
|
||||
}
|
||||
if len(got.Messages) != 1 || got.Messages[0].ID != sent.Message.ID {
|
||||
t.Fatalf("non-member should read public channel message, got %+v", got.Messages)
|
||||
}
|
||||
|
||||
// 私有广播频道(无 username):非成员仍被拒(ErrChannelPrivate)。
|
||||
priv, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner, Title: "private", Broadcast: true, Date: 1_700_000_002})
|
||||
if err != nil {
|
||||
t.Fatalf("create private: %v", err)
|
||||
}
|
||||
privSent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{UserID: owner, ChannelID: priv.Channel.ID, RandomID: 222, Message: "secret", Date: 1_700_000_003})
|
||||
if err != nil {
|
||||
t.Fatalf("send private: %v", err)
|
||||
}
|
||||
if _, err := store.GetChannelMessages(ctx, outsider, priv.Channel.ID, []int{privSent.Message.ID}); !errors.Is(err, domain.ErrChannelPrivate) {
|
||||
t.Fatalf("non-member getMessages on private channel = %v, want ErrChannelPrivate", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelStoreStoryMessageForwardsPublicOnlyAndDeleteRollback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
source := domain.Peer{Type: domain.PeerTypeUser, ID: 42}
|
||||
media := &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindStory,
|
||||
Story: &domain.MessageStory{
|
||||
Peer: source,
|
||||
ID: 7,
|
||||
Story: &domain.Story{
|
||||
Owner: source,
|
||||
ID: 7,
|
||||
},
|
||||
},
|
||||
}
|
||||
publicCreated, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1,
|
||||
Title: "public story forwards",
|
||||
Broadcast: true,
|
||||
Date: 1_700_000_130,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create public channel: %v", err)
|
||||
}
|
||||
publicChannel, err := store.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{
|
||||
UserID: 1,
|
||||
ChannelID: publicCreated.Channel.ID,
|
||||
Username: "story_forward_memory",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("make public channel: %v", err)
|
||||
}
|
||||
privateCreated, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1,
|
||||
Title: "private story forwards",
|
||||
Broadcast: true,
|
||||
Date: 1_700_000_131,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create private channel: %v", err)
|
||||
}
|
||||
publicSent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: 1,
|
||||
ChannelID: publicChannel.ID,
|
||||
RandomID: 9141301,
|
||||
Media: media,
|
||||
Date: 1_700_000_132,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send public story message: %v", err)
|
||||
}
|
||||
if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: 1,
|
||||
ChannelID: privateCreated.Channel.ID,
|
||||
RandomID: 9141302,
|
||||
Media: media,
|
||||
Date: 1_700_000_133,
|
||||
}); err != nil {
|
||||
t.Fatalf("send private story message: %v", err)
|
||||
}
|
||||
list, err := store.ListStoryMessageForwards(ctx, domain.StoryMessageForwardListRequest{
|
||||
ViewerUserID: 42,
|
||||
Owner: source,
|
||||
StoryID: 7,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list story message forwards: %v", err)
|
||||
}
|
||||
if list.Count != 1 || len(list.Forwards) != 1 {
|
||||
t.Fatalf("story message forwards = %+v, want one public forward", list)
|
||||
}
|
||||
if got := list.Forwards[0].PublicForward.Message.ChannelID; got != publicChannel.ID {
|
||||
t.Fatalf("forward channel = %d, want public channel %d", got, publicChannel.ID)
|
||||
}
|
||||
if _, err := store.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
|
||||
UserID: 1,
|
||||
ChannelID: publicChannel.ID,
|
||||
IDs: []int{publicSent.Message.ID},
|
||||
Date: 1_700_000_134,
|
||||
}); err != nil {
|
||||
t.Fatalf("delete public story message: %v", err)
|
||||
}
|
||||
empty, err := store.ListStoryMessageForwards(ctx, domain.StoryMessageForwardListRequest{
|
||||
ViewerUserID: 42,
|
||||
Owner: source,
|
||||
StoryID: 7,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list story message forwards after delete: %v", err)
|
||||
}
|
||||
if empty.Count != 0 || len(empty.Forwards) != 0 {
|
||||
t.Fatalf("story message forwards after delete = %+v, want empty", empty)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -493,8 +789,18 @@ func TestChannelDeleteHistoryCapsHugeMaxID(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("delete second batch: %v", err)
|
||||
}
|
||||
if second.Offset != 0 || len(second.DeletedIDs) != 3 || second.Event.PtsCount != 3 {
|
||||
t.Fatalf("second batch = %+v, want final bounded page", second)
|
||||
if second.Offset != 0 || len(second.DeletedIDs) != 2 || second.Event.PtsCount != 2 {
|
||||
t.Fatalf("second batch = %+v, want final bounded page keeping create service message", second)
|
||||
}
|
||||
if second.Channel.TopMessageID != created.Message.ID {
|
||||
t.Fatalf("top after full clear = %d, want create service message %d", second.Channel.TopMessageID, created.Message.ID)
|
||||
}
|
||||
history, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list history after full clear: %v", err)
|
||||
}
|
||||
if len(history.Messages) != 1 || history.Messages[0].ID != created.Message.ID || history.Messages[0].Action == nil {
|
||||
t.Fatalf("history after full clear = %+v, want only create service message", history.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -678,6 +984,230 @@ func TestChannelUnreadExcludesOwnOutgoing(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChannelMessageReplyMarkupSurvivesReadPaths(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1,
|
||||
Title: "channel reply markup",
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{2},
|
||||
Date: 1_700_000_380,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
markup := &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{
|
||||
{Type: domain.MarkupButtonCallback, Text: "Open", Data: []byte{0x00, 0xff, 0x42}},
|
||||
}}}
|
||||
assertMarkup := func(name string, got *domain.MessageReplyMarkup) {
|
||||
t.Helper()
|
||||
if got == nil || len(got.Inline) != 1 || len(got.Inline[0]) != 1 {
|
||||
t.Fatalf("%s markup = %+v, want one callback button", name, got)
|
||||
}
|
||||
btn := got.Inline[0][0]
|
||||
if btn.Type != domain.MarkupButtonCallback || btn.Text != "Open" || !bytes.Equal(btn.Data, []byte{0x00, 0xff, 0x42}) {
|
||||
t.Fatalf("%s button = %+v, want callback Open bytes", name, btn)
|
||||
}
|
||||
}
|
||||
sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: 1,
|
||||
ChannelID: created.Channel.ID,
|
||||
RandomID: 38_001,
|
||||
Message: "via inline keyboard",
|
||||
ViaBotID: 99,
|
||||
ReplyMarkup: markup,
|
||||
Date: 1_700_000_381,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send channel message: %v", err)
|
||||
}
|
||||
markup.Inline[0][0].Text = "Changed"
|
||||
markup.Inline[0][0].Data[0] = 0x7f
|
||||
assertMarkup("send result", sent.Message.ReplyMarkup)
|
||||
assertMarkup("send event", sent.Event.Message.ReplyMarkup)
|
||||
|
||||
duplicate, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: 1,
|
||||
ChannelID: created.Channel.ID,
|
||||
RandomID: 38_001,
|
||||
Message: "duplicate must not replace",
|
||||
Date: 1_700_000_382,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate send: %v", err)
|
||||
}
|
||||
if !duplicate.Duplicate || duplicate.Message.ViaBotID != 99 {
|
||||
t.Fatalf("duplicate = %+v, want original via bot", duplicate.Message)
|
||||
}
|
||||
assertMarkup("duplicate message", duplicate.Message.ReplyMarkup)
|
||||
assertMarkup("duplicate event", duplicate.Event.Message.ReplyMarkup)
|
||||
|
||||
history, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list history: %v", err)
|
||||
}
|
||||
if len(history.Messages) == 0 || history.Messages[0].ViaBotID != 99 {
|
||||
t.Fatalf("history messages = %+v, want via bot 99", history.Messages)
|
||||
}
|
||||
assertMarkup("history message", history.Messages[0].ReplyMarkup)
|
||||
|
||||
byID, err := store.GetChannelMessages(ctx, 1, created.Channel.ID, []int{sent.Message.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("get channel messages: %v", err)
|
||||
}
|
||||
if len(byID.Messages) != 1 || byID.Messages[0].ViaBotID != 99 {
|
||||
t.Fatalf("get messages = %+v, want via bot 99", byID.Messages)
|
||||
}
|
||||
assertMarkup("get messages", byID.Messages[0].ReplyMarkup)
|
||||
|
||||
diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 1, ChannelID: created.Channel.ID, Pts: created.Channel.Pts, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list difference: %v", err)
|
||||
}
|
||||
if len(diff.NewMessages) != 1 || diff.NewMessages[0].ViaBotID != 99 {
|
||||
t.Fatalf("difference messages = %+v, want via bot 99", diff.NewMessages)
|
||||
}
|
||||
assertMarkup("difference message", diff.NewMessages[0].ReplyMarkup)
|
||||
|
||||
dialogs, err := store.ListChannelDialogs(ctx, 1, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list dialogs: %v", err)
|
||||
}
|
||||
if len(dialogs.Messages) == 0 || dialogs.Messages[0].ViaBotID != 99 {
|
||||
t.Fatalf("dialog messages = %+v, want via bot 99", dialogs.Messages)
|
||||
}
|
||||
assertMarkup("dialog top message", dialogs.Messages[0].ReplyMarkup)
|
||||
}
|
||||
|
||||
func TestChannelMessageViaBotEditUpdatesReplyMarkup(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1,
|
||||
Title: "channel via bot edit",
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{2},
|
||||
Date: 1_700_000_390,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: 1,
|
||||
ChannelID: created.Channel.ID,
|
||||
RandomID: 39_001,
|
||||
Message: "before",
|
||||
ViaBotID: 99,
|
||||
Date: 1_700_000_391,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send channel message: %v", err)
|
||||
}
|
||||
markup := &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{
|
||||
{Type: domain.MarkupButtonCallback, Text: "Done", Data: []byte("v2")},
|
||||
}}}
|
||||
if _, err := store.EditChannelMessage(ctx, domain.EditChannelMessageRequest{
|
||||
UserID: 1,
|
||||
ChannelID: created.Channel.ID,
|
||||
ID: sent.Message.ID,
|
||||
Message: "wrong",
|
||||
ViaBotEditBotID: 100,
|
||||
EditDate: 1_700_000_392,
|
||||
SetReplyMarkup: true,
|
||||
ReplyMarkup: markup,
|
||||
}); err != domain.ErrMessageAuthorRequired {
|
||||
t.Fatalf("wrong via bot edit err = %v, want ErrMessageAuthorRequired", err)
|
||||
}
|
||||
edited, err := store.EditChannelMessage(ctx, domain.EditChannelMessageRequest{
|
||||
UserID: 1,
|
||||
ChannelID: created.Channel.ID,
|
||||
ID: sent.Message.ID,
|
||||
Message: "after",
|
||||
ViaBotEditBotID: 99,
|
||||
EditDate: 1_700_000_393,
|
||||
SetReplyMarkup: true,
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("via bot edit: %v", err)
|
||||
}
|
||||
if edited.Event.Type != domain.ChannelUpdateEditMessage || edited.Message.Body != "after" || edited.Message.ViaBotID != 99 {
|
||||
t.Fatalf("edited = %+v event=%+v, want edit via bot", edited.Message, edited.Event)
|
||||
}
|
||||
if edited.Message.ReplyMarkup == nil || edited.Message.ReplyMarkup.Inline[0][0].Text != "Done" || !bytes.Equal(edited.Message.ReplyMarkup.Inline[0][0].Data, []byte("v2")) {
|
||||
t.Fatalf("edited markup = %+v, want Done/v2", edited.Message.ReplyMarkup)
|
||||
}
|
||||
diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 1, ChannelID: created.Channel.ID, Pts: sent.Event.Pts, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list edit difference: %v", err)
|
||||
}
|
||||
if len(diff.OtherUpdates) != 1 || diff.OtherUpdates[0].Message.Body != "after" {
|
||||
t.Fatalf("edit difference = %+v, want one edited message", diff.OtherUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastChannelReactionsAreAnonymousAndSkipUnread(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1,
|
||||
Title: "broadcast reaction",
|
||||
Broadcast: true,
|
||||
MemberUserIDs: []int64{2},
|
||||
Date: 1_700_000_500,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: 1,
|
||||
ChannelID: created.Channel.ID,
|
||||
RandomID: 50_001,
|
||||
Message: "broadcast post",
|
||||
Date: 1_700_000_501,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send channel message: %v", err)
|
||||
}
|
||||
res, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
|
||||
UserID: 2,
|
||||
ChannelID: created.Channel.ID,
|
||||
MessageID: sent.Message.ID,
|
||||
Reactions: []domain.MessageReaction{{
|
||||
Type: domain.MessageReactionEmoji,
|
||||
Emoticon: "\U0001f44d",
|
||||
}},
|
||||
Date: 1_700_000_502,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("set channel reaction: %v", err)
|
||||
}
|
||||
if len(res.Reactions.Results) != 1 || res.Reactions.Results[0].Count != 1 {
|
||||
t.Fatalf("broadcast reaction results = %+v, want count-only aggregate", res.Reactions.Results)
|
||||
}
|
||||
if len(res.Reactions.Recent) != 0 {
|
||||
t.Fatalf("broadcast recent reactors = %+v, want anonymous (empty)", res.Reactions.Recent)
|
||||
}
|
||||
if res.Reactions.CanSeeList {
|
||||
t.Fatalf("broadcast can_see_list = true, want false")
|
||||
}
|
||||
for _, rows := range store.reactions[created.Channel.ID][sent.Message.ID] {
|
||||
for _, row := range rows {
|
||||
if row.Unread {
|
||||
t.Fatalf("broadcast reaction row = %+v, want unread bookkeeping skipped", row)
|
||||
}
|
||||
}
|
||||
}
|
||||
dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("get owner channel dialogs: %v", err)
|
||||
}
|
||||
if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadReactions != 0 {
|
||||
t.Fatalf("owner dialogs = %+v, want no unread reaction badge on broadcast", dialogs.Dialogs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelReadMessageContentsClearsVisibleUnreadReactions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
|
|
|
|||
200
internal/store/memory/channel_topic_read.go
Normal file
200
internal/store/memory/channel_topic_read.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// memoryTopicRead 是单个 (channel,user,topic) 的已读水位。
|
||||
type memoryTopicRead struct {
|
||||
ReadInbox int
|
||||
ReadOutbox int
|
||||
}
|
||||
|
||||
func topicReadFallbackInboxMem(topicID, availableMinID int) int {
|
||||
base := topicID - 1
|
||||
if availableMinID > base {
|
||||
base = availableMinID
|
||||
}
|
||||
if base < 0 {
|
||||
base = 0
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// channelMessageInTopicMem 判断消息是否属于某 topic,与 postgres channelTopicMessageCond 严格对齐:
|
||||
// 普通 topic 仅 reply_to_top_id==topicID(不含 root 服务消息,其 reply_to_top_id=0);
|
||||
// General(=1) 归并 reply_to_top_id∈{0,1}。
|
||||
func (s *ChannelStore) channelMessageInTopicLocked(channelID int64, msg domain.ChannelMessage, topicID int) bool {
|
||||
top := 0
|
||||
if msg.ReplyTo != nil {
|
||||
top = msg.ReplyTo.TopMessageID
|
||||
}
|
||||
if topicID == domain.ForumGeneralTopicID {
|
||||
if top != domain.ForumGeneralTopicID && top != 0 {
|
||||
return false
|
||||
}
|
||||
// 排除其它话题的根服务消息(reply_to_top_id=0 但 id 是某话题根,不属于 General)。
|
||||
if _, isRoot := s.topics[channelID][msg.ID]; isRoot {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return top == topicID
|
||||
}
|
||||
|
||||
func (s *ChannelStore) channelTopicReadInboxLocked(channelID, userID int64, topicID, availableMinID int) int {
|
||||
fb := topicReadFallbackInboxMem(topicID, availableMinID)
|
||||
if tr, ok := s.topicReads[channelID][userID][topicID]; ok && tr.ReadInbox > fb {
|
||||
return tr.ReadInbox
|
||||
}
|
||||
return fb
|
||||
}
|
||||
|
||||
func (s *ChannelStore) channelTopicReadOutboxLocked(channelID, userID int64, topicID int) int {
|
||||
if tr, ok := s.topicReads[channelID][userID][topicID]; ok {
|
||||
return tr.ReadOutbox
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *ChannelStore) setTopicReadLocked(channelID, userID int64, topicID int, mutate func(*memoryTopicRead)) {
|
||||
if s.topicReads[channelID] == nil {
|
||||
s.topicReads[channelID] = make(map[int64]map[int]memoryTopicRead)
|
||||
}
|
||||
if s.topicReads[channelID][userID] == nil {
|
||||
s.topicReads[channelID][userID] = make(map[int]memoryTopicRead)
|
||||
}
|
||||
tr := s.topicReads[channelID][userID][topicID]
|
||||
mutate(&tr)
|
||||
s.topicReads[channelID][userID][topicID] = tr
|
||||
}
|
||||
|
||||
func (s *ChannelStore) channelTopicTopMessageIDLocked(channelID int64, topicID, availableMinID int) int {
|
||||
maxID := 0
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if msg.Deleted || msg.ID <= availableMinID {
|
||||
continue
|
||||
}
|
||||
if s.channelMessageInTopicLocked(channelID, msg, topicID) && msg.ID > maxID {
|
||||
maxID = msg.ID
|
||||
}
|
||||
}
|
||||
return maxID
|
||||
}
|
||||
|
||||
// channelTopicUnreadCountLocked 现算某 topic 对 viewer 的未读消息数(per-topic 水位 readMaxID)。
|
||||
// 与 postgres populateForumTopicUnreadCounts 同口径:reply_to_top_id==topicID、id>water、sender≠viewer。
|
||||
func (s *ChannelStore) channelTopicUnreadCountLocked(viewerUserID, channelID int64, topicID, readMaxID int) int {
|
||||
unread := 0
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if msg.Deleted || msg.ID <= readMaxID || msg.SenderUserID == viewerUserID {
|
||||
continue
|
||||
}
|
||||
if s.channelMessageInTopicLocked(channelID, msg, topicID) {
|
||||
unread++
|
||||
if unread >= domain.MaxDialogUnreadCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return unread
|
||||
}
|
||||
|
||||
// ReadChannelTopicHistory 推进 viewer 在 forum 单话题的 per-topic 已读水位(不碰频道级)。
|
||||
func (s *ChannelStore) ReadChannelTopicHistory(_ context.Context, req domain.ReadChannelTopicHistoryRequest) (domain.ReadChannelTopicHistoryResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 {
|
||||
return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ReadChannelTopicHistoryResult{}, err
|
||||
}
|
||||
if !channel.Forum {
|
||||
return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelForumMissing
|
||||
}
|
||||
topMax := s.channelTopicTopMessageIDLocked(req.ChannelID, req.TopicID, member.AvailableMinID)
|
||||
maxID := req.MaxID
|
||||
if maxID <= 0 || maxID > topMax {
|
||||
maxID = topMax
|
||||
}
|
||||
prev := s.channelTopicReadInboxLocked(req.ChannelID, req.UserID, req.TopicID, member.AvailableMinID)
|
||||
if maxID <= prev {
|
||||
return domain.ReadChannelTopicHistoryResult{Channel: cloneChannel(channel), TopicID: req.TopicID, MaxID: prev, Changed: false, Pts: channel.Pts}, nil
|
||||
}
|
||||
s.setTopicReadLocked(req.ChannelID, req.UserID, req.TopicID, func(tr *memoryTopicRead) {
|
||||
if maxID > tr.ReadInbox {
|
||||
tr.ReadInbox = maxID
|
||||
}
|
||||
})
|
||||
outbox := s.advanceTopicReadOutboxLocked(req.ChannelID, req.UserID, req.TopicID, prev, maxID)
|
||||
return domain.ReadChannelTopicHistoryResult{Channel: cloneChannel(channel), TopicID: req.TopicID, MaxID: maxID, Changed: true, Pts: channel.Pts, OutboxUpdates: outbox}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) advanceTopicReadOutboxLocked(channelID, readerUserID int64, topicID, prev, maxID int) []domain.ChannelReadOutboxUpdate {
|
||||
senders := make(map[int64]struct{})
|
||||
order := make([]int64, 0, 8)
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if msg.Deleted || msg.ID <= prev || msg.ID > maxID {
|
||||
continue
|
||||
}
|
||||
if msg.SenderUserID == 0 || msg.SenderUserID == readerUserID {
|
||||
continue
|
||||
}
|
||||
if !s.channelMessageInTopicLocked(channelID, msg, topicID) {
|
||||
continue
|
||||
}
|
||||
if _, ok := senders[msg.SenderUserID]; ok {
|
||||
continue
|
||||
}
|
||||
senders[msg.SenderUserID] = struct{}{}
|
||||
order = append(order, msg.SenderUserID)
|
||||
}
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
updates := make([]domain.ChannelReadOutboxUpdate, 0, len(order))
|
||||
for _, sender := range order {
|
||||
s.setTopicReadLocked(channelID, sender, topicID, func(tr *memoryTopicRead) {
|
||||
if maxID > tr.ReadOutbox {
|
||||
tr.ReadOutbox = maxID
|
||||
}
|
||||
})
|
||||
updates = append(updates, domain.ChannelReadOutboxUpdate{UserID: sender, MaxID: maxID})
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
// GeneralForumTopic 现算 General 话题(id=1)对 viewer 的状态(per-topic 水位,排除其它话题根消息)。
|
||||
func (s *ChannelStore) GeneralForumTopic(_ context.Context, viewerUserID, channelID int64) (domain.ChannelForumTopic, error) {
|
||||
if viewerUserID == 0 || channelID == 0 {
|
||||
return domain.ChannelForumTopic{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelForumTopic{}, err
|
||||
}
|
||||
if !channel.Forum {
|
||||
return domain.ChannelForumTopic{}, domain.ErrChannelForumMissing
|
||||
}
|
||||
gid := domain.ForumGeneralTopicID
|
||||
water := s.channelTopicReadInboxLocked(channelID, viewerUserID, gid, member.AvailableMinID)
|
||||
return domain.ChannelForumTopic{
|
||||
ChannelID: channelID,
|
||||
TopicID: gid,
|
||||
Title: "General",
|
||||
CreatorUserID: channel.CreatorUserID,
|
||||
Date: channel.Date,
|
||||
TopMessageID: s.channelTopicTopMessageIDLocked(channelID, gid, member.AvailableMinID),
|
||||
ReadInboxMaxID: water,
|
||||
ReadOutboxMaxID: s.channelTopicReadOutboxLocked(channelID, viewerUserID, gid),
|
||||
UnreadCount: s.channelTopicUnreadCountLocked(viewerUserID, channelID, gid, water),
|
||||
UnreadMentionsCount: s.countChannelUnreadMentionsLocked(viewerUserID, channelID, gid),
|
||||
UnreadReactionsCount: s.countChannelUnreadReactionsLocked(viewerUserID, channelID, gid),
|
||||
}, nil
|
||||
}
|
||||
719
internal/store/memory/channel_topics.go
Normal file
719
internal/store/memory/channel_topics.go
Normal file
|
|
@ -0,0 +1,719 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) SetForum(_ context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
member := s.members[channelID][userID]
|
||||
if !channel.Megagroup || channel.Broadcast {
|
||||
return domain.Channel{}, domain.ErrChannelNotModified
|
||||
}
|
||||
if member.Role != domain.ChannelRoleCreator {
|
||||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
if enabled && channel.LinkedChatID != 0 {
|
||||
return domain.Channel{}, domain.ErrChatDiscussionUnallowed
|
||||
}
|
||||
prevForum := channel.Forum
|
||||
prevTabs := channel.ForumTabs
|
||||
channel.Forum = enabled
|
||||
channel.ForumTabs = enabled && tabs
|
||||
s.channels[channelID] = channel
|
||||
if prevForum != channel.Forum || prevTabs != channel.ForumTabs {
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Date: int(time.Now().Unix()),
|
||||
Type: domain.ChannelAdminLogToggleForum,
|
||||
PrevBool: prevForum,
|
||||
NewBool: enabled,
|
||||
})
|
||||
}
|
||||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetChannelViewForumAsMessages(_ context.Context, userID, channelID int64, enabled bool) (bool, error) {
|
||||
if userID == 0 || channelID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
dialog := s.dialogForUserLocked(userID, channel)
|
||||
changed := dialog.ViewForumAsMessages != enabled
|
||||
dialog.ViewForumAsMessages = enabled
|
||||
if s.dialogs[userID] == nil {
|
||||
s.dialogs[userID] = make(map[int64]domain.ChannelDialog)
|
||||
}
|
||||
s.dialogs[userID][channelID] = dialog
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) CreateForumTopic(ctx context.Context, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.RandomID == 0 {
|
||||
return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
title := strings.TrimSpace(req.Title)
|
||||
if title == "" && !req.TitleMissing {
|
||||
return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.IconColor == 0 {
|
||||
req.IconColor = domain.DefaultForumTopicIconColor
|
||||
}
|
||||
s.mu.Lock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
return domain.CreateChannelForumTopicResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !channel.Forum || channel.Broadcast || !channel.Megagroup {
|
||||
s.mu.Unlock()
|
||||
return domain.CreateChannelForumTopicResult{}, domain.ErrChannelForumMissing
|
||||
}
|
||||
if !canSendChannelMessage(channel, member) {
|
||||
s.mu.Unlock()
|
||||
return domain.CreateChannelForumTopicResult{}, domain.ErrChannelWriteForbidden
|
||||
}
|
||||
if id, ok := s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}]; ok {
|
||||
if topic, ok := s.topics[req.ChannelID][id]; ok {
|
||||
msg, _ := s.findMessageLocked(req.ChannelID, id)
|
||||
event := s.eventForMessageLocked(req.ChannelID, id)
|
||||
recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0)
|
||||
s.mu.Unlock()
|
||||
return domain.CreateChannelForumTopicResult{
|
||||
Channel: cloneChannel(channel),
|
||||
Topic: cloneChannelForumTopic(topic),
|
||||
Message: cloneChannelMessage(msg),
|
||||
Event: cloneChannelEvent(event),
|
||||
Recipients: recipients,
|
||||
Duplicate: true,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: req.UserID,
|
||||
ChannelID: req.ChannelID,
|
||||
RandomID: req.RandomID,
|
||||
SendAs: req.SendAs,
|
||||
Action: &domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionTopicCreate,
|
||||
Title: title,
|
||||
IconColor: req.IconColor,
|
||||
IconEmojiID: req.IconEmojiID,
|
||||
TitleMissing: req.TitleMissing,
|
||||
},
|
||||
Date: req.Date,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.CreateChannelForumTopicResult{}, err
|
||||
}
|
||||
if res.Message.Action == nil || res.Message.Action.Type != domain.ChannelActionTopicCreate {
|
||||
return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel = s.channels[req.ChannelID]
|
||||
if s.topics[req.ChannelID] == nil {
|
||||
s.topics[req.ChannelID] = make(map[int]domain.ChannelForumTopic)
|
||||
}
|
||||
topic, ok := s.topics[req.ChannelID][res.Message.ID]
|
||||
if !ok {
|
||||
topic = domain.ChannelForumTopic{
|
||||
ChannelID: req.ChannelID,
|
||||
TopicID: res.Message.ID,
|
||||
CreatorUserID: req.UserID,
|
||||
Title: title,
|
||||
IconColor: req.IconColor,
|
||||
IconEmojiID: req.IconEmojiID,
|
||||
TitleMissing: req.TitleMissing,
|
||||
Date: res.Message.Date,
|
||||
TopMessageID: res.Message.ID,
|
||||
ReadInboxMaxID: res.Message.ID,
|
||||
ReadOutboxMaxID: res.Message.ID,
|
||||
}
|
||||
s.topics[req.ChannelID][topic.TopicID] = topic
|
||||
}
|
||||
return domain.CreateChannelForumTopicResult{
|
||||
Channel: cloneChannel(channel),
|
||||
Topic: cloneChannelForumTopic(topic),
|
||||
Message: cloneChannelMessage(res.Message),
|
||||
Event: cloneChannelEvent(res.Event),
|
||||
Recipients: append([]int64(nil), res.Recipients...),
|
||||
Duplicate: res.Duplicate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) EditForumTopic(ctx context.Context, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 {
|
||||
return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
return domain.EditChannelForumTopicResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
topic, ok := s.topics[req.ChannelID][req.TopicID]
|
||||
if !channel.Forum {
|
||||
s.mu.Unlock()
|
||||
return domain.EditChannelForumTopicResult{}, domain.ErrChannelForumMissing
|
||||
}
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return domain.EditChannelForumTopicResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if !canManageForumTopic(channel, member, topic, req.UserID) {
|
||||
s.mu.Unlock()
|
||||
return domain.EditChannelForumTopicResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
next := topic
|
||||
action := domain.ChannelMessageAction{Type: domain.ChannelActionTopicEdit}
|
||||
changed := false
|
||||
if req.Title != nil {
|
||||
title := strings.TrimSpace(*req.Title)
|
||||
if title == "" {
|
||||
s.mu.Unlock()
|
||||
return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if next.Title != title {
|
||||
next.Title = title
|
||||
action.Title = title
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if req.IconEmojiID != nil && next.IconEmojiID != *req.IconEmojiID {
|
||||
next.IconEmojiID = *req.IconEmojiID
|
||||
action.IconEmojiID = *req.IconEmojiID
|
||||
action.IconEmojiIDSet = true
|
||||
changed = true
|
||||
}
|
||||
if req.Closed != nil && next.Closed != *req.Closed {
|
||||
next.Closed = *req.Closed
|
||||
action.Closed = boolPtr(*req.Closed)
|
||||
changed = true
|
||||
}
|
||||
if req.Hidden != nil && next.Hidden != *req.Hidden {
|
||||
next.Hidden = *req.Hidden
|
||||
action.Hidden = boolPtr(*req.Hidden)
|
||||
changed = true
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !changed {
|
||||
return domain.EditChannelForumTopicResult{}, domain.ErrChannelNotModified
|
||||
}
|
||||
|
||||
res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: req.UserID,
|
||||
ChannelID: req.ChannelID,
|
||||
ReplyTo: &domain.MessageReply{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID},
|
||||
MessageID: req.TopicID,
|
||||
TopMessageID: req.TopicID,
|
||||
},
|
||||
Action: &action,
|
||||
Date: req.Date,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.EditChannelForumTopicResult{}, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel = s.channels[req.ChannelID]
|
||||
if _, ok := s.topics[req.ChannelID][req.TopicID]; !ok {
|
||||
return domain.EditChannelForumTopicResult{}, domain.ErrChannelForumMissing
|
||||
}
|
||||
next.TopMessageID = maxInt(next.TopMessageID, res.Message.ID)
|
||||
s.topics[req.ChannelID][req.TopicID] = next
|
||||
return domain.EditChannelForumTopicResult{
|
||||
Channel: cloneChannel(channel),
|
||||
Topic: cloneChannelForumTopic(next),
|
||||
Message: cloneChannelMessage(res.Message),
|
||||
Event: cloneChannelEvent(res.Event),
|
||||
Recipients: append([]int64(nil), res.Recipients...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) UpdatePinnedForumTopic(_ context.Context, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 {
|
||||
return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.UpdateChannelForumTopicPinnedResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
topic, ok := s.topics[req.ChannelID][req.TopicID]
|
||||
if !channel.Forum {
|
||||
return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelForumMissing
|
||||
}
|
||||
if !ok {
|
||||
return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if !canPinChannelMessages(channel, member) {
|
||||
return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
if topic.Pinned == req.Pinned {
|
||||
return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelNotModified
|
||||
}
|
||||
topic.Pinned = req.Pinned
|
||||
if req.Pinned && topic.PinnedOrder == 0 {
|
||||
topic.PinnedOrder = s.nextForumTopicPinnedOrderLocked(req.ChannelID)
|
||||
}
|
||||
if !req.Pinned {
|
||||
topic.PinnedOrder = 0
|
||||
}
|
||||
s.topics[req.ChannelID][req.TopicID] = topic
|
||||
return domain.UpdateChannelForumTopicPinnedResult{
|
||||
Channel: cloneChannel(channel),
|
||||
Topic: cloneChannelForumTopic(topic),
|
||||
Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ReorderPinnedForumTopics(_ context.Context, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || len(req.Order) > domain.MaxChannelForumTopicIDs {
|
||||
return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ReorderChannelPinnedForumTopicsResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
if !channel.Forum {
|
||||
return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelForumMissing
|
||||
}
|
||||
if !canPinChannelMessages(channel, member) {
|
||||
return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
seen := make(map[int]struct{}, len(req.Order))
|
||||
order := make([]int, 0, len(req.Order))
|
||||
for _, id := range req.Order {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
topic, ok := s.topics[req.ChannelID][id]
|
||||
if !ok || !topic.Pinned {
|
||||
if req.Force {
|
||||
continue
|
||||
}
|
||||
return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
order = append(order, id)
|
||||
}
|
||||
for i, id := range order {
|
||||
topic := s.topics[req.ChannelID][id]
|
||||
topic.PinnedOrder = len(order) - i
|
||||
s.topics[req.ChannelID][id] = topic
|
||||
}
|
||||
return domain.ReorderChannelPinnedForumTopicsResult{
|
||||
Channel: cloneChannel(channel),
|
||||
Order: append([]int(nil), order...),
|
||||
Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) DeleteForumTopicHistory(_ context.Context, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 {
|
||||
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.DeleteChannelHistoryResult{}, err
|
||||
}
|
||||
member := s.members[req.ChannelID][req.UserID]
|
||||
topic, ok := s.topics[req.ChannelID][req.TopicID]
|
||||
if !channel.Forum {
|
||||
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelForumMissing
|
||||
}
|
||||
if !ok {
|
||||
return domain.DeleteChannelHistoryResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if !canManageForumTopic(channel, member, topic, req.UserID) && !canDeleteAnyChannelMessage(member) {
|
||||
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
ids := make([]int, 0, domain.MaxDeleteHistoryBatch)
|
||||
for i := len(s.messages[req.ChannelID]) - 1; i >= 0; i-- {
|
||||
msg := s.messages[req.ChannelID][i]
|
||||
if msg.Deleted {
|
||||
continue
|
||||
}
|
||||
if msg.ID != req.TopicID && (msg.ReplyTo == nil || msg.ReplyTo.TopMessageID != req.TopicID) {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, msg.ID)
|
||||
if len(ids) >= domain.MaxDeleteHistoryBatch {
|
||||
break
|
||||
}
|
||||
}
|
||||
deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, ids, req.UserID, req.Date)
|
||||
if err != nil {
|
||||
return domain.DeleteChannelHistoryResult{}, err
|
||||
}
|
||||
offset := 0
|
||||
if s.topicHasVisibleMessagesLocked(req.ChannelID, req.TopicID) {
|
||||
offset = 1
|
||||
} else {
|
||||
delete(s.topics[req.ChannelID], req.TopicID)
|
||||
}
|
||||
return domain.DeleteChannelHistoryResult{
|
||||
Channel: cloneChannel(channel),
|
||||
Event: cloneChannelEvent(event),
|
||||
DeletedIDs: append([]int(nil), deleted...),
|
||||
Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0),
|
||||
Offset: offset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListForumTopics(_ context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelForumTopicList{}, err
|
||||
}
|
||||
if !channel.Forum {
|
||||
return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing
|
||||
}
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > domain.MaxChannelForumTopicsLimit {
|
||||
limit = domain.MaxChannelForumTopicsLimit
|
||||
}
|
||||
query := strings.TrimSpace(strings.ToLower(filter.Query))
|
||||
all := make([]domain.ChannelForumTopic, 0, len(s.topics[filter.ChannelID]))
|
||||
for _, topic := range s.topics[filter.ChannelID] {
|
||||
if topic.TopicID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
if query != "" && !strings.Contains(strings.ToLower(topic.Title), query) {
|
||||
continue
|
||||
}
|
||||
if forumTopicBeforeOrAtOffset(topic, filter) {
|
||||
continue
|
||||
}
|
||||
all = append(all, s.topicWithViewerCountersLocked(viewerUserID, filter.ChannelID, topic, member))
|
||||
}
|
||||
sortForumTopics(all)
|
||||
count := len(all)
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
messages := s.forumTopicRootMessagesLocked(filter.ChannelID, all, member.AvailableMinID)
|
||||
return domain.ChannelForumTopicList{
|
||||
Channel: cloneChannel(channel),
|
||||
Dialog: s.dialogForUserLocked(viewerUserID, channel),
|
||||
Topics: all,
|
||||
Messages: messages,
|
||||
Count: count,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetForumTopicsByID(_ context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelForumTopicList{}, err
|
||||
}
|
||||
if !channel.Forum {
|
||||
return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing
|
||||
}
|
||||
wanted := make(map[int]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return domain.ChannelForumTopicList{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
wanted[id] = struct{}{}
|
||||
}
|
||||
topics := make([]domain.ChannelForumTopic, 0, len(wanted))
|
||||
for id := range wanted {
|
||||
topic, ok := s.topics[channelID][id]
|
||||
if !ok || topic.TopicID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
topics = append(topics, s.topicWithViewerCountersLocked(viewerUserID, channelID, topic, member))
|
||||
}
|
||||
sortForumTopics(topics)
|
||||
messages := s.forumTopicRootMessagesLocked(channelID, topics, member.AvailableMinID)
|
||||
return domain.ChannelForumTopicList{
|
||||
Channel: cloneChannel(channel),
|
||||
Dialog: s.dialogForUserLocked(viewerUserID, channel),
|
||||
Topics: topics,
|
||||
Messages: messages,
|
||||
Count: len(topics),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListChannelReplies(_ context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
source, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
root, ok := s.findMessageLocked(filter.ChannelID, filter.RootMessageID)
|
||||
if !ok || root.Deleted || root.ID <= member.AvailableMinID {
|
||||
return domain.ChannelHistory{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
targetChannel := source
|
||||
targetMember := member
|
||||
rootID := root.ID
|
||||
extraChannels := []domain.Channel(nil)
|
||||
if source.Broadcast {
|
||||
if root.Discussion == nil || root.Discussion.ChannelID == 0 || root.Discussion.MessageID == 0 {
|
||||
return domain.ChannelHistory{Channel: source, Count: 0}, nil
|
||||
}
|
||||
linked, ok := s.channels[root.Discussion.ChannelID]
|
||||
if !ok || linked.Deleted {
|
||||
return domain.ChannelHistory{Channel: source, Count: 0}, nil
|
||||
}
|
||||
targetChannel = linked
|
||||
rootID = root.Discussion.MessageID
|
||||
if linkedMember, ok := s.members[linked.ID][viewerUserID]; ok {
|
||||
targetMember = linkedMember
|
||||
} else {
|
||||
targetMember = domain.ChannelMember{}
|
||||
}
|
||||
extraChannels = append(extraChannels, source)
|
||||
}
|
||||
if targetRoot, ok := s.findMessageLocked(targetChannel.ID, rootID); !ok || targetRoot.Deleted {
|
||||
return domain.ChannelHistory{Channel: targetChannel, Channels: extraChannels, Count: 0}, nil
|
||||
}
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > domain.MaxChannelRepliesLimit {
|
||||
limit = domain.MaxChannelRepliesLimit
|
||||
}
|
||||
filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset)
|
||||
base := make([]domain.ChannelMessage, 0, limit)
|
||||
for _, msg := range s.messages[targetChannel.ID] {
|
||||
if msg.Deleted || msg.ID <= targetMember.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
if !channelReplyBelongsToRoot(msg, targetChannel.ID, rootID) {
|
||||
continue
|
||||
}
|
||||
if filter.MaxID > 0 && msg.ID >= filter.MaxID {
|
||||
continue
|
||||
}
|
||||
if filter.MinID > 0 && msg.ID <= filter.MinID {
|
||||
continue
|
||||
}
|
||||
if filter.OffsetDate > 0 && msg.Date == 0 {
|
||||
continue
|
||||
}
|
||||
base = append(base, msg)
|
||||
}
|
||||
sort.SliceStable(base, func(i, j int) bool { return channelMessageLess(base[i], base[j]) })
|
||||
page := pageChannelMessageHistory(base, filter, limit)
|
||||
out := make([]domain.ChannelMessage, 0, len(page))
|
||||
for _, msg := range page {
|
||||
out = append(out, cloneChannelMessage(msg))
|
||||
}
|
||||
s.populateChannelMessageRepliesLocked(viewerUserID, targetChannel.ID, out)
|
||||
s.populateChannelMessageReactionsLocked(viewerUserID, targetChannel, out)
|
||||
topics := []domain.ChannelForumTopic(nil)
|
||||
if targetChannel.Forum {
|
||||
if topic, ok := s.topics[targetChannel.ID][rootID]; ok && !topic.Hidden {
|
||||
topic = s.topicWithViewerCountersLocked(viewerUserID, targetChannel.ID, topic, targetMember)
|
||||
topics = append(topics, cloneChannelForumTopic(topic))
|
||||
}
|
||||
}
|
||||
return domain.ChannelHistory{Channel: targetChannel, Channels: extraChannels, Topics: topics, Messages: out, Count: len(base)}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) populateChannelMessageRepliesLocked(viewerUserID, channelID int64, messages []domain.ChannelMessage) {
|
||||
for i := range messages {
|
||||
messages[i].Replies = s.channelMessageRepliesLocked(viewerUserID, channelID, messages[i])
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelStore) channelMessageRepliesLocked(viewerUserID, channelID int64, msg domain.ChannelMessage) *domain.ChannelMessageReplies {
|
||||
targetChannelID := channelID
|
||||
rootID := msg.ID
|
||||
stats := domain.ChannelMessageReplies{}
|
||||
if msg.Discussion != nil && msg.Discussion.ChannelID != 0 && msg.Discussion.MessageID != 0 {
|
||||
targetChannelID = msg.Discussion.ChannelID
|
||||
rootID = msg.Discussion.MessageID
|
||||
stats.Comments = true
|
||||
stats.ChannelID = msg.Discussion.ChannelID
|
||||
} else if channel, ok := s.channels[channelID]; ok && channel.Broadcast && channel.LinkedChatID != 0 && msg.Post {
|
||||
stats.Comments = true
|
||||
stats.ChannelID = channel.LinkedChatID
|
||||
}
|
||||
if rootID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if member, ok := s.members[targetChannelID][viewerUserID]; ok {
|
||||
stats.ReadMaxID = member.ReadInboxMaxID
|
||||
}
|
||||
seenRecent := map[domain.Peer]struct{}{}
|
||||
for i := len(s.messages[targetChannelID]) - 1; i >= 0; i-- {
|
||||
reply := s.messages[targetChannelID][i]
|
||||
if reply.Deleted || !channelReplyBelongsToRoot(reply, targetChannelID, rootID) {
|
||||
continue
|
||||
}
|
||||
stats.Replies++
|
||||
if stats.MaxID == 0 || reply.ID > stats.MaxID {
|
||||
stats.MaxID = reply.ID
|
||||
stats.RepliesPts = reply.Pts
|
||||
}
|
||||
if len(stats.RecentRepliers) < 3 {
|
||||
peer := reply.From
|
||||
if peer.ID == 0 && reply.SenderUserID != 0 {
|
||||
peer = domain.Peer{Type: domain.PeerTypeUser, ID: reply.SenderUserID}
|
||||
}
|
||||
if peer.ID != 0 {
|
||||
if _, ok := seenRecent[peer]; !ok {
|
||||
seenRecent[peer] = struct{}{}
|
||||
stats.RecentRepliers = append(stats.RecentRepliers, peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if stats.Comments && stats.RepliesPts == 0 {
|
||||
if root, ok := s.findMessageLocked(targetChannelID, rootID); ok {
|
||||
stats.RepliesPts = root.Pts
|
||||
}
|
||||
}
|
||||
if !stats.Comments && stats.Replies == 0 {
|
||||
return nil
|
||||
}
|
||||
return &stats
|
||||
}
|
||||
|
||||
func canManageForumTopic(channel domain.Channel, member domain.ChannelMember, topic domain.ChannelForumTopic, userID int64) bool {
|
||||
if topic.CreatorUserID == userID {
|
||||
return true
|
||||
}
|
||||
return canPinChannelMessages(channel, member)
|
||||
}
|
||||
|
||||
func cloneChannelForumTopic(in domain.ChannelForumTopic) domain.ChannelForumTopic {
|
||||
return in
|
||||
}
|
||||
|
||||
func (s *ChannelStore) updateForumTopicTopMessageLocked(channelID int64, msg domain.ChannelMessage) {
|
||||
if msg.ReplyTo == nil || !msg.ReplyTo.ForumTopic || msg.ReplyTo.TopMessageID <= 0 {
|
||||
return
|
||||
}
|
||||
topic, ok := s.topics[channelID][msg.ReplyTo.TopMessageID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
topic.TopMessageID = msg.ID
|
||||
topic.Date = msg.Date
|
||||
s.topics[channelID][topic.TopicID] = topic
|
||||
}
|
||||
|
||||
func sortForumTopics(topics []domain.ChannelForumTopic) {
|
||||
sort.Slice(topics, func(i, j int) bool {
|
||||
a, b := topics[i], topics[j]
|
||||
if a.Pinned != b.Pinned {
|
||||
return a.Pinned
|
||||
}
|
||||
if a.PinnedOrder != b.PinnedOrder {
|
||||
return a.PinnedOrder > b.PinnedOrder
|
||||
}
|
||||
if a.Date != b.Date {
|
||||
return a.Date > b.Date
|
||||
}
|
||||
return a.TopicID > b.TopicID
|
||||
})
|
||||
}
|
||||
|
||||
func forumTopicBeforeOrAtOffset(topic domain.ChannelForumTopic, filter domain.ChannelForumTopicFilter) bool {
|
||||
if filter.OffsetDate == 0 && filter.OffsetID == 0 && filter.OffsetTopic == 0 {
|
||||
return false
|
||||
}
|
||||
offsetID := filter.OffsetTopic
|
||||
if offsetID == 0 {
|
||||
offsetID = filter.OffsetID
|
||||
}
|
||||
if filter.OffsetDate != 0 {
|
||||
if topic.Date < filter.OffsetDate {
|
||||
return false
|
||||
}
|
||||
if topic.Date > filter.OffsetDate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if offsetID == 0 {
|
||||
return false
|
||||
}
|
||||
return topic.TopicID >= offsetID
|
||||
}
|
||||
|
||||
func (s *ChannelStore) forumTopicRootMessagesLocked(channelID int64, topics []domain.ChannelForumTopic, availableMinID int) []domain.ChannelMessage {
|
||||
if len(topics) == 0 {
|
||||
return nil
|
||||
}
|
||||
wanted := make(map[int]struct{}, len(topics))
|
||||
for _, topic := range topics {
|
||||
if topic.TopMessageID > 0 {
|
||||
wanted[topic.TopMessageID] = struct{}{}
|
||||
}
|
||||
}
|
||||
messages := make([]domain.ChannelMessage, 0, len(wanted))
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if _, ok := wanted[msg.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
if msg.Deleted || msg.ID <= availableMinID {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, cloneChannelMessage(msg))
|
||||
}
|
||||
sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID })
|
||||
return messages
|
||||
}
|
||||
|
||||
func (s *ChannelStore) nextForumTopicPinnedOrderLocked(channelID int64) int {
|
||||
next := 1
|
||||
for _, topic := range s.topics[channelID] {
|
||||
if topic.PinnedOrder >= next {
|
||||
next = topic.PinnedOrder + 1
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func cloneChannelMessageReplies(in *domain.ChannelMessageReplies) *domain.ChannelMessageReplies {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
out.RecentRepliers = append([]domain.Peer(nil), in.RecentRepliers...)
|
||||
return &out
|
||||
}
|
||||
207
internal/store/memory/channel_updates.go
Normal file
207
internal/store/memory/channel_updates.go
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, preview, err := s.channelForViewerLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelDifference{}, err
|
||||
}
|
||||
if req.Pts < 0 || req.Pts > channel.Pts {
|
||||
return domain.ChannelDifference{}, domain.ErrPersistentTimestamp
|
||||
}
|
||||
if !preview && member.AvailableMinPts > req.Pts {
|
||||
req.Pts = minInt(member.AvailableMinPts, channel.Pts)
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 || limit > domain.MaxChannelDifferenceLimit {
|
||||
limit = domain.MaxChannelDifferenceLimit
|
||||
}
|
||||
dialog := s.dialogForUserLocked(req.UserID, channel)
|
||||
if preview {
|
||||
dialog = previewChannelDialog(req.UserID, channel, member)
|
||||
}
|
||||
if channel.Pts-req.Pts > limit {
|
||||
messages := make([]domain.ChannelMessage, 0, domain.MaxChannelDifferenceTooLongMessages)
|
||||
for i := len(s.messages[req.ChannelID]) - 1; i >= 0 && len(messages) < domain.MaxChannelDifferenceTooLongMessages; i-- {
|
||||
msg := s.messages[req.ChannelID][i]
|
||||
if msg.Deleted {
|
||||
continue
|
||||
}
|
||||
if msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, cloneChannelMessage(msg))
|
||||
}
|
||||
s.populateChannelMessageUnreadFlagsLocked(req.UserID, messages)
|
||||
return domain.ChannelDifference{
|
||||
Channel: channel,
|
||||
Self: member,
|
||||
NewMessages: messages,
|
||||
Pts: channel.Pts,
|
||||
Final: true,
|
||||
TooLong: true,
|
||||
Timeout: 30,
|
||||
Dialog: dialog,
|
||||
}, nil
|
||||
}
|
||||
events := make([]domain.ChannelUpdateEvent, 0, limit)
|
||||
lastPts := req.Pts
|
||||
for _, event := range s.events[req.ChannelID] {
|
||||
if event.Pts <= req.Pts {
|
||||
continue
|
||||
}
|
||||
lastPts = event.Pts
|
||||
visible, ok := domain.FilterChannelUpdateEventForAvailableMinID(cloneChannelEvent(event), member.AvailableMinID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if preview && visible.Type == domain.ChannelUpdateParticipant {
|
||||
continue
|
||||
}
|
||||
events = append(events, visible)
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return domain.ChannelDifference{
|
||||
Channel: channel,
|
||||
Self: member,
|
||||
Pts: maxInt(lastPts, req.Pts),
|
||||
Final: true,
|
||||
Timeout: 30,
|
||||
Dialog: dialog,
|
||||
}, nil
|
||||
}
|
||||
diff := domain.ChannelDifference{
|
||||
Channel: channel,
|
||||
Self: member,
|
||||
Events: events,
|
||||
Pts: lastPts,
|
||||
Final: lastPts >= channel.Pts,
|
||||
Timeout: 30,
|
||||
Dialog: dialog,
|
||||
}
|
||||
for _, event := range events {
|
||||
switch event.Type {
|
||||
case domain.ChannelUpdateNewMessage:
|
||||
diff.NewMessages = append(diff.NewMessages, cloneChannelMessage(event.Message))
|
||||
default:
|
||||
diff.OtherUpdates = append(diff.OtherUpdates, cloneChannelEvent(event))
|
||||
}
|
||||
}
|
||||
s.populateChannelMessageUnreadFlagsLocked(req.UserID, diff.NewMessages)
|
||||
for i := range diff.OtherUpdates {
|
||||
if diff.OtherUpdates[i].Message.ID == 0 {
|
||||
continue
|
||||
}
|
||||
messages := []domain.ChannelMessage{diff.OtherUpdates[i].Message}
|
||||
s.populateChannelMessageUnreadFlagsLocked(req.UserID, messages)
|
||||
diff.OtherUpdates[i].Message = messages[0]
|
||||
}
|
||||
return diff, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) MaxChannelPts(_ context.Context, channelID int64) (int, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.ptsSeq[channelID], nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) nextChannelPtsLocked(channelID int64) int {
|
||||
s.ptsSeq[channelID]++
|
||||
return s.ptsSeq[channelID]
|
||||
}
|
||||
|
||||
func (s *ChannelStore) nextChannelPtsNLocked(channelID int64, count int) int {
|
||||
if count <= 0 {
|
||||
return s.ptsSeq[channelID]
|
||||
}
|
||||
s.ptsSeq[channelID] += count
|
||||
return s.ptsSeq[channelID]
|
||||
}
|
||||
|
||||
func transientChannelParticipantEvent(channelID, actorUserID int64, previous, participant domain.ChannelMember, date int) domain.ChannelUpdateEvent {
|
||||
return domain.ChannelUpdateEvent{
|
||||
ChannelID: channelID,
|
||||
Type: domain.ChannelUpdateParticipant,
|
||||
Date: date,
|
||||
SenderUserID: actorUserID,
|
||||
UserIDs: uniqueNonZeroInt64s(actorUserID, previous.UserID, previous.InviterUserID, participant.UserID, participant.InviterUserID),
|
||||
Previous: previous,
|
||||
Participant: participant,
|
||||
}
|
||||
}
|
||||
|
||||
func channelInitialAvailableMinPts(channel domain.Channel) int {
|
||||
return channel.Pts
|
||||
}
|
||||
|
||||
func adminLogEventMatchesFilter(typ domain.ChannelAdminLogEventType, filter domain.ChannelAdminLogFilter) bool {
|
||||
if filter.Empty() {
|
||||
return true
|
||||
}
|
||||
switch typ {
|
||||
case domain.ChannelAdminLogParticipantJoin:
|
||||
return filter.Join
|
||||
case domain.ChannelAdminLogParticipantLeave:
|
||||
return filter.Leave
|
||||
case domain.ChannelAdminLogParticipantInvite:
|
||||
return filter.Invite || filter.Invites
|
||||
case domain.ChannelAdminLogParticipantBan:
|
||||
return filter.Ban
|
||||
case domain.ChannelAdminLogParticipantUnban:
|
||||
return filter.Unban
|
||||
case domain.ChannelAdminLogParticipantKick:
|
||||
return filter.Kick
|
||||
case domain.ChannelAdminLogParticipantUnkick:
|
||||
return filter.Unkick
|
||||
case domain.ChannelAdminLogParticipantPromote:
|
||||
return filter.Promote
|
||||
case domain.ChannelAdminLogParticipantDemote:
|
||||
return filter.Demote
|
||||
case domain.ChannelAdminLogParticipantEditRank:
|
||||
return filter.EditRank
|
||||
case domain.ChannelAdminLogChangeTitle, domain.ChannelAdminLogChangeUsername, domain.ChannelAdminLogChangeLinkedChat, domain.ChannelAdminLogToggleSlowMode:
|
||||
return filter.Info
|
||||
case domain.ChannelAdminLogToggleSignatures, domain.ChannelAdminLogTogglePreHistoryHidden, domain.ChannelAdminLogToggleAntiSpam, domain.ChannelAdminLogToggleAutotranslation:
|
||||
return filter.Settings
|
||||
case domain.ChannelAdminLogToggleForum:
|
||||
return filter.Settings || filter.Forums
|
||||
case domain.ChannelAdminLogUpdatePinned:
|
||||
return filter.Pinned
|
||||
case domain.ChannelAdminLogEditMessage:
|
||||
return filter.Edit
|
||||
case domain.ChannelAdminLogDeleteMessage:
|
||||
return filter.Delete
|
||||
case domain.ChannelAdminLogSendMessage:
|
||||
return filter.Send
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func adminLogEventMatchesQuery(event domain.ChannelAdminLogEvent, query string) bool {
|
||||
if strings.Contains(strings.ToLower(event.PrevString), query) ||
|
||||
strings.Contains(strings.ToLower(event.NewString), query) ||
|
||||
strings.Contains(event.Query, query) {
|
||||
return true
|
||||
}
|
||||
for _, msg := range []*domain.ChannelMessage{event.Message, event.PrevMessage, event.NewMessage} {
|
||||
if msg != nil && strings.Contains(strings.ToLower(msg.Body), query) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloneChannelEvent(in domain.ChannelUpdateEvent) domain.ChannelUpdateEvent {
|
||||
in.Message = cloneChannelMessage(in.Message)
|
||||
in.MessageIDs = append([]int(nil), in.MessageIDs...)
|
||||
in.UserIDs = append([]int64(nil), in.UserIDs...)
|
||||
return in
|
||||
}
|
||||
68
internal/store/memory/channel_webpage_resolve_test.go
Normal file
68
internal/store/memory/channel_webpage_resolve_test.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TestChannelStoreResolveWebPage 验证频道 WebPageResolve 模式:只换 media、不碰 body/edit_date,
|
||||
// 事件为 channel_web_page,幂等。
|
||||
func TestChannelStoreResolveWebPage(t *testing.T) {
|
||||
st := NewChannelStore()
|
||||
ctx := context.Background()
|
||||
const creator = int64(1000000301)
|
||||
const url = "https://example.com/c"
|
||||
urlHash := domain.WebPageURLHash(url)
|
||||
|
||||
created, err := st.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: creator, Title: "WP", Megagroup: true, Date: 1700000000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channelID := created.Channel.ID
|
||||
|
||||
sent, err := st.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: creator, ChannelID: channelID, Message: "see " + url, Date: 1700000000,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStatePending, ID: urlHash, URL: url}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send channel message: %v", err)
|
||||
}
|
||||
|
||||
done := &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: urlHash, URL: url, Title: "Example"}}
|
||||
resolveReq := domain.EditChannelMessageRequest{
|
||||
UserID: creator, ChannelID: channelID, ID: sent.Message.ID,
|
||||
Media: done, WebPageResolve: true, ExpectedWebPageID: urlHash,
|
||||
}
|
||||
res, err := st.EditChannelMessage(ctx, resolveReq)
|
||||
if err != nil {
|
||||
t.Fatalf("EditChannelMessage(WebPageResolve): %v", err)
|
||||
}
|
||||
if res.Event.Type != domain.ChannelUpdateWebPage {
|
||||
t.Fatalf("event type = %q, want channel_web_page", res.Event.Type)
|
||||
}
|
||||
if res.Message.Media == nil || res.Message.Media.WebPage == nil || res.Message.Media.WebPage.State != domain.MessageWebPageStateDone {
|
||||
t.Fatalf("message media not resolved: %+v", res.Message.Media)
|
||||
}
|
||||
if res.Message.Body != "see "+url || res.Message.EditDate != 0 {
|
||||
t.Fatalf("body/edit_date changed: body=%q edit_date=%d", res.Message.Body, res.Message.EditDate)
|
||||
}
|
||||
|
||||
if _, err := st.EditChannelMessage(ctx, resolveReq); !errors.Is(err, domain.ErrMessageNotModified) {
|
||||
t.Fatalf("re-resolve err = %v, want ErrMessageNotModified", err)
|
||||
}
|
||||
|
||||
// 错误的 expectedID → 不替换。
|
||||
_, err = st.EditChannelMessage(ctx, domain.EditChannelMessageRequest{
|
||||
UserID: creator, ChannelID: channelID, ID: sent.Message.ID,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: 999, URL: url}},
|
||||
WebPageResolve: true, ExpectedWebPageID: 999,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrMessageNotModified) {
|
||||
t.Fatalf("wrong-id resolve err = %v, want ErrMessageNotModified (already resolved, not pending)", err)
|
||||
}
|
||||
}
|
||||
429
internal/store/memory/contacts.go
Normal file
429
internal/store/memory/contacts.go
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ContactStore 是 store.ContactStore 的内存实现。
|
||||
type ContactStore struct {
|
||||
mu sync.RWMutex
|
||||
m map[int64]domain.ContactList
|
||||
blocks map[int64]map[int64]domain.BlockedContact
|
||||
}
|
||||
|
||||
// NewContactStore 创建内存 ContactStore。
|
||||
func NewContactStore() *ContactStore {
|
||||
return &ContactStore{
|
||||
m: make(map[int64]domain.ContactList),
|
||||
blocks: make(map[int64]map[int64]domain.BlockedContact),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ContactStore) ListByUser(_ context.Context, userID int64) (domain.ContactList, error) {
|
||||
s.mu.RLock()
|
||||
list := s.m[userID]
|
||||
s.mu.RUnlock()
|
||||
list.Contacts = cloneContacts(list.Contacts)
|
||||
list.Hash = contactListHash(list.Contacts)
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) Get(_ context.Context, userID, contactUserID int64) (domain.Contact, bool, error) {
|
||||
s.mu.RLock()
|
||||
list := s.m[userID]
|
||||
s.mu.RUnlock()
|
||||
for _, contact := range list.Contacts {
|
||||
if contact.User.ID == contactUserID {
|
||||
return cloneContact(contact), true, nil
|
||||
}
|
||||
}
|
||||
return domain.Contact{}, false, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) GetMany(_ context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.Contact, error) {
|
||||
out := make(map[int64]domain.Contact, len(contactUserIDs))
|
||||
if userID == 0 || len(contactUserIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
want := make(map[int64]struct{}, len(contactUserIDs))
|
||||
for _, id := range contactUserIDs {
|
||||
if id != 0 {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
s.mu.RLock()
|
||||
list := s.m[userID]
|
||||
s.mu.RUnlock()
|
||||
for _, contact := range list.Contacts {
|
||||
if _, ok := want[contact.User.ID]; ok {
|
||||
out[contact.User.ID] = cloneContact(contact)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) GetReverseContacts(_ context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) {
|
||||
out := make(map[int64]domain.Contact, len(ownerUserIDs))
|
||||
if userID == 0 || len(ownerUserIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
want := make(map[int64]struct{}, len(ownerUserIDs))
|
||||
for _, id := range ownerUserIDs {
|
||||
if id != 0 {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for ownerID := range want {
|
||||
for _, contact := range s.m[ownerID].Contacts {
|
||||
if contact.User.ID == userID {
|
||||
out[ownerID] = cloneContact(contact)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) Upsert(_ context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||
contact := domain.Contact{
|
||||
User: domain.User{
|
||||
ID: input.ContactUserID,
|
||||
Phone: input.Phone,
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
Contact: true,
|
||||
},
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
Phone: input.Phone,
|
||||
Note: input.Note,
|
||||
NoteEntities: append([]domain.MessageEntity(nil), input.NoteEntities...),
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[userID]
|
||||
reverse := s.m[input.ContactUserID]
|
||||
for i := range reverse.Contacts {
|
||||
if reverse.Contacts[i].User.ID == userID {
|
||||
reverse.Contacts[i].Mutual = true
|
||||
reverse.Contacts[i].User.Mutual = true
|
||||
contact.Mutual = true
|
||||
contact.User.Mutual = true
|
||||
s.m[input.ContactUserID] = reverse
|
||||
break
|
||||
}
|
||||
}
|
||||
for i, existing := range list.Contacts {
|
||||
if existing.User.ID != input.ContactUserID {
|
||||
continue
|
||||
}
|
||||
contact.User.AccessHash = existing.User.AccessHash
|
||||
contact.User.Username = existing.User.Username
|
||||
contact.User.CountryCode = existing.User.CountryCode
|
||||
contact.User.Verified = existing.User.Verified
|
||||
contact.User.Support = existing.User.Support
|
||||
// premium/emoji/bot 列随快照保留(postgres 路径 JOIN users 始终带真实值;
|
||||
// 双 store 行为对齐,防止重复 Upsert 抹掉已知状态)。
|
||||
contact.User.Bot = existing.User.Bot
|
||||
contact.User.BotInfoVersion = existing.User.BotInfoVersion
|
||||
contact.User.PremiumUntil = existing.User.PremiumUntil
|
||||
contact.User.EmojiStatusDocumentID = existing.User.EmojiStatusDocumentID
|
||||
contact.User.EmojiStatusUntil = existing.User.EmojiStatusUntil
|
||||
contact.CloseFriend = existing.CloseFriend
|
||||
contact.User.CloseFriend = existing.CloseFriend || existing.User.CloseFriend
|
||||
if contact.Phone == "" {
|
||||
contact.User.Phone = existing.User.Phone
|
||||
}
|
||||
if contact.FirstName == "" {
|
||||
contact.User.FirstName = existing.User.FirstName
|
||||
}
|
||||
if contact.LastName == "" {
|
||||
contact.User.LastName = existing.User.LastName
|
||||
}
|
||||
list.Contacts[i] = contact
|
||||
list.Hash = contactListHash(list.Contacts)
|
||||
s.m[userID] = list
|
||||
return cloneContact(contact), nil
|
||||
}
|
||||
list.Contacts = append(list.Contacts, contact)
|
||||
list.Hash = contactListHash(list.Contacts)
|
||||
s.m[userID] = list
|
||||
return cloneContact(contact), nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error) {
|
||||
if len(inputs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]domain.Contact, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
contact, err := s.Upsert(ctx, userID, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, contact)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) UpdateNote(_ context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[userID]
|
||||
for i := range list.Contacts {
|
||||
if list.Contacts[i].User.ID != contactUserID {
|
||||
continue
|
||||
}
|
||||
list.Contacts[i].Note = note
|
||||
list.Contacts[i].NoteEntities = append([]domain.MessageEntity(nil), entities...)
|
||||
list.Hash = contactListHash(list.Contacts)
|
||||
s.m[userID] = list
|
||||
return cloneContact(list.Contacts[i]), true, nil
|
||||
}
|
||||
return domain.Contact{}, false, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) SetCloseFriends(_ context.Context, userID int64, contactUserIDs []int64) (domain.CloseFriendsEditResult, error) {
|
||||
want := make(map[int64]struct{}, len(contactUserIDs))
|
||||
for _, id := range contactUserIDs {
|
||||
if id > 0 && id != userID {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[userID]
|
||||
changed := false
|
||||
var result domain.CloseFriendsEditResult
|
||||
for i := range list.Contacts {
|
||||
wasCloseFriend := list.Contacts[i].CloseFriend || list.Contacts[i].User.CloseFriend
|
||||
_, closeFriend := want[list.Contacts[i].User.ID]
|
||||
if list.Contacts[i].CloseFriend == closeFriend && list.Contacts[i].User.CloseFriend == closeFriend {
|
||||
continue
|
||||
}
|
||||
list.Contacts[i].CloseFriend = closeFriend
|
||||
list.Contacts[i].User.CloseFriend = closeFriend
|
||||
switch {
|
||||
case !wasCloseFriend && closeFriend:
|
||||
result.AddedUserIDs = append(result.AddedUserIDs, list.Contacts[i].User.ID)
|
||||
case wasCloseFriend && !closeFriend:
|
||||
result.RemovedUserIDs = append(result.RemovedUserIDs, list.Contacts[i].User.ID)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
list.Hash = contactListHash(list.Contacts)
|
||||
s.m[userID] = list
|
||||
}
|
||||
sort.Slice(result.AddedUserIDs, func(i, j int) bool { return result.AddedUserIDs[i] < result.AddedUserIDs[j] })
|
||||
sort.Slice(result.RemovedUserIDs, func(i, j int) bool { return result.RemovedUserIDs[i] < result.RemovedUserIDs[j] })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) SetPersonalPhoto(_ context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) {
|
||||
_ = date
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[userID]
|
||||
for i := range list.Contacts {
|
||||
if list.Contacts[i].User.ID != contactUserID {
|
||||
continue
|
||||
}
|
||||
list.Contacts[i].User.PhotoID = photoID
|
||||
list.Contacts[i].User.PhotoPersonal = photoID != 0
|
||||
list.Hash = contactListHash(list.Contacts)
|
||||
s.m[userID] = list
|
||||
return cloneContact(list.Contacts[i]), true, nil
|
||||
}
|
||||
return domain.Contact{}, false, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) PersonalPhotos(_ context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
out := make(map[int64]domain.ProfilePhotoRef, len(contactUserIDs))
|
||||
if userID == 0 || len(contactUserIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
want := make(map[int64]struct{}, len(contactUserIDs))
|
||||
for _, id := range contactUserIDs {
|
||||
if id != 0 {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
s.mu.RLock()
|
||||
list := s.m[userID]
|
||||
s.mu.RUnlock()
|
||||
for _, contact := range list.Contacts {
|
||||
if _, ok := want[contact.User.ID]; !ok || contact.User.PhotoID == 0 {
|
||||
continue
|
||||
}
|
||||
out[contact.User.ID] = domain.ProfilePhotoRef{
|
||||
PhotoID: contact.User.PhotoID,
|
||||
DCID: contact.User.PhotoDCID,
|
||||
Stripped: append([]byte(nil), contact.User.PhotoStripped...),
|
||||
Personal: true,
|
||||
HasVideo: contact.User.PhotoHasVideo,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) Delete(_ context.Context, userID int64, contactUserIDs []int64) (int, error) {
|
||||
remove := make(map[int64]struct{}, len(contactUserIDs))
|
||||
for _, id := range contactUserIDs {
|
||||
if id != 0 {
|
||||
remove[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(remove) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[userID]
|
||||
out := list.Contacts[:0]
|
||||
deleted := 0
|
||||
for _, contact := range list.Contacts {
|
||||
if _, ok := remove[contact.User.ID]; ok {
|
||||
deleted++
|
||||
if reverse := s.m[contact.User.ID]; len(reverse.Contacts) > 0 {
|
||||
for i := range reverse.Contacts {
|
||||
if reverse.Contacts[i].User.ID == userID {
|
||||
reverse.Contacts[i].Mutual = false
|
||||
reverse.Contacts[i].User.Mutual = false
|
||||
}
|
||||
}
|
||||
reverse.Hash = contactListHash(reverse.Contacts)
|
||||
s.m[contact.User.ID] = reverse
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, contact)
|
||||
}
|
||||
list.Contacts = out
|
||||
list.Hash = contactListHash(list.Contacts)
|
||||
s.m[userID] = list
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) Block(_ context.Context, userID, blockedUserID int64, date int) (bool, error) {
|
||||
if userID == 0 || blockedUserID == 0 || userID == blockedUserID {
|
||||
return false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.blocks[userID] == nil {
|
||||
s.blocks[userID] = make(map[int64]domain.BlockedContact)
|
||||
}
|
||||
_, existed := s.blocks[userID][blockedUserID]
|
||||
s.blocks[userID][blockedUserID] = domain.BlockedContact{
|
||||
User: domain.User{ID: blockedUserID},
|
||||
Date: date,
|
||||
}
|
||||
return !existed, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) Unblock(_ context.Context, userID, blockedUserID int64) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.blocks[userID] == nil {
|
||||
return false, nil
|
||||
}
|
||||
_, existed := s.blocks[userID][blockedUserID]
|
||||
delete(s.blocks[userID], blockedUserID)
|
||||
return existed, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) IsBlocked(_ context.Context, userID, blockedUserID int64) (bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, blocked := s.blocks[userID][blockedUserID]
|
||||
return blocked, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) ListBlocked(_ context.Context, userID int64, offset, limit int) (domain.BlockedContactList, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
items := make([]domain.BlockedContact, 0, len(s.blocks[userID]))
|
||||
for _, item := range s.blocks[userID] {
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].Date == items[j].Date {
|
||||
return items[i].User.ID > items[j].User.ID
|
||||
}
|
||||
return items[i].Date > items[j].Date
|
||||
})
|
||||
total := len(items)
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= len(items) {
|
||||
return domain.BlockedContactList{Count: total}, nil
|
||||
}
|
||||
if limit <= 0 || limit > len(items)-offset {
|
||||
limit = len(items) - offset
|
||||
}
|
||||
out := append([]domain.BlockedContact(nil), items[offset:offset+limit]...)
|
||||
return domain.BlockedContactList{Blocked: out, Count: total}, nil
|
||||
}
|
||||
|
||||
// SaveList 保存一份用户通讯录,供测试和本地替身使用。
|
||||
func (s *ContactStore) SaveList(_ context.Context, userID int64, list domain.ContactList) error {
|
||||
list.Contacts = cloneContacts(list.Contacts)
|
||||
list.Hash = contactListHash(list.Contacts)
|
||||
s.mu.Lock()
|
||||
s.m[userID] = list
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneContacts(contacts []domain.Contact) []domain.Contact {
|
||||
out := append([]domain.Contact(nil), contacts...)
|
||||
for i := range out {
|
||||
out[i] = cloneContact(out[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneContact(contact domain.Contact) domain.Contact {
|
||||
contact.NoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...)
|
||||
return contact
|
||||
}
|
||||
|
||||
func contactListHash(contacts []domain.Contact) int64 {
|
||||
if len(contacts) == 0 {
|
||||
return 0
|
||||
}
|
||||
h := fnv.New64a()
|
||||
var buf [16]byte
|
||||
for _, contact := range contacts {
|
||||
binary.LittleEndian.PutUint64(buf[:8], uint64(contact.User.ID))
|
||||
if contact.Mutual {
|
||||
buf[8] = 1
|
||||
} else {
|
||||
buf[8] = 0
|
||||
}
|
||||
if contact.CloseFriend || contact.User.CloseFriend {
|
||||
buf[9] = 1
|
||||
} else {
|
||||
buf[9] = 0
|
||||
}
|
||||
_, _ = h.Write(buf[:10])
|
||||
_, _ = h.Write([]byte(contact.FirstName))
|
||||
_, _ = h.Write([]byte{0})
|
||||
_, _ = h.Write([]byte(contact.LastName))
|
||||
_, _ = h.Write([]byte{0})
|
||||
_, _ = h.Write([]byte(contact.Phone))
|
||||
_, _ = h.Write([]byte{0})
|
||||
_, _ = h.Write([]byte(contact.Note))
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
return int64(h.Sum64())
|
||||
}
|
||||
|
|
@ -51,6 +51,42 @@ func TestDialogStoreFiltersAndPaginates(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDialogStorePinnedOrderSortsHighestFirst(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewDialogStore()
|
||||
userID := int64(100)
|
||||
oldPinned := domain.Peer{Type: domain.PeerTypeUser, ID: 1}
|
||||
newPinned := domain.Peer{Type: domain.PeerTypeUser, ID: 2}
|
||||
if err := store.SaveList(ctx, userID, domain.DialogList{
|
||||
Dialogs: []domain.Dialog{
|
||||
{
|
||||
Peer: oldPinned,
|
||||
TopMessage: 20,
|
||||
TopMessageDate: 2000,
|
||||
Pinned: true,
|
||||
PinnedOrder: 1,
|
||||
},
|
||||
{
|
||||
Peer: newPinned,
|
||||
TopMessage: 10,
|
||||
TopMessageDate: 1000,
|
||||
Pinned: true,
|
||||
PinnedOrder: 2,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveList: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.ListByUser(ctx, userID, domain.DialogFilter{PinnedOnly: true, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListByUser pinned: %v", err)
|
||||
}
|
||||
if len(got.Dialogs) != 2 || got.Dialogs[0].Peer != newPinned || got.Dialogs[1].Peer != oldPinned {
|
||||
t.Fatalf("pinned dialogs = %+v, want highest pinned_order first", got.Dialogs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogStoreFoldersAndCustomFilters(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewDialogStore()
|
||||
|
|
@ -227,3 +263,88 @@ func TestDialogStoreListByPeersReturnsExistingAndPlaceholders(t *testing.T) {
|
|||
t.Fatalf("users = %+v, want official user", got.Users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogStoreSetUnreadMarkOnlyReportsRealChanges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewDialogStore()
|
||||
userID := int64(100)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 200}
|
||||
missing := domain.Peer{Type: domain.PeerTypeUser, ID: 999}
|
||||
if err := store.SaveList(ctx, userID, domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 10, TopMessageDate: 1000}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveList: %v", err)
|
||||
}
|
||||
|
||||
// 首次置位为真变化。
|
||||
if changed, err := store.SetUnreadMark(ctx, userID, peer, true); err != nil || !changed {
|
||||
t.Fatalf("first mark = (%v, %v), want (true, nil)", changed, err)
|
||||
}
|
||||
// 重复标记同值不应算 changed(值守卫;否则上层记幽灵 durable 事件 + 多推 update)。
|
||||
if changed, err := store.SetUnreadMark(ctx, userID, peer, true); err != nil || changed {
|
||||
t.Fatalf("repeat same value = (%v, %v), want (false, nil)", changed, err)
|
||||
}
|
||||
// 改回相反值是真变化。
|
||||
if changed, err := store.SetUnreadMark(ctx, userID, peer, false); err != nil || !changed {
|
||||
t.Fatalf("flip value = (%v, %v), want (true, nil)", changed, err)
|
||||
}
|
||||
// 不存在的会话行无法标记。
|
||||
if changed, err := store.SetUnreadMark(ctx, userID, missing, true); err != nil || changed {
|
||||
t.Fatalf("missing peer = (%v, %v), want (false, nil)", changed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogStoreMarkReadClampsFutureMaxID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewDialogStore()
|
||||
userID := int64(100)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 200}
|
||||
if err := store.SaveList(ctx, userID, domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{
|
||||
Peer: peer,
|
||||
TopMessage: 10,
|
||||
TopMessageDate: 1000,
|
||||
ReadInboxMaxID: 4,
|
||||
UnreadCount: 2,
|
||||
UnreadMentions: 1,
|
||||
UnreadReactions: 1,
|
||||
UnreadMark: true,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveList: %v", err)
|
||||
}
|
||||
|
||||
read, err := store.MarkRead(ctx, userID, peer, 8)
|
||||
if err != nil {
|
||||
t.Fatalf("MarkRead partial: %v", err)
|
||||
}
|
||||
if read.MaxID != 8 || read.StillUnreadCount != 2 {
|
||||
t.Fatalf("partial read = %+v, want max 8 with existing unread count preserved", read)
|
||||
}
|
||||
list, err := store.ListByPeers(ctx, userID, []domain.Peer{peer})
|
||||
if err != nil {
|
||||
t.Fatalf("ListByPeers partial: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 1 || list.Dialogs[0].ReadInboxMaxID != 8 || list.Dialogs[0].UnreadCount != 2 {
|
||||
t.Fatalf("dialog after partial read = %+v, want read 8 and unread preserved", list.Dialogs)
|
||||
}
|
||||
|
||||
read, err = store.MarkRead(ctx, userID, peer, domain.MaxMessageBoxID)
|
||||
if err != nil {
|
||||
t.Fatalf("MarkRead: %v", err)
|
||||
}
|
||||
if read.MaxID != 10 {
|
||||
t.Fatalf("read max = %d, want top message 10", read.MaxID)
|
||||
}
|
||||
list, err = store.ListByPeers(ctx, userID, []domain.Peer{peer})
|
||||
if err != nil {
|
||||
t.Fatalf("ListByPeers: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 1 {
|
||||
t.Fatalf("dialogs = %+v, want one dialog", list.Dialogs)
|
||||
}
|
||||
dialog := list.Dialogs[0]
|
||||
if dialog.ReadInboxMaxID != 10 || dialog.UnreadCount != 0 || dialog.UnreadMentions != 0 || dialog.UnreadReactions != 0 || dialog.UnreadMark {
|
||||
t.Fatalf("dialog after read = %+v, want read clamped to top and unread cleared", dialog)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
924
internal/store/memory/dialogs.go
Normal file
924
internal/store/memory/dialogs.go
Normal file
|
|
@ -0,0 +1,924 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// DialogStore 是 store.DialogStore 的内存实现。
|
||||
type DialogStore struct {
|
||||
mu sync.RWMutex
|
||||
m map[int64]domain.DialogList
|
||||
drafts map[int64]map[dialogDraftKey]domain.DialogDraft
|
||||
folders map[int64]map[int]domain.DialogFolder
|
||||
folderOrder map[int64][]int
|
||||
folderTags map[int64]bool
|
||||
// archivePinned 记录 archive folder 行置顶状态;无记录时官方默认 true。
|
||||
archivePinned map[int64]bool
|
||||
}
|
||||
|
||||
type dialogDraftKey struct {
|
||||
peerType domain.PeerType
|
||||
peerID int64
|
||||
topMessageID int
|
||||
}
|
||||
|
||||
// NewDialogStore 创建内存 DialogStore。
|
||||
func NewDialogStore() *DialogStore {
|
||||
return &DialogStore{
|
||||
m: make(map[int64]domain.DialogList),
|
||||
drafts: make(map[int64]map[dialogDraftKey]domain.DialogDraft),
|
||||
folders: make(map[int64]map[int]domain.DialogFolder),
|
||||
folderOrder: make(map[int64][]int),
|
||||
folderTags: make(map[int64]bool),
|
||||
archivePinned: make(map[int64]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DialogStore) ListByUser(_ context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
|
||||
s.mu.RLock()
|
||||
list := s.m[userID]
|
||||
s.mu.RUnlock()
|
||||
list.Dialogs = cloneDialogs(list.Dialogs)
|
||||
list.Messages = cloneMessages(list.Messages)
|
||||
list.Users = append([]domain.User(nil), list.Users...)
|
||||
return filterDialogList(list, filter), nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) ListByPeers(_ context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
|
||||
s.mu.RLock()
|
||||
list := s.m[userID]
|
||||
s.mu.RUnlock()
|
||||
list.Dialogs = cloneDialogs(list.Dialogs)
|
||||
list.Messages = cloneMessages(list.Messages)
|
||||
list.Users = append([]domain.User(nil), list.Users...)
|
||||
|
||||
byPeer := make(map[domain.Peer]domain.Dialog, len(list.Dialogs))
|
||||
for _, dialog := range list.Dialogs {
|
||||
byPeer[dialog.Peer] = dialog
|
||||
}
|
||||
out := domain.DialogList{
|
||||
Dialogs: make([]domain.Dialog, 0, len(peers)),
|
||||
Users: make([]domain.User, 0, len(peers)),
|
||||
}
|
||||
seenPeers := make(map[domain.Peer]struct{}, len(peers))
|
||||
seenUsers := map[int64]struct{}{}
|
||||
for _, peer := range peers {
|
||||
if _, ok := seenPeers[peer]; ok {
|
||||
continue
|
||||
}
|
||||
seenPeers[peer] = struct{}{}
|
||||
dialog := byPeer[peer]
|
||||
if dialog.Peer.ID == 0 {
|
||||
dialog.Peer = peer
|
||||
}
|
||||
out.Dialogs = append(out.Dialogs, dialog)
|
||||
if peer.Type == domain.PeerTypeUser {
|
||||
if user, ok := findDialogUser(list.Users, peer.ID); ok {
|
||||
appendDialogUser(&out, seenUsers, user)
|
||||
} else if u, ok := domain.SystemUserByID(peer.ID); ok {
|
||||
appendDialogUser(&out, seenUsers, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
out.Messages = keepDialogMessages(list.Messages, out.Dialogs)
|
||||
out.Count = len(out.Dialogs)
|
||||
out.Hash = dialogListHash(out.Dialogs)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveList 保存一份用户会话列表,供测试和本地替身使用。
|
||||
func (s *DialogStore) SaveList(_ context.Context, userID int64, list domain.DialogList) error {
|
||||
list.Dialogs = cloneDialogs(list.Dialogs)
|
||||
list.Messages = cloneMessages(list.Messages)
|
||||
list.Users = append([]domain.User(nil), list.Users...)
|
||||
s.mu.Lock()
|
||||
s.m[userID] = list
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) Upsert(_ context.Context, userID int64, dialog domain.Dialog) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[userID]
|
||||
for i, existing := range list.Dialogs {
|
||||
if existing.Peer == dialog.Peer {
|
||||
if dialog.FolderID == domain.DialogMainFolderID && existing.FolderID != domain.DialogMainFolderID {
|
||||
dialog.FolderID = existing.FolderID
|
||||
}
|
||||
list.Dialogs[i] = dialog
|
||||
s.m[userID] = list
|
||||
return nil
|
||||
}
|
||||
}
|
||||
list.Dialogs = append(list.Dialogs, dialog)
|
||||
s.m[userID] = list
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) UpsertInbox(_ context.Context, userID int64, dialog domain.Dialog) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[userID]
|
||||
for i, existing := range list.Dialogs {
|
||||
if existing.Peer != dialog.Peer {
|
||||
continue
|
||||
}
|
||||
if dialog.TopMessage >= existing.TopMessage {
|
||||
existing.TopMessage = dialog.TopMessage
|
||||
existing.TopMessageDate = dialog.TopMessageDate
|
||||
}
|
||||
existing.UnreadCount = countInboxUnread(list, existing.Peer, existing.ReadInboxMaxID, existing.TopMessage)
|
||||
list.Dialogs[i] = existing
|
||||
s.m[userID] = list
|
||||
return nil
|
||||
}
|
||||
dialog.UnreadCount = countInboxUnread(list, dialog.Peer, dialog.ReadInboxMaxID, dialog.TopMessage)
|
||||
if dialog.UnreadCount == 0 {
|
||||
dialog.UnreadCount = 1
|
||||
}
|
||||
list.Dialogs = append(list.Dialogs, dialog)
|
||||
s.m[userID] = list
|
||||
return nil
|
||||
}
|
||||
|
||||
func countInboxUnread(list domain.DialogList, peer domain.Peer, readMax, topMessage int) int {
|
||||
unread := 0
|
||||
for _, msg := range list.Messages {
|
||||
if msg.Peer == peer && !msg.Out && msg.ID > readMax && (topMessage <= 0 || msg.ID <= topMessage) {
|
||||
unread++
|
||||
}
|
||||
}
|
||||
return unread
|
||||
}
|
||||
|
||||
func (s *DialogStore) SaveDraft(_ context.Context, userID int64, draft domain.DialogDraft) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.drafts[userID] == nil {
|
||||
s.drafts[userID] = make(map[dialogDraftKey]domain.DialogDraft)
|
||||
}
|
||||
s.drafts[userID][draftKey(draft.Peer, draft.TopMessageID)] = cloneDialogDraft(draft)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) GetDraft(_ context.Context, userID int64, peer domain.Peer, topMessageID int) (domain.DialogDraft, bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
draft, ok := s.drafts[userID][draftKey(peer, topMessageID)]
|
||||
if !ok {
|
||||
return domain.DialogDraft{}, false, nil
|
||||
}
|
||||
return cloneDialogDraft(draft), true, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) DeleteDraft(_ context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
items := s.drafts[userID]
|
||||
if len(items) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
key := draftKey(peer, topMessageID)
|
||||
if _, ok := items[key]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(items, key)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) ListDrafts(_ context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
|
||||
s.mu.RLock()
|
||||
items := s.drafts[userID]
|
||||
out := make([]domain.DialogDraft, 0, len(items))
|
||||
for _, draft := range items {
|
||||
out = append(out, cloneDialogDraft(draft))
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
sortDialogDrafts(out)
|
||||
if limit <= 0 || limit > domain.MaxDialogDraftsPerUser {
|
||||
limit = domain.MaxDialogDraftsPerUser
|
||||
}
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) ClearDrafts(_ context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
|
||||
if limit <= 0 || limit > domain.MaxDialogDraftsPerUser {
|
||||
limit = domain.MaxDialogDraftsPerUser
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
items := s.drafts[userID]
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]domain.DialogDraft, 0, len(items))
|
||||
for _, draft := range items {
|
||||
out = append(out, cloneDialogDraft(draft))
|
||||
}
|
||||
sortDialogDrafts(out)
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
for _, draft := range out {
|
||||
delete(items, draftKey(draft.Peer, draft.TopMessageID))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) MarkRead(_ context.Context, userID int64, peer domain.Peer, maxID int) (domain.ReadHistoryResult, error) {
|
||||
result := domain.ReadHistoryResult{OwnerUserID: userID, Peer: peer, MaxID: maxID}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[userID]
|
||||
for i, dialog := range list.Dialogs {
|
||||
if dialog.Peer != peer {
|
||||
continue
|
||||
}
|
||||
readMax := maxID
|
||||
if readMax <= 0 {
|
||||
readMax = dialog.TopMessage
|
||||
}
|
||||
if readMax > dialog.TopMessage {
|
||||
readMax = dialog.TopMessage
|
||||
}
|
||||
result.MaxID = readMax
|
||||
result.Changed = dialog.UnreadCount > 0 || readMax > dialog.ReadInboxMaxID
|
||||
if readMax > dialog.ReadInboxMaxID {
|
||||
dialog.ReadInboxMaxID = readMax
|
||||
}
|
||||
if readMax >= dialog.TopMessage {
|
||||
dialog.UnreadCount = 0
|
||||
dialog.UnreadMentions = 0
|
||||
dialog.UnreadReactions = 0
|
||||
}
|
||||
dialog.UnreadMark = false
|
||||
result.StillUnreadCount = dialog.UnreadCount
|
||||
list.Dialogs[i] = dialog
|
||||
s.m[userID] = list
|
||||
return result, nil
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) SetPinned(_ context.Context, userID int64, peer domain.Peer, pinned bool) (bool, int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[userID]
|
||||
targetFolderID := domain.DialogMainFolderID
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer == peer {
|
||||
targetFolderID = dialog.FolderID
|
||||
break
|
||||
}
|
||||
}
|
||||
// order 仅在目标会话所在 folder 内分配;memory 双 store 互不可见,
|
||||
// 与 postgres 跨表统一 order 空间相比此处只看私聊表(reorder 会统一重排)。
|
||||
nextOrder := 1
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Pinned && dialog.FolderID == targetFolderID && dialog.PinnedOrder >= nextOrder {
|
||||
nextOrder = dialog.PinnedOrder + 1
|
||||
}
|
||||
}
|
||||
for i := range list.Dialogs {
|
||||
if list.Dialogs[i].Peer != peer {
|
||||
continue
|
||||
}
|
||||
list.Dialogs[i].Pinned = pinned
|
||||
if pinned {
|
||||
if list.Dialogs[i].PinnedOrder == 0 {
|
||||
list.Dialogs[i].PinnedOrder = nextOrder
|
||||
}
|
||||
} else {
|
||||
list.Dialogs[i].PinnedOrder = 0
|
||||
}
|
||||
s.m[userID] = list
|
||||
return true, targetFolderID, nil
|
||||
}
|
||||
return false, 0, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) ReorderPinned(_ context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[userID]
|
||||
changed := false
|
||||
positions := make(map[domain.Peer]int, len(order))
|
||||
for i, peer := range order {
|
||||
if peer.Type == "" || peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := positions[peer]; ok {
|
||||
continue
|
||||
}
|
||||
positions[peer] = len(order) - i
|
||||
}
|
||||
for i := range list.Dialogs {
|
||||
if list.Dialogs[i].FolderID != folderID {
|
||||
continue
|
||||
}
|
||||
pos, ok := positions[list.Dialogs[i].Peer]
|
||||
if ok {
|
||||
if !list.Dialogs[i].Pinned || list.Dialogs[i].PinnedOrder != pos {
|
||||
changed = true
|
||||
}
|
||||
list.Dialogs[i].Pinned = true
|
||||
list.Dialogs[i].PinnedOrder = pos
|
||||
continue
|
||||
}
|
||||
if force && list.Dialogs[i].Pinned {
|
||||
changed = true
|
||||
list.Dialogs[i].Pinned = false
|
||||
list.Dialogs[i].PinnedOrder = 0
|
||||
}
|
||||
}
|
||||
s.m[userID] = list
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) SetUnreadMark(_ context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[userID]
|
||||
for i := range list.Dialogs {
|
||||
if list.Dialogs[i].Peer != peer {
|
||||
continue
|
||||
}
|
||||
// 值守卫对齐 postgres:重复标记同值返回 changed=false,避免幽灵事件。
|
||||
if list.Dialogs[i].UnreadMark == unread {
|
||||
return false, nil
|
||||
}
|
||||
list.Dialogs[i].UnreadMark = unread
|
||||
s.m[userID] = list
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) ListUnreadMarked(_ context.Context, userID int64) ([]domain.Peer, error) {
|
||||
s.mu.RLock()
|
||||
list := s.m[userID]
|
||||
s.mu.RUnlock()
|
||||
out := make([]domain.Peer, 0, len(list.Dialogs))
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.UnreadMark {
|
||||
out = append(out, dialog.Peer)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) SetChatTheme(_ context.Context, userID int64, peer domain.Peer, emoticon string) (bool, error) {
|
||||
if userID == 0 || peer.Type == "" || peer.ID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[userID]
|
||||
for i := range list.Dialogs {
|
||||
if list.Dialogs[i].Peer != peer {
|
||||
continue
|
||||
}
|
||||
if list.Dialogs[i].ThemeEmoticon == emoticon {
|
||||
return false, nil
|
||||
}
|
||||
list.Dialogs[i].ThemeEmoticon = emoticon
|
||||
s.m[userID] = list
|
||||
return true, nil
|
||||
}
|
||||
if emoticon == "" {
|
||||
return false, nil
|
||||
}
|
||||
list.Dialogs = append(list.Dialogs, domain.Dialog{Peer: peer, ThemeEmoticon: emoticon})
|
||||
s.m[userID] = list
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) SetPeerSettingsBarHidden(_ context.Context, userID int64, peer domain.Peer) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[userID]
|
||||
for i := range list.Dialogs {
|
||||
if list.Dialogs[i].Peer != peer {
|
||||
continue
|
||||
}
|
||||
list.Dialogs[i].PeerSettingsBarHidden = true
|
||||
s.m[userID] = list
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) PeerSettingsBarHidden(_ context.Context, userID int64, peer domain.Peer) (bool, error) {
|
||||
s.mu.RLock()
|
||||
list := s.m[userID]
|
||||
s.mu.RUnlock()
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer == peer {
|
||||
return dialog.PeerSettingsBarHidden, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) ListFolders(_ context.Context, userID int64) (domain.DialogFolderList, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
byID := s.folders[userID]
|
||||
order := append([]int(nil), s.folderOrder[userID]...)
|
||||
seen := make(map[int]struct{}, len(byID))
|
||||
out := domain.DialogFolderList{
|
||||
TagsEnabled: s.folderTags[userID],
|
||||
Folders: make([]domain.DialogFolder, 0, len(byID)),
|
||||
}
|
||||
for _, id := range order {
|
||||
folder, ok := byID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out.Folders = append(out.Folders, cloneDialogFolder(folder))
|
||||
}
|
||||
remaining := make([]int, 0, len(byID))
|
||||
for id := range byID {
|
||||
if _, ok := seen[id]; !ok {
|
||||
remaining = append(remaining, id)
|
||||
}
|
||||
}
|
||||
sort.Ints(remaining)
|
||||
for _, id := range remaining {
|
||||
out.Folders = append(out.Folders, cloneDialogFolder(byID[id]))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) GetFolder(_ context.Context, userID int64, folderID int) (domain.DialogFolder, bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
folder, ok := s.folders[userID][folderID]
|
||||
if !ok {
|
||||
return domain.DialogFolder{}, false, nil
|
||||
}
|
||||
return cloneDialogFolder(folder), true, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) UpsertFolder(_ context.Context, userID int64, folder domain.DialogFolder) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.folders[userID] == nil {
|
||||
s.folders[userID] = make(map[int]domain.DialogFolder)
|
||||
}
|
||||
s.folders[userID][folder.ID] = cloneDialogFolder(folder)
|
||||
if !containsInt(s.folderOrder[userID], folder.ID) {
|
||||
s.folderOrder[userID] = append(s.folderOrder[userID], folder.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) DeleteFolder(_ context.Context, userID int64, folderID int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.folders[userID], folderID)
|
||||
s.folderOrder[userID] = removeInt(s.folderOrder[userID], folderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) ReorderFolders(_ context.Context, userID int64, order []int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
byID := s.folders[userID]
|
||||
seen := make(map[int]struct{}, len(order))
|
||||
next := make([]int, 0, len(byID))
|
||||
for _, id := range order {
|
||||
if id < domain.DialogCustomFolderMinID {
|
||||
continue
|
||||
}
|
||||
if _, ok := byID[id]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
next = append(next, id)
|
||||
}
|
||||
remaining := make([]int, 0, len(byID))
|
||||
for id := range byID {
|
||||
if _, ok := seen[id]; !ok {
|
||||
remaining = append(remaining, id)
|
||||
}
|
||||
}
|
||||
sort.Ints(remaining)
|
||||
next = append(next, remaining...)
|
||||
s.folderOrder[userID] = next
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) SetFolderTagsEnabled(_ context.Context, userID int64, enabled bool) error {
|
||||
s.mu.Lock()
|
||||
s.folderTags[userID] = enabled
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) EditPeerFolders(_ context.Context, userID int64, peers []domain.FolderPeerUpdate) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[userID]
|
||||
updates := make(map[domain.Peer]int, len(peers))
|
||||
for _, item := range peers {
|
||||
if item.Peer.Type == "" || item.Peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if item.FolderID != domain.DialogMainFolderID && item.FolderID != domain.DialogArchiveFolderID {
|
||||
continue
|
||||
}
|
||||
updates[item.Peer] = item.FolderID
|
||||
}
|
||||
for i := range list.Dialogs {
|
||||
folderID, ok := updates[list.Dialogs[i].Peer]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 换 folder 时清 pinned:TDesktop 在归档/还原时本地无条件 unpin,
|
||||
// 服务端保留旧 pin 会在下次 getDialogs 时把状态漂移回来。
|
||||
if list.Dialogs[i].FolderID != folderID {
|
||||
list.Dialogs[i].Pinned = false
|
||||
list.Dialogs[i].PinnedOrder = 0
|
||||
}
|
||||
list.Dialogs[i].FolderID = folderID
|
||||
}
|
||||
s.m[userID] = list
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) SetArchivePinned(_ context.Context, userID int64, pinned bool) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
current, ok := s.archivePinned[userID]
|
||||
if !ok {
|
||||
current = true
|
||||
}
|
||||
s.archivePinned[userID] = pinned
|
||||
return current != pinned, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) ArchivePinned(_ context.Context, userID int64) (bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if pinned, ok := s.archivePinned[userID]; ok {
|
||||
return pinned, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) CountArchiveUnread(_ context.Context, userID int64) (int, int, error) {
|
||||
s.mu.RLock()
|
||||
list := s.m[userID]
|
||||
s.mu.RUnlock()
|
||||
peers, messages := 0, 0
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.FolderID != domain.DialogArchiveFolderID {
|
||||
continue
|
||||
}
|
||||
if dialog.UnreadCount > 0 || dialog.UnreadMark {
|
||||
peers++
|
||||
}
|
||||
messages += dialog.UnreadCount
|
||||
}
|
||||
return peers, messages, nil
|
||||
}
|
||||
|
||||
func upsertMemoryDialog(list domain.DialogList, dialog domain.Dialog) domain.DialogList {
|
||||
for i := range list.Dialogs {
|
||||
if list.Dialogs[i].Peer == dialog.Peer {
|
||||
if dialog.ReadInboxMaxID == 0 {
|
||||
dialog.ReadInboxMaxID = list.Dialogs[i].ReadInboxMaxID
|
||||
}
|
||||
if dialog.ReadOutboxMaxID == 0 {
|
||||
dialog.ReadOutboxMaxID = list.Dialogs[i].ReadOutboxMaxID
|
||||
}
|
||||
if dialog.FolderID == domain.DialogMainFolderID && list.Dialogs[i].FolderID != domain.DialogMainFolderID {
|
||||
dialog.FolderID = list.Dialogs[i].FolderID
|
||||
}
|
||||
if dialog.UnreadCount == 0 {
|
||||
dialog.UnreadCount = list.Dialogs[i].UnreadCount
|
||||
}
|
||||
if dialog.UnreadMentions == 0 {
|
||||
dialog.UnreadMentions = list.Dialogs[i].UnreadMentions
|
||||
}
|
||||
if dialog.UnreadReactions == 0 {
|
||||
dialog.UnreadReactions = list.Dialogs[i].UnreadReactions
|
||||
}
|
||||
if !dialog.UnreadMark {
|
||||
dialog.UnreadMark = list.Dialogs[i].UnreadMark
|
||||
}
|
||||
// 与 postgres 的 ON CONFLICT 局部更新对齐:消息路径的 upsert
|
||||
// 不得抹掉既有置顶/设置类状态。
|
||||
if !dialog.Pinned && list.Dialogs[i].Pinned {
|
||||
dialog.Pinned = list.Dialogs[i].Pinned
|
||||
dialog.PinnedOrder = list.Dialogs[i].PinnedOrder
|
||||
}
|
||||
if dialog.TTLPeriod == 0 {
|
||||
dialog.TTLPeriod = list.Dialogs[i].TTLPeriod
|
||||
}
|
||||
if dialog.ThemeEmoticon == "" {
|
||||
dialog.ThemeEmoticon = list.Dialogs[i].ThemeEmoticon
|
||||
}
|
||||
if !dialog.HasScheduled {
|
||||
dialog.HasScheduled = list.Dialogs[i].HasScheduled
|
||||
}
|
||||
if !dialog.PeerSettingsBarHidden {
|
||||
dialog.PeerSettingsBarHidden = list.Dialogs[i].PeerSettingsBarHidden
|
||||
}
|
||||
if !dialog.ViewForumAsMessages {
|
||||
dialog.ViewForumAsMessages = list.Dialogs[i].ViewForumAsMessages
|
||||
}
|
||||
if dialog.Draft == nil {
|
||||
dialog.Draft = list.Dialogs[i].Draft
|
||||
}
|
||||
list.Dialogs[i] = dialog
|
||||
return list
|
||||
}
|
||||
}
|
||||
list.Dialogs = append(list.Dialogs, dialog)
|
||||
return list
|
||||
}
|
||||
|
||||
func filterDialogList(list domain.DialogList, filter domain.DialogFilter) domain.DialogList {
|
||||
sort.SliceStable(list.Dialogs, func(i, j int) bool {
|
||||
return dialogLess(list.Dialogs[i], list.Dialogs[j])
|
||||
})
|
||||
|
||||
base := make([]domain.Dialog, 0, len(list.Dialogs))
|
||||
for _, d := range list.Dialogs {
|
||||
if !dialogMatchesFolder(d, list.Users, filter) {
|
||||
continue
|
||||
}
|
||||
if filter.PinnedOnly && !d.Pinned {
|
||||
continue
|
||||
}
|
||||
if filter.ExcludePinned && d.Pinned {
|
||||
continue
|
||||
}
|
||||
base = append(base, d)
|
||||
}
|
||||
|
||||
list.Count = len(base)
|
||||
list.Hash = dialogListHash(base)
|
||||
limit := filter.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
page := make([]domain.Dialog, 0, len(base))
|
||||
for _, d := range base {
|
||||
if !afterDialogOffset(d, filter) {
|
||||
continue
|
||||
}
|
||||
page = append(page, d)
|
||||
if len(page) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
list.Dialogs = page
|
||||
list.Messages = keepDialogMessages(list.Messages, page)
|
||||
return list
|
||||
}
|
||||
|
||||
func dialogMatchesFolder(d domain.Dialog, users []domain.User, filter domain.DialogFilter) bool {
|
||||
if !filter.HasFolderID {
|
||||
// 不带 folder_id 视为主列表(folder 0):归档对话只以 dialogFolder
|
||||
// 聚合条目出现,DrKLO Android 主列表请求不设 flag。
|
||||
return d.FolderID == domain.DialogMainFolderID
|
||||
}
|
||||
if filter.FolderID < domain.DialogCustomFolderMinID {
|
||||
return d.FolderID == filter.FolderID
|
||||
}
|
||||
if filter.Folder == nil {
|
||||
return false
|
||||
}
|
||||
folder := filter.Folder
|
||||
if folder.ExcludeArchived && d.FolderID == domain.DialogArchiveFolderID {
|
||||
return false
|
||||
}
|
||||
if folder.ExcludeRead && d.UnreadCount == 0 && !d.UnreadMark {
|
||||
return false
|
||||
}
|
||||
if hasFolderPeer(folder.ExcludePeers, d.Peer) {
|
||||
return false
|
||||
}
|
||||
if hasFolderPeer(folder.IncludePeers, d.Peer) || hasFolderPeer(folder.PinnedPeers, d.Peer) {
|
||||
return true
|
||||
}
|
||||
if d.Peer.Type == domain.PeerTypeUser {
|
||||
user, ok := findDialogUser(users, d.Peer.ID)
|
||||
if ok && user.Contact && folder.Contacts {
|
||||
return true
|
||||
}
|
||||
if (!ok || !user.Contact) && folder.NonContacts {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasFolderPeer(peers []domain.DialogFolderPeer, peer domain.Peer) bool {
|
||||
for _, item := range peers {
|
||||
if item.Peer == peer {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func dialogLess(a, b domain.Dialog) bool {
|
||||
if a.Pinned != b.Pinned {
|
||||
return a.Pinned && !b.Pinned
|
||||
}
|
||||
if a.Pinned && b.Pinned && a.PinnedOrder != b.PinnedOrder {
|
||||
if a.PinnedOrder == 0 {
|
||||
return false
|
||||
}
|
||||
if b.PinnedOrder == 0 {
|
||||
return true
|
||||
}
|
||||
return a.PinnedOrder > b.PinnedOrder
|
||||
}
|
||||
if a.TopMessageDate != b.TopMessageDate {
|
||||
return a.TopMessageDate > b.TopMessageDate
|
||||
}
|
||||
if a.TopMessage != b.TopMessage {
|
||||
return a.TopMessage > b.TopMessage
|
||||
}
|
||||
return a.Peer.ID > b.Peer.ID
|
||||
}
|
||||
|
||||
func afterDialogOffset(d domain.Dialog, filter domain.DialogFilter) bool {
|
||||
if filter.OffsetDate <= 0 && filter.OffsetID <= 0 {
|
||||
return true
|
||||
}
|
||||
if filter.OffsetDate > 0 {
|
||||
if d.TopMessageDate != filter.OffsetDate {
|
||||
return d.TopMessageDate < filter.OffsetDate
|
||||
}
|
||||
if filter.OffsetID <= 0 {
|
||||
return false
|
||||
}
|
||||
if d.TopMessage != filter.OffsetID {
|
||||
return d.TopMessage < filter.OffsetID
|
||||
}
|
||||
if filter.HasOffsetPeer {
|
||||
return d.Peer.ID < filter.OffsetPeer.ID
|
||||
}
|
||||
return false
|
||||
}
|
||||
return d.TopMessage < filter.OffsetID
|
||||
}
|
||||
|
||||
func keepDialogMessages(messages []domain.Message, dialogs []domain.Dialog) []domain.Message {
|
||||
want := make(map[int]struct{}, len(dialogs))
|
||||
for _, d := range dialogs {
|
||||
if d.TopMessage != 0 {
|
||||
want[d.TopMessage] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]domain.Message, 0, len(want))
|
||||
for _, msg := range messages {
|
||||
if _, ok := want[msg.ID]; ok {
|
||||
out = append(out, msg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findDialogUser(users []domain.User, id int64) (domain.User, bool) {
|
||||
for _, user := range users {
|
||||
if user.ID == id {
|
||||
return user, true
|
||||
}
|
||||
}
|
||||
return domain.User{}, false
|
||||
}
|
||||
|
||||
func appendDialogUser(list *domain.DialogList, seen map[int64]struct{}, user domain.User) {
|
||||
if user.ID == 0 {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[user.ID]; ok {
|
||||
return
|
||||
}
|
||||
seen[user.ID] = struct{}{}
|
||||
list.Users = append(list.Users, user)
|
||||
}
|
||||
|
||||
func cloneDialogs(dialogs []domain.Dialog) []domain.Dialog {
|
||||
out := append([]domain.Dialog(nil), dialogs...)
|
||||
for i := range out {
|
||||
if out[i].Draft != nil {
|
||||
draft := cloneDialogDraft(*out[i].Draft)
|
||||
out[i].Draft = &draft
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDialogDraft(draft domain.DialogDraft) domain.DialogDraft {
|
||||
draft.Entities = append([]domain.MessageEntity(nil), draft.Entities...)
|
||||
draft.ReplyTo = cloneMessageReply(draft.ReplyTo)
|
||||
if draft.WebPage != nil {
|
||||
webpage := *draft.WebPage
|
||||
draft.WebPage = &webpage
|
||||
}
|
||||
return draft
|
||||
}
|
||||
|
||||
func sortDialogDrafts(drafts []domain.DialogDraft) {
|
||||
sort.SliceStable(drafts, func(i, j int) bool {
|
||||
if drafts[i].Date != drafts[j].Date {
|
||||
return drafts[i].Date > drafts[j].Date
|
||||
}
|
||||
if drafts[i].Peer.Type != drafts[j].Peer.Type {
|
||||
return drafts[i].Peer.Type < drafts[j].Peer.Type
|
||||
}
|
||||
if drafts[i].Peer.ID != drafts[j].Peer.ID {
|
||||
return drafts[i].Peer.ID > drafts[j].Peer.ID
|
||||
}
|
||||
return drafts[i].TopMessageID > drafts[j].TopMessageID
|
||||
})
|
||||
}
|
||||
|
||||
func cloneDialogFolder(folder domain.DialogFolder) domain.DialogFolder {
|
||||
folder.TitleEntities = append([]domain.MessageEntity(nil), folder.TitleEntities...)
|
||||
folder.PinnedPeers = append([]domain.DialogFolderPeer(nil), folder.PinnedPeers...)
|
||||
folder.IncludePeers = append([]domain.DialogFolderPeer(nil), folder.IncludePeers...)
|
||||
folder.ExcludePeers = append([]domain.DialogFolderPeer(nil), folder.ExcludePeers...)
|
||||
return folder
|
||||
}
|
||||
|
||||
func containsInt(items []int, value int) bool {
|
||||
for _, item := range items {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func removeInt(items []int, value int) []int {
|
||||
out := items[:0]
|
||||
for _, item := range items {
|
||||
if item != value {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dialogListHash(dialogs []domain.Dialog) int64 {
|
||||
if len(dialogs) == 0 {
|
||||
return 0
|
||||
}
|
||||
h := fnv.New64a()
|
||||
var buf [47]byte
|
||||
for _, d := range dialogs {
|
||||
binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID))
|
||||
binary.LittleEndian.PutUint32(buf[8:12], uint32(d.FolderID))
|
||||
binary.LittleEndian.PutUint32(buf[12:16], uint32(d.TopMessage))
|
||||
binary.LittleEndian.PutUint32(buf[16:20], uint32(d.TopMessageDate))
|
||||
binary.LittleEndian.PutUint32(buf[20:24], uint32(d.ReadInboxMaxID))
|
||||
binary.LittleEndian.PutUint32(buf[24:28], uint32(d.ReadOutboxMaxID))
|
||||
binary.LittleEndian.PutUint32(buf[28:32], uint32(d.UnreadCount))
|
||||
binary.LittleEndian.PutUint32(buf[32:36], uint32(d.UnreadMentions))
|
||||
binary.LittleEndian.PutUint32(buf[36:40], uint32(d.UnreadReactions))
|
||||
if d.Pinned {
|
||||
buf[40] = 1
|
||||
} else {
|
||||
buf[40] = 0
|
||||
}
|
||||
binary.LittleEndian.PutUint32(buf[41:45], uint32(d.PinnedOrder))
|
||||
if d.UnreadMark {
|
||||
buf[45] = 1
|
||||
} else {
|
||||
buf[45] = 0
|
||||
}
|
||||
if d.PeerSettingsBarHidden {
|
||||
buf[46] = 1
|
||||
} else {
|
||||
buf[46] = 0
|
||||
}
|
||||
_, _ = h.Write(buf[:])
|
||||
_, _ = h.Write([]byte(d.ThemeEmoticon))
|
||||
}
|
||||
return int64(h.Sum64())
|
||||
}
|
||||
5
internal/store/memory/doc.go
Normal file
5
internal/store/memory/doc.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Package memory 提供 store 各接口的内存实现,用作测试替身与本地开发兜底。
|
||||
//
|
||||
// 与 store/postgres、store/redisstore 对称:store 主包只定义接口与 DTO,
|
||||
// 三种后端实现各自独立成包。
|
||||
package memory
|
||||
465
internal/store/memory/groupcall.go
Normal file
465
internal/store/memory/groupcall.go
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// GroupCallStore 是 store.GroupCallStore 的进程内实现(rpc 单测 fixture 用)。
|
||||
// 行为契约与 postgres 实现由共享 contract test 钉死(groupcall contract test),
|
||||
// 凡动 version/参与者语义两边必须同步并跑 PG 集成。
|
||||
type overrideKey struct {
|
||||
callID, setter, target int64
|
||||
}
|
||||
|
||||
type GroupCallStore struct {
|
||||
mu sync.Mutex
|
||||
calls map[int64]domain.GroupCall
|
||||
activeByChan map[int64]int64 // channelID → active callID
|
||||
participants map[int64]map[int64]domain.GroupCallParticipant // callID → userID → row
|
||||
overrides map[overrideKey]domain.GroupCallParticipantOverride
|
||||
raiseHandSeq map[int64]int64 // callID → 单调举手序号
|
||||
nextSyntheticID int64
|
||||
}
|
||||
|
||||
// NewGroupCallStore 创建内存实现。
|
||||
func NewGroupCallStore() *GroupCallStore {
|
||||
return &GroupCallStore{
|
||||
calls: make(map[int64]domain.GroupCall),
|
||||
activeByChan: make(map[int64]int64),
|
||||
participants: make(map[int64]map[int64]domain.GroupCallParticipant),
|
||||
overrides: make(map[overrideKey]domain.GroupCallParticipantOverride),
|
||||
raiseHandSeq: make(map[int64]int64),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) CreateGroupCall(_ context.Context, call domain.GroupCall) (domain.GroupCall, error) {
|
||||
if call.ID == 0 || call.ChannelID == 0 || call.AccessHash == 0 {
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if activeID, ok := s.activeByChan[call.ChannelID]; ok {
|
||||
if existing, ok := s.calls[activeID]; ok && existing.Active() {
|
||||
return domain.GroupCall{}, domain.ErrGroupCallAlreadyStarted
|
||||
}
|
||||
}
|
||||
if _, exists := s.calls[call.ID]; exists {
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
call.State = domain.GroupCallStateActive
|
||||
if call.Version <= 0 {
|
||||
call.Version = 1
|
||||
}
|
||||
call.ParticipantsCount = 0
|
||||
s.calls[call.ID] = call
|
||||
s.activeByChan[call.ChannelID] = call.ID
|
||||
s.participants[call.ID] = make(map[int64]domain.GroupCallParticipant)
|
||||
return call, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) GetGroupCall(_ context.Context, callID int64) (domain.GroupCall, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[callID]
|
||||
return call, ok, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) JoinGroupCall(_ context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) {
|
||||
if req.SSRC == 0 {
|
||||
return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[req.CallID]
|
||||
if !ok {
|
||||
return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if !call.Active() {
|
||||
return domain.GroupCallMutation{}, domain.ErrGroupCallDiscarded
|
||||
}
|
||||
rows := s.participants[req.CallID]
|
||||
for userID, p := range rows {
|
||||
if !p.Left && p.SSRC == req.SSRC && userID != req.UserID {
|
||||
return domain.GroupCallMutation{}, domain.ErrGroupCallSSRCDuplicate
|
||||
}
|
||||
}
|
||||
existing, rejoining := rows[req.UserID]
|
||||
wasActive := rejoining && !existing.Left
|
||||
p := domain.GroupCallParticipant{
|
||||
CallID: req.CallID,
|
||||
UserID: req.UserID,
|
||||
SSRC: req.SSRC,
|
||||
JoinDate: req.Now,
|
||||
ActiveDate: req.Now,
|
||||
LastCheckDate: req.Now,
|
||||
// VideoJSON 整体替换、PresentationJSON 随全新行清空(rejoin 后客户端
|
||||
// 会重发 joinGroupCallPresentation,旧屏幕登记必须作废)。
|
||||
VideoJSON: append([]byte(nil), req.VideoJSON...),
|
||||
}
|
||||
if rejoining && wasActive {
|
||||
// 同设备换 ssrc 的 rejoin 保留原 join_date(列表排序稳定)。
|
||||
p.JoinDate = existing.JoinDate
|
||||
}
|
||||
// join_muted 策略:普通成员入会即静音且不可自行开麦(muted_by_admin)。
|
||||
if call.JoinMuted && !req.IsAdmin {
|
||||
p.Muted = true
|
||||
p.MutedByAdmin = true
|
||||
}
|
||||
rows[req.UserID] = p
|
||||
if !wasActive {
|
||||
call.ParticipantsCount++
|
||||
}
|
||||
call.Version++
|
||||
s.calls[req.CallID] = call
|
||||
return domain.GroupCallMutation{Call: call, Participant: p}, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) LeaveGroupCall(_ context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[callID]
|
||||
if !ok {
|
||||
return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
p, ok := s.participants[callID][userID]
|
||||
if !ok || p.Left {
|
||||
return domain.GroupCallMutation{}, domain.ErrGroupCallNotJoined
|
||||
}
|
||||
p.Left = true
|
||||
p.ActiveDate = now
|
||||
s.participants[callID][userID] = p
|
||||
if call.ParticipantsCount > 0 {
|
||||
call.ParticipantsCount--
|
||||
}
|
||||
call.Version++
|
||||
s.calls[callID] = call
|
||||
return domain.GroupCallMutation{Call: call, Participant: p}, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) DiscardGroupCall(_ context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[callID]
|
||||
if !ok {
|
||||
return domain.GroupCall{}, nil, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if !call.Active() {
|
||||
return domain.GroupCall{}, nil, domain.ErrGroupCallDiscarded
|
||||
}
|
||||
var active []domain.GroupCallParticipant
|
||||
for userID, p := range s.participants[callID] {
|
||||
if p.Left {
|
||||
continue
|
||||
}
|
||||
active = append(active, p)
|
||||
p.Left = true
|
||||
p.ActiveDate = now
|
||||
s.participants[callID][userID] = p
|
||||
}
|
||||
call.State = domain.GroupCallStateDiscarded
|
||||
call.DiscardedAt = now
|
||||
call.Duration = max(0, now-call.CreatedAt)
|
||||
call.ParticipantsCount = 0
|
||||
call.Version++
|
||||
s.calls[callID] = call
|
||||
if s.activeByChan[call.ChannelID] == callID {
|
||||
delete(s.activeByChan, call.ChannelID)
|
||||
}
|
||||
sort.Slice(active, func(i, j int) bool { return active[i].UserID < active[j].UserID })
|
||||
return call, active, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) TouchParticipant(_ context.Context, callID, userID int64, now int) ([]int64, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[callID]
|
||||
if !ok {
|
||||
return nil, false, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if !call.Active() {
|
||||
return nil, false, nil
|
||||
}
|
||||
p, ok := s.participants[callID][userID]
|
||||
if !ok || p.Left {
|
||||
return nil, false, nil
|
||||
}
|
||||
p.LastCheckDate = now
|
||||
p.ActiveDate = now
|
||||
s.participants[callID][userID] = p
|
||||
return []int64{p.SSRC}, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) GetParticipant(_ context.Context, callID, userID int64) (domain.GroupCallParticipant, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p, ok := s.participants[callID][userID]
|
||||
return p, ok, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) ListParticipants(_ context.Context, callID int64, offset string, limit int) (domain.GroupCallParticipantPage, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[callID]
|
||||
if !ok {
|
||||
return domain.GroupCallParticipantPage{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
var rows []domain.GroupCallParticipant
|
||||
for _, p := range s.participants[callID] {
|
||||
if !p.Left {
|
||||
rows = append(rows, p)
|
||||
}
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].JoinDate != rows[j].JoinDate {
|
||||
return rows[i].JoinDate < rows[j].JoinDate
|
||||
}
|
||||
return rows[i].UserID < rows[j].UserID
|
||||
})
|
||||
page := domain.GroupCallParticipantPage{Count: len(rows), Version: call.Version}
|
||||
offDate, offUser, hasOffset := parseGroupCallOffset(offset)
|
||||
for _, p := range rows {
|
||||
if hasOffset && (p.JoinDate < offDate || (p.JoinDate == offDate && p.UserID <= offUser)) {
|
||||
continue
|
||||
}
|
||||
page.Participants = append(page.Participants, p)
|
||||
if len(page.Participants) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if n := len(page.Participants); n == limit && n < page.Count {
|
||||
last := page.Participants[n-1]
|
||||
page.NextOffset = formatGroupCallOffset(last.JoinDate, last.UserID)
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) UpdateParticipant(_ context.Context, callID, userID int64, update domain.GroupCallParticipantUpdate) (domain.GroupCallMutation, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[callID]
|
||||
if !ok {
|
||||
return domain.GroupCallMutation{}, false, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if !call.Active() {
|
||||
return domain.GroupCallMutation{}, false, domain.ErrGroupCallDiscarded
|
||||
}
|
||||
p, ok := s.participants[callID][userID]
|
||||
if !ok || p.Left {
|
||||
return domain.GroupCallMutation{}, false, domain.ErrGroupCallNotJoined
|
||||
}
|
||||
changed := applyGroupCallParticipantUpdate(&p, update)
|
||||
if !changed {
|
||||
return domain.GroupCallMutation{Call: call, Participant: p}, false, nil
|
||||
}
|
||||
if update.Now > 0 {
|
||||
p.ActiveDate = update.Now
|
||||
}
|
||||
s.participants[callID][userID] = p
|
||||
call.Version++
|
||||
s.calls[callID] = call
|
||||
return domain.GroupCallMutation{Call: call, Participant: p}, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) SetGroupCallTitle(_ context.Context, callID int64, title string) (domain.GroupCall, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[callID]
|
||||
if !ok {
|
||||
return domain.GroupCall{}, false, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if call.Title == title {
|
||||
return call, false, nil
|
||||
}
|
||||
call.Title = title
|
||||
s.calls[callID] = call
|
||||
return call, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) SetGroupCallJoinMuted(_ context.Context, callID int64, joinMuted bool) (domain.GroupCall, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[callID]
|
||||
if !ok {
|
||||
return domain.GroupCall{}, false, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if call.JoinMuted == joinMuted {
|
||||
return call, false, nil
|
||||
}
|
||||
call.JoinMuted = joinMuted
|
||||
s.calls[callID] = call
|
||||
return call, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) SetStartedMessageID(_ context.Context, callID int64, msgID int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[callID]
|
||||
if !ok {
|
||||
return domain.ErrGroupCallInvalid
|
||||
}
|
||||
call.StartedMsgID = msgID
|
||||
s.calls[callID] = call
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) SweepStaleParticipants(_ context.Context, checkOlderThan, now, limit int) ([]domain.GroupCallMutation, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []domain.GroupCallMutation
|
||||
for callID, rows := range s.participants {
|
||||
call := s.calls[callID]
|
||||
if !call.Active() {
|
||||
continue
|
||||
}
|
||||
userIDs := make([]int64, 0, len(rows))
|
||||
for userID := range rows {
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] })
|
||||
for _, userID := range userIDs {
|
||||
p := rows[userID]
|
||||
if p.Left || p.LastCheckDate >= checkOlderThan {
|
||||
continue
|
||||
}
|
||||
p.Left = true
|
||||
p.ActiveDate = now
|
||||
rows[userID] = p
|
||||
if call.ParticipantsCount > 0 {
|
||||
call.ParticipantsCount--
|
||||
}
|
||||
call.Version++
|
||||
out = append(out, domain.GroupCallMutation{Call: call, Participant: p})
|
||||
if len(out) == limit {
|
||||
s.calls[callID] = call
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
s.calls[callID] = call
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) ResetAllParticipants(_ context.Context, now int) ([]domain.GroupCall, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []domain.GroupCall
|
||||
for callID, rows := range s.participants {
|
||||
call := s.calls[callID]
|
||||
if !call.Active() {
|
||||
continue
|
||||
}
|
||||
cleared := false
|
||||
for userID, p := range rows {
|
||||
if p.Left {
|
||||
continue
|
||||
}
|
||||
p.Left = true
|
||||
p.ActiveDate = now
|
||||
rows[userID] = p
|
||||
cleared = true
|
||||
}
|
||||
if !cleared {
|
||||
continue
|
||||
}
|
||||
call.ParticipantsCount = 0
|
||||
call.Version++
|
||||
s.calls[callID] = call
|
||||
out = append(out, call)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func applyGroupCallParticipantUpdate(p *domain.GroupCallParticipant, u domain.GroupCallParticipantUpdate) bool {
|
||||
changed := false
|
||||
if u.Muted != nil && p.Muted != *u.Muted {
|
||||
p.Muted = *u.Muted
|
||||
changed = true
|
||||
}
|
||||
if u.MutedByAdmin != nil && p.MutedByAdmin != *u.MutedByAdmin {
|
||||
p.MutedByAdmin = *u.MutedByAdmin
|
||||
changed = true
|
||||
}
|
||||
if u.VolumeByAdmin != nil && p.VolumeByAdmin != *u.VolumeByAdmin {
|
||||
p.VolumeByAdmin = *u.VolumeByAdmin
|
||||
changed = true
|
||||
}
|
||||
if u.RaiseHandRating != nil && p.RaiseHandRating != *u.RaiseHandRating {
|
||||
p.RaiseHandRating = *u.RaiseHandRating
|
||||
changed = true
|
||||
}
|
||||
if u.VideoJSON != nil && string(p.VideoJSON) != string(*u.VideoJSON) {
|
||||
p.VideoJSON = append([]byte(nil), *u.VideoJSON...)
|
||||
changed = true
|
||||
}
|
||||
if u.PresentationJSON != nil && string(p.PresentationJSON) != string(*u.PresentationJSON) {
|
||||
p.PresentationJSON = append([]byte(nil), *u.PresentationJSON...)
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func parseGroupCallOffset(offset string) (joinDate int, userID int64, ok bool) {
|
||||
if offset == "" {
|
||||
return 0, 0, false
|
||||
}
|
||||
parts := strings.Split(offset, ":")
|
||||
if len(parts) != 3 || parts[0] != "j" {
|
||||
return 0, 0, false
|
||||
}
|
||||
d, err1 := strconv.Atoi(parts[1])
|
||||
u, err2 := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return d, u, true
|
||||
}
|
||||
|
||||
func formatGroupCallOffset(joinDate int, userID int64) string {
|
||||
return fmt.Sprintf("j:%d:%d", joinDate, userID)
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) NextRaiseHandRating(_ context.Context, callID int64) (int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.raiseHandSeq[callID]++
|
||||
return s.raiseHandSeq[callID], nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) SetParticipantOverride(_ context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := overrideKey{callID: callID, setter: setterUserID, target: targetUserID}
|
||||
if clear {
|
||||
delete(s.overrides, key)
|
||||
return nil
|
||||
}
|
||||
s.overrides[key] = override
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) GetParticipantOverride(_ context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ov, ok := s.overrides[overrideKey{callID: callID, setter: setterUserID, target: targetUserID}]
|
||||
return ov, ok, nil
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
25
internal/store/memory/groupcall_test.go
Normal file
25
internal/store/memory/groupcall_test.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package memory_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
"telesrv/internal/store/storetest"
|
||||
)
|
||||
|
||||
func TestGroupCallStoreContract(t *testing.T) {
|
||||
var nextChannel int64 = 5000
|
||||
storetest.RunGroupCallStoreContract(t, func(t *testing.T) (store.GroupCallStore, int64) {
|
||||
nextChannel++
|
||||
return memory.NewGroupCallStore(), nextChannel
|
||||
})
|
||||
}
|
||||
|
||||
func TestGroupCallStoreM2Contract(t *testing.T) {
|
||||
var nextChannel int64 = 6000
|
||||
storetest.RunGroupCallStoreM2Contract(t, func(t *testing.T) (store.GroupCallStore, int64) {
|
||||
nextChannel++
|
||||
return memory.NewGroupCallStore(), nextChannel
|
||||
})
|
||||
}
|
||||
57
internal/store/memory/help.go
Normal file
57
internal/store/memory/help.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// HelpStore 是 store.AppConfigStore 和 store.CountryStore 的内存实现。
|
||||
type HelpStore struct {
|
||||
mu sync.RWMutex
|
||||
appConfig map[string]domain.AppConfig
|
||||
countries domain.CountriesList
|
||||
}
|
||||
|
||||
// NewHelpStore 创建内存 HelpStore。
|
||||
func NewHelpStore() *HelpStore {
|
||||
return &HelpStore{appConfig: make(map[string]domain.AppConfig)}
|
||||
}
|
||||
|
||||
func (s *HelpStore) GetAppConfig(_ context.Context, client string) (domain.AppConfig, bool, error) {
|
||||
s.mu.RLock()
|
||||
cfg, ok := s.appConfig[client]
|
||||
s.mu.RUnlock()
|
||||
cfg.JSON = append([]byte(nil), cfg.JSON...)
|
||||
return cfg, ok, nil
|
||||
}
|
||||
|
||||
func (s *HelpStore) UpsertAppConfig(_ context.Context, cfg domain.AppConfig) error {
|
||||
cfg.JSON = append([]byte(nil), cfg.JSON...)
|
||||
s.mu.Lock()
|
||||
s.appConfig[cfg.Client] = cfg
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *HelpStore) ListCountries(_ context.Context, _ string) (domain.CountriesList, error) {
|
||||
s.mu.RLock()
|
||||
list := s.countries
|
||||
s.mu.RUnlock()
|
||||
list.Countries = append([]domain.Country(nil), list.Countries...)
|
||||
for i := range list.Countries {
|
||||
list.Countries[i].CountryCodes = append([]domain.CountryCode(nil), list.Countries[i].CountryCodes...)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *HelpStore) UpsertCountries(_ context.Context, countries []domain.Country) error {
|
||||
list := domain.CountriesList{Hash: 1, Countries: append([]domain.Country(nil), countries...)}
|
||||
for i := range list.Countries {
|
||||
list.Countries[i].CountryCodes = append([]domain.CountryCode(nil), list.Countries[i].CountryCodes...)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.countries = list
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
16
internal/store/memory/helpers.go
Normal file
16
internal/store/memory/helpers.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"time"
|
||||
)
|
||||
|
||||
func draftKey(peer domain.Peer, topMessageID int) dialogDraftKey {
|
||||
return dialogDraftKey{peerType: peer.Type, peerID: peer.ID, topMessageID: topMessageID}
|
||||
}
|
||||
|
||||
type codeEntry struct {
|
||||
code store.PhoneCode
|
||||
expires time.Time
|
||||
}
|
||||
70
internal/store/memory/langpack.go
Normal file
70
internal/store/memory/langpack.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// LangPackStore 是 store.LangPackStore 的内存实现。
|
||||
type LangPackStore struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]domain.LangPack
|
||||
}
|
||||
|
||||
// NewLangPackStore 创建内存 LangPackStore。
|
||||
func NewLangPackStore() *LangPackStore {
|
||||
return &LangPackStore{m: make(map[string]domain.LangPack)}
|
||||
}
|
||||
|
||||
func (s *LangPackStore) GetPack(_ context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
|
||||
s.mu.RLock()
|
||||
pack := s.m[langPackKey(langPack, langCode)]
|
||||
s.mu.RUnlock()
|
||||
if pack.LangPack == "" {
|
||||
return domain.LangPack{LangPack: langPack, LangCode: langCode, FromVersion: fromVersion}, nil
|
||||
}
|
||||
pack.FromVersion = fromVersion
|
||||
if pack.Version <= fromVersion {
|
||||
pack.Strings = nil
|
||||
} else {
|
||||
pack.Strings = append([]domain.LangPackString(nil), pack.Strings...)
|
||||
}
|
||||
return pack, nil
|
||||
}
|
||||
|
||||
func (s *LangPackStore) GetStrings(_ context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) {
|
||||
s.mu.RLock()
|
||||
pack := s.m[langPackKey(langPack, langCode)]
|
||||
s.mu.RUnlock()
|
||||
if pack.LangPack == "" {
|
||||
return domain.LangPack{LangPack: langPack, LangCode: langCode}, nil
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
pack.Strings = append([]domain.LangPackString(nil), pack.Strings...)
|
||||
return pack, nil
|
||||
}
|
||||
want := make(map[string]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
want[key] = struct{}{}
|
||||
}
|
||||
out := domain.LangPack{LangPack: pack.LangPack, LangCode: pack.LangCode, Version: pack.Version}
|
||||
for _, item := range pack.Strings {
|
||||
if _, ok := want[item.Key]; ok {
|
||||
out.Strings = append(out.Strings, item)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *LangPackStore) UpsertPack(_ context.Context, pack domain.LangPack) error {
|
||||
pack.Strings = append([]domain.LangPackString(nil), pack.Strings...)
|
||||
s.mu.Lock()
|
||||
s.m[langPackKey(pack.LangPack, pack.LangCode)] = pack
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func langPackKey(langPack, langCode string) string {
|
||||
return langPack + "\x00" + langCode
|
||||
}
|
||||
198
internal/store/memory/media_search.go
Normal file
198
internal/store/memory/media_search.go
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// 共享媒体标签页读路径(memory 实现):直接扫内存消息按 domain.ClassifyMediaCategories 分类过滤,
|
||||
// 无需索引表(数据量小)。分类真值与 postgres 写路径/回填同源。
|
||||
|
||||
func mediaCategorySet(cats []domain.MediaCategory) map[domain.MediaCategory]bool {
|
||||
set := make(map[domain.MediaCategory]bool, len(cats))
|
||||
for _, c := range cats {
|
||||
if c != domain.MediaCategoryNone {
|
||||
set[c] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func mediaCategoryMatches(media *domain.MessageMedia, entities []domain.MessageEntity, set map[domain.MediaCategory]bool) bool {
|
||||
for _, c := range domain.ClassifyMediaCategories(media, entities) {
|
||||
if set[c] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pageMediaIDs 把全部匹配 id 按 newest-first 分页(返回本页 id + 满足 max/min 的总数)。
|
||||
func pageMediaIDs(ids []int, req domain.MediaSearchRequest) ([]int, int) {
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(ids)))
|
||||
inRange := make([]int, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if req.MaxID != 0 && id > req.MaxID {
|
||||
continue
|
||||
}
|
||||
if req.MinID != 0 && id < req.MinID {
|
||||
continue
|
||||
}
|
||||
inRange = append(inRange, id)
|
||||
}
|
||||
count := len(inRange)
|
||||
page := make([]int, 0, len(inRange))
|
||||
for _, id := range inRange {
|
||||
if req.OffsetID != 0 && id >= req.OffsetID {
|
||||
continue
|
||||
}
|
||||
page = append(page, id)
|
||||
}
|
||||
off := req.AddOffset
|
||||
if off < 0 {
|
||||
off = 0
|
||||
}
|
||||
if off > len(page) {
|
||||
off = len(page)
|
||||
}
|
||||
page = page[off:]
|
||||
limit := req.Limit
|
||||
if limit == 0 {
|
||||
return nil, count
|
||||
}
|
||||
if limit < 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
if len(page) > limit {
|
||||
page = page[:limit]
|
||||
}
|
||||
return page, count
|
||||
}
|
||||
|
||||
// SearchPrivateMedia 实现 store.MessageStore。
|
||||
func (s *MessageStore) SearchPrivateMedia(ctx context.Context, ownerUserID, peerID int64, req domain.MediaSearchRequest) (domain.MessageList, error) {
|
||||
set := mediaCategorySet(req.Categories)
|
||||
if ownerUserID == 0 || peerID == 0 || len(set) == 0 {
|
||||
return domain.MessageList{}, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
matched := make([]int, 0, len(s.m[ownerUserID]))
|
||||
for _, msg := range s.m[ownerUserID] {
|
||||
if msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peerID {
|
||||
continue
|
||||
}
|
||||
if mediaCategoryMatches(msg.Media, msg.Entities, set) {
|
||||
matched = append(matched, msg.ID)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
ids, count := pageMediaIDs(matched, req)
|
||||
if req.HasKnownCount {
|
||||
count = req.KnownCount
|
||||
}
|
||||
list, err := s.GetByIDs(ctx, ownerUserID, ids)
|
||||
if err != nil {
|
||||
return domain.MessageList{}, err
|
||||
}
|
||||
list.Count = count
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// CountPrivateMediaCategories 实现 store.MessageStore。
|
||||
func (s *MessageStore) CountPrivateMediaCategories(_ context.Context, ownerUserID, peerID int64) (domain.MediaCategoryCounts, error) {
|
||||
if ownerUserID == 0 || peerID == 0 {
|
||||
return domain.MediaCategoryCounts{}, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := domain.MediaCategoryCounts{}
|
||||
for _, msg := range s.m[ownerUserID] {
|
||||
if msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peerID {
|
||||
continue
|
||||
}
|
||||
for _, category := range domain.ClassifyMediaCategories(msg.Media, msg.Entities) {
|
||||
if category != domain.MediaCategoryNone {
|
||||
out[category]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SearchChannelMedia 实现 store.ChannelStore。
|
||||
func (s *ChannelStore) SearchChannelMedia(ctx context.Context, viewerUserID, channelID int64, req domain.MediaSearchRequest) (domain.ChannelHistory, error) {
|
||||
set := mediaCategorySet(req.Categories)
|
||||
if viewerUserID == 0 || channelID == 0 || len(set) == 0 {
|
||||
return domain.ChannelHistory{}, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
_, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
s.mu.RUnlock()
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
matched := make([]int, 0, len(s.messages[channelID]))
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if msg.Deleted || msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
if mediaCategoryMatches(msg.Media, msg.Entities, set) {
|
||||
matched = append(matched, msg.ID)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
ids, count := pageMediaIDs(matched, req)
|
||||
if req.HasKnownCount {
|
||||
count = req.KnownCount
|
||||
}
|
||||
hist, err := s.GetChannelMessages(ctx, viewerUserID, channelID, ids)
|
||||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
hist.Messages = reorderChannelMessagesByMediaOrder(hist.Messages, ids)
|
||||
hist.Count = count
|
||||
return hist, nil
|
||||
}
|
||||
|
||||
// CountChannelMediaCategories 实现 store.ChannelStore。
|
||||
func (s *ChannelStore) CountChannelMediaCategories(_ context.Context, viewerUserID, channelID int64) (domain.MediaCategoryCounts, error) {
|
||||
if viewerUserID == 0 || channelID == 0 {
|
||||
return domain.MediaCategoryCounts{}, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.MediaCategoryCounts{}, err
|
||||
}
|
||||
out := domain.MediaCategoryCounts{}
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if msg.Deleted || msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
for _, category := range domain.ClassifyMediaCategories(msg.Media, msg.Entities) {
|
||||
if category != domain.MediaCategoryNone {
|
||||
out[category]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func reorderChannelMessagesByMediaOrder(msgs []domain.ChannelMessage, order []int) []domain.ChannelMessage {
|
||||
byID := make(map[int]domain.ChannelMessage, len(msgs))
|
||||
for _, m := range msgs {
|
||||
byID[m.ID] = m
|
||||
}
|
||||
out := make([]domain.ChannelMessage, 0, len(order))
|
||||
for _, id := range order {
|
||||
if m, ok := byID[id]; ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
22
internal/store/memory/message_allocators.go
Normal file
22
internal/store/memory/message_allocators.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package memory
|
||||
|
||||
func (s *MessageStore) nextBoxIDLocked(userID int64) int {
|
||||
next := s.nextBox[userID] + 1
|
||||
s.nextBox[userID] = next
|
||||
return next
|
||||
}
|
||||
|
||||
func (s *MessageStore) nextPtsLocked(userID int64) int {
|
||||
next := s.nextPts[userID] + 1
|
||||
s.nextPts[userID] = next
|
||||
return next
|
||||
}
|
||||
|
||||
func (s *MessageStore) nextPtsNLocked(userID int64, count int) int {
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
next := s.nextPts[userID] + count
|
||||
s.nextPts[userID] = next
|
||||
return next
|
||||
}
|
||||
149
internal/store/memory/message_delete.go
Normal file
149
internal/store/memory/message_delete.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *MessageStore) DeleteMessages(_ context.Context, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) {
|
||||
res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID}
|
||||
ids := normalizeMemoryMessageIDs(req.IDs)
|
||||
if req.OwnerUserID == 0 || len(ids) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
if len(ids) > domain.MaxDeleteMessageIDs {
|
||||
return res, fmt.Errorf("delete messages: too many ids: %d > %d", len(ids), domain.MaxDeleteMessageIDs)
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
idSet := make(map[int]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
idSet[id] = struct{}{}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
deleted, revokeUIDs, _ := s.deleteMemoryMessagesLocked(req.OwnerUserID, 0, func(msg domain.Message) bool {
|
||||
_, ok := idSet[msg.ID]
|
||||
return ok
|
||||
})
|
||||
if req.Revoke && len(revokeUIDs) > 0 {
|
||||
deleted = append(deleted, s.deleteMemoryMessagesByUIDLocked(revokeUIDs, req.OwnerUserID)...)
|
||||
}
|
||||
return s.finishMemoryDeleteLocked(res, deleted, req.Date, false), nil
|
||||
}
|
||||
|
||||
type deletedMemoryMessage struct {
|
||||
userID int64
|
||||
peer domain.Peer
|
||||
id int
|
||||
}
|
||||
|
||||
func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult, deleted []deletedMemoryMessage, date int, preserveEmptyDialogs bool) domain.DeleteMessagesResult {
|
||||
if len(deleted) == 0 {
|
||||
return res
|
||||
}
|
||||
idsByOwner := make(map[int64][]int)
|
||||
peersByOwner := make(map[int64]map[domain.Peer]struct{})
|
||||
for _, row := range deleted {
|
||||
idsByOwner[row.userID] = append(idsByOwner[row.userID], row.id)
|
||||
if peersByOwner[row.userID] == nil {
|
||||
peersByOwner[row.userID] = make(map[domain.Peer]struct{})
|
||||
}
|
||||
peersByOwner[row.userID][row.peer] = struct{}{}
|
||||
}
|
||||
if s.dialogs != nil {
|
||||
s.dialogs.mu.Lock()
|
||||
for userID, peers := range peersByOwner {
|
||||
for peer := range peers {
|
||||
s.rebuildMemoryDialogLocked(userID, peer, preserveEmptyDialogs)
|
||||
}
|
||||
}
|
||||
s.dialogs.mu.Unlock()
|
||||
}
|
||||
ownerIDs := make([]int64, 0, len(idsByOwner))
|
||||
for userID := range idsByOwner {
|
||||
ownerIDs = append(ownerIDs, userID)
|
||||
}
|
||||
sort.Slice(ownerIDs, func(i, j int) bool { return ownerIDs[i] < ownerIDs[j] })
|
||||
for _, userID := range ownerIDs {
|
||||
ids := normalizeMemoryMessageIDs(idsByOwner[userID])
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
pts := s.nextPtsNLocked(userID, len(ids))
|
||||
event := domain.UpdateEvent{
|
||||
UserID: userID,
|
||||
Type: domain.UpdateEventDeleteMessages,
|
||||
Pts: pts,
|
||||
PtsCount: len(ids),
|
||||
Date: date,
|
||||
MessageIDs: ids,
|
||||
}
|
||||
res.Deleted = append(res.Deleted, domain.DeletedMessagesForUser{
|
||||
UserID: userID,
|
||||
MessageIDs: ids,
|
||||
Event: event,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (s *MessageStore) rebuildMemoryDialogLocked(userID int64, peer domain.Peer, preserveEmpty bool) {
|
||||
list := s.dialogs.m[userID]
|
||||
topID := 0
|
||||
topDate := 0
|
||||
unread := 0
|
||||
for _, msg := range s.m[userID] {
|
||||
if msg.Peer != peer {
|
||||
continue
|
||||
}
|
||||
if msg.ID > topID {
|
||||
topID = msg.ID
|
||||
topDate = msg.Date
|
||||
}
|
||||
}
|
||||
dialogs := list.Dialogs[:0]
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer != peer {
|
||||
dialogs = append(dialogs, dialog)
|
||||
continue
|
||||
}
|
||||
if topID == 0 {
|
||||
if preserveEmpty {
|
||||
oldTop := dialog.TopMessage
|
||||
dialog.TopMessage = 0
|
||||
dialog.TopMessageDate = 0
|
||||
if dialog.ReadInboxMaxID < oldTop {
|
||||
dialog.ReadInboxMaxID = oldTop
|
||||
}
|
||||
if dialog.ReadOutboxMaxID < oldTop {
|
||||
dialog.ReadOutboxMaxID = oldTop
|
||||
}
|
||||
dialog.UnreadCount = 0
|
||||
dialog.UnreadMark = false
|
||||
dialog.UnreadMentions = 0
|
||||
dialog.UnreadReactions = 0
|
||||
dialogs = append(dialogs, dialog)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, msg := range s.m[userID] {
|
||||
if msg.Peer == peer && !msg.Out && msg.ID > dialog.ReadInboxMaxID {
|
||||
unread++
|
||||
}
|
||||
}
|
||||
dialog.TopMessage = topID
|
||||
dialog.TopMessageDate = topDate
|
||||
dialog.UnreadCount = unread
|
||||
dialog.UnreadMentions = 0
|
||||
dialog.UnreadReactions = 0
|
||||
dialogs = append(dialogs, dialog)
|
||||
}
|
||||
list.Dialogs = dialogs
|
||||
list.Messages = cloneMessages(s.m[userID])
|
||||
s.dialogs.m[userID] = list
|
||||
}
|
||||
115
internal/store/memory/message_edit.go
Normal file
115
internal/store/memory/message_edit.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *MessageStore) EditMessage(_ context.Context, req domain.EditMessageRequest) (domain.EditMessageResult, error) {
|
||||
res := domain.EditMessageResult{OwnerUserID: req.OwnerUserID}
|
||||
if req.OwnerUserID == 0 || req.Peer.ID == 0 || req.ID <= 0 || req.ID > domain.MaxMessageBoxID {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.EditDate == 0 {
|
||||
req.EditDate = int(time.Now().Unix())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
targetIndex := -1
|
||||
var target domain.Message
|
||||
for i, msg := range s.m[req.OwnerUserID] {
|
||||
if msg.ID == req.ID && msg.Peer == req.Peer {
|
||||
targetIndex = i
|
||||
target = msg
|
||||
break
|
||||
}
|
||||
}
|
||||
if targetIndex < 0 {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
authorEdit := target.Out && target.From.ID == req.OwnerUserID
|
||||
viaBotEdit := req.ViaBotEditBotID != 0 && target.ViaBotID == req.ViaBotEditBotID
|
||||
if !authorEdit && !viaBotEdit && !req.WebPageResolve && !validMemoryTodoParticipantEdit(req, target) {
|
||||
return res, domain.ErrMessageAuthorRequired
|
||||
}
|
||||
// WebPageResolve:服务端内部链接预览就地替换。幂等——仅当目标当前 media 仍是匹配 id 的
|
||||
// pending 占位才替换;只换 media、不碰 body/entities/edit_date,事件为 web_page。
|
||||
if req.WebPageResolve {
|
||||
if req.Media == nil || !domain.IsPendingWebPageMedia(target.Media, req.ExpectedWebPageID) {
|
||||
return res, domain.ErrMessageNotModified
|
||||
}
|
||||
}
|
||||
if req.Message == "" && req.Media == nil && target.Media.IsZero() {
|
||||
return res, domain.ErrMessageEmpty
|
||||
}
|
||||
if req.Media == nil && !req.SetReplyMarkup && target.Body == req.Message && equalMessageEntities(target.Entities, req.Entities) {
|
||||
return res, domain.ErrMessageNotModified
|
||||
}
|
||||
messageSenderID := target.From.ID
|
||||
for userID, messages := range s.m {
|
||||
for i, msg := range messages {
|
||||
if msg.UID == target.UID && msg.From.ID == messageSenderID {
|
||||
if req.WebPageResolve {
|
||||
media := *req.Media
|
||||
msg.Media = &media
|
||||
msg.Pts = s.nextPtsLocked(userID)
|
||||
s.m[userID][i] = msg
|
||||
res.Edited = append(res.Edited, domain.EditedMessageForUser{
|
||||
UserID: userID,
|
||||
Message: cloneMessage(msg),
|
||||
Event: webPageEvent(msg),
|
||||
})
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
// 替换 markup(nil/空 = 清空键盘);双盒一致。
|
||||
msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup)
|
||||
}
|
||||
msg.EditDate = req.EditDate
|
||||
msg.Pts = s.nextPtsLocked(userID)
|
||||
s.m[userID][i] = msg
|
||||
event := editMessageEvent(msg)
|
||||
res.Edited = append(res.Edited, domain.EditedMessageForUser{
|
||||
UserID: userID,
|
||||
Message: cloneMessage(msg),
|
||||
Event: event,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.dialogs != nil {
|
||||
s.dialogs.mu.Lock()
|
||||
for userID := range s.dialogs.m {
|
||||
list := s.dialogs.m[userID]
|
||||
list.Messages = cloneMessages(s.m[userID])
|
||||
s.dialogs.m[userID] = list
|
||||
}
|
||||
s.dialogs.mu.Unlock()
|
||||
}
|
||||
sort.Slice(res.Edited, func(i, j int) bool { return res.Edited[i].UserID < res.Edited[j].UserID })
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func validMemoryTodoParticipantEdit(req domain.EditMessageRequest, target domain.Message) bool {
|
||||
if !req.AllowTodoParticipantMutation || req.SetReplyMarkup || req.Media == nil || req.Media.Kind != domain.MessageMediaKindTodo || req.Media.Todo == nil {
|
||||
return false
|
||||
}
|
||||
if target.From.ID == req.OwnerUserID {
|
||||
return false
|
||||
}
|
||||
if target.Body != req.Message || !equalMessageEntities(target.Entities, req.Entities) {
|
||||
return false
|
||||
}
|
||||
if target.Media == nil || target.Media.Kind != domain.MessageMediaKindTodo || target.Media.Todo == nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
95
internal/store/memory/message_forward.go
Normal file
95
internal/store/memory/message_forward.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *MessageStore) ForwardPrivateMessages(ctx context.Context, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) {
|
||||
res := domain.ForwardPrivateMessagesResult{OwnerUserID: req.OwnerUserID}
|
||||
if req.OwnerUserID == 0 || req.ToUserID == 0 || req.FromPeer.Type != domain.PeerTypeUser || req.FromPeer.ID == 0 {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if len(req.MessageIDs) == 0 || len(req.MessageIDs) != len(req.RandomIDs) {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if len(req.MessageIDs) > domain.MaxForwardMessageIDs {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
s.mu.RLock()
|
||||
sources := make([]domain.Message, 0, len(req.MessageIDs))
|
||||
for _, id := range req.MessageIDs {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
s.mu.RUnlock()
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
var source domain.Message
|
||||
for _, msg := range s.m[req.OwnerUserID] {
|
||||
if msg.Peer == req.FromPeer && msg.ID == id {
|
||||
source = cloneMessage(msg)
|
||||
break
|
||||
}
|
||||
}
|
||||
if source.ID == 0 {
|
||||
s.mu.RUnlock()
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if source.NoForwards {
|
||||
s.mu.RUnlock()
|
||||
return res, domain.ErrChatForwardsRestricted
|
||||
}
|
||||
sources = append(sources, source)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
res.SenderMessages = make([]domain.Message, 0, len(sources))
|
||||
res.RecipientMessages = make([]domain.Message, 0, len(sources))
|
||||
res.SenderEvents = make([]domain.UpdateEvent, 0, len(sources))
|
||||
res.RecipientEvents = make([]domain.UpdateEvent, 0, len(sources))
|
||||
res.Duplicates = make([]bool, 0, len(sources))
|
||||
for i, source := range sources {
|
||||
if req.RandomIDs[i] == 0 {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
var forward *domain.MessageForward
|
||||
if !req.DropAuthor {
|
||||
forward = cloneMessageForward(source.Forward)
|
||||
if forward == nil {
|
||||
forward = &domain.MessageForward{From: source.From, Date: source.Date}
|
||||
}
|
||||
if req.ToUserID == req.OwnerUserID {
|
||||
forward.SavedFrom = req.FromPeer
|
||||
forward.SavedFromMsgID = req.MessageIDs[i]
|
||||
}
|
||||
}
|
||||
sent, err := s.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: req.OwnerUserID,
|
||||
RecipientUserID: req.ToUserID,
|
||||
RandomID: req.RandomIDs[i],
|
||||
Message: source.Body,
|
||||
Entities: append([]domain.MessageEntity(nil), source.Entities...),
|
||||
Media: source.Media,
|
||||
Silent: req.Silent,
|
||||
NoForwards: req.NoForwards,
|
||||
ReplyTo: req.ReplyTo,
|
||||
Forward: forward,
|
||||
Date: req.Date,
|
||||
OriginAuthKeyID: req.OriginAuthKeyID,
|
||||
OriginSessionID: req.OriginSessionID,
|
||||
RecipientBlocked: req.RecipientBlocked,
|
||||
})
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.SenderMessages = append(res.SenderMessages, sent.SenderMessage)
|
||||
res.RecipientMessages = append(res.RecipientMessages, sent.RecipientMessage)
|
||||
res.SenderEvents = append(res.SenderEvents, sent.SenderEvent)
|
||||
res.RecipientEvents = append(res.RecipientEvents, sent.RecipientEvent)
|
||||
res.Duplicates = append(res.Duplicates, sent.Duplicate)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
224
internal/store/memory/message_helpers.go
Normal file
224
internal/store/memory/message_helpers.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *MessageStore) deleteMemoryMessagesLocked(userID int64, limit int, match func(domain.Message) bool) ([]deletedMemoryMessage, map[int64]struct{}, bool) {
|
||||
messages := s.m[userID]
|
||||
kept := messages[:0]
|
||||
deleted := make([]deletedMemoryMessage, 0)
|
||||
revokeUIDs := make(map[int64]struct{})
|
||||
more := false
|
||||
for _, msg := range messages {
|
||||
if match(msg) {
|
||||
if limit > 0 && len(deleted) >= limit {
|
||||
kept = append(kept, msg)
|
||||
more = true
|
||||
continue
|
||||
}
|
||||
deleted = append(deleted, deletedMemoryMessage{userID: userID, peer: msg.Peer, id: msg.ID})
|
||||
if msg.UID != 0 {
|
||||
revokeUIDs[msg.UID] = struct{}{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
kept = append(kept, msg)
|
||||
}
|
||||
s.m[userID] = kept
|
||||
return deleted, revokeUIDs, more
|
||||
}
|
||||
|
||||
func (s *MessageStore) deleteMemoryMessagesByUIDLocked(uids map[int64]struct{}, excludeUserID int64) []deletedMemoryMessage {
|
||||
if len(uids) == 0 {
|
||||
return nil
|
||||
}
|
||||
deleted := make([]deletedMemoryMessage, 0)
|
||||
for userID, messages := range s.m {
|
||||
if userID == excludeUserID {
|
||||
continue
|
||||
}
|
||||
kept := messages[:0]
|
||||
for _, msg := range messages {
|
||||
if _, ok := uids[msg.UID]; ok {
|
||||
deleted = append(deleted, deletedMemoryMessage{userID: userID, peer: msg.Peer, id: msg.ID})
|
||||
continue
|
||||
}
|
||||
kept = append(kept, msg)
|
||||
}
|
||||
s.m[userID] = kept
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
func normalizeMemoryMessageIDs(ids []int) []int {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]int, 0, len(ids))
|
||||
seen := make(map[int]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMessage(msg domain.Message) domain.Message {
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
msg.ReplyTo = cloneMessageReply(msg.ReplyTo)
|
||||
msg.Forward = cloneMessageForward(msg.Forward)
|
||||
msg.Reactions = cloneChannelMessageReactionsPtr(msg.Reactions)
|
||||
msg.ReplyMarkup = cloneReplyMarkup(msg.ReplyMarkup)
|
||||
msg.RichMessage = cloneRichMessage(msg.RichMessage)
|
||||
return msg
|
||||
}
|
||||
|
||||
// cloneReplyMarkup 深拷 inline keyboard 快照:与 postgres 每盒独立 decode 对齐
|
||||
// (双 store 行为一致),避免发送方/接收方两行共享底层 rows/Data 切片。
|
||||
func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
clone := domain.MessageReplyMarkup{}
|
||||
if m.Inline != nil {
|
||||
clone.Inline = make([][]domain.MarkupButton, len(m.Inline))
|
||||
for i, row := range m.Inline {
|
||||
cloneRow := make([]domain.MarkupButton, len(row))
|
||||
for j, btn := range row {
|
||||
cloneRow[j] = btn
|
||||
cloneRow[j].Data = append([]byte(nil), btn.Data...)
|
||||
}
|
||||
clone.Inline[i] = cloneRow
|
||||
}
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
// cloneRichMessage 深拷 Layer 227 富文本快照:复制不透明 blocks 字节与内嵌媒体切片,
|
||||
// 避免发送方/接收方两行共享底层切片(与 postgres 每盒独立 decode 对齐)。
|
||||
func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *m
|
||||
clone.Blocks = append([]byte(nil), m.Blocks...)
|
||||
clone.Photos = append([]domain.Photo(nil), m.Photos...)
|
||||
clone.Documents = append([]domain.Document(nil), m.Documents...)
|
||||
return &clone
|
||||
}
|
||||
|
||||
func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply {
|
||||
if reply == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *reply
|
||||
clone.QuoteEntities = append([]domain.MessageEntity(nil), reply.QuoteEntities...)
|
||||
return &clone
|
||||
}
|
||||
|
||||
func cloneMessageForward(forward *domain.MessageForward) *domain.MessageForward {
|
||||
if forward == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *forward
|
||||
return &clone
|
||||
}
|
||||
|
||||
func newMessageEvent(msg domain.Message) domain.UpdateEvent {
|
||||
if msg.ID == 0 {
|
||||
return domain.UpdateEvent{}
|
||||
}
|
||||
return domain.UpdateEvent{
|
||||
UserID: msg.OwnerUserID,
|
||||
Type: domain.UpdateEventNewMessage,
|
||||
Pts: msg.Pts,
|
||||
PtsCount: 1,
|
||||
Date: msg.Date,
|
||||
Message: cloneMessage(msg),
|
||||
}
|
||||
}
|
||||
|
||||
func editMessageEvent(msg domain.Message) domain.UpdateEvent {
|
||||
if msg.ID == 0 {
|
||||
return domain.UpdateEvent{}
|
||||
}
|
||||
return domain.UpdateEvent{
|
||||
UserID: msg.OwnerUserID,
|
||||
Type: domain.UpdateEventEditMessage,
|
||||
Pts: msg.Pts,
|
||||
PtsCount: 1,
|
||||
Date: msg.EditDate,
|
||||
Message: cloneMessage(msg),
|
||||
}
|
||||
}
|
||||
|
||||
// webPageEvent 是链接预览就地替换事件(Date 取消息发送时间,不引入 edit_date)。
|
||||
func webPageEvent(msg domain.Message) domain.UpdateEvent {
|
||||
if msg.ID == 0 {
|
||||
return domain.UpdateEvent{}
|
||||
}
|
||||
return domain.UpdateEvent{
|
||||
UserID: msg.OwnerUserID,
|
||||
Type: domain.UpdateEventWebPage,
|
||||
Pts: msg.Pts,
|
||||
PtsCount: 1,
|
||||
Date: msg.Date,
|
||||
Message: cloneMessage(msg),
|
||||
}
|
||||
}
|
||||
|
||||
func equalMessageEntities(a, b []domain.MessageEntity) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasUser(users []domain.User, id int64) bool {
|
||||
for _, u := range users {
|
||||
if u.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func messageListHash(messages []domain.Message) int64 {
|
||||
if len(messages) == 0 {
|
||||
return 0
|
||||
}
|
||||
h := fnv.New64a()
|
||||
var buf [16]byte
|
||||
for _, msg := range messages {
|
||||
binary.LittleEndian.PutUint32(buf[:4], uint32(msg.ID))
|
||||
binary.LittleEndian.PutUint32(buf[4:8], uint32(msg.Date))
|
||||
binary.LittleEndian.PutUint64(buf[8:16], uint64(msg.From.ID))
|
||||
_, _ = h.Write(buf[:])
|
||||
writeMessageReactionsHash(h, msg.Reactions)
|
||||
}
|
||||
return int64(h.Sum64())
|
||||
}
|
||||
|
||||
func cloneMessages(messages []domain.Message) []domain.Message {
|
||||
out := append([]domain.Message(nil), messages...)
|
||||
for i := range out {
|
||||
out[i] = cloneMessage(out[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
423
internal/store/memory/message_history.go
Normal file
423
internal/store/memory/message_history.go
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *MessageStore) GetByIDs(_ context.Context, userID int64, ids []int) (domain.MessageList, error) {
|
||||
if userID == 0 || len(ids) == 0 {
|
||||
return domain.MessageList{}, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
byID := make(map[int]domain.Message, len(s.m[userID]))
|
||||
for _, msg := range s.m[userID] {
|
||||
item := cloneMessage(msg)
|
||||
reactions := s.privateMessageReactionsForMessageLocked(item)
|
||||
if len(reactions.Results) > 0 || len(reactions.Recent) > 0 {
|
||||
item.Reactions = cloneChannelMessageReactionsPtr(&reactions)
|
||||
}
|
||||
byID[msg.ID] = item
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
out := domain.MessageList{Messages: make([]domain.Message, 0, len(ids))}
|
||||
for _, id := range ids {
|
||||
if msg, ok := byID[id]; ok {
|
||||
out.Messages = append(out.Messages, msg)
|
||||
}
|
||||
}
|
||||
s.enrichPrivateMessagePolls(out.Messages, int(time.Now().Unix()))
|
||||
out.Users = usersForMessages(out.Messages)
|
||||
out.Hash = messageListHash(out.Messages)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) ListByUser(_ context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
|
||||
s.mu.RLock()
|
||||
messages := cloneMessages(s.m[userID])
|
||||
for i := range messages {
|
||||
reactions := s.privateMessageReactionsForMessageLocked(messages[i])
|
||||
if len(reactions.Results) > 0 || len(reactions.Recent) > 0 {
|
||||
messages[i].Reactions = cloneChannelMessageReactionsPtr(&reactions)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
s.enrichPrivateMessagePolls(messages, int(time.Now().Unix()))
|
||||
return filterMessageList(messages, filter), nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) ReadHistory(_ context.Context, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) {
|
||||
res := domain.ReadHistoryResult{OwnerUserID: req.OwnerUserID, Peer: req.Peer, MaxID: req.MaxID}
|
||||
if req.OwnerUserID == 0 || req.Peer.ID == 0 {
|
||||
return res, nil
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.dialogs == nil {
|
||||
return res, nil
|
||||
}
|
||||
s.dialogs.mu.Lock()
|
||||
defer s.dialogs.mu.Unlock()
|
||||
list := s.dialogs.m[req.OwnerUserID]
|
||||
for i, dialog := range list.Dialogs {
|
||||
if dialog.Peer != req.Peer {
|
||||
continue
|
||||
}
|
||||
readMax := req.MaxID
|
||||
if readMax <= 0 {
|
||||
readMax = dialog.TopMessage
|
||||
}
|
||||
if readMax > dialog.TopMessage {
|
||||
readMax = dialog.TopMessage
|
||||
}
|
||||
oldRead := dialog.ReadInboxMaxID
|
||||
res.MaxID = readMax
|
||||
advancesRead := readMax > oldRead
|
||||
if !advancesRead {
|
||||
if dialog.UnreadCount > 0 {
|
||||
unread := 0
|
||||
for _, msg := range s.m[req.OwnerUserID] {
|
||||
if msg.Peer == req.Peer && !msg.Out && msg.ID > oldRead {
|
||||
unread++
|
||||
}
|
||||
}
|
||||
dialog.UnreadCount = unread
|
||||
dialog.UnreadMentions = 0
|
||||
// readHistory 不清 reaction 角标(与 PG 一致;reaction 未读由
|
||||
// readReactions/readMessageContents 单独清,否则角标数与 getUnreadReactions
|
||||
// 跳转列表对不上)。
|
||||
dialog.UnreadMark = false
|
||||
res.MaxID = dialog.ReadInboxMaxID
|
||||
res.StillUnreadCount = unread
|
||||
list.Dialogs[i] = dialog
|
||||
s.dialogs.m[req.OwnerUserID] = list
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
res.Changed = true
|
||||
var latestIncoming domain.Message
|
||||
unread := 0
|
||||
for _, msg := range s.m[req.OwnerUserID] {
|
||||
if msg.Peer != req.Peer || msg.Out {
|
||||
continue
|
||||
}
|
||||
if msg.ID > readMax {
|
||||
unread++
|
||||
continue
|
||||
}
|
||||
if msg.ID > oldRead && msg.ID > latestIncoming.ID {
|
||||
latestIncoming = msg
|
||||
}
|
||||
}
|
||||
if readMax > dialog.ReadInboxMaxID {
|
||||
dialog.ReadInboxMaxID = readMax
|
||||
}
|
||||
dialog.UnreadCount = unread
|
||||
dialog.UnreadMentions = 0
|
||||
// readHistory 不清 reaction 角标(与 PG 一致,见上)。
|
||||
dialog.UnreadMark = false
|
||||
res.StillUnreadCount = unread
|
||||
pts := s.nextPtsLocked(req.OwnerUserID)
|
||||
res.InboxEvent = domain.UpdateEvent{
|
||||
UserID: req.OwnerUserID,
|
||||
Type: domain.UpdateEventReadHistoryInbox,
|
||||
Pts: pts,
|
||||
PtsCount: 1,
|
||||
Date: req.Date,
|
||||
Peer: req.Peer,
|
||||
MaxID: readMax,
|
||||
StillUnreadCount: unread,
|
||||
}
|
||||
list.Dialogs[i] = dialog
|
||||
s.dialogs.m[req.OwnerUserID] = list
|
||||
|
||||
if latestIncoming.ID != 0 && latestIncoming.From.ID != 0 && latestIncoming.From.ID != req.OwnerUserID {
|
||||
senderUserID := latestIncoming.From.ID
|
||||
senderBoxID := 0
|
||||
for _, msg := range s.m[senderUserID] {
|
||||
if msg.UID == latestIncoming.UID && msg.Out {
|
||||
senderBoxID = msg.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if senderBoxID > 0 {
|
||||
senderList := s.dialogs.m[senderUserID]
|
||||
for j, senderDialog := range senderList.Dialogs {
|
||||
if senderDialog.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}) {
|
||||
continue
|
||||
}
|
||||
if senderBoxID <= senderDialog.ReadOutboxMaxID {
|
||||
break
|
||||
}
|
||||
oldOutbox := senderDialog.ReadOutboxMaxID
|
||||
senderDialog.ReadOutboxMaxID = senderBoxID
|
||||
senderList.Dialogs[j] = senderDialog
|
||||
s.dialogs.m[senderUserID] = senderList
|
||||
for _, msg := range s.m[senderUserID] {
|
||||
if msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}) && msg.Out && msg.ID > oldOutbox && msg.ID <= senderBoxID {
|
||||
s.readOutboxDates[readOutboxDateKey{ownerUserID: senderUserID, peerID: req.OwnerUserID, msgID: msg.ID}] = req.Date
|
||||
}
|
||||
}
|
||||
outPts := s.nextPtsLocked(senderUserID)
|
||||
res.OutboxChanged = true
|
||||
res.OutboxUserID = senderUserID
|
||||
res.OutboxEvent = domain.UpdateEvent{
|
||||
UserID: senderUserID,
|
||||
Type: domain.UpdateEventReadHistoryOutbox,
|
||||
Pts: outPts,
|
||||
PtsCount: 1,
|
||||
Date: req.Date,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID},
|
||||
MaxID: senderBoxID,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) DeleteHistory(_ context.Context, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) {
|
||||
res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID}
|
||||
if req.OwnerUserID == 0 || req.Peer.ID == 0 {
|
||||
return res, nil
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
inDateRange := func(msg domain.Message) bool {
|
||||
if req.MinDate > 0 && msg.Date < req.MinDate {
|
||||
return false
|
||||
}
|
||||
if req.MaxDate > 0 && msg.Date > req.MaxDate {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
deleted, revokeUIDs, more := s.deleteMemoryMessagesLocked(req.OwnerUserID, domain.MaxDeleteHistoryBatch, func(msg domain.Message) bool {
|
||||
return msg.Peer == req.Peer && (req.MaxID <= 0 || msg.ID <= req.MaxID) && inDateRange(msg)
|
||||
})
|
||||
if req.Revoke {
|
||||
if len(revokeUIDs) > 0 {
|
||||
deleted = append(deleted, s.deleteMemoryMessagesByUIDLocked(revokeUIDs, req.OwnerUserID)...)
|
||||
}
|
||||
// 与 PG 同语义:全量/按日期的双向清史直扫对端残余,我方早已
|
||||
// 单向删除的消息不能在对端残留。
|
||||
if req.MaxID <= 0 && req.Peer.ID != req.OwnerUserID {
|
||||
peerDeleted, _, peerMore := s.deleteMemoryMessagesLocked(req.Peer.ID, domain.MaxDeleteHistoryBatch, func(msg domain.Message) bool {
|
||||
return msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}) && inDateRange(msg)
|
||||
})
|
||||
deleted = append(deleted, peerDeleted...)
|
||||
more = more || peerMore
|
||||
}
|
||||
}
|
||||
res = s.finishMemoryDeleteLocked(res, deleted, req.Date, req.JustClear)
|
||||
if more {
|
||||
res.Offset = 1
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func filterMessageList(messages []domain.Message, filter domain.MessageFilter) domain.MessageList {
|
||||
filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset)
|
||||
sort.SliceStable(messages, func(i, j int) bool {
|
||||
return messageLess(messages[i], messages[j])
|
||||
})
|
||||
|
||||
query := strings.ToLower(filter.Query)
|
||||
base := make([]domain.Message, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
if filter.HasPeer && msg.Peer != filter.Peer {
|
||||
continue
|
||||
}
|
||||
if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) {
|
||||
continue
|
||||
}
|
||||
if filter.MaxID > 0 && msg.ID >= filter.MaxID {
|
||||
continue
|
||||
}
|
||||
if filter.MinID > 0 && msg.ID <= filter.MinID {
|
||||
continue
|
||||
}
|
||||
if filter.PinnedOnly && !msg.Pinned {
|
||||
continue
|
||||
}
|
||||
if filter.MusicOnly && !msg.Media.IsMusic() {
|
||||
continue
|
||||
}
|
||||
if filter.SavedPeer.ID != 0 && msg.SavedPeer != filter.SavedPeer {
|
||||
continue
|
||||
}
|
||||
base = append(base, msg)
|
||||
}
|
||||
|
||||
limit := filter.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
page := pageMessageHistory(base, filter, limit)
|
||||
return domain.MessageList{
|
||||
Messages: page,
|
||||
Users: usersForMessages(page),
|
||||
Count: len(base),
|
||||
Hash: messageListHash(base),
|
||||
}
|
||||
}
|
||||
|
||||
func pageMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int) []domain.Message {
|
||||
if limit <= 0 || len(base) == 0 {
|
||||
return nil
|
||||
}
|
||||
switch messageHistoryLoadType(filter.AddOffset, limit) {
|
||||
case messageHistoryLoadForward:
|
||||
return cloneMessages(forwardMessageHistory(base, filter, limit))
|
||||
case messageHistoryLoadAround:
|
||||
forwardLimit := -filter.AddOffset
|
||||
if forwardLimit > limit {
|
||||
forwardLimit = limit
|
||||
}
|
||||
backwardLimit := limit + filter.AddOffset
|
||||
if backwardLimit < 0 {
|
||||
backwardLimit = 0
|
||||
}
|
||||
page := make([]domain.Message, 0, limit)
|
||||
page = append(page, forwardMessageHistory(base, filter, forwardLimit)...)
|
||||
page = append(page, backwardMessageHistory(base, filter, backwardLimit, true)...)
|
||||
sort.SliceStable(page, func(i, j int) bool {
|
||||
return messageLess(page[i], page[j])
|
||||
})
|
||||
return cloneMessages(page)
|
||||
default:
|
||||
start := filter.AddOffset
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
candidates := backwardMessageHistory(base, filter, limit+start, false)
|
||||
if start >= len(candidates) {
|
||||
return nil
|
||||
}
|
||||
return cloneMessages(candidates[start:])
|
||||
}
|
||||
}
|
||||
|
||||
type messageHistoryLoad int
|
||||
|
||||
const (
|
||||
messageHistoryLoadBackward messageHistoryLoad = iota
|
||||
messageHistoryLoadForward
|
||||
messageHistoryLoadAround
|
||||
)
|
||||
|
||||
func messageHistoryLoadType(addOffset, limit int) messageHistoryLoad {
|
||||
if addOffset >= 0 {
|
||||
return messageHistoryLoadBackward
|
||||
}
|
||||
if addOffset+limit > 0 {
|
||||
return messageHistoryLoadAround
|
||||
}
|
||||
return messageHistoryLoadForward
|
||||
}
|
||||
|
||||
func backwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int, includeOffset bool) []domain.Message {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]domain.Message, 0, limit)
|
||||
for _, msg := range base {
|
||||
if !messageBeforeHistoryOffset(msg, filter, includeOffset) {
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func forwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int) []domain.Message {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]domain.Message, 0, limit)
|
||||
for i := len(base) - 1; i >= 0; i-- {
|
||||
msg := base[i]
|
||||
if !messageAfterHistoryOffset(msg, filter) {
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return messageLess(out[i], out[j])
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func messageBeforeHistoryOffset(msg domain.Message, filter domain.MessageFilter, includeOffset bool) bool {
|
||||
if filter.OffsetDate > 0 {
|
||||
if includeOffset {
|
||||
return msg.Date <= filter.OffsetDate
|
||||
}
|
||||
return msg.Date < filter.OffsetDate
|
||||
}
|
||||
if filter.OffsetID <= 0 {
|
||||
return true
|
||||
}
|
||||
if includeOffset {
|
||||
return msg.ID <= filter.OffsetID
|
||||
}
|
||||
return msg.ID < filter.OffsetID
|
||||
}
|
||||
|
||||
func messageAfterHistoryOffset(msg domain.Message, filter domain.MessageFilter) bool {
|
||||
if filter.OffsetDate > 0 {
|
||||
return msg.Date >= filter.OffsetDate
|
||||
}
|
||||
if filter.OffsetID <= 0 {
|
||||
return false
|
||||
}
|
||||
return msg.ID > filter.OffsetID
|
||||
}
|
||||
|
||||
func messageLess(a, b domain.Message) bool {
|
||||
if a.Date != b.Date {
|
||||
return a.Date > b.Date
|
||||
}
|
||||
return a.ID > b.ID
|
||||
}
|
||||
|
||||
func usersForMessages(messages []domain.Message) []domain.User {
|
||||
seen := map[int64]struct{}{}
|
||||
users := make([]domain.User, 0, 1)
|
||||
for _, msg := range messages {
|
||||
for _, peer := range []domain.Peer{msg.Peer, msg.From} {
|
||||
if peer.Type != domain.PeerTypeUser {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[peer.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[peer.ID] = struct{}{}
|
||||
if u, ok := domain.SystemUserByID(peer.ID); ok {
|
||||
users = append(users, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
157
internal/store/memory/message_pin.go
Normal file
157
internal/store/memory/message_pin.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// PinPrivateMessage 与 PG 实现同语义:非 pm_oneside 的 pin 双侧置位,
|
||||
// unpin 恒双侧清除,状态未变化时幂等 no-op;服务消息不可置顶。
|
||||
func (s *MessageStore) PinPrivateMessage(_ context.Context, req domain.PinPrivateMessageRequest) (domain.PinPrivateMessageResult, error) {
|
||||
res := domain.PinPrivateMessageResult{OwnerUserID: req.OwnerUserID}
|
||||
if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ownIdx := -1
|
||||
for i, msg := range s.m[req.OwnerUserID] {
|
||||
if msg.Peer == req.Peer && msg.ID == req.MessageID {
|
||||
ownIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if ownIdx < 0 {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
owned := s.m[req.OwnerUserID][ownIdx]
|
||||
if owned.Media != nil && owned.Media.Kind == domain.MessageMediaKindService {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if owned.Pinned == req.Pinned && (!req.Pinned || req.PmOneside) {
|
||||
// 与 PG 同语义:unpin/oneside pin 幂等短路;共享 pin 仍需检查
|
||||
// 对端传播。
|
||||
return res, nil
|
||||
}
|
||||
type pinSide struct {
|
||||
userID int64
|
||||
peer domain.Peer
|
||||
idx int
|
||||
}
|
||||
sides := []pinSide{{userID: req.OwnerUserID, peer: req.Peer, idx: ownIdx}}
|
||||
if req.Peer.ID != req.OwnerUserID && (!req.Pinned || !req.PmOneside) {
|
||||
for i, msg := range s.m[req.Peer.ID] {
|
||||
if msg.UID == owned.UID {
|
||||
sides = append(sides, pinSide{
|
||||
userID: req.Peer.ID,
|
||||
peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID},
|
||||
idx: i,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, side := range sides {
|
||||
if s.m[side.userID][side.idx].Pinned == req.Pinned {
|
||||
continue
|
||||
}
|
||||
s.m[side.userID][side.idx].Pinned = req.Pinned
|
||||
boxID := s.m[side.userID][side.idx].ID
|
||||
res.Updated = append(res.Updated, domain.PinnedMessagesForUser{
|
||||
UserID: side.userID,
|
||||
Peer: side.peer,
|
||||
MessageIDs: []int{boxID},
|
||||
Pinned: req.Pinned,
|
||||
Event: domain.UpdateEvent{
|
||||
UserID: side.userID,
|
||||
Type: domain.UpdateEventPinnedMessages,
|
||||
Pts: s.nextPtsLocked(side.userID),
|
||||
PtsCount: 1,
|
||||
Date: req.Date,
|
||||
Peer: side.peer,
|
||||
Bool: req.Pinned,
|
||||
MessageIDs: []int{boxID},
|
||||
},
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UnpinAllPrivateMessages 与 PG 实现同语义:本侧整批清除并经 UID 同步
|
||||
// 清除对端共享置顶。
|
||||
func (s *MessageStore) UnpinAllPrivateMessages(_ context.Context, req domain.UnpinAllPrivateMessagesRequest) (domain.PinPrivateMessageResult, error) {
|
||||
res := domain.PinPrivateMessageResult{OwnerUserID: req.OwnerUserID}
|
||||
if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// 与 PG 同语义:按 box_id 降序取一批清除,剩余批次以 Offset=1 续清。
|
||||
pinnedIdx := make([]int, 0)
|
||||
for i, msg := range s.m[req.OwnerUserID] {
|
||||
if msg.Peer == req.Peer && msg.Pinned {
|
||||
pinnedIdx = append(pinnedIdx, i)
|
||||
}
|
||||
}
|
||||
sort.Slice(pinnedIdx, func(a, b int) bool {
|
||||
return s.m[req.OwnerUserID][pinnedIdx[a]].ID > s.m[req.OwnerUserID][pinnedIdx[b]].ID
|
||||
})
|
||||
if len(pinnedIdx) > domain.MaxUnpinAllBatch {
|
||||
pinnedIdx = pinnedIdx[:domain.MaxUnpinAllBatch]
|
||||
res.Offset = 1
|
||||
}
|
||||
ownIDs := make([]int, 0, len(pinnedIdx))
|
||||
uids := make(map[int64]struct{})
|
||||
for _, i := range pinnedIdx {
|
||||
s.m[req.OwnerUserID][i].Pinned = false
|
||||
ownIDs = append(ownIDs, s.m[req.OwnerUserID][i].ID)
|
||||
uids[s.m[req.OwnerUserID][i].UID] = struct{}{}
|
||||
}
|
||||
if len(ownIDs) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
appendSide := func(userID int64, peer domain.Peer, ids []int) {
|
||||
res.Updated = append(res.Updated, domain.PinnedMessagesForUser{
|
||||
UserID: userID,
|
||||
Peer: peer,
|
||||
MessageIDs: ids,
|
||||
Pinned: false,
|
||||
Event: domain.UpdateEvent{
|
||||
UserID: userID,
|
||||
Type: domain.UpdateEventPinnedMessages,
|
||||
Pts: s.nextPtsLocked(userID),
|
||||
PtsCount: 1,
|
||||
Date: req.Date,
|
||||
Peer: peer,
|
||||
Bool: false,
|
||||
MessageIDs: ids,
|
||||
},
|
||||
})
|
||||
}
|
||||
appendSide(req.OwnerUserID, req.Peer, ownIDs)
|
||||
if req.Peer.ID != req.OwnerUserID {
|
||||
peerIDs := make([]int, 0)
|
||||
for i, msg := range s.m[req.Peer.ID] {
|
||||
if _, ok := uids[msg.UID]; ok && msg.Pinned {
|
||||
s.m[req.Peer.ID][i].Pinned = false
|
||||
peerIDs = append(peerIDs, msg.ID)
|
||||
}
|
||||
}
|
||||
if len(peerIDs) > 0 {
|
||||
appendSide(req.Peer.ID, domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, peerIDs)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
89
internal/store/memory/message_polls.go
Normal file
89
internal/store/memory/message_polls.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// 私聊消息 poll 投票/关闭:消息可见性在本 store 校验,poll 级校验与状态全部
|
||||
// 委托共享 PollStore(与 postgres 实现共用 domain 纯函数语义)。
|
||||
|
||||
func (s *MessageStore) VoteMessagePoll(_ context.Context, req domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) {
|
||||
target, err := s.pollMessageTarget(req.UserID, req.Peer, req.MessageID)
|
||||
if err != nil {
|
||||
return domain.PrivateMessagePollResult{}, err
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
if err := s.polls.Vote(target.Media.Poll.ID, req.UserID, req.Options, req.Date); err != nil {
|
||||
return domain.PrivateMessagePollResult{}, err
|
||||
}
|
||||
return s.privatePollResult(target.UID, target.Media.Poll.ID, req.Date), nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) CloseMessagePoll(_ context.Context, req domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) {
|
||||
target, err := s.pollMessageTarget(req.UserID, req.Peer, req.MessageID)
|
||||
if err != nil {
|
||||
return domain.PrivateMessagePollResult{}, err
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
if err := s.polls.Close(target.Media.Poll.ID, req.UserID); err != nil {
|
||||
return domain.PrivateMessagePollResult{}, err
|
||||
}
|
||||
return s.privatePollResult(target.UID, target.Media.Poll.ID, req.Date), nil
|
||||
}
|
||||
|
||||
// pollMessageTarget 定位 viewer box 中带 poll 的目标消息。
|
||||
func (s *MessageStore) pollMessageTarget(userID int64, peer domain.Peer, messageID int) (domain.Message, error) {
|
||||
if s == nil || s.polls == nil {
|
||||
return domain.Message{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if userID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 || messageID <= 0 || messageID > domain.MaxMessageBoxID {
|
||||
return domain.Message{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, msg := range s.m[userID] {
|
||||
if msg.ID != messageID || msg.Peer != peer {
|
||||
continue
|
||||
}
|
||||
if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindPoll || msg.Media.Poll == nil || msg.Media.Poll.ID == 0 {
|
||||
return domain.Message{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
return domain.Message{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
|
||||
// privatePollResult 收集同一 UID 的全部 owner 副本,并按各自 owner 视角 enrich poll。
|
||||
func (s *MessageStore) privatePollResult(uid, pollID int64, now int) domain.PrivateMessagePollResult {
|
||||
out := domain.PrivateMessagePollResult{PollID: pollID}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, messages := range s.m {
|
||||
for _, msg := range messages {
|
||||
if msg.UID != uid {
|
||||
continue
|
||||
}
|
||||
item := cloneMessage(msg)
|
||||
item.Media = enrichPollMediaForViewer(s.polls, item.Media, item.OwnerUserID, now)
|
||||
out.Messages = append(out.Messages, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// enrichPrivateMessagePolls 是私聊读路径的 poll enrichment 入口(与 reactions 填充并列)。
|
||||
func (s *MessageStore) enrichPrivateMessagePolls(messages []domain.Message, now int) {
|
||||
if s == nil || s.polls == nil {
|
||||
return
|
||||
}
|
||||
for i := range messages {
|
||||
messages[i].Media = enrichPollMediaForViewer(s.polls, messages[i].Media, messages[i].OwnerUserID, now)
|
||||
}
|
||||
}
|
||||
255
internal/store/memory/message_reactions.go
Normal file
255
internal/store/memory/message_reactions.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"hash"
|
||||
"sort"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *MessageStore) SetMessageReactions(_ context.Context, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) {
|
||||
if req.UserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID {
|
||||
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser {
|
||||
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
req.Reactions = domain.TrimMessageReactionsToUserMax(req.Reactions, req.ReactionsPerUserMax)
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var target domain.Message
|
||||
for _, msg := range s.m[req.UserID] {
|
||||
if msg.ID == req.MessageID && msg.Peer == req.Peer {
|
||||
target = msg
|
||||
break
|
||||
}
|
||||
}
|
||||
if target.ID == 0 || target.UID == 0 {
|
||||
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if _, ok := s.privateReactions[target.UID]; !ok {
|
||||
s.privateReactions[target.UID] = make(map[int64][]domain.ChannelMessagePeerReaction)
|
||||
}
|
||||
rows := make([]domain.ChannelMessagePeerReaction, 0, len(req.Reactions))
|
||||
for i, reaction := range req.Reactions {
|
||||
if !reaction.Valid() {
|
||||
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
rows = append(rows, domain.ChannelMessagePeerReaction{
|
||||
UserID: req.UserID,
|
||||
Reaction: reaction,
|
||||
Big: req.Big,
|
||||
My: true,
|
||||
ChosenOrder: i + 1,
|
||||
Date: req.Date,
|
||||
})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
delete(s.privateReactions[target.UID], req.UserID)
|
||||
} else {
|
||||
s.privateReactions[target.UID][req.UserID] = rows
|
||||
}
|
||||
if target.From.ID != 0 && target.From.ID != req.UserID {
|
||||
for i := range s.m[target.From.ID] {
|
||||
if s.m[target.From.ID][i].UID != target.UID {
|
||||
continue
|
||||
}
|
||||
s.m[target.From.ID][i].ReactionUnread = len(rows) > 0
|
||||
break
|
||||
}
|
||||
}
|
||||
s.refreshPrivateReactionDialogSnapshotsLocked(target.UID)
|
||||
return s.privateReactionResultLocked(target.UID), nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) GetMessageReactions(_ context.Context, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) {
|
||||
if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || len(req.IDs) > domain.MaxGetMessageIDs {
|
||||
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
ids := make(map[int]struct{}, len(req.IDs))
|
||||
for _, id := range req.IDs {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := domain.PrivateMessageReactionsResult{}
|
||||
for _, msg := range s.m[req.OwnerUserID] {
|
||||
if msg.Peer != req.Peer {
|
||||
continue
|
||||
}
|
||||
if _, ok := ids[msg.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
item := cloneMessage(msg)
|
||||
reactions := s.privateMessageReactionsForMessageLocked(item)
|
||||
item.Reactions = cloneChannelMessageReactionsPtr(&reactions)
|
||||
out.Messages = append(out.Messages, item)
|
||||
if len(out.Reactions.Results) == 0 && len(out.Reactions.Recent) == 0 {
|
||||
out.Reactions = reactions
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) privateReactionResultLocked(uid int64) domain.PrivateMessageReactionsResult {
|
||||
out := domain.PrivateMessageReactionsResult{}
|
||||
for _, messages := range s.m {
|
||||
for _, msg := range messages {
|
||||
if msg.UID != uid {
|
||||
continue
|
||||
}
|
||||
item := cloneMessage(msg)
|
||||
reactions := s.privateMessageReactionsForMessageLocked(item)
|
||||
item.Reactions = cloneChannelMessageReactionsPtr(&reactions)
|
||||
out.Messages = append(out.Messages, item)
|
||||
if len(out.Reactions.Results) == 0 && len(out.Reactions.Recent) == 0 {
|
||||
out.Reactions = reactions
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *MessageStore) privateMessageReactionsForMessageLocked(msg domain.Message) domain.ChannelMessageReactions {
|
||||
reactions := s.privateMessageReactionsLocked(msg.UID, msg.OwnerUserID)
|
||||
if len(reactions.Recent) == 0 || msg.From.ID == 0 {
|
||||
return reactions
|
||||
}
|
||||
for i := range reactions.Recent {
|
||||
reactions.Recent[i].SenderUserID = msg.From.ID
|
||||
if msg.ReactionUnread && msg.From.ID == msg.OwnerUserID && reactions.Recent[i].UserID != msg.OwnerUserID {
|
||||
reactions.Recent[i].Unread = true
|
||||
}
|
||||
}
|
||||
return reactions
|
||||
}
|
||||
|
||||
func (s *MessageStore) privateMessageReactionsLocked(uid, viewerUserID int64) domain.ChannelMessageReactions {
|
||||
byUser := s.privateReactions[uid]
|
||||
out := domain.ChannelMessageReactions{CanSeeList: true}
|
||||
if len(byUser) == 0 {
|
||||
return out
|
||||
}
|
||||
counts := make(map[string]int)
|
||||
recent := make([]domain.ChannelMessagePeerReaction, 0, len(byUser))
|
||||
for userID, rows := range byUser {
|
||||
for _, row := range rows {
|
||||
key := row.Reaction.Key()
|
||||
index, ok := counts[key]
|
||||
if !ok {
|
||||
out.Results = append(out.Results, domain.ChannelMessageReactionCount{Reaction: row.Reaction})
|
||||
index = len(out.Results) - 1
|
||||
counts[key] = index
|
||||
}
|
||||
out.Results[index].Count++
|
||||
if userID == viewerUserID && (out.Results[index].ChosenOrder == 0 || row.ChosenOrder < out.Results[index].ChosenOrder) {
|
||||
out.Results[index].ChosenOrder = row.ChosenOrder
|
||||
}
|
||||
item := row
|
||||
item.UserID = userID
|
||||
item.My = userID == viewerUserID
|
||||
recent = append(recent, item)
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Results, func(i, j int) bool {
|
||||
if out.Results[i].Count != out.Results[j].Count {
|
||||
return out.Results[i].Count > out.Results[j].Count
|
||||
}
|
||||
return out.Results[i].Reaction.Key() < out.Results[j].Reaction.Key()
|
||||
})
|
||||
sort.Slice(recent, func(i, j int) bool {
|
||||
if recent[i].Date != recent[j].Date {
|
||||
return recent[i].Date > recent[j].Date
|
||||
}
|
||||
return recent[i].UserID < recent[j].UserID
|
||||
})
|
||||
if len(recent) > domain.MaxChannelMessageReactionRecent {
|
||||
recent = recent[:domain.MaxChannelMessageReactionRecent]
|
||||
}
|
||||
out.Recent = recent
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *MessageStore) countPrivateUnreadReactionsLocked(ownerUserID int64, peer domain.Peer) int {
|
||||
count := 0
|
||||
for _, msg := range s.m[ownerUserID] {
|
||||
if msg.Peer == peer && msg.ReactionUnread {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *MessageStore) refreshPrivateReactionDialogSnapshotsLocked(uid int64) {
|
||||
if s.dialogs == nil || uid == 0 {
|
||||
return
|
||||
}
|
||||
s.dialogs.mu.Lock()
|
||||
defer s.dialogs.mu.Unlock()
|
||||
for ownerID, messages := range s.m {
|
||||
list := s.dialogs.m[ownerID]
|
||||
changed := false
|
||||
for _, msg := range messages {
|
||||
if msg.UID != uid {
|
||||
continue
|
||||
}
|
||||
enriched := cloneMessage(msg)
|
||||
reactions := s.privateMessageReactionsForMessageLocked(enriched)
|
||||
if len(reactions.Results) > 0 || len(reactions.Recent) > 0 {
|
||||
enriched.Reactions = cloneChannelMessageReactionsPtr(&reactions)
|
||||
}
|
||||
for i := range list.Messages {
|
||||
if list.Messages[i].UID == uid && list.Messages[i].OwnerUserID == ownerID {
|
||||
list.Messages[i] = enriched
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for i := range list.Dialogs {
|
||||
if list.Dialogs[i].Peer == msg.Peer {
|
||||
list.Dialogs[i].UnreadReactions = s.countPrivateUnreadReactionsLocked(ownerID, msg.Peer)
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
s.dialogs.m[ownerID] = list
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeMessageReactionsHash(h hash.Hash64, reactions *domain.ChannelMessageReactions) {
|
||||
if reactions == nil {
|
||||
_, _ = h.Write([]byte{0})
|
||||
return
|
||||
}
|
||||
var buf [16]byte
|
||||
for _, item := range reactions.Results {
|
||||
_, _ = h.Write([]byte(item.Reaction.Type))
|
||||
_, _ = h.Write([]byte{0})
|
||||
_, _ = h.Write([]byte(item.Reaction.Value()))
|
||||
_, _ = h.Write([]byte{0})
|
||||
binary.LittleEndian.PutUint32(buf[:4], uint32(item.Count))
|
||||
binary.LittleEndian.PutUint32(buf[4:8], uint32(item.ChosenOrder))
|
||||
_, _ = h.Write(buf[:8])
|
||||
}
|
||||
_, _ = h.Write([]byte{0xfe})
|
||||
for _, item := range reactions.Recent {
|
||||
_, _ = h.Write([]byte(item.Reaction.Type))
|
||||
_, _ = h.Write([]byte{0})
|
||||
_, _ = h.Write([]byte(item.Reaction.Value()))
|
||||
_, _ = h.Write([]byte{0})
|
||||
binary.LittleEndian.PutUint64(buf[:8], uint64(item.UserID))
|
||||
binary.LittleEndian.PutUint32(buf[8:12], uint32(item.Date))
|
||||
binary.LittleEndian.PutUint32(buf[12:16], uint32(item.ChosenOrder))
|
||||
_, _ = h.Write(buf[:])
|
||||
}
|
||||
}
|
||||
173
internal/store/memory/message_read.go
Normal file
173
internal/store/memory/message_read.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *MessageStore) ReadMessageContents(_ context.Context, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) {
|
||||
res := domain.ReadMessageContentsResult{OwnerUserID: req.OwnerUserID}
|
||||
if req.OwnerUserID == 0 {
|
||||
return res, fmt.Errorf("read message contents: missing owner user id")
|
||||
}
|
||||
if len(req.IDs) > domain.MaxGetMessageIDs {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
wanted := make(map[int]struct{}, len(req.IDs))
|
||||
for _, id := range req.IDs {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return res, domain.ErrMessageIDInvalid
|
||||
}
|
||||
wanted[id] = struct{}{}
|
||||
}
|
||||
if len(wanted) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
reactionUIDs := make(map[int64]struct{})
|
||||
senderUIDs := make(map[int64]map[int64]struct{})
|
||||
for i := range s.m[req.OwnerUserID] {
|
||||
msg := &s.m[req.OwnerUserID][i]
|
||||
if _, ok := wanted[msg.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
if !msg.MediaUnread && !msg.ReactionUnread {
|
||||
continue
|
||||
}
|
||||
if msg.ReactionUnread && msg.Peer.ID != 0 {
|
||||
reactionUIDs[msg.UID] = struct{}{}
|
||||
}
|
||||
if msg.MediaUnread && !msg.Out && msg.From.Type == domain.PeerTypeUser && msg.From.ID != 0 && msg.From.ID != req.OwnerUserID {
|
||||
if senderUIDs[msg.From.ID] == nil {
|
||||
senderUIDs[msg.From.ID] = make(map[int64]struct{})
|
||||
}
|
||||
senderUIDs[msg.From.ID][msg.UID] = struct{}{}
|
||||
}
|
||||
msg.MediaUnread = false
|
||||
msg.ReactionUnread = false
|
||||
res.MessageIDs = append(res.MessageIDs, msg.ID)
|
||||
}
|
||||
sort.Ints(res.MessageIDs)
|
||||
if len(res.MessageIDs) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
senderIDs := make([]int64, 0, len(senderUIDs))
|
||||
for senderID := range senderUIDs {
|
||||
senderIDs = append(senderIDs, senderID)
|
||||
}
|
||||
sort.Slice(senderIDs, func(i, j int) bool { return senderIDs[i] < senderIDs[j] })
|
||||
for _, senderID := range senderIDs {
|
||||
boxIDs := make([]int, 0, len(senderUIDs[senderID]))
|
||||
for i := range s.m[senderID] {
|
||||
msg := &s.m[senderID][i]
|
||||
if _, ok := senderUIDs[senderID][msg.UID]; !ok || !msg.Out || !msg.MediaUnread {
|
||||
continue
|
||||
}
|
||||
msg.MediaUnread = false
|
||||
boxIDs = append(boxIDs, msg.ID)
|
||||
}
|
||||
if len(boxIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Ints(boxIDs)
|
||||
res.SenderEvents = append(res.SenderEvents, domain.UpdateEvent{
|
||||
UserID: senderID,
|
||||
Type: domain.UpdateEventReadMessageContents,
|
||||
Pts: s.nextPtsNLocked(senderID, len(boxIDs)),
|
||||
PtsCount: len(boxIDs),
|
||||
Date: req.Date,
|
||||
MessageIDs: boxIDs,
|
||||
})
|
||||
}
|
||||
for uid := range reactionUIDs {
|
||||
s.refreshPrivateReactionDialogSnapshotsLocked(uid)
|
||||
}
|
||||
pts := s.nextPtsNLocked(req.OwnerUserID, len(res.MessageIDs))
|
||||
res.Event = domain.UpdateEvent{
|
||||
UserID: req.OwnerUserID,
|
||||
Type: domain.UpdateEventReadMessageContents,
|
||||
Pts: pts,
|
||||
PtsCount: len(res.MessageIDs),
|
||||
Date: req.Date,
|
||||
MessageIDs: append([]int(nil), res.MessageIDs...),
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) GetOutboxReadDate(_ context.Context, req domain.OutboxReadDateRequest) (int, error) {
|
||||
if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.ID <= 0 || req.ID > domain.MaxMessageBoxID {
|
||||
return 0, domain.ErrMessageIDInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
found := false
|
||||
for _, msg := range s.m[req.OwnerUserID] {
|
||||
if msg.ID == req.ID && msg.Peer == req.Peer && msg.Out {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return 0, domain.ErrMessageIDInvalid
|
||||
}
|
||||
date := s.readOutboxDates[readOutboxDateKey{ownerUserID: req.OwnerUserID, peerID: req.Peer.ID, msgID: req.ID}]
|
||||
if date == 0 {
|
||||
return 0, domain.ErrMessageNotReadYet
|
||||
}
|
||||
return date, nil
|
||||
}
|
||||
|
||||
// ListUnreadReactionMessages 返回当前 owner 在该 peer 下 reaction_unread 的消息。
|
||||
func (s *MessageStore) ListUnreadReactionMessages(_ context.Context, ownerUserID int64, peer domain.Peer, limit int) ([]domain.Message, error) {
|
||||
if ownerUserID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxChannelUnreadReactionsLimit {
|
||||
limit = domain.MaxChannelUnreadReactionsLimit
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]domain.Message, 0, limit)
|
||||
messages := s.m[ownerUserID]
|
||||
for i := len(messages) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
msg := messages[i]
|
||||
if msg.Peer != peer || !msg.ReactionUnread {
|
||||
continue
|
||||
}
|
||||
out = append(out, cloneMessage(msg))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadPeerReactions 清理当前 owner 在该 peer 下的全部未读 reaction 状态。
|
||||
func (s *MessageStore) ReadPeerReactions(_ context.Context, ownerUserID int64, peer domain.Peer) (int, error) {
|
||||
if ownerUserID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cleared := 0
|
||||
reactionUIDs := make(map[int64]struct{})
|
||||
for i := range s.m[ownerUserID] {
|
||||
msg := &s.m[ownerUserID][i]
|
||||
if msg.Peer != peer || !msg.ReactionUnread {
|
||||
continue
|
||||
}
|
||||
msg.ReactionUnread = false
|
||||
reactionUIDs[msg.UID] = struct{}{}
|
||||
cleared++
|
||||
}
|
||||
if cleared > 0 {
|
||||
for uid := range reactionUIDs {
|
||||
s.refreshPrivateReactionDialogSnapshotsLocked(uid)
|
||||
}
|
||||
}
|
||||
return cleared, nil
|
||||
}
|
||||
227
internal/store/memory/message_send.go
Normal file
227
internal/store/memory/message_send.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *MessageStore) Create(_ context.Context, msg domain.Message) (domain.Message, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
msg.ID = s.nextBoxIDLocked(msg.OwnerUserID)
|
||||
msg.UID = s.nextUID
|
||||
s.nextUID++
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
s.m[msg.OwnerUserID] = append(s.m[msg.OwnerUserID], msg)
|
||||
if s.dialogs != nil {
|
||||
s.dialogs.mu.Lock()
|
||||
list := s.dialogs.m[msg.OwnerUserID]
|
||||
list.Messages = append(list.Messages, msg)
|
||||
if msg.Peer.Type == domain.PeerTypeUser && !hasUser(list.Users, msg.Peer.ID) {
|
||||
if u, ok := domain.SystemUserByID(msg.Peer.ID); ok {
|
||||
list.Users = append(list.Users, u)
|
||||
}
|
||||
}
|
||||
s.dialogs.m[msg.OwnerUserID] = list
|
||||
s.dialogs.mu.Unlock()
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, msg := range s.m[req.SenderUserID] {
|
||||
if msg.RandomID != 0 && msg.RandomID == req.RandomID {
|
||||
recipient := domain.Message{}
|
||||
if req.SenderUserID != req.RecipientUserID {
|
||||
for _, peerMsg := range s.m[req.RecipientUserID] {
|
||||
if peerMsg.UID == msg.UID {
|
||||
recipient = peerMsg
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
recipient = msg
|
||||
}
|
||||
return domain.SendPrivateTextResult{
|
||||
SenderMessage: cloneMessage(msg),
|
||||
RecipientMessage: cloneMessage(recipient),
|
||||
SenderEvent: newMessageEvent(msg),
|
||||
RecipientEvent: newMessageEvent(recipient),
|
||||
Duplicate: true,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
senderReply, recipientReply, err := s.resolveMemoryReplyLocked(req)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
uid := s.nextUID
|
||||
s.nextUID++
|
||||
sender := domain.Message{
|
||||
ID: s.nextBoxIDLocked(req.SenderUserID),
|
||||
UID: uid,
|
||||
RandomID: req.RandomID,
|
||||
OwnerUserID: req.SenderUserID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID},
|
||||
Date: req.Date,
|
||||
Out: true,
|
||||
Silent: req.Silent,
|
||||
NoForwards: req.NoForwards,
|
||||
Body: req.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Media: req.Media,
|
||||
ViaBotID: req.ViaBotID,
|
||||
GroupedID: req.GroupedID,
|
||||
Effect: req.Effect,
|
||||
ReplyMarkup: cloneReplyMarkup(req.ReplyMarkup),
|
||||
RichMessage: cloneRichMessage(req.RichMessage),
|
||||
ReplyTo: cloneMessageReply(senderReply),
|
||||
Forward: cloneMessageForward(req.Forward),
|
||||
Pts: s.nextPtsLocked(req.SenderUserID),
|
||||
// voice/round 在发送者副本上同样保持"未听",由对端内容已读清除。
|
||||
MediaUnread: req.Media.HasUnreadPayload() && req.SenderUserID != req.RecipientUserID,
|
||||
}
|
||||
if req.SenderUserID == req.RecipientUserID {
|
||||
sender.SavedPeer = domain.SavedPeerForSelfChat(req.SenderUserID, req.Forward)
|
||||
}
|
||||
recipient := domain.Message{}
|
||||
if req.SenderUserID == req.RecipientUserID {
|
||||
recipient = sender
|
||||
}
|
||||
if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked {
|
||||
recipient = sender
|
||||
recipient.ID = s.nextBoxIDLocked(req.RecipientUserID)
|
||||
recipient.OwnerUserID = req.RecipientUserID
|
||||
recipient.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
|
||||
recipient.Out = false
|
||||
recipient.ReplyTo = cloneMessageReply(recipientReply)
|
||||
// recipient = sender 是值拷贝,共享 sender.ReplyMarkup 指针/Data 切片——深拷
|
||||
// 让双盒各持独立快照(与 postgres 每盒独立 decode 对齐,I3/I2)。
|
||||
recipient.ReplyMarkup = cloneReplyMarkup(sender.ReplyMarkup)
|
||||
recipient.RichMessage = cloneRichMessage(sender.RichMessage)
|
||||
recipient.Pts = s.nextPtsLocked(req.RecipientUserID)
|
||||
recipient.MediaUnread = req.Media.HasUnreadPayload()
|
||||
}
|
||||
s.m[req.SenderUserID] = append(s.m[req.SenderUserID], sender)
|
||||
if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked {
|
||||
s.m[req.RecipientUserID] = append(s.m[req.RecipientUserID], recipient)
|
||||
}
|
||||
if s.dialogs != nil {
|
||||
if recipient.ID != 0 {
|
||||
s.upsertMemoryDialogsLocked(sender, recipient)
|
||||
} else {
|
||||
s.upsertMemoryDialogsLocked(sender, sender)
|
||||
}
|
||||
}
|
||||
return domain.SendPrivateTextResult{
|
||||
SenderMessage: cloneMessage(sender),
|
||||
RecipientMessage: cloneMessage(recipient),
|
||||
SenderEvent: newMessageEvent(sender),
|
||||
RecipientEvent: newMessageEvent(recipient),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) resolveMemoryReplyLocked(req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) {
|
||||
if req.ReplyTo == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if err := domain.ValidateMessageReplyBounds(req.ReplyTo); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if req.ReplyTo.StoryID > 0 {
|
||||
// story 回复(评论):无源消息可查;story 作者就是会话对端,双盒同持。
|
||||
reply := &domain.MessageReply{
|
||||
StoryID: req.ReplyTo.StoryID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID},
|
||||
}
|
||||
return cloneMessageReply(reply), cloneMessageReply(reply), nil
|
||||
}
|
||||
peer := req.ReplyTo.Peer
|
||||
if peer.ID == 0 {
|
||||
peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID}
|
||||
}
|
||||
if peer.Type != domain.PeerTypeUser || peer.ID != req.RecipientUserID {
|
||||
return nil, nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
var target domain.Message
|
||||
for _, msg := range s.m[req.SenderUserID] {
|
||||
if msg.Peer == peer && msg.ID == req.ReplyTo.MessageID {
|
||||
target = msg
|
||||
break
|
||||
}
|
||||
}
|
||||
if target.ID == 0 {
|
||||
return nil, nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
senderReply := cloneMessageReply(req.ReplyTo)
|
||||
senderReply.MessageID = target.ID
|
||||
senderReply.Peer = peer
|
||||
if req.SenderUserID == req.RecipientUserID {
|
||||
return senderReply, cloneMessageReply(senderReply), nil
|
||||
}
|
||||
for _, msg := range s.m[req.RecipientUserID] {
|
||||
if msg.UID == target.UID {
|
||||
recipientReply := cloneMessageReply(senderReply)
|
||||
recipientReply.MessageID = msg.ID
|
||||
recipientReply.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
|
||||
return senderReply, recipientReply, nil
|
||||
}
|
||||
}
|
||||
return senderReply, nil, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) upsertMemoryDialogsLocked(sender, recipient domain.Message) {
|
||||
s.dialogs.mu.Lock()
|
||||
defer s.dialogs.mu.Unlock()
|
||||
list := s.dialogs.m[sender.OwnerUserID]
|
||||
list = upsertMemoryDialog(list, domain.Dialog{Peer: sender.Peer, TopMessage: sender.ID, TopMessageDate: sender.Date})
|
||||
// 发送方向清手动未读标记(对齐 postgres UpsertOutboxDialog 与
|
||||
// channel 发送路径:向会话发出消息即视为已知晓内容)。
|
||||
for i := range list.Dialogs {
|
||||
if list.Dialogs[i].Peer == sender.Peer {
|
||||
list.Dialogs[i].UnreadMark = false
|
||||
break
|
||||
}
|
||||
}
|
||||
list.Messages = append(list.Messages, sender)
|
||||
s.dialogs.m[sender.OwnerUserID] = list
|
||||
if recipient.OwnerUserID != sender.OwnerUserID {
|
||||
peerList := s.dialogs.m[recipient.OwnerUserID]
|
||||
peerList = upsertMemoryDialog(peerList, domain.Dialog{
|
||||
Peer: recipient.Peer,
|
||||
TopMessage: recipient.ID,
|
||||
TopMessageDate: recipient.Date,
|
||||
UnreadCount: s.privateUnreadCountLocked(recipient.OwnerUserID, recipient.Peer),
|
||||
})
|
||||
peerList.Messages = append(peerList.Messages, recipient)
|
||||
s.dialogs.m[recipient.OwnerUserID] = peerList
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MessageStore) privateUnreadCountLocked(ownerUserID int64, peer domain.Peer) int {
|
||||
readMax := 0
|
||||
if s.dialogs != nil {
|
||||
if list, ok := s.dialogs.m[ownerUserID]; ok {
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer == peer {
|
||||
readMax = dialog.ReadInboxMaxID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
unread := 0
|
||||
for _, msg := range s.m[ownerUserID] {
|
||||
if msg.Peer == peer && !msg.Out && msg.ID > readMax {
|
||||
unread++
|
||||
}
|
||||
}
|
||||
return unread
|
||||
}
|
||||
50
internal/store/memory/message_store.go
Normal file
50
internal/store/memory/message_store.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// MessageStore 是 store.MessageStore 的内存实现。
|
||||
type MessageStore struct {
|
||||
mu sync.RWMutex
|
||||
m map[int64][]domain.Message
|
||||
nextUID int64
|
||||
nextBox map[int64]int
|
||||
nextPts map[int64]int
|
||||
readOutboxDates map[readOutboxDateKey]int
|
||||
privateReactions map[int64]map[int64][]domain.ChannelMessagePeerReaction
|
||||
dialogs *DialogStore
|
||||
// polls 是共享 poll 权威(投票校验与读路径 enrichment);nil 时 poll 链路按未接入处理。
|
||||
polls *PollStore
|
||||
// savedPins 是收藏夹子会话置顶顺序(下标即 pinned_order,越小越前)。
|
||||
savedPins map[int64][]domain.Peer
|
||||
}
|
||||
|
||||
// AttachPollStore 注入共享 poll 权威(与 ChannelStore 共用同一实例)。
|
||||
func (s *MessageStore) AttachPollStore(polls *PollStore) {
|
||||
s.polls = polls
|
||||
}
|
||||
|
||||
type readOutboxDateKey struct {
|
||||
ownerUserID int64
|
||||
peerID int64
|
||||
msgID int
|
||||
}
|
||||
|
||||
// NewMessageStore 创建内存 MessageStore。
|
||||
func NewMessageStore(dialogs ...*DialogStore) *MessageStore {
|
||||
s := &MessageStore{
|
||||
m: make(map[int64][]domain.Message),
|
||||
nextUID: 1,
|
||||
nextBox: make(map[int64]int),
|
||||
nextPts: make(map[int64]int),
|
||||
readOutboxDates: make(map[readOutboxDateKey]int),
|
||||
privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction),
|
||||
savedPins: make(map[int64][]domain.Peer),
|
||||
}
|
||||
if len(dialogs) > 0 {
|
||||
s.dialogs = dialogs[0]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
|
@ -57,6 +57,20 @@ func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) {
|
|||
t.Fatalf("duplicate = %+v, want original message boxes", dup)
|
||||
}
|
||||
|
||||
afterDup, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: req.SenderUserID,
|
||||
RecipientUserID: req.RecipientUserID,
|
||||
RandomID: 101,
|
||||
Message: "after duplicate",
|
||||
Date: 1700000111,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText after duplicate: %v", err)
|
||||
}
|
||||
if afterDup.SenderMessage.ID != 3 || afterDup.SenderMessage.Pts != 3 || afterDup.RecipientMessage.ID != 3 || afterDup.RecipientMessage.Pts != 3 {
|
||||
t.Fatalf("send after duplicate = %+v/%+v, want next contiguous box_id and pts", afterDup.SenderMessage, afterDup.RecipientMessage)
|
||||
}
|
||||
|
||||
senderHistory, err := messages.ListByUser(ctx, req.SenderUserID, domain.MessageFilter{HasPeer: true, Peer: got.SenderMessage.Peer, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("sender history: %v", err)
|
||||
|
|
@ -65,11 +79,83 @@ func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("recipient history: %v", err)
|
||||
}
|
||||
if len(senderHistory.Messages) != 2 || len(recipientHistory.Messages) != 2 {
|
||||
if len(senderHistory.Messages) != 3 || len(recipientHistory.Messages) != 3 {
|
||||
t.Fatalf("history sizes = sender %d recipient %d, want both owner partitions populated", len(senderHistory.Messages), len(recipientHistory.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
req := domain.SendPrivateTextRequest{
|
||||
SenderUserID: 1000000001,
|
||||
RecipientUserID: 1000000002,
|
||||
RandomID: 199,
|
||||
Date: 1700000120,
|
||||
Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionWebViewDataSent,
|
||||
WebViewData: &domain.MessageWebViewDataAction{
|
||||
ButtonText: "Open",
|
||||
Data: `{"ok":true}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := messages.SendPrivateText(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
assertWebViewData := func(name string, msg domain.Message) {
|
||||
if msg.Media == nil || msg.Media.ServiceAction == nil ||
|
||||
msg.Media.ServiceAction.Kind != domain.MessageServiceActionWebViewDataSent ||
|
||||
msg.Media.ServiceAction.WebViewData == nil {
|
||||
t.Fatalf("%s media = %+v, want webview data service action", name, msg.Media)
|
||||
}
|
||||
if data := msg.Media.ServiceAction.WebViewData; data.ButtonText != "Open" || data.Data != `{"ok":true}` {
|
||||
t.Fatalf("%s webview data = %+v, want Open/data", name, data)
|
||||
}
|
||||
}
|
||||
assertWebViewData("sender", got.SenderMessage)
|
||||
assertWebViewData("recipient", got.RecipientMessage)
|
||||
|
||||
dup, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: req.SenderUserID,
|
||||
RecipientUserID: req.RecipientUserID,
|
||||
RandomID: req.RandomID,
|
||||
Date: 1700000121,
|
||||
Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionWebViewDataSent,
|
||||
WebViewData: &domain.MessageWebViewDataAction{
|
||||
ButtonText: "Changed",
|
||||
Data: `{"ok":false}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText duplicate: %v", err)
|
||||
}
|
||||
if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID {
|
||||
t.Fatalf("duplicate = %+v, want original boxes", dup)
|
||||
}
|
||||
assertWebViewData("duplicate sender", dup.SenderMessage)
|
||||
|
||||
recipientHistory, err := messages.ListByUser(ctx, req.RecipientUserID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil || len(recipientHistory.Messages) != 1 {
|
||||
t.Fatalf("recipient history = %+v err=%v, want one message", recipientHistory, err)
|
||||
}
|
||||
assertWebViewData("recipient history", recipientHistory.Messages[0])
|
||||
}
|
||||
|
||||
func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
|
|
@ -117,7 +203,7 @@ func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing
|
|||
if got := aliceReactions.Messages[0].Reactions.Results; len(got) != 1 || got[0].Reaction != reaction || got[0].Count != 1 || got[0].ChosenOrder != 0 {
|
||||
t.Fatalf("alice reaction counts = %+v, want one peer reaction without chosen order", got)
|
||||
}
|
||||
if got := aliceReactions.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || !got[0].Big || got[0].My {
|
||||
if got := aliceReactions.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || got[0].SenderUserID != aliceID || !got[0].Unread || !got[0].Big || got[0].My {
|
||||
t.Fatalf("alice recent reactions = %+v, want bob non-my big reaction", got)
|
||||
}
|
||||
aliceBox, err := messages.GetByIDs(ctx, aliceID, []int{sent.SenderMessage.ID})
|
||||
|
|
@ -145,6 +231,9 @@ func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing
|
|||
if len(aliceBox.Messages) != 1 || aliceBox.Messages[0].ReactionUnread {
|
||||
t.Fatalf("alice box after read reaction = %+v, want reaction_unread cleared", aliceBox.Messages)
|
||||
}
|
||||
if got := aliceBox.Messages[0].Reactions.Recent; len(got) != 1 || got[0].Unread || got[0].SenderUserID != aliceID {
|
||||
t.Fatalf("alice recent reactions after read = %+v, want unread flag cleared", got)
|
||||
}
|
||||
|
||||
bobReactions, err := messages.GetMessageReactions(ctx, domain.PrivateMessageReactionsRequest{
|
||||
OwnerUserID: bobID,
|
||||
|
|
@ -157,11 +246,84 @@ func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing
|
|||
if got := bobReactions.Messages[0].Reactions.Results; len(got) != 1 || got[0].ChosenOrder != 1 {
|
||||
t.Fatalf("bob reaction counts = %+v, want own chosen order", got)
|
||||
}
|
||||
if got := bobReactions.Messages[0].Reactions.Recent; len(got) != 1 || !got[0].My {
|
||||
if got := bobReactions.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || got[0].SenderUserID != aliceID || got[0].Unread || !got[0].My {
|
||||
t.Fatalf("bob recent reactions = %+v, want my reaction", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStorePrivateReactionEnrichesDialogTopMessages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := NewDialogStore()
|
||||
messages := NewMessageStore(dialogs)
|
||||
aliceID := int64(1000000001)
|
||||
bobID := int64(1000000002)
|
||||
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: aliceID,
|
||||
RecipientUserID: bobID,
|
||||
RandomID: 111,
|
||||
Message: "latest from alice",
|
||||
Date: 1700000300,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\u2764"}
|
||||
if _, err := messages.SetMessageReactions(ctx, domain.SetPrivateMessageReactionsRequest{
|
||||
UserID: bobID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID},
|
||||
MessageID: sent.RecipientMessage.ID,
|
||||
Reactions: []domain.MessageReaction{
|
||||
reaction,
|
||||
},
|
||||
Date: 1700000310,
|
||||
}); err != nil {
|
||||
t.Fatalf("SetMessageReactions: %v", err)
|
||||
}
|
||||
|
||||
aliceDialogs, err := dialogs.ListByUser(ctx, aliceID, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("alice ListByUser: %v", err)
|
||||
}
|
||||
if len(aliceDialogs.Dialogs) != 1 || aliceDialogs.Dialogs[0].TopMessage != sent.SenderMessage.ID || aliceDialogs.Dialogs[0].UnreadReactions != 1 {
|
||||
t.Fatalf("alice dialog = %+v, want top message with one unread reaction", aliceDialogs.Dialogs)
|
||||
}
|
||||
if len(aliceDialogs.Messages) != 1 || aliceDialogs.Messages[0].ID != sent.SenderMessage.ID || aliceDialogs.Messages[0].Reactions == nil {
|
||||
t.Fatalf("alice dialog messages = %+v, want enriched top message", aliceDialogs.Messages)
|
||||
}
|
||||
if got := aliceDialogs.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || got[0].SenderUserID != aliceID || !got[0].Unread || got[0].My {
|
||||
t.Fatalf("alice dialog recent reactions = %+v, want bob unread non-my reaction", got)
|
||||
}
|
||||
|
||||
bobDialogs, err := dialogs.ListByPeers(ctx, bobID, []domain.Peer{{Type: domain.PeerTypeUser, ID: aliceID}})
|
||||
if err != nil {
|
||||
t.Fatalf("bob ListByPeers: %v", err)
|
||||
}
|
||||
if len(bobDialogs.Messages) != 1 || bobDialogs.Messages[0].Reactions == nil {
|
||||
t.Fatalf("bob peer dialog messages = %+v, want enriched top message", bobDialogs.Messages)
|
||||
}
|
||||
if got := bobDialogs.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || got[0].SenderUserID != aliceID || got[0].Unread || !got[0].My {
|
||||
t.Fatalf("bob peer dialog recent reactions = %+v, want my read reaction", got)
|
||||
}
|
||||
|
||||
if _, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
|
||||
OwnerUserID: aliceID,
|
||||
IDs: []int{sent.SenderMessage.ID},
|
||||
Date: 1700000320,
|
||||
}); err != nil {
|
||||
t.Fatalf("ReadMessageContents: %v", err)
|
||||
}
|
||||
aliceDialogs, err = dialogs.ListByUser(ctx, aliceID, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("alice ListByUser after read: %v", err)
|
||||
}
|
||||
if len(aliceDialogs.Dialogs) != 1 || aliceDialogs.Dialogs[0].UnreadReactions != 0 {
|
||||
t.Fatalf("alice dialog after read = %+v, want no unread reactions", aliceDialogs.Dialogs)
|
||||
}
|
||||
if got := aliceDialogs.Messages[0].Reactions.Recent; len(got) != 1 || got[0].Unread {
|
||||
t.Fatalf("alice dialog recent reactions after read = %+v, want unread cleared", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStoreSendPrivateTextReplyAndForwardMetadata(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
|
|
@ -369,6 +531,270 @@ func TestMessageStoreReadHistoryEmitsInboxAndOutboxReceipts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMessageStoreReadHistoryStaleUnreadRepairDoesNotAdvancePts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := NewDialogStore()
|
||||
messages := NewMessageStore(dialogs)
|
||||
senderID := int64(1000000101)
|
||||
recipientID := int64(1000000102)
|
||||
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: senderID,
|
||||
RecipientUserID: recipientID,
|
||||
RandomID: 201,
|
||||
Message: "hello",
|
||||
Date: 1700000100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: senderID}
|
||||
if _, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{
|
||||
OwnerUserID: recipientID,
|
||||
Peer: peer,
|
||||
MaxID: sent.RecipientMessage.ID,
|
||||
Date: 1700000200,
|
||||
}); err != nil {
|
||||
t.Fatalf("first ReadHistory: %v", err)
|
||||
}
|
||||
|
||||
dialogs.mu.Lock()
|
||||
list := dialogs.m[recipientID]
|
||||
for i := range list.Dialogs {
|
||||
if list.Dialogs[i].Peer != peer {
|
||||
continue
|
||||
}
|
||||
list.Dialogs[i].UnreadCount = 1
|
||||
list.Dialogs[i].UnreadMentions = 1
|
||||
list.Dialogs[i].UnreadReactions = 1
|
||||
list.Dialogs[i].UnreadMark = true
|
||||
}
|
||||
dialogs.m[recipientID] = list
|
||||
dialogs.mu.Unlock()
|
||||
|
||||
read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{
|
||||
OwnerUserID: recipientID,
|
||||
Peer: peer,
|
||||
MaxID: sent.RecipientMessage.ID,
|
||||
Date: 1700000300,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second ReadHistory: %v", err)
|
||||
}
|
||||
if read.Changed || read.InboxEvent.Pts != 0 || read.OutboxChanged {
|
||||
t.Fatalf("stale unread repair = %+v, want no read pts/outbox event", read)
|
||||
}
|
||||
|
||||
dialogs.mu.RLock()
|
||||
repaired := dialogs.m[recipientID].Dialogs[0]
|
||||
dialogs.mu.RUnlock()
|
||||
// readHistory 重算 UnreadCount、清 mentions/mark,但不清 reaction 角标(与 PG
|
||||
// 对齐;reaction 未读由 readReactions/readMessageContents 单独清),故 UnreadReactions
|
||||
// 保留为 1。
|
||||
if repaired.UnreadCount != 0 || repaired.UnreadMentions != 0 || repaired.UnreadReactions != 1 || repaired.UnreadMark {
|
||||
t.Fatalf("dialog after repair = %+v, want unread/mentions/mark cleared and reactions preserved", repaired)
|
||||
}
|
||||
next, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: senderID,
|
||||
RecipientUserID: recipientID,
|
||||
RandomID: 202,
|
||||
Message: "next",
|
||||
Date: 1700000400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("next SendPrivateText: %v", err)
|
||||
}
|
||||
if next.RecipientMessage.Pts != 3 {
|
||||
t.Fatalf("next recipient pts = %d, want 3 after no-op read repair", next.RecipientMessage.Pts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStoreSendPrivateTextRecomputesInboxUnreadFromReadMax(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := NewDialogStore()
|
||||
messages := NewMessageStore(dialogs)
|
||||
senderID := int64(1000000201)
|
||||
recipientID := int64(1000000202)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: senderID}
|
||||
|
||||
first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: senderID,
|
||||
RecipientUserID: recipientID,
|
||||
RandomID: 301,
|
||||
Message: "already read",
|
||||
Date: 1700000500,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText first: %v", err)
|
||||
}
|
||||
if _, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{
|
||||
OwnerUserID: recipientID,
|
||||
Peer: peer,
|
||||
MaxID: first.RecipientMessage.ID,
|
||||
Date: 1700000510,
|
||||
}); err != nil {
|
||||
t.Fatalf("ReadHistory: %v", err)
|
||||
}
|
||||
|
||||
dialogs.mu.Lock()
|
||||
list := dialogs.m[recipientID]
|
||||
for i := range list.Dialogs {
|
||||
if list.Dialogs[i].Peer == peer {
|
||||
list.Dialogs[i].UnreadCount = 2
|
||||
}
|
||||
}
|
||||
dialogs.m[recipientID] = list
|
||||
dialogs.mu.Unlock()
|
||||
|
||||
bodies := []string{"one", "two", "three"}
|
||||
var last domain.SendPrivateTextResult
|
||||
for i, body := range bodies {
|
||||
last, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: senderID,
|
||||
RecipientUserID: recipientID,
|
||||
RandomID: 310 + int64(i),
|
||||
Message: body,
|
||||
Date: 1700000520 + i,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText %q: %v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
dialogs.mu.RLock()
|
||||
var got domain.Dialog
|
||||
for _, dialog := range dialogs.m[recipientID].Dialogs {
|
||||
if dialog.Peer == peer {
|
||||
got = dialog
|
||||
break
|
||||
}
|
||||
}
|
||||
dialogs.mu.RUnlock()
|
||||
if got.UnreadCount != len(bodies) || got.TopMessage != last.RecipientMessage.ID || got.ReadInboxMaxID != first.RecipientMessage.ID {
|
||||
t.Fatalf("dialog = %+v, want unread=3 top=%d read=%d", got, last.RecipientMessage.ID, first.RecipientMessage.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStoreDeleteMessagesRecomputesRecipientUnreadAndTop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := NewDialogStore()
|
||||
messages := NewMessageStore(dialogs)
|
||||
senderID := int64(1000000301)
|
||||
recipientID := int64(1000000302)
|
||||
recipientPeer := domain.Peer{Type: domain.PeerTypeUser, ID: senderID}
|
||||
|
||||
bodies := []string{"one", "two", "three"}
|
||||
sent := make([]domain.SendPrivateTextResult, 0, len(bodies))
|
||||
for i, body := range bodies {
|
||||
got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: senderID,
|
||||
RecipientUserID: recipientID,
|
||||
RandomID: 330 + int64(i),
|
||||
Message: body,
|
||||
Date: 1700000600 + i,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText %q: %v", body, err)
|
||||
}
|
||||
sent = append(sent, got)
|
||||
}
|
||||
|
||||
deleted, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{
|
||||
OwnerUserID: senderID,
|
||||
IDs: []int{sent[2].SenderMessage.ID},
|
||||
Revoke: true,
|
||||
Date: 1700000610,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteMessages revoke latest: %v", err)
|
||||
}
|
||||
if len(deleted.Deleted) != 2 || !deleted.Changed() {
|
||||
t.Fatalf("deleted = %+v, want both owner boxes revoked", deleted)
|
||||
}
|
||||
|
||||
dialogList, err := dialogs.ListByUser(ctx, recipientID, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("recipient dialogs: %v", err)
|
||||
}
|
||||
var got domain.Dialog
|
||||
for _, dialog := range dialogList.Dialogs {
|
||||
if dialog.Peer == recipientPeer {
|
||||
got = dialog
|
||||
break
|
||||
}
|
||||
}
|
||||
if got.UnreadCount != 2 || got.TopMessage != sent[1].RecipientMessage.ID {
|
||||
t.Fatalf("recipient dialog after revoke = %+v, want unread=2 top=%d", got, sent[1].RecipientMessage.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStoreDeleteMiddleUnreadKeepsRecipientTop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := NewDialogStore()
|
||||
messages := NewMessageStore(dialogs)
|
||||
senderID := int64(1000000401)
|
||||
recipientID := int64(1000000402)
|
||||
recipientPeer := domain.Peer{Type: domain.PeerTypeUser, ID: senderID}
|
||||
|
||||
first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: senderID,
|
||||
RecipientUserID: recipientID,
|
||||
RandomID: 429,
|
||||
Message: "already read",
|
||||
Date: 1700000690,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText first: %v", err)
|
||||
}
|
||||
if _, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{
|
||||
OwnerUserID: recipientID,
|
||||
Peer: recipientPeer,
|
||||
MaxID: first.RecipientMessage.ID,
|
||||
Date: 1700000695,
|
||||
}); err != nil {
|
||||
t.Fatalf("ReadHistory first: %v", err)
|
||||
}
|
||||
|
||||
bodies := []string{"one", "two", "three"}
|
||||
sent := make([]domain.SendPrivateTextResult, 0, len(bodies))
|
||||
for i, body := range bodies {
|
||||
got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: senderID,
|
||||
RecipientUserID: recipientID,
|
||||
RandomID: 430 + int64(i),
|
||||
Message: body,
|
||||
Date: 1700000700 + i,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText %q: %v", body, err)
|
||||
}
|
||||
sent = append(sent, got)
|
||||
}
|
||||
|
||||
if _, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{
|
||||
OwnerUserID: senderID,
|
||||
IDs: []int{sent[1].SenderMessage.ID},
|
||||
Revoke: true,
|
||||
Date: 1700000710,
|
||||
}); err != nil {
|
||||
t.Fatalf("DeleteMessages revoke middle: %v", err)
|
||||
}
|
||||
|
||||
dialogList, err := dialogs.ListByUser(ctx, recipientID, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("recipient dialogs: %v", err)
|
||||
}
|
||||
var got domain.Dialog
|
||||
for _, dialog := range dialogList.Dialogs {
|
||||
if dialog.Peer == recipientPeer {
|
||||
got = dialog
|
||||
break
|
||||
}
|
||||
}
|
||||
if got.UnreadCount != 2 || got.TopMessage != sent[2].RecipientMessage.ID || got.ReadInboxMaxID != first.RecipientMessage.ID {
|
||||
t.Fatalf("recipient dialog after middle revoke = %+v, want unread=2 top=%d read=%d", got, sent[2].RecipientMessage.ID, first.RecipientMessage.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStoreReadMessageContentsClearsUnreadContentOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
|
|
@ -419,6 +845,72 @@ func TestMessageStoreReadMessageContentsClearsUnreadContentOnce(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMessageStoreReadMessageContentsNotifiesVoiceSender(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: 1001,
|
||||
RecipientUserID: 1002,
|
||||
RandomID: 99,
|
||||
Message: "",
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Voice: true},
|
||||
Date: 1700000300,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
if !sent.SenderMessage.MediaUnread {
|
||||
t.Fatalf("sender voice MediaUnread = false, want true until the peer listens")
|
||||
}
|
||||
photo, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: 1001,
|
||||
RecipientUserID: 1002,
|
||||
RandomID: 100,
|
||||
Message: "",
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 7}},
|
||||
Date: 1700000301,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText photo: %v", err)
|
||||
}
|
||||
if photo.SenderMessage.MediaUnread || photo.RecipientMessage.MediaUnread {
|
||||
t.Fatalf("photo media_unread sender=%v recipient=%v, want false: only voice/round carry unread payload",
|
||||
photo.SenderMessage.MediaUnread, photo.RecipientMessage.MediaUnread)
|
||||
}
|
||||
read, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
|
||||
OwnerUserID: 1002,
|
||||
IDs: []int{sent.RecipientMessage.ID},
|
||||
Date: 1700000400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReadMessageContents: %v", err)
|
||||
}
|
||||
if len(read.SenderEvents) != 1 {
|
||||
t.Fatalf("SenderEvents = %+v, want one sender receipt", read.SenderEvents)
|
||||
}
|
||||
receipt := read.SenderEvents[0]
|
||||
if receipt.UserID != 1001 || receipt.Type != domain.UpdateEventReadMessageContents || receipt.Pts == 0 {
|
||||
t.Fatalf("receipt = %+v, want sender-side read_message_contents", receipt)
|
||||
}
|
||||
if !reflect.DeepEqual(receipt.MessageIDs, []int{sent.SenderMessage.ID}) {
|
||||
t.Fatalf("receipt ids = %v, want sender box id %d", receipt.MessageIDs, sent.SenderMessage.ID)
|
||||
}
|
||||
if receipt.Date != 1700000400 {
|
||||
t.Fatalf("receipt date = %d, want read time 1700000400", receipt.Date)
|
||||
}
|
||||
repeat, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
|
||||
OwnerUserID: 1002,
|
||||
IDs: []int{sent.RecipientMessage.ID},
|
||||
Date: 1700000500,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReadMessageContents repeat: %v", err)
|
||||
}
|
||||
if len(repeat.SenderEvents) != 0 {
|
||||
t.Fatalf("repeat SenderEvents = %+v, want none", repeat.SenderEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func messageIDs(messages []domain.Message) []int {
|
||||
out := make([]int, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
|
|
@ -478,6 +970,59 @@ func TestMessageStoreEditMessageUpdatesBothBoxes(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMessageStoreEditViaBotMessageUpdatesBothBoxes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := NewDialogStore()
|
||||
messages := NewMessageStore(dialogs)
|
||||
senderID := int64(1000000001)
|
||||
recipientID := int64(1000000002)
|
||||
viaBotID := int64(1000000900)
|
||||
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: senderID,
|
||||
RecipientUserID: recipientID,
|
||||
RandomID: 103,
|
||||
Message: "via before",
|
||||
ViaBotID: viaBotID,
|
||||
Date: 1700000100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
|
||||
if _, err := messages.EditMessage(ctx, domain.EditMessageRequest{
|
||||
OwnerUserID: recipientID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID},
|
||||
ID: sent.RecipientMessage.ID,
|
||||
Message: "bad bot",
|
||||
EditDate: 1700000190,
|
||||
ViaBotEditBotID: viaBotID + 1,
|
||||
}); err != domain.ErrMessageAuthorRequired {
|
||||
t.Fatalf("EditMessage wrong via bot err = %v, want ErrMessageAuthorRequired", err)
|
||||
}
|
||||
|
||||
edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{
|
||||
OwnerUserID: recipientID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID},
|
||||
ID: sent.RecipientMessage.ID,
|
||||
Message: "via after",
|
||||
EditDate: 1700000200,
|
||||
ViaBotEditBotID: viaBotID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EditMessage via bot: %v", err)
|
||||
}
|
||||
if len(edited.Edited) != 2 {
|
||||
t.Fatalf("edited boxes = %d, want 2", len(edited.Edited))
|
||||
}
|
||||
senderHistory, err := messages.ListByUser(ctx, senderID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("sender history: %v", err)
|
||||
}
|
||||
if len(senderHistory.Messages) != 1 || senderHistory.Messages[0].Body != "via after" || senderHistory.Messages[0].ViaBotID != viaBotID {
|
||||
t.Fatalf("sender history = %+v, want via after with via bot", senderHistory.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStoreDeleteHistoryDeletesOrPreservesDialogAndRebuilds(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := NewDialogStore()
|
||||
|
|
@ -573,3 +1118,185 @@ func TestMessageStoreDeleteHistoryDeletesOrPreservesDialogAndRebuilds(t *testing
|
|||
t.Fatalf("preserved dialogs = %+v messages=%+v, want empty dialog kept after just_clear", preservedDialogs.Dialogs, preservedDialogs.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStoreRevokeHistorySweepsPeerSideAfterLocalClear(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: 1001,
|
||||
RecipientUserID: 1002,
|
||||
RandomID: int64(700 + i),
|
||||
Message: "history",
|
||||
Date: 1700000600 + i,
|
||||
}); err != nil {
|
||||
t.Fatalf("SendPrivateText %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// 先单向清空自己侧。
|
||||
if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
|
||||
OwnerUserID: 1001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
|
||||
Date: 1700000700,
|
||||
}); err != nil {
|
||||
t.Fatalf("local clear: %v", err)
|
||||
}
|
||||
// 再双向清史:反查模型对"我方已无 box"的消息失效,必须直扫对端。
|
||||
res, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
|
||||
OwnerUserID: 1001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
|
||||
Revoke: true,
|
||||
Date: 1700000800,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("revoke clear: %v", err)
|
||||
}
|
||||
var peerEvent bool
|
||||
for _, d := range res.Deleted {
|
||||
if d.UserID == 1002 && len(d.MessageIDs) == 3 {
|
||||
peerEvent = true
|
||||
}
|
||||
}
|
||||
if !peerEvent {
|
||||
t.Fatalf("revoke deleted = %+v, want peer-side sweep of all 3 messages", res.Deleted)
|
||||
}
|
||||
peerHistory, err := messages.ListByUser(ctx, 1002, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("peer history: %v", err)
|
||||
}
|
||||
if len(peerHistory.Messages) != 0 {
|
||||
t.Fatalf("peer history after revoke = %+v, want empty", peerHistory.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderPinnedForceScopedToFolder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := NewDialogStore()
|
||||
mainPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2001}
|
||||
archivedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002}
|
||||
if err := dialogs.Upsert(ctx, 1001, domain.Dialog{Peer: mainPeer, TopMessage: 1, TopMessageDate: 10}); err != nil {
|
||||
t.Fatalf("upsert main: %v", err)
|
||||
}
|
||||
if err := dialogs.Upsert(ctx, 1001, domain.Dialog{Peer: archivedPeer, FolderID: domain.DialogArchiveFolderID, TopMessage: 2, TopMessageDate: 20}); err != nil {
|
||||
t.Fatalf("upsert archived: %v", err)
|
||||
}
|
||||
if _, _, err := dialogs.SetPinned(ctx, 1001, mainPeer, true); err != nil {
|
||||
t.Fatalf("pin main: %v", err)
|
||||
}
|
||||
if _, folderID, err := dialogs.SetPinned(ctx, 1001, archivedPeer, true); err != nil || folderID != domain.DialogArchiveFolderID {
|
||||
t.Fatalf("pin archived folder = %d err %v, want archive", folderID, err)
|
||||
}
|
||||
// 归档列表内 force 重排:绝不允许清掉主列表的置顶。
|
||||
if changed, err := dialogs.ReorderPinned(ctx, 1001, domain.DialogArchiveFolderID, []domain.Peer{archivedPeer}, true); err != nil || changed {
|
||||
t.Fatalf("reorder archive = changed %v err %v, want no-op", changed, err)
|
||||
}
|
||||
list, err := dialogs.ListByUser(ctx, 1001, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list main: %v", err)
|
||||
}
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer == mainPeer && !dialog.Pinned {
|
||||
t.Fatalf("main pin cleared by archive force reorder: %+v", dialog)
|
||||
}
|
||||
}
|
||||
// 主列表 force 重排同样不得波及归档置顶。
|
||||
if changed, err := dialogs.ReorderPinned(ctx, 1001, domain.DialogMainFolderID, []domain.Peer{mainPeer}, true); err != nil || changed {
|
||||
t.Fatalf("reorder main = changed %v err %v, want no-op", changed, err)
|
||||
}
|
||||
archived, err := dialogs.ListByUser(ctx, 1001, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list archive: %v", err)
|
||||
}
|
||||
for _, dialog := range archived.Dialogs {
|
||||
if dialog.Peer == archivedPeer && !dialog.Pinned {
|
||||
t.Fatalf("archive pin cleared by main force reorder: %+v", dialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendPrivateTextClearsSenderUnreadMark(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := NewDialogStore()
|
||||
messages := NewMessageStore(dialogs)
|
||||
first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: 1000000001,
|
||||
RecipientUserID: 1000000002,
|
||||
RandomID: 1,
|
||||
Message: "hi",
|
||||
Date: 1700000100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed send: %v", err)
|
||||
}
|
||||
peer := first.SenderMessage.Peer
|
||||
if _, err := dialogs.SetUnreadMark(ctx, 1000000001, peer, true); err != nil {
|
||||
t.Fatalf("mark unread: %v", err)
|
||||
}
|
||||
// 发送方向发出消息即清手动未读标记(对齐 postgres UpsertOutboxDialog
|
||||
// 与 channel 发送路径)。
|
||||
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: 1000000001,
|
||||
RecipientUserID: 1000000002,
|
||||
RandomID: 2,
|
||||
Message: "again",
|
||||
Date: 1700000200,
|
||||
}); err != nil {
|
||||
t.Fatalf("send after mark: %v", err)
|
||||
}
|
||||
marks, err := dialogs.ListUnreadMarked(ctx, 1000000001)
|
||||
if err != nil {
|
||||
t.Fatalf("list unread marks: %v", err)
|
||||
}
|
||||
if len(marks) != 0 {
|
||||
t.Fatalf("unread marks after send = %+v, want cleared", marks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendPrivateTextPreservesPinnedDialog(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := NewDialogStore()
|
||||
messages := NewMessageStore(dialogs)
|
||||
first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: 1000000001,
|
||||
RecipientUserID: 1000000002,
|
||||
RandomID: 1,
|
||||
Message: "hi",
|
||||
Date: 1700000100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed send: %v", err)
|
||||
}
|
||||
peer := first.SenderMessage.Peer
|
||||
if _, _, err := dialogs.SetPinned(ctx, 1000000001, peer, true); err != nil {
|
||||
t.Fatalf("pin: %v", err)
|
||||
}
|
||||
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: 1000000001,
|
||||
RecipientUserID: 1000000002,
|
||||
RandomID: 2,
|
||||
Message: "again",
|
||||
Date: 1700000200,
|
||||
}); err != nil {
|
||||
t.Fatalf("send after pin: %v", err)
|
||||
}
|
||||
list, err := dialogs.ListByUser(ctx, 1000000001, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer == peer {
|
||||
found = true
|
||||
if !dialog.Pinned || dialog.PinnedOrder == 0 {
|
||||
t.Fatalf("dialog after send = %+v, want pinned preserved", dialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("dialog not found after send")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
internal/store/memory/message_webpage_resolve_test.go
Normal file
111
internal/store/memory/message_webpage_resolve_test.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TestMessageStoreResolveWebPageSwapsMediaOnly 验证 WebPageResolve 模式只换 media、不碰
|
||||
// body/entities/edit_date(不标记「已编辑」),双盒一致,事件为 web_page,且幂等。
|
||||
func TestMessageStoreResolveWebPageSwapsMediaOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := NewDialogStore()
|
||||
messages := NewMessageStore(dialogs)
|
||||
senderID := int64(1000000201)
|
||||
recipientID := int64(1000000202)
|
||||
const url = "https://example.com/x"
|
||||
urlHash := domain.WebPageURLHash(url)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipientID}
|
||||
|
||||
pending := &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindWebPage,
|
||||
WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStatePending, ID: urlHash, URL: url},
|
||||
}
|
||||
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: senderID, RecipientUserID: recipientID, RandomID: 201,
|
||||
Message: "see " + url, Date: 1700000100, Media: pending,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
|
||||
done := &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindWebPage,
|
||||
WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: urlHash, URL: url, Title: "Example"},
|
||||
}
|
||||
resolveReq := domain.EditMessageRequest{
|
||||
OwnerUserID: senderID,
|
||||
Peer: peer,
|
||||
ID: sent.SenderMessage.ID,
|
||||
Media: done,
|
||||
WebPageResolve: true,
|
||||
ExpectedWebPageID: urlHash,
|
||||
}
|
||||
res, err := messages.EditMessage(ctx, resolveReq)
|
||||
if err != nil {
|
||||
t.Fatalf("EditMessage(WebPageResolve): %v", err)
|
||||
}
|
||||
if len(res.Edited) != 2 {
|
||||
t.Fatalf("edited boxes = %d, want 2", len(res.Edited))
|
||||
}
|
||||
self := res.Self()
|
||||
if self.Event.Type != domain.UpdateEventWebPage {
|
||||
t.Fatalf("event type = %q, want web_page", self.Event.Type)
|
||||
}
|
||||
if self.Message.Media == nil || self.Message.Media.WebPage == nil || self.Message.Media.WebPage.State != domain.MessageWebPageStateDone {
|
||||
t.Fatalf("self media not resolved: %+v", self.Message.Media)
|
||||
}
|
||||
if self.Message.Body != "see "+url {
|
||||
t.Fatalf("body changed to %q, want unchanged", self.Message.Body)
|
||||
}
|
||||
if self.Message.EditDate != 0 {
|
||||
t.Fatalf("edit_date = %d, want 0 (no 'edited' marker)", self.Message.EditDate)
|
||||
}
|
||||
|
||||
// 接收方盒子也被替换为 done。
|
||||
rh, err := messages.ListByUser(ctx, recipientID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("recipient history: %v", err)
|
||||
}
|
||||
if len(rh.Messages) != 1 || rh.Messages[0].Media == nil || rh.Messages[0].Media.WebPage == nil || rh.Messages[0].Media.WebPage.State != domain.MessageWebPageStateDone {
|
||||
t.Fatalf("recipient media not resolved: %+v", rh.Messages)
|
||||
}
|
||||
|
||||
// 幂等:已解析后再次解析 → ErrMessageNotModified。
|
||||
if _, err := messages.EditMessage(ctx, resolveReq); !errors.Is(err, domain.ErrMessageNotModified) {
|
||||
t.Fatalf("re-resolve err = %v, want ErrMessageNotModified", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessageStoreResolveWebPageWrongIDNoop 验证 expectedID 不匹配时不替换。
|
||||
func TestMessageStoreResolveWebPageWrongIDNoop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := NewDialogStore()
|
||||
messages := NewMessageStore(dialogs)
|
||||
senderID := int64(1000000211)
|
||||
recipientID := int64(1000000212)
|
||||
const url = "https://example.com/y"
|
||||
urlHash := domain.WebPageURLHash(url)
|
||||
|
||||
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: senderID, RecipientUserID: recipientID, RandomID: 211, Message: url, Date: 1700000100,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStatePending, ID: urlHash, URL: url}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
_, err = messages.EditMessage(ctx, domain.EditMessageRequest{
|
||||
OwnerUserID: senderID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID},
|
||||
ID: sent.SenderMessage.ID,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: 999, URL: url}},
|
||||
WebPageResolve: true,
|
||||
ExpectedWebPageID: 999, // 与占位 id 不符
|
||||
})
|
||||
if !errors.Is(err, domain.ErrMessageNotModified) {
|
||||
t.Fatalf("wrong-id resolve err = %v, want ErrMessageNotModified", err)
|
||||
}
|
||||
}
|
||||
122
internal/store/memory/paid_reaction_test.go
Normal file
122
internal/store/memory/paid_reaction_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func seedBroadcastPost(t *testing.T, st *ChannelStore, creator int64, broadcast bool) (channelID int64, msgID int) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
created, err := st.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: creator,
|
||||
Title: "Paid",
|
||||
Broadcast: broadcast,
|
||||
Megagroup: !broadcast,
|
||||
Date: 1700000000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
sent, err := st.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: creator,
|
||||
ChannelID: created.Channel.ID,
|
||||
Message: "post",
|
||||
Date: 1700000000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send channel message: %v", err)
|
||||
}
|
||||
return created.Channel.ID, sent.Message.ID
|
||||
}
|
||||
|
||||
// 付费 reaction 累计 + 聚合:同一 reactor 多次增投累加,TopReactors 含本人带 My。
|
||||
func TestAddChannelMessagePaidReactionAccumulates(t *testing.T) {
|
||||
st := NewChannelStore()
|
||||
ctx := context.Background()
|
||||
const creator = int64(1000000001)
|
||||
channelID, msgID := seedBroadcastPost(t, st, creator, true)
|
||||
|
||||
res, err := st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
|
||||
UserID: creator, ChannelID: channelID, MessageID: msgID, Stars: 100, Date: 1700000001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first paid reaction: %v", err)
|
||||
}
|
||||
if res.Paid.TotalStars != 100 || res.Paid.MyStars != 100 {
|
||||
t.Fatalf("after 100 = total %d my %d, want 100/100", res.Paid.TotalStars, res.Paid.MyStars)
|
||||
}
|
||||
|
||||
res, err = st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
|
||||
UserID: creator, ChannelID: channelID, MessageID: msgID, Stars: 50, Date: 1700000002,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second paid reaction: %v", err)
|
||||
}
|
||||
if res.Paid.TotalStars != 150 || res.Paid.MyStars != 150 {
|
||||
t.Fatalf("after +50 = total %d my %d, want 150/150 (accumulated)", res.Paid.TotalStars, res.Paid.MyStars)
|
||||
}
|
||||
if len(res.Paid.TopReactors) != 1 || res.Paid.TopReactors[0].Stars != 150 || !res.Paid.TopReactors[0].My {
|
||||
t.Fatalf("top reactors = %+v, want one My 150", res.Paid.TopReactors)
|
||||
}
|
||||
}
|
||||
|
||||
// 多 reactor:TopReactors 按星数降序,本人始终在列。
|
||||
func TestAddChannelMessagePaidReactionTopReactors(t *testing.T) {
|
||||
st := NewChannelStore()
|
||||
ctx := context.Background()
|
||||
const creator = int64(1000000001)
|
||||
channelID, msgID := seedBroadcastPost(t, st, creator, true)
|
||||
// 让另外两个用户成为成员并增投(直接写 store 累计,绕过成员校验仅测聚合)。
|
||||
for _, c := range []struct {
|
||||
user int64
|
||||
stars int64
|
||||
}{{creator, 30}, {2000000002, 200}, {2000000003, 80}} {
|
||||
// 仅 creator 经正式路径;其他用户直接累计以构造排行。
|
||||
if c.user == creator {
|
||||
if _, err := st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
|
||||
UserID: c.user, ChannelID: channelID, MessageID: msgID, Stars: c.stars, Date: 1700000010,
|
||||
}); err != nil {
|
||||
t.Fatalf("creator paid reaction: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.paidReactions[channelID][msgID][c.user] = memoryPaidReaction{stars: c.stars, date: 1700000010}
|
||||
st.mu.Unlock()
|
||||
}
|
||||
res, err := st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
|
||||
UserID: creator, ChannelID: channelID, MessageID: msgID, Stars: 0 + 1, Date: 1700000011,
|
||||
})
|
||||
// creator 现在 31+? 重新算:creator 30 + 这次 1 = 31。
|
||||
if err != nil {
|
||||
t.Fatalf("paid reaction: %v", err)
|
||||
}
|
||||
if res.Paid.TotalStars != 31+200+80 {
|
||||
t.Fatalf("total = %d, want %d", res.Paid.TotalStars, 31+200+80)
|
||||
}
|
||||
// 降序:200, 80, 31。
|
||||
if len(res.Paid.TopReactors) != 3 || res.Paid.TopReactors[0].Stars != 200 || res.Paid.TopReactors[1].Stars != 80 || res.Paid.TopReactors[2].Stars != 31 {
|
||||
t.Fatalf("top reactors = %+v, want 200/80/31 desc", res.Paid.TopReactors)
|
||||
}
|
||||
if !res.Paid.TopReactors[2].My {
|
||||
t.Fatalf("creator (31) must carry My flag, got %+v", res.Paid.TopReactors[2])
|
||||
}
|
||||
}
|
||||
|
||||
// 非广播频道拒绝付费 reaction。
|
||||
func TestAddChannelMessagePaidReactionRejectsMegagroup(t *testing.T) {
|
||||
st := NewChannelStore()
|
||||
ctx := context.Background()
|
||||
const creator = int64(1000000001)
|
||||
channelID, msgID := seedBroadcastPost(t, st, creator, false)
|
||||
_, err := st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
|
||||
UserID: creator, ChannelID: channelID, MessageID: msgID, Stars: 10, Date: 1700000001,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrReactionInvalid) {
|
||||
t.Fatalf("megagroup paid reaction err = %v, want ErrReactionInvalid", err)
|
||||
}
|
||||
}
|
||||
117
internal/store/memory/passkey.go
Normal file
117
internal/store/memory/passkey.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// PasskeyStore 是 store.PasskeyStore 的内存实现。
|
||||
type PasskeyStore struct {
|
||||
mu sync.RWMutex
|
||||
byID map[string]domain.PasskeyCredential // key = string(credentialID)
|
||||
}
|
||||
|
||||
// NewPasskeyStore 创建内存 passkey 凭据 store。
|
||||
func NewPasskeyStore() *PasskeyStore {
|
||||
return &PasskeyStore{byID: make(map[string]domain.PasskeyCredential)}
|
||||
}
|
||||
|
||||
func (s *PasskeyStore) InsertPasskey(_ context.Context, cred domain.PasskeyCredential) error {
|
||||
if len(cred.CredentialID) == 0 || cred.UserID == 0 {
|
||||
return domain.ErrPasskeyInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.byID[string(cred.CredentialID)] = cred.Clone()
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PasskeyStore) GetPasskeyByCredentialID(_ context.Context, credentialID []byte) (domain.PasskeyCredential, bool, error) {
|
||||
s.mu.RLock()
|
||||
cred, ok := s.byID[string(credentialID)]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return domain.PasskeyCredential{}, false, nil
|
||||
}
|
||||
return cred.Clone(), true, nil
|
||||
}
|
||||
|
||||
func (s *PasskeyStore) ListPasskeysByUser(_ context.Context, userID int64) ([]domain.PasskeyCredential, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]domain.PasskeyCredential, 0)
|
||||
for _, cred := range s.byID {
|
||||
if cred.UserID == userID {
|
||||
out = append(out, cred.Clone())
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PasskeyStore) UpdatePasskeyUsage(_ context.Context, credentialID []byte, signCount uint32, lastUsedAt int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cred, ok := s.byID[string(credentialID)]
|
||||
if !ok {
|
||||
return domain.ErrPasskeyNotFound
|
||||
}
|
||||
cred.SignCount = signCount
|
||||
cred.LastUsedAt = lastUsedAt
|
||||
s.byID[string(credentialID)] = cred
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PasskeyStore) DeletePasskey(_ context.Context, userID int64, credentialID []byte) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cred, ok := s.byID[string(credentialID)]
|
||||
if !ok || cred.UserID != userID {
|
||||
return false, nil
|
||||
}
|
||||
delete(s.byID, string(credentialID))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// PasskeyChallengeStore 是 store.PasskeyChallengeStore 的内存实现(进程内、短 TTL)。
|
||||
// 与 QR 登录 token 同属进程内一次性凭据,不跨实例。
|
||||
type PasskeyChallengeStore struct {
|
||||
mu sync.Mutex
|
||||
m map[string]challengeEntry // key = string(challenge)
|
||||
}
|
||||
|
||||
type challengeEntry struct {
|
||||
c domain.PasskeyChallenge
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// NewPasskeyChallengeStore 创建内存挑战 store。
|
||||
func NewPasskeyChallengeStore() *PasskeyChallengeStore {
|
||||
return &PasskeyChallengeStore{m: make(map[string]challengeEntry)}
|
||||
}
|
||||
|
||||
func (s *PasskeyChallengeStore) SavePasskeyChallenge(_ context.Context, challenge []byte, c domain.PasskeyChallenge, ttl time.Duration) error {
|
||||
if len(challenge) == 0 {
|
||||
return domain.ErrPasskeyChallengeInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.m[string(challenge)] = challengeEntry{c: c, expiresAt: time.Now().Add(ttl)}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PasskeyChallengeStore) ConsumePasskeyChallenge(_ context.Context, challenge []byte) (domain.PasskeyChallenge, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entry, ok := s.m[string(challenge)]
|
||||
if !ok {
|
||||
return domain.PasskeyChallenge{}, false, nil
|
||||
}
|
||||
delete(s.m, string(challenge)) // 一次性:无论是否过期都删除
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return domain.PasskeyChallenge{}, false, nil
|
||||
}
|
||||
return entry.c, true, nil
|
||||
}
|
||||
413
internal/store/memory/password.go
Normal file
413
internal/store/memory/password.go
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// PasswordStore 是 store.PasswordStore 的内存实现。
|
||||
type PasswordStore struct {
|
||||
mu sync.RWMutex
|
||||
m map[int64]domain.PasswordSettings
|
||||
reactions map[int64]domain.AccountReactionSettings
|
||||
accountSettings map[int64]domain.AccountSettings
|
||||
notifySettings map[notifySettingsKey]domain.PeerNotifySettings
|
||||
stickerCollections map[stickerCollectionKey][]domain.StickerCollectionItem
|
||||
savedMusic map[int64][]domain.Document
|
||||
businessProfiles map[int64]domain.BusinessProfile
|
||||
businessChatLinks map[string]domain.BusinessChatLink
|
||||
businessChatLinkSlugs map[int64][]string
|
||||
quickReplies map[int64]map[int]domain.QuickReply
|
||||
quickReplyByShortcut map[int64]map[string]int
|
||||
quickReplyMessages map[int64]map[int]map[int]domain.QuickReplyMessage
|
||||
businessDeliveries map[businessAutomationDeliveryKey]domain.BusinessAutomationDelivery
|
||||
connectedBusinessBots map[int64]domain.ConnectedBusinessBot
|
||||
connectedBusinessBotPeerStates map[connectedBusinessBotPeerKey]domain.ConnectedBusinessBotPeerState
|
||||
nextQuickReplyID map[int64]int
|
||||
nextQuickReplyMessageID map[int64]int
|
||||
}
|
||||
|
||||
type businessAutomationDeliveryKey struct {
|
||||
ownerUserID int64
|
||||
peerUserID int64
|
||||
kind domain.BusinessAutomationKind
|
||||
triggerMessageID int
|
||||
}
|
||||
|
||||
type connectedBusinessBotPeerKey struct {
|
||||
ownerUserID int64
|
||||
peerUserID int64
|
||||
}
|
||||
|
||||
// NewPasswordStore 创建内存 PasswordStore。
|
||||
func NewPasswordStore() *PasswordStore {
|
||||
return &PasswordStore{
|
||||
m: make(map[int64]domain.PasswordSettings),
|
||||
reactions: make(map[int64]domain.AccountReactionSettings),
|
||||
accountSettings: make(map[int64]domain.AccountSettings),
|
||||
notifySettings: make(map[notifySettingsKey]domain.PeerNotifySettings),
|
||||
stickerCollections: make(map[stickerCollectionKey][]domain.StickerCollectionItem),
|
||||
savedMusic: make(map[int64][]domain.Document),
|
||||
businessProfiles: make(map[int64]domain.BusinessProfile),
|
||||
businessChatLinks: make(map[string]domain.BusinessChatLink),
|
||||
businessChatLinkSlugs: make(map[int64][]string),
|
||||
quickReplies: make(map[int64]map[int]domain.QuickReply),
|
||||
quickReplyByShortcut: make(map[int64]map[string]int),
|
||||
quickReplyMessages: make(map[int64]map[int]map[int]domain.QuickReplyMessage),
|
||||
businessDeliveries: make(map[businessAutomationDeliveryKey]domain.BusinessAutomationDelivery),
|
||||
connectedBusinessBots: make(map[int64]domain.ConnectedBusinessBot),
|
||||
connectedBusinessBotPeerStates: make(map[connectedBusinessBotPeerKey]domain.ConnectedBusinessBotPeerState),
|
||||
nextQuickReplyID: make(map[int64]int),
|
||||
nextQuickReplyMessageID: make(map[int64]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PasswordStore) GetByUser(_ context.Context, userID int64) (domain.PasswordSettings, bool, error) {
|
||||
s.mu.RLock()
|
||||
settings, ok := s.m[userID]
|
||||
s.mu.RUnlock()
|
||||
return clonePasswordSettings(settings), ok, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) Save(_ context.Context, userID int64, settings domain.PasswordSettings) error {
|
||||
s.mu.Lock()
|
||||
s.m[userID] = clonePasswordSettings(settings)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func clonePasswordSettings(in domain.PasswordSettings) domain.PasswordSettings {
|
||||
out := in
|
||||
if in.CurrentAlgo != nil {
|
||||
algo := *in.CurrentAlgo
|
||||
algo.Salt1 = append([]byte(nil), algo.Salt1...)
|
||||
algo.Salt2 = append([]byte(nil), algo.Salt2...)
|
||||
algo.P = append([]byte(nil), algo.P...)
|
||||
out.CurrentAlgo = &algo
|
||||
}
|
||||
out.SRPB = append([]byte(nil), in.SRPB...)
|
||||
out.NewAlgo.Salt1 = append([]byte(nil), in.NewAlgo.Salt1...)
|
||||
out.NewAlgo.Salt2 = append([]byte(nil), in.NewAlgo.Salt2...)
|
||||
out.NewAlgo.P = append([]byte(nil), in.NewAlgo.P...)
|
||||
out.NewSecureAlgo.Salt = append([]byte(nil), in.NewSecureAlgo.Salt...)
|
||||
out.SecureRandom = append([]byte(nil), in.SecureRandom...)
|
||||
out.SRPVerifier = append([]byte(nil), in.SRPVerifier...)
|
||||
out.SRPBSecret = append([]byte(nil), in.SRPBSecret...)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *PasswordStore) GetReactionSettings(_ context.Context, userID int64) (domain.AccountReactionSettings, bool, error) {
|
||||
s.mu.RLock()
|
||||
settings, ok := s.reactions[userID]
|
||||
s.mu.RUnlock()
|
||||
return cloneAccountReactionSettings(settings), ok, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) SaveReactionSettings(_ context.Context, userID int64, settings domain.AccountReactionSettings) error {
|
||||
s.mu.Lock()
|
||||
s.reactions[userID] = cloneAccountReactionSettings(settings)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneAccountReactionSettings(in domain.AccountReactionSettings) domain.AccountReactionSettings {
|
||||
out := in
|
||||
if in.PaidPrivacy.Peer != nil {
|
||||
peer := *in.PaidPrivacy.Peer
|
||||
out.PaidPrivacy.Peer = &peer
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *PasswordStore) GetAccountSettings(_ context.Context, userID int64) (domain.AccountSettings, bool, error) {
|
||||
s.mu.RLock()
|
||||
settings, ok := s.accountSettings[userID]
|
||||
s.mu.RUnlock()
|
||||
return settings, ok, nil // AccountSettings 全是值类型,无需深拷贝
|
||||
}
|
||||
|
||||
func (s *PasswordStore) SaveAccountSettings(_ context.Context, userID int64, settings domain.AccountSettings) error {
|
||||
s.mu.Lock()
|
||||
s.accountSettings[userID] = settings
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
type notifySettingsKey struct {
|
||||
owner int64
|
||||
kind domain.NotifyScopeKind
|
||||
peerType domain.PeerType
|
||||
peerID int64
|
||||
topicID int
|
||||
}
|
||||
|
||||
func notifySettingsKeyOf(owner int64, scope domain.NotifyScope) notifySettingsKey {
|
||||
key := notifySettingsKey{owner: owner, kind: scope.Kind}
|
||||
if scope.Kind == domain.NotifyScopePeer {
|
||||
key.peerType = scope.Peer.Type
|
||||
key.peerID = scope.Peer.ID
|
||||
key.topicID = scope.TopicID
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func (s *PasswordStore) GetNotifySettings(_ context.Context, ownerUserID int64, scope domain.NotifyScope) (domain.PeerNotifySettings, bool, error) {
|
||||
s.mu.RLock()
|
||||
settings, ok := s.notifySettings[notifySettingsKeyOf(ownerUserID, scope)]
|
||||
s.mu.RUnlock()
|
||||
return settings.Clone(), ok, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) SaveNotifySettings(_ context.Context, ownerUserID int64, scope domain.NotifyScope, settings domain.PeerNotifySettings) error {
|
||||
s.mu.Lock()
|
||||
s.notifySettings[notifySettingsKeyOf(ownerUserID, scope)] = settings.Clone()
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) ResetNotifySettings(_ context.Context, ownerUserID int64) error {
|
||||
s.mu.Lock()
|
||||
for key := range s.notifySettings {
|
||||
if key.owner == ownerUserID {
|
||||
delete(s.notifySettings, key)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
type stickerCollectionKey struct {
|
||||
owner int64
|
||||
kind domain.StickerCollectionKind
|
||||
}
|
||||
|
||||
func (s *PasswordStore) SaveStickerCollectionItem(_ context.Context, userID int64, kind domain.StickerCollectionKind, documentID int64, unsave bool, now, max int) error {
|
||||
if userID == 0 || documentID == 0 {
|
||||
return domain.ErrStickerInvalid
|
||||
}
|
||||
if max <= 0 {
|
||||
max = domain.MaxStickerCollectionItems(kind)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := stickerCollectionKey{owner: userID, kind: kind}
|
||||
cur := s.stickerCollections[key]
|
||||
// 移除既有同 id 项。
|
||||
next := make([]domain.StickerCollectionItem, 0, len(cur)+1)
|
||||
for _, it := range cur {
|
||||
if it.DocumentID == documentID {
|
||||
continue
|
||||
}
|
||||
next = append(next, it)
|
||||
}
|
||||
if unsave {
|
||||
s.stickerCollections[key] = next
|
||||
return nil
|
||||
}
|
||||
// 最新置顶 + 截断。
|
||||
next = append([]domain.StickerCollectionItem{{DocumentID: documentID, Date: now}}, next...)
|
||||
if len(next) > max {
|
||||
next = next[:max]
|
||||
}
|
||||
s.stickerCollections[key] = next
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) ListStickerCollection(_ context.Context, userID int64, kind domain.StickerCollectionKind, limit int) ([]domain.StickerCollectionItem, error) {
|
||||
if limit <= 0 || limit > domain.MaxStickerCollectionItems(kind) {
|
||||
limit = domain.MaxStickerCollectionItems(kind)
|
||||
}
|
||||
s.mu.RLock()
|
||||
cur := s.stickerCollections[stickerCollectionKey{owner: userID, kind: kind}]
|
||||
s.mu.RUnlock()
|
||||
if len(cur) > limit {
|
||||
cur = cur[:limit]
|
||||
}
|
||||
return append([]domain.StickerCollectionItem(nil), cur...), nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) ClearStickerCollection(_ context.Context, userID int64, kind domain.StickerCollectionKind) error {
|
||||
s.mu.Lock()
|
||||
delete(s.stickerCollections, stickerCollectionKey{owner: userID, kind: kind})
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) ListNotifyExceptions(_ context.Context, ownerUserID int64) ([]domain.NotifyException, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]domain.NotifyException, 0)
|
||||
for key, settings := range s.notifySettings {
|
||||
if key.owner != ownerUserID || key.kind != domain.NotifyScopePeer || settings.IsZero() {
|
||||
continue
|
||||
}
|
||||
out = append(out, domain.NotifyException{
|
||||
Peer: domain.Peer{Type: key.peerType, ID: key.peerID},
|
||||
TopicID: key.topicID,
|
||||
Settings: settings.Clone(),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) AllPeerNotifySettings(_ context.Context, ownerUserID int64) (map[domain.Peer]domain.PeerNotifySettings, error) {
|
||||
out := make(map[domain.Peer]domain.PeerNotifySettings)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for key, settings := range s.notifySettings {
|
||||
if key.owner != ownerUserID || key.kind != domain.NotifyScopePeer || key.topicID != 0 {
|
||||
continue
|
||||
}
|
||||
out[domain.Peer{Type: key.peerType, ID: key.peerID}] = settings.Clone()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) GetPeerNotifySettings(_ context.Context, ownerUserID int64, peers []domain.Peer) (map[domain.Peer]domain.PeerNotifySettings, error) {
|
||||
out := make(map[domain.Peer]domain.PeerNotifySettings, len(peers))
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, p := range peers {
|
||||
key := notifySettingsKey{owner: ownerUserID, kind: domain.NotifyScopePeer, peerType: p.Type, peerID: p.ID, topicID: 0}
|
||||
if settings, ok := s.notifySettings[key]; ok {
|
||||
out[p] = settings.Clone()
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) SaveMusic(_ context.Context, req domain.SaveMusicRequest) error {
|
||||
if req.UserID == 0 || req.Document.ID == 0 || !req.Document.IsMusic() {
|
||||
return domain.ErrDocumentInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
current := s.savedMusic[req.UserID]
|
||||
if req.Unsave {
|
||||
s.savedMusic[req.UserID] = removeSavedMusicDocument(current, req.Document.ID)
|
||||
return nil
|
||||
}
|
||||
if req.AfterDocumentID == req.Document.ID {
|
||||
for _, doc := range current {
|
||||
if doc.ID == req.Document.ID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return domain.ErrDocumentInvalid
|
||||
}
|
||||
next := make([]domain.Document, 0, len(current)+1)
|
||||
afterIndex := -1
|
||||
for _, doc := range current {
|
||||
if doc.ID == req.Document.ID {
|
||||
continue
|
||||
}
|
||||
if doc.ID == req.AfterDocumentID {
|
||||
afterIndex = len(next)
|
||||
}
|
||||
next = append(next, cloneDocument(doc))
|
||||
}
|
||||
insert := cloneDocument(req.Document)
|
||||
if req.AfterDocumentID != 0 {
|
||||
if afterIndex < 0 {
|
||||
return domain.ErrDocumentInvalid
|
||||
}
|
||||
next = append(next, domain.Document{})
|
||||
copy(next[afterIndex+2:], next[afterIndex+1:])
|
||||
next[afterIndex+1] = insert
|
||||
} else {
|
||||
next = append([]domain.Document{insert}, next...)
|
||||
}
|
||||
if len(next) > domain.MaxSavedMusicItems {
|
||||
next = next[:domain.MaxSavedMusicItems]
|
||||
}
|
||||
s.savedMusic[req.UserID] = next
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) ListSavedMusicIDs(_ context.Context, userID int64, limit int) ([]int64, error) {
|
||||
s.mu.RLock()
|
||||
current := s.savedMusic[userID]
|
||||
s.mu.RUnlock()
|
||||
if limit <= 0 || limit > domain.MaxSavedMusicItems {
|
||||
limit = domain.MaxSavedMusicItems
|
||||
}
|
||||
if len(current) > limit {
|
||||
current = current[:limit]
|
||||
}
|
||||
out := make([]int64, 0, len(current))
|
||||
for _, doc := range current {
|
||||
out = append(out, doc.ID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) ListSavedMusic(_ context.Context, userID int64, offset, limit int) (domain.SavedMusicList, error) {
|
||||
s.mu.RLock()
|
||||
current := cloneDocuments(s.savedMusic[userID])
|
||||
s.mu.RUnlock()
|
||||
out := domain.SavedMusicList{UserID: userID, Count: len(current)}
|
||||
if offset < 0 || offset >= len(current) || limit <= 0 {
|
||||
return out, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(current) {
|
||||
end = len(current)
|
||||
}
|
||||
out.Documents = cloneDocuments(current[offset:end])
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) GetSavedMusicByIDs(_ context.Context, userID int64, ids []int64) (domain.SavedMusicList, error) {
|
||||
s.mu.RLock()
|
||||
current := cloneDocuments(s.savedMusic[userID])
|
||||
s.mu.RUnlock()
|
||||
byID := make(map[int64]domain.Document, len(current))
|
||||
for _, doc := range current {
|
||||
byID[doc.ID] = doc
|
||||
}
|
||||
out := domain.SavedMusicList{UserID: userID, Count: len(current)}
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
if doc, ok := byID[id]; ok {
|
||||
out.Documents = append(out.Documents, cloneDocument(doc))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func removeSavedMusicDocument(in []domain.Document, id int64) []domain.Document {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]domain.Document, 0, len(in))
|
||||
for _, doc := range in {
|
||||
if doc.ID != id {
|
||||
out = append(out, cloneDocument(doc))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDocument(in domain.Document) domain.Document {
|
||||
out := in
|
||||
out.FileReference = append([]byte(nil), in.FileReference...)
|
||||
out.Attributes = append([]domain.DocumentAttribute(nil), in.Attributes...)
|
||||
out.Thumbs = append([]domain.PhotoSize(nil), in.Thumbs...)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDocuments(in []domain.Document) []domain.Document {
|
||||
out := make([]domain.Document, 0, len(in))
|
||||
for _, doc := range in {
|
||||
out = append(out, cloneDocument(doc))
|
||||
}
|
||||
return out
|
||||
}
|
||||
284
internal/store/memory/poll.go
Normal file
284
internal/store/memory/poll.go
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// PollStore 是 store.PollStore 的内存实现,同时充当 MessageStore/ChannelStore 投票与
|
||||
// 读路径 enrichment 的共享权威(postgres 侧对应 polls / poll_votes 两张表)。
|
||||
// 校验与门控复用 domain.ValidatePollVote / ResolvePollResults,保证与 postgres 行为一致。
|
||||
type PollStore struct {
|
||||
mu sync.RWMutex
|
||||
polls map[int64]domain.PollDefinition
|
||||
votes map[int64]map[int64]domain.PollVote
|
||||
}
|
||||
|
||||
// NewPollStore 创建内存 poll 权威存储。
|
||||
func NewPollStore() *PollStore {
|
||||
return &PollStore{
|
||||
polls: make(map[int64]domain.PollDefinition),
|
||||
votes: make(map[int64]map[int64]domain.PollVote),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PollStore) CreatePoll(_ context.Context, def domain.PollDefinition) error {
|
||||
if def.ID == 0 || len(def.Options) == 0 {
|
||||
return domain.ErrPollInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.polls[def.ID]; exists {
|
||||
return domain.ErrPollInvalid
|
||||
}
|
||||
s.polls[def.ID] = clonePollDefinition(def)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PollStore) GetPollDefinition(_ context.Context, pollID int64) (domain.PollDefinition, bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
def, ok := s.polls[pollID]
|
||||
if !ok {
|
||||
return domain.PollDefinition{}, false, nil
|
||||
}
|
||||
return clonePollDefinition(def), true, nil
|
||||
}
|
||||
|
||||
func (s *PollStore) ListPollVotes(_ context.Context, req domain.PollVotesListRequest) (domain.PollVotesList, error) {
|
||||
if req.PollID == 0 || req.Limit <= 0 {
|
||||
return domain.PollVotesList{}, domain.ErrPollInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, ok := s.polls[req.PollID]; !ok {
|
||||
return domain.PollVotesList{}, domain.ErrPollNotFound
|
||||
}
|
||||
rows := make([]domain.PollVote, 0, len(s.votes[req.PollID]))
|
||||
for _, vote := range s.votes[req.PollID] {
|
||||
if len(req.Option) > 0 && !voteHasOption(vote, req.Option) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, clonePollVote(vote))
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].Date != rows[j].Date {
|
||||
return rows[i].Date > rows[j].Date
|
||||
}
|
||||
return rows[i].UserID > rows[j].UserID
|
||||
})
|
||||
out := domain.PollVotesList{Count: len(rows)}
|
||||
start := 0
|
||||
if req.OffsetDate > 0 || req.OffsetUserID > 0 {
|
||||
for i, row := range rows {
|
||||
if row.Date < req.OffsetDate || (row.Date == req.OffsetDate && row.UserID < req.OffsetUserID) {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
end := start + req.Limit
|
||||
if end > len(rows) {
|
||||
end = len(rows)
|
||||
}
|
||||
if start < end {
|
||||
out.Votes = rows[start:end]
|
||||
}
|
||||
out.HasMore = end < len(rows)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Vote 校验并落一票(options 为空 = 撤票)。校验逻辑全部来自 domain.ValidatePollVote。
|
||||
func (s *PollStore) Vote(pollID, userID int64, options [][]byte, date int) error {
|
||||
if pollID == 0 || userID == 0 {
|
||||
return domain.ErrPollNotFound
|
||||
}
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
def, ok := s.polls[pollID]
|
||||
if !ok {
|
||||
return domain.ErrPollNotFound
|
||||
}
|
||||
var existing [][]byte
|
||||
if vote, voted := s.votes[pollID][userID]; voted {
|
||||
existing = vote.Options
|
||||
}
|
||||
if err := domain.ValidatePollVote(def, existing, options, date); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(options) == 0 {
|
||||
delete(s.votes[pollID], userID)
|
||||
return nil
|
||||
}
|
||||
if s.votes[pollID] == nil {
|
||||
s.votes[pollID] = make(map[int64]domain.PollVote)
|
||||
}
|
||||
s.votes[pollID][userID] = clonePollVote(domain.PollVote{PollID: pollID, UserID: userID, Options: options, Date: date})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭 poll;仅创建者可关,重复关闭幂等。
|
||||
func (s *PollStore) Close(pollID, byUserID int64) error {
|
||||
if pollID == 0 || byUserID == 0 {
|
||||
return domain.ErrPollNotFound
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
def, ok := s.polls[pollID]
|
||||
if !ok {
|
||||
return domain.ErrPollNotFound
|
||||
}
|
||||
if def.CreatorUserID != byUserID {
|
||||
return domain.ErrPollNotCreator
|
||||
}
|
||||
def.Closed = true
|
||||
s.polls[pollID] = def
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnrichPoll 把权威态 + viewer 视角聚合写回 media 定义快照;poll 不存在时保持快照原样。
|
||||
func (s *PollStore) EnrichPoll(poll *domain.MessagePoll, viewerUserID int64, now int) {
|
||||
if s == nil || poll == nil || poll.ID == 0 {
|
||||
return
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
def, ok := s.polls[poll.ID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
agg := domain.PollAggregates{Counts: make(map[string]int)}
|
||||
recent := make([]domain.PollVote, 0, len(s.votes[poll.ID]))
|
||||
for userID, vote := range s.votes[poll.ID] {
|
||||
agg.TotalVoters++
|
||||
for _, option := range vote.Options {
|
||||
agg.Counts[string(option)]++
|
||||
}
|
||||
if userID == viewerUserID {
|
||||
agg.ViewerOptions = append([][]byte(nil), vote.Options...)
|
||||
}
|
||||
recent = append(recent, vote)
|
||||
}
|
||||
sort.Slice(recent, func(i, j int) bool {
|
||||
if recent[i].Date != recent[j].Date {
|
||||
return recent[i].Date > recent[j].Date
|
||||
}
|
||||
return recent[i].UserID > recent[j].UserID
|
||||
})
|
||||
for i, vote := range recent {
|
||||
if i >= domain.MaxPollRecentVoters {
|
||||
break
|
||||
}
|
||||
agg.RecentVoters = append(agg.RecentVoters, vote.UserID)
|
||||
}
|
||||
results := domain.ResolvePollResults(def, agg, viewerUserID, now)
|
||||
domain.ApplyPollState(poll, def, results, now)
|
||||
}
|
||||
|
||||
// EnrichPollForViewers 批量为一组 viewer 返回 per-viewer enrich 的 poll(fan-out 模板化):
|
||||
// viewer-invariant 聚合(counts/total/recent)只遍历一次 + per-viewer ViewerOptions,每 viewer 用与
|
||||
// 单 viewer EnrichPoll 完全相同的 ResolvePollResults/ApplyPollState 合成(字节同源)。返回 map[viewer]
|
||||
// 各自的 poll 克隆;def 不存在时返回空 map(与 EnrichPoll 的 no-op 一致)。
|
||||
func (s *PollStore) EnrichPollForViewers(basePoll *domain.MessagePoll, viewers []int64, now int) map[int64]*domain.MessagePoll {
|
||||
out := make(map[int64]*domain.MessagePoll, len(viewers))
|
||||
if s == nil || basePoll == nil || basePoll.ID == 0 || len(viewers) == 0 {
|
||||
return out
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
def, ok := s.polls[basePoll.ID]
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
counts := make(map[string]int)
|
||||
total := 0
|
||||
optionsByUser := make(map[int64][][]byte, len(s.votes[basePoll.ID]))
|
||||
recent := make([]domain.PollVote, 0, len(s.votes[basePoll.ID]))
|
||||
for userID, vote := range s.votes[basePoll.ID] {
|
||||
total++
|
||||
for _, option := range vote.Options {
|
||||
counts[string(option)]++
|
||||
}
|
||||
optionsByUser[userID] = append([][]byte(nil), vote.Options...)
|
||||
recent = append(recent, vote)
|
||||
}
|
||||
sort.Slice(recent, func(i, j int) bool {
|
||||
if recent[i].Date != recent[j].Date {
|
||||
return recent[i].Date > recent[j].Date
|
||||
}
|
||||
return recent[i].UserID > recent[j].UserID
|
||||
})
|
||||
recentVoters := make([]int64, 0, domain.MaxPollRecentVoters)
|
||||
for i, vote := range recent {
|
||||
if i >= domain.MaxPollRecentVoters {
|
||||
break
|
||||
}
|
||||
recentVoters = append(recentVoters, vote.UserID)
|
||||
}
|
||||
for _, viewer := range viewers {
|
||||
agg := domain.PollAggregates{
|
||||
Counts: counts,
|
||||
TotalVoters: total,
|
||||
RecentVoters: recentVoters,
|
||||
ViewerOptions: optionsByUser[viewer],
|
||||
}
|
||||
results := domain.ResolvePollResults(def, agg, viewer, now)
|
||||
pollCopy := *basePoll
|
||||
domain.ApplyPollState(&pollCopy, def, results, now)
|
||||
out[viewer] = &pollCopy
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// enrichPollMediaForViewer 克隆 media(避免共享指针把 viewer 态写进 store 本体)并 enrich。
|
||||
func enrichPollMediaForViewer(polls *PollStore, media *domain.MessageMedia, viewerUserID int64, now int) *domain.MessageMedia {
|
||||
if polls == nil || media == nil || media.Kind != domain.MessageMediaKindPoll || media.Poll == nil {
|
||||
return media
|
||||
}
|
||||
cloned := *media
|
||||
poll := *media.Poll
|
||||
poll.Answers = append([]domain.MessagePollAnswer(nil), media.Poll.Answers...)
|
||||
cloned.Poll = &poll
|
||||
polls.EnrichPoll(cloned.Poll, viewerUserID, now)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func clonePollDefinition(def domain.PollDefinition) domain.PollDefinition {
|
||||
def.Options = cloneOptionList(def.Options)
|
||||
def.CorrectOptions = cloneOptionList(def.CorrectOptions)
|
||||
def.SolutionEntities = append([]domain.MessageEntity(nil), def.SolutionEntities...)
|
||||
return def
|
||||
}
|
||||
|
||||
func clonePollVote(vote domain.PollVote) domain.PollVote {
|
||||
vote.Options = cloneOptionList(vote.Options)
|
||||
return vote
|
||||
}
|
||||
|
||||
func cloneOptionList(options [][]byte) [][]byte {
|
||||
if options == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([][]byte, 0, len(options))
|
||||
for _, option := range options {
|
||||
out = append(out, append([]byte(nil), option...))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func voteHasOption(vote domain.PollVote, option []byte) bool {
|
||||
for _, candidate := range vote.Options {
|
||||
if string(candidate) == string(option) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
270
internal/store/memory/saved_dialog.go
Normal file
270
internal/store/memory/saved_dialog.go
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// savedDialogTopsLocked 聚合 self-chat 按 saved_peer 分组的 top message。
|
||||
// 返回按 top box id 降序。
|
||||
func (s *MessageStore) savedDialogTopsLocked(userID int64) []domain.Message {
|
||||
tops := make(map[domain.Peer]domain.Message)
|
||||
selfPeer := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
|
||||
for _, msg := range s.m[userID] {
|
||||
if msg.Peer != selfPeer || msg.SavedPeer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if cur, ok := tops[msg.SavedPeer]; !ok || msg.ID > cur.ID {
|
||||
tops[msg.SavedPeer] = msg
|
||||
}
|
||||
}
|
||||
out := make([]domain.Message, 0, len(tops))
|
||||
for _, msg := range tops {
|
||||
out = append(out, msg)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *MessageStore) savedPinIndexLocked(userID int64, peer domain.Peer) int {
|
||||
for i, p := range s.savedPins[userID] {
|
||||
if p == peer {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (s *MessageStore) appendSavedDialogLocked(out *domain.SavedDialogList, msg domain.Message, pinned bool) {
|
||||
out.Dialogs = append(out.Dialogs, domain.SavedDialog{
|
||||
Peer: msg.SavedPeer,
|
||||
TopMessage: msg.ID,
|
||||
Pinned: pinned,
|
||||
})
|
||||
out.Messages = append(out.Messages, cloneMessage(msg))
|
||||
}
|
||||
|
||||
func (s *MessageStore) ListSavedDialogs(_ context.Context, userID int64, filter domain.SavedDialogsFilter) (domain.SavedDialogList, error) {
|
||||
out := domain.SavedDialogList{}
|
||||
if userID == 0 {
|
||||
return out, fmt.Errorf("list saved dialogs: missing user id")
|
||||
}
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > domain.MaxSavedDialogsLimit {
|
||||
limit = domain.MaxSavedDialogsLimit
|
||||
}
|
||||
offsetID := filter.OffsetID
|
||||
// DrKLO Android 首页发 offset_id = MaxMessageBoxID(int32 max),用 >= 命中首页
|
||||
// 分支,避免被误判为续页跳过置顶块(与 postgres 对齐)。
|
||||
firstPage := offsetID <= 0 || offsetID >= domain.MaxMessageBoxID
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
tops := s.savedDialogTopsLocked(userID)
|
||||
topByPeer := make(map[domain.Peer]domain.Message, len(tops))
|
||||
for _, msg := range tops {
|
||||
topByPeer[msg.SavedPeer] = msg
|
||||
}
|
||||
// 首页且不排除置顶:置顶块按 pinned_order 在前。
|
||||
if firstPage && !filter.ExcludePinned {
|
||||
for _, peer := range s.savedPins[userID] {
|
||||
if len(out.Dialogs) >= limit {
|
||||
break
|
||||
}
|
||||
if msg, ok := topByPeer[peer]; ok {
|
||||
s.appendSavedDialogLocked(&out, msg, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 普通块恒排除置顶(置顶只随首页返回)。
|
||||
hasMore := false
|
||||
for _, msg := range tops {
|
||||
if len(out.Dialogs) >= limit {
|
||||
if s.savedPinIndexLocked(userID, msg.SavedPeer) < 0 &&
|
||||
(firstPage || msg.ID < offsetID) {
|
||||
hasMore = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.savedPinIndexLocked(userID, msg.SavedPeer) >= 0 {
|
||||
continue
|
||||
}
|
||||
if !firstPage && msg.ID >= offsetID {
|
||||
continue
|
||||
}
|
||||
s.appendSavedDialogLocked(&out, msg, false)
|
||||
}
|
||||
total := 0
|
||||
for _, msg := range tops {
|
||||
if filter.ExcludePinned && s.savedPinIndexLocked(userID, msg.SavedPeer) >= 0 {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
}
|
||||
out.Count = total
|
||||
out.Full = !hasMore
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) ListPinnedSavedDialogs(_ context.Context, userID int64) (domain.SavedDialogList, error) {
|
||||
out := domain.SavedDialogList{Full: true}
|
||||
if userID == 0 {
|
||||
return out, fmt.Errorf("list pinned saved dialogs: missing user id")
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
tops := s.savedDialogTopsLocked(userID)
|
||||
topByPeer := make(map[domain.Peer]domain.Message, len(tops))
|
||||
for _, msg := range tops {
|
||||
topByPeer[msg.SavedPeer] = msg
|
||||
}
|
||||
for _, peer := range s.savedPins[userID] {
|
||||
if msg, ok := topByPeer[peer]; ok {
|
||||
s.appendSavedDialogLocked(&out, msg, true)
|
||||
}
|
||||
}
|
||||
out.Count = len(out.Dialogs)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) ListSavedDialogsByPeers(_ context.Context, userID int64, peers []domain.Peer) (domain.SavedDialogList, error) {
|
||||
out := domain.SavedDialogList{Full: true}
|
||||
if userID == 0 {
|
||||
return out, fmt.Errorf("list saved dialogs by peers: missing user id")
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
tops := s.savedDialogTopsLocked(userID)
|
||||
topByPeer := make(map[domain.Peer]domain.Message, len(tops))
|
||||
for _, msg := range tops {
|
||||
topByPeer[msg.SavedPeer] = msg
|
||||
}
|
||||
seen := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.Type == "" || peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
if msg, ok := topByPeer[peer]; ok {
|
||||
s.appendSavedDialogLocked(&out, msg, s.savedPinIndexLocked(userID, peer) >= 0)
|
||||
}
|
||||
}
|
||||
out.Count = len(out.Dialogs)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) ToggleSavedDialogPin(_ context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) {
|
||||
if userID == 0 || peer.Type == "" || peer.ID == 0 {
|
||||
return false, fmt.Errorf("toggle saved dialog pin: invalid input")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
idx := s.savedPinIndexLocked(userID, peer)
|
||||
if !pinned {
|
||||
if idx < 0 {
|
||||
return false, nil
|
||||
}
|
||||
s.savedPins[userID] = append(s.savedPins[userID][:idx], s.savedPins[userID][idx+1:]...)
|
||||
return true, nil
|
||||
}
|
||||
if idx >= 0 {
|
||||
return false, nil
|
||||
}
|
||||
if len(s.savedPins[userID]) >= domain.MaxPinnedSavedDialogs {
|
||||
return false, domain.ErrPinnedSavedDialogsTooMuch
|
||||
}
|
||||
s.savedPins[userID] = append([]domain.Peer{peer}, s.savedPins[userID]...)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) ReorderPinnedSavedDialogs(_ context.Context, userID int64, order []domain.Peer, force bool) error {
|
||||
if userID == 0 {
|
||||
return fmt.Errorf("reorder pinned saved dialogs: missing user id")
|
||||
}
|
||||
if len(order) > domain.MaxPinnedSavedDialogs {
|
||||
return domain.ErrPinnedSavedDialogsTooMuch
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
next := make([]domain.Peer, 0, len(order))
|
||||
seen := make(map[domain.Peer]struct{}, len(order))
|
||||
for _, peer := range order {
|
||||
if peer.Type == "" || peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
next = append(next, peer)
|
||||
}
|
||||
if !force {
|
||||
// 非 force:order 之外的既有置顶保持原相对顺序排在后面。
|
||||
for _, peer := range s.savedPins[userID] {
|
||||
if _, ok := seen[peer]; !ok {
|
||||
next = append(next, peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.savedPins[userID] = next
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) DeleteSavedHistory(_ context.Context, req domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error) {
|
||||
res := domain.DeleteSavedHistoryResult{}
|
||||
if req.OwnerUserID == 0 || req.SavedPeer.Type == "" || req.SavedPeer.ID == 0 {
|
||||
return res, fmt.Errorf("delete saved history: invalid input")
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
selfPeer := domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
match := func(msg domain.Message) bool {
|
||||
if msg.Peer != selfPeer || msg.SavedPeer != req.SavedPeer {
|
||||
return false
|
||||
}
|
||||
if req.MaxID > 0 && msg.ID > req.MaxID {
|
||||
return false
|
||||
}
|
||||
if req.MinDate > 0 && msg.Date < req.MinDate {
|
||||
return false
|
||||
}
|
||||
if req.MaxDate > 0 && msg.Date > req.MaxDate {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
deleted, _, more := s.deleteMemoryMessagesLocked(req.OwnerUserID, domain.MaxDeleteHistoryBatch, match)
|
||||
delRes := s.finishMemoryDeleteLocked(domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID}, deleted, req.Date, false)
|
||||
res.More = more
|
||||
for _, d := range delRes.Deleted {
|
||||
if d.UserID == req.OwnerUserID {
|
||||
res.MessageIDs = d.MessageIDs
|
||||
res.Event = d.Event
|
||||
}
|
||||
}
|
||||
if len(deleted) > 0 && !more {
|
||||
// 子会话删空时清掉它的置顶行(与 PG 实现同语义)。
|
||||
alive := false
|
||||
for _, msg := range s.m[req.OwnerUserID] {
|
||||
if msg.Peer == selfPeer && msg.SavedPeer == req.SavedPeer {
|
||||
alive = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !alive {
|
||||
if idx := s.savedPinIndexLocked(req.OwnerUserID, req.SavedPeer); idx >= 0 {
|
||||
s.savedPins[req.OwnerUserID] = append(s.savedPins[req.OwnerUserID][:idx], s.savedPins[req.OwnerUserID][idx+1:]...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
296
internal/store/memory/secretchat.go
Normal file
296
internal/store/memory/secretchat.go
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// SecretChatStore 是 store.SecretChatStore 的进程内实现(rpc/app 单测 fixture 用)。
|
||||
// 行为契约与 postgres 实现由 storetest 共享 contract test 钉死;凡动握手态迁移
|
||||
// 语义两边必须同步。
|
||||
type SecretChatStore struct {
|
||||
mu sync.Mutex
|
||||
chats map[int]domain.SecretChat
|
||||
}
|
||||
|
||||
// NewSecretChatStore 创建内存实现。
|
||||
func NewSecretChatStore() *SecretChatStore {
|
||||
return &SecretChatStore{chats: make(map[int]domain.SecretChat)}
|
||||
}
|
||||
|
||||
func cloneSecretChat(c domain.SecretChat) domain.SecretChat {
|
||||
c.GA = append([]byte(nil), c.GA...)
|
||||
c.GB = append([]byte(nil), c.GB...)
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *SecretChatStore) CreateSecretChat(_ context.Context, chat domain.SecretChat) error {
|
||||
if chat.ID == 0 {
|
||||
return domain.ErrSecretChatNotFound
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.chats[chat.ID]; exists {
|
||||
return domain.ErrSecretChatIDConflict
|
||||
}
|
||||
if chat.State == "" {
|
||||
chat.State = domain.SecretChatStateRequested
|
||||
}
|
||||
s.chats[chat.ID] = cloneSecretChat(chat)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SecretChatStore) GetSecretChat(_ context.Context, chatID int) (domain.SecretChat, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
c, ok := s.chats[chatID]
|
||||
if !ok {
|
||||
return domain.SecretChat{}, false, nil
|
||||
}
|
||||
return cloneSecretChat(c), true, nil
|
||||
}
|
||||
|
||||
func (s *SecretChatStore) GetByAdminRandom(_ context.Context, adminAuthKeyID int64, randomID int32) (domain.SecretChat, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// 仅返回非终态匹配(与部分唯一索引 WHERE state <> 'discarded' 一致)。
|
||||
for _, c := range s.chats {
|
||||
if c.AdminAuthKeyID == adminAuthKeyID && c.RandomID == randomID && !c.Terminal() {
|
||||
return cloneSecretChat(c), true, nil
|
||||
}
|
||||
}
|
||||
return domain.SecretChat{}, false, nil
|
||||
}
|
||||
|
||||
func (s *SecretChatStore) AcceptSecretChat(_ context.Context, chatID int, participantAuthKeyID int64, gb []byte, keyFingerprint int64) (domain.SecretChat, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
c, ok := s.chats[chatID]
|
||||
if !ok {
|
||||
return domain.SecretChat{}, domain.ErrSecretChatNotFound
|
||||
}
|
||||
switch c.State {
|
||||
case domain.SecretChatStateNormal:
|
||||
return domain.SecretChat{}, domain.ErrSecretChatAlreadyAccepted
|
||||
case domain.SecretChatStateDiscarded:
|
||||
return domain.SecretChat{}, domain.ErrSecretChatAlreadyDeclined
|
||||
}
|
||||
// requested 且未绑定接受设备:CAS 成功。
|
||||
if c.ParticipantAuthKeyID != 0 {
|
||||
return domain.SecretChat{}, domain.ErrSecretChatAlreadyAccepted
|
||||
}
|
||||
c.State = domain.SecretChatStateNormal
|
||||
c.ParticipantAuthKeyID = participantAuthKeyID
|
||||
c.GB = append([]byte(nil), gb...)
|
||||
c.KeyFingerprint = keyFingerprint
|
||||
s.chats[chatID] = c
|
||||
return cloneSecretChat(c), nil
|
||||
}
|
||||
|
||||
func (s *SecretChatStore) DiscardSecretChat(_ context.Context, chatID int, historyDeleted bool) (domain.SecretChat, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
c, ok := s.chats[chatID]
|
||||
if !ok {
|
||||
return domain.SecretChat{}, false, domain.ErrSecretChatNotFound
|
||||
}
|
||||
if c.State == domain.SecretChatStateDiscarded {
|
||||
return cloneSecretChat(c), true, nil
|
||||
}
|
||||
c.State = domain.SecretChatStateDiscarded
|
||||
c.HistoryDeleted = historyDeleted
|
||||
s.chats[chatID] = c
|
||||
return cloneSecretChat(c), false, nil
|
||||
}
|
||||
|
||||
func (s *SecretChatStore) ListActiveSecretChatsByAuthKey(_ context.Context, authKeyID int64) ([]domain.SecretChat, error) {
|
||||
if authKeyID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []domain.SecretChat
|
||||
for _, c := range s.chats {
|
||||
if c.Terminal() {
|
||||
continue
|
||||
}
|
||||
if c.AdminAuthKeyID == authKeyID || c.ParticipantAuthKeyID == authKeyID {
|
||||
out = append(out, cloneSecretChat(c))
|
||||
}
|
||||
}
|
||||
// map 遍历无序:按 chat_id 升序与 postgres ORDER BY 对齐,确定性供测试断言。
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *SecretChatStore) MaxSecretChatID(_ context.Context) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
max := 0
|
||||
for id := range s.chats {
|
||||
if id > max {
|
||||
max = id
|
||||
}
|
||||
}
|
||||
return max, nil
|
||||
}
|
||||
|
||||
// EncryptedQueueStore 是 store.EncryptedQueueStore 的进程内实现。
|
||||
type EncryptedQueueStore struct {
|
||||
mu sync.Mutex
|
||||
byDevice map[int64][]domain.SecretChatMessage // receiverAuthKeyID → qts 升序消息
|
||||
reserved map[int64]int
|
||||
confirmed map[int64]int
|
||||
dedup map[emqDedupKey]int // → qts
|
||||
stateEvents []domain.EncryptedStateEvent
|
||||
delivered map[int64]map[int64]bool // eventID → deviceAuthKeyID → true
|
||||
nextEventID int64
|
||||
files map[int64]domain.EncryptedFileRef // file id → 快照
|
||||
}
|
||||
|
||||
type emqDedupKey struct {
|
||||
receiver int64
|
||||
chat int
|
||||
random int64
|
||||
}
|
||||
|
||||
// NewEncryptedQueueStore 创建内存实现。
|
||||
func NewEncryptedQueueStore() *EncryptedQueueStore {
|
||||
return &EncryptedQueueStore{
|
||||
byDevice: make(map[int64][]domain.SecretChatMessage),
|
||||
reserved: make(map[int64]int),
|
||||
confirmed: make(map[int64]int),
|
||||
dedup: make(map[emqDedupKey]int),
|
||||
delivered: make(map[int64]map[int64]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneSecretMessage(m domain.SecretChatMessage) domain.SecretChatMessage {
|
||||
m.Bytes = append([]byte(nil), m.Bytes...)
|
||||
if m.File != nil {
|
||||
f := *m.File
|
||||
m.File = &f
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *EncryptedQueueStore) AppendEncryptedMessage(_ context.Context, msg domain.SecretChatMessage) (domain.SecretChatMessage, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := emqDedupKey{msg.ReceiverAuthKeyID, msg.ChatID, msg.RandomID}
|
||||
if qts, ok := s.dedup[key]; ok {
|
||||
for _, m := range s.byDevice[msg.ReceiverAuthKeyID] {
|
||||
if m.Qts == qts {
|
||||
return cloneSecretMessage(m), true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
s.reserved[msg.ReceiverAuthKeyID]++
|
||||
msg.Qts = s.reserved[msg.ReceiverAuthKeyID]
|
||||
stored := cloneSecretMessage(msg)
|
||||
s.byDevice[msg.ReceiverAuthKeyID] = append(s.byDevice[msg.ReceiverAuthKeyID], stored)
|
||||
s.dedup[key] = msg.Qts
|
||||
return cloneSecretMessage(stored), false, nil
|
||||
}
|
||||
|
||||
func (s *EncryptedQueueStore) ListEncryptedMessagesSince(_ context.Context, receiverAuthKeyID int64, sinceQts, limit int) ([]domain.SecretChatMessage, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if limit <= 0 {
|
||||
limit = 1000
|
||||
}
|
||||
var out []domain.SecretChatMessage
|
||||
for _, m := range s.byDevice[receiverAuthKeyID] {
|
||||
if m.Qts > sinceQts {
|
||||
out = append(out, cloneSecretMessage(m))
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *EncryptedQueueStore) ReservedQts(_ context.Context, receiverAuthKeyID int64) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.reserved[receiverAuthKeyID], nil
|
||||
}
|
||||
|
||||
func (s *EncryptedQueueStore) AckEncryptedMessages(_ context.Context, receiverAuthKeyID int64, maxQts int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if maxQts > s.confirmed[receiverAuthKeyID] {
|
||||
s.confirmed[receiverAuthKeyID] = maxQts
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EncryptedQueueStore) AppendStateEvent(_ context.Context, ev domain.EncryptedStateEvent) (int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.nextEventID++
|
||||
ev.ID = s.nextEventID
|
||||
s.stateEvents = append(s.stateEvents, ev)
|
||||
return ev.ID, nil
|
||||
}
|
||||
|
||||
func (s *EncryptedQueueStore) ListUndeliveredStateEvents(_ context.Context, targetUserID, deviceAuthKeyID int64, limit int) ([]domain.EncryptedStateEvent, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if limit <= 0 {
|
||||
limit = 1000
|
||||
}
|
||||
var out []domain.EncryptedStateEvent
|
||||
for _, ev := range s.stateEvents {
|
||||
if ev.TargetUserID != targetUserID {
|
||||
continue
|
||||
}
|
||||
if ev.TargetAuthKeyID != 0 && ev.TargetAuthKeyID != deviceAuthKeyID {
|
||||
continue
|
||||
}
|
||||
if s.delivered[ev.ID][deviceAuthKeyID] {
|
||||
continue
|
||||
}
|
||||
out = append(out, ev)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *EncryptedQueueStore) MarkStateEventsDelivered(_ context.Context, deviceAuthKeyID int64, eventIDs []int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, id := range eventIDs {
|
||||
if s.delivered[id] == nil {
|
||||
s.delivered[id] = make(map[int64]bool)
|
||||
}
|
||||
s.delivered[id][deviceAuthKeyID] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EncryptedQueueStore) PutEncryptedFile(_ context.Context, _ int64, ref domain.EncryptedFileRef) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.files == nil {
|
||||
s.files = make(map[int64]domain.EncryptedFileRef)
|
||||
}
|
||||
s.files[ref.ID] = ref
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EncryptedQueueStore) GetEncryptedFile(_ context.Context, id, accessHash int64) (domain.EncryptedFileRef, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ref, ok := s.files[id]
|
||||
if !ok || ref.AccessHash != accessHash {
|
||||
return domain.EncryptedFileRef{}, false, nil
|
||||
}
|
||||
return ref, true, nil
|
||||
}
|
||||
178
internal/store/memory/star_gift.go
Normal file
178
internal/store/memory/star_gift.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// StarGiftStore 是 store.StarGiftStore 的内存实现。
|
||||
type StarGiftStore struct {
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
gifts []domain.SavedStarGift // 追加序
|
||||
}
|
||||
|
||||
// NewStarGiftStore 创建内存 StarGiftStore。
|
||||
func NewStarGiftStore() *StarGiftStore {
|
||||
return &StarGiftStore{}
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (int64, error) {
|
||||
if !validSavedStarGift(gift) {
|
||||
return 0, domain.ErrStarGiftInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.nextID++
|
||||
gift.ID = s.nextID
|
||||
if gift.Owner.Type == domain.PeerTypeChannel && gift.SavedID == 0 {
|
||||
gift.SavedID = gift.ID
|
||||
}
|
||||
gift.Converted = false
|
||||
s.gifts = append(s.gifts, gift)
|
||||
return gift.ID, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ListByOwner(_ context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
|
||||
if !validStarGiftOwner(owner) {
|
||||
return domain.SavedStarGiftPage{}, nil
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxSavedStarGiftsLimit {
|
||||
limit = domain.MaxSavedStarGiftsLimit
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
matched := make([]domain.SavedStarGift, 0)
|
||||
for _, g := range s.gifts {
|
||||
if g.Owner != owner || g.Converted {
|
||||
continue
|
||||
}
|
||||
if excludeUnsaved && g.Unsaved {
|
||||
continue
|
||||
}
|
||||
matched = append(matched, g)
|
||||
}
|
||||
sort.Slice(matched, func(i, j int) bool { return matched[i].ID > matched[j].ID })
|
||||
page := domain.SavedStarGiftPage{Count: len(matched)}
|
||||
cursor, hasCursor := domain.DecodeStarGiftCursor(offset)
|
||||
out := make([]domain.SavedStarGift, 0, limit)
|
||||
for _, g := range matched {
|
||||
if hasCursor && g.ID >= cursor {
|
||||
continue
|
||||
}
|
||||
out = append(out, g)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(out) == limit {
|
||||
// 还有更早的则给下一页游标。
|
||||
last := out[len(out)-1].ID
|
||||
for _, g := range matched {
|
||||
if g.ID < last {
|
||||
page.NextOffset = domain.EncodeStarGiftCursor(last)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
page.Gifts = out
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) GetByRef(_ context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
|
||||
if !ref.Valid() {
|
||||
return domain.SavedStarGift{}, false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, g := range s.gifts {
|
||||
if savedStarGiftMatchesRef(g, ref) {
|
||||
return g, true, nil
|
||||
}
|
||||
}
|
||||
return domain.SavedStarGift{}, false, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CountByOwner(_ context.Context, owner domain.Peer) (int, error) {
|
||||
if !validStarGiftOwner(owner) {
|
||||
return 0, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
n := 0
|
||||
for _, g := range s.gifts {
|
||||
if g.Owner == owner && !g.Converted && !g.Unsaved {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) SetUnsaved(_ context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) {
|
||||
if !ref.Valid() {
|
||||
return false, domain.ErrStarGiftNotFound
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.gifts {
|
||||
if savedStarGiftMatchesRef(s.gifts[i], ref) && !s.gifts[i].Converted {
|
||||
s.gifts[i].Unsaved = unsaved
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) MarkConverted(_ context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
|
||||
if !ref.Valid() {
|
||||
return domain.SavedStarGift{}, domain.ErrStarGiftNotFound
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.gifts {
|
||||
if savedStarGiftMatchesRef(s.gifts[i], ref) {
|
||||
if s.gifts[i].Converted {
|
||||
return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyConverted
|
||||
}
|
||||
s.gifts[i].Converted = true
|
||||
s.gifts[i].Unsaved = true
|
||||
return s.gifts[i], nil
|
||||
}
|
||||
}
|
||||
return domain.SavedStarGift{}, domain.ErrStarGiftNotFound
|
||||
}
|
||||
|
||||
func validSavedStarGift(g domain.SavedStarGift) bool {
|
||||
if g.GiftID == 0 || !validStarGiftOwner(g.Owner) {
|
||||
return false
|
||||
}
|
||||
switch g.Owner.Type {
|
||||
case domain.PeerTypeUser:
|
||||
return g.MsgID > 0 && g.SavedID == 0
|
||||
case domain.PeerTypeChannel:
|
||||
return g.MsgID == 0 && g.SavedID >= 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validStarGiftOwner(owner domain.Peer) bool {
|
||||
return owner.ID != 0 && (owner.Type == domain.PeerTypeUser || owner.Type == domain.PeerTypeChannel)
|
||||
}
|
||||
|
||||
func savedStarGiftMatchesRef(g domain.SavedStarGift, ref domain.SavedStarGiftRef) bool {
|
||||
if g.Owner != ref.Owner {
|
||||
return false
|
||||
}
|
||||
switch ref.Owner.Type {
|
||||
case domain.PeerTypeUser:
|
||||
return g.MsgID == ref.MsgID
|
||||
case domain.PeerTypeChannel:
|
||||
return g.SavedID == ref.SavedID
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
143
internal/store/memory/stars.go
Normal file
143
internal/store/memory/stars.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// StarsStore 是 store.StarsStore 的内存实现,复刻 postgres 版的原子语义
|
||||
// (在单个互斥锁下完成读-检查-写,等价于 SELECT ... FOR UPDATE)。
|
||||
type StarsStore struct {
|
||||
mu sync.Mutex
|
||||
states map[int64]*starsState
|
||||
nextID int64
|
||||
}
|
||||
|
||||
type starsState struct {
|
||||
balance int64
|
||||
granted bool
|
||||
txns []domain.StarsTransaction // 追加序,读时倒序
|
||||
}
|
||||
|
||||
// NewStarsStore 创建内存 StarsStore。
|
||||
func NewStarsStore() *StarsStore {
|
||||
return &StarsStore{states: make(map[int64]*starsState)}
|
||||
}
|
||||
|
||||
func (s *StarsStore) GetBalance(_ context.Context, userID int64) (domain.StarsBalance, error) {
|
||||
if userID == 0 {
|
||||
return domain.StarsBalance{}, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.states[userID]
|
||||
if st == nil {
|
||||
return domain.StarsBalance{UserID: userID}, nil
|
||||
}
|
||||
return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: st.granted}, nil
|
||||
}
|
||||
|
||||
func (s *StarsStore) EnsureGrant(_ context.Context, userID, amount int64, date int) (domain.StarsBalance, bool, error) {
|
||||
if userID == 0 {
|
||||
return domain.StarsBalance{}, false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.states[userID]
|
||||
if st == nil {
|
||||
st = &starsState{}
|
||||
s.states[userID] = st
|
||||
}
|
||||
if amount <= 0 {
|
||||
return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: st.granted}, false, nil
|
||||
}
|
||||
if st.granted {
|
||||
return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: true}, false, nil
|
||||
}
|
||||
st.balance += amount
|
||||
st.granted = true
|
||||
s.appendTxn(st, userID, amount, domain.StarsReasonGrant, domain.Peer{}, date, "", "")
|
||||
return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: true}, true, nil
|
||||
}
|
||||
|
||||
func (s *StarsStore) Credit(_ context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) (domain.StarsBalance, error) {
|
||||
if userID == 0 || amount <= 0 {
|
||||
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.states[userID]
|
||||
if st == nil {
|
||||
st = &starsState{}
|
||||
s.states[userID] = st
|
||||
}
|
||||
st.balance += amount
|
||||
s.appendTxn(st, userID, amount, reason, peer, date, title, desc)
|
||||
return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: st.granted}, nil
|
||||
}
|
||||
|
||||
func (s *StarsStore) Debit(_ context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) (domain.StarsBalance, error) {
|
||||
if userID == 0 || amount <= 0 {
|
||||
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.states[userID]
|
||||
if st == nil || st.balance < amount {
|
||||
return domain.StarsBalance{}, domain.ErrStarsInsufficient
|
||||
}
|
||||
st.balance -= amount
|
||||
s.appendTxn(st, userID, -amount, reason, peer, date, title, desc)
|
||||
return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: st.granted}, nil
|
||||
}
|
||||
|
||||
func (s *StarsStore) ListTransactions(_ context.Context, userID int64, offset string, limit int) (domain.StarsTransactionPage, error) {
|
||||
if userID == 0 {
|
||||
return domain.StarsTransactionPage{}, nil
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
|
||||
limit = domain.MaxStarsTransactionsLimit
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.states[userID]
|
||||
if st == nil {
|
||||
return domain.StarsTransactionPage{}, nil
|
||||
}
|
||||
page := domain.StarsTransactionPage{Balance: st.balance}
|
||||
cursor, hasCursor := domain.DecodeStarsCursor(offset)
|
||||
// 倒序遍历(id DESC)。
|
||||
out := make([]domain.StarsTransaction, 0, limit)
|
||||
for i := len(st.txns) - 1; i >= 0; i-- {
|
||||
t := st.txns[i]
|
||||
if hasCursor && t.ID >= cursor {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
if len(out) == limit {
|
||||
// 还有更早的流水则给出下一页游标。
|
||||
if i-1 >= 0 {
|
||||
page.NextOffset = domain.EncodeStarsCursor(t.ID)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
page.Transactions = out
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (s *StarsStore) appendTxn(st *starsState, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) {
|
||||
s.nextID++
|
||||
st.txns = append(st.txns, domain.StarsTransaction{
|
||||
ID: s.nextID,
|
||||
UserID: userID,
|
||||
Peer: peer,
|
||||
Amount: amount,
|
||||
Date: date,
|
||||
Reason: reason,
|
||||
Title: title,
|
||||
Description: desc,
|
||||
})
|
||||
}
|
||||
1723
internal/store/memory/story.go
Normal file
1723
internal/store/memory/story.go
Normal file
File diff suppressed because it is too large
Load diff
2020
internal/store/memory/story_test.go
Normal file
2020
internal/store/memory/story_test.go
Normal file
File diff suppressed because it is too large
Load diff
210
internal/store/memory/themes.go
Normal file
210
internal/store/memory/themes.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ThemeStore 是 store.ThemeStore 的内存实现(测试/单实例)。
|
||||
type ThemeStore struct {
|
||||
mu sync.RWMutex
|
||||
byID map[int64]domain.Theme
|
||||
bySlug map[string]int64
|
||||
installs map[int64]map[int64]themeInstall // userID -> themeID -> 安装项
|
||||
seq int64 // 单调序,模拟 postgres installed_at 排序
|
||||
}
|
||||
|
||||
type themeInstall struct {
|
||||
dark bool
|
||||
order int64
|
||||
}
|
||||
|
||||
// NewThemeStore 创建内存主题 store。
|
||||
func NewThemeStore() *ThemeStore {
|
||||
return &ThemeStore{
|
||||
byID: make(map[int64]domain.Theme),
|
||||
bySlug: make(map[string]int64),
|
||||
installs: make(map[int64]map[int64]themeInstall),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ThemeStore) CreateTheme(_ context.Context, t domain.Theme) error {
|
||||
if t.ID == 0 || t.CreatorUserID == 0 {
|
||||
return domain.ErrThemeInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.byID[t.ID]; ok {
|
||||
return domain.ErrThemeInvalid
|
||||
}
|
||||
if t.Slug != "" {
|
||||
if _, ok := s.bySlug[t.Slug]; ok {
|
||||
return domain.ErrThemeSlugTaken
|
||||
}
|
||||
}
|
||||
s.byID[t.ID] = t.Clone()
|
||||
if t.Slug != "" {
|
||||
s.bySlug[t.Slug] = t.ID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ThemeStore) GetThemeByID(_ context.Context, id int64) (domain.Theme, bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
t, ok := s.byID[id]
|
||||
if !ok {
|
||||
return domain.Theme{}, false, nil
|
||||
}
|
||||
return t.Clone(), true, nil
|
||||
}
|
||||
|
||||
func (s *ThemeStore) GetThemeBySlug(_ context.Context, slug string) (domain.Theme, bool, error) {
|
||||
if slug == "" {
|
||||
return domain.Theme{}, false, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
id, ok := s.bySlug[slug]
|
||||
if !ok {
|
||||
return domain.Theme{}, false, nil
|
||||
}
|
||||
t, ok := s.byID[id]
|
||||
if !ok {
|
||||
return domain.Theme{}, false, nil
|
||||
}
|
||||
return t.Clone(), true, nil
|
||||
}
|
||||
|
||||
func (s *ThemeStore) SlugExists(_ context.Context, slug string) (bool, error) {
|
||||
if slug == "" {
|
||||
return false, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, ok := s.bySlug[slug]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (s *ThemeStore) UpdateTheme(_ context.Context, t domain.Theme) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
prev, ok := s.byID[t.ID]
|
||||
if !ok {
|
||||
return domain.ErrThemeNotFound
|
||||
}
|
||||
if t.Slug != "" && t.Slug != prev.Slug {
|
||||
if _, taken := s.bySlug[t.Slug]; taken {
|
||||
return domain.ErrThemeSlugTaken
|
||||
}
|
||||
}
|
||||
if prev.Slug != "" && prev.Slug != t.Slug {
|
||||
delete(s.bySlug, prev.Slug)
|
||||
}
|
||||
s.byID[t.ID] = t.Clone()
|
||||
if t.Slug != "" {
|
||||
s.bySlug[t.Slug] = t.ID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ThemeStore) IncrementInstalls(_ context.Context, id int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
t, ok := s.byID[id]
|
||||
if !ok {
|
||||
return domain.ErrThemeNotFound
|
||||
}
|
||||
t.InstallsCount++
|
||||
s.byID[id] = t
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ThemeStore) SetInstalled(_ context.Context, userID, themeID int64, dark bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.byID[themeID]; !ok {
|
||||
return domain.ErrThemeNotFound
|
||||
}
|
||||
byUser := s.installs[userID]
|
||||
if byUser == nil {
|
||||
byUser = make(map[int64]themeInstall)
|
||||
s.installs[userID] = byUser
|
||||
}
|
||||
if prev, ok := byUser[themeID]; ok {
|
||||
byUser[themeID] = themeInstall{dark: dark, order: prev.order}
|
||||
return nil
|
||||
}
|
||||
s.seq++
|
||||
byUser[themeID] = themeInstall{dark: dark, order: s.seq}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ThemeStore) RemoveInstalled(_ context.Context, userID, themeID int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if byUser := s.installs[userID]; byUser != nil {
|
||||
delete(byUser, themeID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ThemeStore) ListInstalledByUser(_ context.Context, userID int64) ([]domain.Theme, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
byUser := s.installs[userID]
|
||||
if len(byUser) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
type row struct {
|
||||
t domain.Theme
|
||||
order int64
|
||||
}
|
||||
rows := make([]row, 0, len(byUser))
|
||||
for themeID, inst := range byUser {
|
||||
t, ok := s.byID[themeID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row{t: t.Clone(), order: inst.order})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].order < rows[j].order })
|
||||
out := make([]domain.Theme, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ThemeStore) ListThemesForUser(_ context.Context, userID int64) ([]domain.Theme, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
seen := make(map[int64]bool)
|
||||
out := make([]domain.Theme, 0)
|
||||
for _, t := range s.byID {
|
||||
if t.CreatorUserID == userID {
|
||||
out = append(out, t.Clone())
|
||||
seen[t.ID] = true
|
||||
}
|
||||
}
|
||||
if byUser := s.installs[userID]; byUser != nil {
|
||||
for themeID := range byUser {
|
||||
if seen[themeID] {
|
||||
continue
|
||||
}
|
||||
if t, ok := s.byID[themeID]; ok {
|
||||
out = append(out, t.Clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].CreatedAt != out[j].CreatedAt {
|
||||
return out[i].CreatedAt < out[j].CreatedAt
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
109
internal/store/memory/themes_test.go
Normal file
109
internal/store/memory/themes_test.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func sampleTheme(id, creator int64, slug string) domain.Theme {
|
||||
return domain.Theme{
|
||||
ID: id,
|
||||
AccessHash: id + 1,
|
||||
CreatorUserID: creator,
|
||||
Slug: slug,
|
||||
Title: "T",
|
||||
DocumentID: 9000 + id,
|
||||
Settings: []domain.ThemeSettingsSpec{{
|
||||
BaseTheme: domain.ThemeBaseDay,
|
||||
AccentColor: 0x112233,
|
||||
MessageColors: []int{0xaa, 0xbb},
|
||||
Wallpaper: &domain.ThemeWallpaperSpec{BackgroundColors: []int{0x1, 0x2}},
|
||||
}},
|
||||
CreatedAt: 1700000000,
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryThemeStoreContract(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := NewThemeStore()
|
||||
|
||||
// 未命中读 → (zero,false,nil)。
|
||||
if _, ok, err := s.GetThemeByID(ctx, 1); ok || err != nil {
|
||||
t.Fatalf("get missing = ok %v err %v", ok, err)
|
||||
}
|
||||
|
||||
a := sampleTheme(101, 7, "alpha")
|
||||
if err := s.CreateTheme(ctx, a); err != nil {
|
||||
t.Fatalf("create a: %v", err)
|
||||
}
|
||||
// slug 冲突。
|
||||
if err := s.CreateTheme(ctx, sampleTheme(102, 7, "alpha")); !errors.Is(err, domain.ErrThemeSlugTaken) {
|
||||
t.Fatalf("dup slug err = %v, want ErrThemeSlugTaken", err)
|
||||
}
|
||||
|
||||
got, ok, err := s.GetThemeByID(ctx, 101)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("get a by id = ok %v err %v", ok, err)
|
||||
}
|
||||
if got.Slug != "alpha" || len(got.Settings) != 1 || got.Settings[0].BaseTheme != domain.ThemeBaseDay {
|
||||
t.Fatalf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
// Clone 隔离:改返回值不污染内部。
|
||||
got.Settings[0].MessageColors[0] = 0xffff
|
||||
if again, _, _ := s.GetThemeByID(ctx, 101); again.Settings[0].MessageColors[0] != 0xaa {
|
||||
t.Fatalf("clone leak: internal mutated to %#x", again.Settings[0].MessageColors[0])
|
||||
}
|
||||
|
||||
if bySlug, ok, _ := s.GetThemeBySlug(ctx, "alpha"); !ok || bySlug.ID != 101 {
|
||||
t.Fatalf("get by slug = ok %v id %d", ok, bySlug.ID)
|
||||
}
|
||||
|
||||
// update 不存在 → ErrThemeNotFound。
|
||||
if err := s.UpdateTheme(ctx, sampleTheme(999, 7, "ghost")); !errors.Is(err, domain.ErrThemeNotFound) {
|
||||
t.Fatalf("update missing err = %v, want ErrThemeNotFound", err)
|
||||
}
|
||||
|
||||
// IncrementInstalls。
|
||||
if err := s.IncrementInstalls(ctx, 101); err != nil {
|
||||
t.Fatalf("increment: %v", err)
|
||||
}
|
||||
if got, _, _ := s.GetThemeByID(ctx, 101); got.InstallsCount != 1 {
|
||||
t.Fatalf("installs = %d, want 1", got.InstallsCount)
|
||||
}
|
||||
|
||||
// 安装列表顺序按安装先后。
|
||||
b := sampleTheme(103, 7, "beta")
|
||||
if err := s.CreateTheme(ctx, b); err != nil {
|
||||
t.Fatalf("create b: %v", err)
|
||||
}
|
||||
const user = int64(55)
|
||||
if err := s.SetInstalled(ctx, user, 103, false); err != nil {
|
||||
t.Fatalf("install 103: %v", err)
|
||||
}
|
||||
if err := s.SetInstalled(ctx, user, 101, true); err != nil {
|
||||
t.Fatalf("install 101: %v", err)
|
||||
}
|
||||
list, err := s.ListInstalledByUser(ctx, user)
|
||||
if err != nil || len(list) != 2 {
|
||||
t.Fatalf("list installed = %d err %v, want 2", len(list), err)
|
||||
}
|
||||
if list[0].ID != 103 || list[1].ID != 101 {
|
||||
t.Fatalf("install order = [%d,%d], want [103,101]", list[0].ID, list[1].ID)
|
||||
}
|
||||
|
||||
// 移除。
|
||||
if err := s.RemoveInstalled(ctx, user, 103); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
if list, _ := s.ListInstalledByUser(ctx, user); len(list) != 1 || list[0].ID != 101 {
|
||||
t.Fatalf("after remove = %+v, want [101]", list)
|
||||
}
|
||||
|
||||
// 安装不存在的主题 → ErrThemeNotFound。
|
||||
if err := s.SetInstalled(ctx, user, 88888, false); !errors.Is(err, domain.ErrThemeNotFound) {
|
||||
t.Fatalf("install missing theme err = %v, want ErrThemeNotFound", err)
|
||||
}
|
||||
}
|
||||
201
internal/store/memory/updates.go
Normal file
201
internal/store/memory/updates.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// UpdateStateStore 是 store.UpdateStateStore 的内存实现。
|
||||
type UpdateStateStore struct {
|
||||
mu sync.RWMutex
|
||||
states map[updateStateKey]domain.UpdateState
|
||||
}
|
||||
|
||||
// UpdateEventStore 是 store.UpdateEventStore 的内存实现。
|
||||
type UpdateEventStore struct {
|
||||
mu sync.RWMutex
|
||||
events map[int64][]domain.UpdateEvent
|
||||
}
|
||||
|
||||
type updateStateKey struct {
|
||||
authKeyID [8]byte
|
||||
userID int64
|
||||
}
|
||||
|
||||
// NewUpdateEventStore 创建内存 UpdateEventStore。
|
||||
func NewUpdateEventStore() *UpdateEventStore {
|
||||
return &UpdateEventStore{events: make(map[int64][]domain.UpdateEvent)}
|
||||
}
|
||||
|
||||
func (s *UpdateEventStore) Append(_ context.Context, userID int64, event domain.UpdateEvent) error {
|
||||
_, err := s.append(userID, event, false)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *UpdateEventStore) AppendAllocated(_ context.Context, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, error) {
|
||||
return s.append(userID, event, true)
|
||||
}
|
||||
|
||||
func (s *UpdateEventStore) AppendAllocatedWithDispatch(_ context.Context, userID int64, event domain.UpdateEvent, _ [8]byte, _ int64) (domain.UpdateEvent, error) {
|
||||
return s.append(userID, event, true)
|
||||
}
|
||||
|
||||
func (s *UpdateEventStore) append(userID int64, event domain.UpdateEvent, allocate bool) (domain.UpdateEvent, error) {
|
||||
if event.PtsCount <= 0 {
|
||||
event.PtsCount = 1
|
||||
}
|
||||
event.UserID = userID
|
||||
event.Message = cloneMessage(event.Message)
|
||||
event.Story = cloneUpdateStory(event.Story)
|
||||
event.MessageIDs = append([]int(nil), event.MessageIDs...)
|
||||
event.Peers = append([]domain.Peer(nil), event.Peers...)
|
||||
event.Users = append([]domain.User(nil), event.Users...)
|
||||
event.Channels = append([]domain.Channel(nil), event.Channels...)
|
||||
event.Reaction = cloneUpdateReaction(event.Reaction)
|
||||
event.QuickReplies = cloneUpdateQuickReplies(event.QuickReplies)
|
||||
event.QuickReplyMessage = cloneUpdateQuickReplyMessage(event.QuickReplyMessage)
|
||||
s.mu.Lock()
|
||||
if allocate {
|
||||
current := 0
|
||||
for _, item := range s.events[userID] {
|
||||
if item.Pts > current {
|
||||
current = item.Pts
|
||||
}
|
||||
}
|
||||
event.Pts = current + event.PtsCount
|
||||
}
|
||||
s.events[userID] = append(s.events[userID], event)
|
||||
s.mu.Unlock()
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (s *UpdateEventStore) ListAfter(_ context.Context, userID int64, pts, limit int) ([]domain.UpdateEvent, error) {
|
||||
s.mu.RLock()
|
||||
items := append([]domain.UpdateEvent(nil), s.events[userID]...)
|
||||
s.mu.RUnlock()
|
||||
out := make([]domain.UpdateEvent, 0, len(items))
|
||||
for _, event := range items {
|
||||
if event.Pts <= pts {
|
||||
continue
|
||||
}
|
||||
event.Message = cloneMessage(event.Message)
|
||||
event.Story = cloneUpdateStory(event.Story)
|
||||
event.MessageIDs = append([]int(nil), event.MessageIDs...)
|
||||
event.Peers = append([]domain.Peer(nil), event.Peers...)
|
||||
event.Users = append([]domain.User(nil), event.Users...)
|
||||
event.Channels = append([]domain.Channel(nil), event.Channels...)
|
||||
event.Reaction = cloneUpdateReaction(event.Reaction)
|
||||
event.QuickReplies = cloneUpdateQuickReplies(event.QuickReplies)
|
||||
event.QuickReplyMessage = cloneUpdateQuickReplyMessage(event.QuickReplyMessage)
|
||||
out = append(out, event)
|
||||
if limit > 0 && len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cloneUpdateStory(story domain.Story) domain.Story {
|
||||
story.Entities = append([]domain.MessageEntity(nil), story.Entities...)
|
||||
story.Views.Reactions = append([]domain.ChannelMessageReactionCount(nil), story.Views.Reactions...)
|
||||
story.Views.RecentViewers = append([]int64(nil), story.Views.RecentViewers...)
|
||||
story.SentReaction = cloneUpdateReaction(story.SentReaction)
|
||||
return story
|
||||
}
|
||||
|
||||
func cloneUpdateReaction(in *domain.MessageReaction) *domain.MessageReaction {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneUpdateQuickReplies(in []domain.QuickReply) []domain.QuickReply {
|
||||
return append([]domain.QuickReply(nil), in...)
|
||||
}
|
||||
|
||||
func cloneUpdateQuickReplyMessage(in domain.QuickReplyMessage) domain.QuickReplyMessage {
|
||||
out := in
|
||||
out.Entities = append([]domain.MessageEntity(nil), in.Entities...)
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
// MaxContiguousPts 返回从 1 起无空洞的最大 pts(内存版按 pts_count 连续扫描)。
|
||||
func (s *UpdateEventStore) MaxContiguousPts(_ context.Context, userID int64) (int, error) {
|
||||
s.mu.RLock()
|
||||
nextByStart := make(map[int]int, len(s.events[userID]))
|
||||
for _, event := range s.events[userID] {
|
||||
count := event.PtsCount
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
nextByStart[event.Pts-count] = event.Pts
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
contiguous := 0
|
||||
for {
|
||||
next, ok := nextByStart[contiguous]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
contiguous = next
|
||||
}
|
||||
return contiguous, nil
|
||||
}
|
||||
|
||||
// NewUpdateStateStore 创建内存 UpdateStateStore。
|
||||
func NewUpdateStateStore() *UpdateStateStore {
|
||||
return &UpdateStateStore{states: make(map[updateStateKey]domain.UpdateState)}
|
||||
}
|
||||
|
||||
func (s *UpdateStateStore) Get(_ context.Context, id [8]byte, userID int64) (domain.UpdateState, bool, error) {
|
||||
s.mu.RLock()
|
||||
st, ok := s.states[updateStateKey{authKeyID: id, userID: userID}]
|
||||
s.mu.RUnlock()
|
||||
return st, ok, nil
|
||||
}
|
||||
|
||||
func (s *UpdateStateStore) Save(_ context.Context, id [8]byte, userID int64, st domain.UpdateState) error {
|
||||
s.mu.Lock()
|
||||
key := updateStateKey{authKeyID: id, userID: userID}
|
||||
// 确认水位只增不减(与 PG 的 GREATEST upsert 对齐):一个旧 from(较小 pts)
|
||||
// 的 getDifference 不得把设备已确认水位回退,否则 getPeerDialogs.state 会下发
|
||||
// 倒退的 pts 基线。
|
||||
prev := s.states[key]
|
||||
if st.Pts < prev.Pts {
|
||||
st.Pts = prev.Pts
|
||||
}
|
||||
if st.Qts < prev.Qts {
|
||||
st.Qts = prev.Qts
|
||||
}
|
||||
if st.Date < prev.Date {
|
||||
st.Date = prev.Date
|
||||
}
|
||||
if st.Seq < prev.Seq {
|
||||
st.Seq = prev.Seq
|
||||
}
|
||||
s.states[key] = st
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UpdateStateStore) Delete(_ context.Context, id [8]byte, userID int64) error {
|
||||
s.mu.Lock()
|
||||
delete(s.states, updateStateKey{authKeyID: id, userID: userID})
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UpdateStateStore) DeleteAuthKey(_ context.Context, id [8]byte) error {
|
||||
s.mu.Lock()
|
||||
for k := range s.states {
|
||||
if k.authKeyID == id {
|
||||
delete(s.states, k)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
384
internal/store/memory/users.go
Normal file
384
internal/store/memory/users.go
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。
|
||||
type UserStore struct {
|
||||
mu sync.RWMutex
|
||||
byID map[int64]domain.User
|
||||
nextID int64
|
||||
}
|
||||
|
||||
// NewUserStore 创建内存 UserStore。内置系统账号(777000 / BotFather)预置进表,
|
||||
// 与 postgres 的迁移种子(0005 / 0090)保持双 store 行为一致。
|
||||
func NewUserStore() *UserStore {
|
||||
s := &UserStore{byID: make(map[int64]domain.User), nextID: domain.UserIDSequenceBase}
|
||||
for _, id := range []int64{domain.OfficialSystemUserID, domain.BotFatherUserID} {
|
||||
if u, ok := domain.SystemUserByID(id); ok {
|
||||
s.byID[u.ID] = u
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *UserStore) ByID(_ context.Context, id int64) (domain.User, bool, error) {
|
||||
s.mu.RLock()
|
||||
u, ok := s.byID[id]
|
||||
s.mu.RUnlock()
|
||||
return u, ok, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) ByIDs(_ context.Context, ids []int64) ([]domain.User, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]domain.User, 0, len(ids))
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
if u, ok := s.byID[id]; ok {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) ByPhone(_ context.Context, phone string) (domain.User, bool, error) {
|
||||
// bot/系统账号 phone 可为空串,空查询必须判未找到(与 postgres 行为一致)。
|
||||
if phone == "" {
|
||||
return domain.User{}, false, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, u := range s.byID {
|
||||
if u.Phone == phone {
|
||||
return u, true, nil
|
||||
}
|
||||
}
|
||||
return domain.User{}, false, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) ByPhones(_ context.Context, phones []string) ([]domain.User, error) {
|
||||
if len(phones) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
want := make(map[string]struct{}, len(phones))
|
||||
for _, phone := range phones {
|
||||
if phone != "" {
|
||||
want[phone] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]domain.User, 0, len(want))
|
||||
seenIDs := map[int64]struct{}{}
|
||||
for _, u := range s.byID {
|
||||
if _, ok := want[u.Phone]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenIDs[u.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seenIDs[u.ID] = struct{}{}
|
||||
out = append(out, u)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) ByUsername(_ context.Context, username string) (domain.User, bool, error) {
|
||||
username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
||||
if username == "" {
|
||||
return domain.User{}, false, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, u := range s.byID {
|
||||
if strings.ToLower(u.Username) == username {
|
||||
return u, true, nil
|
||||
}
|
||||
}
|
||||
return domain.User{}, false, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) CheckUsername(_ context.Context, userID int64, username string) (bool, error) {
|
||||
username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
||||
if username == "" {
|
||||
return true, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for id, u := range s.byID {
|
||||
if strings.ToLower(u.Username) == username && id != userID {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
query = strings.ToLower(strings.TrimSpace(query))
|
||||
phoneQuery = strings.TrimSpace(phoneQuery)
|
||||
if query == "" {
|
||||
return domain.UserSearchResult{}, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
users := make([]domain.User, 0)
|
||||
for _, u := range s.byID {
|
||||
if u.ID == currentUserID {
|
||||
continue
|
||||
}
|
||||
if userMatchesSearch(u, query, phoneQuery) {
|
||||
users = append(users, u)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(users, func(i, j int) bool {
|
||||
return users[i].ID < users[j].ID
|
||||
})
|
||||
if len(users) > limit {
|
||||
users = users[:limit]
|
||||
}
|
||||
return domain.UserSearchResult{Results: users}, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username string) (domain.User, error) {
|
||||
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
|
||||
usernameLower := strings.ToLower(username)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUsernameNotOccupied
|
||||
}
|
||||
if usernameLower != "" {
|
||||
for id, existing := range s.byID {
|
||||
if id != userID && strings.ToLower(existing.Username) == usernameLower {
|
||||
return domain.User{}, domain.ErrUsernameOccupied
|
||||
}
|
||||
}
|
||||
}
|
||||
u.Username = username
|
||||
s.byID[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) UpdateProfile(_ context.Context, userID int64, firstName, lastName, about string) (domain.User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUsernameNotOccupied
|
||||
}
|
||||
u.FirstName = firstName
|
||||
u.LastName = lastName
|
||||
u.About = about
|
||||
s.byID[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) UpdateBirthday(_ context.Context, userID int64, birthday domain.Birthday) (domain.User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.Birthday = birthday
|
||||
s.byID[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) UpdatePersonalChannel(_ context.Context, userID int64, channelID int64) (domain.User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.PersonalChannelID = channelID
|
||||
s.byID[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// bumpBotInfoVersion 递增 bot 的 bot_info_version(仅 bot 行),返回新值。供同包
|
||||
// BotStore 元数据更新调用,与 postgres 的事务内 bump 对齐。
|
||||
func (s *UserStore) bumpBotInfoVersion(userID int64) (int, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok || !u.Bot {
|
||||
return 0, false
|
||||
}
|
||||
u.BotInfoVersion++
|
||||
s.byID[userID] = u
|
||||
return u.BotInfoVersion, true
|
||||
}
|
||||
|
||||
// updateBotProfile 部分更新 bot 的 first_name/about(setBotInfo 的 name/about)。
|
||||
func (s *UserStore) updateBotProfile(userID int64, setName bool, name string, setAbout bool, about string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok || !u.Bot {
|
||||
return false
|
||||
}
|
||||
if setName {
|
||||
u.FirstName = name
|
||||
}
|
||||
if setAbout {
|
||||
u.About = about
|
||||
}
|
||||
s.byID[userID] = u
|
||||
return true
|
||||
}
|
||||
|
||||
// SetPremiumUntil 把会员到期时间设为绝对 Unix 秒(0 = 清除会员)。
|
||||
func (s *UserStore) SetPremiumUntil(_ context.Context, userID int64, until int) (domain.User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if until < 0 {
|
||||
until = 0
|
||||
}
|
||||
u.PremiumUntil = until
|
||||
s.byID[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// SetVerified 设置/取消用户认证标记。
|
||||
func (s *UserStore) SetVerified(_ context.Context, userID int64, verified bool) (domain.User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.Verified = verified
|
||||
s.byID[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// SweepExpiredPremium 清空到期会员行并返回清理后的用户(与 postgres 语义一致)。
|
||||
func (s *UserStore) SweepExpiredPremium(_ context.Context, now int64, limit int) ([]domain.User, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domain.User, 0)
|
||||
for id, u := range s.byID {
|
||||
if u.PremiumUntil <= 0 || int64(u.PremiumUntil) > now {
|
||||
continue
|
||||
}
|
||||
u.PremiumUntil = 0
|
||||
s.byID[id] = u
|
||||
out = append(out, u)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除)。
|
||||
func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, documentID int64, until int) (domain.User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if documentID == 0 {
|
||||
until = 0
|
||||
}
|
||||
u.EmojiStatusDocumentID = documentID
|
||||
u.EmojiStatusUntil = until
|
||||
s.byID[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) UpdateColor(_ context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if forProfile {
|
||||
u.ProfileColor = color
|
||||
} else {
|
||||
u.Color = color
|
||||
}
|
||||
s.byID[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) UpdateLastSeen(_ context.Context, userID int64, lastSeenAt int) error {
|
||||
if lastSeenAt <= 0 {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok {
|
||||
return domain.ErrUsernameNotOccupied
|
||||
}
|
||||
if lastSeenAt > u.LastSeenAt {
|
||||
u.LastSeenAt = lastSeenAt
|
||||
s.byID[userID] = u
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func userMatchesSearch(u domain.User, query, phoneQuery string) bool {
|
||||
if phoneQuery != "" && strings.HasPrefix(u.Phone, phoneQuery) {
|
||||
return true
|
||||
}
|
||||
first := strings.ToLower(u.FirstName)
|
||||
last := strings.ToLower(u.LastName)
|
||||
username := strings.ToLower(u.Username)
|
||||
fullName := strings.TrimSpace(first + " " + last)
|
||||
return strings.Contains(first, query) ||
|
||||
strings.Contains(last, query) ||
|
||||
strings.Contains(fullName, query) ||
|
||||
strings.Contains(username, query)
|
||||
}
|
||||
|
||||
func (s *UserStore) Create(_ context.Context, u domain.User) (domain.User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
username := strings.ToLower(strings.TrimSpace(u.Username))
|
||||
if username != "" {
|
||||
for _, existing := range s.byID {
|
||||
if strings.ToLower(existing.Username) == username {
|
||||
return domain.User{}, domain.ErrUsernameOccupied
|
||||
}
|
||||
}
|
||||
}
|
||||
u.ID = s.nextID
|
||||
s.nextID++
|
||||
s.byID[u.ID] = u
|
||||
return u, nil
|
||||
}
|
||||
55
internal/store/memory/users_test.go
Normal file
55
internal/store/memory/users_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestUserStoreUpdateColorRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewUserStore()
|
||||
user, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550003301", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
updated, err := store.UpdateColor(ctx, user.ID, false, domain.PeerColor{
|
||||
HasColor: true,
|
||||
Color: 0,
|
||||
BackgroundEmojiID: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update message color: %v", err)
|
||||
}
|
||||
if !updated.Color.HasColor || updated.Color.Color != 0 || updated.Color.BackgroundEmojiID != 100 {
|
||||
t.Fatalf("message color = %+v, want explicit color=0 bg=100", updated.Color)
|
||||
}
|
||||
if !updated.ProfileColor.Empty() {
|
||||
t.Fatalf("profile color = %+v, want unchanged empty", updated.ProfileColor)
|
||||
}
|
||||
|
||||
updated, err = store.UpdateColor(ctx, user.ID, true, domain.PeerColor{
|
||||
HasColor: true,
|
||||
Color: 3,
|
||||
BackgroundEmojiID: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update profile color: %v", err)
|
||||
}
|
||||
if updated.Color.Color != 0 || updated.Color.BackgroundEmojiID != 100 {
|
||||
t.Fatalf("message color after profile update = %+v, want unchanged", updated.Color)
|
||||
}
|
||||
if !updated.ProfileColor.HasColor || updated.ProfileColor.Color != 3 || updated.ProfileColor.BackgroundEmojiID != 200 {
|
||||
t.Fatalf("profile color = %+v, want color=3 bg=200", updated.ProfileColor)
|
||||
}
|
||||
|
||||
updated, err = store.UpdateColor(ctx, user.ID, true, domain.PeerColor{})
|
||||
if err != nil {
|
||||
t.Fatalf("clear profile color: %v", err)
|
||||
}
|
||||
if !updated.ProfileColor.Empty() {
|
||||
t.Fatalf("profile color after clear = %+v, want empty", updated.ProfileColor)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue