Skip to content

Commit 53506a7

Browse files
ernadoclaude
andcommitted
feat(payments): shipping/pre-checkout queries and answers
Add payment query types (ShippingQuery, PreCheckoutQuery, OrderInfo, ShippingAddress, ShippingOption, LabeledPrice), wire the incoming UpdateBotShippingQuery/UpdateBotPrecheckoutQuery into the router with Update.ShippingQuery/PreCheckoutQuery and On{Shipping,PreCheckout}Query handlers, and implement AnswerShippingQuery/AnswerPreCheckoutQuery over messages.setBot{Shipping,Precheckout}Results. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a5402d1 commit 53506a7

7 files changed

Lines changed: 313 additions & 9 deletions

File tree

conformance_test.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,8 @@ var coveredByOtherMeans = map[string]string{
2121
// implemented. Each is an acknowledged gap tracked in docs/roadmap.md; the
2222
// conformance test allows them so it can still catch *unacknowledged* drift.
2323
var deferredMethods = map[string]string{
24-
"answerPreCheckoutQuery": "payments — deferred until payment updates land",
25-
"answerShippingQuery": "payments — deferred until payment updates land",
26-
"sendInvoice": "payments — deferred",
27-
"setPassportDataErrors": "Telegram Passport — deferred",
24+
"sendInvoice": "payments — deferred",
25+
"setPassportDataErrors": "Telegram Passport — deferred",
2826
}
2927

3028
// notApplicableMethods lists published methods that do not apply to the

on.go

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ func (b *Bot) installHandlers() {
3535
b.route(ctx, &Update{InlineQuery: inlineQueryFromTg(e, u)})
3636
return nil
3737
})
38+
b.disp.OnBotShippingQuery(func(ctx context.Context, e tg.Entities, u *tg.UpdateBotShippingQuery) error {
39+
b.route(ctx, &Update{ShippingQuery: shippingQueryFromTg(e, u)})
40+
return nil
41+
})
42+
b.disp.OnBotPrecheckoutQuery(func(ctx context.Context, e tg.Entities, u *tg.UpdateBotPrecheckoutQuery) error {
43+
b.route(ctx, &Update{PreCheckoutQuery: preCheckoutQueryFromTg(e, u)})
44+
return nil
45+
})
3846
}
3947

4048
// dispatchMessage converts a message and routes it as the appropriate update
@@ -73,11 +81,13 @@ func (b *Bot) dispatchMessage(ctx context.Context, msg tg.MessageClass, edited b
7381

7482
// Kind predicates select an update by which field it carries. They are shared
7583
// by Bot.On* and Group.On*.
76-
func hasMessage(u *Update) bool { return u.Message != nil }
77-
func hasEditedMessage(u *Update) bool { return u.EditedMessage != nil }
78-
func hasChannelPost(u *Update) bool { return u.ChannelPost != nil }
79-
func hasCallbackQuery(u *Update) bool { return u.CallbackQuery != nil }
80-
func hasInlineQuery(u *Update) bool { return u.InlineQuery != nil }
84+
func hasMessage(u *Update) bool { return u.Message != nil }
85+
func hasEditedMessage(u *Update) bool { return u.EditedMessage != nil }
86+
func hasChannelPost(u *Update) bool { return u.ChannelPost != nil }
87+
func hasCallbackQuery(u *Update) bool { return u.CallbackQuery != nil }
88+
func hasInlineQuery(u *Update) bool { return u.InlineQuery != nil }
89+
func hasShippingQuery(u *Update) bool { return u.ShippingQuery != nil }
90+
func hasPreCheckoutQuery(u *Update) bool { return u.PreCheckoutQuery != nil }
8191

8292
// OnMessage registers a handler for new messages matching the predicates.
8393
func (b *Bot) OnMessage(h Handler, predicates ...Predicate) {
@@ -104,6 +114,16 @@ func (b *Bot) OnInlineQuery(h Handler, predicates ...Predicate) {
104114
b.on(h, prepend(hasInlineQuery, predicates)...)
105115
}
106116

117+
// OnShippingQuery registers a handler for shipping queries (flexible invoices).
118+
func (b *Bot) OnShippingQuery(h Handler, predicates ...Predicate) {
119+
b.on(h, prepend(hasShippingQuery, predicates)...)
120+
}
121+
122+
// OnPreCheckoutQuery registers a handler for pre-checkout queries.
123+
func (b *Bot) OnPreCheckoutQuery(h Handler, predicates ...Predicate) {
124+
b.on(h, prepend(hasPreCheckoutQuery, predicates)...)
125+
}
126+
107127
// prepend returns p followed by rest, without mutating rest.
108128
func prepend(p Predicate, rest []Predicate) []Predicate {
109129
out := make([]Predicate, 0, len(rest)+1)

payments.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"strconv"
6+
7+
"github.com/gotd/td/tg"
8+
)
9+
10+
// AnswerShippingQuery replies to a shipping query. When ok is true, provide the
11+
// available shipping options; otherwise pass an error message via
12+
// WithShippingError describing why the order can't be shipped.
13+
func (b *Bot) AnswerShippingQuery(ctx context.Context, shippingQueryID string, ok bool, opts ...ShippingAnswerOption) error {
14+
var cfg shippingAnswerConfig
15+
for _, o := range opts {
16+
o(&cfg)
17+
}
18+
19+
queryID, err := strconv.ParseInt(shippingQueryID, 10, 64)
20+
if err != nil {
21+
return &Error{Code: 400, Description: "Bad Request: invalid shipping query id"}
22+
}
23+
24+
req := &tg.MessagesSetBotShippingResultsRequest{QueryID: queryID}
25+
if ok {
26+
req.ShippingOptions = shippingOptionsToTg(cfg.options)
27+
} else {
28+
req.Error = cfg.errorMessage
29+
}
30+
31+
if _, err := b.raw.MessagesSetBotShippingResults(ctx, req); err != nil {
32+
return asAPIError(err)
33+
}
34+
return nil
35+
}
36+
37+
// ShippingAnswerOption configures an AnswerShippingQuery call.
38+
type ShippingAnswerOption func(*shippingAnswerConfig)
39+
40+
type shippingAnswerConfig struct {
41+
options []ShippingOption
42+
errorMessage string
43+
}
44+
45+
// WithShippingOptions sets the available shipping options (required when ok).
46+
func WithShippingOptions(options ...ShippingOption) ShippingAnswerOption {
47+
return func(c *shippingAnswerConfig) { c.options = options }
48+
}
49+
50+
// WithShippingError sets the human-readable reason shipping is impossible (used
51+
// when ok is false).
52+
func WithShippingError(message string) ShippingAnswerOption {
53+
return func(c *shippingAnswerConfig) { c.errorMessage = message }
54+
}
55+
56+
// AnswerPreCheckoutQuery replies to a pre-checkout query. When ok is false,
57+
// provide a human-readable reason via WithPreCheckoutError.
58+
func (b *Bot) AnswerPreCheckoutQuery(ctx context.Context, preCheckoutQueryID string, ok bool, opts ...PreCheckoutAnswerOption) error {
59+
var cfg preCheckoutAnswerConfig
60+
for _, o := range opts {
61+
o(&cfg)
62+
}
63+
64+
queryID, err := strconv.ParseInt(preCheckoutQueryID, 10, 64)
65+
if err != nil {
66+
return &Error{Code: 400, Description: "Bad Request: invalid pre-checkout query id"}
67+
}
68+
69+
req := &tg.MessagesSetBotPrecheckoutResultsRequest{
70+
Success: ok,
71+
QueryID: queryID,
72+
}
73+
if !ok {
74+
req.Error = cfg.errorMessage
75+
}
76+
77+
if _, err := b.raw.MessagesSetBotPrecheckoutResults(ctx, req); err != nil {
78+
return asAPIError(err)
79+
}
80+
return nil
81+
}
82+
83+
// PreCheckoutAnswerOption configures an AnswerPreCheckoutQuery call.
84+
type PreCheckoutAnswerOption func(*preCheckoutAnswerConfig)
85+
86+
type preCheckoutAnswerConfig struct {
87+
errorMessage string
88+
}
89+
90+
// WithPreCheckoutError sets the reason the checkout can't proceed (used when ok
91+
// is false).
92+
func WithPreCheckoutError(message string) PreCheckoutAnswerOption {
93+
return func(c *preCheckoutAnswerConfig) { c.errorMessage = message }
94+
}
95+
96+
// shippingOptionsToTg converts Bot API shipping options to MTProto.
97+
func shippingOptionsToTg(options []ShippingOption) []tg.ShippingOption {
98+
out := make([]tg.ShippingOption, 0, len(options))
99+
for _, o := range options {
100+
prices := make([]tg.LabeledPrice, 0, len(o.Prices))
101+
for _, p := range o.Prices {
102+
prices = append(prices, tg.LabeledPrice{Label: p.Label, Amount: int64(p.Amount)})
103+
}
104+
out = append(out, tg.ShippingOption{ID: o.ID, Title: o.Title, Prices: prices})
105+
}
106+
return out
107+
}

payments_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package botapi
2+
3+
import (
4+
"testing"
5+
6+
"github.com/gotd/td/tg"
7+
)
8+
9+
func TestShippingQueryFromTg(t *testing.T) {
10+
e := tg.Entities{Users: map[int64]*tg.User{7: {ID: 7, FirstName: "Ann"}}}
11+
u := &tg.UpdateBotShippingQuery{
12+
QueryID: 100,
13+
UserID: 7,
14+
Payload: []byte("order-1"),
15+
ShippingAddress: tg.PostAddress{
16+
StreetLine1: "1 Main St",
17+
City: "Town",
18+
CountryISO2: "US",
19+
PostCode: "12345",
20+
},
21+
}
22+
q := shippingQueryFromTg(e, u)
23+
if q.ID != "100" || q.From.FirstName != "Ann" || q.InvoicePayload != "order-1" {
24+
t.Fatalf("query: %#v", q)
25+
}
26+
if q.ShippingAddress.CountryCode != "US" || q.ShippingAddress.City != "Town" {
27+
t.Fatalf("address: %#v", q.ShippingAddress)
28+
}
29+
}
30+
31+
func TestPreCheckoutQueryFromTg(t *testing.T) {
32+
u := &tg.UpdateBotPrecheckoutQuery{
33+
QueryID: 200,
34+
UserID: 9,
35+
Payload: []byte("p"),
36+
Currency: "USD",
37+
TotalAmount: 1500,
38+
ShippingOptionID: "express",
39+
}
40+
u.SetInfo(tg.PaymentRequestedInfo{Name: "Bob", Email: "b@x.io"})
41+
42+
q := preCheckoutQueryFromTg(tg.Entities{}, u)
43+
if q.ID != "200" || q.From.ID != 9 || q.Currency != "USD" || q.TotalAmount != 1500 {
44+
t.Fatalf("query: %#v", q)
45+
}
46+
if q.ShippingOptionID != "express" {
47+
t.Fatalf("shipping option: %q", q.ShippingOptionID)
48+
}
49+
if q.OrderInfo == nil || q.OrderInfo.Name != "Bob" || q.OrderInfo.Email != "b@x.io" {
50+
t.Fatalf("order info: %#v", q.OrderInfo)
51+
}
52+
}
53+
54+
func TestShippingOptionsToTg(t *testing.T) {
55+
opts := []ShippingOption{
56+
{ID: "std", Title: "Standard", Prices: []LabeledPrice{{Label: "Base", Amount: 500}}},
57+
}
58+
got := shippingOptionsToTg(opts)
59+
if len(got) != 1 || got[0].ID != "std" || len(got[0].Prices) != 1 || got[0].Prices[0].Amount != 500 {
60+
t.Fatalf("converted: %#v", got)
61+
}
62+
}

types_payment.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package botapi
2+
3+
// LabeledPrice represents a portion of the price for goods or services.
4+
type LabeledPrice struct {
5+
Label string `json:"label"`
6+
// Amount is the price in the smallest units of the currency (integer), e.g.
7+
// for $1.45 pass amount = 145.
8+
Amount int `json:"amount"`
9+
}
10+
11+
// ShippingOption represents one shipping option.
12+
type ShippingOption struct {
13+
ID string `json:"id"`
14+
Title string `json:"title"`
15+
Prices []LabeledPrice `json:"prices"`
16+
}
17+
18+
// ShippingAddress represents a shipping address.
19+
type ShippingAddress struct {
20+
CountryCode string `json:"country_code"`
21+
State string `json:"state"`
22+
City string `json:"city"`
23+
StreetLine1 string `json:"street_line1"`
24+
StreetLine2 string `json:"street_line2"`
25+
PostCode string `json:"post_code"`
26+
}
27+
28+
// OrderInfo represents information about an order.
29+
type OrderInfo struct {
30+
Name string `json:"name,omitempty"`
31+
PhoneNumber string `json:"phone_number,omitempty"`
32+
Email string `json:"email,omitempty"`
33+
ShippingAddress *ShippingAddress `json:"shipping_address,omitempty"`
34+
}
35+
36+
// ShippingQuery contains information about an incoming shipping query.
37+
type ShippingQuery struct {
38+
ID string `json:"id"`
39+
From User `json:"from"`
40+
InvoicePayload string `json:"invoice_payload"`
41+
ShippingAddress ShippingAddress `json:"shipping_address"`
42+
}
43+
44+
// PreCheckoutQuery contains information about an incoming pre-checkout query.
45+
type PreCheckoutQuery struct {
46+
ID string `json:"id"`
47+
From User `json:"from"`
48+
Currency string `json:"currency"`
49+
TotalAmount int `json:"total_amount"`
50+
InvoicePayload string `json:"invoice_payload"`
51+
ShippingOptionID string `json:"shipping_option_id,omitempty"`
52+
OrderInfo *OrderInfo `json:"order_info,omitempty"`
53+
}

update.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ type Update struct {
1111
InlineQuery *InlineQuery `json:"inline_query,omitempty"`
1212
ChosenInlineResult *ChosenInlineResult `json:"chosen_inline_result,omitempty"`
1313
CallbackQuery *CallbackQuery `json:"callback_query,omitempty"`
14+
ShippingQuery *ShippingQuery `json:"shipping_query,omitempty"`
15+
PreCheckoutQuery *PreCheckoutQuery `json:"pre_checkout_query,omitempty"`
1416
Poll *Poll `json:"poll,omitempty"`
1517
PollAnswer *PollAnswer `json:"poll_answer,omitempty"`
1618
MyChatMember *ChatMemberUpdated `json:"my_chat_member,omitempty"`

updates_map.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,65 @@ func inlineQueryFromTg(e tg.Entities, u *tg.UpdateBotInlineQuery) *InlineQuery {
4949
}
5050
return iq
5151
}
52+
53+
// shippingQueryFromTg builds a ShippingQuery from a bot shipping-query update.
54+
func shippingQueryFromTg(e tg.Entities, u *tg.UpdateBotShippingQuery) *ShippingQuery {
55+
q := &ShippingQuery{
56+
ID: strconv.FormatInt(u.QueryID, 10),
57+
InvoicePayload: string(u.Payload),
58+
ShippingAddress: shippingAddressFromTg(u.ShippingAddress),
59+
}
60+
if user, ok := e.Users[u.UserID]; ok {
61+
q.From = userFromTgUser(user)
62+
} else {
63+
q.From = User{ID: u.UserID}
64+
}
65+
return q
66+
}
67+
68+
// preCheckoutQueryFromTg builds a PreCheckoutQuery from a bot pre-checkout
69+
// update.
70+
func preCheckoutQueryFromTg(e tg.Entities, u *tg.UpdateBotPrecheckoutQuery) *PreCheckoutQuery {
71+
q := &PreCheckoutQuery{
72+
ID: strconv.FormatInt(u.QueryID, 10),
73+
Currency: u.Currency,
74+
TotalAmount: int(u.TotalAmount),
75+
InvoicePayload: string(u.Payload),
76+
ShippingOptionID: u.ShippingOptionID,
77+
}
78+
if user, ok := e.Users[u.UserID]; ok {
79+
q.From = userFromTgUser(user)
80+
} else {
81+
q.From = User{ID: u.UserID}
82+
}
83+
if info, ok := u.GetInfo(); ok {
84+
q.OrderInfo = orderInfoFromTg(info)
85+
}
86+
return q
87+
}
88+
89+
// shippingAddressFromTg converts an MTProto post address.
90+
func shippingAddressFromTg(a tg.PostAddress) ShippingAddress {
91+
return ShippingAddress{
92+
CountryCode: a.CountryISO2,
93+
State: a.State,
94+
City: a.City,
95+
StreetLine1: a.StreetLine1,
96+
StreetLine2: a.StreetLine2,
97+
PostCode: a.PostCode,
98+
}
99+
}
100+
101+
// orderInfoFromTg converts MTProto requested payment info.
102+
func orderInfoFromTg(info tg.PaymentRequestedInfo) *OrderInfo {
103+
out := &OrderInfo{
104+
Name: info.Name,
105+
PhoneNumber: info.Phone,
106+
Email: info.Email,
107+
}
108+
if addr, ok := info.GetShippingAddress(); ok {
109+
a := shippingAddressFromTg(addr)
110+
out.ShippingAddress = &a
111+
}
112+
return out
113+
}

0 commit comments

Comments
 (0)