summaryrefslogtreecommitdiffstats
path: root/internal/app/app.go
blob: 0b78bafc4e45d03d28de248520bdd64be0d0fb0d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
// Package app wires the configuration, the cep.is client, the Telegram client
// and the bot into a single runnable unit.
//
// It exists so that main stays a thin shell around signal handling, and so the
// test suite can run the whole thing against a fake Telegram and a fake cep.is
// without going through the process environment.
package app

import (
	"context"
	"fmt"
	"log/slog"

	cepis "cep.is/go"

	"cep.is/telegrambot/internal/bot"
	"cep.is/telegrambot/internal/config"
	"cep.is/telegrambot/internal/telegram"
)

// App is one configured bot process.
type App struct {
	poller   *telegram.Poller
	username string
	logger   *slog.Logger
}

// New builds the application and identifies the bot.
//
// The getMe call is not a formality. It fails fast on a bad token, and it is the
// only way to learn the username the operator chose with @BotFather, which the
// help text quotes and which "/cep@username" has to match.
//
// The whole-call timeout of the cep.is client is the shutdown grace on purpose:
// handling one update is what a termination signal waits for, so a lookup must
// not be allowed to outlast that budget.
func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) {
	if logger == nil {
		logger = slog.Default()
	}

	lookups, err := cepis.New(
		cepis.WithBaseURL(cfg.CepisBaseURL),
		cepis.WithTimeout(cfg.RequestTimeout),
		cepis.WithMaxAttempts(cfg.MaxAttempts),
		cepis.WithBackoff(cfg.BackoffBase, cfg.BackoffMax),
		cepis.WithTotalTimeout(cfg.ShutdownGrace),
		cepis.WithUserAgent("cepis-telegram/0.1.0 (+https://cep.is)"),
	)
	if err != nil {
		return nil, fmt.Errorf("app: cep.is client: %w", err)
	}

	api, err := telegram.New(cfg.Token,
		telegram.WithBaseURL(cfg.TelegramAPIBaseURL),
		telegram.WithTimeout(cfg.RequestTimeout),
		telegram.WithMaxAttempts(cfg.MaxAttempts),
		telegram.WithBackoff(cfg.BackoffBase, cfg.BackoffMax),
	)
	if err != nil {
		return nil, fmt.Errorf("app: telegram client: %w", err)
	}

	me, err := api.GetMe(ctx)
	if err != nil {
		return nil, fmt.Errorf("app: getMe: %w", err)
	}
	if me.Username == "" {
		return nil, fmt.Errorf("app: getMe returned no username")
	}

	handler := bot.New(lookups, api, me.Username, bot.WithLogger(logger))
	poller := telegram.NewPoller(api, handler,
		telegram.WithLogger(logger),
		telegram.WithPollTimeout(cfg.PollTimeout),
		telegram.WithShutdownGrace(cfg.ShutdownGrace),
		telegram.WithErrorBackoff(cfg.BackoffBase, cfg.BackoffMax),
	)

	return &App{poller: poller, username: me.Username, logger: logger}, nil
}

// Username is the bot's own username, as reported by getMe.
func (a *App) Username() string { return a.username }

// Offset is the identifier of the next update expected. It is meant for tests
// and diagnostics.
func (a *App) Offset() int64 { return a.poller.Offset() }

// Run polls until ctx is cancelled and then shuts down cleanly.
func (a *App) Run(ctx context.Context) error {
	a.logger.Info("bot started", slog.String("username", a.username))
	err := a.poller.Run(ctx)
	a.logger.Info("bot stopped", slog.Int64("offset", a.poller.Offset()))
	return err
}