Skip to content

Commit 8dfd0f9

Browse files
ernadoclaude
andcommitted
docs(examples): add background bot showing serializable PeerRef sends
A new examples/background bot demonstrates proactive sends that are not replies to an update: /subscribe captures the chat's PeerRef (id + access hash) and persists it to a JSON file, a background ticker driven by Bot.Background broadcasts to every subscriber, and on startup the bot reloads the file, deserializes each PeerRef and greets it — proving the references address chats directly across a restart with no re-resolution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5535581 commit 8dfd0f9

4 files changed

Lines changed: 274 additions & 2 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,9 @@ state/*.bbolt
2626
/inline
2727
/media
2828
/advanced
29+
/rich
30+
/background
31+
32+
# Subscriber store written by the background example.
33+
subscribers.json
34+
subscribers.json.tmp

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ messages follow [Conventional Commits](https://www.conventionalcommits.org/).
3131
- **Serializable peer references**`Bot.PeerRef` captures a chat's id and
3232
access hash into a JSON-serializable `PeerRef`; `Peer(ref)` addresses it
3333
directly (no stored peer data, no re-resolution), so background/scheduled
34-
sends survive a restart.
34+
sends survive a restart. The new `examples/background` bot persists
35+
subscriber `PeerRef`s to a JSON file and re-greets them after a restart.
3536

3637
## [0.1.0] - 2026-06-14
3738

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ func main() {
7676
See the [**guide**](./docs/guide.md) for the full surface (sending, media,
7777
keyboards, handlers, predicates, middleware, commands, files, chat management,
7878
errors, pooling) and [`examples/`](./examples) for runnable bots
79-
(`echo`, `buttons`, `inline`, `media`, `rich`, `advanced`).
79+
(`echo`, `buttons`, `inline`, `media`, `rich`, `background`, `advanced`).
8080

8181
`Options.Storage` is optional — leave it nil to keep session, peers and update
8282
state in memory (nothing survives a restart). `storage.BBoltStorage` persists

examples/background/main.go

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// Command background demonstrates proactive, restart-surviving sends with
2+
// github.com/gotd/botapi: messages that are NOT replies to an incoming update.
3+
//
4+
// The trick is a serializable PeerRef. Sending to a chat needs its MTProto
5+
// access hash; a PeerRef captures the chat id and that hash in a small
6+
// JSON-serializable value. This bot persists one PeerRef per subscriber to a
7+
// JSON file, so it can address them later — from a background goroutine, and
8+
// even after a full restart — with no re-resolution and no live update to react
9+
// to.
10+
//
11+
// /subscribe capture this chat's PeerRef and save it to disk
12+
// /unsubscribe forget this chat
13+
//
14+
// While running, a background ticker (driven by Bot.Background, not a per-update
15+
// handler context) broadcasts the time to every saved subscriber every 30s. On
16+
// startup the bot reloads the file, deserializes each PeerRef, and sends a
17+
// "back online" message — proving the references survive a restart.
18+
//
19+
// Run it with an MTProto app identity (https://my.telegram.org) and a BotFather
20+
// token; SUBS_FILE overrides the store path (default ./subscribers.json):
21+
//
22+
// APP_ID=12345 APP_HASH=abcdef BOT_TOKEN=123:abc go run ./examples/background
23+
package main
24+
25+
import (
26+
"context"
27+
"encoding/json"
28+
"os"
29+
"os/signal"
30+
"strconv"
31+
"sync"
32+
"time"
33+
34+
"github.com/gotd/log/logzap"
35+
"go.uber.org/zap"
36+
37+
"github.com/gotd/botapi"
38+
)
39+
40+
func main() {
41+
log, _ := zap.NewProduction()
42+
defer func() { _ = log.Sync() }()
43+
44+
appID, err := strconv.Atoi(os.Getenv("APP_ID"))
45+
if err != nil {
46+
log.Fatal("APP_ID must be a number (see https://my.telegram.org)", zap.Error(err))
47+
}
48+
49+
bot, err := botapi.New(os.Getenv("BOT_TOKEN"), botapi.Options{
50+
AppID: appID,
51+
AppHash: os.Getenv("APP_HASH"),
52+
Logger: logzap.New(log),
53+
})
54+
if err != nil {
55+
log.Fatal("Create bot", zap.Error(err))
56+
}
57+
bot.Use(botapi.Recover(), botapi.Logging())
58+
59+
path := os.Getenv("SUBS_FILE")
60+
if path == "" {
61+
path = "subscribers.json"
62+
}
63+
store, err := loadStore(path)
64+
if err != nil {
65+
log.Fatal("Load subscribers", zap.Error(err))
66+
}
67+
68+
bot.OnCommand("subscribe", "Receive background broadcasts", func(c *botapi.Context) error {
69+
chat, ok := c.Chat()
70+
if !ok {
71+
return nil
72+
}
73+
// Resolve the chat to a PeerRef once, here, while we have it — this is
74+
// what captures the access hash needed to message it later.
75+
ref, err := c.Bot.PeerRef(c, chat)
76+
if err != nil {
77+
return err
78+
}
79+
if store.add(ref) {
80+
if err := store.save(); err != nil {
81+
return err
82+
}
83+
}
84+
_, err = c.Reply("Subscribed. You'll get a broadcast every 30s, and a hello after each restart.")
85+
return err
86+
})
87+
88+
bot.OnCommand("unsubscribe", "Stop background broadcasts", func(c *botapi.Context) error {
89+
chat, ok := c.Chat()
90+
if !ok {
91+
return nil
92+
}
93+
ref, err := c.Bot.PeerRef(c, chat)
94+
if err != nil {
95+
return err
96+
}
97+
if store.remove(ref) {
98+
if err := store.save(); err != nil {
99+
return err
100+
}
101+
}
102+
_, err = c.Reply("Unsubscribed.")
103+
return err
104+
})
105+
106+
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
107+
defer cancel()
108+
109+
// Run the bot in the background so we can use its run-lifetime context for
110+
// proactive sends from this goroutine.
111+
var wg sync.WaitGroup
112+
wg.Add(1)
113+
go func() {
114+
defer wg.Done()
115+
log.Info("Starting background bot", zap.Int("subscribers", store.len()))
116+
if err := bot.Run(ctx); err != nil {
117+
log.Error("Run", zap.Error(err))
118+
cancel()
119+
}
120+
}()
121+
122+
// Bot.Background blocks until Run has connected (or ctx is done), then yields
123+
// the run-lifetime context. Using it (not a handler context) is what makes
124+
// these sends "background": they outlive any single update.
125+
bg := waitReady(ctx, bot)
126+
if bg.Err() != nil {
127+
wg.Wait()
128+
return
129+
}
130+
131+
// On startup, greet every saved subscriber straight from its deserialized
132+
// PeerRef — no incoming update, no re-resolution. This is the restart proof.
133+
for _, ref := range store.refs() {
134+
if _, err := bot.SendMessage(bg, botapi.Peer(ref), "👋 back online"); err != nil {
135+
log.Warn("Greet subscriber", zap.Error(err), zap.Int64("id", ref.ID))
136+
}
137+
}
138+
139+
// A periodic broadcast driven by a ticker, not by any update.
140+
go broadcastLoop(bg, bot, store, log)
141+
142+
wg.Wait()
143+
}
144+
145+
// broadcastLoop sends the time to every subscriber every 30s until ctx is done.
146+
func broadcastLoop(ctx context.Context, bot *botapi.Bot, store *store, log *zap.Logger) {
147+
ticker := time.NewTicker(30 * time.Second)
148+
defer ticker.Stop()
149+
for {
150+
select {
151+
case <-ctx.Done():
152+
return
153+
case t := <-ticker.C:
154+
msg := "⏰ " + t.Format(time.RFC1123)
155+
for _, ref := range store.refs() {
156+
if _, err := bot.SendMessage(ctx, botapi.Peer(ref), msg); err != nil {
157+
log.Warn("Broadcast", zap.Error(err), zap.Int64("id", ref.ID))
158+
}
159+
}
160+
}
161+
}
162+
}
163+
164+
// waitReady returns the bot's run-lifetime context once Run has connected, or a
165+
// canceled context if ctx is done first.
166+
func waitReady(ctx context.Context, bot *botapi.Bot) context.Context {
167+
for {
168+
if bg := bot.Background(); bg.Err() == nil {
169+
return bg
170+
}
171+
select {
172+
case <-ctx.Done():
173+
return ctx
174+
case <-time.After(50 * time.Millisecond):
175+
}
176+
}
177+
}
178+
179+
// store is a tiny JSON-file-backed set of subscriber PeerRefs. A real bot would
180+
// use a database; the point here is only that PeerRef is serializable.
181+
type store struct {
182+
path string
183+
mu sync.Mutex
184+
byID map[int64]botapi.PeerRef
185+
}
186+
187+
// loadStore reads the subscriber file, or starts empty if it does not exist.
188+
func loadStore(path string) (*store, error) {
189+
s := &store{path: path, byID: map[int64]botapi.PeerRef{}}
190+
data, err := os.ReadFile(path) //nolint:gosec // example: path is an operator-provided env var
191+
if os.IsNotExist(err) {
192+
return s, nil
193+
}
194+
if err != nil {
195+
return nil, err
196+
}
197+
var refs []botapi.PeerRef
198+
if err := json.Unmarshal(data, &refs); err != nil {
199+
return nil, err
200+
}
201+
for _, ref := range refs {
202+
s.byID[ref.ID] = ref
203+
}
204+
return s, nil
205+
}
206+
207+
// save atomically writes the current subscriber set back to disk.
208+
func (s *store) save() error {
209+
s.mu.Lock()
210+
defer s.mu.Unlock()
211+
data, err := json.MarshalIndent(s.list(), "", " ")
212+
if err != nil {
213+
return err
214+
}
215+
tmp := s.path + ".tmp"
216+
if err := os.WriteFile(tmp, data, 0o600); err != nil { //nolint:gosec // example: operator-provided path
217+
return err
218+
}
219+
return os.Rename(tmp, s.path) //nolint:gosec // example: operator-provided path
220+
}
221+
222+
// add records a subscriber, reporting whether it was newly added.
223+
func (s *store) add(ref botapi.PeerRef) bool {
224+
s.mu.Lock()
225+
defer s.mu.Unlock()
226+
if _, ok := s.byID[ref.ID]; ok {
227+
return false
228+
}
229+
s.byID[ref.ID] = ref
230+
return true
231+
}
232+
233+
// remove drops a subscriber, reporting whether it was present.
234+
func (s *store) remove(ref botapi.PeerRef) bool {
235+
s.mu.Lock()
236+
defer s.mu.Unlock()
237+
if _, ok := s.byID[ref.ID]; !ok {
238+
return false
239+
}
240+
delete(s.byID, ref.ID)
241+
return true
242+
}
243+
244+
// refs returns a snapshot of the subscriber references.
245+
func (s *store) refs() []botapi.PeerRef {
246+
s.mu.Lock()
247+
defer s.mu.Unlock()
248+
return s.list()
249+
}
250+
251+
// len reports the number of subscribers.
252+
func (s *store) len() int {
253+
s.mu.Lock()
254+
defer s.mu.Unlock()
255+
return len(s.byID)
256+
}
257+
258+
// list copies the references; callers must hold the lock.
259+
func (s *store) list() []botapi.PeerRef {
260+
refs := make([]botapi.PeerRef, 0, len(s.byID))
261+
for _, ref := range s.byID {
262+
refs = append(refs, ref)
263+
}
264+
return refs
265+
}

0 commit comments

Comments
 (0)