Skip to content

Commit c4a583f

Browse files
ernadoclaude
andcommitted
fix: address business sends with the account-scoped peer
A reply on behalf of a business account failed with BUSINESS_PEER_INVALID because the send re-resolved the chat through the bot's peer store, whose access hash is invalid in the business context. Capture the input peer from the update's entities during business-message conversion (Message.businessPeer) and address replies through a PeerRef built from it, bypassing the store. Context.Chat/Reply now target this peer, and Context.BusinessChat() exposes it for explicit sends. Update the business example to use it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b67076f commit c4a583f

6 files changed

Lines changed: 107 additions & 9 deletions

File tree

business_send_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,39 @@ func TestContextReplyBusiness(t *testing.T) {
113113
}
114114
}
115115

116+
func TestBusinessReplyUsesEntitiesPeer(t *testing.T) {
117+
inv := newMockInvoker()
118+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, businessSendReply())
119+
120+
b := newMockBot(inv)
121+
b.OnBusinessMessage(func(c *Context) error {
122+
_, err := c.Reply("hi")
123+
124+
return err
125+
})
126+
127+
// The update delivers the chat's access hash (555) in its entities; the reply
128+
// must address the peer with that hash, not the bot's stored one.
129+
e := tg.Entities{Users: map[int64]*tg.User{10: {ID: 10, AccessHash: 555}}}
130+
msg := &tg.Message{ID: 7, PeerID: &tg.PeerUser{UserID: 10}}
131+
132+
b.dispatchBusinessMessage(context.Background(), e, "bc1", msg, false)
133+
134+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.MessagesSendMessageRequest{}}
135+
136+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
137+
138+
sm, ok := wrapper.Query.(*tg.MessagesSendMessageRequest)
139+
if !ok {
140+
t.Fatalf("query = %#v", wrapper.Query)
141+
}
142+
143+
peer, ok := sm.Peer.(*tg.InputPeerUser)
144+
if !ok || peer.UserID != 10 || peer.AccessHash != 555 {
145+
t.Fatalf("peer = %#v, want user 10 with access hash 555", sm.Peer)
146+
}
147+
}
148+
116149
func TestContextSendBusiness(t *testing.T) {
117150
inv := newMockInvoker()
118151
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, businessSendReply())

business_updates.go

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ func (b *Bot) installBusinessHandlers() {
1515

1616
return nil
1717
})
18-
b.disp.OnBotNewBusinessMessage(func(ctx context.Context, _ tg.Entities, u *tg.UpdateBotNewBusinessMessage) error {
19-
b.dispatchBusinessMessage(ctx, u.ConnectionID, u.Message, false)
18+
b.disp.OnBotNewBusinessMessage(func(ctx context.Context, e tg.Entities, u *tg.UpdateBotNewBusinessMessage) error {
19+
b.dispatchBusinessMessage(ctx, e, u.ConnectionID, u.Message, false)
2020

2121
return nil
2222
})
23-
b.disp.OnBotEditBusinessMessage(func(ctx context.Context, _ tg.Entities, u *tg.UpdateBotEditBusinessMessage) error {
24-
b.dispatchBusinessMessage(ctx, u.ConnectionID, u.Message, true)
23+
b.disp.OnBotEditBusinessMessage(func(ctx context.Context, e tg.Entities, u *tg.UpdateBotEditBusinessMessage) error {
24+
b.dispatchBusinessMessage(ctx, e, u.ConnectionID, u.Message, true)
2525

2626
return nil
2727
})
@@ -47,7 +47,7 @@ func (b *Bot) installBusinessHandlers() {
4747
// and routes it as a (edited) business message. Unlike dispatchMessage it keeps
4848
// outgoing messages: the Bot API delivers the whole business conversation,
4949
// including messages the account itself sent.
50-
func (b *Bot) dispatchBusinessMessage(ctx context.Context, connectionID string, msg tg.MessageClass, edited bool) {
50+
func (b *Bot) dispatchBusinessMessage(ctx context.Context, e tg.Entities, connectionID string, msg tg.MessageClass, edited bool) {
5151
m, err := b.messageFromTg(ctx, msg)
5252
if err != nil {
5353
b.logger().Error(ctx, "Convert business message", log.Error(err))
@@ -60,6 +60,9 @@ func (b *Bot) dispatchBusinessMessage(ctx context.Context, connectionID string,
6060
}
6161

6262
m.BusinessConnectionID = connectionID
63+
if m.raw != nil {
64+
m.businessPeer = inputPeerFromEntities(m.raw.PeerID, e)
65+
}
6366

6467
u := &Update{}
6568
if edited {
@@ -71,6 +74,41 @@ func (b *Bot) dispatchBusinessMessage(ctx context.Context, connectionID string,
7174
b.route(ctx, u)
7275
}
7376

77+
// inputPeerFromEntities builds the input peer for a message's chat from the
78+
// access hashes delivered with the update, so a business send addresses the chat
79+
// with the account's own access hash. Returns nil when the peer is absent.
80+
func inputPeerFromEntities(peer tg.PeerClass, e tg.Entities) tg.InputPeerClass {
81+
switch p := peer.(type) {
82+
case *tg.PeerUser:
83+
if u, ok := e.Users[p.UserID]; ok {
84+
return &tg.InputPeerUser{UserID: p.UserID, AccessHash: u.AccessHash}
85+
}
86+
case *tg.PeerChannel:
87+
if ch, ok := e.Channels[p.ChannelID]; ok {
88+
return &tg.InputPeerChannel{ChannelID: p.ChannelID, AccessHash: ch.AccessHash}
89+
}
90+
case *tg.PeerChat:
91+
return &tg.InputPeerChat{ChatID: p.ChatID}
92+
}
93+
94+
return nil
95+
}
96+
97+
// businessChatID returns a send target for a business message's chat that carries
98+
// the account-scoped access hash, bypassing the bot's peer store.
99+
func businessChatID(m *Message) (ChatID, bool) {
100+
if m == nil || m.businessPeer == nil {
101+
return nil, false
102+
}
103+
104+
ref, err := peerRefFromInputPeer(m.businessPeer)
105+
if err != nil {
106+
return nil, false
107+
}
108+
109+
return Peer(ref), true
110+
}
111+
74112
// businessConnectionID returns the connection id the update belongs to, or empty
75113
// when the update is not a business update.
76114
func (u *Update) businessConnectionID() string {
@@ -132,6 +170,14 @@ func (c *Context) BusinessMessage() *Message {
132170
}
133171
}
134172

173+
// BusinessChat returns a send target for the business message's chat that
174+
// carries the account-scoped access hash, and whether the update is a business
175+
// message. Use it to address sends on behalf of the connected account; the bot's
176+
// stored access hash is invalid in the business context.
177+
func (c *Context) BusinessChat() (ChatID, bool) {
178+
return businessChatID(c.BusinessMessage())
179+
}
180+
135181
// Business returns a BusinessContext scoped to the update's business connection,
136182
// and whether the update belongs to one. Use it to act on behalf of the
137183
// connected account from within a business update handler.

business_updates_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func TestDispatchBusinessMessageKeepsOutgoing(t *testing.T) {
4646

4747
// Unlike a normal message, an outgoing business message is still delivered.
4848
msg := &tg.Message{ID: 7, Out: true, Message: "hi", PeerID: &tg.PeerUser{UserID: 10}}
49-
b.dispatchBusinessMessage(context.Background(), "bc1", msg, false)
49+
b.dispatchBusinessMessage(context.Background(), tg.Entities{}, "bc1", msg, false)
5050

5151
if got == nil {
5252
t.Fatal("outgoing business message should be dispatched")
@@ -67,7 +67,7 @@ func TestDispatchBusinessMessageEdited(t *testing.T) {
6767
fired := false
6868

6969
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)
70+
b.dispatchBusinessMessage(context.Background(), tg.Entities{}, "bc1", &tg.Message{ID: 8, PeerID: &tg.PeerUser{UserID: 10}}, true)
7171

7272
if !fired {
7373
t.Fatal("edited business message handler did not fire")

context.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ func (c *Context) Chat() (ChatID, bool) {
3030
}
3131

3232
if bm := c.BusinessMessage(); bm != nil {
33+
if target, ok := businessChatID(bm); ok {
34+
return target, true
35+
}
36+
3337
return ID(bm.Chat.ID), true
3438
}
3539

@@ -64,7 +68,12 @@ func (c *Context) Reply(text string, opts ...SendOption) (*Message, error) {
6468
WithBusinessConnection(bm.BusinessConnectionID),
6569
}, opts...)
6670

67-
return c.Bot.SendMessage(c, ID(bm.Chat.ID), text, opts...)
71+
target, ok := businessChatID(bm)
72+
if !ok {
73+
target = ID(bm.Chat.ID)
74+
}
75+
76+
return c.Bot.SendMessage(c, target, text, opts...)
6877
}
6978

7079
m := c.Message()

examples/business/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,10 @@ func dispatchCommand(c *botapi.Context, bc *botapi.BusinessContext, fields []str
155155
}
156156

157157
cmd, args := fields[0], fields[1:]
158-
chat := botapi.ID(c.BusinessMessage().Chat.ID)
158+
159+
// Address sends with the account-scoped peer (carries the right access hash),
160+
// not the bot's stored one.
161+
chat, _ := c.BusinessChat()
159162

160163
switch cmd {
161164
case "help":

types_message.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,13 @@ type Message struct {
132132
// nil for synthesized stubs (e.g. reply_to/pinned placeholders). It is not
133133
// serialized; reach it through Raw.
134134
raw *tg.Message
135+
136+
// businessPeer is the input peer of a business message's chat, built from the
137+
// access hash delivered in the update's entities. A reply on behalf of a
138+
// business account must use this peer (the account's own access hash for the
139+
// chat) rather than the bot's stored one, which is invalid in the business
140+
// context (BUSINESS_PEER_INVALID). Nil for non-business messages.
141+
businessPeer tg.InputPeerClass
135142
}
136143

137144
// Raw returns the original MTProto message this Message was converted from, for

0 commit comments

Comments
 (0)