Skip to content

Commit acb2000

Browse files
committed
feat: SendChecklist and EditMessageChecklist
Checklists (Bot API todo lists) sent/edited on behalf of a connected business account. InputChecklist / InputChecklistTask map to tg.InputMediaTodo (TodoList/TodoItem); title and task text resolve through a new textWithEntities helper honoring parse mode or explicit entities. SendChecklist reuses the business media-send path; EditMessageChecklist edits via a business-scoped raw client (new businessRaw helper). Hermetic tests; lint clean.
1 parent b5e9a15 commit acb2000

3 files changed

Lines changed: 244 additions & 0 deletions

File tree

business.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ func (b *Bot) businessSender(connectionID string) *message.Sender {
116116
return message.NewSender(tg.NewClient(businessInvoker{inner: b.invoker, connectionID: connectionID}))
117117
}
118118

119+
// businessRaw returns a raw *tg.Client whose every RPC is forwarded on behalf of
120+
// the business account identified by connectionID. It must not be used for
121+
// uploads (see businessInvoker).
122+
func (b *Bot) businessRaw(connectionID string) *tg.Client {
123+
return tg.NewClient(businessInvoker{inner: b.invoker, connectionID: connectionID})
124+
}
125+
119126
// GetBusinessConnection returns information about the connection of the bot with
120127
// a business account by the connection id received in updates.
121128
func (b *Bot) GetBusinessConnection(ctx context.Context, businessConnectionID string) (*BusinessConnection, error) {

checklist.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
6+
"github.com/gotd/td/telegram/message"
7+
"github.com/gotd/td/telegram/message/styling"
8+
"github.com/gotd/td/tg"
9+
)
10+
11+
// InputChecklistTask describes a task to add to a checklist on creation or edit.
12+
type InputChecklistTask struct {
13+
// ID is the unique identifier of the task, positive and unique among the
14+
// tasks of the checklist.
15+
ID int
16+
// Text is the task text, 1-100 characters after entities parsing.
17+
Text string
18+
// ParseMode is the formatting mode for Text.
19+
ParseMode ParseMode
20+
// TextEntities are explicit entities; they take precedence over ParseMode.
21+
TextEntities []MessageEntity
22+
}
23+
24+
// InputChecklist describes a checklist to create or replace.
25+
type InputChecklist struct {
26+
// Title is the checklist title, 1-255 characters after entities parsing.
27+
Title string
28+
// ParseMode is the formatting mode for Title.
29+
ParseMode ParseMode
30+
// TitleEntities are explicit title entities; they take precedence over
31+
// ParseMode.
32+
TitleEntities []MessageEntity
33+
// Tasks are the checklist tasks, 1-30 items.
34+
Tasks []InputChecklistTask
35+
// OthersCanAddTasks allows other users to add tasks to the checklist.
36+
OthersCanAddTasks bool
37+
// OthersCanMarkTasksAsDone allows other users to mark tasks as done or undone.
38+
OthersCanMarkTasksAsDone bool
39+
}
40+
41+
// textWithEntities resolves text plus a parse mode or explicit entities into the
42+
// MTProto TextWithEntities shape. Explicit entities take precedence.
43+
func (b *Bot) textWithEntities(ctx context.Context, text string, mode ParseMode, explicit []MessageEntity) (tg.TextWithEntities, error) {
44+
if len(explicit) > 0 {
45+
return tg.TextWithEntities{Text: text, Entities: entitiesToTg(explicit)}, nil
46+
}
47+
48+
msg, entities, err := b.styledMessage(ctx, text, mode)
49+
if err != nil {
50+
return tg.TextWithEntities{}, err
51+
}
52+
53+
return tg.TextWithEntities{Text: msg, Entities: entities}, nil
54+
}
55+
56+
// checklistMedia converts an InputChecklist into MTProto todo-list media.
57+
func (b *Bot) checklistMedia(ctx context.Context, c InputChecklist) (*tg.InputMediaTodo, error) {
58+
if len(c.Tasks) == 0 {
59+
return nil, &Error{Code: 400, Description: "Bad Request: checklist must include at least one task"}
60+
}
61+
62+
title, err := b.textWithEntities(ctx, c.Title, c.ParseMode, c.TitleEntities)
63+
if err != nil {
64+
return nil, err
65+
}
66+
67+
list := make([]tg.TodoItem, len(c.Tasks))
68+
69+
for i, task := range c.Tasks {
70+
taskTitle, err := b.textWithEntities(ctx, task.Text, task.ParseMode, task.TextEntities)
71+
if err != nil {
72+
return nil, err
73+
}
74+
75+
list[i] = tg.TodoItem{ID: task.ID, Title: taskTitle}
76+
}
77+
78+
return &tg.InputMediaTodo{Todo: tg.TodoList{
79+
OthersCanAppend: c.OthersCanAddTasks,
80+
OthersCanComplete: c.OthersCanMarkTasksAsDone,
81+
Title: title,
82+
List: list,
83+
}}, nil
84+
}
85+
86+
// SendChecklist sends a checklist on behalf of a connected business account.
87+
func (b *Bot) SendChecklist(
88+
ctx context.Context, businessConnectionID string, chat ChatID, checklist InputChecklist, opts ...SendOption,
89+
) (*Message, error) {
90+
opts = append([]SendOption{WithBusinessConnection(businessConnectionID)}, opts...)
91+
92+
return b.sendResolvedMedia(ctx, chat, "", func(ctx context.Context, _ []styling.StyledTextOption) (message.MediaOption, error) {
93+
todo, err := b.checklistMedia(ctx, checklist)
94+
if err != nil {
95+
return nil, err
96+
}
97+
98+
return message.Media(todo), nil
99+
}, opts...)
100+
}
101+
102+
// EditMessageChecklist replaces the checklist in a message sent on behalf of a
103+
// connected business account.
104+
func (b *Bot) EditMessageChecklist(
105+
ctx context.Context, businessConnectionID string, chat ChatID, messageID int, checklist InputChecklist, opts ...SendOption,
106+
) (*Message, error) {
107+
var cfg sendConfig
108+
109+
for _, o := range opts {
110+
o(&cfg)
111+
}
112+
113+
peer, err := b.resolveInputPeer(ctx, chat)
114+
if err != nil {
115+
return nil, err
116+
}
117+
118+
todo, err := b.checklistMedia(ctx, checklist)
119+
if err != nil {
120+
return nil, err
121+
}
122+
123+
req := &tg.MessagesEditMessageRequest{Peer: peer, ID: messageID}
124+
req.SetMedia(todo)
125+
126+
if cfg.markup != nil {
127+
mkp, err := replyMarkupToTg(cfg.markup)
128+
if err != nil {
129+
return nil, err
130+
}
131+
132+
req.SetReplyMarkup(mkp)
133+
}
134+
135+
resp, err := b.businessRaw(businessConnectionID).MessagesEditMessage(ctx, req)
136+
137+
return b.sentMessage(ctx, peer, resp, err)
138+
}

checklist_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/gotd/td/tg"
8+
)
9+
10+
func sampleChecklist() InputChecklist {
11+
return InputChecklist{
12+
Title: "Groceries",
13+
Tasks: []InputChecklistTask{{ID: 1, Text: "Milk"}, {ID: 2, Text: "Bread"}},
14+
OthersCanAddTasks: true,
15+
}
16+
}
17+
18+
func TestSendChecklist(t *testing.T) {
19+
inv := newMockInvoker()
20+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, businessSendReply())
21+
22+
b := newMockBot(inv)
23+
24+
if _, err := b.SendChecklist(context.Background(), "bc1", userRef(10, 20), sampleChecklist()); err != nil {
25+
t.Fatalf("SendChecklist: %v", err)
26+
}
27+
28+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.MessagesSendMediaRequest{}}
29+
30+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
31+
32+
if wrapper.ConnectionID != "bc1" {
33+
t.Fatalf("connection id = %q", wrapper.ConnectionID)
34+
}
35+
36+
sm, ok := wrapper.Query.(*tg.MessagesSendMediaRequest)
37+
if !ok {
38+
t.Fatalf("query = %#v", wrapper.Query)
39+
}
40+
41+
todo, ok := sm.Media.(*tg.InputMediaTodo)
42+
if !ok {
43+
t.Fatalf("media = %#v, want todo", sm.Media)
44+
}
45+
46+
if todo.Todo.Title.Text != "Groceries" || len(todo.Todo.List) != 2 || !todo.Todo.OthersCanAppend {
47+
t.Fatalf("todo = %#v", todo.Todo)
48+
}
49+
50+
if todo.Todo.List[0].ID != 1 || todo.Todo.List[0].Title.Text != "Milk" {
51+
t.Fatalf("task 0 = %#v", todo.Todo.List[0])
52+
}
53+
}
54+
55+
func TestEditMessageChecklist(t *testing.T) {
56+
inv := newMockInvoker()
57+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, businessSendReply())
58+
59+
b := newMockBot(inv)
60+
61+
if _, err := b.EditMessageChecklist(context.Background(), "bc2", userRef(10, 20), 5, sampleChecklist()); err != nil {
62+
t.Fatalf("EditMessageChecklist: %v", err)
63+
}
64+
65+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.MessagesEditMessageRequest{}}
66+
67+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
68+
69+
if wrapper.ConnectionID != "bc2" {
70+
t.Fatalf("connection id = %q", wrapper.ConnectionID)
71+
}
72+
73+
em, ok := wrapper.Query.(*tg.MessagesEditMessageRequest)
74+
if !ok {
75+
t.Fatalf("query = %#v", wrapper.Query)
76+
}
77+
78+
if em.ID != 5 {
79+
t.Fatalf("message id = %d", em.ID)
80+
}
81+
82+
media, ok := em.GetMedia()
83+
if !ok {
84+
t.Fatal("edit should set media")
85+
}
86+
87+
if _, ok := media.(*tg.InputMediaTodo); !ok {
88+
t.Fatalf("media = %#v, want todo", media)
89+
}
90+
}
91+
92+
func TestChecklistEmptyTasks(t *testing.T) {
93+
inv := newMockInvoker()
94+
95+
if _, err := newMockBot(inv).SendChecklist(context.Background(), "bc1", userRef(10, 20),
96+
InputChecklist{Title: "x"}); err == nil {
97+
t.Fatal("expected error for checklist without tasks")
98+
}
99+
}

0 commit comments

Comments
 (0)