Files
random_coffee_bot/cmd/main.go
Artem Tsyrulnikov 2ffd31885e fix: ensure poll unpins reliably and remove duplicate quiz
- Remove the extra Wednesday 16:19 send_quiz job; only the Friday 17:00
  quiz remains, matching the /start description. The duplicate send
  overwrote the poll mapping, orphaning the earlier pinned poll so it
  was never unpinned.
- Extract unpinPoll() and call it at the start of CreatePairs so the
  poll is always unpinned, even when no pairs can be formed (previously
  early returns skipped the unpin block entirely).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:02:55 +03:00

237 lines
5.5 KiB
Go

package main
import (
"context"
"database/sql"
"io"
"os"
"os/signal"
"sync"
"syscall"
"time"
"example.com/random_coffee/pkg/logger"
"github.com/NicoNex/echotron/v3"
"github.com/pressly/goose/v3"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
_ "modernc.org/sqlite"
)
type Bot struct {
echotron.API
DB *sql.DB
mu sync.Mutex
ChatID int64
}
// dualFormatWriter writes JSON logs to jsonWriter and parses them for consoleWriter
type dualFormatWriter struct {
console io.Writer
json io.Writer
}
func (w *dualFormatWriter) Write(p []byte) (n int, err error) {
// Send JSON to admin notifier
w.json.Write(p)
// Send to console writer
return w.console.Write(p)
}
func recoverPanic(contextFields map[string]any) {
if r := recover(); r != nil {
entry := log.Error().Interface("panic", r)
for k, v := range contextFields {
entry = entry.Interface(k, v)
}
}
}
func (b *Bot) Update(u *echotron.Update) {
b.mu.Lock()
defer b.mu.Unlock()
defer recoverPanic(map[string]any{"handler": "Update"})
ctx := context.Background()
if u.PollAnswer != nil {
HandlePollAnswer(ctx, b.DB, b.API, u.PollAnswer)
return
}
if u.Message != nil {
if u.Message.Chat.Type == "private" {
HandlePrivateCommand(ctx, b.DB, b.API, u.Message)
return
}
HandleGroupCommand(ctx, b.DB, b.API, u.Message)
}
}
func main() {
logger.Init(logger.Config{
PrettyConsole: true,
})
log.Info().Msg("Starting bot...")
botToken := mustEnv("TELEGRAM__TOKEN")
dbPath := mustEnv("DB__URL")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
log.Fatal().Err(err).Msg("sql.Open failed")
}
defer func() { _ = db.Close() }()
err = runMigrations(db)
if err != nil {
log.Fatal().Err(err).Msg("runMigrations failed")
}
initAdmins()
botAPI := echotron.NewAPI(botToken)
if len(adminChatIDsMap) > 0 {
// Setup dual logger: console (pretty) + admin notifier (JSON)
consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}
jsonWriter := NewAdminNotifier(botAPI, adminChatIDsMap, io.Discard)
// Create a custom writer that duplicates to both console and JSON
multiWriter := &dualFormatWriter{
console: consoleWriter,
json: jsonWriter,
}
log.Logger = zerolog.New(multiWriter).With().Timestamp().Logger()
log.Info().Msg("Admin notifier enabled")
}
stop := make(chan struct{})
startScheduler(db, botAPI, stop)
newBot := func(chatID int64) echotron.Bot { return &Bot{ChatID: chatID, DB: db, API: echotron.NewAPI(botToken)} }
dsp := echotron.NewDispatcher(botToken, newBot)
updateOpts := echotron.UpdateOptions{
// AllowedUpdates: []echotron.UpdateType{
// echotron.MessageUpdate,
// echotron.CallbackQueryUpdate,
// echotron.PollAnswerUpdate,
// },
}
errChan := make(chan error, 1)
go func() {
defer recoverPanic(map[string]any{"handler": "polling"})
log.Info().Msg("Bot polling started")
for {
if err := dsp.PollOptions(false, updateOpts); err != nil {
log.Error().Err(err).Msg("dsp.Poll failed, retrying in 5 seconds...")
time.Sleep(5 * time.Second)
continue
}
break
}
errChan <- nil
}()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
select {
case <-sigChan:
log.Info().Msg("Received shutdown signal")
case err := <-errChan:
if err != nil {
log.Error().Err(err).Msg("Bot polling failed")
}
}
log.Info().Msg("Shutting down gracefully...")
close(stop)
time.Sleep(1 * time.Second)
log.Info().Msg("Goodbye!")
}
func runMigrations(db *sql.DB) error {
if err := goose.SetDialect("sqlite3"); err != nil {
return err
}
if err := goose.Up(db, "migrations"); err != nil {
return err
}
log.Info().Msg("Migrations applied successfully")
return nil
}
func scheduleJob(jobName string, weekday time.Weekday, hour, minute int,
jobFunc func(context.Context, *sql.DB, echotron.API), db *sql.DB, api echotron.API, stopChan chan struct{}, location *time.Location) {
go func() {
defer recoverPanic(map[string]any{"handler": "scheduler", "job": jobName})
for {
now := time.Now().In(location)
next := nextOccurrence(now, weekday, hour, minute, location)
duration := next.Sub(now)
log.Info().Str("job", jobName).Time("next_run", next).Dur("in", duration).Msg("Scheduled")
select {
case <-time.After(duration):
log.Info().Str("job", jobName).Msg("Running scheduled job")
ctx := context.Background()
jobFunc(ctx, db, api)
case <-stopChan:
log.Info().Str("job", jobName).Msg("Job stopped")
return
}
}
}()
}
func startScheduler(db *sql.DB, api echotron.API, stopChan chan struct{}) {
moscowTZ, err := time.LoadLocation("Europe/Moscow")
if err != nil {
log.Fatal().Err(err).Msg("Failed to load Europe/Moscow timezone")
}
// Friday 17:00 - send quiz
scheduleJob("send_quiz", time.Friday, 17, 0, SendQuizToAllGroups, db, api, stopChan, moscowTZ)
// Sunday 19:00 - create pairs
scheduleJob("create_pairs", time.Sunday, 19, 0, CreatePairsForAllGroups, db, api, stopChan, moscowTZ)
log.Info().Msg("Scheduler started")
}
func nextOccurrence(now time.Time, weekday time.Weekday, hour, minute int, location *time.Location) time.Time {
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, location)
if now.Weekday() == weekday && now.Before(target) {
return target
}
daysUntil := int(weekday - now.Weekday())
if daysUntil <= 0 {
daysUntil += 7
}
return target.AddDate(0, 0, daysUntil)
}
func mustEnv(key string) string {
value := os.Getenv(key)
if value == "" {
log.Fatal().Str("env", key).Msg("missing required environment variable")
}
return value
}