Skip to content

Commit d451e59

Browse files
ernadoclaude
andcommitted
feat: add ConvertGiftToStars, UpgradeGift, TransferGift
Business-account gift management, acting on the connected account via invokeWithBusinessConnection: - ConvertGiftToStars -> payments.convertStarGift - UpgradeGift -> payments.upgradeStarGift (keep_original_details) - TransferGift -> payments.transferStarGift (to a resolved peer) The Bot API addresses owned gifts with an opaque owned_gift_id; botapi has no TDLib to mint TDLib's format, so it defines its own prefixed encoding of the MTProto inputSavedStarGift (msg / chat / slug) in owned_gift.go, with OwnedGiftFromMessage / OwnedGiftFromSlug constructors. The id round-trips through ownedGiftToTg. Only free (already-prepaid) upgrades/transfers are supported; the paid variant (optional star_count) needs the payment-form flow run over a business connection and is deferred. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4dc233e commit d451e59

4 files changed

Lines changed: 334 additions & 0 deletions

File tree

gift_manage.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
6+
"github.com/gotd/td/tg"
7+
)
8+
9+
// ConvertGiftToStars converts a regular gift owned by the connected business
10+
// account back to Telegram Stars. Requires the can_convert_gifts_to_stars
11+
// business bot right.
12+
func (b *Bot) ConvertGiftToStars(ctx context.Context, businessConnectionID, ownedGiftID string) error {
13+
gift, err := ownedGiftToTg(ownedGiftID)
14+
if err != nil {
15+
return err
16+
}
17+
18+
var res tg.BoolBox
19+
20+
if err := b.invokeBusiness(ctx, businessConnectionID, &tg.PaymentsConvertStarGiftRequest{
21+
Stargift: gift,
22+
}, &res); err != nil {
23+
return asAPIError(err)
24+
}
25+
26+
return nil
27+
}
28+
29+
// UpgradeGift upgrades a regular gift owned by the connected business account to
30+
// a unique (collectible) gift. Requires the can_transfer_and_upgrade_gifts
31+
// business bot right. keepOriginalDetails keeps the original text, sender and
32+
// receiver in the upgraded gift.
33+
//
34+
// Only gifts whose upgrade is already paid for are supported; a paid upgrade
35+
// (the optional star_count of the Bot API method) is not yet implemented.
36+
func (b *Bot) UpgradeGift(ctx context.Context, businessConnectionID, ownedGiftID string, keepOriginalDetails bool) error {
37+
gift, err := ownedGiftToTg(ownedGiftID)
38+
if err != nil {
39+
return err
40+
}
41+
42+
var res tg.UpdatesBox
43+
44+
if err := b.invokeBusiness(ctx, businessConnectionID, &tg.PaymentsUpgradeStarGiftRequest{
45+
KeepOriginalDetails: keepOriginalDetails,
46+
Stargift: gift,
47+
}, &res); err != nil {
48+
return asAPIError(err)
49+
}
50+
51+
return nil
52+
}
53+
54+
// TransferGift transfers a unique gift owned by the connected business account
55+
// to another user or channel chat. Requires the can_transfer_and_upgrade_gifts
56+
// business bot right.
57+
//
58+
// Only free transfers are supported; a paid transfer (the optional star_count
59+
// of the Bot API method) is not yet implemented.
60+
func (b *Bot) TransferGift(ctx context.Context, businessConnectionID, ownedGiftID string, newOwner ChatID) error {
61+
gift, err := ownedGiftToTg(ownedGiftID)
62+
if err != nil {
63+
return err
64+
}
65+
66+
peer, err := b.resolveInputPeer(ctx, newOwner)
67+
if err != nil {
68+
return err
69+
}
70+
71+
var res tg.UpdatesBox
72+
73+
if err := b.invokeBusiness(ctx, businessConnectionID, &tg.PaymentsTransferStarGiftRequest{
74+
Stargift: gift,
75+
ToID: peer,
76+
}, &res); err != nil {
77+
return asAPIError(err)
78+
}
79+
80+
return nil
81+
}

gift_manage_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/gotd/td/tg"
8+
)
9+
10+
func TestConvertGiftToStars(t *testing.T) {
11+
inv := newMockInvoker()
12+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, &tg.BoolBox{Bool: &tg.BoolTrue{}})
13+
14+
err := newMockBot(inv).ConvertGiftToStars(context.Background(), "bc1", OwnedGiftFromMessage(123))
15+
if err != nil {
16+
t.Fatalf("ConvertGiftToStars: %v", err)
17+
}
18+
19+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.PaymentsConvertStarGiftRequest{}}
20+
21+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
22+
23+
if wrapper.ConnectionID != "bc1" {
24+
t.Fatalf("connection id = %q", wrapper.ConnectionID)
25+
}
26+
27+
req, ok := wrapper.Query.(*tg.PaymentsConvertStarGiftRequest)
28+
if !ok {
29+
t.Fatalf("query = %#v", wrapper.Query)
30+
}
31+
32+
gift, ok := req.Stargift.(*tg.InputSavedStarGiftUser)
33+
if !ok || gift.MsgID != 123 {
34+
t.Fatalf("stargift = %#v", req.Stargift)
35+
}
36+
}
37+
38+
func TestUpgradeGift(t *testing.T) {
39+
inv := newMockInvoker()
40+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, &tg.UpdatesBox{Updates: &tg.Updates{}})
41+
42+
err := newMockBot(inv).UpgradeGift(context.Background(), "bc1", OwnedGiftFromMessage(7), true)
43+
if err != nil {
44+
t.Fatalf("UpgradeGift: %v", err)
45+
}
46+
47+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.PaymentsUpgradeStarGiftRequest{}}
48+
49+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
50+
51+
req, ok := wrapper.Query.(*tg.PaymentsUpgradeStarGiftRequest)
52+
if !ok {
53+
t.Fatalf("query = %#v", wrapper.Query)
54+
}
55+
56+
if !req.KeepOriginalDetails {
57+
t.Fatalf("keep original details not set")
58+
}
59+
60+
if g, ok := req.Stargift.(*tg.InputSavedStarGiftUser); !ok || g.MsgID != 7 {
61+
t.Fatalf("stargift = %#v", req.Stargift)
62+
}
63+
}
64+
65+
func TestTransferGift(t *testing.T) {
66+
inv := newMockInvoker()
67+
inv.reply(tg.InvokeWithBusinessConnectionRequestTypeID, &tg.UpdatesBox{Updates: &tg.Updates{}})
68+
69+
err := newMockBot(inv).TransferGift(context.Background(), "bc1", OwnedGiftFromSlug("rare-gift"), userRef(99, 5))
70+
if err != nil {
71+
t.Fatalf("TransferGift: %v", err)
72+
}
73+
74+
wrapper := tg.InvokeWithBusinessConnectionRequest{Query: &tg.PaymentsTransferStarGiftRequest{}}
75+
76+
inv.decode(t, tg.InvokeWithBusinessConnectionRequestTypeID, &wrapper)
77+
78+
req, ok := wrapper.Query.(*tg.PaymentsTransferStarGiftRequest)
79+
if !ok {
80+
t.Fatalf("query = %#v", wrapper.Query)
81+
}
82+
83+
if g, ok := req.Stargift.(*tg.InputSavedStarGiftSlug); !ok || g.Slug != "rare-gift" {
84+
t.Fatalf("stargift = %#v", req.Stargift)
85+
}
86+
87+
if _, ok := req.ToID.(*tg.InputPeerUser); !ok {
88+
t.Fatalf("to id = %#v, want user", req.ToID)
89+
}
90+
}
91+
92+
func TestConvertGiftInvalidID(t *testing.T) {
93+
inv := newMockInvoker()
94+
95+
err := newMockBot(inv).ConvertGiftToStars(context.Background(), "bc1", "bogus")
96+
if err == nil {
97+
t.Fatal("expected error for invalid owned_gift_id")
98+
}
99+
100+
if inv.count() != 0 {
101+
t.Fatalf("made %d RPC calls, want 0", inv.count())
102+
}
103+
}

owned_gift.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package botapi
2+
3+
import (
4+
"strconv"
5+
"strings"
6+
7+
"github.com/gotd/td/tg"
8+
)
9+
10+
// Owned gift id encoding.
11+
//
12+
// The Bot API addresses a gift owned by an account with an opaque
13+
// owned_gift_id string. botapi has no TDLib to mint TDLib's format, so it
14+
// defines its own: a prefixed, ":"-delimited encoding of the MTProto
15+
// inputSavedStarGift the gift maps to. The encoding round-trips through
16+
// ownedGiftToTg and is produced by the OwnedGiftFrom* constructors (and, in the
17+
// future, by the gift-listing methods).
18+
const (
19+
ownedGiftMessagePrefix = "msg"
20+
ownedGiftChatPrefix = "chat"
21+
ownedGiftSlugPrefix = "slug"
22+
)
23+
24+
// OwnedGiftFromMessage builds an owned_gift_id for a gift received by the
25+
// account from the id of the service message that carries the gift (the
26+
// messageActionStarGift). This is the form for gifts owned by a user, including
27+
// a connected business account.
28+
func OwnedGiftFromMessage(messageID int) string {
29+
return ownedGiftMessagePrefix + ":" + strconv.Itoa(messageID)
30+
}
31+
32+
// OwnedGiftFromSlug builds an owned_gift_id for a unique (collectible) gift
33+
// addressed by its public link slug.
34+
func OwnedGiftFromSlug(slug string) string {
35+
return ownedGiftSlugPrefix + ":" + slug
36+
}
37+
38+
// ownedGiftToTg decodes an owned_gift_id into the MTProto inputSavedStarGift it
39+
// addresses.
40+
func ownedGiftToTg(id string) (tg.InputSavedStarGiftClass, error) {
41+
prefix, rest, ok := strings.Cut(id, ":")
42+
if !ok {
43+
return nil, errInvalidOwnedGift()
44+
}
45+
46+
switch prefix {
47+
case ownedGiftMessagePrefix:
48+
msgID, err := strconv.Atoi(rest)
49+
if err != nil {
50+
return nil, errInvalidOwnedGift()
51+
}
52+
53+
return &tg.InputSavedStarGiftUser{MsgID: msgID}, nil
54+
case ownedGiftSlugPrefix:
55+
if rest == "" {
56+
return nil, errInvalidOwnedGift()
57+
}
58+
59+
return &tg.InputSavedStarGiftSlug{Slug: rest}, nil
60+
case ownedGiftChatPrefix:
61+
return ownedGiftChatToTg(rest)
62+
default:
63+
return nil, errInvalidOwnedGift()
64+
}
65+
}
66+
67+
// ownedGiftChatToTg decodes the "chat:<channelID>:<accessHash>:<savedID>" form
68+
// into an inputSavedStarGiftChat.
69+
func ownedGiftChatToTg(rest string) (tg.InputSavedStarGiftClass, error) {
70+
parts := strings.Split(rest, ":")
71+
if len(parts) != 3 {
72+
return nil, errInvalidOwnedGift()
73+
}
74+
75+
channelID, err1 := strconv.ParseInt(parts[0], 10, 64)
76+
accessHash, err2 := strconv.ParseInt(parts[1], 10, 64)
77+
savedID, err3 := strconv.ParseInt(parts[2], 10, 64)
78+
79+
if err1 != nil || err2 != nil || err3 != nil {
80+
return nil, errInvalidOwnedGift()
81+
}
82+
83+
return &tg.InputSavedStarGiftChat{
84+
Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: accessHash},
85+
SavedID: savedID,
86+
}, nil
87+
}
88+
89+
// errInvalidOwnedGift is returned for a malformed owned_gift_id.
90+
func errInvalidOwnedGift() error {
91+
return &Error{Code: 400, Description: "Bad Request: invalid owned_gift_id"}
92+
}

owned_gift_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package botapi
2+
3+
import (
4+
"testing"
5+
6+
"github.com/gotd/td/tg"
7+
)
8+
9+
func TestOwnedGiftToTg(t *testing.T) {
10+
t.Run("message", func(t *testing.T) {
11+
got, err := ownedGiftToTg(OwnedGiftFromMessage(42))
12+
if err != nil {
13+
t.Fatalf("decode: %v", err)
14+
}
15+
16+
g, ok := got.(*tg.InputSavedStarGiftUser)
17+
if !ok || g.MsgID != 42 {
18+
t.Fatalf("got %#v", got)
19+
}
20+
})
21+
22+
t.Run("slug", func(t *testing.T) {
23+
got, err := ownedGiftToTg(OwnedGiftFromSlug("abc"))
24+
if err != nil {
25+
t.Fatalf("decode: %v", err)
26+
}
27+
28+
g, ok := got.(*tg.InputSavedStarGiftSlug)
29+
if !ok || g.Slug != "abc" {
30+
t.Fatalf("got %#v", got)
31+
}
32+
})
33+
34+
t.Run("chat", func(t *testing.T) {
35+
got, err := ownedGiftToTg("chat:777:888:5")
36+
if err != nil {
37+
t.Fatalf("decode: %v", err)
38+
}
39+
40+
g, ok := got.(*tg.InputSavedStarGiftChat)
41+
if !ok || g.SavedID != 5 {
42+
t.Fatalf("got %#v", got)
43+
}
44+
45+
peer, ok := g.Peer.(*tg.InputPeerChannel)
46+
if !ok || peer.ChannelID != 777 || peer.AccessHash != 888 {
47+
t.Fatalf("peer %#v", g.Peer)
48+
}
49+
})
50+
51+
t.Run("invalid", func(t *testing.T) {
52+
for _, id := range []string{"", "nope", "msg:", "msg:x", "chat:1:2", "slug:"} {
53+
if _, err := ownedGiftToTg(id); err == nil {
54+
t.Fatalf("expected error for %q", id)
55+
}
56+
}
57+
})
58+
}

0 commit comments

Comments
 (0)