package bots import ( "context" "strconv" "strings" "telesrv/internal/domain" ) // 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). // // offset/paging is not implemented: the catalog is bounded by // MaxGifCatalogEntries, which is MaxBotInlineResults, so one response always // carries all of it. // // 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 } entries = rankGifCatalogEntries(entries, query) if len(entries) == 0 { return domain.BotInlineResults{Gallery: true}, true, nil } 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. 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...) }