Skip to content

Commit 101779f

Browse files
ernadoclaude
andcommitted
feat: send text messages on behalf of a business account
Add WithBusinessConnection send option and a business-scoped message.Sender (a businessInvoker that wraps the send RPC in invokeWithBusinessConnection), wired into SendMessage. BusinessContext.SendMessage and business-aware Context.Send/Reply let a handler reply as the connected account: in an OnBusinessMessage handler c.Reply now answers on the account's behalf. Scoped to text (single RPC); media-over-business is not wrapped yet because the upload calls must stay outside the connection wrapper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c9f8b1c commit 101779f

5 files changed

Lines changed: 218 additions & 3 deletions

File tree

business.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55

66
"github.com/gotd/td/bin"
7+
"github.com/gotd/td/telegram/message"
78
"github.com/gotd/td/tg"
89
)
910

@@ -85,6 +86,36 @@ func (b *Bot) invokeBusiness(ctx context.Context, connectionID string, query bin
8586
}, output)
8687
}
8788

89+
// businessInvoker wraps every RPC forwarded through it in
90+
// invokeWithBusinessConnection, so a message.Sender built on it sends on behalf
91+
// of a connected business account.
92+
//
93+
// It is suitable for single-RPC sends (e.g. a text message). It must not back
94+
// uploads: it would wrap the upload.saveFilePart calls too, which is not how the
95+
// account-scoped media path works.
96+
type businessInvoker struct {
97+
inner tg.Invoker
98+
connectionID string
99+
}
100+
101+
func (i businessInvoker) Invoke(ctx context.Context, input bin.Encoder, output bin.Decoder) error {
102+
query, ok := input.(bin.Object)
103+
if !ok {
104+
return i.inner.Invoke(ctx, input, output)
105+
}
106+
107+
return i.inner.Invoke(ctx, &tg.InvokeWithBusinessConnectionRequest{
108+
ConnectionID: i.connectionID,
109+
Query: query,
110+
}, output)
111+
}
112+
113+
// businessSender returns a message.Sender that sends on behalf of the business
114+
// account identified by connectionID.
115+
func (b *Bot) businessSender(connectionID string) *message.Sender {
116+
return message.NewSender(tg.NewClient(businessInvoker{inner: b.invoker, connectionID: connectionID}))
117+
}
118+
88119
// GetBusinessConnection returns information about the connection of the bot with
89120
// a business account by the connection id received in updates.
90121
func (b *Bot) GetBusinessConnection(ctx context.Context, businessConnectionID string) (*BusinessConnection, error) {

business_context.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,9 @@ func (c *BusinessContext) StarBalance(ctx context.Context) (StarAmount, error) {
7272
func (c *BusinessContext) DeleteMessages(ctx context.Context, messageIDs []int) error {
7373
return c.bot.DeleteBusinessMessages(ctx, c.connectionID, messageIDs)
7474
}
75+
76+
// SendMessage sends a text message to a chat on behalf of the connected business
77+
// account and returns the sent message.
78+
func (c *BusinessContext) SendMessage(ctx context.Context, chat ChatID, text string, opts ...SendOption) (*Message, error) {
79+
return c.bot.SendMessage(ctx, chat, text, append(opts, WithBusinessConnection(c.connectionID))...)
80+
}

business_send_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/gotd/td/tg"
8+
)
9+
10+
func businessSendReply() *tg.Updates {
11+
return messageUpdates(&tg.Message{ID: 1, Message: "hi", PeerID: &tg.PeerUser{UserID: 10}})
12+
}
13+
14+
func TestSendMessageWithBusinessConnection(t *testing.T) {
15+
inv := newMockInvoker()
16+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, businessSendReply())
17+
18+
b := newMockBot(inv)
19+
20+
m, err := b.SendMessage(context.Background(), userRef(10, 20), "hi", WithBusinessConnection("bc1"))
21+
if err != nil {
22+
t.Fatalf("SendMessage: %v", err)
23+
}
24+
25+
if m.MessageID != 1 {
26+
t.Fatalf("message id = %d", m.MessageID)
27+
}
28+
29+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.MessagesSendMessageRequest{}}
30+
31+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
32+
33+
if wrapper.ConnectionID != "bc1" {
34+
t.Fatalf("connection id = %q", wrapper.ConnectionID)
35+
}
36+
37+
sm, ok := wrapper.Query.(*tg.MessagesSendMessageRequest)
38+
if !ok || sm.Message != "hi" {
39+
t.Fatalf("query = %#v", wrapper.Query)
40+
}
41+
}
42+
43+
func TestSendMessageWithoutBusinessIsDirect(t *testing.T) {
44+
inv := newMockInvoker()
45+
inv.reply(tg.MessagesSendMessageRequestTypeID, businessSendReply())
46+
47+
b := newMockBot(inv)
48+
49+
if _, err := b.SendMessage(context.Background(), userRef(10, 20), "hi"); err != nil {
50+
t.Fatalf("SendMessage: %v", err)
51+
}
52+
53+
if inv.called(tg.InvokeWithBusinessConnectionRequestTypeID) {
54+
t.Fatal("a non-business send must not be wrapped")
55+
}
56+
57+
if !inv.called(tg.MessagesSendMessageRequestTypeID) {
58+
t.Fatal("expected a direct messages.sendMessage")
59+
}
60+
}
61+
62+
func TestBusinessContextSendMessage(t *testing.T) {
63+
inv := newMockInvoker()
64+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, businessSendReply())
65+
66+
b := newMockBot(inv)
67+
68+
if _, err := b.Business("bc3").SendMessage(context.Background(), userRef(10, 20), "hi"); err != nil {
69+
t.Fatalf("SendMessage: %v", err)
70+
}
71+
72+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.MessagesSendMessageRequest{}}
73+
74+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
75+
76+
if wrapper.ConnectionID != "bc3" {
77+
t.Fatalf("connection id = %q", wrapper.ConnectionID)
78+
}
79+
}
80+
81+
func TestContextReplyBusiness(t *testing.T) {
82+
inv := newMockInvoker()
83+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, businessSendReply())
84+
85+
b := newMockBot(inv)
86+
c := &Context{Context: context.Background(), Bot: b, Update: &Update{
87+
BusinessMessage: &Message{
88+
MessageID: 9,
89+
BusinessConnectionID: "bc4",
90+
Chat: Chat{ID: 10, Type: ChatTypePrivate},
91+
},
92+
}}
93+
94+
if _, err := c.Reply("ok"); err != nil {
95+
t.Fatalf("Reply: %v", err)
96+
}
97+
98+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.MessagesSendMessageRequest{}}
99+
100+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
101+
102+
if wrapper.ConnectionID != "bc4" {
103+
t.Fatalf("connection id = %q", wrapper.ConnectionID)
104+
}
105+
106+
sm, ok := wrapper.Query.(*tg.MessagesSendMessageRequest)
107+
if !ok {
108+
t.Fatalf("query = %#v", wrapper.Query)
109+
}
110+
111+
if _, ok := sm.GetReplyTo(); !ok {
112+
t.Fatal("business reply should set reply_to")
113+
}
114+
}
115+
116+
func TestContextSendBusiness(t *testing.T) {
117+
inv := newMockInvoker()
118+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, businessSendReply())
119+
120+
b := newMockBot(inv)
121+
c := &Context{Context: context.Background(), Bot: b, Update: &Update{
122+
BusinessMessage: &Message{
123+
MessageID: 9,
124+
BusinessConnectionID: "bc5",
125+
Chat: Chat{ID: 10, Type: ChatTypePrivate},
126+
},
127+
}}
128+
129+
if _, err := c.Send("hi"); err != nil {
130+
t.Fatalf("Send: %v", err)
131+
}
132+
133+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.MessagesSendMessageRequest{}}
134+
135+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
136+
137+
if wrapper.ConnectionID != "bc5" {
138+
t.Fatalf("connection id = %q", wrapper.ConnectionID)
139+
}
140+
}

context.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,26 +29,44 @@ func (c *Context) Chat() (ChatID, bool) {
2929
return ID(m.Chat.ID), true
3030
}
3131

32+
if bm := c.BusinessMessage(); bm != nil {
33+
return ID(bm.Chat.ID), true
34+
}
35+
3236
if cq := c.Update.CallbackQuery; cq != nil && cq.Message != nil {
3337
return ID(cq.Message.Chat.ID), true
3438
}
3539

3640
return nil, false
3741
}
3842

39-
// Send sends a text message to the update's chat.
43+
// Send sends a text message to the update's chat. For a business update it sends
44+
// on behalf of the connected account.
4045
func (c *Context) Send(text string, opts ...SendOption) (*Message, error) {
4146
chat, ok := c.Chat()
4247
if !ok {
4348
return nil, &Error{Code: 400, Description: "Bad Request: update has no chat to send to"}
4449
}
4550

51+
if id := c.Update.businessConnectionID(); id != "" {
52+
opts = append([]SendOption{WithBusinessConnection(id)}, opts...)
53+
}
54+
4655
return c.Bot.SendMessage(c, chat, text, opts...)
4756
}
4857

4958
// Reply sends a text message to the update's chat as a reply to the incoming
50-
// message.
59+
// message. For a business message it replies on behalf of the connected account.
5160
func (c *Context) Reply(text string, opts ...SendOption) (*Message, error) {
61+
if bm := c.BusinessMessage(); bm != nil {
62+
opts = append([]SendOption{
63+
ReplyTo(bm.MessageID),
64+
WithBusinessConnection(bm.BusinessConnectionID),
65+
}, opts...)
66+
67+
return c.Bot.SendMessage(c, ID(bm.Chat.ID), text, opts...)
68+
}
69+
5270
m := c.Message()
5371
if m == nil {
5472
return nil, &Error{Code: 400, Description: "Bad Request: update has no message to reply to"}

send.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type sendConfig struct {
2323
replyTo int
2424
markup ReplyMarkup
2525
parseMode ParseMode
26+
businessConn string
2627
}
2728

2829
// DisableWebPagePreview disables the link preview for messages with links.
@@ -43,6 +44,25 @@ func WithReplyMarkup(m ReplyMarkup) SendOption { return func(c *sendConfig) { c.
4344
// WithParseMode selects the formatting mode for the text or caption.
4445
func WithParseMode(m ParseMode) SendOption { return func(c *sendConfig) { c.parseMode = m } }
4546

47+
// WithBusinessConnection sends the message on behalf of the business account
48+
// identified by the given connection id, instead of as the bot itself.
49+
//
50+
// It currently applies to SendMessage (text). Sending media on behalf of a
51+
// business account is not yet supported.
52+
func WithBusinessConnection(connectionID string) SendOption {
53+
return func(c *sendConfig) { c.businessConn = connectionID }
54+
}
55+
56+
// senderFor returns the message sender the config selects: the bot's own sender,
57+
// or a business-scoped sender when a business connection is set.
58+
func (b *Bot) senderFor(cfg sendConfig) *message.Sender {
59+
if cfg.businessConn == "" {
60+
return b.sender
61+
}
62+
63+
return b.businessSender(cfg.businessConn)
64+
}
65+
4666
// styledText turns text + parse mode into styling options.
4767
//
4868
// The switch over ParseMode is exhaustive (exhaustive lint).
@@ -141,7 +161,7 @@ func (b *Bot) SendMessage(ctx context.Context, chat ChatID, text string, opts ..
141161
return nil, err
142162
}
143163

144-
builder := &b.sender.To(peer).Builder
164+
builder := &b.senderFor(cfg).To(peer).Builder
145165

146166
builder, err = b.applySendConfig(builder, cfg)
147167
if err != nil {

0 commit comments

Comments
 (0)