Skip to content

Commit 6df2e60

Browse files
ernadoclaude
andcommitted
feat(send): translate ReplyMarkup to MTProto reply markup
replyMarkupToTg maps the sealed ReplyMarkup union (inline/reply keyboards, remove, force-reply) and their buttons onto tg.ReplyMarkupClass via the gotd message/markup helpers. The union switch is exhaustive (gochecksumtype). Inline text buttons are rejected with a 400, matching the Bot API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b7f34c6 commit 6df2e60

2 files changed

Lines changed: 177 additions & 0 deletions

File tree

markup_to_tg.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package botapi
2+
3+
import (
4+
"github.com/gotd/td/telegram/message/markup"
5+
"github.com/gotd/td/tg"
6+
)
7+
8+
// replyMarkupToTg translates a Bot API ReplyMarkup into the MTProto
9+
// tg.ReplyMarkupClass understood by the sender.
10+
//
11+
// The switch over the sealed ReplyMarkup union is exhaustive (enforced by the
12+
// gochecksumtype linter): every variant is handled.
13+
func replyMarkupToTg(m ReplyMarkup) (tg.ReplyMarkupClass, error) {
14+
switch m := m.(type) {
15+
case *InlineKeyboardMarkup:
16+
rows := make([]tg.KeyboardButtonRow, 0, len(m.InlineKeyboard))
17+
for _, row := range m.InlineKeyboard {
18+
buttons := make([]tg.KeyboardButtonClass, len(row))
19+
for i, btn := range row {
20+
b, err := inlineButtonToTg(btn)
21+
if err != nil {
22+
return nil, err
23+
}
24+
buttons[i] = b
25+
}
26+
rows = append(rows, tg.KeyboardButtonRow{Buttons: buttons})
27+
}
28+
return &tg.ReplyInlineMarkup{Rows: rows}, nil
29+
case *ReplyKeyboardMarkup:
30+
rows := make([]tg.KeyboardButtonRow, 0, len(m.Keyboard))
31+
for _, row := range m.Keyboard {
32+
buttons := make([]tg.KeyboardButtonClass, len(row))
33+
for i, btn := range row {
34+
buttons[i] = keyboardButtonToTg(btn)
35+
}
36+
rows = append(rows, tg.KeyboardButtonRow{Buttons: buttons})
37+
}
38+
res := &tg.ReplyKeyboardMarkup{
39+
Resize: m.ResizeKeyboard,
40+
SingleUse: m.OneTimeKeyboard,
41+
Selective: m.Selective,
42+
Persistent: m.IsPersistent,
43+
Rows: rows,
44+
}
45+
if m.InputFieldPlaceholder != "" {
46+
res.SetPlaceholder(m.InputFieldPlaceholder)
47+
}
48+
return res, nil
49+
case *ReplyKeyboardRemove:
50+
if m.Selective {
51+
return markup.SelectiveHide(), nil
52+
}
53+
return markup.Hide(), nil
54+
case *ForceReply:
55+
res := &tg.ReplyKeyboardForceReply{Selective: m.Selective}
56+
if m.InputFieldPlaceholder != "" {
57+
res.SetPlaceholder(m.InputFieldPlaceholder)
58+
}
59+
return res, nil
60+
default:
61+
return nil, &Error{Code: 400, Description: "Bad Request: unsupported reply markup"}
62+
}
63+
}
64+
65+
func inlineButtonToTg(btn InlineKeyboardButton) (tg.KeyboardButtonClass, error) {
66+
switch {
67+
case btn.URL != "":
68+
return markup.URL(btn.Text, btn.URL), nil
69+
case btn.CallbackData != "":
70+
return markup.Callback(btn.Text, []byte(btn.CallbackData)), nil
71+
case btn.WebApp != nil:
72+
return markup.WebView(btn.Text, btn.WebApp.URL), nil
73+
case btn.SwitchInlineQuery != nil:
74+
return markup.SwitchInline(btn.Text, *btn.SwitchInlineQuery, false), nil
75+
case btn.SwitchInlineQueryCurrentChat != nil:
76+
return markup.SwitchInline(btn.Text, *btn.SwitchInlineQueryCurrentChat, true), nil
77+
case btn.Pay:
78+
return markup.Buy(btn.Text), nil
79+
default:
80+
return nil, &Error{Code: 400, Description: "Bad Request: text buttons are unallowed in the inline keyboard"}
81+
}
82+
}
83+
84+
func keyboardButtonToTg(btn KeyboardButton) tg.KeyboardButtonClass {
85+
switch {
86+
case btn.RequestContact:
87+
return markup.RequestPhone(btn.Text)
88+
case btn.RequestLocation:
89+
return markup.RequestGeoLocation(btn.Text)
90+
case btn.RequestPoll != nil:
91+
return markup.RequestPoll(btn.Text, btn.RequestPoll.Type == PollQuiz)
92+
case btn.WebApp != nil:
93+
return markup.SimpleWebView(btn.Text, btn.WebApp.URL)
94+
default:
95+
return markup.Button(btn.Text)
96+
}
97+
}

markup_to_tg_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package botapi
2+
3+
import (
4+
"testing"
5+
6+
"github.com/gotd/td/tg"
7+
)
8+
9+
func TestReplyMarkupToTg_Inline(t *testing.T) {
10+
q := "go"
11+
m := &InlineKeyboardMarkup{InlineKeyboard: [][]InlineKeyboardButton{{
12+
{Text: "site", URL: "https://example.com"},
13+
{Text: "ok", CallbackData: "ok:1"},
14+
{Text: "switch", SwitchInlineQuery: &q},
15+
{Text: "pay", Pay: true},
16+
}}}
17+
18+
got, err := replyMarkupToTg(m)
19+
if err != nil {
20+
t.Fatal(err)
21+
}
22+
inline, isInline := got.(*tg.ReplyInlineMarkup)
23+
if !isInline || len(inline.Rows) != 1 || len(inline.Rows[0].Buttons) != 4 {
24+
t.Fatalf("unexpected: %#v", got)
25+
}
26+
if _, isURL := inline.Rows[0].Buttons[0].(*tg.KeyboardButtonURL); !isURL {
27+
t.Fatalf("button 0: want URL, got %T", inline.Rows[0].Buttons[0])
28+
}
29+
cb, isCb := inline.Rows[0].Buttons[1].(*tg.KeyboardButtonCallback)
30+
if !isCb || string(cb.Data) != "ok:1" {
31+
t.Fatalf("button 1: want callback ok:1, got %#v", inline.Rows[0].Buttons[1])
32+
}
33+
si, isSwitch := inline.Rows[0].Buttons[2].(*tg.KeyboardButtonSwitchInline)
34+
if !isSwitch || si.Query != "go" || si.SamePeer {
35+
t.Fatalf("button 2: want switch-inline, got %#v", inline.Rows[0].Buttons[2])
36+
}
37+
}
38+
39+
func TestReplyMarkupToTg_InlineTextButtonRejected(t *testing.T) {
40+
m := &InlineKeyboardMarkup{InlineKeyboard: [][]InlineKeyboardButton{{{Text: "plain"}}}}
41+
if _, err := replyMarkupToTg(m); err == nil {
42+
t.Fatal("plain text inline button must be rejected")
43+
}
44+
}
45+
46+
func TestReplyMarkupToTg_Reply(t *testing.T) {
47+
m := &ReplyKeyboardMarkup{
48+
Keyboard: [][]KeyboardButton{{Button("hi"), ButtonContact("phone"), ButtonLocation("where")}},
49+
ResizeKeyboard: true,
50+
OneTimeKeyboard: true,
51+
}
52+
got, err := replyMarkupToTg(m)
53+
if err != nil {
54+
t.Fatal(err)
55+
}
56+
kb, ok := got.(*tg.ReplyKeyboardMarkup)
57+
if !ok || !kb.Resize || !kb.SingleUse {
58+
t.Fatalf("unexpected: %#v", got)
59+
}
60+
if _, ok := kb.Rows[0].Buttons[1].(*tg.KeyboardButtonRequestPhone); !ok {
61+
t.Fatalf("want request-phone, got %T", kb.Rows[0].Buttons[1])
62+
}
63+
if _, ok := kb.Rows[0].Buttons[2].(*tg.KeyboardButtonRequestGeoLocation); !ok {
64+
t.Fatalf("want request-geo, got %T", kb.Rows[0].Buttons[2])
65+
}
66+
}
67+
68+
func TestReplyMarkupToTg_RemoveAndForceReply(t *testing.T) {
69+
if _, err := replyMarkupToTg(RemoveKeyboard()); err != nil {
70+
t.Fatal(err)
71+
}
72+
got, err := replyMarkupToTg(&ForceReply{ForceReply: true, InputFieldPlaceholder: "type"})
73+
if err != nil {
74+
t.Fatal(err)
75+
}
76+
fr, ok := got.(*tg.ReplyKeyboardForceReply)
77+
if !ok || fr.Placeholder != "type" {
78+
t.Fatalf("unexpected force reply: %#v", got)
79+
}
80+
}

0 commit comments

Comments
 (0)