feat: sync chatlist sharing support
This commit is contained in:
parent
ec6e8fd13d
commit
2a9c12263f
62 changed files with 3408 additions and 212 deletions
|
|
@ -9,6 +9,7 @@ import (
|
|||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -113,7 +114,7 @@ func (s *Service) CreateBusinessChatLink(ctx context.Context, userID int64, inpu
|
|||
link, err := s.business.CreateBusinessChatLink(ctx, domain.BusinessChatLink{
|
||||
OwnerUserID: userID,
|
||||
Slug: slug,
|
||||
Link: businessChatLinkURL(slug),
|
||||
Link: s.businessChatLinkURL(slug),
|
||||
Message: normalized.Message,
|
||||
Entities: normalized.Entities,
|
||||
Title: normalized.Title,
|
||||
|
|
@ -501,6 +502,10 @@ func randomBusinessChatLinkSlug() (string, error) {
|
|||
return fmt.Sprintf("%x", value), nil
|
||||
}
|
||||
|
||||
func businessChatLinkURL(slug string) string {
|
||||
return "https://telesrv.net/m/" + slug
|
||||
func (s *Service) businessChatLinkURL(slug string) string {
|
||||
baseURL := links.DefaultPublicBaseURL
|
||||
if s != nil && s.publicBaseURL != "" {
|
||||
baseURL = s.publicBaseURL
|
||||
}
|
||||
return links.Build(baseURL, "m/"+slug, nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -29,7 +30,8 @@ type Service struct {
|
|||
savedMusic store.SavedMusicStore
|
||||
business store.BusinessAutomationStore
|
||||
// users 仅用于登录邮箱的 phone→user 解析(sendCode 检测 / login-setup / reset 走 phone)。
|
||||
users store.UserStore
|
||||
users store.UserStore
|
||||
publicBaseURL string
|
||||
}
|
||||
|
||||
// ServiceOption 调整 account 服务依赖。
|
||||
|
|
@ -91,9 +93,15 @@ func WithUsers(users store.UserStore) ServiceOption {
|
|||
}
|
||||
}
|
||||
|
||||
func WithPublicBaseURL(baseURL string) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.publicBaseURL = links.NormalizeBaseURL(baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 account 服务。
|
||||
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
|
||||
s := &Service{passwords: passwords}
|
||||
s := &Service{passwords: passwords, publicBaseURL: links.DefaultPublicBaseURL}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -396,7 +396,7 @@ func (s *Service) handleNewBotUsername(ctx context.Context, state domain.BotChat
|
|||
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, state.UserID); err != nil {
|
||||
s.log.Error("botfather: delete chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
|
||||
}
|
||||
head := fmt.Sprintf("Done! Congratulations on your new bot. You will find it at telesrv.net/%s.\n\nUse this token to access the HTTP API:\n", u.Username)
|
||||
head := fmt.Sprintf("Done! Congratulations on your new bot. You will find it at %s.\n\nUse this token to access the HTTP API:\n", s.publicURL(u.Username))
|
||||
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import (
|
|||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -88,6 +89,7 @@ type Service struct {
|
|||
log *zap.Logger
|
||||
now func() time.Time
|
||||
chatBotStreamThrottle time.Duration
|
||||
publicBaseURL string
|
||||
// replySeq 是回复 randomID 在 crypto/rand 失败时的兜底单调序列。
|
||||
replySeq atomic.Int64
|
||||
replyLocks [replyLockStripes]sync.Mutex
|
||||
|
|
@ -182,6 +184,12 @@ func WithAIChatStreamThrottle(d time.Duration) Option {
|
|||
}
|
||||
}
|
||||
|
||||
func WithPublicBaseURL(baseURL string) Option {
|
||||
return func(s *Service) {
|
||||
s.publicBaseURL = links.NormalizeBaseURL(baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// invalidateUserCache 在 bot 的 users 行变更(含 version bump)后清缓存。
|
||||
// 失效失败只记日志:缓存最长 TTL 后自愈,不阻塞写路径。
|
||||
func (s *Service) invalidateUserCache(ctx context.Context, botUserID int64) {
|
||||
|
|
@ -252,6 +260,7 @@ func NewService(users store.UserStore, bots store.BotStore, messages store.Messa
|
|||
log: zap.NewNop(),
|
||||
now: time.Now,
|
||||
chatBotStreamThrottle: defaultChatBotStreamThrottle,
|
||||
publicBaseURL: links.DefaultPublicBaseURL,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -13,6 +14,7 @@ import (
|
|||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -409,7 +411,7 @@ func (s *Service) handleStickersAddEmoji(ctx context.Context, state domain.BotCh
|
|||
if s.hooks != nil {
|
||||
s.hooks.PushStickerSetsChanged(ctx, state.UserID, stickersBotSetKind(set))
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Done. Added to %s.\n\n%s", stickersBotSetTitle(set), stickersBotPublicURL(set))}
|
||||
return botReply{Text: fmt.Sprintf("Done. Added to %s.\n\n%s", stickersBotSetTitle(set), s.stickersBotPublicURL(set))}
|
||||
}
|
||||
|
||||
func (s *Service) handleStickersDeleteDocument(ctx context.Context, state domain.BotChatState, msg domain.Message) botReply {
|
||||
|
|
@ -443,7 +445,7 @@ func (s *Service) handleStickersDeleteDocument(ctx context.Context, state domain
|
|||
if s.hooks != nil {
|
||||
s.hooks.PushStickerSetsChanged(ctx, state.UserID, stickersBotSetKind(set))
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Done. Removed from %s.\n\n%s", stickersBotSetTitle(set), stickersBotPublicURL(set))}
|
||||
return botReply{Text: fmt.Sprintf("Done. Removed from %s.\n\n%s", stickersBotSetTitle(set), s.stickersBotPublicURL(set))}
|
||||
}
|
||||
|
||||
func (s *Service) handleStickersShortName(ctx context.Context, state domain.BotChatState, raw string) botReply {
|
||||
|
|
@ -484,7 +486,7 @@ func (s *Service) handleStickersShortName(ctx context.Context, state domain.BotC
|
|||
if s.hooks != nil {
|
||||
s.hooks.PushStickerSetsChanged(ctx, state.UserID, installKind)
|
||||
}
|
||||
return botReply{Text: "Done. Your pack is published and installed.\n\n" + stickersBotPublicURL(set)}
|
||||
return botReply{Text: "Done. Your pack is published and installed.\n\n" + s.stickersBotPublicURL(set)}
|
||||
}
|
||||
|
||||
func (s *Service) stickersBotCreateError(userID int64, err error) botReply {
|
||||
|
|
@ -542,7 +544,7 @@ func (s *Service) listStickersBotPacks(ctx context.Context, userID int64) botRep
|
|||
lines := make([]string, 0, len(sets)+1)
|
||||
lines = append(lines, "Your packs:")
|
||||
for _, set := range sets {
|
||||
lines = append(lines, fmt.Sprintf("%s - %s", set.Title, stickersBotPublicURL(set)))
|
||||
lines = append(lines, fmt.Sprintf("%s - %s", set.Title, s.stickersBotPublicURL(set)))
|
||||
}
|
||||
if total > len(sets) {
|
||||
lines = append(lines, fmt.Sprintf("Showing %d of %d.", len(sets), total))
|
||||
|
|
@ -746,12 +748,21 @@ func cloneStickersBotState(state domain.BotChatState) domain.BotChatState {
|
|||
|
||||
func normalizeStickersBotShortName(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimPrefix(raw, "https://telesrv.net/addstickers/")
|
||||
raw = strings.TrimPrefix(raw, "https://telesrv.net/addemoji/")
|
||||
raw = strings.TrimPrefix(raw, "telesrv://addstickers?set=")
|
||||
raw = strings.TrimPrefix(raw, "telesrv://addemoji?set=")
|
||||
raw = strings.TrimPrefix(raw, "tg://addstickers?set=")
|
||||
raw = strings.TrimPrefix(raw, "tg://addemoji?set=")
|
||||
if strings.Contains(raw, "://") {
|
||||
if parsed, err := url.Parse(raw); err == nil {
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if (part == "addstickers" || part == "addemoji") && i+1 < len(parts) {
|
||||
raw = parts[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.ToLower(strings.Trim(raw, " /"))
|
||||
}
|
||||
|
||||
|
|
@ -784,10 +795,18 @@ func validStickersBotEmoji(raw string) bool {
|
|||
return hasEmoji
|
||||
}
|
||||
|
||||
func stickersBotPublicURL(set domain.StickerSet) string {
|
||||
func (s *Service) stickersBotPublicURL(set domain.StickerSet) string {
|
||||
part := "addstickers"
|
||||
if stickersBotSetKind(set) == domain.StickerSetKindEmoji {
|
||||
part = "addemoji"
|
||||
}
|
||||
return "https://telesrv.net/" + part + "/" + set.ShortName
|
||||
return s.publicURL(part + "/" + set.ShortName)
|
||||
}
|
||||
|
||||
func (s *Service) publicURL(path string) string {
|
||||
baseURL := links.DefaultPublicBaseURL
|
||||
if s != nil && s.publicBaseURL != "" {
|
||||
baseURL = s.publicBaseURL
|
||||
}
|
||||
return links.Build(baseURL, path, nil)
|
||||
}
|
||||
|
|
|
|||
834
internal/app/chatlists/service.go
Normal file
834
internal/app/chatlists/service.go
Normal file
|
|
@ -0,0 +1,834 @@
|
|||
package chatlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
chatlists store.ChatlistStore
|
||||
dialogs store.DialogStore
|
||||
channels ChannelService
|
||||
premium PremiumChecker
|
||||
newSlug func() (string, error)
|
||||
}
|
||||
|
||||
type Option func(*Service)
|
||||
|
||||
// ChannelService is the domain-only channel dependency used for shared-folder
|
||||
// peer membership side effects.
|
||||
type ChannelService interface {
|
||||
GetChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error)
|
||||
InviteToChannel(ctx context.Context, userID, channelID int64, userIDs []int64, date int) (domain.CreateChannelResult, error)
|
||||
JoinChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error)
|
||||
LeaveChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error)
|
||||
}
|
||||
|
||||
type PremiumChecker func(ctx context.Context, userID int64) bool
|
||||
|
||||
func WithChannels(channels ChannelService) Option {
|
||||
return func(s *Service) {
|
||||
s.channels = channels
|
||||
}
|
||||
}
|
||||
|
||||
func WithPremiumChecker(fn PremiumChecker) Option {
|
||||
return func(s *Service) {
|
||||
s.premium = fn
|
||||
}
|
||||
}
|
||||
|
||||
func WithSlugGenerator(fn func() (string, error)) Option {
|
||||
return func(s *Service) {
|
||||
if fn != nil {
|
||||
s.newSlug = fn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewService(chatlists store.ChatlistStore, dialogs store.DialogStore, opts ...Option) *Service {
|
||||
s := &Service{
|
||||
chatlists: chatlists,
|
||||
dialogs: dialogs,
|
||||
newSlug: randomChatlistSlug,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Service) ExportInvite(ctx context.Context, userID int64, filterID int, title string, peers []domain.DialogFolderPeer, date int) (domain.DialogFolder, domain.ChatlistInvite, error) {
|
||||
if err := validateChatlistUserFilter(userID, filterID); err != nil {
|
||||
return domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||
}
|
||||
if utf8.RuneCountInString(title) > domain.MaxDialogFolderTitleRunes {
|
||||
return domain.DialogFolder{}, domain.ChatlistInvite{}, domain.ErrChatlistInvalid
|
||||
}
|
||||
folder, err := s.ownerFolder(ctx, userID, filterID)
|
||||
if err != nil {
|
||||
return domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||
}
|
||||
if !folderShareable(folder) {
|
||||
return domain.DialogFolder{}, domain.ChatlistInvite{}, domain.ErrChatlistNotShareable
|
||||
}
|
||||
selected, err := selectChatlistPeers(folder, peers, true)
|
||||
if err != nil {
|
||||
return domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||
}
|
||||
if err := s.validateShareableChannelPeers(ctx, userID, selected); err != nil {
|
||||
return domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||
}
|
||||
count, err := s.chatlists.CountActiveInvites(ctx, userID, filterID)
|
||||
if err != nil {
|
||||
return domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||
}
|
||||
if count >= s.invitesLimit(ctx, userID) {
|
||||
return domain.DialogFolder{}, domain.ChatlistInvite{}, domain.ErrChatlistInvitesTooMuch
|
||||
}
|
||||
folder = exportedChatlistFolder(folder, true)
|
||||
var lastErr error
|
||||
for i := 0; i < 8; i++ {
|
||||
slug, err := s.newSlug()
|
||||
if err != nil {
|
||||
return domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||
}
|
||||
invite := domain.ChatlistInvite{
|
||||
OwnerUserID: userID,
|
||||
FilterID: filterID,
|
||||
Slug: slug,
|
||||
Title: title,
|
||||
Peers: selected,
|
||||
Date: date,
|
||||
}
|
||||
saved, err := s.chatlists.SaveInvite(ctx, invite)
|
||||
if err == nil {
|
||||
if err := s.dialogs.UpsertFolder(ctx, userID, folder); err != nil {
|
||||
rollbackErr := s.deleteInviteIfSaved(ctx, userID, filterID, saved.Slug)
|
||||
return domain.DialogFolder{}, domain.ChatlistInvite{}, errors.Join(fmt.Errorf("save exported chatlist folder: %w", err), rollbackErr)
|
||||
}
|
||||
return folder, saved, nil
|
||||
}
|
||||
if !errors.Is(err, domain.ErrChatlistSlugOccupied) {
|
||||
return domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = domain.ErrChatlistSlugOccupied
|
||||
}
|
||||
return domain.DialogFolder{}, domain.ChatlistInvite{}, lastErr
|
||||
}
|
||||
|
||||
func (s *Service) ListInvites(ctx context.Context, userID int64, filterID int) ([]domain.ChatlistInvite, error) {
|
||||
if err := validateChatlistUserFilter(userID, filterID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.ownerFolder(ctx, userID, filterID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.chatlists.ListInvites(ctx, userID, filterID)
|
||||
}
|
||||
|
||||
func (s *Service) EditInvite(ctx context.Context, userID int64, filterID int, slug string, title *string, peers *[]domain.DialogFolderPeer, revoke bool) (domain.ChatlistInvite, error) {
|
||||
if err := validateChatlistUserFilter(userID, filterID); err != nil {
|
||||
return domain.ChatlistInvite{}, err
|
||||
}
|
||||
slug = CleanSlug(slug)
|
||||
if !links.ValidChatlistSlug(slug) {
|
||||
return domain.ChatlistInvite{}, domain.ErrChatlistInviteInvalid
|
||||
}
|
||||
folder, err := s.ownerFolder(ctx, userID, filterID)
|
||||
if err != nil {
|
||||
return domain.ChatlistInvite{}, err
|
||||
}
|
||||
existing, found, err := s.chatlists.GetInvite(ctx, userID, filterID, slug)
|
||||
if err != nil {
|
||||
return domain.ChatlistInvite{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ChatlistInvite{}, domain.ErrChatlistInviteExpired
|
||||
}
|
||||
if title != nil {
|
||||
if utf8.RuneCountInString(*title) > domain.MaxDialogFolderTitleRunes {
|
||||
return domain.ChatlistInvite{}, domain.ErrChatlistInvalid
|
||||
}
|
||||
existing.Title = *title
|
||||
}
|
||||
if peers != nil {
|
||||
selected, err := selectChatlistPeers(folder, *peers, true)
|
||||
if err != nil {
|
||||
return domain.ChatlistInvite{}, err
|
||||
}
|
||||
if err := s.validateShareableChannelPeers(ctx, userID, selected); err != nil {
|
||||
return domain.ChatlistInvite{}, err
|
||||
}
|
||||
existing.Peers = selected
|
||||
}
|
||||
if revoke {
|
||||
existing.Revoked = true
|
||||
}
|
||||
return s.chatlists.SaveInvite(ctx, existing)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteInvite(ctx context.Context, userID int64, filterID int, slug string) (domain.DialogFolder, bool, error) {
|
||||
if err := validateChatlistUserFilter(userID, filterID); err != nil {
|
||||
return domain.DialogFolder{}, false, err
|
||||
}
|
||||
slug = CleanSlug(slug)
|
||||
if !links.ValidChatlistSlug(slug) {
|
||||
return domain.DialogFolder{}, false, domain.ErrChatlistInviteInvalid
|
||||
}
|
||||
folder, err := s.ownerFolder(ctx, userID, filterID)
|
||||
if err != nil {
|
||||
return domain.DialogFolder{}, false, err
|
||||
}
|
||||
deleted, err := s.chatlists.DeleteInvite(ctx, userID, filterID, slug)
|
||||
if err != nil || !deleted {
|
||||
return domain.DialogFolder{}, false, err
|
||||
}
|
||||
count, err := s.chatlists.CountInvites(ctx, userID, filterID)
|
||||
if err != nil {
|
||||
return domain.DialogFolder{}, false, err
|
||||
}
|
||||
if count > 0 || !folder.HasMyInvites {
|
||||
return domain.DialogFolder{}, false, nil
|
||||
}
|
||||
folder = exportedChatlistFolder(folder, false)
|
||||
if err := s.dialogs.UpsertFolder(ctx, userID, folder); err != nil {
|
||||
return domain.DialogFolder{}, false, fmt.Errorf("clear exported chatlist folder flag: %w", err)
|
||||
}
|
||||
return folder, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) CheckInvite(ctx context.Context, userID int64, slug string) (domain.ChatlistInvitePreview, error) {
|
||||
if userID == 0 {
|
||||
return domain.ChatlistInvitePreview{}, domain.ErrChatlistInvalid
|
||||
}
|
||||
slug = CleanSlug(slug)
|
||||
if !links.ValidChatlistSlug(slug) {
|
||||
return domain.ChatlistInvitePreview{}, domain.ErrChatlistInviteInvalid
|
||||
}
|
||||
invite, folder, err := s.inviteWithFolder(ctx, slug)
|
||||
if err != nil {
|
||||
return domain.ChatlistInvitePreview{}, err
|
||||
}
|
||||
out := domain.ChatlistInvitePreview{Invite: invite, OwnerFolder: folder}
|
||||
if userID == invite.OwnerUserID {
|
||||
ownerFolder := exportedChatlistFolder(folder, true)
|
||||
out.LocalFolder = &ownerFolder
|
||||
out.Already = peersIntersection(invite.Peers, folderPeers(ownerFolder))
|
||||
return out, nil
|
||||
}
|
||||
membership, found, err := s.chatlists.GetMembershipBySlug(ctx, userID, slug)
|
||||
if err != nil {
|
||||
return domain.ChatlistInvitePreview{}, err
|
||||
}
|
||||
if !found {
|
||||
out.Missing = cloneFolderPeers(invite.Peers)
|
||||
return out, nil
|
||||
}
|
||||
local, found, err := s.dialogs.GetFolder(ctx, userID, membership.LocalFilterID)
|
||||
if err != nil {
|
||||
return domain.ChatlistInvitePreview{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ChatlistInvitePreview{}, domain.ErrChatlistInvalid
|
||||
}
|
||||
out.Membership = &membership
|
||||
out.LocalFolder = &local
|
||||
out.Already = peersIntersection(invite.Peers, folderPeers(local))
|
||||
out.Missing = peersDifference(invite.Peers, folderPeers(local))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) JoinInvite(ctx context.Context, userID int64, slug string, peers []domain.DialogFolderPeer, date int) (domain.ChatlistJoinResult, error) {
|
||||
if userID == 0 {
|
||||
return domain.ChatlistJoinResult{}, domain.ErrChatlistInvalid
|
||||
}
|
||||
slug = CleanSlug(slug)
|
||||
if !links.ValidChatlistSlug(slug) {
|
||||
return domain.ChatlistJoinResult{}, domain.ErrChatlistInviteInvalid
|
||||
}
|
||||
invite, ownerFolder, err := s.inviteWithFolder(ctx, slug)
|
||||
if err != nil {
|
||||
return domain.ChatlistJoinResult{}, err
|
||||
}
|
||||
if userID == invite.OwnerUserID {
|
||||
return domain.ChatlistJoinResult{Folder: exportedChatlistFolder(ownerFolder, true), Date: date}, nil
|
||||
}
|
||||
if existing, found, err := s.chatlists.GetMembershipBySlug(ctx, userID, slug); err != nil {
|
||||
return domain.ChatlistJoinResult{}, err
|
||||
} else if found {
|
||||
folder, found, err := s.dialogs.GetFolder(ctx, userID, existing.LocalFilterID)
|
||||
if err != nil {
|
||||
return domain.ChatlistJoinResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ChatlistJoinResult{}, domain.ErrChatlistInvalid
|
||||
}
|
||||
return domain.ChatlistJoinResult{Folder: folder, Membership: existing, Date: date}, nil
|
||||
}
|
||||
selected, err := selectInvitePeers(invite, peers, true)
|
||||
if err != nil {
|
||||
return domain.ChatlistJoinResult{}, err
|
||||
}
|
||||
count, err := s.chatlists.CountMemberships(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.ChatlistJoinResult{}, err
|
||||
}
|
||||
if count >= s.joinedLimit(ctx, userID) {
|
||||
return domain.ChatlistJoinResult{}, domain.ErrChatlistsTooMuch
|
||||
}
|
||||
channelResults, err := s.joinChannelPeers(ctx, invite.OwnerUserID, userID, selected, date)
|
||||
if err != nil {
|
||||
return domain.ChatlistJoinResult{}, err
|
||||
}
|
||||
filterID, err := s.nextLocalFilterID(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.ChatlistJoinResult{}, err
|
||||
}
|
||||
folder := importedChatlistFolder(ownerFolder, filterID, selected)
|
||||
membership := domain.ChatlistMembership{
|
||||
UserID: userID,
|
||||
LocalFilterID: filterID,
|
||||
OwnerUserID: invite.OwnerUserID,
|
||||
OwnerFilterID: invite.FilterID,
|
||||
Slug: invite.Slug,
|
||||
Date: date,
|
||||
}
|
||||
if err := s.dialogs.UpsertFolder(ctx, userID, folder); err != nil {
|
||||
if rollbackErr := s.leaveJoinedChannelResults(ctx, userID, channelResults, date); rollbackErr != nil {
|
||||
return domain.ChatlistJoinResult{}, errors.Join(fmt.Errorf("save joined chatlist folder: %w", err), rollbackErr)
|
||||
}
|
||||
return domain.ChatlistJoinResult{}, fmt.Errorf("save joined chatlist folder: %w", err)
|
||||
}
|
||||
if err := s.chatlists.SaveMembership(ctx, membership); err != nil {
|
||||
rollbackErr := errors.Join(
|
||||
s.dialogs.DeleteFolder(ctx, userID, filterID),
|
||||
s.leaveJoinedChannelResults(ctx, userID, channelResults, date),
|
||||
)
|
||||
return domain.ChatlistJoinResult{}, errors.Join(err, rollbackErr)
|
||||
}
|
||||
return domain.ChatlistJoinResult{Folder: folder, Membership: membership, Date: date, ChannelResults: channelResults}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetUpdates(ctx context.Context, userID int64, localFilterID int) (domain.ChatlistUpdates, error) {
|
||||
membership, folder, invite, err := s.memberFolderInvite(ctx, userID, localFilterID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChatlistInviteExpired) {
|
||||
return domain.ChatlistUpdates{Membership: membership}, nil
|
||||
}
|
||||
if errors.Is(err, domain.ErrChatlistInvalid) {
|
||||
if owner, ownerErr := s.ownerFolder(ctx, userID, localFilterID); ownerErr == nil && owner.IsChatlist && owner.HasMyInvites {
|
||||
return domain.ChatlistUpdates{}, nil
|
||||
}
|
||||
}
|
||||
return domain.ChatlistUpdates{}, err
|
||||
}
|
||||
if membership.HiddenUpdates {
|
||||
return domain.ChatlistUpdates{Membership: membership}, nil
|
||||
}
|
||||
return domain.ChatlistUpdates{
|
||||
Membership: membership,
|
||||
Missing: peersDifference(invite.Peers, folderPeers(folder)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) JoinUpdates(ctx context.Context, userID int64, localFilterID int, peers []domain.DialogFolderPeer, date int) (domain.ChatlistJoinResult, error) {
|
||||
membership, folder, invite, err := s.memberFolderInvite(ctx, userID, localFilterID)
|
||||
if err != nil {
|
||||
return domain.ChatlistJoinResult{}, err
|
||||
}
|
||||
selected, err := selectInvitePeers(invite, peers, true)
|
||||
if err != nil {
|
||||
return domain.ChatlistJoinResult{}, err
|
||||
}
|
||||
if _, err := s.chatlists.SetMembershipHidden(ctx, userID, membership.LocalFilterID, false); err != nil {
|
||||
return domain.ChatlistJoinResult{}, err
|
||||
}
|
||||
channelResults, err := s.joinChannelPeers(ctx, membership.OwnerUserID, userID, selected, date)
|
||||
if err != nil {
|
||||
return domain.ChatlistJoinResult{}, err
|
||||
}
|
||||
folder.IncludePeers = mergeFolderPeers(folder.IncludePeers, selected)
|
||||
folder.IsChatlist = true
|
||||
if err := s.dialogs.UpsertFolder(ctx, userID, folder); err != nil {
|
||||
if rollbackErr := s.leaveJoinedChannelResults(ctx, userID, channelResults, date); rollbackErr != nil {
|
||||
return domain.ChatlistJoinResult{}, errors.Join(fmt.Errorf("save chatlist updates: %w", err), rollbackErr)
|
||||
}
|
||||
return domain.ChatlistJoinResult{}, fmt.Errorf("save chatlist updates: %w", err)
|
||||
}
|
||||
return domain.ChatlistJoinResult{Folder: folder, Membership: membership, ChannelResults: channelResults}, nil
|
||||
}
|
||||
|
||||
func (s *Service) HideUpdates(ctx context.Context, userID int64, localFilterID int) error {
|
||||
if err := validateChatlistUserFilter(userID, localFilterID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, found, err := s.chatlists.GetMembershipByLocalFilter(ctx, userID, localFilterID); err != nil {
|
||||
return err
|
||||
} else if !found {
|
||||
return domain.ErrChatlistInvalid
|
||||
}
|
||||
_, err := s.chatlists.SetMembershipHidden(ctx, userID, localFilterID, true)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Leave(ctx context.Context, userID int64, localFilterID int, peers []domain.DialogFolderPeer, date int) (domain.ChatlistLeaveResult, error) {
|
||||
if err := validateChatlistUserFilter(userID, localFilterID); err != nil {
|
||||
return domain.ChatlistLeaveResult{}, err
|
||||
}
|
||||
if _, found, err := s.chatlists.GetMembershipByLocalFilter(ctx, userID, localFilterID); err != nil {
|
||||
return domain.ChatlistLeaveResult{}, err
|
||||
} else if !found {
|
||||
return domain.ChatlistLeaveResult{}, domain.ErrChatlistInvalid
|
||||
}
|
||||
folder, found, err := s.dialogs.GetFolder(ctx, userID, localFilterID)
|
||||
if err != nil {
|
||||
return domain.ChatlistLeaveResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ChatlistLeaveResult{}, domain.ErrChatlistInvalid
|
||||
}
|
||||
selected, err := selectPeersFromAllowed(folderPeerMap(channelFolderPeers(folderPeers(folder))), peers, false)
|
||||
if err != nil {
|
||||
return domain.ChatlistLeaveResult{}, err
|
||||
}
|
||||
channelResults, err := s.leaveChannelPeers(ctx, userID, selected, date)
|
||||
if err != nil {
|
||||
return domain.ChatlistLeaveResult{}, err
|
||||
}
|
||||
if err := s.dialogs.DeleteFolder(ctx, userID, localFilterID); err != nil {
|
||||
return domain.ChatlistLeaveResult{}, err
|
||||
}
|
||||
if _, err := s.chatlists.DeleteMembershipByLocalFilter(ctx, userID, localFilterID); err != nil {
|
||||
restoreErr := s.dialogs.UpsertFolder(ctx, userID, folder)
|
||||
return domain.ChatlistLeaveResult{}, errors.Join(err, restoreErr)
|
||||
}
|
||||
return domain.ChatlistLeaveResult{FilterID: localFilterID, ChannelResults: channelResults, RequestedLeaves: selected}, nil
|
||||
}
|
||||
|
||||
func (s *Service) LeaveSuggestions(ctx context.Context, userID int64, localFilterID int) ([]domain.DialogFolderPeer, error) {
|
||||
if err := validateChatlistUserFilter(userID, localFilterID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, found, err := s.chatlists.GetMembershipByLocalFilter(ctx, userID, localFilterID); err != nil {
|
||||
return nil, err
|
||||
} else if !found {
|
||||
return nil, domain.ErrChatlistInvalid
|
||||
}
|
||||
folder, found, err := s.dialogs.GetFolder(ctx, userID, localFilterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, domain.ErrChatlistInvalid
|
||||
}
|
||||
return channelFolderPeers(folderPeers(folder)), nil
|
||||
}
|
||||
|
||||
func (s *Service) ownerFolder(ctx context.Context, userID int64, filterID int) (domain.DialogFolder, error) {
|
||||
if s == nil || s.chatlists == nil || s.dialogs == nil {
|
||||
return domain.DialogFolder{}, domain.ErrChatlistInvalid
|
||||
}
|
||||
folder, found, err := s.dialogs.GetFolder(ctx, userID, filterID)
|
||||
if err != nil {
|
||||
return domain.DialogFolder{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.DialogFolder{}, domain.ErrChatlistInvalid
|
||||
}
|
||||
folder.ID = filterID
|
||||
return folder, nil
|
||||
}
|
||||
|
||||
func (s *Service) inviteWithFolder(ctx context.Context, slug string) (domain.ChatlistInvite, domain.DialogFolder, error) {
|
||||
invite, found, err := s.chatlists.GetInviteBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return domain.ChatlistInvite{}, domain.DialogFolder{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ChatlistInvite{}, domain.DialogFolder{}, domain.ErrChatlistInviteExpired
|
||||
}
|
||||
folder, found, err := s.dialogs.GetFolder(ctx, invite.OwnerUserID, invite.FilterID)
|
||||
if err != nil {
|
||||
return domain.ChatlistInvite{}, domain.DialogFolder{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ChatlistInvite{}, domain.DialogFolder{}, domain.ErrChatlistInviteExpired
|
||||
}
|
||||
return invite, exportedChatlistFolder(folder, true), nil
|
||||
}
|
||||
|
||||
func (s *Service) memberFolderInvite(ctx context.Context, userID int64, localFilterID int) (domain.ChatlistMembership, domain.DialogFolder, domain.ChatlistInvite, error) {
|
||||
if err := validateChatlistUserFilter(userID, localFilterID); err != nil {
|
||||
return domain.ChatlistMembership{}, domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||
}
|
||||
membership, found, err := s.chatlists.GetMembershipByLocalFilter(ctx, userID, localFilterID)
|
||||
if err != nil {
|
||||
return domain.ChatlistMembership{}, domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ChatlistMembership{}, domain.DialogFolder{}, domain.ChatlistInvite{}, domain.ErrChatlistInvalid
|
||||
}
|
||||
folder, found, err := s.dialogs.GetFolder(ctx, userID, localFilterID)
|
||||
if err != nil {
|
||||
return membership, domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||
}
|
||||
if !found {
|
||||
return membership, domain.DialogFolder{}, domain.ChatlistInvite{}, domain.ErrChatlistInvalid
|
||||
}
|
||||
invite, found, err := s.chatlists.GetInviteBySlug(ctx, membership.Slug)
|
||||
if err != nil {
|
||||
return membership, domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||
}
|
||||
if !found {
|
||||
return membership, folder, domain.ChatlistInvite{}, domain.ErrChatlistInviteExpired
|
||||
}
|
||||
return membership, folder, invite, nil
|
||||
}
|
||||
|
||||
func (s *Service) nextLocalFilterID(ctx context.Context, userID int64) (int, error) {
|
||||
list, err := s.dialogs.ListFolders(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
used := make(map[int]struct{}, len(list.Folders))
|
||||
for _, folder := range list.Folders {
|
||||
used[folder.ID] = struct{}{}
|
||||
}
|
||||
for id := domain.DialogCustomFolderMinID; id < domain.DialogCustomFolderMinID+domain.MaxDialogFolders; id++ {
|
||||
if _, ok := used[id]; !ok {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
return 0, domain.ErrChatlistsTooMuch
|
||||
}
|
||||
|
||||
func validateChatlistUserFilter(userID int64, filterID int) error {
|
||||
if userID == 0 || filterID < domain.DialogCustomFolderMinID {
|
||||
return domain.ErrChatlistInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func folderShareable(folder domain.DialogFolder) bool {
|
||||
if len(folder.ExcludePeers) > 0 || folder.Contacts || folder.NonContacts || folder.Groups ||
|
||||
folder.Broadcasts || folder.Bots || folder.ExcludeMuted || folder.ExcludeRead || folder.ExcludeArchived {
|
||||
return false
|
||||
}
|
||||
return len(folder.IncludePeers)+len(folder.PinnedPeers) > 0
|
||||
}
|
||||
|
||||
func (s *Service) validateShareableChannelPeers(ctx context.Context, userID int64, peers []domain.DialogFolderPeer) error {
|
||||
if len(peers) == 0 {
|
||||
return domain.ErrChatlistPeersEmpty
|
||||
}
|
||||
for _, item := range peers {
|
||||
if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 {
|
||||
return domain.ErrChatlistNotShareable
|
||||
}
|
||||
if s.channels == nil {
|
||||
continue
|
||||
}
|
||||
view, err := s.channels.GetChannel(ctx, userID, item.Peer.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !channelShareableForChatlist(view) {
|
||||
return domain.ErrChatlistNotShareable
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func channelShareableForChatlist(view domain.ChannelView) bool {
|
||||
if view.Channel.Deleted || view.Forbidden {
|
||||
return false
|
||||
}
|
||||
return channelCanInviteForChatlist(view) || channelPublicJoinableForChatlist(view)
|
||||
}
|
||||
|
||||
func channelCanInviteForChatlist(view domain.ChannelView) bool {
|
||||
switch view.Self.Role {
|
||||
case domain.ChannelRoleCreator:
|
||||
return true
|
||||
case domain.ChannelRoleAdmin:
|
||||
if view.Self.AdminRights.InviteUsers {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func channelPublicJoinableForChatlist(view domain.ChannelView) bool {
|
||||
if view.Channel.Deleted || view.Forbidden {
|
||||
return false
|
||||
}
|
||||
return view.Channel.Username != "" && !view.Channel.JoinRequest
|
||||
}
|
||||
|
||||
func (s *Service) joinChannelPeers(ctx context.Context, ownerUserID, userID int64, peers []domain.DialogFolderPeer, date int) ([]domain.CreateChannelResult, error) {
|
||||
if s.channels == nil {
|
||||
return nil, nil
|
||||
}
|
||||
type joinPlan struct {
|
||||
peer domain.DialogFolderPeer
|
||||
useInvite bool
|
||||
}
|
||||
channelPeers := channelFolderPeers(peers)
|
||||
plans := make([]joinPlan, 0, len(channelPeers))
|
||||
for _, item := range channelPeers {
|
||||
view, err := s.channels.GetChannel(ctx, ownerUserID, item.Peer.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !channelShareableForChatlist(view) {
|
||||
return nil, domain.ErrChatlistNotShareable
|
||||
}
|
||||
plans = append(plans, joinPlan{peer: item, useInvite: channelCanInviteForChatlist(view)})
|
||||
}
|
||||
results := make([]domain.CreateChannelResult, 0, len(plans))
|
||||
for _, plan := range plans {
|
||||
var res domain.CreateChannelResult
|
||||
var err error
|
||||
if plan.useInvite {
|
||||
res, err = s.channels.InviteToChannel(ctx, ownerUserID, plan.peer.Peer.ID, []int64{userID}, date)
|
||||
} else {
|
||||
res, err = s.channels.JoinChannel(ctx, userID, plan.peer.Peer.ID, date)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrUserAlreadyParticipant) {
|
||||
continue
|
||||
}
|
||||
return results, err
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *Service) leaveChannelPeers(ctx context.Context, userID int64, peers []domain.DialogFolderPeer, date int) ([]domain.CreateChannelResult, error) {
|
||||
if s.channels == nil {
|
||||
return nil, nil
|
||||
}
|
||||
results := make([]domain.CreateChannelResult, 0)
|
||||
for _, item := range channelFolderPeers(peers) {
|
||||
res, err := s.channels.LeaveChannel(ctx, userID, item.Peer.ID, date)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrUserNotParticipant) || errors.Is(err, domain.ErrChannelPrivate) {
|
||||
continue
|
||||
}
|
||||
return results, err
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *Service) leaveJoinedChannelResults(ctx context.Context, userID int64, results []domain.CreateChannelResult, date int) error {
|
||||
if s.channels == nil || len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
var joinedErr error
|
||||
for _, res := range results {
|
||||
if res.Channel.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := s.channels.LeaveChannel(ctx, userID, res.Channel.ID, date); err != nil &&
|
||||
!errors.Is(err, domain.ErrUserNotParticipant) &&
|
||||
!errors.Is(err, domain.ErrChannelPrivate) {
|
||||
joinedErr = errors.Join(joinedErr, err)
|
||||
}
|
||||
}
|
||||
return joinedErr
|
||||
}
|
||||
|
||||
func (s *Service) deleteInviteIfSaved(ctx context.Context, ownerUserID int64, filterID int, slug string) error {
|
||||
if slug == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.chatlists.DeleteInvite(ctx, ownerUserID, filterID, slug); err != nil {
|
||||
return fmt.Errorf("rollback chatlist invite: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) invitesLimit(ctx context.Context, userID int64) int {
|
||||
if s != nil && s.premium != nil && s.premium(ctx, userID) {
|
||||
return domain.MaxChatlistInvitesPremium
|
||||
}
|
||||
return domain.MaxChatlistInvitesDefault
|
||||
}
|
||||
|
||||
func (s *Service) joinedLimit(ctx context.Context, userID int64) int {
|
||||
if s != nil && s.premium != nil && s.premium(ctx, userID) {
|
||||
return domain.MaxChatlistsJoinedPremium
|
||||
}
|
||||
return domain.MaxChatlistsJoinedDefault
|
||||
}
|
||||
|
||||
func exportedChatlistFolder(folder domain.DialogFolder, hasMyInvites bool) domain.DialogFolder {
|
||||
folder.Contacts = false
|
||||
folder.NonContacts = false
|
||||
folder.Groups = false
|
||||
folder.Broadcasts = false
|
||||
folder.Bots = false
|
||||
folder.ExcludeMuted = false
|
||||
folder.ExcludeRead = false
|
||||
folder.ExcludeArchived = false
|
||||
folder.ExcludePeers = nil
|
||||
folder.IsChatlist = true
|
||||
folder.HasMyInvites = hasMyInvites
|
||||
return cloneDialogFolder(folder)
|
||||
}
|
||||
|
||||
func importedChatlistFolder(source domain.DialogFolder, filterID int, peers []domain.DialogFolderPeer) domain.DialogFolder {
|
||||
source = exportedChatlistFolder(source, false)
|
||||
source.ID = filterID
|
||||
source.PinnedPeers = nil
|
||||
source.IncludePeers = cloneFolderPeers(peers)
|
||||
source.HasMyInvites = false
|
||||
return source
|
||||
}
|
||||
|
||||
func selectChatlistPeers(folder domain.DialogFolder, requested []domain.DialogFolderPeer, requireNonEmpty bool) ([]domain.DialogFolderPeer, error) {
|
||||
allowed := folderPeerMap(folderPeers(folder))
|
||||
return selectPeersFromAllowed(allowed, requested, requireNonEmpty)
|
||||
}
|
||||
|
||||
func selectInvitePeers(invite domain.ChatlistInvite, requested []domain.DialogFolderPeer, requireNonEmpty bool) ([]domain.DialogFolderPeer, error) {
|
||||
if len(requested) == 0 {
|
||||
requested = invite.Peers
|
||||
}
|
||||
return selectPeersFromAllowed(folderPeerMap(invite.Peers), requested, requireNonEmpty)
|
||||
}
|
||||
|
||||
func selectPeersFromAllowed(allowed map[domain.Peer]domain.DialogFolderPeer, requested []domain.DialogFolderPeer, requireNonEmpty bool) ([]domain.DialogFolderPeer, error) {
|
||||
if len(requested) > domain.MaxChatlistInvitePeers {
|
||||
return nil, domain.ErrChatlistPeersTooMuch
|
||||
}
|
||||
out := make([]domain.DialogFolderPeer, 0, len(requested))
|
||||
seen := make(map[domain.Peer]struct{}, len(requested))
|
||||
for _, item := range requested {
|
||||
if item.Peer.Type == "" || item.Peer.ID == 0 {
|
||||
return nil, domain.ErrChatlistInvalid
|
||||
}
|
||||
allowedPeer, ok := allowed[item.Peer]
|
||||
if !ok {
|
||||
return nil, domain.ErrChatlistInvalid
|
||||
}
|
||||
if _, ok := seen[item.Peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.Peer] = struct{}{}
|
||||
if item.AccessHash == 0 {
|
||||
item.AccessHash = allowedPeer.AccessHash
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if requireNonEmpty && len(out) == 0 {
|
||||
return nil, domain.ErrChatlistPeersEmpty
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func channelFolderPeers(peers []domain.DialogFolderPeer) []domain.DialogFolderPeer {
|
||||
out := make([]domain.DialogFolderPeer, 0, len(peers))
|
||||
for _, item := range peers {
|
||||
if item.Peer.Type == domain.PeerTypeChannel && item.Peer.ID != 0 {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func folderPeers(folder domain.DialogFolder) []domain.DialogFolderPeer {
|
||||
return mergeFolderPeers(folder.PinnedPeers, folder.IncludePeers)
|
||||
}
|
||||
|
||||
func folderPeerMap(peers []domain.DialogFolderPeer) map[domain.Peer]domain.DialogFolderPeer {
|
||||
out := make(map[domain.Peer]domain.DialogFolderPeer, len(peers))
|
||||
for _, item := range peers {
|
||||
if item.Peer.Type == "" || item.Peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := out[item.Peer]; !ok {
|
||||
out[item.Peer] = item
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeFolderPeers(a, b []domain.DialogFolderPeer) []domain.DialogFolderPeer {
|
||||
out := make([]domain.DialogFolderPeer, 0, len(a)+len(b))
|
||||
seen := make(map[domain.Peer]struct{}, len(a)+len(b))
|
||||
for _, list := range [][]domain.DialogFolderPeer{a, b} {
|
||||
for _, item := range list {
|
||||
if item.Peer.Type == "" || item.Peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item.Peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.Peer] = struct{}{}
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func peersDifference(all, existing []domain.DialogFolderPeer) []domain.DialogFolderPeer {
|
||||
existingMap := folderPeerMap(existing)
|
||||
out := make([]domain.DialogFolderPeer, 0)
|
||||
for _, item := range all {
|
||||
if _, ok := existingMap[item.Peer]; !ok {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func peersIntersection(all, existing []domain.DialogFolderPeer) []domain.DialogFolderPeer {
|
||||
existingMap := folderPeerMap(existing)
|
||||
out := make([]domain.DialogFolderPeer, 0)
|
||||
for _, item := range all {
|
||||
if _, ok := existingMap[item.Peer]; ok {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDialogFolder(folder domain.DialogFolder) domain.DialogFolder {
|
||||
folder.TitleEntities = append([]domain.MessageEntity(nil), folder.TitleEntities...)
|
||||
folder.PinnedPeers = cloneFolderPeers(folder.PinnedPeers)
|
||||
folder.IncludePeers = cloneFolderPeers(folder.IncludePeers)
|
||||
folder.ExcludePeers = cloneFolderPeers(folder.ExcludePeers)
|
||||
return folder
|
||||
}
|
||||
|
||||
func cloneFolderPeers(peers []domain.DialogFolderPeer) []domain.DialogFolderPeer {
|
||||
return append([]domain.DialogFolderPeer(nil), peers...)
|
||||
}
|
||||
|
||||
func CleanSlug(raw string) string {
|
||||
return links.CleanChatlistSlug(raw)
|
||||
}
|
||||
|
||||
func randomChatlistSlug() (string, error) {
|
||||
var b [12]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("chatlist slug rand: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
488
internal/app/chatlists/service_test.go
Normal file
488
internal/app/chatlists/service_test.go
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
package chatlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestSharedFolderInviteJoinUpdatesAndLeave(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := memory.NewDialogStore()
|
||||
chatlists := memory.NewChatlistStore()
|
||||
svc := NewService(chatlists, dialogs, WithSlugGenerator(func() (string, error) { return "slug-one", nil }))
|
||||
|
||||
ownerID := int64(1001)
|
||||
viewerID := int64(2002)
|
||||
peerA := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
|
||||
peerB := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3002}, AccessHash: 32}
|
||||
peerC := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3003}, AccessHash: 33}
|
||||
|
||||
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||
ID: 2,
|
||||
Title: "Team",
|
||||
IncludePeers: []domain.DialogFolderPeer{peerA},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed owner folder: %v", err)
|
||||
}
|
||||
|
||||
folder, invite, err := svc.ExportInvite(ctx, ownerID, 2, "Main link", []domain.DialogFolderPeer{peerA}, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportInvite: %v", err)
|
||||
}
|
||||
if invite.Slug != "slug-one" || !folder.IsChatlist || !folder.HasMyInvites {
|
||||
t.Fatalf("export = folder %+v invite %+v, want chatlist with invite slug", folder, invite)
|
||||
}
|
||||
persisted, found, err := dialogs.GetFolder(ctx, ownerID, 2)
|
||||
if err != nil || !found || !persisted.IsChatlist || !persisted.HasMyInvites {
|
||||
t.Fatalf("persisted folder = %+v found %v err %v, want exported chatlist", persisted, found, err)
|
||||
}
|
||||
|
||||
preview, err := svc.CheckInvite(ctx, viewerID, "https://t.me/addlist/slug-one")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckInvite before join: %v", err)
|
||||
}
|
||||
if len(preview.Missing) != 1 || preview.Missing[0].Peer != peerA.Peer || preview.LocalFolder != nil {
|
||||
t.Fatalf("preview before join = %+v, want missing peerA and no local folder", preview)
|
||||
}
|
||||
|
||||
joined, err := svc.JoinInvite(ctx, viewerID, "slug-one", nil, 101)
|
||||
if err != nil {
|
||||
t.Fatalf("JoinInvite: %v", err)
|
||||
}
|
||||
if joined.Folder.ID != 2 || !joined.Folder.IsChatlist || joined.Folder.HasMyInvites || len(joined.Folder.IncludePeers) != 1 {
|
||||
t.Fatalf("joined folder = %+v, want imported local chatlist with peerA", joined.Folder)
|
||||
}
|
||||
|
||||
preview, err = svc.CheckInvite(ctx, viewerID, "slug-one")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckInvite after join: %v", err)
|
||||
}
|
||||
if preview.LocalFolder == nil || preview.Membership == nil || len(preview.Already) != 1 || len(preview.Missing) != 0 {
|
||||
t.Fatalf("preview after join = %+v, want already imported", preview)
|
||||
}
|
||||
|
||||
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||
ID: 2,
|
||||
Title: "Team",
|
||||
IsChatlist: true,
|
||||
HasMyInvites: true,
|
||||
IncludePeers: []domain.DialogFolderPeer{peerA, peerB},
|
||||
}); err != nil {
|
||||
t.Fatalf("extend owner folder: %v", err)
|
||||
}
|
||||
if _, err := svc.EditInvite(ctx, ownerID, 2, "slug-one", nil, &[]domain.DialogFolderPeer{peerA, peerB}, false); err != nil {
|
||||
t.Fatalf("EditInvite add peerB: %v", err)
|
||||
}
|
||||
updates, err := svc.GetUpdates(ctx, viewerID, joined.Folder.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUpdates: %v", err)
|
||||
}
|
||||
if len(updates.Missing) != 1 || updates.Missing[0].Peer != peerB.Peer {
|
||||
t.Fatalf("updates = %+v, want missing peerB", updates)
|
||||
}
|
||||
ownerUpdates, err := svc.GetUpdates(ctx, ownerID, folder.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUpdates owner exported folder: %v", err)
|
||||
}
|
||||
if len(ownerUpdates.Missing) != 0 {
|
||||
t.Fatalf("owner updates = %+v, want empty", ownerUpdates)
|
||||
}
|
||||
updated, err := svc.JoinUpdates(ctx, viewerID, joined.Folder.ID, []domain.DialogFolderPeer{peerB}, 102)
|
||||
if err != nil {
|
||||
t.Fatalf("JoinUpdates: %v", err)
|
||||
}
|
||||
if len(updated.Folder.IncludePeers) != 2 {
|
||||
t.Fatalf("updated folder peers = %+v, want 2 peers", updated.Folder.IncludePeers)
|
||||
}
|
||||
|
||||
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||
ID: 2,
|
||||
Title: "Team",
|
||||
IsChatlist: true,
|
||||
HasMyInvites: true,
|
||||
IncludePeers: []domain.DialogFolderPeer{peerA, peerB, peerC},
|
||||
}); err != nil {
|
||||
t.Fatalf("extend owner folder with peerC: %v", err)
|
||||
}
|
||||
if _, err := svc.EditInvite(ctx, ownerID, 2, "slug-one", nil, &[]domain.DialogFolderPeer{peerA, peerB, peerC}, false); err != nil {
|
||||
t.Fatalf("EditInvite add peerC: %v", err)
|
||||
}
|
||||
if err := svc.HideUpdates(ctx, viewerID, joined.Folder.ID); err != nil {
|
||||
t.Fatalf("HideUpdates: %v", err)
|
||||
}
|
||||
updates, err = svc.GetUpdates(ctx, viewerID, joined.Folder.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUpdates after hide: %v", err)
|
||||
}
|
||||
if len(updates.Missing) != 0 {
|
||||
t.Fatalf("updates after hide = %+v, want empty", updates)
|
||||
}
|
||||
|
||||
suggestions, err := svc.LeaveSuggestions(ctx, viewerID, joined.Folder.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("LeaveSuggestions: %v", err)
|
||||
}
|
||||
if len(suggestions) != 2 {
|
||||
t.Fatalf("leave suggestions = %+v, want current two imported peers", suggestions)
|
||||
}
|
||||
if _, err := svc.Leave(ctx, viewerID, joined.Folder.ID, suggestions, 103); err != nil {
|
||||
t.Fatalf("Leave: %v", err)
|
||||
}
|
||||
if _, found, err := dialogs.GetFolder(ctx, viewerID, joined.Folder.ID); err != nil || found {
|
||||
t.Fatalf("local folder after leave found=%v err=%v, want deleted", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteLastSharedFolderInviteClearsOwnerFlag(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := memory.NewDialogStore()
|
||||
chatlists := memory.NewChatlistStore()
|
||||
slugs := []string{"slug-a", "slug-b"}
|
||||
svc := NewService(chatlists, dialogs, WithSlugGenerator(func() (string, error) {
|
||||
slug := slugs[0]
|
||||
slugs = slugs[1:]
|
||||
return slug, nil
|
||||
}))
|
||||
|
||||
ownerID := int64(1001)
|
||||
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
|
||||
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||
ID: 2,
|
||||
Title: "Team",
|
||||
IncludePeers: []domain.DialogFolderPeer{peer},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed owner folder: %v", err)
|
||||
}
|
||||
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "one", []domain.DialogFolderPeer{peer}, 100); err != nil {
|
||||
t.Fatalf("ExportInvite one: %v", err)
|
||||
}
|
||||
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "two", []domain.DialogFolderPeer{peer}, 101); err != nil {
|
||||
t.Fatalf("ExportInvite two: %v", err)
|
||||
}
|
||||
if folder, changed, err := svc.DeleteInvite(ctx, ownerID, 2, "slug-a"); err != nil || changed || folder.HasMyInvites {
|
||||
t.Fatalf("DeleteInvite first = folder %+v changed %v err %v, want no folder update", folder, changed, err)
|
||||
}
|
||||
persisted, found, err := dialogs.GetFolder(ctx, ownerID, 2)
|
||||
if err != nil || !found || !persisted.HasMyInvites {
|
||||
t.Fatalf("folder after first delete = %+v found %v err %v, want has_my_invites", persisted, found, err)
|
||||
}
|
||||
folder, changed, err := svc.DeleteInvite(ctx, ownerID, 2, "slug-b")
|
||||
if err != nil || !changed || folder.HasMyInvites {
|
||||
t.Fatalf("DeleteInvite last = folder %+v changed %v err %v, want cleared flag", folder, changed, err)
|
||||
}
|
||||
persisted, found, err = dialogs.GetFolder(ctx, ownerID, 2)
|
||||
if err != nil || !found || persisted.HasMyInvites {
|
||||
t.Fatalf("folder after last delete = %+v found %v err %v, want has_my_invites=false", persisted, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedFolderSlugValidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(memory.NewChatlistStore(), memory.NewDialogStore())
|
||||
if _, err := svc.CheckInvite(ctx, 2002, "bad!"); !errors.Is(err, domain.ErrChatlistInviteInvalid) {
|
||||
t.Fatalf("CheckInvite bad slug err = %v, want ErrChatlistInviteInvalid", err)
|
||||
}
|
||||
longSlug := strings.Repeat("a", links.MaxChatlistSlugBytes+1)
|
||||
if _, err := svc.JoinInvite(ctx, 2002, longSlug, nil, 100); !errors.Is(err, domain.ErrChatlistInviteInvalid) {
|
||||
t.Fatalf("JoinInvite long slug err = %v, want ErrChatlistInviteInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokedSharedFolderInviteRemainsListedButCannotBeImported(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := memory.NewDialogStore()
|
||||
chatlists := memory.NewChatlistStore()
|
||||
svc := NewService(chatlists, dialogs, WithSlugGenerator(func() (string, error) { return "slug-revoke", nil }))
|
||||
|
||||
ownerID := int64(1001)
|
||||
viewerID := int64(2002)
|
||||
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
|
||||
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||
ID: 2,
|
||||
Title: "Team",
|
||||
IncludePeers: []domain.DialogFolderPeer{peer},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed owner folder: %v", err)
|
||||
}
|
||||
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "Main link", []domain.DialogFolderPeer{peer}, 100); err != nil {
|
||||
t.Fatalf("ExportInvite: %v", err)
|
||||
}
|
||||
revoked, err := svc.EditInvite(ctx, ownerID, 2, "slug-revoke", nil, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("EditInvite revoke: %v", err)
|
||||
}
|
||||
if !revoked.Revoked {
|
||||
t.Fatalf("revoked invite = %+v, want Revoked=true", revoked)
|
||||
}
|
||||
invites, err := svc.ListInvites(ctx, ownerID, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("ListInvites: %v", err)
|
||||
}
|
||||
if len(invites) != 1 || !invites[0].Revoked {
|
||||
t.Fatalf("listed invites = %+v, want revoked invite visible to owner", invites)
|
||||
}
|
||||
if _, err := svc.CheckInvite(ctx, viewerID, "slug-revoke"); !errors.Is(err, domain.ErrChatlistInviteExpired) {
|
||||
t.Fatalf("CheckInvite revoked err = %v, want ErrChatlistInviteExpired", err)
|
||||
}
|
||||
if _, err := svc.JoinInvite(ctx, viewerID, "slug-revoke", nil, 101); !errors.Is(err, domain.ErrChatlistInviteExpired) {
|
||||
t.Fatalf("JoinInvite revoked err = %v, want ErrChatlistInviteExpired", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedFolderJoinUpdatesAndLeaveUseChannelMemberships(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := memory.NewDialogStore()
|
||||
channels := &fakeChatlistChannels{}
|
||||
svc := NewService(
|
||||
memory.NewChatlistStore(),
|
||||
dialogs,
|
||||
WithChannels(channels),
|
||||
WithSlugGenerator(func() (string, error) { return "slug-channel", nil }),
|
||||
)
|
||||
|
||||
ownerID := int64(1001)
|
||||
viewerID := int64(2002)
|
||||
peerA := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
|
||||
peerB := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3002}, AccessHash: 32}
|
||||
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||
ID: 2,
|
||||
Title: "Team",
|
||||
IncludePeers: []domain.DialogFolderPeer{peerA, peerB},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed owner folder: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "Main link", []domain.DialogFolderPeer{peerA, peerB}, 100); err != nil {
|
||||
t.Fatalf("ExportInvite: %v", err)
|
||||
}
|
||||
if got := channels.getIDs; len(got) != 2 || got[0] != peerA.Peer.ID || got[1] != peerB.Peer.ID {
|
||||
t.Fatalf("shareability channel checks = %v, want peerA/peerB", got)
|
||||
}
|
||||
|
||||
joined, err := svc.JoinInvite(ctx, viewerID, "slug-channel", []domain.DialogFolderPeer{peerA}, 101)
|
||||
if err != nil {
|
||||
t.Fatalf("JoinInvite peerA: %v", err)
|
||||
}
|
||||
if got := channels.inviteIDs; len(got) != 1 || got[0] != peerA.Peer.ID {
|
||||
t.Fatalf("invite channel calls = %v, want peerA", got)
|
||||
}
|
||||
if len(joined.ChannelResults) != 1 || joined.ChannelResults[0].Channel.ID != peerA.Peer.ID {
|
||||
t.Fatalf("join channel results = %+v, want peerA result", joined.ChannelResults)
|
||||
}
|
||||
|
||||
updated, err := svc.JoinUpdates(ctx, viewerID, joined.Folder.ID, []domain.DialogFolderPeer{peerB}, 102)
|
||||
if err != nil {
|
||||
t.Fatalf("JoinUpdates peerB: %v", err)
|
||||
}
|
||||
if got := channels.inviteIDs; len(got) != 2 || got[1] != peerB.Peer.ID {
|
||||
t.Fatalf("invite channel calls after updates = %v, want peerB appended", got)
|
||||
}
|
||||
if len(updated.ChannelResults) != 1 || updated.ChannelResults[0].Channel.ID != peerB.Peer.ID {
|
||||
t.Fatalf("update channel results = %+v, want peerB result", updated.ChannelResults)
|
||||
}
|
||||
|
||||
leave, err := svc.Leave(ctx, viewerID, joined.Folder.ID, []domain.DialogFolderPeer{peerA}, 103)
|
||||
if err != nil {
|
||||
t.Fatalf("Leave peerA: %v", err)
|
||||
}
|
||||
if got := channels.leaveIDs; len(got) != 1 || got[0] != peerA.Peer.ID {
|
||||
t.Fatalf("leave channel calls = %v, want peerA", got)
|
||||
}
|
||||
if len(leave.ChannelResults) != 1 || leave.ChannelResults[0].Channel.ID != peerA.Peer.ID {
|
||||
t.Fatalf("leave channel results = %+v, want peerA result", leave.ChannelResults)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedFolderPublicPeerFallsBackToSelfJoin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := memory.NewDialogStore()
|
||||
channels := &fakeChatlistChannels{publicOnly: true}
|
||||
svc := NewService(
|
||||
memory.NewChatlistStore(),
|
||||
dialogs,
|
||||
WithChannels(channels),
|
||||
WithSlugGenerator(func() (string, error) { return "slug-public", nil }),
|
||||
)
|
||||
|
||||
ownerID := int64(1001)
|
||||
viewerID := int64(2002)
|
||||
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
|
||||
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||
ID: 2,
|
||||
Title: "Public",
|
||||
IncludePeers: []domain.DialogFolderPeer{peer},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed owner folder: %v", err)
|
||||
}
|
||||
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 100); err != nil {
|
||||
t.Fatalf("ExportInvite: %v", err)
|
||||
}
|
||||
if _, err := svc.JoinInvite(ctx, viewerID, "slug-public", []domain.DialogFolderPeer{peer}, 101); err != nil {
|
||||
t.Fatalf("JoinInvite public peer: %v", err)
|
||||
}
|
||||
if len(channels.inviteIDs) != 0 {
|
||||
t.Fatalf("invite channel calls = %v, want none for public-only owner", channels.inviteIDs)
|
||||
}
|
||||
if got := channels.joinIDs; len(got) != 1 || got[0] != peer.Peer.ID {
|
||||
t.Fatalf("join channel calls = %v, want self-join peer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportInviteRejectsRuleBasedFolder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := memory.NewDialogStore()
|
||||
svc := NewService(memory.NewChatlistStore(), dialogs, WithSlugGenerator(func() (string, error) { return "slug-two", nil }))
|
||||
ownerID := int64(1001)
|
||||
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
|
||||
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||
ID: 2,
|
||||
Title: "Rule based",
|
||||
Groups: true,
|
||||
IncludePeers: []domain.DialogFolderPeer{peer},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed folder: %v", err)
|
||||
}
|
||||
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 0); !errors.Is(err, domain.ErrChatlistNotShareable) {
|
||||
t.Fatalf("ExportInvite rule folder err = %v, want ErrChatlistNotShareable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportInviteRejectsUserPeers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := memory.NewDialogStore()
|
||||
svc := NewService(memory.NewChatlistStore(), dialogs, WithSlugGenerator(func() (string, error) { return "slug-user-peer", nil }))
|
||||
ownerID := int64(1001)
|
||||
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2002}, AccessHash: 22}
|
||||
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||
ID: 2,
|
||||
Title: "Users",
|
||||
IncludePeers: []domain.DialogFolderPeer{peer},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed folder: %v", err)
|
||||
}
|
||||
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 0); !errors.Is(err, domain.ErrChatlistNotShareable) {
|
||||
t.Fatalf("ExportInvite user peer err = %v, want ErrChatlistNotShareable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatlistInviteLimitUsesPremiumTier(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := memory.NewDialogStore()
|
||||
chatlists := memory.NewChatlistStore()
|
||||
ownerID := int64(1001)
|
||||
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
|
||||
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||
ID: 2,
|
||||
Title: "Team",
|
||||
IncludePeers: []domain.DialogFolderPeer{peer},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed folder: %v", err)
|
||||
}
|
||||
|
||||
nextSlug := 0
|
||||
slugger := func() (string, error) {
|
||||
nextSlug++
|
||||
return "slug-limit-" + string(rune('a'+nextSlug)), nil
|
||||
}
|
||||
svc := NewService(chatlists, dialogs, WithSlugGenerator(slugger))
|
||||
for i := 0; i < domain.MaxChatlistInvitesDefault; i++ {
|
||||
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, i+1); err != nil {
|
||||
t.Fatalf("ExportInvite default #%d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 10); !errors.Is(err, domain.ErrChatlistInvitesTooMuch) {
|
||||
t.Fatalf("ExportInvite over default limit err = %v, want ErrChatlistInvitesTooMuch", err)
|
||||
}
|
||||
if _, err := svc.EditInvite(ctx, ownerID, 2, "slug-limit-b", nil, nil, true); err != nil {
|
||||
t.Fatalf("EditInvite revoke first default link: %v", err)
|
||||
}
|
||||
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 11); err != nil {
|
||||
t.Fatalf("ExportInvite after revoked link freed active limit: %v", err)
|
||||
}
|
||||
|
||||
premiumSvc := NewService(
|
||||
chatlists,
|
||||
dialogs,
|
||||
WithSlugGenerator(slugger),
|
||||
WithPremiumChecker(func(context.Context, int64) bool { return true }),
|
||||
)
|
||||
if _, _, err := premiumSvc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 11); err != nil {
|
||||
t.Fatalf("ExportInvite premium extra link: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeChatlistChannels struct {
|
||||
getIDs []int64
|
||||
inviteIDs []int64
|
||||
joinIDs []int64
|
||||
leaveIDs []int64
|
||||
publicOnly bool
|
||||
}
|
||||
|
||||
func (f *fakeChatlistChannels) GetChannel(_ context.Context, userID, channelID int64) (domain.ChannelView, error) {
|
||||
f.getIDs = append(f.getIDs, channelID)
|
||||
role := domain.ChannelRoleCreator
|
||||
if f.publicOnly {
|
||||
role = domain.ChannelRoleMember
|
||||
}
|
||||
return domain.ChannelView{
|
||||
Channel: domain.Channel{
|
||||
ID: channelID,
|
||||
AccessHash: channelID * 10,
|
||||
Title: "Team",
|
||||
Username: "team",
|
||||
Megagroup: true,
|
||||
},
|
||||
Self: domain.ChannelMember{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
Status: domain.ChannelMemberActive,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeChatlistChannels) InviteToChannel(_ context.Context, userID, channelID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) {
|
||||
f.inviteIDs = append(f.inviteIDs, channelID)
|
||||
if len(userIDs) == 0 {
|
||||
return domain.CreateChannelResult{}, domain.ErrUsersTooMuch
|
||||
}
|
||||
return fakeChatlistChannelResult(userIDs[0], channelID, domain.ChannelMemberActive, date), nil
|
||||
}
|
||||
|
||||
func (f *fakeChatlistChannels) JoinChannel(_ context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) {
|
||||
f.joinIDs = append(f.joinIDs, channelID)
|
||||
return fakeChatlistChannelResult(userID, channelID, domain.ChannelMemberActive, date), nil
|
||||
}
|
||||
|
||||
func (f *fakeChatlistChannels) LeaveChannel(_ context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) {
|
||||
f.leaveIDs = append(f.leaveIDs, channelID)
|
||||
return fakeChatlistChannelResult(userID, channelID, domain.ChannelMemberLeft, date), nil
|
||||
}
|
||||
|
||||
func fakeChatlistChannelResult(userID, channelID int64, status domain.ChannelMemberStatus, date int) domain.CreateChannelResult {
|
||||
return domain.CreateChannelResult{
|
||||
Channel: domain.Channel{
|
||||
ID: channelID,
|
||||
AccessHash: channelID * 10,
|
||||
Title: "Team",
|
||||
Username: "team",
|
||||
Megagroup: true,
|
||||
Date: date,
|
||||
},
|
||||
Members: []domain.ChannelMember{{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Role: domain.ChannelRoleMember,
|
||||
Status: status,
|
||||
JoinedAt: date,
|
||||
}},
|
||||
Recipients: []int64{userID},
|
||||
}
|
||||
}
|
||||
|
|
@ -10,21 +10,36 @@ import (
|
|||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service 是群通话业务服务。
|
||||
type Service struct {
|
||||
store store.GroupCallStore
|
||||
store store.GroupCallStore
|
||||
publicBaseURL string
|
||||
}
|
||||
|
||||
type Option func(*Service)
|
||||
|
||||
func WithPublicBaseURL(baseURL string) Option {
|
||||
return func(s *Service) {
|
||||
s.publicBaseURL = links.NormalizeBaseURL(baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建群通话服务。
|
||||
func NewService(st store.GroupCallStore) *Service {
|
||||
return &Service{store: st}
|
||||
func NewService(st store.GroupCallStore, opts ...Option) *Service {
|
||||
s := &Service{store: st, publicBaseURL: links.DefaultPublicBaseURL}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Create 分配 id/access_hash 并建会。rtmpStream=true 创建 RTMP 直播房间;
|
||||
|
|
@ -135,7 +150,7 @@ func (s *Service) CreateConference(ctx context.Context, creatorUserID, randomID,
|
|||
Version: 1,
|
||||
CreatedAt: now,
|
||||
InviteSlug: slug,
|
||||
InviteLink: conferenceInviteLink(slug),
|
||||
InviteLink: s.conferenceInviteLink(slug),
|
||||
RandomID: randomID,
|
||||
MigratedFromPhoneCallID: migratedFromPhoneCallID,
|
||||
})
|
||||
|
|
@ -265,6 +280,10 @@ func randomSlug() (string, error) {
|
|||
return base64.RawURLEncoding.EncodeToString(buf[:]), nil
|
||||
}
|
||||
|
||||
func conferenceInviteLink(slug string) string {
|
||||
return "https://telesrv.net/call/" + slug + "?slug=" + slug
|
||||
func (s *Service) conferenceInviteLink(slug string) string {
|
||||
baseURL := links.DefaultPublicBaseURL
|
||||
if s != nil && s.publicBaseURL != "" {
|
||||
baseURL = s.publicBaseURL
|
||||
}
|
||||
return links.Build(baseURL, "call/"+slug, url.Values{"slug": []string{slug}})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,14 +41,15 @@ const tdesktopClient = "tdesktop"
|
|||
// dialogs_folder_pinned 有服务端 enforcement 双档,其余(channels/saved_gifs/
|
||||
// stickers_faved/dialog_filters/caption/fileparts 等)服务端为宽兜底或未 enforce,
|
||||
// 客户端按 self premium flag 自限。bots_create_limit 故意不下发(服务端统一 20,
|
||||
// 见 compatibility-matrix todo);chatlists 和 story 配额/商业化 key 不下发
|
||||
// 见 compatibility-matrix todo);chatlist_update_period 与 chatlist 双档限额
|
||||
// 对齐 shared folders 最小真实实现。story 配额/商业化 key 不下发
|
||||
// (功能全族未实现,下发会诱导客户端走进未实现路径)。stories_stealth_* 是客户端
|
||||
// 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。
|
||||
// - aicompose_tone_* 与 domain/app/ai 默认值一致:TDesktop/DrKLO 创建/预览 tone 时
|
||||
// 直接读取这些 key 做本地输入限制和示例数量。
|
||||
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
|
||||
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
|
||||
|
||||
const defaultAppConfigHash = 20 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
const defaultAppConfigHash = 21 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
|
||||
// Service 提供客户端启动配置与国家区号目录。
|
||||
//
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@ func TestAppConfigPremiumKeys(t *testing.T) {
|
|||
"channels_limit_premium": 1000,
|
||||
"dialog_filters_limit_default": 10,
|
||||
"dialog_filters_limit_premium": 20,
|
||||
"chatlist_update_period": 3600,
|
||||
"chatlist_invites_limit_default": 3,
|
||||
"chatlist_invites_limit_premium": 20,
|
||||
"chatlists_joined_limit_default": 2,
|
||||
"chatlists_joined_limit_premium": 20,
|
||||
"upload_max_fileparts_default": 4000,
|
||||
"upload_max_fileparts_premium": 8000,
|
||||
"aicompose_tone_examples_num": 3,
|
||||
|
|
@ -68,7 +73,7 @@ func TestAppConfigPremiumKeys(t *testing.T) {
|
|||
}
|
||||
}
|
||||
// 未实现功能族的 key 不得下发(诱导客户端进入未实现路径)。
|
||||
for _, forbidden := range []string{"chatlists_joined_limit_default", "stories_sent_weekly_limit_default", "premium_bot_username", "premium_invoice_slug"} {
|
||||
for _, forbidden := range []string{"stories_sent_weekly_limit_default", "premium_bot_username", "premium_invoice_slug"} {
|
||||
if _, ok := decoded[forbidden]; ok {
|
||||
t.Errorf("appConfig 不应包含 %q", forbidden)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue