Skip to content

Commit 89a42c7

Browse files
ernadoclaude
andcommitted
feat(peer): serializable PeerRef for restart-safe sends
Addressing a chat needs its access hash. Add PeerRef — a JSON- serializable {kind,id,access_hash} captured with Bot.PeerRef — and Peer(ref), a ChatID that builds the input peer directly from the reference (no stored peer data, no re-resolution). This is what makes a serialized background/scheduled send to a chat work across restarts. The ref is send-only; chat management still takes a resolved ID/Username. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 50bc25b commit 89a42c7

5 files changed

Lines changed: 218 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ messages follow [Conventional Commits](https://www.conventionalcommits.org/).
1515
- **Background sends**`Bot.Background()` / `Context.Background()` expose a
1616
run-lifetime context for proactive sends to any chat from timers, queues or
1717
goroutines, instead of the per-update handler context. Plus `Bot.Logger()`.
18+
- **Serializable peer references**`Bot.PeerRef` captures a chat's id and
19+
access hash into a JSON-serializable `PeerRef`; `Peer(ref)` addresses it
20+
directly (no stored peer data, no re-resolution), so background/scheduled
21+
sends survive a restart.
1822

1923
## [0.1.0] - 2026-06-14
2024

docs/guide.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,28 @@ botapi.ID(chatID), text)` from wherever you like once the bot is running.
224224
`Background` returns an already-canceled context before `Run` connects (and
225225
after it stops), so background sends fail fast instead of blocking.
226226

227+
#### Across restarts
228+
229+
Addressing a chat needs its MTProto **access hash**. The bot persists access
230+
hashes for peers it has seen (with a `Storage`), but to address a chat after a
231+
restart without relying on that, capture a **`PeerRef`** — a self-contained,
232+
JSON-serializable reference (id + access hash) — and reuse it with `Peer`:
233+
234+
```go
235+
ref, _ := bot.PeerRef(ctx, botapi.ID(chatID)) // resolve once, capture the hash
236+
data, _ := json.Marshal(ref) // persist it (DB, file, …)
237+
238+
// … bot restarts …
239+
var ref botapi.PeerRef
240+
_ = json.Unmarshal(data, &ref)
241+
bot.SendMessage(bot.Background(), botapi.Peer(ref), "still works") // no re-resolution
242+
```
243+
244+
`Peer(ref)` is addressed straight from the reference, so a serialized
245+
`{chat, text}` is all you need to deliver a message after a restart — no task
246+
queue. (`PeerRef` is for sending; chat-management methods still take a resolved
247+
`ID`/`Username`.)
248+
227249
## Predicates
228250

229251
Every `On*` method accepts trailing `Predicate`s (`func(*Update) bool`); the

peerref.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+
"strconv"
6+
7+
"github.com/gotd/td/constant"
8+
"github.com/gotd/td/tg"
9+
)
10+
11+
// Peer kinds for a PeerRef.
12+
const (
13+
peerKindUser = "user"
14+
peerKindChat = "chat"
15+
peerKindChannel = "channel"
16+
)
17+
18+
// PeerRef is a serializable reference to a chat that carries its access hash.
19+
//
20+
// Sending to a chat requires its MTProto access hash. The bot harvests and
21+
// persists access hashes for peers it has seen, but addressing a chat after a
22+
// restart otherwise depends on that stored peer data. A PeerRef captures the
23+
// id and access hash in a self-contained, JSON-serializable form: persist it
24+
// yourself (in a DB, a file, …) and, after a restart, send to it with Peer —
25+
// directly from the reference, no re-resolution.
26+
//
27+
// ref, _ := bot.PeerRef(ctx, botapi.ID(chatID)) // resolve once, capture the hash
28+
// data, _ := json.Marshal(ref) // persist it
29+
// // … restart …
30+
// var ref botapi.PeerRef
31+
// _ = json.Unmarshal(data, &ref)
32+
// bot.SendMessage(ctx, botapi.Peer(ref), "still works")
33+
type PeerRef struct {
34+
Kind string `json:"kind"` // "user", "chat" or "channel"
35+
ID int64 `json:"id"`
36+
AccessHash int64 `json:"access_hash,omitempty"`
37+
}
38+
39+
// PeerRef resolves a chat to a serializable reference including its access hash.
40+
func (b *Bot) PeerRef(ctx context.Context, chat ChatID) (PeerRef, error) {
41+
peer, err := b.resolveInputPeer(ctx, chat)
42+
if err != nil {
43+
return PeerRef{}, err
44+
}
45+
return peerRefFromInputPeer(peer)
46+
}
47+
48+
// peerRefFromInputPeer extracts a PeerRef from a resolved input peer.
49+
func peerRefFromInputPeer(p tg.InputPeerClass) (PeerRef, error) {
50+
switch v := p.(type) {
51+
case *tg.InputPeerUser:
52+
return PeerRef{Kind: peerKindUser, ID: v.UserID, AccessHash: v.AccessHash}, nil
53+
case *tg.InputPeerChannel:
54+
return PeerRef{Kind: peerKindChannel, ID: v.ChannelID, AccessHash: v.AccessHash}, nil
55+
case *tg.InputPeerChat:
56+
return PeerRef{Kind: peerKindChat, ID: v.ChatID}, nil
57+
default:
58+
return PeerRef{}, &Error{Code: 400, Description: "Bad Request: chat can't be referenced"}
59+
}
60+
}
61+
62+
// inputPeer reconstructs the MTProto input peer from the reference.
63+
func (r PeerRef) inputPeer() (tg.InputPeerClass, error) {
64+
switch r.Kind {
65+
case peerKindUser:
66+
return &tg.InputPeerUser{UserID: r.ID, AccessHash: r.AccessHash}, nil
67+
case peerKindChannel:
68+
return &tg.InputPeerChannel{ChannelID: r.ID, AccessHash: r.AccessHash}, nil
69+
case peerKindChat:
70+
return &tg.InputPeerChat{ChatID: r.ID}, nil
71+
default:
72+
return nil, &Error{Code: 400, Description: "Bad Request: invalid peer reference"}
73+
}
74+
}
75+
76+
// tdlibID returns the TDLib (Bot API) chat id for the reference.
77+
func (r PeerRef) tdlibID() int64 {
78+
var id constant.TDLibPeerID
79+
switch r.Kind {
80+
case peerKindUser:
81+
id.User(r.ID)
82+
case peerKindChat:
83+
id.Chat(r.ID)
84+
case peerKindChannel:
85+
id.Channel(r.ID)
86+
}
87+
return int64(id)
88+
}
89+
90+
// chatIDRef adapts a PeerRef to the ChatID union.
91+
type chatIDRef struct{ ref PeerRef }
92+
93+
func (chatIDRef) isChatID() {}
94+
95+
// MarshalJSON encodes the reference as its bare Bot API chat id.
96+
func (c chatIDRef) MarshalJSON() ([]byte, error) {
97+
return []byte(strconv.FormatInt(c.ref.tdlibID(), 10)), nil
98+
}
99+
100+
// Peer targets a chat by a serializable reference captured with Bot.PeerRef.
101+
// The send is addressed directly from the reference's access hash — no stored
102+
// peer data and no re-resolution — so it works across restarts.
103+
func Peer(ref PeerRef) ChatID { return chatIDRef{ref} }

peerref_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+
"context"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/gotd/td/tg"
9+
)
10+
11+
func TestPeerRefInputPeerRoundTrip(t *testing.T) {
12+
cases := []struct {
13+
ref PeerRef
14+
want tg.InputPeerClass
15+
}{
16+
{PeerRef{Kind: peerKindUser, ID: 7, AccessHash: 11}, &tg.InputPeerUser{UserID: 7, AccessHash: 11}},
17+
{PeerRef{Kind: peerKindChannel, ID: 9, AccessHash: 13}, &tg.InputPeerChannel{ChannelID: 9, AccessHash: 13}},
18+
{PeerRef{Kind: peerKindChat, ID: 5}, &tg.InputPeerChat{ChatID: 5}},
19+
}
20+
for _, c := range cases {
21+
got, err := c.ref.inputPeer()
22+
if err != nil {
23+
t.Fatal(err)
24+
}
25+
if got.String() != c.want.String() {
26+
t.Fatalf("inputPeer: got %#v, want %#v", got, c.want)
27+
}
28+
// Extracting a ref back from the input peer yields the original.
29+
back, err := peerRefFromInputPeer(got)
30+
if err != nil || back != c.ref {
31+
t.Fatalf("round-trip: got %#v (%v), want %#v", back, err, c.ref)
32+
}
33+
}
34+
}
35+
36+
func TestPeerSendsDirectlyFromRef(t *testing.T) {
37+
// resolveInputPeer must build the input peer straight from the ref, with no
38+
// peer manager or network — this is what survives a restart.
39+
b := &Bot{}
40+
ref := PeerRef{Kind: peerKindChannel, ID: 100, AccessHash: 200}
41+
got, err := b.resolveInputPeer(context.Background(), Peer(ref))
42+
if err != nil {
43+
t.Fatal(err)
44+
}
45+
ch, ok := got.(*tg.InputPeerChannel)
46+
if !ok || ch.ChannelID != 100 || ch.AccessHash != 200 {
47+
t.Fatalf("input peer: %#v", got)
48+
}
49+
}
50+
51+
func TestPeerRefJSONRoundTrip(t *testing.T) {
52+
ref := PeerRef{Kind: peerKindUser, ID: 42, AccessHash: 99}
53+
data, err := json.Marshal(ref)
54+
if err != nil {
55+
t.Fatal(err)
56+
}
57+
var back PeerRef
58+
if err := json.Unmarshal(data, &back); err != nil {
59+
t.Fatal(err)
60+
}
61+
if back != ref {
62+
t.Fatalf("json round-trip: got %#v, want %#v", back, ref)
63+
}
64+
}
65+
66+
func TestPeerChatIDMarshalsToTDLibID(t *testing.T) {
67+
// Peer(ref) is a ChatID; its JSON form is the bare Bot API chat id.
68+
data, err := json.Marshal(Peer(PeerRef{Kind: peerKindUser, ID: 42}))
69+
if err != nil {
70+
t.Fatal(err)
71+
}
72+
if string(data) != "42" {
73+
t.Fatalf("user chat id: %s", data)
74+
}
75+
// Channels use the -100… supergroup id space.
76+
data, _ = json.Marshal(Peer(PeerRef{Kind: peerKindChannel, ID: 123}))
77+
if string(data) != "-1000000000123" {
78+
t.Fatalf("channel chat id: %s", data)
79+
}
80+
}

resolve.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,22 @@ func (b *Bot) resolvePeer(ctx context.Context, chat ChatID) (peers.Peer, error)
3232
return nil, asAPIError(err)
3333
}
3434
return p, nil
35+
case chatIDRef:
36+
// A PeerRef is addressed directly for sending (resolveInputPeer); it
37+
// cannot back the peers.Peer that chat-management needs.
38+
return nil, &Error{Code: 400, Description: "Bad Request: peer reference is only usable for sending"}
3539
default:
3640
return nil, &Error{Code: 400, Description: "Bad Request: invalid chat_id"}
3741
}
3842
}
3943

4044
// resolveInputPeer resolves a ChatID to the tg.InputPeerClass the sender needs.
4145
func (b *Bot) resolveInputPeer(ctx context.Context, chat ChatID) (tg.InputPeerClass, error) {
46+
// A PeerRef carries its own access hash, so it is addressed directly without
47+
// consulting stored peer data — this is what makes it survive a restart.
48+
if ref, ok := chat.(chatIDRef); ok {
49+
return ref.ref.inputPeer()
50+
}
4251
p, err := b.resolvePeer(ctx, chat)
4352
if err != nil {
4453
return nil, err

0 commit comments

Comments
 (0)