package botapi import ( "encoding/base64" "encoding/json" "errors" "strconv" "strings" "telesrv/internal/domain" ) func apiInt(raw string, fallback int) int { raw = strings.TrimSpace(raw) if raw == "" { return fallback } v, err := strconv.Atoi(raw) if err != nil { return fallback } return v } func botAPIMessageEntities(raw string) ([]domain.MessageEntity, error) { if strings.TrimSpace(raw) == "" { return nil, nil } var payload []apiMessageEntity if err := json.Unmarshal([]byte(raw), &payload); err != nil { return nil, errors.New("ENTITY_INVALID") } return messageEntitiesFromAPI(payload) } func allowedUpdates(raw string) map[string]struct{} { raw = strings.TrimSpace(raw) if raw == "" { return nil } var items []string if err := json.Unmarshal([]byte(raw), &items); err != nil { return nil } out := make(map[string]struct{}, len(items)) for _, item := range items { item = strings.TrimSpace(item) if item != "" { out[item] = struct{}{} } } return out } func apiUpdates(events []domain.UpdateEvent, allowed map[string]struct{}, limit int) []map[string]any { if limit <= 0 || limit > 100 { limit = 100 } out := make([]map[string]any, 0, min(len(events), limit)) for _, event := range events { item, kind, ok := apiUpdate(event) if !ok || !updateAllowed(kind, allowed) { continue } out = append(out, item) if len(out) >= limit { break } } if out == nil { return []map[string]any{} } return out } func updateAllowed(kind string, allowed map[string]struct{}) bool { if len(allowed) == 0 { return true } _, ok := allowed[kind] return ok } func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) { if event.Pts <= 0 { return nil, "", false } switch event.Type { case domain.UpdateEventNewMessage: if !apiMessageProjectable(event.Message) { return nil, "", false } return map[string]any{ "update_id": event.Pts, "message": apiMessage(event.Message, event.Users), }, "message", true case domain.UpdateEventEditMessage: if !apiMessageProjectable(event.Message) { return nil, "", false } return map[string]any{ "update_id": event.Pts, "edited_message": apiMessage(event.Message, event.Users), }, "edited_message", true default: return nil, "", false } } func apiMessageProjectable(msg domain.Message) bool { if msg.Out || msg.ID <= 0 { return false } return msg.Body != "" || len(apiMessageMedia(msg.Media)) > 0 } func apiUser(u domain.User) map[string]any { first := u.FirstName if strings.TrimSpace(first) == "" { first = "User " + strconv.FormatInt(u.ID, 10) } out := map[string]any{ "id": u.ID, "is_bot": u.Bot, "first_name": first, } if u.LastName != "" { out["last_name"] = u.LastName } if u.Username != "" { out["username"] = u.Username } return out } func apiMessage(msg domain.Message, users []domain.User) map[string]any { userByID := map[int64]domain.User{} for _, u := range users { userByID[u.ID] = u } out := map[string]any{ "message_id": msg.ID, "date": msg.Date, "chat": apiChat(msg.Peer, userByID), } if msg.From.Type == domain.PeerTypeUser && msg.From.ID != 0 { from := userByID[msg.From.ID] if from.ID == 0 { from = domain.User{ID: msg.From.ID} } if msg.Out && msg.From.ID == msg.OwnerUserID { from.Bot = true } out["from"] = apiUser(from) } media := apiMessageMedia(msg.Media) if msg.Body != "" { if len(media) > 0 { out["caption"] = msg.Body } else { out["text"] = msg.Body } } if entities := apiMessageEntities(msg.Entities, userByID); len(entities) > 0 { if len(media) > 0 { out["caption_entities"] = entities } else { out["entities"] = entities } } if msg.EditDate > 0 { out["edit_date"] = msg.EditDate } if msg.ReplyTo != nil && msg.ReplyTo.MessageID > 0 { out["reply_to_message"] = map[string]any{ "message_id": msg.ReplyTo.MessageID, "date": 0, "chat": apiChat(msg.ReplyTo.Peer, userByID), } } if markup := apiReplyMarkup(msg.ReplyMarkup); markup != nil { out["reply_markup"] = markup } if len(media) > 0 { for k, v := range media { out[k] = v } } return out } func apiChat(peer domain.Peer, users map[int64]domain.User) map[string]any { switch peer.Type { case domain.PeerTypeUser: out := map[string]any{ "id": peer.ID, "type": "private", } if u := users[peer.ID]; u.ID != 0 { out["first_name"] = apiUserFirstName(u) if u.LastName != "" { out["last_name"] = u.LastName } if u.Username != "" { out["username"] = u.Username } } return out case domain.PeerTypeChannel: return map[string]any{ "id": -1000000000000 - peer.ID, "type": "supergroup", } default: return map[string]any{ "id": peer.ID, "type": "private", } } } func apiUserFirstName(u domain.User) string { if strings.TrimSpace(u.FirstName) != "" { return u.FirstName } return "User " + strconv.FormatInt(u.ID, 10) } func apiMessageEntities(in []domain.MessageEntity, users map[int64]domain.User) []map[string]any { if len(in) == 0 { return nil } out := make([]map[string]any, 0, len(in)) for _, entity := range in { typ, ok := botAPIEntityType(entity.Type) if !ok || entity.Offset < 0 || entity.Length <= 0 { continue } item := map[string]any{ "type": typ, "offset": entity.Offset, "length": entity.Length, } if entity.URL != "" { item["url"] = entity.URL } if entity.Language != "" { item["language"] = entity.Language } if entity.UserID != 0 { u := users[entity.UserID] if u.ID == 0 { u = domain.User{ID: entity.UserID} } item["user"] = apiUser(u) } if entity.DocumentID != 0 { item["custom_emoji_id"] = strconv.FormatInt(entity.DocumentID, 10) } out = append(out, item) } return out } func botAPIEntityType(in domain.MessageEntityType) (string, bool) { switch in { case domain.MessageEntityBold: return "bold", true case domain.MessageEntityItalic: return "italic", true case domain.MessageEntityUnderline: return "underline", true case domain.MessageEntityStrike: return "strikethrough", true case domain.MessageEntityCode: return "code", true case domain.MessageEntityPre: return "pre", true case domain.MessageEntityTextURL: return "text_link", true case domain.MessageEntityMentionName: return "text_mention", true case domain.MessageEntitySpoiler: return "spoiler", true case domain.MessageEntityBlockquote: return "blockquote", true case domain.MessageEntityCustomEmoji: return "custom_emoji", true case domain.MessageEntityMention: return "mention", true case domain.MessageEntityHashtag: return "hashtag", true case domain.MessageEntityCashtag: return "cashtag", true case domain.MessageEntityBotCommand: return "bot_command", true case domain.MessageEntityURL: return "url", true case domain.MessageEntityEmail: return "email", true case domain.MessageEntityPhone: return "phone_number", true default: return "", false } } func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any { if markup.IsZero() { return nil } rows := make([][]map[string]any, 0, len(markup.Inline)) for _, row := range markup.Inline { if len(row) == 0 { continue } apiRow := make([]map[string]any, 0, len(row)) for _, button := range row { item := map[string]any{"text": button.Text} switch button.Type { case domain.MarkupButtonURL: item["url"] = button.URL case domain.MarkupButtonCallback: item["callback_data"] = string(button.Data) default: continue } apiRow = append(apiRow, item) } if len(apiRow) > 0 { rows = append(rows, apiRow) } } if len(rows) == 0 { return nil } return map[string]any{"inline_keyboard": rows} } func apiMessageMedia(media *domain.MessageMedia) map[string]any { if media.IsZero() { return nil } switch media.Kind { case domain.MessageMediaKindPhoto: if media.Photo == nil { return nil } photos := apiPhotoSizes(*media.Photo) if len(photos) == 0 { return nil } return map[string]any{"photo": photos} case domain.MessageMediaKindDocument: if media.Document == nil { return nil } return map[string]any{"document": apiDocument(*media.Document)} default: return nil } } func apiPhotoSizes(photo domain.Photo) []map[string]any { return apiPhotoSizesWithPrefix(photo.Sizes, "photo:"+strconv.FormatInt(photo.ID, 10)+":") } func apiPhotoSizesWithPrefix(sizes []domain.PhotoSize, locationPrefix string) []map[string]any { out := make([]map[string]any, 0, len(sizes)) for _, size := range sizes { if !size.Downloadable() || size.Type == "" { continue } fileID := encodeBotAPIFileID(locationPrefix + size.Type) item := map[string]any{ "file_id": fileID, "file_unique_id": fileID, "width": size.W, "height": size.H, } if size.Size > 0 { item["file_size"] = size.Size } out = append(out, item) } return out } func apiDocument(doc domain.Document) map[string]any { fileID := encodeBotAPIFileID("doc:" + strconv.FormatInt(doc.ID, 10)) out := map[string]any{ "file_id": fileID, "file_unique_id": fileID, } if doc.MimeType != "" { out["mime_type"] = doc.MimeType } if doc.Size > 0 { out["file_size"] = doc.Size } for _, attr := range doc.Attributes { if attr.Kind == domain.DocAttrFilename && strings.TrimSpace(attr.FileName) != "" { out["file_name"] = attr.FileName break } } if thumbs := apiPhotoSizesWithPrefix(doc.Thumbs, "doc:"+strconv.FormatInt(doc.ID, 10)+":"); len(thumbs) > 0 { out["thumbnail"] = thumbs[len(thumbs)-1] } return out } func encodeBotAPIFileID(locationKey string) string { return base64.RawURLEncoding.EncodeToString([]byte(locationKey)) } func decodeBotAPIFileID(fileID string) (string, bool) { fileID = strings.TrimSpace(fileID) if fileID == "" { return "", false } data, err := base64.RawURLEncoding.DecodeString(fileID) if err != nil { return "", false } locationKey := string(data) if strings.HasPrefix(locationKey, "doc:") || strings.HasPrefix(locationKey, "photo:") { return locationKey, true } return "", false }