categorisation for gifs

This commit is contained in:
onysd 2026-08-24 23:09:08 +03:00
parent 5e18bd3830
commit d0669bdc5e
15 changed files with 468 additions and 49 deletions

View file

@ -290,13 +290,14 @@ type GifCatalogRow struct {
DocumentID int64 `json:"DocumentID,string"` DocumentID int64 `json:"DocumentID,string"`
Enabled bool Enabled bool
SortOrder int SortOrder int
Category string
CreatedBy string CreatedBy string
CreatedAt time.Time CreatedAt time.Time
} }
func (s *readStore) ListGifCatalog(ctx context.Context) ([]GifCatalogRow, error) { func (s *readStore) ListGifCatalog(ctx context.Context) ([]GifCatalogRow, error) {
rows, err := s.pool.Query(ctx, ` 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 FROM gif_catalog
ORDER BY sort_order, id`) ORDER BY sort_order, id`)
if err != nil { if err != nil {
@ -306,7 +307,7 @@ ORDER BY sort_order, id`)
out := make([]GifCatalogRow, 0) out := make([]GifCatalogRow, 0)
for rows.Next() { for rows.Next() {
var item GifCatalogRow 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 return nil, err
} }
out = append(out, item) out = append(out, item)

View file

@ -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/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-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-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/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/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI))) 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) 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 { type deleteGifCatalogEntryAPIRequest struct {
CommandID string `json:"command_id"` CommandID string `json:"command_id"`
Reason string `json:"reason"` Reason string `json:"reason"`

View file

@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; 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"; type GifPageSize = 10 | 20 | 50 | 100 | "all";
@ -45,6 +45,7 @@ export function GifCatalogPage() {
const [pageSize, setPageSize] = useState<GifPageSize>(10); const [pageSize, setPageSize] = useState<GifPageSize>(10);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [orderDrafts, setOrderDrafts] = useState<Record<string, string>>({}); const [orderDrafts, setOrderDrafts] = useState<Record<string, string>>({});
const [categoryDrafts, setCategoryDrafts] = useState<Record<string, string>>({});
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
async function load() { async function load() {
@ -83,7 +84,8 @@ export function GifCatalogPage() {
const counts = useMemo(() => ({ const counts = useMemo(() => ({
total: rows.length, 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]); }), [rows]);
return ( return (
@ -95,6 +97,13 @@ export function GifCatalogPage() {
<button className="btn" type="button" onClick={() => load()} disabled={busy}> <button className="btn" type="button" onClick={() => load()} disabled={busy}>
<RefreshCw size={15} /> {"Refresh"} <RefreshCw size={15} /> {"Refresh"}
</button> </button>
<ActionButton
tone="neutral"
label={"Auto-categorize"}
path="/api/actions/auto-categorize-gif-catalog"
payload={() => ({})}
onDone={() => void load()}
/>
<button className="btn primary" type="button" onClick={() => setCreateOpen(true)}> <button className="btn primary" type="button" onClick={() => setCreateOpen(true)}>
<Plus size={15} /> {"Add GIF"} <Plus size={15} /> {"Add GIF"}
</button> </button>
@ -105,6 +114,7 @@ export function GifCatalogPage() {
<div className="metric-row"> <div className="metric-row">
<Metric label={"Total GIFs"} value={String(counts.total)} /> <Metric label={"Total GIFs"} value={String(counts.total)} />
<Metric label={"Enabled"} value={String(counts.enabled)} tone="good" /> <Metric label={"Enabled"} value={String(counts.enabled)} tone="good" />
<Metric label={"Uncategorized"} value={String(counts.uncategorized)} tone={counts.uncategorized > 0 ? "warn" : undefined} />
</div> </div>
<QueryPanel> <QueryPanel>
<div className="toolbar"> <div className="toolbar">
@ -138,6 +148,7 @@ export function GifCatalogPage() {
<th>{"Document ID"}</th> <th>{"Document ID"}</th>
<th>{"Added by"}</th> <th>{"Added by"}</th>
<th>{"Status"}</th> <th>{"Status"}</th>
<th>{"Category"}</th>
<th>{"Sort order"}</th> <th>{"Sort order"}</th>
<th>{"Actions"}</th> <th>{"Actions"}</th>
</tr> </tr>
@ -151,6 +162,28 @@ export function GifCatalogPage() {
<td className="mono">{row.DocumentID}</td> <td className="mono">{row.DocumentID}</td>
<td>{row.CreatedBy || <span className="muted-cell">{"—"}</span>}</td> <td>{row.CreatedBy || <span className="muted-cell">{"—"}</span>}</td>
<td>{row.Enabled ? <Badge tone="good">{"Enabled"}</Badge> : <Badge tone="danger">{"Disabled"}</Badge>}</td> <td>{row.Enabled ? <Badge tone="good">{"Enabled"}</Badge> : <Badge tone="danger">{"Disabled"}</Badge>}</td>
<td>
<div className="sort-order-editor">
<select
className="small-input"
value={categoryDrafts[row.ID] ?? row.Category}
onChange={(event) => setCategoryDrafts((prev) => ({ ...prev, [row.ID]: event.target.value }))}
>
<option value="">{"Uncategorized"}</option>
{GIF_CATALOG_CATEGORIES.map((category) => (
<option key={category} value={category}>{category}</option>
))}
</select>
<ActionButton
compact
tone="neutral"
label={"Save"}
path="/api/actions/set-gif-catalog-category"
payload={() => ({ id: row.ID, category: categoryDrafts[row.ID] ?? row.Category })}
onDone={() => void load()}
/>
</div>
</td>
<td> <td>
<div className="sort-order-editor"> <div className="sort-order-editor">
<input <input
@ -191,7 +224,7 @@ export function GifCatalogPage() {
</td> </td>
</tr> </tr>
))} ))}
{paged.length === 0 && <EmptyRow colSpan={8} />} {paged.length === 0 && <EmptyRow colSpan={9} />}
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -636,6 +636,14 @@ export type StickerSetRow = {
export type StickerSetListResponse = { rows: 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 = { export type GifCatalogRow = {
// String, not number: 18-19 digit snowflake ids, past JS's 2^53 // String, not number: 18-19 digit snowflake ids, past JS's 2^53
// safe-integer limit. // safe-integer limit.
@ -644,6 +652,7 @@ export type GifCatalogRow = {
DocumentID: string; DocumentID: string;
Enabled: boolean; Enabled: boolean;
SortOrder: number; SortOrder: number;
Category: string;
CreatedBy: string; CreatedBy: string;
CreatedAt: string; CreatedAt: string;
}; };

View file

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS public.gif_catalog_category_idx;
ALTER TABLE public.gif_catalog DROP COLUMN IF EXISTS category;

View file

@ -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 <> '';

View file

@ -21,43 +21,45 @@ import (
) )
const ( const (
ActionSetAccountFrozen = "account.set_frozen" ActionSetAccountFrozen = "account.set_frozen"
ActionGrantPremium = "account.grant_premium" ActionGrantPremium = "account.grant_premium"
ActionSetVerified = "account.set_verified" ActionSetVerified = "account.set_verified"
ActionSetUserFlags = "account.set_flags" ActionSetUserFlags = "account.set_flags"
ActionSetSupport = "account.set_support" ActionSetSupport = "account.set_support"
ActionSetUsername = "account.set_username" ActionSetUsername = "account.set_username"
ActionSetUserColor = "account.set_color" ActionSetUserColor = "account.set_color"
ActionSetUserEmojiStatus = "account.set_emoji_status" ActionSetUserEmojiStatus = "account.set_emoji_status"
ActionSetProfile = "account.set_profile" ActionSetProfile = "account.set_profile"
ActionSetPhone = "account.set_phone" ActionSetPhone = "account.set_phone"
ActionSetLoginEmail = "account.set_login_email" ActionSetLoginEmail = "account.set_login_email"
ActionSetAccountAvatar = "account.set_avatar" ActionSetAccountAvatar = "account.set_avatar"
ActionSetChannelAvatar = "channel.set_avatar" ActionSetChannelAvatar = "channel.set_avatar"
ActionSetChannelUsername = "channel.set_username" ActionSetChannelUsername = "channel.set_username"
ActionSetChannelSettings = "channel.set_settings" ActionSetChannelSettings = "channel.set_settings"
ActionSetChannelColor = "channel.set_color" ActionSetChannelColor = "channel.set_color"
ActionSetChannelEmojiStatus = "channel.set_emoji_status" ActionSetChannelEmojiStatus = "channel.set_emoji_status"
ActionSetChannelVerified = "channel.set_verified" ActionSetChannelVerified = "channel.set_verified"
ActionSetChannelFlags = "channel.set_flags" ActionSetChannelFlags = "channel.set_flags"
ActionRevokeSessions = "account.revoke_sessions" ActionRevokeSessions = "account.revoke_sessions"
ActionDeletePrivateMessages = "messages.delete_private_messages" ActionDeletePrivateMessages = "messages.delete_private_messages"
ActionDeletePrivateHistory = "messages.delete_private_history" ActionDeletePrivateHistory = "messages.delete_private_history"
ActionCreateBot = "bot.create" ActionCreateBot = "bot.create"
ActionCreateBroadcast = "broadcast.create" ActionCreateBroadcast = "broadcast.create"
ActionDeleteBot = "bot.delete" ActionDeleteBot = "bot.delete"
ActionExportBotToken = "bot.export_token" ActionExportBotToken = "bot.export_token"
ActionSetStickerSetArchived = "stickers.set_archived" ActionSetStickerSetArchived = "stickers.set_archived"
ActionSetStickerSetSortOrder = "stickers.set_sort_order" ActionSetStickerSetSortOrder = "stickers.set_sort_order"
ActionRenameStickerSet = "stickers.rename" ActionRenameStickerSet = "stickers.rename"
ActionDeleteStickerSet = "stickers.delete" ActionDeleteStickerSet = "stickers.delete"
ActionCreateStickerSet = "stickers.create" ActionCreateStickerSet = "stickers.create"
ActionAddStickerToSet = "stickers.add_sticker" ActionAddStickerToSet = "stickers.add_sticker"
ActionRemoveStickerFromSet = "stickers.remove_sticker" ActionRemoveStickerFromSet = "stickers.remove_sticker"
ActionCreateGifCatalogEntry = "gif_catalog.create" ActionCreateGifCatalogEntry = "gif_catalog.create"
ActionSetGifCatalogEnabled = "gif_catalog.set_enabled" ActionSetGifCatalogEnabled = "gif_catalog.set_enabled"
ActionSetGifCatalogSortOrder = "gif_catalog.set_sort_order" ActionSetGifCatalogSortOrder = "gif_catalog.set_sort_order"
ActionDeleteGifCatalogEntry = "gif_catalog.delete" ActionSetGifCatalogCategory = "gif_catalog.set_category"
ActionAutoCategorizeGifCatalog = "gif_catalog.auto_categorize"
ActionDeleteGifCatalogEntry = "gif_catalog.delete"
// Collectible (Fragment-style) username lifecycle. // Collectible (Fragment-style) username lifecycle.
ActionMintCollectibleUsername = "usernames.collectible.mint" ActionMintCollectibleUsername = "usernames.collectible.mint"
ActionTransferCollectibleUsername = "usernames.collectible.transfer" ActionTransferCollectibleUsername = "usernames.collectible.transfer"
@ -305,6 +307,11 @@ type GifCatalogService interface {
AdminListGifCatalog(ctx context.Context) ([]domain.GifCatalogEntry, error) AdminListGifCatalog(ctx context.Context) ([]domain.GifCatalogEntry, error)
AdminSetGifCatalogEnabled(ctx context.Context, id int64, enabled bool) (bool, error) AdminSetGifCatalogEnabled(ctx context.Context, id int64, enabled bool) (bool, error)
AdminSetGifCatalogSortOrder(ctx context.Context, id int64, order int) (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) AdminDeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error)
} }
@ -700,6 +707,16 @@ type SetGifCatalogSortOrderRequest struct {
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
} }
type SetGifCatalogCategoryRequest struct {
CommandMeta
ID int64 `json:"id"`
Category string `json:"category"`
}
type AutoCategorizeGifCatalogRequest struct {
CommandMeta
}
type DeleteGifCatalogEntryRequest struct { type DeleteGifCatalogEntryRequest struct {
CommandMeta CommandMeta
ID int64 `json:"id"` 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) { func (s *Service) DeleteGifCatalogEntry(ctx context.Context, req DeleteGifCatalogEntryRequest) (CommandResult, error) {
if s == nil || s.gifCatalog == nil || req.ID <= 0 { if s == nil || s.gifCatalog == nil || req.ID <= 0 {
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required") return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")

View file

@ -79,6 +79,8 @@ type Service interface {
CreateGifCatalogEntry(ctx context.Context, req admin.CreateGifCatalogEntryRequest) (admin.CommandResult, error) CreateGifCatalogEntry(ctx context.Context, req admin.CreateGifCatalogEntryRequest) (admin.CommandResult, error)
SetGifCatalogEnabled(ctx context.Context, req admin.SetGifCatalogEnabledRequest) (admin.CommandResult, error) SetGifCatalogEnabled(ctx context.Context, req admin.SetGifCatalogEnabledRequest) (admin.CommandResult, error)
SetGifCatalogSortOrder(ctx context.Context, req admin.SetGifCatalogSortOrderRequest) (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) DeleteGifCatalogEntry(ctx context.Context, req admin.DeleteGifCatalogEntryRequest) (admin.CommandResult, error)
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error) EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error)
ModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, 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/create", s.authenticated(s.handleCreateGifCatalogEntry))
mux.HandleFunc("POST /v1/gif-catalog/set-enabled", s.authenticated(s.handleSetGifCatalogEnabled)) 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-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("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/stickers/documents/{id}/animation", s.authenticated(s.handleStickerDocumentAnimation))
mux.HandleFunc("GET /v1/gif-catalog/documents/{id}/preview", s.authenticated(s.handleGifCatalogDocumentPreview)) 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) 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) { func (s *Server) handleDeleteGifCatalogEntry(w http.ResponseWriter, r *http.Request) {
var req admin.DeleteGifCatalogEntryRequest var req admin.DeleteGifCatalogEntryRequest
if !decodeJSON(w, r, &req) { if !decodeJSON(w, r, &req) {

View file

@ -460,6 +460,14 @@ func (fakeService) SetGifCatalogSortOrder(_ context.Context, req admin.SetGifCat
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil 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) { func (fakeService) DeleteGifCatalogEntry(_ context.Context, req admin.DeleteGifCatalogEntryRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
} }

View file

@ -6,6 +6,7 @@ import (
"strings" "strings"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/seed/catalog"
) )
// HandlesInlineBot reports whether botUserID is a built-in bot this service // 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 { if err != nil {
return domain.BotInlineResults{}, false, err 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 { if len(entries) == 0 {
return domain.BotInlineResults{Gallery: true}, true, nil 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 // with the closest titles first keeps every query useful while still honouring
// the search term. Order within each group stays the admin-set // the search term. Order within each group stays the admin-set
// (sort_order, id) order the store already applied. // (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 { func rankGifCatalogEntries(entries []domain.GifCatalogEntry, query string) []domain.GifCatalogEntry {
query = strings.TrimSpace(strings.ToLower(query)) query = strings.TrimSpace(strings.ToLower(query))
if query == "" { if query == "" {

View file

@ -190,6 +190,59 @@ func (s *Service) AdminSetGifCatalogSortOrder(ctx context.Context, id int64, ord
return true, nil 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 // AdminDeleteGifCatalogEntry removes an entry from the catalog. The
// referenced document is left alone. // referenced document is left alone.
func (s *Service) AdminDeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error) { func (s *Service) AdminDeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error) {

View file

@ -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",
}

View file

@ -42,6 +42,29 @@ const (
MaxGifCatalogUploadSize = 50 << 20 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 // GifCatalogEntry is one admin-curated GIF served by the built-in @gif inline
// bot (see rpc.ServiceBotInlineResults) for the client's GIF picker // bot (see rpc.ServiceBotInlineResults) for the client's GIF picker
// trending/search panel. DocumentID references an already-uploaded document // trending/search panel. DocumentID references an already-uploaded document
@ -54,6 +77,9 @@ type GifCatalogEntry struct {
Enabled bool Enabled bool
SortOrder int SortOrder int
CreatedBy string 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 // SourceFilename is set only for entries files.Service.SeedGifs imported
// from the data/gifs/ drop directory -- empty for anything created through // 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 // the admin panel. It exists purely so a restart can tell "this file was

View file

@ -27,6 +27,9 @@ type GifCatalogStore interface {
SetGifCatalogEnabled(ctx context.Context, id int64, enabled bool) (bool, error) SetGifCatalogEnabled(ctx context.Context, id int64, enabled bool) (bool, error)
// SetGifCatalogSortOrder rewrites an entry's display position. // SetGifCatalogSortOrder rewrites an entry's display position.
SetGifCatalogSortOrder(ctx context.Context, id int64, order int) (bool, error) 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 // DeleteGifCatalogEntry removes an entry. The referenced document is left
// alone -- catalog membership, not the document itself, is what's deleted. // alone -- catalog membership, not the document itself, is what's deleted.
DeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error) DeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error)

View file

@ -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") return domain.GifCatalogEntry{}, fmt.Errorf("create gif catalog entry: id and document_id are required")
} }
row := s.db.QueryRow(ctx, ` row := s.db.QueryRow(ctx, `
INSERT INTO gif_catalog (id, title, document_id, enabled, sort_order, created_by, source_filename) INSERT INTO gif_catalog (id, title, document_id, enabled, sort_order, created_by, source_filename, category)
VALUES ($1, $2, $3, true, $4, $5, $6) 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`, 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.ID, entry.Title, entry.DocumentID, entry.SortOrder, entry.CreatedBy, entry.SourceFilename, entry.Category)
out, err := scanGifCatalogEntry(row.Scan) out, err := scanGifCatalogEntry(row.Scan)
if err != nil { if err != nil {
return domain.GifCatalogEntry{}, fmt.Errorf("create gif catalog entry: %w", err) 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) { func (s *GifCatalogStore) ListGifCatalog(ctx context.Context, onlyEnabled bool) ([]domain.GifCatalogEntry, error) {
rows, err := s.db.Query(ctx, ` 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 FROM gif_catalog
WHERE NOT $1 OR enabled WHERE NOT $1 OR enabled
ORDER BY sort_order, id ORDER BY sort_order, id
@ -86,6 +86,14 @@ func (s *GifCatalogStore) SetGifCatalogSortOrder(ctx context.Context, id int64,
return tag.RowsAffected() > 0, nil 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) { 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) tag, err := s.db.Exec(ctx, `DELETE FROM gif_catalog WHERE id = $1`, id)
if err != nil { 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) { func scanGifCatalogEntry(scan func(dest ...any) error) (domain.GifCatalogEntry, error) {
var e domain.GifCatalogEntry 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 domain.GifCatalogEntry{}, err
} }
return e, nil return e, nil