added full gifts support

This commit is contained in:
onysd 2026-08-07 10:04:13 +03:00
parent ea5b86de8f
commit 354128c106
35 changed files with 1502 additions and 103 deletions

View file

@ -282,6 +282,38 @@ ORDER BY set_kind, sort_order, id`, kind)
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
// 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).

View file

@ -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/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("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/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
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)
}
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) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")

View file

@ -23,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-BKuHz-WL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C7ZZ0RF2.css">
<script type="module" crossorigin src="/assets/index-DQxoDUCy.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CqUW4fne.css">
</head>
<body>
<div id="root"></div>

View file

@ -31,6 +31,7 @@ import type {
ModerationCaseRow,
ModerationReport,
StickerSetListResponse,
GifCatalogListResponse,
VerificationApplicationDetail,
VerificationApplicationListResponse,
VerificationCountsResponse
@ -228,6 +229,8 @@ export const api = {
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 }),
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, {
method: "POST",
body: JSON.stringify(payload)

View file

@ -4,6 +4,7 @@ import {
Bot,
ChevronDown,
Database,
Film,
LayoutDashboard,
LogOut,
Megaphone,
@ -101,6 +102,7 @@ export function Shell({
<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={<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" : ""}`}>
<button
className="nav-section-toggle"

View 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
);
}

View file

@ -15,6 +15,7 @@ import { GroupMessagesPage } from "./GroupMessagesPage";
import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
import { StickerSetsPage } from "./StickerSetsPage";
import { GifCatalogPage } from "./GifCatalogPage";
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
import { ModerationCasesPage } from "./ModerationCasesPage";
import { StoragePage } from "./StoragePage";
@ -120,6 +121,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/stickers") {
return <StickerSetsPage kind="stickers" />;
}
if (route.path === "/gif-catalog") {
return <GifCatalogPage />;
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return (
<MessageDetailPage

View file

@ -30,5 +30,6 @@ export function routeTitle(pathname: string): string {
if (pathname.startsWith("/emoji")) return "Emoji";
if (pathname.startsWith("/messages")) return "Message Audit";
if (pathname.startsWith("/stickers")) return "Stickers";
if (pathname.startsWith("/gif-catalog")) return "GIFs";
return "Operations Console";
}

View file

@ -516,6 +516,10 @@
.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; }
.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 {
display: grid;
grid-template-columns: minmax(200px, 1.5fr) repeat(3, minmax(120px, 1fr));

View file

@ -636,6 +636,20 @@ export type 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 = {
query: string;
limit: number;

View file

@ -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)
}
}
gifCatalogStore := postgres.NewGifCatalogStore(pool)
filesService := filesapp.NewService(mediaStore, blobBackend, cfg.DC,
filesapp.WithLogger(logger),
filesapp.WithGifCatalog(gifCatalogStore),
filesapp.WithUploadPartQuota(domain.UploadPartQuota{
MaxBytes: cfg.UploadInFlightMaxBytes,
MaxParts: cfg.UploadInFlightMaxParts,
@ -1000,6 +1002,7 @@ func run(logger *zap.Logger) error {
botsapp.WithPublicChannelUsernameResolver(channelStore),
botsapp.WithUserCache(userCache),
botsapp.WithStickerSetCreator(filesService),
botsapp.WithGifCatalogSource(filesService),
botsapp.WithUserStickerSets(accountService),
botsapp.WithTelegramLogin(telegramLoginService),
botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow),
@ -1291,45 +1294,46 @@ func run(logger *zap.Logger) error {
help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
help.WithAccountFreezeProvider(adminService),
),
AccountFreeze: adminService,
AICompose: aiComposeService,
Ephemeral: ephemeralService,
EphemeralPush: ephemeralStore,
Moderation: moderationService,
Users: usersService,
Usernames: usernamesService,
BotVerifications: botVerificationService,
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
Updates: updatesService,
BootstrapUpdates: bootstrapUpdateStore,
BotAPIUpdates: botAPIUpdateStore,
BotCallbacks: botCallbackStore,
Contacts: contactsService,
Dialogs: dialogsService,
Chatlists: chatlistsService,
Messages: messagesService,
Translation: translationService,
Channels: channelsService,
Communities: communitiesService,
Files: filesService,
PremiumPromo: filesService,
Bots: botsService,
ServiceBotCallbacks: botsService,
Polls: pollsapp.NewService(pollStore),
Stories: storiesService,
Phone: phoneService,
SecretChats: secretChatService,
Passkey: passkeyService,
Themes: themeService,
GroupCalls: groupCallsService,
LiveStreams: liveStreamDep(liveStreamService),
SFU: sfuService,
TURN: turnService,
LangPack: langPackService,
Sessions: activeSessions,
Metrics: metricRegistry,
Inline: inlineRegistryStore,
Limiter: rateLimiter,
AccountFreeze: adminService,
AICompose: aiComposeService,
Ephemeral: ephemeralService,
EphemeralPush: ephemeralStore,
Moderation: moderationService,
Users: usersService,
Usernames: usernamesService,
BotVerifications: botVerificationService,
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
Updates: updatesService,
BootstrapUpdates: bootstrapUpdateStore,
BotAPIUpdates: botAPIUpdateStore,
BotCallbacks: botCallbackStore,
Contacts: contactsService,
Dialogs: dialogsService,
Chatlists: chatlistsService,
Messages: messagesService,
Translation: translationService,
Channels: channelsService,
Communities: communitiesService,
Files: filesService,
PremiumPromo: filesService,
Bots: botsService,
ServiceBotCallbacks: botsService,
ServiceBotInlineResults: botsService,
Polls: pollsapp.NewService(pollStore),
Stories: storiesService,
Phone: phoneService,
SecretChats: secretChatService,
Passkey: passkeyService,
Themes: themeService,
GroupCalls: groupCallsService,
LiveStreams: liveStreamDep(liveStreamService),
SFU: sfuService,
TURN: turnService,
LangPack: langPackService,
Sessions: activeSessions,
Metrics: metricRegistry,
Inline: inlineRegistryStore,
Limiter: rateLimiter,
}, logger.Named("rpc"), clock.System)
readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{
ReadModelVersions: readModelVersionStore,
@ -1369,6 +1373,7 @@ func run(logger *zap.Logger) error {
Messages: messagesService,
Photos: filesService,
StickerSets: filesService,
GifCatalog: filesService,
Bots: botsService,
Emoji: filesService,
Moderation: moderationService,