Skip to content

Commit 50bc25b

Browse files
ernadoclaude
andcommitted
feat(bot): background-send context (Bot.Background)
A handler's context is per-update and may be canceled (Timeout middleware) when the handler returns, so it can't drive background work. Add Bot.Background()/Context.Background() — a context tied to the bot's run lifetime — for proactive sends to any chat from timers, queues or goroutines. Returns an already-canceled context before Run/after stop. Add Bot.Logger(); demo via the example's /remind, document in the guide, note in the changelog. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bad34e3 commit 50bc25b

6 files changed

Lines changed: 160 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ messages follow [Conventional Commits](https://www.conventionalcommits.org/).
1212
- **Rich messages** (Bot API 10.1) — `SendRichMessage`/`SendRichHTML`/
1313
`SendRichMarkdown` send structured page-block content built with
1414
`github.com/gotd/td/telegram/message/rich`.
15+
- **Background sends**`Bot.Background()` / `Context.Background()` expose a
16+
run-lifetime context for proactive sends to any chat from timers, queues or
17+
goroutines, instead of the per-update handler context. Plus `Bot.Logger()`.
1518

1619
## [0.1.0] - 2026-06-14
1720

background_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"testing"
6+
)
7+
8+
func TestBackgroundContext(t *testing.T) {
9+
b := newTestBot(t)
10+
11+
// Before Run, Background is already canceled so background sends fail fast.
12+
if err := b.Background().Err(); err == nil {
13+
t.Fatal("Background before Run should be canceled")
14+
}
15+
16+
// While running, it returns the live run context.
17+
ctx, cancel := context.WithCancel(context.Background())
18+
b.setRunCtx(ctx)
19+
if got := b.Background(); got != ctx {
20+
t.Fatalf("Background should return the run context, got %v", got)
21+
}
22+
if b.Background().Err() != nil {
23+
t.Fatal("live run context should not be canceled")
24+
}
25+
26+
// Canceling the run context (bot stopping) cancels background work.
27+
cancel()
28+
if b.Background().Err() == nil {
29+
t.Fatal("canceled run context should propagate")
30+
}
31+
32+
// After Run returns, runCtx is cleared back to a canceled context.
33+
b.setRunCtx(nil)
34+
if b.Background().Err() == nil {
35+
t.Fatal("Background after stop should be canceled")
36+
}
37+
}
38+
39+
func TestContextBackgroundDelegates(t *testing.T) {
40+
b := newTestBot(t)
41+
c := &Context{Bot: b, Update: &Update{}}
42+
if c.Background() != b.Background() {
43+
t.Fatal("Context.Background should delegate to Bot.Background")
44+
}
45+
}

bot.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ type Bot struct {
4242
commands []BotCommand
4343
registerCommands bool
4444

45+
// runMu guards runCtx, the bot's run-lifetime context, used for background
46+
// (proactive) sends that must outlive a single update handler.
47+
runMu sync.Mutex
48+
runCtx context.Context
49+
4550
self *tg.User
4651
}
4752

@@ -151,6 +156,11 @@ func (b *Bot) publishCommands(ctx context.Context) {
151156
// canceled or a fatal error occurs. Register handlers before calling Run.
152157
func (b *Bot) Run(ctx context.Context) error {
153158
return b.client.Run(ctx, func(ctx context.Context) error {
159+
// Expose the connection-lifetime context for background sends, and clear
160+
// it on shutdown so background work stops with the bot.
161+
b.setRunCtx(ctx)
162+
defer b.setRunCtx(nil)
163+
154164
status, err := b.client.Auth().Status(ctx)
155165
if err != nil {
156166
return errors.Wrap(err, "auth status")
@@ -207,3 +217,49 @@ func (b *Bot) Peers() *peers.Manager { return b.peers }
207217

208218
// Self returns the bot's own user. It is nil until Run has authorized.
209219
func (b *Bot) Self() *tg.User { return b.self }
220+
221+
// Logger returns the bot's zap logger.
222+
func (b *Bot) Logger() *zap.Logger { return b.log }
223+
224+
func (b *Bot) setRunCtx(ctx context.Context) {
225+
b.runMu.Lock()
226+
b.runCtx = ctx
227+
b.runMu.Unlock()
228+
}
229+
230+
// Background returns a context tied to the bot's run lifetime, for proactive or
231+
// background sends that are not in response to an update — e.g. from a timer,
232+
// queue or goroutine. It is canceled when the bot stops.
233+
//
234+
// A handler's own context is per-update (and may carry a Timeout deadline), so
235+
// it must not be used for work that outlives the handler. Use Background
236+
// instead:
237+
//
238+
// bot.OnCommand("remind", "Remind in a minute", func(c *botapi.Context) error {
239+
// chat, _ := c.Chat()
240+
// ctx := c.Bot.Background()
241+
// go func() {
242+
// time.Sleep(time.Minute)
243+
// c.Bot.SendMessage(ctx, chat, "⏰ reminder")
244+
// }()
245+
// return nil
246+
// })
247+
//
248+
// Before Run has connected (or after it has stopped) Background returns an
249+
// already-canceled context, so background sends fail fast rather than block.
250+
func (b *Bot) Background() context.Context {
251+
b.runMu.Lock()
252+
ctx := b.runCtx
253+
b.runMu.Unlock()
254+
if ctx == nil {
255+
return canceledContext
256+
}
257+
return ctx
258+
}
259+
260+
// canceledContext is returned by Background before the bot is running.
261+
var canceledContext = func() context.Context {
262+
ctx, cancel := context.WithCancel(context.Background())
263+
cancel()
264+
return ctx
265+
}()

context.go

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

3+
import "context"
4+
35
// Message returns the message the update carries (new/edited message or channel
46
// post), or nil.
57
func (c *Context) Message() *Message { return c.Update.EffectiveMessage() }
@@ -60,6 +62,18 @@ func (c *Context) AnswerCallback(opts ...AnswerCallbackQueryOption) error {
6062
return c.Bot.AnswerCallbackQuery(c, cq.ID, opts...)
6163
}
6264

65+
// Background returns a context tied to the bot's run lifetime, for sends that
66+
// must outlive this handler (a timer, queue or goroutine). The handler's own
67+
// context is per-update and may be canceled (e.g. by Timeout middleware) as soon
68+
// as the handler returns, so do not capture it for background work.
69+
//
70+
// Send messages to any chat in the background with Bot.SendMessage and this
71+
// context:
72+
//
73+
// ctx := c.Background()
74+
// go func() { c.Bot.SendMessage(ctx, botapi.ID(other), "hi") }()
75+
func (c *Context) Background() context.Context { return c.Bot.Background() }
76+
6377
// AnswerInline answers the update's inline query with the given results. It is
6478
// an error to call it when the update is not an inline query.
6579
func (c *Context) AnswerInline(results []InlineQueryResult, opts ...AnswerInlineQueryOption) error {

docs/guide.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,31 @@ bot.OnInlineQuery(handler)
199199
> Updates for the bot's own outgoing messages are filtered out (the HTTP Bot API
200200
> never delivers them), so reply handlers won't answer themselves.
201201
202+
### Sending in the background
203+
204+
A handler's context is **per-update** — the `Timeout` middleware may give it a
205+
deadline, and it is canceled once the handler returns. Do not capture it for
206+
work that outlives the handler. For proactive sends (a timer, a queue, a
207+
goroutine) to any chat, use `Bot.Background()` (or `Context.Background()`), a
208+
context tied to the bot's run lifetime:
209+
210+
```go
211+
bot.OnCommand("remind", "Remind me", func(c *botapi.Context) error {
212+
chat, _ := c.Chat()
213+
ctx := c.Background()
214+
go func() {
215+
time.Sleep(time.Minute)
216+
c.Bot.SendMessage(ctx, chat, "⏰ reminder")
217+
}()
218+
return nil
219+
})
220+
```
221+
222+
Outside any handler, keep the `*Bot` and call `bot.SendMessage(bot.Background(),
223+
botapi.ID(chatID), text)` from wherever you like once the bot is running.
224+
`Background` returns an already-canceled context before `Run` connects (and
225+
after it stops), so background sends fail fast instead of blocking.
226+
202227
## Predicates
203228

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

examples/advanced/main.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,22 @@ func registerCommands(bot *botapi.Bot) {
211211
return err
212212
})
213213

214+
// Background send: reply now, then send again from a goroutine that outlives
215+
// the handler. The handler's own context would be canceled on return, so use
216+
// the bot's run-lifetime Background context.
217+
bot.OnCommand("remind", "Send a reminder in 5 seconds (background)", func(c *botapi.Context) error {
218+
chat, _ := c.Chat()
219+
ctx := c.Background()
220+
go func() {
221+
time.Sleep(5 * time.Second)
222+
if _, err := c.Bot.SendMessage(ctx, chat, "⏰ Here's your reminder!"); err != nil {
223+
c.Bot.Logger().Warn("background reminder failed", zap.Error(err))
224+
}
225+
}()
226+
_, err := c.Reply("OK, I'll remind you in 5 seconds.")
227+
return err
228+
})
229+
214230
bot.OnCommand("protect", "Send a message that can't be forwarded", func(c *botapi.Context) error {
215231
_, err := c.Reply("🔒 This message has protected content.", botapi.ProtectContent())
216232
return err
@@ -412,6 +428,7 @@ Commands:
412428
/forward — forward your message back
413429
/silent — silent message
414430
/protect — protected-content message
431+
/remind — background send after a delay
415432
416433
Also try:
417434
• send me a <i>photo</i>, <i>document</i>, <i>sticker</i>, <i>location</i> or <i>contact</i>

0 commit comments

Comments
 (0)