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

@ -17,3 +17,69 @@ type AccountReactionSettingsStore interface {
GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, bool, error)
SaveReactionSettings(ctx context.Context, userID int64, settings domain.AccountReactionSettings) error
}
// AccountSettingsStore persists account-level singleton settings (global privacy,
// account TTL, sensitive-content toggle, contact-signup notification).
type AccountSettingsStore interface {
GetAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, bool, error)
SaveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) error
}
// NotifySettingsStore persists per-scope notification settings (specific peer /
// forum topic / the three category defaults: users, chats, broadcasts).
type NotifySettingsStore interface {
GetNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope) (domain.PeerNotifySettings, bool, error)
SaveNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope, settings domain.PeerNotifySettings) error
ResetNotifySettings(ctx context.Context, ownerUserID int64) error
// GetPeerNotifySettings batch-loads whole-peer (topic 0) settings for the dialog list.
GetPeerNotifySettings(ctx context.Context, ownerUserID int64, peers []domain.Peer) (map[domain.Peer]domain.PeerNotifySettings, error)
// AllPeerNotifySettings loads every whole-peer (topic 0) setting for one owner in a single
// owner-scoped indexed query, for the per-user notify cache (dialog list + Full overlays).
AllPeerNotifySettings(ctx context.Context, ownerUserID int64) (map[domain.Peer]domain.PeerNotifySettings, error)
// ListNotifyExceptions lists all per-peer non-default settings (account.getNotifyExceptions).
ListNotifyExceptions(ctx context.Context, ownerUserID int64) ([]domain.NotifyException, error)
}
// StickerCollectionStore persists per-user personal sticker/GIF collections
// (faved / recent / recent-attached stickers, saved GIFs).
type StickerCollectionStore interface {
// SaveStickerCollectionItem adds (move-to-front, capped at max) or removes a document.
SaveStickerCollectionItem(ctx context.Context, userID int64, kind domain.StickerCollectionKind, documentID int64, unsave bool, now, max int) error
ListStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind, limit int) ([]domain.StickerCollectionItem, error)
ClearStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind) error
}
// SavedMusicStore persists one account's ordered profile music list.
type SavedMusicStore interface {
SaveMusic(ctx context.Context, req domain.SaveMusicRequest) error
ListSavedMusicIDs(ctx context.Context, userID int64, limit int) ([]int64, error)
ListSavedMusic(ctx context.Context, userID int64, offset, limit int) (domain.SavedMusicList, error)
GetSavedMusicByIDs(ctx context.Context, userID int64, ids []int64) (domain.SavedMusicList, error)
}
// BusinessAutomationStore persists account-local Telegram Business settings.
type BusinessAutomationStore interface {
GetBusinessProfile(ctx context.Context, userID int64) (domain.BusinessProfile, bool, error)
SaveBusinessProfile(ctx context.Context, profile domain.BusinessProfile) error
ListBusinessChatLinks(ctx context.Context, ownerUserID int64) ([]domain.BusinessChatLink, error)
CreateBusinessChatLink(ctx context.Context, link domain.BusinessChatLink) (domain.BusinessChatLink, error)
UpdateBusinessChatLink(ctx context.Context, ownerUserID int64, slug string, input domain.BusinessChatLinkInput) (domain.BusinessChatLink, error)
DeleteBusinessChatLink(ctx context.Context, ownerUserID int64, slug string) (bool, error)
ResolveBusinessChatLink(ctx context.Context, slug string, bumpViews bool) (domain.BusinessChatLink, bool, error)
ListQuickReplies(ctx context.Context, ownerUserID int64, includeTopMessages bool) (domain.QuickReplyList, error)
CheckQuickReplyShortcut(ctx context.Context, ownerUserID int64, shortcut string) (bool, error)
SaveQuickReplyText(ctx context.Context, ownerUserID int64, shortcut string, msg domain.QuickReplyMessage) (domain.QuickReplyMutation, error)
GetQuickReplyMessages(ctx context.Context, ownerUserID int64, shortcutID int, ids []int) (domain.QuickReplyMessages, error)
RenameQuickReplyShortcut(ctx context.Context, ownerUserID int64, shortcutID int, shortcut string) (domain.QuickReplyMutation, error)
ReorderQuickReplies(ctx context.Context, ownerUserID int64, order []int) (domain.QuickReplyMutation, error)
DeleteQuickReplyShortcut(ctx context.Context, ownerUserID int64, shortcutID int) (domain.QuickReplyMutation, error)
DeleteQuickReplyMessages(ctx context.Context, ownerUserID int64, shortcutID int, ids []int) (domain.QuickReplyMutation, error)
ReserveBusinessAutomationDelivery(ctx context.Context, delivery domain.BusinessAutomationDelivery) (bool, error)
LastBusinessAutomationDelivery(ctx context.Context, ownerUserID, peerUserID int64, kind domain.BusinessAutomationKind) (domain.BusinessAutomationDelivery, bool, error)
GetConnectedBusinessBot(ctx context.Context, ownerUserID int64) (domain.ConnectedBusinessBot, bool, error)
SaveConnectedBusinessBot(ctx context.Context, bot domain.ConnectedBusinessBot) (domain.ConnectedBusinessBot, error)
DeleteConnectedBusinessBot(ctx context.Context, ownerUserID, botUserID int64) (bool, error)
SetConnectedBusinessBotPaused(ctx context.Context, ownerUserID, peerUserID int64, paused bool) (domain.ConnectedBusinessBotPeerState, error)
DisableConnectedBusinessBotForPeer(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, error)
GetConnectedBusinessBotPeerState(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, bool, error)
}

View file

@ -2,18 +2,6 @@ package store
import "context"
// PtsAllocator 分配用户级 pts。实现必须保证同一 user 内单调递增。
type PtsAllocator interface {
NextPts(ctx context.Context, userID int64) (int, error)
CurrentPts(ctx context.Context, userID int64) (int, error)
}
// PtsRangeAllocator 可一次性分配一段连续 pts,返回该段最终 pts。
// 批量事件(例如 updateDeleteMessages)必须用 pts_count 推进多步时使用它。
type PtsRangeAllocator interface {
NextPtsN(ctx context.Context, userID int64, count int) (int, error)
}
// BoxIDAllocator 分配用户视角 message box id。box_id 允许空洞,但不能回退。
type BoxIDAllocator interface {
NextBoxID(ctx context.Context, userID int64) (int, error)

View file

@ -19,4 +19,7 @@ type AuthKeyStore interface {
Save(ctx context.Context, k AuthKeyData) error
// Get 按 auth_key_id 查询;不存在时 found=false。
Get(ctx context.Context, id [8]byte) (data AuthKeyData, found bool, err error)
// Delete 删除一条 auth key 记录(destroy_auth_key)。不存在时静默成功。
// 连接层每帧按 auth_key_id 回查本接口,删除后该 key 的入站帧立即失效。
Delete(ctx context.Context, id [8]byte) error
}

View file

@ -14,4 +14,6 @@ type AuthorizationStore interface {
Delete(ctx context.Context, authKeyID [8]byte) error
DeleteByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error)
DeleteByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
// MarkPasswordPassed 清除 auth_key 的 password_pending 标记,使其转为完全授权(两步验证通过后调用)。
MarkPasswordPassed(ctx context.Context, authKeyID [8]byte) error
}

65
internal/store/bot.go Normal file
View file

@ -0,0 +1,65 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// BotStore 持久化 bot 账号元数据(bots 表)与内置 bot 对话状态(bot_chat_states 表)。
//
// CreateBotAccount 必须原子地创建 users 行(is_bot=true, bot_info_version=1, phone 空)
// 与 bots 行;username 冲突返回 domain.ErrUsernameOccupied。
type BotStore interface {
CreateBotAccount(ctx context.Context, user domain.User, profile domain.BotProfile) (domain.User, domain.BotProfile, error)
GetBot(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error)
ListBotsByOwner(ctx context.Context, ownerUserID int64) ([]domain.BotProfile, error)
CountBotsByOwner(ctx context.Context, ownerUserID int64) (int, error)
// UpdateBotTokenSecret 写入新 token 随机段;旧 token 立即失效(无缓存层)。
UpdateBotTokenSecret(ctx context.Context, botUserID int64, secret string) error
// 以下 P2 元数据写入:每个都在单事务内同时 bump 该 bot 对应 users 行的
// bot_info_version(客户端据此感知变更并重拉 getFullUser),返回 bump 后的新
// version。UpdateBotInfo 跨 users(first_name/about)与 bots(description)两表,
// 因此放在同一原子方法里。
UpdateBotCommands(ctx context.Context, botUserID int64, commands []domain.BotCommand) (newVersion int, err error)
UpdateBotInfo(ctx context.Context, botUserID int64, upd domain.BotInfoUpdate) (newVersion int, err error)
UpdateBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (newVersion int, err error)
SetBotInlinePlaceholder(ctx context.Context, botUserID int64, placeholder string) (newVersion int, err error)
SetBotInlineGeo(ctx context.Context, botUserID int64, inlineGeo bool) (newVersion int, err error)
SetBotNochats(ctx context.Context, botUserID int64, nochats bool) (newVersion int, err error)
SetBotChatHistory(ctx context.Context, botUserID int64, chatHistory bool) (newVersion int, err error)
CanBotSendMessage(ctx context.Context, botUserID, userID int64) (bool, error)
AllowBotSendMessage(ctx context.Context, botUserID, userID int64, fromRequest bool) (created bool, err error)
UpsertBotApp(ctx context.Context, app domain.BotApp) (domain.BotApp, int, error)
GetBotAppByID(ctx context.Context, appID, accessHash int64) (domain.BotApp, bool, error)
GetBotAppByShortName(ctx context.Context, botUserID int64, shortName string) (domain.BotApp, bool, error)
GetMainBotApp(ctx context.Context, botUserID int64) (domain.BotApp, bool, error)
ListBotApps(ctx context.Context, botUserID int64) ([]domain.BotApp, error)
GetBotAppSettings(ctx context.Context, botUserID int64) (domain.BotAppSettings, bool, error)
UpsertBotAppSettings(ctx context.Context, botUserID int64, settings domain.BotAppSettings) (int, error)
ListBotAppPreviewMedia(ctx context.Context, botUserID, appID int64) ([]domain.BotAppPreviewMedia, error)
UpsertBotAppPreviewMedia(ctx context.Context, media domain.BotAppPreviewMedia) (domain.BotAppPreviewMedia, int, error)
DeleteBotAppPreviewMedia(ctx context.Context, botUserID, appID, mediaID int64) (int, error)
ReorderBotAppPreviewMedia(ctx context.Context, botUserID, appID int64, mediaIDs []int64) (int, error)
UpsertAttachMenuBot(ctx context.Context, bot domain.BotAttachMenuBot) (int, error)
GetAttachMenuBot(ctx context.Context, botUserID int64) (domain.BotAttachMenuBot, bool, error)
ListAttachMenuBots(ctx context.Context) ([]domain.BotAttachMenuBot, error)
GetAttachMenuState(ctx context.Context, userID, botUserID int64) (domain.BotAttachMenuState, bool, error)
SetAttachMenuState(ctx context.Context, state domain.BotAttachMenuState) (domain.BotAttachMenuState, error)
SaveRequestedWebViewButton(ctx context.Context, button domain.BotRequestedWebViewButton) error
GetRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) (domain.BotRequestedWebViewButton, bool, error)
DeleteRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) error
SetBotEmojiStatusPermission(ctx context.Context, botUserID, userID int64, allowed bool) error
BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error)
PutWebViewCustomMethodQuery(ctx context.Context, query domain.BotWebViewCustomMethodQuery) error
GetBotChatState(ctx context.Context, botUserID, userID int64) (domain.BotChatState, bool, error)
UpsertBotChatState(ctx context.Context, state domain.BotChatState) error
DeleteBotChatState(ctx context.Context, botUserID, userID int64) error
}

View file

@ -10,22 +10,32 @@ import (
type ChannelStore interface {
CreateChannel(ctx context.Context, req domain.CreateChannelRequest) (domain.CreateChannelResult, error)
GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error)
// ResolveChannel 是 GetChannel 的轻量版:只做访问校验并返回 Channel(含 access_hash)+Self,
// 跳过 dialog top/读态/boost 这 3 条额外查询。供只需 access_hash/频道标志的纯解析路径用。
ResolveChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error)
GetChannels(ctx context.Context, viewerUserID int64, channelIDs []int64) ([]domain.ChannelView, error)
GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error)
SaveChannelDefaultSendAs(ctx context.Context, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error)
GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error)
GetParticipant(ctx context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error)
FutureCreatorAfterLeave(ctx context.Context, channelID, userID int64) (domain.ChannelMember, error)
InviteToChannel(ctx context.Context, channelID, inviterUserID int64, userIDs []int64, date int) (domain.CreateChannelResult, error)
JoinChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error)
LeaveChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error)
EditChannelTitle(ctx context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error)
SetChannelWallpaper(ctx context.Context, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error)
EditChannelAbout(ctx context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error)
EditChannelAdmin(ctx context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error)
EditChannelMemberRank(ctx context.Context, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error)
EditChannelBanned(ctx context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error)
EditChannelDefaultBannedRights(ctx context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error)
DeleteChannel(ctx context.Context, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error)
CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error)
UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error)
SetChannelVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error)
ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ListSendAsChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error)
SearchPublicChannels(ctx context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error)
SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
@ -35,9 +45,10 @@ type ChannelStore interface {
SetForum(ctx context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error)
SetAutotranslation(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetRestrictedSponsored(ctx context.Context, userID, channelID int64, restricted bool) (domain.Channel, error)
SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error)
SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.ChannelPaidMessagesPriceResult, error)
SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetSlowMode(ctx context.Context, userID, channelID int64, seconds int) (domain.Channel, error)
SetBoostsToUnblockRestrictions(ctx context.Context, userID, channelID int64, boosts int) (domain.Channel, error)
SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetJoinRequest(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
@ -47,13 +58,21 @@ type ChannelStore interface {
ListAdminLog(ctx context.Context, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error)
GetChannelMessageViews(ctx context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error)
SetChannelMessageReactions(ctx context.Context, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error)
AddChannelMessagePaidReaction(ctx context.Context, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error)
GetChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error)
VoteChannelMessagePoll(ctx context.Context, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error)
CloseChannelMessagePoll(ctx context.Context, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error)
ListChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error)
ListTopMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
ListRecentMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
ClearRecentMessageReactions(ctx context.Context, userID int64) error
ListSavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error)
UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error
GetPremiumBoostStatus(ctx context.Context, viewerUserID, channelID int64, now int) (domain.PremiumBoostStatus, error)
ListPremiumBoosts(ctx context.Context, viewerUserID, channelID int64, gifts bool, offset string, limit, now int) (domain.PremiumBoostList, error)
GetPremiumMyBoosts(ctx context.Context, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error)
ApplyPremiumBoost(ctx context.Context, userID, channelID int64, slots []int, now, premiumUntil int) (domain.PremiumMyBoosts, error)
GetPremiumUserBoosts(ctx context.Context, viewerUserID, channelID, targetUserID int64, now int) (domain.PremiumBoostList, error)
CreateForumTopic(ctx context.Context, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error)
EditForumTopic(ctx context.Context, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error)
UpdatePinnedForumTopic(ctx context.Context, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error)
@ -62,12 +81,21 @@ type ChannelStore interface {
ListForumTopics(ctx context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error)
GetForumTopicsByID(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error)
SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error)
// SendMonoforumMessage 向频道私信(monoforum)发消息,按 saved_peer 分订阅者子会话;不要求成员身份。
SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error)
EditChannelMessage(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error)
DeleteChannelMessages(ctx context.Context, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error)
DeleteChannelHistory(ctx context.Context, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error)
DeleteChannelParticipantHistory(ctx context.Context, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error)
UpdatePinnedMessage(ctx context.Context, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error)
UnpinAllChannelMessages(ctx context.Context, req domain.UnpinAllChannelMessagesRequest) (domain.UpdateChannelPinnedMessageResult, error)
ClearDanglingPinnedMessage(ctx context.Context, channelID int64, messageID int) error
ExportInvite(ctx context.Context, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error)
// EnsurePermanentInvite 幂等返回 (channel, admin) 当前未撤销的永久邀请,缺失则创建。
// 官方语义:每个有邀请权限的管理员都持有自己的主链接(DrKLO 建频道后
// getExportedChatInvites(limit=1) 直接取 invites[0],空列表即客户端闪退)。
// date==0 时由实现取当前时间。
EnsurePermanentInvite(ctx context.Context, channelID, adminUserID int64, date int) (domain.ChannelInvite, error)
CheckInvite(ctx context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error)
ImportInvite(ctx context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error)
ListExportedInvites(ctx context.Context, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error)
@ -88,16 +116,36 @@ type ChannelStore interface {
ListChannelRecommendations(ctx context.Context, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error)
ListDiscussionGroups(ctx context.Context, userID int64, limit int) ([]domain.Channel, error)
SetDiscussionGroup(ctx context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error)
SetChannelDialogPinned(ctx context.Context, userID, channelID int64, pinned bool) (bool, error)
ReorderChannelPinnedDialogs(ctx context.Context, userID int64, order []domain.Peer, force bool) error
// SetChannelDialogPinned 置顶/取消置顶频道会话;order 在会话当前 folder
// 内分配,返回 (changed, 该会话所在 folder_id)。
SetChannelDialogPinned(ctx context.Context, userID, channelID int64, pinned bool) (bool, int, error)
// ReorderChannelPinnedDialogs 重排指定 folder 内的频道置顶;force 只清除
// 该 folder 内不在 order 中的置顶。
ReorderChannelPinnedDialogs(ctx context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error)
SetChannelDialogUnreadMark(ctx context.Context, userID, channelID int64, unread bool) (bool, error)
SetChannelViewForumAsMessages(ctx context.Context, userID, channelID int64, enabled bool) (bool, error)
ListChannelUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error)
EditChannelPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error
// CountChannelArchiveUnread 统计归档中有未读(含手动标记)的频道会话数
// 与未读消息总数,供主列表 dialogFolder 条目聚合。
CountChannelArchiveUnread(ctx context.Context, userID int64) (peers int, messages int, err error)
ListChannelHistory(ctx context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error)
// ListMonoforumHistory 拉取某订阅者在频道私信(monoforum)内的历史。
ListMonoforumHistory(ctx context.Context, filter domain.MonoforumHistoryFilter) (domain.ChannelHistory, error)
// ListMonoforumDialogs 列出 monoforum 的订阅者子会话(管理员视角的私信列表)。
ListMonoforumDialogs(ctx context.Context, filter domain.MonoforumDialogsFilter) (domain.MonoforumDialogList, error)
// ResolveMonoforumSend 按 id 取 monoforum 频道(不要求成员身份)并返回调用者是否为其母频道管理员。
ResolveMonoforumSend(ctx context.Context, viewerUserID, monoforumID int64) (domain.Channel, bool, error)
SearchPublicPosts(ctx context.Context, viewerUserID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error)
SearchJoinedMessages(ctx context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error)
GetChannelMessages(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelHistory, error)
// SearchChannelMedia 返回某频道中属于给定媒体类别的消息(共享媒体标签页),newest-first 分页。
SearchChannelMedia(ctx context.Context, viewerUserID, channelID int64, req domain.MediaSearchRequest) (domain.ChannelHistory, error)
// CountChannelMediaCategories 返回某频道对当前 viewer 可见消息按基础媒体类别聚合的精确计数。
CountChannelMediaCategories(ctx context.Context, viewerUserID, channelID int64) (domain.MediaCategoryCounts, error)
ChannelPollFanoutViews(ctx context.Context, channelID int64, msgID int, viewers []int64, now int) (domain.ChannelPollFanoutViews, error)
ListStoryMessageForwards(ctx context.Context, req domain.StoryMessageForwardListRequest) (domain.StoryMessageForwardList, error)
GetChannelMessageForInlineBot(ctx context.Context, botID, channelID int64, id int) (domain.Channel, domain.ChannelMessage, bool, error)
ReadChannelMessageContents(ctx context.Context, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error)
ListChannelReplies(ctx context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error)
ListChannelUnreadMentions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error)
@ -106,15 +154,27 @@ type ChannelStore interface {
ReadChannelReactions(ctx context.Context, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error)
GetDiscussionMessage(ctx context.Context, viewerUserID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error)
ReadChannelHistory(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error)
// ReadChannelTopicHistory 推进 forum 单话题的 per-viewer 已读水位(不碰频道级),返回需推进 outbox 的发送者。
ReadChannelTopicHistory(ctx context.Context, req domain.ReadChannelTopicHistoryRequest) (domain.ReadChannelTopicHistoryResult, error)
// GeneralForumTopic 现算 forum General 话题(id=1)对 viewer 的状态(per-topic 水位,不被普通话题串扰)。
GeneralForumTopic(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelForumTopic, error)
ListMessageReadParticipants(ctx context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error)
ListChannelDifference(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error)
ListActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error)
ListDirtyActiveChannelsForUser(ctx context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error)
ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error)
ListActiveChannelMembers(ctx context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error)
ListChannelInviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error)
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
MaxChannelPts(ctx context.Context, channelID int64) (int, error)
MaxChannelMessageID(ctx context.Context, channelID int64) (int, error)
// SetActiveCall 写入/清除(callID=0)channel 行上的活跃群通话关联
//(channel.call_active/call_not_empty flag 与 channelFull.call 的数据源)。
SetActiveCall(ctx context.Context, channelID, callID, callAccessHash int64, notEmpty bool) (domain.Channel, error)
// AppendCallServiceMessage 生成群通话服务消息(started/ended/invite,带频道
// pts),Recipients 为活跃成员(rpc 据此扇出 updateNewChannelMessage)。
AppendCallServiceMessage(ctx context.Context, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error)
// AppendStarGiftAdminLog 记录频道 Star gift 的 Recent Actions 快照,不插入频道历史、不推进 pts。
AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error
}
// ChannelIDAllocator allocates channel IDs.
@ -123,17 +183,6 @@ type ChannelIDAllocator interface {
CurrentChannelID(ctx context.Context) (int64, error)
}
// ChannelPtsAllocator allocates channel-scoped pts.
type ChannelPtsAllocator interface {
NextChannelPts(ctx context.Context, channelID int64) (int, error)
CurrentChannelPts(ctx context.Context, channelID int64) (int, error)
}
// ChannelPtsRangeAllocator allocates a range of channel pts and returns the final pts.
type ChannelPtsRangeAllocator interface {
NextChannelPtsN(ctx context.Context, channelID int64, count int) (int, error)
}
// ChannelMessageIDAllocator allocates channel-scoped message IDs.
type ChannelMessageIDAllocator interface {
NextChannelMessageID(ctx context.Context, channelID int64) (int, error)

View file

@ -15,6 +15,7 @@ type ContactStore interface {
Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error)
UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error)
UpdateNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error)
SetCloseFriends(ctx context.Context, userID int64, contactUserIDs []int64) (domain.CloseFriendsEditResult, error)
SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error)
PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error)
Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error)

View file

@ -11,15 +11,24 @@ type DialogStore interface {
ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error)
ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error)
Upsert(ctx context.Context, userID int64, dialog domain.Dialog) error
// UpsertInbox records a newly received private message in a dialog without
// overwriting existing read watermarks or pinned/folder metadata.
UpsertInbox(ctx context.Context, userID int64, dialog domain.Dialog) error
SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error
GetDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (domain.DialogDraft, bool, error)
DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error)
ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error)
ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error)
MarkRead(ctx context.Context, userID int64, peer domain.Peer, maxID int) (domain.ReadHistoryResult, error)
SetPinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error)
ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error
// SetPinned 置顶/取消置顶一条会话;order 在会话当前 folder 内分配,
// 返回 (changed, 该会话所在 folder_id)。
SetPinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, int, error)
// ReorderPinned 重排指定 folder(0 主列表/1 归档)内的置顶顺序;
// force 只清除该 folder 内不在 order 中的置顶。
ReorderPinned(ctx context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error)
SetUnreadMark(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error)
ListUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error)
SetChatTheme(ctx context.Context, userID int64, peer domain.Peer, emoticon string) (bool, error)
SetPeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error)
PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error)
ListFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error)
@ -29,4 +38,12 @@ type DialogStore interface {
ReorderFolders(ctx context.Context, userID int64, order []int) error
SetFolderTagsEnabled(ctx context.Context, userID int64, enabled bool) error
EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error
// CountArchiveUnread 统计归档(folder_id=1)中有未读(含手动标记)的
// 会话数与未读消息总数,供主列表 dialogFolder 条目聚合。
CountArchiveUnread(ctx context.Context, userID int64) (peers int, messages int, err error)
// SetArchivePinned 设置 archive folder 行本身的置顶状态
// (toggleDialogPin(inputDialogPeerFolder)),返回是否发生变化。
SetArchivePinned(ctx context.Context, userID int64, pinned bool) (bool, error)
// ArchivePinned 返回 archive folder 行置顶状态;从未设置过时官方默认 true。
ArchivePinned(ctx context.Context, userID int64) (bool, error)
}

View file

@ -0,0 +1,48 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// GroupCallStore 持久化群通话信令真值(memory/postgres 双实现,行为契约由
// 共享 contract test 钉死;version 单调性见 domain/groupcall.go 注释)。
type GroupCallStore interface {
// CreateGroupCall 建会;同频道已有活跃通话返回 domain.ErrGroupCallAlreadyStarted。
CreateGroupCall(ctx context.Context, call domain.GroupCall) (domain.GroupCall, error)
GetGroupCall(ctx context.Context, callID int64) (domain.GroupCall, bool, error)
// JoinGroupCall 加入/重进(同主键 upsert 换新 ssrc);ssrc 与他人撞活跃唯一
// 约束返回 domain.ErrGroupCallSSRCDuplicate;version++。
JoinGroupCall(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error)
// LeaveGroupCall 置 left+version++;未在会返回 domain.ErrGroupCallNotJoined。
LeaveGroupCall(ctx context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error)
// DiscardGroupCall 终结通话并清空参与者,返回终态 call 与此前活跃的参与者。
DiscardGroupCall(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error)
// TouchParticipant 刷新 checkGroupCall 保活水位,返回该用户当前活跃 ssrc 集合
//(joined=false 表示未在会,客户端据空集自动 rejoin)。
TouchParticipant(ctx context.Context, callID, userID int64, now int) (activeSSRCs []int64, joined bool, err error)
GetParticipant(ctx context.Context, callID, userID int64) (domain.GroupCallParticipant, bool, error)
// ListParticipants 按 (join_date, user_id) 游标分页;offset 为上次返回的
// NextOffset(空=从头)。响应携带当前 version(客户端跳号全量 reload 依赖)。
ListParticipants(ctx context.Context, callID int64, offset string, limit int) (domain.GroupCallParticipantPage, error)
// UpdateParticipant 应用字段级更新;changed=false 表示无有效变化(version 不动)。
UpdateParticipant(ctx context.Context, callID, userID int64, update domain.GroupCallParticipantUpdate) (domain.GroupCallMutation, bool, error)
// SetGroupCallTitle / SetGroupCallJoinMuted 只动 call 行,不动参与者 version。
SetGroupCallTitle(ctx context.Context, callID int64, title string) (domain.GroupCall, bool, error)
SetGroupCallJoinMuted(ctx context.Context, callID int64, joinMuted bool) (domain.GroupCall, bool, error)
SetStartedMessageID(ctx context.Context, callID int64, msgID int) error
// SweepStaleParticipants 清理保活水位早于 checkOlderThan 的活跃参与者
//(每清一人 version++)。注意调用方必须叠加 SFU 媒体面活性做双过期判定。
SweepStaleParticipants(ctx context.Context, checkOlderThan, now int, limit int) ([]domain.GroupCallMutation, error)
// ResetAllParticipants 服务端重启恢复:把全部活跃通话的参与者批量置 left
//(每通话 version++),返回受影响的通话。
ResetAllParticipants(ctx context.Context, now int) ([]domain.GroupCall, error)
// NextRaiseHandRating 分配全局单调递增的举手序号(举手排序用)。
NextRaiseHandRating(ctx context.Context, callID int64) (int64, error)
// SetParticipantOverride 写入 setter 对 target 的 per-viewer 覆盖(本地静音/音量),
// 仅影响 setter 自己的视图。clear=true 删除覆盖。
SetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error
// GetParticipantOverride 取某 setter 对某 target 的覆盖。
GetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error)
}

View file

@ -0,0 +1,116 @@
package store
import (
"context"
"time"
"telesrv/internal/domain"
)
type InlineCacheKey struct {
BotUserID int64
UserID int64
Peer domain.Peer
Query string
Offset string
HasGeo bool
GeoLat float64
GeoLong float64
GeoAccuracy int
}
type InlinePending struct {
QueryID int64
BotUserID int64
UserID int64
Peer domain.Peer
CacheKey InlineCacheKey
CreatedAt time.Time
}
type InlineWebDocumentKey struct {
URL string
AccessHash int64
}
type InlineWebDocumentEntry struct {
Document domain.BotInlineWebDocument
Bytes []byte
MimeType string
}
type PreparedInlineMessage struct {
ID string
BotUserID int64
UserID int64
Results domain.BotInlineResults
ExpiresAt time.Time
}
type WebViewSession struct {
QueryID int64
BotQueryID string
BotUserID int64
UserID int64
Peer domain.Peer
AppID int64
Source string
StartParam string
WriteAllowed bool
Silent bool
ReplyTo *domain.MessageReply
SendAs *domain.Peer
CreatedAt time.Time
ExpiresAt time.Time
}
const (
InlineQueryPeerTypeSameBotPM = "same_bot_pm"
InlineQueryPeerTypePM = "pm"
InlineQueryPeerTypeChat = "chat"
InlineQueryPeerTypeMegagroup = "megagroup"
InlineQueryPeerTypeBroadcast = "broadcast"
InlineQueryPeerTypeBotPM = "bot_pm"
)
type BotInlineQueryPush struct {
SourceID string
QueryID int64
BotUserID int64
UserID int64
Query string
Offset string
PeerType string
Geo *domain.MessageGeoPoint
Date int
}
type InlineRegistryStore interface {
PutInlinePending(ctx context.Context, pending InlinePending, ttl time.Duration) error
GetInlinePending(ctx context.Context, queryID int64) (InlinePending, bool, error)
DeleteInlinePending(ctx context.Context, queryID int64) error
PutInlineResult(ctx context.Context, results domain.BotInlineResults, ttl time.Duration) error
GetInlineResult(ctx context.Context, queryID int64) (domain.BotInlineResults, bool, error)
DeleteInlineResult(ctx context.Context, queryID int64) error
PutInlineCache(ctx context.Context, key InlineCacheKey, results domain.BotInlineResults, ttl time.Duration) error
GetInlineCache(ctx context.Context, key InlineCacheKey) (domain.BotInlineResults, bool, time.Duration, error)
PutInlineWebDocument(ctx context.Context, document domain.BotInlineWebDocument, ttl time.Duration) error
GetInlineWebDocument(ctx context.Context, key InlineWebDocumentKey) (InlineWebDocumentEntry, bool, error)
PutInlineWebDocumentBytes(ctx context.Context, key InlineWebDocumentKey, data []byte, mimeType string, ttl time.Duration) error
PutPreparedInlineMessage(ctx context.Context, msg PreparedInlineMessage, ttl time.Duration) error
GetPreparedInlineMessage(ctx context.Context, id string) (PreparedInlineMessage, bool, error)
PutWebViewSession(ctx context.Context, session WebViewSession, ttl time.Duration) error
GetWebViewSession(ctx context.Context, queryID int64) (WebViewSession, bool, error)
GetWebViewSessionByBotQuery(ctx context.Context, botQueryID string) (WebViewSession, bool, error)
DeleteWebViewSession(ctx context.Context, queryID int64, botQueryID string) error
}
type BotInlineQueryPushBroker interface {
PublishBotInlineQuery(ctx context.Context, event BotInlineQueryPush) error
SubscribeBotInlineQueries(ctx context.Context, handle func(context.Context, BotInlineQueryPush)) error
}

View file

@ -2,6 +2,7 @@ package store
import (
"context"
"time"
"telesrv/internal/domain"
)
@ -12,12 +13,23 @@ import (
type MediaStore interface {
// 上传分片(transient:组装成 blob 后即清理)。
SaveFilePart(ctx context.Context, part domain.UploadPart) error
UploadPartUsage(ctx context.Context, ownerUserID int64) (domain.UploadPartUsage, error)
UploadPartSlot(ctx context.Context, ownerUserID, fileID int64, part int) (domain.UploadPartSlot, error)
LoadFileParts(ctx context.Context, ownerUserID, fileID int64) ([]domain.UploadPart, error)
DeleteFileParts(ctx context.Context, ownerUserID, fileID int64) error
DeleteFileParts(ctx context.Context, ownerUserID, fileID int64) ([]string, error)
DeleteExpiredUploadParts(ctx context.Context, before time.Time, limit int) ([]string, error)
// blob 索引。
PutFileBlob(ctx context.Context, blob domain.FileBlob) error
GetFileBlob(ctx context.Context, locationKey string) (domain.FileBlob, bool, error)
// GetFileBlobs 批量按 location_key 取 FileBlob 元数据(缺失的 key 不出现在返回 map 中)。
// 供启动预热等需一次性加载大量 blob 的路径,替代逐个 GetFileBlob 的 N+1 往返。
GetFileBlobs(ctx context.Context, locationKeys []string) (map[string]domain.FileBlob, error)
// seed 状态。只记录静态资源 catalog 的内容 hash,用于启动时跳过未变化的重复导入;
// 真实可服务性仍由 documents/file_blobs 校验保证,不能只相信这里的 hash。
GetSeedState(ctx context.Context, key string) (hash string, found bool, err error)
PutSeedState(ctx context.Context, key, hash string) error
// 文档 / 照片注册表。
PutDocument(ctx context.Context, doc domain.Document) error
@ -26,6 +38,10 @@ type MediaStore interface {
PutPhoto(ctx context.Context, photo domain.Photo) error
GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error)
// 链接预览解析缓存(web_pages)。按规范化 URL 哈希去重,snapshot 为完整快照。
PutWebPage(ctx context.Context, urlHash int64, page domain.MessageWebPage, now int) error
GetWebPageByURLHash(ctx context.Context, urlHash int64) (page domain.MessageWebPage, refreshedAt int, found bool, err error)
// 贴纸集 / 可用 reaction。
PutStickerSet(ctx context.Context, set domain.StickerSet) error
GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error)
@ -38,14 +54,12 @@ type MediaStore interface {
CountAvailableReactions(ctx context.Context) (int, error)
// 头像历史(owner = user/channel;current = active 中 sort_order 最大者)。
AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error
AddProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) error
CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error)
CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, bool, error)
CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error)
CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error)
ListProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) (ids []int64, total int, err error)
ListProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) (ids []int64, total int, err error)
ListProfilePhotoDetailsKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) (photos []domain.Photo, total int, err error)
DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error)
DeleteProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) ([]int64, error)
}

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.mu(UserStore 不反向引用 BotStore,无死锁)。
s.mu.Lock()
defer s.mu.Unlock()
owned := 0
for _, p := range s.byID {
if p.OwnerUserID == profile.OwnerUserID && p.BotUserID != p.OwnerUserID {
owned++
}
}
if owned >= domain.MaxBotsPerOwner {
return domain.User{}, domain.BotProfile{}, domain.ErrBotsTooMany
}
created, err := s.users.Create(ctx, user)
if err != nil {
return domain.User{}, domain.BotProfile{}, err
}
profile.BotUserID = created.ID
s.byID[created.ID] = cloneBotProfile(profile)
return created, profile, nil
}
func (s *BotStore) GetBot(_ context.Context, botUserID int64) (domain.BotProfile, bool, error) {
s.mu.RLock()
p, ok := s.byID[botUserID]
if ok {
p = s.enrichBotProfileLocked(p)
}
s.mu.RUnlock()
if !ok {
return domain.BotProfile{}, false, nil
}
return cloneBotProfile(p), true, nil
}
func (s *BotStore) GetBots(_ context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) {
if len(botUserIDs) == 0 {
return nil, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
out := make(map[int64]domain.BotProfile, len(botUserIDs))
for _, id := range botUserIDs {
if id == 0 {
continue
}
if _, ok := out[id]; ok {
continue
}
if p, ok := s.byID[id]; ok {
out[id] = cloneBotProfile(s.enrichBotProfileLocked(p))
}
}
return out, nil
}
func (s *BotStore) ListBotsByOwner(_ context.Context, ownerUserID int64) ([]domain.BotProfile, error) {
if ownerUserID == 0 {
return nil, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]domain.BotProfile, 0)
for _, p := range s.byID {
if p.OwnerUserID == ownerUserID && p.BotUserID != p.OwnerUserID {
out = append(out, cloneBotProfile(s.enrichBotProfileLocked(p)))
}
}
sortBotProfiles(out)
return out, nil
}
func (s *BotStore) CountBotsByOwner(_ context.Context, ownerUserID int64) (int, error) {
if ownerUserID == 0 {
return 0, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
n := 0
for _, p := range s.byID {
if p.OwnerUserID == ownerUserID && p.BotUserID != p.OwnerUserID {
n++
}
}
return n, nil
}
func (s *BotStore) UpdateBotTokenSecret(_ context.Context, botUserID int64, secret string) error {
s.mu.Lock()
defer s.mu.Unlock()
p, ok := s.byID[botUserID]
if !ok {
return domain.ErrBotNotFound
}
p.TokenSecret = secret
s.byID[botUserID] = p
return nil
}
// editProfile 在 BotStore.mu 内改 bots 行字段,随后 bump users 的 bot_info_version。
// 与 postgres 不同(非单事务),但 memory 仅测试替身、元数据更新低频,可接受。
func (s *BotStore) editProfile(botUserID int64, fn func(p *domain.BotProfile)) (int, error) {
s.mu.Lock()
p, ok := s.byID[botUserID]
if !ok {
s.mu.Unlock()
return 0, domain.ErrBotNotFound
}
fn(&p)
s.byID[botUserID] = p
s.mu.Unlock()
ver, ok := s.users.bumpBotInfoVersion(botUserID)
if !ok {
return 0, domain.ErrBotNotFound
}
return ver, nil
}
func (s *BotStore) UpdateBotCommands(_ context.Context, botUserID int64, commands []domain.BotCommand) (int, error) {
return s.editProfile(botUserID, func(p *domain.BotProfile) {
p.Commands = append([]domain.BotCommand(nil), commands...)
})
}
func (s *BotStore) UpdateBotInfo(_ context.Context, botUserID int64, upd domain.BotInfoUpdate) (int, error) {
if _, ok := s.GetBotProfile(botUserID); !ok {
return 0, domain.ErrBotNotFound
}
if upd.SetName || upd.SetAbout {
if !s.users.updateBotProfile(botUserID, upd.SetName, upd.Name, upd.SetAbout, upd.About) {
return 0, domain.ErrBotNotFound
}
}
return s.editProfile(botUserID, func(p *domain.BotProfile) {
if upd.SetDescription {
p.Description = upd.Description
}
})
}
func (s *BotStore) UpdateBotMenuButton(_ context.Context, botUserID int64, button domain.BotMenuButton) (int, error) {
return s.editProfile(botUserID, func(p *domain.BotProfile) {
p.MenuButton = button
})
}
func (s *BotStore) SetBotInlinePlaceholder(_ context.Context, botUserID int64, placeholder string) (int, error) {
return s.editProfile(botUserID, func(p *domain.BotProfile) {
p.InlinePlaceholder = placeholder
})
}
func (s *BotStore) SetBotInlineGeo(_ context.Context, botUserID int64, inlineGeo bool) (int, error) {
return s.editProfile(botUserID, func(p *domain.BotProfile) { p.InlineGeo = inlineGeo })
}
func (s *BotStore) SetBotNochats(_ context.Context, botUserID int64, nochats bool) (int, error) {
return s.editProfile(botUserID, func(p *domain.BotProfile) { p.Nochats = nochats })
}
func (s *BotStore) SetBotChatHistory(_ context.Context, botUserID int64, chatHistory bool) (int, error) {
return s.editProfile(botUserID, func(p *domain.BotProfile) { p.ChatHistory = chatHistory })
}
func (s *BotStore) CanBotSendMessage(_ context.Context, botUserID, userID int64) (bool, error) {
if botUserID == 0 || userID == 0 || botUserID == userID {
return false, nil
}
s.mu.RLock()
allowed := s.permissions[[2]int64{botUserID, userID}]
s.mu.RUnlock()
return allowed, nil
}
func (s *BotStore) AllowBotSendMessage(_ context.Context, botUserID, userID int64, _ bool) (bool, error) {
if botUserID == 0 || userID == 0 || botUserID == userID {
return false, domain.ErrBotNotFound
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.byID[botUserID]; !ok {
return false, domain.ErrBotNotFound
}
key := [2]int64{botUserID, userID}
if s.permissions[key] {
return false, nil
}
s.permissions[key] = true
return true, nil
}
func (s *BotStore) UpsertBotApp(_ context.Context, app domain.BotApp) (domain.BotApp, int, error) {
if app.BotUserID == 0 || app.ID == 0 || app.AccessHash == 0 || app.ShortName == "" || app.URL == "" {
return domain.BotApp{}, 0, domain.ErrBotAppInvalid
}
app.ShortName = strings.ToLower(strings.TrimSpace(app.ShortName))
s.mu.Lock()
if _, ok := s.byID[app.BotUserID]; !ok {
s.mu.Unlock()
return domain.BotApp{}, 0, domain.ErrBotNotFound
}
if existing, ok := s.appShortNames[botAppShortKey{botUserID: app.BotUserID, shortName: app.ShortName}]; ok && existing != app.ID {
s.mu.Unlock()
return domain.BotApp{}, 0, domain.ErrBotAppInvalid
}
if old, ok := s.appsByID[app.ID]; ok && old.BotUserID != app.BotUserID {
s.mu.Unlock()
return domain.BotApp{}, 0, domain.ErrBotAppInvalid
}
s.appsByID[app.ID] = app
s.appShortNames[botAppShortKey{botUserID: app.BotUserID, shortName: app.ShortName}] = app.ID
p := s.byID[app.BotUserID]
if app.Main {
for id, item := range s.appsByID {
if item.BotUserID == app.BotUserID && id != app.ID && item.Main {
item.Main = false
s.appsByID[id] = item
}
}
p.HasMainApp = true
}
if app.HasSettings {
p.HasMainApp = p.HasMainApp || app.Main
}
s.byID[app.BotUserID] = p
s.mu.Unlock()
ver, ok := s.users.bumpBotInfoVersion(app.BotUserID)
if !ok {
return domain.BotApp{}, 0, domain.ErrBotNotFound
}
return cloneBotApp(app), ver, nil
}
func (s *BotStore) GetBotAppByID(_ context.Context, appID, accessHash int64) (domain.BotApp, bool, error) {
if appID == 0 || accessHash == 0 {
return domain.BotApp{}, false, nil
}
s.mu.RLock()
app, ok := s.appsByID[appID]
s.mu.RUnlock()
if !ok || app.AccessHash != accessHash {
return domain.BotApp{}, false, nil
}
return cloneBotApp(app), true, nil
}
func (s *BotStore) GetBotAppByShortName(_ context.Context, botUserID int64, shortName string) (domain.BotApp, bool, error) {
key := botAppShortKey{botUserID: botUserID, shortName: strings.ToLower(strings.TrimSpace(shortName))}
s.mu.RLock()
id, ok := s.appShortNames[key]
var app domain.BotApp
if ok {
app, ok = s.appsByID[id]
}
s.mu.RUnlock()
if !ok {
return domain.BotApp{}, false, nil
}
return cloneBotApp(app), true, nil
}
func (s *BotStore) GetMainBotApp(_ context.Context, botUserID int64) (domain.BotApp, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, app := range s.appsByID {
if app.BotUserID == botUserID && app.Main {
return cloneBotApp(app), true, nil
}
}
return domain.BotApp{}, false, nil
}
func (s *BotStore) ListBotApps(_ context.Context, botUserID int64) ([]domain.BotApp, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]domain.BotApp, 0)
for _, app := range s.appsByID {
if app.BotUserID == botUserID {
out = append(out, cloneBotApp(app))
}
}
sortBotApps(out)
return out, nil
}
func (s *BotStore) GetBotAppSettings(_ context.Context, botUserID int64) (domain.BotAppSettings, bool, error) {
s.mu.RLock()
settings, ok := s.appSettings[botUserID]
s.mu.RUnlock()
if !ok {
return domain.BotAppSettings{}, false, nil
}
return cloneBotAppSettings(settings), true, nil
}
func (s *BotStore) UpsertBotAppSettings(_ context.Context, botUserID int64, settings domain.BotAppSettings) (int, error) {
s.mu.Lock()
if _, ok := s.byID[botUserID]; !ok {
s.mu.Unlock()
return 0, domain.ErrBotNotFound
}
s.appSettings[botUserID] = cloneBotAppSettings(settings)
p := s.byID[botUserID]
c := cloneBotAppSettings(settings)
p.AppSettings = &c
s.byID[botUserID] = p
s.mu.Unlock()
ver, ok := s.users.bumpBotInfoVersion(botUserID)
if !ok {
return 0, domain.ErrBotNotFound
}
return ver, nil
}
func (s *BotStore) ListBotAppPreviewMedia(_ context.Context, botUserID, appID int64) ([]domain.BotAppPreviewMedia, error) {
s.mu.RLock()
defer s.mu.RUnlock()
items := s.previewMedia[appID]
out := make([]domain.BotAppPreviewMedia, 0, len(items))
for _, media := range items {
if media.BotUserID == botUserID {
out = append(out, media)
}
}
sortBotPreviewMedia(out)
return out, nil
}
func (s *BotStore) UpsertBotAppPreviewMedia(_ context.Context, media domain.BotAppPreviewMedia) (domain.BotAppPreviewMedia, int, error) {
if media.BotUserID == 0 || media.AppID == 0 || (media.PhotoID == 0 && media.DocumentID == 0) {
return domain.BotAppPreviewMedia{}, 0, domain.ErrBotAppInvalid
}
s.mu.Lock()
if app, ok := s.appsByID[media.AppID]; !ok || app.BotUserID != media.BotUserID {
s.mu.Unlock()
return domain.BotAppPreviewMedia{}, 0, domain.ErrBotAppInvalid
}
if s.previewMedia[media.AppID] == nil {
s.previewMedia[media.AppID] = make(map[int64]domain.BotAppPreviewMedia)
}
if media.ID == 0 {
s.previewSeq++
media.ID = s.previewSeq
}
if media.Position <= 0 {
media.Position = len(s.previewMedia[media.AppID]) + 1
}
s.previewMedia[media.AppID][media.ID] = media
p := s.byID[media.BotUserID]
p.HasPreviewMedias = true
s.byID[media.BotUserID] = p
s.mu.Unlock()
ver, ok := s.users.bumpBotInfoVersion(media.BotUserID)
if !ok {
return domain.BotAppPreviewMedia{}, 0, domain.ErrBotNotFound
}
return media, ver, nil
}
func (s *BotStore) DeleteBotAppPreviewMedia(_ context.Context, botUserID, appID, mediaID int64) (int, error) {
s.mu.Lock()
items := s.previewMedia[appID]
if mediaID == 0 || items == nil {
s.mu.Unlock()
return 0, domain.ErrBotAppInvalid
}
media, ok := items[mediaID]
if !ok || media.BotUserID != botUserID {
s.mu.Unlock()
return 0, domain.ErrBotAppInvalid
}
delete(items, mediaID)
has := false
for _, items := range s.previewMedia {
for _, media := range items {
if media.BotUserID == botUserID {
has = true
break
}
}
}
p := s.byID[botUserID]
p.HasPreviewMedias = has
s.byID[botUserID] = p
s.mu.Unlock()
ver, ok := s.users.bumpBotInfoVersion(botUserID)
if !ok {
return 0, domain.ErrBotNotFound
}
return ver, nil
}
func (s *BotStore) ReorderBotAppPreviewMedia(_ context.Context, botUserID, appID int64, mediaIDs []int64) (int, error) {
s.mu.Lock()
items := s.previewMedia[appID]
if items == nil {
s.mu.Unlock()
return 0, domain.ErrBotAppInvalid
}
for pos, id := range mediaIDs {
media, ok := items[id]
if !ok || media.BotUserID != botUserID {
s.mu.Unlock()
return 0, domain.ErrBotAppInvalid
}
media.Position = pos + 1
items[id] = media
}
s.mu.Unlock()
ver, ok := s.users.bumpBotInfoVersion(botUserID)
if !ok {
return 0, domain.ErrBotNotFound
}
return ver, nil
}
func (s *BotStore) UpsertAttachMenuBot(_ context.Context, bot domain.BotAttachMenuBot) (int, error) {
if bot.BotUserID == 0 || bot.ShortName == "" {
return 0, domain.ErrBotAttachMenuInvalid
}
bot.ShortName = strings.ToLower(strings.TrimSpace(bot.ShortName))
s.mu.Lock()
if _, ok := s.byID[bot.BotUserID]; !ok {
s.mu.Unlock()
return 0, domain.ErrBotNotFound
}
s.attachMenu[bot.BotUserID] = cloneAttachMenuBot(bot)
p := s.byID[bot.BotUserID]
p.HasAttachMenu = true
s.byID[bot.BotUserID] = p
s.mu.Unlock()
ver, ok := s.users.bumpBotInfoVersion(bot.BotUserID)
if !ok {
return 0, domain.ErrBotNotFound
}
return ver, nil
}
func (s *BotStore) GetAttachMenuBot(_ context.Context, botUserID int64) (domain.BotAttachMenuBot, bool, error) {
s.mu.RLock()
bot, ok := s.attachMenu[botUserID]
s.mu.RUnlock()
if !ok {
return domain.BotAttachMenuBot{}, false, nil
}
return cloneAttachMenuBot(bot), true, nil
}
func (s *BotStore) ListAttachMenuBots(_ context.Context) ([]domain.BotAttachMenuBot, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]domain.BotAttachMenuBot, 0, len(s.attachMenu))
for _, bot := range s.attachMenu {
out = append(out, cloneAttachMenuBot(bot))
}
sortAttachMenuBots(out)
return out, nil
}
func (s *BotStore) GetAttachMenuState(_ context.Context, userID, botUserID int64) (domain.BotAttachMenuState, bool, error) {
s.mu.RLock()
state, ok := s.attachStates[[2]int64{userID, botUserID}]
s.mu.RUnlock()
if !ok {
return domain.BotAttachMenuState{}, false, nil
}
return state, true, nil
}
func (s *BotStore) SetAttachMenuState(_ context.Context, state domain.BotAttachMenuState) (domain.BotAttachMenuState, error) {
if state.UserID == 0 || state.BotUserID == 0 || state.UserID == state.BotUserID {
return domain.BotAttachMenuState{}, domain.ErrBotAttachMenuInvalid
}
s.mu.Lock()
if _, ok := s.attachMenu[state.BotUserID]; !ok {
s.mu.Unlock()
return domain.BotAttachMenuState{}, domain.ErrBotAttachMenuInvalid
}
s.attachStates[[2]int64{state.UserID, state.BotUserID}] = state
s.mu.Unlock()
return state, nil
}
func (s *BotStore) SaveRequestedWebViewButton(_ context.Context, button domain.BotRequestedWebViewButton) error {
if button.BotUserID == 0 || button.UserID == 0 || button.WebAppReqID == "" || button.ButtonID == 0 {
return domain.ErrBotRequestedButtonInvalid
}
s.mu.Lock()
s.requestButtons[requestedButtonKey{botUserID: button.BotUserID, userID: button.UserID, reqID: button.WebAppReqID}] = button
s.mu.Unlock()
return nil
}
func (s *BotStore) GetRequestedWebViewButton(_ context.Context, botUserID, userID int64, webAppReqID string) (domain.BotRequestedWebViewButton, bool, error) {
key := requestedButtonKey{botUserID: botUserID, userID: userID, reqID: webAppReqID}
s.mu.Lock()
button, ok := s.requestButtons[key]
if ok && !button.ExpiresAt.IsZero() && !button.ExpiresAt.After(time.Now()) {
delete(s.requestButtons, key)
ok = false
}
s.mu.Unlock()
if !ok {
return domain.BotRequestedWebViewButton{}, false, nil
}
return button, true, nil
}
func (s *BotStore) DeleteRequestedWebViewButton(_ context.Context, botUserID, userID int64, webAppReqID string) error {
s.mu.Lock()
delete(s.requestButtons, requestedButtonKey{botUserID: botUserID, userID: userID, reqID: webAppReqID})
s.mu.Unlock()
return nil
}
func (s *BotStore) SetBotEmojiStatusPermission(_ context.Context, botUserID, userID int64, allowed bool) error {
if botUserID == 0 || userID == 0 || botUserID == userID {
return domain.ErrBotNotFound
}
s.mu.Lock()
if _, ok := s.byID[botUserID]; !ok {
s.mu.Unlock()
return domain.ErrBotNotFound
}
if allowed {
s.emojiPerms[[2]int64{botUserID, userID}] = true
} else {
delete(s.emojiPerms, [2]int64{botUserID, userID})
}
s.mu.Unlock()
return nil
}
func (s *BotStore) BotEmojiStatusPermission(_ context.Context, botUserID, userID int64) (bool, error) {
s.mu.RLock()
allowed := s.emojiPerms[[2]int64{botUserID, userID}]
s.mu.RUnlock()
return allowed, nil
}
func (s *BotStore) PutWebViewCustomMethodQuery(_ context.Context, query domain.BotWebViewCustomMethodQuery) error {
if query.ID == "" || query.BotUserID == 0 || query.UserID == 0 || query.CustomMethod == "" {
return domain.ErrBotCustomMethodUnavailable
}
s.mu.Lock()
s.customMethod[query.ID] = query
s.mu.Unlock()
return nil
}
// GetBotProfile 是 GetBot 的同步只读快照(editProfile 前置存在性检查用)。
func (s *BotStore) GetBotProfile(botUserID int64) (domain.BotProfile, bool) {
s.mu.RLock()
p, ok := s.byID[botUserID]
s.mu.RUnlock()
return p, ok
}
func (s *BotStore) GetBotChatState(_ context.Context, botUserID, userID int64) (domain.BotChatState, bool, error) {
s.mu.RLock()
state, ok := s.states[[2]int64{botUserID, userID}]
s.mu.RUnlock()
if !ok {
return domain.BotChatState{}, false, nil
}
return cloneBotChatState(state), true, nil
}
func (s *BotStore) UpsertBotChatState(_ context.Context, state domain.BotChatState) error {
s.mu.Lock()
s.states[[2]int64{state.BotUserID, state.UserID}] = cloneBotChatState(state)
s.mu.Unlock()
return nil
}
func (s *BotStore) DeleteBotChatState(_ context.Context, botUserID, userID int64) error {
s.mu.Lock()
delete(s.states, [2]int64{botUserID, userID})
s.mu.Unlock()
return nil
}
func cloneBotProfile(p domain.BotProfile) domain.BotProfile {
out := p
out.Commands = append([]domain.BotCommand(nil), p.Commands...)
if p.AppSettings != nil {
settings := cloneBotAppSettings(*p.AppSettings)
out.AppSettings = &settings
}
return out
}
func (s *BotStore) enrichBotProfileLocked(p domain.BotProfile) domain.BotProfile {
if _, ok := s.appSettings[p.BotUserID]; ok {
settings := cloneBotAppSettings(s.appSettings[p.BotUserID])
p.AppSettings = &settings
}
for _, app := range s.appsByID {
if app.BotUserID == p.BotUserID && app.Main {
p.HasMainApp = true
break
}
}
if _, ok := s.attachMenu[p.BotUserID]; ok {
p.HasAttachMenu = true
}
for _, items := range s.previewMedia {
for _, media := range items {
if media.BotUserID == p.BotUserID {
p.HasPreviewMedias = true
return p
}
}
}
return p
}
func cloneBotApp(app domain.BotApp) domain.BotApp {
return app
}
func cloneBotAppSettings(settings domain.BotAppSettings) domain.BotAppSettings {
out := settings
out.PlaceholderPath = append([]byte(nil), settings.PlaceholderPath...)
return out
}
func cloneAttachMenuBot(bot domain.BotAttachMenuBot) domain.BotAttachMenuBot {
out := bot
out.PeerTypes = append([]string(nil), bot.PeerTypes...)
out.Icons = make([]domain.BotAttachMenuIcon, len(bot.Icons))
for i, icon := range bot.Icons {
out.Icons[i] = icon
out.Icons[i].Colors = append([]domain.BotAttachMenuIconColor(nil), icon.Colors...)
}
return out
}
func cloneBotChatState(state domain.BotChatState) domain.BotChatState {
out := state
if state.Draft != nil {
out.Draft = make(map[string]string, len(state.Draft))
for k, v := range state.Draft {
out.Draft[k] = v
}
}
return out
}
func sortBotProfiles(list []domain.BotProfile) {
for i := 1; i < len(list); i++ {
for j := i; j > 0 && list[j].BotUserID < list[j-1].BotUserID; j-- {
list[j], list[j-1] = list[j-1], list[j]
}
}
}
func sortBotApps(list []domain.BotApp) {
for i := 1; i < len(list); i++ {
for j := i; j > 0 && appSortKey(list[j]) < appSortKey(list[j-1]); j-- {
list[j], list[j-1] = list[j-1], list[j]
}
}
}
func appSortKey(app domain.BotApp) string {
return strconv.FormatInt(app.BotUserID, 10) + ":" + app.ShortName
}
func sortBotPreviewMedia(list []domain.BotAppPreviewMedia) {
for i := 1; i < len(list); i++ {
for j := i; j > 0 && (list[j].Position < list[j-1].Position || (list[j].Position == list[j-1].Position && list[j].ID < list[j-1].ID)); j-- {
list[j], list[j-1] = list[j-1], list[j]
}
}
}
func sortAttachMenuBots(list []domain.BotAttachMenuBot) {
for i := 1; i < len(list); i++ {
for j := i; j > 0 && list[j].BotUserID < list[j-1].BotUserID; j-- {
list[j], list[j-1] = list[j-1], list[j]
}
}
}

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:不保留旧的角色/管理权/Tag;inviter 重置为本人(自加入)。
// 只有 channel 当前 owner 仍是该账号时,才保留 creator 身份。
if existing.Role != domain.ChannelRoleCreator || channel.CreatorUserID != userID {
member.Role = domain.ChannelRoleMember
member.AdminRights = domain.ChannelAdminRights{}
member.Rank = ""
member.InviterUserID = userID
member.JoinedAt = date
}
if minID := channelInitialAvailableMinID(channel); minID > member.AvailableMinID {
member.AvailableMinID = minID
member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, minID)
}
member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, preJoinTopID)
if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts {
member.AvailableMinPts = minPts
}
channel.ParticipantsCount++
} else {
member.AvailableMinID = channelInitialAvailableMinID(channel)
member.AvailableMinPts = channelInitialAvailableMinPts(channel)
member.ReadInboxMaxID = maxInt(member.AvailableMinID, preJoinTopID)
channel.ParticipantsCount++
}
if s.members[channelID] == nil {
s.members[channelID] = make(map[int64]domain.ChannelMember)
}
s.members[channelID][userID] = member
s.channels[channelID] = channel
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
ChannelID: channelID,
UserID: userID,
Date: date,
Type: domain.ChannelAdminLogParticipantJoin,
})
var msg domain.ChannelMessage
var event domain.ChannelUpdateEvent
if channel.Megagroup {
msg, event = s.appendChannelServiceMessageLocked(channelID, userID, date, domain.ChannelMessageAction{
Type: domain.ChannelActionChatJoined,
UserIDs: []int64{userID},
})
channel.TopMessageID = msg.ID
channel.Pts = event.Pts
s.channels[channelID] = channel
}
member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID)
if msg.ID != 0 && msg.SenderUserID == userID {
member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID)
}
s.members[channelID][userID] = member
s.upsertChannelDialogLocked(userID, channel, msg, true)
return domain.CreateChannelResult{
Channel: channel,
Members: []domain.ChannelMember{member},
Message: cloneChannelMessage(msg),
Event: cloneChannelEvent(event),
Recipients: s.activeMemberIDsLocked(channelID, 0, 0),
}, nil
}
func (s *ChannelStore) LeaveChannel(_ context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
channel, member, err := s.channelAndMemberLocked(userID, channelID)
if err != nil {
return domain.CreateChannelResult{}, err
}
members := []domain.ChannelMember{}
adminsDelta := 0
if isChannelAdmin(member) {
adminsDelta--
}
if channel.CreatorUserID == userID || member.Role == domain.ChannelRoleCreator {
if channel.CreatorUserID != userID || member.Role != domain.ChannelRoleCreator {
return domain.CreateChannelResult{}, domain.ErrChannelUserCreator
}
future, err := s.futureCreatorAfterLeaveLocked(channelID, userID)
if err != nil {
return domain.CreateChannelResult{}, domain.ErrChannelUserCreator
}
if !isChannelAdmin(future) {
adminsDelta++
}
future.Role = domain.ChannelRoleCreator
future.AdminRights = creatorChannelAdminRights()
future.Rank = ""
future.Status = domain.ChannelMemberActive
future.LeftAt = 0
s.members[channelID][future.UserID] = future
channel.CreatorUserID = future.UserID
members = append(members, future)
member.Role = domain.ChannelRoleMember
member.AdminRights = domain.ChannelAdminRights{}
member.Rank = ""
}
member.Status = domain.ChannelMemberLeft
member.LeftAt = date
s.members[channelID][userID] = member
members = append([]domain.ChannelMember{member}, members...)
s.clearChannelMentionsForUserLocked(channelID, userID)
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
ChannelID: channelID,
UserID: userID,
Date: date,
Type: domain.ChannelAdminLogParticipantLeave,
})
if channel.ParticipantsCount > 0 {
channel.ParticipantsCount--
}
channel.AdminsCount += adminsDelta
if channel.AdminsCount < 0 {
channel.AdminsCount = 0
}
var msg domain.ChannelMessage
var event domain.ChannelUpdateEvent
if channel.Megagroup {
msg, event = s.appendChannelServiceMessageLocked(channelID, userID, date, domain.ChannelMessageAction{
Type: domain.ChannelActionChatDelete,
UserIDs: []int64{userID},
})
channel.TopMessageID = msg.ID
channel.Pts = event.Pts
}
s.channels[channelID] = channel
return domain.CreateChannelResult{
Channel: channel,
Members: cloneChannelMembers(members),
Message: cloneChannelMessage(msg),
Event: cloneChannelEvent(event),
Recipients: append(s.activeMemberIDsLocked(channelID, 0, 0), userID),
}, nil
}
func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error) {
if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 {
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.EditChannelAdminResult{}, err
}
actor := s.members[req.ChannelID][req.UserID]
if !canAddChannelAdmins(actor) {
return domain.EditChannelAdminResult{}, domain.ErrChannelAdminRequired
}
if actor.Role != domain.ChannelRoleCreator && !adminRightsSubset(req.AdminRights, actor.AdminRights) {
return domain.EditChannelAdminResult{}, domain.ErrChannelRightForbidden
}
previous, ok := s.members[req.ChannelID][req.MemberID]
if !ok {
previous = domain.ChannelMember{
ChannelID: req.ChannelID,
UserID: req.MemberID,
InviterUserID: req.UserID,
Role: domain.ChannelRoleMember,
Status: domain.ChannelMemberActive,
JoinedAt: req.Date,
AvailableMinID: channelInitialAvailableMinID(channel),
AvailableMinPts: channelInitialAvailableMinPts(channel),
ReadInboxMaxID: channel.TopMessageID,
}
}
if previous.Role == domain.ChannelRoleCreator {
return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator
}
member := previous
member.InviterUserID = req.UserID
member.Status = domain.ChannelMemberActive
member.LeftAt = 0
if previous.Status != domain.ChannelMemberActive {
if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts {
member.AvailableMinPts = minPts
}
}
member.AdminRights = req.AdminRights
member.Rank = req.Rank
if zeroChannelAdminRights(req.AdminRights) {
member.Role = domain.ChannelRoleMember
member.Rank = ""
} else {
member.Role = domain.ChannelRoleAdmin
}
s.members[req.ChannelID][req.MemberID] = member
logType := domain.ChannelAdminLogParticipantPromote
if member.Role != domain.ChannelRoleAdmin {
logType = domain.ChannelAdminLogParticipantDemote
}
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
ChannelID: req.ChannelID,
UserID: req.UserID,
Date: req.Date,
Type: logType,
PrevParticipant: ptrChannelMember(previous),
NewParticipant: ptrChannelMember(member),
})
s.refreshChannelCountsLocked(req.ChannelID)
channel = s.channels[req.ChannelID]
event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date)
if msg, ok := s.findMessageLocked(req.ChannelID, channel.TopMessageID); ok {
s.upsertChannelDialogLocked(member.UserID, channel, msg, false)
}
recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0)
recipients = append(recipients, req.MemberID)
return domain.EditChannelAdminResult{
Channel: channel,
Previous: previous,
Participant: member,
Event: event,
Recipients: recipients,
Date: req.Date,
}, nil
}
func (s *ChannelStore) EditChannelMemberRank(_ context.Context, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) {
if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 {
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.EditChannelAdminResult{}, err
}
actor := s.members[req.ChannelID][req.UserID]
previous, ok := s.members[req.ChannelID][req.MemberID]
if !ok || previous.Status != domain.ChannelMemberActive {
return domain.EditChannelAdminResult{}, domain.ErrUserNotParticipant
}
if err := checkEditMemberRank(channel, actor, previous); err != nil {
return domain.EditChannelAdminResult{}, err
}
member := previous
member.Rank = req.Rank
s.members[req.ChannelID][req.MemberID] = member
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
ChannelID: req.ChannelID,
UserID: req.UserID,
Date: req.Date,
Type: domain.ChannelAdminLogParticipantEditRank,
PrevString: previous.Rank,
NewString: member.Rank,
Participant: ptrChannelMember(member),
})
event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date)
recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0)
recipients = append(recipients, req.MemberID)
return domain.EditChannelAdminResult{
Channel: channel,
Previous: previous,
Participant: member,
Event: event,
Recipients: recipients,
Date: req.Date,
}, nil
}
func (s *ChannelStore) EditChannelBanned(_ context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) {
if req.UserID == 0 || req.ChannelID == 0 || req.Participant.Type != domain.PeerTypeUser || req.Participant.ID == 0 {
return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.EditChannelBannedResult{}, err
}
actor := s.members[req.ChannelID][req.UserID]
if !canBanChannelUsers(actor) {
return domain.EditChannelBannedResult{}, domain.ErrChannelAdminRequired
}
previous, ok := s.members[req.ChannelID][req.Participant.ID]
if !ok {
previous = domain.ChannelMember{ChannelID: req.ChannelID, UserID: req.Participant.ID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberLeft}
}
if previous.Role == domain.ChannelRoleCreator {
return domain.EditChannelBannedResult{}, domain.ErrChannelUserCreator
}
member := previous
member.Role = domain.ChannelRoleMember
member.BannedRights = req.BannedRights
switch {
case req.BannedRights.ViewMessages:
member.InviterUserID = req.UserID
member.Status = domain.ChannelMemberKicked
member.LeftAt = req.Date
case zeroChannelBannedRights(req.BannedRights):
if previous.Status == domain.ChannelMemberActive {
member.Status = domain.ChannelMemberActive
} else {
member.Status = domain.ChannelMemberLeft
}
member.LeftAt = 0
default:
member.InviterUserID = req.UserID
if previous.Status == domain.ChannelMemberActive {
member.Status = domain.ChannelMemberActive
} else {
member.Status = domain.ChannelMemberBanned
}
}
if member.JoinedAt == 0 && member.Status == domain.ChannelMemberActive {
member.JoinedAt = req.Date
}
s.members[req.ChannelID][req.Participant.ID] = member
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
ChannelID: req.ChannelID,
UserID: req.UserID,
Date: req.Date,
Type: adminLogBanType(previous, member),
PrevParticipant: ptrChannelMember(previous),
NewParticipant: ptrChannelMember(member),
})
s.refreshChannelCountsLocked(req.ChannelID)
channel = s.channels[req.ChannelID]
event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date)
if member.Status == domain.ChannelMemberActive {
if msg, ok := s.findMessageLocked(req.ChannelID, channel.TopMessageID); ok {
s.upsertChannelDialogLocked(member.UserID, channel, msg, false)
}
}
if member.Status == domain.ChannelMemberKicked && previous.Status == domain.ChannelMemberActive {
s.clearChannelMentionsForUserLocked(req.ChannelID, req.Participant.ID)
}
var serviceMsg domain.ChannelMessage
var serviceEvent domain.ChannelUpdateEvent
if channel.Megagroup && previous.Status == domain.ChannelMemberActive && member.Status == domain.ChannelMemberKicked {
serviceMsg, serviceEvent = s.appendChannelServiceMessageLocked(req.ChannelID, req.UserID, req.Date, domain.ChannelMessageAction{
Type: domain.ChannelActionChatDelete,
UserIDs: []int64{req.Participant.ID},
})
channel.TopMessageID = serviceMsg.ID
channel.Pts = serviceEvent.Pts
s.channels[req.ChannelID] = channel
}
recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0)
recipients = append(recipients, req.Participant.ID)
return domain.EditChannelBannedResult{
Channel: channel,
Previous: previous,
Participant: member,
Event: event,
Recipients: recipients,
Date: req.Date,
Message: cloneChannelMessage(serviceMsg),
ServiceEvent: cloneChannelEvent(serviceEvent),
}, nil
}
func (s *ChannelStore) EditChannelDefaultBannedRights(_ context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) {
if req.UserID == 0 || req.ChannelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.Channel{}, err
}
actor := s.members[req.ChannelID][req.UserID]
if !canBanChannelUsers(actor) {
return domain.Channel{}, domain.ErrChannelAdminRequired
}
if channel.DefaultBannedRights == req.BannedRights {
return domain.Channel{}, domain.ErrChannelNotModified
}
channel.DefaultBannedRights = req.BannedRights
s.channels[req.ChannelID] = channel
return channel, nil
}
func (s *ChannelStore) ListAdminedPublicChannels(_ context.Context, userID int64) ([]domain.Channel, error) {
if userID == 0 {
return nil, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]domain.Channel, 0)
for channelID, members := range s.members {
member := members[userID]
if member.Status != domain.ChannelMemberActive || !isChannelAdmin(member) {
continue
}
channel, ok := s.channels[channelID]
if !ok || channel.Deleted || channel.Username == "" {
continue
}
out = append(out, channel)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if len(out) > domain.MaxAdminedPublicChannels {
out = out[:domain.MaxAdminedPublicChannels]
}
return append([]domain.Channel(nil), out...), nil
}
func (s *ChannelStore) ListStoryPostableChannels(_ context.Context, userID int64) ([]domain.Channel, error) {
if userID == 0 {
return nil, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]domain.Channel, 0)
for channelID, members := range s.members {
member := members[userID]
if member.Status != domain.ChannelMemberActive {
continue
}
if member.Role != domain.ChannelRoleCreator && !(member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostStories) {
continue
}
channel, ok := s.channels[channelID]
if !ok || channel.Deleted || (!channel.Broadcast && !channel.Megagroup) {
continue
}
out = append(out, channel)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if len(out) > domain.MaxStorySendAsChannels {
out = out[:domain.MaxStorySendAsChannels]
}
return append([]domain.Channel(nil), out...), nil
}
func (s *ChannelStore) ListSendAsChannels(_ context.Context, userID int64) ([]domain.Channel, error) {
if userID == 0 {
return nil, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]domain.Channel, 0)
for channelID, members := range s.members {
member := members[userID]
if member.Status != domain.ChannelMemberActive {
continue
}
if member.Role != domain.ChannelRoleCreator && !(member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostMessages) {
continue
}
channel, ok := s.channels[channelID]
if !ok || channel.Deleted || !channel.Broadcast {
continue
}
out = append(out, channel)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if len(out) > domain.MaxSendAsChannels {
out = out[:domain.MaxSendAsChannels]
}
return append([]domain.Channel(nil), out...), nil
}
func (s *ChannelStore) SetParticipantsHidden(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
if userID == 0 || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
channel, err := s.channelForMemberLocked(userID, channelID)
if err != nil {
return domain.Channel{}, err
}
member := s.members[channelID][userID]
if !channel.Megagroup || !canBanChannelUsers(member) {
return domain.Channel{}, domain.ErrChannelAdminRequired
}
channel.ParticipantsHidden = enabled
s.channels[channelID] = channel
return channel, nil
}
func (s *ChannelStore) SetJoinToSend(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
if userID == 0 || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
channel, err := s.channelForMemberLocked(userID, channelID)
if err != nil {
return domain.Channel{}, err
}
member := s.members[channelID][userID]
if !channel.Megagroup || !canExportChannelInvite(member) {
return domain.Channel{}, domain.ErrChannelAdminRequired
}
channel.JoinToSend = enabled
s.channels[channelID] = channel
return channel, nil
}
func (s *ChannelStore) SetJoinRequest(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
if userID == 0 || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
channel, err := s.channelForMemberLocked(userID, channelID)
if err != nil {
return domain.Channel{}, err
}
member := s.members[channelID][userID]
if !channel.Megagroup || !canExportChannelInvite(member) {
return domain.Channel{}, domain.ErrChannelAdminRequired
}
if enabled && strings.TrimSpace(channel.Username) == "" {
return domain.Channel{}, domain.ErrChatPublicRequired
}
channel.JoinRequest = enabled
s.channels[channelID] = channel
return channel, nil
}
func (s *ChannelStore) HideChatJoinRequest(_ context.Context, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) {
if req.UserID == 0 || req.ChannelID == 0 || req.TargetUserID == 0 {
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.CreateChannelResult{}, err
}
member := s.members[req.ChannelID][req.UserID]
if !canExportChannelInvite(member) {
return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired
}
importer, ok := s.importers[req.ChannelID][req.TargetUserID]
if !ok || !importer.Requested {
return domain.CreateChannelResult{}, domain.ErrHideRequesterMissing
}
invite := domain.ChannelInvite{ChannelID: req.ChannelID, AdminUserID: req.UserID}
if importer.InviteID != 0 {
var err error
invite, err = s.inviteByIDLocked(req.ChannelID, importer.InviteID)
if err != nil {
return domain.CreateChannelResult{}, err
}
}
if !req.Approved {
s.deletePendingInviteImporterLocked(invite, req.TargetUserID)
return domain.CreateChannelResult{Channel: channel, Recipients: s.activeMemberIDsLocked(req.ChannelID, req.TargetUserID, 0)}, nil
}
return s.approveInviteImporterLocked(channel, invite, req.TargetUserID, req.UserID, req.Date)
}
func (s *ChannelStore) recordPublicJoinRequestLocked(channel domain.Channel, userID int64, date int) error {
if existing, ok := s.members[channel.ID][userID]; ok {
if existing.Status == domain.ChannelMemberActive {
return domain.ErrUserAlreadyParticipant
}
if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages {
return domain.ErrInviteHashInvalid
}
}
if s.importers[channel.ID] == nil {
s.importers[channel.ID] = make(map[int64]domain.ChannelInviteImporter)
}
if existing, ok := s.importers[channel.ID][userID]; ok && existing.Requested {
return domain.ErrInviteRequestSent
}
s.importers[channel.ID][userID] = domain.ChannelInviteImporter{
ChannelID: channel.ID,
UserID: userID,
Date: date,
Requested: true,
}
return nil
}
func (s *ChannelStore) ListActiveChannelMemberIDs(_ context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil {
return nil, err
}
return s.activeMemberIDsLocked(channelID, 0, limit), nil
}
func (s *ChannelStore) FilterActiveChannelMemberIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if channelID == 0 || len(userIDs) == 0 {
return nil, nil
}
members := s.members[channelID]
if len(members) == 0 {
return nil, nil
}
out := make([]int64, 0, len(userIDs))
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID == 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
member, ok := members[userID]
if !ok || member.Status != domain.ChannelMemberActive {
continue
}
out = append(out, userID)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out, nil
}
func (s *ChannelStore) ListActiveChannelMembers(_ context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) {
s.mu.RLock()
defer s.mu.RUnlock()
channel, viewer, err := s.channelAndMemberLocked(viewerUserID, channelID)
if err != nil {
return domain.Channel{}, domain.ChannelMember{}, nil, err
}
if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout {
limit = domain.MaxSynchronousChannelDialogFanout
}
members := s.members[channelID]
out := make([]domain.ChannelMember, 0, minInt(limit, len(members)))
for _, member := range members {
if member.Status != domain.ChannelMemberActive {
continue
}
out = append(out, member)
if len(out) >= limit {
break
}
}
sort.Slice(out, func(i, j int) bool { return out[i].UserID < out[j].UserID })
return channel, viewer, cloneChannelMembers(out), nil
}
func (s *ChannelStore) channelForMemberLocked(userID, channelID int64) (domain.Channel, error) {
channel, _, err := s.channelAndMemberLocked(userID, channelID)
return channel, err
}
func (s *ChannelStore) channelAndMemberLocked(userID, channelID int64) (domain.Channel, domain.ChannelMember, error) {
channel, ok := s.channels[channelID]
if !ok || channel.Deleted {
return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelInvalid
}
member, ok := s.members[channelID][userID]
if !ok || member.Status == domain.ChannelMemberLeft {
return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelPrivate
}
if member.Status == domain.ChannelMemberBanned || member.Status == domain.ChannelMemberKicked || member.BannedRights.ViewMessages {
return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelUserBanned
}
return channel, member, nil
}
func (s *ChannelStore) activeMemberIDsLocked(channelID, excludeUserID int64, limit int) []int64 {
members := s.members[channelID]
if limit <= 0 || limit > domain.MaxChannelRealtimeFanout {
limit = domain.MaxChannelRealtimeFanout
}
capacity := limit
if len(members) < capacity {
capacity = len(members)
}
out := make([]int64, 0, capacity)
for userID, member := range members {
if userID == excludeUserID || member.Status != domain.ChannelMemberActive {
continue
}
out = append(out, userID)
if len(out) >= limit {
break
}
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func (s *ChannelStore) futureCreatorAfterLeaveLocked(channelID, userID int64) (domain.ChannelMember, error) {
members := s.members[channelID]
var selected domain.ChannelMember
found := false
for _, member := range members {
if member.UserID == userID || member.Status != domain.ChannelMemberActive || member.Role == domain.ChannelRoleCreator || member.BannedRights.ViewMessages {
continue
}
if !found || futureCreatorCandidateLess(member, selected) {
selected = member
found = true
}
}
if !found {
return domain.ChannelMember{}, domain.ErrUserNotParticipant
}
return selected, nil
}
func futureCreatorCandidateLess(a, b domain.ChannelMember) bool {
if channelRoleOrder(a.Role) != channelRoleOrder(b.Role) {
return channelRoleOrder(a.Role) < channelRoleOrder(b.Role)
}
return a.UserID < b.UserID
}
func publicPreviewMember(channel domain.Channel, userID int64, existing domain.ChannelMember, found bool) domain.ChannelMember {
member := domain.ChannelMember{
ChannelID: channel.ID,
UserID: userID,
Role: domain.ChannelRoleMember,
Status: domain.ChannelMemberLeft,
AvailableMinID: channelInitialAvailableMinID(channel),
AvailableMinPts: channelInitialAvailableMinPts(channel),
ReadInboxMaxID: channel.TopMessageID,
ReadOutboxMaxID: channel.TopMessageID,
}
if found {
member.InviterUserID = existing.InviterUserID
member.JoinedAt = existing.JoinedAt
member.LeftAt = existing.LeftAt
member.AvailableMinID = maxInt(member.AvailableMinID, existing.AvailableMinID)
member.AvailableMinPts = maxInt(member.AvailableMinPts, existing.AvailableMinPts)
member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, existing.ReadInboxMaxID)
member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, existing.ReadOutboxMaxID)
}
return member
}
func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.ChannelMember) domain.ChannelMember {
member := parentMember
member.ChannelID = mono.ID
member.Status = domain.ChannelMemberActive
if mono.CreatorUserID == parentMember.UserID {
member.Role = domain.ChannelRoleCreator
} else {
member.Role = domain.ChannelRoleAdmin
}
member.AvailableMinID = 0
member.AvailableMinPts = 0
member.ReadInboxMaxID = mono.TopMessageID
member.ReadOutboxMaxID = mono.TopMessageID
member.UnreadMark = false
member.SlowmodeLastSendDate = 0
return member
}
func publicChannelSearchRank(channel domain.Channel, queryLower string) (int, bool) {
if !publicSearchableChannel(channel) {
return 0, false
}
username := strings.ToLower(strings.TrimSpace(channel.Username))
title := strings.ToLower(strings.TrimSpace(channel.Title))
switch {
case username == queryLower:
return 0, true
case strings.HasPrefix(username, queryLower):
return 1, true
case strings.Contains(username, queryLower):
return 2, true
case strings.HasPrefix(title, queryLower):
return 3, true
case strings.Contains(title, queryLower):
return 4, true
default:
return 0, false
}
}
func canPostToBroadcast(member domain.ChannelMember) bool {
return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostMessages)
}
func isChannelAdmin(member domain.ChannelMember) bool {
return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin
}
func shouldHideAnonymousAdminFromParticipantList(viewer, member domain.ChannelMember) bool {
if isChannelAdmin(viewer) {
return false
}
if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin {
return false
}
return member.AdminRights.Anonymous
}
func channelParticipantMatchesFilter(member domain.ChannelMember, kind domain.ChannelParticipantsFilterKind, query string) bool {
if query != "" && !strings.Contains(strconv.FormatInt(member.UserID, 10), query) {
return false
}
switch kind {
case "", domain.ChannelParticipantsRecent, domain.ChannelParticipantsContacts, domain.ChannelParticipantsMentions, domain.ChannelParticipantsSearch:
return member.Status == domain.ChannelMemberActive
case domain.ChannelParticipantsAdmins:
// Layer 225 起 admins filter 同时是消息徽章数据源:客户端把整个返回
// (含 rank)灌进 badge 缓存,因此带成员 Tag 的普通成员也必须返回。
return member.Status == domain.ChannelMemberActive && (isChannelAdmin(member) || member.Rank != "")
case domain.ChannelParticipantsKicked:
return member.Status == domain.ChannelMemberKicked || member.BannedRights.ViewMessages
case domain.ChannelParticipantsBanned:
return member.Status != domain.ChannelMemberKicked && !zeroChannelBannedRights(member.BannedRights)
case domain.ChannelParticipantsBots:
return false
default:
return member.Status == domain.ChannelMemberActive
}
}
func canManageDiscussionBroadcast(member domain.ChannelMember) bool {
return canChangeChannelInfo(member)
}
func canManageDiscussionGroup(member domain.ChannelMember) bool {
return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages)
}
func canAddChannelAdmins(member domain.ChannelMember) bool {
return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.AddAdmins)
}
func canBanChannelUsers(member domain.ChannelMember) bool {
return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.BanUsers)
}
func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool {
return rights == domain.ChannelAdminRights{}
}
func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool {
return rights == domain.ChannelBannedRights{}
}
func creatorChannelAdminRights() domain.ChannelAdminRights {
return domain.ChannelAdminRights{
ChangeInfo: true,
PostMessages: true,
EditMessages: true,
DeleteMessages: true,
PostStories: true,
EditStories: true,
DeleteStories: true,
BanUsers: true,
InviteUsers: true,
PinMessages: true,
AddAdmins: true,
ManageCall: true,
}
}
func cloneChannelMembers(in []domain.ChannelMember) []domain.ChannelMember {
return append([]domain.ChannelMember(nil), in...)
}
func ptrChannelMember(in domain.ChannelMember) *domain.ChannelMember {
out := in
return &out
}

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 返回最新置顶消息 id(channelFull.pinned_msg_id 缓存语义)。
func (s *ChannelStore) latestPinnedMessageIDLocked(channelID int64) int {
latest := 0
for _, m := range s.messages[channelID] {
if m.Pinned && !m.Deleted && m.ID > latest {
latest = m.ID
}
}
return latest
}
// UnpinAllChannelMessages 清空全部置顶并以单条 channel 事件携带全部 id。
func (s *ChannelStore) UnpinAllChannelMessages(_ context.Context, req domain.UnpinAllChannelMessagesRequest) (domain.UpdateChannelPinnedMessageResult, error) {
if req.UserID == 0 || req.ChannelID == 0 {
return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.UpdateChannelPinnedMessageResult{}, err
}
member := s.members[req.ChannelID][req.UserID]
if !canPinChannelMessages(channel, member) {
return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelAdminRequired
}
cleared := []int(nil)
for i := range s.messages[req.ChannelID] {
if s.messages[req.ChannelID][i].Pinned && !s.messages[req.ChannelID][i].Deleted {
s.messages[req.ChannelID][i].Pinned = false
cleared = append(cleared, s.messages[req.ChannelID][i].ID)
}
}
if len(cleared) == 0 {
return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelNotModified
}
sort.Ints(cleared)
pts := s.nextChannelPtsLocked(req.ChannelID)
channel.PinnedMessageID = 0
channel.Pts = pts
s.channels[req.ChannelID] = channel
event := domain.ChannelUpdateEvent{
ChannelID: req.ChannelID,
Type: domain.ChannelUpdatePinnedMessages,
Pts: pts,
PtsCount: 1,
Date: req.Date,
MessageIDs: cleared,
SenderUserID: req.UserID,
Pinned: false,
}
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
return domain.UpdateChannelPinnedMessageResult{
Channel: channel,
Event: cloneChannelEvent(event),
Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0),
}, nil
}
// ClearDanglingPinnedMessage 把指向已删除消息的置顶值清零(unpinAll 自愈)。
func (s *ChannelStore) ClearDanglingPinnedMessage(_ context.Context, channelID int64, messageID int) error {
if channelID == 0 || messageID <= 0 {
return domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
channel, ok := s.channels[channelID]
if !ok {
return domain.ErrChannelInvalid
}
if channel.PinnedMessageID == messageID {
channel.PinnedMessageID = 0
s.channels[channelID] = channel
}
return nil
}
func canPinChannelMessages(channel domain.Channel, member domain.ChannelMember) bool {
if member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) {
return true
}
return channel.Megagroup && !channel.DefaultBannedRights.PinMessages && !member.BannedRights.PinMessages
}
func canEditChannelMessage(member domain.ChannelMember) bool {
return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.EditMessages)
}

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 >= msgID(pre-history 隐藏)
banned = int64(7006)
restricted = int64(7007) // active 但 BannedRights.ViewMessages
nonMember = int64(7008)
)
opt1, opt2, opt3 := []byte{1}, []byte{2}, []byte{3}
for _, hideResults := range []bool{false, true} {
t.Run(map[bool]string{false: "visible_counts", true: "hide_results"}[hideResults], func(t *testing.T) {
channels := NewChannelStore()
polls := NewPollStore()
channels.AttachPollStore(polls)
channels.channels[channelID] = domain.Channel{ID: channelID, CreatorUserID: creator, Megagroup: true, PreHistoryHidden: true}
channels.members[channelID] = map[int64]domain.ChannelMember{
creator: {UserID: creator, Status: domain.ChannelMemberActive, Role: domain.ChannelRoleCreator},
votedA: {UserID: votedA, Status: domain.ChannelMemberActive},
votedB: {UserID: votedB, Status: domain.ChannelMemberActive},
unvoted: {UserID: unvoted, Status: domain.ChannelMemberActive},
late: {UserID: late, Status: domain.ChannelMemberActive, AvailableMinID: msgID + 10},
banned: {UserID: banned, Status: domain.ChannelMemberBanned},
restricted: {UserID: restricted, Status: domain.ChannelMemberActive, BannedRights: domain.ChannelBannedRights{ViewMessages: true}},
}
def := domain.PollDefinition{
ID: pollID,
CreatorUserID: creator,
Options: [][]byte{opt1, opt2, opt3},
PublicVoters: true,
MultipleChoice: false,
HideResultsUntilClose: hideResults,
}
if err := polls.CreatePoll(ctx, def); err != nil {
t.Fatalf("create poll: %v", err)
}
basePoll := &domain.MessagePoll{
ID: pollID,
Question: "Q?",
Answers: []domain.MessagePollAnswer{{Text: "A", Option: opt1}, {Text: "B", Option: opt2}, {Text: "C", Option: opt3}},
PublicVoters: true,
HideResultsUntilClose: hideResults,
}
channels.messages[channelID] = []domain.ChannelMessage{{
ChannelID: channelID,
ID: msgID,
SenderUserID: creator,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: basePoll},
}}
if err := polls.Vote(pollID, votedA, [][]byte{opt1}, 100); err != nil {
t.Fatalf("vote A: %v", err)
}
if err := polls.Vote(pollID, votedB, [][]byte{opt2}, 101); err != nil {
t.Fatalf("vote B: %v", err)
}
viewers := []int64{creator, votedA, votedB, unvoted, late, banned, restricted, nonMember}
views, err := channels.ChannelPollFanoutViews(ctx, channelID, msgID, viewers, 200)
if err != nil {
t.Fatalf("ChannelPollFanoutViews: %v", err)
}
if !views.Found {
t.Fatal("poll fan-out views Found=false, want true")
}
for _, viewer := range viewers {
got, evaluated := views.Polls[viewer]
if !evaluated {
t.Fatalf("viewer %d not evaluated in batch (all passed viewers must be evaluated)", viewer)
}
// 旧路径参考:GetChannelMessages 按 viewer 取消息(含可见性 + per-viewer poll enrich)。
ref, err := channels.GetChannelMessages(ctx, viewer, channelID, []int{msgID})
var refPoll *domain.MessagePoll
if err == nil {
for _, m := range ref.Messages {
if m.ID == msgID && m.Media != nil {
refPoll = m.Media.Poll
}
}
}
if (got != nil) != (refPoll != nil) {
t.Fatalf("viewer %d visibility mismatch: batch nil=%v, GetMessages nil=%v (err=%v)", viewer, got == nil, refPoll == nil, err)
}
if got != nil && !reflect.DeepEqual(got.Results, refPoll.Results) {
t.Fatalf("viewer %d poll Results mismatch:\n batch=%+v\n old=%+v", viewer, got.Results, refPoll.Results)
}
}
// 显式断言可见性分类,防 GetMessages 与批量同时漏判。
if views.Polls[creator] == nil || views.Polls[votedA] == nil || views.Polls[votedB] == nil || views.Polls[unvoted] == nil {
t.Fatalf("active members should be visible: creator/votedA/votedB/unvoted")
}
if views.Polls[late] != nil || views.Polls[banned] != nil || views.Polls[restricted] != nil || views.Polls[nonMember] != nil {
t.Fatalf("late/banned/restricted/nonMember must be invisible (nil)")
}
// hide_results 期:非创建者已投票者计数应被隐藏(Voters=0),创建者可见真实计数。
if hideResults {
for _, v := range views.Polls[votedA].Results.Voters {
if v.Voters != 0 {
t.Fatalf("hide_results: non-creator votedA should see Voters=0, got %d", v.Voters)
}
}
sawCount := false
for _, v := range views.Polls[creator].Results.Voters {
if v.Voters > 0 {
sawCount = true
}
}
if !sawCount {
t.Fatal("hide_results: creator should see real counts")
}
}
})
}
}

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 +
// pollChannelMessageTarget;poll enrich 委托 PollStore.EnrichPollForViewers(模板一次)。bot 历史过滤
// 在 app 层叠加。Polls:key 存在=已评估;nil=不可见;非 nil=可见 enrich poll。
func (s *ChannelStore) ChannelPollFanoutViews(_ context.Context, channelID int64, msgID int, viewers []int64, now int) (domain.ChannelPollFanoutViews, error) {
out := domain.ChannelPollFanoutViews{Polls: map[int64]*domain.MessagePoll{}}
if s == nil || s.polls == nil || channelID == 0 || msgID <= 0 || len(viewers) == 0 {
return out, nil
}
if now == 0 {
now = int(time.Now().Unix())
}
s.mu.RLock()
defer s.mu.RUnlock()
channel, ok := s.channels[channelID]
if !ok || channel.Deleted {
return out, nil
}
msg, ok := s.findMessageLocked(channelID, msgID)
if !ok || msg.Deleted || msg.Action != nil || msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindPoll || msg.Media.Poll == nil || msg.Media.Poll.ID == 0 {
return out, nil
}
out.Found = true
out.Message = cloneChannelMessage(msg)
visible := make([]int64, 0, len(viewers))
for _, viewer := range viewers {
if viewer == 0 {
continue
}
member, ok := s.members[channelID][viewer]
if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages || (member.AvailableMinID > 0 && msgID <= member.AvailableMinID) {
out.Polls[viewer] = nil // 已评估但不可见
continue
}
visible = append(visible, viewer)
}
enriched := s.polls.EnrichPollForViewers(msg.Media.Poll, visible, now)
for viewer, poll := range enriched {
out.Polls[viewer] = poll
}
return out, nil
}
// channelPollResult 为投票者视角组装结果;实时 fan-out 与 reaction 同款由 rpc 层按 viewer 重建。
func (s *ChannelStore) channelPollResult(channel domain.Channel, msg domain.ChannelMessage, viewerUserID int64, now int) domain.ChannelMessagePollResult {
msg.Media = enrichPollMediaForViewer(s.polls, msg.Media, viewerUserID, now)
return domain.ChannelMessagePollResult{
PollID: msg.Media.Poll.ID,
Channel: channel,
Message: msg,
Recipients: []int64{viewerUserID, msg.SenderUserID},
}
}

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++
// 钳到 MaxDialogUnreadCount(P1-v),与 postgres 的 LIMIT 子查询同 min(actual,cap) 语义。
if unread >= domain.MaxDialogUnreadCount {
break
}
}
}
return unread
}
func (s *ChannelStore) channelUnreadCountLocked(viewerUserID, channelID int64, readMaxID, topID int) int {
if viewerUserID == 0 || channelID == 0 || topID <= readMaxID {
return 0
}
unread := 0
for _, msg := range s.messages[channelID] {
if msg.Deleted || msg.ID <= readMaxID || msg.ID > topID || msg.SenderUserID == viewerUserID {
continue
}
unread++
// 钳到 MaxDialogUnreadCount(P1-v),与 postgres 的 LIMIT 子查询同 min(actual,cap) 语义。
if unread >= domain.MaxDialogUnreadCount {
break
}
}
return unread
}
func (s *ChannelStore) addChannelUnreadMentionsLocked(channelID int64, msg domain.ChannelMessage, senderUserID int64, userIDs []int64) {
if len(userIDs) == 0 || msg.ID == 0 {
return
}
seen := make(map[int64]struct{}, len(userIDs))
written := 0
topID := channelMentionTopID(msg)
for _, userID := range userIDs {
if userID == 0 || userID == senderUserID {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
member, ok := s.members[channelID][userID]
if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages {
continue
}
if msg.ID <= member.AvailableMinID || msg.ID <= member.ReadInboxMaxID {
continue
}
if s.mentions[userID] == nil {
s.mentions[userID] = make(map[int64]map[int]memoryMention)
}
if s.mentions[userID][channelID] == nil {
s.mentions[userID][channelID] = make(map[int]memoryMention)
}
s.mentions[userID][channelID][msg.ID] = memoryMention{topID: topID, unread: true}
written++
if written == domain.MaxChannelMentionRecipients {
return
}
}
}
func (s *ChannelStore) countChannelUnreadMentionsLocked(userID, channelID int64, topMsgID int) int {
count := 0
for _, mention := range s.mentions[userID][channelID] {
if !mention.unread {
continue
}
if topMsgID == 0 || mention.topID == topMsgID || (topMsgID == 1 && mention.topID == 0) {
count++
}
}
return count
}
func (s *ChannelStore) deleteChannelUnreadMentionsLocked(channelID int64, ids []int) {
if len(ids) == 0 {
return
}
set := make(map[int]struct{}, len(ids))
for _, id := range ids {
set[id] = struct{}{}
}
for userID, byChannel := range s.mentions {
mentions := byChannel[channelID]
if len(mentions) == 0 {
continue
}
for id := range set {
delete(mentions, id)
}
if len(mentions) == 0 {
delete(byChannel, channelID)
}
if len(byChannel) == 0 {
delete(s.mentions, userID)
}
}
}
func (s *ChannelStore) deleteChannelUnreadMentionsUpToLocked(userID, channelID int64, maxID int) {
if maxID <= 0 || len(s.mentions[userID][channelID]) == 0 {
return
}
for id := range s.mentions[userID][channelID] {
if id <= maxID {
delete(s.mentions[userID][channelID], id)
}
}
if len(s.mentions[userID][channelID]) == 0 {
delete(s.mentions[userID], channelID)
}
if len(s.mentions[userID]) == 0 {
delete(s.mentions, userID)
}
}
func channelMentionTopID(msg domain.ChannelMessage) int {
if msg.ReplyTo == nil {
return 0
}
if msg.ReplyTo.TopMessageID > 0 {
return msg.ReplyTo.TopMessageID
}
return msg.ReplyTo.MessageID
}
func (s *ChannelStore) populateChannelMessageUnreadFlagsLocked(viewerUserID int64, messages []domain.ChannelMessage) {
if viewerUserID == 0 || len(messages) == 0 {
return
}
for i := range messages {
if messages[i].ChannelID == 0 || messages[i].ID <= 0 {
continue
}
mention, ok := s.mentions[viewerUserID][messages[i].ChannelID][messages[i].ID]
if !ok {
continue
}
messages[i].Mentioned = true
messages[i].MediaUnread = mention.unread
}
}
// clearChannelMentionsForUserLocked 在离开/被踢时清空该用户的提及状态。
func (s *ChannelStore) clearChannelMentionsForUserLocked(channelID, userID int64) {
if byChannel := s.mentions[userID]; byChannel != nil {
delete(byChannel, channelID)
if len(byChannel) == 0 {
delete(s.mentions, userID)
}
}
if dialogs := s.dialogs[userID]; dialogs != nil {
if dialog, ok := dialogs[channelID]; ok {
dialog.UnreadMentions = 0
dialogs[channelID] = dialog
}
}
}

View file

@ -0,0 +1,49 @@
package memory
import (
"testing"
"telesrv/internal/domain"
)
// TestChannelUnreadCountClampedToMax 锁定 P1-v:未读 COUNT 钳到 MaxDialogUnreadCount(min(actual,cap)),
// 与 postgres 的 LIMIT 子查询同语义。直接喂 s.messages 避免 1000+ 次 send 建链路。
func TestChannelUnreadCountClampedToMax(t *testing.T) {
const (
channelID = int64(7000000001)
viewer = int64(5001)
sender = int64(5002)
)
s := NewChannelStore()
over := domain.MaxDialogUnreadCount + 25
msgs := make([]domain.ChannelMessage, 0, over)
for i := 1; i <= over; i++ {
msgs = append(msgs, domain.ChannelMessage{ChannelID: channelID, ID: i, SenderUserID: sender, ReplyTo: &domain.MessageReply{TopMessageID: 100}})
}
s.mu.Lock()
s.messages[channelID] = msgs
s.mu.Unlock()
// 全量未读(readMaxID=0, topID 覆盖全部)→ 应钳到 cap,而非 over。
if got := s.channelUnreadCountLocked(viewer, channelID, 0, over); got != domain.MaxDialogUnreadCount {
t.Fatalf("channelUnreadCountLocked = %d, want clamped %d", got, domain.MaxDialogUnreadCount)
}
// thread 未读同样钳到 cap(全部消息挂在 root=100 线程)。
if got := s.channelThreadUnreadCountLocked(viewer, channelID, 100, 0); got != domain.MaxDialogUnreadCount {
t.Fatalf("channelThreadUnreadCountLocked = %d, want clamped %d", got, domain.MaxDialogUnreadCount)
}
// 未超上界时返回精确值(cap 内不被钳)。
const fewChannel = int64(7000000002)
few := make([]domain.ChannelMessage, 0, 3)
for i := 1; i <= 3; i++ {
few = append(few, domain.ChannelMessage{ChannelID: fewChannel, ID: i, SenderUserID: sender})
}
s.mu.Lock()
s.messages[fewChannel] = few
s.mu.Unlock()
if got := s.channelUnreadCountLocked(viewer, fewChannel, 0, 3); got != 3 {
t.Fatalf("channelUnreadCountLocked(few) = %d, want exact 3 (below cap unaffected)", got)
}
}

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 时清 pinned:TDesktop 在归档/还原时本地无条件 unpin,
// 服务端保留旧 pin 会在下次 getDialogs 时把状态漂移回来。
if list.Dialogs[i].FolderID != folderID {
list.Dialogs[i].Pinned = false
list.Dialogs[i].PinnedOrder = 0
}
list.Dialogs[i].FolderID = folderID
}
s.m[userID] = list
return nil
}
func (s *DialogStore) SetArchivePinned(_ context.Context, userID int64, pinned bool) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
current, ok := s.archivePinned[userID]
if !ok {
current = true
}
s.archivePinned[userID] = pinned
return current != pinned, nil
}
func (s *DialogStore) ArchivePinned(_ context.Context, userID int64) (bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if pinned, ok := s.archivePinned[userID]; ok {
return pinned, nil
}
return true, nil
}
func (s *DialogStore) CountArchiveUnread(_ context.Context, userID int64) (int, int, error) {
s.mu.RLock()
list := s.m[userID]
s.mu.RUnlock()
peers, messages := 0, 0
for _, dialog := range list.Dialogs {
if dialog.FolderID != domain.DialogArchiveFolderID {
continue
}
if dialog.UnreadCount > 0 || dialog.UnreadMark {
peers++
}
messages += dialog.UnreadCount
}
return peers, messages, nil
}
func upsertMemoryDialog(list domain.DialogList, dialog domain.Dialog) domain.DialogList {
for i := range list.Dialogs {
if list.Dialogs[i].Peer == dialog.Peer {
if dialog.ReadInboxMaxID == 0 {
dialog.ReadInboxMaxID = list.Dialogs[i].ReadInboxMaxID
}
if dialog.ReadOutboxMaxID == 0 {
dialog.ReadOutboxMaxID = list.Dialogs[i].ReadOutboxMaxID
}
if dialog.FolderID == domain.DialogMainFolderID && list.Dialogs[i].FolderID != domain.DialogMainFolderID {
dialog.FolderID = list.Dialogs[i].FolderID
}
if dialog.UnreadCount == 0 {
dialog.UnreadCount = list.Dialogs[i].UnreadCount
}
if dialog.UnreadMentions == 0 {
dialog.UnreadMentions = list.Dialogs[i].UnreadMentions
}
if dialog.UnreadReactions == 0 {
dialog.UnreadReactions = list.Dialogs[i].UnreadReactions
}
if !dialog.UnreadMark {
dialog.UnreadMark = list.Dialogs[i].UnreadMark
}
// 与 postgres 的 ON CONFLICT 局部更新对齐:消息路径的 upsert
// 不得抹掉既有置顶/设置类状态。
if !dialog.Pinned && list.Dialogs[i].Pinned {
dialog.Pinned = list.Dialogs[i].Pinned
dialog.PinnedOrder = list.Dialogs[i].PinnedOrder
}
if dialog.TTLPeriod == 0 {
dialog.TTLPeriod = list.Dialogs[i].TTLPeriod
}
if dialog.ThemeEmoticon == "" {
dialog.ThemeEmoticon = list.Dialogs[i].ThemeEmoticon
}
if !dialog.HasScheduled {
dialog.HasScheduled = list.Dialogs[i].HasScheduled
}
if !dialog.PeerSettingsBarHidden {
dialog.PeerSettingsBarHidden = list.Dialogs[i].PeerSettingsBarHidden
}
if !dialog.ViewForumAsMessages {
dialog.ViewForumAsMessages = list.Dialogs[i].ViewForumAsMessages
}
if dialog.Draft == nil {
dialog.Draft = list.Dialogs[i].Draft
}
list.Dialogs[i] = dialog
return list
}
}
list.Dialogs = append(list.Dialogs, dialog)
return list
}
func filterDialogList(list domain.DialogList, filter domain.DialogFilter) domain.DialogList {
sort.SliceStable(list.Dialogs, func(i, j int) bool {
return dialogLess(list.Dialogs[i], list.Dialogs[j])
})
base := make([]domain.Dialog, 0, len(list.Dialogs))
for _, d := range list.Dialogs {
if !dialogMatchesFolder(d, list.Users, filter) {
continue
}
if filter.PinnedOnly && !d.Pinned {
continue
}
if filter.ExcludePinned && d.Pinned {
continue
}
base = append(base, d)
}
list.Count = len(base)
list.Hash = dialogListHash(base)
limit := filter.Limit
if limit <= 0 {
limit = 100
}
if limit > 500 {
limit = 500
}
page := make([]domain.Dialog, 0, len(base))
for _, d := range base {
if !afterDialogOffset(d, filter) {
continue
}
page = append(page, d)
if len(page) >= limit {
break
}
}
list.Dialogs = page
list.Messages = keepDialogMessages(list.Messages, page)
return list
}
func dialogMatchesFolder(d domain.Dialog, users []domain.User, filter domain.DialogFilter) bool {
if !filter.HasFolderID {
// 不带 folder_id 视为主列表(folder 0):归档对话只以 dialogFolder
// 聚合条目出现,DrKLO Android 主列表请求不设 flag。
return d.FolderID == domain.DialogMainFolderID
}
if filter.FolderID < domain.DialogCustomFolderMinID {
return d.FolderID == filter.FolderID
}
if filter.Folder == nil {
return false
}
folder := filter.Folder
if folder.ExcludeArchived && d.FolderID == domain.DialogArchiveFolderID {
return false
}
if folder.ExcludeRead && d.UnreadCount == 0 && !d.UnreadMark {
return false
}
if hasFolderPeer(folder.ExcludePeers, d.Peer) {
return false
}
if hasFolderPeer(folder.IncludePeers, d.Peer) || hasFolderPeer(folder.PinnedPeers, d.Peer) {
return true
}
if d.Peer.Type == domain.PeerTypeUser {
user, ok := findDialogUser(users, d.Peer.ID)
if ok && user.Contact && folder.Contacts {
return true
}
if (!ok || !user.Contact) && folder.NonContacts {
return true
}
}
return false
}
func hasFolderPeer(peers []domain.DialogFolderPeer, peer domain.Peer) bool {
for _, item := range peers {
if item.Peer == peer {
return true
}
}
return false
}
func dialogLess(a, b domain.Dialog) bool {
if a.Pinned != b.Pinned {
return a.Pinned && !b.Pinned
}
if a.Pinned && b.Pinned && a.PinnedOrder != b.PinnedOrder {
if a.PinnedOrder == 0 {
return false
}
if b.PinnedOrder == 0 {
return true
}
return a.PinnedOrder > b.PinnedOrder
}
if a.TopMessageDate != b.TopMessageDate {
return a.TopMessageDate > b.TopMessageDate
}
if a.TopMessage != b.TopMessage {
return a.TopMessage > b.TopMessage
}
return a.Peer.ID > b.Peer.ID
}
func afterDialogOffset(d domain.Dialog, filter domain.DialogFilter) bool {
if filter.OffsetDate <= 0 && filter.OffsetID <= 0 {
return true
}
if filter.OffsetDate > 0 {
if d.TopMessageDate != filter.OffsetDate {
return d.TopMessageDate < filter.OffsetDate
}
if filter.OffsetID <= 0 {
return false
}
if d.TopMessage != filter.OffsetID {
return d.TopMessage < filter.OffsetID
}
if filter.HasOffsetPeer {
return d.Peer.ID < filter.OffsetPeer.ID
}
return false
}
return d.TopMessage < filter.OffsetID
}
func keepDialogMessages(messages []domain.Message, dialogs []domain.Dialog) []domain.Message {
want := make(map[int]struct{}, len(dialogs))
for _, d := range dialogs {
if d.TopMessage != 0 {
want[d.TopMessage] = struct{}{}
}
}
out := make([]domain.Message, 0, len(want))
for _, msg := range messages {
if _, ok := want[msg.ID]; ok {
out = append(out, msg)
}
}
return out
}
func findDialogUser(users []domain.User, id int64) (domain.User, bool) {
for _, user := range users {
if user.ID == id {
return user, true
}
}
return domain.User{}, false
}
func appendDialogUser(list *domain.DialogList, seen map[int64]struct{}, user domain.User) {
if user.ID == 0 {
return
}
if _, ok := seen[user.ID]; ok {
return
}
seen[user.ID] = struct{}{}
list.Users = append(list.Users, user)
}
func cloneDialogs(dialogs []domain.Dialog) []domain.Dialog {
out := append([]domain.Dialog(nil), dialogs...)
for i := range out {
if out[i].Draft != nil {
draft := cloneDialogDraft(*out[i].Draft)
out[i].Draft = &draft
}
}
return out
}
func cloneDialogDraft(draft domain.DialogDraft) domain.DialogDraft {
draft.Entities = append([]domain.MessageEntity(nil), draft.Entities...)
draft.ReplyTo = cloneMessageReply(draft.ReplyTo)
if draft.WebPage != nil {
webpage := *draft.WebPage
draft.WebPage = &webpage
}
return draft
}
func sortDialogDrafts(drafts []domain.DialogDraft) {
sort.SliceStable(drafts, func(i, j int) bool {
if drafts[i].Date != drafts[j].Date {
return drafts[i].Date > drafts[j].Date
}
if drafts[i].Peer.Type != drafts[j].Peer.Type {
return drafts[i].Peer.Type < drafts[j].Peer.Type
}
if drafts[i].Peer.ID != drafts[j].Peer.ID {
return drafts[i].Peer.ID > drafts[j].Peer.ID
}
return drafts[i].TopMessageID > drafts[j].TopMessageID
})
}
func cloneDialogFolder(folder domain.DialogFolder) domain.DialogFolder {
folder.TitleEntities = append([]domain.MessageEntity(nil), folder.TitleEntities...)
folder.PinnedPeers = append([]domain.DialogFolderPeer(nil), folder.PinnedPeers...)
folder.IncludePeers = append([]domain.DialogFolderPeer(nil), folder.IncludePeers...)
folder.ExcludePeers = append([]domain.DialogFolderPeer(nil), folder.ExcludePeers...)
return folder
}
func containsInt(items []int, value int) bool {
for _, item := range items {
if item == value {
return true
}
}
return false
}
func removeInt(items []int, value int) []int {
out := items[:0]
for _, item := range items {
if item != value {
out = append(out, item)
}
}
return out
}
func dialogListHash(dialogs []domain.Dialog) int64 {
if len(dialogs) == 0 {
return 0
}
h := fnv.New64a()
var buf [47]byte
for _, d := range dialogs {
binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID))
binary.LittleEndian.PutUint32(buf[8:12], uint32(d.FolderID))
binary.LittleEndian.PutUint32(buf[12:16], uint32(d.TopMessage))
binary.LittleEndian.PutUint32(buf[16:20], uint32(d.TopMessageDate))
binary.LittleEndian.PutUint32(buf[20:24], uint32(d.ReadInboxMaxID))
binary.LittleEndian.PutUint32(buf[24:28], uint32(d.ReadOutboxMaxID))
binary.LittleEndian.PutUint32(buf[28:32], uint32(d.UnreadCount))
binary.LittleEndian.PutUint32(buf[32:36], uint32(d.UnreadMentions))
binary.LittleEndian.PutUint32(buf[36:40], uint32(d.UnreadReactions))
if d.Pinned {
buf[40] = 1
} else {
buf[40] = 0
}
binary.LittleEndian.PutUint32(buf[41:45], uint32(d.PinnedOrder))
if d.UnreadMark {
buf[45] = 1
} else {
buf[45] = 0
}
if d.PeerSettingsBarHidden {
buf[46] = 1
} else {
buf[46] = 0
}
_, _ = h.Write(buf[:])
_, _ = h.Write([]byte(d.ThemeEmoticon))
}
return int64(h.Sum64())
}

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 {
// 替换 markup(nil/空 = 清空键盘);双盒一致。
msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup)
}
msg.EditDate = req.EditDate
msg.Pts = s.nextPtsLocked(userID)
s.m[userID][i] = msg
event := editMessageEvent(msg)
res.Edited = append(res.Edited, domain.EditedMessageForUser{
UserID: userID,
Message: cloneMessage(msg),
Event: event,
})
}
}
}
if s.dialogs != nil {
s.dialogs.mu.Lock()
for userID := range s.dialogs.m {
list := s.dialogs.m[userID]
list.Messages = cloneMessages(s.m[userID])
s.dialogs.m[userID] = list
}
s.dialogs.mu.Unlock()
}
sort.Slice(res.Edited, func(i, j int) bool { return res.Edited[i].UserID < res.Edited[j].UserID })
return res, nil
}
func validMemoryTodoParticipantEdit(req domain.EditMessageRequest, target domain.Message) bool {
if !req.AllowTodoParticipantMutation || req.SetReplyMarkup || req.Media == nil || req.Media.Kind != domain.MessageMediaKindTodo || req.Media.Todo == nil {
return false
}
if target.From.ID == req.OwnerUserID {
return false
}
if target.Body != req.Message || !equalMessageEntities(target.Entities, req.Entities) {
return false
}
if target.Media == nil || target.Media.Kind != domain.MessageMediaKindTodo || target.Media.Todo == nil {
return false
}
return true
}

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 权威(投票校验与读路径 enrichment);nil 时 poll 链路按未接入处理。
polls *PollStore
// savedPins 是收藏夹子会话置顶顺序(下标即 pinned_order,越小越前)。
savedPins map[int64][]domain.Peer
}
// AttachPollStore 注入共享 poll 权威(与 ChannelStore 共用同一实例)。
func (s *MessageStore) AttachPollStore(polls *PollStore) {
s.polls = polls
}
type readOutboxDateKey struct {
ownerUserID int64
peerID int64
msgID int
}
// NewMessageStore 创建内存 MessageStore。
func NewMessageStore(dialogs ...*DialogStore) *MessageStore {
s := &MessageStore{
m: make(map[int64][]domain.Message),
nextUID: 1,
nextBox: make(map[int64]int),
nextPts: make(map[int64]int),
readOutboxDates: make(map[readOutboxDateKey]int),
privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction),
savedPins: make(map[int64][]domain.Peer),
}
if len(dialogs) > 0 {
s.dialogs = dialogs[0]
}
return s
}

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)
}
}
// 多 reactor:TopReactors 按星数降序,本人始终在列。
func TestAddChannelMessagePaidReactionTopReactors(t *testing.T) {
st := NewChannelStore()
ctx := context.Background()
const creator = int64(1000000001)
channelID, msgID := seedBroadcastPost(t, st, creator, true)
// 让另外两个用户成为成员并增投(直接写 store 累计,绕过成员校验仅测聚合)。
for _, c := range []struct {
user int64
stars int64
}{{creator, 30}, {2000000002, 200}, {2000000003, 80}} {
// 仅 creator 经正式路径;其他用户直接累计以构造排行。
if c.user == creator {
if _, err := st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
UserID: c.user, ChannelID: channelID, MessageID: msgID, Stars: c.stars, Date: 1700000010,
}); err != nil {
t.Fatalf("creator paid reaction: %v", err)
}
continue
}
st.mu.Lock()
st.paidReactions[channelID][msgID][c.user] = memoryPaidReaction{stars: c.stars, date: 1700000010}
st.mu.Unlock()
}
res, err := st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
UserID: creator, ChannelID: channelID, MessageID: msgID, Stars: 0 + 1, Date: 1700000011,
})
// creator 现在 31+? 重新算:creator 30 + 这次 1 = 31。
if err != nil {
t.Fatalf("paid reaction: %v", err)
}
if res.Paid.TotalStars != 31+200+80 {
t.Fatalf("total = %d, want %d", res.Paid.TotalStars, 31+200+80)
}
// 降序:200, 80, 31。
if len(res.Paid.TopReactors) != 3 || res.Paid.TopReactors[0].Stars != 200 || res.Paid.TopReactors[1].Stars != 80 || res.Paid.TopReactors[2].Stars != 31 {
t.Fatalf("top reactors = %+v, want 200/80/31 desc", res.Paid.TopReactors)
}
if !res.Paid.TopReactors[2].My {
t.Fatalf("creator (31) must carry My flag, got %+v", res.Paid.TopReactors[2])
}
}
// 非广播频道拒绝付费 reaction。
func TestAddChannelMessagePaidReactionRejectsMegagroup(t *testing.T) {
st := NewChannelStore()
ctx := context.Background()
const creator = int64(1000000001)
channelID, msgID := seedBroadcastPost(t, st, creator, false)
_, err := st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{
UserID: creator, ChannelID: channelID, MessageID: msgID, Stars: 10, Date: 1700000001,
})
if !errors.Is(err, domain.ErrReactionInvalid) {
t.Fatalf("megagroup paid reaction err = %v, want ErrReactionInvalid", err)
}
}

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 的 poll(fan-out 模板化):
// viewer-invariant 聚合(counts/total/recent)只遍历一次 + per-viewer ViewerOptions,每 viewer 用与
// 单 viewer EnrichPoll 完全相同的 ResolvePollResults/ApplyPollState 合成(字节同源)。返回 map[viewer]
// 各自的 poll 克隆;def 不存在时返回空 map(与 EnrichPoll 的 no-op 一致)。
func (s *PollStore) EnrichPollForViewers(basePoll *domain.MessagePoll, viewers []int64, now int) map[int64]*domain.MessagePoll {
out := make(map[int64]*domain.MessagePoll, len(viewers))
if s == nil || basePoll == nil || basePoll.ID == 0 || len(viewers) == 0 {
return out
}
s.mu.RLock()
defer s.mu.RUnlock()
def, ok := s.polls[basePoll.ID]
if !ok {
return out
}
counts := make(map[string]int)
total := 0
optionsByUser := make(map[int64][][]byte, len(s.votes[basePoll.ID]))
recent := make([]domain.PollVote, 0, len(s.votes[basePoll.ID]))
for userID, vote := range s.votes[basePoll.ID] {
total++
for _, option := range vote.Options {
counts[string(option)]++
}
optionsByUser[userID] = append([][]byte(nil), vote.Options...)
recent = append(recent, vote)
}
sort.Slice(recent, func(i, j int) bool {
if recent[i].Date != recent[j].Date {
return recent[i].Date > recent[j].Date
}
return recent[i].UserID > recent[j].UserID
})
recentVoters := make([]int64, 0, domain.MaxPollRecentVoters)
for i, vote := range recent {
if i >= domain.MaxPollRecentVoters {
break
}
recentVoters = append(recentVoters, vote.UserID)
}
for _, viewer := range viewers {
agg := domain.PollAggregates{
Counts: counts,
TotalVoters: total,
RecentVoters: recentVoters,
ViewerOptions: optionsByUser[viewer],
}
results := domain.ResolvePollResults(def, agg, viewer, now)
pollCopy := *basePoll
domain.ApplyPollState(&pollCopy, def, results, now)
out[viewer] = &pollCopy
}
return out
}
// enrichPollMediaForViewer 克隆 media(避免共享指针把 viewer 态写进 store 本体)并 enrich。
func enrichPollMediaForViewer(polls *PollStore, media *domain.MessageMedia, viewerUserID int64, now int) *domain.MessageMedia {
if polls == nil || media == nil || media.Kind != domain.MessageMediaKindPoll || media.Poll == nil {
return media
}
cloned := *media
poll := *media.Poll
poll.Answers = append([]domain.MessagePollAnswer(nil), media.Poll.Answers...)
cloned.Poll = &poll
polls.EnrichPoll(cloned.Poll, viewerUserID, now)
return &cloned
}
func clonePollDefinition(def domain.PollDefinition) domain.PollDefinition {
def.Options = cloneOptionList(def.Options)
def.CorrectOptions = cloneOptionList(def.CorrectOptions)
def.SolutionEntities = append([]domain.MessageEntity(nil), def.SolutionEntities...)
return def
}
func clonePollVote(vote domain.PollVote) domain.PollVote {
vote.Options = cloneOptionList(vote.Options)
return vote
}
func cloneOptionList(options [][]byte) [][]byte {
if options == nil {
return nil
}
out := make([][]byte, 0, len(options))
for _, option := range options {
out = append(out, append([]byte(nil), option...))
}
return out
}
func voteHasOption(vote domain.PollVote, option []byte) bool {
for _, candidate := range vote.Options {
if string(candidate) == string(option) {
return true
}
}
return false
}

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 = MaxMessageBoxID(int32 max),用 >= 命中首页
// 分支,避免被误判为续页跳过置顶块(与 postgres 对齐)。
firstPage := offsetID <= 0 || offsetID >= domain.MaxMessageBoxID
s.mu.RLock()
defer s.mu.RUnlock()
tops := s.savedDialogTopsLocked(userID)
topByPeer := make(map[domain.Peer]domain.Message, len(tops))
for _, msg := range tops {
topByPeer[msg.SavedPeer] = msg
}
// 首页且不排除置顶:置顶块按 pinned_order 在前。
if firstPage && !filter.ExcludePinned {
for _, peer := range s.savedPins[userID] {
if len(out.Dialogs) >= limit {
break
}
if msg, ok := topByPeer[peer]; ok {
s.appendSavedDialogLocked(&out, msg, true)
}
}
}
// 普通块恒排除置顶(置顶只随首页返回)。
hasMore := false
for _, msg := range tops {
if len(out.Dialogs) >= limit {
if s.savedPinIndexLocked(userID, msg.SavedPeer) < 0 &&
(firstPage || msg.ID < offsetID) {
hasMore = true
}
continue
}
if s.savedPinIndexLocked(userID, msg.SavedPeer) >= 0 {
continue
}
if !firstPage && msg.ID >= offsetID {
continue
}
s.appendSavedDialogLocked(&out, msg, false)
}
total := 0
for _, msg := range tops {
if filter.ExcludePinned && s.savedPinIndexLocked(userID, msg.SavedPeer) >= 0 {
continue
}
total++
}
out.Count = total
out.Full = !hasMore
return out, nil
}
func (s *MessageStore) ListPinnedSavedDialogs(_ context.Context, userID int64) (domain.SavedDialogList, error) {
out := domain.SavedDialogList{Full: true}
if userID == 0 {
return out, fmt.Errorf("list pinned saved dialogs: missing user id")
}
s.mu.RLock()
defer s.mu.RUnlock()
tops := s.savedDialogTopsLocked(userID)
topByPeer := make(map[domain.Peer]domain.Message, len(tops))
for _, msg := range tops {
topByPeer[msg.SavedPeer] = msg
}
for _, peer := range s.savedPins[userID] {
if msg, ok := topByPeer[peer]; ok {
s.appendSavedDialogLocked(&out, msg, true)
}
}
out.Count = len(out.Dialogs)
return out, nil
}
func (s *MessageStore) ListSavedDialogsByPeers(_ context.Context, userID int64, peers []domain.Peer) (domain.SavedDialogList, error) {
out := domain.SavedDialogList{Full: true}
if userID == 0 {
return out, fmt.Errorf("list saved dialogs by peers: missing user id")
}
s.mu.RLock()
defer s.mu.RUnlock()
tops := s.savedDialogTopsLocked(userID)
topByPeer := make(map[domain.Peer]domain.Message, len(tops))
for _, msg := range tops {
topByPeer[msg.SavedPeer] = msg
}
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if peer.Type == "" || peer.ID == 0 {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
if msg, ok := topByPeer[peer]; ok {
s.appendSavedDialogLocked(&out, msg, s.savedPinIndexLocked(userID, peer) >= 0)
}
}
out.Count = len(out.Dialogs)
return out, nil
}
func (s *MessageStore) ToggleSavedDialogPin(_ context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) {
if userID == 0 || peer.Type == "" || peer.ID == 0 {
return false, fmt.Errorf("toggle saved dialog pin: invalid input")
}
s.mu.Lock()
defer s.mu.Unlock()
idx := s.savedPinIndexLocked(userID, peer)
if !pinned {
if idx < 0 {
return false, nil
}
s.savedPins[userID] = append(s.savedPins[userID][:idx], s.savedPins[userID][idx+1:]...)
return true, nil
}
if idx >= 0 {
return false, nil
}
if len(s.savedPins[userID]) >= domain.MaxPinnedSavedDialogs {
return false, domain.ErrPinnedSavedDialogsTooMuch
}
s.savedPins[userID] = append([]domain.Peer{peer}, s.savedPins[userID]...)
return true, nil
}
func (s *MessageStore) ReorderPinnedSavedDialogs(_ context.Context, userID int64, order []domain.Peer, force bool) error {
if userID == 0 {
return fmt.Errorf("reorder pinned saved dialogs: missing user id")
}
if len(order) > domain.MaxPinnedSavedDialogs {
return domain.ErrPinnedSavedDialogsTooMuch
}
s.mu.Lock()
defer s.mu.Unlock()
next := make([]domain.Peer, 0, len(order))
seen := make(map[domain.Peer]struct{}, len(order))
for _, peer := range order {
if peer.Type == "" || peer.ID == 0 {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
next = append(next, peer)
}
if !force {
// 非 force:order 之外的既有置顶保持原相对顺序排在后面。
for _, peer := range s.savedPins[userID] {
if _, ok := seen[peer]; !ok {
next = append(next, peer)
}
}
}
s.savedPins[userID] = next
return nil
}
func (s *MessageStore) DeleteSavedHistory(_ context.Context, req domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error) {
res := domain.DeleteSavedHistoryResult{}
if req.OwnerUserID == 0 || req.SavedPeer.Type == "" || req.SavedPeer.ID == 0 {
return res, fmt.Errorf("delete saved history: invalid input")
}
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
selfPeer := domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}
s.mu.Lock()
defer s.mu.Unlock()
match := func(msg domain.Message) bool {
if msg.Peer != selfPeer || msg.SavedPeer != req.SavedPeer {
return false
}
if req.MaxID > 0 && msg.ID > req.MaxID {
return false
}
if req.MinDate > 0 && msg.Date < req.MinDate {
return false
}
if req.MaxDate > 0 && msg.Date > req.MaxDate {
return false
}
return true
}
deleted, _, more := s.deleteMemoryMessagesLocked(req.OwnerUserID, domain.MaxDeleteHistoryBatch, match)
delRes := s.finishMemoryDeleteLocked(domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID}, deleted, req.Date, false)
res.More = more
for _, d := range delRes.Deleted {
if d.UserID == req.OwnerUserID {
res.MessageIDs = d.MessageIDs
res.Event = d.Event
}
}
if len(deleted) > 0 && !more {
// 子会话删空时清掉它的置顶行(与 PG 实现同语义)。
alive := false
for _, msg := range s.m[req.OwnerUserID] {
if msg.Peer == selfPeer && msg.SavedPeer == req.SavedPeer {
alive = true
break
}
}
if !alive {
if idx := s.savedPinIndexLocked(req.OwnerUserID, req.SavedPeer); idx >= 0 {
s.savedPins[req.OwnerUserID] = append(s.savedPins[req.OwnerUserID][:idx], s.savedPins[req.OwnerUserID][idx+1:]...)
}
}
}
return res, nil
}

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/about(setBotInfo 的 name/about)。
func (s *UserStore) updateBotProfile(userID int64, setName bool, name string, setAbout bool, about string) bool {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.byID[userID]
if !ok || !u.Bot {
return false
}
if setName {
u.FirstName = name
}
if setAbout {
u.About = about
}
s.byID[userID] = u
return true
}
// SetPremiumUntil 把会员到期时间设为绝对 Unix 秒(0 = 清除会员)。
func (s *UserStore) SetPremiumUntil(_ context.Context, userID int64, until int) (domain.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.byID[userID]
if !ok {
return domain.User{}, domain.ErrUserNotFound
}
if until < 0 {
until = 0
}
u.PremiumUntil = until
s.byID[userID] = u
return u, nil
}
// SetVerified 设置/取消用户认证标记。
func (s *UserStore) SetVerified(_ context.Context, userID int64, verified bool) (domain.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.byID[userID]
if !ok {
return domain.User{}, domain.ErrUserNotFound
}
u.Verified = verified
s.byID[userID] = u
return u, nil
}
// SweepExpiredPremium 清空到期会员行并返回清理后的用户(与 postgres 语义一致)。
func (s *UserStore) SweepExpiredPremium(_ context.Context, now int64, limit int) ([]domain.User, error) {
if limit <= 0 {
return nil, nil
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.User, 0)
for id, u := range s.byID {
if u.PremiumUntil <= 0 || int64(u.PremiumUntil) > now {
continue
}
u.PremiumUntil = 0
s.byID[id] = u
out = append(out, u)
if len(out) >= limit {
break
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除)。
func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, documentID int64, until int) (domain.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.byID[userID]
if !ok {
return domain.User{}, domain.ErrUserNotFound
}
if documentID == 0 {
until = 0
}
u.EmojiStatusDocumentID = documentID
u.EmojiStatusUntil = until
s.byID[userID] = u
return u, nil
}
func (s *UserStore) UpdateColor(_ context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.byID[userID]
if !ok {
return domain.User{}, domain.ErrUserNotFound
}
if forProfile {
u.ProfileColor = color
} else {
u.Color = color
}
s.byID[userID] = u
return u, nil
}
func (s *UserStore) UpdateLastSeen(_ context.Context, userID int64, lastSeenAt int) error {
if lastSeenAt <= 0 {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.byID[userID]
if !ok {
return domain.ErrUsernameNotOccupied
}
if lastSeenAt > u.LastSeenAt {
u.LastSeenAt = lastSeenAt
s.byID[userID] = u
}
return nil
}
func userMatchesSearch(u domain.User, query, phoneQuery string) bool {
if phoneQuery != "" && strings.HasPrefix(u.Phone, phoneQuery) {
return true
}
first := strings.ToLower(u.FirstName)
last := strings.ToLower(u.LastName)
username := strings.ToLower(u.Username)
fullName := strings.TrimSpace(first + " " + last)
return strings.Contains(first, query) ||
strings.Contains(last, query) ||
strings.Contains(fullName, query) ||
strings.Contains(username, query)
}
func (s *UserStore) Create(_ context.Context, u domain.User) (domain.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
username := strings.ToLower(strings.TrimSpace(u.Username))
if username != "" {
for _, existing := range s.byID {
if strings.ToLower(existing.Username) == username {
return domain.User{}, domain.ErrUsernameOccupied
}
}
}
u.ID = s.nextID
s.nextID++
s.byID[u.ID] = u
return u, nil
}

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

View file

@ -16,9 +16,24 @@ type MessageStore interface {
GetOutboxReadDate(ctx context.Context, req domain.OutboxReadDateRequest) (int, error)
SetMessageReactions(ctx context.Context, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
GetMessageReactions(ctx context.Context, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
VoteMessagePoll(ctx context.Context, req domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error)
CloseMessagePoll(ctx context.Context, req domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error)
EditMessage(ctx context.Context, req domain.EditMessageRequest) (domain.EditMessageResult, error)
PinPrivateMessage(ctx context.Context, req domain.PinPrivateMessageRequest) (domain.PinPrivateMessageResult, error)
UnpinAllPrivateMessages(ctx context.Context, req domain.UnpinAllPrivateMessagesRequest) (domain.PinPrivateMessageResult, error)
DeleteMessages(ctx context.Context, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error)
DeleteHistory(ctx context.Context, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error)
GetByIDs(ctx context.Context, userID int64, ids []int) (domain.MessageList, error)
ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error)
// SearchPrivateMedia 返回某私聊会话中属于给定媒体类别的消息(共享媒体标签页),newest-first 分页。
SearchPrivateMedia(ctx context.Context, ownerUserID, peerID int64, req domain.MediaSearchRequest) (domain.MessageList, error)
// CountPrivateMediaCategories 返回某私聊会话按基础媒体类别聚合的精确计数。
CountPrivateMediaCategories(ctx context.Context, ownerUserID, peerID int64) (domain.MediaCategoryCounts, error)
// Saved Messages 分会话(self-chat 按 saved_peer 分组的子会话)。
ListSavedDialogs(ctx context.Context, userID int64, filter domain.SavedDialogsFilter) (domain.SavedDialogList, error)
ListPinnedSavedDialogs(ctx context.Context, userID int64) (domain.SavedDialogList, error)
ListSavedDialogsByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.SavedDialogList, error)
ToggleSavedDialogPin(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error)
ReorderPinnedSavedDialogs(ctx context.Context, userID int64, order []domain.Peer, force bool) error
DeleteSavedHistory(ctx context.Context, req domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error)
}

24
internal/store/passkey.go Normal file
View file

@ -0,0 +1,24 @@
package store
import (
"context"
"time"
"telesrv/internal/domain"
)
// PasskeyStore 持久化已注册的 passkey 凭据(durable)。
type PasskeyStore interface {
InsertPasskey(ctx context.Context, cred domain.PasskeyCredential) error
GetPasskeyByCredentialID(ctx context.Context, credentialID []byte) (domain.PasskeyCredential, bool, error)
ListPasskeysByUser(ctx context.Context, userID int64) ([]domain.PasskeyCredential, error)
UpdatePasskeyUsage(ctx context.Context, credentialID []byte, signCount uint32, lastUsedAt int64) error
DeletePasskey(ctx context.Context, userID int64, credentialID []byte) (bool, error)
}
// PasskeyChallengeStore 暂存一次性 WebAuthn 挑战(短 TTL、用后即焚)。
type PasskeyChallengeStore interface {
SavePasskeyChallenge(ctx context.Context, challenge []byte, c domain.PasskeyChallenge, ttl time.Duration) error
// ConsumePasskeyChallenge 取出并删除挑战;过期或不存在返回 found=false。
ConsumePasskeyChallenge(ctx context.Context, challenge []byte) (domain.PasskeyChallenge, bool, error)
}

16
internal/store/poll.go Normal file
View file

@ -0,0 +1,16 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// PollStore 持久化 poll 权威态(定义 + closed + 机密)与投票人列表查询。
// 投票/关闭走 MessageStore.VoteMessagePoll / ChannelStore.VoteChannelMessagePoll 等
// 消息侧入口(需要消息可见性校验);本接口只承担发送时建 poll 与 getPollVotes 列表。
type PollStore interface {
CreatePoll(ctx context.Context, def domain.PollDefinition) error
GetPollDefinition(ctx context.Context, pollID int64) (domain.PollDefinition, bool, error)
ListPollVotes(ctx context.Context, req domain.PollVotesListRequest) (domain.PollVotesList, error)
}

View file

@ -31,7 +31,7 @@ SELECT
email_unconfirmed_pattern, login_email_pattern, secure_random,
current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p,
srp_id, srp_verifier, srp_b_secret, srp_b,
recovery_email, recovery_code, recovery_code_expires_at
recovery_email, recovery_code, recovery_code_expires_at, login_email
FROM account_passwords
WHERE user_id = $1`, userID)
var settings domain.PasswordSettings
@ -42,7 +42,7 @@ WHERE user_id = $1`, userID)
&settings.EmailUnconfirmedPattern, &settings.LoginEmailPattern, &settings.SecureRandom,
&salt1, &salt2, &settings.NewAlgo.G, &p,
&settings.SRPID, &settings.SRPVerifier, &settings.SRPBSecret, &settings.SRPB,
&settings.RecoveryEmail, &settings.RecoveryCode, &recoveryExpires,
&settings.RecoveryEmail, &settings.RecoveryCode, &recoveryExpires, &settings.LoginEmail,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.PasswordSettings{}, false, nil
@ -85,9 +85,9 @@ INSERT INTO account_passwords (
email_unconfirmed_pattern, login_email_pattern, secure_random,
current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p,
srp_id, srp_verifier, srp_b_secret, srp_b,
recovery_email, recovery_code, recovery_code_expires_at
recovery_email, recovery_code, recovery_code_expires_at, login_email
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
ON CONFLICT (user_id) DO UPDATE SET
has_recovery = EXCLUDED.has_recovery,
has_secure_values = EXCLUDED.has_secure_values,
@ -107,13 +107,14 @@ ON CONFLICT (user_id) DO UPDATE SET
recovery_email = EXCLUDED.recovery_email,
recovery_code = EXCLUDED.recovery_code,
recovery_code_expires_at = EXCLUDED.recovery_code_expires_at,
login_email = EXCLUDED.login_email,
updated_at = now()`,
userID,
settings.HasRecovery, settings.HasSecureValues, settings.HasPassword, settings.Hint,
settings.EmailUnconfirmedPattern, settings.LoginEmailPattern, settings.SecureRandom,
algo.Salt1, algo.Salt2, algo.G, algo.P,
settings.SRPID, settings.SRPVerifier, settings.SRPBSecret, settings.SRPB,
settings.RecoveryEmail, settings.RecoveryCode, recoveryExpires,
settings.EmailUnconfirmedPattern, settings.LoginEmailPattern, nonNilBytea(settings.SecureRandom),
nonNilBytea(algo.Salt1), nonNilBytea(algo.Salt2), algo.G, nonNilBytea(algo.P),
settings.SRPID, nonNilBytea(settings.SRPVerifier), nonNilBytea(settings.SRPBSecret), nonNilBytea(settings.SRPB),
settings.RecoveryEmail, settings.RecoveryCode, recoveryExpires, settings.LoginEmail,
)
if err != nil {
return fmt.Errorf("upsert account password: %w", err)
@ -121,6 +122,13 @@ ON CONFLICT (user_id) DO UPDATE SET
return nil
}
func nonNilBytea(in []byte) []byte {
if in != nil {
return in
}
return []byte{}
}
func (s *PasswordStore) GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT messages_notify_from, stories_notify_from, poll_votes_notify_from, show_previews,
@ -146,7 +154,9 @@ WHERE user_id = $1`, userID)
settings.Notify.MessagesFrom = domain.ReactionNotifyFrom(messagesFrom)
settings.Notify.StoriesFrom = domain.ReactionNotifyFrom(storiesFrom)
settings.Notify.PollVotesFrom = domain.ReactionNotifyFrom(pollVotesFrom)
settings.DefaultReaction = domain.MessageReaction{Type: domain.MessageReactionType(defaultType), Emoticon: defaultValue}
if reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(defaultType), defaultValue); ok {
settings.DefaultReaction = reaction
}
settings.PaidPrivacy = domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyKind(paidKind)}
if settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyPeer && paidPeerType.Valid && paidPeerID.Valid {
peer := domain.Peer{Type: domain.PeerType(paidPeerType.String), ID: paidPeerID.Int64}
@ -180,10 +190,601 @@ ON CONFLICT (user_id) DO UPDATE SET
updated_at = now()`,
userID,
string(settings.Notify.MessagesFrom), string(settings.Notify.StoriesFrom), string(settings.Notify.PollVotesFrom), settings.Notify.ShowPreviews,
string(settings.DefaultReaction.Type), settings.DefaultReaction.Emoticon,
string(settings.DefaultReaction.Type), settings.DefaultReaction.Value(),
string(settings.PaidPrivacy.Kind), paidPeerType, paidPeerID,
); err != nil {
return fmt.Errorf("save account reaction settings: %w", err)
}
return nil
}
func (s *PasswordStore) GetAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent
FROM account_settings
WHERE user_id = $1`, userID)
settings := domain.DefaultAccountSettings()
gp := &settings.GlobalPrivacy
if err := row.Scan(
&gp.ArchiveAndMuteNewNoncontactPeers, &gp.KeepArchivedUnmuted, &gp.KeepArchivedFolders,
&gp.HideReadMarks, &gp.NewNoncontactPeersRequirePremium, &gp.DisplayGiftsButton,
&gp.NoncontactPeersPaidStars, &settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountSettings{}, false, nil
}
return domain.AccountSettings{}, false, fmt.Errorf("get account settings: %w", err)
}
return settings, true, nil
}
func (s *PasswordStore) SaveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) error {
gp := settings.GlobalPrivacy
if _, err := s.db.Exec(ctx, `
INSERT INTO account_settings (
user_id, archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (user_id) DO UPDATE SET
archive_and_mute_new_noncontact_peers = EXCLUDED.archive_and_mute_new_noncontact_peers,
keep_archived_unmuted = EXCLUDED.keep_archived_unmuted,
keep_archived_folders = EXCLUDED.keep_archived_folders,
hide_read_marks = EXCLUDED.hide_read_marks,
new_noncontact_peers_require_premium = EXCLUDED.new_noncontact_peers_require_premium,
display_gifts_button = EXCLUDED.display_gifts_button,
noncontact_peers_paid_stars = EXCLUDED.noncontact_peers_paid_stars,
account_ttl_days = EXCLUDED.account_ttl_days,
sensitive_content_enabled = EXCLUDED.sensitive_content_enabled,
contact_signup_silent = EXCLUDED.contact_signup_silent,
updated_at = now()`,
userID,
gp.ArchiveAndMuteNewNoncontactPeers, gp.KeepArchivedUnmuted, gp.KeepArchivedFolders,
gp.HideReadMarks, gp.NewNoncontactPeersRequirePremium, gp.DisplayGiftsButton,
gp.NoncontactPeersPaidStars, settings.NormalizedTTLDays(), settings.SensitiveContentEnabled, settings.ContactSignUpSilent,
); err != nil {
return fmt.Errorf("save account settings: %w", err)
}
return nil
}
func notifyScopeColumns(scope domain.NotifyScope) (kind, peerType string, peerID int64, topicID int) {
kind = string(scope.Kind)
if scope.Kind == domain.NotifyScopePeer {
return kind, string(scope.Peer.Type), scope.Peer.ID, scope.TopicID
}
return kind, "", 0, 0
}
func scanNotifySettings(row pgx.Row) (domain.PeerNotifySettings, error) {
var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool
var muteUntil sql.NullInt32
if err := row.Scan(&showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender); err != nil {
return domain.PeerNotifySettings{}, err
}
out := domain.PeerNotifySettings{}
if showPreviews.Valid {
out.ShowPreviews = &showPreviews.Bool
}
if silent.Valid {
out.Silent = &silent.Bool
}
if muteUntil.Valid {
v := int(muteUntil.Int32)
out.MuteUntil = &v
}
if storiesMuted.Valid {
out.StoriesMuted = &storiesMuted.Bool
}
if storiesHideSender.Valid {
out.StoriesHideSender = &storiesHideSender.Bool
}
return out, nil
}
func (s *PasswordStore) GetNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope) (domain.PeerNotifySettings, bool, error) {
kind, peerType, peerID, topicID := notifyScopeColumns(scope)
row := s.db.QueryRow(ctx, `
SELECT show_previews, silent, mute_until, stories_muted, stories_hide_sender
FROM notify_settings
WHERE owner_user_id = $1 AND scope_kind = $2 AND peer_type = $3 AND peer_id = $4 AND topic_id = $5`,
ownerUserID, kind, peerType, peerID, topicID)
settings, err := scanNotifySettings(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.PeerNotifySettings{}, false, nil
}
return domain.PeerNotifySettings{}, false, fmt.Errorf("get notify settings: %w", err)
}
return settings, true, nil
}
func (s *PasswordStore) SaveNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope, settings domain.PeerNotifySettings) error {
kind, peerType, peerID, topicID := notifyScopeColumns(scope)
if _, err := s.db.Exec(ctx, `
INSERT INTO notify_settings (
owner_user_id, scope_kind, peer_type, peer_id, topic_id,
show_previews, silent, mute_until, stories_muted, stories_hide_sender
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (owner_user_id, scope_kind, peer_type, peer_id, topic_id) DO UPDATE SET
show_previews = EXCLUDED.show_previews,
silent = EXCLUDED.silent,
mute_until = EXCLUDED.mute_until,
stories_muted = EXCLUDED.stories_muted,
stories_hide_sender = EXCLUDED.stories_hide_sender,
updated_at = now()`,
ownerUserID, kind, peerType, peerID, topicID,
nullableBool(settings.ShowPreviews), nullableBool(settings.Silent), nullableInt(settings.MuteUntil),
nullableBool(settings.StoriesMuted), nullableBool(settings.StoriesHideSender),
); err != nil {
return fmt.Errorf("save notify settings: %w", err)
}
return nil
}
func (s *PasswordStore) ResetNotifySettings(ctx context.Context, ownerUserID int64) error {
if _, err := s.db.Exec(ctx, `DELETE FROM notify_settings WHERE owner_user_id = $1`, ownerUserID); err != nil {
return fmt.Errorf("reset notify settings: %w", err)
}
return nil
}
func (s *PasswordStore) GetPeerNotifySettings(ctx context.Context, ownerUserID int64, peers []domain.Peer) (map[domain.Peer]domain.PeerNotifySettings, error) {
out := make(map[domain.Peer]domain.PeerNotifySettings, len(peers))
if len(peers) == 0 {
return out, nil
}
types := make([]string, 0, len(peers))
ids := make([]int64, 0, len(peers))
for _, p := range peers {
types = append(types, string(p.Type))
ids = append(ids, p.ID)
}
rows, err := s.db.Query(ctx, `
SELECT peer_type, peer_id, show_previews, silent, mute_until, stories_muted, stories_hide_sender
FROM notify_settings
WHERE owner_user_id = $1 AND scope_kind = 'peer' AND topic_id = 0
AND (peer_type, peer_id) IN (SELECT * FROM unnest($2::text[], $3::bigint[]))`,
ownerUserID, types, ids)
if err != nil {
return nil, fmt.Errorf("batch get notify settings: %w", err)
}
defer rows.Close()
for rows.Next() {
var peerType string
var peerID int64
var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool
var muteUntil sql.NullInt32
if err := rows.Scan(&peerType, &peerID, &showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender); err != nil {
return nil, fmt.Errorf("scan notify settings: %w", err)
}
settings := domain.PeerNotifySettings{}
if showPreviews.Valid {
settings.ShowPreviews = &showPreviews.Bool
}
if silent.Valid {
settings.Silent = &silent.Bool
}
if muteUntil.Valid {
v := int(muteUntil.Int32)
settings.MuteUntil = &v
}
if storiesMuted.Valid {
settings.StoriesMuted = &storiesMuted.Bool
}
if storiesHideSender.Valid {
settings.StoriesHideSender = &storiesHideSender.Bool
}
out[domain.Peer{Type: domain.PeerType(peerType), ID: peerID}] = settings
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate notify settings: %w", err)
}
return out, nil
}
func (s *PasswordStore) AllPeerNotifySettings(ctx context.Context, ownerUserID int64) (map[domain.Peer]domain.PeerNotifySettings, error) {
// owner-scoped 单查询,走部分索引 notify_settings_owner_peer_idx(scope_kind='peer' AND topic_id=0)。
rows, err := s.db.Query(ctx, `
SELECT peer_type, peer_id, show_previews, silent, mute_until, stories_muted, stories_hide_sender
FROM notify_settings
WHERE owner_user_id = $1 AND scope_kind = 'peer' AND topic_id = 0`, ownerUserID)
if err != nil {
return nil, fmt.Errorf("all peer notify settings: %w", err)
}
defer rows.Close()
out := make(map[domain.Peer]domain.PeerNotifySettings)
for rows.Next() {
var peerType string
var peerID int64
var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool
var muteUntil sql.NullInt32
if err := rows.Scan(&peerType, &peerID, &showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender); err != nil {
return nil, fmt.Errorf("scan all peer notify settings: %w", err)
}
settings := domain.PeerNotifySettings{}
if showPreviews.Valid {
settings.ShowPreviews = &showPreviews.Bool
}
if silent.Valid {
settings.Silent = &silent.Bool
}
if muteUntil.Valid {
v := int(muteUntil.Int32)
settings.MuteUntil = &v
}
if storiesMuted.Valid {
settings.StoriesMuted = &storiesMuted.Bool
}
if storiesHideSender.Valid {
settings.StoriesHideSender = &storiesHideSender.Bool
}
out[domain.Peer{Type: domain.PeerType(peerType), ID: peerID}] = settings
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate all peer notify settings: %w", err)
}
return out, nil
}
func (s *PasswordStore) ListNotifyExceptions(ctx context.Context, ownerUserID int64) ([]domain.NotifyException, error) {
rows, err := s.db.Query(ctx, `
SELECT peer_type, peer_id, topic_id, show_previews, silent, mute_until, stories_muted, stories_hide_sender
FROM notify_settings
WHERE owner_user_id = $1 AND scope_kind = 'peer'
ORDER BY peer_type, peer_id, topic_id`, ownerUserID)
if err != nil {
return nil, fmt.Errorf("list notify exceptions: %w", err)
}
defer rows.Close()
out := make([]domain.NotifyException, 0)
for rows.Next() {
var peerType string
var peerID int64
var topicID int
var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool
var muteUntil sql.NullInt32
if err := rows.Scan(&peerType, &peerID, &topicID, &showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender); err != nil {
return nil, fmt.Errorf("scan notify exception: %w", err)
}
settings := domain.PeerNotifySettings{}
if showPreviews.Valid {
settings.ShowPreviews = &showPreviews.Bool
}
if silent.Valid {
settings.Silent = &silent.Bool
}
if muteUntil.Valid {
v := int(muteUntil.Int32)
settings.MuteUntil = &v
}
if storiesMuted.Valid {
settings.StoriesMuted = &storiesMuted.Bool
}
if storiesHideSender.Valid {
settings.StoriesHideSender = &storiesHideSender.Bool
}
if settings.IsZero() {
continue // 全默认行(如曾静音后取消)不算异常
}
out = append(out, domain.NotifyException{
Peer: domain.Peer{Type: domain.PeerType(peerType), ID: peerID},
TopicID: topicID,
Settings: settings,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate notify exceptions: %w", err)
}
return out, nil
}
func (s *PasswordStore) SaveStickerCollectionItem(ctx context.Context, userID int64, kind domain.StickerCollectionKind, documentID int64, unsave bool, now, max int) error {
if userID == 0 || documentID == 0 {
return domain.ErrStickerInvalid
}
if unsave {
if _, err := s.db.Exec(ctx, `DELETE FROM user_sticker_collections WHERE owner_user_id = $1 AND kind = $2 AND document_id = $3`,
userID, string(kind), documentID); err != nil {
return fmt.Errorf("unsave sticker collection item: %w", err)
}
return nil
}
if max <= 0 {
max = domain.MaxStickerCollectionItems(kind)
}
return withTx(ctx, s.db, "save sticker collection item", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `
INSERT INTO user_sticker_collections (owner_user_id, kind, document_id, used_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (owner_user_id, kind, document_id) DO UPDATE SET used_at = EXCLUDED.used_at`,
userID, string(kind), documentID, now); err != nil {
return fmt.Errorf("upsert sticker collection item: %w", err)
}
// 截断超上界:单次有序窗口扫描(索引 user_sticker_collections_order_idx 服务
// used_at DESC 排序),按 ctid 删除排名 > max 的旧项,避免 NOT IN 双扫全集。
if _, err := tx.Exec(ctx, `
DELETE FROM user_sticker_collections
WHERE ctid IN (
SELECT ctid FROM (
SELECT ctid, ROW_NUMBER() OVER (ORDER BY used_at DESC, document_id DESC) AS rn
FROM user_sticker_collections
WHERE owner_user_id = $1 AND kind = $2
) t WHERE rn > $3
)`, userID, string(kind), max); err != nil {
return fmt.Errorf("trim sticker collection: %w", err)
}
return nil
})
}
func (s *PasswordStore) ListStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind, limit int) ([]domain.StickerCollectionItem, error) {
if userID == 0 {
return nil, nil
}
if limit <= 0 || limit > domain.MaxStickerCollectionItems(kind) {
limit = domain.MaxStickerCollectionItems(kind)
}
rows, err := s.db.Query(ctx, `
SELECT document_id, used_at
FROM user_sticker_collections
WHERE owner_user_id = $1 AND kind = $2
ORDER BY used_at DESC, document_id DESC
LIMIT $3`, userID, string(kind), limit)
if err != nil {
return nil, fmt.Errorf("list sticker collection: %w", err)
}
defer rows.Close()
out := make([]domain.StickerCollectionItem, 0, limit)
for rows.Next() {
var item domain.StickerCollectionItem
if err := rows.Scan(&item.DocumentID, &item.Date); err != nil {
return nil, fmt.Errorf("scan sticker collection item: %w", err)
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate sticker collection: %w", err)
}
return out, nil
}
func (s *PasswordStore) ClearStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind) error {
if _, err := s.db.Exec(ctx, `DELETE FROM user_sticker_collections WHERE owner_user_id = $1 AND kind = $2`,
userID, string(kind)); err != nil {
return fmt.Errorf("clear sticker collection: %w", err)
}
return nil
}
func nullableBool(v *bool) any {
if v == nil {
return nil
}
return *v
}
func nullableInt(v *int) any {
if v == nil {
return nil
}
return *v
}
func (s *PasswordStore) SaveMusic(ctx context.Context, req domain.SaveMusicRequest) error {
if req.UserID == 0 || req.Document.ID == 0 || !req.Document.IsMusic() {
return domain.ErrDocumentInvalid
}
return withTx(ctx, s.db, "save account music", func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT document_id
FROM saved_music
WHERE user_id = $1
ORDER BY sort_order ASC, document_id ASC
FOR UPDATE`, req.UserID)
if err != nil {
return fmt.Errorf("lock saved music: %w", err)
}
defer rows.Close()
current := make([]int64, 0, domain.MaxSavedMusicItems)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return fmt.Errorf("scan saved music id: %w", err)
}
current = append(current, id)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("scan saved music ids: %w", err)
}
if req.Unsave {
if _, err := tx.Exec(ctx, `DELETE FROM saved_music WHERE user_id = $1 AND document_id = $2`, req.UserID, req.Document.ID); err != nil {
return fmt.Errorf("delete saved music: %w", err)
}
return nil
}
exists := false
for _, id := range current {
if id == req.Document.ID {
exists = true
break
}
}
if req.AfterDocumentID == req.Document.ID {
if exists {
return nil
}
return domain.ErrDocumentInvalid
}
next := make([]int64, 0, len(current)+1)
afterIndex := -1
for _, id := range current {
if id == req.Document.ID {
continue
}
if id == req.AfterDocumentID {
afterIndex = len(next)
}
next = append(next, id)
}
if req.AfterDocumentID != 0 {
if afterIndex < 0 {
return domain.ErrDocumentInvalid
}
next = append(next, 0)
copy(next[afterIndex+2:], next[afterIndex+1:])
next[afterIndex+1] = req.Document.ID
} else {
next = append([]int64{req.Document.ID}, next...)
}
if len(next) > domain.MaxSavedMusicItems {
next = next[:domain.MaxSavedMusicItems]
}
if _, err := tx.Exec(ctx, `DELETE FROM saved_music WHERE user_id = $1`, req.UserID); err != nil {
return fmt.Errorf("clear saved music order: %w", err)
}
for i, id := range next {
if _, err := tx.Exec(ctx, `
INSERT INTO saved_music (user_id, document_id, sort_order, created_at, updated_at)
VALUES ($1, $2, $3, now(), now())`, req.UserID, id, i+1); err != nil {
return fmt.Errorf("insert saved music: %w", err)
}
}
return nil
})
}
func (s *PasswordStore) ListSavedMusicIDs(ctx context.Context, userID int64, limit int) ([]int64, error) {
if userID == 0 {
return nil, nil
}
if limit <= 0 || limit > domain.MaxSavedMusicItems {
limit = domain.MaxSavedMusicItems
}
rows, err := s.db.Query(ctx, `
SELECT document_id
FROM saved_music
WHERE user_id = $1
ORDER BY sort_order ASC, document_id ASC
LIMIT $2`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list saved music ids: %w", err)
}
defer rows.Close()
ids := make([]int64, 0, limit)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan saved music id: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("scan saved music ids: %w", err)
}
return ids, nil
}
func (s *PasswordStore) ListSavedMusic(ctx context.Context, userID int64, offset, limit int) (domain.SavedMusicList, error) {
out := domain.SavedMusicList{UserID: userID}
if userID == 0 {
return out, nil
}
if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM saved_music WHERE user_id = $1`, userID).Scan(&out.Count); err != nil {
return domain.SavedMusicList{}, fmt.Errorf("count saved music: %w", err)
}
if limit <= 0 || offset < 0 || offset >= out.Count {
return out, nil
}
if limit > domain.MaxSavedMusicItems {
limit = domain.MaxSavedMusicItems
}
docs, err := s.querySavedMusicDocuments(ctx, `
SELECT d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text AS attributes_json, d.thumbs::text AS thumbs_json
FROM saved_music sm
JOIN documents d ON d.id = sm.document_id
WHERE sm.user_id = $1
ORDER BY sm.sort_order ASC, sm.document_id ASC
OFFSET $2
LIMIT $3`, userID, offset, limit)
if err != nil {
return domain.SavedMusicList{}, err
}
out.Documents = docs
return out, nil
}
func (s *PasswordStore) GetSavedMusicByIDs(ctx context.Context, userID int64, ids []int64) (domain.SavedMusicList, error) {
out := domain.SavedMusicList{UserID: userID}
if userID == 0 || len(ids) == 0 {
return out, nil
}
if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM saved_music WHERE user_id = $1`, userID).Scan(&out.Count); err != nil {
return domain.SavedMusicList{}, fmt.Errorf("count saved music: %w", err)
}
docs, err := s.querySavedMusicDocuments(ctx, `
SELECT d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
d.attributes::text AS attributes_json, d.thumbs::text AS thumbs_json
FROM saved_music sm
JOIN documents d ON d.id = sm.document_id
WHERE sm.user_id = $1
AND sm.document_id = ANY($2::bigint[])
ORDER BY sm.sort_order ASC, sm.document_id ASC`, userID, ids)
if err != nil {
return domain.SavedMusicList{}, err
}
byID := make(map[int64]domain.Document, len(docs))
for _, doc := range docs {
byID[doc.ID] = doc
}
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
if doc, ok := byID[id]; ok {
out.Documents = append(out.Documents, doc)
}
}
return out, nil
}
func (s *PasswordStore) querySavedMusicDocuments(ctx context.Context, sql string, args ...any) ([]domain.Document, error) {
rows, err := s.db.Query(ctx, sql, args...)
if err != nil {
return nil, fmt.Errorf("query saved music documents: %w", err)
}
defer rows.Close()
docs := make([]domain.Document, 0)
for rows.Next() {
var row sqlcgen.GetDocumentRow
if err := rows.Scan(
&row.ID,
&row.AccessHash,
&row.FileReference,
&row.Date,
&row.MimeType,
&row.Size,
&row.DcID,
&row.AttributesJson,
&row.ThumbsJson,
); err != nil {
return nil, fmt.Errorf("scan saved music document: %w", err)
}
doc, err := documentFromRow(row)
if err != nil {
return nil, err
}
if doc.IsMusic() {
docs = append(docs, doc)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("scan saved music documents: %w", err)
}
return docs, nil
}

View file

@ -0,0 +1,128 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
// TestNotifySettingsRoundTripPostgres 回归迁移 0005:per-scope 通知设置真实持久化
// (peer / 类别默认 / 批量 / reset),含可空字段=未设置语义。
func TestNotifySettingsRoundTripPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewPasswordStore(pool)
users := NewUserStore(pool)
suffix := randomSuffix(t)
u, err := users.Create(ctx, domain.User{AccessHash: 92, Phone: "+1663" + suffix + "01", FirstName: "NotifyOwner"})
if err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM notify_settings WHERE owner_user_id = $1", u.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", u.ID)
})
peerA := domain.Peer{Type: domain.PeerTypeUser, ID: 7001}
peerB := domain.Peer{Type: domain.PeerTypeChannel, ID: 7002}
scopeA := domain.NotifyScope{Kind: domain.NotifyScopePeer, Peer: peerA}
// not found.
if _, found, err := store.GetNotifySettings(ctx, u.ID, scopeA); err != nil || found {
t.Fatalf("get before save = found %v err %v", found, err)
}
// peer A:只设 mute_until + silent(其余 nil=未设置)。
mute := 2000000000
silent := true
if err := store.SaveNotifySettings(ctx, u.ID, scopeA, domain.PeerNotifySettings{MuteUntil: &mute, Silent: &silent}); err != nil {
t.Fatalf("save scope A: %v", err)
}
got, found, err := store.GetNotifySettings(ctx, u.ID, scopeA)
if err != nil || !found {
t.Fatalf("get scope A = found %v err %v", found, err)
}
if got.MuteUntil == nil || *got.MuteUntil != mute || got.Silent == nil || !*got.Silent {
t.Fatalf("scope A = %+v, want mute=%d silent=true", got, mute)
}
if got.ShowPreviews != nil || got.StoriesMuted != nil {
t.Fatalf("unset fields must stay nil, got %+v", got)
}
// 类别默认(users)独立。
usersScope := domain.NotifyScope{Kind: domain.NotifyScopeUsers}
dmute := 100
if err := store.SaveNotifySettings(ctx, u.ID, usersScope, domain.PeerNotifySettings{MuteUntil: &dmute}); err != nil {
t.Fatalf("save users default: %v", err)
}
if g, found, _ := store.GetNotifySettings(ctx, u.ID, usersScope); !found || g.MuteUntil == nil || *g.MuteUntil != dmute {
t.Fatalf("users default = %+v found %v, want mute=%d", g, found, dmute)
}
// peer A 不受类别默认影响。
if g, _, _ := store.GetNotifySettings(ctx, u.ID, scopeA); g.MuteUntil == nil || *g.MuteUntil != mute {
t.Fatalf("scope A polluted by users default: %+v", g)
}
// upsert 覆盖 + 批量(mute_until 是 TL int32,取接近上限的合法值)。
mute2 := 2100000000
if err := store.SaveNotifySettings(ctx, u.ID, domain.NotifyScope{Kind: domain.NotifyScopePeer, Peer: peerB}, domain.PeerNotifySettings{MuteUntil: &mute2}); err != nil {
t.Fatalf("save scope B: %v", err)
}
batch, err := store.GetPeerNotifySettings(ctx, u.ID, []domain.Peer{peerA, peerB, {Type: domain.PeerTypeUser, ID: 9999}})
if err != nil {
t.Fatalf("batch: %v", err)
}
if len(batch) != 2 {
t.Fatalf("batch len = %d, want 2 (peerA+peerB, 9999 unset)", len(batch))
}
if a, ok := batch[peerA]; !ok || a.MuteUntil == nil || *a.MuteUntil != mute {
t.Fatalf("batch peerA = %+v ok %v", a, ok)
}
if b, ok := batch[peerB]; !ok || b.MuteUntil == nil || *b.MuteUntil != mute2 {
t.Fatalf("batch peerB = %+v ok %v", b, ok)
}
// AllPeerNotifySettings:返全部整-peer 设置(peerA+peerB),排除类别默认(per-user notify 缓存的加载源)。
all, err := store.AllPeerNotifySettings(ctx, u.ID)
if err != nil {
t.Fatalf("all peer notify settings: %v", err)
}
if len(all) != 2 {
t.Fatalf("AllPeerNotifySettings = %d, want 2 (peerA+peerB, 类别默认不计)", len(all))
}
if a, ok := all[peerA]; !ok || a.MuteUntil == nil || *a.MuteUntil != mute {
t.Fatalf("AllPeerNotifySettings peerA = %+v ok %v", a, ok)
}
// ListNotifyExceptions:只返 per-peer 非默认(peerA+peerB),排除类别默认。
exceptions, err := store.ListNotifyExceptions(ctx, u.ID)
if err != nil {
t.Fatalf("list exceptions: %v", err)
}
if len(exceptions) != 2 {
t.Fatalf("exceptions = %d, want 2 (peerA+peerB, 类别默认不计)", len(exceptions))
}
seen := map[domain.Peer]bool{}
for _, ex := range exceptions {
seen[ex.Peer] = true
}
if !seen[peerA] || !seen[peerB] {
t.Fatalf("exceptions peers = %v, want peerA+peerB", seen)
}
// reset 清空全部作用域。
if err := store.ResetNotifySettings(ctx, u.ID); err != nil {
t.Fatalf("reset: %v", err)
}
if ex, _ := store.ListNotifyExceptions(ctx, u.ID); len(ex) != 0 {
t.Fatalf("exceptions after reset = %d, want 0", len(ex))
}
if _, found, _ := store.GetNotifySettings(ctx, u.ID, scopeA); found {
t.Fatalf("scope A must be gone after reset")
}
if _, found, _ := store.GetNotifySettings(ctx, u.ID, usersScope); found {
t.Fatalf("users default must be gone after reset")
}
}

View file

@ -0,0 +1,69 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
// TestAccountSettingsRoundTripPostgres 回归迁移 0004:账号级单例设置真实持久化
// (globalPrivacy/accountTTL/sensitive/contactSignUp),含 not-found→默认、upsert 覆盖。
func TestAccountSettingsRoundTripPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewPasswordStore(pool)
users := NewUserStore(pool)
suffix := randomSuffix(t)
u, err := users.Create(ctx, domain.User{AccessHash: 91, Phone: "+1664" + suffix + "01", FirstName: "AcctSettings"})
if err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM account_settings WHERE user_id = $1", u.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", u.ID)
})
// 未持久化:not found。
if _, found, err := store.GetAccountSettings(ctx, u.ID); err != nil || found {
t.Fatalf("get before save = found %v err %v, want not found", found, err)
}
want := domain.AccountSettings{
GlobalPrivacy: domain.GlobalPrivacy{
ArchiveAndMuteNewNoncontactPeers: true,
HideReadMarks: true,
DisplayGiftsButton: true,
NoncontactPeersPaidStars: 75,
},
AccountTTLDays: 30,
SensitiveContentEnabled: true,
ContactSignUpSilent: true,
}
if err := store.SaveAccountSettings(ctx, u.ID, want); err != nil {
t.Fatalf("save account settings: %v", err)
}
got, found, err := store.GetAccountSettings(ctx, u.ID)
if err != nil || !found {
t.Fatalf("get after save = found %v err %v", found, err)
}
if got != want {
t.Fatalf("account settings round-trip = %+v, want %+v", got, want)
}
// upsert 覆盖:改若干字段再读回。
want.GlobalPrivacy.HideReadMarks = false
want.AccountTTLDays = 365
want.SensitiveContentEnabled = false
if err := store.SaveAccountSettings(ctx, u.ID, want); err != nil {
t.Fatalf("re-save account settings: %v", err)
}
got, found, err = store.GetAccountSettings(ctx, u.ID)
if err != nil || !found {
t.Fatalf("get after re-save = found %v err %v", found, err)
}
if got != want {
t.Fatalf("account settings after upsert = %+v, want %+v", got, want)
}
}

View file

@ -0,0 +1,90 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
// TestStickerCollectionsRoundTripPostgres 回归迁移 0006:个人贴纸/GIF 集合持久化
// (最新置顶 / 截断 / unsave / clear / 类别隔离)。store 仅存 document_id(无 FK)。
func TestStickerCollectionsRoundTripPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewPasswordStore(pool)
const owner = int64(778899001)
t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM user_sticker_collections WHERE owner_user_id = $1", owner) })
_, _ = pool.Exec(ctx, "DELETE FROM user_sticker_collections WHERE owner_user_id = $1", owner)
faved := domain.StickerCollectionFaved
// fave 101 then 102 → 最新在前。
if err := store.SaveStickerCollectionItem(ctx, owner, faved, 101, false, 1000, 100); err != nil {
t.Fatalf("fave 101: %v", err)
}
if err := store.SaveStickerCollectionItem(ctx, owner, faved, 102, false, 1001, 100); err != nil {
t.Fatalf("fave 102: %v", err)
}
if ids := stickerIDs(t, store, ctx, owner, faved); len(ids) != 2 || ids[0] != 102 || ids[1] != 101 {
t.Fatalf("faved = %v, want [102 101]", ids)
}
// 重 fave 101(更新 used_at)→ 移到最前。
if err := store.SaveStickerCollectionItem(ctx, owner, faved, 101, false, 1002, 100); err != nil {
t.Fatalf("re-fave 101: %v", err)
}
if ids := stickerIDs(t, store, ctx, owner, faved); len(ids) != 2 || ids[0] != 101 {
t.Fatalf("faved after re-fave = %v, want 101 first", ids)
}
// 截断:max=2,加第 3 个挤掉最旧。
if err := store.SaveStickerCollectionItem(ctx, owner, faved, 103, false, 1003, 2); err != nil {
t.Fatalf("fave 103 (max 2): %v", err)
}
if ids := stickerIDs(t, store, ctx, owner, faved); len(ids) != 2 || ids[0] != 103 || ids[1] != 101 {
t.Fatalf("faved after trim = %v, want [103 101]", ids)
}
// 类别隔离:recent 与 faved 独立。
if err := store.SaveStickerCollectionItem(ctx, owner, domain.StickerCollectionRecent, 999, false, 2000, 30); err != nil {
t.Fatalf("save recent: %v", err)
}
if ids := stickerIDs(t, store, ctx, owner, domain.StickerCollectionRecent); len(ids) != 1 || ids[0] != 999 {
t.Fatalf("recent = %v, want [999]", ids)
}
if ids := stickerIDs(t, store, ctx, owner, faved); len(ids) != 2 {
t.Fatalf("faved polluted by recent: %v", ids)
}
// unsave + clear。
if err := store.SaveStickerCollectionItem(ctx, owner, faved, 101, true, 0, 100); err != nil {
t.Fatalf("unsave 101: %v", err)
}
if ids := stickerIDs(t, store, ctx, owner, faved); len(ids) != 1 || ids[0] != 103 {
t.Fatalf("faved after unsave = %v, want [103]", ids)
}
if err := store.ClearStickerCollection(ctx, owner, faved); err != nil {
t.Fatalf("clear faved: %v", err)
}
if ids := stickerIDs(t, store, ctx, owner, faved); len(ids) != 0 {
t.Fatalf("faved after clear = %v, want empty", ids)
}
// clear 只清该类别,recent 仍在。
if ids := stickerIDs(t, store, ctx, owner, domain.StickerCollectionRecent); len(ids) != 1 {
t.Fatalf("recent gone after clearing faved: %v", ids)
}
}
func stickerIDs(t *testing.T, store *PasswordStore, ctx context.Context, owner int64, kind domain.StickerCollectionKind) []int64 {
t.Helper()
items, err := store.ListStickerCollection(ctx, owner, kind, 100)
if err != nil {
t.Fatalf("list %s: %v", kind, err)
}
ids := make([]int64, 0, len(items))
for _, it := range items {
ids = append(ids, it.DocumentID)
}
return ids
}

View file

@ -0,0 +1,223 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type AdminStore struct {
db sqlcgen.DBTX
}
func NewAdminStore(db sqlcgen.DBTX) *AdminStore {
return &AdminStore{db: db}
}
func (s *AdminStore) BeginCommand(ctx context.Context, cmd domain.AdminCommand) (domain.AdminCommand, bool, error) {
inserted, err := scanAdminCommand(s.db.QueryRow(ctx, `
INSERT INTO admin_commands (
command_id, actor, action, target_user_id, target_peer_type, target_peer_id,
dry_run, reason, request, result, status, error, created_at
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,'{}'::jsonb,$10,'',$11
)
ON CONFLICT (command_id) DO NOTHING
RETURNING command_id, actor, action, target_user_id, target_peer_type, target_peer_id,
dry_run, reason, request, result, status, error, created_at, completed_at`,
cmd.CommandID, cmd.Actor, cmd.Action, cmd.TargetUserID, string(cmd.TargetPeer.Type), cmd.TargetPeer.ID,
cmd.DryRun, cmd.Reason, string(cmd.RequestJSON), string(cmd.Status), cmd.CreatedAt,
))
if err == nil {
return inserted, true, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.AdminCommand{}, false, fmt.Errorf("insert admin command: %w", err)
}
existing, err := s.commandByID(ctx, cmd.CommandID)
if err != nil {
return domain.AdminCommand{}, false, err
}
return existing, false, nil
}
func (s *AdminStore) FinishCommand(ctx context.Context, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error) {
if len(resultJSON) == 0 {
resultJSON = []byte("{}")
}
beginner, ok := s.db.(txBeginner)
if !ok {
return s.finishCommandNoTx(ctx, commandID, status, resultJSON, errorText)
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.AdminCommand{}, fmt.Errorf("begin finish admin command tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
cmd, err := finishAdminCommand(ctx, tx, commandID, status, resultJSON, errorText)
if err != nil {
return domain.AdminCommand{}, err
}
if err := appendAdminAuditLog(ctx, tx, commandID); err != nil {
return domain.AdminCommand{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.AdminCommand{}, fmt.Errorf("commit finish admin command tx: %w", err)
}
committed = true
return cmd, nil
}
func (s *AdminStore) finishCommandNoTx(ctx context.Context, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error) {
cmd, err := finishAdminCommand(ctx, s.db, commandID, status, resultJSON, errorText)
if err != nil {
return domain.AdminCommand{}, err
}
if err := appendAdminAuditLog(ctx, s.db, commandID); err != nil {
return domain.AdminCommand{}, err
}
return cmd, nil
}
func finishAdminCommand(ctx context.Context, db sqlcgen.DBTX, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error) {
cmd, err := scanAdminCommand(db.QueryRow(ctx, `
UPDATE admin_commands
SET status = $2, result = $3::jsonb, error = $4, completed_at = now()
WHERE command_id = $1
RETURNING command_id, actor, action, target_user_id, target_peer_type, target_peer_id,
dry_run, reason, request, result, status, error, created_at, completed_at`,
commandID, string(status), string(resultJSON), errorText,
))
if err != nil {
return domain.AdminCommand{}, fmt.Errorf("finish admin command: %w", err)
}
return cmd, nil
}
func appendAdminAuditLog(ctx context.Context, db sqlcgen.DBTX, commandID string) error {
if _, err := db.Exec(ctx, `
INSERT INTO admin_audit_logs (
command_id, actor, action, target_user_id, target_peer_type, target_peer_id,
dry_run, reason, request, result, status, error, created_at
)
SELECT command_id, actor, action, target_user_id, target_peer_type, target_peer_id,
dry_run, reason, request, result, status, error, now()
FROM admin_commands
WHERE command_id = $1
ON CONFLICT (command_id) DO NOTHING`, commandID); err != nil {
return fmt.Errorf("append admin audit log: %w", err)
}
return nil
}
func (s *AdminStore) commandByID(ctx context.Context, commandID string) (domain.AdminCommand, error) {
cmd, err := scanAdminCommand(s.db.QueryRow(ctx, `
SELECT command_id, actor, action, target_user_id, target_peer_type, target_peer_id,
dry_run, reason, request, result, status, error, created_at, completed_at
FROM admin_commands
WHERE command_id = $1`, commandID))
if err != nil {
return domain.AdminCommand{}, fmt.Errorf("get admin command: %w", err)
}
return cmd, nil
}
func scanAdminCommand(row pgx.Row) (domain.AdminCommand, error) {
var cmd domain.AdminCommand
var peerType string
var status string
var completed pgtype.Timestamptz
if err := row.Scan(
&cmd.CommandID,
&cmd.Actor,
&cmd.Action,
&cmd.TargetUserID,
&peerType,
&cmd.TargetPeer.ID,
&cmd.DryRun,
&cmd.Reason,
&cmd.RequestJSON,
&cmd.ResultJSON,
&status,
&cmd.Error,
&cmd.CreatedAt,
&completed,
); err != nil {
return domain.AdminCommand{}, err
}
cmd.TargetPeer.Type = domain.PeerType(peerType)
cmd.Status = domain.AdminCommandStatus(status)
if completed.Valid {
t := completed.Time
cmd.CompletedAt = &t
}
return cmd, nil
}
func (s *AdminStore) GetSendRestriction(ctx context.Context, userID int64) (domain.AccountSendRestriction, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT user_id, frozen, reason, actor, command_id, updated_at
FROM account_send_restrictions
WHERE user_id = $1`, userID)
r, err := scanSendRestriction(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountSendRestriction{}, false, nil
}
return domain.AccountSendRestriction{}, false, fmt.Errorf("get send restriction: %w", err)
}
return r, true, nil
}
func (s *AdminStore) SetSendRestriction(ctx context.Context, restriction domain.AccountSendRestriction) (domain.AccountSendRestriction, error) {
row := s.db.QueryRow(ctx, `
INSERT INTO account_send_restrictions (user_id, frozen, reason, actor, command_id, updated_at)
VALUES ($1,$2,$3,$4,$5,now())
ON CONFLICT (user_id) DO UPDATE SET
frozen = EXCLUDED.frozen,
reason = EXCLUDED.reason,
actor = EXCLUDED.actor,
command_id = EXCLUDED.command_id,
updated_at = now()
RETURNING user_id, frozen, reason, actor, command_id, updated_at`,
restriction.UserID, restriction.Frozen, restriction.Reason, restriction.Actor, restriction.CommandID,
)
out, err := scanSendRestriction(row)
if err != nil {
return domain.AccountSendRestriction{}, fmt.Errorf("set send restriction: %w", err)
}
return out, nil
}
func (s *AdminStore) IsSendFrozen(ctx context.Context, userID int64) (bool, error) {
var frozen bool
if err := s.db.QueryRow(ctx, `SELECT frozen FROM account_send_restrictions WHERE user_id = $1`, userID).Scan(&frozen); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("check send restriction: %w", err)
}
return frozen, nil
}
func scanSendRestriction(row pgx.Row) (domain.AccountSendRestriction, error) {
var r domain.AccountSendRestriction
var updated time.Time
if err := row.Scan(&r.UserID, &r.Frozen, &r.Reason, &r.Actor, &r.CommandID, &updated); err != nil {
return domain.AccountSendRestriction{}, err
}
r.UpdatedAt = updated
return r, nil
}

View file

@ -84,3 +84,115 @@ func TestAuthSignUpWritesOfficialLoginMessagePostgres(t *testing.T) {
t.Fatalf("users = %+v, want official support user", list.Users)
}
}
func TestAuthSignInOfficialLoginMessagePreservesReadWatermarkPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
phone := fmt.Sprintf("1555%d32", time.Now().UnixNano())
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE phone = $1", phone)
})
users := NewUserStore(pool)
dialogs := NewDialogStore(pool)
messages := NewMessageStore(pool)
svc := appauth.NewService(
users,
NewAuthorizationStore(pool),
memory.NewCodeStore(),
nil,
nil,
"12345",
appauth.WithLoginMessages(messages, dialogs),
)
var authKeyID [8]byte
var authKeyBody [256]byte
if _, err := rand.Read(authKeyID[:]); err != nil {
t.Fatal(err)
}
if _, err := rand.Read(authKeyBody[:]); err != nil {
t.Fatal(err)
}
if err := NewAuthKeyStore(pool).Save(ctx, store.AuthKeyData{ID: authKeyID, Value: authKeyBody}); err != nil {
t.Fatalf("save auth key: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(authKeyID))
})
hash, err := svc.SendCode(ctx, phone)
if err != nil {
t.Fatalf("SendCode signup: %v", err)
}
u, first, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "PgLogin", "Read")
if err != nil {
t.Fatalf("SignUp: %v", err)
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}
read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{
OwnerUserID: u.ID,
Peer: peer,
MaxID: domain.MaxMessageBoxID,
Date: int(time.Now().Unix()),
})
if err != nil {
t.Fatalf("ReadHistory first login message: %v", err)
}
if read.MaxID != first.ID || read.StillUnreadCount != 0 {
t.Fatalf("read first login message = %+v, want max_id %d unread 0", read, first.ID)
}
assertOfficialDialog := func(wantTop, wantRead, wantUnread int) {
t.Helper()
var top, readMax, unread, computed int
if err := pool.QueryRow(ctx, `
WITH target AS (
SELECT top_message_id, read_inbox_max_id, unread_count
FROM dialogs
WHERE user_id = $1 AND peer_type = 'user' AND peer_id = $2
)
SELECT
target.top_message_id,
target.read_inbox_max_id,
target.unread_count,
(
SELECT count(*)::int
FROM message_boxes m
WHERE m.owner_user_id = $1
AND m.peer_type = 'user'
AND m.peer_id = $2
AND NOT m.outgoing
AND NOT m.deleted
AND m.box_id > target.read_inbox_max_id
) AS computed_unread
FROM target`, u.ID, domain.OfficialSystemUserID).Scan(&top, &readMax, &unread, &computed); err != nil {
t.Fatalf("query official dialog state: %v", err)
}
if top != wantTop || readMax != wantRead || unread != wantUnread || computed != wantUnread {
t.Fatalf("official dialog top=%d read=%d unread=%d computed=%d, want top=%d read=%d unread=%d",
top, readMax, unread, computed, wantTop, wantRead, wantUnread)
}
}
hash, err = svc.SendCode(ctx, phone)
if err != nil {
t.Fatalf("SendCode signin second: %v", err)
}
_, second, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "12345")
if err != nil || needSignUp {
t.Fatalf("SignIn second needSignUp=%v err=%v", needSignUp, err)
}
assertOfficialDialog(second.ID, first.ID, 1)
hash, err = svc.SendCode(ctx, phone)
if err != nil {
t.Fatalf("SendCode signin third: %v", err)
}
_, third, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "12345")
if err != nil || needSignUp {
t.Fatalf("SignIn third needSignUp=%v err=%v", needSignUp, err)
}
assertOfficialDialog(third.ID, first.ID, 2)
}

View file

@ -14,12 +14,13 @@ import (
// AuthKeyStore 用 PostgreSQL 实现 store.AuthKeyStore。
type AuthKeyStore struct {
q *sqlcgen.Queries
q *sqlcgen.Queries
db sqlcgen.DBTX
}
// NewAuthKeyStore 基于 pgx 连接池(或事务)创建 AuthKeyStore。
func NewAuthKeyStore(db sqlcgen.DBTX) *AuthKeyStore {
return &AuthKeyStore{q: sqlcgen.New(db)}
return &AuthKeyStore{q: sqlcgen.New(db), db: db}
}
// Save 实现 store.AuthKeyStore。auth_key_id 以小端解释为 int64 存入 BIGINT;
@ -55,6 +56,32 @@ func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData,
return data, true, nil
}
// Delete 实现 store.AuthKeyStore。不存在时静默成功。
// 手写 SQL 而非 sqlc 生成:避免触碰 sqlcgen 再生成链路。
//
// 同时清理把本 key 当作 perm key 的 temp auth key 行:temp_auth_key_bindings.temp_auth_key_id
// 侧有外键 ON DELETE CASCADE,删除 temp key 会自动清绑定;perm_auth_key_id 列无外键,
// 因此被踢/登出删除 perm key 时必须先把关联 temp key 一并删掉。否则 Web/上传连接用
// raw temp key 重连时仍能进入 RPC 层,只得到 AUTH_KEY_UNREGISTERED,而不是连接层 404。
func (s *AuthKeyStore) Delete(ctx context.Context, id [8]byte) error {
keyID := authKeyIDToInt64(id)
if _, err := s.db.Exec(ctx, `
WITH doomed_temp AS (
SELECT temp_auth_key_id
FROM temp_auth_key_bindings
WHERE perm_auth_key_id = $1
), deleted_temp AS (
DELETE FROM auth_keys
WHERE auth_key_id IN (SELECT temp_auth_key_id FROM doomed_temp)
)
DELETE FROM auth_keys
WHERE auth_key_id = $1
`, keyID); err != nil {
return fmt.Errorf("delete auth key and temp bindings: %w", err)
}
return nil
}
// authKeyIDToInt64 把 [8]byte 的 auth_key_id 按小端解释为 int64(MTProto 定义即 SHA1 低 64 位)。
func authKeyIDToInt64(id [8]byte) int64 {
return int64(binary.LittleEndian.Uint64(id[:]))

View file

@ -29,8 +29,8 @@ func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) e
a.Hash = authorizationHash(a.AuthKeyID)
}
_, err := s.db.Exec(ctx, `
INSERT INTO authorizations (auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
INSERT INTO authorizations (auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (auth_key_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
hash = EXCLUDED.hash,
@ -41,8 +41,9 @@ ON CONFLICT (auth_key_id) DO UPDATE SET
api_id = EXCLUDED.api_id,
app_version = EXCLUDED.app_version,
ip = EXCLUDED.ip,
password_pending = EXCLUDED.password_pending,
active_at = now()`,
authKeyIDToInt64(a.AuthKeyID), a.UserID, a.Hash, int32(a.Layer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP,
authKeyIDToInt64(a.AuthKeyID), a.UserID, a.Hash, int32(a.Layer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP, a.PasswordPending,
)
if err != nil {
return fmt.Errorf("upsert authorization: %w", err)
@ -51,24 +52,29 @@ ON CONFLICT (auth_key_id) DO UPDATE SET
}
func (s *AuthorizationStore) ByAuthKey(ctx context.Context, id [8]byte) (domain.Authorization, bool, error) {
row, err := s.q.GetAuthorizationByAuthKey(ctx, authKeyIDToInt64(id))
if err != nil {
row := s.db.QueryRow(ctx, `
SELECT user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending, created_at, active_at
FROM authorizations WHERE auth_key_id = $1`, authKeyIDToInt64(id))
a := domain.Authorization{AuthKeyID: id}
if err := row.Scan(
&a.UserID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion,
&a.APIID, &a.AppVersion, &a.IP, &a.PasswordPending, &a.CreatedAt, &a.ActiveAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Authorization{}, false, nil
}
return domain.Authorization{}, false, fmt.Errorf("get authorization: %w", err)
}
return domain.Authorization{
AuthKeyID: id,
UserID: row.UserID,
Layer: int(row.Layer),
DeviceModel: row.DeviceModel,
Platform: row.Platform,
SystemVersion: row.SystemVersion,
APIID: int(row.ApiID),
AppVersion: row.AppVersion,
IP: row.Ip,
}, true, nil
return a, true, nil
}
// MarkPasswordPassed 在两步验证通过后清除 password_pending,使 auth_key 转为完全授权。
func (s *AuthorizationStore) MarkPasswordPassed(ctx context.Context, id [8]byte) error {
if _, err := s.db.Exec(ctx, `
UPDATE authorizations SET password_pending = false, active_at = now() WHERE auth_key_id = $1`, authKeyIDToInt64(id)); err != nil {
return fmt.Errorf("mark authorization password passed: %w", err)
}
return nil
}
func (s *AuthorizationStore) ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error) {
@ -110,6 +116,42 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
return a, true, nil
}
// RevokeByHash 删除协议 auth_key 作为远程踢设备的持久化事实入口。
// authorizations/update_states 通过 FK cascade 删除;关联 temp auth key 显式删除,避免 raw temp key 重连。
func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
row := s.db.QueryRow(ctx, `
WITH target AS MATERIALIZED (
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending, created_at, active_at
FROM authorizations
WHERE user_id = $1 AND hash = $2
), deleted_temp AS (
DELETE FROM auth_keys
WHERE auth_key_id IN (
SELECT temp_auth_key_id
FROM temp_auth_key_bindings
WHERE perm_auth_key_id IN (SELECT auth_key_id FROM target)
)
RETURNING auth_key_id
), deleted_keys AS (
DELETE FROM auth_keys
WHERE auth_key_id IN (SELECT auth_key_id FROM target)
RETURNING auth_key_id
), touched AS (
SELECT count(*) FROM deleted_temp
)
SELECT target.auth_key_id, target.user_id, target.hash, target.layer, target.device_model, target.platform,
target.system_version, target.api_id, target.app_version, target.ip, target.password_pending,
target.created_at, target.active_at
FROM target
JOIN deleted_keys USING (auth_key_id)
CROSS JOIN touched`, userID, hash)
a, found, err := scanRevokedAuthorization(row)
if err != nil {
return domain.Authorization{}, false, fmt.Errorf("revoke authorization by hash: %w", err)
}
return a, found, nil
}
func (s *AuthorizationStore) DeleteByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
rows, err := s.db.Query(ctx, `
DELETE FROM authorizations
@ -138,6 +180,53 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
return out, nil
}
// RevokeByUserExcept 批量删除协议 auth_key,保留 keepAuthKeyID 对应的当前设备。
func (s *AuthorizationStore) RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
rows, err := s.db.Query(ctx, `
WITH target AS MATERIALIZED (
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending, created_at, active_at
FROM authorizations
WHERE user_id = $1 AND auth_key_id <> $2
), deleted_temp AS (
DELETE FROM auth_keys
WHERE auth_key_id IN (
SELECT temp_auth_key_id
FROM temp_auth_key_bindings
WHERE perm_auth_key_id IN (SELECT auth_key_id FROM target)
)
RETURNING auth_key_id
), deleted_keys AS (
DELETE FROM auth_keys
WHERE auth_key_id IN (SELECT auth_key_id FROM target)
RETURNING auth_key_id
), touched AS (
SELECT count(*) FROM deleted_temp
)
SELECT target.auth_key_id, target.user_id, target.hash, target.layer, target.device_model, target.platform,
target.system_version, target.api_id, target.app_version, target.ip, target.password_pending,
target.created_at, target.active_at
FROM target
JOIN deleted_keys USING (auth_key_id)
CROSS JOIN touched
ORDER BY target.created_at, target.auth_key_id`, userID, authKeyIDToInt64(keepAuthKeyID))
if err != nil {
return nil, fmt.Errorf("revoke authorizations by user: %w", err)
}
defer rows.Close()
out := make([]domain.Authorization, 0)
for rows.Next() {
a, err := scanRevokedAuthorizationRow(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate revoked authorizations: %w", err)
}
return out, nil
}
func authorizationFromRow(row sqlcgen.Authorization) domain.Authorization {
return domain.Authorization{
AuthKeyID: authKeyIDFromInt64(row.AuthKeyID),
@ -155,6 +244,34 @@ func authorizationFromRow(row sqlcgen.Authorization) domain.Authorization {
}
}
type authorizationScanner interface {
Scan(dest ...any) error
}
func scanRevokedAuthorization(row authorizationScanner) (domain.Authorization, bool, error) {
a, err := scanRevokedAuthorizationRow(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Authorization{}, false, nil
}
return domain.Authorization{}, false, err
}
return a, true, nil
}
func scanRevokedAuthorizationRow(row authorizationScanner) (domain.Authorization, error) {
var a domain.Authorization
var authKeyID int64
if err := row.Scan(
&authKeyID, &a.UserID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion,
&a.APIID, &a.AppVersion, &a.IP, &a.PasswordPending, &a.CreatedAt, &a.ActiveAt,
); err != nil {
return domain.Authorization{}, err
}
a.AuthKeyID = authKeyIDFromInt64(authKeyID)
return a, nil
}
func authorizationHash(authKeyID [8]byte) int64 {
sum := sha256.Sum256(authKeyID[:])
hash := int64(binary.LittleEndian.Uint64(sum[:8]))

View file

@ -0,0 +1,180 @@
package postgres
import (
"context"
"fmt"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "hash")
perm := revokeTestAuthKeyID(0x91)
temp := revokeTestAuthKeyID(0x92)
keys := NewAuthKeyStore(pool)
auths := NewAuthorizationStore(pool)
saveRevokeTestAuthKey(t, ctx, keys, perm)
saveRevokeTestAuthKey(t, ctx, keys, temp)
if err := auths.Bind(ctx, domain.Authorization{
AuthKeyID: perm,
UserID: userID,
Hash: 9001,
Layer: 225,
DeviceModel: "WebA",
Platform: "web",
SystemVersion: "test",
APIID: 100,
AppVersion: "test",
IP: "127.0.0.1",
PasswordPending: true,
}); err != nil {
t.Fatalf("bind authorization: %v", err)
}
if err := NewUpdateStateStore(pool).Save(ctx, perm, userID, domain.UpdateState{Pts: 11, Date: int(time.Now().Unix())}); err != nil {
t.Fatalf("save update state: %v", err)
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
TempAuthKeyID: temp,
PermAuthKeyID: authKeyIDToInt64(perm),
Nonce: 1,
TempSessionID: 2,
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
EncryptedMessage: []byte{1, 2, 3, 4},
}); err != nil {
t.Fatalf("save temp binding: %v", err)
}
deleted, found, err := auths.RevokeByHash(ctx, userID, 9001)
if err != nil || !found {
t.Fatalf("RevokeByHash found=%v err=%v, want found", found, err)
}
if deleted.AuthKeyID != perm || !deleted.PasswordPending {
t.Fatalf("deleted authorization = %+v, want perm key and password_pending", deleted)
}
assertRevokeTestMissingAuthKey(t, ctx, keys, perm)
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
assertRevokeTestTableCount(t, ctx, pool, "update_states", "auth_key_id", authKeyIDToInt64(perm), 0)
assertRevokeTestTableCount(t, ctx, pool, "temp_auth_key_bindings", "temp_auth_key_id", authKeyIDToInt64(temp), 0)
}
func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "bulk")
keys := NewAuthKeyStore(pool)
auths := NewAuthorizationStore(pool)
keep := revokeTestAuthKeyID(0xa1)
revokedOne := revokeTestAuthKeyID(0xa2)
revokedTwo := revokeTestAuthKeyID(0xa3)
tempForTwo := revokeTestAuthKeyID(0xa4)
for i, key := range [][8]byte{keep, revokedOne, revokedTwo, tempForTwo} {
saveRevokeTestAuthKey(t, ctx, keys, key)
if i < 3 {
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: key, UserID: userID, Hash: int64(9100 + i)}); err != nil {
t.Fatalf("bind auth %x: %v", key, err)
}
}
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
TempAuthKeyID: tempForTwo,
PermAuthKeyID: authKeyIDToInt64(revokedTwo),
Nonce: 3,
TempSessionID: 4,
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
EncryptedMessage: []byte{5, 6, 7, 8},
}); err != nil {
t.Fatalf("save temp binding: %v", err)
}
deleted, err := auths.RevokeByUserExcept(ctx, userID, keep)
if err != nil {
t.Fatalf("RevokeByUserExcept: %v", err)
}
if len(deleted) != 2 {
t.Fatalf("deleted len = %d, want 2 (%+v)", len(deleted), deleted)
}
assertRevokeTestPresentAuthKey(t, ctx, keys, keep)
assertRevokeTestPresentAuthorization(t, ctx, auths, keep)
assertRevokeTestMissingAuthKey(t, ctx, keys, revokedOne)
assertRevokeTestMissingAuthKey(t, ctx, keys, revokedTwo)
assertRevokeTestMissingAuthKey(t, ctx, keys, tempForTwo)
}
func createRevokeTestUser(t *testing.T, ctx context.Context, db *pgxpool.Pool, suffix string) int64 {
t.Helper()
phone := fmt.Sprintf("+1555%09d", time.Now().UnixNano()%1_000_000_000)
var userID int64
if err := db.QueryRow(ctx, `
INSERT INTO users (access_hash, phone, first_name)
VALUES ($1, $2, $3)
RETURNING id`, time.Now().UnixNano(), phone, "revoke-"+suffix).Scan(&userID); err != nil {
t.Fatalf("insert user: %v", err)
}
t.Cleanup(func() {
_, _ = db.Exec(ctx, "DELETE FROM users WHERE id = $1", userID)
})
return userID
}
func revokeTestAuthKeyID(seed byte) [8]byte {
return [8]byte{seed, seed, seed, seed, seed, seed, seed, seed}
}
func saveRevokeTestAuthKey(t *testing.T, ctx context.Context, keys store.AuthKeyStore, id [8]byte) {
t.Helper()
if err := keys.Save(ctx, store.AuthKeyData{ID: id, ServerSalt: int64(id[0])}); err != nil {
t.Fatalf("save auth key %x: %v", id, err)
}
t.Cleanup(func() {
_ = keys.Delete(ctx, id)
})
}
func assertRevokeTestMissingAuthKey(t *testing.T, ctx context.Context, keys store.AuthKeyStore, id [8]byte) {
t.Helper()
if _, found, err := keys.Get(ctx, id); err != nil || found {
t.Fatalf("auth key %x found=%v err=%v, want missing", id, found, err)
}
}
func assertRevokeTestPresentAuthKey(t *testing.T, ctx context.Context, keys store.AuthKeyStore, id [8]byte) {
t.Helper()
if _, found, err := keys.Get(ctx, id); err != nil || !found {
t.Fatalf("auth key %x found=%v err=%v, want present", id, found, err)
}
}
func assertRevokeTestNoAuthorization(t *testing.T, ctx context.Context, auths *AuthorizationStore, id [8]byte) {
t.Helper()
if _, found, err := auths.ByAuthKey(ctx, id); err != nil || found {
t.Fatalf("authorization %x found=%v err=%v, want missing", id, found, err)
}
}
func assertRevokeTestPresentAuthorization(t *testing.T, ctx context.Context, auths *AuthorizationStore, id [8]byte) {
t.Helper()
if _, found, err := auths.ByAuthKey(ctx, id); err != nil || !found {
t.Fatalf("authorization %x found=%v err=%v, want present", id, found, err)
}
}
func assertRevokeTestTableCount(t *testing.T, ctx context.Context, db *pgxpool.Pool, table, column string, value int64, want int) {
t.Helper()
var got int
if err := db.QueryRow(ctx, fmt.Sprintf("SELECT count(*) FROM %s WHERE %s = $1", table, column), value).Scan(&got); err != nil {
t.Fatalf("count %s.%s: %v", table, column, err)
}
if got != want {
t.Fatalf("count %s.%s = %d, want %d", table, column, got, want)
}
}

View file

@ -0,0 +1,441 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// BotStore 用 PostgreSQL 实现 store.BotStore。
type BotStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewBotStore 基于 pgx 连接池(或事务)创建 BotStore。
func NewBotStore(db sqlcgen.DBTX) *BotStore {
return &BotStore{db: db, q: sqlcgen.New(db)}
}
// CreateBotAccount 在单事务内创建 users 行(is_bot=true, phone 空)与 bots 行。
func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profile domain.BotProfile) (domain.User, domain.BotProfile, error) {
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
q := s.q.WithTx(tx)
// 事务内对 owner 取 advisory lock 后复核计数,封死 service 层 count-then-insert
// 的 TOCTOU(多设备/并发 /newbot 各自读到 count<limit 后超额落库)。key 用
// owner_user_id,可能与私聊发送锁共享 key 空间,但 bot 创建低频,最坏只是偶发
// 串行化、无正确性影响。
if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", profile.OwnerUserID); err != nil {
return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: lock owner: %w", err)
}
var owned int64
if err := tx.QueryRow(ctx,
"SELECT count(*) FROM bots WHERE owner_user_id = $1 AND bot_user_id <> owner_user_id",
profile.OwnerUserID).Scan(&owned); err != nil {
return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: count: %w", err)
}
if owned >= int64(domain.MaxBotsPerOwner) {
return domain.User{}, domain.BotProfile{}, domain.ErrBotsTooMany
}
row, err := q.InsertBotUser(ctx, sqlcgen.InsertBotUserParams{
AccessHash: user.AccessHash,
FirstName: user.FirstName,
Username: strings.TrimSpace(strings.TrimPrefix(user.Username, "@")),
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation && pgErr.ConstraintName == "users_username_lower_unique_idx" {
return domain.User{}, domain.BotProfile{}, domain.ErrUsernameOccupied
}
return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: insert user: %w", err)
}
if usernameLower := strings.ToLower(row.Username); usernameLower != "" {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, usernameLower); err != nil {
return domain.User{}, domain.BotProfile{}, err
}
}
if err := q.InsertBot(ctx, sqlcgen.InsertBotParams{
BotUserID: row.ID,
OwnerUserID: profile.OwnerUserID,
TokenSecret: profile.TokenSecret,
}); err != nil {
return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: insert bot: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: commit: %w", err)
}
profile.BotUserID = row.ID
return userFromModel(row), profile, nil
}
func (s *BotStore) GetBot(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) {
if botUserID == 0 {
return domain.BotProfile{}, false, nil
}
row, err := s.q.GetBot(ctx, botUserID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.BotProfile{}, false, nil
}
return domain.BotProfile{}, false, fmt.Errorf("get bot: %w", err)
}
profile, err := s.enrichBotProfile(ctx, botProfileFromModel(row))
if err != nil {
return domain.BotProfile{}, false, err
}
return profile, true, nil
}
func (s *BotStore) GetBots(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) {
ids := uniqueNonZeroInt64s(botUserIDs...)
if len(ids) == 0 {
return nil, nil
}
rows, err := s.db.Query(ctx, `
SELECT bot_user_id, owner_user_id, token_secret, description, commands, bot_chat_history,
bot_nochats, inline_placeholder, created_at, updated_at, menu_button_type,
menu_button_text, menu_button_url, bot_inline_geo
FROM bots
WHERE bot_user_id = ANY($1::bigint[])`, ids)
if err != nil {
return nil, fmt.Errorf("get bots: %w", err)
}
defer rows.Close()
out := make(map[int64]domain.BotProfile, len(ids))
for rows.Next() {
var row sqlcgen.Bot
if err := rows.Scan(
&row.BotUserID,
&row.OwnerUserID,
&row.TokenSecret,
&row.Description,
&row.Commands,
&row.BotChatHistory,
&row.BotNochats,
&row.InlinePlaceholder,
&row.CreatedAt,
&row.UpdatedAt,
&row.MenuButtonType,
&row.MenuButtonText,
&row.MenuButtonUrl,
&row.BotInlineGeo,
); err != nil {
return nil, err
}
out[row.BotUserID] = botProfileFromModel(row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("get bots: %w", err)
}
for id, profile := range out {
enriched, err := s.enrichBotProfile(ctx, profile)
if err != nil {
return nil, err
}
out[id] = enriched
}
return out, nil
}
func (s *BotStore) ListBotsByOwner(ctx context.Context, ownerUserID int64) ([]domain.BotProfile, error) {
if ownerUserID == 0 {
return nil, nil
}
rows, err := s.q.ListBotsByOwner(ctx, ownerUserID)
if err != nil {
return nil, fmt.Errorf("list bots by owner: %w", err)
}
out := make([]domain.BotProfile, 0, len(rows))
for _, row := range rows {
profile, err := s.enrichBotProfile(ctx, botProfileFromModel(row))
if err != nil {
return nil, err
}
out = append(out, profile)
}
return out, nil
}
func (s *BotStore) CountBotsByOwner(ctx context.Context, ownerUserID int64) (int, error) {
if ownerUserID == 0 {
return 0, nil
}
n, err := s.q.CountBotsByOwner(ctx, ownerUserID)
if err != nil {
return 0, fmt.Errorf("count bots by owner: %w", err)
}
return int(n), nil
}
func (s *BotStore) UpdateBotTokenSecret(ctx context.Context, botUserID int64, secret string) error {
n, err := s.q.UpdateBotTokenSecret(ctx, sqlcgen.UpdateBotTokenSecretParams{
BotUserID: botUserID,
TokenSecret: secret,
})
if err != nil {
return fmt.Errorf("update bot token secret: %w", err)
}
if n == 0 {
return domain.ErrBotNotFound
}
return nil
}
// withBumpTx 在单事务内执行 update 闭包后 bump bot 的 bot_info_version,返回新版本。
func (s *BotStore) withBumpTx(ctx context.Context, botUserID int64, fn func(q *sqlcgen.Queries) error) (int, error) {
beginner, ok := s.db.(txBeginner)
if !ok {
return 0, fmt.Errorf("bot update: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("bot update: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
q := s.q.WithTx(tx)
if err := fn(q); err != nil {
return 0, err
}
ver, err := q.BumpBotInfoVersion(ctx, botUserID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, domain.ErrBotNotFound
}
return 0, fmt.Errorf("bot update: bump version: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("bot update: commit: %w", err)
}
return int(ver), nil
}
func (s *BotStore) UpdateBotCommands(ctx context.Context, botUserID int64, commands []domain.BotCommand) (int, error) {
payload, err := json.Marshal(commands)
if err != nil {
return 0, fmt.Errorf("update bot commands: encode: %w", err)
}
if len(commands) == 0 {
payload = []byte("[]")
}
return s.withBumpTx(ctx, botUserID, func(q *sqlcgen.Queries) error {
if _, err := q.UpdateBotCommandsRow(ctx, sqlcgen.UpdateBotCommandsRowParams{
BotUserID: botUserID,
Commands: payload,
}); err != nil {
return fmt.Errorf("update bot commands: %w", err)
}
return nil
})
}
func (s *BotStore) UpdateBotInfo(ctx context.Context, botUserID int64, upd domain.BotInfoUpdate) (int, error) {
return s.withBumpTx(ctx, botUserID, func(q *sqlcgen.Queries) error {
if upd.SetName || upd.SetAbout {
params := sqlcgen.UpdateBotProfileFieldsParams{ID: botUserID}
if upd.SetName {
name := upd.Name
params.FirstName = &name
}
if upd.SetAbout {
about := upd.About
params.About = &about
}
if _, err := q.UpdateBotProfileFields(ctx, params); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrBotNotFound
}
return fmt.Errorf("update bot profile: %w", err)
}
}
if upd.SetDescription {
if _, err := q.UpdateBotDescriptionRow(ctx, sqlcgen.UpdateBotDescriptionRowParams{
BotUserID: botUserID,
Description: upd.Description,
}); err != nil {
return fmt.Errorf("update bot description: %w", err)
}
}
return nil
})
}
func (s *BotStore) UpdateBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (int, error) {
return s.withBumpTx(ctx, botUserID, func(q *sqlcgen.Queries) error {
if _, err := q.UpdateBotMenuButtonRow(ctx, sqlcgen.UpdateBotMenuButtonRowParams{
BotUserID: botUserID,
MenuButtonType: int16(button.Type),
MenuButtonText: button.Text,
MenuButtonUrl: button.URL,
}); err != nil {
return fmt.Errorf("update bot menu button: %w", err)
}
return nil
})
}
func (s *BotStore) SetBotInlinePlaceholder(ctx context.Context, botUserID int64, placeholder string) (int, error) {
return s.withBumpTx(ctx, botUserID, func(q *sqlcgen.Queries) error {
if _, err := q.SetBotInlinePlaceholderRow(ctx, sqlcgen.SetBotInlinePlaceholderRowParams{
BotUserID: botUserID,
InlinePlaceholder: placeholder,
}); err != nil {
return fmt.Errorf("set bot inline placeholder: %w", err)
}
return nil
})
}
func (s *BotStore) SetBotInlineGeo(ctx context.Context, botUserID int64, inlineGeo bool) (int, error) {
return s.withBumpTx(ctx, botUserID, func(q *sqlcgen.Queries) error {
if _, err := q.SetBotInlineGeoRow(ctx, sqlcgen.SetBotInlineGeoRowParams{
BotUserID: botUserID,
BotInlineGeo: inlineGeo,
}); err != nil {
return fmt.Errorf("set bot inline geo: %w", err)
}
return nil
})
}
func (s *BotStore) SetBotNochats(ctx context.Context, botUserID int64, nochats bool) (int, error) {
return s.withBumpTx(ctx, botUserID, func(q *sqlcgen.Queries) error {
if _, err := q.SetBotNochatsRow(ctx, sqlcgen.SetBotNochatsRowParams{
BotUserID: botUserID,
BotNochats: nochats,
}); err != nil {
return fmt.Errorf("set bot nochats: %w", err)
}
return nil
})
}
func (s *BotStore) SetBotChatHistory(ctx context.Context, botUserID int64, chatHistory bool) (int, error) {
return s.withBumpTx(ctx, botUserID, func(q *sqlcgen.Queries) error {
if _, err := q.SetBotChatHistoryRow(ctx, sqlcgen.SetBotChatHistoryRowParams{
BotUserID: botUserID,
BotChatHistory: chatHistory,
}); err != nil {
return fmt.Errorf("set bot chat history: %w", err)
}
return nil
})
}
func (s *BotStore) CanBotSendMessage(ctx context.Context, botUserID, userID int64) (bool, error) {
if botUserID == 0 || userID == 0 || botUserID == userID {
return false, nil
}
allowed, err := s.q.CanBotSendMessage(ctx, sqlcgen.CanBotSendMessageParams{
BotUserID: botUserID,
UserID: userID,
})
if err != nil {
return false, fmt.Errorf("can bot send message: %w", err)
}
return allowed, nil
}
func (s *BotStore) AllowBotSendMessage(ctx context.Context, botUserID, userID int64, fromRequest bool) (bool, error) {
if botUserID == 0 || userID == 0 || botUserID == userID {
return false, domain.ErrBotNotFound
}
created, err := s.q.AllowBotSendMessage(ctx, sqlcgen.AllowBotSendMessageParams{
BotUserID: botUserID,
UserID: userID,
FromRequest: fromRequest,
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.ForeignKeyViolation {
return false, domain.ErrBotNotFound
}
return false, fmt.Errorf("allow bot send message: %w", err)
}
return created, nil
}
func (s *BotStore) GetBotChatState(ctx context.Context, botUserID, userID int64) (domain.BotChatState, bool, error) {
row, err := s.q.GetBotChatState(ctx, sqlcgen.GetBotChatStateParams{
BotUserID: botUserID,
UserID: userID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.BotChatState{}, false, nil
}
return domain.BotChatState{}, false, fmt.Errorf("get bot chat state: %w", err)
}
state := domain.BotChatState{BotUserID: botUserID, UserID: userID}
if err := json.Unmarshal(row.State, &state); err != nil {
return domain.BotChatState{}, false, fmt.Errorf("get bot chat state: decode: %w", err)
}
state.BotUserID, state.UserID = botUserID, userID
return state, true, nil
}
func (s *BotStore) UpsertBotChatState(ctx context.Context, state domain.BotChatState) error {
payload, err := json.Marshal(state)
if err != nil {
return fmt.Errorf("upsert bot chat state: encode: %w", err)
}
if err := s.q.UpsertBotChatState(ctx, sqlcgen.UpsertBotChatStateParams{
BotUserID: state.BotUserID,
UserID: state.UserID,
State: payload,
}); err != nil {
return fmt.Errorf("upsert bot chat state: %w", err)
}
return nil
}
func (s *BotStore) DeleteBotChatState(ctx context.Context, botUserID, userID int64) error {
if err := s.q.DeleteBotChatState(ctx, sqlcgen.DeleteBotChatStateParams{
BotUserID: botUserID,
UserID: userID,
}); err != nil {
return fmt.Errorf("delete bot chat state: %w", err)
}
return nil
}
func botProfileFromModel(r sqlcgen.Bot) domain.BotProfile {
p := domain.BotProfile{
BotUserID: r.BotUserID,
OwnerUserID: r.OwnerUserID,
TokenSecret: r.TokenSecret,
Description: r.Description,
ChatHistory: r.BotChatHistory,
Nochats: r.BotNochats,
InlineGeo: r.BotInlineGeo,
InlinePlaceholder: r.InlinePlaceholder,
MenuButton: domain.BotMenuButton{
Type: domain.BotMenuButtonType(r.MenuButtonType),
Text: r.MenuButtonText,
URL: r.MenuButtonUrl,
},
}
if len(r.Commands) > 0 {
// commands 列损坏不阻断读路径:botInfo 退化为空命令列表。
_ = json.Unmarshal(r.Commands, &p.Commands)
}
return p
}

View file

@ -0,0 +1,235 @@
package postgres
import (
"context"
"fmt"
"testing"
"time"
appauth "telesrv/internal/app/auth"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// TestBotStoreRoundTripPostgres 验证迁移 0090 的 bot 模型:BotFather 种子行、
// CreateBotAccount 双行事务、空 phone 不撞唯一索引、token 轮换、对话状态。
func TestBotStoreRoundTripPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
users := NewUserStore(pool)
bots := NewBotStore(pool)
// BotFather 种子:users 行 + bots 行 + 不可登录(token 为空)。
bf, found, err := users.ByID(ctx, domain.BotFatherUserID)
if err != nil || !found {
t.Fatalf("BotFather user not seeded: found=%v err=%v", found, err)
}
if !bf.Bot || bf.BotInfoVersion < 1 || bf.Username != "BotFather" || !bf.Verified {
t.Fatalf("BotFather user = %+v, want verified bot with bot_info_version>=1", bf)
}
bfProfile, found, err := bots.GetBot(ctx, domain.BotFatherUserID)
if err != nil || !found {
t.Fatalf("BotFather bots row not seeded: found=%v err=%v", found, err)
}
if bfProfile.TokenSecret != "" || len(bfProfile.Commands) == 0 {
t.Fatalf("BotFather profile = %+v, want empty token with seeded commands", bfProfile)
}
// 空 phone 查询不得命中任何行。
if _, found, err := users.ByPhone(ctx, ""); err != nil || found {
t.Fatalf("ByPhone('') found=%v err=%v, want not found", found, err)
}
// owner 用户 + 两个空 phone 的 bot(验证部分唯一索引)。
suffix := time.Now().UnixNano() % 1_000_000_000
ownerPhone := fmt.Sprintf("1666%d", suffix)
owner, err := users.Create(ctx, domain.User{AccessHash: 7, Phone: ownerPhone, FirstName: "BotOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
makeBot := func(i int) (domain.User, domain.BotProfile) {
u, p, err := bots.CreateBotAccount(ctx, domain.User{
AccessHash: int64(100 + i),
FirstName: fmt.Sprintf("IT Bot %d", i),
Username: fmt.Sprintf("it%d_%d_bot", i, suffix),
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: fmt.Sprintf("secret-%d-%d", i, suffix)})
if err != nil {
t.Fatalf("create bot %d: %v", i, err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", u.ID) // bots 行随 FK CASCADE
})
return u, p
}
bot1, profile1 := makeBot(1)
bot2, _ := makeBot(2)
if bot1.Phone != "" || bot2.Phone != "" {
t.Fatalf("bot phones = %q/%q, want empty", bot1.Phone, bot2.Phone)
}
if !bot1.Bot || bot1.BotInfoVersion != 1 {
t.Fatalf("bot1 = %+v, want is_bot with bot_info_version=1", bot1)
}
// username 冲突映射 ErrUsernameOccupied,且不留 users 孤儿行。
if _, _, err := bots.CreateBotAccount(ctx, domain.User{
AccessHash: 999,
FirstName: "Dup",
Username: bot1.Username,
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "x"}); err != domain.ErrUsernameOccupied {
t.Fatalf("duplicate username err = %v, want ErrUsernameOccupied", err)
}
if n, err := bots.CountBotsByOwner(ctx, owner.ID); err != nil || n != 2 {
t.Fatalf("CountBotsByOwner = %d err=%v, want 2", n, err)
}
list, err := bots.ListBotsByOwner(ctx, owner.ID)
if err != nil || len(list) != 2 {
t.Fatalf("ListBotsByOwner = %d err=%v, want 2", len(list), err)
}
// token 轮换。
if err := bots.UpdateBotTokenSecret(ctx, bot1.ID, "rotated-secret"); err != nil {
t.Fatalf("rotate token: %v", err)
}
rotated, _, err := bots.GetBot(ctx, bot1.ID)
if err != nil || rotated.TokenSecret != "rotated-secret" {
t.Fatalf("rotated profile = %+v err=%v", rotated, err)
}
if err := bots.UpdateBotTokenSecret(ctx, 424242, "x"); err != domain.ErrBotNotFound {
t.Fatalf("rotate missing bot err = %v, want ErrBotNotFound", err)
}
// 对话状态 upsert/get/delete。
state := domain.BotChatState{
BotUserID: domain.BotFatherUserID,
UserID: owner.ID,
Command: "newbot",
Step: "username",
Draft: map[string]string{"name": "IT Bot"},
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM bot_chat_states WHERE user_id = $1", owner.ID)
})
if err := bots.UpsertBotChatState(ctx, state); err != nil {
t.Fatalf("upsert state: %v", err)
}
got, found, err := bots.GetBotChatState(ctx, domain.BotFatherUserID, owner.ID)
if err != nil || !found {
t.Fatalf("get state: found=%v err=%v", found, err)
}
if got.Command != "newbot" || got.Step != "username" || got.Draft["name"] != "IT Bot" {
t.Fatalf("state = %+v, want round-trip", got)
}
if err := bots.DeleteBotChatState(ctx, domain.BotFatherUserID, owner.ID); err != nil {
t.Fatalf("delete state: %v", err)
}
if _, found, _ := bots.GetBotChatState(ctx, domain.BotFatherUserID, owner.ID); found {
t.Fatal("state still present after delete")
}
// P2 元数据写入 + bot_info_version bump(postgres 单事务)。
uBefore, _, _ := users.ByID(ctx, bot1.ID)
v1, err := bots.UpdateBotCommands(ctx, bot1.ID, []domain.BotCommand{{Command: "start", Description: "begin"}})
if err != nil || v1 <= uBefore.BotInfoVersion {
t.Fatalf("UpdateBotCommands version = %d err=%v, want > %d", v1, err, uBefore.BotInfoVersion)
}
gotBot, _, _ := bots.GetBot(ctx, bot1.ID)
if len(gotBot.Commands) != 1 || gotBot.Commands[0].Command != "start" {
t.Fatalf("commands = %+v, want [start]", gotBot.Commands)
}
v2, err := bots.UpdateBotInfo(ctx, bot1.ID, domain.BotInfoUpdate{
SetName: true, Name: "PG Bot", SetAbout: true, About: "pg about", SetDescription: true, Description: "pg desc",
})
if err != nil || v2 <= v1 {
t.Fatalf("UpdateBotInfo version = %d err=%v, want > %d", v2, err, v1)
}
uAfter, _, _ := users.ByID(ctx, bot1.ID)
if uAfter.FirstName != "PG Bot" || uAfter.About != "pg about" {
t.Fatalf("bot user after setInfo = first_name=%q about=%q", uAfter.FirstName, uAfter.About)
}
if descBot, _, _ := bots.GetBot(ctx, bot1.ID); descBot.Description != "pg desc" {
t.Fatalf("description = %q, want 'pg desc'", descBot.Description)
}
v3, err := bots.UpdateBotMenuButton(ctx, bot1.ID, domain.BotMenuButton{Type: domain.BotMenuButtonWebView, Text: "Open", URL: "https://pg.example/app"})
if err != nil || v3 <= v2 {
t.Fatalf("UpdateBotMenuButton version = %d err=%v, want > %d", v3, err, v2)
}
if mbBot, _, _ := bots.GetBot(ctx, bot1.ID); mbBot.MenuButton.Type != domain.BotMenuButtonWebView || mbBot.MenuButton.URL != "https://pg.example/app" {
t.Fatalf("menu button = %+v", mbBot.MenuButton)
}
v4, err := bots.SetBotInlinePlaceholder(ctx, bot1.ID, "Search PG")
if err != nil || v4 <= v3 {
t.Fatalf("SetBotInlinePlaceholder version = %d err=%v, want > %d", v4, err, v3)
}
if inlineBot, _, _ := bots.GetBot(ctx, bot1.ID); inlineBot.InlinePlaceholder != "Search PG" {
t.Fatalf("inline placeholder = %q, want Search PG", inlineBot.InlinePlaceholder)
}
v5, err := bots.SetBotInlineGeo(ctx, bot1.ID, true)
if err != nil || v5 <= v4 {
t.Fatalf("SetBotInlineGeo version = %d err=%v, want > %d", v5, err, v4)
}
if inlineBot, _, _ := bots.GetBot(ctx, bot1.ID); !inlineBot.InlineGeo {
t.Fatalf("inline geo = false, want true")
}
if _, err := bots.SetBotNochats(ctx, bot1.ID, true); err != nil {
t.Fatalf("SetBotNochats: %v", err)
}
if _, err := bots.SetBotChatHistory(ctx, bot1.ID, true); err != nil {
t.Fatalf("SetBotChatHistory: %v", err)
}
if flagBot, _, _ := bots.GetBot(ctx, bot1.ID); !flagBot.Nochats || !flagBot.ChatHistory {
t.Fatalf("flags = nochats=%v chat_history=%v, want both true", flagBot.Nochats, flagBot.ChatHistory)
}
if can, err := bots.CanBotSendMessage(ctx, bot1.ID, owner.ID); err != nil || can {
t.Fatalf("CanBotSendMessage before allow = %v,%v, want false,nil", can, err)
}
if created, err := bots.AllowBotSendMessage(ctx, bot1.ID, owner.ID, true); err != nil || !created {
t.Fatalf("AllowBotSendMessage first = %v,%v, want true,nil", created, err)
}
if can, err := bots.CanBotSendMessage(ctx, bot1.ID, owner.ID); err != nil || !can {
t.Fatalf("CanBotSendMessage after allow = %v,%v, want true,nil", can, err)
}
if created, err := bots.AllowBotSendMessage(ctx, bot1.ID, owner.ID, true); err != nil || created {
t.Fatalf("AllowBotSendMessage repeat = %v,%v, want false,nil", created, err)
}
// 不存在的 bot → ErrBotNotFound。
if _, err := bots.UpdateBotCommands(ctx, 424243, nil); err != domain.ErrBotNotFound {
t.Fatalf("update missing bot err = %v, want ErrBotNotFound", err)
}
// SignInBot:PG 全链路 token 校验 + authorizations 绑定。
authSvc := appauth.NewService(users, NewAuthorizationStore(pool), memory.NewCodeStore(), nil, nil, "12345",
appauth.WithBotLogin(bots))
var authKeyID [8]byte
copy(authKeyID[:], fmt.Sprintf("%08d", suffix%100000000))
if _, err := pool.Exec(ctx, "INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, $2, 0) ON CONFLICT DO NOTHING",
authKeyIDToInt64(authKeyID), make([]byte, 256)); err != nil {
t.Fatalf("seed auth key: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(authKeyID))
})
token := domain.FormatBotToken(profile1.BotUserID, "rotated-secret")
u, err := authSvc.SignInBot(ctx, domain.Authorization{AuthKeyID: authKeyID}, token)
if err != nil {
t.Fatalf("SignInBot: %v", err)
}
if u.ID != bot1.ID || !u.Bot {
t.Fatalf("SignInBot user = %+v, want bot %d", u, bot1.ID)
}
if uid, ok, err := authSvc.UserID(ctx, authKeyID); err != nil || !ok || uid != bot1.ID {
t.Fatalf("UserID after SignInBot = %d,%v,%v, want %d", uid, ok, err, bot1.ID)
}
// 旧 token(轮换前 secret)必须失效。
oldToken := domain.FormatBotToken(profile1.BotUserID, profile1.TokenSecret)
if _, err := authSvc.SignInBot(ctx, domain.Authorization{AuthKeyID: authKeyID}, oldToken); err != domain.ErrBotTokenInvalid {
t.Fatalf("old token err = %v, want ErrBotTokenInvalid", err)
}
// BotFather 空 token 永不可登录。
if _, err := authSvc.SignInBot(ctx, domain.Authorization{AuthKeyID: authKeyID}, domain.FormatBotToken(domain.BotFatherUserID, "")); err != domain.ErrBotTokenInvalid {
t.Fatalf("BotFather token err = %v, want ErrBotTokenInvalid", err)
}
}

View file

@ -0,0 +1,585 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
func (s *BotStore) withBotInfoBumpRawTx(ctx context.Context, botUserID int64, fn func(pgx.Tx) error) (int, error) {
beginner, ok := s.db.(txBeginner)
if !ok {
return 0, fmt.Errorf("bot mini app update: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("bot mini app update: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := fn(tx); err != nil {
return 0, err
}
ver, err := s.q.WithTx(tx).BumpBotInfoVersion(ctx, botUserID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, domain.ErrBotNotFound
}
return 0, fmt.Errorf("bot mini app update: bump version: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("bot mini app update: commit: %w", err)
}
return int(ver), nil
}
func (s *BotStore) enrichBotProfile(ctx context.Context, profile domain.BotProfile) (domain.BotProfile, error) {
if profile.BotUserID == 0 {
return profile, nil
}
var hasMain, hasAttach, hasPreview bool
if err := s.db.QueryRow(ctx, `
SELECT
EXISTS(SELECT 1 FROM bot_apps WHERE bot_user_id=$1 AND is_main),
EXISTS(SELECT 1 FROM attach_menu_bots WHERE bot_user_id=$1),
EXISTS(SELECT 1 FROM bot_app_preview_media WHERE bot_user_id=$1)`, profile.BotUserID).
Scan(&hasMain, &hasAttach, &hasPreview); err != nil {
return profile, fmt.Errorf("enrich bot profile: flags: %w", err)
}
profile.HasMainApp = hasMain
profile.HasAttachMenu = hasAttach
profile.HasPreviewMedias = hasPreview
settings, found, err := s.GetBotAppSettings(ctx, profile.BotUserID)
if err != nil {
return profile, err
}
if found {
profile.AppSettings = &settings
}
return profile, nil
}
func (s *BotStore) UpsertBotApp(ctx context.Context, app domain.BotApp) (domain.BotApp, int, error) {
if app.BotUserID == 0 || app.ID == 0 || app.AccessHash == 0 || strings.TrimSpace(app.ShortName) == "" || strings.TrimSpace(app.URL) == "" {
return domain.BotApp{}, 0, domain.ErrBotAppInvalid
}
app.ShortName = strings.ToLower(strings.TrimSpace(app.ShortName))
version, err := s.withBotInfoBumpRawTx(ctx, app.BotUserID, func(tx pgx.Tx) error {
if app.Main {
if _, err := tx.Exec(ctx, `UPDATE bot_apps SET is_main=false, updated_at=now() WHERE bot_user_id=$1 AND id<>$2 AND is_main`, app.BotUserID, app.ID); err != nil {
return fmt.Errorf("upsert bot app: clear main: %w", err)
}
}
_, err := tx.Exec(ctx, `
INSERT INTO bot_apps (
id, bot_user_id, short_name, title, description, url, photo_id, document_id,
access_hash, hash, inactive, request_write_access, has_settings, is_main
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
ON CONFLICT (id) DO UPDATE SET
short_name=EXCLUDED.short_name,
title=EXCLUDED.title,
description=EXCLUDED.description,
url=EXCLUDED.url,
photo_id=EXCLUDED.photo_id,
document_id=EXCLUDED.document_id,
access_hash=EXCLUDED.access_hash,
hash=EXCLUDED.hash,
inactive=EXCLUDED.inactive,
request_write_access=EXCLUDED.request_write_access,
has_settings=EXCLUDED.has_settings,
is_main=EXCLUDED.is_main,
updated_at=now()`,
app.ID, app.BotUserID, app.ShortName, app.Title, app.Description, app.URL, app.PhotoID, app.DocumentID,
app.AccessHash, app.Hash, app.Inactive, app.RequestWriteAccess, app.HasSettings, app.Main)
if err != nil {
if isUniqueViolation(err) {
return domain.ErrBotAppInvalid
}
return fmt.Errorf("upsert bot app: %w", err)
}
return nil
})
if err != nil {
return domain.BotApp{}, 0, err
}
return app, version, nil
}
func (s *BotStore) GetBotAppByID(ctx context.Context, appID, accessHash int64) (domain.BotApp, bool, error) {
if appID == 0 || accessHash == 0 {
return domain.BotApp{}, false, nil
}
row := s.db.QueryRow(ctx, botAppSelectSQL()+` WHERE id=$1 AND access_hash=$2`, appID, accessHash)
return scanBotApp(row)
}
func (s *BotStore) GetBotAppByShortName(ctx context.Context, botUserID int64, shortName string) (domain.BotApp, bool, error) {
shortName = strings.ToLower(strings.TrimSpace(shortName))
if botUserID == 0 || shortName == "" {
return domain.BotApp{}, false, nil
}
row := s.db.QueryRow(ctx, botAppSelectSQL()+` WHERE bot_user_id=$1 AND lower(short_name)=lower($2)`, botUserID, shortName)
return scanBotApp(row)
}
func (s *BotStore) GetMainBotApp(ctx context.Context, botUserID int64) (domain.BotApp, bool, error) {
if botUserID == 0 {
return domain.BotApp{}, false, nil
}
row := s.db.QueryRow(ctx, botAppSelectSQL()+` WHERE bot_user_id=$1 AND is_main LIMIT 1`, botUserID)
return scanBotApp(row)
}
func (s *BotStore) ListBotApps(ctx context.Context, botUserID int64) ([]domain.BotApp, error) {
rows, err := s.db.Query(ctx, botAppSelectSQL()+` WHERE bot_user_id=$1 ORDER BY is_main DESC, short_name, id`, botUserID)
if err != nil {
return nil, fmt.Errorf("list bot apps: %w", err)
}
defer rows.Close()
var out []domain.BotApp
for rows.Next() {
app, err := scanBotAppRows(rows)
if err != nil {
return nil, err
}
out = append(out, app)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list bot apps: %w", err)
}
return out, nil
}
func (s *BotStore) GetBotAppSettings(ctx context.Context, botUserID int64) (domain.BotAppSettings, bool, error) {
var settings domain.BotAppSettings
var bg, bgDark, header, headerDark sql.NullInt64
err := s.db.QueryRow(ctx, `
SELECT placeholder_path, background_color, background_dark_color, header_color, header_dark_color
FROM bot_app_settings WHERE bot_user_id=$1`, botUserID).
Scan(&settings.PlaceholderPath, &bg, &bgDark, &header, &headerDark)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.BotAppSettings{}, false, nil
}
return domain.BotAppSettings{}, false, fmt.Errorf("get bot app settings: %w", err)
}
settings.HasBackgroundColor = bg.Valid
settings.HasBackgroundDark = bgDark.Valid
settings.HasHeaderColor = header.Valid
settings.HasHeaderDarkColor = headerDark.Valid
if bg.Valid {
settings.BackgroundColor = int(bg.Int64)
}
if bgDark.Valid {
settings.BackgroundDarkColor = int(bgDark.Int64)
}
if header.Valid {
settings.HeaderColor = int(header.Int64)
}
if headerDark.Valid {
settings.HeaderDarkColor = int(headerDark.Int64)
}
return settings, true, nil
}
func (s *BotStore) UpsertBotAppSettings(ctx context.Context, botUserID int64, settings domain.BotAppSettings) (int, error) {
return s.withBotInfoBumpRawTx(ctx, botUserID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
INSERT INTO bot_app_settings (
bot_user_id, placeholder_path, background_color, background_dark_color, header_color, header_dark_color
) VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (bot_user_id) DO UPDATE SET
placeholder_path=EXCLUDED.placeholder_path,
background_color=EXCLUDED.background_color,
background_dark_color=EXCLUDED.background_dark_color,
header_color=EXCLUDED.header_color,
header_dark_color=EXCLUDED.header_dark_color,
updated_at=now()`,
botUserID, settings.PlaceholderPath,
nullableBotAppInt(settings.BackgroundColor, settings.HasBackgroundColor),
nullableBotAppInt(settings.BackgroundDarkColor, settings.HasBackgroundDark),
nullableBotAppInt(settings.HeaderColor, settings.HasHeaderColor),
nullableBotAppInt(settings.HeaderDarkColor, settings.HasHeaderDarkColor))
if err != nil {
return fmt.Errorf("upsert bot app settings: %w", err)
}
return nil
})
}
func (s *BotStore) ListBotAppPreviewMedia(ctx context.Context, botUserID, appID int64) ([]domain.BotAppPreviewMedia, error) {
rows, err := s.db.Query(ctx, `
SELECT id, bot_user_id, app_id, position, photo_id, document_id
FROM bot_app_preview_media
WHERE bot_user_id=$1 AND app_id=$2
ORDER BY position, id`, botUserID, appID)
if err != nil {
return nil, fmt.Errorf("list bot app preview media: %w", err)
}
defer rows.Close()
var out []domain.BotAppPreviewMedia
for rows.Next() {
var media domain.BotAppPreviewMedia
if err := rows.Scan(&media.ID, &media.BotUserID, &media.AppID, &media.Position, &media.PhotoID, &media.DocumentID); err != nil {
return nil, err
}
out = append(out, media)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list bot app preview media: %w", err)
}
return out, nil
}
func (s *BotStore) UpsertBotAppPreviewMedia(ctx 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
}
version, err := s.withBotInfoBumpRawTx(ctx, media.BotUserID, func(tx pgx.Tx) error {
if media.Position <= 0 {
if err := tx.QueryRow(ctx, `SELECT COALESCE(MAX(position), 0) + 1 FROM bot_app_preview_media WHERE app_id=$1`, media.AppID).Scan(&media.Position); err != nil {
return fmt.Errorf("upsert bot preview media: next position: %w", err)
}
}
if media.ID == 0 {
return tx.QueryRow(ctx, `
INSERT INTO bot_app_preview_media (bot_user_id, app_id, position, photo_id, document_id)
VALUES ($1,$2,$3,$4,$5)
RETURNING id`, media.BotUserID, media.AppID, media.Position, media.PhotoID, media.DocumentID).Scan(&media.ID)
}
_, err := tx.Exec(ctx, `
INSERT INTO bot_app_preview_media (id, bot_user_id, app_id, position, photo_id, document_id)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (id) DO UPDATE SET
position=EXCLUDED.position,
photo_id=EXCLUDED.photo_id,
document_id=EXCLUDED.document_id,
updated_at=now()`,
media.ID, media.BotUserID, media.AppID, media.Position, media.PhotoID, media.DocumentID)
return err
})
if err != nil {
return domain.BotAppPreviewMedia{}, 0, err
}
return media, version, nil
}
func (s *BotStore) DeleteBotAppPreviewMedia(ctx context.Context, botUserID, appID, mediaID int64) (int, error) {
return s.withBotInfoBumpRawTx(ctx, botUserID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `DELETE FROM bot_app_preview_media WHERE bot_user_id=$1 AND app_id=$2 AND id=$3`, botUserID, appID, mediaID)
if err != nil {
return fmt.Errorf("delete bot app preview media: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.ErrBotAppInvalid
}
return nil
})
}
func (s *BotStore) ReorderBotAppPreviewMedia(ctx context.Context, botUserID, appID int64, mediaIDs []int64) (int, error) {
return s.withBotInfoBumpRawTx(ctx, botUserID, func(tx pgx.Tx) error {
for i, id := range mediaIDs {
tag, err := tx.Exec(ctx, `UPDATE bot_app_preview_media SET position=$1, updated_at=now() WHERE bot_user_id=$2 AND app_id=$3 AND id=$4`, -(i + 1), botUserID, appID, id)
if err != nil {
return fmt.Errorf("reorder bot preview media: stage: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.ErrBotAppInvalid
}
}
for i, id := range mediaIDs {
tag, err := tx.Exec(ctx, `UPDATE bot_app_preview_media SET position=$1, updated_at=now() WHERE bot_user_id=$2 AND app_id=$3 AND id=$4`, i+1, botUserID, appID, id)
if err != nil {
return fmt.Errorf("reorder bot preview media: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.ErrBotAppInvalid
}
}
return nil
})
}
func (s *BotStore) UpsertAttachMenuBot(ctx context.Context, bot domain.BotAttachMenuBot) (int, error) {
if bot.BotUserID == 0 || strings.TrimSpace(bot.ShortName) == "" {
return 0, domain.ErrBotAttachMenuInvalid
}
bot.ShortName = strings.ToLower(strings.TrimSpace(bot.ShortName))
icons, err := json.Marshal(bot.Icons)
if err != nil {
return 0, fmt.Errorf("upsert attach menu bot: icons: %w", err)
}
return s.withBotInfoBumpRawTx(ctx, bot.BotUserID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
INSERT INTO attach_menu_bots (
bot_user_id, app_id, short_name, inactive, has_settings, request_write_access,
show_in_attach_menu, show_in_side_menu, side_menu_disclaimer_needed, peer_types, icons
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (bot_user_id) DO UPDATE SET
app_id=EXCLUDED.app_id,
short_name=EXCLUDED.short_name,
inactive=EXCLUDED.inactive,
has_settings=EXCLUDED.has_settings,
request_write_access=EXCLUDED.request_write_access,
show_in_attach_menu=EXCLUDED.show_in_attach_menu,
show_in_side_menu=EXCLUDED.show_in_side_menu,
side_menu_disclaimer_needed=EXCLUDED.side_menu_disclaimer_needed,
peer_types=EXCLUDED.peer_types,
icons=EXCLUDED.icons,
updated_at=now()`,
bot.BotUserID, nullableInt64(bot.AppID), bot.ShortName, bot.Inactive, bot.HasSettings, bot.RequestWriteAccess,
bot.ShowInAttachMenu, bot.ShowInSideMenu, bot.SideMenuDisclaimerNeeded, bot.PeerTypes, icons)
if err != nil {
return fmt.Errorf("upsert attach menu bot: %w", err)
}
return nil
})
}
func (s *BotStore) GetAttachMenuBot(ctx context.Context, botUserID int64) (domain.BotAttachMenuBot, bool, error) {
row := s.db.QueryRow(ctx, attachMenuBotSelectSQL()+` WHERE bot_user_id=$1`, botUserID)
return scanAttachMenuBot(row)
}
func (s *BotStore) ListAttachMenuBots(ctx context.Context) ([]domain.BotAttachMenuBot, error) {
rows, err := s.db.Query(ctx, attachMenuBotSelectSQL()+` ORDER BY inactive, bot_user_id`)
if err != nil {
return nil, fmt.Errorf("list attach menu bots: %w", err)
}
defer rows.Close()
var out []domain.BotAttachMenuBot
for rows.Next() {
bot, err := scanAttachMenuBotRows(rows)
if err != nil {
return nil, err
}
out = append(out, bot)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list attach menu bots: %w", err)
}
return out, nil
}
func (s *BotStore) GetAttachMenuState(ctx context.Context, userID, botUserID int64) (domain.BotAttachMenuState, bool, error) {
var state domain.BotAttachMenuState
err := s.db.QueryRow(ctx, `
SELECT user_id, bot_user_id, enabled, write_allowed
FROM attach_menu_user_states WHERE user_id=$1 AND bot_user_id=$2`, userID, botUserID).
Scan(&state.UserID, &state.BotUserID, &state.Enabled, &state.WriteAllowed)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.BotAttachMenuState{}, false, nil
}
return domain.BotAttachMenuState{}, false, fmt.Errorf("get attach menu state: %w", err)
}
return state, true, nil
}
func (s *BotStore) SetAttachMenuState(ctx context.Context, state domain.BotAttachMenuState) (domain.BotAttachMenuState, error) {
if state.UserID == 0 || state.BotUserID == 0 || state.UserID == state.BotUserID {
return domain.BotAttachMenuState{}, domain.ErrBotAttachMenuInvalid
}
_, err := s.db.Exec(ctx, `
INSERT INTO attach_menu_user_states (user_id, bot_user_id, enabled, write_allowed)
VALUES ($1,$2,$3,$4)
ON CONFLICT (user_id, bot_user_id) DO UPDATE SET
enabled=EXCLUDED.enabled,
write_allowed=attach_menu_user_states.write_allowed OR EXCLUDED.write_allowed,
updated_at=now()`,
state.UserID, state.BotUserID, state.Enabled, state.WriteAllowed)
if err != nil {
return domain.BotAttachMenuState{}, fmt.Errorf("set attach menu state: %w", err)
}
return s.GetAttachMenuStateValue(ctx, state.UserID, state.BotUserID)
}
func (s *BotStore) GetAttachMenuStateValue(ctx context.Context, userID, botUserID int64) (domain.BotAttachMenuState, error) {
state, found, err := s.GetAttachMenuState(ctx, userID, botUserID)
if err != nil {
return domain.BotAttachMenuState{}, err
}
if !found {
return domain.BotAttachMenuState{}, domain.ErrBotAttachMenuInvalid
}
return state, nil
}
func (s *BotStore) SaveRequestedWebViewButton(ctx context.Context, button domain.BotRequestedWebViewButton) error {
if button.BotUserID == 0 || button.UserID == 0 || button.WebAppReqID == "" || button.ExpiresAt.IsZero() {
return domain.ErrBotRequestedButtonInvalid
}
_, err := s.db.Exec(ctx, `
INSERT INTO webview_requested_buttons (webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (webapp_req_id) DO UPDATE SET
button_id=EXCLUDED.button_id,
text=EXCLUDED.text,
peer_type=EXCLUDED.peer_type,
max_quantity=EXCLUDED.max_quantity,
expires_at=EXCLUDED.expires_at`,
button.WebAppReqID, button.BotUserID, button.UserID, button.ButtonID, button.Text, button.PeerType, button.MaxQuantity, button.CreatedAt, button.ExpiresAt)
if err != nil {
return fmt.Errorf("save requested webview button: %w", err)
}
return nil
}
func (s *BotStore) GetRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) (domain.BotRequestedWebViewButton, bool, error) {
_, _ = s.db.Exec(ctx, `DELETE FROM webview_requested_buttons WHERE expires_at <= now()`)
var button domain.BotRequestedWebViewButton
err := s.db.QueryRow(ctx, `
SELECT webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at
FROM webview_requested_buttons
WHERE bot_user_id=$1 AND user_id=$2 AND webapp_req_id=$3 AND expires_at > now()`,
botUserID, userID, webAppReqID).
Scan(&button.WebAppReqID, &button.BotUserID, &button.UserID, &button.ButtonID, &button.Text, &button.PeerType, &button.MaxQuantity, &button.CreatedAt, &button.ExpiresAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.BotRequestedWebViewButton{}, false, nil
}
return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("get requested webview button: %w", err)
}
return button, true, nil
}
func (s *BotStore) DeleteRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM webview_requested_buttons WHERE bot_user_id=$1 AND user_id=$2 AND webapp_req_id=$3`, botUserID, userID, webAppReqID)
if err != nil {
return fmt.Errorf("delete requested webview button: %w", err)
}
return nil
}
func (s *BotStore) SetBotEmojiStatusPermission(ctx context.Context, botUserID, userID int64, allowed bool) error {
if botUserID == 0 || userID == 0 || botUserID == userID {
return domain.ErrBotNotFound
}
if !allowed {
_, err := s.db.Exec(ctx, `DELETE FROM bot_emoji_status_permissions WHERE bot_user_id=$1 AND user_id=$2`, botUserID, userID)
return err
}
_, err := s.db.Exec(ctx, `
INSERT INTO bot_emoji_status_permissions (bot_user_id, user_id, allowed)
VALUES ($1,$2,true)
ON CONFLICT (bot_user_id, user_id) DO UPDATE SET allowed=true, updated_at=now()`, botUserID, userID)
if err != nil {
return fmt.Errorf("set bot emoji status permission: %w", err)
}
return nil
}
func (s *BotStore) BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error) {
var allowed bool
err := s.db.QueryRow(ctx, `SELECT allowed FROM bot_emoji_status_permissions WHERE bot_user_id=$1 AND user_id=$2`, botUserID, userID).Scan(&allowed)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("bot emoji status permission: %w", err)
}
return allowed, nil
}
func (s *BotStore) PutWebViewCustomMethodQuery(ctx context.Context, query domain.BotWebViewCustomMethodQuery) error {
if query.ID == "" || query.BotUserID == 0 || query.UserID == 0 || query.CustomMethod == "" || query.ExpiresAt.IsZero() {
return domain.ErrBotCustomMethodUnavailable
}
var params any = json.RawMessage(query.ParamsJSON)
if query.ParamsJSON == "" {
params = json.RawMessage(`{}`)
}
_, err := s.db.Exec(ctx, `
INSERT INTO webview_custom_method_queries (id, bot_user_id, user_id, custom_method, params, created_at, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (id) DO UPDATE SET
params=EXCLUDED.params,
expires_at=EXCLUDED.expires_at`,
query.ID, query.BotUserID, query.UserID, query.CustomMethod, params, query.CreatedAt, query.ExpiresAt)
if err != nil {
return fmt.Errorf("put webview custom method query: %w", err)
}
return nil
}
func botAppSelectSQL() string {
return `SELECT id, bot_user_id, short_name, title, description, url, photo_id, document_id,
access_hash, hash, inactive, request_write_access, has_settings, is_main FROM bot_apps`
}
func scanBotApp(row rowScanner) (domain.BotApp, bool, error) {
app, err := scanBotAppAny(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.BotApp{}, false, nil
}
return domain.BotApp{}, false, err
}
return app, true, nil
}
func scanBotAppRows(rows pgx.Rows) (domain.BotApp, error) {
return scanBotAppAny(rows)
}
func scanBotAppAny(row rowScanner) (domain.BotApp, error) {
var app domain.BotApp
if err := row.Scan(&app.ID, &app.BotUserID, &app.ShortName, &app.Title, &app.Description, &app.URL, &app.PhotoID, &app.DocumentID, &app.AccessHash, &app.Hash, &app.Inactive, &app.RequestWriteAccess, &app.HasSettings, &app.Main); err != nil {
return domain.BotApp{}, err
}
return app, nil
}
func attachMenuBotSelectSQL() string {
return `SELECT bot_user_id, COALESCE(app_id,0), short_name, inactive, has_settings,
request_write_access, show_in_attach_menu, show_in_side_menu, side_menu_disclaimer_needed,
peer_types, icons FROM attach_menu_bots`
}
func scanAttachMenuBot(row rowScanner) (domain.BotAttachMenuBot, bool, error) {
bot, err := scanAttachMenuBotAny(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.BotAttachMenuBot{}, false, nil
}
return domain.BotAttachMenuBot{}, false, err
}
return bot, true, nil
}
func scanAttachMenuBotRows(rows pgx.Rows) (domain.BotAttachMenuBot, error) {
return scanAttachMenuBotAny(rows)
}
func scanAttachMenuBotAny(row rowScanner) (domain.BotAttachMenuBot, error) {
var bot domain.BotAttachMenuBot
var icons []byte
if err := row.Scan(&bot.BotUserID, &bot.AppID, &bot.ShortName, &bot.Inactive, &bot.HasSettings, &bot.RequestWriteAccess, &bot.ShowInAttachMenu, &bot.ShowInSideMenu, &bot.SideMenuDisclaimerNeeded, &bot.PeerTypes, &icons); err != nil {
return domain.BotAttachMenuBot{}, err
}
if len(icons) > 0 {
_ = json.Unmarshal(icons, &bot.Icons)
}
return bot, nil
}
func nullableBotAppInt(value int, valid bool) any {
if !valid {
return nil
}
return value
}
func nullableInt64(value int64) any {
if value == 0 {
return nil
}
return value
}

Some files were not shown because too many files have changed in this diff Show more