533 lines
17 KiB
Go
533 lines
17 KiB
Go
package rpc
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
|
||
"github.com/iamxvbaba/td/tg"
|
||
|
||
"strings"
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/seed/catalog"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// webpageRequestResolveBudget 是交互式读 RPC(getWebPagePreview/getWebPage)未命中缓存时
|
||
// 同步抓取的短预算上界——远小于异步解析的 20s,避免慢/挂上游把 RPC worker 钉死。
|
||
const webpageRequestResolveBudget = 6 * time.Second
|
||
|
||
func (r *Router) onMessagesGetSavedHistory(ctx context.Context, req *tg.MessagesGetSavedHistoryRequest) (tg.MessagesMessagesClass, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if err := validateSavedHistoryBounds(req.OffsetID, req.OffsetDate, req.AddOffset, req.Limit, req.MaxID, req.MinID); err != nil {
|
||
return nil, err
|
||
}
|
||
parentPeer, hasParent, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
savedPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if hasParent {
|
||
mono, isMono, err := r.resolveMonoforumForAdmin(ctx, userID, parentPeer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !isMono {
|
||
// 普通频道(非 monoforum)传 parent_peer:保持旧的良性空响应。
|
||
if req.Hash != 0 {
|
||
return &tg.MessagesMessagesNotModified{Count: 0}, nil
|
||
}
|
||
return &tg.MessagesMessages{
|
||
Messages: []tg.MessageClass{},
|
||
Chats: r.savedHistoryChats(ctx, userID, hasParent, parentPeer, req.Peer),
|
||
Users: []tg.UserClass{},
|
||
}, nil
|
||
}
|
||
// parent_peer = monoforum:返回该订阅者(req.Peer)在频道私信内的历史。
|
||
return r.monoforumSavedHistory(ctx, userID, mono, savedPeer, req.Limit, req.OffsetID)
|
||
}
|
||
if r.deps.Messages == nil {
|
||
return messagesNotModifiedOrEmpty(req.Hash), nil
|
||
}
|
||
list, err := r.deps.Messages.GetHistory(ctx, userID, domain.MessageFilter{
|
||
HasPeer: true,
|
||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||
SavedPeer: savedPeer,
|
||
OffsetID: req.OffsetID,
|
||
OffsetDate: req.OffsetDate,
|
||
AddOffset: req.AddOffset,
|
||
Limit: req.Limit,
|
||
MaxID: req.MaxID,
|
||
MinID: req.MinID,
|
||
Hash: req.Hash,
|
||
NeedTotalCount: true,
|
||
})
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if req.Hash != 0 && list.Hash == req.Hash {
|
||
return &tg.MessagesMessagesNotModified{Count: list.Count}, nil
|
||
}
|
||
out := tgMessagesMessages(userID, r.enrichMessageList(ctx, userID, list))
|
||
// saved peer 是频道(频道转发子会话)时补 chat 上下文。
|
||
if chats := r.savedHistoryChats(ctx, userID, false, domain.Peer{}, req.Peer); len(chats) > 0 {
|
||
switch m := out.(type) {
|
||
case *tg.MessagesMessages:
|
||
m.Chats = mergeTGChats(m.Chats, chats)
|
||
case *tg.MessagesMessagesSlice:
|
||
m.Chats = mergeTGChats(m.Chats, chats)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (r *Router) onMessagesReadSavedHistory(ctx context.Context, req *tg.MessagesReadSavedHistoryRequest) (bool, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return false, internalErr()
|
||
}
|
||
if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID {
|
||
return false, messageIDInvalidErr()
|
||
}
|
||
if err := r.validateRequiredSavedHistoryParentPeer(ctx, userID, req.ParentPeer); err != nil {
|
||
return false, err
|
||
}
|
||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
||
return false, err
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
func (r *Router) onMessagesDeleteSavedHistory(ctx context.Context, req *tg.MessagesDeleteSavedHistoryRequest) (*tg.MessagesAffectedHistory, error) {
|
||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID {
|
||
return nil, messageIDInvalidErr()
|
||
}
|
||
minDate, hasMinDate := req.GetMinDate()
|
||
if hasMinDate && minDate < 0 {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
maxDate, hasMaxDate := req.GetMaxDate()
|
||
if hasMaxDate && maxDate < 0 {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
_, hasParent, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
savedPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if hasParent || r.deps.Messages == nil {
|
||
// monoforum 子会话删除范围外:parent 校验后保持 no-op 语义。
|
||
return r.affectedHistory(ctx, authKeyID, userID, 0)
|
||
}
|
||
sessionID, _ := SessionIDFrom(ctx)
|
||
res, err := r.deps.Messages.DeleteSavedHistory(ctx, userID, domain.DeleteSavedHistoryRequest{
|
||
OwnerUserID: userID,
|
||
SavedPeer: savedPeer,
|
||
MaxID: req.MaxID,
|
||
MinDate: minDate,
|
||
MaxDate: maxDate,
|
||
Date: int(r.clock.Now().Unix()),
|
||
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
|
||
OriginSessionID: sessionID,
|
||
})
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if len(res.MessageIDs) == 0 || res.Event.Pts == 0 {
|
||
return r.affectedHistory(ctx, authKeyID, userID, 0)
|
||
}
|
||
offset := 0
|
||
if res.More {
|
||
offset = 1
|
||
}
|
||
return &tg.MessagesAffectedHistory{
|
||
Pts: res.Event.Pts,
|
||
PtsCount: res.Event.PtsCount,
|
||
Offset: offset,
|
||
}, nil
|
||
}
|
||
|
||
func (r *Router) onMessagesGetCommonChats(ctx context.Context, req *tg.MessagesGetCommonChatsRequest) (tg.MessagesChatsClass, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if req.Limit < 0 || req.Limit > maxCommonChatsLimit {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
if req.MaxID < 0 {
|
||
return nil, messageIDInvalidErr()
|
||
}
|
||
target, found, err := r.userFromInput(ctx, userID, req.UserID)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if !found || target.ID == 0 || target.ID == userID {
|
||
return nil, userIDInvalidErr()
|
||
}
|
||
if req.Limit == 0 || r.deps.Channels == nil {
|
||
return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil
|
||
}
|
||
common, err := r.deps.Channels.CommonChannels(ctx, userID, domain.CommonChannelsRequest{
|
||
UserID: userID,
|
||
TargetUserID: target.ID,
|
||
MaxID: req.MaxID,
|
||
Limit: req.Limit,
|
||
})
|
||
if err != nil {
|
||
return nil, channelInvalidErr(err)
|
||
}
|
||
chats := make([]tg.ChatClass, 0, len(common.Channels))
|
||
for _, ch := range common.Channels {
|
||
chats = append(chats, tgChannelChatMin(userID, ch))
|
||
}
|
||
return &tg.MessagesChats{Chats: chats}, nil
|
||
}
|
||
|
||
func (r *Router) onMessagesGetAttachedStickers(ctx context.Context, media tg.InputStickeredMediaClass) ([]tg.StickerSetCoveredClass, error) {
|
||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if media == nil {
|
||
return nil, mediaEmptyErr()
|
||
}
|
||
return []tg.StickerSetCoveredClass{}, nil
|
||
}
|
||
|
||
func (r *Router) onMessagesGetCustomEmojiDocuments(ctx context.Context, documentIDs []int64) ([]tg.DocumentClass, error) {
|
||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if len(documentIDs) > maxEmojiDocuments {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
for _, id := range documentIDs {
|
||
if id <= 0 {
|
||
return nil, messageIDInvalidErr()
|
||
}
|
||
}
|
||
if r.deps.Files == nil || len(documentIDs) == 0 {
|
||
return []tg.DocumentClass{}, nil
|
||
}
|
||
docs, err := r.deps.Files.GetDocuments(ctx, documentIDs)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
byID := documentsByID(docs)
|
||
out := make([]tg.DocumentClass, 0, len(documentIDs))
|
||
for _, id := range documentIDs {
|
||
if d, ok := byID[id]; ok {
|
||
out = append(out, tgDocument(d))
|
||
} else {
|
||
out = append(out, &tg.DocumentEmpty{ID: id})
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (r *Router) onMessagesGetEmojiKeywords(ctx context.Context, langcode string) (*tg.EmojiKeywordsDifference, error) {
|
||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if err := validateEmojiLangCode(langcode); err != nil {
|
||
return nil, err
|
||
}
|
||
if set, ok := catalog.EmojiKeywords(langcode); ok {
|
||
return &tg.EmojiKeywordsDifference{
|
||
LangCode: langcode,
|
||
FromVersion: 0,
|
||
Version: set.Version,
|
||
Keywords: catalogEmojiKeywords(set.Keywords),
|
||
}, nil
|
||
}
|
||
return &tg.EmojiKeywordsDifference{
|
||
LangCode: langcode,
|
||
FromVersion: 0,
|
||
Version: 0,
|
||
Keywords: []tg.EmojiKeywordClass{},
|
||
}, nil
|
||
}
|
||
|
||
func (r *Router) onMessagesGetEmojiKeywordsDifference(ctx context.Context, req *tg.MessagesGetEmojiKeywordsDifferenceRequest) (*tg.EmojiKeywordsDifference, error) {
|
||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if err := validateEmojiLangCode(req.LangCode); err != nil {
|
||
return nil, err
|
||
}
|
||
if req.FromVersion < 0 {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
// 我们只固化了单一版本词典:客户端版本落后则下发全量(0..version),否则回显空增量。
|
||
if set, ok := catalog.EmojiKeywords(req.LangCode); ok && req.FromVersion < set.Version {
|
||
return &tg.EmojiKeywordsDifference{
|
||
LangCode: req.LangCode,
|
||
FromVersion: req.FromVersion,
|
||
Version: set.Version,
|
||
Keywords: catalogEmojiKeywords(set.Keywords),
|
||
}, nil
|
||
}
|
||
version := req.FromVersion
|
||
if set, ok := catalog.EmojiKeywords(req.LangCode); ok {
|
||
version = set.Version
|
||
}
|
||
return &tg.EmojiKeywordsDifference{
|
||
LangCode: req.LangCode,
|
||
FromVersion: req.FromVersion,
|
||
Version: version,
|
||
Keywords: []tg.EmojiKeywordClass{},
|
||
}, nil
|
||
}
|
||
|
||
// catalogEmojiKeywords 把 catalog 词典条目转成 TL emojiKeyword 向量。
|
||
func catalogEmojiKeywords(kws []catalog.EmojiKeyword) []tg.EmojiKeywordClass {
|
||
out := make([]tg.EmojiKeywordClass, 0, len(kws))
|
||
for _, k := range kws {
|
||
out = append(out, &tg.EmojiKeyword{Keyword: k.Keyword, Emoticons: k.Emoticons})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (r *Router) onMessagesGetExtendedMedia(ctx context.Context, req *tg.MessagesGetExtendedMediaRequest) (tg.UpdatesClass, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if err := validateMessageIDVector(req.ID); err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
||
return nil, err
|
||
}
|
||
return tgEmptyUpdates(int(r.clock.Now().Unix())), nil
|
||
}
|
||
|
||
func (r *Router) onMessagesGetWebPagePreview(ctx context.Context, req *tg.MessagesGetWebPagePreviewRequest) (*tg.MessagesWebPagePreview, error) {
|
||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
message := strings.TrimSpace(req.Message)
|
||
if message == "" {
|
||
return nil, messageEmptyErr()
|
||
}
|
||
if utf8.RuneCountInString(message) > maxSendMessageTextLength || len(req.Entities) > maxMessageEntityCount {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
media := r.webPagePreviewMedia(ctx, req.Message, req.Entities)
|
||
return &tg.MessagesWebPagePreview{
|
||
Media: media,
|
||
Chats: []tg.ChatClass{},
|
||
Users: []tg.UserClass{},
|
||
}, nil
|
||
}
|
||
|
||
// onMessagesGetWebPage 返回某 URL 的已解析链接预览(instant view 入口;cached_page 不填充)。
|
||
// hash 匹配则回 webPageNotModified 让客户端复用本地缓存;无预览/未启用/失败回 webPageEmpty。
|
||
func (r *Router) onMessagesGetWebPage(ctx context.Context, req *tg.MessagesGetWebPageRequest) (*tg.MessagesWebPage, error) {
|
||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
return r.webPageForURL(ctx, req.URL, req.Hash), nil
|
||
}
|
||
|
||
// webPageForURL 解析 URL 的链接预览供 getWebPage 返回。hash 匹配回 webPageNotModified;
|
||
// 无预览/未启用/失败回 webPageEmpty(带 URL)。cached_page(instant view)不填充。
|
||
func (r *Router) webPageForURL(ctx context.Context, url string, hash int) *tg.MessagesWebPage {
|
||
emptyResult := func() *tg.MessagesWebPage {
|
||
page := &tg.WebPageEmpty{}
|
||
if url != "" {
|
||
page.SetURL(url)
|
||
}
|
||
return &tg.MessagesWebPage{Webpage: page, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}
|
||
}
|
||
page, ok := r.resolveWebPageForRequest(ctx, url)
|
||
if !ok || page.State != domain.MessageWebPageStateDone {
|
||
return emptyResult()
|
||
}
|
||
if hash != 0 && hash == page.Hash {
|
||
return &tg.MessagesWebPage{Webpage: &tg.WebPageNotModified{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}
|
||
}
|
||
return &tg.MessagesWebPage{Webpage: tgWebPage(page), Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}
|
||
}
|
||
|
||
// webPagePreviewMedia 解析消息内首个链接,返回输入框预览用的 media。getWebPagePreview 是同步
|
||
// 探针:只有已解析出 done 卡片才返回 messageMediaWebPage,其余一律 messageMediaEmpty(不返回
|
||
// pending——客户端不会对 preview 轮询 pending)。抓取失败/未启用一律降级为空,绝不报错。
|
||
func (r *Router) webPagePreviewMedia(ctx context.Context, message string, entities []tg.MessageEntityClass) tg.MessageMediaClass {
|
||
url, ok := firstPreviewableURL(message, entities)
|
||
if !ok {
|
||
return &tg.MessageMediaEmpty{}
|
||
}
|
||
page, ok := r.resolveWebPageForRequest(ctx, url)
|
||
if !ok || page.State != domain.MessageWebPageStateDone {
|
||
return &tg.MessageMediaEmpty{}
|
||
}
|
||
return tgWebPageMedia(page)
|
||
}
|
||
|
||
// resolveWebPageForRequest 为交互式读 RPC 解析链接预览:先查缓存(LookupWebPage,命中即返回,
|
||
// 不抓取不阻塞);未命中才同步抓取,但用受限短预算(webpageRequestResolveBudget)而非异步解析
|
||
// 的 30s,避免慢/挂上游把 RPC worker 钉死。命中(含负缓存的 empty)返回 ok=true,调用方据 state
|
||
// 决定;抓取失败返回 false。未启用返回 false。
|
||
func (r *Router) resolveWebPageForRequest(ctx context.Context, url string) (domain.MessageWebPage, bool) {
|
||
if page, ok := r.resolveAIComposeStyleWebPage(ctx, url); ok {
|
||
return page, true
|
||
}
|
||
if r.deps.Files == nil {
|
||
return domain.MessageWebPage{}, false
|
||
}
|
||
if page, ok := r.deps.Files.LookupWebPage(ctx, url); ok {
|
||
return page, true
|
||
}
|
||
fctx, cancel := context.WithTimeout(ctx, webpageRequestResolveBudget)
|
||
defer cancel()
|
||
page, err := r.deps.Files.ResolveWebPage(fctx, url)
|
||
if err != nil {
|
||
return domain.MessageWebPage{}, false
|
||
}
|
||
return page, true
|
||
}
|
||
|
||
func tgInputMessageEntities(entities []domain.MessageEntity) []tg.MessageEntityClass {
|
||
// 定时消息到点投递必须与即时发送等价:实体全类型回放(mentionName
|
||
// 在 domain 已解析为 user_id,直接走输出形态)。
|
||
return tgMessageEntities(entities)
|
||
}
|
||
|
||
func mergeTGUsers(base []tg.UserClass, extra []tg.UserClass) []tg.UserClass {
|
||
seen := make(map[int64]struct{}, len(base)+len(extra))
|
||
out := make([]tg.UserClass, 0, len(base)+len(extra))
|
||
add := func(user tg.UserClass) {
|
||
u, ok := user.(*tg.User)
|
||
if !ok || u.ID == 0 {
|
||
out = append(out, user)
|
||
return
|
||
}
|
||
if _, ok := seen[u.ID]; ok {
|
||
return
|
||
}
|
||
seen[u.ID] = struct{}{}
|
||
out = append(out, user)
|
||
}
|
||
for _, user := range base {
|
||
add(user)
|
||
}
|
||
for _, user := range extra {
|
||
add(user)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func mergeTGChats(base []tg.ChatClass, extra []tg.ChatClass) []tg.ChatClass {
|
||
seen := make(map[int64]struct{}, len(base)+len(extra))
|
||
out := make([]tg.ChatClass, 0, len(base)+len(extra))
|
||
add := func(chat tg.ChatClass) {
|
||
var id int64
|
||
switch c := chat.(type) {
|
||
case *tg.Channel:
|
||
id = c.ID
|
||
case *tg.Chat:
|
||
id = c.ID
|
||
}
|
||
if id == 0 {
|
||
out = append(out, chat)
|
||
return
|
||
}
|
||
if _, ok := seen[id]; ok {
|
||
return
|
||
}
|
||
seen[id] = struct{}{}
|
||
out = append(out, chat)
|
||
}
|
||
for _, chat := range base {
|
||
add(chat)
|
||
}
|
||
for _, chat := range extra {
|
||
add(chat)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func optionalString(get func() (string, bool)) string {
|
||
if get == nil {
|
||
return ""
|
||
}
|
||
value, ok := get()
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return value
|
||
}
|
||
|
||
func validateEmojiLangCode(langcode string) error {
|
||
if langcode == "" || len(langcode) > maxEmojiLangCodeLength {
|
||
return limitInvalidErr()
|
||
}
|
||
for _, c := range langcode {
|
||
switch {
|
||
case c >= 'a' && c <= 'z':
|
||
case c >= 'A' && c <= 'Z':
|
||
case c >= '0' && c <= '9':
|
||
case c == '-' || c == '_':
|
||
default:
|
||
return limitInvalidErr()
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateSavedHistoryBounds(offsetID, offsetDate, addOffset, limit, maxID, minID int) error {
|
||
if err := validateHistoryBounds(offsetID, addOffset, limit, maxID, minID); err != nil {
|
||
return err
|
||
}
|
||
if offsetDate < 0 {
|
||
return limitInvalidErr()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Router) validateSavedHistoryParentPeer(ctx context.Context, userID int64, getParent func() (tg.InputPeerClass, bool)) (domain.Peer, bool, error) {
|
||
parentPeer, ok := getParent()
|
||
if !ok {
|
||
return domain.Peer{}, false, nil
|
||
}
|
||
if err := r.validateRequiredSavedHistoryParentPeer(ctx, userID, parentPeer); err != nil {
|
||
return domain.Peer{}, false, err
|
||
}
|
||
parent, _ := r.domainPeerFromInputPeer(userID, parentPeer)
|
||
return parent, true, nil
|
||
}
|
||
|
||
func (r *Router) validateRequiredSavedHistoryParentPeer(ctx context.Context, userID int64, parentPeer tg.InputPeerClass) error {
|
||
parent, err := r.checkedDomainPeerFromInputPeer(ctx, userID, parentPeer)
|
||
if err != nil || parent.Type != domain.PeerTypeChannel {
|
||
return parentPeerInvalidErr()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func messagesAllStickersEmpty(hash int64) tg.MessagesAllStickersClass {
|
||
return &tg.MessagesAllStickers{Hash: 0, Sets: []tg.StickerSet{}}
|
||
}
|
||
|
||
func messagesFeaturedStickersEmpty(hash int64) tg.MessagesFeaturedStickersClass {
|
||
return &tg.MessagesFeaturedStickers{
|
||
Hash: 0,
|
||
Count: 0,
|
||
Sets: []tg.StickerSetCoveredClass{},
|
||
Unread: []int64{},
|
||
}
|
||
}
|