chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View 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
}

View 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.muUserStore 不反向引用 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]
}
}
}

View 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

View 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
}

View 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
}

View 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)
}
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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不保留旧的角色/管理权/Taginviter 重置为本人(自加入)。
// 只有 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
}

View 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)
}

View 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 返回最新置顶消息 idchannelFull.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)
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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])
}
}

View 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)
}
}
}

View 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 >= msgIDpre-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")
}
}
})
}
}

View 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 +
// pollChannelMessageTargetpoll enrich 委托 PollStore.EnrichPollForViewers模板一次。bot 历史过滤
// 在 app 层叠加。Pollskey 存在=已评估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},
}
}

File diff suppressed because it is too large Load diff

View 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++
// 钳到 MaxDialogUnreadCountP1-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++
// 钳到 MaxDialogUnreadCountP1-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
}
}
}

View file

@ -0,0 +1,49 @@
package memory
import (
"testing"
"telesrv/internal/domain"
)
// TestChannelUnreadCountClampedToMax 锁定 P1-v未读 COUNT 钳到 MaxDialogUnreadCountmin(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)
}
}

View 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
}

View 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),
}
}

View file

@ -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()

View 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
}

View 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
}

View 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
}

View 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)
}
}

View 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())
}

View file

@ -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)
}
}

View 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 时清 pinnedTDesktop 在归档/还原时本地无条件 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())
}

View file

@ -0,0 +1,5 @@
// Package memory 提供 store 各接口的内存实现,用作测试替身与本地开发兜底。
//
// 与 store/postgres、store/redisstore 对称store 主包只定义接口与 DTO
// 三种后端实现各自独立成包。
package memory

View 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
}

View 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
})
}

View 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
}

View 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
}

View 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
}

View 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

View 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
}

View 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
}

View 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 {
// 替换 markupnil/空 = 清空键盘);双盒一致。
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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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)
}
}

View 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[:])
}
}

View 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
}

View 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
}

View 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 权威(投票校验与读路径 enrichmentnil 时 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
}

View file

@ -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")
}
}

View 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)
}
}

View 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)
}
}
// 多 reactorTopReactors 按星数降序,本人始终在列。
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)
}
}

View 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
}

View 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
}

View 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 的 pollfan-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
}

View 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 = MaxMessageBoxIDint32 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 {
// 非 forceorder 之外的既有置顶保持原相对顺序排在后面。
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
}

View 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
}

View 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
}
}

View 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,
})
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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
}

View 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)
}
}

View 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
}

View 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/aboutsetBotInfo 的 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 statusdocumentID=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
}

View 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)
}
}