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

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