owpengram-server/internal/botapi/projection.go

983 lines
29 KiB
Go

package botapi
import (
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"strconv"
"strings"
"telesrv/internal/domain"
"telesrv/internal/store"
)
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 parseAllowedUpdates(raw string) ([]domain.BotAPIUpdateKind, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, errors.New("ALLOWED_UPDATES_INVALID")
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil, errors.New("ALLOWED_UPDATES_INVALID")
}
if len(items) > 100 {
return nil, errors.New("ALLOWED_UPDATES_INVALID")
}
seen := make(map[domain.BotAPIUpdateKind]struct{}, len(items))
out := make([]domain.BotAPIUpdateKind, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" || len(item) > 64 {
return nil, errors.New("ALLOWED_UPDATES_INVALID")
}
kind := domain.BotAPIUpdateKind(item)
if _, ok := seen[kind]; !ok {
seen[kind] = struct{}{}
out = append(out, kind)
}
}
return out, nil
}
func apiUpdates(events []domain.UpdateEvent, 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, _, ok := apiUpdate(event)
if !ok {
continue
}
out = append(out, item)
if len(out) >= limit {
break
}
}
if out == nil {
return []map[string]any{}
}
return out
}
func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
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": 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": updateID,
"edited_message": apiMessage(event.Message, event.Users, event.Channels),
}, "edited_message", true
case domain.UpdateEventBotCallbackQuery:
callback := event.BotCallbackQuery
if callback == nil || callback.ID == 0 || callback.UserID == 0 {
return nil, "", false
}
var from domain.User
for _, user := range event.Users {
if user.ID == callback.UserID {
from = user
break
}
}
if from.ID == 0 {
from = domain.User{ID: callback.UserID}
}
query := map[string]any{
"id": strconv.FormatInt(callback.ID, 10),
"from": apiUser(from),
"chat_instance": strconv.FormatInt(callback.ChatInstance, 10),
"data": string(callback.Data),
}
if callback.InlineMessage != nil {
inlineMessageID, ok := encodeBotAPIInlineMessageID(*callback.InlineMessage)
if !ok || callback.MessageID != 0 || callback.Peer != (domain.Peer{}) {
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
}
query["message"] = apiMessage(event.Message, event.Users, event.Channels)
}
return map[string]any{
"update_id": updateID,
"callback_query": query,
}, "callback_query", true
default:
return nil, "", false
}
}
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
// opaque, fixed-size Bot API token. AccessHash remains the authorization boundary; the
// version byte lets us reject rather than reinterpret future shapes.
func encodeBotAPIInlineMessageID(id domain.BotInlineMessageID) (string, bool) {
if id.DCID <= 0 || id.OwnerID == 0 || id.ID <= 0 || id.AccessHash == 0 {
return "", false
}
buf := make([]byte, 1+4+8+4+8)
buf[0] = botAPIInlineMessageIDVersion
binary.LittleEndian.PutUint32(buf[1:5], uint32(id.DCID))
binary.LittleEndian.PutUint64(buf[5:13], uint64(id.OwnerID))
binary.LittleEndian.PutUint32(buf[13:17], uint32(id.ID))
binary.LittleEndian.PutUint64(buf[17:25], uint64(id.AccessHash))
return base64.RawURLEncoding.EncodeToString(buf), true
}
func decodeBotAPIInlineMessageID(raw string) (domain.BotInlineMessageID, error) {
buf, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw))
if err != nil || len(buf) != 25 || buf[0] != botAPIInlineMessageIDVersion {
return domain.BotInlineMessageID{}, errors.New("INLINE_MESSAGE_ID_INVALID")
}
id := domain.BotInlineMessageID{
DCID: int(binary.LittleEndian.Uint32(buf[1:5])),
OwnerID: int64(binary.LittleEndian.Uint64(buf[5:13])),
ID: int(binary.LittleEndian.Uint32(buf[13:17])),
AccessHash: int64(binary.LittleEndian.Uint64(buf[17:25])),
}
if id.DCID <= 0 || id.OwnerID == 0 || id.ID <= 0 || id.AccessHash == 0 {
return domain.BotInlineMessageID{}, errors.New("INLINE_MESSAGE_ID_INVALID")
}
return id, nil
}
func apiMessageProjectable(msg domain.Message) bool {
if msg.Out || msg.ID <= 0 {
return false
}
return msg.Body != "" || (msg.RichMessage != nil && len(msg.RichMessage.BotAPIProjection) > 0) || len(apiMessageMedia(msg.Media, nil, nil)) > 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, channelLists ...[]domain.Channel) map[string]any {
userByID := map[int64]domain.User{}
for _, u := range users {
userByID[u.ID] = u
}
channelByID := map[int64]domain.Channel{}
if len(channelLists) > 0 {
for _, channel := range channelLists[0] {
channelByID[channel.ID] = channel
}
}
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, userByID, channelByID)
if msg.Body != "" {
if apiMediaUsesCaption(media) {
out["caption"] = msg.Body
} else if poll, ok := media["poll"].(map[string]any); ok {
poll["description"] = msg.Body
} else {
out["text"] = msg.Body
}
}
if entities := apiMessageEntities(msg.Entities, userByID); len(entities) > 0 {
if apiMediaUsesCaption(media) {
out["caption_entities"] = entities
} else if poll, ok := media["poll"].(map[string]any); ok && msg.Body != "" {
poll["description_entities"] = entities
} else {
out["entities"] = entities
}
}
if msg.RichMessage != nil && len(msg.RichMessage.BotAPIProjection) > 0 {
var richMessage any
if json.Unmarshal(msg.RichMessage.BotAPIProjection, &richMessage) == nil && richMessage != nil {
out["rich_message"] = richMessage
}
}
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 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:
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.Type == domain.MessageEntityBlockquote && entity.Collapsed {
item["type"] = "expandable_blockquote"
}
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)
}
if entity.Type == domain.MessageEntityFormattedDate {
item["unix_time"] = entity.Date
item["date_time_format"] = botAPIFormattedDateFormat(entity)
}
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
case domain.MessageEntityBankCard:
return "bank_card_number", true
case domain.MessageEntityFormattedDate:
return "date_time", true
default:
return "", false
}
}
func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
if markup.IsZero() {
return nil
}
// Bot API Message.reply_markup is InlineKeyboardMarkup only. ReplyKeyboardMarkup,
// ReplyKeyboardRemove and ForceReply are send parameters, not message response fields.
if markup.Kind() != domain.MessageReplyMarkupInline {
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}
if button.Style != "" {
item["style"] = string(button.Style)
}
if button.IconCustomEmojiID > 0 {
item["icon_custom_emoji_id"] = strconv.FormatInt(button.IconCustomEmojiID, 10)
}
switch button.Type {
case domain.MarkupButtonURL:
item["url"] = button.URL
case domain.MarkupButtonLoginURL:
login := map[string]any{"url": button.URL}
if button.ForwardText != "" {
login["forward_text"] = button.ForwardText
}
if button.LoginBotUsername != "" {
login["bot_username"] = button.LoginBotUsername
}
if button.RequestWriteAccess {
login["request_write_access"] = true
}
item["login_url"] = login
case domain.MarkupButtonCallback:
item["callback_data"] = string(button.Data)
case domain.MarkupButtonWebView:
item["web_app"] = map[string]any{"url": button.URL}
case domain.MarkupButtonSwitchInline:
switch {
case button.SamePeer:
item["switch_inline_query_current_chat"] = button.Query
case len(button.PeerTypes) > 0:
chosen := map[string]any{"query": button.Query}
for _, peerType := range button.PeerTypes {
switch peerType {
case store.InlineQueryPeerTypePM:
chosen["allow_user_chats"] = true
case store.InlineQueryPeerTypeBotPM:
chosen["allow_bot_chats"] = true
case store.InlineQueryPeerTypeChat, store.InlineQueryPeerTypeMegagroup:
chosen["allow_group_chats"] = true
case store.InlineQueryPeerTypeBroadcast:
chosen["allow_channel_chats"] = true
}
}
item["switch_inline_query_chosen_chat"] = chosen
default:
item["switch_inline_query"] = button.Query
}
case domain.MarkupButtonCopy:
item["copy_text"] = map[string]any{"text": button.CopyText}
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, users map[int64]domain.User, channels map[int64]domain.Channel) 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
}
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 apiDocumentMedia(*media.Document)
case domain.MessageMediaKindContact:
if media.Contact == nil {
return nil
}
contact := map[string]any{
"phone_number": media.Contact.PhoneNumber,
"first_name": media.Contact.FirstName,
}
if media.Contact.LastName != "" {
contact["last_name"] = media.Contact.LastName
}
if media.Contact.Vcard != "" {
contact["vcard"] = media.Contact.Vcard
}
if media.Contact.UserID != 0 {
contact["user_id"] = media.Contact.UserID
}
return map[string]any{"contact": contact}
case domain.MessageMediaKindGeo:
if media.Geo == nil {
return nil
}
return map[string]any{"location": apiLocation(*media.Geo, nil)}
case domain.MessageMediaKindVenue:
if media.Venue == nil {
return nil
}
return map[string]any{"venue": apiVenue(*media.Venue)}
case domain.MessageMediaKindGeoLive:
if media.GeoLive == nil {
return nil
}
return map[string]any{"location": apiLocation(media.GeoLive.Geo, media.GeoLive)}
case domain.MessageMediaKindPoll:
if media.Poll == nil {
return nil
}
return map[string]any{"poll": apiPoll(*media.Poll, users)}
case domain.MessageMediaKindService:
if media.ServiceAction == nil {
return nil
}
switch media.ServiceAction.Kind {
case domain.MessageServiceActionWebViewDataSent:
if media.ServiceAction.WebViewData == nil {
return nil
}
return map[string]any{"web_app_data": map[string]any{
"data": media.ServiceAction.WebViewData.Data, "button_text": media.ServiceAction.WebViewData.ButtonText,
}}
case domain.MessageServiceActionRequestedPeer:
return apiRequestedPeer(media.ServiceAction.RequestedPeer, users, channels)
default:
return nil
}
default:
return nil
}
}
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 {
out["horizontal_accuracy"] = float64(geo.AccuracyRadius)
}
if live != nil {
if live.Period > 0 {
out["live_period"] = live.Period
}
if live.Heading > 0 {
out["heading"] = live.Heading
}
if live.ProximityNotificationRadius > 0 {
out["proximity_alert_radius"] = live.ProximityNotificationRadius
}
}
return out
}
func apiVenue(venue domain.MessageVenue) map[string]any {
out := map[string]any{
"location": apiLocation(venue.Geo, nil), "title": venue.Title, "address": venue.Address,
}
switch strings.ToLower(venue.Provider) {
case "foursquare":
if venue.VenueID != "" {
out["foursquare_id"] = venue.VenueID
}
if venue.VenueType != "" {
out["foursquare_type"] = venue.VenueType
}
case "gplaces", "google":
if venue.VenueID != "" {
out["google_place_id"] = venue.VenueID
}
if venue.VenueType != "" {
out["google_place_type"] = venue.VenueType
}
}
return out
}
func apiPoll(poll domain.MessagePoll, users map[int64]domain.User) map[string]any {
resultByOption := make(map[string]domain.MessagePollAnswerVoters)
totalVoters := 0
if poll.Results != nil {
totalVoters = poll.Results.TotalVoters
for _, result := range poll.Results.Voters {
resultByOption[string(result.Option)] = result
}
}
options := make([]map[string]any, 0, len(poll.Answers))
correct := make([]int, 0, len(poll.Answers))
for index, answer := range poll.Answers {
persistentID := base64.RawURLEncoding.EncodeToString(answer.Option)
if persistentID == "" {
persistentID = strconv.Itoa(index)
}
result := resultByOption[string(answer.Option)]
option := map[string]any{
"persistent_id": persistentID, "text": answer.Text, "voter_count": result.Voters,
}
if entities := apiMessageEntities(answer.Entities, users); len(entities) > 0 {
option["text_entities"] = entities
}
if answer.Media != nil {
if projected := apiPollMedia(answer.Media); len(projected) > 0 {
option["media"] = projected
}
}
if result.Correct {
correct = append(correct, index)
}
options = append(options, option)
}
pollType := "regular"
if poll.Quiz {
pollType = "quiz"
}
out := map[string]any{
"id": strconv.FormatInt(poll.ID, 10), "question": poll.Question,
"options": options, "total_voter_count": totalVoters, "is_closed": poll.Closed,
"is_anonymous": !poll.PublicVoters, "type": pollType,
"allows_multiple_answers": poll.MultipleChoice, "allows_revoting": !poll.RevotingDisabled,
}
if entities := apiMessageEntities(poll.QuestionEntities, users); len(entities) > 0 {
out["question_entities"] = entities
}
if len(correct) > 0 {
out["correct_option_ids"] = correct
}
if poll.Results != nil && poll.Results.Solution != "" {
out["explanation"] = poll.Results.Solution
if entities := apiMessageEntities(poll.Results.SolutionEntities, users); len(entities) > 0 {
out["explanation_entities"] = entities
}
}
if poll.ClosePeriod > 0 {
out["open_period"] = poll.ClosePeriod
}
if poll.CloseDate > 0 {
out["close_date"] = poll.CloseDate
}
if poll.AttachedMedia != nil {
if projected := apiPollMedia(poll.AttachedMedia); len(projected) > 0 {
out["media"] = projected
}
}
return out
}
func apiPollMedia(media *domain.MessageMedia) map[string]any {
if media.IsZero() {
return nil
}
switch media.Kind {
case domain.MessageMediaKindPhoto:
if media.Photo != nil {
if sizes := apiPhotoSizes(*media.Photo); len(sizes) > 0 {
return map[string]any{"photo": sizes}
}
}
case domain.MessageMediaKindDocument:
if media.Document != nil {
return map[string]any{"document": apiDocument(*media.Document)}
}
case domain.MessageMediaKindGeo:
if media.Geo != nil {
return map[string]any{"location": apiLocation(*media.Geo, nil)}
}
case domain.MessageMediaKindVenue:
if media.Venue != nil {
return map[string]any{"venue": apiVenue(*media.Venue)}
}
}
return nil
}
func apiRequestedPeer(action *domain.MessageRequestedPeerAction, _ map[int64]domain.User, _ map[int64]domain.Channel) map[string]any {
if action == nil || action.ButtonID == 0 || len(action.Peers) == 0 {
return nil
}
allUsers := true
details := make(map[domain.Peer]domain.MessageRequestedPeerDetails, len(action.Details))
for _, detail := range action.Details {
details[detail.Peer] = detail
}
for _, peer := range action.Peers {
if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
return nil
}
allUsers = allUsers && peer.Type == domain.PeerTypeUser
}
if allUsers {
shared := make([]map[string]any, 0, len(action.Peers))
for _, peer := range action.Peers {
item := map[string]any{"user_id": peer.ID}
detail := details[peer]
if action.NameRequested {
if detail.FirstName != "" {
item["first_name"] = detail.FirstName
}
if detail.LastName != "" {
item["last_name"] = detail.LastName
}
}
if action.UsernameRequested && detail.Username != "" {
item["username"] = detail.Username
}
if action.PhotoRequested && detail.Photo != nil {
if photo := apiPhotoSizes(*detail.Photo); len(photo) > 0 {
item["photo"] = photo
}
}
shared = append(shared, item)
}
return map[string]any{"users_shared": map[string]any{"request_id": action.ButtonID, "users": shared}}
}
if len(action.Peers) != 1 || action.Peers[0].Type != domain.PeerTypeChannel {
return nil
}
peer := action.Peers[0]
shared := map[string]any{"request_id": action.ButtonID, "chat_id": -1000000000000 - peer.ID}
detail := details[peer]
if action.NameRequested && detail.Title != "" {
shared["title"] = detail.Title
}
if action.UsernameRequested && detail.Username != "" {
shared["username"] = detail.Username
}
if action.PhotoRequested && detail.Photo != nil {
if photo := apiPhotoSizes(*detail.Photo); len(photo) > 0 {
shared["photo"] = photo
}
}
return map[string]any{"chat_shared": shared}
}
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
}