owpengram-server/internal/rpc/bots_inline.go
2026-08-24 22:42:40 +03:00

1234 lines
38 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package rpc
import (
"context"
"net/url"
"strings"
"time"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/store"
)
const (
botInlineQueryTTL = 25 * time.Second
botInlineCacheMaxTTL = 5 * time.Minute
botInlineCacheMaxEntries = 256
)
func botInlineDisabledErr() error { return tgerr.New(400, "BOT_INLINE_DISABLED") }
func botInlineGeoNotAllowedErr() error { return tgerr.New(400, "BOT_INLINE_GEO_NOT_ALLOWED") }
func botWebviewDisabledErr() error { return tgerr.New(400, "BOT_WEBVIEW_DISABLED") }
func queryIDInvalidErr() error { return tgerr.New(400, "QUERY_ID_INVALID") }
func queryIDEmptyErr() error { return tgerr.New(400, "QUERY_ID_EMPTY") }
func resultIDEmptyErr() error { return tgerr.New(400, "RESULT_ID_EMPTY") }
func resultIDInvalidErr() error { return tgerr.New(400, "RESULT_ID_INVALID") }
func resultIDDuplicateErr() error { return tgerr.New(400, "RESULT_ID_DUPLICATE") }
func resultTypeInvalidErr() error { return tgerr.New(400, "RESULT_TYPE_INVALID") }
func resultsTooMuchErr() error { return tgerr.New(400, "RESULTS_TOO_MUCH") }
func sendMessageTypeInvalidErr() error { return tgerr.New(400, "SEND_MESSAGE_TYPE_INVALID") }
func nextOffsetInvalidErr() error { return tgerr.New(400, "NEXT_OFFSET_INVALID") }
func startParamEmptyErr() error { return tgerr.New(400, "START_PARAM_EMPTY") }
func switchPmTextEmptyErr() error { return tgerr.New(400, "SWITCH_PM_TEXT_EMPTY") }
func switchWebviewInvalidErr() error { return tgerr.New(400, "SWITCH_WEBVIEW_URL_INVALID") }
func inlineResultExpiredErr() error { return tgerr.New(400, "INLINE_RESULT_EXPIRED") }
func webDocumentInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_INVALID") }
func webDocumentMimeInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_MIME_INVALID") }
func webDocumentSizeTooBigErr() error { return tgerr.New(400, "WEBDOCUMENT_SIZE_TOO_BIG") }
func webDocumentURLEmptyErr() error { return tgerr.New(400, "WEBDOCUMENT_URL_EMPTY") }
func webDocumentURLInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_URL_INVALID") }
func (r *Router) onMessagesGetInlineBotResults(ctx context.Context, req *tg.MessagesGetInlineBotResultsRequest) (*tg.MessagesBotResults, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if userID == 0 || r.deps.Bots == nil {
return nil, botInvalidErr()
}
if len(req.Offset) > domain.MaxBotInlineNextOffsetLen {
return nil, nextOffsetInvalidErr()
}
bot, found, err := r.userFromInput(ctx, userID, req.Bot)
if err != nil {
return nil, internalErr()
}
if !found || !bot.Bot {
return nil, botInvalidErr()
}
profile, found, err := r.deps.Bots.BotInfo(ctx, bot.ID)
if err != nil {
return nil, internalErr()
}
if !found || profile.InlinePlaceholder == "" {
return nil, botInlineDisabledErr()
}
var queryGeo *domain.MessageGeoPoint
if geo, ok := req.GetGeoPointAsNotEmpty(); ok {
if !profile.InlineGeo {
return nil, botInlineGeoNotAllowedErr()
}
queryGeo, err = domainGeoPointFromInput(geo)
if err != nil {
return nil, err
}
}
// inputPeerEmpty 是合法输入:客户端在未绑定具体聊天的全局搜索场景(如 GIF 面板的
// 类目/搜索,尚未选定发送目标)会这样发。之前这里无条件走 checkedDomainPeerFromInputPeer,
// 把它当无效 peer 拒绝(PEER_ID_INVALID),导致 Android 的 GIF 类目/搜索请求每次都
// 400,客户端只能一直转圈——真实 Telegram 服务端接受这种"无聊天上下文"的查询。
var peer domain.Peer
if _, empty := req.Peer.(*tg.InputPeerEmpty); !empty {
peer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
}
// 内置进程内service bot 分支:@gif 没有 MTProto session、也没有 Bot API 消费者,
// 走下面的「推 updateBotInlineQuery + 挂起 25s」必然超时故同步问 responder。
//
// 结果仍必须登记进 inline registrymessages.sendInlineBotResult 用
// (query_id, result_id) 反查用户选中的那一条query_id==0 会让每次发送都以
// QUERY_ID_EMPTY 失败。registerCachedContext 正是「已有结果、只需分配
// query_id」这条路径与 cacheKey 命中时同一个函数),它不写 cacheKey 查询
// 缓存,因此管理员改动目录后下一次查询依旧立即生效。
if r.deps.ServiceBotInlineResults != nil && r.deps.ServiceBotInlineResults.HandlesInlineBot(bot.ID) {
results, handled, err := r.deps.ServiceBotInlineResults.OnInlineQuery(ctx, bot.ID, userID, req.Query, req.Offset)
if err != nil {
r.log.Warn("service bot inline query",
zap.Int64("bot_user_id", bot.ID), zap.Int64("user_id", userID), zap.Error(err))
return nil, internalErr()
}
if !handled {
return nil, botInvalidErr()
}
registered := r.inlines.registerCachedContext(ctx, r.clock.Now(), bot.ID, userID, peer, results)
return r.tgBotInlineResults(ctx, userID, registered), nil
}
cacheKey := inlineCacheKey{
botUserID: bot.ID,
userID: userID,
peer: peer,
query: req.Query,
offset: req.Offset,
}
if queryGeo != nil {
cacheKey.hasGeo = true
cacheKey.geoLat = queryGeo.Lat
cacheKey.geoLong = queryGeo.Long
cacheKey.geoAccuracy = queryGeo.AccuracyRadius
}
now := r.clock.Now()
if cached, ok := r.inlines.cachedContext(ctx, now, cacheKey); ok {
results := r.inlines.registerCachedContext(ctx, now, bot.ID, userID, peer, cached)
return r.tgBotInlineResults(ctx, userID, results), nil
}
queryID, pending := r.inlines.registerWithCacheKeyContext(ctx, now, bot.ID, userID, peer, cacheKey)
defer r.inlines.deregisterIfUnansweredContext(ctx, queryID)
peerType := tgInlineQueryPeerType(ctx, r, userID, bot.ID, peer)
update := &tg.UpdateBotInlineQuery{
QueryID: queryID,
UserID: userID,
Query: req.Query,
Offset: req.Offset,
PeerType: peerType,
}
if queryGeo != nil {
update.SetGeo(tgGeoPoint(*queryGeo))
}
r.pushUserMessage(ctx, bot.ID, "push bot inline query", &tg.Updates{
Updates: []tg.UpdateClass{update},
Date: int(now.Unix()),
})
push := store.BotInlineQueryPush{
QueryID: queryID,
BotUserID: bot.ID,
UserID: userID,
Query: req.Query,
Offset: req.Offset,
PeerType: storeInlineQueryPeerType(peerType),
Date: int(now.Unix()),
}
if queryGeo != nil {
geo := *queryGeo
push.Geo = &geo
}
r.publishInlineBotQuery(ctx, push)
waitCtx, cancel := context.WithTimeout(ctx, botInlineQueryTTL)
defer cancel()
results, err := r.awaitInlineBotResults(waitCtx, pending, userID, queryID)
if err != nil {
return nil, err
}
return r.tgBotInlineResults(ctx, userID, results), nil
}
func (r *Router) onMessagesSetInlineBotResults(ctx context.Context, req *tg.MessagesSetInlineBotResultsRequest) (bool, error) {
botID, err := r.callerBotID(ctx)
if err != nil {
return false, err
}
results, err := r.domainInlineResultsFromTG(ctx, botID, req)
if err != nil {
return false, err
}
if !r.inlines.resolveContext(ctx, r.clock.Now(), botID, req.QueryID, results) {
return false, queryIDInvalidErr()
}
return true, nil
}
func (r *Router) onMessagesSendInlineBotResult(ctx context.Context, req *tg.MessagesSendInlineBotResultRequest) (tg.UpdatesClass, error) {
if req.QueryID == 0 {
return nil, queryIDEmptyErr()
}
if req.ID == "" {
return nil, resultIDEmptyErr()
}
if req.RandomID == 0 {
return nil, randomIDEmptyErr()
}
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
return nil, scheduleDateInvalidErr()
}
if req.QuickReplyShortcut != nil {
return nil, shortcutInvalidErr()
}
if req.AllowPaidStars < 0 {
return nil, starsAmountInvalidErr()
}
if req.AllowPaidStars > 0 {
return nil, paymentUnsupportedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if userID == 0 {
return nil, peerIDInvalidErr()
}
if err := r.checkSendRateLimit(ctx, userID, 1); err != nil {
return nil, err
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel {
return nil, peerIDInvalidErr()
}
results, result, ok := r.inlines.resultForSendContext(ctx, r.clock.Now(), userID, req.QueryID, req.ID)
if !ok {
return nil, inlineResultExpiredErr()
}
if !r.inlineResultsAllowPeer(ctx, userID, results, peer) {
return nil, peerIDInvalidErr()
}
media := cloneInlineMedia(result.Media)
if result.MediaAuto {
media, err = r.domainInlineExternalContentMedia(ctx, result)
if err != nil {
return nil, err
}
}
r.resolveInlineContactMediaForSender(ctx, userID, media)
updates, duplicate, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{
randomID: req.RandomID,
message: result.Message,
entities: tgMessageEntities(result.Entities),
media: media,
silent: req.Silent,
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
replyMarkup: result.ReplyMarkup,
viaBotID: results.BotUserID,
})
if err != nil {
return nil, err
}
if !duplicate {
r.pushInlineBotSendFeedback(ctx, userID, results, result, updates)
r.inlines.consumeContext(ctx, req.QueryID)
r.autoSaveSentGif(ctx, userID, media)
}
return updates, nil
}
func (r *Router) inlineResultsAllowPeer(ctx context.Context, userID int64, results domain.BotInlineResults, peer domain.Peer) bool {
if results.Peer.Type != "" || results.Peer.ID != 0 {
return results.Peer == peer
}
if len(results.PeerTypes) == 0 {
return true
}
actual := storeInlineQueryPeerType(tgInlineQueryPeerType(ctx, r, userID, results.BotUserID, peer))
if actual == "" {
return false
}
for _, allowed := range results.PeerTypes {
if allowed == actual {
return true
}
}
return false
}
func (r *Router) awaitInlineBotResults(ctx context.Context, pending *pendingInlineQuery, userID, queryID int64) (domain.BotInlineResults, error) {
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
select {
case results := <-pending.ch:
return results, nil
case <-ticker.C:
if results, ok := r.inlines.resultsForQueryContext(ctx, r.clock.Now(), userID, queryID); ok {
return results, nil
}
case <-ctx.Done():
return domain.BotInlineResults{}, botResponseTimeoutErr()
}
}
}
func (r *Router) domainInlineResultsFromTG(ctx context.Context, botID int64, req *tg.MessagesSetInlineBotResultsRequest) (domain.BotInlineResults, error) {
if len(req.Results) > domain.MaxBotInlineResults {
return domain.BotInlineResults{}, resultsTooMuchErr()
}
if len(req.NextOffset) > domain.MaxBotInlineNextOffsetLen {
return domain.BotInlineResults{}, nextOffsetInvalidErr()
}
switchPM, err := domainInlineSwitchPM(req)
if err != nil {
return domain.BotInlineResults{}, err
}
switchWeb, err := domainInlineSwitchWebView(req)
if err != nil {
return domain.BotInlineResults{}, err
}
seen := make(map[string]struct{}, len(req.Results))
out := domain.BotInlineResults{
BotUserID: botID,
QueryID: req.QueryID,
Gallery: req.Gallery,
Private: req.Private,
CacheTime: req.CacheTime,
NextOffset: req.NextOffset,
SwitchPM: switchPM,
SwitchWeb: switchWeb,
Results: make([]domain.BotInlineResult, 0, len(req.Results)),
}
for _, raw := range req.Results {
item, err := r.domainInlineResultFromTG(ctx, botID, raw)
if err != nil {
return domain.BotInlineResults{}, err
}
if err := r.prepareTelegramLoginMarkup(ctx, botID, item.ReplyMarkup); err != nil {
return domain.BotInlineResults{}, replyMarkupErr(err)
}
if _, ok := seen[item.ID]; ok {
return domain.BotInlineResults{}, resultIDDuplicateErr()
}
seen[item.ID] = struct{}{}
out.Results = append(out.Results, item)
}
return out, nil
}
func domainInlineSwitchWebView(req *tg.MessagesSetInlineBotResultsRequest) (*domain.BotInlineSwitchWebView, error) {
switchWeb, ok := req.GetSwitchWebview()
if !ok {
return nil, nil
}
if strings.TrimSpace(switchWeb.Text) == "" || utf8.RuneCountInString(switchWeb.Text) > domain.MaxBotInlineSwitchTextLen {
return nil, buttonInvalidErr()
}
if err := validateInlineWebURL(switchWeb.URL, true); err != nil {
return nil, switchWebviewInvalidErr()
}
return &domain.BotInlineSwitchWebView{
Text: switchWeb.Text,
URL: switchWeb.URL,
}, nil
}
func domainInlineSwitchPM(req *tg.MessagesSetInlineBotResultsRequest) (*domain.BotInlineSwitchPM, error) {
switchPM, ok := req.GetSwitchPm()
if !ok {
return nil, nil
}
if switchPM.Text == "" {
return nil, switchPmTextEmptyErr()
}
if utf8.RuneCountInString(switchPM.Text) > domain.MaxBotInlineSwitchTextLen {
return nil, buttonInvalidErr()
}
if switchPM.StartParam == "" {
return nil, startParamEmptyErr()
}
if !validInlineStartParam(switchPM.StartParam) {
return nil, startParamInvalidErr()
}
return &domain.BotInlineSwitchPM{
Text: switchPM.Text,
StartParam: switchPM.StartParam,
}, nil
}
func validInlineStartParam(in string) bool {
if utf8.RuneCountInString(in) > domain.MaxStartParamLen {
return false
}
for _, r := range in {
if r >= 'a' && r <= 'z' {
continue
}
if r >= 'A' && r <= 'Z' {
continue
}
if r >= '0' && r <= '9' {
continue
}
if r == '_' || r == '-' {
continue
}
return false
}
return true
}
func (r *Router) domainInlineResultFromTG(ctx context.Context, botID int64, raw tg.InputBotInlineResultClass) (domain.BotInlineResult, error) {
switch result := raw.(type) {
case *tg.InputBotInlineResult:
if result.ID == "" {
return domain.BotInlineResult{}, resultIDEmptyErr()
}
if len(result.ID) > domain.MaxBotInlineResultIDLen {
return domain.BotInlineResult{}, resultIDInvalidErr()
}
if result.Type == "" {
return domain.BotInlineResult{}, resultTypeInvalidErr()
}
switch msg := result.SendMessage.(type) {
case *tg.InputBotInlineMessageText:
if result.Type != "article" {
return domain.BotInlineResult{}, resultTypeInvalidErr()
}
resultURL, thumb, content, err := domainInlineResultWebPreview(result)
if err != nil {
return domain.BotInlineResult{}, err
}
message, entities, markup, noWebpage, err := domainInlineTextMessage(botID, msg)
if err != nil {
return domain.BotInlineResult{}, err
}
return domain.BotInlineResult{
ID: result.ID,
Type: result.Type,
Title: result.Title,
Description: result.Description,
URL: resultURL,
Thumb: thumb,
Content: content,
Message: message,
Entities: entities,
ReplyMarkup: markup,
NoWebpage: noWebpage,
}, nil
case *tg.InputBotInlineMessageMediaAuto:
if !validInlineExternalContentResultType(result.Type) {
return domain.BotInlineResult{}, resultTypeInvalidErr()
}
resultURL, thumb, content, err := domainInlineResultWebPreview(result)
if err != nil {
return domain.BotInlineResult{}, err
}
if content == nil {
return domain.BotInlineResult{}, webDocumentInvalidErr()
}
if !inlineExternalContentMimeAllowed(result.Type, content.MimeType) {
return domain.BotInlineResult{}, mediaInvalidErr()
}
message, entities, markup, err := domainInlineMediaAutoMessage(botID, msg)
if err != nil {
return domain.BotInlineResult{}, err
}
return domain.BotInlineResult{
ID: result.ID,
Type: result.Type,
Title: result.Title,
Description: result.Description,
URL: resultURL,
Thumb: thumb,
Content: content,
Message: message,
Entities: entities,
ReplyMarkup: markup,
MediaAuto: true,
}, nil
case *tg.InputBotInlineMessageMediaGeo:
if result.Type != "geo" {
return domain.BotInlineResult{}, resultTypeInvalidErr()
}
if inlineResultHasWebPreview(result) {
return domain.BotInlineResult{}, webDocumentInvalidErr()
}
media, markup, err := domainInlineGeoMessage(msg)
if err != nil {
return domain.BotInlineResult{}, err
}
return domain.BotInlineResult{
ID: result.ID,
Type: result.Type,
Title: result.Title,
Description: result.Description,
ReplyMarkup: markup,
Media: media,
}, nil
case *tg.InputBotInlineMessageMediaVenue:
if result.Type != "venue" {
return domain.BotInlineResult{}, resultTypeInvalidErr()
}
if inlineResultHasWebPreview(result) {
return domain.BotInlineResult{}, webDocumentInvalidErr()
}
media, markup, err := domainInlineVenueMessage(msg)
if err != nil {
return domain.BotInlineResult{}, err
}
return domain.BotInlineResult{
ID: result.ID,
Type: result.Type,
Title: result.Title,
Description: result.Description,
ReplyMarkup: markup,
Media: media,
}, nil
case *tg.InputBotInlineMessageMediaContact:
if result.Type != "contact" {
return domain.BotInlineResult{}, resultTypeInvalidErr()
}
if inlineResultHasWebPreview(result) {
return domain.BotInlineResult{}, webDocumentInvalidErr()
}
media, markup, err := domainInlineContactMessage(msg)
if err != nil {
return domain.BotInlineResult{}, err
}
return domain.BotInlineResult{
ID: result.ID,
Type: result.Type,
Title: result.Title,
Description: result.Description,
ReplyMarkup: markup,
Media: media,
}, nil
default:
return domain.BotInlineResult{}, sendMessageTypeInvalidErr()
}
case *tg.InputBotInlineResultPhoto:
if result.ID == "" {
return domain.BotInlineResult{}, resultIDEmptyErr()
}
if len(result.ID) > domain.MaxBotInlineResultIDLen {
return domain.BotInlineResult{}, resultIDInvalidErr()
}
if result.Type != "photo" {
return domain.BotInlineResult{}, resultTypeInvalidErr()
}
media, err := r.domainInlinePhotoMedia(ctx, result.Photo)
if err != nil {
return domain.BotInlineResult{}, err
}
message, entities, markup, err := domainInlineMediaAutoMessage(botID, result.SendMessage)
if err != nil {
return domain.BotInlineResult{}, err
}
return domain.BotInlineResult{
ID: result.ID,
Type: result.Type,
Message: message,
Entities: entities,
ReplyMarkup: markup,
Media: media,
}, nil
case *tg.InputBotInlineResultDocument:
if result.ID == "" {
return domain.BotInlineResult{}, resultIDEmptyErr()
}
if len(result.ID) > domain.MaxBotInlineResultIDLen {
return domain.BotInlineResult{}, resultIDInvalidErr()
}
if !validInlineDocumentResultType(result.Type) {
return domain.BotInlineResult{}, resultTypeInvalidErr()
}
media, err := r.domainInlineDocumentMedia(ctx, result.Document)
if err != nil {
return domain.BotInlineResult{}, err
}
message, entities, markup, err := domainInlineMediaAutoMessage(botID, result.SendMessage)
if err != nil {
return domain.BotInlineResult{}, err
}
return domain.BotInlineResult{
ID: result.ID,
Type: result.Type,
Title: result.Title,
Description: result.Description,
Message: message,
Entities: entities,
ReplyMarkup: markup,
Media: media,
}, nil
default:
return domain.BotInlineResult{}, resultTypeInvalidErr()
}
}
func domainInlineResultWebPreview(result *tg.InputBotInlineResult) (string, *domain.BotInlineWebDocument, *domain.BotInlineWebDocument, error) {
var resultURL string
if v, ok := result.GetURL(); ok {
if err := validateInlineWebURL(v, false); err != nil {
return "", nil, nil, err
}
resultURL = v
}
var thumb *domain.BotInlineWebDocument
if raw, ok := result.GetThumb(); ok {
var err error
thumb, err = domainInlineWebDocument(raw)
if err != nil {
return "", nil, nil, err
}
}
var content *domain.BotInlineWebDocument
if raw, ok := result.GetContent(); ok {
var err error
content, err = domainInlineWebDocument(raw)
if err != nil {
return "", nil, nil, err
}
}
return resultURL, thumb, content, nil
}
func inlineResultHasWebPreview(result *tg.InputBotInlineResult) bool {
if _, ok := result.GetURL(); ok {
return true
}
if _, ok := result.GetThumb(); ok {
return true
}
if _, ok := result.GetContent(); ok {
return true
}
return false
}
func domainInlineWebDocument(input tg.InputWebDocument) (*domain.BotInlineWebDocument, error) {
if err := validateInlineWebURL(input.URL, true); err != nil {
return nil, err
}
if input.Size <= 0 {
return nil, webDocumentInvalidErr()
}
if input.Size > domain.MaxBotInlineWebSize {
return nil, webDocumentSizeTooBigErr()
}
if !validInlineWebMime(input.MimeType) {
return nil, webDocumentMimeInvalidErr()
}
return &domain.BotInlineWebDocument{
URL: input.URL,
AccessHash: randomNonZeroInt64(),
Size: input.Size,
MimeType: input.MimeType,
Attributes: domainDocumentAttributes(input.Attributes),
}, nil
}
func validateInlineWebURL(raw string, emptyIsURLSpecific bool) error {
if raw == "" {
if emptyIsURLSpecific {
return webDocumentURLEmptyErr()
}
return webDocumentURLInvalidErr()
}
if len(raw) > domain.MaxBotInlineWebURLLen || strings.TrimSpace(raw) != raw {
return webDocumentURLInvalidErr()
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" || u.User != nil {
return webDocumentURLInvalidErr()
}
if strings.ToLower(u.Scheme) != "https" {
return webDocumentURLInvalidErr()
}
if strings.ContainsAny(u.Host, " \t\r\n") {
return webDocumentURLInvalidErr()
}
return nil
}
func validInlineWebMime(in string) bool {
if in == "" || len(in) > domain.MaxBotInlineWebMimeLen || strings.TrimSpace(in) != in {
return false
}
slash := strings.IndexByte(in, '/')
if slash <= 0 || slash == len(in)-1 {
return false
}
for _, r := range in {
if r <= 32 || r == 127 {
return false
}
}
return true
}
func domainInlineTextMessage(botID int64, msg *tg.InputBotInlineMessageText) (string, []domain.MessageEntity, *domain.MessageReplyMarkup, bool, error) {
if msg.Message == "" {
return "", nil, nil, false, messageEmptyErr()
}
if utf8.RuneCountInString(msg.Message) > maxSendMessageTextLength {
return "", nil, nil, false, messageTooLongErr()
}
if len(msg.Entities) > maxMessageEntityCount {
return "", nil, nil, false, entitiesTooLongErr()
}
markup, err := domainReplyMarkupForSender(msg.ReplyMarkup, true)
if err != nil {
return "", nil, nil, false, replyMarkupErr(err)
}
return msg.Message, domainMessageEntitiesForViewer(botID, msg.Entities), markup, msg.NoWebpage, nil
}
func domainInlineMediaAutoMessage(botID int64, raw tg.InputBotInlineMessageClass) (string, []domain.MessageEntity, *domain.MessageReplyMarkup, error) {
msg, ok := raw.(*tg.InputBotInlineMessageMediaAuto)
if !ok {
return "", nil, nil, sendMessageTypeInvalidErr()
}
if utf8.RuneCountInString(msg.Message) > maxSendMessageTextLength {
return "", nil, nil, messageTooLongErr()
}
if len(msg.Entities) > maxMessageEntityCount {
return "", nil, nil, entitiesTooLongErr()
}
markup, err := domainReplyMarkupForSender(msg.ReplyMarkup, true)
if err != nil {
return "", nil, nil, replyMarkupErr(err)
}
return msg.Message, domainMessageEntitiesForViewer(botID, msg.Entities), markup, nil
}
func domainInlineGeoMessage(msg *tg.InputBotInlineMessageMediaGeo) (*domain.MessageMedia, *domain.MessageReplyMarkup, error) {
if _, ok := msg.GetHeading(); ok {
return nil, nil, mediaInvalidErr()
}
if _, ok := msg.GetPeriod(); ok {
return nil, nil, mediaInvalidErr()
}
if _, ok := msg.GetProximityNotificationRadius(); ok {
return nil, nil, mediaInvalidErr()
}
geo, err := domainGeoPointFromInput(msg.GeoPoint)
if err != nil {
return nil, nil, err
}
markup, err := domainReplyMarkupForSender(msg.ReplyMarkup, true)
if err != nil {
return nil, nil, replyMarkupErr(err)
}
return &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: geo}, markup, nil
}
func domainInlineVenueMessage(msg *tg.InputBotInlineMessageMediaVenue) (*domain.MessageMedia, *domain.MessageReplyMarkup, error) {
geo, err := domainGeoPointFromInput(msg.GeoPoint)
if err != nil {
return nil, nil, err
}
if strings.TrimSpace(msg.Title) == "" {
return nil, nil, mediaEmptyErr()
}
if utf8.RuneCountInString(msg.Title) > maxVenueTitleLength ||
utf8.RuneCountInString(msg.Address) > maxVenueAddressLength ||
utf8.RuneCountInString(msg.Provider) > maxVenueProviderLength ||
utf8.RuneCountInString(msg.VenueID) > maxVenueIDLength ||
utf8.RuneCountInString(msg.VenueType) > maxVenueIDLength {
return nil, nil, mediaInvalidErr()
}
markup, err := domainReplyMarkupForSender(msg.ReplyMarkup, true)
if err != nil {
return nil, nil, replyMarkupErr(err)
}
return &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{
Geo: *geo,
Title: msg.Title,
Address: msg.Address,
Provider: msg.Provider,
VenueID: msg.VenueID,
VenueType: msg.VenueType,
}}, markup, nil
}
func domainInlineContactMessage(msg *tg.InputBotInlineMessageMediaContact) (*domain.MessageMedia, *domain.MessageReplyMarkup, error) {
if !validContactInput(msg.PhoneNumber, msg.FirstName, msg.LastName, "", 0) || utf8.RuneCountInString(msg.Vcard) > maxContactVcardLength {
return nil, nil, mediaInvalidErr()
}
if strings.TrimSpace(msg.PhoneNumber) == "" && strings.TrimSpace(msg.FirstName) == "" && strings.TrimSpace(msg.LastName) == "" && strings.TrimSpace(msg.Vcard) == "" {
return nil, nil, mediaEmptyErr()
}
markup, err := domainReplyMarkupForSender(msg.ReplyMarkup, true)
if err != nil {
return nil, nil, replyMarkupErr(err)
}
return &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{
PhoneNumber: msg.PhoneNumber,
FirstName: msg.FirstName,
LastName: msg.LastName,
Vcard: msg.Vcard,
}}, markup, nil
}
func (r *Router) resolveInlineContactMediaForSender(ctx context.Context, userID int64, media *domain.MessageMedia) {
if media == nil || media.Kind != domain.MessageMediaKindContact || media.Contact == nil || media.Contact.UserID != 0 {
return
}
media.Contact.UserID = r.messageContactUserID(ctx, userID, media.Contact.PhoneNumber)
}
func (r *Router) domainInlinePhotoMedia(ctx context.Context, input tg.InputPhotoClass) (*domain.MessageMedia, error) {
if r.deps.Files == nil {
return nil, mediaInvalidErr()
}
photoID, ok := inputPhotoID(input)
if !ok {
return nil, photoInvalidErr()
}
photo, found, err := r.deps.Files.GetPhoto(ctx, photoID)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, photoInvalidErr()
}
return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo}, nil
}
func (r *Router) domainInlineDocumentMedia(ctx context.Context, input tg.InputDocumentClass) (*domain.MessageMedia, error) {
if r.deps.Files == nil {
return nil, mediaInvalidErr()
}
docIDs, ok := inputDocumentCandidateIDs(input)
if !ok {
return nil, mediaInvalidErr()
}
for _, docID := range docIDs {
doc, found, err := r.deps.Files.GetDocument(ctx, docID)
if err != nil {
return nil, internalErr()
}
if found {
return messageMediaFromDocument(doc, false, 0), nil
}
}
return nil, mediaInvalidErr()
}
func validInlineDocumentResultType(t string) bool {
switch t {
case "file", "gif", "video", "audio", "voice", "sticker":
return true
default:
return false
}
}
func validInlineExternalContentResultType(t string) bool {
return t == "photo" || validInlineDocumentResultType(t)
}
func (r *Router) domainInlineExternalContentMedia(ctx context.Context, result domain.BotInlineResult) (*domain.MessageMedia, error) {
if r.deps.Files == nil || result.Content == nil {
return nil, mediaInvalidErr()
}
document, data, mime, err := r.registeredInlineWebDocumentBytes(ctx, result.Content.URL, result.Content.AccessHash)
if err != nil {
return nil, mediaInvalidErr()
}
document.MimeType = mime
document.Size = len(data)
if !inlineExternalContentMimeAllowed(result.Type, mime) {
return nil, mediaInvalidErr()
}
if result.Type == "photo" {
photo, err := r.deps.Files.CreatePhotoFromBytes(ctx, data)
if err != nil {
return nil, photoInvalidErr()
}
return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo}, nil
}
spec := inlineExternalContentDocumentSpec(result.Type, document)
doc, err := r.deps.Files.CreateDocumentFromBytes(ctx, data, spec)
if err != nil {
return nil, documentInvalidErr()
}
return messageMediaFromDocument(doc, false, 0), nil
}
func inlineExternalContentMimeAllowed(resultType, mime string) bool {
mime = strings.ToLower(strings.TrimSpace(mime))
if !validInlineWebMime(mime) {
return false
}
switch resultType {
case "photo":
return strings.HasPrefix(mime, "image/") && mime != "image/gif"
case "gif":
return mime == "image/gif" || mime == "video/mp4" || mime == "image/webp"
case "video":
return strings.HasPrefix(mime, "video/")
case "audio", "voice":
return strings.HasPrefix(mime, "audio/")
case "sticker":
return strings.HasPrefix(mime, "image/") || strings.HasPrefix(mime, "application/x-tg")
case "file":
return true
default:
return false
}
}
func inlineExternalContentDocumentSpec(resultType string, document domain.BotInlineWebDocument) domain.DocumentSpec {
mime := strings.ToLower(strings.TrimSpace(document.MimeType))
attrs := append([]domain.DocumentAttribute(nil), document.Attributes...)
w, h := inlineExternalContentDimensions(attrs)
switch resultType {
case "gif":
attrs = ensureDocumentAttribute(attrs, domain.DocumentAttribute{Kind: domain.DocAttrAnimated})
attrs = ensureImageSizeAttribute(attrs, w, h)
case "video":
attrs = ensureVideoAttribute(attrs, w, h)
case "audio":
attrs = ensureAudioAttribute(attrs, false)
case "voice":
attrs = ensureAudioAttribute(attrs, true)
case "sticker":
attrs = ensureImageSizeAttribute(attrs, w, h)
attrs = ensureDocumentAttribute(attrs, domain.DocumentAttribute{Kind: domain.DocAttrSticker})
}
attrs = ensureFilenameAttribute(attrs, inlineExternalContentFilename(resultType, mime))
return domain.DocumentSpec{MimeType: mime, Attributes: attrs}
}
func inlineExternalContentDimensions(attrs []domain.DocumentAttribute) (int, int) {
for _, attr := range attrs {
if (attr.Kind == domain.DocAttrImageSize || attr.Kind == domain.DocAttrVideo) && attr.W > 0 && attr.H > 0 {
return attr.W, attr.H
}
}
return 0, 0
}
func ensureImageSizeAttribute(attrs []domain.DocumentAttribute, w, h int) []domain.DocumentAttribute {
if hasDocumentAttribute(attrs, domain.DocAttrImageSize) || w <= 0 || h <= 0 {
return attrs
}
return append(attrs, domain.DocumentAttribute{Kind: domain.DocAttrImageSize, W: w, H: h})
}
func ensureVideoAttribute(attrs []domain.DocumentAttribute, w, h int) []domain.DocumentAttribute {
if hasDocumentAttribute(attrs, domain.DocAttrVideo) {
return attrs
}
return append(attrs, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, SupportsStreaming: true})
}
func ensureAudioAttribute(attrs []domain.DocumentAttribute, voice bool) []domain.DocumentAttribute {
for i := range attrs {
if attrs[i].Kind == domain.DocAttrAudio {
if voice {
attrs[i].Voice = true
}
return attrs
}
}
return append(attrs, domain.DocumentAttribute{Kind: domain.DocAttrAudio, Voice: voice})
}
func ensureFilenameAttribute(attrs []domain.DocumentAttribute, name string) []domain.DocumentAttribute {
for i := range attrs {
if attrs[i].Kind == domain.DocAttrFilename {
if strings.TrimSpace(attrs[i].FileName) == "" {
attrs[i].FileName = name
}
return attrs
}
}
return append(attrs, domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: name})
}
func ensureDocumentAttribute(attrs []domain.DocumentAttribute, attr domain.DocumentAttribute) []domain.DocumentAttribute {
if hasDocumentAttribute(attrs, attr.Kind) {
return attrs
}
return append(attrs, attr)
}
func hasDocumentAttribute(attrs []domain.DocumentAttribute, kind domain.DocumentAttributeKind) bool {
for _, attr := range attrs {
if attr.Kind == kind {
return true
}
}
return false
}
func inlineExternalContentFilename(resultType, mime string) string {
switch resultType {
case "gif":
if mime == "video/mp4" {
return "animation.mp4"
}
return "animation.gif"
case "video":
return "video.mp4"
case "audio":
return "audio.mp3"
case "voice":
return "audio.ogg"
case "sticker":
return "sticker.webp"
default:
if slash := strings.LastIndexByte(mime, '/'); slash >= 0 && slash < len(mime)-1 {
ext := mime[slash+1:]
if ext == "jpeg" {
ext = "jpg"
}
if validInlineFilenameExt(ext) {
return "file." + ext
}
}
return "file"
}
}
func validInlineFilenameExt(ext string) bool {
if ext == "" || len(ext) > 16 {
return false
}
for _, r := range ext {
if r >= 'a' && r <= 'z' {
continue
}
if r >= '0' && r <= '9' {
continue
}
return false
}
return true
}
func (r *Router) tgBotInlineResults(ctx context.Context, viewerUserID int64, in domain.BotInlineResults) *tg.MessagesBotResults {
out := &tg.MessagesBotResults{
Gallery: in.Gallery,
QueryID: in.QueryID,
Results: make([]tg.BotInlineResultClass, 0, len(in.Results)),
CacheTime: in.CacheTime,
}
if in.NextOffset != "" {
out.SetNextOffset(in.NextOffset)
}
if in.SwitchPM != nil {
out.SetSwitchPm(tg.InlineBotSwitchPM{
Text: in.SwitchPM.Text,
StartParam: in.SwitchPM.StartParam,
})
}
if in.SwitchWeb != nil {
out.SetSwitchWebview(tg.InlineBotWebView{
Text: in.SwitchWeb.Text,
URL: in.SwitchWeb.URL,
})
}
for _, item := range in.Results {
out.Results = append(out.Results, tgBotInlineResult(item))
}
if r.deps.Users != nil && in.BotUserID != 0 {
if u, found, err := r.deps.Users.ByID(ctx, viewerUserID, in.BotUserID); err == nil && found {
out.Users = append(out.Users, r.tgUser(u))
}
}
r.applyPeerReadModels(ctx, viewerUserID, out.Users, nil)
return out
}
func tgBotInlineResult(in domain.BotInlineResult) tg.BotInlineResultClass {
if in.Media != nil && !in.Media.IsZero() {
msg := tgBotInlineMediaMessage(in)
out := &tg.BotInlineResult{
ID: in.ID,
Type: in.Type,
Title: in.Title,
Description: in.Description,
SendMessage: msg,
}
if in.Media.Kind == domain.MessageMediaKindGeo ||
in.Media.Kind == domain.MessageMediaKindVenue ||
in.Media.Kind == domain.MessageMediaKindContact {
return out
}
mediaMsg := &tg.BotInlineMessageMediaAuto{
Message: in.Message,
Entities: tgMessageEntities(in.Entities),
}
if markup := tgReplyMarkup(in.ReplyMarkup); markup != nil {
mediaMsg.SetReplyMarkup(markup)
}
mediaOut := &tg.BotInlineMediaResult{
ID: in.ID,
Type: in.Type,
Title: in.Title,
Description: in.Description,
SendMessage: mediaMsg,
}
if in.Media.Kind == domain.MessageMediaKindPhoto && in.Media.Photo != nil {
mediaOut.SetPhoto(tgPhoto(*in.Media.Photo))
}
if in.Media.Kind == domain.MessageMediaKindDocument && in.Media.Document != nil {
mediaOut.SetDocument(tgDocument(*in.Media.Document))
}
return mediaOut
}
if in.MediaAuto {
msg := &tg.BotInlineMessageMediaAuto{
Message: in.Message,
Entities: tgMessageEntities(in.Entities),
}
if markup := tgReplyMarkup(in.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup)
}
out := &tg.BotInlineResult{
ID: in.ID,
Type: in.Type,
Title: in.Title,
Description: in.Description,
SendMessage: msg,
}
if in.URL != "" {
out.SetURL(in.URL)
}
if in.Thumb != nil {
out.SetThumb(tgInlineWebDocument(*in.Thumb))
}
if in.Content != nil {
out.SetContent(tgInlineWebDocument(*in.Content))
}
return out
}
msg := &tg.BotInlineMessageText{
NoWebpage: in.NoWebpage,
Message: in.Message,
Entities: tgMessageEntities(in.Entities),
}
if markup := tgReplyMarkup(in.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup)
}
out := &tg.BotInlineResult{
ID: in.ID,
Type: in.Type,
Title: in.Title,
Description: in.Description,
SendMessage: msg,
}
if in.URL != "" {
out.SetURL(in.URL)
}
if in.Thumb != nil {
out.SetThumb(tgInlineWebDocument(*in.Thumb))
}
if in.Content != nil {
out.SetContent(tgInlineWebDocument(*in.Content))
}
return out
}
func tgInlineWebDocument(in domain.BotInlineWebDocument) tg.WebDocumentClass {
return &tg.WebDocument{
URL: in.URL,
AccessHash: in.AccessHash,
Size: in.Size,
MimeType: in.MimeType,
Attributes: tgDocumentAttributes(in.MimeType, in.Attributes),
}
}
func tgBotInlineMediaMessage(in domain.BotInlineResult) tg.BotInlineMessageClass {
switch {
case in.Media.Kind == domain.MessageMediaKindGeo && in.Media.Geo != nil:
msg := &tg.BotInlineMessageMediaGeo{Geo: tgGeoPoint(*in.Media.Geo)}
if markup := tgReplyMarkup(in.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup)
}
return msg
case in.Media.Kind == domain.MessageMediaKindVenue && in.Media.Venue != nil:
msg := &tg.BotInlineMessageMediaVenue{
Geo: tgGeoPoint(in.Media.Venue.Geo),
Title: in.Media.Venue.Title,
Address: in.Media.Venue.Address,
Provider: in.Media.Venue.Provider,
VenueID: in.Media.Venue.VenueID,
VenueType: in.Media.Venue.VenueType,
}
if markup := tgReplyMarkup(in.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup)
}
return msg
case in.Media.Kind == domain.MessageMediaKindContact && in.Media.Contact != nil:
msg := &tg.BotInlineMessageMediaContact{
PhoneNumber: in.Media.Contact.PhoneNumber,
FirstName: in.Media.Contact.FirstName,
LastName: in.Media.Contact.LastName,
Vcard: in.Media.Contact.Vcard,
}
if markup := tgReplyMarkup(in.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup)
}
return msg
default:
return &tg.BotInlineMessageMediaAuto{
Message: in.Message,
Entities: tgMessageEntities(in.Entities),
}
}
}
func tgInlineQueryPeerType(ctx context.Context, r *Router, userID, botID int64, peer domain.Peer) tg.InlineQueryPeerTypeClass {
switch peer.Type {
case domain.PeerTypeUser:
if peer.ID == botID {
return &tg.InlineQueryPeerTypeSameBotPM{}
}
if r.userIsBot(ctx, peer.ID) {
return &tg.InlineQueryPeerTypeBotPM{}
}
return &tg.InlineQueryPeerTypePM{}
case domain.PeerTypeChannel:
if r.deps.Channels != nil {
// 只读 Megagroup 标志:走轻量 ResolveChannel省 dialog/读态/boost 查询。
if view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID); err == nil && !view.Channel.Megagroup {
return &tg.InlineQueryPeerTypeBroadcast{}
}
}
return &tg.InlineQueryPeerTypeMegagroup{}
default:
return &tg.InlineQueryPeerTypePM{}
}
}