Skip to content

Commit 143e49c

Browse files
ernadoclaude
andcommitted
feat(stickers): add sticker file upload and set management
UploadStickerFile (uploads via messages.uploadMedia and returns a file_id-bearing File), CreateNewStickerSet, AddStickerToSet, DeleteStickerFromSet and SetStickerPositionInSet over the stickers.* API. InputSticker carries the file (file_id or upload), format, emoji and keywords; StickerFormat maps to the MTProto MIME type. GetStickerSet and SetStickerSetThumb deferred (need full Sticker[] conversion). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3e8fd06 commit 143e49c

2 files changed

Lines changed: 281 additions & 0 deletions

File tree

sticker.go

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"strings"
6+
7+
"github.com/gotd/td/fileid"
8+
"github.com/gotd/td/tg"
9+
)
10+
11+
// StickerFormat is the format of a sticker file.
12+
type StickerFormat string
13+
14+
const (
15+
// StickerFormatStatic is a static .WEBP or .PNG sticker.
16+
StickerFormatStatic StickerFormat = "static"
17+
// StickerFormatAnimated is an animated .TGS sticker.
18+
StickerFormatAnimated StickerFormat = "animated"
19+
// StickerFormatVideo is a video .WEBM sticker.
20+
StickerFormatVideo StickerFormat = "video"
21+
)
22+
23+
// mimeStickerStatic is the MIME type used for static (and unknown) stickers.
24+
const mimeStickerStatic = "image/png"
25+
26+
// mimeType reports the MTProto MIME type for the sticker format. Static (and
27+
// any unknown) formats default to PNG.
28+
func (f StickerFormat) mimeType() string {
29+
switch f {
30+
case StickerFormatAnimated:
31+
return "application/x-tgsticker"
32+
case StickerFormatVideo:
33+
return "video/webm"
34+
case StickerFormatStatic:
35+
return mimeStickerStatic
36+
default:
37+
return mimeStickerStatic
38+
}
39+
}
40+
41+
// InputSticker describes a sticker to be added to a set.
42+
type InputSticker struct {
43+
// Sticker is the sticker file: a file_id from UploadStickerFile, or a local
44+
// upload. URLs are not accepted.
45+
Sticker InputFile
46+
// Format is the sticker file format.
47+
Format StickerFormat
48+
// EmojiList are the emoji associated with the sticker (1-20).
49+
EmojiList []string
50+
// Keywords are optional search keywords, comma-joined on the wire.
51+
Keywords []string
52+
}
53+
54+
// UploadStickerFile uploads a file for later use in a sticker set and returns a
55+
// File whose file_id can be passed to CreateNewStickerSet or AddStickerToSet.
56+
func (b *Bot) UploadStickerFile(ctx context.Context, userID int64, sticker InputFile, format StickerFormat) (*File, error) {
57+
up, ok := sticker.(*InputFileUpload)
58+
if !ok {
59+
return nil, &Error{Code: 400, Description: "Bad Request: sticker file must be an uploaded file"}
60+
}
61+
doc, err := b.uploadStickerDocument(ctx, up, format)
62+
if err != nil {
63+
return nil, err
64+
}
65+
encoded, err := fileid.EncodeFileID(fileid.FromDocument(doc))
66+
if err != nil {
67+
return nil, asAPIError(err)
68+
}
69+
return &File{
70+
FileID: encoded,
71+
FileUniqueID: fileUniqueID(fileid.FromDocument(doc)),
72+
FileSize: doc.Size,
73+
}, nil
74+
}
75+
76+
// uploadStickerDocument uploads a local file and turns it into a saved document
77+
// via messages.uploadMedia, returning the resulting document.
78+
func (b *Bot) uploadStickerDocument(ctx context.Context, up *InputFileUpload, format StickerFormat) (*tg.Document, error) {
79+
uploaded, err := b.uploadInputFile(ctx, up)
80+
if err != nil {
81+
return nil, err
82+
}
83+
name := up.Name
84+
if name == "" {
85+
name = "sticker"
86+
}
87+
media, err := b.raw.MessagesUploadMedia(ctx, &tg.MessagesUploadMediaRequest{
88+
Peer: &tg.InputPeerSelf{},
89+
Media: &tg.InputMediaUploadedDocument{
90+
File: uploaded,
91+
MimeType: format.mimeType(),
92+
Attributes: []tg.DocumentAttributeClass{
93+
&tg.DocumentAttributeFilename{FileName: name},
94+
},
95+
},
96+
})
97+
if err != nil {
98+
return nil, asAPIError(err)
99+
}
100+
mediaDoc, ok := media.(*tg.MessageMediaDocument)
101+
if !ok {
102+
return nil, &Error{Code: 500, Description: "Internal Server Error: unexpected upload media response"}
103+
}
104+
doc, ok := mediaDoc.Document.(*tg.Document)
105+
if !ok {
106+
return nil, &Error{Code: 500, Description: "Internal Server Error: uploaded media is not a document"}
107+
}
108+
return doc, nil
109+
}
110+
111+
// resolveStickerItem converts an InputSticker into the MTProto set item,
112+
// uploading the file when it is a local upload.
113+
func (b *Bot) resolveStickerItem(ctx context.Context, sticker InputSticker) (tg.InputStickerSetItem, error) {
114+
var doc tg.InputDocumentClass
115+
switch f := sticker.Sticker.(type) {
116+
case InputFileID:
117+
d, err := inputDocumentFromFileID(string(f))
118+
if err != nil {
119+
return tg.InputStickerSetItem{}, err
120+
}
121+
doc = d
122+
case *InputFileUpload:
123+
uploaded, err := b.uploadStickerDocument(ctx, f, sticker.Format)
124+
if err != nil {
125+
return tg.InputStickerSetItem{}, err
126+
}
127+
doc = &tg.InputDocument{ID: uploaded.ID, AccessHash: uploaded.AccessHash, FileReference: uploaded.FileReference}
128+
default:
129+
return tg.InputStickerSetItem{}, &Error{Code: 400, Description: "Bad Request: sticker must be a file_id or an uploaded file"}
130+
}
131+
132+
item := tg.InputStickerSetItem{
133+
Document: doc,
134+
Emoji: strings.Join(sticker.EmojiList, ""),
135+
}
136+
if len(sticker.Keywords) > 0 {
137+
item.SetKeywords(strings.Join(sticker.Keywords, ","))
138+
}
139+
return item, nil
140+
}
141+
142+
// CreateNewStickerSet creates a new sticker set owned by the given user. name is
143+
// the set short name (used in t.me/addstickers/<name>).
144+
func (b *Bot) CreateNewStickerSet(
145+
ctx context.Context, userID int64, name, title string, stickers []InputSticker, opts ...StickerSetOption,
146+
) error {
147+
var cfg stickerSetConfig
148+
for _, o := range opts {
149+
o(&cfg)
150+
}
151+
user, err := b.resolveInputUser(ctx, userID)
152+
if err != nil {
153+
return err
154+
}
155+
items := make([]tg.InputStickerSetItem, 0, len(stickers))
156+
for _, s := range stickers {
157+
item, err := b.resolveStickerItem(ctx, s)
158+
if err != nil {
159+
return err
160+
}
161+
items = append(items, item)
162+
}
163+
if _, err := b.raw.StickersCreateStickerSet(ctx, &tg.StickersCreateStickerSetRequest{
164+
Masks: cfg.masks,
165+
UserID: user,
166+
Title: title,
167+
ShortName: name,
168+
Stickers: items,
169+
}); err != nil {
170+
return asAPIError(err)
171+
}
172+
return nil
173+
}
174+
175+
// AddStickerToSet adds a sticker to a set created by the bot.
176+
func (b *Bot) AddStickerToSet(ctx context.Context, name string, sticker InputSticker) error {
177+
item, err := b.resolveStickerItem(ctx, sticker)
178+
if err != nil {
179+
return err
180+
}
181+
if _, err := b.raw.StickersAddStickerToSet(ctx, &tg.StickersAddStickerToSetRequest{
182+
Stickerset: &tg.InputStickerSetShortName{ShortName: name},
183+
Sticker: item,
184+
}); err != nil {
185+
return asAPIError(err)
186+
}
187+
return nil
188+
}
189+
190+
// DeleteStickerFromSet removes a sticker, referenced by file_id, from the set it
191+
// belongs to.
192+
func (b *Bot) DeleteStickerFromSet(ctx context.Context, sticker string) error {
193+
doc, err := inputDocumentFromFileID(sticker)
194+
if err != nil {
195+
return err
196+
}
197+
if _, err := b.raw.StickersRemoveStickerFromSet(ctx, doc); err != nil {
198+
return asAPIError(err)
199+
}
200+
return nil
201+
}
202+
203+
// SetStickerPositionInSet moves a sticker, referenced by file_id, to the given
204+
// zero-based position in its set.
205+
func (b *Bot) SetStickerPositionInSet(ctx context.Context, sticker string, position int) error {
206+
doc, err := inputDocumentFromFileID(sticker)
207+
if err != nil {
208+
return err
209+
}
210+
if _, err := b.raw.StickersChangeStickerPosition(ctx, &tg.StickersChangeStickerPositionRequest{
211+
Sticker: doc,
212+
Position: position,
213+
}); err != nil {
214+
return asAPIError(err)
215+
}
216+
return nil
217+
}
218+
219+
// StickerSetOption configures CreateNewStickerSet.
220+
type StickerSetOption func(*stickerSetConfig)
221+
222+
type stickerSetConfig struct {
223+
masks bool
224+
}
225+
226+
// WithMaskStickers marks the new set as a set of mask stickers.
227+
func WithMaskStickers() StickerSetOption {
228+
return func(c *stickerSetConfig) { c.masks = true }
229+
}

sticker_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/gotd/td/tg"
8+
)
9+
10+
func TestStickerFormatMime(t *testing.T) {
11+
cases := map[StickerFormat]string{
12+
StickerFormatStatic: mimeStickerStatic,
13+
StickerFormatAnimated: "application/x-tgsticker",
14+
StickerFormatVideo: "video/webm",
15+
StickerFormat("xxx"): mimeStickerStatic,
16+
}
17+
for f, want := range cases {
18+
if got := f.mimeType(); got != want {
19+
t.Fatalf("%s: got %q, want %q", f, got, want)
20+
}
21+
}
22+
}
23+
24+
func TestResolveStickerItemFromFileID(t *testing.T) {
25+
b := &Bot{}
26+
fid := documentFileID(t, 0x99)
27+
item, err := b.resolveStickerItem(context.Background(), InputSticker{
28+
Sticker: InputFileID(fid),
29+
EmojiList: []string{"😀", "🎉"},
30+
Keywords: []string{"party", "fun"},
31+
})
32+
if err != nil {
33+
t.Fatal(err)
34+
}
35+
if d, ok := item.Document.(*tg.InputDocument); !ok || d.ID != 0x99 {
36+
t.Fatalf("document: %#v", item.Document)
37+
}
38+
if item.Emoji != "😀🎉" {
39+
t.Fatalf("emoji: %q", item.Emoji)
40+
}
41+
if kw, ok := item.GetKeywords(); !ok || kw != "party,fun" {
42+
t.Fatalf("keywords: %q %v", kw, ok)
43+
}
44+
}
45+
46+
func TestResolveStickerItemRejectsURL(t *testing.T) {
47+
b := &Bot{}
48+
_, err := b.resolveStickerItem(context.Background(), InputSticker{Sticker: InputFileURL("https://x/y.png")})
49+
if err == nil {
50+
t.Fatal("expected error for URL sticker")
51+
}
52+
}

0 commit comments

Comments
 (0)