// 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/lookup" "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. // // The client goes to the bot wrapped in [lookup.Client], which is what keeps the // municipality the API names on a 404. The wrapper has its own short timeout, // deliberately far below the grace: it is a footnote on an answer that is // already writable. func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) { if logger == nil { logger = slog.Default() } cepisClient, 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.2.0 (+https://cep.is)"), ) if err != nil { return nil, fmt.Errorf("app: cep.is client: %w", err) } lookups := lookup.New(cepisClient) 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 }