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

@ -54,6 +54,10 @@ const (
ActionCreateStickerSet = "stickers.create"
ActionAddStickerToSet = "stickers.add_sticker"
ActionRemoveStickerFromSet = "stickers.remove_sticker"
ActionCreateGifCatalogEntry = "gif_catalog.create"
ActionSetGifCatalogEnabled = "gif_catalog.set_enabled"
ActionSetGifCatalogSortOrder = "gif_catalog.set_sort_order"
ActionDeleteGifCatalogEntry = "gif_catalog.delete"
// Collectible (Fragment-style) username lifecycle.
ActionMintCollectibleUsername = "usernames.collectible.mint"
ActionTransferCollectibleUsername = "usernames.collectible.transfer"
@ -289,6 +293,21 @@ type StickerSetsService interface {
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
// 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.
@ -353,6 +372,7 @@ type Dependencies struct {
Messages MessagesService
Photos AvatarResolver
StickerSets StickerSetsService
GifCatalog GifCatalogService
Bots BotService
Emoji EmojiService
Moderation ModerationService
@ -383,6 +403,7 @@ type Service struct {
messages MessagesService
photos AvatarResolver
stickerSets StickerSetsService
gifCatalog GifCatalogService
bots BotService
emoji EmojiService
moderation ModerationService
@ -439,6 +460,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.StickerSets != nil {
s.stickerSets = deps.StickerSets
}
if deps.GifCatalog != nil {
s.gifCatalog = deps.GifCatalog
}
if deps.Bots != nil {
s.bots = deps.Bots
}
@ -657,6 +681,30 @@ type RemoveStickerFromSetRequest struct {
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 {
CommandMeta
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
// StickerDocumentAnimation returns a sticker/custom-emoji document's preview

View file

@ -75,6 +75,10 @@ type Service interface {
AddStickerToSet(ctx context.Context, req admin.AddStickerToSetRequest) (admin.CommandResult, error)
RemoveStickerFromSet(ctx context.Context, req admin.RemoveStickerFromSetRequest) (admin.CommandResult, 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)
ModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, 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/add", s.authenticated(s.handleAddStickerToSet))
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/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation))
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)
}
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) {
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || documentID <= 0 {

View file

@ -444,6 +444,22 @@ func (fakeService) StickerDocumentAnimation(context.Context, int64) ([]byte, str
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) {
return []byte(`{"v":"5.7","w":100,"h":100}`), true, nil
}

102
internal/app/bots/gifbot.go Normal file
View 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...)
}

View file

@ -44,6 +44,14 @@ type userStickerSetInstaller interface {
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 {
GenerateTextStream(ctx context.Context, req domain.AITextGenerationRequest, emit func(domain.AIComposeText) error) (domain.AIComposeText, error)
}
@ -110,6 +118,7 @@ type Service struct {
verification verificationApplications
customVerification customVerifications
verifierTargets verifierBotTargets
gifCatalog gifCatalogSource
telegramLogin *telegramloginapp.Service
hooks RouterHooks
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 文本生成器。
func WithAIChatGenerator(g aiChatGenerator) Option {
return func(s *Service) {

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

View file

@ -46,8 +46,8 @@ const (
// Service 实现 upload 分片累积、blob 落盘、getFile 下载,并把上传文件组装成 Photo / Document。
type Service struct {
media store.MediaStore
blobs BlobBackend
media store.MediaStore
blobs BlobBackend
// otherBackends holds additional, non-active BlobBackend instances keyed
// by Name() (e.g. "localfs" while s3 is active, or vice versa). Only
// used for reading/deleting rows written before the deployment switched
@ -56,14 +56,14 @@ type Service struct {
// (the row/bytes still exist, but nothing would know how to reach them).
otherBackends map[string]BlobBackend
uploadParts UploadPartBackend
dc int
log *zap.Logger
thumbs VideoThumbnailer
thumbsSet bool
gifs GIFTranscoder
gifsSet bool
blobCache *blobMetaCache
byteCache *blobBytesCache
dc int
log *zap.Logger
thumbs VideoThumbnailer
thumbsSet bool
gifs GIFTranscoder
gifsSet bool
blobCache *blobMetaCache
byteCache *blobBytesCache
// blobMetaSF/blobBytesSF 合并对同一热 blob 的并发首次访问:否则每个并发 getFile 都各打
// 一发 PG GetFileBlob + backend GetRange(热门贴纸/reaction/头像被大量用户同时拉时尤甚)。
blobMetaSF singleflight.Group
@ -87,6 +87,8 @@ type Service struct {
premiumPromoMu sync.RWMutex
premiumPromo domain.PremiumPromoCatalog
premiumPromoReady bool
gifCatalog store.GifCatalogStore
}
// 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。
func NewService(media store.MediaStore, blobs BlobBackend, dc int, opts ...Option) *Service {
s := &Service{

View file

@ -54,13 +54,20 @@ const tdesktopClient = "tdesktop"
// 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。
// - aicompose_tone_* 与 domain/app/ai 默认值一致TDesktop/DrKLO 创建/预览 tone 时
// 直接读取这些 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,
// 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 defaultAppConfigHash = 27 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
const defaultAppConfigHash = 28 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
// Service 提供客户端启动配置与国家区号目录。
//

View file

@ -619,8 +619,13 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
// Resolution covers both the editable username slot (5..32) and
// Fragment-style collectible usernames (4..32). Keep the stricter
// validUsername check on create/update paths; only lookup accepts the
// collectible lower bound.
if !domain.ValidCollectibleUsername(username) {
// collectible lower bound. Built-in system accounts (e.g. @gif, 3
// 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
}
u, found, err := s.users.ByUsername(ctx, username)

View file

@ -66,5 +66,12 @@ func BuildConfig(dc int, ip string, port int, now time.Time, publicBaseURL strin
WebfileDCID: dc,
}
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
}

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

View file

@ -1,6 +1,10 @@
package domain
import "telesrv/internal/branding"
import (
"strings"
"telesrv/internal/branding"
)
const (
// OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。
@ -75,6 +79,17 @@ const (
// startup -- not a document minted specifically for this feature -- so it
// resolves the same way any other seeded custom emoji does.
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
@ -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。
// 所有对 777000 的硬编码注入点统一经此函数,新增内置账号只改这里。
func SystemUserByID(id int64) (User, bool) {
@ -286,6 +314,8 @@ func SystemUserByID(id int64) (User, bool) {
return VerifyBotUser(), true
case VerifierBotUserID:
return VerifierBotUser(), true
case GifBotUserID:
return GifBotUser(), true
}
return User{}, false
}
@ -308,9 +338,29 @@ func SystemUserIDs() []int64 {
ChatBotUserID,
VerifyBotUserID,
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) {
phone = NormalizePhone(phone)
for _, id := range SystemUserIDs() {

View file

@ -9,6 +9,7 @@ import (
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/store"
@ -81,6 +82,27 @@ func (r *Router) onMessagesGetInlineBotResults(ctx context.Context, req *tg.Mess
if err != nil {
return nil, err
}
// 内置进程内service bot 分支:@gif 没有 MTProto session、也没有 Bot API 消费者,
// 走下面的「推 updateBotInlineQuery + 挂起 25s」必然超时故同步问 responder。
//
// 结果仍必须登记进 inline registrymessages.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{
botUserID: bot.ID,
userID: userID,

View file

@ -360,6 +360,29 @@ type ServiceBotCallbacks interface {
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 解析。
type UserIdentityService interface {
CheckUsername(ctx context.Context, userID int64, username string) (bool, error)
@ -1043,49 +1066,50 @@ type Deps struct {
// AuthKeySessionLayers is the protocol-only durable ordering boundary for
// explicit invokeWithLayer evidence. Production must wire the same auth-key
// store used by the MTProto edge; nil is reserved for isolated router tests.
AuthKeySessionLayers store.AuthKeySessionLayerStore
Account AccountService
Privacy PrivacyService
Help HelpService
AccountFreeze AccountFreezeService
AICompose AIComposeService
Ephemeral EphemeralService
EphemeralPush store.EphemeralPushBroker
Moderation ModerationService
Users UsersService
Usernames UsernameRegistryService
BotVerifications BotVerificationService
TelegramLogin TelegramLoginService
Updates UpdatesService
BootstrapUpdates store.BootstrapUpdateJobStore
BotAPIUpdates store.BotAPIUpdateStore
BotCallbacks store.BotCallbackRegistryStore
Contacts ContactsService
Dialogs DialogsService
Chatlists ChatlistsService
Messages MessagesService
Translation TranslationService
Stories StoriesService
Channels ChannelsService
Communities CommunitiesService
Files FilesService
PremiumPromo PremiumPromoService
Bots BotsService
ServiceBotCallbacks ServiceBotCallbacks
Polls PollsService
Phone PhoneService
GroupCalls GroupCallsService
LiveStreams LiveStreamsService
SFU sfu.Service
TURN turnsrv.Service
LangPack LangPackService
Sessions SessionBinder
Inline store.InlineRegistryStore
Limiter RateLimiter
Metrics Metrics
SecretChats SecretChatService
Passkey PasskeyService
Themes ThemeService
AuthKeySessionLayers store.AuthKeySessionLayerStore
Account AccountService
Privacy PrivacyService
Help HelpService
AccountFreeze AccountFreezeService
AICompose AIComposeService
Ephemeral EphemeralService
EphemeralPush store.EphemeralPushBroker
Moderation ModerationService
Users UsersService
Usernames UsernameRegistryService
BotVerifications BotVerificationService
TelegramLogin TelegramLoginService
Updates UpdatesService
BootstrapUpdates store.BootstrapUpdateJobStore
BotAPIUpdates store.BotAPIUpdateStore
BotCallbacks store.BotCallbackRegistryStore
Contacts ContactsService
Dialogs DialogsService
Chatlists ChatlistsService
Messages MessagesService
Translation TranslationService
Stories StoriesService
Channels ChannelsService
Communities CommunitiesService
Files FilesService
PremiumPromo PremiumPromoService
Bots BotsService
ServiceBotCallbacks ServiceBotCallbacks
ServiceBotInlineResults ServiceBotInlineResults
Polls PollsService
Phone PhoneService
GroupCalls GroupCallsService
LiveStreams LiveStreamsService
SFU sfu.Service
TURN turnsrv.Service
LangPack LangPackService
Sessions SessionBinder
Inline store.InlineRegistryStore
Limiter RateLimiter
Metrics Metrics
SecretChats SecretChatService
Passkey PasskeyService
Themes ThemeService
}
// ThemeService 抽象自定义云主题(app/themes):创建/更新/查询主题 + 维护每用户已安装列表。

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

View file

@ -22,7 +22,7 @@ type UserStore struct {
// 预置进表,与 postgres 的迁移种子保持双 store 行为一致。
func NewUserStore() *UserStore {
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 {
s.byID[u.ID] = u
}

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