Skip to content

Commit 7f267b6

Browse files
ernadoclaude
andcommitted
feat: add PostStory, EditStory, DeleteStory, RepostStory
Story management on behalf of a managed business account, over the business connection (peer inputPeerSelf): - PostStory -> stories.sendStory (uploaded/URL/file_id photo or video media, allow-all privacy, random id, active period) - EditStory -> stories.editStory - DeleteStory -> stories.deleteStories - RepostStory -> stories.sendStory with fwd_from_id/fwd_from_story and empty media Adds the Story type and the InputStoryContent union (photo/video), building raw tg.InputMedia directly since stories bypass the message sender. The posted story is extracted from the updateStory in the response (id + resolved owner chat). Story areas are not yet supported. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8dba133 commit 7f267b6

3 files changed

Lines changed: 571 additions & 0 deletions

File tree

stories.go

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"encoding/binary"
7+
8+
"github.com/gotd/td/bin"
9+
"github.com/gotd/td/constant"
10+
"github.com/gotd/td/fileid"
11+
"github.com/gotd/td/tg"
12+
)
13+
14+
// storyConfig holds the optional parameters of the story-posting methods.
15+
type storyConfig struct {
16+
caption string
17+
parseMode ParseMode
18+
captionEntities []MessageEntity
19+
postToChatPage bool
20+
protectContent bool
21+
}
22+
23+
// StoryOption customizes PostStory, EditStory and RepostStory.
24+
type StoryOption func(*storyConfig)
25+
26+
// WithStoryCaption sets the story caption.
27+
func WithStoryCaption(caption string) StoryOption {
28+
return func(c *storyConfig) { c.caption = caption }
29+
}
30+
31+
// WithStoryParseMode sets the parse mode used for the caption.
32+
func WithStoryParseMode(mode ParseMode) StoryOption {
33+
return func(c *storyConfig) { c.parseMode = mode }
34+
}
35+
36+
// WithStoryCaptionEntities sets explicit caption entities, overriding the parse
37+
// mode.
38+
func WithStoryCaptionEntities(entities []MessageEntity) StoryOption {
39+
return func(c *storyConfig) { c.captionEntities = entities }
40+
}
41+
42+
// WithStoryPostToChatPage also posts the story to the chat's profile page.
43+
func WithStoryPostToChatPage() StoryOption {
44+
return func(c *storyConfig) { c.postToChatPage = true }
45+
}
46+
47+
// WithStoryProtectContent protects the story content from forwarding and
48+
// screenshots.
49+
func WithStoryProtectContent() StoryOption {
50+
return func(c *storyConfig) { c.protectContent = true }
51+
}
52+
53+
// caption resolves the caption text into a (text, entities) pair: explicit
54+
// entities take precedence over the parse mode.
55+
func (b *Bot) storyCaption(ctx context.Context, cfg storyConfig) (string, []tg.MessageEntityClass, error) {
56+
if cfg.caption == "" {
57+
return "", nil, nil
58+
}
59+
60+
if len(cfg.captionEntities) > 0 {
61+
return cfg.caption, entitiesToTg(cfg.captionEntities), nil
62+
}
63+
64+
return b.styledMessage(ctx, cfg.caption, cfg.parseMode)
65+
}
66+
67+
// PostStory posts a story on behalf of a managed business account, active for
68+
// activePeriod seconds (one of 6, 12, 24 or 48 hours). Requires the
69+
// can_manage_stories business bot right. Story areas are not yet supported.
70+
func (b *Bot) PostStory(
71+
ctx context.Context, businessConnectionID string, content InputStoryContent, activePeriod int, opts ...StoryOption,
72+
) (*Story, error) {
73+
cfg := storyConfigFrom(opts)
74+
75+
media, err := b.storyMedia(ctx, content)
76+
if err != nil {
77+
return nil, err
78+
}
79+
80+
caption, entities, err := b.storyCaption(ctx, cfg)
81+
if err != nil {
82+
return nil, err
83+
}
84+
85+
id, err := randInt64()
86+
if err != nil {
87+
return nil, err
88+
}
89+
90+
req := &tg.StoriesSendStoryRequest{
91+
Peer: &tg.InputPeerSelf{},
92+
Media: media,
93+
Caption: caption,
94+
Entities: entities,
95+
PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}},
96+
RandomID: id,
97+
Period: activePeriod,
98+
Pinned: cfg.postToChatPage,
99+
Noforwards: cfg.protectContent,
100+
}
101+
102+
return b.sendStory(ctx, businessConnectionID, req)
103+
}
104+
105+
// RepostStory reposts a story from another business account managed by the same
106+
// bot. Requires the can_manage_stories business bot right for both accounts.
107+
func (b *Bot) RepostStory(
108+
ctx context.Context, businessConnectionID string, fromChat ChatID, fromStoryID, activePeriod int, opts ...StoryOption,
109+
) (*Story, error) {
110+
cfg := storyConfigFrom(opts)
111+
112+
from, err := b.resolveInputPeer(ctx, fromChat)
113+
if err != nil {
114+
return nil, err
115+
}
116+
117+
id, err := randInt64()
118+
if err != nil {
119+
return nil, err
120+
}
121+
122+
req := &tg.StoriesSendStoryRequest{
123+
Peer: &tg.InputPeerSelf{},
124+
// A repost carries no new media; the content comes from the source story.
125+
Media: &tg.InputMediaEmpty{},
126+
PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}},
127+
RandomID: id,
128+
Period: activePeriod,
129+
Pinned: cfg.postToChatPage,
130+
Noforwards: cfg.protectContent,
131+
FwdFromID: from,
132+
FwdFromStory: fromStoryID,
133+
}
134+
135+
return b.sendStory(ctx, businessConnectionID, req)
136+
}
137+
138+
// EditStory edits a story previously posted by the bot on behalf of a managed
139+
// business account. Requires the can_manage_stories business bot right. Story
140+
// areas are not yet supported.
141+
func (b *Bot) EditStory(
142+
ctx context.Context, businessConnectionID string, storyID int, content InputStoryContent, opts ...StoryOption,
143+
) (*Story, error) {
144+
cfg := storyConfigFrom(opts)
145+
146+
media, err := b.storyMedia(ctx, content)
147+
if err != nil {
148+
return nil, err
149+
}
150+
151+
caption, entities, err := b.storyCaption(ctx, cfg)
152+
if err != nil {
153+
return nil, err
154+
}
155+
156+
req := &tg.StoriesEditStoryRequest{
157+
Peer: &tg.InputPeerSelf{},
158+
ID: storyID,
159+
Media: media,
160+
Caption: caption,
161+
Entities: entities,
162+
}
163+
164+
return b.sendStory(ctx, businessConnectionID, req)
165+
}
166+
167+
// DeleteStory deletes a story previously posted by the bot on behalf of a
168+
// managed business account. Requires the can_manage_stories business bot right.
169+
func (b *Bot) DeleteStory(ctx context.Context, businessConnectionID string, storyID int) error {
170+
var res tg.IntVector
171+
172+
err := b.invokeBusiness(ctx, businessConnectionID, &tg.StoriesDeleteStoriesRequest{
173+
Peer: &tg.InputPeerSelf{},
174+
ID: []int{storyID},
175+
}, &res)
176+
if err != nil {
177+
return asAPIError(err)
178+
}
179+
180+
return nil
181+
}
182+
183+
// sendStory runs a story-mutating request over the business connection and
184+
// extracts the resulting Story from the update list.
185+
func (b *Bot) sendStory(ctx context.Context, connectionID string, req bin.Object) (*Story, error) {
186+
var res tg.UpdatesBox
187+
188+
if err := b.invokeBusiness(ctx, connectionID, req, &res); err != nil {
189+
return nil, asAPIError(err)
190+
}
191+
192+
story := storyFromUpdates(res.Updates)
193+
if story == nil {
194+
return nil, &Error{Code: 500, Description: "Internal Server Error: no story in response"}
195+
}
196+
197+
return story, nil
198+
}
199+
200+
// storyConfigFrom collapses the options into a config.
201+
func storyConfigFrom(opts []StoryOption) storyConfig {
202+
var cfg storyConfig
203+
204+
for _, opt := range opts {
205+
opt(&cfg)
206+
}
207+
208+
return cfg
209+
}
210+
211+
// storyMedia builds the MTProto input media for a story content.
212+
//
213+
// The switch over the sealed InputStoryContent union is exhaustive.
214+
func (b *Bot) storyMedia(ctx context.Context, content InputStoryContent) (tg.InputMediaClass, error) {
215+
switch c := content.(type) {
216+
case InputStoryContentPhoto:
217+
return b.storyPhotoMedia(ctx, c.Photo)
218+
case InputStoryContentVideo:
219+
return b.storyVideoMedia(ctx, c)
220+
default:
221+
return nil, &Error{Code: 400, Description: descInvalidFile}
222+
}
223+
}
224+
225+
// storyPhotoMedia builds the input media for a photo story.
226+
func (b *Bot) storyPhotoMedia(ctx context.Context, photo InputFile) (tg.InputMediaClass, error) {
227+
switch f := photo.(type) {
228+
case InputFileID:
229+
fid, err := fileid.DecodeFileID(string(f))
230+
if err != nil {
231+
return nil, &Error{Code: 400, Description: descWrongFileID}
232+
}
233+
234+
return &tg.InputMediaPhoto{ID: &tg.InputPhoto{
235+
ID: fid.ID,
236+
AccessHash: fid.AccessHash,
237+
FileReference: fid.FileReference,
238+
}}, nil
239+
case InputFileURL:
240+
return &tg.InputMediaPhotoExternal{URL: string(f)}, nil
241+
case *InputFileUpload:
242+
upFile, err := b.uploadInputFile(ctx, f)
243+
if err != nil {
244+
return nil, err
245+
}
246+
247+
return &tg.InputMediaUploadedPhoto{File: upFile}, nil
248+
default:
249+
return nil, &Error{Code: 400, Description: descInvalidFile}
250+
}
251+
}
252+
253+
// storyVideoMedia builds the input media for a video story.
254+
func (b *Bot) storyVideoMedia(ctx context.Context, content InputStoryContentVideo) (tg.InputMediaClass, error) {
255+
switch f := content.Video.(type) {
256+
case InputFileID:
257+
fid, err := fileid.DecodeFileID(string(f))
258+
if err != nil {
259+
return nil, &Error{Code: 400, Description: descWrongFileID}
260+
}
261+
262+
return &tg.InputMediaDocument{ID: &tg.InputDocument{
263+
ID: fid.ID,
264+
AccessHash: fid.AccessHash,
265+
FileReference: fid.FileReference,
266+
}}, nil
267+
case InputFileURL:
268+
return &tg.InputMediaDocumentExternal{URL: string(f)}, nil
269+
case *InputFileUpload:
270+
upFile, err := b.uploadInputFile(ctx, f)
271+
if err != nil {
272+
return nil, err
273+
}
274+
275+
video := tg.DocumentAttributeVideo{
276+
SupportsStreaming: true,
277+
Duration: content.Duration,
278+
Nosound: content.IsAnimation,
279+
}
280+
281+
return &tg.InputMediaUploadedDocument{
282+
File: upFile,
283+
MimeType: "video/mp4",
284+
NosoundVideo: content.IsAnimation,
285+
Attributes: []tg.DocumentAttributeClass{&video},
286+
}, nil
287+
default:
288+
return nil, &Error{Code: 400, Description: descInvalidFile}
289+
}
290+
}
291+
292+
// storyFromUpdates extracts the posted/edited story from an update list.
293+
func storyFromUpdates(resp tg.UpdatesClass) *Story {
294+
var (
295+
updates []tg.UpdateClass
296+
users map[int64]*tg.User
297+
chats map[int64]tg.ChatClass
298+
)
299+
300+
switch u := resp.(type) {
301+
case *tg.Updates:
302+
updates, users, chats = u.Updates, usersByID(u.Users), chatsByID(u.Chats)
303+
case *tg.UpdatesCombined:
304+
updates, users, chats = u.Updates, usersByID(u.Users), chatsByID(u.Chats)
305+
default:
306+
return nil
307+
}
308+
309+
for _, upd := range updates {
310+
us, ok := upd.(*tg.UpdateStory)
311+
if !ok {
312+
continue
313+
}
314+
315+
item, ok := us.Story.(*tg.StoryItem)
316+
if !ok {
317+
continue
318+
}
319+
320+
return &Story{Chat: storyChat(us.Peer, users, chats), ID: item.ID}
321+
}
322+
323+
return nil
324+
}
325+
326+
// storyChat resolves the peer that owns a story into a Bot API Chat.
327+
func storyChat(peer tg.PeerClass, users map[int64]*tg.User, chats map[int64]tg.ChatClass) Chat {
328+
pu, ok := peer.(*tg.PeerUser)
329+
if !ok {
330+
return chatFromRaw(peer, chats)
331+
}
332+
333+
var id constant.TDLibPeerID
334+
335+
id.User(pu.UserID)
336+
337+
c := Chat{ID: int64(id), Type: ChatTypePrivate}
338+
if u, ok := users[pu.UserID]; ok {
339+
c.FirstName = u.FirstName
340+
c.LastName = u.LastName
341+
c.Username = u.Username
342+
}
343+
344+
return c
345+
}
346+
347+
// randInt64 returns a cryptographically random int64 for use as an RPC random
348+
// id.
349+
func randInt64() (int64, error) {
350+
var buf [8]byte
351+
352+
if _, err := rand.Read(buf[:]); err != nil {
353+
return 0, &Error{Code: 500, Description: "Internal Server Error: " + err.Error()}
354+
}
355+
356+
return int64(binary.LittleEndian.Uint64(buf[:])), nil //nolint:gosec // wraparound is fine for a random id
357+
}

0 commit comments

Comments
 (0)