Skip to content

Commit 7740497

Browse files
ernadoclaude
andcommitted
feat(send): implement SendMessage (text, HTML/Markdown, markup)
The first end-to-end outgoing slice: resolve ChatID -> peer, build the message via the gotd sender (web-preview/silent/protect/reply/markup options), style the text by parse mode, send, then unpack the response back into a Bot API Message (with peer-id backfill). Errors flow through asAPIError. Shared functional SendOptions (Silent, DisableWebPagePreview, ProtectContent, ReplyTo, WithReplyMarkup, WithParseMode) will be reused by the other send methods. Adds goldmark via the gotd markdown package. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f7f55cb commit 7740497

4 files changed

Lines changed: 174 additions & 0 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ require (
3838
github.com/refraction-networking/utls v1.8.2 // indirect
3939
github.com/segmentio/asm v1.2.1 // indirect
4040
github.com/shopspring/decimal v1.4.0 // indirect
41+
github.com/yuin/goldmark v1.8.2 // indirect
4142
go.opentelemetry.io/otel v1.44.0 // indirect
4243
go.opentelemetry.io/otel/metric v1.44.0 // indirect
4344
go.opentelemetry.io/otel/trace v1.44.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
7979
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
8080
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
8181
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
82+
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
83+
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
8284
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
8385
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
8486
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=

send.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
6+
"github.com/gotd/td/telegram/message"
7+
"github.com/gotd/td/telegram/message/entity"
8+
"github.com/gotd/td/telegram/message/html"
9+
"github.com/gotd/td/telegram/message/markdown"
10+
"github.com/gotd/td/telegram/message/styling"
11+
"github.com/gotd/td/telegram/message/unpack"
12+
"github.com/gotd/td/tg"
13+
)
14+
15+
// SendOption configures an outgoing send. Options are shared across the send
16+
// methods; pass any combination.
17+
type SendOption func(*sendConfig)
18+
19+
type sendConfig struct {
20+
disableWebPreview bool
21+
silent bool
22+
protect bool
23+
replyTo int
24+
markup ReplyMarkup
25+
parseMode ParseMode
26+
}
27+
28+
// DisableWebPagePreview disables the link preview for messages with links.
29+
func DisableWebPagePreview() SendOption { return func(c *sendConfig) { c.disableWebPreview = true } }
30+
31+
// Silent sends the message without a notification sound.
32+
func Silent() SendOption { return func(c *sendConfig) { c.silent = true } }
33+
34+
// ProtectContent prevents the message content from being forwarded or saved.
35+
func ProtectContent() SendOption { return func(c *sendConfig) { c.protect = true } }
36+
37+
// ReplyTo makes the message a reply to the message with the given id.
38+
func ReplyTo(messageID int) SendOption { return func(c *sendConfig) { c.replyTo = messageID } }
39+
40+
// WithReplyMarkup attaches an inline/reply keyboard (or removes one).
41+
func WithReplyMarkup(m ReplyMarkup) SendOption { return func(c *sendConfig) { c.markup = m } }
42+
43+
// WithParseMode selects the formatting mode for the text or caption.
44+
func WithParseMode(m ParseMode) SendOption { return func(c *sendConfig) { c.parseMode = m } }
45+
46+
// styledText turns text + parse mode into styling options.
47+
//
48+
// The switch over ParseMode is exhaustive (exhaustive lint).
49+
func styledText(text string, mode ParseMode, resolver entity.UserResolver) ([]styling.StyledTextOption, error) {
50+
switch mode {
51+
case ParseModeNone:
52+
return []styling.StyledTextOption{styling.Plain(text)}, nil
53+
case ParseModeHTML:
54+
return []styling.StyledTextOption{html.String(resolver, text)}, nil
55+
case ParseModeMarkdownV2, ParseModeMarkdown:
56+
return []styling.StyledTextOption{markdown.String(resolver, text)}, nil
57+
default:
58+
return nil, &Error{Code: 400, Description: "Bad Request: unsupported parse mode"}
59+
}
60+
}
61+
62+
// applySendConfig applies the common builder options to a message builder.
63+
func (b *Bot) applySendConfig(builder *message.Builder, cfg sendConfig) (*message.Builder, error) {
64+
if cfg.disableWebPreview {
65+
builder = builder.NoWebpage()
66+
}
67+
if cfg.silent {
68+
builder = builder.Silent()
69+
}
70+
if cfg.protect {
71+
builder = builder.NoForwards()
72+
}
73+
if cfg.replyTo != 0 {
74+
builder = builder.Reply(cfg.replyTo)
75+
}
76+
if cfg.markup != nil {
77+
mkp, err := replyMarkupToTg(cfg.markup)
78+
if err != nil {
79+
return nil, err
80+
}
81+
builder = builder.Markup(mkp)
82+
}
83+
return builder, nil
84+
}
85+
86+
// sentMessage unpacks a send response into a Bot API Message, backfilling the
87+
// peer id when the server omitted it.
88+
func (b *Bot) sentMessage(ctx context.Context, peer tg.InputPeerClass, resp tg.UpdatesClass, sendErr error) (*Message, error) {
89+
m, err := unpack.MessageClass(resp, sendErr)
90+
if err != nil {
91+
return nil, asAPIError(err)
92+
}
93+
msg, ok := m.(*tg.Message)
94+
if !ok {
95+
return &Message{}, nil
96+
}
97+
if msg.PeerID == nil {
98+
switch p := peer.(type) {
99+
case *tg.InputPeerChat:
100+
msg.PeerID = &tg.PeerChat{ChatID: p.ChatID}
101+
case *tg.InputPeerUser:
102+
msg.PeerID = &tg.PeerUser{UserID: p.UserID}
103+
case *tg.InputPeerChannel:
104+
msg.PeerID = &tg.PeerChannel{ChannelID: p.ChannelID}
105+
}
106+
}
107+
return b.convertMessage(ctx, msg)
108+
}
109+
110+
// SendMessage sends a text message to a chat and returns the sent message.
111+
func (b *Bot) SendMessage(ctx context.Context, chat ChatID, text string, opts ...SendOption) (*Message, error) {
112+
var cfg sendConfig
113+
for _, o := range opts {
114+
o(&cfg)
115+
}
116+
117+
peer, err := b.resolveInputPeer(ctx, chat)
118+
if err != nil {
119+
return nil, err
120+
}
121+
122+
styled, err := styledText(text, cfg.parseMode, b.peers.UserResolveHook(ctx))
123+
if err != nil {
124+
return nil, err
125+
}
126+
127+
builder := &b.sender.To(peer).Builder
128+
builder, err = b.applySendConfig(builder, cfg)
129+
if err != nil {
130+
return nil, err
131+
}
132+
133+
resp, err := builder.StyledText(ctx, styled...)
134+
return b.sentMessage(ctx, peer, resp, err)
135+
}

send_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package botapi
2+
3+
import "testing"
4+
5+
func TestSendOptions(t *testing.T) {
6+
var cfg sendConfig
7+
q := "x"
8+
for _, o := range []SendOption{
9+
Silent(),
10+
DisableWebPagePreview(),
11+
ProtectContent(),
12+
ReplyTo(99),
13+
WithParseMode(ParseModeHTML),
14+
WithReplyMarkup(&InlineKeyboardMarkup{InlineKeyboard: [][]InlineKeyboardButton{{{Text: "t", SwitchInlineQuery: &q}}}}),
15+
} {
16+
o(&cfg)
17+
}
18+
if !cfg.silent || !cfg.disableWebPreview || !cfg.protect {
19+
t.Fatalf("bool options not applied: %+v", cfg)
20+
}
21+
if cfg.replyTo != 99 || cfg.parseMode != ParseModeHTML || cfg.markup == nil {
22+
t.Fatalf("value options not applied: %+v", cfg)
23+
}
24+
}
25+
26+
func TestStyledText(t *testing.T) {
27+
for _, mode := range []ParseMode{ParseModeNone, ParseModeHTML, ParseModeMarkdownV2, ParseModeMarkdown} {
28+
opts, err := styledText("hello", mode, nil)
29+
if err != nil || len(opts) != 1 {
30+
t.Fatalf("mode %q: got %d opts, err %v", mode, len(opts), err)
31+
}
32+
}
33+
if _, err := styledText("x", ParseMode("weird"), nil); err == nil {
34+
t.Fatal("unknown parse mode should error")
35+
}
36+
}

0 commit comments

Comments
 (0)