feat: sync Bot API gateway support

This commit is contained in:
A 2026-07-09 13:49:24 +08:00
parent 9a501f900a
commit 4c0cc2b7a7
44 changed files with 4609 additions and 49 deletions

View file

@ -0,0 +1,431 @@
package botapi
import (
"encoding/base64"
"encoding/json"
"errors"
"strconv"
"strings"
"telesrv/internal/domain"
)
func apiInt(raw string, fallback int) int {
raw = strings.TrimSpace(raw)
if raw == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return v
}
func botAPIMessageEntities(raw string) ([]domain.MessageEntity, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil
}
var payload []apiMessageEntity
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, errors.New("ENTITY_INVALID")
}
return messageEntitiesFromAPI(payload)
}
func allowedUpdates(raw string) map[string]struct{} {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil
}
out := make(map[string]struct{}, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item != "" {
out[item] = struct{}{}
}
}
return out
}
func apiUpdates(events []domain.UpdateEvent, allowed map[string]struct{}, limit int) []map[string]any {
if limit <= 0 || limit > 100 {
limit = 100
}
out := make([]map[string]any, 0, min(len(events), limit))
for _, event := range events {
item, kind, ok := apiUpdate(event)
if !ok || !updateAllowed(kind, allowed) {
continue
}
out = append(out, item)
if len(out) >= limit {
break
}
}
if out == nil {
return []map[string]any{}
}
return out
}
func updateAllowed(kind string, allowed map[string]struct{}) bool {
if len(allowed) == 0 {
return true
}
_, ok := allowed[kind]
return ok
}
func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
if event.Pts <= 0 {
return nil, "", false
}
switch event.Type {
case domain.UpdateEventNewMessage:
if !apiMessageProjectable(event.Message) {
return nil, "", false
}
return map[string]any{
"update_id": event.Pts,
"message": apiMessage(event.Message, event.Users),
}, "message", true
case domain.UpdateEventEditMessage:
if !apiMessageProjectable(event.Message) {
return nil, "", false
}
return map[string]any{
"update_id": event.Pts,
"edited_message": apiMessage(event.Message, event.Users),
}, "edited_message", true
default:
return nil, "", false
}
}
func apiMessageProjectable(msg domain.Message) bool {
if msg.Out || msg.ID <= 0 {
return false
}
return msg.Body != "" || len(apiMessageMedia(msg.Media)) > 0
}
func apiUser(u domain.User) map[string]any {
first := u.FirstName
if strings.TrimSpace(first) == "" {
first = "User " + strconv.FormatInt(u.ID, 10)
}
out := map[string]any{
"id": u.ID,
"is_bot": u.Bot,
"first_name": first,
}
if u.LastName != "" {
out["last_name"] = u.LastName
}
if u.Username != "" {
out["username"] = u.Username
}
return out
}
func apiMessage(msg domain.Message, users []domain.User) map[string]any {
userByID := map[int64]domain.User{}
for _, u := range users {
userByID[u.ID] = u
}
out := map[string]any{
"message_id": msg.ID,
"date": msg.Date,
"chat": apiChat(msg.Peer, userByID),
}
if msg.From.Type == domain.PeerTypeUser && msg.From.ID != 0 {
from := userByID[msg.From.ID]
if from.ID == 0 {
from = domain.User{ID: msg.From.ID}
}
if msg.Out && msg.From.ID == msg.OwnerUserID {
from.Bot = true
}
out["from"] = apiUser(from)
}
media := apiMessageMedia(msg.Media)
if msg.Body != "" {
if len(media) > 0 {
out["caption"] = msg.Body
} else {
out["text"] = msg.Body
}
}
if entities := apiMessageEntities(msg.Entities, userByID); len(entities) > 0 {
if len(media) > 0 {
out["caption_entities"] = entities
} else {
out["entities"] = entities
}
}
if msg.EditDate > 0 {
out["edit_date"] = msg.EditDate
}
if msg.ReplyTo != nil && msg.ReplyTo.MessageID > 0 {
out["reply_to_message"] = map[string]any{
"message_id": msg.ReplyTo.MessageID,
"date": 0,
"chat": apiChat(msg.ReplyTo.Peer, userByID),
}
}
if markup := apiReplyMarkup(msg.ReplyMarkup); markup != nil {
out["reply_markup"] = markup
}
if len(media) > 0 {
for k, v := range media {
out[k] = v
}
}
return out
}
func apiChat(peer domain.Peer, users map[int64]domain.User) map[string]any {
switch peer.Type {
case domain.PeerTypeUser:
out := map[string]any{
"id": peer.ID,
"type": "private",
}
if u := users[peer.ID]; u.ID != 0 {
out["first_name"] = apiUserFirstName(u)
if u.LastName != "" {
out["last_name"] = u.LastName
}
if u.Username != "" {
out["username"] = u.Username
}
}
return out
case domain.PeerTypeChannel:
return map[string]any{
"id": -1000000000000 - peer.ID,
"type": "supergroup",
}
default:
return map[string]any{
"id": peer.ID,
"type": "private",
}
}
}
func apiUserFirstName(u domain.User) string {
if strings.TrimSpace(u.FirstName) != "" {
return u.FirstName
}
return "User " + strconv.FormatInt(u.ID, 10)
}
func apiMessageEntities(in []domain.MessageEntity, users map[int64]domain.User) []map[string]any {
if len(in) == 0 {
return nil
}
out := make([]map[string]any, 0, len(in))
for _, entity := range in {
typ, ok := botAPIEntityType(entity.Type)
if !ok || entity.Offset < 0 || entity.Length <= 0 {
continue
}
item := map[string]any{
"type": typ,
"offset": entity.Offset,
"length": entity.Length,
}
if entity.URL != "" {
item["url"] = entity.URL
}
if entity.Language != "" {
item["language"] = entity.Language
}
if entity.UserID != 0 {
u := users[entity.UserID]
if u.ID == 0 {
u = domain.User{ID: entity.UserID}
}
item["user"] = apiUser(u)
}
if entity.DocumentID != 0 {
item["custom_emoji_id"] = strconv.FormatInt(entity.DocumentID, 10)
}
out = append(out, item)
}
return out
}
func botAPIEntityType(in domain.MessageEntityType) (string, bool) {
switch in {
case domain.MessageEntityBold:
return "bold", true
case domain.MessageEntityItalic:
return "italic", true
case domain.MessageEntityUnderline:
return "underline", true
case domain.MessageEntityStrike:
return "strikethrough", true
case domain.MessageEntityCode:
return "code", true
case domain.MessageEntityPre:
return "pre", true
case domain.MessageEntityTextURL:
return "text_link", true
case domain.MessageEntityMentionName:
return "text_mention", true
case domain.MessageEntitySpoiler:
return "spoiler", true
case domain.MessageEntityBlockquote:
return "blockquote", true
case domain.MessageEntityCustomEmoji:
return "custom_emoji", true
case domain.MessageEntityMention:
return "mention", true
case domain.MessageEntityHashtag:
return "hashtag", true
case domain.MessageEntityCashtag:
return "cashtag", true
case domain.MessageEntityBotCommand:
return "bot_command", true
case domain.MessageEntityURL:
return "url", true
case domain.MessageEntityEmail:
return "email", true
case domain.MessageEntityPhone:
return "phone_number", true
default:
return "", false
}
}
func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
if markup.IsZero() {
return nil
}
rows := make([][]map[string]any, 0, len(markup.Inline))
for _, row := range markup.Inline {
if len(row) == 0 {
continue
}
apiRow := make([]map[string]any, 0, len(row))
for _, button := range row {
item := map[string]any{"text": button.Text}
switch button.Type {
case domain.MarkupButtonURL:
item["url"] = button.URL
case domain.MarkupButtonCallback:
item["callback_data"] = string(button.Data)
default:
continue
}
apiRow = append(apiRow, item)
}
if len(apiRow) > 0 {
rows = append(rows, apiRow)
}
}
if len(rows) == 0 {
return nil
}
return map[string]any{"inline_keyboard": rows}
}
func apiMessageMedia(media *domain.MessageMedia) map[string]any {
if media.IsZero() {
return nil
}
switch media.Kind {
case domain.MessageMediaKindPhoto:
if media.Photo == nil {
return nil
}
photos := apiPhotoSizes(*media.Photo)
if len(photos) == 0 {
return nil
}
return map[string]any{"photo": photos}
case domain.MessageMediaKindDocument:
if media.Document == nil {
return nil
}
return map[string]any{"document": apiDocument(*media.Document)}
default:
return nil
}
}
func apiPhotoSizes(photo domain.Photo) []map[string]any {
return apiPhotoSizesWithPrefix(photo.Sizes, "photo:"+strconv.FormatInt(photo.ID, 10)+":")
}
func apiPhotoSizesWithPrefix(sizes []domain.PhotoSize, locationPrefix string) []map[string]any {
out := make([]map[string]any, 0, len(sizes))
for _, size := range sizes {
if !size.Downloadable() || size.Type == "" {
continue
}
fileID := encodeBotAPIFileID(locationPrefix + size.Type)
item := map[string]any{
"file_id": fileID,
"file_unique_id": fileID,
"width": size.W,
"height": size.H,
}
if size.Size > 0 {
item["file_size"] = size.Size
}
out = append(out, item)
}
return out
}
func apiDocument(doc domain.Document) map[string]any {
fileID := encodeBotAPIFileID("doc:" + strconv.FormatInt(doc.ID, 10))
out := map[string]any{
"file_id": fileID,
"file_unique_id": fileID,
}
if doc.MimeType != "" {
out["mime_type"] = doc.MimeType
}
if doc.Size > 0 {
out["file_size"] = doc.Size
}
for _, attr := range doc.Attributes {
if attr.Kind == domain.DocAttrFilename && strings.TrimSpace(attr.FileName) != "" {
out["file_name"] = attr.FileName
break
}
}
if thumbs := apiPhotoSizesWithPrefix(doc.Thumbs, "doc:"+strconv.FormatInt(doc.ID, 10)+":"); len(thumbs) > 0 {
out["thumbnail"] = thumbs[len(thumbs)-1]
}
return out
}
func encodeBotAPIFileID(locationKey string) string {
return base64.RawURLEncoding.EncodeToString([]byte(locationKey))
}
func decodeBotAPIFileID(fileID string) (string, bool) {
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return "", false
}
data, err := base64.RawURLEncoding.DecodeString(fileID)
if err != nil {
return "", false
}
locationKey := string(data)
if strings.HasPrefix(locationKey, "doc:") || strings.HasPrefix(locationKey, "photo:") {
return locationKey, true
}
return "", false
}

View file

@ -35,18 +35,36 @@ type WebAppService interface {
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) {
type GatewayService interface {
BotAPISelf(ctx context.Context, botID int64) (domain.User, error)
BotAPIUpdates(ctx context.Context, botID int64, offset int64) ([]domain.UpdateEvent, error)
BotAPISendMessage(ctx context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error)
BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error)
BotAPIEditMessageText(ctx context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error)
BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error)
BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, callbackQueryID, text, url string, showAlert bool, cacheTime int) (bool, error)
BotAPIGetFile(ctx context.Context, botID int64, locationKey string, offset int64, limit int) (domain.FileChunk, bool, error)
}
type GatewayUpdateWaiter interface {
BotAPIUpdateWaitVersion(botID int64) uint64
WaitBotAPIUpdate(ctx context.Context, botID int64, version uint64, timeout time.Duration) bool
}
func Start(ctx context.Context, addr string, bots BotsService, users UsersService, webapps WebAppService, gateway GatewayService, 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}
handler := &handler{bots: bots, users: users, webapps: webapps, gateway: gateway, logger: logger}
srv := &http.Server{
Addr: addr,
Handler: handler.routes(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
ln, err := net.Listen("tcp", addr)
if err != nil {
@ -71,9 +89,23 @@ type handler struct {
bots BotsService
users UsersService
webapps WebAppService
gateway GatewayService
logger *zap.Logger
}
const (
maxBotAPIUploadBytes = 25 << 20
maxBotAPIRequestOverheadBytes = 1 << 20
maxBotAPIRequestBytes = maxBotAPIUploadBytes + maxBotAPIRequestOverheadBytes
botAPILongPollFallback = 5 * time.Second
)
type uploadedFile struct {
Name string
MimeType string
Bytes []byte
}
func (h *handler) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", h.handle)
@ -81,6 +113,11 @@ func (h *handler) routes() http.Handler {
}
func (h *handler) handle(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBotAPIRequestBytes)
if strings.HasPrefix(r.URL.Path, "/file/bot") {
h.downloadFile(w, r)
return
}
token, method, ok := splitBotPath(r.URL.Path)
if !ok {
writeAPIError(w, http.StatusNotFound, "METHOD_NOT_FOUND")
@ -92,6 +129,30 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) {
return
}
switch strings.ToLower(method) {
case "getme":
h.getMe(w, r, botID)
case "getupdates":
h.getUpdates(w, r, botID)
case "sendmessage":
h.sendMessage(w, r, botID)
case "sendphoto":
h.sendMedia(w, r, botID, "photo")
case "senddocument":
h.sendMedia(w, r, botID, "document")
case "editmessagetext":
h.editMessageText(w, r, botID)
case "deletemessage":
h.deleteMessage(w, r, botID)
case "answercallbackquery":
h.answerCallbackQuery(w, r, botID)
case "getfile":
h.getFile(w, r, botID)
case "deletewebhook":
writeAPIOK(w, true)
case "getwebhookinfo":
writeAPIOK(w, map[string]any{"url": "", "has_custom_certificate": false, "pending_update_count": 0})
case "setwebhook":
h.setWebhook(w, r)
case "setchatmenubutton":
h.setChatMenuButton(w, r, botID)
case "getchatmenubutton":
@ -122,6 +183,393 @@ func splitBotPath(path string) (token, method string, ok bool) {
return token, method, true
}
func splitFilePath(path string) (token, fileID string, ok bool) {
rest := strings.TrimPrefix(path, "/file/bot")
rest = strings.TrimPrefix(rest, "/")
token, fileID, found := strings.Cut(rest, "/")
if !found || token == "" || fileID == "" || strings.Contains(fileID, "/") {
return "", "", false
}
return token, fileID, true
}
func (h *handler) getMe(w http.ResponseWriter, r *http.Request, botID int64) {
if h.gateway == nil {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
u, err := h.gateway.BotAPISelf(r.Context(), botID)
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
writeAPIOK(w, apiUser(u))
}
func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64) {
if h.gateway == nil {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
offset, _ := strconv.ParseInt(strings.TrimSpace(values["offset"]), 10, 64)
limit := apiInt(values["limit"], 100)
if limit <= 0 {
limit = 100
}
if limit > 100 {
limit = 100
}
timeoutSeconds := apiInt(values["timeout"], 0)
if timeoutSeconds < 0 {
timeoutSeconds = 0
}
if timeoutSeconds > 50 {
timeoutSeconds = 50
}
allowed := allowedUpdates(values["allowed_updates"])
deadline := time.Now().Add(time.Duration(timeoutSeconds) * time.Second)
for {
version := botAPIUpdateWaitVersion(h.gateway, botID)
events, err := h.gateway.BotAPIUpdates(r.Context(), botID, offset)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
updates := apiUpdates(events, allowed, limit)
if len(updates) > 0 || timeoutSeconds == 0 || time.Now().After(deadline) {
writeAPIOK(w, updates)
return
}
waitForBotAPIUpdate(r.Context(), h.gateway, botID, version, time.Until(deadline))
}
}
func botAPIUpdateWaitVersion(gateway GatewayService, botID int64) uint64 {
waiter, ok := gateway.(GatewayUpdateWaiter)
if !ok {
return 0
}
return waiter.BotAPIUpdateWaitVersion(botID)
}
func waitForBotAPIUpdate(ctx context.Context, gateway GatewayService, botID int64, version uint64, timeout time.Duration) {
if timeout <= 0 {
return
}
if timeout > botAPILongPollFallback {
timeout = botAPILongPollFallback
}
if waiter, ok := gateway.(GatewayUpdateWaiter); ok {
waiter.WaitBotAPIUpdate(ctx, botID, version, timeout)
return
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-ctx.Done():
case <-timer.C:
}
}
func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int64) {
if h.gateway == nil {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
if err != nil || chatID == 0 {
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
return
}
text := values["text"]
if strings.TrimSpace(values["parse_mode"]) != "" {
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return
}
entities, err := botAPIMessageEntities(values["entities"])
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
var markup *domain.MessageReplyMarkup
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
markup, err = replyMarkupFromAPI(json.RawMessage(raw))
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
}
replyTo := apiInt(values["reply_to_message_id"], 0)
msg, err := h.gateway.BotAPISendMessage(r.Context(), botID, chatID, text, entities, markup, apiBool(values["disable_web_page_preview"]), apiBool(values["disable_notification"]), replyTo)
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
users := []domain.User(nil)
if self, err := h.gateway.BotAPISelf(r.Context(), botID); err == nil && self.ID != 0 {
users = append(users, self)
}
writeAPIOK(w, apiMessage(msg, users))
}
func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64, kind string) {
if h.gateway == nil {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
values, files, err := requestValuesWithFiles(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
if err != nil || chatID == 0 {
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
return
}
if strings.TrimSpace(values["parse_mode"]) != "" {
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return
}
entities, err := botAPIMessageEntities(values["caption_entities"])
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
var markup *domain.MessageReplyMarkup
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
markup, err = replyMarkupFromAPI(json.RawMessage(raw))
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
}
locationKey, remoteURL, fileName, mimeType, fileBytes, ok := mediaInput(values[kind], files, kind)
if !ok {
writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID")
return
}
msg, err := h.gateway.BotAPISendMedia(r.Context(), botID, chatID, kind, locationKey, remoteURL, fileName, mimeType, fileBytes, values["caption"], entities, markup, apiBool(values["disable_notification"]), apiInt(values["reply_to_message_id"], 0))
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
users := []domain.User(nil)
if self, err := h.gateway.BotAPISelf(r.Context(), botID); err == nil && self.ID != 0 {
users = append(users, self)
}
writeAPIOK(w, apiMessage(msg, users))
}
func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID int64) {
if h.gateway == nil {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
if err != nil || chatID == 0 {
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
return
}
messageID := apiInt(values["message_id"], 0)
if messageID <= 0 {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_ID_INVALID")
return
}
if strings.TrimSpace(values["parse_mode"]) != "" {
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return
}
entities, err := botAPIMessageEntities(values["entities"])
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
var markup *domain.MessageReplyMarkup
_, setReplyMarkup := values["reply_markup"]
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
markup, err = replyMarkupFromAPI(json.RawMessage(raw))
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
}
msg, err := h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
users := []domain.User(nil)
if self, err := h.gateway.BotAPISelf(r.Context(), botID); err == nil && self.ID != 0 {
users = append(users, self)
}
writeAPIOK(w, apiMessage(msg, users))
}
func (h *handler) deleteMessage(w http.ResponseWriter, r *http.Request, botID int64) {
if h.gateway == nil {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
if err != nil || chatID == 0 {
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
return
}
messageID := apiInt(values["message_id"], 0)
if messageID <= 0 {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_ID_INVALID")
return
}
ok, err := h.gateway.BotAPIDeleteMessage(r.Context(), botID, chatID, messageID)
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
writeAPIOK(w, ok)
}
func (h *handler) answerCallbackQuery(w http.ResponseWriter, r *http.Request, botID int64) {
if h.gateway == nil {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
queryID := strings.TrimSpace(values["callback_query_id"])
if queryID == "" {
writeAPIError(w, http.StatusBadRequest, "QUERY_ID_INVALID")
return
}
ok, err := h.gateway.BotAPIAnswerCallbackQuery(r.Context(), botID, queryID, values["text"], values["url"], apiBool(values["show_alert"]), apiInt(values["cache_time"], 0))
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
writeAPIOK(w, ok)
}
func (h *handler) getFile(w http.ResponseWriter, r *http.Request, botID int64) {
if h.gateway == nil {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
fileID := strings.TrimSpace(values["file_id"])
locationKey, ok := decodeBotAPIFileID(fileID)
if !ok {
writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID")
return
}
chunk, found, err := h.gateway.BotAPIGetFile(r.Context(), botID, locationKey, 0, 1)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
if !found {
writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID")
return
}
writeAPIOK(w, map[string]any{
"file_id": fileID,
"file_unique_id": fileID,
"file_size": chunk.Total,
"file_path": fileID,
})
}
func (h *handler) downloadFile(w http.ResponseWriter, r *http.Request) {
if h.gateway == nil {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
token, fileID, ok := splitFilePath(r.URL.Path)
if !ok {
writeAPIError(w, http.StatusNotFound, "FILE_NOT_FOUND")
return
}
botID, ok := h.authenticate(r.Context(), token)
if !ok {
writeAPIError(w, http.StatusUnauthorized, "ACCESS_TOKEN_INVALID")
return
}
locationKey, ok := decodeBotAPIFileID(fileID)
if !ok {
writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID")
return
}
var offset int64
for {
chunk, found, err := h.gateway.BotAPIGetFile(r.Context(), botID, locationKey, offset, 1<<20)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
if !found {
writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID")
return
}
if offset == 0 {
if chunk.MimeType != "" {
w.Header().Set("Content-Type", chunk.MimeType)
}
if chunk.Total > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(chunk.Total, 10))
}
}
if len(chunk.Bytes) == 0 {
return
}
if _, err := w.Write(chunk.Bytes); err != nil {
return
}
offset += int64(len(chunk.Bytes))
if chunk.Total == 0 || offset >= chunk.Total {
return
}
}
}
func (h *handler) setWebhook(w http.ResponseWriter, r *http.Request) {
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
if strings.TrimSpace(values["url"]) == "" {
writeAPIOK(w, true)
return
}
writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_NOT_IMPLEMENTED")
}
func (h *handler) authenticate(ctx context.Context, token string) (int64, bool) {
if h.bots == nil {
return 0, false
@ -286,11 +734,16 @@ func (h *handler) savePreparedInlineMessage(w http.ResponseWriter, r *http.Reque
}
func requestValues(r *http.Request) (map[string]string, error) {
values, _, err := requestValuesWithFiles(r)
return values, err
}
func requestValuesWithFiles(r *http.Request) (map[string]string, map[string]uploadedFile, 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
return nil, nil, err
}
for k, v := range body {
switch x := v.(type) {
@ -307,17 +760,79 @@ func requestValues(r *http.Request) (map[string]string, error) {
out["menu_button"] = string(b)
}
}
return out, nil
return out, nil, nil
}
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
if err := r.ParseMultipartForm(maxBotAPIUploadBytes); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "request body too large") {
return nil, nil, errors.New("FILE_TOO_BIG")
}
return nil, nil, errors.New("FILE_ID_INVALID")
}
for k, v := range r.MultipartForm.Value {
if len(v) > 0 {
out[k] = v[0]
}
}
files := map[string]uploadedFile{}
for field, headers := range r.MultipartForm.File {
if len(headers) == 0 {
continue
}
header := headers[0]
file, err := header.Open()
if err != nil {
return nil, nil, errors.New("FILE_ID_INVALID")
}
data, readErr := io.ReadAll(io.LimitReader(file, maxBotAPIUploadBytes+1))
closeErr := file.Close()
if readErr != nil {
return nil, nil, errors.New("FILE_ID_INVALID")
}
if closeErr != nil {
return nil, nil, errors.New("FILE_ID_INVALID")
}
if len(data) > maxBotAPIUploadBytes {
return nil, nil, errors.New("FILE_TOO_BIG")
}
files[field] = uploadedFile{
Name: header.Filename,
MimeType: header.Header.Get("Content-Type"),
Bytes: data,
}
}
return out, files, nil
}
if err := r.ParseForm(); err != nil {
return nil, err
return nil, nil, err
}
for k, v := range r.Form {
if len(v) > 0 {
out[k] = v[0]
}
}
return out, nil
return out, nil, nil
}
func mediaInput(raw string, files map[string]uploadedFile, defaultField string) (locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, ok bool) {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(raw, "attach://") {
field := strings.TrimPrefix(raw, "attach://")
if file, found := files[field]; found && len(file.Bytes) > 0 {
return "", "", file.Name, file.MimeType, file.Bytes, true
}
return "", "", "", "", nil, false
}
if file, found := files[defaultField]; found && len(file.Bytes) > 0 {
return "", "", file.Name, file.MimeType, file.Bytes, true
}
if strings.HasPrefix(strings.ToLower(raw), "http://") || strings.HasPrefix(strings.ToLower(raw), "https://") {
return "", raw, "", "", nil, true
}
if key, decoded := decodeBotAPIFileID(raw); decoded {
return key, "", "", "", nil, true
}
return "", "", "", "", nil, false
}
func menuButtonFromAPI(raw string) (domain.BotMenuButton, error) {
@ -394,7 +909,23 @@ func apiErrorDescription(err error) string {
"BUTTON_DATA_INVALID",
"BUTTON_URL_INVALID",
"BOT_INVALID",
"CHAT_ID_INVALID",
"ENTITY_INVALID",
"ENTITY_PARSE_UNSUPPORTED",
"ENTITIES_TOO_LONG",
"ENTITY_BOUNDS_INVALID",
"ENTITY_TYPE_UNSUPPORTED",
"FILE_ID_INVALID",
"FILE_TOO_BIG",
"MEDIA_INVALID",
"USER_BOT_REQUIRED",
"QUERY_ID_INVALID",
"MESSAGE_ID_INVALID",
"MESSAGE_NOT_MODIFIED",
"CHAT_WRITE_FORBIDDEN",
"CHAT_ADMIN_REQUIRED",
"REPLY_MESSAGE_ID_INVALID",
"WEBHOOK_NOT_IMPLEMENTED",
} {
if strings.Contains(text, marker) {
return marker

View file

@ -1,8 +1,10 @@
package botapi
import (
"bytes"
"context"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"reflect"
@ -129,6 +131,429 @@ func TestAnswerWebAppQueryRejectsUnsupportedResult(t *testing.T) {
}
}
func TestGetMeUsesGateway(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{
self: domain.User{ID: 1001, FirstName: "Echo", Username: "echo_bot", Bot: true},
}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "getMe", `{}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Result struct {
ID int64 `json:"id"`
IsBot bool `json:"is_bot"`
FirstName string `json:"first_name"`
Username string `json:"username"`
} `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.OK || resp.Result.ID != 1001 || !resp.Result.IsBot || resp.Result.Username != "echo_bot" {
t.Fatalf("response = %s", rec.Body.String())
}
}
func TestGetUpdatesProjectsIncomingPrivateText(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{
updates: []domain.UpdateEvent{{
UserID: 1001,
Type: domain.UpdateEventNewMessage,
Pts: 7,
Message: domain.Message{
ID: 3,
OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
Date: 1700000000,
Body: "/start",
Entities: []domain.MessageEntity{{Type: domain.MessageEntityBotCommand, Offset: 0, Length: 6}},
},
Users: []domain.User{{ID: 2001, FirstName: "Alice", Username: "alice"}},
}},
}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{"offset":1,"allowed_updates":["message"]}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Result []struct {
UpdateID int `json:"update_id"`
Message struct {
MessageID int `json:"message_id"`
Text string `json:"text"`
From struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
} `json:"from"`
Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
} `json:"chat"`
Entities []struct {
Type string `json:"type"`
Offset int `json:"offset"`
Length int `json:"length"`
} `json:"entities"`
} `json:"message"`
} `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.OK || len(resp.Result) != 1 {
t.Fatalf("response = %s", rec.Body.String())
}
got := resp.Result[0]
if got.UpdateID != 7 || got.Message.MessageID != 3 || got.Message.Text != "/start" || got.Message.From.ID != 2001 || got.Message.Chat.ID != 2001 || got.Message.Chat.Type != "private" {
t.Fatalf("update = %#v", got)
}
if len(got.Message.Entities) != 1 || got.Message.Entities[0].Type != "bot_command" {
t.Fatalf("entities = %#v", got.Message.Entities)
}
if gateway.updateOffset != 1 {
t.Fatalf("offset = %d, want 1", gateway.updateOffset)
}
}
func TestGetUpdatesSkipsOutgoingBotMessage(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{
updates: []domain.UpdateEvent{{
UserID: 1001,
Type: domain.UpdateEventNewMessage,
Pts: 8,
Message: domain.Message{
ID: 4,
OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Date: 1700000001,
Body: "sent by bot",
Out: true,
},
}},
}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Result []json.RawMessage `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.OK || len(resp.Result) != 0 {
t.Fatalf("response = %s", rec.Body.String())
}
}
func TestSendMessageParsesEntitiesMarkupAndCallsGateway(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{
self: domain.User{ID: 1001, FirstName: "Echo", Username: "echo_bot", Bot: true},
sendMessage: domain.Message{
ID: 9,
OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Date: 1700000002,
Body: "hello",
Out: true,
Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 5}},
ReplyMarkup: &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{{Type: domain.MarkupButtonCallback, Text: "Tap", Data: []byte("cb")}}}},
},
}
h := (&handler{bots: bots, gateway: gateway}).routes()
body := `{
"chat_id": 2001,
"text": "hello",
"entities": [{"type":"bold","offset":0,"length":5}],
"reply_markup": {"inline_keyboard": [[{"text":"Tap","callback_data":"cb"}]]},
"disable_notification": true,
"reply_to_message_id": 5
}`
rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", body)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
if !gateway.sendCalled || gateway.sendBotID != 1001 || gateway.sendChatID != 2001 || gateway.sendText != "hello" || !gateway.sendSilent || gateway.sendReplyTo != 5 {
t.Fatalf("send call = %#v", gateway)
}
if len(gateway.sendEntities) != 1 || gateway.sendEntities[0].Type != domain.MessageEntityBold {
t.Fatalf("entities = %#v", gateway.sendEntities)
}
if gateway.sendMarkup == nil || len(gateway.sendMarkup.Inline) != 1 || len(gateway.sendMarkup.Inline[0]) != 1 || string(gateway.sendMarkup.Inline[0][0].Data) != "cb" {
t.Fatalf("markup = %#v", gateway.sendMarkup)
}
var resp struct {
OK bool `json:"ok"`
Result struct {
MessageID int `json:"message_id"`
Text string `json:"text"`
From struct {
ID int64 `json:"id"`
IsBot bool `json:"is_bot"`
} `json:"from"`
ReplyMarkup struct {
InlineKeyboard [][]struct {
Text string `json:"text"`
CallbackData string `json:"callback_data"`
} `json:"inline_keyboard"`
} `json:"reply_markup"`
} `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.OK || resp.Result.MessageID != 9 || resp.Result.Text != "hello" || resp.Result.From.ID != 1001 || !resp.Result.From.IsBot {
t.Fatalf("response = %s", rec.Body.String())
}
if len(resp.Result.ReplyMarkup.InlineKeyboard) != 1 || resp.Result.ReplyMarkup.InlineKeyboard[0][0].CallbackData != "cb" {
t.Fatalf("reply_markup response = %#v", resp.Result.ReplyMarkup)
}
}
func TestSendDocumentMultipartParsesFileAndCaption(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{
self: domain.User{ID: 1001, FirstName: "Echo", Username: "echo_bot", Bot: true},
sendMediaMessage: domain.Message{
ID: 11,
OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Date: 1700000006,
Body: "doc caption",
Out: true,
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindDocument,
Document: &domain.Document{
ID: 42,
MimeType: "text/plain",
Size: 10,
Attributes: []domain.DocumentAttribute{{
Kind: domain.DocAttrFilename,
FileName: "note.txt",
}},
},
},
},
}
h := (&handler{bots: bots, gateway: gateway}).routes()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
_ = writer.WriteField("chat_id", "2001")
_ = writer.WriteField("caption", "doc caption")
part, err := writer.CreateFormFile("document", "note.txt")
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write([]byte("hello file")); err != nil {
t.Fatalf("write file: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart: %v", err)
}
token := domain.FormatBotToken(bots.profile.BotUserID, bots.profile.TokenSecret)
req := httptest.NewRequest(http.MethodPost, "/bot"+token+"/sendDocument", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
if !gateway.sendMediaCalled || gateway.sendMediaKind != "document" || gateway.sendMediaChatID != 2001 || gateway.sendMediaCaption != "doc caption" {
t.Fatalf("send media call = %#v", gateway)
}
if gateway.sendMediaFileName != "note.txt" || string(gateway.sendMediaBytes) != "hello file" {
t.Fatalf("file = name %q bytes %q", gateway.sendMediaFileName, string(gateway.sendMediaBytes))
}
var resp struct {
OK bool `json:"ok"`
Result struct {
Caption string `json:"caption"`
Document struct {
FileName string `json:"file_name"`
FileID string `json:"file_id"`
} `json:"document"`
} `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.OK || resp.Result.Caption != "doc caption" || resp.Result.Document.FileName != "note.txt" {
t.Fatalf("response = %s", rec.Body.String())
}
}
func TestEditDeleteCallbackAndFileEndpoints(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
locationKey := "doc:42"
fileID := encodeBotAPIFileID(locationKey)
gateway := &fakeBotAPIGateway{
self: domain.User{ID: 1001, FirstName: "Echo", Username: "echo_bot", Bot: true},
editMessage: domain.Message{
ID: 9,
OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Date: 1700000003,
EditDate: 1700000004,
Body: "edited",
Out: true,
},
fileChunks: map[string]domain.FileChunk{
locationKey: {Bytes: []byte("hello file"), MimeType: "text/plain", Total: int64(len("hello file"))},
},
}
h := (&handler{bots: bots, gateway: gateway}).routes()
edit := performBotAPIRequest(t, h, bots.profile, "editMessageText", `{"chat_id":2001,"message_id":9,"text":"edited","reply_markup":{"inline_keyboard":[]}}`)
if edit.Code != http.StatusOK {
t.Fatalf("edit status = %d body = %s", edit.Code, edit.Body.String())
}
if !gateway.editCalled || !gateway.editSetMarkup {
t.Fatalf("edit gateway = %#v", gateway)
}
del := performBotAPIRequest(t, h, bots.profile, "deleteMessage", `{"chat_id":2001,"message_id":9}`)
if del.Code != http.StatusOK || !gateway.deleteCalled {
t.Fatalf("delete status = %d body = %s gateway=%#v", del.Code, del.Body.String(), gateway)
}
cb := performBotAPIRequest(t, h, bots.profile, "answerCallbackQuery", `{"callback_query_id":"123","text":"ok"}`)
if cb.Code != http.StatusOK || !gateway.callbackCalled || gateway.callbackID != "123" {
t.Fatalf("callback status = %d body = %s gateway=%#v", cb.Code, cb.Body.String(), gateway)
}
file := performBotAPIRequest(t, h, bots.profile, "getFile", `{"file_id":"`+fileID+`"}`)
if file.Code != http.StatusOK {
t.Fatalf("getFile status = %d body = %s", file.Code, file.Body.String())
}
var fileResp struct {
OK bool `json:"ok"`
Result struct {
FileID string `json:"file_id"`
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
} `json:"result"`
}
if err := json.Unmarshal(file.Body.Bytes(), &fileResp); err != nil {
t.Fatalf("decode getFile: %v", err)
}
if !fileResp.OK || fileResp.Result.FileID != fileID || fileResp.Result.FilePath != fileID || fileResp.Result.FileSize != int64(len("hello file")) || gateway.fileLocationKey != locationKey {
t.Fatalf("getFile response = %s gateway=%#v", file.Body.String(), gateway)
}
token := domain.FormatBotToken(bots.profile.BotUserID, bots.profile.TokenSecret)
req := httptest.NewRequest(http.MethodGet, "/file/bot"+token+"/"+fileID, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != "hello file" || rec.Header().Get("Content-Type") != "text/plain" {
t.Fatalf("download status=%d content-type=%q body=%q", rec.Code, rec.Header().Get("Content-Type"), rec.Body.String())
}
}
func TestAPIMessageProjectsMediaCaptionAndFileID(t *testing.T) {
msg := domain.Message{
ID: 3,
OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
Date: 1700000005,
Body: "caption",
Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 7}},
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindDocument,
Document: &domain.Document{
ID: 42,
MimeType: "text/plain",
Size: 10,
Attributes: []domain.DocumentAttribute{{
Kind: domain.DocAttrFilename,
FileName: "note.txt",
}},
},
},
}
projected := apiMessage(msg, []domain.User{{ID: 2001, FirstName: "Alice"}})
if _, hasText := projected["text"]; hasText {
t.Fatalf("media message has text field: %#v", projected)
}
if projected["caption"] != "caption" {
t.Fatalf("caption = %#v", projected["caption"])
}
if _, ok := projected["caption_entities"].([]map[string]any); !ok {
t.Fatalf("caption_entities = %#v", projected["caption_entities"])
}
document, ok := projected["document"].(map[string]any)
if !ok {
t.Fatalf("document = %#v", projected["document"])
}
fileID, _ := document["file_id"].(string)
if locationKey, ok := decodeBotAPIFileID(fileID); !ok || locationKey != "doc:42" {
t.Fatalf("file_id %q decodes to %q ok=%v", fileID, locationKey, ok)
}
if document["file_name"] != "note.txt" || document["mime_type"] != "text/plain" {
t.Fatalf("document = %#v", document)
}
}
func TestAPIUpdateProjectsCaptionlessMediaMessage(t *testing.T) {
item, kind, ok := apiUpdate(domain.UpdateEvent{
Type: domain.UpdateEventNewMessage,
Pts: 12,
Message: domain.Message{
ID: 4,
OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
Date: 1700000006,
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindDocument,
Document: &domain.Document{
ID: 43,
MimeType: "application/octet-stream",
Size: 4,
},
},
},
})
if !ok || kind != "message" {
t.Fatalf("apiUpdate ok=%v kind=%q item=%#v", ok, kind, item)
}
msg, ok := item["message"].(map[string]any)
if !ok {
t.Fatalf("message = %#v", item["message"])
}
if _, hasText := msg["text"]; hasText {
t.Fatalf("captionless media has text: %#v", msg)
}
if _, hasCaption := msg["caption"]; hasCaption {
t.Fatalf("captionless media has caption: %#v", msg)
}
if _, ok := msg["document"].(map[string]any); !ok {
t.Fatalf("document = %#v", msg["document"])
}
}
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)
@ -196,3 +621,105 @@ func (f *fakeWebAppService) SavePreparedInlineMessageFromBotAPI(_ context.Contex
f.preparedPeerTypes = append([]string(nil), peerTypes...)
return f.preparedID, f.preparedExpire, nil
}
type fakeBotAPIGateway struct {
self domain.User
updates []domain.UpdateEvent
updateBotID int64
updateOffset int64
sendCalled bool
sendBotID int64
sendChatID int64
sendText string
sendEntities []domain.MessageEntity
sendMarkup *domain.MessageReplyMarkup
sendNoWebpage bool
sendSilent bool
sendReplyTo int
sendMessage domain.Message
sendMediaCalled bool
sendMediaKind string
sendMediaChatID int64
sendMediaFileName string
sendMediaBytes []byte
sendMediaCaption string
sendMediaMessage domain.Message
editCalled bool
editSetMarkup bool
editMessage domain.Message
deleteCalled bool
callbackCalled bool
callbackID string
fileLocationKey string
fileChunks map[string]domain.FileChunk
}
func (f *fakeBotAPIGateway) BotAPISelf(context.Context, int64) (domain.User, error) {
return f.self, nil
}
func (f *fakeBotAPIGateway) BotAPIUpdates(_ context.Context, botID int64, offset int64) ([]domain.UpdateEvent, error) {
f.updateBotID = botID
f.updateOffset = offset
return append([]domain.UpdateEvent(nil), f.updates...), nil
}
func (f *fakeBotAPIGateway) BotAPISendMessage(_ context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error) {
f.sendCalled = true
f.sendBotID = botID
f.sendChatID = chatID
f.sendText = text
f.sendEntities = append([]domain.MessageEntity(nil), entities...)
f.sendMarkup = replyMarkup
f.sendNoWebpage = disableWebPagePreview
f.sendSilent = silent
f.sendReplyTo = replyToMessageID
return f.sendMessage, nil
}
func (f *fakeBotAPIGateway) BotAPISendMedia(_ context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error) {
f.sendMediaCalled = true
f.sendMediaKind = kind
f.sendMediaChatID = chatID
f.sendMediaFileName = fileName
f.sendMediaBytes = append([]byte(nil), fileBytes...)
f.sendMediaCaption = caption
return f.sendMediaMessage, nil
}
func (f *fakeBotAPIGateway) BotAPIEditMessageText(_ context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error) {
f.editCalled = true
f.editSetMarkup = setReplyMarkup
return f.editMessage, nil
}
func (f *fakeBotAPIGateway) BotAPIDeleteMessage(context.Context, int64, int64, int) (bool, error) {
f.deleteCalled = true
return true, nil
}
func (f *fakeBotAPIGateway) BotAPIAnswerCallbackQuery(_ context.Context, _ int64, callbackQueryID, text, url string, showAlert bool, cacheTime int) (bool, error) {
f.callbackCalled = true
f.callbackID = callbackQueryID
return true, nil
}
func (f *fakeBotAPIGateway) BotAPIGetFile(_ context.Context, _ int64, locationKey string, offset int64, limit int) (domain.FileChunk, bool, error) {
f.fileLocationKey = locationKey
chunk, ok := f.fileChunks[locationKey]
if !ok {
return domain.FileChunk{}, false, nil
}
if offset >= int64(len(chunk.Bytes)) {
return domain.FileChunk{MimeType: chunk.MimeType, Total: chunk.Total}, true, nil
}
end := offset + int64(limit)
if end > int64(len(chunk.Bytes)) {
end = int64(len(chunk.Bytes))
}
out := chunk
out.Bytes = append([]byte(nil), chunk.Bytes[offset:end]...)
return out, true, nil
}