Skip to content

Commit c9f8b1c

Browse files
ernadoclaude
andcommitted
feat: dispatch inbound business updates to handlers
Convert the MTProto business updates (botBusinessConnect, bot{New,Edit} BusinessMessage, botDeleteBusinessMessage) into botapi.Update fields (BusinessConnection, BusinessMessage, EditedBusinessMessage, DeletedBusinessMessages) and route them. Adds On{Business,EditedBusiness} Message / OnBusinessConnection / OnDeletedBusinessMessages registrars, Message.BusinessConnectionID, the BusinessMessagesDeleted type, and Context.Business()/BusinessMessage() so a handler can act on behalf of the connected account. Business messages keep outgoing messages (the Bot API delivers the whole conversation). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f886ecd commit c9f8b1c

6 files changed

Lines changed: 304 additions & 13 deletions

File tree

business.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ type BusinessConnection struct {
6464
IsEnabled bool `json:"is_enabled"`
6565
}
6666

67+
// BusinessMessagesDeleted reports that messages were deleted from a connected
68+
// business account.
69+
type BusinessMessagesDeleted struct {
70+
// BusinessConnectionID is the unique identifier of the business connection.
71+
BusinessConnectionID string `json:"business_connection_id"`
72+
// Chat is the chat in the business account the messages were deleted from.
73+
Chat Chat `json:"chat"`
74+
// MessageIDs are the identifiers of the deleted messages.
75+
MessageIDs []int `json:"message_ids"`
76+
}
77+
6778
// invokeBusiness sends an inner RPC on behalf of a connected business account,
6879
// wrapping it in invokeWithBusinessConnection. *tg.Client has no generated
6980
// helper for this wrapper, so it goes through the raw invoker.

business_updates.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
6+
"github.com/gotd/log"
7+
"github.com/gotd/td/tg"
8+
)
9+
10+
// installBusinessHandlers wires the business-connection updates to the router. It
11+
// is called from installHandlers.
12+
func (b *Bot) installBusinessHandlers() {
13+
b.disp.OnBotBusinessConnect(func(ctx context.Context, e tg.Entities, u *tg.UpdateBotBusinessConnect) error {
14+
b.route(ctx, &Update{BusinessConnection: b.businessConnectionFromTg(u.Connection, e.Users)})
15+
16+
return nil
17+
})
18+
b.disp.OnBotNewBusinessMessage(func(ctx context.Context, _ tg.Entities, u *tg.UpdateBotNewBusinessMessage) error {
19+
b.dispatchBusinessMessage(ctx, u.ConnectionID, u.Message, false)
20+
21+
return nil
22+
})
23+
b.disp.OnBotEditBusinessMessage(func(ctx context.Context, _ tg.Entities, u *tg.UpdateBotEditBusinessMessage) error {
24+
b.dispatchBusinessMessage(ctx, u.ConnectionID, u.Message, true)
25+
26+
return nil
27+
})
28+
b.disp.OnBotDeleteBusinessMessage(func(ctx context.Context, _ tg.Entities, u *tg.UpdateBotDeleteBusinessMessage) error {
29+
chat, err := b.chatByPeer(ctx, u.Peer)
30+
if err != nil {
31+
b.logger().Error(ctx, "Convert business deleted messages", log.Error(err))
32+
33+
return nil
34+
}
35+
36+
b.route(ctx, &Update{DeletedBusinessMessages: &BusinessMessagesDeleted{
37+
BusinessConnectionID: u.ConnectionID,
38+
Chat: chat,
39+
MessageIDs: u.Messages,
40+
}})
41+
42+
return nil
43+
})
44+
}
45+
46+
// dispatchBusinessMessage converts a message delivered over a business connection
47+
// and routes it as a (edited) business message. Unlike dispatchMessage it keeps
48+
// outgoing messages: the Bot API delivers the whole business conversation,
49+
// including messages the account itself sent.
50+
func (b *Bot) dispatchBusinessMessage(ctx context.Context, connectionID string, msg tg.MessageClass, edited bool) {
51+
m, err := b.messageFromTg(ctx, msg)
52+
if err != nil {
53+
b.logger().Error(ctx, "Convert business message", log.Error(err))
54+
55+
return
56+
}
57+
58+
if m == nil {
59+
return
60+
}
61+
62+
m.BusinessConnectionID = connectionID
63+
64+
u := &Update{}
65+
if edited {
66+
u.EditedBusinessMessage = m
67+
} else {
68+
u.BusinessMessage = m
69+
}
70+
71+
b.route(ctx, u)
72+
}
73+
74+
// businessConnectionID returns the connection id the update belongs to, or empty
75+
// when the update is not a business update.
76+
func (u *Update) businessConnectionID() string {
77+
switch {
78+
case u.BusinessMessage != nil:
79+
return u.BusinessMessage.BusinessConnectionID
80+
case u.EditedBusinessMessage != nil:
81+
return u.EditedBusinessMessage.BusinessConnectionID
82+
case u.BusinessConnection != nil:
83+
return u.BusinessConnection.ID
84+
case u.DeletedBusinessMessages != nil:
85+
return u.DeletedBusinessMessages.BusinessConnectionID
86+
default:
87+
return ""
88+
}
89+
}
90+
91+
// Kind predicates for business updates.
92+
func hasBusinessMessage(u *Update) bool { return u.BusinessMessage != nil }
93+
func hasEditedBusinessMessage(u *Update) bool { return u.EditedBusinessMessage != nil }
94+
func hasBusinessConnection(u *Update) bool { return u.BusinessConnection != nil }
95+
func hasDeletedBusinessMessages(u *Update) bool { return u.DeletedBusinessMessages != nil }
96+
97+
// OnBusinessMessage registers a handler for new messages from a connected
98+
// business account.
99+
func (b *Bot) OnBusinessMessage(h Handler, predicates ...Predicate) {
100+
b.on(h, prepend(hasBusinessMessage, predicates)...)
101+
}
102+
103+
// OnEditedBusinessMessage registers a handler for edited messages from a
104+
// connected business account.
105+
func (b *Bot) OnEditedBusinessMessage(h Handler, predicates ...Predicate) {
106+
b.on(h, prepend(hasEditedBusinessMessage, predicates)...)
107+
}
108+
109+
// OnBusinessConnection registers a handler for business connection updates (the
110+
// bot was connected to, disconnected from, or had its rights changed on a
111+
// business account).
112+
func (b *Bot) OnBusinessConnection(h Handler, predicates ...Predicate) {
113+
b.on(h, prepend(hasBusinessConnection, predicates)...)
114+
}
115+
116+
// OnDeletedBusinessMessages registers a handler for messages deleted from a
117+
// connected business account.
118+
func (b *Bot) OnDeletedBusinessMessages(h Handler, predicates ...Predicate) {
119+
b.on(h, prepend(hasDeletedBusinessMessages, predicates)...)
120+
}
121+
122+
// BusinessMessage returns the business message the update carries (new or
123+
// edited), or nil when the update carries none.
124+
func (c *Context) BusinessMessage() *Message {
125+
switch {
126+
case c.Update.BusinessMessage != nil:
127+
return c.Update.BusinessMessage
128+
case c.Update.EditedBusinessMessage != nil:
129+
return c.Update.EditedBusinessMessage
130+
default:
131+
return nil
132+
}
133+
}
134+
135+
// Business returns a BusinessContext scoped to the update's business connection,
136+
// and whether the update belongs to one. Use it to act on behalf of the
137+
// connected account from within a business update handler.
138+
func (c *Context) Business() (*BusinessContext, bool) {
139+
id := c.Update.businessConnectionID()
140+
if id == "" {
141+
return nil, false
142+
}
143+
144+
return c.Bot.Business(id), true
145+
}

business_updates_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/gotd/td/tg"
8+
)
9+
10+
func TestBusinessRouteByKind(t *testing.T) {
11+
b := newMockBot(newMockInvoker())
12+
13+
var fired string
14+
15+
b.OnBusinessMessage(func(c *Context) error { fired = "message"; return nil })
16+
b.OnEditedBusinessMessage(func(c *Context) error { fired = "edited"; return nil })
17+
b.OnBusinessConnection(func(c *Context) error { fired = "connection"; return nil })
18+
b.OnDeletedBusinessMessages(func(c *Context) error { fired = "deleted"; return nil })
19+
20+
cases := []struct {
21+
u *Update
22+
want string
23+
}{
24+
{&Update{BusinessMessage: &Message{}}, "message"},
25+
{&Update{EditedBusinessMessage: &Message{}}, "edited"},
26+
{&Update{BusinessConnection: &BusinessConnection{ID: "bc1"}}, "connection"},
27+
{&Update{DeletedBusinessMessages: &BusinessMessagesDeleted{BusinessConnectionID: "bc1"}}, "deleted"},
28+
}
29+
for _, c := range cases {
30+
fired = ""
31+
32+
b.route(context.Background(), c.u)
33+
34+
if fired != c.want {
35+
t.Fatalf("update %+v fired %q, want %q", c.u, fired, c.want)
36+
}
37+
}
38+
}
39+
40+
func TestDispatchBusinessMessageKeepsOutgoing(t *testing.T) {
41+
b := newMockBot(newMockInvoker())
42+
43+
var got *Message
44+
45+
b.OnBusinessMessage(func(c *Context) error { got = c.BusinessMessage(); return nil })
46+
47+
// Unlike a normal message, an outgoing business message is still delivered.
48+
msg := &tg.Message{ID: 7, Out: true, Message: "hi", PeerID: &tg.PeerUser{UserID: 10}}
49+
b.dispatchBusinessMessage(context.Background(), "bc1", msg, false)
50+
51+
if got == nil {
52+
t.Fatal("outgoing business message should be dispatched")
53+
}
54+
55+
if got.BusinessConnectionID != "bc1" {
56+
t.Fatalf("connection id = %q", got.BusinessConnectionID)
57+
}
58+
59+
if got.MessageID != 7 {
60+
t.Fatalf("message id = %d", got.MessageID)
61+
}
62+
}
63+
64+
func TestDispatchBusinessMessageEdited(t *testing.T) {
65+
b := newMockBot(newMockInvoker())
66+
67+
fired := false
68+
69+
b.OnEditedBusinessMessage(func(c *Context) error { fired = true; return nil })
70+
b.dispatchBusinessMessage(context.Background(), "bc1", &tg.Message{ID: 8, PeerID: &tg.PeerUser{UserID: 10}}, true)
71+
72+
if !fired {
73+
t.Fatal("edited business message handler did not fire")
74+
}
75+
}
76+
77+
func TestContextBusinessFromMessage(t *testing.T) {
78+
b := newMockBot(newMockInvoker())
79+
c := &Context{Context: context.Background(), Bot: b, Update: &Update{
80+
BusinessMessage: &Message{MessageID: 1, BusinessConnectionID: "bc7"},
81+
}}
82+
83+
bc, ok := c.Business()
84+
if !ok || bc.ConnectionID() != "bc7" {
85+
t.Fatalf("business = %#v, ok=%v", bc, ok)
86+
}
87+
88+
if c.BusinessMessage() == nil {
89+
t.Fatal("BusinessMessage accessor returned nil")
90+
}
91+
}
92+
93+
func TestContextBusinessFromConnection(t *testing.T) {
94+
b := newMockBot(newMockInvoker())
95+
c := &Context{Context: context.Background(), Bot: b, Update: &Update{
96+
BusinessConnection: &BusinessConnection{ID: "bc8"},
97+
}}
98+
99+
bc, ok := c.Business()
100+
if !ok || bc.ConnectionID() != "bc8" {
101+
t.Fatalf("business = %#v, ok=%v", bc, ok)
102+
}
103+
}
104+
105+
func TestContextBusinessAbsent(t *testing.T) {
106+
b := newMockBot(newMockInvoker())
107+
c := &Context{Context: context.Background(), Bot: b, Update: &Update{
108+
Message: &Message{MessageID: 1},
109+
}}
110+
111+
if bc, ok := c.Business(); ok || bc != nil {
112+
t.Fatalf("expected no business context, got %#v", bc)
113+
}
114+
115+
if c.BusinessMessage() != nil {
116+
t.Fatal("non-business update should have no business message")
117+
}
118+
}

on.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ func (b *Bot) installHandlers() {
5151

5252
return nil
5353
})
54+
b.installBusinessHandlers()
5455
}
5556

5657
// dispatchMessage converts a message and routes it as the appropriate update

types_message.go

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,23 @@ type PollAnswer struct {
8282

8383
// Message represents a message.
8484
type Message struct {
85-
MessageID int `json:"message_id"`
86-
MessageThreadID int `json:"message_thread_id,omitempty"`
87-
From *User `json:"from,omitempty"`
88-
SenderChat *Chat `json:"sender_chat,omitempty"`
89-
Date int `json:"date"`
90-
Chat Chat `json:"chat"`
91-
ForwardOrigin MessageOrigin `json:"forward_origin,omitempty"`
92-
ReplyToMessage *Message `json:"reply_to_message,omitempty"`
93-
ViaBot *User `json:"via_bot,omitempty"`
94-
EditDate int `json:"edit_date,omitempty"`
95-
HasProtectedContent bool `json:"has_protected_content,omitempty"`
96-
MediaGroupID string `json:"media_group_id,omitempty"`
97-
AuthorSignature string `json:"author_signature,omitempty"`
85+
MessageID int `json:"message_id"`
86+
MessageThreadID int `json:"message_thread_id,omitempty"`
87+
// BusinessConnectionID is the unique identifier of the business connection
88+
// the message was received from, for messages delivered as part of a
89+
// business connection.
90+
BusinessConnectionID string `json:"business_connection_id,omitempty"`
91+
From *User `json:"from,omitempty"`
92+
SenderChat *Chat `json:"sender_chat,omitempty"`
93+
Date int `json:"date"`
94+
Chat Chat `json:"chat"`
95+
ForwardOrigin MessageOrigin `json:"forward_origin,omitempty"`
96+
ReplyToMessage *Message `json:"reply_to_message,omitempty"`
97+
ViaBot *User `json:"via_bot,omitempty"`
98+
EditDate int `json:"edit_date,omitempty"`
99+
HasProtectedContent bool `json:"has_protected_content,omitempty"`
100+
MediaGroupID string `json:"media_group_id,omitempty"`
101+
AuthorSignature string `json:"author_signature,omitempty"`
98102

99103
Text string `json:"text,omitempty"`
100104
Entities []MessageEntity `json:"entities,omitempty"`

update.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ type Update struct {
1818
MyChatMember *ChatMemberUpdated `json:"my_chat_member,omitempty"`
1919
ChatMember *ChatMemberUpdated `json:"chat_member,omitempty"`
2020

21+
// BusinessConnection is set when the bot is connected to or disconnected from
22+
// a business account, or a connection setting was changed.
23+
BusinessConnection *BusinessConnection `json:"business_connection,omitempty"`
24+
// BusinessMessage is a new message from a connected business account.
25+
BusinessMessage *Message `json:"business_message,omitempty"`
26+
// EditedBusinessMessage is an edited message from a connected business
27+
// account.
28+
EditedBusinessMessage *Message `json:"edited_business_message,omitempty"`
29+
// DeletedBusinessMessages reports messages deleted from a connected business
30+
// account.
31+
DeletedBusinessMessages *BusinessMessagesDeleted `json:"deleted_business_messages,omitempty"`
32+
2133
// botUsername is this bot's @username (without the @), set by the router so
2234
// command predicates can tell a command targeted at this bot ("/cmd@me")
2335
// from one targeted at another ("/cmd@other"). Not part of the Bot API

0 commit comments

Comments
 (0)