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

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)