From d0669bdc5efe5683dccf3638aa98f914608458c8 Mon Sep 17 00:00:00 2001 From: onysd Date: Mon, 24 Aug 2026 23:09:08 +0300 Subject: [PATCH] categorisation for gifs --- cmd/telesrv-admin/readstore.go | 5 +- cmd/telesrv-admin/server.go | 41 ++++++ .../web/src/pages/GifCatalogPage.tsx | 39 +++++- cmd/telesrv-admin/web/src/types.ts | 9 ++ ...260824000000_gif_catalog_category.down.sql | 2 + ...20260824000000_gif_catalog_category.up.sql | 9 ++ internal/admin/service.go | 124 ++++++++++++------ internal/adminapi/server.go | 22 ++++ internal/adminapi/server_test.go | 8 ++ internal/app/bots/gifbot.go | 71 +++++++++- internal/app/files/gif_admin.go | 53 ++++++++ internal/app/files/gif_classify.go | 85 ++++++++++++ internal/domain/gif_catalog.go | 26 ++++ internal/store/gif_catalog.go | 3 + internal/store/postgres/gif_catalog.go | 20 ++- 15 files changed, 468 insertions(+), 49 deletions(-) create mode 100644 deploy/migrations/20260824000000_gif_catalog_category.down.sql create mode 100644 deploy/migrations/20260824000000_gif_catalog_category.up.sql create mode 100644 internal/app/files/gif_classify.go diff --git a/cmd/telesrv-admin/readstore.go b/cmd/telesrv-admin/readstore.go index ce258ee1..df718903 100644 --- a/cmd/telesrv-admin/readstore.go +++ b/cmd/telesrv-admin/readstore.go @@ -290,13 +290,14 @@ type GifCatalogRow struct { DocumentID int64 `json:"DocumentID,string"` Enabled bool SortOrder int + Category string CreatedBy string CreatedAt time.Time } func (s *readStore) ListGifCatalog(ctx context.Context) ([]GifCatalogRow, error) { rows, err := s.pool.Query(ctx, ` -SELECT id, title, document_id, enabled, sort_order, created_by, created_at +SELECT id, title, document_id, enabled, sort_order, category, created_by, created_at FROM gif_catalog ORDER BY sort_order, id`) if err != nil { @@ -306,7 +307,7 @@ ORDER BY sort_order, id`) out := make([]GifCatalogRow, 0) for rows.Next() { var item GifCatalogRow - if err := rows.Scan(&item.ID, &item.Title, &item.DocumentID, &item.Enabled, &item.SortOrder, &item.CreatedBy, &item.CreatedAt); err != nil { + if err := rows.Scan(&item.ID, &item.Title, &item.DocumentID, &item.Enabled, &item.SortOrder, &item.Category, &item.CreatedBy, &item.CreatedAt); err != nil { return nil, err } out = append(out, item) diff --git a/cmd/telesrv-admin/server.go b/cmd/telesrv-admin/server.go index 8e5abf40..be8c4eca 100644 --- a/cmd/telesrv-admin/server.go +++ b/cmd/telesrv-admin/server.go @@ -124,6 +124,8 @@ func (s *server) routes() http.Handler { mux.Handle("POST /api/actions/create-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleCreateGifCatalogEntryAPI))) mux.Handle("POST /api/actions/set-gif-catalog-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogEnabledAPI))) mux.Handle("POST /api/actions/set-gif-catalog-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogSortOrderAPI))) + mux.Handle("POST /api/actions/set-gif-catalog-category", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogCategoryAPI))) + mux.Handle("POST /api/actions/auto-categorize-gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleAutoCategorizeGifCatalogAPI))) mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI))) mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI))) mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI))) @@ -1920,6 +1922,45 @@ func (s *server) handleSetGifCatalogSortOrderAPI(w http.ResponseWriter, r *http. writeCommandResultAPI(w, result, err) } +type setGifCatalogCategoryAPIRequest struct { + CommandID string `json:"command_id"` + Reason string `json:"reason"` + Confirm bool `json:"confirm"` + ID int64 `json:"id,string"` + Category string `json:"category"` +} + +func (s *server) handleSetGifCatalogCategoryAPI(w http.ResponseWriter, r *http.Request) { + var body setGifCatalogCategoryAPIRequest + if !decodeAction(w, r, &body) { + return + } + req := admin.SetGifCatalogCategoryRequest{ + CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-gif-catalog-category"), + ID: body.ID, Category: body.Category, + } + result, err := s.callAdminAPI(r.Context(), "/v1/gif-catalog/set-category", req) + writeCommandResultAPI(w, result, err) +} + +type autoCategorizeGifCatalogAPIRequest struct { + CommandID string `json:"command_id"` + Reason string `json:"reason"` + Confirm bool `json:"confirm"` +} + +func (s *server) handleAutoCategorizeGifCatalogAPI(w http.ResponseWriter, r *http.Request) { + var body autoCategorizeGifCatalogAPIRequest + if !decodeAction(w, r, &body) { + return + } + req := admin.AutoCategorizeGifCatalogRequest{ + CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "auto-categorize-gif-catalog"), + } + result, err := s.callAdminAPI(r.Context(), "/v1/gif-catalog/auto-categorize", req) + writeCommandResultAPI(w, result, err) +} + type deleteGifCatalogEntryAPIRequest struct { CommandID string `json:"command_id"` Reason string `json:"reason"` diff --git a/cmd/telesrv-admin/web/src/pages/GifCatalogPage.tsx b/cmd/telesrv-admin/web/src/pages/GifCatalogPage.tsx index 6e5ad5dc..4e7377ba 100644 --- a/cmd/telesrv-admin/web/src/pages/GifCatalogPage.tsx +++ b/cmd/telesrv-admin/web/src/pages/GifCatalogPage.tsx @@ -4,7 +4,7 @@ import { createPortal } from "react-dom"; import { api, errorMessage } from "../api"; import { ActionButton } from "../components/ActionButton"; import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; -import type { GifCatalogRow } from "../types"; +import { GIF_CATALOG_CATEGORIES, type GifCatalogRow } from "../types"; type GifPageSize = 10 | 20 | 50 | 100 | "all"; @@ -45,6 +45,7 @@ export function GifCatalogPage() { const [pageSize, setPageSize] = useState(10); const [page, setPage] = useState(1); const [orderDrafts, setOrderDrafts] = useState>({}); + const [categoryDrafts, setCategoryDrafts] = useState>({}); const [createOpen, setCreateOpen] = useState(false); async function load() { @@ -83,7 +84,8 @@ export function GifCatalogPage() { const counts = useMemo(() => ({ total: rows.length, - enabled: rows.filter((row) => row.Enabled).length + enabled: rows.filter((row) => row.Enabled).length, + uncategorized: rows.filter((row) => !row.Category).length }), [rows]); return ( @@ -95,6 +97,13 @@ export function GifCatalogPage() { + ({})} + onDone={() => void load()} + /> @@ -105,6 +114,7 @@ export function GifCatalogPage() {
+ 0 ? "warn" : undefined} />
@@ -138,6 +148,7 @@ export function GifCatalogPage() { {"Document ID"} {"Added by"} {"Status"} + {"Category"} {"Sort order"} {"Actions"} @@ -151,6 +162,28 @@ export function GifCatalogPage() { {row.DocumentID} {row.CreatedBy || {"—"}} {row.Enabled ? {"Enabled"} : {"Disabled"}} + +
+ + ({ id: row.ID, category: categoryDrafts[row.ID] ?? row.Category })} + onDone={() => void load()} + /> +
+
))} - {paged.length === 0 && } + {paged.length === 0 && }
diff --git a/cmd/telesrv-admin/web/src/types.ts b/cmd/telesrv-admin/web/src/types.ts index d3450b46..fdcb5835 100644 --- a/cmd/telesrv-admin/web/src/types.ts +++ b/cmd/telesrv-admin/web/src/types.ts @@ -636,6 +636,14 @@ export type StickerSetRow = { export type StickerSetListResponse = { rows: StickerSetRow[] }; +// GifCatalogCategories mirrors domain.GifCatalogCategories -- the titles +// internal/seed/catalog/emoji_groups.json uses for the GIF picker's category +// icons. "" (rendered as "Uncategorized") is always a valid value too. +export const GIF_CATALOG_CATEGORIES = [ + "Love", "Approval", "Disapproval", "Cheers", "Laughter", + "Astonishment", "Sadness", "Anger", "Neutral", "Doubt", "Silly", +]; + export type GifCatalogRow = { // String, not number: 18-19 digit snowflake ids, past JS's 2^53 // safe-integer limit. @@ -644,6 +652,7 @@ export type GifCatalogRow = { DocumentID: string; Enabled: boolean; SortOrder: number; + Category: string; CreatedBy: string; CreatedAt: string; }; diff --git a/deploy/migrations/20260824000000_gif_catalog_category.down.sql b/deploy/migrations/20260824000000_gif_catalog_category.down.sql new file mode 100644 index 00000000..46b4aede --- /dev/null +++ b/deploy/migrations/20260824000000_gif_catalog_category.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS public.gif_catalog_category_idx; +ALTER TABLE public.gif_catalog DROP COLUMN IF EXISTS category; diff --git a/deploy/migrations/20260824000000_gif_catalog_category.up.sql b/deploy/migrations/20260824000000_gif_catalog_category.up.sql new file mode 100644 index 00000000..6f47de2a --- /dev/null +++ b/deploy/migrations/20260824000000_gif_catalog_category.up.sql @@ -0,0 +1,9 @@ +-- Tags a gif_catalog entry with one of the messages.getEmojiGroups category +-- titles (see internal/seed/catalog/emoji_groups.json) so tapping a category +-- icon in the client's GIF picker can filter to that emotion instead of +-- always showing the whole catalog (see files.ClassifyGifCategory). Empty +-- means uncategorized -- still served by plain text search, just not by any +-- category tap. +ALTER TABLE public.gif_catalog ADD COLUMN category text DEFAULT ''::text NOT NULL; + +CREATE INDEX gif_catalog_category_idx ON public.gif_catalog (category) WHERE category <> ''; diff --git a/internal/admin/service.go b/internal/admin/service.go index 4c46c2c5..da600770 100644 --- a/internal/admin/service.go +++ b/internal/admin/service.go @@ -21,43 +21,45 @@ import ( ) const ( - ActionSetAccountFrozen = "account.set_frozen" - ActionGrantPremium = "account.grant_premium" - ActionSetVerified = "account.set_verified" - ActionSetUserFlags = "account.set_flags" - ActionSetSupport = "account.set_support" - ActionSetUsername = "account.set_username" - ActionSetUserColor = "account.set_color" - ActionSetUserEmojiStatus = "account.set_emoji_status" - ActionSetProfile = "account.set_profile" - ActionSetPhone = "account.set_phone" - ActionSetLoginEmail = "account.set_login_email" - ActionSetAccountAvatar = "account.set_avatar" - ActionSetChannelAvatar = "channel.set_avatar" - ActionSetChannelUsername = "channel.set_username" - ActionSetChannelSettings = "channel.set_settings" - ActionSetChannelColor = "channel.set_color" - ActionSetChannelEmojiStatus = "channel.set_emoji_status" - ActionSetChannelVerified = "channel.set_verified" - ActionSetChannelFlags = "channel.set_flags" - ActionRevokeSessions = "account.revoke_sessions" - ActionDeletePrivateMessages = "messages.delete_private_messages" - ActionDeletePrivateHistory = "messages.delete_private_history" - ActionCreateBot = "bot.create" - ActionCreateBroadcast = "broadcast.create" - ActionDeleteBot = "bot.delete" - ActionExportBotToken = "bot.export_token" - ActionSetStickerSetArchived = "stickers.set_archived" - ActionSetStickerSetSortOrder = "stickers.set_sort_order" - ActionRenameStickerSet = "stickers.rename" - ActionDeleteStickerSet = "stickers.delete" - ActionCreateStickerSet = "stickers.create" - ActionAddStickerToSet = "stickers.add_sticker" - ActionRemoveStickerFromSet = "stickers.remove_sticker" - ActionCreateGifCatalogEntry = "gif_catalog.create" - ActionSetGifCatalogEnabled = "gif_catalog.set_enabled" - ActionSetGifCatalogSortOrder = "gif_catalog.set_sort_order" - ActionDeleteGifCatalogEntry = "gif_catalog.delete" + ActionSetAccountFrozen = "account.set_frozen" + ActionGrantPremium = "account.grant_premium" + ActionSetVerified = "account.set_verified" + ActionSetUserFlags = "account.set_flags" + ActionSetSupport = "account.set_support" + ActionSetUsername = "account.set_username" + ActionSetUserColor = "account.set_color" + ActionSetUserEmojiStatus = "account.set_emoji_status" + ActionSetProfile = "account.set_profile" + ActionSetPhone = "account.set_phone" + ActionSetLoginEmail = "account.set_login_email" + ActionSetAccountAvatar = "account.set_avatar" + ActionSetChannelAvatar = "channel.set_avatar" + ActionSetChannelUsername = "channel.set_username" + ActionSetChannelSettings = "channel.set_settings" + ActionSetChannelColor = "channel.set_color" + ActionSetChannelEmojiStatus = "channel.set_emoji_status" + ActionSetChannelVerified = "channel.set_verified" + ActionSetChannelFlags = "channel.set_flags" + ActionRevokeSessions = "account.revoke_sessions" + ActionDeletePrivateMessages = "messages.delete_private_messages" + ActionDeletePrivateHistory = "messages.delete_private_history" + ActionCreateBot = "bot.create" + ActionCreateBroadcast = "broadcast.create" + ActionDeleteBot = "bot.delete" + ActionExportBotToken = "bot.export_token" + ActionSetStickerSetArchived = "stickers.set_archived" + ActionSetStickerSetSortOrder = "stickers.set_sort_order" + ActionRenameStickerSet = "stickers.rename" + ActionDeleteStickerSet = "stickers.delete" + ActionCreateStickerSet = "stickers.create" + ActionAddStickerToSet = "stickers.add_sticker" + ActionRemoveStickerFromSet = "stickers.remove_sticker" + ActionCreateGifCatalogEntry = "gif_catalog.create" + ActionSetGifCatalogEnabled = "gif_catalog.set_enabled" + ActionSetGifCatalogSortOrder = "gif_catalog.set_sort_order" + ActionSetGifCatalogCategory = "gif_catalog.set_category" + ActionAutoCategorizeGifCatalog = "gif_catalog.auto_categorize" + ActionDeleteGifCatalogEntry = "gif_catalog.delete" // Collectible (Fragment-style) username lifecycle. ActionMintCollectibleUsername = "usernames.collectible.mint" ActionTransferCollectibleUsername = "usernames.collectible.transfer" @@ -305,6 +307,11 @@ type GifCatalogService interface { AdminListGifCatalog(ctx context.Context) ([]domain.GifCatalogEntry, error) AdminSetGifCatalogEnabled(ctx context.Context, id int64, enabled bool) (bool, error) AdminSetGifCatalogSortOrder(ctx context.Context, id int64, order int) (bool, error) + AdminSetGifCatalogCategory(ctx context.Context, id int64, category string) (bool, error) + // AdminAutoCategorizeGifCatalog runs files.ClassifyGifCategory over every + // currently-uncategorized entry's title and returns how many got a + // category assigned. + AdminAutoCategorizeGifCatalog(ctx context.Context) (int, error) AdminDeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error) } @@ -700,6 +707,16 @@ type SetGifCatalogSortOrderRequest struct { SortOrder int `json:"sort_order"` } +type SetGifCatalogCategoryRequest struct { + CommandMeta + ID int64 `json:"id"` + Category string `json:"category"` +} + +type AutoCategorizeGifCatalogRequest struct { + CommandMeta +} + type DeleteGifCatalogEntryRequest struct { CommandMeta ID int64 `json:"id"` @@ -2847,6 +2864,39 @@ func (s *Service) SetGifCatalogSortOrder(ctx context.Context, req SetGifCatalogS }) } +func (s *Service) SetGifCatalogCategory(ctx context.Context, req SetGifCatalogCategoryRequest) (CommandResult, error) { + if s == nil || s.gifCatalog == nil || req.ID <= 0 { + return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required") + } + if !domain.ValidGifCatalogCategory(req.Category) { + return CommandResult{}, domain.ErrGifCatalogEntryInvalid + } + return s.runCommand(ctx, req.CommandMeta, ActionSetGifCatalogCategory, 0, domain.Peer{}, req, func() (CommandResult, error) { + details := map[string]any{"id": strconv.FormatInt(req.ID, 10), "category": req.Category} + if req.DryRun { + return CommandResult{Message: "gif catalog entry category change validated", Details: details}, nil + } + changed, err := s.gifCatalog.AdminSetGifCatalogCategory(ctx, req.ID, req.Category) + details["changed"] = changed + return CommandResult{Message: "gif catalog entry category updated", Details: details}, err + }) +} + +func (s *Service) AutoCategorizeGifCatalog(ctx context.Context, req AutoCategorizeGifCatalogRequest) (CommandResult, error) { + if s == nil || s.gifCatalog == nil { + return CommandResult{}, fmt.Errorf("gif catalog service is not configured") + } + return s.runCommand(ctx, req.CommandMeta, ActionAutoCategorizeGifCatalog, 0, domain.Peer{}, req, func() (CommandResult, error) { + details := map[string]any{} + if req.DryRun { + return CommandResult{Message: "gif catalog auto-categorize validated", Details: details}, nil + } + count, err := s.gifCatalog.AdminAutoCategorizeGifCatalog(ctx) + details["categorized"] = count + return CommandResult{Message: "gif catalog auto-categorized", Details: details}, err + }) +} + func (s *Service) DeleteGifCatalogEntry(ctx context.Context, req DeleteGifCatalogEntryRequest) (CommandResult, error) { if s == nil || s.gifCatalog == nil || req.ID <= 0 { return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required") diff --git a/internal/adminapi/server.go b/internal/adminapi/server.go index cfdc2202..581ec79c 100644 --- a/internal/adminapi/server.go +++ b/internal/adminapi/server.go @@ -79,6 +79,8 @@ type Service interface { CreateGifCatalogEntry(ctx context.Context, req admin.CreateGifCatalogEntryRequest) (admin.CommandResult, error) SetGifCatalogEnabled(ctx context.Context, req admin.SetGifCatalogEnabledRequest) (admin.CommandResult, error) SetGifCatalogSortOrder(ctx context.Context, req admin.SetGifCatalogSortOrderRequest) (admin.CommandResult, error) + SetGifCatalogCategory(ctx context.Context, req admin.SetGifCatalogCategoryRequest) (admin.CommandResult, error) + AutoCategorizeGifCatalog(ctx context.Context, req admin.AutoCategorizeGifCatalogRequest) (admin.CommandResult, error) DeleteGifCatalogEntry(ctx context.Context, req admin.DeleteGifCatalogEntryRequest) (admin.CommandResult, error) EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error) ModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error) @@ -211,6 +213,8 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("POST /v1/gif-catalog/create", s.authenticated(s.handleCreateGifCatalogEntry)) mux.HandleFunc("POST /v1/gif-catalog/set-enabled", s.authenticated(s.handleSetGifCatalogEnabled)) mux.HandleFunc("POST /v1/gif-catalog/set-sort-order", s.authenticated(s.handleSetGifCatalogSortOrder)) + mux.HandleFunc("POST /v1/gif-catalog/set-category", s.authenticated(s.handleSetGifCatalogCategory)) + mux.HandleFunc("POST /v1/gif-catalog/auto-categorize", s.authenticated(s.handleAutoCategorizeGifCatalog)) mux.HandleFunc("POST /v1/gif-catalog/delete", s.authenticated(s.handleDeleteGifCatalogEntry)) mux.HandleFunc("GET /v1/stickers/documents/{id}/animation", s.authenticated(s.handleStickerDocumentAnimation)) mux.HandleFunc("GET /v1/gif-catalog/documents/{id}/preview", s.authenticated(s.handleGifCatalogDocumentPreview)) @@ -755,6 +759,24 @@ func (s *Server) handleSetGifCatalogSortOrder(w http.ResponseWriter, r *http.Req writeCommandResult(w, result, err) } +func (s *Server) handleSetGifCatalogCategory(w http.ResponseWriter, r *http.Request) { + var req admin.SetGifCatalogCategoryRequest + if !decodeJSON(w, r, &req) { + return + } + result, err := s.svc.SetGifCatalogCategory(r.Context(), req) + writeCommandResult(w, result, err) +} + +func (s *Server) handleAutoCategorizeGifCatalog(w http.ResponseWriter, r *http.Request) { + var req admin.AutoCategorizeGifCatalogRequest + if !decodeJSON(w, r, &req) { + return + } + result, err := s.svc.AutoCategorizeGifCatalog(r.Context(), req) + writeCommandResult(w, result, err) +} + func (s *Server) handleDeleteGifCatalogEntry(w http.ResponseWriter, r *http.Request) { var req admin.DeleteGifCatalogEntryRequest if !decodeJSON(w, r, &req) { diff --git a/internal/adminapi/server_test.go b/internal/adminapi/server_test.go index f20a8157..9333ce24 100644 --- a/internal/adminapi/server_test.go +++ b/internal/adminapi/server_test.go @@ -460,6 +460,14 @@ func (fakeService) SetGifCatalogSortOrder(_ context.Context, req admin.SetGifCat return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil } +func (fakeService) SetGifCatalogCategory(_ context.Context, req admin.SetGifCatalogCategoryRequest) (admin.CommandResult, error) { + return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil +} + +func (fakeService) AutoCategorizeGifCatalog(_ context.Context, req admin.AutoCategorizeGifCatalogRequest) (admin.CommandResult, error) { + return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil +} + func (fakeService) DeleteGifCatalogEntry(_ context.Context, req admin.DeleteGifCatalogEntryRequest) (admin.CommandResult, error) { return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil } diff --git a/internal/app/bots/gifbot.go b/internal/app/bots/gifbot.go index 30f871c4..0443511d 100644 --- a/internal/app/bots/gifbot.go +++ b/internal/app/bots/gifbot.go @@ -6,6 +6,7 @@ import ( "strings" "telesrv/internal/domain" + "telesrv/internal/seed/catalog" ) // HandlesInlineBot reports whether botUserID is a built-in bot this service @@ -39,7 +40,15 @@ func (s *Service) OnInlineQuery(ctx context.Context, botUserID, _ int64, query, if err != nil { return domain.BotInlineResults{}, false, err } - entries = rankGifCatalogEntries(entries, query) + if category := gifCategoryFromQuery(query); 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 len(entries) == 0 { return domain.BotInlineResults{Gallery: true}, true, nil } @@ -84,6 +93,66 @@ func (s *Service) OnInlineQuery(ctx context.Context, botUserID, _ int64, query, // 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. +// +// The two clients encode a tap completely differently, and neither sends the +// category's name: +// - Android (StickerCategoriesListView.EmojiCategory.remote, EmojiView.java) +// concatenates the whole tapped group's emoticons with no separator and +// sends that as the query -- an exact match against +// strings.Join(group.Emoticons, ""). +// - TDesktop (GifSectionsValue, stickers_list_footer.cpp) reads a *separate* +// fixed emoji list (app config's gif_search_emojies, defaulting to 10 +// emoji including some this server never configured) and sends the +// single tapped emoji as the query -- an exact match against one +// Emoticons entry. +// +// Both are checked against the same internal/seed/catalog data (the source of +// truth messages.getEmojiGroups itself serves), so no client-side changes or +// server-side emoji-list duplication are needed. +func gifCategoryFromQuery(query string) string { + query = strings.TrimSpace(query) + if query == "" { + return "" + } + groups, _ := catalog.EmojiGroups() + for _, g := range groups { + if len(g.Emoticons) == 0 { + continue + } + if strings.Join(g.Emoticons, "") == query { + return g.Title + } + } + for _, g := range groups { + for _, e := range g.Emoticons { + if e == query { + return g.Title + } + } + } + return "" +} + +// 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 == "" { diff --git a/internal/app/files/gif_admin.go b/internal/app/files/gif_admin.go index 3a9a6731..a0056248 100644 --- a/internal/app/files/gif_admin.go +++ b/internal/app/files/gif_admin.go @@ -190,6 +190,59 @@ func (s *Service) AdminSetGifCatalogSortOrder(ctx context.Context, id int64, ord return true, nil } +// AdminSetGifCatalogCategory sets (or clears, via "") an entry's category. +func (s *Service) AdminSetGifCatalogCategory(ctx context.Context, id int64, category string) (bool, error) { + if s.gifCatalog == nil { + return false, domain.ErrGifCatalogUnavailable + } + if !domain.ValidGifCatalogCategory(category) { + return false, domain.ErrGifCatalogEntryInvalid + } + changed, err := s.gifCatalog.SetGifCatalogCategory(ctx, id, category) + if err != nil { + return false, err + } + if !changed { + return false, domain.ErrGifCatalogEntryNotFound + } + return true, nil +} + +// AdminAutoCategorizeGifCatalog runs ClassifyGifCategory against every +// currently-uncategorized entry's title and assigns whatever category it +// guesses (category stays "" -- i.e. the entry is left for manual tagging -- +// when nothing matches). Already-categorized entries are left untouched, so +// this is safe to re-run after every new batch of GIFs lands (seeded or +// admin-uploaded) without clobbering an operator's manual corrections. +// Returns how many entries got a category assigned. +func (s *Service) AdminAutoCategorizeGifCatalog(ctx context.Context) (int, error) { + if s.gifCatalog == nil { + return 0, domain.ErrGifCatalogUnavailable + } + entries, err := s.gifCatalog.ListGifCatalog(ctx, false) + if err != nil { + return 0, err + } + changed := 0 + for _, e := range entries { + if e.Category != "" { + continue + } + category := ClassifyGifCategory(e.Title) + if category == "" { + continue + } + ok, err := s.gifCatalog.SetGifCatalogCategory(ctx, e.ID, category) + if err != nil { + return changed, err + } + if ok { + changed++ + } + } + return changed, nil +} + // AdminDeleteGifCatalogEntry removes an entry from the catalog. The // referenced document is left alone. func (s *Service) AdminDeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error) { diff --git a/internal/app/files/gif_classify.go b/internal/app/files/gif_classify.go new file mode 100644 index 00000000..7e5ce0d6 --- /dev/null +++ b/internal/app/files/gif_classify.go @@ -0,0 +1,85 @@ +package files + +import "strings" + +// gifCategoryKeywords maps each domain.GifCatalogCategories entry to a list +// of lowercase words/phrases whose presence in a GIF title is decent +// (imperfect, but reversible -- an operator can always correct it in the +// admin panel) evidence the GIF belongs to that emotional category. Checked +// in this order, first match wins, so more specific/less ambiguous +// categories are listed before catch-alls like Neutral/Silly. +var gifCategoryKeywords = map[string][]string{ + "Love": { + "love", "heart", "kiss", "kissing", "crush", "romance", "romantic", + "valentine", "adorable", "hug", "hugging", "xoxo", "sweetheart", + }, + "Cheers": { + "party", "celebrate", "celebration", "celebrating", "woohoo", "cheers", + "congrats", "congratulations", "dance", "dancing", "yay", "hooray", + "victory", "champagne", "toast", "birthday", "win", "winning", + }, + "Laughter": { + "laugh", "laughing", "lol", "lmao", "haha", "hilarious", "funny", + "joke", "giggle", "chuckle", "rofl", "lulz", + }, + "Astonishment": { + "shock", "shocked", "shocking", "omg", "wow", "surprised", "surprise", + "gasp", "whoa", "unbelievable", "stunned", "speechless", "mindblown", + "mind blown", "jawdrop", + }, + "Sadness": { + "sad", "sadness", "cry", "crying", "cries", "tears", "depressed", + "heartbroken", "sorrow", "sob", "sobbing", "disappointed", "unhappy", + }, + "Anger": { + "angry", "anger", "mad", "rage", "furious", "pissed", "annoyed", + "irritated", "punch", "smash", "yell", "yelling", "scream", "fist", + }, + "Disapproval": { + "disgusting", "disgusted", "gross", "eww", "yuck", "ugh", "cringe", + "boo", "nope", "fail", + }, + "Approval": { + "agree", "thumbsup", "thumbs up", "nailed it", "well done", + "applause", "clap", "clapping", "bravo", "approved", "salute", + "respect", "nice one", + }, + "Doubt": { + "confused", "confusion", "suspicious", "skeptical", "hmm", "hmmm", + "thinking", "uncertain", "unsure", "really", + }, + "Silly": { + "silly", "goofy", "derp", "wacky", "ridiculous", "nonsense", + }, + "Neutral": { + "meh", "whatever", "shrug", "indifferent", "blank stare", "boring", + }, +} + +// ClassifyGifCategory guesses one of domain.GifCatalogCategories from a GIF's +// title via keyword matching, or "" if nothing matches. See +// gifCategoryKeywords for the word lists and Service.AdminAutoCategorizeGifCatalog +// for how this gets applied in bulk. +// +// This is deliberately simple substring matching, not NLP: real GIF titles +// range from descriptive ("Sad Tenant") to meaningless filename fragments +// ("jIDL92q") that no text-based heuristic can classify -- those are left +// uncategorized for an operator to tag by hand rather than guessed wrong. +func ClassifyGifCategory(title string) string { + lower := strings.ToLower(title) + for _, category := range gifCategoryOrder { + for _, kw := range gifCategoryKeywords[category] { + if strings.Contains(lower, kw) { + return category + } + } + } + return "" +} + +// gifCategoryOrder is gifCategoryKeywords' check order (Go map iteration is +// unordered, and the order is meaningful -- see gifCategoryKeywords' doc). +var gifCategoryOrder = []string{ + "Love", "Cheers", "Laughter", "Astonishment", "Sadness", "Anger", + "Disapproval", "Approval", "Doubt", "Silly", "Neutral", +} diff --git a/internal/domain/gif_catalog.go b/internal/domain/gif_catalog.go index a33fc0fe..7a098392 100644 --- a/internal/domain/gif_catalog.go +++ b/internal/domain/gif_catalog.go @@ -42,6 +42,29 @@ const ( MaxGifCatalogUploadSize = 50 << 20 ) +// GifCatalogCategories are the valid values for GifCatalogEntry.Category -- +// exactly the titles internal/seed/catalog/emoji_groups.json uses, so a +// category tap in the client's GIF picker (see files.ClassifyGifCategory and +// bots.rankGifCatalogEntries) maps onto them with no translation step. +var GifCatalogCategories = []string{ + "Love", "Approval", "Disapproval", "Cheers", "Laughter", + "Astonishment", "Sadness", "Anger", "Neutral", "Doubt", "Silly", +} + +// ValidGifCatalogCategory reports whether category is "" (uncategorized) or +// one of GifCatalogCategories. +func ValidGifCatalogCategory(category string) bool { + if category == "" { + return true + } + for _, c := range GifCatalogCategories { + if c == category { + return true + } + } + return false +} + // GifCatalogEntry is one admin-curated GIF served by the built-in @gif inline // bot (see rpc.ServiceBotInlineResults) for the client's GIF picker // trending/search panel. DocumentID references an already-uploaded document @@ -54,6 +77,9 @@ type GifCatalogEntry struct { Enabled bool SortOrder int CreatedBy string + // Category is one of GifCatalogCategories, or "" if uncategorized. Set + // manually via the admin panel or in bulk via files.ClassifyGifCategory. + Category string // SourceFilename is set only for entries files.Service.SeedGifs imported // from the data/gifs/ drop directory -- empty for anything created through // the admin panel. It exists purely so a restart can tell "this file was diff --git a/internal/store/gif_catalog.go b/internal/store/gif_catalog.go index 424e361f..3e13c1c2 100644 --- a/internal/store/gif_catalog.go +++ b/internal/store/gif_catalog.go @@ -27,6 +27,9 @@ type GifCatalogStore interface { SetGifCatalogEnabled(ctx context.Context, id int64, enabled bool) (bool, error) // SetGifCatalogSortOrder rewrites an entry's display position. SetGifCatalogSortOrder(ctx context.Context, id int64, order int) (bool, error) + // SetGifCatalogCategory sets (or clears, via "") an entry's category. + // changed=false if the id doesn't exist. + SetGifCatalogCategory(ctx context.Context, id int64, category string) (bool, error) // DeleteGifCatalogEntry removes an entry. The referenced document is left // alone -- catalog membership, not the document itself, is what's deleted. DeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error) diff --git a/internal/store/postgres/gif_catalog.go b/internal/store/postgres/gif_catalog.go index ea5b1f32..f05f4f39 100644 --- a/internal/store/postgres/gif_catalog.go +++ b/internal/store/postgres/gif_catalog.go @@ -21,10 +21,10 @@ func (s *GifCatalogStore) CreateGifCatalogEntry(ctx context.Context, entry domai return domain.GifCatalogEntry{}, fmt.Errorf("create gif catalog entry: id and document_id are required") } row := s.db.QueryRow(ctx, ` -INSERT INTO gif_catalog (id, title, document_id, enabled, sort_order, created_by, source_filename) -VALUES ($1, $2, $3, true, $4, $5, $6) -RETURNING id, title, document_id, enabled, sort_order, created_by, created_at, updated_at, source_filename`, - entry.ID, entry.Title, entry.DocumentID, entry.SortOrder, entry.CreatedBy, entry.SourceFilename) +INSERT INTO gif_catalog (id, title, document_id, enabled, sort_order, created_by, source_filename, category) +VALUES ($1, $2, $3, true, $4, $5, $6, $7) +RETURNING id, title, document_id, enabled, sort_order, created_by, created_at, updated_at, source_filename, category`, + entry.ID, entry.Title, entry.DocumentID, entry.SortOrder, entry.CreatedBy, entry.SourceFilename, entry.Category) out, err := scanGifCatalogEntry(row.Scan) if err != nil { return domain.GifCatalogEntry{}, fmt.Errorf("create gif catalog entry: %w", err) @@ -50,7 +50,7 @@ func (s *GifCatalogStore) HasGifCatalogSourceFilename(ctx context.Context, filen func (s *GifCatalogStore) ListGifCatalog(ctx context.Context, onlyEnabled bool) ([]domain.GifCatalogEntry, error) { rows, err := s.db.Query(ctx, ` -SELECT id, title, document_id, enabled, sort_order, created_by, created_at, updated_at, source_filename +SELECT id, title, document_id, enabled, sort_order, created_by, created_at, updated_at, source_filename, category FROM gif_catalog WHERE NOT $1 OR enabled ORDER BY sort_order, id @@ -86,6 +86,14 @@ func (s *GifCatalogStore) SetGifCatalogSortOrder(ctx context.Context, id int64, return tag.RowsAffected() > 0, nil } +func (s *GifCatalogStore) SetGifCatalogCategory(ctx context.Context, id int64, category string) (bool, error) { + tag, err := s.db.Exec(ctx, `UPDATE gif_catalog SET category = $2, updated_at = now() WHERE id = $1`, id, category) + if err != nil { + return false, fmt.Errorf("set gif catalog entry category: %w", err) + } + return tag.RowsAffected() > 0, nil +} + func (s *GifCatalogStore) DeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error) { tag, err := s.db.Exec(ctx, `DELETE FROM gif_catalog WHERE id = $1`, id) if err != nil { @@ -96,7 +104,7 @@ func (s *GifCatalogStore) DeleteGifCatalogEntry(ctx context.Context, id int64) ( func scanGifCatalogEntry(scan func(dest ...any) error) (domain.GifCatalogEntry, error) { var e domain.GifCatalogEntry - if err := scan(&e.ID, &e.Title, &e.DocumentID, &e.Enabled, &e.SortOrder, &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt, &e.SourceFilename); err != nil { + if err := scan(&e.ID, &e.Title, &e.DocumentID, &e.Enabled, &e.SortOrder, &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt, &e.SourceFilename, &e.Category); err != nil { return domain.GifCatalogEntry{}, err } return e, nil