commit f833aeaf41c4f9f8a5cc299d8d9f6fff5d8ca799 Author: Artem Tsyrulnikov Date: Thu Feb 5 15:41:56 2026 +0300 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1f12ba1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +README.md +CLAUDE.md +.env +.env.example +docker-compose.yml +Dockerfile +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5b48137 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Telegram Bot Token +TELEGRAM__TOKEN=8048299556:AAH16U8XWYuuCL8txcNLwumFCv-NT82vlcE + +# Database URL (SQLite) +DB__URL=/data/random_coffee.db + +# Group Chat IDs (comma-separated, negative numbers for groups) +# Example: GROUP_CHAT_IDS=-1001234567890,-1009876543210 +GROUP_CHAT_IDS= + +# Admin Chat IDs (comma-separated, positive numbers for users) +# Used for admin commands and error notifications +# Example: ADMIN_CHAT_IDS=123456789,987654321 +ADMIN_CHAT_IDS=690548930 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99d644a --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Исключаем временные файлы и кэш Python +__pycache__/ +*.pyc +*.pyo +*.pyd + +# Исключаем логи +*.log + +# Исключаем файлы конфигурации с секретами +config.yaml + +# Исключаем скомпилированные файлы +*.egg-info/ +dist/ +build/ + +# Исключаем IDE-специфичные файлы +.vscode/ +.idea/ +*.sublime-project +*.sublime-workspace + +# Исключаем операционные файлы +.env +.env.local + +random_coffee.db \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8f462d6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,165 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Random Coffee Bot is a Telegram bot that organizes random coffee meetings between participants. It automatically creates pairs, sends polls to collect participation, and notifies users about their matches. The bot runs on a scheduled basis using APScheduler. + +**Multi-Group Support**: The bot supports multiple Telegram groups simultaneously, with isolated participant pools and pair tracking per group. + +## Development Commands + +### Docker Development (Primary Method) + +```bash +# Build the Docker image +docker-compose build + +# Start the bot in detached mode +docker-compose up -d + +# View logs +docker-compose logs randomcoffee_bot +docker-compose logs -f randomcoffee_bot # Follow logs + +# Stop the bot +docker-compose down + +# Rebuild and restart +docker-compose up --build + +# Clean up all resources +docker-compose down --rmi all --volumes --remove-orphans +``` + +### Direct Python Execution + +The bot uses `uv` for dependency management (see `uv.lock`). To run directly: + +```bash +# Install dependencies +uv sync + +# Run the bot +python -m src.main +``` + +### Testing Bot Commands + +The bot responds to these Telegram commands: +- `/create_pairs` - Manually trigger pair creation +- `/send_quiz` - Manually send participation quiz + +## Architecture + +### Layered Architecture + +The project follows a clean architecture pattern with clear separation of concerns: + +``` +src/ +├── adapter/ # External interfaces (Telegram, Database) +├── controller/ # Request handlers and scheduling +├── usecase/ # Business logic +├── model/ # Domain models (SQLAlchemy) +└── config.py # Configuration using Pydantic Settings +``` + +### Adapter Layer +- **telegram/** - Telegram Bot API integration using aiogram 3.x + - `connection.py`: Bot instance + - `routes.py`: Message/poll sending utilities +- **database/** - SQLAlchemy async database operations + - `connection.py`: Database helper initialization + - `participant.py`: Participant CRUD operations (filtered by group_id) + - `pair.py`: Pair CRUD and matching logic (filtered by group_id) + - `poll_mapping.py`: Maps poll IDs to group IDs for poll answer handling + +### Controller Layer +- **telegram_callback.py**: Aiogram router with command handlers and poll answer handlers + - Extracts group_id from message.chat.id for commands + - Uses poll_mapping to find group_id for poll answers +- **scheduler.py**: APScheduler configuration + - Sends quiz to all groups every Friday at 17:00 Moscow time + - Creates pairs for all groups every Sunday at 19:00 Moscow time + +### Use Case Layer +- **send_quiz.py**: Sends participation poll to specific group and stores poll_id -> group_id mapping +- **create_pairs.py**: Generates unique pairs from available participants for specific group +- **handle_quiz_answer.py**: Processes poll responses for specific group + +### Model Layer +- Based on SQLAlchemy 2.0 with async support +- **base.py**: Declarative base with auto table naming +- **participant.py**: Participant model with group_id +- **pair.py**: Pair model with group_id and week tracking, unique constraint on (group_id, week_start, user1_id, user2_id) +- **poll_mapping.py**: Poll ID to group ID mapping for tracking poll sources + +### Shared Utilities +Located in `shared/` directory: +- **db_helper.py**: Reusable `DatabaseHelper` class for SQLAlchemy async session management +- **logger.py**: Logging configuration with admin notifications + +## Configuration + +Configuration uses Pydantic Settings with environment variables: + +```python +# Required environment variables (see .env.example) +bot__token= +bot__group_chat_ids='[group_id1, group_id2, ...]' # JSON list of group IDs (negative numbers) +bot__admin_chat_id= +db__url= # PostgreSQL or SQLite URL +``` + +**Multi-Group Configuration**: The bot now supports multiple groups via `bot__group_chat_ids` as a JSON list. Each group operates independently with its own participant pool and pair history. + +## Database + +- Supports both SQLite (for local dev) and PostgreSQL +- Uses SQLAlchemy 2.0 async API +- Database file `random_coffee.db` is mounted as volume in Docker +- Tables auto-created on startup via `database.create_tables()` +- Session management through `database.session_getter()` context manager + +## Scheduling + +APScheduler (AsyncIOScheduler) runs timezone-aware jobs: +- Timezone: Europe/Moscow +- Jobs defined in `src/controller/scheduler.py` +- All jobs removed and re-added on startup to avoid duplicates + +## Key Workflows + +### Poll → Pair Creation Flow (Per Group) +1. Friday 17:00: Quiz sent to all configured groups asking about participation +2. Poll ID → group ID mapping stored in poll_mapping table +3. Users respond to poll (anonymous=False to track users) +4. Responses handled by `handle_quiz_answer` use case → poll_id looked up to find group_id → participant stored with group_id +5. Sunday 19:00: `create_pairs` use case runs for each group independently +6. Available participants matched using logic in `adapter/database/pair.py` (filtered by group_id) +7. Pairs posted to respective group with usernames +8. Participants table cleared for that group for next week + +### Pair Matching Logic +Located in `src/adapter/database/pair.py`: +- Ensures unique pairings (checks historical pairs for that group_id) +- Filters participants by group_id +- Uses SQL queries with aliases to find valid combinations +- Randomizes pair selection + +## Important Notes + +- All Telegram operations use aiogram 3.x async API +- Logging configured to file `shared/logs/bot.log` with admin notifications on errors +- Bot uses `skip_updates=True` to ignore messages received while offline +- Router registered in main.py, actual handlers in `controller/telegram_callback.py` +- The main entry point expects `router` to be imported from `src.controller.tg_handlers`, but the actual file is `telegram_callback.py` (verify import path) + +### Multi-Group Implementation +- All database queries filter by `group_id` to isolate data per group +- Poll answers don't contain chat_id, so poll_id → group_id mapping is stored when quiz is sent +- Scheduler iterates over all configured groups for scheduled tasks +- Commands (like `/create_pairs`, `/send_quiz`) extract group_id from message.chat.id +- Each group maintains independent participant pools and pair history diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..615ef64 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Build stage +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY cmd/ ./cmd/ +COPY database/ ./database/ +COPY pkg/ ./pkg/ +COPY migrations/ ./migrations/ + +# Build binary +RUN CGO_ENABLED=0 GOOS=linux go build -o bot ./cmd/ + +# Runtime stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /app + +# Create data directory for database +RUN mkdir -p /data + +# Copy binary and migrations +COPY --from=builder /app/bot . +COPY --from=builder /app/migrations ./migrations/ + +CMD ["./bot"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa454cb --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +# Random Coffee Bot + +Telegram бот для организации случайных встреч (Random Coffee) между участниками групп. + +## Описание + +Бот автоматически создает уникальные пары участников для неформальных встреч: +- 📊 Отправляет опросы участникам каждую пятницу +- 👥 Формирует пары каждое воскресенье +- 🔄 Отслеживает историю встреч (пары не повторяются) +- 🌐 Поддержка нескольких групп одновременно +- 🗄️ Миграции базы данных через Alembic + +## Быстрый старт + +### 1. Клонирование репозитория + +```bash +git clone https://github.com/yourusername/random_coffee_bot.git +cd random_coffee_bot +``` + +### 2. Настройка окружения + +Создайте файл `.env` на основе `.env.example`: + +```bash +cp .env.example .env +``` + +Заполните `.env`: + +```env +bot__token=YOUR_BOT_TOKEN # Токен от @BotFather +bot__group_chat_ids='[]' # Оставьте пустым, группы добавятся через команды +bot__admin_chat_ids='[YOUR_TELEGRAM_ID]' # Ваш Telegram ID +db__url='sqlite+aiosqlite:///random_coffee.db' +``` + +**Как получить Telegram ID:** +- Напишите боту [@userinfobot](https://t.me/userinfobot) + +### 3. Запуск + +#### Docker (рекомендуется) + +```bash +# Собрать и запустить +docker-compose up -d --build + +# Просмотр логов +docker-compose logs -f + +# Остановка +docker-compose down +``` + +#### Локально + +```bash +# Установка зависимостей +uv sync + +# Применить миграции +alembic upgrade head + +# Запуск +python -m src.main +``` + +## Использование + +### Команды для админов + +**В личных сообщениях с ботом:** +- `/groups` - Список подключенных групп + +**В группах (только админы):** +- `/add_group` - Добавить текущую группу +- `/remove_group` - Удалить текущую группу +- `/send_quiz` - Отправить опрос вручную +- `/create_pairs` - Создать пары вручную + +### Автоматическое расписание + +Бот работает по расписанию (московское время): +- **Пятница 17:00** - Отправка опроса участникам +- **Воскресенье 19:00** - Формирование и публикация пар + +### Первая настройка + +1. Запустите бота +2. Добавьте бота в нужные группы +3. Выдайте боту права администратора (для чтения сообщений) +4. Напишите боту в личку `/start` +5. В каждой группе напишите `/add_group` + +## Архитектура + +Проект следует принципам Clean Architecture: + +``` +src/ +├── adapter/ # Внешние интерфейсы +│ ├── database/ # SQLAlchemy + async +│ └── telegram/ # Aiogram 3.x +├── controller/ # Обработчики и планировщик +├── usecase/ # Бизнес-логика +├── model/ # Модели данных +└── config.py # Конфигурация (Pydantic) +``` + +### Технологии + +- **Python 3.13** +- **aiogram 3.x** - Telegram Bot API +- **SQLAlchemy 2.0** - ORM с async support +- **Alembic** - Миграции БД +- **APScheduler** - Планировщик задач +- **Pydantic** - Валидация конфигурации +- **uv** - Управление зависимостями + +## Разработка + +### Работа с базой данных + +```bash +# Создать новую миграцию +alembic revision --autogenerate -m "описание" + +# Применить миграции +alembic upgrade head + +# Откатить миграцию +alembic downgrade -1 + +# Текущая версия +alembic current +``` + +### Структура базы данных + +- **participants** - Участники опроса (очищается после создания пар) +- **pairs** - История всех пар (для предотвращения повторов) +- **poll_mappings** - Связь poll_id → group_id + +### Docker команды + +```bash +# Пересборка +docker-compose up -d --build + +# Логи +docker-compose logs -f randomcoffee_bot + +# Перезапуск +docker-compose restart + +# Очистка +docker-compose down --rmi all --volumes +``` + +## Логирование + +Логи сохраняются в `shared/logs/bot.log` и дублируются в консоль. При критических ошибках отправляется уведомление всем админам. + +## Мультигрупповая поддержка + +- Каждая группа имеет независимый пул участников +- История пар **общая для всех групп** - если два человека встретились в группе A, они не встретятся в группе B +- Планировщик обрабатывает все группы одновременно + +## Troubleshooting + +**Бот не видит команды в группе:** +- Проверьте права администратора +- Отключите Privacy Mode у @BotFather: `/setprivacy` → Disable + +**База данных заблокирована:** +```bash +# Остановите все процессы и пересоздайте базу +rm random_coffee.db +alembic upgrade head +``` + +**Изменение часового пояса:** +Измените `Europe/Moscow` в `src/controller/scheduler.py` + +## License + +MIT diff --git a/cmd/handlers.go b/cmd/handlers.go new file mode 100644 index 0000000..127aa95 --- /dev/null +++ b/cmd/handlers.go @@ -0,0 +1,422 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "os" + "strconv" + "strings" + "time" + + "example.com/random_coffee/database" + "github.com/NicoNex/echotron/v3" + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +var adminChatIDsMap map[int64]bool + +// sendMessage is a helper that sends a message and logs errors +func sendMessage(api echotron.API, text string, chatID int64) { + if _, err := api.SendMessage(text, chatID, nil); err != nil { + // Check if bot was blocked/kicked from chat + errStr := err.Error() + if strings.Contains(errStr, "bot was blocked") || + strings.Contains(errStr, "bot was kicked") || + strings.Contains(errStr, "chat not found") || + strings.Contains(errStr, "have no rights") { + // Don't spam with errors - bot was removed from group + if chatID < 0 { + log.Warn().Err(err).Int64("group_id", chatID).Msg("Bot removed from group or no permissions") + } else { + log.Warn().Err(err).Int64("chat_id", chatID).Msg("Bot blocked by user or chat not found") + } + } else { + // Real error + if chatID < 0 { + log.Error().Err(err).Int64("group_id", chatID).Msg("SendMessage failed") + } else { + log.Error().Err(err).Int64("chat_id", chatID).Msg("SendMessage failed") + } + } + } +} + +// getDisplayName returns username with @ prefix if available, otherwise returns full name +func getDisplayName(p database.Participant) string { + if p.Username != "" { + return "@" + p.Username + } + return p.FullName +} + +// getWeekStart returns Monday of the current week in YYYY-MM-DD format +func getWeekStart(t time.Time) string { + offset := int(t.Weekday() - time.Monday) + if offset < 0 { + offset += 7 + } + return t.AddDate(0, 0, -offset).Format("2006-01-02") +} + +func parseCommaSeparatedIDs(envKey, fieldName string) []int64 { + value := os.Getenv(envKey) + if value == "" { + return []int64{} + } + + ids := make([]int64, 0) + for _, part := range strings.Split(value, ",") { + id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64) + if err != nil { + log.Warn().Err(err).Str("value", part).Str("field", fieldName).Msg("Failed to parse chat ID") + continue + } + ids = append(ids, id) + } + return ids +} + +func initAdmins() { + adminChatIDsMap = make(map[int64]bool) + for _, id := range parseCommaSeparatedIDs("ADMIN_CHAT_IDS", "admin") { + adminChatIDsMap[id] = true + } +} + +func isAdmin(userID int64) bool { + return adminChatIDsMap[userID] +} + +func getConfiguredGroups() []int64 { + return parseCommaSeparatedIDs("GROUP_CHAT_IDS", "group") +} + +// HandlePollAnswer processes poll responses +func HandlePollAnswer(ctx context.Context, db *sql.DB, api echotron.API, pollAnswer *echotron.PollAnswer) { + if pollAnswer.User == nil { + return + } + + // Try to find the poll in our database + groupID, err := database.GetGroupIDByPollID(ctx, db, pollAnswer.PollID) + if err != nil { + // Poll not found - this is OK, it might be an old poll that was already processed + // Don't spam logs with errors for old polls + log.Debug().Str("poll_id", pollAnswer.PollID).Msg("Poll not found (probably old poll)") + return + } + + // If cancelled vote or selected "No" (option 1) + if len(pollAnswer.OptionIDs) == 0 || pollAnswer.OptionIDs[0] != 0 { + // Try to remove participant (ignore if not found) + if err := database.DeleteParticipant(ctx, db, groupID, pollAnswer.User.ID); err != nil { + log.Warn().Err(err).Int64("user_id", pollAnswer.User.ID).Int64("group_id", groupID).Msg("Failed to delete participant") + } else { + log.Info().Int64("user_id", pollAnswer.User.ID).Int64("group_id", groupID).Msg("User removed from participants") + } + return + } + + // Selected "Yes" (option 0) - add participant + fullName := pollAnswer.User.FirstName + if pollAnswer.User.LastName != "" { + fullName += " " + pollAnswer.User.LastName + } + + p := database.Participant{ + ID: uuid.New(), + GroupID: groupID, + UserID: pollAnswer.User.ID, + Username: pollAnswer.User.Username, + FullName: fullName, + CreatedAt: time.Now(), + } + + if err := database.CreateOrUpdateParticipant(ctx, db, p); err != nil { + log.Error().Err(err).Int64("group_id", groupID).Int64("user_id", pollAnswer.User.ID).Msg("CreateOrUpdateParticipant failed") + return + } + + log.Info().Int64("user_id", pollAnswer.User.ID).Int64("group_id", groupID).Str("username", p.Username).Msg("User added to participants") +} + +// HandleGroupCommand processes commands in group chats +func HandleGroupCommand(ctx context.Context, db *sql.DB, api echotron.API, message *echotron.Message) { + if message.From == nil || !isAdmin(message.From.ID) { + return + } + + groupID := message.Chat.ID + + switch message.Text { + case "/create_pairs": + log.Info().Int64("group_id", groupID).Msg("Manual create_pairs command") + CreatePairs(ctx, db, api, groupID) + case "/send_quiz": + log.Info().Int64("group_id", groupID).Msg("Manual send_quiz command") + SendQuiz(ctx, db, api, groupID) + } +} + +// HandlePrivateCommand processes commands in private chats +func HandlePrivateCommand(ctx context.Context, db *sql.DB, api echotron.API, message *echotron.Message) { + if message.From == nil { + return + } + + switch message.Text { + case "/start": + text := "👋 Привет! Это Random Coffee Bot.\n\n" + + "Бот автоматически создает пары для случайных встреч.\n\n" + + "📅 Расписание:\n" + + "• Пятница 17:00 - рассылка опроса\n" + + "• Воскресенье 19:00 - создание пар\n\n" + + "Команды в личке (только для админов):\n" + + "/groups - список групп\n\n" + + "Команды в группе (только для админов):\n" + + "/send_quiz - отправить опрос вручную\n" + + "/create_pairs - создать пары вручную" + sendMessage(api, text, message.Chat.ID) + + case "/groups": + if !isAdmin(message.From.ID) { + sendMessage(api, "❌ Доступ запрещен", message.Chat.ID) + return + } + + groupIDs := getConfiguredGroups() + if len(groupIDs) == 0 { + sendMessage(api, "Группы не настроены", message.Chat.ID) + return + } + + text := "Настроенные группы:\n" + for _, gid := range groupIDs { + text += fmt.Sprintf("• %d\n", gid) + } + sendMessage(api, text, message.Chat.ID) + + default: + sendMessage(api, "Неизвестная команда. Используй /start для справки.", message.Chat.ID) + } +} + +// SendQuiz sends a poll to the group +func SendQuiz(ctx context.Context, db *sql.DB, api echotron.API, groupID int64) { + // Clean up old poll mapping for this group if exists + // This handles the case where a new poll is sent before pairs were created + if err := database.DeletePollMapping(ctx, db, groupID); err != nil { + log.Warn().Err(err).Int64("group_id", groupID).Msg("Failed to delete old poll mapping") + } + + question := "Участвуешь в Random Coffee на этой неделе? ☕️" + options := []echotron.InputPollOption{ + {Text: "Да!"}, + {Text: "Нет"}, + } + opts := &echotron.PollOptions{ + IsAnonymous: false, + AllowsMultipleAnswers: false, + } + + result, err := api.SendPoll(groupID, question, options, opts) + if err != nil { + log.Error().Err(err).Int64("group_id", groupID).Msg("SendPoll failed") + return + } + + if result.Result == nil || result.Result.Poll == nil { + log.Error().Int64("group_id", groupID).Msg("Poll result is nil") + return + } + + messageID := result.Result.ID + + pm := database.PollMapping{ + PollID: result.Result.Poll.ID, + GroupID: groupID, + MessageID: int64(messageID), + } + + if err := database.CreatePollMapping(ctx, db, pm); err != nil { + log.Error().Err(err).Str("poll_id", pm.PollID).Msg("CreatePollMapping failed") + return + } + + // Pin the poll message + _, err = api.PinChatMessage(groupID, messageID, &echotron.PinMessageOptions{DisableNotification: true}) + if err != nil { + log.Warn().Err(err).Int64("group_id", groupID).Int("message_id", messageID).Msg("PinChatMessage failed (check bot permissions)") + // Don't return - poll was sent successfully + } + + log.Info().Str("poll_id", pm.PollID).Int64("group_id", groupID).Int("message_id", messageID).Msg("Quiz sent and pinned successfully") +} + +// filterUniquePairs selects pairs where each participant appears only once +func filterUniquePairs(availablePairs [][2]database.Participant) ([][2]database.Participant, map[int64]bool) { + usedUsers := make(map[int64]bool) + finalPairs := make([][2]database.Participant, 0) + + for _, pair := range availablePairs { + p1, p2 := pair[0], pair[1] + if !usedUsers[p1.UserID] && !usedUsers[p2.UserID] { + finalPairs = append(finalPairs, pair) + usedUsers[p1.UserID] = true + usedUsers[p2.UserID] = true + } + } + return finalPairs, usedUsers +} + +// savePairsToDatabase saves pairs to database for current week +func savePairsToDatabase(ctx context.Context, db *sql.DB, finalPairs [][2]database.Participant, groupID int64) error { + weekStart := getWeekStart(time.Now()) + pairs := make([]database.Pair, 0, len(finalPairs)) + + for _, fp := range finalPairs { + pairs = append(pairs, database.Pair{ + ID: uuid.New(), + GroupID: groupID, + WeekStart: weekStart, + User1ID: fp[0].UserID, + User2ID: fp[1].UserID, + CreatedAt: time.Now(), + }) + } + + return database.CreatePairs(ctx, db, pairs) +} + +// buildPairsMessage creates formatted message with pairs list +func buildPairsMessage(finalPairs [][2]database.Participant) string { + message := "🎉 Пары Random Coffee на эту неделю ☕️\n\n" + for _, pair := range finalPairs { + p1, p2 := pair[0], pair[1] + message += fmt.Sprintf("▫️ %s ✖️ %s\n\n", getDisplayName(p1), getDisplayName(p2)) + } + message += "💬 Напиши прямо сейчас собеседнику в личку и договорись о месте и времени!" + return message +} + +// appendUnpairedMessage adds list of unpaired participants to message +func appendUnpairedMessage(ctx context.Context, db *sql.DB, message string, groupID int64, usedUsers map[int64]bool) string { + allParticipants, err := database.GetAllParticipants(ctx, db, groupID) + if err != nil { + log.Error().Err(err).Int64("group_id", groupID).Msg("GetAllParticipants failed") + return message + } + + if len(allParticipants) == 0 { + return message + } + + unpaired := make([]database.Participant, 0) + for _, p := range allParticipants { + if !usedUsers[p.UserID] { + unpaired = append(unpaired, p) + } + } + + if len(unpaired) == 0 { + return message + } + + message += "\n\n😔 К сожалению, без пары: " + names := make([]string, 0, len(unpaired)) + for _, p := range unpaired { + names = append(names, getDisplayName(p)) + } + message += strings.Join(names, ", ") + return message +} + +// CreatePairs generates random pairs +func CreatePairs(ctx context.Context, db *sql.DB, api echotron.API, groupID int64) { + availablePairs, err := database.GetAvailablePairs(ctx, db, groupID) + if err != nil { + log.Error().Err(err).Int64("group_id", groupID).Msg("GetAvailablePairs failed") + sendMessage(api, "❌ Ошибка при получении доступных пар", groupID) + return + } + + if len(availablePairs) == 0 { + sendMessage(api, "❌ Недостаточно участников или нет уникальных пар", groupID) + return + } + + finalPairs, usedUsers := filterUniquePairs(availablePairs) + if len(finalPairs) == 0 { + sendMessage(api, "❌ Не удалось создать уникальные пары", groupID) + return + } + + if err = savePairsToDatabase(ctx, db, finalPairs, groupID); err != nil { + log.Error().Err(err).Int64("group_id", groupID).Msg("CreatePairs failed") + sendMessage(api, "❌ Ошибка при сохранении пар", groupID) + return + } + + message := buildPairsMessage(finalPairs) + message = appendUnpairedMessage(ctx, db, message, groupID, usedUsers) + + // Check message length (Telegram limit is 4096 characters) + if len(message) > 4000 { + // Truncate at a reasonable boundary + message = message[:4000] + "\n\n...(обрезано)" + } + + sendMessage(api, message, groupID) + + // Unpin the poll message + pollMapping, err := database.GetPollMappingByGroupID(ctx, db, groupID) + if err != nil { + log.Warn().Err(err).Int64("group_id", groupID).Msg("GetPollMappingByGroupID failed (no active poll)") + } else if pollMapping != nil { + _, err = api.UnpinChatMessage(groupID, &echotron.UnpinMessageOptions{MessageID: int(pollMapping.MessageID)}) + if err != nil { + log.Warn().Err(err).Int64("group_id", groupID).Int64("message_id", pollMapping.MessageID).Msg("UnpinChatMessage failed (check bot permissions)") + } else { + log.Info().Int64("group_id", groupID).Int64("message_id", pollMapping.MessageID).Msg("Poll message unpinned") + } + + // Delete poll mapping after attempting to unpin (even if unpin failed) + if err := database.DeletePollMapping(ctx, db, groupID); err != nil { + log.Warn().Err(err).Int64("group_id", groupID).Msg("DeletePollMapping failed") + } + } + + if err = database.ClearAllParticipants(ctx, db, groupID); err != nil { + log.Error().Err(err).Int64("group_id", groupID).Msg("ClearAllParticipants failed") + } + + log.Info().Int64("group_id", groupID).Int("pairs_count", len(finalPairs)).Msg("Pairs created successfully") +} + +func SendQuizToAllGroups(ctx context.Context, db *sql.DB, api echotron.API) { + groups := getConfiguredGroups() + if len(groups) == 0 { + log.Warn().Msg("No groups configured in GROUP_CHAT_IDS") + return + } + + log.Info().Int("groups_count", len(groups)).Msg("Sending quiz to all groups") + for _, groupID := range groups { + SendQuiz(ctx, db, api, groupID) + } +} + +func CreatePairsForAllGroups(ctx context.Context, db *sql.DB, api echotron.API) { + groups := getConfiguredGroups() + if len(groups) == 0 { + log.Warn().Msg("No groups configured in GROUP_CHAT_IDS") + return + } + + log.Info().Int("groups_count", len(groups)).Msg("Creating pairs for all groups") + for _, groupID := range groups { + CreatePairs(ctx, db, api, groupID) + } +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..c214525 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,238 @@ +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) + + scheduleJob("send_quiz", time.Wednesday, 16, 19, 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 +} diff --git a/cmd/notifier.go b/cmd/notifier.go new file mode 100644 index 0000000..22f513a --- /dev/null +++ b/cmd/notifier.go @@ -0,0 +1,110 @@ +package main + +import ( + "encoding/json" + "fmt" + "html" + "io" + "os" + "sync" + + "github.com/NicoNex/echotron/v3" +) + +// AdminNotifier is a writer that sends error logs to Telegram admins +type AdminNotifier struct { + api echotron.API + mu sync.Mutex + adminIDs map[int64]bool + writer io.Writer // Original writer to pass logs through +} + +func NewAdminNotifier(api echotron.API, adminIDs map[int64]bool, writer io.Writer) *AdminNotifier { + return &AdminNotifier{ + api: api, + adminIDs: adminIDs, + writer: writer, + } +} + +func (n *AdminNotifier) Write(p []byte) (int, error) { + // Parse JSON log entry + var logEntry map[string]interface{} + if err := json.Unmarshal(p, &logEntry); err != nil { + // Not JSON, skip + return len(p), nil + } + + // Check if this is an error log (only ERROR and FATAL, not WARN) + level, ok := logEntry["level"].(string) + if !ok || (level != "error" && level != "fatal") { + return len(p), nil + } + + // Extract fields + message, _ := logEntry["message"].(string) + errorMsg, _ := logEntry["error"].(string) + timeStr, _ := logEntry["time"].(string) + + // Build notification message + go n.sendNotification(message, errorMsg, timeStr, logEntry) + + return len(p), nil +} + +func (n *AdminNotifier) WriteLevel(level string, p []byte) (int, error) { + return n.Write(p) +} + +func (n *AdminNotifier) sendNotification(message, errorMsg, timeStr string, logEntry map[string]interface{}) { + n.mu.Lock() + defer n.mu.Unlock() + + // Build a compact formatted message + notificationMsg := "🚨 Ошибка\n" + + if message != "" { + notificationMsg += html.EscapeString(message) + } + + if errorMsg != "" { + notificationMsg += "\n" + html.EscapeString(errorMsg) + } + + // Add only important contextual fields + var details []string + if groupID, ok := logEntry["group_id"]; ok { + details = append(details, fmt.Sprintf("группа: %v", groupID)) + } + if userID, ok := logEntry["user_id"]; ok { + details = append(details, fmt.Sprintf("юзер: %v", userID)) + } + if pollID, ok := logEntry["poll_id"]; ok { + details = append(details, fmt.Sprintf("опрос: %v", pollID)) + } + + if len(details) > 0 { + notificationMsg += "\n" + html.EscapeString(fmt.Sprintf("(%s)", joinStrings(details, ", "))) + "" + } + + for adminID := range n.adminIDs { + opts := &echotron.MessageOptions{ + ParseMode: echotron.HTML, + } + if _, err := n.api.SendMessage(notificationMsg, adminID, opts); err != nil { + // Fallback to stderr to avoid recursion with zerolog + fmt.Fprintf(os.Stderr, "Failed to send admin notification to %d: %v\n", adminID, err) + } + } +} + +func joinStrings(strs []string, sep string) string { + if len(strs) == 0 { + return "" + } + result := strs[0] + for i := 1; i < len(strs); i++ { + result += sep + strs[i] + } + return result +} diff --git a/database/database.go b/database/database.go new file mode 100644 index 0000000..37912f0 --- /dev/null +++ b/database/database.go @@ -0,0 +1,185 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" +) + +type Participant struct { + ID uuid.UUID + GroupID int64 + UserID int64 + Username string + FullName string + CreatedAt time.Time +} + +type Pair struct { + ID uuid.UUID + GroupID int64 + WeekStart string + User1ID int64 + User2ID int64 + CreatedAt time.Time +} + +type PollMapping struct { + PollID string + GroupID int64 + MessageID int64 +} + +// Participant operations + +func CreateOrUpdateParticipant(ctx context.Context, db *sql.DB, p Participant) error { + query := `INSERT INTO participant (id, group_id, user_id, username, full_name, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (group_id, user_id) DO UPDATE + SET username = EXCLUDED.username, full_name = EXCLUDED.full_name` + + _, err := db.ExecContext(ctx, query, p.ID.String(), p.GroupID, p.UserID, p.Username, p.FullName, p.CreatedAt) + return err +} + +func GetAllParticipants(ctx context.Context, db *sql.DB, groupID int64) ([]Participant, error) { + query := `SELECT id, group_id, user_id, username, full_name, created_at + FROM participant WHERE group_id = ?` + + rows, err := db.QueryContext(ctx, query, groupID) + if err != nil { + return nil, err + } + defer rows.Close() + + participants := make([]Participant, 0) + for rows.Next() { + var p Participant + var idStr string + if err := rows.Scan(&idStr, &p.GroupID, &p.UserID, &p.Username, &p.FullName, &p.CreatedAt); err != nil { + return nil, err + } + p.ID, _ = uuid.Parse(idStr) + participants = append(participants, p) + } + return participants, nil +} + +func DeleteParticipant(ctx context.Context, db *sql.DB, groupID, userID int64) error { + query := `DELETE FROM participant WHERE group_id = ? AND user_id = ?` + _, err := db.ExecContext(ctx, query, groupID, userID) + return err +} + +func ClearAllParticipants(ctx context.Context, db *sql.DB, groupID int64) error { + query := `DELETE FROM participant WHERE group_id = ?` + _, err := db.ExecContext(ctx, query, groupID) + return err +} + +// Pair operations + +func CreatePairs(ctx context.Context, db *sql.DB, pairs []Pair) error { + if len(pairs) == 0 { + return nil + } + + query := `INSERT INTO pair (id, group_id, week_start, user1_id, user2_id, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + + for _, p := range pairs { + if _, err := db.ExecContext(ctx, query, p.ID.String(), p.GroupID, p.WeekStart, p.User1ID, p.User2ID, p.CreatedAt); err != nil { + return err + } + } + return nil +} + +func GetAvailablePairs(ctx context.Context, db *sql.DB, groupID int64) ([][2]Participant, error) { + query := ` + WITH available_users AS ( + SELECT + p1.id as p1_id, p1.user_id as p1_user_id, p1.username as p1_username, + p1.full_name as p1_full_name, p1.created_at as p1_created_at, + p2.id as p2_id, p2.user_id as p2_user_id, p2.username as p2_username, + p2.full_name as p2_full_name, p2.created_at as p2_created_at + FROM participant p1 + CROSS JOIN participant p2 + WHERE p1.group_id = ? AND p2.group_id = ? AND p1.user_id < p2.user_id + ) + SELECT p1_id, p1_user_id, p1_username, p1_full_name, p1_created_at, + p2_id, p2_user_id, p2_username, p2_full_name, p2_created_at + FROM available_users au + WHERE NOT EXISTS ( + SELECT 1 FROM pair pr + WHERE pr.group_id = ? + AND ((pr.user1_id = au.p1_user_id AND pr.user2_id = au.p2_user_id) + OR (pr.user1_id = au.p2_user_id AND pr.user2_id = au.p1_user_id)) + ) + ORDER BY RANDOM()` + + rows, err := db.QueryContext(ctx, query, groupID, groupID, groupID) + if err != nil { + return nil, err + } + defer rows.Close() + + pairs := make([][2]Participant, 0) + for rows.Next() { + var p1, p2 Participant + var p1IDStr, p2IDStr string + p1.GroupID = groupID + p2.GroupID = groupID + + if err := rows.Scan(&p1IDStr, &p1.UserID, &p1.Username, &p1.FullName, &p1.CreatedAt, + &p2IDStr, &p2.UserID, &p2.Username, &p2.FullName, &p2.CreatedAt); err != nil { + return nil, err + } + p1.ID, _ = uuid.Parse(p1IDStr) + p2.ID, _ = uuid.Parse(p2IDStr) + pairs = append(pairs, [2]Participant{p1, p2}) + } + return pairs, nil +} + +// Poll mapping operations + +func CreatePollMapping(ctx context.Context, db *sql.DB, pm PollMapping) error { + query := `INSERT INTO poll_mapping (poll_id, group_id, message_id) VALUES (?, ?, ?)` + _, err := db.ExecContext(ctx, query, pm.PollID, pm.GroupID, pm.MessageID) + return err +} + +func GetGroupIDByPollID(ctx context.Context, db *sql.DB, pollID string) (int64, error) { + query := `SELECT group_id FROM poll_mapping WHERE poll_id = ?` + + var groupID int64 + err := db.QueryRowContext(ctx, query, pollID).Scan(&groupID) + if err != nil { + return 0, fmt.Errorf("poll not found: %w", err) + } + return groupID, nil +} + +func GetPollMappingByGroupID(ctx context.Context, db *sql.DB, groupID int64) (*PollMapping, error) { + query := `SELECT poll_id, group_id, message_id FROM poll_mapping WHERE group_id = ? ORDER BY rowid DESC LIMIT 1` + + var pm PollMapping + err := db.QueryRowContext(ctx, query, groupID).Scan(&pm.PollID, &pm.GroupID, &pm.MessageID) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get poll mapping: %w", err) + } + return &pm, nil +} + +func DeletePollMapping(ctx context.Context, db *sql.DB, groupID int64) error { + query := `DELETE FROM poll_mapping WHERE group_id = ?` + _, err := db.ExecContext(ctx, query, groupID) + return err +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c71885c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + bot: + build: . + container_name: random_coffee_bot + env_file: + - .env + volumes: + - ./data:/data + restart: unless-stopped diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..641759e --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module example.com/random_coffee + +go 1.24.0 + +require ( + github.com/NicoNex/echotron/v3 v3.38.0 + github.com/google/uuid v1.6.0 + github.com/pressly/goose/v3 v3.26.0 + github.com/rs/zerolog v1.33.0 + modernc.org/sqlite v1.38.2 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/time v0.5.0 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..856f194 --- /dev/null +++ b/go.sum @@ -0,0 +1,81 @@ +github.com/NicoNex/echotron/v3 v3.38.0 h1:YzW0eRHiBFPpniSBead2hJemcWyL2ZKRKoXhG9jbQUs= +github.com/NicoNex/echotron/v3 v3.38.0/go.mod h1:7LvjveJmezuUOeaoA3nzQduNlSPQYfq219Z+baKY04Q= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= +github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= +modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= +modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/migrations/00001_init_schema.sql b/migrations/00001_init_schema.sql new file mode 100644 index 0000000..18fd14a --- /dev/null +++ b/migrations/00001_init_schema.sql @@ -0,0 +1,27 @@ +-- Initial tables (ported from Alembic revision 51089e76be28) +-- Created: 2025-11-04 14:43:35.891617 + +CREATE TABLE IF NOT EXISTS pair ( + id TEXT PRIMARY KEY, + group_id INTEGER NOT NULL, + week_start TEXT NOT NULL, + user1_id INTEGER NOT NULL, + user2_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + CONSTRAINT uix_pairs_week_user UNIQUE (group_id, week_start, user1_id, user2_id) +); + +CREATE TABLE IF NOT EXISTS participant ( + id TEXT PRIMARY KEY, + group_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + username TEXT NOT NULL, + full_name TEXT NOT NULL, + created_at TEXT NOT NULL, + CONSTRAINT uix_participants_group_user UNIQUE (group_id, user_id) +); + +CREATE TABLE IF NOT EXISTS poll_mapping ( + poll_id TEXT PRIMARY KEY, + group_id INTEGER NOT NULL +); diff --git a/migrations/00002_add_message_id_to_poll_mapping.sql b/migrations/00002_add_message_id_to_poll_mapping.sql new file mode 100644 index 0000000..3be9562 --- /dev/null +++ b/migrations/00002_add_message_id_to_poll_mapping.sql @@ -0,0 +1,4 @@ +-- Added by us: store the poll message_id for pin/unpin logic + +ALTER TABLE poll_mapping +ADD COLUMN message_id INTEGER NOT NULL DEFAULT 0; diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..c1a391a --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,32 @@ +package logger + +import ( + "os" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +type Config struct { + Level string `default:"error" envconfig:"LOGGER_LEVEL"` + PrettyConsole bool `default:"false" envconfig:"LOGGER_PRETTY_CONSOLE"` +} + +func Init(c Config) { + zerolog.TimeFieldFormat = time.RFC3339 + zerolog.SetGlobalLevel(zerolog.InfoLevel) + + level, err := zerolog.ParseLevel(c.Level) + if err != nil { + zerolog.SetGlobalLevel(level) + } + + log.Logger = log.With().Logger() + + if c.PrettyConsole { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}) + } + + log.Info().Msg("Logger initialized") +}