Skip to content

Commit 092d585

Browse files
ernadoclaude
andcommitted
feat: expose raw MTProto message and add pp example
Add Message.Raw to reach the original tg.Message behind a Bot API Message, set during conversion. Add Bot.GetMessage to fetch a message by id (with raw populated), since replied-to messages are not carried by the update. New examples/pp bot pretty-prints the replied-to MTProto message via tdp.Format. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2593c91 commit 092d585

6 files changed

Lines changed: 235 additions & 0 deletions

File tree

convert.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ func (b *Bot) convertMessage(ctx context.Context, m *tg.Message) (*Message, erro
192192
Date: m.Date,
193193
Chat: chat,
194194
HasProtectedContent: m.Noforwards,
195+
raw: m,
195196
}
196197
if v, ok := m.GetEditDate(); ok {
197198
r.EditDate = v

convert_message_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,22 @@ func convMsg(t *testing.T, m *tg.Message) *Message {
2121
return r
2222
}
2323

24+
func TestConvertMessageExposesRaw(t *testing.T) {
25+
m := &tg.Message{ID: 42, Message: "hi"}
26+
27+
m.PeerID = &tg.PeerUser{UserID: 10}
28+
29+
r := convMsg(t, m)
30+
if r.Raw() != m {
31+
t.Fatalf("Raw() = %p, want original %p", r.Raw(), m)
32+
}
33+
34+
// Synthesized stubs (e.g. JSON-decoded messages) carry no raw message.
35+
if (&Message{}).Raw() != nil {
36+
t.Fatal("Raw() of a zero Message should be nil")
37+
}
38+
}
39+
2440
func TestConvertTextMessageFromUser(t *testing.T) {
2541
m := &tg.Message{ID: 7, Message: "hello world"}
2642

examples/pp/main.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Command pp is a bot that pretty-prints the raw MTProto message behind any
2+
// Bot API message. Send it anything and it replies with the tdp.Format dump of
3+
// the underlying tg.Message, reachable through Message.Raw — useful for
4+
// inspecting fields the typed Bot API surface does not expose.
5+
//
6+
// Run it with an MTProto app identity (https://my.telegram.org) and a BotFather
7+
// token:
8+
//
9+
// APP_ID=12345 APP_HASH=abcdef BOT_TOKEN=123:abc go run ./examples/pp
10+
package main
11+
12+
import (
13+
"context"
14+
"os"
15+
"os/signal"
16+
"strconv"
17+
"time"
18+
19+
"github.com/gotd/log/logzap"
20+
"github.com/gotd/td/tdp"
21+
"go.uber.org/zap"
22+
23+
"github.com/gotd/botapi"
24+
"github.com/gotd/botapi/storage"
25+
)
26+
27+
func main() {
28+
log, _ := zap.NewProduction()
29+
defer func() { _ = log.Sync() }()
30+
31+
appID, err := strconv.Atoi(os.Getenv("APP_ID"))
32+
if err != nil {
33+
log.Fatal("APP_ID must be a number (see https://my.telegram.org)", zap.Error(err))
34+
}
35+
36+
// Persist session, peers and update state so the bot resumes across restarts.
37+
store, err := storage.Open("session.bbolt")
38+
if err != nil {
39+
log.Fatal("Open storage", zap.Error(err))
40+
}
41+
42+
defer func() { _ = store.Close() }()
43+
44+
bot, err := botapi.New(os.Getenv("BOT_TOKEN"), botapi.Options{
45+
AppID: appID,
46+
AppHash: os.Getenv("APP_HASH"),
47+
Logger: logzap.New(log),
48+
Storage: store,
49+
})
50+
if err != nil {
51+
log.Fatal("Create bot", zap.Error(err))
52+
}
53+
54+
bot.Use(botapi.Recover(), botapi.Timeout(30*time.Second))
55+
56+
bot.OnCommand("start", "Show the welcome message", func(c *botapi.Context) error {
57+
_, err := c.Reply("Reply to any message with /pp to dump its raw MTProto message.")
58+
return err
59+
})
60+
61+
// /pp pretty-prints the MTProto message the command replies to. The replied
62+
// message is not carried by the update, so it is fetched with Bot.GetMessage,
63+
// whose raw tg.Message (via Message.Raw) is dumped with tdp.Format.
64+
bot.OnCommand("pp", "Pretty-print the replied-to MTProto message", func(c *botapi.Context) error {
65+
msg := c.Message()
66+
67+
reply := msg.ReplyToMessage
68+
if reply == nil {
69+
_, err := c.Reply("Reply to a message with /pp to dump it.")
70+
return err
71+
}
72+
73+
target, err := c.Bot.GetMessage(c, botapi.ID(msg.Chat.ID), reply.MessageID)
74+
if err != nil {
75+
_, replyErr := c.Reply("Could not fetch the replied message: " + err.Error())
76+
if replyErr != nil {
77+
return replyErr
78+
}
79+
80+
return err
81+
}
82+
83+
_, err = c.Reply(tdp.Format(target.Raw()))
84+
85+
return err
86+
})
87+
88+
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
89+
defer cancel()
90+
91+
log.Info("Starting pp bot")
92+
93+
if err := bot.Run(ctx); err != nil {
94+
log.Fatal("Run", zap.Error(err))
95+
}
96+
}

get_message.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+
"context"
5+
6+
"github.com/gotd/td/telegram/peers"
7+
"github.com/gotd/td/tg"
8+
)
9+
10+
// GetMessage fetches a single message from chat by its id and converts it to a
11+
// Bot API Message. The returned Message exposes the original MTProto message
12+
// through Raw, so callers can reach fields the typed surface does not cover.
13+
//
14+
// It issues the channel- or user-appropriate getMessages call, so chat must be
15+
// a peer the bot can address (seen before or backed by stored peer data); a
16+
// send-only PeerRef is rejected.
17+
func (b *Bot) GetMessage(ctx context.Context, chat ChatID, messageID int) (*Message, error) {
18+
p, err := b.resolvePeer(ctx, chat)
19+
if err != nil {
20+
return nil, err
21+
}
22+
23+
ids := []tg.InputMessageClass{&tg.InputMessageID{ID: messageID}}
24+
25+
var (
26+
res tg.MessagesMessagesClass
27+
rpcErr error
28+
)
29+
30+
if ch, ok := p.(peers.Channel); ok {
31+
res, rpcErr = b.raw.ChannelsGetMessages(ctx, &tg.ChannelsGetMessagesRequest{
32+
Channel: ch.InputChannel(),
33+
ID: ids,
34+
})
35+
} else {
36+
res, rpcErr = b.raw.MessagesGetMessages(ctx, ids)
37+
}
38+
39+
if rpcErr != nil {
40+
return nil, asAPIError(rpcErr)
41+
}
42+
43+
msgs, ok := res.AsModified()
44+
if !ok {
45+
return nil, &Error{Code: 400, Description: "Bad Request: message not found"}
46+
}
47+
48+
for _, m := range msgs.GetMessages() {
49+
msg, ok := m.(*tg.Message)
50+
if !ok || msg.ID != messageID {
51+
continue
52+
}
53+
54+
return b.convertMessage(ctx, msg)
55+
}
56+
57+
return nil, &Error{Code: 400, Description: "Bad Request: message not found"}
58+
}

get_message_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/gotd/td/tg"
8+
)
9+
10+
func TestGetMessageExposesRaw(t *testing.T) {
11+
inv := newMockInvoker()
12+
13+
raw := &tg.Message{ID: 7, Message: "hi", PeerID: &tg.PeerChannel{ChannelID: 50}}
14+
inv.reply(tg.ChannelsGetMessagesRequestTypeID, &tg.MessagesChannelMessages{
15+
Messages: []tg.MessageClass{raw},
16+
})
17+
18+
b := newMockBot(inv)
19+
20+
m, err := b.GetMessage(context.Background(), tdlibChannel(50), 7)
21+
if err != nil {
22+
t.Fatalf("GetMessage: %v", err)
23+
}
24+
25+
if m.MessageID != 7 || m.Text != "hi" {
26+
t.Fatalf("message = %#v", m)
27+
}
28+
29+
if m.Raw() == nil || m.Raw().ID != 7 || m.Raw().Message != "hi" {
30+
t.Fatalf("Raw() = %#v, want the fetched tg.Message", m.Raw())
31+
}
32+
33+
var req tg.ChannelsGetMessagesRequest
34+
35+
inv.decode(t, tg.ChannelsGetMessagesRequestTypeID, &req)
36+
37+
if len(req.ID) != 1 {
38+
t.Fatalf("requested ids = %#v", req.ID)
39+
}
40+
}
41+
42+
func TestGetMessageNotFound(t *testing.T) {
43+
inv := newMockInvoker()
44+
inv.reply(tg.ChannelsGetMessagesRequestTypeID, &tg.MessagesChannelMessages{})
45+
46+
b := newMockBot(inv)
47+
48+
if _, err := b.GetMessage(context.Background(), tdlibChannel(50), 7); err == nil {
49+
t.Fatal("expected error for missing message")
50+
}
51+
}

types_message.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package botapi
22

3+
import "github.com/gotd/td/tg"
4+
35
// MessageEntity represents one special entity in a text message (e.g. a
46
// hashtag, link, or formatted run).
57
type MessageEntity struct {
@@ -121,4 +123,15 @@ type Message struct {
121123
PinnedMessage *Message `json:"pinned_message,omitempty"`
122124

123125
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
126+
127+
// raw holds the original MTProto message this Message was converted from, or
128+
// nil for synthesized stubs (e.g. reply_to/pinned placeholders). It is not
129+
// serialized; reach it through Raw.
130+
raw *tg.Message
124131
}
132+
133+
// Raw returns the original MTProto message this Message was converted from, for
134+
// anything the typed Bot API surface does not expose. It is nil for messages
135+
// that were not converted from a live update — synthesized reply_to and pinned
136+
// placeholders, or values produced by JSON decoding.
137+
func (m *Message) Raw() *tg.Message { return m.raw }

0 commit comments

Comments
 (0)