added full gifts support
This commit is contained in:
parent
ea5b86de8f
commit
354128c106
35 changed files with 1502 additions and 103 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -38,3 +38,4 @@ tmp/
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
*.exe~
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,38 @@ ORDER BY set_kind, sort_order, id`, kind)
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GifCatalogRow is one entry of the admin-curated GIF catalog @gif serves for
|
||||||
|
// the client's GIF picker.
|
||||||
|
type GifCatalogRow struct {
|
||||||
|
ID int64 `json:"ID,string"`
|
||||||
|
Title string
|
||||||
|
DocumentID int64 `json:"DocumentID,string"`
|
||||||
|
Enabled bool
|
||||||
|
SortOrder int
|
||||||
|
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
|
||||||
|
FROM gif_catalog
|
||||||
|
ORDER BY sort_order, id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list gif catalog: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// StickerSetDocumentIDs returns document ids as strings, not int64 — a plain
|
// StickerSetDocumentIDs returns document ids as strings, not int64 — a plain
|
||||||
// JSON number array would let the browser silently round these snowflake ids
|
// JSON number array would let the browser silently round these snowflake ids
|
||||||
// past 2^53 (see StickerSetRow.ID for the same issue on the set id itself).
|
// past 2^53 (see StickerSetRow.ID for the same issue on the set id itself).
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,11 @@ func (s *server) routes() http.Handler {
|
||||||
mux.Handle("POST /api/actions/create-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleCreateStickerSetAPI)))
|
mux.Handle("POST /api/actions/create-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleCreateStickerSetAPI)))
|
||||||
mux.Handle("POST /api/actions/add-sticker-to-set", s.requireAuthAPI(http.HandlerFunc(s.handleAddStickerToSetAPI)))
|
mux.Handle("POST /api/actions/add-sticker-to-set", s.requireAuthAPI(http.HandlerFunc(s.handleAddStickerToSetAPI)))
|
||||||
mux.Handle("POST /api/actions/remove-sticker-from-set", s.requireAuthAPI(http.HandlerFunc(s.handleRemoveStickerFromSetAPI)))
|
mux.Handle("POST /api/actions/remove-sticker-from-set", s.requireAuthAPI(http.HandlerFunc(s.handleRemoveStickerFromSetAPI)))
|
||||||
|
mux.Handle("GET /api/gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleGifCatalogAPI)))
|
||||||
|
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/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)))
|
||||||
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
|
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
|
||||||
|
|
@ -1816,6 +1821,124 @@ func (s *server) handleRemoveStickerFromSetAPI(w http.ResponseWriter, r *http.Re
|
||||||
writeCommandResultAPI(w, result, err)
|
writeCommandResultAPI(w, result, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *server) handleGifCatalogAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.read == nil {
|
||||||
|
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := s.read.ListGifCatalog(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
||||||
|
}
|
||||||
|
|
||||||
|
type createGifCatalogEntryAPIRequest struct {
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleCreateGifCatalogEntryAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 21<<20)
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.MultipartForm != nil {
|
||||||
|
defer r.MultipartForm.RemoveAll()
|
||||||
|
}
|
||||||
|
var body createGifCatalogEntryAPIRequest
|
||||||
|
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(&body); err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "gif file is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, (20<<20)+1))
|
||||||
|
if err != nil || len(data) == 0 || len(data) > 20<<20 {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "gif file is empty or too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := admin.CreateGifCatalogEntryRequest{
|
||||||
|
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "create-gif-catalog-entry"),
|
||||||
|
Title: body.Title, FileName: header.Filename,
|
||||||
|
}
|
||||||
|
result, err := s.callAdminMultipart(r.Context(), "/v1/gif-catalog/create", req, header.Filename, data)
|
||||||
|
writeCommandResultAPI(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type setGifCatalogEnabledAPIRequest struct {
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
ID int64 `json:"id,string"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleSetGifCatalogEnabledAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body setGifCatalogEnabledAPIRequest
|
||||||
|
if !decodeAction(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := admin.SetGifCatalogEnabledRequest{
|
||||||
|
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-gif-catalog-enabled"),
|
||||||
|
ID: body.ID, Enabled: body.Enabled,
|
||||||
|
}
|
||||||
|
result, err := s.callAdminAPI(r.Context(), "/v1/gif-catalog/set-enabled", req)
|
||||||
|
writeCommandResultAPI(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type setGifCatalogSortOrderAPIRequest struct {
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
ID int64 `json:"id,string"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleSetGifCatalogSortOrderAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body setGifCatalogSortOrderAPIRequest
|
||||||
|
if !decodeAction(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := admin.SetGifCatalogSortOrderRequest{
|
||||||
|
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-gif-catalog-sort-order"),
|
||||||
|
ID: body.ID, SortOrder: body.SortOrder,
|
||||||
|
}
|
||||||
|
result, err := s.callAdminAPI(r.Context(), "/v1/gif-catalog/set-sort-order", req)
|
||||||
|
writeCommandResultAPI(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteGifCatalogEntryAPIRequest struct {
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
ID int64 `json:"id,string"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleDeleteGifCatalogEntryAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body deleteGifCatalogEntryAPIRequest
|
||||||
|
if !decodeAction(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := admin.DeleteGifCatalogEntryRequest{
|
||||||
|
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-gif-catalog-entry"),
|
||||||
|
ID: body.ID,
|
||||||
|
}
|
||||||
|
result, err := s.callAdminAPI(r.Context(), "/v1/gif-catalog/delete", req)
|
||||||
|
writeCommandResultAPI(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) handleStickerSetsAPI(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleStickerSetsAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.read == nil {
|
if s.read == nil {
|
||||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-BKuHz-WL.js"></script>
|
<script type="module" crossorigin src="/assets/index-DQxoDUCy.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C7ZZ0RF2.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CqUW4fne.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import type {
|
||||||
ModerationCaseRow,
|
ModerationCaseRow,
|
||||||
ModerationReport,
|
ModerationReport,
|
||||||
StickerSetListResponse,
|
StickerSetListResponse,
|
||||||
|
GifCatalogListResponse,
|
||||||
VerificationApplicationDetail,
|
VerificationApplicationDetail,
|
||||||
VerificationApplicationListResponse,
|
VerificationApplicationListResponse,
|
||||||
VerificationCountsResponse
|
VerificationCountsResponse
|
||||||
|
|
@ -228,6 +229,8 @@ export const api = {
|
||||||
setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }),
|
setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }),
|
||||||
setChannelAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-channel-avatar", { method: "POST", body: form }),
|
setChannelAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-channel-avatar", { method: "POST", body: form }),
|
||||||
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
|
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
|
||||||
|
gifCatalog: () => request<GifCatalogListResponse>("/api/gif-catalog"),
|
||||||
|
createGifCatalogEntry: (form: FormData) => request<CommandResult>("/api/actions/create-gif-catalog-entry", { method: "POST", body: form }),
|
||||||
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
Bot,
|
Bot,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Database,
|
Database,
|
||||||
|
Film,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
|
|
@ -101,6 +102,7 @@ export function Shell({
|
||||||
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
|
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
|
||||||
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
|
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
|
||||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
|
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
|
||||||
|
<NavLink icon={<Film size={16} />} href="/gif-catalog" route={route} navigate={navigate}>{"GIFs"}</NavLink>
|
||||||
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
|
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
|
||||||
<button
|
<button
|
||||||
className="nav-section-toggle"
|
className="nav-section-toggle"
|
||||||
|
|
|
||||||
271
cmd/telesrv-admin/web/src/pages/GifCatalogPage.tsx
Normal file
271
cmd/telesrv-admin/web/src/pages/GifCatalogPage.tsx
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
import { ChevronLeft, ChevronRight, Loader2, Plus, RefreshCw, Search, Upload, X } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
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";
|
||||||
|
|
||||||
|
type GifPageSize = 10 | 20 | 50 | 100 | "all";
|
||||||
|
|
||||||
|
// Manage view for the admin-curated GIF catalog the built-in @gif inline bot
|
||||||
|
// serves for the client's GIF picker trending/search panel.
|
||||||
|
export function GifCatalogPage() {
|
||||||
|
const [rows, setRows] = useState<GifCatalogRow[]>([]);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [pageSize, setPageSize] = useState<GifPageSize>(10);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [orderDrafts, setOrderDrafts] = useState<Record<string, string>>({});
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
setRows((await api.gifCatalog()).rows ?? []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, []);
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const normalized = query.trim().toLowerCase();
|
||||||
|
if (!normalized) return rows;
|
||||||
|
return rows.filter((row) =>
|
||||||
|
row.ID.includes(normalized) || row.Title.toLowerCase().includes(normalized)
|
||||||
|
);
|
||||||
|
}, [rows, query]);
|
||||||
|
|
||||||
|
useEffect(() => { setPage(1); }, [query, pageSize]);
|
||||||
|
|
||||||
|
const totalPages = pageSize === "all" ? 1 : Math.max(1, Math.ceil(visible.length / pageSize));
|
||||||
|
const currentPage = Math.min(page, totalPages);
|
||||||
|
const paged = useMemo(() => {
|
||||||
|
if (pageSize === "all") return visible;
|
||||||
|
const start = (currentPage - 1) * pageSize;
|
||||||
|
return visible.slice(start, start + pageSize);
|
||||||
|
}, [visible, currentPage, pageSize]);
|
||||||
|
const rangeStart = paged.length === 0 ? 0 : pageSize === "all" ? 1 : (currentPage - 1) * pageSize + 1;
|
||||||
|
const rangeEnd = rangeStart === 0 ? 0 : rangeStart + paged.length - 1;
|
||||||
|
|
||||||
|
const counts = useMemo(() => ({
|
||||||
|
total: rows.length,
|
||||||
|
enabled: rows.filter((row) => row.Enabled).length
|
||||||
|
}), [rows]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageFrame
|
||||||
|
title={"GIFs"}
|
||||||
|
eyebrow={"Curated GIFs served by @gif in the client's GIF picker (trending + search)"}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<button className="btn" type="button" onClick={() => load()} disabled={busy}>
|
||||||
|
<RefreshCw size={15} /> {"Refresh"}
|
||||||
|
</button>
|
||||||
|
<button className="btn primary" type="button" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus size={15} /> {"Add GIF"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<div className="metric-row">
|
||||||
|
<Metric label={"Total GIFs"} value={String(counts.total)} />
|
||||||
|
<Metric label={"Enabled"} value={String(counts.enabled)} tone="good" />
|
||||||
|
</div>
|
||||||
|
<QueryPanel>
|
||||||
|
<div className="toolbar">
|
||||||
|
<label className="searchbox">
|
||||||
|
<Search size={15} />
|
||||||
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={"Search ID or title"} />
|
||||||
|
</label>
|
||||||
|
<label className="gift-page-size">
|
||||||
|
<span>{"Per page"}</span>
|
||||||
|
<select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as GifPageSize))}
|
||||||
|
>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="all">{"All"}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span className="gift-list-summary">{`Showing ${visible.length} of ${rows.length}`}</span>
|
||||||
|
</div>
|
||||||
|
</QueryPanel>
|
||||||
|
<div className="table-wrap gift-table-wrap">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{"ID"}</th>
|
||||||
|
<th>{"Title"}</th>
|
||||||
|
<th>{"Document ID"}</th>
|
||||||
|
<th>{"Added by"}</th>
|
||||||
|
<th>{"Status"}</th>
|
||||||
|
<th>{"Sort order"}</th>
|
||||||
|
<th>{"Actions"}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paged.map((row) => (
|
||||||
|
<tr className={row.Enabled ? "" : "gift-row-disabled"} key={row.ID}>
|
||||||
|
<td className="mono">{row.ID}</td>
|
||||||
|
<td>{row.Title || <span className="muted-cell">{"Untitled"}</span>}</td>
|
||||||
|
<td className="mono">{row.DocumentID}</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>
|
||||||
|
<div className="sort-order-editor">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="small-input"
|
||||||
|
value={orderDrafts[row.ID] ?? String(row.SortOrder)}
|
||||||
|
onChange={(event) => setOrderDrafts((prev) => ({ ...prev, [row.ID]: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
compact
|
||||||
|
tone="neutral"
|
||||||
|
label={"Save"}
|
||||||
|
path="/api/actions/set-gif-catalog-sort-order"
|
||||||
|
payload={() => ({ id: row.ID, sort_order: Number(orderDrafts[row.ID] ?? row.SortOrder) })}
|
||||||
|
onDone={() => void load()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="gift-table-actions">
|
||||||
|
<ActionButton
|
||||||
|
compact
|
||||||
|
tone="neutral"
|
||||||
|
label={row.Enabled ? "Disable" : "Enable"}
|
||||||
|
path="/api/actions/set-gif-catalog-enabled"
|
||||||
|
payload={() => ({ id: row.ID, enabled: !row.Enabled })}
|
||||||
|
onDone={() => void load()}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
compact
|
||||||
|
tone="danger"
|
||||||
|
label={"Delete"}
|
||||||
|
path="/api/actions/delete-gif-catalog-entry"
|
||||||
|
payload={() => ({ id: row.ID })}
|
||||||
|
onDone={() => void load()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{paged.length === 0 && <EmptyRow colSpan={7} />}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{pageSize !== "all" && visible.length > 0 && (
|
||||||
|
<div className="gift-pager">
|
||||||
|
<span className="gift-pager-range">{`Showing ${rangeStart}-${rangeEnd} of ${visible.length}`}</span>
|
||||||
|
<div className="gift-pager-controls">
|
||||||
|
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
|
||||||
|
<ChevronLeft size={14} /> {"Previous"}
|
||||||
|
</button>
|
||||||
|
<span className="gift-pager-page">{`Page ${currentPage} of ${totalPages}`}</span>
|
||||||
|
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
|
||||||
|
{"Next"} <ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{createOpen && <AddGifModal onClose={() => setCreateOpen(false)} onCreated={() => void load()} />}
|
||||||
|
</PageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddGifModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [previewURL, setPreviewURL] = useState<string | null>(null);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
function pickFile(picked: File | null) {
|
||||||
|
setFile(picked);
|
||||||
|
setPreviewURL((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
|
return picked ? URL.createObjectURL(picked) : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!title.trim() || !file) {
|
||||||
|
setError("Title and a GIF/MP4 file are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!reason.trim()) {
|
||||||
|
setError("Please enter an operation reason");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, title: title.trim() }));
|
||||||
|
form.set("file", file, file.name);
|
||||||
|
await api.createGifCatalogEntry(form);
|
||||||
|
onCreated();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="modal-backdrop" role="presentation">
|
||||||
|
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Add a GIF"}>
|
||||||
|
<div className="modal-head">
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow">{"New catalog entry"}</div>
|
||||||
|
<h2>{"Add a GIF"}</h2>
|
||||||
|
</div>
|
||||||
|
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="command-body">
|
||||||
|
<div className="gift-fields-grid">
|
||||||
|
<label><span>{"Title"}</span><input value={title} maxLength={128} onChange={(event) => setTitle(event.target.value)} /></label>
|
||||||
|
</div>
|
||||||
|
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
|
||||||
|
<input type="file" accept=".gif,.mp4,image/gif,video/mp4" onChange={(event) => pickFile(event.target.files?.[0] ?? null)} />
|
||||||
|
<span className="gift-file-copy"><span className="gift-field-label">{"File"}</span><strong>{file ? file.name : "Choose a GIF or MP4 file"}</strong></span>
|
||||||
|
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
|
||||||
|
</label>
|
||||||
|
{previewURL && (
|
||||||
|
<div className="gif-catalog-preview">
|
||||||
|
{file?.type === "video/mp4" ? (
|
||||||
|
<video src={previewURL} autoPlay loop muted playsInline />
|
||||||
|
) : (
|
||||||
|
<img src={previewURL} alt="" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this GIF is being added"} onChange={(event) => setReason(event.target.value)} /></label>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
|
||||||
|
<button className="btn primary" type="button" onClick={submit} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
|
||||||
|
{"Add GIF"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import { GroupMessagesPage } from "./GroupMessagesPage";
|
||||||
import { MessageDetailPage } from "./MessageDetailPage";
|
import { MessageDetailPage } from "./MessageDetailPage";
|
||||||
import { MessagesPage } from "./MessagesPage";
|
import { MessagesPage } from "./MessagesPage";
|
||||||
import { StickerSetsPage } from "./StickerSetsPage";
|
import { StickerSetsPage } from "./StickerSetsPage";
|
||||||
|
import { GifCatalogPage } from "./GifCatalogPage";
|
||||||
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
|
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
|
||||||
import { ModerationCasesPage } from "./ModerationCasesPage";
|
import { ModerationCasesPage } from "./ModerationCasesPage";
|
||||||
import { StoragePage } from "./StoragePage";
|
import { StoragePage } from "./StoragePage";
|
||||||
|
|
@ -120,6 +121,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
||||||
if (route.path === "/stickers") {
|
if (route.path === "/stickers") {
|
||||||
return <StickerSetsPage kind="stickers" />;
|
return <StickerSetsPage kind="stickers" />;
|
||||||
}
|
}
|
||||||
|
if (route.path === "/gif-catalog") {
|
||||||
|
return <GifCatalogPage />;
|
||||||
|
}
|
||||||
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
|
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
|
||||||
return (
|
return (
|
||||||
<MessageDetailPage
|
<MessageDetailPage
|
||||||
|
|
|
||||||
|
|
@ -30,5 +30,6 @@ export function routeTitle(pathname: string): string {
|
||||||
if (pathname.startsWith("/emoji")) return "Emoji";
|
if (pathname.startsWith("/emoji")) return "Emoji";
|
||||||
if (pathname.startsWith("/messages")) return "Message Audit";
|
if (pathname.startsWith("/messages")) return "Message Audit";
|
||||||
if (pathname.startsWith("/stickers")) return "Stickers";
|
if (pathname.startsWith("/stickers")) return "Stickers";
|
||||||
|
if (pathname.startsWith("/gif-catalog")) return "GIFs";
|
||||||
return "Operations Console";
|
return "Operations Console";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -516,6 +516,10 @@
|
||||||
.gift-file-copy small { color: var(--muted); font-size: 11px; font-weight: 500; }
|
.gift-file-copy small { color: var(--muted); font-size: 11px; font-weight: 500; }
|
||||||
.gift-file-action { padding: 7px 10px; color: var(--brand); background: var(--brand-tint); border: 1px solid var(--brand-tint-border); border-radius: var(--radius-sm); font-size: 11px; font-weight: 800; }
|
.gift-file-action { padding: 7px 10px; color: var(--brand); background: var(--brand-tint); border: 1px solid var(--brand-tint-border); border-radius: var(--radius-sm); font-size: 11px; font-weight: 800; }
|
||||||
|
|
||||||
|
.gif-catalog-preview { display: grid; place-items: center; max-height: 220px; overflow: hidden; background: var(--panel-subtle); border: 1px solid var(--line); border-radius: var(--radius-sm); }
|
||||||
|
.gif-catalog-preview img,
|
||||||
|
.gif-catalog-preview video { max-width: 100%; max-height: 220px; object-fit: contain; }
|
||||||
|
|
||||||
.gift-fields-grid {
|
.gift-fields-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(200px, 1.5fr) repeat(3, minmax(120px, 1fr));
|
grid-template-columns: minmax(200px, 1.5fr) repeat(3, minmax(120px, 1fr));
|
||||||
|
|
|
||||||
|
|
@ -636,6 +636,20 @@ export type StickerSetRow = {
|
||||||
|
|
||||||
export type StickerSetListResponse = { rows: StickerSetRow[] };
|
export type StickerSetListResponse = { rows: StickerSetRow[] };
|
||||||
|
|
||||||
|
export type GifCatalogRow = {
|
||||||
|
// String, not number: 18-19 digit snowflake ids, past JS's 2^53
|
||||||
|
// safe-integer limit.
|
||||||
|
ID: string;
|
||||||
|
Title: string;
|
||||||
|
DocumentID: string;
|
||||||
|
Enabled: boolean;
|
||||||
|
SortOrder: number;
|
||||||
|
CreatedBy: string;
|
||||||
|
CreatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GifCatalogListResponse = { rows: GifCatalogRow[] };
|
||||||
|
|
||||||
export type AccountListResponse = {
|
export type AccountListResponse = {
|
||||||
query: string;
|
query: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|
|
||||||
|
|
@ -761,8 +761,10 @@ func run(logger *zap.Logger) error {
|
||||||
go filesapp.NewLocalDiskUsageWorker(localGuard, cfg.BlobDir, cfg.StorageUsageRefreshInterval, logger.Named("files").Named("diskusage")).Run(ctx)
|
go filesapp.NewLocalDiskUsageWorker(localGuard, cfg.BlobDir, cfg.StorageUsageRefreshInterval, logger.Named("files").Named("diskusage")).Run(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
gifCatalogStore := postgres.NewGifCatalogStore(pool)
|
||||||
filesService := filesapp.NewService(mediaStore, blobBackend, cfg.DC,
|
filesService := filesapp.NewService(mediaStore, blobBackend, cfg.DC,
|
||||||
filesapp.WithLogger(logger),
|
filesapp.WithLogger(logger),
|
||||||
|
filesapp.WithGifCatalog(gifCatalogStore),
|
||||||
filesapp.WithUploadPartQuota(domain.UploadPartQuota{
|
filesapp.WithUploadPartQuota(domain.UploadPartQuota{
|
||||||
MaxBytes: cfg.UploadInFlightMaxBytes,
|
MaxBytes: cfg.UploadInFlightMaxBytes,
|
||||||
MaxParts: cfg.UploadInFlightMaxParts,
|
MaxParts: cfg.UploadInFlightMaxParts,
|
||||||
|
|
@ -1000,6 +1002,7 @@ func run(logger *zap.Logger) error {
|
||||||
botsapp.WithPublicChannelUsernameResolver(channelStore),
|
botsapp.WithPublicChannelUsernameResolver(channelStore),
|
||||||
botsapp.WithUserCache(userCache),
|
botsapp.WithUserCache(userCache),
|
||||||
botsapp.WithStickerSetCreator(filesService),
|
botsapp.WithStickerSetCreator(filesService),
|
||||||
|
botsapp.WithGifCatalogSource(filesService),
|
||||||
botsapp.WithUserStickerSets(accountService),
|
botsapp.WithUserStickerSets(accountService),
|
||||||
botsapp.WithTelegramLogin(telegramLoginService),
|
botsapp.WithTelegramLogin(telegramLoginService),
|
||||||
botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow),
|
botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow),
|
||||||
|
|
@ -1315,6 +1318,7 @@ func run(logger *zap.Logger) error {
|
||||||
PremiumPromo: filesService,
|
PremiumPromo: filesService,
|
||||||
Bots: botsService,
|
Bots: botsService,
|
||||||
ServiceBotCallbacks: botsService,
|
ServiceBotCallbacks: botsService,
|
||||||
|
ServiceBotInlineResults: botsService,
|
||||||
Polls: pollsapp.NewService(pollStore),
|
Polls: pollsapp.NewService(pollStore),
|
||||||
Stories: storiesService,
|
Stories: storiesService,
|
||||||
Phone: phoneService,
|
Phone: phoneService,
|
||||||
|
|
@ -1369,6 +1373,7 @@ func run(logger *zap.Logger) error {
|
||||||
Messages: messagesService,
|
Messages: messagesService,
|
||||||
Photos: filesService,
|
Photos: filesService,
|
||||||
StickerSets: filesService,
|
StickerSets: filesService,
|
||||||
|
GifCatalog: filesService,
|
||||||
Bots: botsService,
|
Bots: botsService,
|
||||||
Emoji: filesService,
|
Emoji: filesService,
|
||||||
Moderation: moderationService,
|
Moderation: moderationService,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Remove the built-in @gif seed. Its private history is left alone: chat rows
|
||||||
|
-- reference the account, and dropping them would rewrite users' dialogs.
|
||||||
|
DELETE FROM public.read_model_versions
|
||||||
|
WHERE owner_user_id = 1250000015 AND peer_type = 'user' AND peer_id = 1250000015;
|
||||||
|
|
||||||
|
DELETE FROM public.peer_usernames
|
||||||
|
WHERE peer_type = 'user' AND peer_id = 1250000015;
|
||||||
|
|
||||||
|
DELETE FROM public.bots WHERE bot_user_id = 1250000015;
|
||||||
85
deploy/migrations/20260807120000_gif_service_bot.up.sql
Normal file
85
deploy/migrations/20260807120000_gif_service_bot.up.sql
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
-- Built-in @gif: answers messages.getInlineBotResults synchronously (server-side,
|
||||||
|
-- no MTProto session or Bot API process -- see rpc.ServiceBotInlineResults) with
|
||||||
|
-- the admin-curated GIF catalog, the same role Telegram's own @gif plays for the
|
||||||
|
-- client's GIF picker "trending"/search panel.
|
||||||
|
--
|
||||||
|
-- Seeded here rather than lazily on first message so the username is occupied
|
||||||
|
-- from the moment the schema is current, same rationale as 20260714003112's
|
||||||
|
-- @verifybot seed.
|
||||||
|
--
|
||||||
|
-- access_hash is double-written with domain.GifBotAccessHash; the two must never
|
||||||
|
-- drift, exactly as for the other service bots.
|
||||||
|
--
|
||||||
|
-- inline_placeholder must be non-empty: internal/rpc/bots_inline.go's
|
||||||
|
-- onMessagesGetInlineBotResults refuses BOT_INLINE_DISABLED otherwise.
|
||||||
|
|
||||||
|
INSERT INTO public.users (
|
||||||
|
id, access_hash, phone, first_name, last_name, username, country_code,
|
||||||
|
created_at, updated_at, verified, support, about, last_seen_at,
|
||||||
|
default_history_ttl_period, is_bot, bot_info_version, premium_expires_at,
|
||||||
|
emoji_status_document_id, emoji_status_until, color_set, color,
|
||||||
|
color_background_emoji_id, profile_color_set, profile_color,
|
||||||
|
profile_color_background_emoji_id
|
||||||
|
) VALUES (
|
||||||
|
1250000015, 7233282977235616768, '', 'GIFs', '', 'gif', '',
|
||||||
|
now(), now(), true, false,
|
||||||
|
'Search and browse this server''s GIF catalog.',
|
||||||
|
0, 0, true, 1, NULL, 0, 0, false, 0, 0, false, 0, 0
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
access_hash = EXCLUDED.access_hash,
|
||||||
|
phone = EXCLUDED.phone,
|
||||||
|
first_name = EXCLUDED.first_name,
|
||||||
|
last_name = EXCLUDED.last_name,
|
||||||
|
username = EXCLUDED.username,
|
||||||
|
verified = EXCLUDED.verified,
|
||||||
|
support = EXCLUDED.support,
|
||||||
|
about = EXCLUDED.about,
|
||||||
|
is_bot = EXCLUDED.is_bot,
|
||||||
|
bot_info_version = GREATEST(public.users.bot_info_version, EXCLUDED.bot_info_version),
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
INSERT INTO public.bots (
|
||||||
|
bot_user_id, owner_user_id, token_secret, description, commands,
|
||||||
|
bot_chat_history, bot_nochats, inline_placeholder, created_at, updated_at,
|
||||||
|
menu_button_type, menu_button_text, menu_button_url, bot_inline_geo
|
||||||
|
) VALUES (
|
||||||
|
1250000015, 1250000015, '',
|
||||||
|
'Search and browse this server''s GIF catalog from the GIF picker.',
|
||||||
|
'[]'::jsonb,
|
||||||
|
false, true, 'Search GIFs', now(), now(), 0, '', '', false
|
||||||
|
)
|
||||||
|
ON CONFLICT (bot_user_id) DO UPDATE SET
|
||||||
|
owner_user_id = EXCLUDED.owner_user_id,
|
||||||
|
token_secret = EXCLUDED.token_secret,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
commands = EXCLUDED.commands,
|
||||||
|
bot_chat_history = EXCLUDED.bot_chat_history,
|
||||||
|
bot_nochats = EXCLUDED.bot_nochats,
|
||||||
|
inline_placeholder = EXCLUDED.inline_placeholder,
|
||||||
|
menu_button_type = EXCLUDED.menu_button_type,
|
||||||
|
menu_button_text = EXCLUDED.menu_button_text,
|
||||||
|
menu_button_url = EXCLUDED.menu_button_url,
|
||||||
|
bot_inline_geo = EXCLUDED.bot_inline_geo,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
INSERT INTO public.peer_usernames (
|
||||||
|
username_lower, username, peer_type, peer_id, active, editable, sort_order, updated_at
|
||||||
|
)
|
||||||
|
VALUES ('gif', 'gif', 'user', 1250000015, true, true, 0, now())
|
||||||
|
ON CONFLICT (username_lower) DO UPDATE SET
|
||||||
|
username = EXCLUDED.username,
|
||||||
|
peer_type = EXCLUDED.peer_type,
|
||||||
|
peer_id = EXCLUDED.peer_id,
|
||||||
|
active = EXCLUDED.active,
|
||||||
|
editable = EXCLUDED.editable,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
INSERT INTO public.read_model_versions (model, owner_user_id, peer_type, peer_id, version, updated_at, hash)
|
||||||
|
VALUES
|
||||||
|
('contact_account', 1250000015, 'user', 1250000015, 1, now(), 12500000150001),
|
||||||
|
('channel_active_memberships', 1250000015, 'user', 1250000015, 1, now(), 12500000150002)
|
||||||
|
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO UPDATE SET
|
||||||
|
version = GREATEST(public.read_model_versions.version, EXCLUDED.version),
|
||||||
|
updated_at = now(),
|
||||||
|
hash = EXCLUDED.hash;
|
||||||
1
deploy/migrations/20260807120001_gif_catalog.down.sql
Normal file
1
deploy/migrations/20260807120001_gif_catalog.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS public.gif_catalog;
|
||||||
17
deploy/migrations/20260807120001_gif_catalog.up.sql
Normal file
17
deploy/migrations/20260807120001_gif_catalog.up.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- Admin-curated GIF catalog: what @gif (see 20260807120000) serves as inline
|
||||||
|
-- results for the client's GIF picker. document_id/document_ids are loose
|
||||||
|
-- bigint references (no FK), matching every other media table in this schema
|
||||||
|
-- (e.g. sticker_sets.thumb_document_id) -- documents are immutable once
|
||||||
|
-- created, so there is nothing to cascade.
|
||||||
|
CREATE TABLE public.gif_catalog (
|
||||||
|
id bigint PRIMARY KEY,
|
||||||
|
title text DEFAULT ''::text NOT NULL,
|
||||||
|
document_id bigint NOT NULL,
|
||||||
|
enabled boolean DEFAULT true NOT NULL,
|
||||||
|
sort_order integer DEFAULT 0 NOT NULL,
|
||||||
|
created_by text DEFAULT ''::text NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX gif_catalog_enabled_sort_idx ON public.gif_catalog (enabled, sort_order, id);
|
||||||
|
|
@ -54,6 +54,10 @@ const (
|
||||||
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"
|
||||||
|
ActionSetGifCatalogEnabled = "gif_catalog.set_enabled"
|
||||||
|
ActionSetGifCatalogSortOrder = "gif_catalog.set_sort_order"
|
||||||
|
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"
|
||||||
|
|
@ -289,6 +293,21 @@ type StickerSetsService interface {
|
||||||
AdminRemoveStickerFromSet(ctx context.Context, setID int64, documentID int64) (domain.StickerSet, []domain.Document, error)
|
AdminRemoveStickerFromSet(ctx context.Context, setID int64, documentID int64) (domain.StickerSet, []domain.Document, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GifCatalogService is the admin-console management surface over the
|
||||||
|
// admin-curated GIF catalog the built-in @gif inline bot serves for the
|
||||||
|
// client's GIF picker.
|
||||||
|
type GifCatalogService interface {
|
||||||
|
// ValidateGifUpload is a pure check (no store writes) so a dry-run preview
|
||||||
|
// can validate an uploaded file's shape without materializing it.
|
||||||
|
ValidateGifUpload(fileName string, data []byte) (mimeType string, ok bool)
|
||||||
|
AdminUploadGifMaterial(ctx context.Context, fileName string, data []byte) (domain.Document, error)
|
||||||
|
AdminCreateGifCatalogEntry(ctx context.Context, title string, documentID int64) (domain.GifCatalogEntry, error)
|
||||||
|
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)
|
||||||
|
AdminDeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
// BotService creates bot accounts on behalf of the admin. It mirrors the
|
// BotService creates bot accounts on behalf of the admin. It mirrors the
|
||||||
// owner-scoped /newbot flow: a bot is a users row (is_bot=true) plus a bots row
|
// owner-scoped /newbot flow: a bot is a users row (is_bot=true) plus a bots row
|
||||||
// owned by ownerUserID, and the returned token is shown once to the operator.
|
// owned by ownerUserID, and the returned token is shown once to the operator.
|
||||||
|
|
@ -353,6 +372,7 @@ type Dependencies struct {
|
||||||
Messages MessagesService
|
Messages MessagesService
|
||||||
Photos AvatarResolver
|
Photos AvatarResolver
|
||||||
StickerSets StickerSetsService
|
StickerSets StickerSetsService
|
||||||
|
GifCatalog GifCatalogService
|
||||||
Bots BotService
|
Bots BotService
|
||||||
Emoji EmojiService
|
Emoji EmojiService
|
||||||
Moderation ModerationService
|
Moderation ModerationService
|
||||||
|
|
@ -383,6 +403,7 @@ type Service struct {
|
||||||
messages MessagesService
|
messages MessagesService
|
||||||
photos AvatarResolver
|
photos AvatarResolver
|
||||||
stickerSets StickerSetsService
|
stickerSets StickerSetsService
|
||||||
|
gifCatalog GifCatalogService
|
||||||
bots BotService
|
bots BotService
|
||||||
emoji EmojiService
|
emoji EmojiService
|
||||||
moderation ModerationService
|
moderation ModerationService
|
||||||
|
|
@ -439,6 +460,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
||||||
if deps.StickerSets != nil {
|
if deps.StickerSets != nil {
|
||||||
s.stickerSets = deps.StickerSets
|
s.stickerSets = deps.StickerSets
|
||||||
}
|
}
|
||||||
|
if deps.GifCatalog != nil {
|
||||||
|
s.gifCatalog = deps.GifCatalog
|
||||||
|
}
|
||||||
if deps.Bots != nil {
|
if deps.Bots != nil {
|
||||||
s.bots = deps.Bots
|
s.bots = deps.Bots
|
||||||
}
|
}
|
||||||
|
|
@ -657,6 +681,30 @@ type RemoveStickerFromSetRequest struct {
|
||||||
DocumentID int64 `json:"document_id"`
|
DocumentID int64 `json:"document_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateGifCatalogEntryRequest struct {
|
||||||
|
CommandMeta
|
||||||
|
Title string `json:"title"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
Data []byte `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetGifCatalogEnabledRequest struct {
|
||||||
|
CommandMeta
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetGifCatalogSortOrderRequest struct {
|
||||||
|
CommandMeta
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteGifCatalogEntryRequest struct {
|
||||||
|
CommandMeta
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
type SetAccountFrozenRequest struct {
|
type SetAccountFrozenRequest struct {
|
||||||
CommandMeta
|
CommandMeta
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
|
|
@ -2737,6 +2785,83 @@ func (s *Service) RemoveStickerFromSet(ctx context.Context, req RemoveStickerFro
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateGifCatalogEntry(ctx context.Context, req CreateGifCatalogEntryRequest) (CommandResult, error) {
|
||||||
|
if s == nil || s.gifCatalog == nil {
|
||||||
|
return CommandResult{}, fmt.Errorf("gif catalog service is not configured")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Title) == "" {
|
||||||
|
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
|
||||||
|
}
|
||||||
|
mimeType, ok := s.gifCatalog.ValidateGifUpload(req.FileName, req.Data)
|
||||||
|
if !ok {
|
||||||
|
return CommandResult{}, domain.ErrGifCatalogFileInvalid
|
||||||
|
}
|
||||||
|
return s.runCommand(ctx, req.CommandMeta, ActionCreateGifCatalogEntry, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||||
|
details := map[string]any{
|
||||||
|
"title": req.Title, "file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
|
||||||
|
}
|
||||||
|
if req.DryRun {
|
||||||
|
return CommandResult{Message: "gif catalog entry validated", Details: details}, nil
|
||||||
|
}
|
||||||
|
doc, err := s.gifCatalog.AdminUploadGifMaterial(ctx, req.FileName, req.Data)
|
||||||
|
if err != nil {
|
||||||
|
return CommandResult{Details: details}, err
|
||||||
|
}
|
||||||
|
entry, err := s.gifCatalog.AdminCreateGifCatalogEntry(ctx, req.Title, doc.ID)
|
||||||
|
if err != nil {
|
||||||
|
return CommandResult{Details: details}, err
|
||||||
|
}
|
||||||
|
details["id"] = strconv.FormatInt(entry.ID, 10)
|
||||||
|
details["document_id"] = strconv.FormatInt(doc.ID, 10)
|
||||||
|
return CommandResult{Message: "gif catalog entry created", Details: details}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetGifCatalogEnabled(ctx context.Context, req SetGifCatalogEnabledRequest) (CommandResult, error) {
|
||||||
|
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
|
||||||
|
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")
|
||||||
|
}
|
||||||
|
return s.runCommand(ctx, req.CommandMeta, ActionSetGifCatalogEnabled, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||||
|
details := map[string]any{"id": strconv.FormatInt(req.ID, 10), "enabled": req.Enabled}
|
||||||
|
if req.DryRun {
|
||||||
|
return CommandResult{Message: "gif catalog entry state change validated", Details: details}, nil
|
||||||
|
}
|
||||||
|
changed, err := s.gifCatalog.AdminSetGifCatalogEnabled(ctx, req.ID, req.Enabled)
|
||||||
|
details["changed"] = changed
|
||||||
|
return CommandResult{Message: "gif catalog entry state updated", Details: details}, err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetGifCatalogSortOrder(ctx context.Context, req SetGifCatalogSortOrderRequest) (CommandResult, error) {
|
||||||
|
if s == nil || s.gifCatalog == nil || req.ID <= 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
|
||||||
|
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")
|
||||||
|
}
|
||||||
|
return s.runCommand(ctx, req.CommandMeta, ActionSetGifCatalogSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||||
|
details := map[string]any{"id": strconv.FormatInt(req.ID, 10), "sort_order": req.SortOrder}
|
||||||
|
if req.DryRun {
|
||||||
|
return CommandResult{Message: "gif catalog entry order change validated", Details: details}, nil
|
||||||
|
}
|
||||||
|
changed, err := s.gifCatalog.AdminSetGifCatalogSortOrder(ctx, req.ID, req.SortOrder)
|
||||||
|
details["changed"] = changed
|
||||||
|
return CommandResult{Message: "gif catalog entry order updated", 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")
|
||||||
|
}
|
||||||
|
return s.runCommand(ctx, req.CommandMeta, ActionDeleteGifCatalogEntry, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||||
|
details := map[string]any{"id": strconv.FormatInt(req.ID, 10)}
|
||||||
|
if req.DryRun {
|
||||||
|
return CommandResult{Message: "gif catalog entry deletion validated", Details: details}, nil
|
||||||
|
}
|
||||||
|
changed, err := s.gifCatalog.AdminDeleteGifCatalogEntry(ctx, req.ID)
|
||||||
|
details["changed"] = changed
|
||||||
|
return CommandResult{Message: "gif catalog entry deleted", Details: details}, err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const maxStickerDocumentBytes = 8 << 20
|
const maxStickerDocumentBytes = 8 << 20
|
||||||
|
|
||||||
// StickerDocumentAnimation returns a sticker/custom-emoji document's preview
|
// StickerDocumentAnimation returns a sticker/custom-emoji document's preview
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,10 @@ type Service interface {
|
||||||
AddStickerToSet(ctx context.Context, req admin.AddStickerToSetRequest) (admin.CommandResult, error)
|
AddStickerToSet(ctx context.Context, req admin.AddStickerToSetRequest) (admin.CommandResult, error)
|
||||||
RemoveStickerFromSet(ctx context.Context, req admin.RemoveStickerFromSetRequest) (admin.CommandResult, error)
|
RemoveStickerFromSet(ctx context.Context, req admin.RemoveStickerFromSetRequest) (admin.CommandResult, error)
|
||||||
StickerDocumentAnimation(ctx context.Context, documentID int64) ([]byte, string, bool, error)
|
StickerDocumentAnimation(ctx context.Context, documentID int64) ([]byte, string, bool, error)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
ModerationCase(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error)
|
ModerationCase(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error)
|
||||||
|
|
@ -203,6 +207,10 @@ func (s *Server) routes() http.Handler {
|
||||||
mux.HandleFunc("POST /v1/stickers/create", s.authenticated(s.handleCreateStickerSet))
|
mux.HandleFunc("POST /v1/stickers/create", s.authenticated(s.handleCreateStickerSet))
|
||||||
mux.HandleFunc("POST /v1/stickers/add", s.authenticated(s.handleAddStickerToSet))
|
mux.HandleFunc("POST /v1/stickers/add", s.authenticated(s.handleAddStickerToSet))
|
||||||
mux.HandleFunc("POST /v1/stickers/remove", s.authenticated(s.handleRemoveStickerFromSet))
|
mux.HandleFunc("POST /v1/stickers/remove", s.authenticated(s.handleRemoveStickerFromSet))
|
||||||
|
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/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/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation))
|
mux.HandleFunc("GET /v1/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation))
|
||||||
mux.HandleFunc("GET /v1/moderation/cases", s.authenticated(s.handleModerationCases))
|
mux.HandleFunc("GET /v1/moderation/cases", s.authenticated(s.handleModerationCases))
|
||||||
|
|
@ -693,6 +701,67 @@ func (s *Server) handleRemoveStickerFromSet(w http.ResponseWriter, r *http.Reque
|
||||||
writeCommandResult(w, result, err)
|
writeCommandResult(w, result, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateGifCatalogEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, domain.MaxGifCatalogUploadSize+(1<<20))
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.MultipartForm != nil {
|
||||||
|
defer r.MultipartForm.RemoveAll()
|
||||||
|
}
|
||||||
|
var req admin.CreateGifCatalogEntryRequest
|
||||||
|
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "gif file is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, domain.MaxGifCatalogUploadSize+1))
|
||||||
|
if err != nil || len(data) == 0 || int64(len(data)) > domain.MaxGifCatalogUploadSize {
|
||||||
|
writeError(w, http.StatusBadRequest, "gif file is empty or too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.FileName = header.Filename
|
||||||
|
req.Data = data
|
||||||
|
result, err := s.svc.CreateGifCatalogEntry(r.Context(), req)
|
||||||
|
writeCommandResult(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSetGifCatalogEnabled(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req admin.SetGifCatalogEnabledRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := s.svc.SetGifCatalogEnabled(r.Context(), req)
|
||||||
|
writeCommandResult(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSetGifCatalogSortOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req admin.SetGifCatalogSortOrderRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := s.svc.SetGifCatalogSortOrder(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) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := s.svc.DeleteGifCatalogEntry(r.Context(), req)
|
||||||
|
writeCommandResult(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleStickerDocumentAnimation(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleStickerDocumentAnimation(w http.ResponseWriter, r *http.Request) {
|
||||||
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil || documentID <= 0 {
|
if err != nil || documentID <= 0 {
|
||||||
|
|
|
||||||
|
|
@ -444,6 +444,22 @@ func (fakeService) StickerDocumentAnimation(context.Context, int64) ([]byte, str
|
||||||
return nil, "", false, nil
|
return nil, "", false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fakeService) CreateGifCatalogEntry(_ context.Context, req admin.CreateGifCatalogEntryRequest) (admin.CommandResult, error) {
|
||||||
|
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeService) SetGifCatalogEnabled(_ context.Context, req admin.SetGifCatalogEnabledRequest) (admin.CommandResult, error) {
|
||||||
|
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeService) SetGifCatalogSortOrder(_ context.Context, req admin.SetGifCatalogSortOrderRequest) (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
|
||||||
|
}
|
||||||
|
|
||||||
func (fakeService) EmojiAnimation(context.Context, int64) ([]byte, bool, error) {
|
func (fakeService) EmojiAnimation(context.Context, int64) ([]byte, bool, error) {
|
||||||
return []byte(`{"v":"5.7","w":100,"h":100}`), true, nil
|
return []byte(`{"v":"5.7","w":100,"h":100}`), true, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
102
internal/app/bots/gifbot.go
Normal file
102
internal/app/bots/gifbot.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
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...)
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,14 @@ type userStickerSetInstaller interface {
|
||||||
InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error
|
InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gifCatalogSource is the built-in @gif inline bot's read-only view of the
|
||||||
|
// admin-curated GIF catalog (app/files.Service satisfies it as-is: it already
|
||||||
|
// exposes GetDocuments for the sticker-set responder above).
|
||||||
|
type gifCatalogSource interface {
|
||||||
|
ListGifCatalog(ctx context.Context, onlyEnabled bool) ([]domain.GifCatalogEntry, error)
|
||||||
|
GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error)
|
||||||
|
}
|
||||||
|
|
||||||
type aiChatGenerator interface {
|
type aiChatGenerator interface {
|
||||||
GenerateTextStream(ctx context.Context, req domain.AITextGenerationRequest, emit func(domain.AIComposeText) error) (domain.AIComposeText, error)
|
GenerateTextStream(ctx context.Context, req domain.AITextGenerationRequest, emit func(domain.AIComposeText) error) (domain.AIComposeText, error)
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +118,7 @@ type Service struct {
|
||||||
verification verificationApplications
|
verification verificationApplications
|
||||||
customVerification customVerifications
|
customVerification customVerifications
|
||||||
verifierTargets verifierBotTargets
|
verifierTargets verifierBotTargets
|
||||||
|
gifCatalog gifCatalogSource
|
||||||
telegramLogin *telegramloginapp.Service
|
telegramLogin *telegramloginapp.Service
|
||||||
hooks RouterHooks
|
hooks RouterHooks
|
||||||
textDrafts TextDraftPusher
|
textDrafts TextDraftPusher
|
||||||
|
|
@ -207,6 +216,19 @@ func WithUserStickerSets(c userStickerSetInstaller) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithGifCatalogSource injects the read-only catalog access used by the
|
||||||
|
// built-in @gif inline bot. Without it, HandlesInlineBot still claims
|
||||||
|
// GifBotUserID but OnInlineQuery reports handled=true with zero results
|
||||||
|
// rather than erroring -- an unconfigured catalog is "nothing to show", not a
|
||||||
|
// bot failure.
|
||||||
|
func WithGifCatalogSource(c gifCatalogSource) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if c != nil {
|
||||||
|
s.gifCatalog = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithAIChatGenerator 注入内置 @ChatBot 使用的 AI 文本生成器。
|
// WithAIChatGenerator 注入内置 @ChatBot 使用的 AI 文本生成器。
|
||||||
func WithAIChatGenerator(g aiChatGenerator) Option {
|
func WithAIChatGenerator(g aiChatGenerator) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
|
|
|
||||||
199
internal/app/files/gif_admin.go
Normal file
199
internal/app/files/gif_admin.go
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateGifUpload is a pure check (no store writes), used by a dry-run
|
||||||
|
// preview before AdminUploadGifMaterial actually materializes the document.
|
||||||
|
func (s *Service) ValidateGifUpload(fileName string, data []byte) (string, bool) {
|
||||||
|
if len(data) == 0 || int64(len(data)) > domain.MaxGifCatalogUploadSize {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return detectGifCatalogUploadMime(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUploadGifMaterial turns a raw uploaded GIF/MP4 file into a loose
|
||||||
|
// Document not yet attached to any catalog entry -- the
|
||||||
|
// upload-bytes-into-a-document-row shape AdminUploadStickerMaterial uses for
|
||||||
|
// stickers, but routed through the same GIF->MP4 normalization the ordinary
|
||||||
|
// user upload path uses (normalizeUploadedGIF in photos.go).
|
||||||
|
//
|
||||||
|
// Transcoding is mandatory, not an optimization: a Telegram client only treats
|
||||||
|
// a document as a playable GIF when it is a silent H.264 MP4 carrying a video
|
||||||
|
// attribute (DocumentData::isGifv() requires mime video/mp4, and the inline
|
||||||
|
// GIF layout sizes each cell from document->dimensions). Storing the raw
|
||||||
|
// upload would produce a catalog entry the picker lays out at zero size and
|
||||||
|
// never animates, so both mime shapes go through the transcoder: it returns
|
||||||
|
// the canonical bytes plus the real width/height/duration the attributes need.
|
||||||
|
//
|
||||||
|
// MP4 input works even though the transcoder stages its input in a .gif temp
|
||||||
|
// file -- ffmpeg detects the format from content, not the extension -- and
|
||||||
|
// re-encoding it is what guarantees faststart/yuv420p/no-audio regardless of
|
||||||
|
// how the operator's file was produced.
|
||||||
|
func (s *Service) AdminUploadGifMaterial(ctx context.Context, fileName string, data []byte) (domain.Document, error) {
|
||||||
|
if len(data) == 0 || int64(len(data)) > domain.MaxGifCatalogUploadSize {
|
||||||
|
return domain.Document{}, domain.ErrGifCatalogFileInvalid
|
||||||
|
}
|
||||||
|
if _, ok := detectGifCatalogUploadMime(data); !ok {
|
||||||
|
return domain.Document{}, domain.ErrGifCatalogFileInvalid
|
||||||
|
}
|
||||||
|
if s.gifs == nil {
|
||||||
|
return domain.Document{}, fmt.Errorf("%w: ffmpeg/ffprobe are required to normalize a GIF for playback", domain.ErrGifCatalogFileInvalid)
|
||||||
|
}
|
||||||
|
converted, err := s.gifs.Transcode(ctx, data)
|
||||||
|
if err != nil || len(converted.Data) == 0 || converted.Width <= 0 || converted.Height <= 0 {
|
||||||
|
s.log.Warn("admin GIF catalog upload conversion failed", zap.Int("input_bytes", len(data)), zap.Error(err))
|
||||||
|
return domain.Document{}, domain.ErrGifCatalogFileInvalid
|
||||||
|
}
|
||||||
|
objectKey, err := s.blobs.Put(ctx, converted.Data)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Document{}, err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(converted.Data)
|
||||||
|
docID := randomID()
|
||||||
|
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||||
|
LocationKey: fmt.Sprintf("doc:%d", docID),
|
||||||
|
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||||
|
ObjectKey: objectKey,
|
||||||
|
Size: int64(len(converted.Data)),
|
||||||
|
SHA256: append([]byte(nil), sum[:]...),
|
||||||
|
MimeType: "video/mp4",
|
||||||
|
}); err != nil {
|
||||||
|
return domain.Document{}, err
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(fileName)
|
||||||
|
if name == "" {
|
||||||
|
name = "animation.gif"
|
||||||
|
}
|
||||||
|
doc := domain.Document{
|
||||||
|
ID: docID,
|
||||||
|
AccessHash: randomID(),
|
||||||
|
FileReference: randomFileReference(),
|
||||||
|
Date: int(time.Now().Unix()),
|
||||||
|
MimeType: "video/mp4",
|
||||||
|
Size: int64(len(converted.Data)),
|
||||||
|
DCID: s.dc,
|
||||||
|
Attributes: canonicalGIFVideoAttributes(
|
||||||
|
[]domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: name}},
|
||||||
|
converted, false),
|
||||||
|
}
|
||||||
|
if err := s.media.PutDocument(ctx, doc); err != nil {
|
||||||
|
return domain.Document{}, err
|
||||||
|
}
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectGifCatalogUploadMime accepts exactly what an inline "gif" result is
|
||||||
|
// allowed to carry (see inlineExternalContentMimeAllowed): a real GIF, or an
|
||||||
|
// MP4 (Telegram normalizes animated GIFs to silent MP4 for delivery, so an
|
||||||
|
// operator uploading an already-converted MP4 is the common case, not an
|
||||||
|
// edge case).
|
||||||
|
func detectGifCatalogUploadMime(data []byte) (string, bool) {
|
||||||
|
switch {
|
||||||
|
case len(data) >= 6 && (string(data[0:6]) == "GIF87a" || string(data[0:6]) == "GIF89a"):
|
||||||
|
return "image/gif", true
|
||||||
|
case len(data) >= 12 && string(data[4:8]) == "ftyp":
|
||||||
|
return "video/mp4", true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateGifCatalogEntry adds an already-materialized document (from
|
||||||
|
// AdminUploadGifMaterial) to the catalog @gif serves.
|
||||||
|
func (s *Service) AdminCreateGifCatalogEntry(ctx context.Context, title string, documentID int64) (domain.GifCatalogEntry, error) {
|
||||||
|
if s.gifCatalog == nil {
|
||||||
|
return domain.GifCatalogEntry{}, domain.ErrGifCatalogUnavailable
|
||||||
|
}
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
if len(title) > domain.MaxGifCatalogTitleLen || documentID == 0 {
|
||||||
|
return domain.GifCatalogEntry{}, domain.ErrGifCatalogEntryInvalid
|
||||||
|
}
|
||||||
|
if _, found, err := s.media.GetDocument(ctx, documentID); err != nil {
|
||||||
|
return domain.GifCatalogEntry{}, err
|
||||||
|
} else if !found {
|
||||||
|
return domain.GifCatalogEntry{}, domain.ErrGifCatalogEntryInvalid
|
||||||
|
}
|
||||||
|
entry, err := s.gifCatalog.CreateGifCatalogEntry(ctx, domain.GifCatalogEntry{
|
||||||
|
ID: randomID(),
|
||||||
|
Title: title,
|
||||||
|
DocumentID: documentID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.GifCatalogEntry{}, err
|
||||||
|
}
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListGifCatalog returns every entry (enabled and disabled), for the
|
||||||
|
// admin panel's list view.
|
||||||
|
func (s *Service) AdminListGifCatalog(ctx context.Context) ([]domain.GifCatalogEntry, error) {
|
||||||
|
if s.gifCatalog == nil {
|
||||||
|
return nil, domain.ErrGifCatalogUnavailable
|
||||||
|
}
|
||||||
|
return s.gifCatalog.ListGifCatalog(ctx, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGifCatalog is bots.gifCatalogSource's read: onlyEnabled=true is what
|
||||||
|
// @gif actually serves.
|
||||||
|
func (s *Service) ListGifCatalog(ctx context.Context, onlyEnabled bool) ([]domain.GifCatalogEntry, error) {
|
||||||
|
if s.gifCatalog == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return s.gifCatalog.ListGifCatalog(ctx, onlyEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSetGifCatalogEnabled toggles whether an entry is served.
|
||||||
|
func (s *Service) AdminSetGifCatalogEnabled(ctx context.Context, id int64, enabled bool) (bool, error) {
|
||||||
|
if s.gifCatalog == nil {
|
||||||
|
return false, domain.ErrGifCatalogUnavailable
|
||||||
|
}
|
||||||
|
changed, err := s.gifCatalog.SetGifCatalogEnabled(ctx, id, enabled)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return false, domain.ErrGifCatalogEntryNotFound
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSetGifCatalogSortOrder rewrites an entry's display position.
|
||||||
|
func (s *Service) AdminSetGifCatalogSortOrder(ctx context.Context, id int64, order int) (bool, error) {
|
||||||
|
if s.gifCatalog == nil {
|
||||||
|
return false, domain.ErrGifCatalogUnavailable
|
||||||
|
}
|
||||||
|
changed, err := s.gifCatalog.SetGifCatalogSortOrder(ctx, id, order)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return false, domain.ErrGifCatalogEntryNotFound
|
||||||
|
}
|
||||||
|
return true, 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) {
|
||||||
|
if s.gifCatalog == nil {
|
||||||
|
return false, domain.ErrGifCatalogUnavailable
|
||||||
|
}
|
||||||
|
changed, err := s.gifCatalog.DeleteGifCatalogEntry(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return false, domain.ErrGifCatalogEntryNotFound
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
@ -87,6 +87,8 @@ type Service struct {
|
||||||
premiumPromoMu sync.RWMutex
|
premiumPromoMu sync.RWMutex
|
||||||
premiumPromo domain.PremiumPromoCatalog
|
premiumPromo domain.PremiumPromoCatalog
|
||||||
premiumPromoReady bool
|
premiumPromoReady bool
|
||||||
|
|
||||||
|
gifCatalog store.GifCatalogStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option 配置 files 服务的可选能力。
|
// Option 配置 files 服务的可选能力。
|
||||||
|
|
@ -165,6 +167,19 @@ func WithUploadPartBackend(backend UploadPartBackend) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithGifCatalog injects the store backing the admin-curated GIF catalog
|
||||||
|
// (AdminUploadGifMaterial/AdminCreateGifCatalogEntry and friends below, plus
|
||||||
|
// the ListGifCatalog the built-in @gif inline bot reads through
|
||||||
|
// bots.gifCatalogSource). Without it those methods report
|
||||||
|
// domain.ErrGifCatalogUnavailable.
|
||||||
|
func WithGifCatalog(c store.GifCatalogStore) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if c != nil {
|
||||||
|
s.gifCatalog = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewService 创建 files 服务。dc 是本 server 的 DC id,写入新建 document/photo 的 dc_id。
|
// NewService 创建 files 服务。dc 是本 server 的 DC id,写入新建 document/photo 的 dc_id。
|
||||||
func NewService(media store.MediaStore, blobs BlobBackend, dc int, opts ...Option) *Service {
|
func NewService(media store.MediaStore, blobs BlobBackend, dc int, opts ...Option) *Service {
|
||||||
s := &Service{
|
s := &Service{
|
||||||
|
|
|
||||||
|
|
@ -54,13 +54,20 @@ const tdesktopClient = "tdesktop"
|
||||||
// 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。
|
// 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。
|
||||||
// - aicompose_tone_* 与 domain/app/ai 默认值一致:TDesktop/DrKLO 创建/预览 tone 时
|
// - aicompose_tone_* 与 domain/app/ai 默认值一致:TDesktop/DrKLO 创建/预览 tone 时
|
||||||
// 直接读取这些 key 做本地输入限制和示例数量。
|
// 直接读取这些 key 做本地输入限制和示例数量。
|
||||||
|
// - gif_search_username="gif" must be present: the client's GIF picker
|
||||||
|
// (trending panel + search-as-you-type) only fires messages.getInlineBotResults
|
||||||
|
// against the bot named here — without this key the picker falls back to
|
||||||
|
// showing nothing but the user's own Saved GIFs. Matches the built-in
|
||||||
|
// @gif system bot (domain.GifBotUserID), whose inline results are served
|
||||||
|
// synchronously in-process (see rpc.ServiceBotInlineResults) from the
|
||||||
|
// admin-curated gif_catalog table.
|
||||||
//
|
//
|
||||||
// WebK directly calls Array.some on fragment_prefixes while rendering user profiles,
|
// WebK directly calls Array.some on fragment_prefixes while rendering user profiles,
|
||||||
// so this compatibility key must always remain an array, even when it is empty.
|
// so this compatibility key must always remain an array, even when it is empty.
|
||||||
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"giveaway_gifts_purchase_available":true,"giveaway_boosts_per_premium":4,"giveaway_countries_max":10,"giveaway_add_peers_max":10,"giveaway_period_max":604800,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"bot_verification_description_length_limit":70,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
|
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"giveaway_gifts_purchase_available":true,"giveaway_boosts_per_premium":4,"giveaway_countries_max":10,"giveaway_add_peers_max":10,"giveaway_period_max":604800,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"bot_verification_description_length_limit":70,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000,"gif_search_username":"gif"`
|
||||||
const tdesktopNoForwardsAppConfig = `,"no_forwards_request_expire_period":86400`
|
const tdesktopNoForwardsAppConfig = `,"no_forwards_request_expire_period":86400`
|
||||||
|
|
||||||
const defaultAppConfigHash = 27 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
const defaultAppConfigHash = 28 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||||
|
|
||||||
// Service 提供客户端启动配置与国家区号目录。
|
// Service 提供客户端启动配置与国家区号目录。
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -619,8 +619,13 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
|
||||||
// Resolution covers both the editable username slot (5..32) and
|
// Resolution covers both the editable username slot (5..32) and
|
||||||
// Fragment-style collectible usernames (4..32). Keep the stricter
|
// Fragment-style collectible usernames (4..32). Keep the stricter
|
||||||
// validUsername check on create/update paths; only lookup accepts the
|
// validUsername check on create/update paths; only lookup accepts the
|
||||||
// collectible lower bound.
|
// collectible lower bound. Built-in system accounts (e.g. @gif, 3
|
||||||
if !domain.ValidCollectibleUsername(username) {
|
// characters) are exempted from the floor itself -- they're fixed,
|
||||||
|
// server-controlled handles, not user input -- but still resolve through
|
||||||
|
// the normal DB-backed path below, so caching/projection/hidden-bot
|
||||||
|
// handling stay exactly as for any other account.
|
||||||
|
_, isSystemUsername := domain.SystemUserByUsername(username)
|
||||||
|
if !isSystemUsername && !domain.ValidCollectibleUsername(username) {
|
||||||
return domain.User{}, false, domain.ErrUsernameInvalid
|
return domain.User{}, false, domain.ErrUsernameInvalid
|
||||||
}
|
}
|
||||||
u, found, err := s.users.ByUsername(ctx, username)
|
u, found, err := s.users.ByUsername(ctx, username)
|
||||||
|
|
|
||||||
|
|
@ -66,5 +66,12 @@ func BuildConfig(dc int, ip string, port int, now time.Time, publicBaseURL strin
|
||||||
WebfileDCID: dc,
|
WebfileDCID: dc,
|
||||||
}
|
}
|
||||||
config.SetReactionsDefault(&tg.ReactionEmoji{Emoticon: DefaultReactionEmoticon})
|
config.SetReactionsDefault(&tg.ReactionEmoji{Emoticon: DefaultReactionEmoticon})
|
||||||
|
// The GIF picker's trending/search panel reads this from help.getConfig's
|
||||||
|
// typed Config (gifs_list_widget.cpp: session().serverConfig().gifSearchUsername)
|
||||||
|
// -- NOT from help.getAppConfig's loose JSON blob, which is a separate RPC/
|
||||||
|
// response entirely. Must match the built-in @gif system bot's username
|
||||||
|
// (domain.GifBotUser().Username), whose inline results are served
|
||||||
|
// synchronously in-process (see rpc.ServiceBotInlineResults).
|
||||||
|
config.SetGifSearchUsername("gif")
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
|
||||||
50
internal/domain/gif_catalog.go
Normal file
50
internal/domain/gif_catalog.go
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrGifCatalogUnavailable is returned by the admin GIF-catalog write path
|
||||||
|
// when no store.GifCatalogStore was configured (files.WithGifCatalog).
|
||||||
|
ErrGifCatalogUnavailable = errors.New("gif catalog is not configured")
|
||||||
|
// ErrGifCatalogFileInvalid is returned when an uploaded file isn't a GIF
|
||||||
|
// or MP4, or exceeds MaxGifCatalogUploadSize.
|
||||||
|
ErrGifCatalogFileInvalid = errors.New("gif catalog file invalid")
|
||||||
|
// ErrGifCatalogEntryInvalid is returned for a title that fails validation
|
||||||
|
// or a document_id that doesn't resolve to an uploaded document.
|
||||||
|
ErrGifCatalogEntryInvalid = errors.New("gif catalog entry invalid")
|
||||||
|
// ErrGifCatalogEntryNotFound is returned by an update/delete against an id
|
||||||
|
// that doesn't exist.
|
||||||
|
ErrGifCatalogEntryNotFound = errors.New("gif catalog entry not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MaxGifCatalogTitleLen bounds an admin-entered catalog entry title.
|
||||||
|
MaxGifCatalogTitleLen = 128
|
||||||
|
// MaxGifCatalogEntries caps how many entries @gif serves in one inline
|
||||||
|
// response -- mirrors MaxBotInlineResults, the TL-level cap the client
|
||||||
|
// itself enforces per messages.getInlineBotResults response.
|
||||||
|
MaxGifCatalogEntries = MaxBotInlineResults
|
||||||
|
// MaxGifCatalogUploadSize bounds one admin-uploaded catalog file.
|
||||||
|
// 20MB matches MaxBotInlineWebSize, the size a client-side inline GIF
|
||||||
|
// result is already allowed to be.
|
||||||
|
MaxGifCatalogUploadSize = MaxBotInlineWebSize
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// (see files.Service.AdminUploadGifMaterial) -- the catalog only tracks which
|
||||||
|
// documents are featured and in what order, it does not own the media itself.
|
||||||
|
type GifCatalogEntry struct {
|
||||||
|
ID int64
|
||||||
|
Title string
|
||||||
|
DocumentID int64
|
||||||
|
Enabled bool
|
||||||
|
SortOrder int
|
||||||
|
CreatedBy string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
package domain
|
package domain
|
||||||
|
|
||||||
import "telesrv/internal/branding"
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"telesrv/internal/branding"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。
|
// OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。
|
||||||
|
|
@ -75,6 +79,17 @@ const (
|
||||||
// startup -- not a document minted specifically for this feature -- so it
|
// startup -- not a document minted specifically for this feature -- so it
|
||||||
// resolves the same way any other seeded custom emoji does.
|
// resolves the same way any other seeded custom emoji does.
|
||||||
VerifierBotDefaultIconDocumentID int64 = 5237699328843200968
|
VerifierBotDefaultIconDocumentID int64 = 5237699328843200968
|
||||||
|
|
||||||
|
// GifBotUserID is the built-in @gif inline bot: it answers
|
||||||
|
// messages.getInlineBotResults synchronously (no MTProto session, no Bot API
|
||||||
|
// process -- see rpc.ServiceBotInlineResults) with the admin-curated GIF
|
||||||
|
// catalog, the same role Telegram's own @gif plays for the client's GIF
|
||||||
|
// picker "trending"/search panel. The id is reserved and stable, so a
|
||||||
|
// restart never re-creates the account under a different identity.
|
||||||
|
GifBotUserID int64 = 1250000015
|
||||||
|
// GifBotAccessHash is fixed and double-written with the seed row in this
|
||||||
|
// feature's migration; the two must never drift.
|
||||||
|
GifBotAccessHash int64 = 7233282977235616768
|
||||||
)
|
)
|
||||||
|
|
||||||
// officialSystemUserPhotoDCID/Stripped 由 files.Service.SeedOfficialSystemAvatar
|
// officialSystemUserPhotoDCID/Stripped 由 files.Service.SeedOfficialSystemAvatar
|
||||||
|
|
@ -270,6 +285,19 @@ func VerifierBotUser() User {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GifBotUser returns the built-in @gif account.
|
||||||
|
func GifBotUser() User {
|
||||||
|
return User{
|
||||||
|
ID: GifBotUserID,
|
||||||
|
AccessHash: GifBotAccessHash,
|
||||||
|
FirstName: "GIFs",
|
||||||
|
Username: "gif",
|
||||||
|
Verified: true,
|
||||||
|
Bot: true,
|
||||||
|
BotInfoVersion: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SystemUserByID 返回内置系统账号;非系统账号返回 ok=false。
|
// SystemUserByID 返回内置系统账号;非系统账号返回 ok=false。
|
||||||
// 所有对 777000 的硬编码注入点统一经此函数,新增内置账号只改这里。
|
// 所有对 777000 的硬编码注入点统一经此函数,新增内置账号只改这里。
|
||||||
func SystemUserByID(id int64) (User, bool) {
|
func SystemUserByID(id int64) (User, bool) {
|
||||||
|
|
@ -286,6 +314,8 @@ func SystemUserByID(id int64) (User, bool) {
|
||||||
return VerifyBotUser(), true
|
return VerifyBotUser(), true
|
||||||
case VerifierBotUserID:
|
case VerifierBotUserID:
|
||||||
return VerifierBotUser(), true
|
return VerifierBotUser(), true
|
||||||
|
case GifBotUserID:
|
||||||
|
return GifBotUser(), true
|
||||||
}
|
}
|
||||||
return User{}, false
|
return User{}, false
|
||||||
}
|
}
|
||||||
|
|
@ -308,9 +338,29 @@ func SystemUserIDs() []int64 {
|
||||||
ChatBotUserID,
|
ChatBotUserID,
|
||||||
VerifyBotUserID,
|
VerifyBotUserID,
|
||||||
VerifierBotUserID,
|
VerifierBotUserID,
|
||||||
|
GifBotUserID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SystemUserByUsername resolves a case-insensitive exact username match
|
||||||
|
// against every built-in account, e.g. for username-lookup paths that
|
||||||
|
// otherwise enforce a minimum length shorter than some reserved system
|
||||||
|
// handles need (@gif is 3 characters -- shorter than
|
||||||
|
// MinCollectibleUsernameLength's 4-character floor for an ordinary lookup).
|
||||||
|
func SystemUserByUsername(username string) (User, bool) {
|
||||||
|
username = NormalizeUsername(username)
|
||||||
|
if username == "" {
|
||||||
|
return User{}, false
|
||||||
|
}
|
||||||
|
for _, id := range SystemUserIDs() {
|
||||||
|
u, ok := SystemUserByID(id)
|
||||||
|
if ok && strings.EqualFold(u.Username, username) {
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return User{}, false
|
||||||
|
}
|
||||||
|
|
||||||
func SystemUserByPhone(phone string) (User, bool) {
|
func SystemUserByPhone(phone string) (User, bool) {
|
||||||
phone = NormalizePhone(phone)
|
phone = NormalizePhone(phone)
|
||||||
for _, id := range SystemUserIDs() {
|
for _, id := range SystemUserIDs() {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
|
|
||||||
"github.com/iamxvbaba/td/tg"
|
"github.com/iamxvbaba/td/tg"
|
||||||
"github.com/iamxvbaba/td/tgerr"
|
"github.com/iamxvbaba/td/tgerr"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
"telesrv/internal/store"
|
"telesrv/internal/store"
|
||||||
|
|
@ -81,6 +82,27 @@ func (r *Router) onMessagesGetInlineBotResults(ctx context.Context, req *tg.Mess
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 内置(进程内)service bot 分支:@gif 没有 MTProto session、也没有 Bot API 消费者,
|
||||||
|
// 走下面的「推 updateBotInlineQuery + 挂起 25s」必然超时,故同步问 responder。
|
||||||
|
//
|
||||||
|
// 结果仍必须登记进 inline registry:messages.sendInlineBotResult 用
|
||||||
|
// (query_id, result_id) 反查用户选中的那一条,query_id==0 会让每次发送都以
|
||||||
|
// QUERY_ID_EMPTY 失败。registerCachedContext 正是「已有结果、只需分配
|
||||||
|
// query_id」这条路径(与 cacheKey 命中时同一个函数),它不写 cacheKey 查询
|
||||||
|
// 缓存,因此管理员改动目录后下一次查询依旧立即生效。
|
||||||
|
if r.deps.ServiceBotInlineResults != nil && r.deps.ServiceBotInlineResults.HandlesInlineBot(bot.ID) {
|
||||||
|
results, handled, err := r.deps.ServiceBotInlineResults.OnInlineQuery(ctx, bot.ID, userID, req.Query, req.Offset)
|
||||||
|
if err != nil {
|
||||||
|
r.log.Warn("service bot inline query",
|
||||||
|
zap.Int64("bot_user_id", bot.ID), zap.Int64("user_id", userID), zap.Error(err))
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
if !handled {
|
||||||
|
return nil, botInvalidErr()
|
||||||
|
}
|
||||||
|
registered := r.inlines.registerCachedContext(ctx, r.clock.Now(), bot.ID, userID, peer, results)
|
||||||
|
return r.tgBotInlineResults(ctx, userID, registered), nil
|
||||||
|
}
|
||||||
cacheKey := inlineCacheKey{
|
cacheKey := inlineCacheKey{
|
||||||
botUserID: bot.ID,
|
botUserID: bot.ID,
|
||||||
userID: userID,
|
userID: userID,
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,29 @@ type ServiceBotCallbacks interface {
|
||||||
OnCallbackQuery(ctx context.Context, query domain.BotCallbackQuery) (domain.BotCallbackAnswer, bool, error)
|
OnCallbackQuery(ctx context.Context, query domain.BotCallbackQuery) (domain.BotCallbackAnswer, bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServiceBotInlineResults answers messages.getInlineBotResults for built-in
|
||||||
|
// bots that run inside this process (@gif); app/bots implements it.
|
||||||
|
//
|
||||||
|
// Same rationale as ServiceBotCallbacks above: an internal bot has no MTProto
|
||||||
|
// session to receive updateBotInlineQuery and no Bot API consumer to drain the
|
||||||
|
// queue, so the ordinary push-and-wait-25s path could only ever time out. A bot
|
||||||
|
// claimed here is answered synchronously by the responder that owns it, and
|
||||||
|
// nothing is registered in the shared inline-query registry for it.
|
||||||
|
//
|
||||||
|
// A nil Deps.ServiceBotInlineResults keeps the edge behaviour exactly as it
|
||||||
|
// was: every inline query is pushed to the bot's session and waited on.
|
||||||
|
//
|
||||||
|
// HandlesInlineBot is deliberately its own method, not a reuse of
|
||||||
|
// ServiceBotCallbacks'/messages.BotResponder's shared HandlesBot: that one
|
||||||
|
// method already drives both private-message and callback routing for the
|
||||||
|
// bots it covers, and @gif should answer inline queries only, not also become
|
||||||
|
// eligible for private-message/callback dispatch it was never given a case
|
||||||
|
// for.
|
||||||
|
type ServiceBotInlineResults interface {
|
||||||
|
HandlesInlineBot(botUserID int64) bool
|
||||||
|
OnInlineQuery(ctx context.Context, botUserID, userID int64, query, offset string) (domain.BotInlineResults, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
// UserIdentityService 是 UsersService 的资料扩展能力,用于 username/phone 解析。
|
// UserIdentityService 是 UsersService 的资料扩展能力,用于 username/phone 解析。
|
||||||
type UserIdentityService interface {
|
type UserIdentityService interface {
|
||||||
CheckUsername(ctx context.Context, userID int64, username string) (bool, error)
|
CheckUsername(ctx context.Context, userID int64, username string) (bool, error)
|
||||||
|
|
@ -1072,6 +1095,7 @@ type Deps struct {
|
||||||
PremiumPromo PremiumPromoService
|
PremiumPromo PremiumPromoService
|
||||||
Bots BotsService
|
Bots BotsService
|
||||||
ServiceBotCallbacks ServiceBotCallbacks
|
ServiceBotCallbacks ServiceBotCallbacks
|
||||||
|
ServiceBotInlineResults ServiceBotInlineResults
|
||||||
Polls PollsService
|
Polls PollsService
|
||||||
Phone PhoneService
|
Phone PhoneService
|
||||||
GroupCalls GroupCallsService
|
GroupCalls GroupCallsService
|
||||||
|
|
|
||||||
27
internal/store/gif_catalog.go
Normal file
27
internal/store/gif_catalog.go
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GifCatalogStore owns the admin-curated GIF catalog the built-in @gif inline
|
||||||
|
// bot serves as results for the client's GIF picker.
|
||||||
|
type GifCatalogStore interface {
|
||||||
|
// CreateGifCatalogEntry inserts a new entry. entry.ID must already be set
|
||||||
|
// by the caller (same convention as documents/photos elsewhere in this
|
||||||
|
// codebase -- ids are app-generated, not database-serial).
|
||||||
|
CreateGifCatalogEntry(ctx context.Context, entry domain.GifCatalogEntry) (domain.GifCatalogEntry, error)
|
||||||
|
// ListGifCatalog returns every entry ordered by (sort_order, id).
|
||||||
|
// onlyEnabled=true is what @gif serves; the admin panel lists everything.
|
||||||
|
ListGifCatalog(ctx context.Context, onlyEnabled bool) ([]domain.GifCatalogEntry, error)
|
||||||
|
// SetGifCatalogEnabled toggles whether an entry is served. changed=false
|
||||||
|
// if the id doesn't exist.
|
||||||
|
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)
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,7 @@ type UserStore struct {
|
||||||
// 预置进表,与 postgres 的迁移种子保持双 store 行为一致。
|
// 预置进表,与 postgres 的迁移种子保持双 store 行为一致。
|
||||||
func NewUserStore() *UserStore {
|
func NewUserStore() *UserStore {
|
||||||
s := &UserStore{byID: make(map[int64]domain.User), nextID: domain.UserIDSequenceBase}
|
s := &UserStore{byID: make(map[int64]domain.User), nextID: domain.UserIDSequenceBase}
|
||||||
for _, id := range []int64{domain.OfficialSystemUserID, domain.BotFatherUserID, domain.StickersBotUserID, domain.ChatBotUserID} {
|
for _, id := range []int64{domain.OfficialSystemUserID, domain.BotFatherUserID, domain.StickersBotUserID, domain.ChatBotUserID, domain.GifBotUserID} {
|
||||||
if u, ok := domain.SystemUserByID(id); ok {
|
if u, ok := domain.SystemUserByID(id); ok {
|
||||||
s.byID[u.ID] = u
|
s.byID[u.ID] = u
|
||||||
}
|
}
|
||||||
|
|
|
||||||
87
internal/store/postgres/gif_catalog.go
Normal file
87
internal/store/postgres/gif_catalog.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store/postgres/sqlcgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GifCatalogStore struct {
|
||||||
|
db sqlcgen.DBTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGifCatalogStore(db sqlcgen.DBTX) *GifCatalogStore {
|
||||||
|
return &GifCatalogStore{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GifCatalogStore) CreateGifCatalogEntry(ctx context.Context, entry domain.GifCatalogEntry) (domain.GifCatalogEntry, error) {
|
||||||
|
if entry.ID == 0 || entry.DocumentID == 0 {
|
||||||
|
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)
|
||||||
|
VALUES ($1, $2, $3, true, $4, $5)
|
||||||
|
RETURNING id, title, document_id, enabled, sort_order, created_by, created_at, updated_at`,
|
||||||
|
entry.ID, entry.Title, entry.DocumentID, entry.SortOrder, entry.CreatedBy)
|
||||||
|
out, err := scanGifCatalogEntry(row.Scan)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GifCatalogEntry{}, fmt.Errorf("create gif catalog entry: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
FROM gif_catalog
|
||||||
|
WHERE NOT $1 OR enabled
|
||||||
|
ORDER BY sort_order, id
|
||||||
|
LIMIT `+fmt.Sprint(domain.MaxGifCatalogEntries), onlyEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list gif catalog: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]domain.GifCatalogEntry, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
item, err := scanGifCatalogEntry(rows.Scan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan gif catalog entry: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GifCatalogStore) SetGifCatalogEnabled(ctx context.Context, id int64, enabled bool) (bool, error) {
|
||||||
|
tag, err := s.db.Exec(ctx, `UPDATE gif_catalog SET enabled = $2, updated_at = now() WHERE id = $1`, id, enabled)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("set gif catalog entry enabled: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GifCatalogStore) SetGifCatalogSortOrder(ctx context.Context, id int64, order int) (bool, error) {
|
||||||
|
tag, err := s.db.Exec(ctx, `UPDATE gif_catalog SET sort_order = $2, updated_at = now() WHERE id = $1`, id, order)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("set gif catalog entry sort order: %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 {
|
||||||
|
return false, fmt.Errorf("delete gif catalog entry: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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); err != nil {
|
||||||
|
return domain.GifCatalogEntry{}, err
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue