owpengram-server/internal/botapi/projection.go
A bf965f610c feat: sync bot keyboards and callbacks
Sync telesrv b96f2dd (feat(bot): complete keyboards callbacks and durable delivery).

Skipped private docs and preserved public README files per sync rules; normalized the appearance seed log label for public naming.
2026-07-19 20:38:48 +08:00

810 lines
23 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) {
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, event.Channels),
}, "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, 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 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": event.Pts,
"callback_query": query,
}, "callback_query", true
default:
return nil, "", false
}
}
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 != "" || 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 _, photo := media["photo"]; photo {
out["caption"] = msg.Body
} else if _, document := media["document"]; document {
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 _, photo := media["photo"]; photo {
out["caption_entities"] = entities
} else if _, document := media["document"]; document {
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.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
}
// 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.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
}
return map[string]any{"photo": photos}
case domain.MessageMediaKindDocument:
if media.Document == nil {
return nil
}
return map[string]any{"document": apiDocument(*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 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
}