Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9
This commit is contained in:
commit
b443ff0c73
277 changed files with 30747 additions and 1551 deletions
|
|
@ -75,6 +75,10 @@ func botFatherSeedProfile() domain.BotProfile {
|
|||
{Command: "mybots", Description: "list your bots"},
|
||||
{Command: "token", Description: "show a bot's token"},
|
||||
{Command: "revoke", Description: "revoke a bot's token"},
|
||||
{Command: "setlogin", Description: "configure Telegram Login"},
|
||||
{Command: "logininfo", Description: "show Telegram Login configuration"},
|
||||
{Command: "resetloginsecret", Description: "rotate an OIDC Client Secret"},
|
||||
{Command: "done", Description: "finish Telegram Login configuration"},
|
||||
{Command: "cancel", Description: "cancel the current operation"},
|
||||
{Command: "help", Description: "show help"},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ func (s *ChannelStore) ListChannelDialogs(_ context.Context, viewerUserID int64,
|
|||
continue
|
||||
}
|
||||
parentMember, ok := s.members[channel.LinkedMonoforumID][viewerUserID]
|
||||
if !ok || parentMember.Status != domain.ChannelMemberActive || !isChannelAdmin(parentMember) {
|
||||
if !ok || !parentMember.CanManageDirectMessages() {
|
||||
continue
|
||||
}
|
||||
channelIDs = append(channelIDs, channelID)
|
||||
|
|
@ -131,7 +131,7 @@ func (s *ChannelStore) GetChannelDialogs(_ context.Context, viewerUserID int64,
|
|||
continue
|
||||
}
|
||||
parentMember, ok := s.members[channel.LinkedMonoforumID][viewerUserID]
|
||||
if !ok || parentMember.Status != domain.ChannelMemberActive || !isChannelAdmin(parentMember) {
|
||||
if !ok || !parentMember.CanManageDirectMessages() {
|
||||
continue
|
||||
}
|
||||
member = syntheticMonoforumAdminMember(channel, parentMember)
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ func (s *ChannelStore) monoforumVisibleToUserLocked(mono domain.Channel, userID
|
|||
if !ok || parent.Deleted || !parent.BroadcastMessagesAllowed || parent.LinkedMonoforumID != mono.ID {
|
||||
return false
|
||||
}
|
||||
if member, ok := s.members[parent.ID][userID]; ok && member.Status == domain.ChannelMemberActive && isChannelAdmin(member) {
|
||||
if member, ok := s.members[parent.ID][userID]; ok && member.CanManageDirectMessages() {
|
||||
return true
|
||||
}
|
||||
for _, msg := range s.messages[mono.ID] {
|
||||
|
|
@ -418,7 +418,7 @@ func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.C
|
|||
}
|
||||
if channel.Monoforum && channel.LinkedMonoforumID != 0 {
|
||||
parentMember, ok := s.members[channel.LinkedMonoforumID][userID]
|
||||
if ok && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember) {
|
||||
if ok && parentMember.CanManageDirectMessages() {
|
||||
return channel, syntheticMonoforumAdminMember(channel, parentMember), true, nil
|
||||
}
|
||||
parent, ok := s.channels[channel.LinkedMonoforumID]
|
||||
|
|
|
|||
|
|
@ -77,6 +77,10 @@ func cloneChannelMessageAction(in *domain.ChannelMessageAction) *domain.ChannelM
|
|||
}
|
||||
out.StarGift = &g
|
||||
}
|
||||
if in.SuggestedPostPrice != nil {
|
||||
price := *in.SuggestedPostPrice
|
||||
out.SuggestedPostPrice = &price
|
||||
}
|
||||
out.Wallpaper = domain.CloneWallpaperPtr(in.Wallpaper)
|
||||
out.Photo = domain.ClonePhotoPtr(in.Photo)
|
||||
return &out
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
|
|||
// 静态过滤(不含 offset 锚点的方向条件),结果保持 id 降序。
|
||||
query := strings.ToLower(strings.TrimSpace(filter.Query))
|
||||
matched := make([]domain.ChannelMessage, 0, len(items))
|
||||
monoforumUserView := channel.Monoforum && !isChannelAdmin(member)
|
||||
monoforumUserView := channel.Monoforum && !member.CanManageDirectMessages()
|
||||
for _, msg := range items {
|
||||
if msg.Deleted {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
return domain.SendChannelMessageResult{}, domain.ErrChannelPrivate
|
||||
}
|
||||
parentMember, parentMemberOK := s.members[parent.ID][req.SenderUserID]
|
||||
isAdmin := parentMemberOK && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember)
|
||||
isAdmin := parentMemberOK && parentMember.CanManageDirectMessages()
|
||||
if req.SenderUserID != req.SavedPeer.ID && !isAdmin {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
|
|
@ -157,7 +157,7 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
s.channels[req.MonoforumID] = channel
|
||||
recipients := []int64{req.SavedPeer.ID}
|
||||
for userID, member := range s.members[parent.ID] {
|
||||
if member.Status == domain.ChannelMemberActive && isChannelAdmin(member) {
|
||||
if member.CanManageDirectMessages() {
|
||||
recipients = append(recipients, userID)
|
||||
}
|
||||
}
|
||||
|
|
@ -214,7 +214,7 @@ func (s *ChannelStore) ListMonoforumHistory(_ context.Context, filter domain.Mon
|
|||
}
|
||||
|
||||
// ResolveMonoforumSend 按 id 取 monoforum 频道(不要求调用者是 monoforum 成员——订阅者私信频道时
|
||||
// 并非 monoforum 成员),并返回调用者是否为其母广播频道的创建者/管理员。非 monoforum/不存在 → ErrChannelInvalid。
|
||||
// 并非 monoforum 成员),并返回调用者是否可管理其母广播频道的 Direct Messages。非 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
|
||||
|
|
@ -226,8 +226,7 @@ func (s *ChannelStore) ResolveMonoforumSend(_ context.Context, viewerUserID, mon
|
|||
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)
|
||||
isAdmin := ok && member.CanManageDirectMessages()
|
||||
return cloneChannel(mono), isAdmin, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -197,6 +197,113 @@ func (s *ChannelStore) SetChannelVerified(_ context.Context, channelID int64, ve
|
|||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetChannelScamFake(_ context.Context, channelID int64, scam, fake bool) (domain.Channel, error) {
|
||||
if channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if scam && fake {
|
||||
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
channel.Scam = scam
|
||||
channel.Fake = fake
|
||||
s.channels[channelID] = channel
|
||||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetChannelAdminSettings(_ context.Context, channelID int64, patch domain.ChannelAdminSettings) (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
|
||||
}
|
||||
if patch.Gigagroup != nil {
|
||||
channel.Gigagroup = *patch.Gigagroup
|
||||
}
|
||||
if patch.AntiSpam != nil {
|
||||
channel.AntiSpam = *patch.AntiSpam
|
||||
}
|
||||
if patch.ParticipantsHidden != nil {
|
||||
channel.ParticipantsHidden = *patch.ParticipantsHidden
|
||||
}
|
||||
if patch.NoForwards != nil {
|
||||
channel.NoForwards = *patch.NoForwards
|
||||
}
|
||||
if patch.JoinToSend != nil {
|
||||
channel.JoinToSend = *patch.JoinToSend
|
||||
}
|
||||
if patch.JoinRequest != nil {
|
||||
channel.JoinRequest = *patch.JoinRequest
|
||||
}
|
||||
if patch.SlowmodeSeconds != nil {
|
||||
channel.SlowmodeSeconds = *patch.SlowmodeSeconds
|
||||
}
|
||||
s.channels[channelID] = channel
|
||||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetChannelUsernameAdmin(_ context.Context, channelID int64, username string) (domain.Channel, error) {
|
||||
if channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
channel.Username = username
|
||||
s.channels[channelID] = channel
|
||||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetChannelColorAdmin(_ context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (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
|
||||
}
|
||||
if forProfile {
|
||||
channel.ProfileColor = color
|
||||
} else {
|
||||
channel.Color = color
|
||||
}
|
||||
s.channels[channelID] = channel
|
||||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetChannelEmojiStatusAdmin(_ context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
|
||||
if channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if status.DocumentID == 0 {
|
||||
status.Until = 0
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channel.Deleted {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
channel.EmojiStatus = status
|
||||
s.channels[channelID] = channel
|
||||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ResolvePublicChannelUsername(_ context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) {
|
||||
_ = viewerUserID // zero is the anonymous public-web view; no membership state is projected.
|
||||
username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
||||
|
|
|
|||
|
|
@ -73,29 +73,32 @@ type ChannelStore struct {
|
|||
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
|
||||
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
|
||||
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
|
||||
sendSnapshots map[channelMessageReplayKey][]byte
|
||||
sendFingerprints map[channelMessageReplayKey][]byte
|
||||
deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent
|
||||
starsBalances map[int64]int64
|
||||
channelStarsBalances map[int64]int64
|
||||
boostSlots map[boostSlotKey]domain.PremiumBoostSlot
|
||||
readMarks map[int64]channelReadWatermark
|
||||
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
|
||||
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
|
||||
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
|
||||
sendSnapshots map[channelMessageReplayKey][]byte
|
||||
sendFingerprints map[channelMessageReplayKey][]byte
|
||||
deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent
|
||||
starsBalances map[int64]int64
|
||||
channelStarsBalances map[int64]int64
|
||||
tonBalances map[int64]int64
|
||||
channelTONBalances map[int64]int64
|
||||
suggestedPostApprovals map[memorySuggestedPostKey]memorySuggestedPostApproval
|
||||
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 链路按未接入处理。
|
||||
|
|
@ -110,37 +113,40 @@ func (s *ChannelStore) AttachPollStore(polls *PollStore) {
|
|||
// 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),
|
||||
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
|
||||
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),
|
||||
sendSnapshots: make(map[channelMessageReplayKey][]byte),
|
||||
sendFingerprints: make(map[channelMessageReplayKey][]byte),
|
||||
deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent),
|
||||
starsBalances: make(map[int64]int64),
|
||||
channelStarsBalances: make(map[int64]int64),
|
||||
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
|
||||
readMarks: make(map[int64]channelReadWatermark),
|
||||
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
|
||||
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),
|
||||
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
|
||||
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),
|
||||
sendSnapshots: make(map[channelMessageReplayKey][]byte),
|
||||
sendFingerprints: make(map[channelMessageReplayKey][]byte),
|
||||
deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent),
|
||||
starsBalances: make(map[int64]int64),
|
||||
channelStarsBalances: make(map[int64]int64),
|
||||
tonBalances: make(map[int64]int64),
|
||||
channelTONBalances: make(map[int64]int64),
|
||||
suggestedPostApprovals: make(map[memorySuggestedPostKey]memorySuggestedPostApproval),
|
||||
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
|
||||
readMarks: make(map[int64]channelReadWatermark),
|
||||
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
421
internal/store/memory/channel_suggested_post.go
Normal file
421
internal/store/memory/channel_suggested_post.go
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const suggestedPostSettlementAge = 24 * 60 * 60
|
||||
|
||||
type memorySuggestedPostKey struct {
|
||||
monoforumID int64
|
||||
messageID int
|
||||
}
|
||||
|
||||
type memorySuggestedPostApproval struct {
|
||||
actorUserID int64
|
||||
parentID int64
|
||||
savedPeer domain.Peer
|
||||
state domain.SuggestedPostLifecycleState
|
||||
price *domain.SuggestedPostPrice
|
||||
scheduleDate int
|
||||
publishedMessageID int
|
||||
settlementDue int
|
||||
lastResult domain.ToggleSuggestedPostApprovalResult
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ToggleSuggestedPostApproval(_ context.Context, req domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
if req.UserID == 0 || req.MonoforumID == 0 || req.MessageID <= 0 || (!req.Reject && strings.TrimSpace(req.RejectComment) != "") {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.toggleSuggestedPostApprovalLocked(req)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) toggleSuggestedPostApprovalLocked(req domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
mono, ok := s.channels[req.MonoforumID]
|
||||
if !ok || mono.Deleted || !mono.Monoforum || mono.LinkedMonoforumID == 0 {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
parent, ok := s.channels[mono.LinkedMonoforumID]
|
||||
if !ok || parent.Deleted || !parent.Broadcast || parent.LinkedMonoforumID != mono.ID {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
idx := -1
|
||||
var original domain.ChannelMessage
|
||||
for i := range s.messages[mono.ID] {
|
||||
candidate := s.messages[mono.ID][i]
|
||||
if candidate.ID == req.MessageID && !candidate.Deleted {
|
||||
idx, original = i, cloneChannelMessage(candidate)
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 || original.SavedPeer.Type != domain.PeerTypeUser || original.SavedPeer.ID == 0 || original.SuggestedPost == nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
manager := s.members[parent.ID][req.UserID]
|
||||
fromSubscriber := original.From.Type == domain.PeerTypeUser
|
||||
if fromSubscriber {
|
||||
if !manager.CanManageDirectMessages() || (!req.Reject && !manager.CanPostChannelMessages()) {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostApprovalForbidden
|
||||
}
|
||||
} else if req.UserID != original.SavedPeer.ID {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostApprovalForbidden
|
||||
}
|
||||
key := memorySuggestedPostKey{monoforumID: mono.ID, messageID: original.ID}
|
||||
approval, exists := s.suggestedPostApprovals[key]
|
||||
if exists && approval.state != domain.SuggestedPostStateBalanceLow {
|
||||
out := cloneSuggestedPostResult(approval.lastResult)
|
||||
out.Duplicate = true
|
||||
return out, nil
|
||||
}
|
||||
if original.SuggestedPost.Accepted || original.SuggestedPost.Rejected {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostAlreadyHandled
|
||||
}
|
||||
price := cloneSuggestedPostPrice(original.SuggestedPost.Price)
|
||||
scheduleDate := original.SuggestedPost.ScheduleDate
|
||||
if req.ScheduleDate > 0 {
|
||||
scheduleDate = req.ScheduleDate
|
||||
}
|
||||
if !req.Reject && scheduleDate > 0 && (scheduleDate < req.Date+5*60 || scheduleDate > req.Date+31*24*60*60) {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
recipients := s.monoforumRecipientsLocked(parent.ID, original.SavedPeer.ID)
|
||||
base := domain.ToggleSuggestedPostApprovalResult{
|
||||
Monoforum: cloneChannel(mono), Parent: cloneChannel(parent), SavedPeer: original.SavedPeer,
|
||||
State: domain.SuggestedPostStateBalanceLow, Recipients: recipients,
|
||||
}
|
||||
if req.Reject {
|
||||
original.SuggestedPost.Rejected = true
|
||||
original.SuggestedPost.Accepted = false
|
||||
original.Pts = s.nextChannelPtsLocked(mono.ID)
|
||||
s.messages[mono.ID][idx] = cloneChannelMessage(original)
|
||||
edit := domain.ChannelUpdateEvent{ChannelID: mono.ID, Type: domain.ChannelUpdateEditMessage, Pts: original.Pts, PtsCount: 1, Date: req.Date, Message: cloneChannelMessage(original), SenderUserID: req.UserID}
|
||||
s.appendChannelEventLocked(edit)
|
||||
service, serviceEvent := s.appendSuggestedPostServiceLocked(mono, parent, req.UserID, original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostRejected: true,
|
||||
SuggestedPostRejectComment: strings.TrimSpace(req.RejectComment), SuggestedPostPrice: price,
|
||||
})
|
||||
mono = s.channels[mono.ID]
|
||||
base.Monoforum, base.State = cloneChannel(mono), domain.SuggestedPostStateRejected
|
||||
base.OriginalMessage, base.OriginalEvent = cloneChannelMessage(original), cloneChannelEvent(edit)
|
||||
base.ServiceMessage, base.ServiceEvent = cloneChannelMessage(service), cloneChannelEvent(serviceEvent)
|
||||
approval = memorySuggestedPostApproval{actorUserID: req.UserID, parentID: parent.ID, savedPeer: original.SavedPeer, state: base.State, price: price, lastResult: cloneSuggestedPostResult(base)}
|
||||
s.suggestedPostApprovals[key] = approval
|
||||
return base, nil
|
||||
}
|
||||
|
||||
starsBalance, tonBalance, enough := s.reserveSuggestedPostPaymentLocked(original.SavedPeer.ID, parent.ID, price)
|
||||
if !enough {
|
||||
if exists {
|
||||
out := cloneSuggestedPostResult(approval.lastResult)
|
||||
out.PayerStarsBalance, out.PayerTONBalance = starsBalance, tonBalance
|
||||
out.Duplicate = true
|
||||
return out, nil
|
||||
}
|
||||
service, serviceEvent := s.appendSuggestedPostServiceLocked(mono, parent, req.UserID, original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostBalanceTooLow: true,
|
||||
SuggestedPostScheduleDate: scheduleDate, SuggestedPostPrice: price,
|
||||
})
|
||||
base.Monoforum = cloneChannel(s.channels[mono.ID])
|
||||
base.ServiceMessage, base.ServiceEvent = cloneChannelMessage(service), cloneChannelEvent(serviceEvent)
|
||||
base.PayerStarsBalance, base.PayerTONBalance = starsBalance, tonBalance
|
||||
approval = memorySuggestedPostApproval{actorUserID: req.UserID, parentID: parent.ID, savedPeer: original.SavedPeer, state: base.State, price: price, scheduleDate: scheduleDate, lastResult: cloneSuggestedPostResult(base)}
|
||||
s.suggestedPostApprovals[key] = approval
|
||||
return base, nil
|
||||
}
|
||||
|
||||
original.SuggestedPost.Accepted = true
|
||||
original.SuggestedPost.Rejected = false
|
||||
effectivePublishDate := scheduleDate
|
||||
if effectivePublishDate == 0 {
|
||||
// TDesktop deliberately omits schedule_date for "Publish Now", but
|
||||
// renders the approval service action as an absolute date. Persist one
|
||||
// effective publication timestamp across the edited suggestion, action
|
||||
// and approval record instead of leaking an accepted zero date.
|
||||
effectivePublishDate = req.Date
|
||||
}
|
||||
original.SuggestedPost.ScheduleDate = effectivePublishDate
|
||||
original.Pts = s.nextChannelPtsLocked(mono.ID)
|
||||
s.messages[mono.ID][idx] = cloneChannelMessage(original)
|
||||
edit := domain.ChannelUpdateEvent{ChannelID: mono.ID, Type: domain.ChannelUpdateEditMessage, Pts: original.Pts, PtsCount: 1, Date: req.Date, Message: cloneChannelMessage(original), SenderUserID: req.UserID}
|
||||
s.appendChannelEventLocked(edit)
|
||||
service, serviceEvent := s.appendSuggestedPostServiceLocked(mono, parent, req.UserID, original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostScheduleDate: effectivePublishDate, SuggestedPostPrice: price,
|
||||
})
|
||||
base.Monoforum, base.OriginalMessage, base.OriginalEvent = cloneChannel(s.channels[mono.ID]), cloneChannelMessage(original), cloneChannelEvent(edit)
|
||||
base.ServiceMessage, base.ServiceEvent = cloneChannelMessage(service), cloneChannelEvent(serviceEvent)
|
||||
base.PayerStarsBalance, base.PayerTONBalance = starsBalance, tonBalance
|
||||
base.State = domain.SuggestedPostStateScheduled
|
||||
approval = memorySuggestedPostApproval{actorUserID: req.UserID, parentID: parent.ID, savedPeer: original.SavedPeer, state: base.State, price: price, scheduleDate: effectivePublishDate}
|
||||
if effectivePublishDate <= req.Date {
|
||||
published := s.publishSuggestedPostLocked(parent, original, req.UserID, req.Date)
|
||||
base.Published = &published
|
||||
approval.publishedMessageID = published.Message.ID
|
||||
if price == nil {
|
||||
base.State = domain.SuggestedPostStateCompleted
|
||||
} else {
|
||||
base.State = domain.SuggestedPostStatePublished
|
||||
approval.settlementDue = req.Date + suggestedPostSettlementAge
|
||||
}
|
||||
approval.state = base.State
|
||||
}
|
||||
approval.lastResult = cloneSuggestedPostResult(base)
|
||||
s.suggestedPostApprovals[key] = approval
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ProcessSuggestedPostLifecycle(_ context.Context, req domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
if req.Now == 0 {
|
||||
req.Now = int(time.Now().Unix())
|
||||
}
|
||||
if req.Limit <= 0 || req.Limit > 100 {
|
||||
req.Limit = 100
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domain.ToggleSuggestedPostApprovalResult, 0)
|
||||
for key, approval := range s.suggestedPostApprovals {
|
||||
if len(out) >= req.Limit {
|
||||
break
|
||||
}
|
||||
if approval.state != domain.SuggestedPostStateScheduled && approval.state != domain.SuggestedPostStatePublished {
|
||||
continue
|
||||
}
|
||||
mono, monoOK := s.channels[key.monoforumID]
|
||||
parent, parentOK := s.channels[approval.parentID]
|
||||
if !monoOK || !parentOK {
|
||||
return out, fmt.Errorf("suggested post lifecycle invariant: missing monoforum %d or parent %d", key.monoforumID, approval.parentID)
|
||||
}
|
||||
if mono.Deleted || !mono.Monoforum || mono.LinkedMonoforumID != parent.ID || parent.Deleted || !parent.Broadcast || parent.LinkedMonoforumID != mono.ID {
|
||||
return out, fmt.Errorf("suggested post lifecycle invariant: broken monoforum link %d <-> %d", mono.ID, parent.ID)
|
||||
}
|
||||
if approval.scheduleDate <= 0 {
|
||||
return out, fmt.Errorf("suggested post lifecycle invariant: state %s has zero publish date", approval.state)
|
||||
}
|
||||
var original domain.ChannelMessage
|
||||
originalFound := false
|
||||
for _, message := range s.messages[mono.ID] {
|
||||
if message.ID == key.messageID {
|
||||
original = cloneChannelMessage(message)
|
||||
originalFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !originalFound || original.SuggestedPost == nil || !original.SuggestedPost.Accepted || original.SuggestedPost.Rejected {
|
||||
return out, fmt.Errorf("suggested post lifecycle invariant: missing or invalid accepted suggestion %d/%d", mono.ID, key.messageID)
|
||||
}
|
||||
result := domain.ToggleSuggestedPostApprovalResult{Monoforum: cloneChannel(mono), Parent: cloneChannel(parent), SavedPeer: approval.savedPeer, State: approval.state, Recipients: s.monoforumRecipientsLocked(parent.ID, approval.savedPeer.ID)}
|
||||
changed := false
|
||||
if approval.state == domain.SuggestedPostStateScheduled && original.Deleted {
|
||||
if approval.price != nil {
|
||||
s.refundSuggestedPostPaymentLocked(approval.savedPeer.ID, approval.price)
|
||||
service, event := s.appendSuggestedPostServiceLocked(mono, parent, approval.actorUserID, approval.savedPeer, key.messageID, req.Now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostRefund})
|
||||
result.ServiceMessage, result.ServiceEvent = service, event
|
||||
}
|
||||
approval.state, result.State, changed = domain.SuggestedPostStateRefunded, domain.SuggestedPostStateRefunded, true
|
||||
}
|
||||
if approval.state == domain.SuggestedPostStateScheduled && approval.scheduleDate <= req.Now {
|
||||
published := s.publishSuggestedPostLocked(parent, original, approval.actorUserID, req.Now)
|
||||
result.Published = &published
|
||||
approval.publishedMessageID = published.Message.ID
|
||||
if approval.price == nil {
|
||||
approval.state = domain.SuggestedPostStateCompleted
|
||||
} else {
|
||||
approval.state = domain.SuggestedPostStatePublished
|
||||
approval.settlementDue = req.Now + suggestedPostSettlementAge
|
||||
}
|
||||
result.State, changed = approval.state, true
|
||||
}
|
||||
if approval.state == domain.SuggestedPostStatePublished {
|
||||
if approval.price == nil || approval.publishedMessageID <= 0 || approval.settlementDue <= 0 {
|
||||
return out, fmt.Errorf("suggested post lifecycle invariant: incomplete published state %d/%d", mono.ID, key.messageID)
|
||||
}
|
||||
deleted := false
|
||||
publishedFound := false
|
||||
for _, message := range s.messages[parent.ID] {
|
||||
if message.ID == approval.publishedMessageID {
|
||||
deleted = message.Deleted
|
||||
publishedFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !publishedFound {
|
||||
return out, fmt.Errorf("suggested post lifecycle invariant: missing published message %d/%d", parent.ID, approval.publishedMessageID)
|
||||
}
|
||||
deleteDate := s.channelMessageDeleteDateLocked(parent.ID, approval.publishedMessageID)
|
||||
if deleted && (deleteDate == 0 || deleteDate < approval.settlementDue) {
|
||||
s.refundSuggestedPostPaymentLocked(approval.savedPeer.ID, approval.price)
|
||||
service, event := s.appendSuggestedPostServiceLocked(mono, parent, approval.actorUserID, approval.savedPeer, key.messageID, req.Now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostRefund})
|
||||
result.ServiceMessage, result.ServiceEvent = service, event
|
||||
approval.state, result.State, changed = domain.SuggestedPostStateRefunded, domain.SuggestedPostStateRefunded, true
|
||||
} else if approval.settlementDue <= req.Now {
|
||||
s.settleSuggestedPostPaymentLocked(parent.ID, approval.price)
|
||||
service, event := s.appendSuggestedPostServiceLocked(mono, parent, approval.actorUserID, approval.savedPeer, key.messageID, req.Now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostSuccess, SuggestedPostPrice: cloneSuggestedPostPrice(approval.price)})
|
||||
result.ServiceMessage, result.ServiceEvent = service, event
|
||||
approval.state, result.State, changed = domain.SuggestedPostStateCompleted, domain.SuggestedPostStateCompleted, true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
result.Monoforum, result.Parent = cloneChannel(s.channels[mono.ID]), cloneChannel(s.channels[parent.ID])
|
||||
approval.lastResult = cloneSuggestedPostResult(result)
|
||||
s.suggestedPostApprovals[key] = approval
|
||||
out = append(out, result)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) channelMessageDeleteDateLocked(channelID int64, messageID int) int {
|
||||
for i := len(s.events[channelID]) - 1; i >= 0; i-- {
|
||||
event := s.events[channelID][i]
|
||||
if event.Type != domain.ChannelUpdateDeleteMessages {
|
||||
continue
|
||||
}
|
||||
for _, id := range event.MessageIDs {
|
||||
if id == messageID {
|
||||
return event.Date
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *ChannelStore) reserveSuggestedPostPaymentLocked(payerID, parentID int64, price *domain.SuggestedPostPrice) (*domain.StarsBalance, *int64, bool) {
|
||||
if price == nil {
|
||||
return nil, nil, true
|
||||
}
|
||||
switch price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
current, ok := s.starsBalances[payerID]
|
||||
if !ok {
|
||||
current = domain.DefaultStarsStartingGrant
|
||||
}
|
||||
balance := &domain.StarsBalance{UserID: payerID, Balance: current, Granted: true}
|
||||
if price.Nanos != 0 || current < price.Amount {
|
||||
return balance, nil, false
|
||||
}
|
||||
current -= price.Amount
|
||||
s.starsBalances[payerID] = current
|
||||
balance.Balance = current
|
||||
return balance, nil, true
|
||||
case domain.SuggestedPostPriceTON:
|
||||
current := s.tonBalances[payerID]
|
||||
balance := current
|
||||
if current < price.Amount {
|
||||
return nil, &balance, false
|
||||
}
|
||||
current -= price.Amount
|
||||
s.tonBalances[payerID] = current
|
||||
balance = current
|
||||
return nil, &balance, true
|
||||
default:
|
||||
return nil, nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelStore) refundSuggestedPostPaymentLocked(payerID int64, price *domain.SuggestedPostPrice) {
|
||||
if price == nil {
|
||||
return
|
||||
}
|
||||
if price.Kind == domain.SuggestedPostPriceStars {
|
||||
s.starsBalances[payerID] += price.Amount
|
||||
} else if price.Kind == domain.SuggestedPostPriceTON {
|
||||
s.tonBalances[payerID] += price.Amount
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelStore) settleSuggestedPostPaymentLocked(parentID int64, price *domain.SuggestedPostPrice) {
|
||||
if price == nil {
|
||||
return
|
||||
}
|
||||
credit := price.Amount * paidMessageChannelCommissionPermille / 1000
|
||||
if price.Kind == domain.SuggestedPostPriceStars {
|
||||
s.channelStarsBalances[parentID] += credit
|
||||
} else if price.Kind == domain.SuggestedPostPriceTON {
|
||||
s.channelTONBalances[parentID] += credit
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelStore) appendSuggestedPostServiceLocked(mono, parent domain.Channel, actor int64, saved domain.Peer, replyID, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent) {
|
||||
pts := s.nextChannelPtsLocked(mono.ID)
|
||||
from := domain.Peer{Type: domain.PeerTypeUser, ID: actor}
|
||||
if member, ok := s.members[parent.ID][actor]; ok && member.CanManageDirectMessages() {
|
||||
from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}
|
||||
}
|
||||
msg := domain.ChannelMessage{ChannelID: mono.ID, ID: s.nextChannelMessageIDLocked(mono.ID), SenderUserID: actor, From: from, SavedPeer: saved, Date: date, Action: cloneChannelMessageAction(&action), ReplyTo: &domain.MessageReply{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: mono.ID}, MessageID: replyID}, Pts: pts}
|
||||
event := domain.ChannelUpdateEvent{ChannelID: mono.ID, Type: domain.ChannelUpdateNewMessage, Pts: pts, PtsCount: 1, Date: date, Message: cloneChannelMessage(msg), SenderUserID: actor}
|
||||
s.messages[mono.ID] = append(s.messages[mono.ID], cloneChannelMessage(msg))
|
||||
s.appendChannelEventLocked(event)
|
||||
mono.TopMessageID, mono.Pts = msg.ID, pts
|
||||
s.channels[mono.ID] = mono
|
||||
return cloneChannelMessage(msg), cloneChannelEvent(event)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) publishSuggestedPostLocked(parent domain.Channel, original domain.ChannelMessage, actor int64, date int) domain.SendChannelMessageResult {
|
||||
msg := cloneChannelMessage(original)
|
||||
msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID = parent.ID, s.nextChannelMessageIDLocked(parent.ID), 0, actor
|
||||
msg.From, msg.SavedPeer, msg.Date, msg.EditDate, msg.Post = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}, domain.Peer{}, date, 0, true
|
||||
msg.ReplyTo, msg.PaidMessageStars, msg.Pts, msg.Deleted = nil, 0, s.nextChannelPtsLocked(parent.ID), false
|
||||
event := domain.ChannelUpdateEvent{ChannelID: parent.ID, Type: domain.ChannelUpdateNewMessage, Pts: msg.Pts, PtsCount: 1, Date: date, Message: cloneChannelMessage(msg), SenderUserID: actor}
|
||||
s.messages[parent.ID] = append(s.messages[parent.ID], cloneChannelMessage(msg))
|
||||
s.appendChannelEventLocked(event)
|
||||
parent.TopMessageID, parent.Pts = msg.ID, msg.Pts
|
||||
s.channels[parent.ID] = parent
|
||||
recipients := make([]int64, 0, len(s.members[parent.ID]))
|
||||
for id, member := range s.members[parent.ID] {
|
||||
if member.Status == domain.ChannelMemberActive {
|
||||
recipients = append(recipients, id)
|
||||
}
|
||||
}
|
||||
return domain.SendChannelMessageResult{Channel: cloneChannel(parent), Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event), Recipients: uniqueNonZero(recipients, 0)}
|
||||
}
|
||||
|
||||
func (s *ChannelStore) monoforumRecipientsLocked(parentID, subscriberID int64) []int64 {
|
||||
ids := []int64{subscriberID}
|
||||
for id, member := range s.members[parentID] {
|
||||
if member.CanManageDirectMessages() {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return uniqueNonZero(ids, 0)
|
||||
}
|
||||
|
||||
func cloneSuggestedPostPrice(in *domain.SuggestedPostPrice) *domain.SuggestedPostPrice {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneSuggestedPostResult(in domain.ToggleSuggestedPostApprovalResult) domain.ToggleSuggestedPostApprovalResult {
|
||||
in.Monoforum, in.Parent = cloneChannel(in.Monoforum), cloneChannel(in.Parent)
|
||||
in.OriginalMessage, in.ServiceMessage = cloneChannelMessage(in.OriginalMessage), cloneChannelMessage(in.ServiceMessage)
|
||||
in.OriginalEvent, in.ServiceEvent = cloneChannelEvent(in.OriginalEvent), cloneChannelEvent(in.ServiceEvent)
|
||||
in.Recipients = append([]int64(nil), in.Recipients...)
|
||||
if in.Published != nil {
|
||||
p := *in.Published
|
||||
p.Message = cloneChannelMessage(p.Message)
|
||||
p.Event = cloneChannelEvent(p.Event)
|
||||
p.Recipients = append([]int64(nil), p.Recipients...)
|
||||
in.Published = &p
|
||||
}
|
||||
if in.PayerStarsBalance != nil {
|
||||
b := *in.PayerStarsBalance
|
||||
in.PayerStarsBalance = &b
|
||||
}
|
||||
if in.PayerTONBalance != nil {
|
||||
b := *in.PayerTONBalance
|
||||
in.PayerTONBalance = &b
|
||||
}
|
||||
return in
|
||||
}
|
||||
276
internal/store/memory/channel_suggested_post_test.go
Normal file
276
internal/store/memory/channel_suggested_post_test.go
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func newSuggestedPostMemoryFixture(t *testing.T) (*ChannelStore, domain.Channel, domain.Channel, domain.Peer) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: 1, Title: "Suggestions", Broadcast: true, Date: 1_700_000_000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, err := store.SetPaidMessagesPrice(ctx, 1, created.Channel.ID, 0, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mono := store.channels[enabled.Channel.LinkedMonoforumID]
|
||||
return store, store.channels[created.Channel.ID], mono, domain.Peer{Type: domain.PeerTypeUser, ID: 42}
|
||||
}
|
||||
|
||||
func TestMonoforumManagerRequiresManageDirectMessages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
|
||||
store.mu.Lock()
|
||||
store.members[parent.ID][2] = domain.ChannelMember{ChannelID: parent.ID, UserID: 2, Role: domain.ChannelRoleAdmin, Status: domain.ChannelMemberActive, AdminRights: domain.ChannelAdminRights{PostMessages: true}}
|
||||
store.mu.Unlock()
|
||||
if _, manager, err := store.ResolveMonoforumSend(ctx, 2, mono.ID); err != nil || manager {
|
||||
t.Fatalf("ordinary admin resolved as manager: manager=%v err=%v", manager, err)
|
||||
}
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: 2, SavedPeer: subscriber, RandomID: 1, Message: "must not send", Date: 1_700_000_010}); !errors.Is(err, domain.ErrChannelAdminRequired) {
|
||||
t.Fatalf("ordinary admin send err=%v, want admin required", err)
|
||||
}
|
||||
fromSubscriber, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 10, Message: "private", Date: 1_700_000_010})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if containsInt64(fromSubscriber.Recipients, 2) {
|
||||
t.Fatalf("ordinary admin leaked into recipients: %v", fromSubscriber.Recipients)
|
||||
}
|
||||
dialogs, err := store.ListChannelDialogs(ctx, 2, domain.DialogFilter{Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, dialog := range dialogs.Dialogs {
|
||||
if dialog.Peer.ID == mono.ID {
|
||||
t.Fatalf("ordinary admin received monoforum dialog")
|
||||
}
|
||||
}
|
||||
store.mu.Lock()
|
||||
member := store.members[parent.ID][2]
|
||||
member.AdminRights.ManageDirectMessages = true
|
||||
store.members[parent.ID][2] = member
|
||||
store.mu.Unlock()
|
||||
if _, manager, err := store.ResolveMonoforumSend(ctx, 2, mono.ID); err != nil || !manager {
|
||||
t.Fatalf("DM manager not resolved: manager=%v err=%v", manager, err)
|
||||
}
|
||||
dialogs, err = store.ListChannelDialogs(ctx, 2, domain.DialogFilter{Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foundMono := false
|
||||
for _, dialog := range dialogs.Dialogs {
|
||||
foundMono = foundMono || dialog.Peer.ID == mono.ID
|
||||
}
|
||||
if !foundMono {
|
||||
t.Fatalf("DM manager missing monoforum dialog")
|
||||
}
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: 2, SavedPeer: subscriber, RandomID: 2, Message: "allowed", Date: 1_700_000_011}); err != nil {
|
||||
t.Fatalf("DM manager send: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestedPostStarsApprovalRefundAndSettlement(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
|
||||
store.starsBalances[subscriber.ID] = 100
|
||||
|
||||
suggestion, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 11, Message: "publish me", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_000_100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approved, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, Date: 1_700_000_200})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if approved.State != domain.SuggestedPostStatePublished || approved.OriginalEvent.Type != domain.ChannelUpdateEditMessage || approved.ServiceMessage.Action == nil || approved.ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostApproval || approved.Published == nil {
|
||||
t.Fatalf("approval result=%+v", approved)
|
||||
}
|
||||
if approved.OriginalMessage.SuggestedPost.ScheduleDate != 1_700_000_200 || approved.ServiceMessage.Action.SuggestedPostScheduleDate != 1_700_000_200 {
|
||||
t.Fatalf("immediate approval dates original/action=%d/%d, want commit date", approved.OriginalMessage.SuggestedPost.ScheduleDate, approved.ServiceMessage.Action.SuggestedPostScheduleDate)
|
||||
}
|
||||
if store.starsBalances[subscriber.ID] != 90 || store.channelStarsBalances[parent.ID] != 0 {
|
||||
t.Fatalf("escrow/channel balances=%d/%d, want 90/0", store.starsBalances[subscriber.ID], store.channelStarsBalances[parent.ID])
|
||||
}
|
||||
duplicate, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, Date: 1_700_000_201})
|
||||
if err != nil || !duplicate.Duplicate || store.starsBalances[subscriber.ID] != 90 {
|
||||
t.Fatalf("duplicate=%+v err=%v balance=%d", duplicate, err, store.starsBalances[subscriber.ID])
|
||||
}
|
||||
if duplicate.OriginalMessage.SuggestedPost.ScheduleDate != 1_700_000_200 || duplicate.ServiceMessage.Action.SuggestedPostScheduleDate != 1_700_000_200 {
|
||||
t.Fatalf("duplicate changed immediate approval date: %+v", duplicate)
|
||||
}
|
||||
store.mu.Lock()
|
||||
for i := range store.messages[parent.ID] {
|
||||
if store.messages[parent.ID][i].ID == approved.Published.Message.ID {
|
||||
store.messages[parent.ID][i].Deleted = true
|
||||
}
|
||||
}
|
||||
store.mu.Unlock()
|
||||
lifecycle, err := store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_000_300, Limit: 10})
|
||||
if err != nil || len(lifecycle) != 1 || lifecycle[0].State != domain.SuggestedPostStateRefunded || lifecycle[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostRefund {
|
||||
t.Fatalf("refund lifecycle=%+v err=%v", lifecycle, err)
|
||||
}
|
||||
if store.starsBalances[subscriber.ID] != 100 || store.channelStarsBalances[parent.ID] != 0 {
|
||||
t.Fatalf("refund balances=%d/%d, want 100/0", store.starsBalances[subscriber.ID], store.channelStarsBalances[parent.ID])
|
||||
}
|
||||
|
||||
second, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 12, Message: "settle me", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 20}}, Date: 1_700_000_400})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settling, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: second.Message.ID, Date: 1_700_000_500})
|
||||
if err != nil || settling.State != domain.SuggestedPostStatePublished {
|
||||
t.Fatalf("second approval=%+v err=%v", settling, err)
|
||||
}
|
||||
lifecycle, err = store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_000_500 + suggestedPostSettlementAge, Limit: 10})
|
||||
if err != nil || len(lifecycle) != 1 || lifecycle[0].State != domain.SuggestedPostStateCompleted || lifecycle[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostSuccess {
|
||||
t.Fatalf("success lifecycle=%+v err=%v", lifecycle, err)
|
||||
}
|
||||
if store.starsBalances[subscriber.ID] != 80 || store.channelStarsBalances[parent.ID] != 17 {
|
||||
t.Fatalf("settled balances=%d/%d, want 80/17", store.starsBalances[subscriber.ID], store.channelStarsBalances[parent.ID])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestedPostLowBalanceRetryScheduleAndRoleMatrix(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
|
||||
store.starsBalances[subscriber.ID] = 5
|
||||
suggestion, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 21, Message: "later", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_001_000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
low, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, ScheduleDate: 1_700_001_400, Date: 1_700_001_000})
|
||||
if err != nil || low.State != domain.SuggestedPostStateBalanceLow || low.ServiceMessage.Action == nil || !low.ServiceMessage.Action.SuggestedPostBalanceTooLow {
|
||||
t.Fatalf("low=%+v err=%v", low, err)
|
||||
}
|
||||
again, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, ScheduleDate: 1_700_001_400, Date: 1_700_001_001})
|
||||
if err != nil || !again.Duplicate {
|
||||
t.Fatalf("low retry=%+v err=%v", again, err)
|
||||
}
|
||||
store.starsBalances[subscriber.ID] = 20
|
||||
accepted, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, ScheduleDate: 1_700_001_400, Date: 1_700_001_050})
|
||||
if err != nil || accepted.State != domain.SuggestedPostStateScheduled || accepted.Published != nil {
|
||||
t.Fatalf("scheduled=%+v err=%v", accepted, err)
|
||||
}
|
||||
due, err := store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_001_400, Limit: 10})
|
||||
if err != nil || len(due) != 1 || due[0].Published == nil || due[0].State != domain.SuggestedPostStatePublished {
|
||||
t.Fatalf("due=%+v err=%v", due, err)
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
store.members[parent.ID][2] = domain.ChannelMember{ChannelID: parent.ID, UserID: 2, Role: domain.ChannelRoleAdmin, Status: domain.ChannelMemberActive, AdminRights: domain.ChannelAdminRights{ManageDirectMessages: true}}
|
||||
store.mu.Unlock()
|
||||
third, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 22, Message: "decline only", SuggestedPost: &domain.SuggestedPost{}, Date: 1_700_002_000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 2, MonoforumID: mono.ID, MessageID: third.Message.ID, Date: 1_700_002_100}); !errors.Is(err, domain.ErrSuggestedPostApprovalForbidden) {
|
||||
t.Fatalf("manager without post right approve err=%v", err)
|
||||
}
|
||||
rejected, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 2, MonoforumID: mono.ID, MessageID: third.Message.ID, Reject: true, RejectComment: "no", Date: 1_700_002_100})
|
||||
if err != nil || rejected.State != domain.SuggestedPostStateRejected {
|
||||
t.Fatalf("decline=%+v err=%v", rejected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelAuthoredSuggestedPostAcceptedBySubscriber(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, mono, subscriber := newSuggestedPostMemoryFixture(t)
|
||||
fromChannel, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: 1, SavedPeer: subscriber, RandomID: 31, Message: "channel proposal", SuggestedPost: &domain.SuggestedPost{}, Date: 1_700_003_000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: subscriber.ID, MonoforumID: mono.ID, MessageID: fromChannel.Message.ID, Date: 1_700_003_100})
|
||||
if err != nil || result.State != domain.SuggestedPostStateCompleted || result.Published == nil {
|
||||
t.Fatalf("subscriber approval=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduledSuggestedPostDeletionRefundsBeforePublication(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
|
||||
store.starsBalances[subscriber.ID] = 30
|
||||
suggestion, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 41, Message: "cancel scheduled", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_004_000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accepted, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, ScheduleDate: 1_700_004_600, Date: 1_700_004_000})
|
||||
if err != nil || accepted.State != domain.SuggestedPostStateScheduled {
|
||||
t.Fatalf("accepted=%+v err=%v", accepted, err)
|
||||
}
|
||||
store.mu.Lock()
|
||||
for i := range store.messages[mono.ID] {
|
||||
if store.messages[mono.ID][i].ID == suggestion.Message.ID {
|
||||
store.messages[mono.ID][i].Deleted = true
|
||||
}
|
||||
}
|
||||
store.mu.Unlock()
|
||||
resolved, err := store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_004_100, Limit: 10})
|
||||
if err != nil || len(resolved) != 1 || resolved[0].State != domain.SuggestedPostStateRefunded || resolved[0].Published != nil {
|
||||
t.Fatalf("resolved=%+v err=%v", resolved, err)
|
||||
}
|
||||
if store.starsBalances[subscriber.ID] != 30 || store.channelStarsBalances[parent.ID] != 0 {
|
||||
t.Fatalf("balances=%d/%d", store.starsBalances[subscriber.ID], store.channelStarsBalances[parent.ID])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestedPostLifecycleFailsFastOnCorruptAcceptedState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, mono, subscriber := newSuggestedPostMemoryFixture(t)
|
||||
suggestion, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber,
|
||||
RandomID: 61, Message: "must fail fast", SuggestedPost: &domain.SuggestedPost{}, Date: 1_700_006_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
||||
UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID,
|
||||
ScheduleDate: 1_700_006_600, Date: 1_700_006_000,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.mu.Lock()
|
||||
for i, message := range store.messages[mono.ID] {
|
||||
if message.ID == suggestion.Message.ID {
|
||||
store.messages[mono.ID] = append(store.messages[mono.ID][:i], store.messages[mono.ID][i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
store.mu.Unlock()
|
||||
if _, err := store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_006_100, Limit: 10}); err == nil {
|
||||
t.Fatal("corrupt accepted suggestion was silently skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestedPostDeletedAfterMinimumAgeStillSettles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
|
||||
store.starsBalances[subscriber.ID] = 30
|
||||
suggestion, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 51, Message: "late delete", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_005_000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approvedAt := 1_700_005_100
|
||||
approved, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, Date: approvedAt})
|
||||
if err != nil || approved.Published == nil {
|
||||
t.Fatalf("approved=%+v err=%v", approved, err)
|
||||
}
|
||||
due := approvedAt + suggestedPostSettlementAge
|
||||
if _, err := store.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{UserID: 1, ChannelID: parent.ID, IDs: []int{approved.Published.Message.ID}, Date: due + 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: due + 2, Limit: 10})
|
||||
if err != nil || len(resolved) != 1 || resolved[0].State != domain.SuggestedPostStateCompleted || resolved[0].ServiceMessage.Action == nil || resolved[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostSuccess {
|
||||
t.Fatalf("resolved=%+v err=%v", resolved, err)
|
||||
}
|
||||
if store.starsBalances[subscriber.ID] != 20 || store.channelStarsBalances[parent.ID] != 8 {
|
||||
t.Fatalf("balances=%d/%d, want 20/8", store.starsBalances[subscriber.ID], store.channelStarsBalances[parent.ID])
|
||||
}
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
|
|||
if msg.Deleted {
|
||||
continue
|
||||
}
|
||||
if channel.Monoforum && !isChannelAdmin(member) && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
|
||||
if channel.Monoforum && !member.CanManageDirectMessages() && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
|
||||
continue
|
||||
}
|
||||
if msg.ID <= member.AvailableMinID {
|
||||
|
|
@ -72,7 +72,7 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
|
|||
events := make([]domain.ChannelUpdateEvent, 0, limit)
|
||||
lastPts := req.Pts
|
||||
var visibleMonoforumMessageIDs map[int]struct{}
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
if channel.Monoforum && !member.CanManageDirectMessages() {
|
||||
visibleMonoforumMessageIDs = make(map[int]struct{})
|
||||
savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}
|
||||
for _, message := range s.messages[req.ChannelID] {
|
||||
|
|
@ -90,7 +90,7 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
|
|||
if !ok {
|
||||
continue
|
||||
}
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
if channel.Monoforum && !member.CanManageDirectMessages() {
|
||||
visible, ok = filterMonoforumEventForUser(visible, req.UserID, visibleMonoforumMessageIDs)
|
||||
if !ok {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup {
|
|||
return &clone
|
||||
}
|
||||
|
||||
// cloneRichMessage 深拷 Layer 227 富文本快照:复制不透明 blocks 字节与内嵌媒体切片,
|
||||
// cloneRichMessage 深拷 Layer 228 富文本快照:复制不透明 blocks、Bot API 投影与内嵌媒体切片,
|
||||
// 避免发送方/接收方两行共享底层切片(与 postgres 每盒独立 decode 对齐)。
|
||||
func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
|
||||
if m == nil {
|
||||
|
|
@ -166,6 +166,7 @@ func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
|
|||
clone.Blocks = append([]byte(nil), m.Blocks...)
|
||||
clone.Photos = append([]domain.Photo(nil), m.Photos...)
|
||||
clone.Documents = append([]domain.Document(nil), m.Documents...)
|
||||
clone.BotAPIProjection = append([]byte(nil), m.BotAPIProjection...)
|
||||
return &clone
|
||||
}
|
||||
|
||||
|
|
|
|||
40
internal/store/memory/moderation_flags_test.go
Normal file
40
internal/store/memory/moderation_flags_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestModerationStoresRejectScamAndFakeTogether(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := NewUserStore()
|
||||
user, err := users.Create(ctx, domain.User{Phone: "+15550009999", FirstName: "Flag"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := users.SetScamFake(ctx, user.ID, true, true); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
|
||||
t.Fatalf("user SetScamFake error=%v", err)
|
||||
}
|
||||
gotUser, found, err := users.ByID(ctx, user.ID)
|
||||
if err != nil || !found || gotUser.Scam || gotUser.Fake {
|
||||
t.Fatalf("user after rejected flags=%+v found=%v err=%v", gotUser, found, err)
|
||||
}
|
||||
|
||||
channels := NewChannelStore()
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: user.ID, Title: "Flags", Megagroup: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := channels.SetChannelScamFake(ctx, created.Channel.ID, true, true); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
|
||||
t.Fatalf("channel SetChannelScamFake error=%v", err)
|
||||
}
|
||||
gotChannel, err := channels.GetChannelByID(ctx, created.Channel.ID)
|
||||
if err != nil || gotChannel.Scam || gotChannel.Fake {
|
||||
t.Fatalf("channel after rejected flags=%+v err=%v", gotChannel, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -530,6 +530,15 @@ func (s *StarGiftStore) SetUnsaved(_ context.Context, ref domain.SavedStarGiftRe
|
|||
for i := range s.gifts {
|
||||
if s.savedStarGiftMatchesRef(s.gifts[i], ref) && s.gifts[i].LifecycleStatus.Live() {
|
||||
s.gifts[i].Unsaved = unsaved
|
||||
if unsaved && s.gifts[i].PinnedOrder > 0 {
|
||||
removedOrder := s.gifts[i].PinnedOrder
|
||||
s.gifts[i].PinnedOrder = 0
|
||||
for j := range s.gifts {
|
||||
if s.gifts[j].Owner == ref.Owner && s.gifts[j].PinnedOrder > removedOrder {
|
||||
s.gifts[j].PinnedOrder--
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -701,10 +710,16 @@ func (s *StarGiftStore) ReorderCollections(_ context.Context, owner domain.Peer,
|
|||
func (s *StarGiftStore) SetPinned(_ context.Context, owner domain.Peer, savedGiftIDs []int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(savedGiftIDs) > domain.MaxPinnedStarGifts {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
ids, err := s.validCollectionGiftIDsLocked(owner, savedGiftIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) != len(savedGiftIDs) {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
order := make(map[int64]int, len(ids))
|
||||
for i, id := range ids {
|
||||
order[id] = i + 1
|
||||
|
|
@ -712,6 +727,9 @@ func (s *StarGiftStore) SetPinned(_ context.Context, owner domain.Peer, savedGif
|
|||
for i := range s.gifts {
|
||||
if s.gifts[i].Owner == owner {
|
||||
s.gifts[i].PinnedOrder = order[s.gifts[i].ID]
|
||||
if s.gifts[i].PinnedOrder > 0 {
|
||||
s.gifts[i].Unsaved = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -47,6 +47,24 @@ func TestStarGiftProfilePinOrderAndPagination(t *testing.T) {
|
|||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("paged order = %v, want %v", got, want)
|
||||
}
|
||||
if ok, err := store.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 100}, true); err != nil || !ok {
|
||||
t.Fatalf("hide pinned gift = %v err %v", ok, err)
|
||||
}
|
||||
hidden, found, err := store.GetByRef(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 100})
|
||||
if err != nil || !found || !hidden.Unsaved || hidden.PinnedOrder != 0 {
|
||||
t.Fatalf("hidden pinned gift = %+v found %v err %v", hidden, found, err)
|
||||
}
|
||||
remaining, found, err := store.GetByRef(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 102})
|
||||
if err != nil || !found || remaining.PinnedOrder != 1 {
|
||||
t.Fatalf("remaining pin = %+v found %v err %v", remaining, found, err)
|
||||
}
|
||||
if err := store.SetPinned(ctx, owner, []int64{ids[0], ids[2]}); err != nil {
|
||||
t.Fatalf("repin hidden gift: %v", err)
|
||||
}
|
||||
repinned, found, err := store.GetByRef(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 100})
|
||||
if err != nil || !found || repinned.Unsaved || repinned.PinnedOrder != 1 {
|
||||
t.Fatalf("repinned gift = %+v found %v err %v", repinned, found, err)
|
||||
}
|
||||
|
||||
if err := store.SetPinned(ctx, owner, nil); err != nil {
|
||||
t.Fatalf("clear pinned: %v", err)
|
||||
|
|
|
|||
721
internal/store/memory/telegram_login.go
Normal file
721
internal/store/memory/telegram_login.go
Normal file
|
|
@ -0,0 +1,721 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type telegramLoginBotPermissionWriter interface {
|
||||
AllowBotSendMessage(ctx context.Context, botUserID, userID int64, fromRequest bool) (bool, error)
|
||||
}
|
||||
|
||||
// TelegramLoginStore is the deterministic in-memory implementation used by
|
||||
// application and RPC tests. A single mutex makes the same aggregate changes
|
||||
// atomic; production uses PostgreSQL row locks and one transaction.
|
||||
type TelegramLoginStore struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
permissions telegramLoginBotPermissionWriter
|
||||
nextURLID int64
|
||||
nextAppID int64
|
||||
nextRequestID int64
|
||||
nextCodeID int64
|
||||
|
||||
clientsByID map[string]domain.TelegramLoginClient
|
||||
clientByBot map[int64]string
|
||||
allowedURLs map[string]domain.TelegramLoginAllowedURL
|
||||
nativeApps map[int64]domain.TelegramLoginNativeApp
|
||||
requests map[int64]domain.TelegramLoginRequest
|
||||
requestToken map[string]int64
|
||||
browserToken map[string]int64
|
||||
codes map[int64]domain.TelegramLoginAuthorizationCode
|
||||
codeByHash map[string]int64
|
||||
codeByRequest map[int64]int64
|
||||
webAuths map[int64]domain.TelegramLoginWebAuthorization
|
||||
}
|
||||
|
||||
func NewTelegramLoginStore(permissions telegramLoginBotPermissionWriter) *TelegramLoginStore {
|
||||
return &TelegramLoginStore{
|
||||
permissions: permissions,
|
||||
clientsByID: make(map[string]domain.TelegramLoginClient),
|
||||
clientByBot: make(map[int64]string),
|
||||
allowedURLs: make(map[string]domain.TelegramLoginAllowedURL),
|
||||
nativeApps: make(map[int64]domain.TelegramLoginNativeApp),
|
||||
requests: make(map[int64]domain.TelegramLoginRequest),
|
||||
requestToken: make(map[string]int64),
|
||||
browserToken: make(map[string]int64),
|
||||
codes: make(map[int64]domain.TelegramLoginAuthorizationCode),
|
||||
codeByHash: make(map[string]int64),
|
||||
codeByRequest: make(map[int64]int64),
|
||||
webAuths: make(map[int64]domain.TelegramLoginWebAuthorization),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) CreateTelegramLoginClient(_ context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) {
|
||||
if err := client.Validate(); err != nil {
|
||||
return domain.TelegramLoginClient{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.clientByBot[client.BotUserID]; exists {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
if _, exists := s.clientsByID[client.ClientID]; exists {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
s.clientsByID[client.ClientID] = client.Clone()
|
||||
s.clientByBot[client.BotUserID] = client.ClientID
|
||||
return client.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) UpsertTelegramLoginClient(_ context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) {
|
||||
if err := client.Validate(); err != nil {
|
||||
return domain.TelegramLoginClient{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if existingID, exists := s.clientByBot[client.BotUserID]; exists && existingID != client.ClientID {
|
||||
delete(s.clientsByID, existingID)
|
||||
}
|
||||
if existing, exists := s.clientsByID[client.ClientID]; exists && existing.BotUserID != client.BotUserID {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
s.clientsByID[client.ClientID] = client.Clone()
|
||||
s.clientByBot[client.BotUserID] = client.ClientID
|
||||
return client.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) GetTelegramLoginClient(_ context.Context, clientID string) (domain.TelegramLoginClient, bool, error) {
|
||||
s.mu.RLock()
|
||||
client, ok := s.clientsByID[clientID]
|
||||
s.mu.RUnlock()
|
||||
return client.Clone(), ok, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) GetTelegramLoginClientByBot(_ context.Context, botUserID int64) (domain.TelegramLoginClient, bool, error) {
|
||||
s.mu.RLock()
|
||||
clientID, ok := s.clientByBot[botUserID]
|
||||
client := s.clientsByID[clientID]
|
||||
s.mu.RUnlock()
|
||||
return client.Clone(), ok, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) RotateTelegramLoginClientSecret(_ context.Context, botUserID, expectedVersion int64, secretHash []byte, now time.Time) (domain.TelegramLoginClient, error) {
|
||||
if botUserID <= 0 || expectedVersion <= 0 || len(secretHash) != 32 {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
clientID, ok := s.clientByBot[botUserID]
|
||||
if !ok {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
client := s.clientsByID[clientID]
|
||||
if client.SecretVersion != expectedVersion {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
client.SecretVersion++
|
||||
client.SecretHash = append([]byte(nil), secretHash...)
|
||||
client.UpdatedAt = now
|
||||
s.clientsByID[clientID] = client
|
||||
return client.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) SetTelegramLoginClientSigningAlgorithm(_ context.Context, botUserID int64, algorithm domain.TelegramLoginSigningAlgorithm, now time.Time) (domain.TelegramLoginClient, error) {
|
||||
if botUserID <= 0 || !algorithm.Valid() {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
clientID, ok := s.clientByBot[botUserID]
|
||||
if !ok {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
client := s.clientsByID[clientID]
|
||||
client.SigningAlgorithm = algorithm
|
||||
client.UpdatedAt = now
|
||||
s.clientsByID[clientID] = client
|
||||
return client.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) SetTelegramLoginClientEnabled(_ context.Context, botUserID int64, enabled bool, now time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
clientID, ok := s.clientByBot[botUserID]
|
||||
if !ok {
|
||||
return domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
client := s.clientsByID[clientID]
|
||||
client.Enabled = enabled
|
||||
client.UpdatedAt = now
|
||||
s.clientsByID[clientID] = client
|
||||
return nil
|
||||
}
|
||||
|
||||
func telegramLoginAllowedURLKey(botUserID int64, kind domain.TelegramLoginAllowedURLKind, value string) string {
|
||||
return strconv.FormatInt(botUserID, 10) + "\x00" + string(kind) + "\x00" + value
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) AddTelegramLoginAllowedURL(_ context.Context, allowed domain.TelegramLoginAllowedURL) (domain.TelegramLoginAllowedURL, error) {
|
||||
if allowed.BotUserID <= 0 || allowed.NormalizedURL == "" || (allowed.Kind != domain.TelegramLoginAllowedWebOrigin && allowed.Kind != domain.TelegramLoginAllowedRedirectURI) {
|
||||
return domain.TelegramLoginAllowedURL{}, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.clientByBot[allowed.BotUserID]; !ok {
|
||||
return domain.TelegramLoginAllowedURL{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
key := telegramLoginAllowedURLKey(allowed.BotUserID, allowed.Kind, allowed.NormalizedURL)
|
||||
if existing, ok := s.allowedURLs[key]; ok {
|
||||
return existing, nil
|
||||
}
|
||||
s.nextURLID++
|
||||
allowed.ID = s.nextURLID
|
||||
s.allowedURLs[key] = allowed
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) DeleteTelegramLoginAllowedURL(_ context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := telegramLoginAllowedURLKey(botUserID, kind, normalizedURL)
|
||||
if _, ok := s.allowedURLs[key]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(s.allowedURLs, key)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) ListTelegramLoginAllowedURLs(_ context.Context, botUserID int64) ([]domain.TelegramLoginAllowedURL, error) {
|
||||
s.mu.RLock()
|
||||
out := make([]domain.TelegramLoginAllowedURL, 0)
|
||||
for _, allowed := range s.allowedURLs {
|
||||
if allowed.BotUserID == botUserID {
|
||||
out = append(out, allowed)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) IsTelegramLoginURLAllowed(_ context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) {
|
||||
s.mu.RLock()
|
||||
_, ok := s.allowedURLs[telegramLoginAllowedURLKey(botUserID, kind, normalizedURL)]
|
||||
s.mu.RUnlock()
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) UpsertTelegramLoginNativeApp(_ context.Context, app domain.TelegramLoginNativeApp) (domain.TelegramLoginNativeApp, error) {
|
||||
if err := app.Validate(); err != nil {
|
||||
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.clientByBot[app.BotUserID]; !ok {
|
||||
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
if app.ID == 0 {
|
||||
for id, existing := range s.nativeApps {
|
||||
if existing.BotUserID == app.BotUserID && existing.Platform == app.Platform && existing.ApplicationID == app.ApplicationID && existing.VerificationID == app.VerificationID {
|
||||
app.ID, app.CreatedAt = id, existing.CreatedAt
|
||||
s.nativeApps[id] = app
|
||||
return app, nil
|
||||
}
|
||||
if existing.BotUserID == app.BotUserID && existing.CallbackURI == app.CallbackURI {
|
||||
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
}
|
||||
count := 0
|
||||
for _, existing := range s.nativeApps {
|
||||
if existing.BotUserID == app.BotUserID {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count >= domain.MaxTelegramLoginNativeApps {
|
||||
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
s.nextAppID++
|
||||
app.ID = s.nextAppID
|
||||
} else if existing, ok := s.nativeApps[app.ID]; ok && existing.BotUserID != app.BotUserID {
|
||||
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
s.nativeApps[app.ID] = app
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) DeleteTelegramLoginNativeApp(_ context.Context, botUserID, appID int64) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
app, ok := s.nativeApps[appID]
|
||||
if !ok || app.BotUserID != botUserID {
|
||||
return false, nil
|
||||
}
|
||||
delete(s.nativeApps, appID)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) ListTelegramLoginNativeApps(_ context.Context, botUserID int64) ([]domain.TelegramLoginNativeApp, error) {
|
||||
s.mu.RLock()
|
||||
out := make([]domain.TelegramLoginNativeApp, 0)
|
||||
for _, app := range s.nativeApps {
|
||||
if app.BotUserID == botUserID {
|
||||
out = append(out, app)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
if len(out) > domain.MaxTelegramLoginNativeApps {
|
||||
out = out[:domain.MaxTelegramLoginNativeApps]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) CreateTelegramLoginRequest(_ context.Context, request domain.TelegramLoginRequest) (domain.TelegramLoginRequest, error) {
|
||||
if err := request.Validate(); err != nil {
|
||||
return domain.TelegramLoginRequest{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
client, ok := s.clientsByID[request.ClientID]
|
||||
if !ok || client.BotUserID != request.BotUserID || !client.Enabled || client.SigningAlgorithm != request.SigningAlgorithm {
|
||||
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginClientDisabled
|
||||
}
|
||||
if _, exists := s.requestToken[string(request.RequestTokenHash)]; exists {
|
||||
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
if _, exists := s.browserToken[string(request.BrowserTokenHash)]; exists {
|
||||
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
s.nextRequestID++
|
||||
request.ID = s.nextRequestID
|
||||
s.requests[request.ID] = request.Clone()
|
||||
s.requestToken[string(request.RequestTokenHash)] = request.ID
|
||||
s.browserToken[string(request.BrowserTokenHash)] = request.ID
|
||||
return request.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) GetTelegramLoginRequest(_ context.Context, requestID int64) (domain.TelegramLoginRequest, bool, error) {
|
||||
s.mu.RLock()
|
||||
request, ok := s.requests[requestID]
|
||||
s.mu.RUnlock()
|
||||
return request.Clone(), ok, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) GetTelegramLoginRequestByTokenHash(_ context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) {
|
||||
s.mu.RLock()
|
||||
id, ok := s.requestToken[string(tokenHash)]
|
||||
request := s.requests[id]
|
||||
s.mu.RUnlock()
|
||||
return request.Clone(), ok, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) GetTelegramLoginRequestByBrowserTokenHash(_ context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) {
|
||||
s.mu.RLock()
|
||||
id, ok := s.browserToken[string(tokenHash)]
|
||||
request := s.requests[id]
|
||||
s.mu.RUnlock()
|
||||
return request.Clone(), ok, nil
|
||||
}
|
||||
|
||||
func grantedTelegramLoginScopes(request domain.TelegramLoginRequest, approval domain.TelegramLoginApproval) ([]domain.TelegramLoginScope, error) {
|
||||
if approval.WriteAllowed && !request.Requests(domain.TelegramLoginScopeBotAccess) {
|
||||
return nil, domain.ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
if approval.PhoneShared && !request.Requests(domain.TelegramLoginScopePhone) {
|
||||
return nil, domain.ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
out := make([]domain.TelegramLoginScope, 0, len(request.Scopes))
|
||||
for _, scope := range request.Scopes {
|
||||
if scope == domain.TelegramLoginScopePhone && !approval.PhoneShared {
|
||||
continue
|
||||
}
|
||||
if scope == domain.TelegramLoginScopeBotAccess && !approval.WriteAllowed {
|
||||
continue
|
||||
}
|
||||
out = append(out, scope)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) ApproveTelegramLoginRequest(ctx context.Context, approval domain.TelegramLoginApproval, webAuthorizationHash int64) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) {
|
||||
if approval.RequestID <= 0 || approval.Identity.UserID <= 0 || webAuthorizationHash == 0 || approval.ApprovedAt.IsZero() {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
request, ok := s.requests[approval.RequestID]
|
||||
if !ok {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if request.Status != domain.TelegramLoginRequestPending {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
if !approval.ApprovedAt.Before(request.ExpiresAt) {
|
||||
request.Status = domain.TelegramLoginRequestExpired
|
||||
s.requests[request.ID] = request
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestExpired
|
||||
}
|
||||
client, clientExists := s.clientsByID[request.ClientID]
|
||||
if !clientExists || !client.Enabled || client.BotUserID != request.BotUserID || client.SigningAlgorithm != request.SigningAlgorithm {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginClientDisabled
|
||||
}
|
||||
if request.ResponseType == "code" {
|
||||
_, webAllowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedRedirectURI, request.RedirectURI)]
|
||||
if !webAllowed && !(request.Source == domain.TelegramLoginRequestNative && s.nativeCallbackAllowedLocked(request.BotUserID, request.RedirectURI)) {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRedirectNotAllowed
|
||||
}
|
||||
} else if request.ResponseType == "post_message" || request.ResponseType == "legacy_url" {
|
||||
if _, ok := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, request.Origin)]; !ok {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
|
||||
}
|
||||
} else {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if request.InAppOrigin != "" {
|
||||
if _, ok := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, request.InAppOrigin)]; !ok {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
|
||||
}
|
||||
}
|
||||
if len(request.MatchCodes) > 0 && approval.MatchCode != request.MatchCode {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginMatchCodeInvalid
|
||||
}
|
||||
scopes, err := grantedTelegramLoginScopes(request, approval)
|
||||
if err != nil {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err
|
||||
}
|
||||
if _, exists := s.webAuths[webAuthorizationHash]; exists {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
identity, err := approval.Identity.Sanitized(request.Requests(domain.TelegramLoginScopeProfile), approval.PhoneShared)
|
||||
if err != nil {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err
|
||||
}
|
||||
activeAuthorizations := 0
|
||||
for _, authorization := range s.webAuths {
|
||||
if authorization.UserID == identity.UserID && authorization.RevokedAt.IsZero() {
|
||||
activeAuthorizations++
|
||||
}
|
||||
}
|
||||
if activeAuthorizations >= domain.MaxTelegramLoginWebAuthorizations {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginAuthorizationsTooMany
|
||||
}
|
||||
if approval.WriteAllowed && s.permissions != nil {
|
||||
if _, err := s.permissions.AllowBotSendMessage(ctx, request.BotUserID, identity.UserID, true); err != nil {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err
|
||||
}
|
||||
}
|
||||
request.Status = domain.TelegramLoginRequestApproved
|
||||
request.AuthorizedUserID = identity.UserID
|
||||
request.ProfileName = identity.Name
|
||||
request.GivenName = identity.GivenName
|
||||
request.FamilyName = identity.FamilyName
|
||||
request.PreferredUsername = identity.PreferredUsername
|
||||
request.Picture = identity.Picture
|
||||
request.PhoneNumber = identity.PhoneNumber
|
||||
request.WriteAllowed = approval.WriteAllowed
|
||||
request.PhoneShared = approval.PhoneShared
|
||||
request.ApprovedAt = approval.ApprovedAt
|
||||
s.requests[request.ID] = request.Clone()
|
||||
web := domain.TelegramLoginWebAuthorization{
|
||||
Hash: webAuthorizationHash,
|
||||
RequestID: request.ID,
|
||||
UserID: identity.UserID,
|
||||
BotUserID: request.BotUserID,
|
||||
Domain: request.Domain,
|
||||
Browser: request.Browser,
|
||||
Platform: request.Platform,
|
||||
IP: request.IP,
|
||||
Region: request.Region,
|
||||
Scopes: scopes,
|
||||
PhoneShared: approval.PhoneShared,
|
||||
BotAccessGranted: approval.WriteAllowed,
|
||||
CreatedAt: approval.ApprovedAt,
|
||||
LastActiveAt: approval.ApprovedAt,
|
||||
}
|
||||
s.webAuths[web.Hash] = web.Clone()
|
||||
return request.Clone(), web.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) DeclineTelegramLoginRequest(_ context.Context, requestID, userID int64, now time.Time) (domain.TelegramLoginRequest, error) {
|
||||
if requestID <= 0 || userID <= 0 || now.IsZero() {
|
||||
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
request, ok := s.requests[requestID]
|
||||
if !ok {
|
||||
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if request.Status != domain.TelegramLoginRequestPending {
|
||||
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
if !now.Before(request.ExpiresAt) {
|
||||
request.Status = domain.TelegramLoginRequestExpired
|
||||
s.requests[request.ID] = request
|
||||
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestExpired
|
||||
}
|
||||
request.Status = domain.TelegramLoginRequestDeclined
|
||||
request.DeclinedAt = now
|
||||
s.requests[request.ID] = request.Clone()
|
||||
return request.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) PutTelegramLoginAuthorizationCode(_ context.Context, code domain.TelegramLoginAuthorizationCode) (domain.TelegramLoginAuthorizationCode, error) {
|
||||
if code.RequestID <= 0 || len(code.CodeHash) != 32 || len(code.SealedCode) < 32 || len(code.SealNonce) < 12 || code.SealKeyID == "" || code.IssuedAt.IsZero() || !code.ExpiresAt.After(code.IssuedAt) {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
request, ok := s.requests[code.RequestID]
|
||||
if !ok || request.Status != domain.TelegramLoginRequestApproved {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
client, clientExists := s.clientsByID[request.ClientID]
|
||||
if !clientExists || !client.Enabled || client.BotUserID != request.BotUserID || client.SigningAlgorithm != request.SigningAlgorithm {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginClientDisabled
|
||||
}
|
||||
switch request.ResponseType {
|
||||
case "code":
|
||||
_, allowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedRedirectURI, request.RedirectURI)]
|
||||
if !allowed && !(request.Source == domain.TelegramLoginRequestNative && s.nativeCallbackAllowedLocked(request.BotUserID, request.RedirectURI)) {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRedirectNotAllowed
|
||||
}
|
||||
case "post_message":
|
||||
if _, allowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, request.Origin)]; !allowed {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginOriginNotAllowed
|
||||
}
|
||||
default:
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
web, active := s.webAuthByRequestLocked(request.ID)
|
||||
if !active || !web.RevokedAt.IsZero() {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
if id, exists := s.codeByRequest[code.RequestID]; exists {
|
||||
return s.codes[id].Clone(), nil
|
||||
}
|
||||
if _, exists := s.codeByHash[string(code.CodeHash)]; exists {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
s.nextCodeID++
|
||||
code.ID = s.nextCodeID
|
||||
s.codes[code.ID] = code.Clone()
|
||||
s.codeByHash[string(code.CodeHash)] = code.ID
|
||||
s.codeByRequest[code.RequestID] = code.ID
|
||||
return code.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) GetTelegramLoginAuthorizationCodeByRequest(_ context.Context, requestID int64) (domain.TelegramLoginAuthorizationCode, bool, error) {
|
||||
s.mu.RLock()
|
||||
id, ok := s.codeByRequest[requestID]
|
||||
code := s.codes[id]
|
||||
s.mu.RUnlock()
|
||||
return code.Clone(), ok, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) GetTelegramLoginAuthorizationCodeByHash(_ context.Context, codeHash []byte) (domain.TelegramLoginAuthorizationCode, bool, error) {
|
||||
s.mu.RLock()
|
||||
id, ok := s.codeByHash[string(codeHash)]
|
||||
code := s.codes[id]
|
||||
s.mu.RUnlock()
|
||||
return code.Clone(), ok, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) ConsumeTelegramLoginAuthorizationCode(_ context.Context, exchange domain.TelegramLoginCodeExchange) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) {
|
||||
if len(exchange.CodeHash) != 32 || exchange.ClientID == "" || exchange.ClientSecretVersion <= 0 || exchange.RedirectURI == "" || exchange.CodeChallenge == "" || exchange.Now.IsZero() {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id, ok := s.codeByHash[string(exchange.CodeHash)]
|
||||
if !ok {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
code := s.codes[id]
|
||||
if !code.ConsumedAt.IsZero() {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeConsumed
|
||||
}
|
||||
if !exchange.Now.Before(code.ExpiresAt) {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
request := s.requests[code.RequestID]
|
||||
client, clientExists := s.clientsByID[exchange.ClientID]
|
||||
if !clientExists || !client.Enabled || client.SecretVersion != exchange.ClientSecretVersion || request.ResponseType != "code" || request.ClientID != exchange.ClientID || request.RedirectURI != exchange.RedirectURI || request.CodeChallenge != exchange.CodeChallenge {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
_, webAllowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedRedirectURI, request.RedirectURI)]
|
||||
if !webAllowed && !(request.Source == domain.TelegramLoginRequestNative && s.nativeCallbackAllowedLocked(request.BotUserID, request.RedirectURI)) {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
web, exists := s.webAuthByRequestLocked(code.RequestID)
|
||||
if request.Status != domain.TelegramLoginRequestApproved || !exists || !web.RevokedAt.IsZero() {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
code.ConsumedAt = exchange.Now
|
||||
web.LastActiveAt = exchange.Now
|
||||
s.codes[id] = code.Clone()
|
||||
s.webAuths[web.Hash] = web.Clone()
|
||||
return code.Clone(), request.Clone(), web.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) ConsumeTelegramLoginDirectToken(_ context.Context, tokenHash []byte, origin string, now time.Time) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) {
|
||||
if len(tokenHash) != 32 || origin == "" || now.IsZero() {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id, ok := s.codeByHash[string(tokenHash)]
|
||||
if !ok {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
code := s.codes[id]
|
||||
if !code.ConsumedAt.IsZero() {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeConsumed
|
||||
}
|
||||
if !now.Before(code.ExpiresAt) {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
request := s.requests[code.RequestID]
|
||||
client, clientExists := s.clientsByID[request.ClientID]
|
||||
if !clientExists || !client.Enabled || client.BotUserID != request.BotUserID ||
|
||||
request.Status != domain.TelegramLoginRequestApproved || request.Source != domain.TelegramLoginRequestMiniApp ||
|
||||
request.ResponseType != "post_message" || request.Origin != origin || request.InAppOrigin != origin {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
if _, allowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, origin)]; !allowed {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
web, exists := s.webAuthByRequestLocked(code.RequestID)
|
||||
if !exists || !web.RevokedAt.IsZero() {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
code.ConsumedAt = now
|
||||
web.LastActiveAt = now
|
||||
s.codes[id] = code.Clone()
|
||||
s.webAuths[web.Hash] = web.Clone()
|
||||
return code.Clone(), request.Clone(), web.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) webAuthByRequestLocked(requestID int64) (domain.TelegramLoginWebAuthorization, bool) {
|
||||
for _, web := range s.webAuths {
|
||||
if web.RequestID == requestID {
|
||||
return web, true
|
||||
}
|
||||
}
|
||||
return domain.TelegramLoginWebAuthorization{}, false
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) nativeCallbackAllowedLocked(botUserID int64, callbackURI string) bool {
|
||||
for _, app := range s.nativeApps {
|
||||
if app.BotUserID == botUserID && app.Enabled && app.CallbackURI == callbackURI {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) ListTelegramLoginWebAuthorizations(_ context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error) {
|
||||
s.mu.RLock()
|
||||
out := make([]domain.TelegramLoginWebAuthorization, 0, min(len(s.webAuths), domain.MaxTelegramLoginWebAuthorizations))
|
||||
for _, web := range s.webAuths {
|
||||
if web.UserID == userID && web.RevokedAt.IsZero() {
|
||||
out = append(out, web.Clone())
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].LastActiveAt.Equal(out[j].LastActiveAt) {
|
||||
return out[i].Hash > out[j].Hash
|
||||
}
|
||||
return out[i].LastActiveAt.After(out[j].LastActiveAt)
|
||||
})
|
||||
if len(out) > domain.MaxTelegramLoginWebAuthorizations {
|
||||
out = out[:domain.MaxTelegramLoginWebAuthorizations]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) RevokeTelegramLoginWebAuthorization(_ context.Context, userID, hash int64, now time.Time) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
web, ok := s.webAuths[hash]
|
||||
if !ok || web.UserID != userID || !web.RevokedAt.IsZero() {
|
||||
return false, nil
|
||||
}
|
||||
web.RevokedAt = now
|
||||
s.webAuths[hash] = web
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) RevokeAllTelegramLoginWebAuthorizations(_ context.Context, userID int64, now time.Time) (int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var count int64
|
||||
for hash, web := range s.webAuths {
|
||||
if web.UserID == userID && web.RevokedAt.IsZero() {
|
||||
web.RevokedAt = now
|
||||
s.webAuths[hash] = web
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) DeleteExpiredTelegramLoginArtifacts(_ context.Context, before time.Time, limit int) (int64, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
return 0, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var deleted int64
|
||||
for id, code := range s.codes {
|
||||
if deleted >= int64(limit) {
|
||||
break
|
||||
}
|
||||
if code.ExpiresAt.Before(before) || (!code.ConsumedAt.IsZero() && code.ConsumedAt.Before(before)) {
|
||||
delete(s.codes, id)
|
||||
delete(s.codeByHash, string(code.CodeHash))
|
||||
delete(s.codeByRequest, code.RequestID)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
for id, request := range s.requests {
|
||||
if deleted >= int64(limit) {
|
||||
break
|
||||
}
|
||||
deleteRequest := (request.Status == domain.TelegramLoginRequestPending || request.Status == domain.TelegramLoginRequestDeclined || request.Status == domain.TelegramLoginRequestExpired) && request.ExpiresAt.Before(before)
|
||||
var revokedWebHash int64
|
||||
if request.Status == domain.TelegramLoginRequestApproved && !request.ApprovedAt.IsZero() && request.ApprovedAt.Before(before) {
|
||||
// Approved requests remain the immutable claim snapshot behind an active
|
||||
// web authorization. They may only be collected after the grant itself
|
||||
// was revoked and every exchange code has left the retention window.
|
||||
for hash, web := range s.webAuths {
|
||||
if web.RequestID == id && !web.RevokedAt.IsZero() && web.RevokedAt.Before(before) {
|
||||
deleteRequest = true
|
||||
revokedWebHash = hash
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, hasCode := s.codeByRequest[id]; hasCode {
|
||||
deleteRequest = false
|
||||
}
|
||||
}
|
||||
if !deleteRequest {
|
||||
continue
|
||||
}
|
||||
delete(s.requests, id)
|
||||
delete(s.requestToken, string(request.RequestTokenHash))
|
||||
delete(s.browserToken, string(request.BrowserTokenHash))
|
||||
if revokedWebHash != 0 {
|
||||
delete(s.webAuths, revokedWebHash)
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
320
internal/store/memory/telegram_login_test.go
Normal file
320
internal/store/memory/telegram_login_test.go
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type telegramLoginPermissionRecorder struct {
|
||||
mu sync.Mutex
|
||||
grants map[[2]int64]int
|
||||
}
|
||||
|
||||
func (r *telegramLoginPermissionRecorder) AllowBotSendMessage(_ context.Context, botUserID, userID int64, _ bool) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.grants == nil {
|
||||
r.grants = make(map[[2]int64]int)
|
||||
}
|
||||
key := [2]int64{botUserID, userID}
|
||||
created := r.grants[key] == 0
|
||||
r.grants[key]++
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func telegramLoginTestHash(value string) []byte {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func seedTelegramLoginRequest(t *testing.T, s *TelegramLoginStore, now time.Time) domain.TelegramLoginRequest {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
client := domain.TelegramLoginClient{
|
||||
BotUserID: 9001,
|
||||
ClientID: "9001",
|
||||
SecretHash: telegramLoginTestHash("client-secret"),
|
||||
SecretVersion: 1,
|
||||
SigningAlgorithm: domain.TelegramLoginSigningRS256,
|
||||
Enabled: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if _, err := s.UpsertTelegramLoginClient(ctx, client); err != nil {
|
||||
t.Fatalf("UpsertTelegramLoginClient: %v", err)
|
||||
}
|
||||
if _, err := s.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
|
||||
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedRedirectURI,
|
||||
NormalizedURL: "https://rp.example/callback", CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddTelegramLoginAllowedURL: %v", err)
|
||||
}
|
||||
request := domain.TelegramLoginRequest{
|
||||
RequestTokenHash: telegramLoginTestHash("request-token"),
|
||||
BrowserTokenHash: telegramLoginTestHash("browser-token"),
|
||||
BotUserID: client.BotUserID,
|
||||
ClientID: client.ClientID,
|
||||
SigningAlgorithm: client.SigningAlgorithm,
|
||||
Source: domain.TelegramLoginRequestWeb,
|
||||
ResponseType: "code",
|
||||
RedirectURI: "https://rp.example/callback",
|
||||
Origin: "https://rp.example",
|
||||
Domain: "rp.example",
|
||||
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopePhone, domain.TelegramLoginScopeBotAccess},
|
||||
State: "state",
|
||||
Nonce: "nonce",
|
||||
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
CodeChallengeMethod: "S256",
|
||||
Browser: "Firefox",
|
||||
Platform: "Windows",
|
||||
IP: "192.0.2.10",
|
||||
Region: "Test Region",
|
||||
MatchCodes: []string{"🟢", "🔵", "🟠"},
|
||||
MatchCode: "🔵",
|
||||
MatchCodesFirst: true,
|
||||
Status: domain.TelegramLoginRequestPending,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(5 * time.Minute),
|
||||
}
|
||||
created, err := s.CreateTelegramLoginRequest(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTelegramLoginRequest: %v", err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func approveTelegramLoginRequest(t *testing.T, s *TelegramLoginStore, request domain.TelegramLoginRequest, now time.Time) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization) {
|
||||
t.Helper()
|
||||
approved, web, err := s.ApproveTelegramLoginRequest(context.Background(), domain.TelegramLoginApproval{
|
||||
RequestID: request.ID,
|
||||
Identity: domain.TelegramLoginIdentitySnapshot{
|
||||
UserID: 42, Name: "Alice Example", GivenName: "Alice", FamilyName: "Example",
|
||||
PreferredUsername: "alice", Picture: "https://oauth.example/userpic/42",
|
||||
},
|
||||
WriteAllowed: true,
|
||||
PhoneShared: false,
|
||||
MatchCode: request.MatchCode,
|
||||
ApprovedAt: now,
|
||||
}, 7000+request.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ApproveTelegramLoginRequest: %v", err)
|
||||
}
|
||||
return approved, web
|
||||
}
|
||||
|
||||
func TestTelegramLoginApproveIsAtomicAndShrinksConsent(t *testing.T) {
|
||||
now := time.Unix(1_780_000_000, 0)
|
||||
permissions := &telegramLoginPermissionRecorder{}
|
||||
s := NewTelegramLoginStore(permissions)
|
||||
request := seedTelegramLoginRequest(t, s, now)
|
||||
approved, web := approveTelegramLoginRequest(t, s, request, now.Add(time.Second))
|
||||
if approved.Status != domain.TelegramLoginRequestApproved || approved.AuthorizedUserID != 42 {
|
||||
t.Fatalf("approved request = %#v", approved)
|
||||
}
|
||||
if web.PhoneShared || web.BotAccessGranted != true {
|
||||
t.Fatalf("web consent = %#v", web)
|
||||
}
|
||||
if len(web.Scopes) != 3 || web.Scopes[0] != domain.TelegramLoginScopeOpenID || web.Scopes[1] != domain.TelegramLoginScopeProfile || web.Scopes[2] != domain.TelegramLoginScopeBotAccess {
|
||||
t.Fatalf("granted scopes = %#v", web.Scopes)
|
||||
}
|
||||
permissions.mu.Lock()
|
||||
grants := permissions.grants[[2]int64{9001, 42}]
|
||||
permissions.mu.Unlock()
|
||||
if grants != 1 {
|
||||
t.Fatalf("bot permission grants = %d, want 1", grants)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginAcceptDeclineRaceHasOneTerminalState(t *testing.T) {
|
||||
now := time.Unix(1_780_000_000, 0)
|
||||
s := NewTelegramLoginStore(nil)
|
||||
request := seedTelegramLoginRequest(t, s, now)
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, 2)
|
||||
go func() {
|
||||
<-start
|
||||
_, _, err := s.ApproveTelegramLoginRequest(context.Background(), domain.TelegramLoginApproval{
|
||||
RequestID: request.ID,
|
||||
Identity: domain.TelegramLoginIdentitySnapshot{UserID: 42, Name: "Alice", GivenName: "Alice"},
|
||||
MatchCode: request.MatchCode, ApprovedAt: now.Add(time.Second),
|
||||
}, 7001)
|
||||
errs <- err
|
||||
}()
|
||||
go func() {
|
||||
<-start
|
||||
_, err := s.DeclineTelegramLoginRequest(context.Background(), request.ID, 42, now.Add(time.Second))
|
||||
errs <- err
|
||||
}()
|
||||
close(start)
|
||||
var success, conflict int
|
||||
for range 2 {
|
||||
err := <-errs
|
||||
switch {
|
||||
case err == nil:
|
||||
success++
|
||||
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
|
||||
conflict++
|
||||
default:
|
||||
t.Fatalf("unexpected race error: %v", err)
|
||||
}
|
||||
}
|
||||
if success != 1 || conflict != 1 {
|
||||
t.Fatalf("success=%d conflict=%d, want 1/1", success, conflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginAuthorizationCodeSingleConsumeAndRevocation(t *testing.T) {
|
||||
now := time.Unix(1_780_000_000, 0)
|
||||
s := NewTelegramLoginStore(nil)
|
||||
request := seedTelegramLoginRequest(t, s, now)
|
||||
approveTelegramLoginRequest(t, s, request, now.Add(time.Second))
|
||||
code := domain.TelegramLoginAuthorizationCode{
|
||||
RequestID: request.ID,
|
||||
CodeHash: telegramLoginTestHash("authorization-code"),
|
||||
SealedCode: append(make([]byte, 32), 1),
|
||||
SealNonce: make([]byte, 12),
|
||||
SealKeyID: "test-key",
|
||||
IssuedAt: now.Add(2 * time.Second),
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}
|
||||
if _, err := s.PutTelegramLoginAuthorizationCode(context.Background(), code); err != nil {
|
||||
t.Fatalf("PutTelegramLoginAuthorizationCode: %v", err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, 8)
|
||||
for range 8 {
|
||||
go func() {
|
||||
<-start
|
||||
_, _, _, err := s.ConsumeTelegramLoginAuthorizationCode(context.Background(), domain.TelegramLoginCodeExchange{
|
||||
CodeHash: code.CodeHash, ClientID: request.ClientID, ClientSecretVersion: 1,
|
||||
RedirectURI: request.RedirectURI, CodeChallenge: request.CodeChallenge, Now: now.Add(3 * time.Second),
|
||||
})
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
var success, consumed int
|
||||
for range 8 {
|
||||
err := <-errs
|
||||
switch {
|
||||
case err == nil:
|
||||
success++
|
||||
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
|
||||
consumed++
|
||||
default:
|
||||
t.Fatalf("unexpected consume error: %v", err)
|
||||
}
|
||||
}
|
||||
if success != 1 || consumed != 7 {
|
||||
t.Fatalf("success=%d consumed=%d, want 1/7", success, consumed)
|
||||
}
|
||||
|
||||
request2 := request.Clone()
|
||||
request2.ID = 0
|
||||
request2.RequestTokenHash = telegramLoginTestHash("request-token-2")
|
||||
request2.BrowserTokenHash = telegramLoginTestHash("browser-token-2")
|
||||
request2, err := s.CreateTelegramLoginRequest(context.Background(), request2)
|
||||
if err != nil {
|
||||
t.Fatalf("Create second request: %v", err)
|
||||
}
|
||||
_, web2 := approveTelegramLoginRequest(t, s, request2, now.Add(4*time.Second))
|
||||
code2 := code.Clone()
|
||||
code2.ID = 0
|
||||
code2.RequestID = request2.ID
|
||||
code2.CodeHash = telegramLoginTestHash("authorization-code-2")
|
||||
if _, err := s.PutTelegramLoginAuthorizationCode(context.Background(), code2); err != nil {
|
||||
t.Fatalf("Put second code: %v", err)
|
||||
}
|
||||
if revoked, err := s.RevokeTelegramLoginWebAuthorization(context.Background(), web2.UserID, web2.Hash, now.Add(5*time.Second)); err != nil || !revoked {
|
||||
t.Fatalf("RevokeTelegramLoginWebAuthorization = %v,%v", revoked, err)
|
||||
}
|
||||
if _, _, _, err := s.ConsumeTelegramLoginAuthorizationCode(context.Background(), domain.TelegramLoginCodeExchange{
|
||||
CodeHash: code2.CodeHash, ClientID: request2.ClientID, ClientSecretVersion: 1,
|
||||
RedirectURI: request2.RedirectURI, CodeChallenge: request2.CodeChallenge, Now: now.Add(6 * time.Second),
|
||||
}); !errors.Is(err, domain.ErrTelegramLoginCodeInvalid) {
|
||||
t.Fatalf("consume after revoke error = %v, want code invalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginRetentionPreservesActiveAndReferencedApprovals(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0)
|
||||
before := now.Add(24 * time.Hour)
|
||||
s := NewTelegramLoginStore(nil)
|
||||
|
||||
active := seedTelegramLoginRequest(t, s, now)
|
||||
_, activeWeb := approveTelegramLoginRequest(t, s, active, now.Add(time.Second))
|
||||
|
||||
revoked := active.Clone()
|
||||
revoked.ID = 0
|
||||
revoked.RequestTokenHash = telegramLoginTestHash("retention-revoked-request")
|
||||
revoked.BrowserTokenHash = telegramLoginTestHash("retention-revoked-browser")
|
||||
revoked.Status = domain.TelegramLoginRequestPending
|
||||
revoked.AuthorizedUserID = 0
|
||||
revoked.ProfileName, revoked.GivenName, revoked.FamilyName = "", "", ""
|
||||
revoked.PreferredUsername, revoked.Picture, revoked.PhoneNumber = "", "", ""
|
||||
revoked.WriteAllowed, revoked.PhoneShared = false, false
|
||||
revoked.ApprovedAt = time.Time{}
|
||||
revoked, err := s.CreateTelegramLoginRequest(ctx, revoked)
|
||||
if err != nil {
|
||||
t.Fatalf("create revoked request: %v", err)
|
||||
}
|
||||
_, revokedWeb := approveTelegramLoginRequest(t, s, revoked, now.Add(2*time.Second))
|
||||
if ok, err := s.RevokeTelegramLoginWebAuthorization(ctx, revokedWeb.UserID, revokedWeb.Hash, now.Add(3*time.Second)); err != nil || !ok {
|
||||
t.Fatalf("revoke old authorization = %v,%v", ok, err)
|
||||
}
|
||||
|
||||
referenced := revoked.Clone()
|
||||
referenced.ID = 0
|
||||
referenced.RequestTokenHash = telegramLoginTestHash("retention-referenced-request")
|
||||
referenced.BrowserTokenHash = telegramLoginTestHash("retention-referenced-browser")
|
||||
referenced.Status = domain.TelegramLoginRequestPending
|
||||
referenced.AuthorizedUserID = 0
|
||||
referenced.ProfileName, referenced.GivenName, referenced.FamilyName = "", "", ""
|
||||
referenced.PreferredUsername, referenced.Picture, referenced.PhoneNumber = "", "", ""
|
||||
referenced.WriteAllowed, referenced.PhoneShared = false, false
|
||||
referenced.ApprovedAt = time.Time{}
|
||||
referenced, err = s.CreateTelegramLoginRequest(ctx, referenced)
|
||||
if err != nil {
|
||||
t.Fatalf("create referenced request: %v", err)
|
||||
}
|
||||
_, referencedWeb := approveTelegramLoginRequest(t, s, referenced, now.Add(4*time.Second))
|
||||
if _, err := s.PutTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginAuthorizationCode{
|
||||
RequestID: referenced.ID, CodeHash: telegramLoginTestHash("retention-live-code"),
|
||||
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "test-key",
|
||||
IssuedAt: before.Add(time.Hour), ExpiresAt: before.Add(2 * time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("put retained code: %v", err)
|
||||
}
|
||||
if ok, err := s.RevokeTelegramLoginWebAuthorization(ctx, referencedWeb.UserID, referencedWeb.Hash, now.Add(5*time.Second)); err != nil || !ok {
|
||||
t.Fatalf("revoke referenced authorization = %v,%v", ok, err)
|
||||
}
|
||||
|
||||
deleted, err := s.DeleteExpiredTelegramLoginArtifacts(ctx, before, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("delete expired artifacts: %v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Fatalf("deleted = %d, want revoked request only", deleted)
|
||||
}
|
||||
if _, found, _ := s.GetTelegramLoginRequest(ctx, active.ID); !found {
|
||||
t.Fatal("active authorization request was deleted")
|
||||
}
|
||||
if _, found, _ := s.GetTelegramLoginRequest(ctx, referenced.ID); !found {
|
||||
t.Fatal("request with retained code was deleted")
|
||||
}
|
||||
if _, found, _ := s.GetTelegramLoginRequest(ctx, revoked.ID); found {
|
||||
t.Fatal("old revoked authorization request was retained")
|
||||
}
|
||||
listed, err := s.ListTelegramLoginWebAuthorizations(ctx, activeWeb.UserID)
|
||||
if err != nil || len(listed) != 1 || listed[0].Hash != activeWeb.Hash {
|
||||
t.Fatalf("active authorizations after retention = %#v, %v", listed, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -301,6 +301,36 @@ func (s *UserStore) SetVerified(_ context.Context, userID int64, verified bool)
|
|||
return u, nil
|
||||
}
|
||||
|
||||
// SetSupport 设置/取消用户的 support 标记(与 postgres 语义一致)。
|
||||
func (s *UserStore) SetSupport(_ context.Context, userID int64, support bool) (domain.User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.Support = support
|
||||
s.byID[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// SetScamFake 设置/取消用户的 scam 与 fake 标记(与 postgres 语义一致)。
|
||||
func (s *UserStore) SetScamFake(_ context.Context, userID int64, scam, fake bool) (domain.User, error) {
|
||||
if scam && fake {
|
||||
return domain.User{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.Scam = scam
|
||||
u.Fake = fake
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue