owpengram-server/internal/app/bots/gifbot.go
2026-08-25 00:01:59 +03:00

189 lines
6.7 KiB
Go

package bots
import (
"context"
"strconv"
"strings"
"unicode"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/seed/catalog"
)
// HandlesInlineBot reports whether botUserID is a built-in bot this service
// answers messages.getInlineBotResults for synchronously
// (rpc.ServiceBotInlineResults). Deliberately separate from HandlesBot (see
// that interface's doc comment in internal/rpc/deps.go) -- @gif gets inline
// queries only, not private-message/callback dispatch.
func (s *Service) HandlesInlineBot(botUserID int64) bool {
return s != nil && botUserID == domain.GifBotUserID
}
// OnInlineQuery serves @gif's admin-curated catalog as inline gif results,
// ordered by title relevance to query (see rankGifCatalogEntries) or, for a
// category-icon tap, filtered to that category (see gifCategoryFromQuery).
//
// offset/paging is not implemented: filtering/ranking runs over the whole
// catalog (an operator's library can run into the thousands, see
// files.Service.ListGifCatalog's doc comment for why that fetch is
// deliberately unbounded), then the result is truncated to
// MaxGifCatalogEntries -- the real per-response cap the client enforces --
// so one response always carries as much of the *relevant* slice as fits,
// not an arbitrary (sort_order, id) prefix of the whole catalog.
//
// Note TDesktop only ever calls this with a non-empty query: its GIF tab has
// no trending panel, and GifsListWidget::searchForGifs returns early on an
// empty string (chat_helpers/gifs_list_widget.cpp), showing saved GIFs alone.
// An empty query is still handled here for clients that do ask for one.
func (s *Service) OnInlineQuery(ctx context.Context, botUserID, _ int64, query, _ string) (domain.BotInlineResults, bool, error) {
if s == nil || botUserID != domain.GifBotUserID {
return domain.BotInlineResults{}, false, nil
}
if s.gifCatalog == nil {
return domain.BotInlineResults{Gallery: true}, true, nil
}
entries, err := s.gifCatalog.ListGifCatalog(ctx, true)
if err != nil {
return domain.BotInlineResults{}, false, err
}
category := gifCategoryFromQuery(query)
if category != "" {
// A category-icon tap, not a typed word (see gifCategoryFromQuery) --
// filter by domain.GifCatalogEntry.Category instead of ranking by
// title, since the query is an emoji/emoji-blob a title never
// contains.
entries = filterGifCatalogEntriesByCategory(entries, category)
} else {
entries = rankGifCatalogEntries(entries, query)
}
if s.log != nil {
s.log.Info("gif inline query",
zap.String("query", query), zap.String("detected_category", category),
zap.Int("result_count", len(entries)))
}
if len(entries) == 0 {
return domain.BotInlineResults{Gallery: true}, true, nil
}
if len(entries) > domain.MaxGifCatalogEntries {
entries = entries[:domain.MaxGifCatalogEntries]
}
ids := make([]int64, len(entries))
for i, e := range entries {
ids[i] = e.DocumentID
}
docs, err := s.gifCatalog.GetDocuments(ctx, ids)
if err != nil {
return domain.BotInlineResults{}, false, err
}
byID := make(map[int64]domain.Document, len(docs))
for _, d := range docs {
byID[d.ID] = d
}
results := make([]domain.BotInlineResult, 0, len(entries))
for _, e := range entries {
doc, ok := byID[e.DocumentID]
if !ok {
// Catalog entry outlived its document (shouldn't happen -- documents
// are never deleted -- but skip rather than surface a broken result).
continue
}
docCopy := doc
results = append(results, domain.BotInlineResult{
ID: strconv.FormatInt(e.ID, 10),
Type: "gif",
Title: e.Title,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &docCopy},
})
}
return domain.BotInlineResults{Gallery: true, Results: results}, true, nil
}
// rankGifCatalogEntries orders title matches first but never drops the rest.
//
// A real @gif searches a huge third-party index, so "no match" there means the
// query genuinely found nothing. A self-hosted catalog is a handful of curated
// files instead: filtering it down to exact title matches would leave the
// picker empty for almost every word an operator's users type, which reads as
// "the feature is broken" rather than "no results". Showing the whole catalog
// with the closest titles first keeps every query useful while still honouring
// the search term. Order within each group stays the admin-set
// (sort_order, id) order the store already applied.
// gifCategoryFromQuery recognizes a GIF-picker category-icon tap and returns
// which domain.GifCatalogCategories entry it names, or "" for an ordinary
// typed search. Neither client sends the category's name -- both instead
// send the tapped group's own Emoticons back as the query, observed (see
// the "gif inline query" log line this handler emits) to differ only in
// whitespace between clients: Android sends them concatenated with no
// separator, TDesktop space-separated. Comparing with all whitespace
// stripped from the query handles both without caring which client asked,
// and is why this checks against internal/seed/catalog data directly (the
// same source messages.getEmojiGroups itself serves) rather than hardcoding
// either shape.
func gifCategoryFromQuery(query string) string {
stripped := stripWhitespace(query)
if stripped == "" {
return ""
}
groups, _ := catalog.EmojiGroups()
for _, g := range groups {
if len(g.Emoticons) == 0 {
continue
}
if strings.Join(g.Emoticons, "") == stripped {
return g.Title
}
}
for _, g := range groups {
for _, e := range g.Emoticons {
if e == stripped {
return g.Title
}
}
}
return ""
}
func stripWhitespace(s string) string {
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) {
return -1
}
return r
}, s)
}
// filterGifCatalogEntriesByCategory keeps only entries tagged with category,
// falling back to the full (enabled) catalog if none are tagged yet -- an
// operator who hasn't categorized anything should still see every GIF on a
// category tap, not an empty picker.
func filterGifCatalogEntriesByCategory(entries []domain.GifCatalogEntry, category string) []domain.GifCatalogEntry {
filtered := make([]domain.GifCatalogEntry, 0, len(entries))
for _, e := range entries {
if e.Category == category {
filtered = append(filtered, e)
}
}
if len(filtered) == 0 {
return entries
}
return filtered
}
func rankGifCatalogEntries(entries []domain.GifCatalogEntry, query string) []domain.GifCatalogEntry {
query = strings.TrimSpace(strings.ToLower(query))
if query == "" {
return entries
}
matched := make([]domain.GifCatalogEntry, 0, len(entries))
rest := make([]domain.GifCatalogEntry, 0, len(entries))
for _, e := range entries {
if strings.Contains(strings.ToLower(e.Title), query) {
matched = append(matched, e)
} else {
rest = append(rest, e)
}
}
return append(matched, rest...)
}