owpengram-server/internal/botapi/ephemeral.go
A 2965f5d47d feat: sync bot api formatted text parse modes
Sync telesrv 00630bc (feat(botapi): support formatted text parse modes).

Skipped telesrv docs changes per public sync rules.
2026-07-20 20:23:47 +08:00

351 lines
13 KiB
Go

package botapi
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"telesrv/internal/domain"
)
type ephemeralSendTarget struct {
receiverUserID int64
callbackQueryID int64
replyToEphemeralID int
topMessageID int
}
func parseEphemeralSendTarget(values map[string]string) (ephemeralSendTarget, bool, error) {
var result ephemeralSendTarget
receiverRaw := strings.TrimSpace(values["receiver_user_id"])
callbackRaw := strings.TrimSpace(values["callback_query_id"])
var reply struct {
MessageID int `json:"message_id"`
EphemeralMessageID int `json:"ephemeral_message_id"`
}
if raw := strings.TrimSpace(values["reply_parameters"]); raw != "" {
if json.Unmarshal([]byte(raw), &reply) != nil || reply.MessageID < 0 || reply.EphemeralMessageID < 0 ||
(reply.MessageID != 0 && reply.EphemeralMessageID != 0) {
return result, false, errors.New("REPLY_PARAMETERS_INVALID")
}
}
if receiverRaw == "" {
if callbackRaw != "" || reply.EphemeralMessageID != 0 {
return result, false, errors.New("USER_ID_INVALID")
}
return result, false, nil
}
receiver, err := strconv.ParseInt(receiverRaw, 10, 64)
if err != nil || receiver <= 0 {
return result, false, errors.New("USER_ID_INVALID")
}
result.receiverUserID = receiver
result.replyToEphemeralID = reply.EphemeralMessageID
if reply.MessageID != 0 {
return result, false, errors.New("REPLY_PARAMETERS_INVALID")
}
if callbackRaw != "" {
result.callbackQueryID, err = strconv.ParseInt(callbackRaw, 10, 64)
if err != nil || result.callbackQueryID == 0 {
return result, false, errors.New("QUERY_ID_INVALID")
}
}
if result.callbackQueryID != 0 && result.replyToEphemeralID != 0 {
return result, false, errors.New("REPLY_PARAMETERS_INVALID")
}
if raw := strings.TrimSpace(values["message_thread_id"]); raw != "" {
result.topMessageID, err = strconv.Atoi(raw)
if err != nil || result.topMessageID <= 0 || result.topMessageID > domain.MaxMessageBoxID {
return result, false, errors.New("MESSAGE_THREAD_ID_INVALID")
}
}
return result, true, nil
}
func botAPIFileInput(raw string, files map[string]uploadedFile, field string, values map[string]string) (domain.BotAPIFileInput, bool) {
locationKey, remoteURL, fileName, mimeType, fileBytes, ok := mediaInput(raw, files, field)
if !ok {
return domain.BotAPIFileInput{}, false
}
return domain.BotAPIFileInput{
LocationKey: locationKey, RemoteURL: remoteURL, FileName: fileName, MimeType: mimeType, Bytes: fileBytes,
Width: apiInt(values["width"], 0), Height: apiInt(values["height"], 0), Duration: apiInt(values["duration"], 0),
Title: values["title"], Performer: values["performer"], Emoji: values["emoji"],
}, true
}
func (h *handler) writeEphemeralMessage(w http.ResponseWriter, r *http.Request, botID int64, message domain.EphemeralMessage) {
users := make([]domain.User, 0, 1)
if self, err := h.gateway.BotAPISelf(r.Context(), botID); err == nil && self.ID != 0 {
users = append(users, self)
}
projected, ok := apiEphemeralMessage(message, users, nil)
if !ok {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
writeAPIOK(w, projected)
}
func (h *handler) sendEphemeralContact(w http.ResponseWriter, r *http.Request, botID int64) {
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
target, ephemeral, err := parseEphemeralSendTarget(values)
if err != nil || !ephemeral {
if err == nil {
err = errors.New("EPHEMERAL_TARGET_REQUIRED")
}
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
chatID, ok := parsePositiveOrNegativeID(values["chat_id"])
if !ok || strings.TrimSpace(values["phone_number"]) == "" || strings.TrimSpace(values["first_name"]) == "" || len(values["vcard"]) > 2048 {
writeAPIError(w, http.StatusBadRequest, "MEDIA_INVALID")
return
}
markup, _, err := optionalInlineReplyMarkup(values)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
gateway, ok := h.gateway.(EphemeralGatewayService)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{
BotUserID: botID, ChatID: chatID, ReceiverUserID: target.receiverUserID,
CallbackQueryID: target.callbackQueryID, ReplyToEphemeralID: target.replyToEphemeralID, TopMessageID: target.topMessageID,
Kind: "contact", ReplyMarkup: markup, DirectMedia: &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{
PhoneNumber: values["phone_number"], FirstName: values["first_name"], LastName: values["last_name"], Vcard: values["vcard"],
}},
})
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
h.writeEphemeralMessage(w, r, botID, message)
}
func (h *handler) sendEphemeralLocation(w http.ResponseWriter, r *http.Request, botID int64, venue bool) {
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
target, ephemeral, err := parseEphemeralSendTarget(values)
if err != nil || !ephemeral {
if err == nil {
err = errors.New("EPHEMERAL_TARGET_REQUIRED")
}
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
chatID, ok := parsePositiveOrNegativeID(values["chat_id"])
latitude, latErr := strconv.ParseFloat(strings.TrimSpace(values["latitude"]), 64)
longitude, longErr := strconv.ParseFloat(strings.TrimSpace(values["longitude"]), 64)
accuracy, accuracyErr := strconv.ParseFloat(defaultString(values["horizontal_accuracy"], "0"), 64)
if !ok || latErr != nil || longErr != nil || accuracyErr != nil || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 || accuracy < 0 || accuracy > 1500 || apiInt(values["live_period"], 0) != 0 {
writeAPIError(w, http.StatusBadRequest, "MEDIA_INVALID")
return
}
markup, _, err := optionalInlineReplyMarkup(values)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
geo := domain.MessageGeoPoint{Lat: latitude, Long: longitude, AccuracyRadius: int(accuracy)}
media := &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: &geo}
if venue {
if strings.TrimSpace(values["title"]) == "" || strings.TrimSpace(values["address"]) == "" {
writeAPIError(w, http.StatusBadRequest, "MEDIA_INVALID")
return
}
provider, venueID, venueType := "", "", ""
if values["foursquare_id"] != "" || values["foursquare_type"] != "" {
provider, venueID, venueType = "foursquare", values["foursquare_id"], values["foursquare_type"]
} else if values["google_place_id"] != "" || values["google_place_type"] != "" {
provider, venueID, venueType = "gplaces", values["google_place_id"], values["google_place_type"]
}
media = &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{
Geo: geo, Title: values["title"], Address: values["address"], Provider: provider, VenueID: venueID, VenueType: venueType,
}}
}
gateway, ok := h.gateway.(EphemeralGatewayService)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{
BotUserID: botID, ChatID: chatID, ReceiverUserID: target.receiverUserID,
CallbackQueryID: target.callbackQueryID, ReplyToEphemeralID: target.replyToEphemeralID, TopMessageID: target.topMessageID,
Kind: "location", ReplyMarkup: markup, DirectMedia: media,
})
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
h.writeEphemeralMessage(w, r, botID, message)
}
func (h *handler) editEphemeralMessage(w http.ResponseWriter, r *http.Request, botID int64, mode string) {
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
chatID, ok := parsePositiveOrNegativeID(values["chat_id"])
receiverID, receiverErr := strconv.ParseInt(strings.TrimSpace(values["receiver_user_id"]), 10, 64)
messageID := apiInt(values["ephemeral_message_id"], 0)
if !ok || receiverErr != nil || receiverID <= 0 || messageID <= 0 {
writeAPIError(w, http.StatusBadRequest, "EPHEMERAL_MESSAGE_ID_INVALID")
return
}
input := domain.BotAPIEphemeralEditInput{
BotUserID: botID, ChatID: chatID, ReceiverUserID: receiverID, MessageID: messageID,
Mode: domain.EphemeralEditMode(mode),
}
markup, markupSet, err := optionalInlineReplyMarkup(values)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
input.Fields.SetReplyMarkup, input.Fields.ReplyMarkup = markupSet, markup
switch mode {
case "text":
text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, text, entities
case "caption":
caption, entities, err := botAPIFormattedTextRaw(values["caption"], values["parse_mode"], values["caption_entities"], domain.MaxEphemeralCaptionLength, false)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, caption, entities
case "reply_markup":
input.Fields.SetReplyMarkup = true
case "media":
if err := parseEphemeralEditMedia(values["media"], &input); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
default:
writeAPIError(w, http.StatusNotFound, "METHOD_NOT_FOUND")
return
}
gateway, ok := h.gateway.(EphemeralGatewayService)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
result, err := gateway.BotAPIEditEphemeral(r.Context(), input)
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
writeAPIOK(w, result)
}
func parseEphemeralEditMedia(raw string, input *domain.BotAPIEphemeralEditInput) error {
var media struct {
Type string `json:"type"`
Media string `json:"media"`
Photo string `json:"photo"`
Caption string `json:"caption"`
ParseMode string `json:"parse_mode"`
CaptionEntities json.RawMessage `json:"caption_entities"`
Width int `json:"width"`
Height int `json:"height"`
Duration int `json:"duration"`
Title string `json:"title"`
Performer string `json:"performer"`
}
if input == nil || json.Unmarshal([]byte(raw), &media) != nil || media.Type == "" {
return errors.New("MEDIA_INVALID")
}
allowed := map[string]bool{"animation": true, "audio": true, "document": true, "live_photo": true, "photo": true, "video": true}
if !allowed[media.Type] {
return errors.New("MEDIA_INVALID")
}
primaryRaw := media.Media
if media.Type == "live_photo" {
primaryRaw = media.Photo
}
primary, ok := botAPIFileInput(primaryRaw, nil, "", map[string]string{
"width": strconv.Itoa(media.Width), "height": strconv.Itoa(media.Height), "duration": strconv.Itoa(media.Duration),
"title": media.Title, "performer": media.Performer,
})
if !ok || len(primary.Bytes) != 0 {
return errors.New("FILE_ID_INVALID")
}
input.MediaKind, input.File = media.Type, primary
if media.Type == "live_photo" {
secondary, ok := botAPIFileInput(media.Media, nil, "", map[string]string{"duration": strconv.Itoa(media.Duration)})
if !ok || len(secondary.Bytes) != 0 {
return errors.New("FILE_ID_INVALID")
}
input.SecondaryFile = secondary
}
caption, entities, err := botAPIFormattedTextRaw(media.Caption, media.ParseMode, string(media.CaptionEntities), domain.MaxEphemeralCaptionLength, false)
if err != nil {
return err
}
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, caption, entities
return nil
}
func (h *handler) deleteEphemeralMessage(w http.ResponseWriter, r *http.Request, botID int64) {
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
chatID, ok := parsePositiveOrNegativeID(values["chat_id"])
receiverID, receiverErr := strconv.ParseInt(strings.TrimSpace(values["receiver_user_id"]), 10, 64)
messageID := apiInt(values["ephemeral_message_id"], 0)
if !ok || receiverErr != nil || receiverID <= 0 || messageID <= 0 {
writeAPIError(w, http.StatusBadRequest, "EPHEMERAL_MESSAGE_ID_INVALID")
return
}
gateway, ok := h.gateway.(EphemeralGatewayService)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
return
}
result, err := gateway.BotAPIDeleteEphemeral(r.Context(), botID, chatID, receiverID, messageID)
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
writeAPIOK(w, result)
}
func optionalInlineReplyMarkup(values map[string]string) (*domain.MessageReplyMarkup, bool, error) {
raw, exists := values["reply_markup"]
if !exists || strings.TrimSpace(raw) == "" {
return nil, exists, nil
}
markup, err := inlineReplyMarkupFromAPI(json.RawMessage(raw))
return markup, true, err
}
func parsePositiveOrNegativeID(raw string) (int64, bool) {
id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
return id, err == nil && id != 0
}
func defaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}