feat: sync chatlist sharing support

This commit is contained in:
A 2026-07-06 21:06:33 +08:00
parent ec6e8fd13d
commit 2a9c12263f
62 changed files with 3408 additions and 212 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

@ -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 todochatlists 和 story 配额/商业化 key 不下发
// 见 compatibility-matrix todochatlist_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 提供客户端启动配置与国家区号目录。
//

View file

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

View file

@ -4,13 +4,16 @@ import (
"time"
"github.com/gotd/td/tg"
"telesrv/internal/links"
)
// BuildConfig 构造 help.getConfig 返回的 tg.Config含自建 DC 的 DCOptions。
//
// 字段值取 Telegram 常见默认TDesktop 联调阶段按客户端实际需要微调
// (记录于 docs/compatibility-matrix.md
func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config {
func BuildConfig(dc int, ip string, port int, now time.Time, publicBaseURL string) *tg.Config {
meURLPrefix := links.NormalizeBaseURL(publicBaseURL) + "/"
return &tg.Config{
Date: int(now.Unix()),
Expires: int(now.Add(time.Hour).Unix()),
@ -47,7 +50,7 @@ func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config {
CallRingTimeoutMs: 90000,
CallConnectTimeoutMs: 30000,
CallPacketTimeoutMs: 10000,
MeURLPrefix: "https://telesrv.net/",
MeURLPrefix: meURLPrefix,
CaptionLengthMax: 1024,
MessageLengthMax: 4096,
WebfileDCID: dc,

View file

@ -7,7 +7,7 @@ import (
)
const (
appConfigHash = 15 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。
appConfigHash = 16 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。
countriesListHash = 1
timezonesListHash = 1
)
@ -44,6 +44,11 @@ func readMarkAppConfig(mapboxToken string) *tg.JSONObject {
{Key: "rich_message_posting", Value: &tg.JSONString{Value: "enabled"}},
// dialog_filters_enabled=trueTDesktop 据此(或已有文件夹)才显示 Settings→Folders 入口。
{Key: "dialog_filters_enabled", Value: &tg.JSONBool{Value: true}},
{Key: "chatlist_update_period", Value: &tg.JSONNumber{Value: 3600}},
{Key: "chatlist_invites_limit_default", Value: &tg.JSONNumber{Value: 3}},
{Key: "chatlist_invites_limit_premium", Value: &tg.JSONNumber{Value: 20}},
{Key: "chatlists_joined_limit_default", Value: &tg.JSONNumber{Value: 2}},
{Key: "chatlists_joined_limit_premium", Value: &tg.JSONNumber{Value: 20}},
{Key: "stories_stealth_future_period", Value: &tg.JSONNumber{Value: 1500}},
{Key: "stories_stealth_past_period", Value: &tg.JSONNumber{Value: 300}},
{Key: "stories_stealth_cooldown_period", Value: &tg.JSONNumber{Value: 10800}},

View file

@ -8,6 +8,8 @@ import (
"strconv"
"strings"
"time"
"telesrv/internal/links"
)
const defaultConfigFile = ".env"
@ -40,11 +42,12 @@ type Config struct {
AdminAPIAddr string
// AdminAPIToken 是 Admin API bearer token开启 AdminAPIAddr 时必须显式配置。
AdminAPIToken string
// StickerWebAddr 是公开 sticker/custom emoji deep link 落地页监听地址;为空关闭。
// 生产应只监听 loopback并由 nginx 将 /addstickers/ 与 /addemoji/ 反代到该地址。
StickerWebAddr string
// StickerWebPublicURL 是生成 canonical telesrv.net 链接的公开根 URL。
StickerWebPublicURL string
// PublicBaseURL 是所有客户端可见 telesrv 链接的公开根 URL。
// 生产默认 https://telesrv.net本地可设为 http://127.0.0.1:2401。
PublicBaseURL string
// PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。
// 生产应只监听 loopback并由 nginx 将 /addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。
PublicLinkWebAddr string
// Admin UI 独立进程配置项保留在统一配置中cmd/telesrv-admin 也按同名 env 读取。
AdminUIAddr string
AdminUIPassword string
@ -281,6 +284,8 @@ func Load() (Config, error) {
envInt64Or := fileEnv.envInt64Or
envDurationOr := fileEnv.envDurationOr
publicBaseURL := links.NormalizeBaseURL(envOr("TELESRV_PUBLIC_BASE_URL", links.DefaultPublicBaseURL))
cfg := Config{
ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"),
WebSocketEnable: envBoolOr("TELESRV_WEBSOCKET_ENABLE", true),
@ -291,19 +296,19 @@ func Load() (Config, error) {
// AdvertiseIP 当前不影响 help.getConfig——getConfig 返回空 DCOptions
// 客户端使用其写死的 static DC 地址(见 compat/tdesktop/config.go
// 字段与默认值保留,供未来需要显式下发 DC 地址时使用。
AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"),
RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"),
DC: envIntOr("TELESRV_DC", 2),
DebugAddr: envOr("TELESRV_DEBUG_ADDR", "127.0.0.1:6060"),
BotAPIAddr: envOr("TELESRV_BOT_API_ADDR", ""),
AdminAPIAddr: envOr("TELESRV_ADMIN_API_ADDR", ""),
AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""),
StickerWebAddr: envOr("TELESRV_STICKER_WEB_ADDR", ""),
StickerWebPublicURL: envOr("TELESRV_STICKER_WEB_PUBLIC_URL", "https://telesrv.net"),
AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"),
AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""),
AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""),
AdminSessionKey: envOr("TELESRV_ADMIN_SESSION_KEY", ""),
AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"),
RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"),
DC: envIntOr("TELESRV_DC", 2),
DebugAddr: envOr("TELESRV_DEBUG_ADDR", "127.0.0.1:6060"),
BotAPIAddr: envOr("TELESRV_BOT_API_ADDR", ""),
AdminAPIAddr: envOr("TELESRV_ADMIN_API_ADDR", ""),
AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""),
PublicBaseURL: publicBaseURL,
PublicLinkWebAddr: envOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""),
AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"),
AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""),
AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""),
AdminSessionKey: envOr("TELESRV_ADMIN_SESSION_KEY", ""),
// 用 127.0.0.1 而非 localhostlocalhost 在 Windows 上会先解析到 IPv6 ::1而 Docker
// Desktop 的端口转发只在 IPv4 监听IPv6 连接要等 ~1s 超时才回退 IPv4实测 localhost

View file

@ -8,7 +8,9 @@ import (
)
func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_ADVERTISE_IP", "")
t.Setenv("TELESRV_PUBLIC_BASE_URL", "")
cfg, err := Load()
if err != nil {
@ -17,9 +19,13 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
if cfg.AdvertiseIP != "127.0.0.1" {
t.Fatalf("AdvertiseIP = %q, want loopback default", cfg.AdvertiseIP)
}
if cfg.PublicBaseURL != "https://telesrv.net" {
t.Fatalf("PublicBaseURL = %q, want https://telesrv.net", cfg.PublicBaseURL)
}
}
func TestLoadUsesExplicitAdvertiseIP(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_ADVERTISE_IP", "203.0.113.10")
cfg, err := Load()
@ -32,6 +38,7 @@ func TestLoadUsesExplicitAdvertiseIP(t *testing.T) {
}
func TestLoadBusinessAIProvider(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_BUSINESS_AI_PROVIDER", "echo")
cfg, err := Load()
@ -44,6 +51,7 @@ func TestLoadBusinessAIProvider(t *testing.T) {
}
func TestLoadBusinessAIProviderDefaultsToEcho(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_BUSINESS_AI_PROVIDER", "")
cfg, err := Load()
@ -56,6 +64,7 @@ func TestLoadBusinessAIProviderDefaultsToEcho(t *testing.T) {
}
func TestLoadKeepsAdminAndRtmpDefaultPortsSeparate(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_ADMIN_UI_ADDR", "")
t.Setenv("TELESRV_LIVESTREAM_RTMP_ADDR", "")
@ -75,6 +84,7 @@ func TestLoadKeepsAdminAndRtmpDefaultPortsSeparate(t *testing.T) {
}
func TestLoadAIProviders(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_AI_PROVIDERS", "local,openai,gemini")
t.Setenv("TELESRV_AI_OPENAI_API_KEY", "openai-key")
t.Setenv("TELESRV_AI_OPENAI_MODEL", "gpt-test")
@ -115,8 +125,8 @@ TELESRV_MAPBOX_TOKEN="file-token"
TELESRV_POSTGRES_MAX_CONNS=77
TELESRV_WEBSOCKET_ALLOWED_ORIGINS=https://one.example, https://two.example
TELESRV_CALL_RING_TIMEOUT=2m
TELESRV_STICKER_WEB_ADDR=127.0.0.1:2401
TELESRV_STICKER_WEB_PUBLIC_URL=https://packs.example.test
TELESRV_PUBLIC_BASE_URL=links.example.test/root
TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401
`)
t.Setenv("TELESRV_CONFIG", path)
@ -136,11 +146,24 @@ TELESRV_STICKER_WEB_PUBLIC_URL=https://packs.example.test
if cfg.CallRingTimeout != 2*time.Minute {
t.Fatalf("CallRingTimeout = %v, want 2m", cfg.CallRingTimeout)
}
if cfg.StickerWebAddr != "127.0.0.1:2401" {
t.Fatalf("StickerWebAddr = %q, want 127.0.0.1:2401", cfg.StickerWebAddr)
if cfg.PublicLinkWebAddr != "127.0.0.1:2401" {
t.Fatalf("PublicLinkWebAddr = %q, want 127.0.0.1:2401", cfg.PublicLinkWebAddr)
}
if cfg.StickerWebPublicURL != "https://packs.example.test" {
t.Fatalf("StickerWebPublicURL = %q, want https://packs.example.test", cfg.StickerWebPublicURL)
if cfg.PublicBaseURL != "https://links.example.test/root" {
t.Fatalf("PublicBaseURL = %q, want https://links.example.test/root", cfg.PublicBaseURL)
}
}
func TestLoadNormalizesLocalPublicBaseURL(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_PUBLIC_BASE_URL", "http://127.0.0.1:2401/")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.PublicBaseURL != "http://127.0.0.1:2401" {
t.Fatalf("PublicBaseURL = %q, want http://127.0.0.1:2401", cfg.PublicBaseURL)
}
}
@ -183,3 +206,8 @@ func writeConfigFile(t *testing.T, path, body string) {
t.Fatalf("write config file: %v", err)
}
}
func disableDefaultConfigFile(t *testing.T) {
t.Helper()
t.Setenv("TELESRV_CONFIG", "")
}

View file

@ -0,0 +1,82 @@
package domain
import "errors"
const (
// MaxChatlistInvitePeers bounds one exported shared folder link snapshot.
MaxChatlistInvitePeers = MaxDialogFolderPeers
// MaxChatlistInvitesDefault follows the non-premium appConfig limit.
MaxChatlistInvitesDefault = 3
// MaxChatlistInvitesPremium follows the premium appConfig limit.
MaxChatlistInvitesPremium = 20
// MaxChatlistsJoinedDefault follows the non-premium appConfig limit.
MaxChatlistsJoinedDefault = 2
// MaxChatlistsJoinedPremium follows the premium appConfig limit.
MaxChatlistsJoinedPremium = 20
)
var (
ErrChatlistInvalid = errors.New("chatlist invalid")
ErrChatlistInviteInvalid = errors.New("chatlist invite invalid")
ErrChatlistInviteExpired = errors.New("chatlist invite expired")
ErrChatlistInviteConflict = errors.New("chatlist invite conflict")
ErrChatlistInvitesTooMuch = errors.New("chatlist invites too much")
ErrChatlistsTooMuch = errors.New("chatlists too much")
ErrChatlistPeersEmpty = errors.New("chatlist peers empty")
ErrChatlistPeersTooMuch = errors.New("chatlist peers too much")
ErrChatlistNotShareable = errors.New("chatlist not shareable")
ErrChatlistSlugOccupied = errors.New("chatlist slug occupied")
)
// ChatlistInvite is one exported shared-folder link. Peers is a bounded
// snapshot selected by the owner for this link.
type ChatlistInvite struct {
ID int64
OwnerUserID int64
FilterID int
Slug string
Title string
Peers []DialogFolderPeer
Date int
Revoked bool
Deleted bool
}
// ChatlistMembership binds an imported shared-folder link to the viewer's
// local dialog filter.
type ChatlistMembership struct {
UserID int64
LocalFilterID int
OwnerUserID int64
OwnerFilterID int
Slug string
HiddenUpdates bool
Date int
}
type ChatlistInvitePreview struct {
Invite ChatlistInvite
OwnerFolder DialogFolder
LocalFolder *DialogFolder
Membership *ChatlistMembership
Missing []DialogFolderPeer
Already []DialogFolderPeer
}
type ChatlistJoinResult struct {
Folder DialogFolder
Membership ChatlistMembership
Date int
ChannelResults []CreateChannelResult
}
type ChatlistUpdates struct {
Membership ChatlistMembership
Missing []DialogFolderPeer
}
type ChatlistLeaveResult struct {
FilterID int
ChannelResults []CreateChannelResult
RequestedLeaves []DialogFolderPeer
}

View file

@ -205,6 +205,7 @@ type DialogFolder struct {
HasEmoticon bool
Color int
HasColor bool
HasMyInvites bool
PinnedPeers []DialogFolderPeer
IncludePeers []DialogFolderPeer
ExcludePeers []DialogFolderPeer

94
internal/links/links.go Normal file
View file

@ -0,0 +1,94 @@
package links
import (
"net/url"
"strings"
)
const DefaultPublicBaseURL = "https://telesrv.net"
const MaxChatlistSlugBytes = 128
func NormalizeBaseURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
raw = DefaultPublicBaseURL
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
return strings.TrimRight(raw, "/")
}
func Build(baseURL, path string, query url.Values) string {
baseURL = NormalizeBaseURL(baseURL)
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
baseURL = DefaultPublicBaseURL
parsed, _ = url.Parse(baseURL)
}
basePath := strings.TrimRight(parsed.Path, "/")
path = strings.TrimLeft(path, "/")
if path != "" {
parsed.Path = basePath + "/" + path
} else if basePath != "" {
parsed.Path = basePath
}
parsed.RawQuery = query.Encode()
return parsed.String()
}
func Host(baseURL string) string {
parsed, err := url.Parse(NormalizeBaseURL(baseURL))
if err != nil || parsed.Host == "" {
return "telesrv.net"
}
if host := parsed.Hostname(); host != "" {
return host
}
return parsed.Host
}
func CleanChatlistSlug(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.Contains(raw, "://") {
if parsed, err := url.Parse(raw); err == nil {
if slug := parsed.Query().Get("slug"); slug != "" {
raw = slug
} else {
raw = strings.Trim(parsed.Path, "/")
}
}
}
raw = strings.TrimPrefix(raw, "addlist/")
raw = strings.Trim(raw, "/")
if idx := strings.LastIndex(raw, "/"); idx >= 0 {
raw = raw[idx+1:]
}
if idx := strings.IndexAny(raw, "?#"); idx >= 0 {
raw = raw[:idx]
}
if decoded, err := url.PathUnescape(raw); err == nil {
raw = decoded
}
return raw
}
func ValidChatlistSlug(slug string) bool {
if slug == "" || len(slug) > MaxChatlistSlugBytes {
return false
}
for _, r := range slug {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '_' || r == '-' || r == '.':
default:
return false
}
}
return true
}

View file

@ -0,0 +1,70 @@
package links
import (
"net/url"
"testing"
)
func TestNormalizeBaseURL(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{name: "default", raw: "", want: "https://telesrv.net"},
{name: "host only", raw: "telesrv.net/", want: "https://telesrv.net"},
{name: "local http", raw: "http://127.0.0.1:2401/", want: "http://127.0.0.1:2401"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := NormalizeBaseURL(tt.raw); got != tt.want {
t.Fatalf("NormalizeBaseURL(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
func TestBuildPreservesBasePathAndQuery(t *testing.T) {
got := Build("http://127.0.0.1:2401/root/", "/call/abc", url.Values{"slug": []string{"abc"}})
if want := "http://127.0.0.1:2401/root/call/abc?slug=abc"; got != want {
t.Fatalf("Build = %q, want %q", got, want)
}
}
func TestBuildDoesNotDoubleEscapeBasePath(t *testing.T) {
got := Build("http://127.0.0.1:2401/root%20path/", "/addlist/slug", nil)
if want := "http://127.0.0.1:2401/root%20path/addlist/slug"; got != want {
t.Fatalf("Build encoded path = %q, want %q", got, want)
}
}
func TestHostDropsPort(t *testing.T) {
if got, want := Host("http://127.0.0.1:2401"), "127.0.0.1"; got != want {
t.Fatalf("Host = %q, want %q", got, want)
}
}
func TestCleanAndValidateChatlistSlug(t *testing.T) {
tests := []struct {
name string
raw string
clean string
valid bool
}{
{name: "raw", raw: "abc.DEF-12", clean: "abc.DEF-12", valid: true},
{name: "public url", raw: "http://127.0.0.1:2401/addlist/abc-12?x=1", clean: "abc-12", valid: true},
{name: "app url", raw: "telesrv://addlist?slug=abc_12", clean: "abc_12", valid: true},
{name: "bad char", raw: "abc/../bad!", clean: "bad!", valid: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CleanChatlistSlug(tt.raw)
if got != tt.clean {
t.Fatalf("CleanChatlistSlug(%q) = %q, want %q", tt.raw, got, tt.clean)
}
if valid := ValidChatlistSlug(got); valid != tt.valid {
t.Fatalf("ValidChatlistSlug(%q) = %v, want %v", got, valid, tt.valid)
}
})
}
}

View file

@ -455,7 +455,7 @@ func (r *Router) connectedBusinessBotPeerSettings(ctx context.Context, ownerUser
if botUser, found, err := r.connectedBusinessBotByID(ctx, ownerUserID, bot.BotUserID); err != nil {
return domain.PeerSettings{}, err
} else if found {
settings.BusinessBotManageURL = connectedBusinessBotManageURL(botUser)
settings.BusinessBotManageURL = r.connectedBusinessBotManageURL(botUser)
}
if settings.BusinessBotManageURL == "" {
settings.BusinessBotManageURL = "telesrv://business-bot"
@ -477,11 +477,11 @@ func (r *Router) connectedBusinessPeerFacts(ctx context.Context, ownerUserID, pe
return existingChat, isContact
}
func connectedBusinessBotManageURL(bot domain.User) string {
func (r *Router) connectedBusinessBotManageURL(bot domain.User) string {
if bot.Username == "" {
return ""
}
return "https://telesrv.net/" + bot.Username
return r.publicLink(bot.Username)
}
func (r *Router) recordConnectedBusinessPeerSettings(ctx context.Context, userID int64, peer domain.Peer, settings domain.PeerSettings) error {

View file

@ -238,7 +238,7 @@ func (r *Router) onAccountInstallTheme(ctx context.Context, req *tg.AccountInsta
return true, nil
}
// onAccountGetTheme 按 id 或 slug(深链 telesrv.net/addtheme/<slug>)取主题。
// onAccountGetTheme 按 id 或 slug(公开 addtheme 深链)取主题。
func (r *Router) onAccountGetTheme(ctx context.Context, req *tg.AccountGetThemeRequest) (*tg.Theme, error) {
if req == nil {
return nil, themeInvalidErr()

View file

@ -14,7 +14,7 @@ import (
const aiComposeToneWebPageType = "telegram_aicomposetone"
func (r *Router) resolveAIComposeStyleWebPage(ctx context.Context, rawURL string) (domain.MessageWebPage, bool) {
link, ok := parseAIComposeStyleLink(rawURL)
link, ok := parseAIComposeStyleLink(rawURL, r.publicLinkHost())
if !ok || r.deps.AICompose == nil {
return domain.MessageWebPage{}, false
}
@ -62,7 +62,7 @@ type aiComposeStyleLink struct {
slug string
}
func parseAIComposeStyleLink(raw string) (aiComposeStyleLink, bool) {
func parseAIComposeStyleLink(raw, publicHost string) (aiComposeStyleLink, bool) {
normalized, ok := domain.NormalizeWebPageURL(raw)
if !ok {
return aiComposeStyleLink{}, false
@ -72,7 +72,7 @@ func parseAIComposeStyleLink(raw string) (aiComposeStyleLink, bool) {
return aiComposeStyleLink{}, false
}
host := strings.ToLower(u.Hostname())
if !aiComposeStyleHostAllowed(host) {
if !aiComposeStyleHostAllowed(host, publicHost) {
return aiComposeStyleLink{}, false
}
parts := strings.Split(strings.Trim(strings.ToLower(u.EscapedPath()), "/"), "/")
@ -96,7 +96,10 @@ func parseAIComposeStyleLink(raw string) (aiComposeStyleLink, bool) {
return aiComposeStyleLink{normalized: normalized, display: display, slug: slug}, true
}
func aiComposeStyleHostAllowed(host string) bool {
func aiComposeStyleHostAllowed(host, publicHost string) bool {
if publicHost != "" && strings.EqualFold(host, publicHost) {
return true
}
switch host {
case "t.me", "telegram.me", "telesrv.net", "localhost", "127.0.0.1":
return true

View file

@ -12,7 +12,7 @@ import (
func startParamInvalidErr() error { return tgerr.New(400, "START_PARAM_INVALID") }
// onMessagesStartBot 处理 messages.startBot深链 telesrv.net/bot?start=payload 与「启动 bot」
// onMessagesStartBot 处理 messages.startBot公开 bot?start 深链与「启动 bot」
// 入口)。语义:向 bot 发送一条可见的 "/start" 或 "/start <param>" 普通私聊消息,走标准
// SendPrivateText 双盒+outbox返回真实 UpdatesI7。bot 经此收到 start_param。
// P3 仅私聊启动peer 为群(加 bot 进群后移P4群内 bot

View file

@ -135,7 +135,7 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
if err != nil {
return nil, err
}
full := tgChannelFull(view)
full := tgChannelFull(view, r.cfg.PublicBaseURL)
r.applyStarGiftsCountToChannelFull(ctx, view.Channel.ID, full)
userIDs := []int64{view.Channel.CreatorUserID, view.Self.UserID}
// 注Bots 过滤实际会返回群内 botTestGroupBotRPCShape 覆盖),这里据此富化 full.BotInfo。

View file

@ -3,9 +3,11 @@ package rpc
import (
"context"
"errors"
"strings"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"strings"
"telesrv/internal/domain"
)
@ -41,7 +43,7 @@ func (r *Router) onMessagesExportChatInvite(ctx context.Context, req *tg.Message
return nil, channelInviteErr(err)
}
r.invalidateRPCProjectionForChannel(res.Channel.ID)
return tgExportedChannelInvite(res.Invite), nil
return r.tgExportedChannelInvite(res.Invite), nil
}
func (r *Router) onMessagesCheckChatInvite(ctx context.Context, hash string) (tg.ChatInviteClass, error) {
@ -141,7 +143,7 @@ func (r *Router) onMessagesGetExportedChatInvites(ctx context.Context, req *tg.M
userIDs := []int64{adminID}
invites := make([]tg.ExportedChatInviteClass, 0, len(list.Invites))
for _, invite := range list.Invites {
invites = append(invites, tgExportedChannelInvite(invite))
invites = append(invites, r.tgExportedChannelInvite(invite))
userIDs = append(userIDs, invite.AdminUserID)
}
return &tg.MessagesExportedChatInvites{
@ -169,7 +171,7 @@ func (r *Router) onMessagesGetExportedChatInvite(ctx context.Context, req *tg.Me
return nil, channelInviteErr(err)
}
return &tg.MessagesExportedChatInvite{
Invite: tgExportedChannelInvite(invite),
Invite: r.tgExportedChannelInvite(invite),
Users: r.tgUsersForIDs(ctx, userID, []int64{invite.AdminUserID}),
}, nil
}
@ -212,12 +214,12 @@ func (r *Router) onMessagesEditExportedChatInvite(ctx context.Context, req *tg.M
users := r.tgUsersForIDs(ctx, userID, []int64{edited.Invite.AdminUserID})
if edited.NewInvite != nil {
return &tg.MessagesExportedChatInviteReplaced{
Invite: tgExportedChannelInvite(edited.Invite),
NewInvite: tgExportedChannelInvite(*edited.NewInvite),
Invite: r.tgExportedChannelInvite(edited.Invite),
NewInvite: r.tgExportedChannelInvite(*edited.NewInvite),
Users: users,
}, nil
}
return &tg.MessagesExportedChatInvite{Invite: tgExportedChannelInvite(edited.Invite), Users: users}, nil
return &tg.MessagesExportedChatInvite{Invite: r.tgExportedChannelInvite(edited.Invite), Users: users}, nil
}
func (r *Router) onMessagesDeleteRevokedExportedChatInvites(ctx context.Context, req *tg.MessagesDeleteRevokedExportedChatInvitesRequest) (bool, error) {
@ -353,12 +355,16 @@ func createChatInviteMemberIDs(ids []int64, selfUserID int64) []int64 {
return out
}
func tgExportedChannelInvite(invite domain.ChannelInvite) tg.ExportedChatInviteClass {
func (r *Router) tgExportedChannelInvite(invite domain.ChannelInvite) tg.ExportedChatInviteClass {
return tgExportedChannelInvite(invite, r.cfg.PublicBaseURL)
}
func tgExportedChannelInvite(invite domain.ChannelInvite, publicBaseURL string) tg.ExportedChatInviteClass {
out := &tg.ChatInviteExported{
Revoked: invite.Revoked,
Permanent: invite.Permanent,
RequestNeeded: invite.RequestNeeded,
Link: "https://telesrv.net/+" + invite.Hash,
Link: publicLinkWithBaseURL(publicBaseURL, "+"+invite.Hash),
AdminID: invite.AdminUserID,
Date: invite.Date,
}

View file

@ -3,12 +3,14 @@ package rpc
import (
"context"
"errors"
"github.com/gotd/td/tg"
"sort"
"strconv"
"strings"
"telesrv/internal/domain"
"unicode/utf8"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func (r *Router) onChannelsExportMessageLink(ctx context.Context, req *tg.ChannelsExportMessageLinkRequest) (*tg.ExportedMessageLink, error) {
@ -28,9 +30,9 @@ func (r *Router) onChannelsExportMessageLink(ctx context.Context, req *tg.Channe
}
link := ""
if view.Channel.Username != "" {
link = "https://telesrv.net/" + view.Channel.Username + "/" + strconv.Itoa(req.ID)
link = r.publicLink(view.Channel.Username + "/" + strconv.Itoa(req.ID))
} else {
link = "https://telesrv.net/c/" + strconv.FormatInt(view.Channel.ID, 10) + "/" + strconv.Itoa(req.ID)
link = r.publicLink("c/" + strconv.FormatInt(view.Channel.ID, 10) + "/" + strconv.Itoa(req.ID))
}
if req.Thread {
if rootID := channelMessageThreadRootID(history.Messages[0]); rootID > 0 && rootID != req.ID {

526
internal/rpc/chatlists.go Normal file
View file

@ -0,0 +1,526 @@
package rpc
import (
"context"
"errors"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func (r *Router) registerChatlists(d *tg.ServerDispatcher) {
d.OnChatlistsExportChatlistInvite(r.onChatlistsExportChatlistInvite)
d.OnChatlistsDeleteExportedInvite(r.onChatlistsDeleteExportedInvite)
d.OnChatlistsEditExportedInvite(r.onChatlistsEditExportedInvite)
d.OnChatlistsGetExportedInvites(r.onChatlistsGetExportedInvites)
d.OnChatlistsCheckChatlistInvite(r.onChatlistsCheckChatlistInvite)
d.OnChatlistsJoinChatlistInvite(r.onChatlistsJoinChatlistInvite)
d.OnChatlistsGetChatlistUpdates(r.onChatlistsGetChatlistUpdates)
d.OnChatlistsJoinChatlistUpdates(r.onChatlistsJoinChatlistUpdates)
d.OnChatlistsHideChatlistUpdates(r.onChatlistsHideChatlistUpdates)
d.OnChatlistsGetLeaveChatlistSuggestions(r.onChatlistsGetLeaveChatlistSuggestions)
d.OnChatlistsLeaveChatlist(r.onChatlistsLeaveChatlist)
}
func (r *Router) onChatlistsExportChatlistInvite(ctx context.Context, req *tg.ChatlistsExportChatlistInviteRequest) (*tg.ChatlistsExportedChatlistInvite, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
if r.deps.Chatlists == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
peers, err := r.dialogFolderPeersFromInput(ctx, userID, req.Peers)
if err != nil {
return nil, err
}
folder, invite, err := r.deps.Chatlists.ExportInvite(ctx, userID, chatlistFilterID(req.Chatlist), req.Title, peers, int(r.clock.Now().Unix()))
if err != nil {
return nil, chatlistErr(err)
}
if err := r.recordChatlistFilterUpdate(ctx, userID, folder.ID, &folder); err != nil {
return nil, err
}
return &tg.ChatlistsExportedChatlistInvite{
Filter: tgDialogFilter(folder),
Invite: *r.tgExportedChatlistInvite(invite),
}, nil
}
func (r *Router) onChatlistsDeleteExportedInvite(ctx context.Context, req *tg.ChatlistsDeleteExportedInviteRequest) (bool, error) {
if req == nil {
return false, inputRequestInvalidErr()
}
if r.deps.Chatlists == nil {
return false, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
folder, changed, err := r.deps.Chatlists.DeleteInvite(ctx, userID, chatlistFilterID(req.Chatlist), req.Slug)
if err != nil {
return false, chatlistErr(err)
}
if changed {
if err := r.recordChatlistFilterUpdate(ctx, userID, folder.ID, &folder); err != nil {
return false, err
}
}
return true, nil
}
func (r *Router) onChatlistsEditExportedInvite(ctx context.Context, req *tg.ChatlistsEditExportedInviteRequest) (*tg.ExportedChatlistInvite, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
if r.deps.Chatlists == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
var title *string
if v, ok := req.GetTitle(); ok {
title = &v
}
var peersPtr *[]domain.DialogFolderPeer
if v, ok := req.GetPeers(); ok {
peers, err := r.dialogFolderPeersFromInput(ctx, userID, v)
if err != nil {
return nil, err
}
peersPtr = &peers
}
invite, err := r.deps.Chatlists.EditInvite(ctx, userID, chatlistFilterID(req.Chatlist), req.Slug, title, peersPtr, req.Flags.Has(0))
if err != nil {
return nil, chatlistErr(err)
}
return r.tgExportedChatlistInvite(invite), nil
}
func (r *Router) onChatlistsGetExportedInvites(ctx context.Context, chatlist tg.InputChatlistDialogFilter) (*tg.ChatlistsExportedInvites, error) {
if r.deps.Chatlists == nil {
return &tg.ChatlistsExportedInvites{}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
invites, err := r.deps.Chatlists.ListInvites(ctx, userID, chatlistFilterID(chatlist))
if err != nil {
return nil, chatlistErr(err)
}
out := &tg.ChatlistsExportedInvites{
Invites: make([]tg.ExportedChatlistInvite, 0, len(invites)),
}
allPeers := make([]domain.DialogFolderPeer, 0)
for _, invite := range invites {
out.Invites = append(out.Invites, *r.tgExportedChatlistInvite(invite))
allPeers = append(allPeers, invite.Peers...)
}
out.Users, out.Chats = r.tgChatlistPeerEnvelope(ctx, userID, allPeers)
return out, nil
}
func (r *Router) onChatlistsCheckChatlistInvite(ctx context.Context, slug string) (tg.ChatlistsChatlistInviteClass, error) {
if r.deps.Chatlists == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
preview, err := r.deps.Chatlists.CheckInvite(ctx, userID, slug)
if err != nil {
return nil, chatlistErr(err)
}
if preview.LocalFolder != nil {
filterID := preview.LocalFolder.ID
if preview.Membership != nil {
filterID = preview.Membership.LocalFilterID
}
users, chats, channels := r.tgChatlistPeerEnvelopeWithChannels(ctx, userID, append(preview.Missing, preview.Already...))
if preview.Membership != nil {
r.pushChatlistJoinedChannelNudges(ctx, userID, channels)
}
return &tg.ChatlistsChatlistInviteAlready{
FilterID: filterID,
MissingPeers: tgChatlistPeers(preview.Missing),
AlreadyPeers: tgChatlistPeers(preview.Already),
Chats: chats,
Users: users,
}, nil
}
users, chats := r.tgChatlistPeerEnvelope(ctx, userID, preview.Missing)
out := &tg.ChatlistsChatlistInvite{
TitleNoanimate: preview.OwnerFolder.TitleNoanimate,
Title: tg.TextWithEntities{
Text: preview.OwnerFolder.Title,
Entities: tgMessageEntities(preview.OwnerFolder.TitleEntities),
},
Peers: tgChatlistPeers(preview.Missing),
Chats: chats,
Users: users,
}
if preview.OwnerFolder.HasEmoticon {
out.SetEmoticon(preview.OwnerFolder.Emoticon)
}
return out, nil
}
func (r *Router) onChatlistsJoinChatlistInvite(ctx context.Context, req *tg.ChatlistsJoinChatlistInviteRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
if r.deps.Chatlists == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
peers, err := r.dialogFolderPeersFromInput(ctx, userID, req.Peers)
if err != nil {
return nil, err
}
result, err := r.deps.Chatlists.JoinInvite(ctx, userID, req.Slug, peers, int(r.clock.Now().Unix()))
if err != nil {
return nil, chatlistErr(err)
}
return r.chatlistOperationUpdates(ctx, userID, result.Folder.ID, &result.Folder, result.ChannelResults, false)
}
func (r *Router) onChatlistsGetChatlistUpdates(ctx context.Context, chatlist tg.InputChatlistDialogFilter) (*tg.ChatlistsChatlistUpdates, error) {
if r.deps.Chatlists == nil {
return &tg.ChatlistsChatlistUpdates{}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
updates, err := r.deps.Chatlists.GetUpdates(ctx, userID, chatlistFilterID(chatlist))
if err != nil {
return nil, chatlistErr(err)
}
users, chats := r.tgChatlistPeerEnvelope(ctx, userID, updates.Missing)
return &tg.ChatlistsChatlistUpdates{
MissingPeers: tgChatlistPeers(updates.Missing),
Chats: chats,
Users: users,
}, nil
}
func (r *Router) onChatlistsJoinChatlistUpdates(ctx context.Context, req *tg.ChatlistsJoinChatlistUpdatesRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
if r.deps.Chatlists == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
peers, err := r.dialogFolderPeersFromInput(ctx, userID, req.Peers)
if err != nil {
return nil, err
}
result, err := r.deps.Chatlists.JoinUpdates(ctx, userID, chatlistFilterID(req.Chatlist), peers, int(r.clock.Now().Unix()))
if err != nil {
return nil, chatlistErr(err)
}
return r.chatlistOperationUpdates(ctx, userID, result.Folder.ID, &result.Folder, result.ChannelResults, false)
}
func (r *Router) onChatlistsHideChatlistUpdates(ctx context.Context, chatlist tg.InputChatlistDialogFilter) (bool, error) {
if r.deps.Chatlists == nil {
return false, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if err := r.deps.Chatlists.HideUpdates(ctx, userID, chatlistFilterID(chatlist)); err != nil {
return false, chatlistErr(err)
}
return true, nil
}
func (r *Router) onChatlistsGetLeaveChatlistSuggestions(ctx context.Context, chatlist tg.InputChatlistDialogFilter) ([]tg.PeerClass, error) {
if r.deps.Chatlists == nil {
return []tg.PeerClass{}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
peers, err := r.deps.Chatlists.LeaveSuggestions(ctx, userID, chatlistFilterID(chatlist))
if err != nil {
return nil, chatlistErr(err)
}
return tgChatlistPeers(peers), nil
}
func (r *Router) onChatlistsLeaveChatlist(ctx context.Context, req *tg.ChatlistsLeaveChatlistRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
if r.deps.Chatlists == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
peers, err := r.dialogFolderPeersFromInput(ctx, userID, req.Peers)
if err != nil {
return nil, err
}
result, err := r.deps.Chatlists.Leave(ctx, userID, chatlistFilterID(req.Chatlist), peers, int(r.clock.Now().Unix()))
if err != nil {
return nil, chatlistErr(err)
}
return r.chatlistOperationUpdates(ctx, userID, result.FilterID, nil, result.ChannelResults, true)
}
func (r *Router) tgExportedChatlistInvite(invite domain.ChatlistInvite) *tg.ExportedChatlistInvite {
out := &tg.ExportedChatlistInvite{
Title: invite.Title,
URL: r.publicLink("addlist/" + invite.Slug),
Peers: tgChatlistPeers(invite.Peers),
}
if invite.Revoked {
out.Flags.Set(0)
}
return out
}
func (r *Router) tgChatlistPeerEnvelope(ctx context.Context, userID int64, peers []domain.DialogFolderPeer) ([]tg.UserClass, []tg.ChatClass) {
users, chats, _ := r.tgChatlistPeerEnvelopeWithChannels(ctx, userID, peers)
return users, chats
}
func (r *Router) tgChatlistPeerEnvelopeWithChannels(ctx context.Context, userID int64, peers []domain.DialogFolderPeer) ([]tg.UserClass, []tg.ChatClass, []domain.Channel) {
userIDs := make([]int64, 0)
channelIDs := make([]int64, 0)
seenUsers := make(map[int64]struct{})
seenChannels := make(map[int64]struct{})
for _, item := range peers {
switch item.Peer.Type {
case domain.PeerTypeUser:
if item.Peer.ID == 0 {
continue
}
if _, ok := seenUsers[item.Peer.ID]; ok {
continue
}
seenUsers[item.Peer.ID] = struct{}{}
userIDs = append(userIDs, item.Peer.ID)
case domain.PeerTypeChannel:
if item.Peer.ID == 0 {
continue
}
if _, ok := seenChannels[item.Peer.ID]; ok {
continue
}
seenChannels[item.Peer.ID] = struct{}{}
channelIDs = append(channelIDs, item.Peer.ID)
}
}
chats := make([]tg.ChatClass, 0, len(channelIDs))
channels := make([]domain.Channel, 0, len(channelIDs))
if r.deps.Channels != nil && len(channelIDs) > 0 {
if views, err := r.deps.Channels.GetChannels(ctx, userID, channelIDs); err == nil {
for _, view := range views {
if view.Channel.ID == 0 {
continue
}
channels = append(channels, view.Channel)
chats = append(chats, tgChannelChatForView(userID, view))
}
}
}
return r.tgUsersForIDs(ctx, userID, userIDs), chats, channels
}
func tgChatlistPeers(peers []domain.DialogFolderPeer) []tg.PeerClass {
out := make([]tg.PeerClass, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, item := range peers {
if _, ok := seen[item.Peer]; ok {
continue
}
seen[item.Peer] = struct{}{}
if peer := tgPeer(item.Peer); peer != nil {
out = append(out, peer)
}
}
return out
}
func chatlistFilterID(chatlist tg.InputChatlistDialogFilter) int {
return chatlist.FilterID
}
func (r *Router) recordChatlistFilterUpdate(ctx context.Context, userID int64, filterID int, folder *domain.DialogFolder) error {
event := domain.UpdateEvent{
Type: domain.UpdateEventDialogFilter,
FilterID: filterID,
DialogFilter: folder,
Date: int(r.clock.Now().Unix()),
}
var err error
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, _, err = r.deps.Updates.RecordDialogFilter(ctx, authKeyID, userID, filterID, folder, sessionID)
if err != nil {
return internalErr()
}
}
r.bookkeepAuxPtsForCurrentSession(ctx, event)
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event))
return nil
}
func (r *Router) chatlistFilterUpdates(ctx context.Context, userID int64, filterID int, folder *domain.DialogFolder) (tg.UpdatesClass, error) {
event := domain.UpdateEvent{
Type: domain.UpdateEventDialogFilter,
FilterID: filterID,
DialogFilter: folder,
Date: int(r.clock.Now().Unix()),
}
var err error
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, _, err = r.deps.Updates.RecordDialogFilter(ctx, authKeyID, userID, filterID, folder, sessionID)
if err != nil {
return nil, internalErr()
}
}
r.bookkeepAuxPtsForCurrentSession(ctx, event)
out := tgUpdateForOutboxEvent(event)
if out == nil {
out = &tg.Updates{Date: event.Date}
}
if folder != nil {
out.Users, out.Chats = r.tgChatlistPeerEnvelope(ctx, userID, append(folder.PinnedPeers, folder.IncludePeers...))
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, out)
return out, nil
}
func (r *Router) chatlistOperationUpdates(ctx context.Context, userID int64, filterID int, folder *domain.DialogFolder, channelResults []domain.CreateChannelResult, leaving bool) (tg.UpdatesClass, error) {
base, err := r.chatlistFilterUpdates(ctx, userID, filterID, folder)
if err != nil {
return nil, err
}
out, ok := base.(*tg.Updates)
if !ok {
out = &tg.Updates{Date: int(r.clock.Now().Unix())}
}
for _, res := range channelResults {
if res.Channel.ID == 0 {
continue
}
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
if leaving {
r.removeOnlineChannelMemberships(res.Channel.ID, userID)
r.recordChannelStateForUser(ctx, userID, res.Channel.ID, true)
} else {
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
}
channelUpdates := r.channelOperationUpdates(ctx, userID, res)
out = mergeUpdates(out, channelUpdates)
if !leaving {
if nudge := chatlistJoinedChannelNudge(res.Channel); nudge != nil {
out.Updates = append(out.Updates, nudge)
}
}
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelOperationUpdates(ctx, viewerUserID, res)
})
}
return out, nil
}
func chatlistJoinedChannelNudge(channel domain.Channel) tg.UpdateClass {
if channel.ID == 0 || channel.Pts <= 0 {
return nil
}
update := &tg.UpdateChannelTooLong{ChannelID: channel.ID}
update.SetPts(channel.Pts)
return update
}
func (r *Router) pushChatlistJoinedChannelNudges(ctx context.Context, userID int64, channels []domain.Channel) {
if userID == 0 || len(channels) == 0 {
return
}
updates := make([]tg.UpdateClass, 0, len(channels))
for _, channel := range channels {
if nudge := chatlistJoinedChannelNudge(channel); nudge != nil {
updates = append(updates, nudge)
}
}
if len(updates) == 0 {
return
}
out := &tg.Updates{
Updates: updates,
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
if _, ok := SessionIDFrom(ctx); ok {
r.pushCurrentSessionMessage(ctx, "push chatlist joined channel nudges", out)
return
}
r.pushUserUpdates(ctx, userID, out)
}
func chatlistErr(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, domain.ErrInviteRequestSent),
errors.Is(err, domain.ErrUserAlreadyParticipant),
errors.Is(err, domain.ErrChannelUserBanned),
errors.Is(err, domain.ErrUserKicked),
errors.Is(err, domain.ErrBotGroupsBlocked):
return channelInviteErr(err)
case errors.Is(err, domain.ErrChannelUserCreator),
errors.Is(err, domain.ErrUserNotParticipant),
errors.Is(err, domain.ErrChannelAdminRequired):
return channelAdminErr(err)
case errors.Is(err, domain.ErrChannelInvalid),
errors.Is(err, domain.ErrChannelPrivate):
return channelInvalidErr(err)
case errors.Is(err, domain.ErrChatlistInviteInvalid):
return inviteSlugEmptyErr()
case errors.Is(err, domain.ErrChatlistInviteExpired):
return inviteSlugExpiredErr()
case errors.Is(err, domain.ErrChatlistInvitesTooMuch):
return invitesTooMuchErr()
case errors.Is(err, domain.ErrChatlistsTooMuch):
return chatlistsTooMuchErr()
case errors.Is(err, domain.ErrChatlistPeersEmpty):
return peersListEmptyErr()
case errors.Is(err, domain.ErrChatlistPeersTooMuch):
return limitInvalidErr()
case errors.Is(err, domain.ErrChatlistNotShareable):
return filterNotSupportedErr()
case errors.Is(err, domain.ErrChatlistInvalid):
return filterIDInvalidErr()
default:
return internalErr()
}
}

View file

@ -0,0 +1,307 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
func TestChatlistsExportInviteRPCShape(t *testing.T) {
fake := &fakeChatlistsService{
exportFolder: domain.DialogFolder{
ID: 2,
Title: "Team",
IsChatlist: true,
HasMyInvites: true,
IncludePeers: []domain.DialogFolderPeer{{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 42}, AccessHash: 4200}},
},
exportInvite: domain.ChatlistInvite{
OwnerUserID: 1001,
FilterID: 2,
Slug: "slug-rpc",
Title: "Main link",
Peers: []domain.DialogFolderPeer{{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 42}, AccessHash: 4200}},
Date: 100,
},
}
r := New(Config{PublicBaseURL: "http://127.0.0.1:2401"}, Deps{Chatlists: fake}, zap.NewNop(), clock.System)
got, err := r.onChatlistsExportChatlistInvite(WithUserID(context.Background(), 1001), &tg.ChatlistsExportChatlistInviteRequest{
Chatlist: tg.InputChatlistDialogFilter{FilterID: 2},
Title: "Main link",
Peers: []tg.InputPeerClass{&tg.InputPeerChannel{ChannelID: 42, AccessHash: 4200}},
})
if err != nil {
t.Fatalf("export rpc: %v", err)
}
filter, ok := got.Filter.(*tg.DialogFilterChatlist)
if !ok || !filter.HasMyInvites || filter.ID != 2 {
t.Fatalf("filter = %#v, want dialogFilterChatlist with has_my_invites", got.Filter)
}
if got.Invite.URL != "http://127.0.0.1:2401/addlist/slug-rpc" || len(got.Invite.Peers) != 1 {
t.Fatalf("invite = %+v, want url and one peer", got.Invite)
}
if fake.exportPeers[0].Peer.Type != domain.PeerTypeChannel || fake.exportPeers[0].Peer.ID != 42 || fake.exportPeers[0].AccessHash != 4200 {
t.Fatalf("service peers = %+v, want parsed input peer", fake.exportPeers)
}
}
func TestChatlistsExportInvitePublicBaseURLIsRouterScoped(t *testing.T) {
fake := &fakeChatlistsService{
exportFolder: domain.DialogFolder{ID: 2, Title: "Team", IsChatlist: true, HasMyInvites: true},
exportInvite: domain.ChatlistInvite{
OwnerUserID: 1001,
FilterID: 2,
Slug: "slug-rpc",
Title: "Main link",
},
}
local := New(Config{PublicBaseURL: "http://127.0.0.1:2401"}, Deps{Chatlists: fake}, zap.NewNop(), clock.System)
_ = New(Config{PublicBaseURL: "https://telesrv.net"}, Deps{Chatlists: fake}, zap.NewNop(), clock.System)
got, err := local.onChatlistsExportChatlistInvite(WithUserID(context.Background(), 1001), &tg.ChatlistsExportChatlistInviteRequest{
Chatlist: tg.InputChatlistDialogFilter{FilterID: 2},
Title: "Main link",
})
if err != nil {
t.Fatalf("export rpc: %v", err)
}
if got.Invite.URL != "http://127.0.0.1:2401/addlist/slug-rpc" {
t.Fatalf("invite url = %q, want local router base URL", got.Invite.URL)
}
}
func TestChatlistsJoinInviteReturnsDialogFilterUpdate(t *testing.T) {
fake := &fakeChatlistsService{
joinResult: domain.ChatlistJoinResult{
Folder: domain.DialogFolder{
ID: 3,
Title: "Team",
IsChatlist: true,
IncludePeers: []domain.DialogFolderPeer{
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 42}, AccessHash: 4200},
},
},
ChannelResults: []domain.CreateChannelResult{
{
Channel: domain.Channel{
ID: 42,
AccessHash: 4200,
Title: "Team channel",
Megagroup: true,
Pts: 9,
},
Members: []domain.ChannelMember{
{ChannelID: 42, UserID: 2002, Status: domain.ChannelMemberActive},
},
},
},
Date: 100,
},
}
r := New(Config{}, Deps{Chatlists: fake}, zap.NewNop(), clock.System)
got, err := r.onChatlistsJoinChatlistInvite(WithUserID(context.Background(), 2002), &tg.ChatlistsJoinChatlistInviteRequest{
Slug: "slug-rpc",
Peers: []tg.InputPeerClass{&tg.InputPeerChannel{ChannelID: 42, AccessHash: 4200}},
})
if err != nil {
t.Fatalf("join rpc: %v", err)
}
updates, ok := got.(*tg.Updates)
if !ok || len(updates.Updates) == 0 {
t.Fatalf("updates = %#v, want updates with updateDialogFilter", got)
}
update, ok := updates.Updates[0].(*tg.UpdateDialogFilter)
if !ok || update.ID != 3 {
t.Fatalf("first update = %#v, want updateDialogFilter id=3", updates.Updates[0])
}
filter, ok := update.Filter.(*tg.DialogFilterChatlist)
if !ok || filter.ID != 3 || len(filter.IncludePeers) != 1 {
t.Fatalf("update filter = %#v, want joined chatlist filter", update.Filter)
}
if fake.joinPeers[0].Peer.Type != domain.PeerTypeChannel || fake.joinPeers[0].Peer.ID != 42 || fake.joinPeers[0].AccessHash != 4200 {
t.Fatalf("service join peers = %+v, want parsed input peer", fake.joinPeers)
}
var gotChannelNudge bool
for _, u := range updates.Updates {
nudge, ok := u.(*tg.UpdateChannelTooLong)
if !ok || nudge.ChannelID != 42 {
continue
}
pts, ok := nudge.GetPts()
if !ok || pts != 9 {
t.Fatalf("channel nudge pts = %d ok %v, want 9 true", pts, ok)
}
gotChannelNudge = true
}
if !gotChannelNudge {
t.Fatalf("updates = %#v, want UpdateChannelTooLong for joined channel", updates.Updates)
}
}
func TestChatlistsJoinedChannelNudgesCarryPts(t *testing.T) {
sessions := &captureSessions{}
r := New(Config{}, Deps{Sessions: sessions}, zap.NewNop(), clock.System)
r.pushChatlistJoinedChannelNudges(context.Background(), 2002, []domain.Channel{
{ID: 42, Pts: 9},
{ID: 43},
})
updates, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("pushed updates = %#v, want one UpdateChannelTooLong", sessions.lastUserPush())
}
nudge, ok := updates.Updates[0].(*tg.UpdateChannelTooLong)
if !ok || nudge.ChannelID != 42 {
t.Fatalf("pushed update = %#v, want channel 42 nudge", updates.Updates[0])
}
pts, ok := nudge.GetPts()
if !ok || pts != 9 {
t.Fatalf("pushed nudge pts = %d ok %v, want 9 true", pts, ok)
}
sessions.clearMessages()
r.pushChatlistJoinedChannelNudges(WithSessionID(context.Background(), 77), 2002, []domain.Channel{{ID: 44, Pts: 11}})
snap := sessions.snapshot()
currentUpdates, ok := snap.message.(*tg.Updates)
if !ok || len(currentUpdates.Updates) != 1 {
t.Fatalf("current session push = %#v, want one UpdateChannelTooLong", snap.message)
}
if ids := sessions.pushedUserIDs(); len(ids) != 0 {
t.Fatalf("current session nudge pushed to user ids = %v, want none", ids)
}
}
func TestChatlistsCheckInviteRPCAlreadyAndFreshShapes(t *testing.T) {
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 42}, AccessHash: 4200}
fake := &fakeChatlistsService{
checkPreview: domain.ChatlistInvitePreview{
Invite: domain.ChatlistInvite{Slug: "slug-rpc", Peers: []domain.DialogFolderPeer{peer}},
OwnerFolder: domain.DialogFolder{
ID: 2,
Title: "Team",
HasEmoticon: true,
Emoticon: ":)",
IncludePeers: []domain.DialogFolderPeer{peer},
},
Missing: []domain.DialogFolderPeer{peer},
},
}
r := New(Config{}, Deps{Chatlists: fake}, zap.NewNop(), clock.System)
fresh, err := r.onChatlistsCheckChatlistInvite(WithUserID(context.Background(), 2002), "slug-rpc")
if err != nil {
t.Fatalf("check fresh rpc: %v", err)
}
invite, ok := fresh.(*tg.ChatlistsChatlistInvite)
if !ok || invite.Title.Text != "Team" || len(invite.Peers) != 1 {
t.Fatalf("fresh = %#v, want chatlistInvite with title and peer", fresh)
}
if emoticon, ok := invite.GetEmoticon(); !ok || emoticon != ":)" {
t.Fatalf("fresh emoticon = %q ok %v, want folder emoji", emoticon, ok)
}
local := domain.DialogFolder{ID: 5, Title: "Team", IsChatlist: true, IncludePeers: []domain.DialogFolderPeer{peer}}
fake.checkPreview.LocalFolder = &local
fake.checkPreview.Membership = &domain.ChatlistMembership{UserID: 2002, LocalFilterID: 5, Slug: "slug-rpc"}
fake.checkPreview.Missing = nil
fake.checkPreview.Already = []domain.DialogFolderPeer{peer}
already, err := r.onChatlistsCheckChatlistInvite(WithUserID(context.Background(), 2002), "slug-rpc")
if err != nil {
t.Fatalf("check already rpc: %v", err)
}
gotAlready, ok := already.(*tg.ChatlistsChatlistInviteAlready)
if !ok || gotAlready.FilterID != 5 || len(gotAlready.AlreadyPeers) != 1 {
t.Fatalf("already = %#v, want already filter id and peer", already)
}
}
func TestChatlistsEditInviteRevokedFlagRoundTrips(t *testing.T) {
fake := &fakeChatlistsService{
exportInvite: domain.ChatlistInvite{
OwnerUserID: 1001,
FilterID: 2,
Slug: "slug-rpc",
Title: "Main link",
Revoked: true,
},
}
r := New(Config{PublicBaseURL: "https://telesrv.net"}, Deps{Chatlists: fake}, zap.NewNop(), clock.System)
var flags bin.Fields
flags.Set(0)
got, err := r.onChatlistsEditExportedInvite(WithUserID(context.Background(), 1001), &tg.ChatlistsEditExportedInviteRequest{
Flags: flags,
Chatlist: tg.InputChatlistDialogFilter{FilterID: 2},
Slug: "slug-rpc",
})
if err != nil {
t.Fatalf("edit rpc: %v", err)
}
if !fake.editRevoke {
t.Fatalf("service revoke = false, want true")
}
if !got.Flags.Has(0) {
t.Fatalf("edited invite flags = %v, want revoked bit", got.Flags)
}
}
type fakeChatlistsService struct {
exportFolder domain.DialogFolder
exportInvite domain.ChatlistInvite
exportPeers []domain.DialogFolderPeer
checkPreview domain.ChatlistInvitePreview
joinResult domain.ChatlistJoinResult
joinPeers []domain.DialogFolderPeer
updates domain.ChatlistUpdates
joinUpdFolder domain.DialogFolder
editRevoke bool
}
func (f *fakeChatlistsService) ExportInvite(_ context.Context, _ int64, _ int, _ string, peers []domain.DialogFolderPeer, _ int) (domain.DialogFolder, domain.ChatlistInvite, error) {
f.exportPeers = append([]domain.DialogFolderPeer(nil), peers...)
return f.exportFolder, f.exportInvite, nil
}
func (f *fakeChatlistsService) ListInvites(context.Context, int64, int) ([]domain.ChatlistInvite, error) {
return []domain.ChatlistInvite{f.exportInvite}, nil
}
func (f *fakeChatlistsService) EditInvite(_ context.Context, _ int64, _ int, _ string, _ *string, _ *[]domain.DialogFolderPeer, revoke bool) (domain.ChatlistInvite, error) {
f.editRevoke = revoke
return f.exportInvite, nil
}
func (f *fakeChatlistsService) DeleteInvite(context.Context, int64, int, string) (domain.DialogFolder, bool, error) {
return domain.DialogFolder{}, false, nil
}
func (f *fakeChatlistsService) CheckInvite(context.Context, int64, string) (domain.ChatlistInvitePreview, error) {
return f.checkPreview, nil
}
func (f *fakeChatlistsService) JoinInvite(_ context.Context, _ int64, _ string, peers []domain.DialogFolderPeer, _ int) (domain.ChatlistJoinResult, error) {
f.joinPeers = append([]domain.DialogFolderPeer(nil), peers...)
return f.joinResult, nil
}
func (f *fakeChatlistsService) GetUpdates(context.Context, int64, int) (domain.ChatlistUpdates, error) {
return f.updates, nil
}
func (f *fakeChatlistsService) JoinUpdates(context.Context, int64, int, []domain.DialogFolderPeer, int) (domain.ChatlistJoinResult, error) {
return domain.ChatlistJoinResult{Folder: f.joinUpdFolder}, nil
}
func (f *fakeChatlistsService) HideUpdates(context.Context, int64, int) error {
return nil
}
func (f *fakeChatlistsService) Leave(context.Context, int64, int, []domain.DialogFolderPeer, int) (domain.ChatlistLeaveResult, error) {
return domain.ChatlistLeaveResult{FilterID: 3}, nil
}
func (f *fakeChatlistsService) LeaveSuggestions(context.Context, int64, int) ([]domain.DialogFolderPeer, error) {
return f.exportInvite.Peers, nil
}

View file

@ -466,7 +466,7 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
return out
}
func tgChannelFull(view domain.ChannelView) *tg.ChannelFull {
func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.ChannelFull {
ch := view.Channel
full := &tg.ChannelFull{
// 广播频道的订阅者列表仅管理员可见(官方语义):非管理员订阅者拿到
@ -496,7 +496,11 @@ func tgChannelFull(view domain.ChannelView) *tg.ChannelFull {
full.SetAvailableMinID(view.Self.AvailableMinID)
}
if view.ExportedInvite != nil && !view.ExportedInvite.Revoked {
full.SetExportedInvite(tgExportedChannelInvite(*view.ExportedInvite))
baseURL := ""
if len(publicBaseURL) > 0 {
baseURL = publicBaseURL[0]
}
full.SetExportedInvite(tgExportedChannelInvite(*view.ExportedInvite, baseURL))
}
if ch.AdminsCount > 0 {
full.SetAdminsCount(ch.AdminsCount)

View file

@ -234,6 +234,7 @@ func tgDialogFilter(folder domain.DialogFolder) tg.DialogFilterClass {
title := tg.TextWithEntities{Text: folder.Title, Entities: tgMessageEntities(folder.TitleEntities)}
if folder.IsChatlist {
out := &tg.DialogFilterChatlist{
HasMyInvites: folder.HasMyInvites,
TitleNoanimate: folder.TitleNoanimate,
ID: folder.ID,
Title: title,

View file

@ -26,7 +26,7 @@ const groupCallUnmutedVideoLimit = 30
var groupCallStreamDCID = 2
// tgGroupCall 把 call 行转为 TL groupCall。
func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool) tg.GroupCallClass {
func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool, publicBaseURL ...string) tg.GroupCallClass {
if !call.Active() {
return &tg.GroupCallDiscarded{ID: call.ID, AccessHash: call.AccessHash, Duration: call.Duration}
}
@ -64,7 +64,11 @@ func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool) tg.G
out.SetTitle(call.Title)
}
if call.Conference() {
if link := conferenceCanonicalInviteLink(call.InviteSlug); link != "" {
baseURL := ""
if len(publicBaseURL) > 0 {
baseURL = publicBaseURL[0]
}
if link := conferenceCanonicalInviteLink(call.InviteSlug, baseURL); link != "" {
out.SetInviteLink(link)
} else if call.InviteLink != "" {
out.SetInviteLink(call.InviteLink)

View file

@ -400,6 +400,21 @@ type DialogsService interface {
EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error
}
// ChatlistsService 抽象 Telegram shared folders / chatlists 业务。
type ChatlistsService interface {
ExportInvite(ctx context.Context, userID int64, filterID int, title string, peers []domain.DialogFolderPeer, date int) (domain.DialogFolder, domain.ChatlistInvite, error)
ListInvites(ctx context.Context, userID int64, filterID int) ([]domain.ChatlistInvite, error)
EditInvite(ctx context.Context, userID int64, filterID int, slug string, title *string, peers *[]domain.DialogFolderPeer, revoke bool) (domain.ChatlistInvite, error)
DeleteInvite(ctx context.Context, userID int64, filterID int, slug string) (domain.DialogFolder, bool, error)
CheckInvite(ctx context.Context, userID int64, slug string) (domain.ChatlistInvitePreview, error)
JoinInvite(ctx context.Context, userID int64, slug string, peers []domain.DialogFolderPeer, date int) (domain.ChatlistJoinResult, error)
GetUpdates(ctx context.Context, userID int64, localFilterID int) (domain.ChatlistUpdates, error)
JoinUpdates(ctx context.Context, userID int64, localFilterID int, peers []domain.DialogFolderPeer, date int) (domain.ChatlistJoinResult, error)
HideUpdates(ctx context.Context, userID int64, localFilterID int) error
Leave(ctx context.Context, userID int64, localFilterID int, peers []domain.DialogFolderPeer, date int) (domain.ChatlistLeaveResult, error)
LeaveSuggestions(ctx context.Context, userID int64, localFilterID int) ([]domain.DialogFolderPeer, error)
}
// MessagesService 抽象消息历史、搜索与已读。
type MessagesService interface {
SendPrivateText(ctx context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error)
@ -697,6 +712,7 @@ type Deps struct {
BootstrapUpdates store.BootstrapUpdateJobStore
Contacts ContactsService
Dialogs DialogsService
Chatlists ChatlistsService
Messages MessagesService
Stories StoriesService
Channels ChannelsService

View file

@ -41,6 +41,18 @@ func filterIncludeEmptyErr() error { return tgerr.New(400, "FILTER_INCLUDE_EMPTY
func filterTitleEmptyErr() error { return tgerr.New(400, "FILTER_TITLE_EMPTY") }
func filterNotSupportedErr() error { return tgerr.New(400, "FILTER_NOT_SUPPORTED") }
func inviteSlugEmptyErr() error { return tgerr.New(400, "INVITE_SLUG_EMPTY") }
func inviteSlugExpiredErr() error { return tgerr.New(400, "INVITE_SLUG_EXPIRED") }
func invitesTooMuchErr() error { return tgerr.New(400, "INVITES_TOO_MUCH") }
func chatlistsTooMuchErr() error { return tgerr.New(400, "CHATLISTS_TOO_MUCH") }
func peersListEmptyErr() error { return tgerr.New(400, "PEERS_LIST_EMPTY") }
// peerIDInvalidErr 表示目标 peer 不存在或当前阶段不支持。
func peerIDInvalidErr() error { return tgerr.New(400, "PEER_ID_INVALID") }

View file

@ -12,7 +12,7 @@ import (
// registerHelp 注册 help.* RPC handlerDC 配置、最近 DC
func (r *Router) registerHelp(d *tg.ServerDispatcher) {
d.OnHelpGetConfig(func(ctx context.Context) (*tg.Config, error) {
return tdesktop.BuildConfig(r.cfg.DC, r.cfg.IP, r.cfg.Port, r.clock.Now()), nil
return tdesktop.BuildConfig(r.cfg.DC, r.cfg.IP, r.cfg.Port, r.clock.Now(), r.cfg.PublicBaseURL), nil
})
d.OnHelpGetNearestDC(func(ctx context.Context) (*tg.NearestDC, error) {
return tdesktop.NearestDC(r.cfg.DC), nil

View file

@ -966,6 +966,7 @@ func (r *Router) dialogFolderFromTG(ctx context.Context, userID int64, id int, f
color, hasColor := f.GetColor()
return domain.DialogFolder{
ID: id,
HasMyInvites: f.HasMyInvites,
TitleNoanimate: f.TitleNoanimate,
Title: title,
TitleEntities: domainMessageEntities(f.Title.Entities),

View file

@ -419,6 +419,11 @@ func (r *Router) domainFolderPeerFromInputPeer(ctx context.Context, userID int64
return domain.Peer{}, 0, peerIDInvalidErr()
}
return out, 0, nil
case *tg.InputPeerChat:
if p.ChatID <= 0 {
return domain.Peer{}, 0, peerIDInvalidErr()
}
return domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChatID}, 0, nil
case *tg.InputPeerSelf:
if userID == 0 {
return domain.Peer{}, 0, peerIDInvalidErr()

View file

@ -3,6 +3,7 @@ package rpc
import (
"context"
"encoding/binary"
"net/url"
"github.com/gotd/td/tg"
"go.uber.org/zap"
@ -40,7 +41,7 @@ func (r *Router) onPhoneCreateConferenceCall(ctx context.Context, req *tg.PhoneC
return nil, groupCallErr(err)
}
out := r.groupCallUpdateContainer(ctx, userID, domain.Channel{},
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, true)}, []int64{userID})
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, true, r.cfg.PublicBaseURL)}, []int64{userID})
if !req.Join {
return out, nil
}
@ -81,7 +82,7 @@ func (r *Router) onPhoneExportGroupCallInvite(ctx context.Context, req *tg.Phone
return nil, groupCallInvalidErr()
}
if scope.call.Conference() {
link := conferenceExportInviteLink(scope.call)
link := conferenceExportInviteLink(scope.call, r.cfg.PublicBaseURL)
if link == "" {
return nil, groupCallInvalidErr()
}
@ -93,21 +94,25 @@ func (r *Router) onPhoneExportGroupCallInvite(ctx context.Context, req *tg.Phone
if scope.channel.Username == "" {
return nil, publicChannelMissingErr()
}
return &tg.PhoneExportedGroupCallInvite{Link: "https://telesrv.net/" + scope.channel.Username}, nil
return &tg.PhoneExportedGroupCallInvite{Link: r.publicLink(scope.channel.Username)}, nil
}
func conferenceExportInviteLink(call domain.GroupCall) string {
if link := conferenceCanonicalInviteLink(call.InviteSlug); link != "" {
func conferenceExportInviteLink(call domain.GroupCall, publicBaseURL ...string) string {
if link := conferenceCanonicalInviteLink(call.InviteSlug, publicBaseURL...); link != "" {
return link
}
return call.InviteLink
}
func conferenceCanonicalInviteLink(slug string) string {
func conferenceCanonicalInviteLink(slug string, publicBaseURL ...string) string {
if slug == "" {
return ""
}
return "https://telesrv.net/call/" + slug + "?slug=" + slug
baseURL := ""
if len(publicBaseURL) > 0 {
baseURL = publicBaseURL[0]
}
return publicLinkQueryWithBaseURL(baseURL, "call/"+slug, url.Values{"slug": []string{slug}})
}
func (r *Router) onPhoneInviteConferenceCallParticipant(ctx context.Context, req *tg.PhoneInviteConferenceCallParticipantRequest) (tg.UpdatesClass, error) {
@ -208,7 +213,7 @@ func (r *Router) onPhoneDeclineConferenceCallInvite(ctx context.Context, msgID i
}
r.pushConferenceGroupCallUpdate(ctx, call)
return r.groupCallUpdateContainer(ctx, userID, domain.Channel{},
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, userID == call.CreatorUserID)}, []int64{inv.InviterUserID, inv.InviteeUserID}), nil
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, userID == call.CreatorUserID, r.cfg.PublicBaseURL)}, []int64{inv.InviterUserID, inv.InviteeUserID}), nil
}
func (r *Router) onPhoneDeleteConferenceCallParticipants(ctx context.Context, req *tg.PhoneDeleteConferenceCallParticipantsRequest) (tg.UpdatesClass, error) {

View file

@ -246,7 +246,7 @@ func (r *Router) onPhoneCreateGroupCall(ctx context.Context, req *tg.PhoneCreate
r.pushGroupCallServiceMessage(ctx, userID, serviceRes)
}
// 响应updateGroupCall + 服务消息发起设备视角。TDesktop 创建后自行 joinGroupCall。
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, userID, true)}
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, userID, true, r.cfg.PublicBaseURL)}
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
out := r.groupCallUpdateContainer(ctx, userID, channel, update, []int64{userID})
if serviceRes.Event.Pts != 0 {
@ -368,7 +368,7 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
Version: mut.Call.Version,
}, []int64{scope.userID})
out.Updates = append(out.Updates, &tg.UpdateGroupCallConnection{Params: tg.DataJSON{Data: params}})
callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage())}
callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage(), r.cfg.PublicBaseURL)}
if channel.ID != 0 {
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
}
@ -395,7 +395,7 @@ func (r *Router) onPhoneLeaveGroupCall(ctx context.Context, req *tg.PhoneLeaveGr
if errors.Is(err, domain.ErrGroupCallNotJoined) {
// 幂等:重复 leave / sweeper 已清,返回当前快照。
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel,
groupCallUpdateFor(scope.channel, scope.call, scope.userID, false), nil), nil
groupCallUpdateFor(scope.channel, scope.call, scope.userID, false, r.cfg.PublicBaseURL), nil), nil
}
if err != nil {
return nil, groupCallErr(err)
@ -411,7 +411,7 @@ func (r *Router) onPhoneLeaveGroupCall(ctx context.Context, req *tg.PhoneLeaveGr
Version: mut.Call.Version,
}, []int64{scope.userID})
if mut.Call.Conference() && !mut.Call.Active() {
out.Updates = append(out.Updates, groupCallUpdateFor(domain.Channel{}, mut.Call, scope.userID, scope.userID == mut.Call.CreatorUserID))
out.Updates = append(out.Updates, groupCallUpdateFor(domain.Channel{}, mut.Call, scope.userID, scope.userID == mut.Call.CreatorUserID, r.cfg.PublicBaseURL))
}
return out, nil
}
@ -435,7 +435,7 @@ func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCa
if call.Conference() {
r.pushConferenceGroupCallUpdateTo(ctx, call, groupCallParticipantUserIDs(activeBeforeDiscard))
return r.groupCallUpdateContainer(ctx, scope.userID, domain.Channel{},
groupCallUpdateFor(domain.Channel{}, call, scope.userID, true), nil), nil
groupCallUpdateFor(domain.Channel{}, call, scope.userID, true, r.cfg.PublicBaseURL), nil), nil
}
// RTMP 直播结束:断开推流并清空缓冲(观众后续拉流转 resync/停止)。
if call.RtmpStream && r.deps.LiveStreams != nil {
@ -463,7 +463,7 @@ func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCa
r.pushGroupCallServiceMessage(ctx, scope.userID, serviceRes)
}
out := r.groupCallUpdateContainer(ctx, scope.userID, channel,
groupCallUpdateFor(channel, call, scope.userID, true), nil)
groupCallUpdateFor(channel, call, scope.userID, true, r.cfg.PublicBaseURL), nil)
if serviceRes.Event.Pts != 0 {
if msgUpdate := tgChannelUpdate(scope.userID, serviceRes.Event); msgUpdate != nil {
out.Updates = append(out.Updates, msgUpdate)
@ -499,7 +499,7 @@ func (r *Router) onPhoneGetGroupCall(ctx context.Context, req *tg.PhoneGetGroupC
// 定时通话:回填 viewer 自己的开播提醒订阅(客户端 reload 全量重建本地状态)。
call := r.applyScheduleSubscription(ctx, scope.call, scope.userID)
return &tg.PhoneGroupCall{
Call: tgGroupCall(call, scope.userID, scope.canManage()),
Call: tgGroupCall(call, scope.userID, scope.canManage(), r.cfg.PublicBaseURL),
Participants: tgGroupCallParticipants(page.Participants, scope.userID),
ParticipantsNextOffset: page.NextOffset,
Chats: chats,
@ -589,8 +589,8 @@ func (r *Router) onPhoneCheckGroupCall(ctx context.Context, req *tg.PhoneCheckGr
return out, nil
}
func groupCallUpdateFor(channel domain.Channel, call domain.GroupCall, viewerUserID int64, canManage bool) *tg.UpdateGroupCall {
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerUserID, canManage)}
func groupCallUpdateFor(channel domain.Channel, call domain.GroupCall, viewerUserID int64, canManage bool, publicBaseURL ...string) *tg.UpdateGroupCall {
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerUserID, canManage, publicBaseURL...)}
if channel.ID != 0 {
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
}

View file

@ -233,7 +233,7 @@ func (r *Router) onPhoneEditGroupCallTitle(ctx context.Context, req *tg.PhoneEdi
}
r.pushGroupCallUpdate(ctx, scope.channel, call)
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel,
groupCallUpdateFor(scope.channel, call, scope.userID, true), nil), nil
groupCallUpdateFor(scope.channel, call, scope.userID, true, r.cfg.PublicBaseURL), nil), nil
}
func (r *Router) onPhoneToggleGroupCallSettings(ctx context.Context, req *tg.PhoneToggleGroupCallSettingsRequest) (tg.UpdatesClass, error) {
@ -261,7 +261,7 @@ func (r *Router) onPhoneToggleGroupCallSettings(ctx context.Context, req *tg.Pho
}
r.pushGroupCallUpdate(ctx, scope.channel, call)
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel,
groupCallUpdateFor(scope.channel, call, scope.userID, true), nil), nil
groupCallUpdateFor(scope.channel, call, scope.userID, true, r.cfg.PublicBaseURL), nil), nil
}
const maxInviteToGroupCallUsers = 10

View file

@ -106,7 +106,7 @@ func (r *Router) onPhoneLeaveGroupCallPresentation(ctx context.Context, call tg.
// 幂等快照:未在共享/已清理时返回当前态而非报错(屏幕共享既已下架即达成目的)。
idempotentSnapshot := func() (tg.UpdatesClass, error) {
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel,
groupCallUpdateFor(scope.channel, scope.call, scope.userID, scope.canManage()), nil), nil
groupCallUpdateFor(scope.channel, scope.call, scope.userID, scope.canManage(), r.cfg.PublicBaseURL), nil), nil
}
self, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, scope.userID)
if err != nil {

View file

@ -96,7 +96,7 @@ func (r *Router) joinRtmpGroupCall(ctx context.Context, scope *groupCallScope, r
// updateGroupCall 必须先于 updateGroupCallConnectionTDesktop 按序 applyUpdates
// 处理 connection 时若还没从 groupCall 读到 stream_dc_id 会打
// "Api Error: Empty stream_dc_id" 并 fallback 主 DC。
callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage())}
callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage(), r.cfg.PublicBaseURL)}
if channel.ID != 0 {
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
}

View file

@ -52,7 +52,7 @@ func (r *Router) onPhoneStartScheduledGroupCall(ctx context.Context, in tg.Input
if !changed {
// 幂等:已开始,只回快照,不重复扇出/服务消息。
return r.groupCallUpdateContainer(ctx, scope.userID, channel,
groupCallUpdateFor(channel, call, scope.userID, true), nil), nil
groupCallUpdateFor(channel, call, scope.userID, true, r.cfg.PublicBaseURL), nil), nil
}
now := int(r.clock.Now().Unix())
// started 服务消息(与即时创建的 started 同构)。
@ -75,7 +75,7 @@ func (r *Router) onPhoneStartScheduledGroupCall(ctx context.Context, in tg.Input
r.pushGroupCallServiceMessage(ctx, scope.userID, serviceRes)
}
out := r.groupCallUpdateContainer(ctx, scope.userID, channel,
groupCallUpdateFor(channel, call, scope.userID, true), nil)
groupCallUpdateFor(channel, call, scope.userID, true, r.cfg.PublicBaseURL), nil)
if serviceRes.Event.Pts != 0 {
if msgUpdate := tgChannelUpdate(scope.userID, serviceRes.Event); msgUpdate != nil {
out.Updates = append(out.Updates, msgUpdate)
@ -105,7 +105,7 @@ func (r *Router) onPhoneToggleGroupCallStartSubscription(ctx context.Context, re
call := scope.call
call.ScheduleStartSubscribed = req.Subscribed
// 订阅是 per-viewer 私有状态:响应给本设备,推送同步本人其它在线设备即可。
update := groupCallUpdateFor(scope.channel, call, scope.userID, scope.canManage())
update := groupCallUpdateFor(scope.channel, call, scope.userID, scope.canManage(), r.cfg.PublicBaseURL)
r.pushUserMessage(ctx, scope.userID, "schedule subscription update",
r.groupCallUpdateContainer(ctx, scope.userID, scope.channel, update, nil))
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel, update, nil), nil

View file

@ -75,7 +75,7 @@ func (r *Router) pushGroupCallUpdate(ctx context.Context, channel domain.Channel
if subscribed != nil {
_, viewerCall.ScheduleStartSubscribed = subscribed[viewerID]
}
update := &tg.UpdateGroupCall{Call: tgGroupCall(viewerCall, viewerID, false)}
update := &tg.UpdateGroupCall{Call: tgGroupCall(viewerCall, viewerID, false, r.cfg.PublicBaseURL)}
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
r.pushUserMessage(ctx, viewerID, "group call update",
r.groupCallUpdateContainer(ctx, viewerID, channel, update, []int64{call.CreatorUserID}))
@ -138,7 +138,7 @@ func (r *Router) pushConferenceGroupCallUpdate(ctx context.Context, call domain.
func (r *Router) pushConferenceGroupCallUpdateTo(ctx context.Context, call domain.GroupCall, extraUserIDs []int64) {
recipients := r.conferenceCallRecipientsWith(ctx, call.ID, extraUserIDs)
for _, viewerID := range recipients {
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerID, viewerID == call.CreatorUserID)}
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerID, viewerID == call.CreatorUserID, r.cfg.PublicBaseURL)}
r.pushUserMessage(ctx, viewerID, "conference call update",
r.groupCallUpdateContainer(ctx, viewerID, domain.Channel{}, update, []int64{call.CreatorUserID}))
}

View file

@ -31,7 +31,7 @@ func (r *Router) onPremiumGetBoostsStatus(ctx context.Context, peer tg.InputPeer
if err != nil {
return nil, premiumBoostErr(err)
}
return tgPremiumBoostsStatus(view.Channel.ID, status), nil
return tgPremiumBoostsStatus(view.Channel.ID, status, r.cfg.PublicBaseURL), nil
}
func (r *Router) onPremiumGetBoostsList(ctx context.Context, req *tg.PremiumGetBoostsListRequest) (*tg.PremiumBoostsList, error) {
@ -190,13 +190,13 @@ func (r *Router) currentPremiumUntil(ctx context.Context) (int64, int, error) {
return userID, u.PremiumUntil, nil
}
func tgPremiumBoostsStatus(channelID int64, in domain.PremiumBoostStatus) *tg.PremiumBoostsStatus {
func tgPremiumBoostsStatus(channelID int64, in domain.PremiumBoostStatus, publicBaseURL string) *tg.PremiumBoostsStatus {
out := &tg.PremiumBoostsStatus{
MyBoost: len(in.MyBoostSlots) > 0,
Level: in.Level,
CurrentLevelBoosts: in.CurrentLevelBoosts,
Boosts: in.Boosts,
BoostURL: fmt.Sprintf("https://telesrv.net/boost?c=%d", channelID),
BoostURL: publicLinkParamWithBaseURL(publicBaseURL, "boost", "c", fmt.Sprintf("%d", channelID)),
}
if in.GiftBoosts > 0 {
out.SetGiftBoosts(in.GiftBoosts)

View file

@ -0,0 +1,35 @@
package rpc
import (
"net/url"
"telesrv/internal/links"
)
func (r *Router) publicLink(path string) string {
return publicLinkWithBaseURL(r.cfg.PublicBaseURL, path)
}
func (r *Router) publicLinkQuery(path string, query url.Values) string {
return publicLinkQueryWithBaseURL(r.cfg.PublicBaseURL, path, query)
}
func (r *Router) publicLinkParam(path, key, value string) string {
return r.publicLinkQuery(path, url.Values{key: []string{value}})
}
func (r *Router) publicLinkHost() string {
return links.Host(r.cfg.PublicBaseURL)
}
func publicLinkWithBaseURL(baseURL, path string) string {
return links.Build(baseURL, path, nil)
}
func publicLinkQueryWithBaseURL(baseURL, path string, query url.Values) string {
return links.Build(baseURL, path, query)
}
func publicLinkParamWithBaseURL(baseURL, path, key, value string) string {
return publicLinkQueryWithBaseURL(baseURL, path, url.Values{key: []string{value}})
}

View file

@ -70,6 +70,8 @@ type Config struct {
// RtmpIngestURL 是 getGroupCallStreamRtmpUrl 返回给推流端OBS的服务器地址
// 形如 "rtmp://<host>:<port>/live"。为空时回落 "rtmp://<AdvertiseIP>:2400/live"。
RtmpIngestURL string
// PublicBaseURL 是所有客户端可见 telesrv 链接的公开根 URL。
PublicBaseURL string
// TempKeyResolveCacheTTL 是 PFS temp→perm auth key 解析的进程内缓存有效期。>0 时同一 temp key
// 在 TTL 内复用上次解析、跳过每帧 ResolveAuthKey 的 PG 查询0默认/测试)关闭=每帧重校验。
// 显式撤销会删除协议 auth key、清缓存并断开活跃连接TTL 只影响自然过期或异常路径下的
@ -186,6 +188,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
r.registerUpload(d)
r.registerPhotos(d)
r.registerFolders(d)
r.registerChatlists(d)
r.registerContacts(d)
r.registerLangpack(d)
r.registerStories(d)

View file

@ -495,7 +495,7 @@ func (r *Router) onStoriesExportStoryLink(ctx context.Context, req *tg.StoriesEx
return nil, storyIDInvalidErr()
}
}
return &tg.ExportedStoryLink{Link: storyExportLink(peer, req.ID)}, nil
return &tg.ExportedStoryLink{Link: r.storyExportLink(peer, req.ID)}, nil
}
func validateStoriesExportStoryLinkRequest(req *tg.StoriesExportStoryLinkRequest) error {
@ -3018,8 +3018,8 @@ func uniqueStoryIDs(ids []int) []int {
return out
}
func storyExportLink(peer domain.Peer, storyID int) string {
return fmt.Sprintf("https://telesrv.local/story/%s/%d/%d", peer.Type, peer.ID, storyID)
func (r *Router) storyExportLink(peer domain.Peer, storyID int) string {
return r.publicLink(fmt.Sprintf("story/%s/%d/%d", peer.Type, peer.ID, storyID))
}
func (r *Router) recordStoryChange(ctx context.Context, userID int64, story domain.Story) error {

View file

@ -5889,7 +5889,7 @@ func TestStoriesLongtailCompatHandlers(t *testing.T) {
if err != nil {
t.Fatalf("export story link: %v", err)
}
if link.Link != "https://telesrv.local/story/user/9311/1" {
if link.Link != "https://telesrv.net/story/user/9311/1" {
t.Fatalf("export story link = %q, want deterministic telesrv link", link.Link)
}
if _, err := r.onStoriesExportStoryLink(reqCtx, &tg.StoriesExportStoryLinkRequest{

View file

@ -0,0 +1,25 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// ChatlistStore persists exported shared-folder links and imported memberships.
type ChatlistStore interface {
CountInvites(ctx context.Context, ownerUserID int64, filterID int) (int, error)
CountActiveInvites(ctx context.Context, ownerUserID int64, filterID int) (int, error)
SaveInvite(ctx context.Context, invite domain.ChatlistInvite) (domain.ChatlistInvite, error)
GetInvite(ctx context.Context, ownerUserID int64, filterID int, slug string) (domain.ChatlistInvite, bool, error)
GetInviteBySlug(ctx context.Context, slug string) (domain.ChatlistInvite, bool, error)
ListInvites(ctx context.Context, ownerUserID int64, filterID int) ([]domain.ChatlistInvite, error)
DeleteInvite(ctx context.Context, ownerUserID int64, filterID int, slug string) (bool, error)
CountMemberships(ctx context.Context, userID int64) (int, error)
SaveMembership(ctx context.Context, membership domain.ChatlistMembership) error
GetMembershipBySlug(ctx context.Context, userID int64, slug string) (domain.ChatlistMembership, bool, error)
GetMembershipByLocalFilter(ctx context.Context, userID int64, localFilterID int) (domain.ChatlistMembership, bool, error)
DeleteMembershipByLocalFilter(ctx context.Context, userID int64, localFilterID int) (bool, error)
SetMembershipHidden(ctx context.Context, userID int64, localFilterID int, hidden bool) (bool, error)
}

View file

@ -0,0 +1,212 @@
package memory
import (
"context"
"sort"
"sync"
"telesrv/internal/domain"
)
type ChatlistStore struct {
mu sync.RWMutex
nextInvite int64
invites map[string]domain.ChatlistInvite
memberships map[chatlistMembershipKey]domain.ChatlistMembership
}
type chatlistMembershipKey struct {
userID int64
localFilterID int
}
// NewChatlistStore creates an in-memory ChatlistStore.
func NewChatlistStore() *ChatlistStore {
return &ChatlistStore{
nextInvite: 1,
invites: make(map[string]domain.ChatlistInvite),
memberships: make(map[chatlistMembershipKey]domain.ChatlistMembership),
}
}
func (s *ChatlistStore) CountInvites(_ context.Context, ownerUserID int64, filterID int) (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
count := 0
for _, invite := range s.invites {
if invite.OwnerUserID == ownerUserID && invite.FilterID == filterID && !invite.Deleted {
count++
}
}
return count, nil
}
func (s *ChatlistStore) CountActiveInvites(_ context.Context, ownerUserID int64, filterID int) (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
count := 0
for _, invite := range s.invites {
if invite.OwnerUserID == ownerUserID && invite.FilterID == filterID && !invite.Deleted && !invite.Revoked {
count++
}
}
return count, nil
}
func (s *ChatlistStore) SaveInvite(_ context.Context, invite domain.ChatlistInvite) (domain.ChatlistInvite, error) {
s.mu.Lock()
defer s.mu.Unlock()
if invite.Slug == "" {
return domain.ChatlistInvite{}, domain.ErrChatlistInviteInvalid
}
if existing, ok := s.invites[invite.Slug]; ok && (existing.OwnerUserID != invite.OwnerUserID || existing.FilterID != invite.FilterID) {
return domain.ChatlistInvite{}, domain.ErrChatlistSlugOccupied
}
if invite.ID == 0 {
if existing, ok := s.invites[invite.Slug]; ok {
invite.ID = existing.ID
if invite.Date == 0 {
invite.Date = existing.Date
}
} else {
invite.ID = s.nextInvite
s.nextInvite++
}
}
invite.Deleted = false
s.invites[invite.Slug] = cloneChatlistInvite(invite)
return cloneChatlistInvite(invite), nil
}
func (s *ChatlistStore) GetInvite(_ context.Context, ownerUserID int64, filterID int, slug string) (domain.ChatlistInvite, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
invite, ok := s.invites[slug]
if !ok || invite.Deleted || invite.OwnerUserID != ownerUserID || invite.FilterID != filterID {
return domain.ChatlistInvite{}, false, nil
}
return cloneChatlistInvite(invite), true, nil
}
func (s *ChatlistStore) GetInviteBySlug(_ context.Context, slug string) (domain.ChatlistInvite, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
invite, ok := s.invites[slug]
if !ok || invite.Deleted || invite.Revoked {
return domain.ChatlistInvite{}, false, nil
}
return cloneChatlistInvite(invite), true, nil
}
func (s *ChatlistStore) ListInvites(_ context.Context, ownerUserID int64, filterID int) ([]domain.ChatlistInvite, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]domain.ChatlistInvite, 0)
for _, invite := range s.invites {
if invite.OwnerUserID == ownerUserID && invite.FilterID == filterID && !invite.Deleted {
out = append(out, cloneChatlistInvite(invite))
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Date != out[j].Date {
return out[i].Date < out[j].Date
}
return out[i].Slug < out[j].Slug
})
return out, nil
}
func (s *ChatlistStore) DeleteInvite(_ context.Context, ownerUserID int64, filterID int, slug string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
invite, ok := s.invites[slug]
if !ok || invite.Deleted || invite.OwnerUserID != ownerUserID || invite.FilterID != filterID {
return false, nil
}
invite.Deleted = true
s.invites[slug] = invite
return true, nil
}
func (s *ChatlistStore) CountMemberships(_ context.Context, userID int64) (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
count := 0
for key := range s.memberships {
if key.userID == userID {
count++
}
}
return count, nil
}
func (s *ChatlistStore) SaveMembership(_ context.Context, membership domain.ChatlistMembership) error {
s.mu.Lock()
defer s.mu.Unlock()
if membership.UserID == 0 || membership.LocalFilterID == 0 || membership.Slug == "" {
return domain.ErrChatlistInvalid
}
key := chatlistMembershipKey{userID: membership.UserID, localFilterID: membership.LocalFilterID}
for existingKey, existing := range s.memberships {
if existingKey != key && existing.UserID == membership.UserID && existing.Slug == membership.Slug {
delete(s.memberships, existingKey)
}
}
s.memberships[key] = cloneChatlistMembership(membership)
return nil
}
func (s *ChatlistStore) GetMembershipBySlug(_ context.Context, userID int64, slug string) (domain.ChatlistMembership, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, membership := range s.memberships {
if membership.UserID == userID && membership.Slug == slug {
return cloneChatlistMembership(membership), true, nil
}
}
return domain.ChatlistMembership{}, false, nil
}
func (s *ChatlistStore) GetMembershipByLocalFilter(_ context.Context, userID int64, localFilterID int) (domain.ChatlistMembership, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
membership, ok := s.memberships[chatlistMembershipKey{userID: userID, localFilterID: localFilterID}]
if !ok {
return domain.ChatlistMembership{}, false, nil
}
return cloneChatlistMembership(membership), true, nil
}
func (s *ChatlistStore) DeleteMembershipByLocalFilter(_ context.Context, userID int64, localFilterID int) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
key := chatlistMembershipKey{userID: userID, localFilterID: localFilterID}
if _, ok := s.memberships[key]; !ok {
return false, nil
}
delete(s.memberships, key)
return true, nil
}
func (s *ChatlistStore) SetMembershipHidden(_ context.Context, userID int64, localFilterID int, hidden bool) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
key := chatlistMembershipKey{userID: userID, localFilterID: localFilterID}
membership, ok := s.memberships[key]
if !ok {
return false, nil
}
changed := membership.HiddenUpdates != hidden
membership.HiddenUpdates = hidden
s.memberships[key] = membership
return changed, nil
}
func cloneChatlistInvite(invite domain.ChatlistInvite) domain.ChatlistInvite {
invite.Peers = append([]domain.DialogFolderPeer(nil), invite.Peers...)
return invite
}
func cloneChatlistMembership(membership domain.ChatlistMembership) domain.ChatlistMembership {
return membership
}

View file

@ -0,0 +1,279 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type ChatlistStore struct {
db sqlcgen.DBTX
}
func NewChatlistStore(db sqlcgen.DBTX) *ChatlistStore {
return &ChatlistStore{db: db}
}
func (s *ChatlistStore) CountInvites(ctx context.Context, ownerUserID int64, filterID int) (int, error) {
var count int
if err := s.db.QueryRow(ctx, `
SELECT count(*)::int
FROM chatlist_invites
WHERE owner_user_id = $1 AND filter_id = $2 AND NOT deleted`, ownerUserID, filterID).Scan(&count); err != nil {
return 0, fmt.Errorf("count chatlist invites: %w", err)
}
return count, nil
}
func (s *ChatlistStore) CountActiveInvites(ctx context.Context, ownerUserID int64, filterID int) (int, error) {
var count int
if err := s.db.QueryRow(ctx, `
SELECT count(*)::int
FROM chatlist_invites
WHERE owner_user_id = $1 AND filter_id = $2 AND NOT deleted AND NOT revoked`, ownerUserID, filterID).Scan(&count); err != nil {
return 0, fmt.Errorf("count active chatlist invites: %w", err)
}
return count, nil
}
func (s *ChatlistStore) SaveInvite(ctx context.Context, invite domain.ChatlistInvite) (domain.ChatlistInvite, error) {
peers, err := json.Marshal(invite.Peers)
if err != nil {
return domain.ChatlistInvite{}, fmt.Errorf("marshal chatlist invite peers: %w", err)
}
row := s.db.QueryRow(ctx, `
INSERT INTO chatlist_invites (owner_user_id, filter_id, slug, title, peers, revoked, deleted, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5::jsonb, $6, false,
CASE WHEN $7::int > 0 THEN to_timestamp($7::int) ELSE now() END, now())
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
peers = EXCLUDED.peers,
revoked = EXCLUDED.revoked,
deleted = false,
updated_at = now()
WHERE chatlist_invites.owner_user_id = EXCLUDED.owner_user_id
AND chatlist_invites.filter_id = EXCLUDED.filter_id
RETURNING id, owner_user_id, filter_id, slug, title, peers::text, revoked, deleted,
EXTRACT(EPOCH FROM created_at)::int`, invite.OwnerUserID, invite.FilterID, invite.Slug, invite.Title, peers, invite.Revoked, invite.Date)
out, err := scanChatlistInvite(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChatlistInvite{}, domain.ErrChatlistSlugOccupied
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return domain.ChatlistInvite{}, domain.ErrChatlistSlugOccupied
}
return domain.ChatlistInvite{}, fmt.Errorf("save chatlist invite: %w", err)
}
return out, nil
}
func (s *ChatlistStore) GetInvite(ctx context.Context, ownerUserID int64, filterID int, slug string) (domain.ChatlistInvite, bool, error) {
invite, err := scanChatlistInvite(s.db.QueryRow(ctx, `
SELECT id, owner_user_id, filter_id, slug, title, peers::text, revoked, deleted,
EXTRACT(EPOCH FROM created_at)::int
FROM chatlist_invites
WHERE owner_user_id = $1 AND filter_id = $2 AND slug = $3 AND NOT deleted`, ownerUserID, filterID, slug))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChatlistInvite{}, false, nil
}
return domain.ChatlistInvite{}, false, fmt.Errorf("get chatlist invite: %w", err)
}
return invite, true, nil
}
func (s *ChatlistStore) GetInviteBySlug(ctx context.Context, slug string) (domain.ChatlistInvite, bool, error) {
invite, err := scanChatlistInvite(s.db.QueryRow(ctx, `
SELECT id, owner_user_id, filter_id, slug, title, peers::text, revoked, deleted,
EXTRACT(EPOCH FROM created_at)::int
FROM chatlist_invites
WHERE slug = $1 AND NOT deleted AND NOT revoked`, slug))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChatlistInvite{}, false, nil
}
return domain.ChatlistInvite{}, false, fmt.Errorf("get chatlist invite by slug: %w", err)
}
return invite, true, nil
}
func (s *ChatlistStore) ListInvites(ctx context.Context, ownerUserID int64, filterID int) ([]domain.ChatlistInvite, error) {
rows, err := s.db.Query(ctx, `
SELECT id, owner_user_id, filter_id, slug, title, peers::text, revoked, deleted,
EXTRACT(EPOCH FROM created_at)::int
FROM chatlist_invites
WHERE owner_user_id = $1 AND filter_id = $2 AND NOT deleted
ORDER BY created_at ASC, slug ASC`, ownerUserID, filterID)
if err != nil {
return nil, fmt.Errorf("list chatlist invites: %w", err)
}
defer rows.Close()
out := make([]domain.ChatlistInvite, 0)
for rows.Next() {
invite, err := scanChatlistInvite(rows)
if err != nil {
return nil, err
}
out = append(out, invite)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list chatlist invites rows: %w", err)
}
return out, nil
}
func (s *ChatlistStore) DeleteInvite(ctx context.Context, ownerUserID int64, filterID int, slug string) (bool, error) {
tag, err := s.db.Exec(ctx, `
UPDATE chatlist_invites
SET deleted = true, updated_at = now()
WHERE owner_user_id = $1 AND filter_id = $2 AND slug = $3 AND NOT deleted`, ownerUserID, filterID, slug)
if err != nil {
return false, fmt.Errorf("delete chatlist invite: %w", err)
}
return tag.RowsAffected() > 0, nil
}
func (s *ChatlistStore) CountMemberships(ctx context.Context, userID int64) (int, error) {
var count int
if err := s.db.QueryRow(ctx, `
SELECT count(*)::int
FROM chatlist_memberships
WHERE user_id = $1`, userID).Scan(&count); err != nil {
return 0, fmt.Errorf("count chatlist memberships: %w", err)
}
return count, nil
}
func (s *ChatlistStore) SaveMembership(ctx context.Context, membership domain.ChatlistMembership) error {
if membership.Date == 0 {
membership.Date = nowUnix()
}
exec := func(ctx context.Context, db sqlcgen.DBTX) error {
if _, err := db.Exec(ctx, `
DELETE FROM chatlist_memberships
WHERE user_id = $1 AND slug = $2 AND local_filter_id <> $3`, membership.UserID, membership.Slug, membership.LocalFilterID); err != nil {
return fmt.Errorf("dedupe chatlist membership: %w", err)
}
if _, err := db.Exec(ctx, `
INSERT INTO chatlist_memberships (
user_id, local_filter_id, owner_user_id, owner_filter_id, slug, hidden_updates, joined_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
CASE WHEN $7::int > 0 THEN to_timestamp($7::int) ELSE now() END,
now()
)
ON CONFLICT (user_id, local_filter_id) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
owner_filter_id = EXCLUDED.owner_filter_id,
slug = EXCLUDED.slug,
hidden_updates = EXCLUDED.hidden_updates,
updated_at = now()`, membership.UserID, membership.LocalFilterID, membership.OwnerUserID, membership.OwnerFilterID, membership.Slug, membership.HiddenUpdates, membership.Date); err != nil {
return fmt.Errorf("save chatlist membership: %w", err)
}
return nil
}
beginner, ok := s.db.(txBeginner)
if !ok {
return exec(ctx, s.db)
}
tx, err := beginner.Begin(ctx)
if err != nil {
return fmt.Errorf("begin save chatlist membership: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := exec(ctx, tx); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit save chatlist membership: %w", err)
}
committed = true
return nil
}
func (s *ChatlistStore) GetMembershipBySlug(ctx context.Context, userID int64, slug string) (domain.ChatlistMembership, bool, error) {
membership, err := scanChatlistMembership(s.db.QueryRow(ctx, `
SELECT user_id, local_filter_id, owner_user_id, owner_filter_id, slug, hidden_updates,
EXTRACT(EPOCH FROM joined_at)::int
FROM chatlist_memberships
WHERE user_id = $1 AND slug = $2`, userID, slug))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChatlistMembership{}, false, nil
}
return domain.ChatlistMembership{}, false, fmt.Errorf("get chatlist membership by slug: %w", err)
}
return membership, true, nil
}
func (s *ChatlistStore) GetMembershipByLocalFilter(ctx context.Context, userID int64, localFilterID int) (domain.ChatlistMembership, bool, error) {
membership, err := scanChatlistMembership(s.db.QueryRow(ctx, `
SELECT user_id, local_filter_id, owner_user_id, owner_filter_id, slug, hidden_updates,
EXTRACT(EPOCH FROM joined_at)::int
FROM chatlist_memberships
WHERE user_id = $1 AND local_filter_id = $2`, userID, localFilterID))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChatlistMembership{}, false, nil
}
return domain.ChatlistMembership{}, false, fmt.Errorf("get chatlist membership by local filter: %w", err)
}
return membership, true, nil
}
func (s *ChatlistStore) DeleteMembershipByLocalFilter(ctx context.Context, userID int64, localFilterID int) (bool, error) {
tag, err := s.db.Exec(ctx, `
DELETE FROM chatlist_memberships
WHERE user_id = $1 AND local_filter_id = $2`, userID, localFilterID)
if err != nil {
return false, fmt.Errorf("delete chatlist membership: %w", err)
}
return tag.RowsAffected() > 0, nil
}
func (s *ChatlistStore) SetMembershipHidden(ctx context.Context, userID int64, localFilterID int, hidden bool) (bool, error) {
tag, err := s.db.Exec(ctx, `
UPDATE chatlist_memberships
SET hidden_updates = $3, updated_at = now()
WHERE user_id = $1 AND local_filter_id = $2 AND hidden_updates IS DISTINCT FROM $3`, userID, localFilterID, hidden)
if err != nil {
return false, fmt.Errorf("set chatlist membership hidden: %w", err)
}
return tag.RowsAffected() > 0, nil
}
func scanChatlistInvite(row rowScanner) (domain.ChatlistInvite, error) {
var invite domain.ChatlistInvite
var peersJSON string
if err := row.Scan(&invite.ID, &invite.OwnerUserID, &invite.FilterID, &invite.Slug, &invite.Title, &peersJSON, &invite.Revoked, &invite.Deleted, &invite.Date); err != nil {
return domain.ChatlistInvite{}, err
}
if peersJSON != "" {
if err := json.Unmarshal([]byte(peersJSON), &invite.Peers); err != nil {
return domain.ChatlistInvite{}, fmt.Errorf("decode chatlist invite peers: %w", err)
}
}
return invite, nil
}
func scanChatlistMembership(row rowScanner) (domain.ChatlistMembership, error) {
var membership domain.ChatlistMembership
if err := row.Scan(&membership.UserID, &membership.LocalFilterID, &membership.OwnerUserID, &membership.OwnerFilterID, &membership.Slug, &membership.HiddenUpdates, &membership.Date); err != nil {
return domain.ChatlistMembership{}, err
}
return membership, nil
}

View file

@ -7,11 +7,13 @@ import (
"context"
"errors"
"fmt"
"net/url"
"reflect"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/links"
"telesrv/internal/store"
)
@ -22,6 +24,10 @@ func baseNow() int {
return int(time.Now().Unix())
}
func conferenceInviteLink(slug string) string {
return links.Build(links.DefaultPublicBaseURL, "call/"+slug, url.Values{"slug": []string{slug}})
}
// GroupCallStoreFactory 为每个用例提供干净的 store 与不冲突的 channel id。
type GroupCallStoreFactory func(t *testing.T) (st store.GroupCallStore, channelID int64)
@ -340,7 +346,7 @@ func contractConferenceChainBlocks(t *testing.T, factory GroupCallStoreFactory)
slug := fmt.Sprintf("contract-chain-%d", channelID)
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
ID: channelID*100 + 51, AccessHash: channelID*100 + 58, CreatorUserID: 1,
InviteSlug: slug, InviteLink: "https://telesrv.net/call/" + slug + "?slug=" + slug,
InviteSlug: slug, InviteLink: conferenceInviteLink(slug),
RandomID: channelID*100 + 51, CreatedAt: now,
})
if err != nil {
@ -388,7 +394,7 @@ func contractConferenceRecipientsTerminalAccess(t *testing.T, factory GroupCallS
slug := fmt.Sprintf("contract-recipient-%d", channelID)
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
ID: channelID*100 + 61, AccessHash: channelID*100 + 68, CreatorUserID: 1,
InviteSlug: slug, InviteLink: "https://telesrv.net/call/" + slug + "?slug=" + slug,
InviteSlug: slug, InviteLink: conferenceInviteLink(slug),
RandomID: channelID*100 + 61, CreatedAt: now,
})
if err != nil {
@ -434,10 +440,11 @@ func contractConferenceEmptyDiscards(t *testing.T, factory GroupCallStoreFactory
st, channelID := factory(t)
ctx := context.Background()
now := baseNow()
emptySlug := fmt.Sprintf("contract-empty-%d", channelID)
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
ID: channelID*100 + 71, AccessHash: channelID*100 + 78, CreatorUserID: 1,
InviteSlug: fmt.Sprintf("contract-empty-%d", channelID),
InviteLink: fmt.Sprintf("https://telesrv.net/call/contract-empty-%d?slug=contract-empty-%d", channelID, channelID),
InviteSlug: emptySlug,
InviteLink: conferenceInviteLink(emptySlug),
RandomID: channelID*100 + 71,
CreatedAt: now,
})
@ -464,10 +471,11 @@ func contractConferenceEmptyDiscards(t *testing.T, factory GroupCallStoreFactory
t.Fatalf("join empty discarded conference err = %v, want ErrGroupCallDiscarded", err)
}
resetSlug := fmt.Sprintf("contract-reset-empty-%d", channelID)
resetCall, err := st.CreateConferenceCall(ctx, domain.GroupCall{
ID: channelID*100 + 81, AccessHash: channelID*100 + 88, CreatorUserID: 1,
InviteSlug: fmt.Sprintf("contract-reset-empty-%d", channelID),
InviteLink: fmt.Sprintf("https://telesrv.net/call/contract-reset-empty-%d?slug=contract-reset-empty-%d", channelID, channelID),
InviteSlug: resetSlug,
InviteLink: conferenceInviteLink(resetSlug),
RandomID: channelID*100 + 81,
CreatedAt: now + 10,
})

View file

@ -15,10 +15,9 @@ import (
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/links"
)
const defaultPublicBaseURL = "https://telesrv.net"
type Config struct {
Addr string
PublicBaseURL string
@ -50,9 +49,9 @@ func Start(ctx context.Context, cfg Config, resolver Resolver, logger *zap.Logge
return nil, err
}
go func() {
logger.Info("Sticker link Web endpoint enabled", zap.String("addr", addr), zap.String("public_base_url", normalizePublicBaseURL(cfg.PublicBaseURL)))
logger.Info("Public link Web endpoint enabled", zap.String("addr", addr), zap.String("public_base_url", normalizePublicBaseURL(cfg.PublicBaseURL)))
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Warn("Sticker link Web endpoint exited", zap.Error(err))
logger.Warn("Public link Web endpoint exited", zap.Error(err))
}
}()
go func() {
@ -73,6 +72,7 @@ func NewHandler(resolver Resolver, publicBaseURL string) http.Handler {
mux.HandleFunc("GET /healthz", h.healthz)
mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers)
mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji)
mux.HandleFunc("GET /addlist/{slug}", h.addList)
return mux
}
@ -94,6 +94,30 @@ func (h *handler) addEmoji(w http.ResponseWriter, r *http.Request) {
h.serveSet(w, r, "addemoji")
}
func (h *handler) addList(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.PathValue("slug"))
if !validSlugPath(slug) {
http.NotFound(w, r)
return
}
app := appURL("addlist", "slug", slug)
data := pageData{
Title: "Shared Folder",
KindLabel: "shared folder",
Subtitle: slug,
Description: "This page opens the app so you can preview and add this shared folder.",
CanonicalURL: h.publicURL("addlist", slug),
AppURL: template.URL(app),
LegacyTgURL: template.URL(legacyTgURL("addlist", "slug", slug)),
}
data.AppURLJS = template.JS(strconv.Quote(app))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=60")
if err := landingTemplate.Execute(w, data); err != nil {
http.Error(w, "render shared folder page failed", http.StatusInternalServerError)
}
}
func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind string) {
shortName := strings.TrimSpace(r.PathValue("shortName"))
if !validShortNamePath(shortName) {
@ -114,23 +138,22 @@ func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind stri
}
canonicalKind := linkKind(set)
if canonicalKind != pathKind {
http.Redirect(w, r, h.setURL(canonicalKind, set.ShortName), http.StatusPermanentRedirect)
http.Redirect(w, r, h.publicURL(canonicalKind, set.ShortName), http.StatusPermanentRedirect)
return
}
count := set.Count
if count == 0 {
count = len(docs)
}
app := appURL(canonicalKind, set.ShortName)
app := appURL(canonicalKind, "set", set.ShortName)
data := pageData{
Title: fallbackTitle(set),
ShortName: set.ShortName,
Count: count,
KindLabel: kindLabel(set),
ItemNoun: itemNoun(set, count),
CanonicalURL: h.setURL(canonicalKind, set.ShortName),
Subtitle: fmt.Sprintf("@%s · %d %s", set.ShortName, count, itemNoun(set, count)),
Description: "This page opens the app so you can preview and install the set. Files are still fetched by the app through MTProto.",
CanonicalURL: h.publicURL(canonicalKind, set.ShortName),
AppURL: template.URL(app),
LegacyTgURL: template.URL(legacyTgURL(canonicalKind, set.ShortName)),
LegacyTgURL: template.URL(legacyTgURL(canonicalKind, "set", set.ShortName)),
}
data.AppURLJS = template.JS(strconv.Quote(app))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@ -140,18 +163,14 @@ func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind stri
}
}
func (h *handler) setURL(kind, shortName string) string {
return h.publicBaseURL + "/" + kind + "/" + url.PathEscape(shortName)
func (h *handler) publicURL(kind, value string) string {
return h.publicBaseURL + "/" + kind + "/" + url.PathEscape(value)
}
func normalizePublicBaseURL(raw string) string {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
return defaultPublicBaseURL
}
u, err := url.Parse(raw)
u, err := url.Parse(links.NormalizeBaseURL(raw))
if err != nil || u.Scheme == "" || u.Host == "" {
return defaultPublicBaseURL
return links.DefaultPublicBaseURL
}
u.Path = strings.TrimRight(u.Path, "/")
u.RawQuery = ""
@ -176,6 +195,10 @@ func validShortNamePath(shortName string) bool {
return true
}
func validSlugPath(slug string) bool {
return links.ValidChatlistSlug(slug)
}
func linkKind(set domain.StickerSet) string {
if set.Kind == domain.StickerSetKindEmoji || set.Emojis {
return "addemoji"
@ -214,20 +237,23 @@ func itemNoun(set domain.StickerSet, count int) string {
return "stickers"
}
func appURL(kind, shortName string) string {
return "telesrv://" + kind + "?set=" + url.QueryEscape(shortName)
func appURL(kind, key, value string) string {
return schemeURL("telesrv", kind, key, value)
}
func legacyTgURL(kind, shortName string) string {
return "tg://" + kind + "?set=" + url.QueryEscape(shortName)
func legacyTgURL(kind, key, value string) string {
return schemeURL("tg", kind, key, value)
}
func schemeURL(scheme, kind, key, value string) string {
return scheme + "://" + kind + "?" + key + "=" + url.QueryEscape(value)
}
type pageData struct {
Title string
ShortName string
Count int
KindLabel string
ItemNoun string
Subtitle string
Description string
CanonicalURL string
AppURL template.URL
LegacyTgURL template.URL
@ -242,7 +268,7 @@ var landingTemplate = template.Must(template.New("landing").Parse(`<!doctype htm
<title>{{.Title}} - telesrv</title>
<link rel="canonical" href="{{.CanonicalURL}}">
<meta property="og:title" content="{{.Title}}">
<meta property="og:description" content="{{.Count}} {{.ItemNoun}} in this {{.KindLabel}}.">
<meta property="og:description" content="{{.Description}}">
<meta property="og:url" content="{{.CanonicalURL}}">
<meta name="robots" content="noindex">
<style>
@ -267,9 +293,9 @@ var landingTemplate = template.Must(template.New("landing").Parse(`<!doctype htm
<main>
<p class="meta">{{.KindLabel}}</p>
<h1>{{.Title}}</h1>
<p class="meta">@{{.ShortName}} · {{.Count}} {{.ItemNoun}}</p>
<p class="meta">{{.Subtitle}}</p>
<p><a class="button" href="{{.AppURL}}">Open in telesrv</a></p>
<p>This page opens the app so you can preview and install the set. Files are still fetched by the app through MTProto.</p>
<p>{{.Description}}</p>
<p class="meta">Old test clients only: <a class="raw" href="{{.LegacyTgURL}}">open with tg://</a></p>
<p class="meta"><a class="raw" href="{{.CanonicalURL}}">{{.CanonicalURL}}</a></p>
</main>

View file

@ -81,6 +81,32 @@ func TestHandlerServesEmojiLandingPage(t *testing.T) {
}
}
func TestHandlerServesChatlistLandingPage(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/addlist/zNhytIbwRwjaC2GH", nil)
NewHandler(fakeResolver{}, "http://127.0.0.1:2401").ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{
"Shared Folder",
"http://127.0.0.1:2401/addlist/zNhytIbwRwjaC2GH",
"telesrv://addlist?slug=zNhytIbwRwjaC2GH",
"tg://addlist?slug=zNhytIbwRwjaC2GH",
"preview and add this shared folder",
} {
if !strings.Contains(body, want) {
t.Fatalf("body missing %q:\n%s", want, body)
}
}
if strings.Contains(body, `window.location.href = "tg://`) {
t.Fatalf("landing page must not auto-open tg:// and steal official Telegram:\n%s", body)
}
}
func TestHandlerRedirectsMismatchedKindToCanonicalURL(t *testing.T) {
resolver := fakeResolver{
"emoji_pack": {
@ -110,6 +136,8 @@ func TestHandlerNotFoundForMissingOrInvalidShortName(t *testing.T) {
"/addstickers/missing_pack",
"/addstickers/bad-name",
"/addemoji/%E4%B8%AD%E6%96%87",
"/addlist/bad!slug",
"/addlist/%E4%B8%AD%E6%96%87",
} {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)