Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review. The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation. Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9 Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
943 lines
28 KiB
Go
943 lines
28 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"hash/fnv"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/iamxvbaba/td/tg"
|
|
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
func (r *Router) resolveBotUserForViewer(ctx context.Context, viewerID int64, bot tg.InputUserClass) (domain.User, error) {
|
|
if bot == nil || r.deps.Bots == nil {
|
|
return domain.User{}, botInvalidErr()
|
|
}
|
|
u, found, err := r.userFromInput(ctx, viewerID, bot)
|
|
if err != nil || !found {
|
|
return domain.User{}, botInvalidErr()
|
|
}
|
|
if _, found, err := r.deps.Bots.BotInfo(ctx, u.ID); err != nil {
|
|
return domain.User{}, internalErr()
|
|
} else if !found {
|
|
return domain.User{}, botInvalidErr()
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (r *Router) resolveOwnedBotUser(ctx context.Context, ownerID int64, bot tg.InputUserClass) (domain.User, error) {
|
|
u, err := r.resolveBotUserForViewer(ctx, ownerID, bot)
|
|
if err != nil {
|
|
return domain.User{}, err
|
|
}
|
|
owns, err := r.deps.Bots.OwnsBot(ctx, ownerID, u.ID)
|
|
if err != nil {
|
|
return domain.User{}, internalErr()
|
|
}
|
|
if !owns {
|
|
return domain.User{}, botInvalidErr()
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (r *Router) onBotsSendCustomRequest(ctx context.Context, req *tg.BotsSendCustomRequestRequest) (*tg.DataJSON, error) {
|
|
if _, err := r.callerBotID(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, methodInvalidErr()
|
|
}
|
|
|
|
func (r *Router) onBotsAnswerWebhookJSONQuery(ctx context.Context, req *tg.BotsAnswerWebhookJSONQueryRequest) (bool, error) {
|
|
if _, err := r.callerBotID(ctx); err != nil {
|
|
return false, err
|
|
}
|
|
return false, queryIDInvalidErr()
|
|
}
|
|
|
|
func (r *Router) onBotsSetBotBroadcastDefaultAdminRights(ctx context.Context, _ tg.ChatAdminRights) (bool, error) {
|
|
if _, err := r.callerBotID(ctx); err != nil {
|
|
return false, err
|
|
}
|
|
return false, rightsNotModifiedErr()
|
|
}
|
|
|
|
func (r *Router) onBotsSetBotGroupDefaultAdminRights(ctx context.Context, _ tg.ChatAdminRights) (bool, error) {
|
|
if _, err := r.callerBotID(ctx); err != nil {
|
|
return false, err
|
|
}
|
|
return false, rightsNotModifiedErr()
|
|
}
|
|
|
|
// onBotsReorderUsernames reorders a bot's collectible usernames. A bot is a user
|
|
// peer in the registry, so the only difference from account.reorderUsernames is
|
|
// the ownership gate: resolveOwnedBotUser already rejects a caller who does not
|
|
// own the bot.
|
|
//
|
|
// With no registry wired the historical USERNAME_NOT_MODIFIED answer is kept --
|
|
// a bot with a single editable username genuinely has nothing to reorder.
|
|
func (r *Router) onBotsReorderUsernames(ctx context.Context, req *tg.BotsReorderUsernamesRequest) (bool, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return false, internalErr()
|
|
}
|
|
if req == nil {
|
|
return false, botInvalidErr()
|
|
}
|
|
bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if r.deps.Usernames == nil {
|
|
return false, usernameNotModifiedErr()
|
|
}
|
|
peer := domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID}
|
|
if err := r.reorderRegistryUsernames(ctx, peer, req.Order); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (r *Router) onBotsToggleUsername(ctx context.Context, req *tg.BotsToggleUsernameRequest) (bool, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return false, internalErr()
|
|
}
|
|
if req == nil {
|
|
return false, botInvalidErr()
|
|
}
|
|
bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if r.deps.Usernames == nil {
|
|
return false, usernameNotModifiedErr()
|
|
}
|
|
peer := domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID}
|
|
if err := r.toggleRegistryUsername(ctx, peer, req.Username, req.Active); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (r *Router) onBotsCanSendMessage(ctx context.Context, bot tg.InputUserClass) (bool, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return false, internalErr()
|
|
}
|
|
botUser, err := r.resolveBotUserForViewer(ctx, userID, bot)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
allowed, err := r.deps.Bots.CanSendMessage(ctx, userID, botUser.ID)
|
|
if err != nil {
|
|
return false, internalErr()
|
|
}
|
|
return allowed, nil
|
|
}
|
|
|
|
func (r *Router) onBotsAllowSendMessage(ctx context.Context, bot tg.InputUserClass) (tg.UpdatesClass, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
botUser, err := r.resolveBotUserForViewer(ctx, userID, bot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := r.deps.Bots.AllowSendMessage(ctx, userID, botUser.ID, true); err != nil {
|
|
return nil, botInvalidErr()
|
|
}
|
|
res, err := r.sendBotAllowedServiceMessage(ctx, userID, botUser.ID)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
if res.Duplicate {
|
|
return tgEmptyUpdates(int(r.clock.Now().Unix())), nil
|
|
}
|
|
users := r.usersForMessageUpdate(ctx, userID, res.SenderMessage)
|
|
chats := r.chatsForMessageUpdate(ctx, userID, res.SenderMessage)
|
|
return tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, 0, false, users, chats), nil
|
|
}
|
|
|
|
func (r *Router) sendBotAllowedServiceMessage(ctx context.Context, userID, botUserID int64) (domain.SendPrivateTextResult, error) {
|
|
return r.sendBotAllowedServiceMessageWith(ctx, userID, botUserID, domain.MessageBotAllowedAction{FromRequest: true})
|
|
}
|
|
|
|
func (r *Router) sendBotAllowedServiceMessageWith(ctx context.Context, userID, botUserID int64, action domain.MessageBotAllowedAction) (domain.SendPrivateTextResult, error) {
|
|
if r.deps.Messages == nil {
|
|
return domain.SendPrivateTextResult{}, botInvalidErr()
|
|
}
|
|
recipientBlocked, err := r.peerBlocksUser(ctx, userID, botUserID)
|
|
if err != nil {
|
|
return domain.SendPrivateTextResult{}, err
|
|
}
|
|
sessionID, _ := SessionIDFrom(ctx)
|
|
return r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
|
|
SenderUserID: userID,
|
|
RecipientUserID: botUserID,
|
|
RandomID: botAllowedServiceMessageRandomID(userID, botUserID, action),
|
|
Media: &domain.MessageMedia{
|
|
Kind: domain.MessageMediaKindService,
|
|
ServiceAction: &domain.MessageServiceAction{
|
|
Kind: domain.MessageServiceActionBotAllowed,
|
|
BotAllowed: &action,
|
|
},
|
|
},
|
|
Date: int(r.clock.Now().Unix()),
|
|
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
|
|
OriginSessionID: sessionID,
|
|
RecipientBlocked: recipientBlocked,
|
|
})
|
|
}
|
|
|
|
func botAllowedServiceMessageRandomID(userID, botUserID int64, action domain.MessageBotAllowedAction) int64 {
|
|
var raw [16]byte
|
|
binary.LittleEndian.PutUint64(raw[:8], uint64(userID))
|
|
binary.LittleEndian.PutUint64(raw[8:], uint64(botUserID))
|
|
h := fnv.New64a()
|
|
_, _ = h.Write(raw[:])
|
|
if action.AttachMenu {
|
|
_, _ = h.Write([]byte("attach_menu"))
|
|
}
|
|
if action.FromRequest {
|
|
_, _ = h.Write([]byte("from_request"))
|
|
}
|
|
if action.Domain != "" {
|
|
_, _ = h.Write([]byte(action.Domain))
|
|
}
|
|
id := int64(h.Sum64() & ((uint64(1) << 63) - 1))
|
|
if id == 0 {
|
|
return -1
|
|
}
|
|
return -id
|
|
}
|
|
|
|
func (r *Router) onBotsInvokeWebViewCustomMethod(ctx context.Context, req *tg.BotsInvokeWebViewCustomMethodRequest) (*tg.DataJSON, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
bot, err := r.resolveBotUserForViewer(ctx, userID, req.Bot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
params := req.Params.Data
|
|
if !json.Valid([]byte(params)) {
|
|
return nil, methodInvalidErr()
|
|
}
|
|
if _, err := r.deps.Bots.PutWebViewCustomMethodQuery(ctx, bot.ID, userID, req.CustomMethod, params); err != nil {
|
|
return nil, methodInvalidErr()
|
|
}
|
|
return nil, methodInvalidErr()
|
|
}
|
|
|
|
func (r *Router) onBotsGetPopularAppBots(ctx context.Context, req *tg.BotsGetPopularAppBotsRequest) (*tg.BotsPopularAppBots, error) {
|
|
if _, _, err := r.currentUserID(ctx); err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
return &tg.BotsPopularAppBots{Users: []tg.UserClass{}}, nil
|
|
}
|
|
|
|
func (r *Router) onBotsAddPreviewMedia(ctx context.Context, req *tg.BotsAddPreviewMediaRequest) (*tg.BotPreviewMedia, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
app, err := r.mainBotAppForOwner(ctx, bot.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
media, err := r.botPreviewMediaFromInput(ctx, userID, req.Media)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
media.BotUserID = bot.ID
|
|
media.AppID = app.ID
|
|
out, _, err := r.deps.Bots.UpsertBotAppPreviewMedia(ctx, media)
|
|
if err != nil {
|
|
return nil, botAppInvalidErr()
|
|
}
|
|
return r.tgBotPreviewMedia(ctx, out), nil
|
|
}
|
|
|
|
func (r *Router) onBotsEditPreviewMedia(ctx context.Context, req *tg.BotsEditPreviewMediaRequest) (*tg.BotPreviewMedia, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
app, err := r.mainBotAppForOwner(ctx, bot.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
current, err := r.botPreviewMediaIDFromInput(ctx, userID, bot.ID, app.ID, req.Media)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
next, err := r.botPreviewMediaFromInput(ctx, userID, req.NewMedia)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
next.ID = current.ID
|
|
next.Position = current.Position
|
|
next.BotUserID = bot.ID
|
|
next.AppID = app.ID
|
|
out, _, err := r.deps.Bots.UpsertBotAppPreviewMedia(ctx, next)
|
|
if err != nil {
|
|
return nil, botAppInvalidErr()
|
|
}
|
|
return r.tgBotPreviewMedia(ctx, out), nil
|
|
}
|
|
|
|
func (r *Router) onBotsDeletePreviewMedia(ctx context.Context, req *tg.BotsDeletePreviewMediaRequest) (bool, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return false, internalErr()
|
|
}
|
|
bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
app, err := r.mainBotAppForOwner(ctx, bot.ID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if len(req.Media) == 0 || len(req.Media) > domain.MaxBotPreviewMedia {
|
|
return false, mediaInvalidErr()
|
|
}
|
|
for _, input := range req.Media {
|
|
current, err := r.botPreviewMediaIDFromInput(ctx, userID, bot.ID, app.ID, input)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if _, err := r.deps.Bots.DeleteBotAppPreviewMedia(ctx, bot.ID, app.ID, current.ID); err != nil {
|
|
return false, botAppInvalidErr()
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (r *Router) onBotsReorderPreviewMedias(ctx context.Context, req *tg.BotsReorderPreviewMediasRequest) (bool, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return false, internalErr()
|
|
}
|
|
bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
app, err := r.mainBotAppForOwner(ctx, bot.ID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if len(req.Order) == 0 || len(req.Order) > domain.MaxBotPreviewMedia {
|
|
return false, mediaInvalidErr()
|
|
}
|
|
ids := make([]int64, 0, len(req.Order))
|
|
seen := map[int64]bool{}
|
|
for _, media := range req.Order {
|
|
current, err := r.botPreviewMediaIDFromInput(ctx, userID, bot.ID, app.ID, media)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if seen[current.ID] {
|
|
return false, mediaInvalidErr()
|
|
}
|
|
seen[current.ID] = true
|
|
ids = append(ids, current.ID)
|
|
}
|
|
if _, err := r.deps.Bots.ReorderBotAppPreviewMedia(ctx, bot.ID, app.ID, ids); err != nil {
|
|
return false, botAppInvalidErr()
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (r *Router) onBotsGetPreviewInfo(ctx context.Context, req *tg.BotsGetPreviewInfoRequest) (*tg.BotsPreviewInfo, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
bot, err := r.resolveBotUserForViewer(ctx, userID, req.Bot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
app, found, err := r.deps.Bots.GetMainBotApp(ctx, bot.ID)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
if !found {
|
|
return &tg.BotsPreviewInfo{Media: []tg.BotPreviewMedia{}, LangCodes: []string{}}, nil
|
|
}
|
|
media, err := r.botPreviewMedias(ctx, bot.ID, app.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
langs := []string{}
|
|
if len(media) > 0 {
|
|
langs = append(langs, "")
|
|
}
|
|
return &tg.BotsPreviewInfo{Media: media, LangCodes: langs}, nil
|
|
}
|
|
|
|
func (r *Router) onBotsGetPreviewMedias(ctx context.Context, bot tg.InputUserClass) ([]tg.BotPreviewMedia, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
botUser, err := r.resolveBotUserForViewer(ctx, userID, bot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
app, found, err := r.deps.Bots.GetMainBotApp(ctx, botUser.ID)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
if !found {
|
|
return []tg.BotPreviewMedia{}, nil
|
|
}
|
|
return r.botPreviewMedias(ctx, botUser.ID, app.ID)
|
|
}
|
|
|
|
func (r *Router) onBotsUpdateUserEmojiStatus(ctx context.Context, req *tg.BotsUpdateUserEmojiStatusRequest) (bool, error) {
|
|
botID, err := r.callerBotID(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return false, internalErr()
|
|
}
|
|
if req.UserID == nil || req.EmojiStatus == nil {
|
|
return false, userIDInvalidErr()
|
|
}
|
|
if r.deps.Bots == nil || r.deps.Users == nil {
|
|
return false, userPermissionDeniedErr()
|
|
}
|
|
target, found, err := r.userFromInput(ctx, userID, req.UserID)
|
|
if err != nil {
|
|
return false, internalErr()
|
|
}
|
|
if !found {
|
|
return false, userIDInvalidErr()
|
|
}
|
|
allowed, err := r.deps.Bots.BotEmojiStatusPermission(ctx, botID, target.ID)
|
|
if err != nil {
|
|
return false, internalErr()
|
|
}
|
|
if !allowed {
|
|
return false, userPermissionDeniedErr()
|
|
}
|
|
documentID, until, err := botEmojiStatusFromTG(req.EmojiStatus)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
svc, ok := r.deps.Users.(UserPremiumService)
|
|
if !ok {
|
|
return false, userPermissionDeniedErr()
|
|
}
|
|
u, err := svc.UpdateEmojiStatus(ctx, target.ID, domain.UserEmojiStatus{DocumentID: documentID, Until: until})
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrPremiumRequired) {
|
|
return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
|
|
}
|
|
return false, internalErr()
|
|
}
|
|
r.invalidateRPCProjectionForUser(u.ID)
|
|
r.pushUserUpdates(ctx, u.ID, &tg.Updates{
|
|
Updates: []tg.UpdateClass{&tg.UpdateUserEmojiStatus{
|
|
UserID: u.ID,
|
|
EmojiStatus: tgUserEmojiStatus(u, r.clock.Now().Unix()),
|
|
}},
|
|
Users: []tg.UserClass{r.tgUser(u)},
|
|
Date: int(r.clock.Now().Unix()),
|
|
})
|
|
return true, nil
|
|
}
|
|
|
|
func (r *Router) onBotsToggleUserEmojiStatusPermission(ctx context.Context, req *tg.BotsToggleUserEmojiStatusPermissionRequest) (bool, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return false, internalErr()
|
|
}
|
|
if r.deps.Bots == nil {
|
|
return false, botInvalidErr()
|
|
}
|
|
bot, err := r.resolveBotUserForViewer(ctx, userID, req.Bot)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if err := r.deps.Bots.SetBotEmojiStatusPermission(ctx, bot.ID, userID, req.Enabled); err != nil {
|
|
return false, botInvalidErr()
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (r *Router) onBotsCheckDownloadFileParams(ctx context.Context, req *tg.BotsCheckDownloadFileParamsRequest) (bool, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return false, internalErr()
|
|
}
|
|
if _, err := r.resolveBotUserForViewer(ctx, userID, req.Bot); err != nil {
|
|
return false, err
|
|
}
|
|
return checkBotDownloadFileParams(ctx, req.FileName, req.URL), nil
|
|
}
|
|
|
|
func (r *Router) onBotsUpdateStarRefProgram(ctx context.Context, req *tg.BotsUpdateStarRefProgramRequest) (*tg.StarRefProgram, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
if _, err := r.resolveOwnedBotUser(ctx, userID, req.Bot); err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, botInvalidErr()
|
|
}
|
|
|
|
// onBotsSetCustomVerification answers bots.setCustomVerification#8b89dfbd: a
|
|
// verifier bot adding or removing its own third-party mark on a peer
|
|
// (core.telegram.org/api/bots/verification).
|
|
//
|
|
// The TL constructor allows exactly two callers, and the schema encodes which:
|
|
// bot:flags.0?InputUser "must not be set if invoked by a bot, must be set to the ID
|
|
// of an owned bot if invoked by a user". Both branches are resolved to the same
|
|
// verifier bot id before anything is written, so a user can only ever act through a
|
|
// bot they own and a bot can only ever act as itself.
|
|
//
|
|
// Bool reports successful handling. Idempotent retries still return true; both
|
|
// official clients and the Bot API interpret boolFalse as failure.
|
|
func (r *Router) onBotsSetCustomVerification(ctx context.Context, req *tg.BotsSetCustomVerificationRequest) (bool, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return false, internalErr()
|
|
}
|
|
if req == nil {
|
|
return false, peerIDInvalidErr()
|
|
}
|
|
var verifierBotID int64
|
|
if bot, ok := req.GetBot(); ok {
|
|
owned, err := r.resolveOwnedBotUser(ctx, userID, bot)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
verifierBotID = owned.ID
|
|
} else {
|
|
botID, err := r.callerBotID(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
verifierBotID = botID
|
|
}
|
|
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if r.deps.BotVerifications == nil {
|
|
// Unwired deployment: no bot can be a verifier, which is exactly what
|
|
// BOT_VERIFIER_FORBIDDEN says.
|
|
return false, botVerifierForbiddenErr()
|
|
}
|
|
setRequest := domain.SetCustomVerificationRequest{
|
|
VerifierBotID: verifierBotID,
|
|
Peer: peer,
|
|
Enabled: req.GetEnabled(),
|
|
CallerUserID: userID,
|
|
}
|
|
if description, ok := req.GetCustomDescription(); ok {
|
|
setRequest.CustomDescription = description
|
|
}
|
|
// Shape-only validation at the edge (peer kind, description length, revoke that
|
|
// still carries a description) so a malformed request gets a deterministic TL
|
|
// error regardless of how strict the wired service is.
|
|
if err := setRequest.Validate(); err != nil {
|
|
return false, setCustomVerificationErr(err)
|
|
}
|
|
_, err = r.deps.BotVerifications.SetCustomVerification(ctx, setRequest)
|
|
if err != nil {
|
|
return false, setCustomVerificationErr(err)
|
|
}
|
|
// The push belongs to the service, not here: it owns the same invalidation for
|
|
// all three drivers (this RPC, the bot dialog and the admin panel), so pushing
|
|
// again would fan out to the whole audience twice per change.
|
|
return true, nil
|
|
}
|
|
|
|
func (r *Router) onBotsGetBotRecommendations(ctx context.Context, _ tg.InputUserClass) (tg.UsersUsersClass, error) {
|
|
if _, _, err := r.currentUserID(ctx); err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
return &tg.UsersUsers{Users: []tg.UserClass{}}, nil
|
|
}
|
|
|
|
func (r *Router) onBotsRequestWebViewButton(ctx context.Context, req *tg.BotsRequestWebViewButtonRequest) (*tg.BotsRequestedButton, error) {
|
|
botID, err := r.callerBotID(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
if req.UserID == nil || req.Button == nil {
|
|
return nil, buttonDataInvalidErr()
|
|
}
|
|
if r.deps.Bots == nil {
|
|
return nil, buttonDataInvalidErr()
|
|
}
|
|
if _, found, err := r.userFromInput(ctx, userID, req.UserID); err != nil || !found {
|
|
return nil, userIDInvalidErr()
|
|
}
|
|
button, err := domainRequestedButtonFromTG(botID, req.UserID, req.Button)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
target, found, err := r.userFromInput(ctx, userID, req.UserID)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
if !found {
|
|
return nil, userIDInvalidErr()
|
|
}
|
|
button.UserID = target.ID
|
|
saved, err := r.deps.Bots.SaveRequestedWebViewButton(ctx, button)
|
|
if err != nil {
|
|
return nil, buttonDataInvalidErr()
|
|
}
|
|
return &tg.BotsRequestedButton{WebappReqID: saved.WebAppReqID}, nil
|
|
}
|
|
|
|
func (r *Router) onBotsGetRequestedWebViewButton(ctx context.Context, req *tg.BotsGetRequestedWebViewButtonRequest) (tg.KeyboardButtonClass, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
bot, err := r.resolveBotUserForViewer(ctx, userID, req.Bot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
button, found, err := r.deps.Bots.GetRequestedWebViewButton(ctx, bot.ID, userID, req.WebappReqID)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
if !found {
|
|
return nil, buttonDataInvalidErr()
|
|
}
|
|
return tgKeyboardButtonRequestPeer(button), nil
|
|
}
|
|
|
|
func botEmojiStatusFromTG(status tg.EmojiStatusClass) (documentID int64, until int, err error) {
|
|
switch s := status.(type) {
|
|
case *tg.EmojiStatusEmpty:
|
|
return 0, 0, nil
|
|
case *tg.EmojiStatus:
|
|
if s.DocumentID < 0 {
|
|
return 0, 0, tgerr400("EMOJI_STATUS_INVALID")
|
|
}
|
|
if v, ok := s.GetUntil(); ok {
|
|
until = v
|
|
}
|
|
return s.DocumentID, until, nil
|
|
case *tg.EmojiStatusCollectible:
|
|
if s.DocumentID < 0 {
|
|
return 0, 0, tgerr400("EMOJI_STATUS_INVALID")
|
|
}
|
|
if v, ok := s.GetUntil(); ok {
|
|
until = v
|
|
}
|
|
return s.DocumentID, until, nil
|
|
default:
|
|
return 0, 0, tgerr400("EMOJI_STATUS_INVALID")
|
|
}
|
|
}
|
|
|
|
func (r *Router) mainBotAppForOwner(ctx context.Context, botUserID int64) (domain.BotApp, error) {
|
|
app, found, err := r.deps.Bots.GetMainBotApp(ctx, botUserID)
|
|
if err != nil {
|
|
return domain.BotApp{}, internalErr()
|
|
}
|
|
if !found {
|
|
return domain.BotApp{}, botAppInvalidErr()
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
func (r *Router) botPreviewMediaFromInput(ctx context.Context, userID int64, input tg.InputMediaClass) (domain.BotAppPreviewMedia, error) {
|
|
media, err := r.resolveInputMedia(ctx, userID, input)
|
|
if err != nil {
|
|
return domain.BotAppPreviewMedia{}, err
|
|
}
|
|
if media == nil {
|
|
return domain.BotAppPreviewMedia{}, mediaInvalidErr()
|
|
}
|
|
switch media.Kind {
|
|
case domain.MessageMediaKindPhoto:
|
|
if media.Photo == nil || media.Photo.ID == 0 {
|
|
return domain.BotAppPreviewMedia{}, mediaInvalidErr()
|
|
}
|
|
return domain.BotAppPreviewMedia{PhotoID: media.Photo.ID}, nil
|
|
case domain.MessageMediaKindDocument:
|
|
if media.Document == nil || media.Document.ID == 0 {
|
|
return domain.BotAppPreviewMedia{}, mediaInvalidErr()
|
|
}
|
|
return domain.BotAppPreviewMedia{DocumentID: media.Document.ID}, nil
|
|
default:
|
|
return domain.BotAppPreviewMedia{}, mediaTypeInvalidErr()
|
|
}
|
|
}
|
|
|
|
func (r *Router) botPreviewMediaIDFromInput(ctx context.Context, userID, botUserID, appID int64, input tg.InputMediaClass) (domain.BotAppPreviewMedia, error) {
|
|
target, err := r.botPreviewMediaFromInput(ctx, userID, input)
|
|
if err != nil {
|
|
return domain.BotAppPreviewMedia{}, err
|
|
}
|
|
items, err := r.deps.Bots.ListBotAppPreviewMedia(ctx, botUserID, appID)
|
|
if err != nil {
|
|
return domain.BotAppPreviewMedia{}, internalErr()
|
|
}
|
|
for _, item := range items {
|
|
if target.PhotoID != 0 && item.PhotoID == target.PhotoID {
|
|
return item, nil
|
|
}
|
|
if target.DocumentID != 0 && item.DocumentID == target.DocumentID {
|
|
return item, nil
|
|
}
|
|
}
|
|
return domain.BotAppPreviewMedia{}, mediaInvalidErr()
|
|
}
|
|
|
|
func (r *Router) botPreviewMedias(ctx context.Context, botUserID, appID int64) ([]tg.BotPreviewMedia, error) {
|
|
items, err := r.deps.Bots.ListBotAppPreviewMedia(ctx, botUserID, appID)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
out := make([]tg.BotPreviewMedia, 0, len(items))
|
|
for _, item := range items {
|
|
converted := r.tgBotPreviewMedia(ctx, item)
|
|
if converted != nil {
|
|
out = append(out, *converted)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Router) tgBotPreviewMedia(ctx context.Context, item domain.BotAppPreviewMedia) *tg.BotPreviewMedia {
|
|
out := &tg.BotPreviewMedia{Date: int(r.clock.Now().Unix())}
|
|
switch {
|
|
case item.PhotoID != 0:
|
|
var photo tg.PhotoClass = &tg.PhotoEmpty{ID: item.PhotoID}
|
|
if r.deps.Files != nil {
|
|
if p, found, err := r.deps.Files.GetPhoto(ctx, item.PhotoID); err == nil && found {
|
|
photo = tgPhoto(p)
|
|
}
|
|
}
|
|
out.Media = &tg.MessageMediaPhoto{Photo: photo}
|
|
case item.DocumentID != 0:
|
|
var doc tg.DocumentClass = &tg.DocumentEmpty{ID: item.DocumentID}
|
|
if r.deps.Files != nil {
|
|
if d, found, err := r.deps.Files.GetDocument(ctx, item.DocumentID); err == nil && found {
|
|
doc = tgDocument(d)
|
|
}
|
|
}
|
|
out.Media = &tg.MessageMediaDocument{Document: doc}
|
|
default:
|
|
out.Media = &tg.MessageMediaEmpty{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func domainRequestedButtonFromTG(botUserID int64, _ tg.InputUserClass, button tg.KeyboardButtonClass) (domain.BotRequestedWebViewButton, error) {
|
|
var out domain.BotRequestedWebViewButton
|
|
out.BotUserID = botUserID
|
|
switch b := button.(type) {
|
|
case *tg.InputKeyboardButtonRequestPeer:
|
|
out.ButtonID = b.ButtonID
|
|
out.Text = strings.TrimSpace(b.Text)
|
|
out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
|
|
out.MaxQuantity = b.MaxQuantity
|
|
out.NameRequested = b.NameRequested
|
|
out.UsernameRequested = b.UsernameRequested
|
|
out.PhotoRequested = b.PhotoRequested
|
|
case *tg.KeyboardButtonRequestPeer:
|
|
out.ButtonID = b.ButtonID
|
|
out.Text = strings.TrimSpace(b.Text)
|
|
out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
|
|
out.MaxQuantity = b.MaxQuantity
|
|
default:
|
|
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
|
|
}
|
|
if out.ButtonID == 0 || out.Text == "" || out.PeerType == "" {
|
|
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func requestPeerTypeName(peerType tg.RequestPeerTypeClass) string {
|
|
switch peerType.(type) {
|
|
case *tg.RequestPeerTypeUser:
|
|
return "user"
|
|
case *tg.RequestPeerTypeChat:
|
|
return "chat"
|
|
case *tg.RequestPeerTypeBroadcast:
|
|
return "broadcast"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func tgKeyboardButtonRequestPeer(button domain.BotRequestedWebViewButton) tg.KeyboardButtonClass {
|
|
return &tg.KeyboardButtonRequestPeer{
|
|
Text: button.Text,
|
|
ButtonID: button.ButtonID,
|
|
PeerType: tgRequestPeerTypeWithFilter(button.PeerType, button.PeerFilter),
|
|
MaxQuantity: button.MaxQuantity,
|
|
}
|
|
}
|
|
|
|
func tgRequestPeerType(kind string) tg.RequestPeerTypeClass {
|
|
switch kind {
|
|
case "chat":
|
|
return &tg.RequestPeerTypeChat{}
|
|
case "broadcast":
|
|
return &tg.RequestPeerTypeBroadcast{}
|
|
default:
|
|
return &tg.RequestPeerTypeUser{}
|
|
}
|
|
}
|
|
|
|
const maxBotDownloadBytes = 25 << 20
|
|
|
|
func checkBotDownloadFileParams(ctx context.Context, fileName, rawURL string) bool {
|
|
fileName = strings.TrimSpace(fileName)
|
|
if fileName == "" || len(fileName) > 128 || filepath.Base(fileName) != fileName {
|
|
return false
|
|
}
|
|
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || len(rawURL) > domain.MaxBotAppURLLen {
|
|
return false
|
|
}
|
|
if !downloadURLHostAllowed(ctx, parsed) || !downloadExtensionAllowed(fileName, parsed.Path) {
|
|
return false
|
|
}
|
|
reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodHead, parsed.String(), nil)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
client := &http.Client{
|
|
Timeout: 3 * time.Second,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 3 || req.URL == nil || req.URL.Scheme != "https" || !downloadURLHostAllowed(req.Context(), req.URL) {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
|
return false
|
|
}
|
|
if resp.ContentLength > maxBotDownloadBytes {
|
|
return false
|
|
}
|
|
if ct := strings.ToLower(strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])); ct != "" && !downloadMIMEAllowed(ct) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func downloadURLHostAllowed(ctx context.Context, u *url.URL) bool {
|
|
host := u.Hostname()
|
|
if host == "" || strings.EqualFold(host, "localhost") {
|
|
return false
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
return publicDownloadIP(ip)
|
|
}
|
|
lookupCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
|
|
defer cancel()
|
|
addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host)
|
|
if err != nil || len(addrs) == 0 {
|
|
return false
|
|
}
|
|
for _, addr := range addrs {
|
|
if !publicDownloadIP(addr.IP) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func publicDownloadIP(ip net.IP) bool {
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
return !(ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast())
|
|
}
|
|
|
|
func downloadExtensionAllowed(fileName, urlPath string) bool {
|
|
ext := strings.ToLower(filepath.Ext(fileName))
|
|
if ext == "" {
|
|
ext = strings.ToLower(filepath.Ext(urlPath))
|
|
}
|
|
switch ext {
|
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4", ".mov", ".pdf", ".txt", ".json", ".zip":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func downloadMIMEAllowed(mime string) bool {
|
|
if strings.HasPrefix(mime, "image/") || strings.HasPrefix(mime, "video/") {
|
|
return true
|
|
}
|
|
switch mime {
|
|
case "application/pdf", "application/json", "application/zip", "application/octet-stream", "text/plain":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (r *Router) onBotsGetAccessSettings(ctx context.Context, bot tg.InputUserClass) (*tg.BotsAccessSettings, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
if _, err := r.resolveBotUserForViewer(ctx, userID, bot); err != nil {
|
|
return nil, err
|
|
}
|
|
return &tg.BotsAccessSettings{}, nil
|
|
}
|
|
|
|
func (r *Router) onBotsEditAccessSettings(ctx context.Context, req *tg.BotsEditAccessSettingsRequest) (bool, error) {
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return false, internalErr()
|
|
}
|
|
if _, err := r.resolveOwnedBotUser(ctx, userID, req.Bot); err != nil {
|
|
return false, err
|
|
}
|
|
return false, botInvalidErr()
|
|
}
|