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

@ -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