chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -0,0 +1,535 @@
package rpc
import (
"context"
"time"
"github.com/gotd/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: authKeyID,
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)而非异步解析
// 的 20s,避免慢/挂上游把 RPC worker 钉死。命中(含负缓存的 empty)返回 ok=true,调用方据 state
// 决定;抓取失败返回 false。未启用返回 false。
func (r *Router) resolveWebPageForRequest(ctx context.Context, url string) (domain.MessageWebPage, bool) {
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 {
if hash != 0 {
return &tg.MessagesAllStickersNotModified{}
}
return &tg.MessagesAllStickers{Sets: []tg.StickerSet{}}
}
func messagesFeaturedStickersEmpty(hash int64) tg.MessagesFeaturedStickersClass {
if hash != 0 {
return &tg.MessagesFeaturedStickersNotModified{Count: 0}
}
return &tg.MessagesFeaturedStickers{
Count: 0,
Sets: []tg.StickerSetCoveredClass{},
Unread: []int64{},
}
}