feat: sync built-in sticker bot

This commit is contained in:
A 2026-07-01 21:55:55 +08:00
parent 7096625e13
commit 6867d201ed
60 changed files with 7063 additions and 144 deletions

View file

@ -559,6 +559,125 @@ func (s *PasswordStore) ClearStickerCollection(ctx context.Context, userID int64
return nil
}
func (s *PasswordStore) InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error {
if userID == 0 || setID == 0 {
return domain.ErrStickerInvalid
}
orderValue := int64(installedDate) << 32
_, err := s.db.Exec(ctx, `
INSERT INTO user_sticker_sets (owner_user_id, sticker_set_id, set_kind, archived, installed_date, order_value, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
ON CONFLICT (owner_user_id, sticker_set_id) DO UPDATE SET
set_kind = EXCLUDED.set_kind,
archived = EXCLUDED.archived,
installed_date = CASE
WHEN user_sticker_sets.installed_date = 0 THEN EXCLUDED.installed_date
ELSE user_sticker_sets.installed_date
END,
order_value = EXCLUDED.order_value,
updated_at = now()`,
userID, setID, string(kind), archived, installedDate, orderValue)
if err != nil {
return fmt.Errorf("install user sticker set: %w", err)
}
return nil
}
func (s *PasswordStore) UninstallUserStickerSet(ctx context.Context, userID int64, setID int64) error {
if _, err := s.db.Exec(ctx, `
DELETE FROM user_sticker_sets
WHERE owner_user_id = $1 AND sticker_set_id = $2`, userID, setID); err != nil {
return fmt.Errorf("uninstall user sticker set: %w", err)
}
return nil
}
func (s *PasswordStore) SetUserStickerSetArchived(ctx context.Context, userID int64, setID int64, archived bool, now int) error {
orderValue := int64(now) << 32
_, err := s.db.Exec(ctx, `
UPDATE user_sticker_sets
SET archived = $3,
order_value = CASE WHEN $3::boolean = false AND $4::bigint > 0 THEN $4::bigint ELSE order_value END,
updated_at = now()
WHERE owner_user_id = $1 AND sticker_set_id = $2`, userID, setID, archived, orderValue)
if err != nil {
return fmt.Errorf("set user sticker set archived: %w", err)
}
return nil
}
func (s *PasswordStore) ReorderUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, order []int64, now int) error {
if len(order) == 0 {
return nil
}
return withTx(ctx, s.db, "reorder user sticker sets", func(tx pgx.Tx) error {
orderValue := int64(now) << 32
for _, id := range order {
if _, err := tx.Exec(ctx, `
UPDATE user_sticker_sets
SET order_value = $4, updated_at = now()
WHERE owner_user_id = $1 AND set_kind = $2 AND sticker_set_id = $3`,
userID, string(kind), id, orderValue); err != nil {
return fmt.Errorf("update user sticker set order: %w", err)
}
orderValue--
}
return nil
})
}
func (s *PasswordStore) ListUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, archived *bool, offsetID int64, limit int) ([]domain.UserStickerSet, int, error) {
if userID == 0 {
return nil, 0, nil
}
if limit <= 0 || limit > domain.MaxInstalledStickerSets {
limit = domain.MaxInstalledStickerSets
}
var archivedFilter any
if archived != nil {
archivedFilter = *archived
}
rows, err := s.db.Query(ctx, `
WITH ordered AS (
SELECT owner_user_id, sticker_set_id, set_kind, archived, installed_date, order_value,
ROW_NUMBER() OVER (ORDER BY order_value DESC, sticker_set_id DESC) AS rn,
COUNT(*) OVER () AS total
FROM user_sticker_sets
WHERE owner_user_id = $1
AND set_kind = $2
AND ($3::boolean IS NULL OR archived = $3::boolean)
),
page AS (
SELECT COALESCE((SELECT rn FROM ordered WHERE sticker_set_id = $4), 0) AS offset_rn
)
SELECT owner_user_id, sticker_set_id, set_kind, archived, installed_date, order_value, total
FROM ordered, page
WHERE ordered.rn > page.offset_rn
ORDER BY ordered.rn ASC
LIMIT $5`, userID, string(kind), archivedFilter, offsetID, limit)
if err != nil {
return nil, 0, fmt.Errorf("list user sticker sets: %w", err)
}
defer rows.Close()
out := make([]domain.UserStickerSet, 0, limit)
total := 0
for rows.Next() {
var (
item domain.UserStickerSet
kindText string
)
if err := rows.Scan(&item.OwnerUserID, &item.StickerSetID, &kindText, &item.Archived, &item.InstalledDate, &item.OrderValue, &total); err != nil {
return nil, 0, fmt.Errorf("scan user sticker set: %w", err)
}
item.Kind = domain.StickerSetKind(kindText)
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("iterate user sticker sets: %w", err)
}
return out, total, nil
}
func nullableBool(v *bool) any {
if v == nil {
return nil

View file

@ -8,7 +8,9 @@ import (
"sync"
"time"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"telesrv/internal/domain"
@ -237,15 +239,27 @@ ON CONFLICT (key) DO UPDATE SET
// ---- 文档 ----
func (s *MediaStore) PutDocument(ctx context.Context, doc domain.Document) error {
attrs, err := jsonArrayOrEmpty(doc.Attributes)
params, err := putDocumentParams(doc)
if err != nil {
return err
}
if err := s.q.PutDocument(ctx, params); err != nil {
return err
}
s.documents.put(doc.ID, doc)
return nil
}
func putDocumentParams(doc domain.Document) (sqlcgen.PutDocumentParams, error) {
attrs, err := jsonArrayOrEmpty(doc.Attributes)
if err != nil {
return sqlcgen.PutDocumentParams{}, err
}
thumbs, err := jsonArrayOrEmpty(doc.Thumbs)
if err != nil {
return err
return sqlcgen.PutDocumentParams{}, err
}
if err := s.q.PutDocument(ctx, sqlcgen.PutDocumentParams{
return sqlcgen.PutDocumentParams{
ID: doc.ID,
AccessHash: doc.AccessHash,
FileReference: bytesOrEmpty(doc.FileReference),
@ -255,11 +269,7 @@ func (s *MediaStore) PutDocument(ctx context.Context, doc domain.Document) error
DcID: int32(doc.DCID),
AttributesJson: attrs,
ThumbsJson: thumbs,
}); err != nil {
return err
}
s.documents.put(doc.ID, doc)
return nil
}, nil
}
func (s *MediaStore) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) {
@ -559,53 +569,399 @@ func (s *MediaStore) PutStickerSet(ctx context.Context, set domain.StickerSet) e
})
}
func (s *MediaStore) GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) {
row, err := s.q.GetStickerSetByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.StickerSet{}, false, nil
func (s *MediaStore) CreateStickerSet(ctx context.Context, set domain.StickerSet, docs []domain.Document) error {
err := withTx(ctx, s.db, "create sticker set", func(tx pgx.Tx) error {
if err := insertStickerSet(ctx, tx, set); err != nil {
return err
}
return domain.StickerSet{}, false, err
qtx := s.q.WithTx(tx)
for _, doc := range docs {
params, err := putDocumentParams(doc)
if err != nil {
return err
}
if err := qtx.PutDocument(ctx, params); err != nil {
return err
}
}
return nil
})
if err != nil {
if stickerSetShortNameConflict(err) {
return domain.ErrStickerSetShortNameOccupied
}
return err
}
return stickerSetFromRow(row)
for _, doc := range docs {
s.documents.put(doc.ID, doc)
}
return nil
}
func (s *MediaStore) UpdateStickerSet(ctx context.Context, set domain.StickerSet, docs []domain.Document) error {
err := withTx(ctx, s.db, "update sticker set", func(tx pgx.Tx) error {
if err := updateStickerSet(ctx, tx, set); err != nil {
return err
}
qtx := s.q.WithTx(tx)
for _, doc := range docs {
params, err := putDocumentParams(doc)
if err != nil {
return err
}
if err := qtx.PutDocument(ctx, params); err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
for _, doc := range docs {
s.documents.put(doc.ID, doc)
}
return nil
}
func (s *MediaStore) DeleteStickerSet(ctx context.Context, setID int64, creatorUserID int64) error {
return withTx(ctx, s.db, "delete sticker set", func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE sticker_sets
SET deleted = true, updated_at = now()
WHERE id = $1
AND creator_user_id = $2
AND deleted = false`, setID, creatorUserID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return domain.ErrStickerSetInvalid
}
_, err = tx.Exec(ctx, `DELETE FROM user_sticker_sets WHERE sticker_set_id = $1`, setID)
return err
})
}
func insertStickerSet(ctx context.Context, db sqlcgen.DBTX, set domain.StickerSet) error {
thumbs, err := jsonArrayOrEmpty(set.Thumbs)
if err != nil {
return err
}
docIDs, err := jsonArrayOrEmpty(set.DocumentIDs)
if err != nil {
return err
}
packs, err := jsonArrayOrEmpty(set.Packs)
if err != nil {
return err
}
keywords, err := jsonArrayOrEmpty(set.Keywords)
if err != nil {
return err
}
kind := string(set.Kind)
if kind == "" {
kind = string(domain.StickerSetKindStickers)
}
_, err = db.Exec(ctx, `
INSERT INTO sticker_sets (
id, access_hash, short_name, title, count, hash, set_kind,
official, animated, videos, emojis, masks, text_color, creator_user_id,
installed, archived, deleted, installed_date,
thumb_document_id, thumbs, thumb_dc_id, thumb_version,
document_ids, packs, keywords, sort_order, system_key, software, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12, $13, $14,
$15, $16, $17, $18,
$19, $20::jsonb, $21, $22,
$23::jsonb, $24::jsonb, $25::jsonb, $26, $27, $28, now()
)`,
set.ID, set.AccessHash, set.ShortName, set.Title, set.Count, set.Hash, kind,
set.Official, set.Animated, set.Videos, set.Emojis, set.Masks, set.TextColor, set.CreatorUserID,
set.Installed, set.Archived, set.Deleted, set.InstalledDate,
set.ThumbDocumentID, thumbs, set.ThumbDCID, set.ThumbVersion,
docIDs, packs, keywords, set.SortOrder, set.SystemKey, set.Software,
)
return err
}
func updateStickerSet(ctx context.Context, db sqlcgen.DBTX, set domain.StickerSet) error {
thumbs, err := jsonArrayOrEmpty(set.Thumbs)
if err != nil {
return err
}
docIDs, err := jsonArrayOrEmpty(set.DocumentIDs)
if err != nil {
return err
}
packs, err := jsonArrayOrEmpty(set.Packs)
if err != nil {
return err
}
keywords, err := jsonArrayOrEmpty(set.Keywords)
if err != nil {
return err
}
kind := string(set.Kind)
if kind == "" {
kind = string(domain.StickerSetKindStickers)
}
tag, err := db.Exec(ctx, `
UPDATE sticker_sets
SET title = $3,
count = $4,
hash = $5,
set_kind = $6,
official = $7,
animated = $8,
videos = $9,
emojis = $10,
masks = $11,
text_color = $12,
installed = $13,
archived = $14,
installed_date = $15,
thumb_document_id = $16,
thumbs = $17::jsonb,
thumb_dc_id = $18,
thumb_version = $19,
document_ids = $20::jsonb,
packs = $21::jsonb,
keywords = $22::jsonb,
sort_order = $23,
software = $24,
updated_at = now()
WHERE id = $1
AND access_hash = $2
AND deleted = false`,
set.ID, set.AccessHash,
set.Title, set.Count, set.Hash, kind,
set.Official, set.Animated, set.Videos, set.Emojis, set.Masks, set.TextColor,
set.Installed, set.Archived, set.InstalledDate,
set.ThumbDocumentID, thumbs, set.ThumbDCID, set.ThumbVersion,
docIDs, packs, keywords, set.SortOrder, set.Software,
)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return domain.ErrStickerSetInvalid
}
return nil
}
func stickerSetShortNameConflict(err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != pgerrcode.UniqueViolation {
return false
}
switch pgErr.ConstraintName {
case "sticker_sets_short_name_lower_idx", "sticker_sets_short_name_idx":
return true
default:
return false
}
}
func (s *MediaStore) GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) {
return queryStickerSet(ctx, s.db, "id = $1", id)
}
func (s *MediaStore) GetStickerSetByShortName(ctx context.Context, shortName string) (domain.StickerSet, bool, error) {
row, err := s.q.GetStickerSetByShortName(ctx, shortName)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.StickerSet{}, false, nil
}
return domain.StickerSet{}, false, err
}
return stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(row))
return queryStickerSet(ctx, s.db, "lower(short_name) = lower($1)", shortName)
}
func (s *MediaStore) GetStickerSetBySystemKey(ctx context.Context, systemKey string) (domain.StickerSet, bool, error) {
row, err := s.q.GetStickerSetBySystemKey(ctx, systemKey)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.StickerSet{}, false, nil
}
return domain.StickerSet{}, false, err
}
return stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(row))
return queryStickerSet(ctx, s.db, "system_key = $1", systemKey)
}
func (s *MediaStore) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) {
rows, err := s.q.ListStickerSetsByKind(ctx, string(kind))
rows, err := s.db.Query(ctx, stickerSetSelectSQL+`
WHERE set_kind = $1 AND deleted = false
ORDER BY sort_order ASC, id ASC`, string(kind))
if err != nil {
return nil, err
}
out := make([]domain.StickerSet, 0, len(rows))
for _, r := range rows {
set, _, err := stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(r))
defer rows.Close()
out := []domain.StickerSet{}
for rows.Next() {
set, err := scanStickerSet(rows)
if err != nil {
return nil, err
}
out = append(out, set)
}
return out, nil
return out, rows.Err()
}
func (s *MediaStore) ListStickerSetsByCreator(ctx context.Context, creatorUserID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error) {
if limit <= 0 {
limit = domain.MaxCreatedStickerSets
}
if limit > domain.MaxCreatedStickerSets {
limit = domain.MaxCreatedStickerSets
}
var total int
if err := s.db.QueryRow(ctx, `
SELECT count(*)::int
FROM sticker_sets
WHERE creator_user_id = $1 AND deleted = false`, creatorUserID).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.db.Query(ctx, stickerSetSelectSQL+`
WHERE creator_user_id = $1
AND deleted = false
AND ($2::bigint = 0 OR id < $2::bigint)
ORDER BY id DESC
LIMIT $3`, creatorUserID, offsetID, limit)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []domain.StickerSet{}
for rows.Next() {
set, err := scanStickerSet(rows)
if err != nil {
return nil, 0, err
}
set.Creator = true
out = append(out, set)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return out, total, nil
}
func (s *MediaStore) StickerSetShortNameAvailable(ctx context.Context, shortName string) (bool, error) {
var exists bool
if err := s.db.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM sticker_sets
WHERE lower(short_name) = lower($1)
AND short_name <> ''
AND deleted = false
)`, shortName).Scan(&exists); err != nil {
return false, err
}
return !exists, nil
}
const stickerSetSelectSQL = `
SELECT
id, access_hash, short_name, title, count, hash, set_kind,
official, animated, videos, emojis, masks, installed, archived, installed_date,
thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version,
document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key,
creator_user_id, text_color, deleted, software, keywords::text AS keywords_json
FROM sticker_sets
`
type stickerSetScanner interface {
Scan(dest ...any) error
}
func queryStickerSet(ctx context.Context, db sqlcgen.DBTX, predicate string, args ...any) (domain.StickerSet, bool, error) {
row := db.QueryRow(ctx, stickerSetSelectSQL+"WHERE "+predicate+" AND deleted = false LIMIT 1", args...)
set, err := scanStickerSet(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.StickerSet{}, false, nil
}
return domain.StickerSet{}, false, err
}
return set, true, nil
}
func scanStickerSet(row stickerSetScanner) (domain.StickerSet, error) {
var (
id int64
accessHash int64
shortName string
title string
count int32
hash int32
kind string
official bool
animated bool
videos bool
emojis bool
masks bool
installed bool
archived bool
installedDate int32
thumbDocumentID int64
thumbsJSON string
thumbDCID int32
thumbVersion int32
docIDsJSON string
packsJSON string
sortOrder int32
systemKey string
creatorUserID int64
textColor bool
deleted bool
software string
keywordsJSON string
)
if err := row.Scan(
&id, &accessHash, &shortName, &title, &count, &hash, &kind,
&official, &animated, &videos, &emojis, &masks, &installed, &archived, &installedDate,
&thumbDocumentID, &thumbsJSON, &thumbDCID, &thumbVersion,
&docIDsJSON, &packsJSON, &sortOrder, &systemKey,
&creatorUserID, &textColor, &deleted, &software, &keywordsJSON,
); err != nil {
return domain.StickerSet{}, err
}
thumbs, err := decodePhotoSizes(thumbsJSON)
if err != nil {
return domain.StickerSet{}, err
}
docIDs, err := decodeInt64Slice(docIDsJSON)
if err != nil {
return domain.StickerSet{}, err
}
packs, err := decodeStickerPacks(packsJSON)
if err != nil {
return domain.StickerSet{}, err
}
keywords, err := decodeStickerKeywords(keywordsJSON)
if err != nil {
return domain.StickerSet{}, err
}
return domain.StickerSet{
ID: id,
AccessHash: accessHash,
ShortName: shortName,
Title: title,
Count: int(count),
Hash: int(hash),
Kind: domain.StickerSetKind(kind),
Official: official,
Animated: animated,
Videos: videos,
Emojis: emojis,
Masks: masks,
TextColor: textColor,
CreatorUserID: creatorUserID,
Installed: installed,
Archived: archived,
Deleted: deleted,
InstalledDate: int(installedDate),
ThumbDocumentID: thumbDocumentID,
Thumbs: thumbs,
ThumbDCID: int(thumbDCID),
ThumbVersion: int(thumbVersion),
DocumentIDs: docIDs,
Packs: packs,
Keywords: keywords,
SortOrder: int(sortOrder),
SystemKey: systemKey,
Software: software,
}, nil
}
func (s *MediaStore) CountStickerSets(ctx context.Context) (int, error) {

View file

@ -136,3 +136,14 @@ func decodeStickerPacks(s string) ([]domain.StickerPack, error) {
}
return out, nil
}
func decodeStickerKeywords(s string) ([]domain.StickerKeyword, error) {
if s == "" || s == "[]" || s == "null" {
return nil, nil
}
var out []domain.StickerKeyword
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil, err
}
return out, nil
}