feat: sync ephemeral transient messages
Sync telesrv 570ccf8 (feat(ephemeral): implement Layer 228 transient messages). Skipped telesrv docs changes per public sync rules; normalized the public appearance seed label.
This commit is contained in:
parent
3f78eaa2c6
commit
f49c817def
53 changed files with 5793 additions and 112 deletions
104
internal/botapi/bot_commands.go
Normal file
104
internal/botapi/bot_commands.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package botapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const maxBotAPICommands = 100
|
||||
|
||||
func validateDefaultBotCommandScope(values map[string]string) error {
|
||||
if strings.TrimSpace(values["language_code"]) != "" {
|
||||
return errors.New("BOT_COMMAND_SCOPE_UNSUPPORTED")
|
||||
}
|
||||
raw := strings.TrimSpace(values["scope"])
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var scope struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if json.Unmarshal([]byte(raw), &scope) != nil || scope.Type != "default" {
|
||||
return errors.New("BOT_COMMAND_SCOPE_UNSUPPORTED")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) setMyCommands(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
values, err := requestValues(r)
|
||||
if err != nil || h.bots == nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := validateDefaultBotCommandScope(values); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var input []struct {
|
||||
Command string `json:"command"`
|
||||
Description string `json:"description"`
|
||||
IsEphemeral bool `json:"is_ephemeral"`
|
||||
}
|
||||
if json.Unmarshal([]byte(values["commands"]), &input) != nil || len(input) > maxBotAPICommands {
|
||||
writeAPIError(w, http.StatusBadRequest, "BOT_COMMAND_INVALID")
|
||||
return
|
||||
}
|
||||
commands := make([]domain.BotCommand, 0, len(input))
|
||||
for _, command := range input {
|
||||
commands = append(commands, domain.BotCommand{
|
||||
Command: command.Command, Description: command.Description, Ephemeral: command.IsEphemeral,
|
||||
})
|
||||
}
|
||||
if _, err := h.bots.SetBotCommands(r.Context(), botID, commands); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, true)
|
||||
}
|
||||
|
||||
func (h *handler) deleteMyCommands(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
values, err := requestValues(r)
|
||||
if err != nil || h.bots == nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := validateDefaultBotCommandScope(values); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := h.bots.SetBotCommands(r.Context(), botID, nil); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, true)
|
||||
}
|
||||
|
||||
func (h *handler) getMyCommands(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
values, err := requestValues(r)
|
||||
if err != nil || h.bots == nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := validateDefaultBotCommandScope(values); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
commands, err := h.bots.GetBotCommands(r.Context(), botID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(commands))
|
||||
for _, command := range commands {
|
||||
item := map[string]any{"command": command.Command, "description": command.Description}
|
||||
if command.Ephemeral {
|
||||
item["is_ephemeral"] = true
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
writeAPIOK(w, out)
|
||||
}
|
||||
375
internal/botapi/ephemeral.go
Normal file
375
internal/botapi/ephemeral.go
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
package botapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"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":
|
||||
if strings.TrimSpace(values["parse_mode"]) != "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
|
||||
return
|
||||
}
|
||||
if values["text"] == "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_EMPTY")
|
||||
return
|
||||
}
|
||||
if !utf8.ValidString(values["text"]) || utf8.RuneCountInString(values["text"]) > domain.MaxMessageTextLength {
|
||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG")
|
||||
return
|
||||
}
|
||||
entities, err := botAPIMessageEntities(values["entities"])
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, values["text"], entities
|
||||
case "caption":
|
||||
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
|
||||
}
|
||||
if !utf8.ValidString(values["caption"]) || utf8.RuneCountInString(values["caption"]) > domain.MaxEphemeralCaptionLength {
|
||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG")
|
||||
return
|
||||
}
|
||||
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, values["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 == "" || strings.TrimSpace(media.ParseMode) != "" {
|
||||
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
|
||||
}
|
||||
entities, err := botAPIMessageEntities(string(media.CaptionEntities))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !utf8.ValidString(media.Caption) || utf8.RuneCountInString(media.Caption) > domain.MaxEphemeralCaptionLength {
|
||||
return errors.New("MESSAGE_TOO_LONG")
|
||||
}
|
||||
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, media.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
|
||||
}
|
||||
|
|
@ -85,24 +85,42 @@ func apiUpdates(events []domain.UpdateEvent, limit int) []map[string]any {
|
|||
}
|
||||
|
||||
func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
|
||||
if event.Pts <= 0 {
|
||||
updateID := event.BotAPIUpdateID
|
||||
if updateID <= 0 {
|
||||
updateID = int64(event.Pts)
|
||||
}
|
||||
if updateID <= 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
switch event.Type {
|
||||
case domain.UpdateEventNewMessage:
|
||||
if event.EphemeralMessage != nil {
|
||||
message, ok := apiEphemeralMessage(*event.EphemeralMessage, event.Users, event.Channels)
|
||||
if !ok {
|
||||
return nil, "", false
|
||||
}
|
||||
return map[string]any{"update_id": updateID, "message": message}, "message", true
|
||||
}
|
||||
if !apiMessageProjectable(event.Message) {
|
||||
return nil, "", false
|
||||
}
|
||||
return map[string]any{
|
||||
"update_id": event.Pts,
|
||||
"update_id": updateID,
|
||||
"message": apiMessage(event.Message, event.Users, event.Channels),
|
||||
}, "message", true
|
||||
case domain.UpdateEventEditMessage:
|
||||
if event.EphemeralMessage != nil {
|
||||
message, ok := apiEphemeralMessage(*event.EphemeralMessage, event.Users, event.Channels)
|
||||
if !ok {
|
||||
return nil, "", false
|
||||
}
|
||||
return map[string]any{"update_id": updateID, "edited_message": message}, "edited_message", true
|
||||
}
|
||||
if !apiMessageProjectable(event.Message) {
|
||||
return nil, "", false
|
||||
}
|
||||
return map[string]any{
|
||||
"update_id": event.Pts,
|
||||
"update_id": updateID,
|
||||
"edited_message": apiMessage(event.Message, event.Users, event.Channels),
|
||||
}, "edited_message", true
|
||||
case domain.UpdateEventBotCallbackQuery:
|
||||
|
|
@ -132,6 +150,15 @@ func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
|
|||
return nil, "", false
|
||||
}
|
||||
query["inline_message_id"] = inlineMessageID
|
||||
} else if event.EphemeralMessage != nil {
|
||||
if callback.MessageID <= 0 || event.EphemeralMessage.ID != callback.MessageID || event.EphemeralMessage.Peer != callback.Peer {
|
||||
return nil, "", false
|
||||
}
|
||||
message, ok := apiEphemeralMessage(*event.EphemeralMessage, event.Users, event.Channels)
|
||||
if !ok {
|
||||
return nil, "", false
|
||||
}
|
||||
query["message"] = message
|
||||
} else {
|
||||
if callback.MessageID <= 0 || event.Message.ID != callback.MessageID {
|
||||
return nil, "", false
|
||||
|
|
@ -139,7 +166,7 @@ func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
|
|||
query["message"] = apiMessage(event.Message, event.Users, event.Channels)
|
||||
}
|
||||
return map[string]any{
|
||||
"update_id": event.Pts,
|
||||
"update_id": updateID,
|
||||
"callback_query": query,
|
||||
}, "callback_query", true
|
||||
default:
|
||||
|
|
@ -147,6 +174,46 @@ func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
|
|||
}
|
||||
}
|
||||
|
||||
func apiEphemeralMessage(message domain.EphemeralMessage, users []domain.User, channels []domain.Channel) (map[string]any, bool) {
|
||||
return apiEphemeralMessageDepth(message, users, channels, 0)
|
||||
}
|
||||
|
||||
func apiEphemeralMessageDepth(message domain.EphemeralMessage, users []domain.User, channels []domain.Channel, depth int) (map[string]any, bool) {
|
||||
if message.ID <= 0 || message.Peer.Type != domain.PeerTypeChannel || message.Peer.ID <= 0 ||
|
||||
message.SenderUserID <= 0 || message.ReceiverUserID <= 0 || message.Date <= 0 || message.Deleted {
|
||||
return nil, false
|
||||
}
|
||||
if message.Content.Message == "" && (message.Content.Media == nil || message.Content.Media.IsZero()) {
|
||||
return nil, false
|
||||
}
|
||||
projected := apiMessage(domain.Message{
|
||||
ID: 0, Peer: message.Peer, From: domain.Peer{Type: domain.PeerTypeUser, ID: message.SenderUserID},
|
||||
Date: message.Date, EditDate: message.EditDate, Body: message.Content.Message,
|
||||
Entities: message.Content.Entities, Media: message.Content.Media, ReplyMarkup: message.Content.ReplyMarkup,
|
||||
}, users, channels)
|
||||
projected["message_id"] = 0
|
||||
projected["ephemeral_message_id"] = message.ID
|
||||
receiver := domain.User{ID: message.ReceiverUserID}
|
||||
for _, user := range users {
|
||||
if user.ID == message.ReceiverUserID {
|
||||
receiver = user
|
||||
break
|
||||
}
|
||||
}
|
||||
projected["receiver_user"] = apiUser(receiver)
|
||||
if message.ReplyToEphemeralID > 0 {
|
||||
if depth != 0 || message.BotAPIReply == nil || message.BotAPIReply.ID != message.ReplyToEphemeralID {
|
||||
return nil, false
|
||||
}
|
||||
reply, ok := apiEphemeralMessageDepth(*message.BotAPIReply, users, channels, depth+1)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
projected["reply_to_message"] = reply
|
||||
}
|
||||
return projected, true
|
||||
}
|
||||
|
||||
const botAPIInlineMessageIDVersion byte = 1
|
||||
|
||||
// encodeBotAPIInlineMessageID exposes the signed MTProto inline-message identity as an
|
||||
|
|
@ -236,9 +303,7 @@ func apiMessage(msg domain.Message, users []domain.User, channelLists ...[]domai
|
|||
}
|
||||
media := apiMessageMedia(msg.Media, userByID, channelByID)
|
||||
if msg.Body != "" {
|
||||
if _, photo := media["photo"]; photo {
|
||||
out["caption"] = msg.Body
|
||||
} else if _, document := media["document"]; document {
|
||||
if apiMediaUsesCaption(media) {
|
||||
out["caption"] = msg.Body
|
||||
} else if poll, ok := media["poll"].(map[string]any); ok {
|
||||
poll["description"] = msg.Body
|
||||
|
|
@ -247,9 +312,7 @@ func apiMessage(msg domain.Message, users []domain.User, channelLists ...[]domai
|
|||
}
|
||||
}
|
||||
if entities := apiMessageEntities(msg.Entities, userByID); len(entities) > 0 {
|
||||
if _, photo := media["photo"]; photo {
|
||||
out["caption_entities"] = entities
|
||||
} else if _, document := media["document"]; document {
|
||||
if apiMediaUsesCaption(media) {
|
||||
out["caption_entities"] = entities
|
||||
} else if poll, ok := media["poll"].(map[string]any); ok && msg.Body != "" {
|
||||
poll["description_entities"] = entities
|
||||
|
|
@ -278,6 +341,15 @@ func apiMessage(msg domain.Message, users []domain.User, channelLists ...[]domai
|
|||
return out
|
||||
}
|
||||
|
||||
func apiMediaUsesCaption(media map[string]any) bool {
|
||||
for _, key := range []string{"photo", "live_photo", "animation", "audio", "document", "video", "voice"} {
|
||||
if _, ok := media[key]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func apiChat(peer domain.Peer, users map[int64]domain.User) map[string]any {
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
|
|
@ -476,12 +548,23 @@ func apiMessageMedia(media *domain.MessageMedia, users map[int64]domain.User, ch
|
|||
if len(photos) == 0 {
|
||||
return nil
|
||||
}
|
||||
if media.LivePhotoVideo != nil {
|
||||
live := apiDocument(*media.LivePhotoVideo)
|
||||
live["photo"] = photos
|
||||
for _, attribute := range media.LivePhotoVideo.Attributes {
|
||||
if attribute.Kind == domain.DocAttrVideo {
|
||||
live["width"], live["height"], live["duration"] = attribute.W, attribute.H, int(attribute.Duration)
|
||||
break
|
||||
}
|
||||
}
|
||||
return map[string]any{"live_photo": live}
|
||||
}
|
||||
return map[string]any{"photo": photos}
|
||||
case domain.MessageMediaKindDocument:
|
||||
if media.Document == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"document": apiDocument(*media.Document)}
|
||||
return apiDocumentMedia(*media.Document)
|
||||
case domain.MessageMediaKindContact:
|
||||
if media.Contact == nil {
|
||||
return nil
|
||||
|
|
@ -542,6 +625,67 @@ func apiMessageMedia(media *domain.MessageMedia, users map[int64]domain.User, ch
|
|||
}
|
||||
}
|
||||
|
||||
func apiDocumentMedia(document domain.Document) map[string]any {
|
||||
base := apiDocument(document)
|
||||
for _, attribute := range document.Attributes {
|
||||
switch attribute.Kind {
|
||||
case domain.DocAttrSticker:
|
||||
sticker := cloneAPIMap(base)
|
||||
sticker["type"], sticker["width"], sticker["height"] = "regular", attribute.W, attribute.H
|
||||
sticker["is_animated"] = hasDocumentAttribute(document, domain.DocAttrAnimated)
|
||||
sticker["is_video"] = hasDocumentAttribute(document, domain.DocAttrVideo)
|
||||
if attribute.Alt != "" {
|
||||
sticker["emoji"] = attribute.Alt
|
||||
}
|
||||
return map[string]any{"sticker": sticker}
|
||||
case domain.DocAttrAudio:
|
||||
audio := cloneAPIMap(base)
|
||||
audio["duration"] = attribute.AudioDuration
|
||||
if attribute.Voice {
|
||||
return map[string]any{"voice": audio}
|
||||
}
|
||||
if attribute.Title != "" {
|
||||
audio["title"] = attribute.Title
|
||||
}
|
||||
if attribute.Performer != "" {
|
||||
audio["performer"] = attribute.Performer
|
||||
}
|
||||
return map[string]any{"audio": audio}
|
||||
case domain.DocAttrVideo:
|
||||
video := cloneAPIMap(base)
|
||||
video["width"], video["height"], video["duration"] = attribute.W, attribute.H, int(attribute.Duration)
|
||||
if attribute.RoundMessage {
|
||||
video["length"] = attribute.W
|
||||
delete(video, "width")
|
||||
delete(video, "height")
|
||||
return map[string]any{"video_note": video}
|
||||
}
|
||||
if hasDocumentAttribute(document, domain.DocAttrAnimated) {
|
||||
return map[string]any{"animation": video, "document": base}
|
||||
}
|
||||
return map[string]any{"video": video}
|
||||
}
|
||||
}
|
||||
return map[string]any{"document": base}
|
||||
}
|
||||
|
||||
func hasDocumentAttribute(document domain.Document, kind domain.DocumentAttributeKind) bool {
|
||||
for _, attribute := range document.Attributes {
|
||||
if attribute.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloneAPIMap(input map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(input)+4)
|
||||
for key, value := range input {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apiLocation(geo domain.MessageGeoPoint, live *domain.MessageGeoLive) map[string]any {
|
||||
out := map[string]any{"latitude": geo.Lat, "longitude": geo.Long}
|
||||
if geo.AccuracyRadius > 0 {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
|
|
@ -23,6 +24,8 @@ import (
|
|||
|
||||
type BotsService interface {
|
||||
BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error)
|
||||
SetBotCommands(ctx context.Context, botUserID int64, commands []domain.BotCommand) (int, error)
|
||||
GetBotCommands(ctx context.Context, botUserID int64) ([]domain.BotCommand, error)
|
||||
SetBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (int, error)
|
||||
GetBotMenuButton(ctx context.Context, botUserID int64) (domain.BotMenuButton, error)
|
||||
BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error)
|
||||
|
|
@ -49,6 +52,12 @@ type GatewayService interface {
|
|||
BotAPIGetFile(ctx context.Context, botID int64, locationKey string, offset int64, limit int) (domain.FileChunk, bool, error)
|
||||
}
|
||||
|
||||
type EphemeralGatewayService interface {
|
||||
BotAPISendEphemeral(ctx context.Context, input domain.BotAPIEphemeralSendInput) (domain.EphemeralMessage, error)
|
||||
BotAPIEditEphemeral(ctx context.Context, input domain.BotAPIEphemeralEditInput) (bool, error)
|
||||
BotAPIDeleteEphemeral(ctx context.Context, botUserID, chatID, receiverUserID int64, messageID int) (bool, error)
|
||||
}
|
||||
|
||||
type GatewayUpdateWaiter interface {
|
||||
BotAPIUpdateWaitVersion(botID int64) uint64
|
||||
WaitBotAPIUpdate(ctx context.Context, botID int64, version uint64, timeout time.Duration) bool
|
||||
|
|
@ -186,18 +195,54 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) {
|
|||
switch strings.ToLower(method) {
|
||||
case "getme":
|
||||
h.getMe(w, r, botID)
|
||||
case "setmycommands":
|
||||
h.setMyCommands(w, r, botID)
|
||||
case "deletemycommands":
|
||||
h.deleteMyCommands(w, r, botID)
|
||||
case "getmycommands":
|
||||
h.getMyCommands(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 "sendanimation":
|
||||
h.sendMedia(w, r, botID, "animation")
|
||||
case "sendaudio":
|
||||
h.sendMedia(w, r, botID, "audio")
|
||||
case "senddocument":
|
||||
h.sendMedia(w, r, botID, "document")
|
||||
case "sendlivephoto":
|
||||
h.sendMedia(w, r, botID, "live_photo")
|
||||
case "sendsticker":
|
||||
h.sendMedia(w, r, botID, "sticker")
|
||||
case "sendvideo":
|
||||
h.sendMedia(w, r, botID, "video")
|
||||
case "sendvideonote":
|
||||
h.sendMedia(w, r, botID, "video_note")
|
||||
case "sendvoice":
|
||||
h.sendMedia(w, r, botID, "voice")
|
||||
case "sendcontact":
|
||||
h.sendEphemeralContact(w, r, botID)
|
||||
case "sendlocation":
|
||||
h.sendEphemeralLocation(w, r, botID, false)
|
||||
case "sendvenue":
|
||||
h.sendEphemeralLocation(w, r, botID, true)
|
||||
case "editmessagetext":
|
||||
h.editMessageText(w, r, botID)
|
||||
case "deletemessage":
|
||||
h.deleteMessage(w, r, botID)
|
||||
case "editephemeralmessagetext":
|
||||
h.editEphemeralMessage(w, r, botID, "text")
|
||||
case "editephemeralmessagemedia":
|
||||
h.editEphemeralMessage(w, r, botID, "media")
|
||||
case "editephemeralmessagecaption":
|
||||
h.editEphemeralMessage(w, r, botID, "caption")
|
||||
case "editephemeralmessagereplymarkup":
|
||||
h.editEphemeralMessage(w, r, botID, "reply_markup")
|
||||
case "deleteephemeralmessage":
|
||||
h.deleteEphemeralMessage(w, r, botID)
|
||||
case "answercallbackquery":
|
||||
h.answerCallbackQuery(w, r, botID)
|
||||
case "getfile":
|
||||
|
|
@ -481,6 +526,10 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6
|
|||
return
|
||||
}
|
||||
text := values["text"]
|
||||
if text == "" || !utf8.ValidString(text) || utf8.RuneCountInString(text) > domain.MaxMessageTextLength {
|
||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_EMPTY")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(values["parse_mode"]) != "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
|
||||
return
|
||||
|
|
@ -498,6 +547,33 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6
|
|||
return
|
||||
}
|
||||
}
|
||||
ephemeral, isEphemeral, err := parseEphemeralSendTarget(values)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if isEphemeral {
|
||||
if markup != nil && !markup.IsZero() && markup.Kind() != domain.MessageReplyMarkupInline {
|
||||
writeAPIError(w, http.StatusBadRequest, "BUTTON_TYPE_INVALID")
|
||||
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: ephemeral.receiverUserID,
|
||||
CallbackQueryID: ephemeral.callbackQueryID, ReplyToEphemeralID: ephemeral.replyToEphemeralID,
|
||||
TopMessageID: ephemeral.topMessageID, Kind: "message", Text: text, Entities: entities, ReplyMarkup: markup,
|
||||
})
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
}
|
||||
h.writeEphemeralMessage(w, r, botID, message)
|
||||
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 {
|
||||
|
|
@ -535,6 +611,10 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64,
|
|||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !utf8.ValidString(values["caption"]) || utf8.RuneCountInString(values["caption"]) > domain.MaxEphemeralCaptionLength {
|
||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG")
|
||||
return
|
||||
}
|
||||
var markup *domain.MessageReplyMarkup
|
||||
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
|
||||
markup, err = replyMarkupFromAPI(json.RawMessage(raw))
|
||||
|
|
@ -543,11 +623,56 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64,
|
|||
return
|
||||
}
|
||||
}
|
||||
locationKey, remoteURL, fileName, mimeType, fileBytes, ok := mediaInput(values[kind], files, kind)
|
||||
var file, secondary domain.BotAPIFileInput
|
||||
var ok bool
|
||||
if kind == "live_photo" {
|
||||
file, ok = botAPIFileInput(values["photo"], files, "photo", values)
|
||||
if ok {
|
||||
secondary, ok = botAPIFileInput(values["live_photo"], files, "live_photo", values)
|
||||
}
|
||||
} else {
|
||||
file, ok = botAPIFileInput(values[kind], files, kind, values)
|
||||
}
|
||||
if !ok {
|
||||
writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID")
|
||||
return
|
||||
}
|
||||
// The official Bot API does not accept HTTP URLs for the video part of a
|
||||
// live photo or for video notes. Reject them before either the ordinary or
|
||||
// ephemeral send path can fetch the remote resource.
|
||||
if (kind == "live_photo" && secondary.RemoteURL != "") || (kind == "video_note" && file.RemoteURL != "") {
|
||||
writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID")
|
||||
return
|
||||
}
|
||||
ephemeral, isEphemeral, err := parseEphemeralSendTarget(values)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if isEphemeral {
|
||||
if markup != nil && !markup.IsZero() && markup.Kind() != domain.MessageReplyMarkupInline {
|
||||
writeAPIError(w, http.StatusBadRequest, "BUTTON_TYPE_INVALID")
|
||||
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: ephemeral.receiverUserID,
|
||||
CallbackQueryID: ephemeral.callbackQueryID, ReplyToEphemeralID: ephemeral.replyToEphemeralID,
|
||||
TopMessageID: ephemeral.topMessageID, Kind: kind, Text: values["caption"], Entities: entities,
|
||||
ReplyMarkup: markup, File: file, SecondaryFile: secondary,
|
||||
})
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
}
|
||||
h.writeEphemeralMessage(w, r, botID, message)
|
||||
return
|
||||
}
|
||||
locationKey, remoteURL, fileName, mimeType, fileBytes := file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes
|
||||
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))
|
||||
|
|
@ -1252,6 +1377,9 @@ func apiErrorDescription(err error) string {
|
|||
"QUERY_ID_INVALID",
|
||||
"MESSAGE_ID_INVALID",
|
||||
"MESSAGE_NOT_MODIFIED",
|
||||
"BOT_COMMAND_INVALID",
|
||||
"EPHEMERAL_MESSAGE_ID_INVALID",
|
||||
"EPHEMERAL_ACTION_EXPIRED",
|
||||
"CHAT_WRITE_FORBIDDEN",
|
||||
"CHAT_ADMIN_REQUIRED",
|
||||
"REPLY_MESSAGE_ID_INVALID",
|
||||
|
|
|
|||
|
|
@ -160,6 +160,62 @@ func TestGetMeUsesGateway(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBotCommandsPreserveEphemeralFlag(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
h := (&handler{bots: bots}).routes()
|
||||
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "setMyCommands", `{
|
||||
"commands": [
|
||||
{"command":"private","description":"Private reply","is_ephemeral":true},
|
||||
{"command":"public","description":"Public reply"}
|
||||
]
|
||||
}`)
|
||||
if rec.Code != http.StatusOK || len(bots.commands) != 2 || !bots.commands[0].Ephemeral || bots.commands[1].Ephemeral {
|
||||
t.Fatalf("setMyCommands status=%d body=%s commands=%#v", rec.Code, rec.Body.String(), bots.commands)
|
||||
}
|
||||
|
||||
rec = performBotAPIRequest(t, h, bots.profile, "getMyCommands", `{}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("getMyCommands status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
OK bool `json:"ok"`
|
||||
Result []struct {
|
||||
Command string `json:"command"`
|
||||
Description string `json:"description"`
|
||||
IsEphemeral bool `json:"is_ephemeral"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !response.OK || len(response.Result) != 2 || !response.Result[0].IsEphemeral || response.Result[1].IsEphemeral {
|
||||
t.Fatalf("getMyCommands response=%s", rec.Body.String())
|
||||
}
|
||||
|
||||
rec = performBotAPIRequest(t, h, bots.profile, "deleteMyCommands", `{}`)
|
||||
if rec.Code != http.StatusOK || len(bots.commands) != 0 {
|
||||
t.Fatalf("deleteMyCommands status=%d body=%s commands=%#v", rec.Code, rec.Body.String(), bots.commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotCommandsRejectUnsupportedScopeAndLanguage(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
h := (&handler{bots: bots}).routes()
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"scope": `{"scope":{"type":"all_group_chats"},"commands":[]}`,
|
||||
"language": `{"language_code":"en","commands":[]}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "setMyCommands", body)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "BOT_COMMAND_SCOPE_UNSUPPORTED") {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUpdatesProjectsIncomingPrivateText(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
gateway := &fakeBotAPIGateway{
|
||||
|
|
@ -262,6 +318,221 @@ func TestGetUpdatesSkipsOutgoingBotMessage(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetUpdatesProjectsEphemeralMessageWithoutPts(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
message := domain.EphemeralMessage{
|
||||
ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
|
||||
SenderUserID: 2001, ReceiverUserID: 1001, Date: 1_900_000_000,
|
||||
Content: domain.EphemeralContent{Message: "/private"},
|
||||
}
|
||||
gateway := &fakeBotAPIGateway{updates: []domain.UpdateEvent{{
|
||||
Type: domain.UpdateEventNewMessage, BotAPIUpdateID: 901, EphemeralMessage: &message,
|
||||
Users: []domain.User{{ID: 2001, FirstName: "Alice"}, {ID: 1001, FirstName: "Bot", Bot: true}},
|
||||
Channels: []domain.Channel{{ID: 3001, Title: "Group", Megagroup: 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 response struct {
|
||||
OK bool `json:"ok"`
|
||||
Result []struct {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message struct {
|
||||
MessageID int `json:"message_id"`
|
||||
EphemeralMessageID int `json:"ephemeral_message_id"`
|
||||
Text string `json:"text"`
|
||||
ReceiverUser struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"receiver_user"`
|
||||
} `json:"message"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !response.OK || len(response.Result) != 1 || response.Result[0].UpdateID != 901 ||
|
||||
response.Result[0].Message.MessageID != 0 || response.Result[0].Message.EphemeralMessageID != 77 ||
|
||||
response.Result[0].Message.ReceiverUser.ID != 1001 || response.Result[0].Message.Text != "/private" {
|
||||
t.Fatalf("response=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEphemeralReplyProjectionContainsValidOneLevelTarget(t *testing.T) {
|
||||
target := domain.EphemeralMessage{
|
||||
ID: 70, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
|
||||
SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000,
|
||||
Content: domain.EphemeralContent{Message: "question"},
|
||||
}
|
||||
message := domain.EphemeralMessage{
|
||||
ID: 71, Peer: target.Peer, SenderUserID: 2001, ReceiverUserID: 1001,
|
||||
Date: 1_900_000_001, ReplyToEphemeralID: target.ID,
|
||||
Content: domain.EphemeralContent{Message: "answer"}, BotAPIReply: &target,
|
||||
}
|
||||
projected, ok := apiEphemeralMessage(message, []domain.User{{ID: 1001, Bot: true}, {ID: 2001}}, []domain.Channel{{ID: 3001, Title: "Group", Megagroup: true}})
|
||||
if !ok {
|
||||
t.Fatal("reply was not projectable")
|
||||
}
|
||||
reply, ok := projected["reply_to_message"].(map[string]any)
|
||||
if !ok || reply["message_id"] != 0 || reply["ephemeral_message_id"] != target.ID || reply["date"] != target.Date || reply["text"] != "question" {
|
||||
t.Fatalf("reply_to_message=%#v", projected["reply_to_message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEphemeralSendMethodsRouteAllOfficialMediaKinds(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
gateway := &fakeBotAPIGateway{
|
||||
self: domain.User{ID: 1001, FirstName: "Bot", Bot: true},
|
||||
ephemeralMessage: domain.EphemeralMessage{
|
||||
ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
|
||||
SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000,
|
||||
Content: domain.EphemeralContent{Message: "sent"},
|
||||
},
|
||||
}
|
||||
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||
chatID := int64(-1000000003001)
|
||||
documentID := encodeBotAPIFileID("doc:7001")
|
||||
photoID := encodeBotAPIFileID("photo:7002:m")
|
||||
tests := []struct {
|
||||
method string
|
||||
kind string
|
||||
body map[string]any
|
||||
}{
|
||||
{"sendMessage", "message", map[string]any{"text": "hello", "message_thread_id": 42}},
|
||||
{"sendAnimation", "animation", map[string]any{"animation": documentID}},
|
||||
{"sendAudio", "audio", map[string]any{"audio": documentID}},
|
||||
{"sendDocument", "document", map[string]any{"document": documentID}},
|
||||
{"sendLivePhoto", "live_photo", map[string]any{"photo": photoID, "live_photo": documentID}},
|
||||
{"sendPhoto", "photo", map[string]any{"photo": photoID}},
|
||||
{"sendSticker", "sticker", map[string]any{"sticker": documentID}},
|
||||
{"sendVideo", "video", map[string]any{"video": documentID}},
|
||||
{"sendVideoNote", "video_note", map[string]any{"video_note": documentID}},
|
||||
{"sendVoice", "voice", map[string]any{"voice": documentID}},
|
||||
{"sendContact", "contact", map[string]any{"phone_number": "+100", "first_name": "Alice"}},
|
||||
{"sendLocation", "location", map[string]any{"latitude": 1.25, "longitude": 2.5}},
|
||||
{"sendVenue", "location", map[string]any{"latitude": 1.25, "longitude": 2.5, "title": "Place", "address": "Street"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.method, func(t *testing.T) {
|
||||
body := test.body
|
||||
body["chat_id"] = chatID
|
||||
body["receiver_user_id"] = int64(2001)
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := performBotAPIRequest(t, h, bots.profile, test.method, string(raw))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
got := gateway.ephemeralSends[len(gateway.ephemeralSends)-1]
|
||||
if got.Kind != test.kind || got.ChatID != chatID || got.ReceiverUserID != 2001 {
|
||||
t.Fatalf("input=%+v", got)
|
||||
}
|
||||
var response struct {
|
||||
OK bool `json:"ok"`
|
||||
Result struct {
|
||||
MessageID int `json:"message_id"`
|
||||
EphemeralMessageID int `json:"ephemeral_message_id"`
|
||||
ReceiverUser struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"receiver_user"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if json.Unmarshal(rec.Body.Bytes(), &response) != nil || !response.OK || response.Result.MessageID != 0 ||
|
||||
response.Result.EphemeralMessageID != 77 || response.Result.ReceiverUser.ID != 2001 {
|
||||
t.Fatalf("response=%s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
if gateway.ephemeralSends[0].TopMessageID != 42 {
|
||||
t.Fatalf("message_thread_id=%d", gateway.ephemeralSends[0].TopMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEphemeralSendRejectsOfficiallyUnsupportedMediaURLs(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
gateway := &fakeBotAPIGateway{}
|
||||
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||
photoID := encodeBotAPIFileID("photo:7002:m")
|
||||
|
||||
tests := []struct {
|
||||
method string
|
||||
body map[string]any
|
||||
}{
|
||||
{"sendVideoNote", map[string]any{"video_note": "https://example.com/note.mp4"}},
|
||||
{"sendLivePhoto", map[string]any{"photo": photoID, "live_photo": "https://example.com/live.mp4"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.method, func(t *testing.T) {
|
||||
test.body["chat_id"] = int64(-1000000003001)
|
||||
test.body["receiver_user_id"] = int64(2001)
|
||||
raw, _ := json.Marshal(test.body)
|
||||
rec := performBotAPIRequest(t, h, bots.profile, test.method, string(raw))
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "FILE_ID_INVALID") {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
if len(gateway.ephemeralSends) != 0 {
|
||||
t.Fatalf("gateway was called: %+v", gateway.ephemeralSends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEphemeralCallbackReplyEditAndDeleteContracts(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
gateway := &fakeBotAPIGateway{
|
||||
self: domain.User{ID: 1001, FirstName: "Bot", Bot: true},
|
||||
ephemeralMessage: domain.EphemeralMessage{
|
||||
ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
|
||||
SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000,
|
||||
Content: domain.EphemeralContent{Message: "sent"},
|
||||
},
|
||||
}
|
||||
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||
chatID := int64(-1000000003001)
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"callback_query_id":"991","text":"answer"}`)
|
||||
if rec.Code != http.StatusOK || len(gateway.ephemeralSends) != 1 || gateway.ephemeralSends[0].CallbackQueryID != 991 {
|
||||
t.Fatalf("callback send status=%d body=%s inputs=%+v", rec.Code, rec.Body.String(), gateway.ephemeralSends)
|
||||
}
|
||||
rec = performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"reply_parameters":{"ephemeral_message_id":66},"text":"reply"}`)
|
||||
if rec.Code != http.StatusOK || gateway.ephemeralSends[1].ReplyToEphemeralID != 66 {
|
||||
t.Fatalf("reply send status=%d body=%s input=%+v", rec.Code, rec.Body.String(), gateway.ephemeralSends[1])
|
||||
}
|
||||
|
||||
photoID := encodeBotAPIFileID("photo:7002:m")
|
||||
media, _ := json.Marshal(map[string]any{"type": "photo", "media": photoID, "caption": "new"})
|
||||
edits := []struct {
|
||||
method string
|
||||
body map[string]any
|
||||
}{
|
||||
{"editEphemeralMessageText", map[string]any{"text": "edited"}},
|
||||
{"editEphemeralMessageMedia", map[string]any{"media": json.RawMessage(media)}},
|
||||
{"editEphemeralMessageCaption", map[string]any{"caption": "caption"}},
|
||||
{"editEphemeralMessageReplyMarkup", map[string]any{"reply_markup": map[string]any{"inline_keyboard": []any{}}}},
|
||||
}
|
||||
for _, edit := range edits {
|
||||
body := edit.body
|
||||
body["chat_id"], body["receiver_user_id"], body["ephemeral_message_id"] = chatID, int64(2001), 77
|
||||
raw, _ := json.Marshal(body)
|
||||
rec = performBotAPIRequest(t, h, bots.profile, edit.method, string(raw))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status=%d body=%s", edit.method, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if len(gateway.ephemeralEdits) != 4 || gateway.ephemeralEdits[0].Mode != domain.EphemeralEditText ||
|
||||
gateway.ephemeralEdits[1].Mode != domain.EphemeralEditMedia || gateway.ephemeralEdits[1].MediaKind != "photo" ||
|
||||
gateway.ephemeralEdits[2].Mode != domain.EphemeralEditCaption ||
|
||||
gateway.ephemeralEdits[3].Mode != domain.EphemeralEditReplyMarkup || !gateway.ephemeralEdits[3].Fields.SetReplyMarkup {
|
||||
t.Fatalf("edits=%+v", gateway.ephemeralEdits)
|
||||
}
|
||||
rec = performBotAPIRequest(t, h, bots.profile, "deleteEphemeralMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"ephemeral_message_id":77}`)
|
||||
if rec.Code != http.StatusOK || !gateway.ephemeralDeleteCalled || gateway.ephemeralDeleteMessageID != 77 {
|
||||
t.Fatalf("delete status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessageParsesEntitiesMarkupAndCallsGateway(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
gateway := &fakeBotAPIGateway{
|
||||
|
|
@ -980,13 +1251,23 @@ type apiResponse struct {
|
|||
}
|
||||
|
||||
type fakeBotAPIBots struct {
|
||||
profile domain.BotProfile
|
||||
profile domain.BotProfile
|
||||
commands []domain.BotCommand
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIBots) BotInfo(context.Context, int64) (domain.BotProfile, bool, error) {
|
||||
return f.profile, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIBots) SetBotCommands(_ context.Context, _ int64, commands []domain.BotCommand) (int, error) {
|
||||
f.commands = append([]domain.BotCommand(nil), commands...)
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIBots) GetBotCommands(context.Context, int64) ([]domain.BotCommand, error) {
|
||||
return append([]domain.BotCommand(nil), f.commands...), nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIBots) SetBotMenuButton(context.Context, int64, domain.BotMenuButton) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
|
@ -1038,41 +1319,46 @@ type fakeBotAPIGateway struct {
|
|||
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
|
||||
editInlineCalled bool
|
||||
editInlineID domain.BotInlineMessageID
|
||||
deleteCalled bool
|
||||
callbackCalled bool
|
||||
callbackID string
|
||||
fileLocationKey string
|
||||
fileChunks map[string]domain.FileChunk
|
||||
allowedUpdates []domain.BotAPIUpdateKind
|
||||
dropPending bool
|
||||
pendingCount int
|
||||
webhook domain.BotAPIWebhook
|
||||
webhookFound bool
|
||||
webhookDeleted bool
|
||||
webhookDrop bool
|
||||
webhookConfirmed 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
|
||||
editInlineCalled bool
|
||||
editInlineID domain.BotInlineMessageID
|
||||
deleteCalled bool
|
||||
callbackCalled bool
|
||||
callbackID string
|
||||
fileLocationKey string
|
||||
fileChunks map[string]domain.FileChunk
|
||||
allowedUpdates []domain.BotAPIUpdateKind
|
||||
dropPending bool
|
||||
pendingCount int
|
||||
webhook domain.BotAPIWebhook
|
||||
webhookFound bool
|
||||
webhookDeleted bool
|
||||
webhookDrop bool
|
||||
webhookConfirmed int64
|
||||
ephemeralMessage domain.EphemeralMessage
|
||||
ephemeralSends []domain.BotAPIEphemeralSendInput
|
||||
ephemeralEdits []domain.BotAPIEphemeralEditInput
|
||||
ephemeralDeleteCalled bool
|
||||
ephemeralDeleteMessageID int
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPISelf(context.Context, int64) (domain.User, error) {
|
||||
|
|
@ -1206,3 +1492,19 @@ func (f *fakeBotAPIGateway) BotAPIGetFile(_ context.Context, _ int64, locationKe
|
|||
out.Bytes = append([]byte(nil), chunk.Bytes[offset:end]...)
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPISendEphemeral(_ context.Context, input domain.BotAPIEphemeralSendInput) (domain.EphemeralMessage, error) {
|
||||
f.ephemeralSends = append(f.ephemeralSends, input)
|
||||
return f.ephemeralMessage, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIEditEphemeral(_ context.Context, input domain.BotAPIEphemeralEditInput) (bool, error) {
|
||||
f.ephemeralEdits = append(f.ephemeralEdits, input)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIDeleteEphemeral(_ context.Context, _ int64, _ int64, _ int64, messageID int) (bool, error) {
|
||||
f.ephemeralDeleteCalled = true
|
||||
f.ephemeralDeleteMessageID = messageID
|
||||
return true, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue