feat: sync Bot API gateway support
This commit is contained in:
parent
9a501f900a
commit
4c0cc2b7a7
44 changed files with 4609 additions and 49 deletions
431
internal/botapi/projection.go
Normal file
431
internal/botapi/projection.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue