Skip to content

Commit 2f31192

Browse files
committed
feat: forum topic management (create/edit/close/reopen/delete + general variants)
Implements the 12 forum-topic methods now feasible on gotd/td v0.156.3, whose RPCs moved into the messages.*ForumTopic namespace: - CreateForumTopic, EditForumTopic, CloseForumTopic, ReopenForumTopic, DeleteForumTopic, UnpinAllForumTopicMessages - EditGeneralForumTopic, CloseGeneralForumTopic, ReopenGeneralForumTopic, HideGeneralForumTopic, UnhideGeneralForumTopic, UnpinAllGeneralForumTopicMessages General-topic methods operate on topic id 1. CreateForumTopic returns a ForumTopic extracted from the creation service message's MessageActionTopicCreate. Hermetic mockInvoker tests; lint clean.
1 parent 269209c commit 2f31192

2 files changed

Lines changed: 550 additions & 0 deletions

File tree

forum_topics.go

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,341 @@ package botapi
22

33
import (
44
"context"
5+
"strconv"
56

67
"github.com/gotd/td/tg"
78
)
89

10+
// topicCreateService extracts the service message that a forum-topic creation
11+
// produced from the RPC's Updates response.
12+
func topicCreateService(resp tg.UpdatesClass) (*tg.MessageService, bool) {
13+
var ups []tg.UpdateClass
14+
15+
switch u := resp.(type) {
16+
case *tg.Updates:
17+
ups = u.Updates
18+
case *tg.UpdatesCombined:
19+
ups = u.Updates
20+
default:
21+
return nil, false
22+
}
23+
24+
for _, up := range ups {
25+
var msg tg.MessageClass
26+
27+
switch m := up.(type) {
28+
case *tg.UpdateNewChannelMessage:
29+
msg = m.Message
30+
case *tg.UpdateNewMessage:
31+
msg = m.Message
32+
default:
33+
continue
34+
}
35+
36+
if svc, ok := msg.(*tg.MessageService); ok {
37+
return svc, true
38+
}
39+
}
40+
41+
return nil, false
42+
}
43+
44+
// generalForumTopicID is the topic id of the special "General" topic that every
45+
// forum supergroup has. The general-topic methods operate on it.
46+
const generalForumTopicID = 1
47+
48+
// ForumTopic is the result of CreateForumTopic: a topic in a forum supergroup.
49+
type ForumTopic struct {
50+
// MessageThreadID is the unique identifier of the forum topic.
51+
MessageThreadID int `json:"message_thread_id"`
52+
// Name is the topic name.
53+
Name string `json:"name"`
54+
// IconColor is the color of the topic icon in RGB format.
55+
IconColor int `json:"icon_color"`
56+
// IconCustomEmojiID is the unique identifier of the custom emoji shown as the
57+
// topic icon, if any.
58+
IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"`
59+
}
60+
61+
// forumTopicConfig accumulates the optional parameters shared by CreateForumTopic
62+
// and EditForumTopic.
63+
type forumTopicConfig struct {
64+
name string
65+
nameSet bool
66+
iconColor int
67+
iconColorSet bool
68+
iconEmoji string
69+
iconEmojiSet bool
70+
}
71+
72+
// ForumTopicOption configures CreateForumTopic / EditForumTopic.
73+
type ForumTopicOption func(*forumTopicConfig)
74+
75+
// WithForumTopicName sets the topic name. Used by EditForumTopic; CreateForumTopic
76+
// takes the name as a positional argument.
77+
func WithForumTopicName(name string) ForumTopicOption {
78+
return func(c *forumTopicConfig) {
79+
c.name = name
80+
c.nameSet = true
81+
}
82+
}
83+
84+
// WithForumTopicIconColor sets the color of the topic icon in RGB format. One of
85+
// 0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, or 0xFB6F5F. Only honored on
86+
// creation.
87+
func WithForumTopicIconColor(color int) ForumTopicOption {
88+
return func(c *forumTopicConfig) {
89+
c.iconColor = color
90+
c.iconColorSet = true
91+
}
92+
}
93+
94+
// WithForumTopicIconCustomEmojiID sets the custom emoji shown as the topic icon.
95+
// An empty string removes the icon (EditForumTopic only).
96+
func WithForumTopicIconCustomEmojiID(id string) ForumTopicOption {
97+
return func(c *forumTopicConfig) {
98+
c.iconEmoji = id
99+
c.iconEmojiSet = true
100+
}
101+
}
102+
103+
// parseCustomEmojiID turns a Bot API custom_emoji_id string into the MTProto
104+
// document id. An empty string maps to 0 (no/removed icon).
105+
func parseCustomEmojiID(id string) (int64, error) {
106+
if id == "" {
107+
return 0, nil
108+
}
109+
110+
v, err := strconv.ParseInt(id, 10, 64)
111+
if err != nil {
112+
return 0, &Error{Code: 400, Description: "Bad Request: invalid icon_custom_emoji_id"}
113+
}
114+
115+
return v, nil
116+
}
117+
118+
// CreateForumTopic creates a topic in a forum supergroup. The bot must be an
119+
// administrator with the can_manage_topics right. Returns the created topic.
120+
func (b *Bot) CreateForumTopic(ctx context.Context, chat ChatID, name string, opts ...ForumTopicOption) (*ForumTopic, error) {
121+
var cfg forumTopicConfig
122+
123+
for _, o := range opts {
124+
o(&cfg)
125+
}
126+
127+
peer, err := b.resolveInputPeer(ctx, chat)
128+
if err != nil {
129+
return nil, err
130+
}
131+
132+
randomID, err := randInt64()
133+
if err != nil {
134+
return nil, err
135+
}
136+
137+
req := &tg.MessagesCreateForumTopicRequest{
138+
Peer: peer,
139+
Title: name,
140+
RandomID: randomID,
141+
}
142+
143+
if cfg.iconColorSet {
144+
req.SetIconColor(cfg.iconColor)
145+
}
146+
147+
if cfg.iconEmojiSet {
148+
emojiID, err := parseCustomEmojiID(cfg.iconEmoji)
149+
if err != nil {
150+
return nil, err
151+
}
152+
153+
req.SetIconEmojiID(emojiID)
154+
}
155+
156+
resp, err := b.raw.MessagesCreateForumTopic(ctx, req)
157+
if err != nil {
158+
return nil, asAPIError(err)
159+
}
160+
161+
// The topic id is the id of the service message that created it; its
162+
// MessageActionTopicCreate carries the resolved name/color/icon.
163+
topic := &ForumTopic{Name: name, IconColor: cfg.iconColor, IconCustomEmojiID: cfg.iconEmoji}
164+
165+
if svc, ok := topicCreateService(resp); ok {
166+
topic.MessageThreadID = svc.ID
167+
168+
if act, ok := svc.Action.(*tg.MessageActionTopicCreate); ok {
169+
topic.Name = act.Title
170+
topic.IconColor = act.IconColor
171+
172+
if act.IconEmojiID != 0 {
173+
topic.IconCustomEmojiID = strconv.FormatInt(act.IconEmojiID, 10)
174+
}
175+
}
176+
}
177+
178+
return topic, nil
179+
}
180+
181+
// editForumTopic is the shared implementation behind the (re)naming, icon,
182+
// close/reopen and hide/unhide operations on a topic.
183+
func (b *Bot) editForumTopic(ctx context.Context, chat ChatID, topicID int, apply func(*tg.MessagesEditForumTopicRequest) error) error {
184+
peer, err := b.resolveInputPeer(ctx, chat)
185+
if err != nil {
186+
return err
187+
}
188+
189+
req := &tg.MessagesEditForumTopicRequest{Peer: peer, TopicID: topicID}
190+
191+
if err := apply(req); err != nil {
192+
return err
193+
}
194+
195+
if _, err := b.raw.MessagesEditForumTopic(ctx, req); err != nil {
196+
return asAPIError(err)
197+
}
198+
199+
return nil
200+
}
201+
202+
// EditForumTopic edits the name and/or icon of a topic in a forum supergroup. The
203+
// bot must be an administrator with the can_manage_topics right.
204+
func (b *Bot) EditForumTopic(ctx context.Context, chat ChatID, messageThreadID int, opts ...ForumTopicOption) error {
205+
var cfg forumTopicConfig
206+
207+
for _, o := range opts {
208+
o(&cfg)
209+
}
210+
211+
return b.editForumTopic(ctx, chat, messageThreadID, func(req *tg.MessagesEditForumTopicRequest) error {
212+
if cfg.nameSet {
213+
req.SetTitle(cfg.name)
214+
}
215+
216+
if cfg.iconEmojiSet {
217+
emojiID, err := parseCustomEmojiID(cfg.iconEmoji)
218+
if err != nil {
219+
return err
220+
}
221+
222+
req.SetIconEmojiID(emojiID)
223+
}
224+
225+
return nil
226+
})
227+
}
228+
229+
// CloseForumTopic closes an open topic in a forum supergroup.
230+
func (b *Bot) CloseForumTopic(ctx context.Context, chat ChatID, messageThreadID int) error {
231+
return b.editForumTopic(ctx, chat, messageThreadID, func(req *tg.MessagesEditForumTopicRequest) error {
232+
req.SetClosed(true)
233+
234+
return nil
235+
})
236+
}
237+
238+
// ReopenForumTopic reopens a closed topic in a forum supergroup.
239+
func (b *Bot) ReopenForumTopic(ctx context.Context, chat ChatID, messageThreadID int) error {
240+
return b.editForumTopic(ctx, chat, messageThreadID, func(req *tg.MessagesEditForumTopicRequest) error {
241+
req.SetClosed(false)
242+
243+
return nil
244+
})
245+
}
246+
247+
// DeleteForumTopic deletes a topic in a forum supergroup together with all its
248+
// messages. The bot must be an administrator with the can_delete_messages right.
249+
func (b *Bot) DeleteForumTopic(ctx context.Context, chat ChatID, messageThreadID int) error {
250+
peer, err := b.resolveInputPeer(ctx, chat)
251+
if err != nil {
252+
return err
253+
}
254+
255+
if _, err := b.raw.MessagesDeleteTopicHistory(ctx, &tg.MessagesDeleteTopicHistoryRequest{
256+
Peer: peer,
257+
TopMsgID: messageThreadID,
258+
}); err != nil {
259+
return asAPIError(err)
260+
}
261+
262+
return nil
263+
}
264+
265+
// UnpinAllForumTopicMessages clears the list of pinned messages in a forum topic.
266+
func (b *Bot) UnpinAllForumTopicMessages(ctx context.Context, chat ChatID, messageThreadID int) error {
267+
return b.unpinAllTopicMessages(ctx, chat, messageThreadID)
268+
}
269+
270+
// unpinAllTopicMessages unpins every message in the given topic.
271+
func (b *Bot) unpinAllTopicMessages(ctx context.Context, chat ChatID, topicID int) error {
272+
peer, err := b.resolveInputPeer(ctx, chat)
273+
if err != nil {
274+
return err
275+
}
276+
277+
req := &tg.MessagesUnpinAllMessagesRequest{Peer: peer}
278+
req.SetTopMsgID(topicID)
279+
280+
if _, err := b.raw.MessagesUnpinAllMessages(ctx, req); err != nil {
281+
return asAPIError(err)
282+
}
283+
284+
return nil
285+
}
286+
287+
// EditGeneralForumTopic edits the name of the "General" topic in a forum
288+
// supergroup. The bot must be an administrator with the can_manage_topics right.
289+
func (b *Bot) EditGeneralForumTopic(ctx context.Context, chat ChatID, name string) error {
290+
return b.editForumTopic(ctx, chat, generalForumTopicID, func(req *tg.MessagesEditForumTopicRequest) error {
291+
req.SetTitle(name)
292+
293+
return nil
294+
})
295+
}
296+
297+
// CloseGeneralForumTopic closes the "General" topic in a forum supergroup.
298+
func (b *Bot) CloseGeneralForumTopic(ctx context.Context, chat ChatID) error {
299+
return b.editForumTopic(ctx, chat, generalForumTopicID, func(req *tg.MessagesEditForumTopicRequest) error {
300+
req.SetClosed(true)
301+
302+
return nil
303+
})
304+
}
305+
306+
// ReopenGeneralForumTopic reopens the "General" topic in a forum supergroup.
307+
func (b *Bot) ReopenGeneralForumTopic(ctx context.Context, chat ChatID) error {
308+
return b.editForumTopic(ctx, chat, generalForumTopicID, func(req *tg.MessagesEditForumTopicRequest) error {
309+
req.SetClosed(false)
310+
311+
return nil
312+
})
313+
}
314+
315+
// HideGeneralForumTopic hides the "General" topic in a forum supergroup. The
316+
// topic is automatically closed if it was open.
317+
func (b *Bot) HideGeneralForumTopic(ctx context.Context, chat ChatID) error {
318+
return b.editForumTopic(ctx, chat, generalForumTopicID, func(req *tg.MessagesEditForumTopicRequest) error {
319+
req.SetHidden(true)
320+
321+
return nil
322+
})
323+
}
324+
325+
// UnhideGeneralForumTopic unhides the "General" topic in a forum supergroup.
326+
func (b *Bot) UnhideGeneralForumTopic(ctx context.Context, chat ChatID) error {
327+
return b.editForumTopic(ctx, chat, generalForumTopicID, func(req *tg.MessagesEditForumTopicRequest) error {
328+
req.SetHidden(false)
329+
330+
return nil
331+
})
332+
}
333+
334+
// UnpinAllGeneralForumTopicMessages clears the list of pinned messages in the
335+
// "General" topic of a forum supergroup.
336+
func (b *Bot) UnpinAllGeneralForumTopicMessages(ctx context.Context, chat ChatID) error {
337+
return b.unpinAllTopicMessages(ctx, chat, generalForumTopicID)
338+
}
339+
9340
// GetForumTopicIconStickers returns custom emoji stickers that can be used as a
10341
// forum topic icon by any user.
11342
func (b *Bot) GetForumTopicIconStickers(ctx context.Context) ([]Sticker, error) {

0 commit comments

Comments
 (0)