chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
294
internal/botapi/inline.go
Normal file
294
internal/botapi/inline.go
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
package botapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func inlineResultFromAPI(raw string) (domain.BotInlineResult, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return domain.BotInlineResult{}, errors.New("RESULT_ID_INVALID")
|
||||
}
|
||||
var payload apiInlineResult
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return domain.BotInlineResult{}, errors.New("RESULT_TYPE_INVALID")
|
||||
}
|
||||
if payload.ID == "" {
|
||||
return domain.BotInlineResult{}, errors.New("RESULT_ID_EMPTY")
|
||||
}
|
||||
if len(payload.ID) > domain.MaxBotInlineResultIDLen {
|
||||
return domain.BotInlineResult{}, errors.New("RESULT_ID_INVALID")
|
||||
}
|
||||
if payload.Type != "article" {
|
||||
return domain.BotInlineResult{}, errors.New("RESULT_TYPE_INVALID")
|
||||
}
|
||||
if payload.URL != "" && !validBotAPIHTTPSURL(payload.URL) {
|
||||
return domain.BotInlineResult{}, errors.New("BUTTON_URL_INVALID")
|
||||
}
|
||||
message, entities, noWebpage, err := inputTextMessageContentFromAPI(payload)
|
||||
if err != nil {
|
||||
return domain.BotInlineResult{}, err
|
||||
}
|
||||
markup, err := replyMarkupFromAPI(payload.ReplyMarkup)
|
||||
if err != nil {
|
||||
return domain.BotInlineResult{}, err
|
||||
}
|
||||
return domain.BotInlineResult{
|
||||
ID: payload.ID,
|
||||
Type: payload.Type,
|
||||
Title: payload.Title,
|
||||
Description: payload.Description,
|
||||
URL: payload.URL,
|
||||
Message: message,
|
||||
Entities: entities,
|
||||
ReplyMarkup: markup,
|
||||
NoWebpage: noWebpage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func inputTextMessageContentFromAPI(payload apiInlineResult) (string, []domain.MessageEntity, bool, error) {
|
||||
var content apiInputTextMessageContent
|
||||
if len(payload.InputMessageContent) > 0 && string(payload.InputMessageContent) != "null" {
|
||||
if err := json.Unmarshal(payload.InputMessageContent, &content); err != nil {
|
||||
return "", nil, false, errors.New("MESSAGE_EMPTY")
|
||||
}
|
||||
} else if payload.MessageText != "" {
|
||||
content.MessageText = payload.MessageText
|
||||
}
|
||||
if content.ParseMode != "" {
|
||||
return "", nil, false, errors.New("ENTITY_PARSE_UNSUPPORTED")
|
||||
}
|
||||
message := content.MessageText
|
||||
if message == "" {
|
||||
return "", nil, false, errors.New("MESSAGE_EMPTY")
|
||||
}
|
||||
if utf8.RuneCountInString(message) > domain.MaxMessageTextLength {
|
||||
return "", nil, false, errors.New("MESSAGE_TOO_LONG")
|
||||
}
|
||||
entities, err := messageEntitiesFromAPI(content.Entities)
|
||||
if err != nil {
|
||||
return "", nil, false, err
|
||||
}
|
||||
noWebpage := content.DisableWebPagePreview || content.LinkPreviewOptions.IsDisabled
|
||||
return message, entities, noWebpage, nil
|
||||
}
|
||||
|
||||
func messageEntitiesFromAPI(in []apiMessageEntity) ([]domain.MessageEntity, error) {
|
||||
if len(in) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(in) > domain.MaxMessageEntityCount {
|
||||
return nil, errors.New("ENTITIES_TOO_LONG")
|
||||
}
|
||||
out := make([]domain.MessageEntity, 0, len(in))
|
||||
for _, entity := range in {
|
||||
if entity.Offset < 0 || entity.Length <= 0 {
|
||||
return nil, errors.New("ENTITY_BOUNDS_INVALID")
|
||||
}
|
||||
mapped, ok := apiEntityType(entity.Type)
|
||||
if !ok {
|
||||
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
|
||||
}
|
||||
item := domain.MessageEntity{
|
||||
Type: mapped,
|
||||
Offset: entity.Offset,
|
||||
Length: entity.Length,
|
||||
URL: entity.URL,
|
||||
Language: entity.Language,
|
||||
}
|
||||
if entity.User != nil {
|
||||
item.UserID = entity.User.ID
|
||||
}
|
||||
if entity.CustomEmojiID != "" {
|
||||
id, err := strconv.ParseInt(entity.CustomEmojiID, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
|
||||
}
|
||||
item.DocumentID = id
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func apiEntityType(in string) (domain.MessageEntityType, bool) {
|
||||
switch in {
|
||||
case "bold":
|
||||
return domain.MessageEntityBold, true
|
||||
case "italic":
|
||||
return domain.MessageEntityItalic, true
|
||||
case "underline":
|
||||
return domain.MessageEntityUnderline, true
|
||||
case "strikethrough":
|
||||
return domain.MessageEntityStrike, true
|
||||
case "code":
|
||||
return domain.MessageEntityCode, true
|
||||
case "pre":
|
||||
return domain.MessageEntityPre, true
|
||||
case "text_link":
|
||||
return domain.MessageEntityTextURL, true
|
||||
case "text_mention":
|
||||
return domain.MessageEntityMentionName, true
|
||||
case "spoiler":
|
||||
return domain.MessageEntitySpoiler, true
|
||||
case "blockquote":
|
||||
return domain.MessageEntityBlockquote, true
|
||||
case "custom_emoji":
|
||||
return domain.MessageEntityCustomEmoji, true
|
||||
case "mention":
|
||||
return domain.MessageEntityMention, true
|
||||
case "hashtag":
|
||||
return domain.MessageEntityHashtag, true
|
||||
case "cashtag":
|
||||
return domain.MessageEntityCashtag, true
|
||||
case "bot_command":
|
||||
return domain.MessageEntityBotCommand, true
|
||||
case "url":
|
||||
return domain.MessageEntityURL, true
|
||||
case "email":
|
||||
return domain.MessageEntityEmail, true
|
||||
case "phone_number":
|
||||
return domain.MessageEntityPhone, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var payload apiInlineKeyboardMarkup
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return nil, errors.New("BUTTON_INVALID")
|
||||
}
|
||||
if len(payload.InlineKeyboard) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := &domain.MessageReplyMarkup{Inline: make([][]domain.MarkupButton, 0, len(payload.InlineKeyboard))}
|
||||
for _, row := range payload.InlineKeyboard {
|
||||
domainRow := make([]domain.MarkupButton, 0, len(row))
|
||||
for _, button := range row {
|
||||
item, err := markupButtonFromAPI(button)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domainRow = append(domainRow, item)
|
||||
}
|
||||
out.Inline = append(out.Inline, domainRow)
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(out); err != nil {
|
||||
return nil, replyMarkupErrFromDomain(err)
|
||||
}
|
||||
if out.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, error) {
|
||||
if button.URL != "" {
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL}, nil
|
||||
}
|
||||
if button.CallbackData != nil {
|
||||
if *button.CallbackData == "" || len([]byte(*button.CallbackData)) > domain.MaxCallbackDataLen {
|
||||
return domain.MarkupButton{}, errors.New("BUTTON_DATA_INVALID")
|
||||
}
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonCallback, Text: button.Text, Data: []byte(*button.CallbackData)}, nil
|
||||
}
|
||||
return domain.MarkupButton{}, errors.New("BUTTON_INVALID")
|
||||
}
|
||||
|
||||
func replyMarkupErrFromDomain(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrButtonURLInvalid):
|
||||
return errors.New("BUTTON_URL_INVALID")
|
||||
case errors.Is(err, domain.ErrButtonDataInvalid):
|
||||
return errors.New("BUTTON_DATA_INVALID")
|
||||
default:
|
||||
return errors.New("BUTTON_INVALID")
|
||||
}
|
||||
}
|
||||
|
||||
func preparedPeerTypesFromAPI(values map[string]string) []string {
|
||||
out := make([]string, 0, 5)
|
||||
if apiBool(values["allow_user_chats"]) {
|
||||
out = append(out, store.InlineQueryPeerTypePM)
|
||||
}
|
||||
if apiBool(values["allow_bot_chats"]) {
|
||||
out = append(out, store.InlineQueryPeerTypeBotPM)
|
||||
}
|
||||
if apiBool(values["allow_group_chats"]) {
|
||||
out = append(out, store.InlineQueryPeerTypeChat, store.InlineQueryPeerTypeMegagroup)
|
||||
}
|
||||
if apiBool(values["allow_channel_chats"]) {
|
||||
out = append(out, store.InlineQueryPeerTypeBroadcast)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apiBool(raw string) bool {
|
||||
v, err := strconv.ParseBool(strings.TrimSpace(raw))
|
||||
return err == nil && v
|
||||
}
|
||||
|
||||
func validBotAPIHTTPSURL(raw string) bool {
|
||||
if raw == "" || len(raw) > domain.MaxBotInlineWebURLLen || strings.TrimSpace(raw) != raw {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.User != nil || parsed.Host == "" || strings.ToLower(parsed.Scheme) != "https" {
|
||||
return false
|
||||
}
|
||||
return !strings.ContainsAny(parsed.Host, " \t\r\n")
|
||||
}
|
||||
|
||||
type apiInlineResult struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
MessageText string `json:"message_text"`
|
||||
InputMessageContent json.RawMessage `json:"input_message_content"`
|
||||
ReplyMarkup json.RawMessage `json:"reply_markup"`
|
||||
}
|
||||
|
||||
type apiInputTextMessageContent struct {
|
||||
MessageText string `json:"message_text"`
|
||||
ParseMode string `json:"parse_mode"`
|
||||
Entities []apiMessageEntity `json:"entities"`
|
||||
DisableWebPagePreview bool `json:"disable_web_page_preview"`
|
||||
LinkPreviewOptions struct {
|
||||
IsDisabled bool `json:"is_disabled"`
|
||||
} `json:"link_preview_options"`
|
||||
}
|
||||
|
||||
type apiMessageEntity struct {
|
||||
Type string `json:"type"`
|
||||
Offset int `json:"offset"`
|
||||
Length int `json:"length"`
|
||||
URL string `json:"url"`
|
||||
User *struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"user"`
|
||||
Language string `json:"language"`
|
||||
CustomEmojiID string `json:"custom_emoji_id"`
|
||||
}
|
||||
|
||||
type apiInlineKeyboardMarkup struct {
|
||||
InlineKeyboard [][]apiInlineKeyboardButton `json:"inline_keyboard"`
|
||||
}
|
||||
|
||||
type apiInlineKeyboardButton struct {
|
||||
Text string `json:"text"`
|
||||
URL string `json:"url"`
|
||||
CallbackData *string `json:"callback_data"`
|
||||
}
|
||||
416
internal/botapi/server.go
Normal file
416
internal/botapi/server.go
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
package botapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type BotsService interface {
|
||||
BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error)
|
||||
SetBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (int, error)
|
||||
GetBotMenuButton(ctx context.Context, botUserID int64) (domain.BotMenuButton, error)
|
||||
BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error)
|
||||
}
|
||||
|
||||
type UsersService interface {
|
||||
UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error)
|
||||
}
|
||||
|
||||
type WebAppService interface {
|
||||
AnswerWebAppQueryFromBotAPI(ctx context.Context, botID int64, webAppQueryID string, result domain.BotInlineResult) (inlineMessageID string, err error)
|
||||
SavePreparedInlineMessageFromBotAPI(ctx context.Context, botID, userID int64, result domain.BotInlineResult, peerTypes []string) (id string, expireDate int, err error)
|
||||
}
|
||||
|
||||
func Start(ctx context.Context, addr string, bots BotsService, users UsersService, webapps WebAppService, logger *zap.Logger) (*http.Server, error) {
|
||||
if strings.TrimSpace(addr) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
handler := &handler{bots: bots, users: users, webapps: webapps, logger: logger}
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler.routes(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go func() {
|
||||
logger.Info("Bot API 网关已启用", zap.String("addr", addr))
|
||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Warn("Bot API 网关退出", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
}()
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
bots BotsService
|
||||
users UsersService
|
||||
webapps WebAppService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func (h *handler) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", h.handle)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (h *handler) handle(w http.ResponseWriter, r *http.Request) {
|
||||
token, method, ok := splitBotPath(r.URL.Path)
|
||||
if !ok {
|
||||
writeAPIError(w, http.StatusNotFound, "METHOD_NOT_FOUND")
|
||||
return
|
||||
}
|
||||
botID, ok := h.authenticate(r.Context(), token)
|
||||
if !ok {
|
||||
writeAPIError(w, http.StatusUnauthorized, "ACCESS_TOKEN_INVALID")
|
||||
return
|
||||
}
|
||||
switch strings.ToLower(method) {
|
||||
case "setchatmenubutton":
|
||||
h.setChatMenuButton(w, r, botID)
|
||||
case "getchatmenubutton":
|
||||
h.getChatMenuButton(w, r, botID)
|
||||
case "setuseremojistatus":
|
||||
h.setUserEmojiStatus(w, r, botID)
|
||||
case "answerwebappquery":
|
||||
h.answerWebAppQuery(w, r, botID)
|
||||
case "savepreparedinlinemessage":
|
||||
h.savePreparedInlineMessage(w, r, botID)
|
||||
case "answershippingquery", "answerprecheckoutquery":
|
||||
writeAPIError(w, http.StatusNotImplemented, "BLOCKED_DURABLE_QUERY_STATE_MISSING")
|
||||
default:
|
||||
writeAPIError(w, http.StatusNotFound, "METHOD_NOT_FOUND")
|
||||
}
|
||||
}
|
||||
|
||||
func splitBotPath(path string) (token, method string, ok bool) {
|
||||
rest := strings.TrimPrefix(path, "/bot")
|
||||
rest = strings.TrimPrefix(rest, "/")
|
||||
if rest == "" {
|
||||
return "", "", false
|
||||
}
|
||||
token, method, found := strings.Cut(rest, "/")
|
||||
if !found || token == "" || method == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return token, method, true
|
||||
}
|
||||
|
||||
func (h *handler) authenticate(ctx context.Context, token string) (int64, bool) {
|
||||
if h.bots == nil {
|
||||
return 0, false
|
||||
}
|
||||
botID, secret, ok := domain.ParseBotToken(token)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
profile, found, err := h.bots.BotInfo(ctx, botID)
|
||||
if err != nil || !found || profile.TokenSecret != secret {
|
||||
return 0, false
|
||||
}
|
||||
return botID, true
|
||||
}
|
||||
|
||||
func (h *handler) setChatMenuButton(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
if h.bots == nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
values, err := requestValues(r)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
button, err := menuButtonFromAPI(values["menu_button"])
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BUTTON_INVALID")
|
||||
return
|
||||
}
|
||||
if _, err := h.bots.SetBotMenuButton(r.Context(), botID, button); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BUTTON_INVALID")
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, true)
|
||||
}
|
||||
|
||||
func (h *handler) getChatMenuButton(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
if h.bots == nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
button, err := h.bots.GetBotMenuButton(r.Context(), botID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, apiMenuButton(button))
|
||||
}
|
||||
|
||||
func (h *handler) setUserEmojiStatus(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
if h.bots == nil || h.users == nil {
|
||||
writeAPIError(w, http.StatusNotImplemented, "BLOCKED_USER_EMOJI_STATUS_SERVICE_MISSING")
|
||||
return
|
||||
}
|
||||
values, err := requestValues(r)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseInt(values["user_id"], 10, 64)
|
||||
if err != nil || userID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "USER_ID_INVALID")
|
||||
return
|
||||
}
|
||||
allowed, err := h.bots.BotEmojiStatusPermission(r.Context(), botID, userID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
writeAPIError(w, http.StatusForbidden, "USER_PERMISSION_DENIED")
|
||||
return
|
||||
}
|
||||
var documentID int64
|
||||
if raw := strings.TrimSpace(values["emoji_status_custom_emoji_id"]); raw != "" {
|
||||
documentID, err = strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || documentID < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "EMOJI_STATUS_INVALID")
|
||||
return
|
||||
}
|
||||
}
|
||||
var until int
|
||||
if raw := strings.TrimSpace(values["emoji_status_expiration_date"]); raw != "" {
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "EMOJI_STATUS_INVALID")
|
||||
return
|
||||
}
|
||||
until = n
|
||||
}
|
||||
if _, err := h.users.UpdateEmojiStatus(r.Context(), userID, documentID, until); err != nil {
|
||||
if errors.Is(err, domain.ErrPremiumRequired) {
|
||||
writeAPIError(w, http.StatusBadRequest, "PREMIUM_ACCOUNT_REQUIRED")
|
||||
return
|
||||
}
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, true)
|
||||
}
|
||||
|
||||
func (h *handler) answerWebAppQuery(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
if h.webapps == nil {
|
||||
writeAPIError(w, http.StatusNotImplemented, "BLOCKED_WEBAPP_QUERY_SERVICE_MISSING")
|
||||
return
|
||||
}
|
||||
values, err := requestValues(r)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
queryID := strings.TrimSpace(values["web_app_query_id"])
|
||||
if queryID == "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "QUERY_ID_INVALID")
|
||||
return
|
||||
}
|
||||
result, err := inlineResultFromAPI(values["result"])
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
inlineID, err := h.webapps.AnswerWebAppQueryFromBotAPI(r.Context(), botID, queryID, result)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
}
|
||||
resp := map[string]any{}
|
||||
if inlineID != "" {
|
||||
resp["inline_message_id"] = inlineID
|
||||
}
|
||||
writeAPIOK(w, resp)
|
||||
}
|
||||
|
||||
func (h *handler) savePreparedInlineMessage(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
if h.webapps == nil {
|
||||
writeAPIError(w, http.StatusNotImplemented, "BLOCKED_PREPARED_INLINE_SERVICE_MISSING")
|
||||
return
|
||||
}
|
||||
values, err := requestValues(r)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseInt(values["user_id"], 10, 64)
|
||||
if err != nil || userID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "USER_ID_INVALID")
|
||||
return
|
||||
}
|
||||
result, err := inlineResultFromAPI(values["result"])
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
peerTypes := preparedPeerTypesFromAPI(values)
|
||||
id, expireDate, err := h.webapps.SavePreparedInlineMessageFromBotAPI(r.Context(), botID, userID, result, peerTypes)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, map[string]any{"id": id, "expiration_date": expireDate})
|
||||
}
|
||||
|
||||
func requestValues(r *http.Request) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range body {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
out[k] = x
|
||||
default:
|
||||
b, _ := json.Marshal(x)
|
||||
out[k] = string(b)
|
||||
}
|
||||
}
|
||||
if _, nested := out["menu_button"]; !nested {
|
||||
if _, direct := body["type"]; direct {
|
||||
b, _ := json.Marshal(body)
|
||||
out["menu_button"] = string(b)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range r.Form {
|
||||
if len(v) > 0 {
|
||||
out[k] = v[0]
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func menuButtonFromAPI(raw string) (domain.BotMenuButton, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return domain.BotMenuButton{Type: domain.BotMenuButtonDefault}, nil
|
||||
}
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
WebApp struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"web_app"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return domain.BotMenuButton{}, err
|
||||
}
|
||||
switch payload.Type {
|
||||
case "default":
|
||||
return domain.BotMenuButton{Type: domain.BotMenuButtonDefault}, nil
|
||||
case "commands":
|
||||
return domain.BotMenuButton{Type: domain.BotMenuButtonCommands}, nil
|
||||
case "web_app":
|
||||
return domain.BotMenuButton{Type: domain.BotMenuButtonWebView, Text: payload.Text, URL: payload.WebApp.URL}, nil
|
||||
default:
|
||||
return domain.BotMenuButton{}, fmt.Errorf("unknown menu button type")
|
||||
}
|
||||
}
|
||||
|
||||
func apiMenuButton(button domain.BotMenuButton) map[string]any {
|
||||
switch button.Type {
|
||||
case domain.BotMenuButtonCommands:
|
||||
return map[string]any{"type": "commands"}
|
||||
case domain.BotMenuButtonWebView:
|
||||
return map[string]any{
|
||||
"type": "web_app",
|
||||
"text": button.Text,
|
||||
"web_app": map[string]any{
|
||||
"url": button.URL,
|
||||
},
|
||||
}
|
||||
default:
|
||||
return map[string]any{"type": "default"}
|
||||
}
|
||||
}
|
||||
|
||||
func writeAPIOK(w http.ResponseWriter, result any) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result})
|
||||
}
|
||||
|
||||
func writeAPIError(w http.ResponseWriter, status int, description string) {
|
||||
writeJSON(w, status, map[string]any{"ok": false, "error_code": status, "description": description})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func apiErrorDescription(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.ToUpper(err.Error())
|
||||
for _, marker := range []string{
|
||||
"QUERY_ID_INVALID",
|
||||
"USER_ID_INVALID",
|
||||
"RESULT_ID_INVALID",
|
||||
"RESULT_ID_EMPTY",
|
||||
"RESULT_TYPE_INVALID",
|
||||
"MESSAGE_EMPTY",
|
||||
"MESSAGE_TOO_LONG",
|
||||
"BUTTON_INVALID",
|
||||
"BUTTON_DATA_INVALID",
|
||||
"BUTTON_URL_INVALID",
|
||||
"BOT_INVALID",
|
||||
"USER_BOT_REQUIRED",
|
||||
} {
|
||||
if strings.Contains(text, marker) {
|
||||
return marker
|
||||
}
|
||||
}
|
||||
return "BAD_REQUEST"
|
||||
}
|
||||
|
||||
func randomNonZeroInt64() int64 {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return time.Now().UnixNano()
|
||||
}
|
||||
v := int64(binary.LittleEndian.Uint64(b[:]) & 0x7fffffffffffffff)
|
||||
if v == 0 {
|
||||
return time.Now().UnixNano()
|
||||
}
|
||||
return v
|
||||
}
|
||||
198
internal/botapi/server_test.go
Normal file
198
internal/botapi/server_test.go
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
package botapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestAnswerWebAppQueryParsesArticleAndCallsService(t *testing.T) {
|
||||
webapps := &fakeWebAppService{}
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
h := (&handler{bots: bots, webapps: webapps}).routes()
|
||||
body := `{
|
||||
"web_app_query_id": "web-query-1",
|
||||
"result": {
|
||||
"type": "article",
|
||||
"id": "share-1",
|
||||
"title": "Share",
|
||||
"description": "from mini app",
|
||||
"url": "https://example.com/share",
|
||||
"input_message_content": {
|
||||
"message_text": "hello mini app",
|
||||
"disable_web_page_preview": true,
|
||||
"entities": [{"type": "bold", "offset": 0, "length": 5}]
|
||||
},
|
||||
"reply_markup": {
|
||||
"inline_keyboard": [[
|
||||
{"text": "Open", "url": "https://example.com/open"},
|
||||
{"text": "Tap", "callback_data": "cb"}
|
||||
]]
|
||||
}
|
||||
}
|
||||
}`
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "answerWebAppQuery", body)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !resp.OK {
|
||||
t.Fatalf("response ok = false: %s", rec.Body.String())
|
||||
}
|
||||
if !webapps.answerCalled || webapps.answerBotID != bots.profile.BotUserID || webapps.answerQueryID != "web-query-1" {
|
||||
t.Fatalf("answer call = %#v", webapps)
|
||||
}
|
||||
got := webapps.answerResult
|
||||
if got.ID != "share-1" || got.Type != "article" || got.Message != "hello mini app" || !got.NoWebpage || got.URL != "https://example.com/share" {
|
||||
t.Fatalf("result = %#v", got)
|
||||
}
|
||||
if len(got.Entities) != 1 || got.Entities[0].Type != domain.MessageEntityBold {
|
||||
t.Fatalf("entities = %#v", got.Entities)
|
||||
}
|
||||
if got.ReplyMarkup == nil || len(got.ReplyMarkup.Inline) != 1 || len(got.ReplyMarkup.Inline[0]) != 2 {
|
||||
t.Fatalf("reply markup = %#v", got.ReplyMarkup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePreparedInlineMessageParsesPeerTypes(t *testing.T) {
|
||||
webapps := &fakeWebAppService{preparedID: "prepared-1", preparedExpire: 123456}
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
h := (&handler{bots: bots, webapps: webapps}).routes()
|
||||
body := `{
|
||||
"user_id": 2001,
|
||||
"allow_user_chats": true,
|
||||
"allow_channel_chats": true,
|
||||
"result": {
|
||||
"type": "article",
|
||||
"id": "prepared-share",
|
||||
"title": "Prepared",
|
||||
"input_message_content": {"message_text": "share me"}
|
||||
}
|
||||
}`
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "savePreparedInlineMessage", body)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !webapps.preparedCalled || webapps.preparedBotID != 1001 || webapps.preparedUserID != 2001 {
|
||||
t.Fatalf("prepared call = %#v", webapps)
|
||||
}
|
||||
wantPeers := []string{store.InlineQueryPeerTypePM, store.InlineQueryPeerTypeBroadcast}
|
||||
if !reflect.DeepEqual(webapps.preparedPeerTypes, wantPeers) {
|
||||
t.Fatalf("peer types = %#v, want %#v", webapps.preparedPeerTypes, wantPeers)
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Result struct {
|
||||
ID string `json:"id"`
|
||||
ExpirationDate int `json:"expiration_date"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Result.ID != "prepared-1" || resp.Result.ExpirationDate != 123456 {
|
||||
t.Fatalf("response = %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerWebAppQueryRejectsUnsupportedResult(t *testing.T) {
|
||||
webapps := &fakeWebAppService{}
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
h := (&handler{bots: bots, webapps: webapps}).routes()
|
||||
body := `{
|
||||
"web_app_query_id": "web-query-1",
|
||||
"result": {"type": "photo", "id": "bad", "photo_url": "https://example.com/p.jpg"}
|
||||
}`
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "answerWebAppQuery", body)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if webapps.answerCalled {
|
||||
t.Fatalf("unsupported result should not call webapp service")
|
||||
}
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.OK || resp.Description != "RESULT_TYPE_INVALID" {
|
||||
t.Fatalf("response = %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func performBotAPIRequest(t *testing.T, h http.Handler, profile domain.BotProfile, method, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
token := domain.FormatBotToken(profile.BotUserID, profile.TokenSecret)
|
||||
req := httptest.NewRequest(http.MethodPost, "/bot"+token+"/"+method, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type fakeBotAPIBots struct {
|
||||
profile domain.BotProfile
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIBots) BotInfo(context.Context, int64) (domain.BotProfile, bool, error) {
|
||||
return f.profile, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIBots) SetBotMenuButton(context.Context, int64, domain.BotMenuButton) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIBots) GetBotMenuButton(context.Context, int64) (domain.BotMenuButton, error) {
|
||||
return domain.BotMenuButton{Type: domain.BotMenuButtonDefault}, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIBots) BotEmojiStatusPermission(context.Context, int64, int64) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type fakeWebAppService struct {
|
||||
answerCalled bool
|
||||
answerBotID int64
|
||||
answerQueryID string
|
||||
answerResult domain.BotInlineResult
|
||||
|
||||
preparedCalled bool
|
||||
preparedBotID int64
|
||||
preparedUserID int64
|
||||
preparedResult domain.BotInlineResult
|
||||
preparedPeerTypes []string
|
||||
preparedID string
|
||||
preparedExpire int
|
||||
}
|
||||
|
||||
func (f *fakeWebAppService) AnswerWebAppQueryFromBotAPI(_ context.Context, botID int64, queryID string, result domain.BotInlineResult) (string, error) {
|
||||
f.answerCalled = true
|
||||
f.answerBotID = botID
|
||||
f.answerQueryID = queryID
|
||||
f.answerResult = result
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f *fakeWebAppService) SavePreparedInlineMessageFromBotAPI(_ context.Context, botID, userID int64, result domain.BotInlineResult, peerTypes []string) (string, int, error) {
|
||||
f.preparedCalled = true
|
||||
f.preparedBotID = botID
|
||||
f.preparedUserID = userID
|
||||
f.preparedResult = result
|
||||
f.preparedPeerTypes = append([]string(nil), peerTypes...)
|
||||
return f.preparedID, f.preparedExpire, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue