diff --git a/.env.example b/.env.example index ddca089c..24dbabac 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,11 @@ -# twitch app data -TWITCH_CLIENTID= -TWITCH_CLIENTSECRET= -# telegram bot token -TELEGRAM_TOKEN= -# ids, separated by command -TELEGRAM_BOT_ADMINS= -DATABASE_URL=postgres://test:test@localhost:54326/test?sslmode= +# Secrets (use wrangler secret put) +APP_ENV = "development" +BASE_URL = "http://localhost:8787" +TELEGRAM_TOKEN = "" +TWITCH_CLIENT_ID = "" +TWITCH_CLIENT_SECRET = "" +TELEGRAM_BOT_ADMINS = "comma-separated user IDs" +TWITCH_EVENTSUB_SECRET = "for webhook verification" + +# BOT INFO FOR SKIP /me REQUEST ON EACH REQUEST +BOT_INFO = """{"id": 1234567890,"is_bot": true,"first_name": "mybot","username": "MyBot","can_join_groups": true,"can_read_all_group_messages": false,"supports_inline_queries": true,"can_connect_to_business": false}""" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 34252245..00000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Docker Image CI - latest - -on: - push: - branches: - - main - workflow_dispatch: - -jobs: - docker: - if: "! contains(toJSON(github.event.commits.*.message), '[skip-docker]')" - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to Quay Container Registry - uses: docker/login-action@v3 - with: - registry: quay.io - username: ${{ secrets.QUAY_USERNAME }} - password: ${{ secrets.QUAY_ROBOT_TOKEN }} - - name: Build and push - uses: docker/build-push-action@v2 - with: - context: . - push: true - tags: | - quay.io/satont/twitch-notifier:latest - quay.io/satont/twitch-notifier:${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/migrations_lint.yml b/.github/workflows/migrations_lint.yml deleted file mode 100644 index 6bdb7a54..00000000 --- a/.github/workflows/migrations_lint.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Migrations lint - -on: - pull_request: - -jobs: - lint: - services: - postgres: - image: postgres:15 - env: - POSTGRES_DB: test - POSTGRES_PASSWORD: pass - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3.0.1 - with: - fetch-depth: 0 - - uses: ariga/atlas-action@v0 - with: - dir: ent/migrate/migrations - dir-format: atlas - dev-url: postgres://postgres:pass@localhost:5432/test?sslmode=disable diff --git a/.github/workflows/pr_title_lint.yml b/.github/workflows/pr_title_lint.yml deleted file mode 100644 index 92933635..00000000 --- a/.github/workflows/pr_title_lint.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: PR Title Lint - -on: - pull_request_target: - types: - - opened - - edited - - synchronize - -jobs: - pr_title_lint: - runs-on: ubuntu-latest - steps: - - uses: amannn/action-semantic-pull-request@v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 5964f65e..00000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Tests - -on: - push: - branches: - - main - pull_request: - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - go: - - 1.21.x - - 1.20.x - - 1.19.x - name: Test with Go v${{ matrix.go }} - steps: - - uses: actions/checkout@v2 - - name: Setup go - uses: actions/setup-go@v4 - with: - go-version: ${{ matrix.go }} - - name: Intall goveralls - run: | - go install github.com/mattn/goveralls@latest - - name: Generate ent - run: | - make generate - - name: Test - run: | - make tests - - name: Send coverage - env: - COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - goveralls -coverprofile=coverage.out -service=github diff --git a/.gitignore b/.gitignore index c03420e4..3c99929e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ ent/**/* !ent/generate.go .vscode .DS_Store +wrangler.toml +node_modules +.wrangler diff --git a/README.md b/README.md index ee1c2068..9b866c7f 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,187 @@ -# Twitch Notifier +# Twitch Notifier Bot -![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/satont/twitch-notifier) -[![Coverage Status](https://coveralls.io/repos/github/Satont/twitch-notifier/badge.svg)](https://coveralls.io/github/Satont/twitch-notifier) +Telegram бот для уведомлений о стримах Twitch с использованием Cloudflare Workers, D1 и KV. -Bot for sending twitch streams notifications in telegram. +## Архитектура -# Development +### Serverless-Agnostic Design +Проект построен с учетом возможности запуска в разных окружениях: +- **Cloudflare Workers** (основная платформа) +- **Docker** (для локальной разработки) +- Другие serverless платформы (AWS Lambda, Vercel, etc.) -Download dependencies - -```bash -go mod download +### Repository Pattern +``` +src/db/ +├── connection.ts # IDatabaseConnection интерфейс +├── repository.factory.ts # Factory для создания репозиториев +├── repositories/ +│ ├── interfaces/ # Интерфейсы репозиториев +│ ├── drizzle/ # Реализации для Drizzle ORM (D1, PostgreSQL) +│ └── cloudflare-kv/ # Реализации для Cloudflare KV ``` -### Requirements +### Технологический стек +- **Runtime**: Cloudflare Workers (Node.js compatible) +- **Database**: Cloudflare D1 (SQLite) +- **Cache/Sessions**: Cloudflare KV +- **ORM**: Drizzle ORM +- **Bot Framework**: Grammy +- **HTTP Framework**: Hono +- **Twitch API**: Twurple -- Golang `1.19+` +## Команды бота -### Generate +### Пользовательские команды: +- `/start`, `/help`, `/info`, `/settings` - Меню настроек +- `/follow ` - Подписаться на канал Twitch +- `/follows`, `/unfollow` - Управление подписками +- `/live` - Показать онлайн стримы -After clone/on first setup/on schema change - you should run +### Админские команды: +- `/broadcast ` - Рассылка всем пользователям +- `/change_channel_id ` - Обновить Twitch ID канала +## Установка и деплой + +### 1. Установка зависимостей ```bash -make generate +bun install ``` -### Testing +### 2. Создание Cloudflare D1 базы данных +```bash +wrangler d1 create twitch-notifier-db +``` + +Скопируйте `database_id` из вывода команды и вставьте в `wrangler.toml`: +```toml +[[d1_databases]] +binding = "DB" +database_name = "twitch-notifier-db" +database_id = "YOUR_DATABASE_ID_HERE" +``` +### 3. Создание Cloudflare KV namespace для сессий ```bash -make tests +wrangler kv:namespace create SESSIONS_KV ``` -### Running +Скопируйте `id` из вывода команды и вставьте в `wrangler.toml`: +```toml +[[kv_namespaces]] +binding = "SESSIONS_KV" +id = "YOUR_KV_ID_HERE" +``` +### 4. Применение миграций ```bash -docker compose -f docker-compose.dev.yml up -d -make dev +wrangler d1 execute twitch-notifier-db --file=./drizzle/0000_init.sql ``` -## Database schemas and migrations +### 5. Настройка переменных окружения -### Writing schemas +**Через Cloudflare Dashboard** или с помощью `wrangler secret put`: -All schemas located in `./ent/schema` directory, but also we are using internal structures. Internal structures located in `internal/db/db_models`. So you should change both of them. +```bash +wrangler secret put TELEGRAM_TOKEN +wrangler secret put BASE_URL # URL вашего воркера, например: https://twitch-notifier.yourname.workers.dev +``` + +Остальные переменные можно задать в `wrangler.toml`: +```toml +[vars] +TWITCH_CLIENT_ID = "your_client_id" +TWITCH_CLIENT_SECRET = "your_client_secret" +TELEGRAM_BOT_ADMINS = "123456789,987654321" # Telegram user IDs через запятую +TWITCH_EVENTSUB_SECRET = "your_eventsub_secret" +``` -After changing any schema in `/ent/schema` folder, you should regenerate data via `make generate` +### 6. Деплой +```bash +bun run deploy +``` -### Migrations +### 7. Настройка Telegram webhook +После деплоя настройте webhook для бота: +```bash +curl -X POST "https://api.telegram.org/bot/setWebhook" \ + -H "Content-Type: application/json" \ + -d '{"url":"https://your-worker.workers.dev/telegram-webhook"}' +``` -#### Requirements +### 8. Настройка Twitch EventSub +Webhook для Twitch EventSub настроится автоматически при подписке на каналы через команду `/follow`. -- [atlasgo cli](https://atlasgo.io/getting-started#installation) -- Docker +URL для EventSub: `https://your-worker.workers.dev/twitch-webhook` -### Create +## Разработка +### Локальный запуск ```bash -make migrate-create somecoolname +bun run dev ``` -### Apply +### Генерация миграций +```bash +bun drizzle-kit generate +``` + +### Применение миграций локально +```bash +bun drizzle-kit migrate +``` +### Проверка типов ```bash -make migrate-apply +bun run typecheck +``` + +## Структура проекта + ``` +src/ +├── bot/ +│ ├── commands/ # Команды через Composer +│ ├── helpers.ts # Вспомогательные функции +│ ├── storage.ts # Storage adapter для Grammy +│ └── types.ts # Типы контекста +├── db/ +│ ├── connection.ts # Абстракция подключения к БД +│ ├── schema.ts # Drizzle схема +│ ├── repository.factory.ts +│ └── repositories/ +│ ├── interfaces/ # Интерфейсы репозиториев +│ ├── drizzle/ # Реализации для D1 +│ └── cloudflare-kv/ # Реализации для KV +├── domain/ +│ ├── models.ts # Доменные модели +│ └── mapper.ts # Маппер DB → Domain +├── services/ # Сервисы (Twitch, Telegram, etc.) +├── webhooks/ # Обработчики webhook'ов +└── index.ts # Hono приложение +``` + +## Особенности реализации + +### Персистентные сессии через Cloudflare KV +Сессии Grammy хранятся в Cloudflare KV с автоматическим TTL. Это решает проблему сброса сессий в serverless окружении. KV обеспечивает: +- Низкую латентность (читается с ближайшего edge) +- Автоматическое истечение ключей +- Глобальное распределение + +### EventSub вместо polling +Используются Twitch EventSub webhooks для получения событий в реальном времени: +- `stream.online` - стример начал трансляцию +- `stream.offline` - стример закончил трансляцию +- `channel.update` - изменились название или категория + +### Domain-Driven Design +Разделение между DB schema и domain models для чистой архитектуры. + +### Factory Pattern +Единая точка создания репозиториев для простой замены реализаций. + +## Лицензия + +MIT diff --git a/bun.lock b/bun.lock new file mode 100644 index 00000000..064a7465 --- /dev/null +++ b/bun.lock @@ -0,0 +1,425 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "twitch-notifier", + "dependencies": { + "@grammyjs/conversations": "2.1.1", + "@twurple/api": "8.0.3", + "@twurple/auth": "8.0.3", + "@twurple/eventsub-http": "8.0.3", + "drizzle-orm": "0.45.1", + "grammy": "1.41.1", + "hono": "^4.7.11", + "i18next": "25.8.14", + }, + "devDependencies": { + "@cloudflare/workers-types": "4.20260313.1", + "@types/node": "^22.10.6", + "drizzle-kit": "0.31.9", + "typescript": "^5.7.3", + "wrangler": "4.73.0", + }, + }, + }, + "packages": { + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.15.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-EGYmJaGZKWl+X8tXxcnx4v2bOZSjQeNI5dWFeXivgX9+YCT69AkzHHwlNbVpqtEUTbew8eQurpyOpeN8fg00nw=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260312.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HUAtDWaqUduS6yasV6+NgsK7qBpP1qGU49ow/Wb117IHjYp+PZPUGReDYocpB4GOMRoQlvdd4L487iFxzdARpw=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260312.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DOn7TPTHSxJYfi4m4NYga/j32wOTqvJf/pY4Txz5SDKWIZHSTXFyGz2K4B+thoPWLop/KZxGoyTv7db0mk/qyw=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260312.1", "", { "os": "linux", "cpu": "x64" }, "sha512-TdkIh3WzPXYHuvz7phAtFEEvAxvFd30tHrm4gsgpw0R0F5b8PtoM3hfL2uY7EcBBWVYUBtkY2ahDYFfufnXw/g=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260312.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kNauZhL569Iy94t844OMwa1zP6zKFiL3xiJ4tGLS+TFTEfZ3pZsRH6lWWOtkXkjTyCmBEOog0HSEKjIV4oAffw=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260312.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5dBrlSK+nMsZy5bYQpj8t9iiQNvCRlkm9GGvswJa9vVU/1BNO4BhJMlqOLWT24EmFyApZ+kaBiPJMV8847NDTg=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260313.1", "", {}, "sha512-jMEeX3RKfOSVqqXRKr/ulgglcTloeMzSH3FdzIfqJHtvc12/ELKd5Ldsg8ZHahKX/4eRxYdw3kbzb8jLXbq/jQ=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@d-fischer/cache-decorators": ["@d-fischer/cache-decorators@4.0.1", "", { "dependencies": { "@d-fischer/shared-utils": "^3.6.3", "tslib": "^2.6.2" } }, "sha512-HNYLBLWs/t28GFZZeqdIBqq8f37mqDIFO6xNPof94VjpKvuP6ROqCZGafx88dk5zZUlBfViV9jD8iNNlXfc4CA=="], + + "@d-fischer/detect-node": ["@d-fischer/detect-node@3.0.1", "", {}, "sha512-0Rf3XwTzuTh8+oPZW9SfxTIiL+26RRJ0BRPwj5oVjZFyFKmsj9RGfN2zuTRjOuA3FCK/jYm06HOhwNK+8Pfv8w=="], + + "@d-fischer/logger": ["@d-fischer/logger@4.2.4", "", { "dependencies": { "@d-fischer/detect-node": "^3.0.1", "@d-fischer/shared-utils": "^3.6.1", "tslib": "^2.5.0" } }, "sha512-TFMZ/SVW8xyQtyJw9Rcuci4betSKy0qbQn2B5+1+72vVXeO8Qb1pYvuwF5qr0vDGundmSWq7W8r19nVPnXXSvA=="], + + "@d-fischer/rate-limiter": ["@d-fischer/rate-limiter@1.1.0", "", { "dependencies": { "@d-fischer/logger": "^4.2.3", "@d-fischer/shared-utils": "^3.6.3", "tslib": "^2.6.2" } }, "sha512-O5HgACwApyCZhp4JTEBEtbv/W3eAwEkrARFvgWnEsDmXgCMWjIHwohWoHre5BW6IYXFSHBGsuZB/EvNL3942kQ=="], + + "@d-fischer/shared-utils": ["@d-fischer/shared-utils@3.6.4", "", { "dependencies": { "tslib": "^2.4.1" } }, "sha512-BPkVLHfn2Lbyo/ENDBwtEB8JVQ+9OzkjJhUunLaxkw4k59YFlQxUUwlDBejVSFcpQT0t+D3CQlX+ySZnQj0wxw=="], + + "@d-fischer/typed-event-emitter": ["@d-fischer/typed-event-emitter@3.3.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-OvSEOa8icfdWDqcRtjSEZtgJTFOFNgTjje7zaL0+nAtu2/kZtRCSK5wUMrI/aXtCH8o0Qz2vA8UqkhWUTARFQQ=="], + + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@grammyjs/conversations": ["@grammyjs/conversations@2.1.1", "", { "peerDependencies": { "grammy": "^1.20.1" } }, "sha512-hoxqwSkaXDeU7mzXulpk3A4Cmd6UZO3HU4aPoITX5ekSHK7ZcUEmMl7RhKKkqw3z6zVbbAShQreJoVV5/dDSLA=="], + + "@grammyjs/types": ["@grammyjs/types@3.25.0", "", {}, "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="], + + "@twurple/api": ["@twurple/api@8.0.3", "", { "dependencies": { "@d-fischer/cache-decorators": "^4.0.0", "@d-fischer/detect-node": "^3.0.1", "@d-fischer/logger": "^4.2.1", "@d-fischer/rate-limiter": "^1.1.0", "@d-fischer/shared-utils": "^3.6.1", "@d-fischer/typed-event-emitter": "^3.3.1", "@twurple/api-call": "8.0.3", "@twurple/common": "8.0.3", "retry": "^0.13.1", "tslib": "^2.0.3" }, "peerDependencies": { "@twurple/auth": "8.0.3" } }, "sha512-vnqVi9YlNDbCqgpUUvTIq4sDitKCY0dkTw9zPluZvRNqUB1eCsuoaRNW96HQDhKtA9P4pRzwZ8xU7v/1KU2ytg=="], + + "@twurple/api-call": ["@twurple/api-call@8.0.3", "", { "dependencies": { "@d-fischer/shared-utils": "^3.6.1", "@twurple/common": "8.0.3", "tslib": "^2.0.3" } }, "sha512-/5DBTqFjpYB+qqOkkFzoTWE79a7+I8uLXmBIIIYjGoq/CIPxKcHnlemXlU8cQhTr87PVa3th8zJXGYiNkpRx8w=="], + + "@twurple/auth": ["@twurple/auth@8.0.3", "", { "dependencies": { "@d-fischer/logger": "^4.2.1", "@d-fischer/shared-utils": "^3.6.1", "@d-fischer/typed-event-emitter": "^3.3.1", "@twurple/api-call": "8.0.3", "@twurple/common": "8.0.3", "tslib": "^2.0.3" } }, "sha512-Xlv+WNXmGQir4aBXYeRCqdno5XurA6jzYTIovSEHa7FZf3AMHMFqtzW7yqTCUn4iOahfUSA2TIIxmxFM0wis0g=="], + + "@twurple/common": ["@twurple/common@8.0.3", "", { "dependencies": { "@d-fischer/shared-utils": "^3.6.1", "klona": "^2.0.4", "tslib": "^2.0.3" } }, "sha512-JQ2lb5qSFT21Y9qMfIouAILb94ppedLHASq49Fe/AP8oq0k3IC9Q7tX2n6tiMzGWqn+n8MnONUpMSZ6FhulMXA=="], + + "@twurple/eventsub-base": ["@twurple/eventsub-base@8.0.3", "", { "dependencies": { "@d-fischer/logger": "^4.2.1", "@d-fischer/shared-utils": "^3.6.1", "@d-fischer/typed-event-emitter": "^3.3.0", "@twurple/api": "8.0.3", "@twurple/auth": "8.0.3", "@twurple/common": "8.0.3", "tslib": "^2.0.3" } }, "sha512-59G5xJbHWLTSO6NAgwtkHPfIlmdjrABgiEumFnHhNusMbLM9qdA+kLcW5NB2NImNliytl6zZtqY92FInzUE6NA=="], + + "@twurple/eventsub-http": ["@twurple/eventsub-http@8.0.3", "", { "dependencies": { "@d-fischer/logger": "^4.2.1", "@d-fischer/shared-utils": "^3.6.1", "@d-fischer/typed-event-emitter": "^3.3.0", "@twurple/auth": "8.0.3", "@twurple/common": "8.0.3", "@twurple/eventsub-base": "8.0.3", "@types/express-serve-static-core": "^5.1.0", "httpanda": "^0.4.6", "raw-body": "^3.0.2", "tslib": "^2.0.3" }, "peerDependencies": { "@twurple/api": "8.0.3" } }, "sha512-ds8l01GfsIC0hhILepv/UUn/Ix8s0wLg9aGy10xWaG9/Hlfe82NPI8gAg0LYsmlCsOADPwJZSckMTGPJrpw1Iw=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="], + + "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + + "@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="], + + "drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="], + + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + + "grammy": ["grammy@1.41.1", "", { "dependencies": { "@grammyjs/types": "3.25.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ=="], + + "hono": ["hono@4.12.5", "", {}, "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "httpanda": ["httpanda@0.4.7", "", { "dependencies": { "@types/node": "^14.11.2", "tslib": "^2.0.3" } }, "sha512-NieTiR7kfOheL9OeEi6+JKFmJ2JP9ZRqUQ4tiXZ9J+EMMKxApHUQlEM5l4gZ+l67lxE9Er6oigZnujmhlodNCg=="], + + "i18next": ["i18next@25.8.14", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="], + + "miniflare": ["miniflare@4.20260312.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260312.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-pieP2rfXynPT6VRINYaiHe/tfMJ4c5OIhqRlIdLF6iZ9g5xgpEmvimvIgMpgAdDJuFlrLcwDUi8MfAo2R6dt/w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "workerd": ["workerd@1.20260312.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260312.1", "@cloudflare/workerd-darwin-arm64": "1.20260312.1", "@cloudflare/workerd-linux-64": "1.20260312.1", "@cloudflare/workerd-linux-arm64": "1.20260312.1", "@cloudflare/workerd-windows-64": "1.20260312.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-nNpPkw9jaqo79B+iBCOiksx+N62xC+ETIfyzofUEdY3cSOHJg6oNnVSHm7vHevzVblfV76c8Gr0cXHEapYMBEg=="], + + "wrangler": ["wrangler@4.73.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.15.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260312.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260312.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260312.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-VJXsqKDFCp6OtFEHXITSOR5kh95JOknwPY8m7RyQuWJQguSybJy43m4vhoCSt42prutTef7eeuw7L4V4xiynGw=="], + + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + + "httpanda/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="], + + "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + } +} diff --git a/cmd/main.go b/cmd/main.go deleted file mode 100644 index 1d2b9030..00000000 --- a/cmd/main.go +++ /dev/null @@ -1,146 +0,0 @@ -package main - -import ( - "context" - "log" - "os" - "os/signal" - "path/filepath" - "syscall" - "time" - - "github.com/getsentry/sentry-go" - - "entgo.io/ent/dialect/sql" - "github.com/TheZeroSlave/zapsentry" - "github.com/lib/pq" - "github.com/satont/twitch-notifier/ent" - "github.com/satont/twitch-notifier/internal/config" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/message_sender" - "github.com/satont/twitch-notifier/internal/telegram" - "github.com/satont/twitch-notifier/internal/twitch" - "github.com/satont/twitch-notifier/internal/twitch_streams_cheker" - "github.com/satont/twitch-notifier/internal/types" - "github.com/satont/twitch-notifier/pkg/i18n" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" -) - -func createEnt(cfg *config.Config) (*ent.Client, error) { - pgConnectionUrl, err := pq.ParseURL(cfg.DatabaseUrl) - if err != nil { - log.Fatalln(err) - } - - drv, err := sql.Open("postgres", pgConnectionUrl) - if err != nil { - return nil, err - } - - db := drv.DB() - db.SetMaxIdleConns(2) - db.SetMaxOpenConns(10) - db.SetConnMaxLifetime(time.Hour) - return ent.NewClient(ent.Driver(drv)), nil -} - -func main() { - wd, err := os.Getwd() - if err != nil { - log.Fatalln(err) - } - - cfg, err := config.NewConfig(nil) - if err != nil { - log.Fatalln(err) - } - - logger, _ := zap.NewDevelopment() - - if cfg.SentryDsn != "" { - sentryClient, err := sentry.NewClient( - sentry.ClientOptions{ - Dsn: cfg.SentryDsn, - EnableTracing: true, - }, - ) - if err != nil { - log.Fatalln(err) - } - logger = modifyToSentryLogger(logger, sentryClient) - defer sentry.Flush(2 * time.Second) - } - - zap.ReplaceGlobals(logger) - - client, err := createEnt(cfg) - if err != nil { - logger.Sugar().Fatalln("failed opening connection to postgres: %v", err) - } - // Run the auto migration tool. - // if err := client.Schema.Create(context.Background()); err != nil { - // log.Fatalf("failed creating schema resources: %v", err) - // } - - twitchService, err := twitch.NewTwitchService(cfg.TwitchClientId, cfg.TwitchClientSecret) - if err != nil { - logger.Sugar().Fatalln(err) - } - - i18, err := i18n.NewI18n(filepath.Join(wd, "locales")) - if err != nil { - logger.Sugar().Fatalln(err) - } - - services := &types.Services{ - Config: cfg, - Twitch: twitchService, - Chat: db.NewChatEntRepository(client), - Channel: db.NewChannelEntService(client), - Follow: db.NewFollowService(client), - Stream: db.NewStreamEntService(client), - I18N: i18, - } - - ctx, cancel := context.WithCancel(context.Background()) - - tg := telegram.NewTelegram(ctx, cfg.TelegramToken, services) - tg.StartPolling(ctx) - - sender := message_sender.NewMessageSender(tg.Client) - - checker := twitch_streams_cheker.NewTwitchStreamChecker(services, sender, nil) - checker.StartPolling(ctx) - - logger.Sugar().Info("Started") - exitSignal := make(chan os.Signal, 1) - signal.Notify(exitSignal, syscall.SIGINT, syscall.SIGTERM) - <-exitSignal - logger.Sugar().Info("Closing...") - cancel() - _ = client.Close() -} - -func modifyToSentryLogger(log *zap.Logger, client *sentry.Client) *zap.Logger { - cfg := zapsentry.Configuration{ - Level: zapcore.ErrorLevel, // when to send message to sentry - EnableBreadcrumbs: true, // enable sending breadcrumbs to Sentry - BreadcrumbLevel: zapcore.InfoLevel, // at what level should we sent breadcrumbs to sentry - Tags: map[string]string{ - "component": "system", - }, - } - core, err := zapsentry.NewCore(cfg, zapsentry.NewSentryClientFromClient(client)) - - // in case of err it will return noop core. so we can safely attach it - if err != nil { - log.Warn("failed to init zap", zap.Error(err)) - } - - log = zapsentry.AttachCoreToLogger(core, log) - - // to use breadcrumbs feature - create new scope explicitly - // and attach after attaching the core - return log.With(zapsentry.NewScope()) -} diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 00000000..e44c16ec --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'sqlite', + driver: 'd1-http', +}); diff --git a/ent/generate.go b/ent/generate.go deleted file mode 100644 index 8d3fdfdc..00000000 --- a/ent/generate.go +++ /dev/null @@ -1,3 +0,0 @@ -package ent - -//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema diff --git a/ent/migrate/migrations/20230327181912_initial.sql b/ent/migrate/migrations/20230327181912_initial.sql deleted file mode 100644 index 9a284d1b..00000000 --- a/ent/migrate/migrations/20230327181912_initial.sql +++ /dev/null @@ -1,62 +0,0 @@ --- create "chats" table -CREATE TABLE "chats" -( - "id" uuid NOT NULL, - "chat_id" character varying NOT NULL, - "service" character varying NOT NULL, - PRIMARY KEY ("id") -); --- create index "chat_chat_id_service" to table: "chats" -CREATE UNIQUE INDEX "chat_chat_id_service" ON "chats" ("chat_id", "service"); --- create "chat_settings" table -CREATE TABLE "chat_settings" -( - "id" uuid NOT NULL, - "game_change_notification" boolean NOT NULL DEFAULT true, - "offline_notification" boolean NOT NULL DEFAULT true, - "chat_language" character varying NOT NULL DEFAULT 'en', - "chat_id" uuid NOT NULL, - PRIMARY KEY ("id"), - CONSTRAINT "chat_settings_chats_settings" FOREIGN KEY ("chat_id") REFERENCES "chats" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION -); --- create index "chat_settings_chat_id_key" to table: "chat_settings" -CREATE UNIQUE INDEX "chat_settings_chat_id_key" ON "chat_settings" ("chat_id"); --- create "channels" table -CREATE TABLE "channels" -( - "id" uuid NOT NULL, - "channel_id" character varying NOT NULL, - "service" character varying NOT NULL, - "is_live" boolean NOT NULL DEFAULT false, - "title" character varying NULL, - "category" character varying NULL, - "updated_at" timestamptz NULL, - PRIMARY KEY ("id") -); --- create index "channel_channel_id_service" to table: "channels" -CREATE UNIQUE INDEX "channel_channel_id_service" ON "channels" ("channel_id", "service"); --- create "follows" table -CREATE TABLE "follows" -( - "id" uuid NOT NULL, - "channel_id" uuid NOT NULL, - "chat_id" uuid NOT NULL, - PRIMARY KEY ("id"), - CONSTRAINT "follows_channels_follows" FOREIGN KEY ("channel_id") REFERENCES "channels" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION, - CONSTRAINT "follows_chats_follows" FOREIGN KEY ("chat_id") REFERENCES "chats" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION -); --- create index "follow_channel_id_chat_id" to table: "follows" -CREATE UNIQUE INDEX "follow_channel_id_chat_id" ON "follows" ("channel_id", "chat_id"); --- create "streams" table -CREATE TABLE "streams" -( - "id" character varying NOT NULL, - "titles" text[] NULL, - "categories" text[] NULL, - "started_at" timestamptz NULL, - "updated_at" timestamptz NULL, - "ended_at" timestamptz NULL, - "channel_id" uuid NOT NULL, - PRIMARY KEY ("id"), - CONSTRAINT "streams_channels_streams" FOREIGN KEY ("channel_id") REFERENCES "channels" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION -); diff --git a/ent/migrate/migrations/20230401125338_TitleChangeNotification.sql b/ent/migrate/migrations/20230401125338_TitleChangeNotification.sql deleted file mode 100644 index 1310ab25..00000000 --- a/ent/migrate/migrations/20230401125338_TitleChangeNotification.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Modify "chat_settings" table -ALTER TABLE "chat_settings" ADD COLUMN "title_change_notification" boolean NOT NULL DEFAULT false; diff --git a/ent/migrate/migrations/20230506114213_EnableImageInNotification.sql b/ent/migrate/migrations/20230506114213_EnableImageInNotification.sql deleted file mode 100644 index 1347b67a..00000000 --- a/ent/migrate/migrations/20230506114213_EnableImageInNotification.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Modify "chat_settings" table -ALTER TABLE "chat_settings" ADD COLUMN "image_in_notification" boolean NOT NULL DEFAULT true; diff --git a/ent/migrate/migrations/20230521162457_GameAndTitleChangeNotificationSetting.sql b/ent/migrate/migrations/20230521162457_GameAndTitleChangeNotificationSetting.sql deleted file mode 100644 index 4a4a78f1..00000000 --- a/ent/migrate/migrations/20230521162457_GameAndTitleChangeNotificationSetting.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Modify "chat_settings" table -ALTER TABLE "chat_settings" ADD COLUMN "game_and_title_change_notification" boolean NOT NULL DEFAULT false; diff --git a/ent/migrate/migrations/atlas.sum b/ent/migrate/migrations/atlas.sum deleted file mode 100644 index 63de8f2f..00000000 --- a/ent/migrate/migrations/atlas.sum +++ /dev/null @@ -1,5 +0,0 @@ -h1:5dIiqHm4gM6G6fF/AjBxNMcJBJYgK25xeMdiqHT0XMk= -20230327181912_initial.sql h1:L6nniWh3O35p6lwgIG+NrRkkU2n2iG48Be5/zfPAz0I= -20230401125338_TitleChangeNotification.sql h1:9u5qCYBNNL6RHtLdcW691tRhYyli7/pG2kBxYuHs1fA= -20230506114213_EnableImageInNotification.sql h1:cGbFYJRVhaB3swtBPyOmw627aemnTVRd6HByCSJa0OQ= -20230521162457_GameAndTitleChangeNotificationSetting.sql h1:g1bUjbU8y2uGqbiDh89URK3QWv+XBRYC4rwsW6Zu408= diff --git a/ent/schema/channel.go b/ent/schema/channel.go deleted file mode 100644 index 11764532..00000000 --- a/ent/schema/channel.go +++ /dev/null @@ -1,50 +0,0 @@ -package schema - -import ( - "entgo.io/ent" - "entgo.io/ent/schema/edge" - "entgo.io/ent/schema/field" - "entgo.io/ent/schema/index" - "github.com/google/uuid" - "time" -) - -type Channel struct { - ent.Schema -} - -type ChannelService string - -func (c ChannelService) String() string { - return string(c) -} - -const ( - Twitch ChannelService = "twitch" -) - -func (Channel) Fields() []ent.Field { - return []ent.Field{ - field.UUID("id", uuid.UUID{}).Default(uuid.New), - field.String("channel_id"), - field.Enum("service").Values(Twitch.String()), - field.Bool("is_live").Default(false), - field.String("title").Nillable().Optional(), - field.String("category").Nillable().Optional(), - field.Time("updated_at").Nillable().Optional().Default(nil).UpdateDefault(time.Now().UTC), - } -} - -func (Channel) Indexes() []ent.Index { - return []ent.Index{ - index.Fields("channel_id", "service"). - Unique(), - } -} - -func (Channel) Edges() []ent.Edge { - return []ent.Edge{ - edge.To("follows", Follow.Type), - edge.To("streams", Stream.Type), - } -} diff --git a/ent/schema/chat.go b/ent/schema/chat.go deleted file mode 100644 index 3bd9378e..00000000 --- a/ent/schema/chat.go +++ /dev/null @@ -1,50 +0,0 @@ -package schema - -import ( - "entgo.io/ent" - "entgo.io/ent/schema/edge" - "entgo.io/ent/schema/field" - "entgo.io/ent/schema/index" - "github.com/google/uuid" -) - -type Chat struct { - ent.Schema -} - -type ChatService string - -func (c ChatService) String() string { - return string(c) -} - -func (ChatService) Values() []string { - return []string{Telegram.String()} -} - -const ( - Telegram ChatService = "telegram" -) - -func (Chat) Fields() []ent.Field { - return []ent.Field{ - field.UUID("id", uuid.UUID{}).Default(uuid.New), - field.String("chat_id"), - field.Enum("service").Values(Telegram.String()), - } -} - -func (Chat) Indexes() []ent.Index { - return []ent.Index{ - index.Fields("chat_id", "service"). - Unique(), - } -} - -func (Chat) Edges() []ent.Edge { - return []ent.Edge{ - edge.To("settings", ChatSettings.Type).Unique(), - //edge.To("id", ChatSettings.Type).Unique().Required(), - edge.To("follows", Follow.Type), - } -} diff --git a/ent/schema/chat_settings.go b/ent/schema/chat_settings.go deleted file mode 100644 index 808b7bb2..00000000 --- a/ent/schema/chat_settings.go +++ /dev/null @@ -1,49 +0,0 @@ -package schema - -import ( - "entgo.io/ent" - "entgo.io/ent/schema/edge" - "entgo.io/ent/schema/field" - "github.com/google/uuid" -) - -type ChatSettings struct { - ent.Schema -} - -type ChatLanguage string - -const ( - ChatLanguageRu ChatLanguage = "ru" - ChatLanguageEn ChatLanguage = "en" - ChatLanguageUk ChatLanguage = "uk" -) - -func (c ChatLanguage) String() string { - return string(c) -} - -func (ChatSettings) Fields() []ent.Field { - return []ent.Field{ - field.UUID("id", uuid.UUID{}).Default(uuid.New), - field.Bool("game_change_notification").Default(true), - field.Bool("title_change_notification").Default(false), - field.Bool("game_and_title_change_notification").Default(false), - field.Bool("offline_notification").Default(true), - field.Bool("image_in_notification").Default(true), - field.Enum("chat_language"). - Values(ChatLanguageRu.String(), ChatLanguageEn.String(), ChatLanguageUk.String()). - Default(ChatLanguageEn.String()), - field.UUID("chat_id", uuid.UUID{}), - } -} - -func (ChatSettings) Edges() []ent.Edge { - return []ent.Edge{ - edge.From("chat", Chat.Type). - Ref("settings"). - Unique(). - Field("chat_id"). - Required(), - } -} diff --git a/ent/schema/follow.go b/ent/schema/follow.go deleted file mode 100644 index c49e10c2..00000000 --- a/ent/schema/follow.go +++ /dev/null @@ -1,43 +0,0 @@ -package schema - -import ( - "entgo.io/ent" - "entgo.io/ent/schema/edge" - "entgo.io/ent/schema/field" - "entgo.io/ent/schema/index" - "github.com/google/uuid" -) - -type Follow struct { - ent.Schema -} - -func (Follow) Fields() []ent.Field { - return []ent.Field{ - field.UUID("id", uuid.UUID{}).Default(uuid.New), - field.UUID("channel_id", uuid.UUID{}), - field.UUID("chat_id", uuid.UUID{}), - } -} - -func (Follow) Edges() []ent.Edge { - return []ent.Edge{ - edge.From("channel", Channel.Type). - Required(). - Ref("follows"). - Unique(). - Field("channel_id"), - edge.From("chat", Chat.Type). - Required(). - Ref("follows"). - Unique(). - Field("chat_id"), - } -} - -func (Follow) Indexes() []ent.Index { - return []ent.Index{ - index.Fields("channel_id", "chat_id"). - Unique(), - } -} diff --git a/ent/schema/stream.go b/ent/schema/stream.go deleted file mode 100644 index 2d8681b4..00000000 --- a/ent/schema/stream.go +++ /dev/null @@ -1,60 +0,0 @@ -package schema - -import ( - "entgo.io/ent" - "entgo.io/ent/dialect" - "entgo.io/ent/schema/edge" - "entgo.io/ent/schema/field" - "github.com/google/uuid" - "github.com/lib/pq" - "time" -) - -type Stream struct { - ent.Schema -} - -func (Stream) Fields() []ent.Field { - return []ent.Field{ - field.String("id").Unique().Immutable(), - field.UUID("channel_id", uuid.UUID{}), - - field.Other("titles", pq.StringArray{}). - SchemaType(map[string]string{ - dialect.Postgres: "text[]", - dialect.SQLite: "JSON", - }). - Default(pq.StringArray{}). - Optional(), - //SchemaType(map[string]string{ - // "postgres": "text[]", - // "sqlite": "text[]", - //}), - field.Other("categories", pq.StringArray{}). - SchemaType(map[string]string{ - dialect.Postgres: "text[]", - dialect.SQLite: "JSON", - }). - Default(pq.StringArray{}). - Optional(), - - //SchemaType(map[string]string{ - // "postgres": "text[]", - // "sqlite": "text[]", - //}), - - field.Time("started_at").Optional().Default(time.Now().UTC), - field.Time("updated_at").Nillable().Optional().Default(nil).UpdateDefault(time.Now().UTC), - field.Time("ended_at").Nillable().Optional().Default(nil), - } -} - -func (Stream) Edges() []ent.Edge { - return []ent.Edge{ - edge.From("channel", Channel.Type). - Ref("streams"). - Required(). - Unique(). - Field("channel_id"), - } -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index f45960a5..00000000 --- a/internal/config/config.go +++ /dev/null @@ -1,45 +0,0 @@ -package config - -import ( - "github.com/joho/godotenv" - "github.com/kelseyhightower/envconfig" - "os" - "path/filepath" -) - -type Config struct { - TwitchClientId string `required:"true" envconfig:"TWITCH_CLIENTID"` - TwitchClientSecret string `required:"true" envconfig:"TWITCH_CLIENTSECRET"` - TelegramToken string `required:"true" envconfig:"TELEGRAM_TOKEN"` - AppEnv string `required:"true" envconfig:"APP_ENV" default:"development"` - TelegramBotAdmins []string `required:"false" envconfig:"TELEGRAM_BOT_ADMINS"` - DatabaseUrl string `required:"true" envconfig:"DATABASE_URL"` - SentryDsn string `required:"false" envconfig:"SENTRY_DSN"` -} - -var getWd = os.Getwd -var processEnv = envconfig.Process - -func NewConfig(customPath *string) (*Config, error) { - var newCfg Config - - var err error - - wd, err := getWd() - if err != nil { - return nil, err - } - - envPath := filepath.Join(wd, ".env") - - if customPath != nil { - envPath = *customPath - } - - _ = godotenv.Overload(envPath) - if err = processEnv("", &newCfg); err != nil { - return nil, err - } - - return &newCfg, nil -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index 97b37365..00000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package config - -import ( - "os" - "testing" - - "github.com/kelseyhightower/envconfig" - "github.com/stretchr/testify/assert" -) - -var strConfig = ` -TWITCH_CLIENTID=1 -TWITCH_CLIENTSECRET=2 -TELEGRAM_TOKEN=3 -TELEGRAM_BOT_ADMINS=4 -DATABASE_URL=5 -` - -func Test_NewConfig(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - setupEnv func(t *testing.T) (*Config, error) - checkEnv func(t *testing.T, config *Config, err error) - }{ - { - name: "OK", - setupEnv: func(t *testing.T) (*Config, error) { - file, err := os.CreateTemp("", "temp-env") - assert.NoError(t, err) - - filepath := file.Name() - - _, err = file.Write([]byte(strConfig)) - assert.NoError(t, err) - - defer file.Close() - defer os.Remove(filepath) - - config, err := NewConfig(&filepath) - - return config, err - }, - checkEnv: func(t *testing.T, config *Config, err error) { - assert.NoError(t, err) - - assert.Equal(t, "1", config.TwitchClientId) - assert.Equal(t, "2", config.TwitchClientSecret) - assert.Equal(t, "3", config.TelegramToken) - assert.IsType(t, []string{}, config.TelegramBotAdmins) - assert.Contains(t, config.TelegramBotAdmins, "4") - assert.Equal(t, "5", config.DatabaseUrl) - }, - }, - { - name: "os.Getwd() provides some error", - setupEnv: func(t *testing.T) (*Config, error) { - getWd = func() (string, error) { - return "", os.ErrNotExist - } - defer func() { getWd = os.Getwd }() - - config, err := NewConfig(nil) - - return config, err - }, - checkEnv: func(t *testing.T, config *Config, err error) { - assert.Error(t, err) - assert.ErrorIs(t, err, os.ErrNotExist) - assert.Nil(t, config) - }, - }, - { - name: "envconfig.Process() provides some error", - setupEnv: func(t *testing.T) (*Config, error) { - processEnv = func(s string, i interface{}) error { - return os.ErrNotExist - } - defer func() { processEnv = envconfig.Process }() - - config, err := NewConfig(nil) - - return config, err - }, - checkEnv: func(t *testing.T, config *Config, err error) { - assert.Error(t, err) - assert.ErrorIs(t, err, os.ErrNotExist) - assert.Nil(t, config) - }, - }, - } - - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - tt.setupEnv(t) - - cfg, err := tt.setupEnv(t) - tt.checkEnv(t, cfg, err) - }) - } -} diff --git a/internal/db/channel.go b/internal/db/channel.go deleted file mode 100644 index 9c9e531e..00000000 --- a/internal/db/channel.go +++ /dev/null @@ -1,41 +0,0 @@ -package db - -import ( - "context" - - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type ChannelUpdateQuery struct { - IsLive *bool - Category *string - Title *string - - DangerNewChannelId *string -} - -type ChannelInterface interface { - GetByID( - _ context.Context, - id string, - service db_models.ChannelService, - ) (*db_models.Channel, error) - GetByChannelID( - _ context.Context, - channelID string, - service db_models.ChannelService, - ) (*db_models.Channel, error) - Create(_ context.Context, channelID string, service db_models.ChannelService) (*db_models.Channel, error) - Update( - _ context.Context, - channelID string, - service db_models.ChannelService, - updateQuery *ChannelUpdateQuery, - ) (*db_models.Channel, error) - GetByIdOrCreate( - _ context.Context, - channelID string, - service db_models.ChannelService, - ) (*db_models.Channel, error) - GetAll(_ context.Context) ([]*db_models.Channel, error) -} diff --git a/internal/db/channel_impl_ent.go b/internal/db/channel_impl_ent.go deleted file mode 100644 index 91794682..00000000 --- a/internal/db/channel_impl_ent.go +++ /dev/null @@ -1,192 +0,0 @@ -package db - -import ( - "context" - - "github.com/google/uuid" - "github.com/satont/twitch-notifier/ent" - "github.com/satont/twitch-notifier/ent/channel" - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type channelEntService struct { - entClient *ent.Client -} - -func (c *channelEntService) convertEntity(ch *ent.Channel) *db_models.Channel { - return &db_models.Channel{ - ID: ch.ID, - ChannelID: ch.ChannelID, - Service: db_models.ChannelService(ch.Service.String()), - IsLive: ch.IsLive, - Title: ch.Title, - Category: ch.Category, - UpdatedAt: ch.UpdatedAt, - } -} - -func (c *channelEntService) GetByIdOrCreate( - ctx context.Context, - channelID string, - service db_models.ChannelService, -) (*db_models.Channel, error) { - channelService := channel.Service(service.String()) - - ch, err := c.entClient.Channel. - Query(). - Where(channel.ChannelID(channelID), channel.ServiceEQ(channelService)). - First(ctx) - - if ent.IsNotFound(err) { - newChannel, err := c.Create(ctx, channelID, service) - if err != nil { - return nil, err - } - return newChannel, nil - } else if err != nil { - return nil, err - } - - return c.convertEntity(ch), nil -} - -func (c *channelEntService) GetByID( - ctx context.Context, - id string, - service db_models.ChannelService, -) (*db_models.Channel, error) { - channelService := channel.Service(service.String()) - - idUUID, err := uuid.Parse(id) - if err != nil { - return nil, err - } - - ch, err := c.entClient.Channel. - Query(). - Where(channel.ID(idUUID), channel.ServiceEQ(channelService)). - Only(ctx) - - if err != nil { - if ent.IsNotFound(err) { - return nil, db_models.ChannelNotFoundError - } - - return nil, err - } - - if err != nil { - return nil, err - } - - return c.convertEntity(ch), nil -} - -func (c *channelEntService) GetByChannelID( - ctx context.Context, - channelID string, - service db_models.ChannelService, -) (*db_models.Channel, error) { - channelService := channel.Service(service.String()) - - ch, err := c.entClient.Channel. - Query(). - Where(channel.ChannelID(channelID), channel.ServiceEQ(channelService)). - Only(ctx) - - if err != nil { - if ent.IsNotFound(err) { - return nil, db_models.ChannelNotFoundError - } - - return nil, err - } - - if err != nil { - return nil, err - } - - return c.convertEntity(ch), nil -} - -func (c *channelEntService) Create( - ctx context.Context, - channelID string, - service db_models.ChannelService, -) (*db_models.Channel, error) { - channelService := channel.Service(service.String()) - - ch, err := c.entClient.Channel.Create(). - SetChannelID(channelID). - SetService(channelService).Save(ctx) - if err != nil { - return nil, err - } - - return c.convertEntity(ch), nil -} - -func (c *channelEntService) Update( - ctx context.Context, - channelID string, - service db_models.ChannelService, - query *ChannelUpdateQuery, -) (*db_models.Channel, error) { - channelService := channel.Service(service.String()) - ch, err := c.entClient.Channel. - Query(). - Where(channel.ChannelIDIn(channelID), channel.ServiceEQ(channelService)). - Only(ctx) - if err != nil { - return nil, err - } - - updateQuery := c.entClient.Channel.UpdateOne(ch) - - if query.IsLive != nil { - updateQuery.SetIsLive(*query.IsLive) - } - - if query.Category != nil { - updateQuery.SetCategory(*query.Category) - } - - if query.Title != nil { - updateQuery.SetTitle(*query.Title) - } - - if query.DangerNewChannelId != nil { - updateQuery.SetChannelID(*query.DangerNewChannelId) - } - - newChannel, err := updateQuery.Save(context.Background()) - - if err != nil { - return nil, err - } - - return c.convertEntity(newChannel), nil -} - -func (c *channelEntService) GetAll(ctx context.Context) ([]*db_models.Channel, error) { - channels, err := c.entClient.Channel. - Query(). - All(ctx) - - if err != nil { - return nil, err - } - - result := make([]*db_models.Channel, 0, len(channels)) - for _, ch := range channels { - result = append(result, c.convertEntity(ch)) - } - - return result, nil -} - -func NewChannelEntService(entClient *ent.Client) ChannelInterface { - return &channelEntService{ - entClient: entClient, - } -} diff --git a/internal/db/channel_impl_ent_test.go b/internal/db/channel_impl_ent_test.go deleted file mode 100644 index 09f34a8e..00000000 --- a/internal/db/channel_impl_ent_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package db - -import ( - "context" - "strconv" - "testing" - - "github.com/satont/twitch-notifier/internal/db/db_models" - - "github.com/samber/lo" - "github.com/sourcegraph/conc" - "github.com/stretchr/testify/assert" -) - -func TestChannelEntService_GetByIdOrCreate(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - channelService := NewChannelEntService(entClient) - - channel, err := channelService.GetByIdOrCreate(context.Background(), "123", db_models.ChannelServiceTwitch) - assert.NoError(t, err) - assert.Equal(t, "123", channel.ChannelID) - assert.Equal(t, db_models.ChannelServiceTwitch, channel.Service) - assert.False(t, channel.IsLive) - assert.Nil(t, channel.Title) - assert.Nil(t, channel.Category) - assert.Nil(t, channel.UpdatedAt) -} - -func TestChannelEntService_GetByID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - channelService := NewChannelEntService(entClient) - - table := []struct { - name string - channelID string - service db_models.ChannelService - wantErr bool - createChannel bool - }{ - { - name: "channel not found", - channelID: "123", - service: db_models.ChannelServiceTwitch, - wantErr: true, - }, - { - name: "channel found", - channelID: "321", - service: db_models.ChannelServiceTwitch, - wantErr: false, - createChannel: true, - }, - } - - for _, tt := range table { - t.Run( - tt.name, func(t *testing.T) { - if tt.createChannel { - _, err := channelService.Create(context.Background(), tt.channelID, tt.service) - assert.NoError(t, err) - } - - channel, err := channelService.GetByChannelID(context.Background(), tt.channelID, tt.service) - if tt.wantErr { - assert.Error(t, err) - assert.EqualError(t, err, db_models.ChannelNotFoundError.Error()) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.channelID, channel.ChannelID) - assert.Equal(t, tt.service, channel.Service) - } - }, - ) - } -} - -func TestChannelEntService_Create(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - channelService := NewChannelEntService(entClient) - - table := []struct { - name string - channel string - service db_models.ChannelService - wantErr bool - }{ - { - name: "channel should be created", - channel: "123", - service: db_models.ChannelServiceTwitch, - }, - { - name: "should fail create because channel exists", - channel: "123", - service: db_models.ChannelServiceTwitch, - wantErr: true, - }, - } - - for _, tt := range table { - t.Run( - tt.name, func(t *testing.T) { - channel, err := channelService.Create(context.Background(), tt.channel, tt.service) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.channel, channel.ChannelID) - assert.Equal(t, tt.service, channel.Service) - assert.False(t, channel.IsLive) - assert.Nil(t, channel.Title) - assert.Nil(t, channel.Category) - assert.Nil(t, channel.UpdatedAt) - } - }, - ) - } -} - -func TestChannelEntService_Update(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - channelService := NewChannelEntService(entClient) - - table := []struct { - name string - channelID string - service db_models.ChannelService - wantErr bool - createChannel bool - }{ - { - name: "channel should be update", - channelID: "123", - service: db_models.ChannelServiceTwitch, - createChannel: true, - }, - { - name: "should fail update because channel not exists", - channelID: "321", - service: db_models.ChannelServiceTwitch, - wantErr: true, - }, - } - - for _, tt := range table { - t.Run( - tt.name, func(t *testing.T) { - if tt.createChannel { - _, err := channelService.Create(context.Background(), tt.channelID, tt.service) - assert.NoError(t, err) - } - - channel, err := channelService.Update( - context.Background(), - tt.channelID, - tt.service, - &ChannelUpdateQuery{ - IsLive: lo.ToPtr(true), - Category: lo.ToPtr("Category"), - Title: lo.ToPtr("Title"), - }, - ) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.channelID, channel.ChannelID) - assert.Equal(t, tt.service, channel.Service) - assert.True(t, channel.IsLive) - assert.Equal(t, "Title", *channel.Title) - assert.Equal(t, "Category", *channel.Category) - assert.NotNil(t, channel.UpdatedAt) - } - }, - ) - } -} - -func TestChannelEntService_GetAll(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - channelService := NewChannelEntService(entClient) - - ctx := context.Background() - - wg := conc.NewWaitGroup() - for i := 0; i < 5; i++ { - i := i - wg.Go( - func() { - _, err = channelService.Create(ctx, strconv.Itoa(i), db_models.ChannelServiceTwitch) - assert.NoError(t, err) - }, - ) - } - wg.Wait() - - channels, err := channelService.GetAll(ctx) - assert.NoError(t, err) - - assert.Len(t, channels, 5) -} diff --git a/internal/db/chat.go b/internal/db/chat.go deleted file mode 100644 index d5a597c3..00000000 --- a/internal/db/chat.go +++ /dev/null @@ -1,40 +0,0 @@ -package db - -import ( - "context" - - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type ChatUpdateSettingsQuery struct { - GameChangeNotification *bool - OfflineNotification *bool - TitleChangeNotification *bool - GameAndTitleChangeNotification *bool - ImageInNotification *bool - ChatLanguage *db_models.ChatLanguage -} - -type ChatUpdateQuery struct { - Settings *ChatUpdateSettingsQuery -} - -type ChatInterface interface { - GetByID( - _ context.Context, - chatId string, - service db_models.ChatService, - ) (*db_models.Chat, error) - Create( - _ context.Context, - chatId string, - service db_models.ChatService, - ) (*db_models.Chat, error) - Update( - _ context.Context, - chatId string, - service db_models.ChatService, - query *ChatUpdateQuery, - ) (*db_models.Chat, error) - GetAllByService(_ context.Context, service db_models.ChatService) ([]*db_models.Chat, error) -} diff --git a/internal/db/chat_ent_impl.go b/internal/db/chat_ent_impl.go deleted file mode 100644 index cb353be0..00000000 --- a/internal/db/chat_ent_impl.go +++ /dev/null @@ -1,163 +0,0 @@ -package db - -import ( - "context" - - "github.com/satont/twitch-notifier/ent" - "github.com/satont/twitch-notifier/ent/chat" - "github.com/satont/twitch-notifier/ent/chatsettings" - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type chatService struct { - entClient *ent.Client -} - -func (c *chatService) convertEntity(entity *ent.Chat) *db_models.Chat { - settings := &db_models.ChatSettings{ - ID: entity.Edges.Settings.ID, - GameChangeNotification: entity.Edges.Settings.GameChangeNotification, - OfflineNotification: entity.Edges.Settings.OfflineNotification, - TitleChangeNotification: entity.Edges.Settings.TitleChangeNotification, - GameAndTitleChangeNotification: entity.Edges.Settings.GameAndTitleChangeNotification, - ImageInNotification: entity.Edges.Settings.ImageInNotification, - ChatLanguage: db_models.ChatLanguage(entity.Edges.Settings.ChatLanguage), - ChatID: entity.Edges.Settings.ChatID, - } - - return &db_models.Chat{ - ID: entity.ID, - ChatID: entity.ChatID, - Service: db_models.ChatService(entity.Service), - Settings: settings, - } -} - -func (c *chatService) Update( - ctx context.Context, - chatId string, - service db_models.ChatService, - settings *ChatUpdateQuery, -) (*db_models.Chat, error) { - ch, err := c.entClient.Chat. - Query(). - Where(chat.ChatID(chatId), chat.ServiceEQ(chat.Service(service))). - WithSettings(). - Only(ctx) - if err != nil { - return nil, err - } - - if settings.Settings != nil { - updater := ch.Edges.Settings.Update() - - if settings.Settings.ChatLanguage != nil { - updater.SetChatLanguage(chatsettings.ChatLanguage(*settings.Settings.ChatLanguage)) - } - - if settings.Settings.GameChangeNotification != nil { - updater.SetGameChangeNotification(*settings.Settings.GameChangeNotification) - } - - if settings.Settings.OfflineNotification != nil { - updater.SetOfflineNotification(*settings.Settings.OfflineNotification) - } - - if settings.Settings.TitleChangeNotification != nil { - updater.SetTitleChangeNotification(*settings.Settings.TitleChangeNotification) - } - - if settings.Settings.ImageInNotification != nil { - updater.SetImageInNotification(*settings.Settings.ImageInNotification) - } - - if settings.Settings.GameAndTitleChangeNotification != nil { - updater.SetGameAndTitleChangeNotification(*settings.Settings.GameAndTitleChangeNotification) - } - - _, err = updater.Save(ctx) - if err != nil { - return nil, err - } - } - - return c.GetByID(ctx, chatId, service) -} - -func (c *chatService) Create( - ctx context.Context, - chatId string, - service db_models.ChatService, -) (*db_models.Chat, error) { - ch, err := c.entClient.Chat. - Create(). - SetChatID(chatId). - SetService(chat.Service(service.String())). - Save(ctx) - if err != nil { - return nil, err - } - - settings, err := c.entClient.ChatSettings.Create().SetChatID(ch.ID).Save(ctx) - if err != nil { - return nil, err - } - - ch.Edges.Settings = settings - - return c.convertEntity(ch), nil -} - -func (c *chatService) GetByID( - ctx context.Context, - chatId string, - service db_models.ChatService, -) (*db_models.Chat, error) { - ch, err := c.entClient.Chat. - Query(). - Where(chat.ChatID(chatId), chat.ServiceEQ(chat.Service(service))). - WithSettings(). - Only(ctx) - - if err != nil { - if ent.IsNotFound(err) { - return nil, nil - } - - return nil, err - } - - if err != nil { - return nil, err - } - - return c.convertEntity(ch), nil -} - -func (c *chatService) GetAllByService( - ctx context.Context, - service db_models.ChatService, -) ([]*db_models.Chat, error) { - chats, err := c.entClient.Chat. - Query(). - Where(chat.ServiceEQ(chat.Service(service))). - Order(ent.Desc(chat.FieldChatID)). - WithSettings(). - All(ctx) - if err != nil { - return nil, err - } - - var result []*db_models.Chat - for _, ch := range chats { - result = append(result, c.convertEntity(ch)) - } - - return result, nil -} - -func NewChatEntRepository(entClient *ent.Client) ChatInterface { - return &chatService{ - entClient: entClient, - } -} diff --git a/internal/db/chat_ent_impl_test.go b/internal/db/chat_ent_impl_test.go deleted file mode 100644 index ae92382d..00000000 --- a/internal/db/chat_ent_impl_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package db - -import ( - "context" - "strconv" - "testing" - - _ "github.com/mattn/go-sqlite3" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/stretchr/testify/assert" -) - -func TestChatService_GetByID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - chatService := NewChatEntRepository(entClient) - - _, err = chatService.Create( - context.Background(), - "123", - db_models.ChatServiceTelegram, - ) - assert.NoError(t, err) - - table := []struct { - name string - chatID string - wantNil bool - expects struct { - chatID string - service db_models.ChatService - language db_models.ChatLanguage - gameChangeNotification bool - streamStartNotification bool - titleChangeNotification bool - imageInNotification bool - } - }{ - { - name: "Get chat by id", - chatID: "123", - wantNil: false, - expects: struct { - chatID string - service db_models.ChatService - language db_models.ChatLanguage - gameChangeNotification bool - streamStartNotification bool - titleChangeNotification bool - imageInNotification bool - }{ - chatID: "123", - service: db_models.ChatServiceTelegram, - language: db_models.ChatLanguageEn, - gameChangeNotification: true, - streamStartNotification: true, - titleChangeNotification: false, - imageInNotification: true, - }, - }, - { - name: "Should fail if chat not found", - chatID: "321", - wantNil: true, - }, - } - - for _, tt := range table { - t.Run(tt.chatID, func(t *testing.T) { - chat, err := chatService.GetByID( - context.Background(), - tt.chatID, - db_models.ChatServiceTelegram, - ) - - if tt.wantNil { - assert.Nil(t, chat) - } else { - assert.NoError(t, err) - - assert.Equal(t, tt.expects.chatID, chat.ChatID) - assert.Equal(t, tt.expects.service, chat.Service) - assert.Equal(t, tt.expects.language, chat.Settings.ChatLanguage) - assert.Equal(t, tt.expects.gameChangeNotification, chat.Settings.GameChangeNotification) - assert.Equal(t, tt.expects.titleChangeNotification, chat.Settings.TitleChangeNotification) - assert.Equal(t, tt.expects.streamStartNotification, chat.Settings.OfflineNotification) - assert.Equal(t, tt.expects.imageInNotification, chat.Settings.ImageInNotification) - assert.Equal(t, chat.ID, chat.Settings.ChatID) - } - }) - } -} - -func TestChatService_Create(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - chatService := NewChatEntRepository(entClient) - - table := []struct { - name string - chatID string - wantErr bool - }{ - { - name: "Create chat", - chatID: "123", - wantErr: false, - }, - { - name: "Should fail if chat already exists", - chatID: "123", - wantErr: true, - }, - } - - for _, tt := range table { - t.Run(tt.chatID, func(t *testing.T) { - chat, err := chatService.Create( - context.Background(), - tt.chatID, - db_models.ChatServiceTelegram, - ) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.chatID, chat.ChatID) - assert.Equal(t, db_models.ChatServiceTelegram, chat.Service) - assert.NotEmpty(t, chat.Settings.ID) - assert.Equal(t, db_models.ChatLanguageEn, chat.Settings.ChatLanguage) - assert.Equal(t, true, chat.Settings.GameChangeNotification) - assert.Equal(t, true, chat.Settings.OfflineNotification) - assert.Equal(t, false, chat.Settings.TitleChangeNotification) - assert.Equal(t, true, chat.Settings.ImageInNotification) - assert.Equal(t, chat.ID, chat.Settings.ChatID) - } - }) - } -} - -func TestChatService_Update(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - chatService := NewChatEntRepository(entClient) - - table := []struct { - name string - chatID string - wantErr bool - shouldCreate bool - newValues struct { - language db_models.ChatLanguage - gameChangeNotification bool - streamStartNotification bool - titleChaneNotification bool - imageInNotification bool - } - }{ - { - name: "Update chat", - chatID: "123", - wantErr: false, - shouldCreate: true, - newValues: struct { - language db_models.ChatLanguage - gameChangeNotification bool - streamStartNotification bool - titleChaneNotification bool - imageInNotification bool - }{ - language: db_models.ChatLanguageRu, - gameChangeNotification: false, - streamStartNotification: false, - titleChaneNotification: true, - imageInNotification: true, - }, - }, - { - name: "Should fail if chat not found", - chatID: "321", - wantErr: true, - shouldCreate: false, - }, - } - - for _, tt := range table { - t.Run(tt.chatID, func(t *testing.T) { - if tt.shouldCreate { - _, err = chatService.Create( - context.Background(), - tt.chatID, - db_models.ChatServiceTelegram, - ) - assert.NoError(t, err) - } - - newChat, err := chatService.Update( - context.Background(), - tt.chatID, - db_models.ChatServiceTelegram, - &ChatUpdateQuery{ - Settings: &ChatUpdateSettingsQuery{ - GameChangeNotification: lo.ToPtr(false), - OfflineNotification: lo.ToPtr(false), - TitleChangeNotification: lo.ToPtr(true), - ImageInNotification: lo.ToPtr(true), - ChatLanguage: lo.ToPtr(db_models.ChatLanguageRu), - }, - }, - ) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - - assert.Equal(t, tt.chatID, newChat.ChatID) - assert.Equal(t, tt.newValues.language, newChat.Settings.ChatLanguage) - assert.Equal(t, tt.newValues.gameChangeNotification, newChat.Settings.GameChangeNotification) - assert.Equal(t, tt.newValues.streamStartNotification, newChat.Settings.OfflineNotification) - assert.Equal(t, tt.newValues.titleChaneNotification, newChat.Settings.TitleChangeNotification) - assert.Equal(t, tt.newValues.imageInNotification, newChat.Settings.ImageInNotification) - } - }) - } -} - -func TestChatService_GetAllByService(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - ctx := context.Background() - - chatService := NewChatEntRepository(entClient) - - var created []*db_models.Chat - - for i := 0; i < 10; i++ { - newChat, err := chatService.Create( - ctx, - strconv.Itoa(i), - db_models.ChatServiceTelegram, - ) - assert.NoError(t, err) - created = append(created, newChat) - } - - chats, err := chatService.GetAllByService(ctx, db_models.ChatServiceTelegram) - assert.NoError(t, err) - assert.Len(t, chats, 10) - - for _, chat := range chats { - assert.Contains(t, created, chat) - } -} diff --git a/internal/db/db_models/channel.go b/internal/db/db_models/channel.go deleted file mode 100644 index 053b5122..00000000 --- a/internal/db/db_models/channel.go +++ /dev/null @@ -1,34 +0,0 @@ -package db_models - -import ( - "errors" - "github.com/google/uuid" - "time" -) - -var ( - ChannelNotFoundError = errors.New("channel not found") -) - -type ChannelService string - -const ( - ChannelServiceTwitch ChannelService = "twitch" -) - -func (s ChannelService) String() string { - return string(s) -} - -type Channel struct { - ID uuid.UUID `json:"id,omitempty"` - ChannelID string `json:"channel_id,omitempty"` - Service ChannelService `json:"service,omitempty"` - IsLive bool `json:"is_live,omitempty"` - Title *string `json:"title,omitempty"` - Category *string `json:"category,omitempty"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` - - Follows []*Follow `json:"follows,omitempty"` - Streams []*Stream `json:"streams,omitempty"` -} diff --git a/internal/db/db_models/chat.go b/internal/db/db_models/chat.go deleted file mode 100644 index 801d9091..00000000 --- a/internal/db/db_models/chat.go +++ /dev/null @@ -1,58 +0,0 @@ -package db_models - -import ( - "github.com/google/uuid" -) - -type ChatService string - -const ( - ChatServiceTelegram ChatService = "telegram" -) - -func (s ChatService) String() string { - return string(s) -} - -func LanguageExists(l ChatLanguage) bool { - switch l { - case ChatLanguageRu, ChatLanguageEn, ChatLanguageUk: - return true - default: - return false - } -} - -type Chat struct { - ID uuid.UUID `json:"id,omitempty"` - ChatID string `json:"chat_id,omitempty"` - Service ChatService `json:"service,omitempty"` - - Follows []*Follow `json:"follows,omitempty"` - Settings *ChatSettings `json:"settings,omitempty"` -} - -type ChatLanguage string - -var DefaultChatLanguage = ChatLanguageEn - -var ( - ChatLanguageRu ChatLanguage = "ru" - ChatLanguageEn ChatLanguage = "en" - ChatLanguageUk ChatLanguage = "uk" -) - -func (cl ChatLanguage) String() string { - return string(cl) -} - -type ChatSettings struct { - ID uuid.UUID `json:"id,omitempty"` - GameChangeNotification bool `json:"game_change_notification,omitempty"` - TitleChangeNotification bool `json:"title_change_notification,omitempty"` - GameAndTitleChangeNotification bool `json:"game_and_title_change_notification,omitempty"` - OfflineNotification bool `json:"offline_notification,omitempty"` - ChatLanguage ChatLanguage `json:"chat_language,omitempty"` - ChatID uuid.UUID `json:"chat_id,omitempty"` - ImageInNotification bool `json:"image_in_notification,omitempty"` -} diff --git a/internal/db/db_models/follow.go b/internal/db/db_models/follow.go deleted file mode 100644 index 2fca9253..00000000 --- a/internal/db/db_models/follow.go +++ /dev/null @@ -1,20 +0,0 @@ -package db_models - -import ( - "errors" - "github.com/google/uuid" -) - -var ( - FollowAlreadyExistsError = errors.New("follow already exists") - FollowNotFoundError = errors.New("follow not found") -) - -type Follow struct { - ID uuid.UUID `json:"id,omitempty"` - ChannelID uuid.UUID `json:"channel_id,omitempty"` - ChatID uuid.UUID `json:"chat_id,omitempty"` - - Channel *Channel `json:"channel,omitempty"` - Chat *Chat `json:"chat,omitempty"` -} diff --git a/internal/db/db_models/stream.go b/internal/db/db_models/stream.go deleted file mode 100644 index b7b8323d..00000000 --- a/internal/db/db_models/stream.go +++ /dev/null @@ -1,16 +0,0 @@ -package db_models - -import ( - "github.com/google/uuid" - "time" -) - -type Stream struct { - ID string `json:"id,omitempty"` - ChannelID uuid.UUID `json:"channel_id,omitempty"` - Titles []string `json:"titles,omitempty"` - Categories []string `json:"categories,omitempty"` - StartedAt time.Time `json:"started_at,omitempty"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` - EndedAt *time.Time `json:"ended_at,omitempty"` -} diff --git a/internal/db/follow.go b/internal/db/follow.go deleted file mode 100644 index 827bb216..00000000 --- a/internal/db/follow.go +++ /dev/null @@ -1,20 +0,0 @@ -package db - -import ( - "context" - "github.com/google/uuid" - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type FollowInterface interface { - Create(_ context.Context, channelID uuid.UUID, chatID uuid.UUID) (*db_models.Follow, error) - Delete(_ context.Context, id uuid.UUID) error - GetByChatAndChannel( - _ context.Context, - channelID uuid.UUID, - chatID uuid.UUID, - ) (*db_models.Follow, error) - GetByChannelID(_ context.Context, channelID uuid.UUID) ([]*db_models.Follow, error) - GetByChatID(_ context.Context, chatID uuid.UUID, limit, offset int) ([]*db_models.Follow, error) - CountByChatID(_ context.Context, chatID uuid.UUID) (int, error) -} diff --git a/internal/db/follow_ent_impl.go b/internal/db/follow_ent_impl.go deleted file mode 100644 index 17ca005a..00000000 --- a/internal/db/follow_ent_impl.go +++ /dev/null @@ -1,201 +0,0 @@ -package db - -import ( - "context" - - "github.com/google/uuid" - "github.com/satont/twitch-notifier/ent" - "github.com/satont/twitch-notifier/ent/channel" - "github.com/satont/twitch-notifier/ent/chat" - "github.com/satont/twitch-notifier/ent/follow" - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type followService struct { - entClient *ent.Client -} - -func (f *followService) convertEntity(follow *ent.Follow) *db_models.Follow { - convertedFollow := &db_models.Follow{ - ID: follow.ID, - } - - if follow.Edges.Channel != nil { - convertedFollow.ChannelID = follow.Edges.Channel.ID - - convertedFollow.Channel = &db_models.Channel{ - ID: follow.Edges.Channel.ID, - ChannelID: follow.Edges.Channel.ChannelID, - Service: db_models.ChannelService(follow.Edges.Channel.Service), - IsLive: false, - UpdatedAt: follow.Edges.Channel.UpdatedAt, - } - } - - if follow.Edges.Chat != nil { - convertedFollow.ChatID = follow.Edges.Chat.ID - chatSettings := &db_models.ChatSettings{} - - if follow.Edges.Chat.Edges.Settings != nil { - chatSettings.ID = follow.Edges.Chat.Edges.Settings.ID - chatSettings.ChatID = follow.Edges.Chat.Edges.Settings.ChatID - chatSettings.ChatLanguage = db_models.ChatLanguage( - follow.Edges.Chat.Edges.Settings.ChatLanguage, - ) - chatSettings.GameChangeNotification = follow.Edges.Chat.Edges.Settings.GameChangeNotification - chatSettings.TitleChangeNotification = follow.Edges.Chat.Edges.Settings.TitleChangeNotification - chatSettings.OfflineNotification = follow.Edges.Chat.Edges.Settings.OfflineNotification - chatSettings.ImageInNotification = follow.Edges.Chat.Edges.Settings.ImageInNotification - chatSettings.GameAndTitleChangeNotification = follow.Edges.Chat.Edges.Settings.GameAndTitleChangeNotification - } - - convertedFollow.Chat = &db_models.Chat{ - ID: follow.Edges.Chat.ID, - ChatID: follow.Edges.Chat.ChatID, - Service: db_models.ChatService(follow.Edges.Chat.Service), - Settings: chatSettings, - } - } - - return convertedFollow -} - -func (f *followService) Create( - ctx context.Context, - channelID uuid.UUID, - chatID uuid.UUID, -) (*db_models.Follow, error) { - _, err := f.entClient.Follow. - Create(). - SetChatID(chatID). - SetChannelID(channelID). - Save(ctx) - - if ent.IsConstraintError(err) { - return nil, db_models.FollowAlreadyExistsError - } else if err != nil { - return nil, err - } - - return f.GetByChatAndChannel(ctx, channelID, chatID) -} - -func (f *followService) Delete(ctx context.Context, followID uuid.UUID) error { - err := f.entClient.Follow. - DeleteOneID(followID). - Exec(ctx) - if err != nil { - return err - } - - return nil -} - -func (f *followService) GetByChatAndChannel( - ctx context.Context, - channelID uuid.UUID, - chatID uuid.UUID, -) (*db_models.Follow, error) { - fol, err := f.entClient.Follow. - Query(). - Where(follow.ChannelID(channelID), follow.ChatID(chatID)). - WithChannel(). - WithChat( - func(query *ent.ChatQuery) { - query.WithSettings() - }, - ). - First(ctx) - - if err != nil && ent.IsNotFound(err) { - return nil, db_models.FollowNotFoundError - } else if err != nil { - return nil, err - } - - if fol == nil { - return nil, nil - } - - return f.convertEntity(fol), err -} - -func (f *followService) GetByChannelID( - ctx context.Context, - channelID uuid.UUID, -) ([]*db_models.Follow, error) { - follows, err := f.entClient.Follow. - Query(). - Where(follow.HasChannelWith(channel.IDEQ(channelID))). - WithChannel(). - WithChat( - func(query *ent.ChatQuery) { - query.WithSettings() - }, - ). - All(ctx) - - if err != nil { - return nil, err - } - - result := make([]*db_models.Follow, 0, len(follows)) - for _, foll := range follows { - if foll != nil { - result = append(result, f.convertEntity(foll)) - } - } - - return result, nil -} - -func (f *followService) GetByChatID( - ctx context.Context, - chatID uuid.UUID, - limit, - offset int, -) ([]*db_models.Follow, error) { - query := f.entClient.Follow. - Query(). - Where(follow.HasChatWith(chat.IDEQ(chatID))). - WithChat( - func(query *ent.ChatQuery) { - query.WithSettings() - }, - ). - WithChannel(). - Order(ent.Desc(follow.FieldChannelID)) - - if limit > 0 { - query = query.Limit(limit) - } - query.Offset(offset) - - follows, err := query.All(ctx) - - if err != nil { - return nil, err - } - - result := make([]*db_models.Follow, len(follows)) - for i, foll := range follows { - result[i] = f.convertEntity(foll) - } - - return result, nil -} - -func (f *followService) CountByChatID(_ context.Context, chatID uuid.UUID) (int, error) { - count, err := f.entClient.Follow.Query(). - Where(follow.ChatIDEQ(chatID)). - Count(context.Background()) - if err != nil { - return 0, err - } - - return count, nil -} - -func NewFollowService(entClient *ent.Client) FollowInterface { - return &followService{entClient: entClient} -} diff --git a/internal/db/follow_ent_test.go b/internal/db/follow_ent_test.go deleted file mode 100644 index 1d8d48a2..00000000 --- a/internal/db/follow_ent_test.go +++ /dev/null @@ -1,269 +0,0 @@ -package db - -import ( - "context" - "fmt" - "github.com/google/uuid" - db_models2 "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/stretchr/testify/assert" - "testing" -) - -func TestFollowService_Create(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - defer teardownTest(entClient) - - ctx := context.Background() - - chService := NewChatEntRepository(entClient) - channelsService := NewChannelEntService(entClient) - service := NewFollowService(entClient) - - newChat, err := chService.Create(ctx, "1", db_models2.ChatServiceTelegram) - assert.NoError(t, err) - - newChannel, err := channelsService.Create(ctx, "1", db_models2.ChannelServiceTwitch) - assert.NoError(t, err) - - table := []struct { - name string - chatID uuid.UUID - channelID uuid.UUID - wantErr bool - }{ - { - name: "Create follow", - chatID: newChat.ID, - channelID: newChannel.ID, - wantErr: false, - }, - { - name: "Should fail if follow already exists", - chatID: newChat.ID, - channelID: newChannel.ID, - wantErr: true, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - f, err := service.Create(ctx, tt.channelID, tt.chatID) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, newChannel.ID, f.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, newChat.ID, f.ChatID, "Expects chat_id to be equal.") - } - }) - } -} - -func TestFollowService_Delete(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - defer teardownTest(entClient) - - ctx := context.Background() - - chService := NewChatEntRepository(entClient) - channelsService := NewChannelEntService(entClient) - service := NewFollowService(entClient) - - newChat, err := chService.Create(ctx, "1", db_models2.ChatServiceTelegram) - assert.NoError(t, err) - - newChannel, err := channelsService.Create(ctx, "1", db_models2.ChannelServiceTwitch) - assert.NoError(t, err) - - foll, err := service.Create(ctx, newChannel.ID, newChat.ID) - - table := []struct { - name string - id uuid.UUID - wantErr bool - }{ - { - name: "Delete follow", - id: foll.ID, - wantErr: false, - }, - { - name: "Should fail if follow does not exist", - id: uuid.New(), - wantErr: true, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - err := service.Delete(ctx, tt.id) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestFollowService_GetByChatAndChannel(t *testing.T) { - entClient, err := setupTest() - assert.NoError(t, err) - defer teardownTest(entClient) - - ctx := context.Background() - - chService := NewChatEntRepository(entClient) - channelsService := NewChannelEntService(entClient) - service := NewFollowService(entClient) - - newChat, err := chService.Create(ctx, "1", db_models2.ChatServiceTelegram) - assert.NoError(t, err) - - newChannel, err := channelsService.Create(ctx, "1", db_models2.ChannelServiceTwitch) - assert.NoError(t, err) - - _, err = service.Create(ctx, newChannel.ID, newChat.ID) - assert.NoError(t, err) - - table := []struct { - name string - chatID uuid.UUID - channelID uuid.UUID - wantNil bool - wantErr bool - }{ - { - name: "Get follow", - chatID: newChat.ID, - channelID: newChannel.ID, - wantNil: false, - }, - { - name: "Should fail if follow does not exist", - chatID: uuid.New(), - channelID: uuid.New(), - wantNil: true, - wantErr: true, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - f, err := service.GetByChatAndChannel(ctx, tt.channelID, tt.chatID) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - - } - if tt.wantNil { - assert.Nil(t, f) - } else { - assert.Equal(t, newChannel.ID, f.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, newChat.ID, f.ChatID, "Expects chat_id to be equal.") - } - }) - } -} - -func TestFollowService_GetByChannelID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - defer teardownTest(entClient) - - ctx := context.Background() - - chService := NewChatEntRepository(entClient) - channelsService := NewChannelEntService(entClient) - service := NewFollowService(entClient) - - newChat, err := chService.Create(ctx, "1", db_models2.ChatServiceTelegram) - assert.NoError(t, err) - - channelsIds := make([]uuid.UUID, 0) - - for i := 0; i < 5; i++ { - ch, err := channelsService.Create( - ctx, - fmt.Sprintf("%v", i), - db_models2.ChannelServiceTwitch, - ) - assert.NoError(t, err) - channelsIds = append(channelsIds, ch.ID) - } - - for _, channelID := range channelsIds { - f, err := service.Create(ctx, channelID, newChat.ID) - assert.NoError(t, err) - assert.Equal(t, channelID, f.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, newChat.ID, f.ChatID, "Expects chat_id to be equal.") - } - - for _, channelID := range channelsIds { - follows, err := service.GetByChannelID(ctx, channelID) - assert.NoError(t, err) - - for _, foll := range follows { - assert.Equal(t, channelID, foll.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, newChat.ID, foll.ChatID, "Expects chat_id to be equal.") - } - } -} - -func TestFollowService_GetByChatID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - defer teardownTest(entClient) - - ctx := context.Background() - - chService := NewChatEntRepository(entClient) - channelsService := NewChannelEntService(entClient) - service := NewFollowService(entClient) - - newChat, err := chService.Create(ctx, "1", db_models2.ChatServiceTelegram) - assert.NoError(t, err) - - channelsIds := make([]uuid.UUID, 0) - - for i := 0; i < 5; i++ { - ch, err := channelsService.Create( - ctx, - fmt.Sprintf("%v", i), - db_models2.ChannelServiceTwitch, - ) - assert.NoError(t, err) - channelsIds = append(channelsIds, ch.ID) - } - - for _, channelID := range channelsIds { - f, err := service.Create(ctx, channelID, newChat.ID) - assert.NoError(t, err) - assert.Equal(t, channelID, f.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, newChat.ID, f.ChatID, "Expects chat_id to be equal.") - } - - follows, err := service.GetByChatID(ctx, newChat.ID, 0, 0) - assert.NoError(t, err) - assert.Len(t, follows, 5) - - for _, foll := range follows { - assert.Equal(t, newChat.ID, foll.ChatID, "Expects chat_id to be equal.") - } - - followsPaginated, err := service.GetByChatID(ctx, newChat.ID, 0, 2) - assert.NoError(t, err) - assert.Len(t, followsPaginated, 3) -} diff --git a/internal/db/mock_db.go b/internal/db/mock_db.go deleted file mode 100644 index d29e7be8..00000000 --- a/internal/db/mock_db.go +++ /dev/null @@ -1,26 +0,0 @@ -package db - -import ( - "context" - "fmt" - "github.com/satont/twitch-notifier/ent" - "time" -) - -func setupTest() (*ent.Client, error) { - source := fmt.Sprintf("file:tests%v?mode=memory&cache=shared&_fk=1", time.Now().UnixMicro()) - - entClient, err := ent.Open("sqlite3", source) - if err != nil { - return nil, err - } - if err := entClient.Schema.Create(context.Background()); err != nil { - fmt.Println(err) - return nil, err - } - return entClient, nil -} - -func teardownTest(entClient *ent.Client) { - _ = entClient.Close() -} diff --git a/internal/db/stream.go b/internal/db/stream.go deleted file mode 100644 index 51f0357e..00000000 --- a/internal/db/stream.go +++ /dev/null @@ -1,39 +0,0 @@ -package db - -import ( - "context" - "github.com/google/uuid" - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type StreamUpdateQuery struct { - StreamID string - IsLive *bool - Category *string - Title *string -} - -type StreamInterface interface { - GetByID(_ context.Context, streamId string) (*db_models.Stream, error) - - GetLatestByChannelID( - _ context.Context, - channelEntityID uuid.UUID, - ) (*db_models.Stream, error) - GetManyByChannelID( - _ context.Context, - channelEntityID uuid.UUID, - limit int, - ) ([]*db_models.Stream, error) - - UpdateOneByStreamID( - _ context.Context, - streamID string, - updateQuery *StreamUpdateQuery, - ) (*db_models.Stream, error) - CreateOneByChannelID( - _ context.Context, - channelEntityID uuid.UUID, - updateQuery *StreamUpdateQuery, - ) (*db_models.Stream, error) -} diff --git a/internal/db/stream_impl_ent.go b/internal/db/stream_impl_ent.go deleted file mode 100644 index 27e3dd21..00000000 --- a/internal/db/stream_impl_ent.go +++ /dev/null @@ -1,161 +0,0 @@ -package db - -import ( - "context" - "errors" - "github.com/google/uuid" - "github.com/lib/pq" - "github.com/satont/twitch-notifier/ent" - "github.com/satont/twitch-notifier/ent/channel" - "github.com/satont/twitch-notifier/ent/stream" - "github.com/satont/twitch-notifier/internal/db/db_models" - "time" -) - -type StreamEntService struct { - entClient *ent.Client -} - -func (s *StreamEntService) convertEntity(stream *ent.Stream) *db_models.Stream { - return &db_models.Stream{ - ID: stream.ID, - ChannelID: stream.ChannelID, - Titles: stream.Titles, - Categories: stream.Categories, - StartedAt: stream.StartedAt, - UpdatedAt: stream.UpdatedAt, - EndedAt: stream.EndedAt, - } -} - -func (s *StreamEntService) GetByID(ctx context.Context, streamID string) (*db_models.Stream, error) { - str, err := s.entClient.Stream.Query().Where(stream.IDEQ(streamID)).Only(ctx) - if err != nil { - if ent.IsNotFound(err) { - return nil, nil - } else { - return nil, err - } - } - - return s.convertEntity(str), nil -} - -func (s *StreamEntService) GetLatestByChannelID( - ctx context.Context, - channelEntityID uuid.UUID, -) (*db_models.Stream, error) { - str, err := s.entClient.Stream. - Query(). - Where(stream.ChannelIDEQ(channelEntityID), stream.EndedAtIsNil()). - Order(ent.Desc(stream.FieldStartedAt)). - First(ctx) - if err != nil { - if ent.IsNotFound(err) { - return nil, nil - } else { - return nil, err - } - } - - return s.convertEntity(str), nil -} - -func (s *StreamEntService) GetManyByChannelID( - ctx context.Context, - channelEntityID uuid.UUID, - limit int, -) ([]*db_models.Stream, error) { - streams, err := s.entClient.Stream. - Query(). - Where(stream.HasChannelWith(channel.IDEQ(channelEntityID))). - Order(ent.Desc(stream.FieldStartedAt)). - Limit(limit). - All(ctx) - - if err != nil { - return nil, err - } - - convertedStreams := make([]*db_models.Stream, len(streams)) - for i, str := range streams { - convertedStreams[i] = s.convertEntity(str) - } - - return convertedStreams, err -} - -func (s *StreamEntService) UpdateOneByStreamID( - ctx context.Context, - streamID string, - updateQuery *StreamUpdateQuery, -) (*db_models.Stream, error) { - str, err := s.GetByID(ctx, streamID) - if err != nil { - return nil, err - } - if str == nil { - return nil, errors.New("stream not found") - } - - query := s.entClient.Stream.UpdateOneID(str.ID) - - if updateQuery.IsLive != nil && *updateQuery.IsLive { - query.SetStartedAt(time.Now().UTC()) - } - - if updateQuery.IsLive != nil && !*updateQuery.IsLive { - query.SetEndedAt(time.Now().UTC()) - } - - if updateQuery.Category != nil { - str.Categories = append(str.Categories, *updateQuery.Category) - query.SetCategories(str.Categories) - } - - if updateQuery.Title != nil { - str.Titles = append(str.Titles, *updateQuery.Title) - query.SetTitles(str.Titles) - } - - newStream, err := query.Save(ctx) - if err != nil { - return nil, err - } - - return s.convertEntity(newStream), nil -} - -func (s *StreamEntService) CreateOneByChannelID( - ctx context.Context, - channelEntityID uuid.UUID, - data *StreamUpdateQuery, -) (*db_models.Stream, error) { - query := s.entClient.Stream.Create() - - query.SetChannelID(channelEntityID) - - query.SetStartedAt(time.Now().UTC()) - query.SetID(data.StreamID) - - if data.Title != nil { - query.SetTitles(pq.StringArray{*data.Title}) - } - - if data.Category != nil { - query.SetCategories(pq.StringArray{*data.Category}) - } - - str, err := query.Save(ctx) - if err != nil { - return nil, err - } - - return s.convertEntity(str), nil -} - -func NewStreamEntService(entClient *ent.Client) *StreamEntService { - return &StreamEntService{ - entClient: entClient, - } -} diff --git a/internal/db/stream_impl_ent_test.go b/internal/db/stream_impl_ent_test.go deleted file mode 100644 index 8fc7e384..00000000 --- a/internal/db/stream_impl_ent_test.go +++ /dev/null @@ -1,293 +0,0 @@ -package db - -import ( - "context" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/stretchr/testify/assert" - "testing" -) - -func TestStreamEntService_GetByID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - defer teardownTest(entClient) - - ctx := context.Background() - - channelsService := NewChannelEntService(entClient) - service := NewStreamEntService(entClient) - - newChannel, err := channelsService.Create(ctx, "1", db_models.ChannelServiceTwitch) - assert.NoError(t, err) - assert.Equal(t, "1", newChannel.ChannelID, "Expects channel_id to be equal.") - - _, err = channelsService.Create(ctx, "2", db_models.ChannelServiceTwitch) - assert.NoError(t, err) - - table := []struct { - name string - channelID string - wantNil bool - create bool - streamID string - }{ - { - name: "Get stream by id", - channelID: newChannel.ChannelID, - wantNil: false, - create: true, - streamID: "1", - }, - { - name: "Should return nil if stream not found", - channelID: "2", - wantNil: true, - create: false, - streamID: "2", - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - if tt.create { - _, err = service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - IsLive: nil, - Category: nil, - Title: nil, - StreamID: tt.streamID, - }) - assert.NoError(t, err) - } - - stream, err := service.GetByID(ctx, tt.streamID) - if tt.wantNil { - assert.Nil(t, stream) - } else { - assert.NoError(t, err) - assert.Equal(t, newChannel.ID, stream.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, tt.streamID, stream.ID, "Expects stream_id to be equal.") - } - }) - } -} - -func TestStreamEntService_GetLatestByChannelID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - defer teardownTest(entClient) - - ctx := context.Background() - - channelsService := NewChannelEntService(entClient) - service := NewStreamEntService(entClient) - - newChannel, err := channelsService.Create(ctx, "1", db_models.ChannelServiceTwitch) - assert.NoError(t, err) - - table := []struct { - name string - channelID string - wantNil bool - wantedStreamID string - clearTable bool - before func() - }{ - { - name: "Get latest stream by channel id", - channelID: newChannel.ChannelID, - wantNil: false, - wantedStreamID: "321", - clearTable: true, - before: func() { - _, _ = service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - StreamID: "321", - IsLive: lo.ToPtr(true), - Category: lo.ToPtr("Category"), - Title: lo.ToPtr("Title"), - }) - }, - }, - { - name: "Should return nil if stream not found", - channelID: newChannel.ChannelID, - wantNil: true, - wantedStreamID: "2", - clearTable: true, - before: func() {}, - }, - { - name: "Should return correct stream", - channelID: newChannel.ChannelID, - wantNil: false, - before: func() { - _, _ = service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - StreamID: "321", - IsLive: lo.ToPtr(false), - Category: lo.ToPtr("Category"), - Title: lo.ToPtr("Title"), - }) - _, _ = service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - StreamID: "4321", - IsLive: lo.ToPtr(true), - Category: lo.ToPtr("Category"), - Title: lo.ToPtr("Title"), - }) - }, - wantedStreamID: "4321", - clearTable: true, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - tt.before() - - stream, err := service.GetLatestByChannelID(ctx, newChannel.ID) - assert.NoError(t, err) - - if tt.wantNil { - assert.Nil(t, stream) - } else { - assert.NotNil(t, stream) - assert.Equal(t, newChannel.ID, stream.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, tt.wantedStreamID, stream.ID, "Expects stream_id to be equal.") - assert.Nil(t, stream.EndedAt, "Expects is_live to be equal.") - assert.Contains(t, stream.Categories, "Category", "Expects category to be equal.") - assert.Contains(t, stream.Titles, "Title", "Expects title to be equal.") - } - - if tt.clearTable { - _, err = entClient.Stream.Delete().Exec(ctx) - assert.NoError(t, err) - } - }) - } - -} - -func TestStreamEntService_GetManyByChannelID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - - defer teardownTest(entClient) - - ctx := context.Background() - - channelsService := NewChannelEntService(entClient) - service := NewStreamEntService(entClient) - - newChannel, err := channelsService.Create(ctx, "1", db_models.ChannelServiceTwitch) - - assert.NoError(t, err) - - _, err = service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - StreamID: "123", - IsLive: lo.ToPtr(true), - Category: nil, - Title: nil, - }) - assert.NoError(t, err) - - _, err = service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - StreamID: "321", - IsLive: lo.ToPtr(true), - Category: lo.ToPtr("Category"), - Title: nil, - }) - assert.NoError(t, err) - - streams, err := service.GetManyByChannelID(ctx, newChannel.ID, 100) - assert.NoError(t, err) - - assert.Len(t, streams, 2, "Expects streams length to be equal.") - assert.Equal(t, "321", streams[0].ID, "Expects stream_id to be equal.") - assert.Contains(t, streams[0].Categories, "Category", "Expects category to be equal.") - assert.Equal(t, "123", streams[1].ID, "Expects stream_id to be equal.") - - _, err = entClient.Stream.Delete().Exec(ctx) - assert.NoError(t, err) - - streams, err = service.GetManyByChannelID(ctx, newChannel.ID, 100) - assert.NoError(t, err) - assert.Len(t, streams, 0, "Expects streams length to be equal.") -} - -func TestStreamEntService_UpdateOneByStreamID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - - defer teardownTest(entClient) - - ctx := context.Background() - - channelsService := NewChannelEntService(entClient) - service := NewStreamEntService(entClient) - - newChannel, err := channelsService.Create(ctx, "1", db_models.ChannelServiceTwitch) - assert.NoError(t, err) - - _, err = service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - StreamID: "123", - IsLive: lo.ToPtr(true), - Category: nil, - Title: nil, - }) - assert.NoError(t, err) - - newStream, err := service.UpdateOneByStreamID(ctx, "123", &StreamUpdateQuery{ - IsLive: lo.ToPtr(false), - Title: lo.ToPtr("Title"), - Category: lo.ToPtr("Category"), - }) - assert.NoError(t, err) - - assert.Equal(t, "123", newStream.ID, "Expects stream_id to be equal.") - assert.Equal(t, newChannel.ID, newStream.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, "Title", newStream.Titles[0], "Expects title to be equal.") - assert.Equal(t, "Category", newStream.Categories[0], "Expects category to be equal.") - assert.NotNil(t, newStream.EndedAt, "Expects ended_at to be not nil.") - - stream, err := service.UpdateOneByStreamID(ctx, "321", &StreamUpdateQuery{}) - assert.Error(t, err) - assert.Nil(t, stream) -} - -func TestStreamEntService_CreateOneByChannelID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - - defer teardownTest(entClient) - - ctx := context.Background() - - channelsService := NewChannelEntService(entClient) - service := NewStreamEntService(entClient) - - newChannel, err := channelsService.Create(ctx, "1", db_models.ChannelServiceTwitch) - assert.NoError(t, err) - - newStream, err := service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - StreamID: "123", - IsLive: lo.ToPtr(true), - Category: nil, - Title: nil, - }) - assert.NoError(t, err) - - assert.Equal(t, "123", newStream.ID, "Expects stream_id to be equal.") - assert.Equal(t, newChannel.ID, newStream.ChannelID, "Expects channel_id to be equal.") - assert.Nil(t, newStream.EndedAt, "Expects ended_at to be nil.") - assert.NotNil(t, newStream.StartedAt, "Expects started_at to be not nil.") - assert.Len(t, newStream.Categories, 0, "Expects categories length to be equal.") -} diff --git a/internal/message_sender/message_sender.go b/internal/message_sender/message_sender.go deleted file mode 100644 index e177f143..00000000 --- a/internal/message_sender/message_sender.go +++ /dev/null @@ -1,36 +0,0 @@ -package message_sender - -import ( - "context" - - "github.com/mr-linch/go-tg" - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type MessageOpts struct { - Text string - ImageURL string - ParseMode *tg.ParseMode - Buttons [][]KeyboardButton - SkipButtons bool -} - -type KeyboardButton struct { - // kostil chto bi skipnut knopki v gruppah - SkipInGroup bool - - Text string `json:"text"` - CallbackData string `json:"callback_data,omitempty"` - // this is not needed currently - // URL string `json:"url,omitempty"` - // WebApp *WebAppInfo `json:"web_app,omitempty"` - // LoginURL *LoginURL `json:"login_url,omitempty"` - // SwitchInlineQuery string `json:"switch_inline_query,omitempty"` - // SwitchInlineQueryCurrentChat string `json:"switch_inline_query_current_chat,omitempty"` - // CallbackGame *CallbackGame `json:"callback_game,omitempty"` - // Pay bool `json:"pay,omitempty"` -} - -type MessageSenderInterface interface { - SendMessage(ctx context.Context, chat *db_models.Chat, opts *MessageOpts) error -} diff --git a/internal/message_sender/message_sender_impl.go b/internal/message_sender/message_sender_impl.go deleted file mode 100644 index 040a6e07..00000000 --- a/internal/message_sender/message_sender_impl.go +++ /dev/null @@ -1,89 +0,0 @@ -package message_sender - -import ( - "context" - "strconv" - - "github.com/mr-linch/go-tg" - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type MessageSender struct { - telegram *tg.Client -} - -func (m *MessageSender) SendMessage(ctx context.Context, chat *db_models.Chat, opts *MessageOpts) error { - if chat.Service == db_models.ChatServiceTelegram { - chatId, err := strconv.Atoi(chat.ChatID) - if err != nil { - return err - } - - var keyboard *tg.InlineKeyboardMarkup - if opts.Buttons != nil && len(opts.Buttons) > 0 { - keyboard = &tg.InlineKeyboardMarkup{ - InlineKeyboard: make([][]tg.InlineKeyboardButton, 0, len(opts.Buttons)), - } - - for _, row := range opts.Buttons { - var buttons []tg.InlineKeyboardButton - for _, button := range row { - if button.SkipInGroup && chatId < 0 { - continue - } - - buttons = append( - buttons, tg.InlineKeyboardButton{ - Text: button.Text, - CallbackData: button.CallbackData, - }, - ) - } - - if len(buttons) != 0 { - keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, buttons) - } - } - } - - if opts.ImageURL != "" { - query := m.telegram. - SendPhoto(tg.ChatID(chatId), tg.FileArg{URL: opts.ImageURL}). - Caption(opts.Text) - - if opts.ParseMode != nil { - query = query.ParseMode(*opts.ParseMode) - } - - if keyboard != nil && keyboard.InlineKeyboard != nil && len(keyboard.InlineKeyboard) > 0 { - query = query.ReplyMarkup(keyboard) - } - - return query.DoVoid(ctx) - } else { - query := m.telegram. - SendMessage(tg.ChatID(chatId), opts.Text). - LinkPreviewOptions(tg.LinkPreviewOptions{ - IsDisabled: true, - }) - - if keyboard != nil && keyboard.InlineKeyboard != nil && len(keyboard.InlineKeyboard) > 0 { - query = query.ReplyMarkup(keyboard) - } - - if opts.ParseMode != nil { - query = query.ParseMode(*opts.ParseMode) - } - - return query.DoVoid(ctx) - } - } - - return nil -} - -func NewMessageSender(telegram *tg.Client) MessageSenderInterface { - return &MessageSender{ - telegram: telegram, - } -} diff --git a/internal/message_sender/message_sender_impl_test.go b/internal/message_sender/message_sender_impl_test.go deleted file mode 100644 index 1b0283bc..00000000 --- a/internal/message_sender/message_sender_impl_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package message_sender - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/mr-linch/go-tg" - "github.com/satont/twitch-notifier/internal/db/db_models" - - "github.com/satont/twitch-notifier/internal/test_utils" - "github.com/stretchr/testify/assert" -) - -func TestMessageSender_SendMessage(t *testing.T) { - t.Parallel() - - chat := &db_models.Chat{ - ChatID: "-123", - Service: db_models.ChatServiceTelegram, - } - - table := []struct { - name string - chat *db_models.Chat - opts *MessageOpts - createServer func(*testing.T) *httptest.Server - }{ - { - name: "should call send message method", - chat: chat, - opts: &MessageOpts{ - Text: "test", - }, - createServer: func(t *testing.T) *httptest.Server { - return httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, "test", query.Get("text")) - assert.Equal(t, "-123", query.Get("chat_id")) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - }, - ), - ) - }, - }, - { - name: "should call send photo method", - chat: chat, - opts: &MessageOpts{ - Text: "test photo", - ImageURL: "https://example.com/image.jpg", - }, - createServer: func(t *testing.T) *httptest.Server { - return httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, "test photo", query.Get("caption")) - assert.Equal(t, "https://example.com/image.jpg", query.Get("photo")) - assert.Equal(t, "-123", query.Get("chat_id")) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendPhoto", test_utils.TelegramClientToken), - r.URL.Path, - ) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - }, - ), - ) - }, - }, - { - name: "should call send message method with parse mode", - chat: chat, - opts: &MessageOpts{ - Text: "test md", - ParseMode: &tg.MD, - }, - createServer: func(t *testing.T) *httptest.Server { - return httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, "test md", query.Get("text")) - assert.Equal(t, "-123", query.Get("chat_id")) - assert.Equal(t, "Markdown", query.Get("parse_mode")) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - }, - ), - ) - }, - }, - { - name: "should send keyboard buttons", - chat: chat, - opts: &MessageOpts{ - Text: "test buttons", - Buttons: [][]KeyboardButton{ - { - KeyboardButton{Text: "click me", CallbackData: "click"}, - }, - }, - }, - createServer: func(t *testing.T) *httptest.Server { - return httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, "test buttons", query.Get("text")) - assert.Equal(t, "-123", query.Get("chat_id")) - - keyboard := map[string]any{} - - err = json.Unmarshal([]byte(query.Get("reply_markup")), &keyboard) - assert.NoError(t, err) - - assert.Equal( - t, - "click me", - keyboard["inline_keyboard"].([]interface{})[0].([]interface{})[0].(map[string]any)["text"], - ) - assert.Equal( - t, - "click", - keyboard["inline_keyboard"].([]interface{})[0].([]interface{})[0].(map[string]any)["callback_data"], - ) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - }, - ), - ) - }, - }, - { - name: "should skip button", - chat: chat, - opts: &MessageOpts{ - Text: "test buttons", - Buttons: [][]KeyboardButton{ - { - KeyboardButton{Text: "click me", CallbackData: "click", SkipInGroup: true}, - }, - }, - }, - createServer: func(t *testing.T) *httptest.Server { - return httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, "test buttons", query.Get("text")) - assert.Equal(t, "-123", query.Get("chat_id")) - assert.Empty(t, query.Get("reply_markup")) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - }, - ), - ) - }, - }, - } - - for _, tt := range table { - t.Run( - tt.name, func(c *testing.T) { - server := tt.createServer(c) - tgClient := test_utils.NewTelegramClient(server) - sender := NewMessageSender(tgClient) - - err := sender.SendMessage(context.Background(), tt.chat, tt.opts) - assert.NoError(c, err) - assert.Nil(c, err) - }, - ) - } -} diff --git a/internal/telegram/commands/broadcast.go b/internal/telegram/commands/broadcast.go deleted file mode 100644 index 55582591..00000000 --- a/internal/telegram/commands/broadcast.go +++ /dev/null @@ -1,77 +0,0 @@ -package commands - -import ( - "context" - "strconv" - "strings" - "sync" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db/db_models" - tgtypes "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - "go.uber.org/zap" -) - -type BroadcastCommand struct { - *tgtypes.CommandOpts -} - -func (c *BroadcastCommand) HandleCommand(ctx context.Context, msg *tgb.MessageUpdate) error { - chats, err := c.Services.Chat.GetAllByService(ctx, db_models.ChatServiceTelegram) - if err != nil { - zap.S().Error(err) - return msg.Answer("Error").DoVoid(ctx) - } - - wg := sync.WaitGroup{} - wg.Add(len(chats)) - - for _, chat := range chats { - go func(chat *db_models.Chat) { - defer wg.Done() - - chatId, _ := strconv.Atoi(chat.ChatID) - - // filter channels, thay have negative id. - if chatId <= 0 { - return - } - - err := msg.Client. - SendMessage( - tg.ChatID(chatId), - strings.Replace(msg.Message.Text, "/broadcast ", "", 1), - ).DoVoid(ctx) - if err != nil { - zap.S().Error(err) - } - }(chat) - } - - wg.Wait() - - return nil -} - -var ( - broadcastCommandFilter = tgb.Command("broadcast") - broadcastCommandAdminFilter = func(services *types.Services) tgb.Filter { - return tgb.FilterFunc(func(ctx context.Context, update *tgb.Update) (bool, error) { - return lo.Contains(services.Config.TelegramBotAdmins, update.Message.Chat.ID.PeerID()), nil - }) - } -) - -func NewBroadcastCommand(opts *tgtypes.CommandOpts) { - cmd := &BroadcastCommand{ - CommandOpts: opts, - } - opts.Router.Message( - cmd.HandleCommand, - broadcastCommandFilter, - broadcastCommandAdminFilter(opts.Services), - ) -} diff --git a/internal/telegram/commands/broadcast_test.go b/internal/telegram/commands/broadcast_test.go deleted file mode 100644 index 2debeb35..00000000 --- a/internal/telegram/commands/broadcast_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package commands - -import ( - "context" - "fmt" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - "io" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/test_utils" - "github.com/satont/twitch-notifier/internal/test_utils/mocks" - "github.com/stretchr/testify/assert" -) - -func TestBroadcastCommand_HandleCommand(t *testing.T) { - t.Parallel() - - ctx := context.Background() - chatMock := &mocks.DbChatMock{} - - table := []struct { - name string - message *tgb.MessageUpdate - serverMock *httptest.Server - setupMocks func() - }{ - { - name: "Should call SendMessage for each chat", - message: &tgb.MessageUpdate{ - Message: &tg.Message{ - Text: "/broadcast test", - }, - }, - serverMock: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - assert.Equal(t, "test", query.Get("text")) - assert.Contains(t, []string{"1", "2"}, query.Get("chat_id")) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - })), - setupMocks: func() { - chatMock. - On("GetAllByService", ctx, db_models.ChatServiceTelegram). - Return( - []*db_models.Chat{{ChatID: "1"}, {ChatID: "2"}}, - nil, - ) - }, - }, - } - - for _, tt := range table { - tt := tt - t.Run(tt.name, func(t *testing.T) { - defer tt.serverMock.Close() - tt.setupMocks() - client := test_utils.NewTelegramClient(tt.serverMock) - tt.message.Client = client - cmd := &BroadcastCommand{ - CommandOpts: &tg_types.CommandOpts{ - Services: &types.Services{ - Chat: chatMock, - }, - }, - } - err := cmd.HandleCommand(ctx, tt.message) - assert.NoError(t, err) - - chatMock.AssertExpectations(t) - }) - } -} diff --git a/internal/telegram/commands/change_channel_id.go b/internal/telegram/commands/change_channel_id.go deleted file mode 100644 index 2701a5e2..00000000 --- a/internal/telegram/commands/change_channel_id.go +++ /dev/null @@ -1,64 +0,0 @@ -package commands - -import ( - "context" - "github.com/mr-linch/go-tg/tgb" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - tgtypes "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - "go.uber.org/zap" - "strings" -) - -type ChangeChannelId struct { - *tgtypes.CommandOpts -} - -func (c *ChangeChannelId) HandleCommand(ctx context.Context, msg *tgb.MessageUpdate) error { - text := strings.ReplaceAll(msg.Message.Text, "/change_channel_id ", "") - splittedText := strings.Split(strings.TrimSpace(text), " ") - - if len(splittedText) != 2 { - return nil - } - - sourceChannelID := splittedText[0] - targetChannelID := splittedText[1] - - _, err := c.Services.Channel.Update( - ctx, - sourceChannelID, - db_models.ChannelServiceTwitch, - &db.ChannelUpdateQuery{ - DangerNewChannelId: &targetChannelID, - }, - ) - - if err != nil { - zap.S().Error(err) - } - - return msg.Answer("done").DoVoid(ctx) -} - -var ( - changeChannelIdFilter = tgb.Command("change_channel_id") - changeChannelIdFilterAdminFilter = func(services *types.Services) tgb.Filter { - return tgb.FilterFunc(func(ctx context.Context, update *tgb.Update) (bool, error) { - return lo.Contains(services.Config.TelegramBotAdmins, update.Message.Chat.ID.PeerID()), nil - }) - } -) - -func NewChangeChannelId(opts *tgtypes.CommandOpts) { - cmd := &ChangeChannelId{ - CommandOpts: opts, - } - opts.Router.Message( - cmd.HandleCommand, - changeChannelIdFilter, - changeChannelIdFilterAdminFilter(opts.Services), - ) -} diff --git a/internal/telegram/commands/filters.go b/internal/telegram/commands/filters.go deleted file mode 100644 index 028fb146..00000000 --- a/internal/telegram/commands/filters.go +++ /dev/null @@ -1,37 +0,0 @@ -package commands - -import ( - "context" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" -) - -var channelsAdminFilter = tgb.FilterFunc(func(ctx context.Context, update *tgb.Update) (bool, error) { - if update.Chat().Type == tg.ChatTypePrivate || update.Chat().Type == tg.ChatTypeSender { - return true, nil - } - - admins, err := update.Client.GetChatAdministrators(update.Chat().ID).Do(ctx) - if err != nil { - return false, err - } - - if update.CallbackQuery != nil { - for _, admin := range admins { - if admin.User.ID == update.CallbackQuery.From.ID { - return true, nil - } - } - } else if update.Message != nil && update.Message.From != nil { - for _, admin := range admins { - if admin.User.ID == update.Message.From.ID { - return true, nil - } - } - } else { - return true, nil - } - - return false, nil -}) diff --git a/internal/telegram/commands/follow.go b/internal/telegram/commands/follow.go deleted file mode 100644 index bd834332..00000000 --- a/internal/telegram/commands/follow.go +++ /dev/null @@ -1,180 +0,0 @@ -package commands - -import ( - "context" - "errors" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/db/db_models" - tgtypes "github.com/satont/twitch-notifier/internal/telegram/types" - "go.uber.org/zap" - "regexp" - "strings" -) - -type FollowCommand struct { - *tgtypes.CommandOpts -} - -var ( - twitchInvalidNamesString = "Invalid login names, emails or IDs in request" - channelNotFoundError = errors.New("channel not found") - invalidNameError = errors.New(twitchInvalidNamesString) - TwitchLinkRegular = regexp.MustCompile(`(?:https?://)?(?:www\.)?twitch\.tv/(\w+)`) -) - -func (c *FollowCommand) createFollow( - ctx context.Context, - chat *db_models.Chat, - input string, -) (*db_models.Follow, error) { - twitchChannel, err := c.Services.Twitch.GetUser("", input) - if err != nil { - if err.Error() == twitchInvalidNamesString { - return nil, invalidNameError - } - - return nil, err - } - - if twitchChannel == nil { - return nil, channelNotFoundError - } - - dbChannel, err := c.Services.Channel.GetByIdOrCreate( - ctx, - twitchChannel.ID, - db_models.ChannelServiceTwitch, - ) - if err != nil { - return nil, err - } - - follow, err := c.Services.Follow.Create(ctx, dbChannel.ID, chat.ID) - if err != nil { - return nil, err - } - - return follow, nil -} - -func (c *FollowCommand) handleScene(ctx context.Context, msg *tgb.MessageUpdate) error { - chat := c.SessionManager.Get(ctx).Chat - - nicknames := make([]string, 0) - - regularMatches := TwitchLinkRegular.FindAllStringSubmatch(msg.Text, -1) - - if len(regularMatches) > 0 { - for _, match := range regularMatches { - nicknames = append(nicknames, match[1]) - } - } else { - nicknames = append(nicknames, msg.Text) - } - - succeeded := make([]string, 0) - failed := make([]string, 0) - - for _, nickname := range nicknames { - _, err := c.createFollow(ctx, chat, nickname) - - if errors.Is(err, channelNotFoundError) { - message := c.Services.I18N.Translate( - "commands.follow.errors.streamerNotFound", - chat.Settings.ChatLanguage.String(), - map[string]string{ - "streamer": nickname, - }, - ) - failed = append(failed, message) - } else if errors.Is(err, db_models.FollowAlreadyExistsError) { - message := c.Services.I18N.Translate( - "commands.follow.errors.alreadyFollowed", - chat.Settings.ChatLanguage.String(), - map[string]string{ - "streamer": nickname, - }, - ) - failed = append(failed, message) - } else if errors.Is(err, invalidNameError) { - message := c.Services.I18N.Translate( - "commands.follow.errors.badUsername", - chat.Settings.ChatLanguage.String(), - map[string]string{ - "streamer": nickname, - }, - ) - failed = append(failed, message) - } else if err != nil { - zap.S().Error(err) - failed = append(failed, "internal error") - } else { - message := c.Services.I18N.Translate( - "commands.follow.success", - chat.Settings.ChatLanguage.String(), - map[string]string{ - "streamer": nickname, - }, - ) - succeeded = append(succeeded, message) - } - } - - c.SessionManager.Get(ctx).Scene = "" - - message := strings.Join(succeeded, "\n") - message += "\n\n" - message += strings.Join(failed, "\n") - - return msg.Answer(message).DoVoid(ctx) -} - -func (c *FollowCommand) HandleCommand(ctx context.Context, msg *tgb.MessageUpdate) error { - session := c.SessionManager.Get(ctx) - - text := strings.ReplaceAll(msg.Text, "/follow", "") - text = strings.TrimSpace(text) - - if text != "" { - msg.Text = text - return c.handleScene(ctx, msg) - } else { - c.SessionManager.Get(ctx).Scene = "follow" - return msg. - Answer(c.Services.I18N.Translate( - "commands.follow.enter", - session.Chat.Settings.ChatLanguage.String(), - nil, - )). - DoVoid(ctx) - } -} - -var ( - followCommandQuery = tgb.Command("follow") -) - -func NewFollowCommand(opts *tgtypes.CommandOpts) { - cmd := &FollowCommand{ - CommandOpts: opts, - } - - sceneFilter := []tgb.Filter{ - channelsAdminFilter, - tgb.FilterFunc(func(ctx context.Context, update *tgb.Update) (bool, error) { - session := opts.SessionManager.Get(ctx) - return session.Scene == "follow", nil - }), - } - - opts.Router.Message(cmd.handleScene, sceneFilter...) - opts.Router.ChannelPost(cmd.handleScene, sceneFilter...) - - messageFilter := []tgb.Filter{ - channelsAdminFilter, - followCommandQuery, - } - - opts.Router.Message(cmd.HandleCommand, messageFilter...) - opts.Router.ChannelPost(cmd.HandleCommand, messageFilter...) -} diff --git a/internal/telegram/commands/follow_test.go b/internal/telegram/commands/follow_test.go deleted file mode 100644 index 79afe2e5..00000000 --- a/internal/telegram/commands/follow_test.go +++ /dev/null @@ -1,424 +0,0 @@ -package commands - -import ( - "context" - "errors" - "fmt" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - "github.com/satont/twitch-notifier/pkg/i18n/mocks" - "github.com/stretchr/testify/mock" - "net/http" - "net/http/httptest" - "testing" - - "github.com/google/uuid" - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/nicklaw5/helix/v2" - "github.com/satont/twitch-notifier/internal/test_utils" - "github.com/satont/twitch-notifier/internal/test_utils/mocks" - "github.com/stretchr/testify/assert" -) - -func TestFollowService(t *testing.T) { - t.Parallel() - - mockedTwitch := &mocks.TwitchApiMock{} - channelsMock := &mocks.DbChannelMock{} - followsMock := &mocks.DbFollowMock{} - i18nMock := i18nmocks.NewI18nMock() - - userLogin := "fukushine" - user := &helix.User{ - ID: "1", - Login: userLogin, - DisplayName: "Fukushine", - } - - ctx := context.Background() - - chat := &db_models.Chat{ - ID: uuid.New(), - } - chann := &db_models.Channel{ - ID: uuid.New(), - ChannelID: "1", - } - f := &db_models.Follow{} - - follow := &FollowCommand{ - &tg_types.CommandOpts{ - Services: &types.Services{ - Twitch: mockedTwitch, - Channel: channelsMock, - Follow: followsMock, - I18N: i18nMock, - }, - }, - } - - // table tests - table := []struct { - name string - input string - want *db_models.Follow - wantErr bool - setupMocks func() - }{ - { - name: "Should fail because of GetUser error", - input: "fukushine2", - want: nil, - wantErr: true, - setupMocks: func() { - mockedTwitch.On("GetUser", "", "fukushine2").Return((*helix.User)(nil), nil) - }, - }, - { - name: "Should create", - input: userLogin, - want: f, - wantErr: false, - setupMocks: func() { - mockedTwitch. - On("GetUser", "", userLogin).Return(user, nil) - channelsMock. - On("GetByIdOrCreate", ctx, user.ID, db_models.ChannelServiceTwitch).Return(chann, nil) - followsMock. - On("Create", ctx, chann.ID, chat.ID).Return(f, nil) - }, - }, - { - name: "Should fail because follow exists", - input: userLogin, - want: nil, - wantErr: true, - setupMocks: func() { - mockedTwitch. - On("GetUser", "", userLogin).Return(user, nil) - channelsMock. - On("GetByIdOrCreate", ctx, user.ID, db_models.ChannelServiceTwitch).Return(chann, nil) - followsMock. - On("Create", ctx, chann.ID, chat.ID).Return((*db_models.Follow)(nil), db_models.FollowAlreadyExistsError) - }, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - tt.setupMocks() - - got, err := follow.createFollow(ctx, chat, tt.input) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.want, got) - } - - mockedTwitch.AssertExpectations(t) - channelsMock.AssertExpectations(t) - followsMock.AssertExpectations(t) - - mockedTwitch.ExpectedCalls = nil - channelsMock.ExpectedCalls = nil - followsMock.ExpectedCalls = nil - }) - } -} - -func TestFollowCommand_HandleCommand(t *testing.T) { - t.Parallel() - - ctx := context.Background() - - sessionService := tg_types.NewMockedSessionManager() - - sessionService.On("Get", ctx).Return(&tg_types.Session{ - Chat: &db_models.Chat{ - ChatID: "123", - Settings: &db_models.ChatSettings{ - ChatLanguage: db_models.ChatLanguageEn, - }, - }, - }) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - })) - - tgClient := test_utils.NewTelegramClient(server) - - i18nMock := i18nmocks.NewI18nMock() - i18nMock. - On( - "Translate", - "commands.follow.enter", - "en", - (map[string]string)(nil), - ). - Return("test") - - followCommand := &FollowCommand{ - &tg_types.CommandOpts{ - SessionManager: sessionService, - Services: &types.Services{ - I18N: i18nMock, - }, - }, - } - - assert.Equal(t, "", sessionService.Get(ctx).Scene) - err := followCommand.HandleCommand(ctx, &tgb.MessageUpdate{ - Client: tgClient, - Message: &tg.Message{ - Chat: tg.Chat{ - ID: 123, - }, - }, - }) - assert.NoError(t, err) - assert.Equal(t, "follow", sessionService.Get(ctx).Scene) - - sessionService.AssertExpectations(t) - i18nMock.AssertExpectations(t) -} - -func TestFollowCommand_HandleScene(t *testing.T) { - t.Parallel() - - mockedTwitch := &mocks.TwitchApiMock{} - channelsMock := &mocks.DbChannelMock{} - followsMock := &mocks.DbFollowMock{} - i18nMock := i18nmocks.NewI18nMock() - sessionMock := tg_types.NewMockedSessionManager() - - ctx := context.Background() - - userLogin := "satont" - helixUser := &helix.User{ - ID: "1", - Login: userLogin, - DisplayName: "Satont", - } - - dbChat := &db_models.Chat{ - ID: uuid.New(), - Settings: &db_models.ChatSettings{ - ChatLanguage: db_models.ChatLanguageEn, - }, - } - dbChannel := &db_models.Channel{ - ID: uuid.New(), - ChannelID: "1", - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - })) - defer server.Close() - tgMockedServer := test_utils.NewTelegramClient(server) - - sessionMock.On("Get", ctx).Return(&tg_types.Session{ - Chat: dbChat, - }) - - var clearMocks = func() { - mockedTwitch.ExpectedCalls = nil - channelsMock.ExpectedCalls = nil - followsMock.ExpectedCalls = nil - i18nMock.ExpectedCalls = nil - - mockedTwitch.Calls = nil - channelsMock.Calls = nil - followsMock.Calls = nil - i18nMock.Calls = nil - } - - table := []struct { - name string - input string - setupMocks func() - asserts func(t *testing.T, err error) - }{ - { - name: "Should fail because of GetUser error", - input: "satont", - setupMocks: func() { - mockedTwitch. - On("GetUser", "", userLogin). - Return((*helix.User)(nil), nil) - i18nMock.On( - "Translate", - "commands.follow.errors.streamerNotFound", - "en", - map[string]string{"streamer": userLogin}, - ).Return("") - }, - asserts: func(t *testing.T, err error) { - assert.NoError(t, err) - - i18nMock.AssertExpectations(t) - mockedTwitch.AssertExpectations(t) - channelsMock.AssertExpectations(t) - followsMock.AssertExpectations(t) - - clearMocks() - }, - }, - { - name: "Should fail because db follow exists", - input: userLogin, - setupMocks: func() { - mockedTwitch.On("GetUser", "", userLogin).Return(helixUser, nil) - channelsMock. - On("GetByIdOrCreate", ctx, helixUser.ID, db_models.ChannelServiceTwitch). - Return(dbChannel, nil) - followsMock. - On("Create", ctx, dbChannel.ID, dbChat.ID). - Return((*db_models.Follow)(nil), db_models.FollowAlreadyExistsError) - i18nMock.On( - "Translate", - "commands.follow.errors.alreadyFollowed", - "en", - map[string]string{"streamer": userLogin}, - ).Return("") - }, - asserts: func(t *testing.T, err error) { - assert.NoError(t, err) - - i18nMock.AssertExpectations(t) - mockedTwitch.AssertExpectations(t) - channelsMock.AssertExpectations(t) - followsMock.AssertExpectations(t) - - clearMocks() - }, - }, - { - name: "Should fail because db channel cannot be created", - input: userLogin, - setupMocks: func() { - mockedTwitch.On("GetUser", "", userLogin).Return(helixUser, nil) - channelsMock. - On("GetByIdOrCreate", ctx, helixUser.ID, db_models.ChannelServiceTwitch). - Return(dbChannel, errors.New("some error")) - }, - asserts: func(t *testing.T, err error) { - assert.NoError(t, err) - - i18nMock.AssertExpectations(t) - mockedTwitch.AssertExpectations(t) - channelsMock.AssertExpectations(t) - followsMock.AssertExpectations(t) - - clearMocks() - }, - }, - { - name: "Should success", - input: userLogin, - setupMocks: func() { - mockedTwitch.On("GetUser", "", userLogin).Return(helixUser, nil) - channelsMock. - On("GetByIdOrCreate", ctx, helixUser.ID, db_models.ChannelServiceTwitch). - Return(dbChannel, nil) - followsMock. - On("Create", ctx, dbChannel.ID, dbChat.ID). - Return((*db_models.Follow)(nil), nil) - i18nMock.On( - "Translate", - "commands.follow.success", - "en", - map[string]string{"streamer": userLogin}, - ).Return("") - }, - asserts: func(t *testing.T, err error) { - assert.NoError(t, err) - - i18nMock.AssertExpectations(t) - mockedTwitch.AssertExpectations(t) - channelsMock.AssertExpectations(t) - followsMock.AssertExpectations(t) - - clearMocks() - }, - }, - { - name: "Should create multiple follows", - input: "https://www.twitch.tv/satont, https://www.twitch.tv/satont2", - setupMocks: func() { - mockedTwitch.On("GetUser", mock.Anything, mock.Anything).Return(helixUser, nil) - channelsMock. - On("GetByIdOrCreate", ctx, helixUser.ID, db_models.ChannelServiceTwitch). - Return(dbChannel, nil) - followsMock. - On("Create", ctx, dbChannel.ID, dbChat.ID). - Return((*db_models.Follow)(nil), nil) - i18nMock.On( - "Translate", - "commands.follow.success", - "en", - mock.Anything, - ).Return("") - }, - asserts: func(t *testing.T, err error) { - assert.NoError(t, err) - - mockedTwitch.AssertNumberOfCalls(t, "GetUser", 2) - channelsMock.AssertNumberOfCalls(t, "GetByIdOrCreate", 2) - followsMock.AssertNumberOfCalls(t, "Create", 2) - i18nMock.AssertNumberOfCalls(t, "Translate", 2) - - clearMocks() - }, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - tt.setupMocks() - - followCommand := &FollowCommand{ - &tg_types.CommandOpts{ - SessionManager: sessionMock, - Services: &types.Services{ - Twitch: mockedTwitch, - Channel: channelsMock, - Follow: followsMock, - I18N: i18nMock, - }, - }, - } - - tgMsg := &tgb.MessageUpdate{ - Client: tgMockedServer, - Message: &tg.Message{ - Chat: tg.Chat{ID: 1}, - Text: tt.input, - }, - } - - err := followCommand.handleScene(ctx, tgMsg) - tt.asserts(t, err) - }) - } -} diff --git a/internal/telegram/commands/follows.go b/internal/telegram/commands/follows.go deleted file mode 100644 index 626ec5cf..00000000 --- a/internal/telegram/commands/follows.go +++ /dev/null @@ -1,249 +0,0 @@ -package commands - -import ( - "context" - "errors" - "fmt" - "math" - "strings" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db/db_models" - tgtypes "github.com/satont/twitch-notifier/internal/telegram/types" - "go.uber.org/zap" -) - -type FollowsCommand struct { - *tgtypes.CommandOpts -} - -const followsMaxRows = 3 -const followsPerRow = 3 - -func (c *FollowsCommand) newKeyboard( - ctx context.Context, - maxRows, perRow int, -) (*tg.InlineKeyboardMarkup, error) { - session := c.SessionManager.Get(ctx) - - limit := maxRows * perRow - offset := (session.FollowsMenu.CurrentPage - 1) * limit - - if offset < 0 { - offset = 0 - } - - layout := tg.NewButtonLayout[tg.InlineKeyboardButton](perRow) - - follows, err := c.Services.Follow.GetByChatID( - ctx, - session.Chat.ID, - limit, - offset, - ) - if err != nil { - zap.S().Error(err) - return nil, err - } - if len(follows) == 0 { - markup := tg.NewInlineKeyboardMarkup(layout.Keyboard()...) - return &markup, nil - } - - totalFollows, err := c.Services.Follow.CountByChatID(ctx, session.Chat.ID) - if err != nil { - zap.S().Error(err) - return nil, err - } - - session.FollowsMenu.TotalPages = int(math.Ceil(float64(totalFollows) / float64(limit))) - // spew.Dump(totalFollows) - // spew.Dump(session.FollowsMenu) - // spew.Dump(session.FollowsMenu.CurrentPage) - - channelsIds := lo.Map( - follows, func(follow *db_models.Follow, _ int) string { - return follow.Channel.ChannelID - }, - ) - - channels, err := c.Services.Twitch.GetChannelsByUserIds(channelsIds) - - if err != nil { - return nil, err - } - - for _, channel := range channels { - internalChannel, _ := lo.Find( - follows, - func(follow *db_models.Follow) bool { - return follow.Channel.ChannelID == channel.BroadcasterID - }, - ) - - layout.Insert( - tg.NewInlineKeyboardButtonCallback( - channel.BroadcasterName, - fmt.Sprintf("channels_unfollow_%s", internalChannel.ChannelID), - ), - ) - } - - var paginationRow *tg.ButtonLayout[tg.InlineKeyboardButton] - - if session.FollowsMenu.CurrentPage > 1 || - session.FollowsMenu.CurrentPage < session.FollowsMenu.TotalPages { - paginationRow = layout.Row() - - // Add "Prev" button - if session.FollowsMenu.CurrentPage > 1 { - paginationRow.Insert( - tg.NewInlineKeyboardButtonCallback( - "«", - "channels_unfollow_prev_page", - ), - ) - } - - // Add "Next" button - if session.FollowsMenu.CurrentPage < session.FollowsMenu.TotalPages { - paginationRow.Insert( - tg.NewInlineKeyboardButtonCallback( - "»", - "channels_unfollow_next_page", - ), - ) - } - } - - markup := tg.NewInlineKeyboardMarkup(layout.Keyboard()...) - - return &markup, nil -} - -func (c *FollowsCommand) HandleCommand(ctx context.Context, msg *tgb.MessageUpdate) error { - session := c.SessionManager.Get(ctx) - - session.FollowsMenu.TotalPages = 1 - session.FollowsMenu.CurrentPage = 1 - - keyboard, err := c.newKeyboard(ctx, followsMaxRows, followsPerRow) - if err != nil { - zap.S().Error(err) - return msg.Answer("internal error").DoVoid(ctx) - } - - totalFollows, err := c.Services.Follow.CountByChatID(ctx, session.Chat.ID) - - return msg. - Answer( - c.Services.I18N.Translate( - "commands.follows.total", - session.Chat.Settings.ChatLanguage.String(), - map[string]string{"count": fmt.Sprintf("%v", totalFollows)}, - ), - ). - ReplyMarkup(keyboard).DoVoid(ctx) -} - -func (c *FollowsCommand) handleUnfollow( - ctx context.Context, - chat *db_models.Chat, - input string, -) error { - channelID := strings.Replace(input, "channels_unfollow_", "", 1) - - channel, err := c.Services.Channel.GetByID(ctx, channelID, db_models.ChannelServiceTwitch) - if err != nil { - return err - } - - follow, err := c.Services.Follow.GetByChatAndChannel(ctx, channel.ID, chat.ID) - if err != nil { - return err - } - - return c.Services.Follow.Delete(ctx, follow.ID) -} - -func (c *FollowsCommand) unfollowQuery(ctx context.Context, msg *tgb.CallbackQueryUpdate) error { - chat := c.SessionManager.Get(ctx).Chat - - if err := c.handleUnfollow(ctx, chat, msg.CallbackQuery.Data); err != nil { - if errors.Is(err, db_models.FollowNotFoundError) { - return msg.Answer().Text("already unfollowed").DoVoid(ctx) - } - - zap.S().Error(err) - - return msg.Answer().Text("internal error").DoVoid(ctx) - } - - return msg.Answer().Text("unfollowed").DoVoid(ctx) -} - -func (c *FollowsCommand) prevPageQuery(ctx context.Context, msg *tgb.CallbackQueryUpdate) error { - session := c.SessionManager.Get(ctx) - - if session.FollowsMenu.CurrentPage > 0 { - session.FollowsMenu.CurrentPage-- - } - - keyboard, err := c.newKeyboard(ctx, followsMaxRows, followsPerRow) - if err != nil { - zap.S().Error(err) - } - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat().ID, msg.Message.MessageID()). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -func (c *FollowsCommand) nextPageQuery(ctx context.Context, msg *tgb.CallbackQueryUpdate) error { - session := c.SessionManager.Get(ctx) - - if session.FollowsMenu.CurrentPage+1 <= session.FollowsMenu.TotalPages { - session.FollowsMenu.CurrentPage++ - } - - keyboard, err := c.newKeyboard(ctx, followsMaxRows, followsPerRow) - if err != nil { - zap.S().Error(err) - } - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat().ID, msg.Message.MessageID()). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -var ( - followsCommandFilter = tgb.Command( - "follows", - tgb.WithCommandAlias("unfollow"), - ) - followsPrevPageQuery = tgb.TextEqual("channels_unfollow_prev_page") - followsNextPageQuery = tgb.TextEqual("channels_unfollow_next_page") - followUnfollowQuery = tgb.TextHasPrefix("channels_unfollow_") -) - -func NewFollowsCommand(opts *tgtypes.CommandOpts) { - cmd := &FollowsCommand{ - CommandOpts: opts, - } - - messageFilter := []tgb.Filter{ - channelsAdminFilter, - followsCommandFilter, - } - - opts.Router.Message(cmd.HandleCommand, messageFilter...) - opts.Router.ChannelPost(cmd.HandleCommand, messageFilter...) - - opts.Router.CallbackQuery(cmd.prevPageQuery, channelsAdminFilter, followsPrevPageQuery) - opts.Router.CallbackQuery(cmd.nextPageQuery, channelsAdminFilter, followsNextPageQuery) - opts.Router.CallbackQuery(cmd.unfollowQuery, channelsAdminFilter, followUnfollowQuery) -} diff --git a/internal/telegram/commands/follows_test.go b/internal/telegram/commands/follows_test.go deleted file mode 100644 index 68f9293e..00000000 --- a/internal/telegram/commands/follows_test.go +++ /dev/null @@ -1,429 +0,0 @@ -package commands - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strconv" - "testing" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/nicklaw5/helix/v2" - "github.com/samber/lo" - db_models2 "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/test_utils" - "github.com/satont/twitch-notifier/internal/types" - i18nmocks "github.com/satont/twitch-notifier/pkg/i18n/mocks" - - "github.com/google/uuid" - "github.com/satont/twitch-notifier/internal/test_utils/mocks" - "github.com/stretchr/testify/assert" -) - -func TestFollowsCommand_handleUnfollow(t *testing.T) { - t.Parallel() - - type fields struct { - CommandOpts *tg_types.CommandOpts - } - type args struct { - ctx context.Context - chat *db_models2.Chat - input string - } - - // mockedTwitch := &twitch.MockedService{} - channelsMock := &mocks.DbChannelMock{} - followsMock := &mocks.DbFollowMock{} - - ctx := context.Background() - chat := &db_models2.Chat{ - ID: uuid.New(), - ChatID: "1", - } - - commandOpts := &tg_types.CommandOpts{ - Services: &types.Services{ - Channel: channelsMock, - Follow: followsMock, - }, - } - - tests := []struct { - name string - fields fields - args args - wantErr bool - wantedErr error - setupMocks func() - }{ - { - name: "should return error if channel not found", - fields: fields{CommandOpts: commandOpts}, - args: args{ - ctx: ctx, - chat: chat, - input: "channels_unfollow_1", - }, - wantErr: true, - wantedErr: db_models2.ChannelNotFoundError, - setupMocks: func() { - channelsMock. - On("GetByID", ctx, "1", db_models2.ChannelServiceTwitch). - Return((*db_models2.Channel)(nil), db_models2.ChannelNotFoundError) - }, - }, - { - name: "should return error if follow not found", - fields: fields{ - CommandOpts: commandOpts, - }, - args: args{ - ctx: ctx, - chat: chat, - input: "channels_unfollow_1", - }, - wantErr: true, - wantedErr: db_models2.FollowNotFoundError, - setupMocks: func() { - channelId := uuid.New() - channelsMock. - On("GetByID", ctx, "1", db_models2.ChannelServiceTwitch). - Return( - &db_models2.Channel{ - ID: channelId, - ChannelID: "1", - }, nil, - ) - followsMock. - On("GetByChatAndChannel", ctx, channelId, chat.ID). - Return((*db_models2.Follow)(nil), db_models2.FollowNotFoundError) - }, - }, - { - name: "should return nil", - fields: fields{ - CommandOpts: commandOpts, - }, - args: args{ - ctx: ctx, - chat: chat, - input: "channels_unfollow_1", - }, - wantErr: false, - wantedErr: nil, - setupMocks: func() { - channelID := uuid.New() - followID := uuid.New() - channelsMock. - On("GetByID", ctx, "1", db_models2.ChannelServiceTwitch). - Return( - &db_models2.Channel{ - ID: channelID, - ChannelID: "1", - }, nil, - ) - followsMock. - On("GetByChatAndChannel", ctx, channelID, chat.ID). - Return( - &db_models2.Follow{ - ID: followID, - }, nil, - ) - followsMock. - On("Delete", ctx, followID). - Return(nil) - }, - }, - } - for _, tt := range tests { - t.Run( - tt.name, func(t *testing.T) { - c := &FollowsCommand{ - CommandOpts: tt.fields.CommandOpts, - } - - tt.setupMocks() - - err := c.handleUnfollow(tt.args.ctx, tt.args.chat, tt.args.input) - if tt.wantErr { - assert.ErrorIs(t, err, tt.wantedErr) - } - - channelsMock.AssertExpectations(t) - - channelsMock.ExpectedCalls = nil - }, - ) - } -} - -func TestFollowsCommand_HandleCommand(t *testing.T) { - t.Parallel() - - sessionMock := tg_types.NewMockedSessionManager() - followsMock := &mocks.DbFollowMock{} - i18nMock := i18nmocks.NewI18nMock() - - ctx := context.Background() - chat := &db_models2.Chat{ - ID: uuid.New(), - ChatID: "1", - Settings: &db_models2.ChatSettings{ - ChatLanguage: db_models2.ChatLanguageEn, - }, - } - - session := &tg_types.Session{ - Chat: chat, - FollowsMenu: &tg_types.Menu{ - CurrentPage: 5, - TotalPages: 10, - }, - } - - sessionMock.On("Get", ctx).Return(session) - followsMock.On("GetByChatID", ctx, chat.ID, 9, 0).Return([]*db_models2.Follow{}, nil) - followsMock.On("CountByChatID", ctx, chat.ID).Return(1, nil) - i18nMock. - On( - "Translate", - "commands.follows.total", - "en", - map[string]string{"count": "1"}, - ).Return("Total: 1") - - commandOpts := &tg_types.CommandOpts{ - Services: &types.Services{ - Follow: followsMock, - I18N: i18nMock, - }, - SessionManager: sessionMock, - } - - cmd := &FollowsCommand{CommandOpts: commandOpts} - - server := httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - query, _ := url.ParseQuery(string(body)) - - assert.Greater(t, len(query.Get("text")), 1) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - }, - ), - ) - defer server.Close() - - msg := &tgb.MessageUpdate{ - Client: test_utils.NewTelegramClient(server), - Message: &tg.Message{ - Chat: tg.Chat{ID: 1}, - }, - } - - err := cmd.HandleCommand(ctx, msg) - assert.NoError(t, err) -} - -func TestFollowsCommand_newKeyboard(t *testing.T) { - t.Parallel() - - ctx := context.Background() - - followsMock := &mocks.DbFollowMock{} - sessionsMock := tg_types.NewMockedSessionManager() - twitchMock := &mocks.TwitchApiMock{} - - dbChat := &db_models2.Chat{ - ID: uuid.New(), - ChatID: "1", - Settings: &db_models2.ChatSettings{ - ChatLanguage: db_models2.ChatLanguageEn, - }, - } - - session := &tg_types.Session{ - Chat: dbChat, - FollowsMenu: &tg_types.Menu{ - CurrentPage: 1, - TotalPages: 0, - }, - } - - entityId := uuid.New() - channelId := uuid.New() - - table := []struct { - name string - setupMocks func() - asserts func(t *testing.T, keyboard *tg.InlineKeyboardMarkup) - }{ - { - name: "should return keyboard with 1 page and no next and prev buttons", - setupMocks: func() { - sessionsMock.On("Get", ctx).Return(session) - followsMock.On("GetByChatID", ctx, dbChat.ID, 9, 0). - Return( - []*db_models2.Follow{ - { - ID: entityId, - ChannelID: channelId, - ChatID: dbChat.ID, - Channel: &db_models2.Channel{ - ID: channelId, - ChannelID: "1", - }, - }, - }, nil, - ) - followsMock.On("CountByChatID", ctx, dbChat.ID). - Return(1, nil) - twitchMock.On("GetChannelsByUserIds", []string{"1"}). - Return( - []helix.ChannelInformation{ - {BroadcasterID: "1", BroadcasterName: "Satont"}, - }, nil, - ) - }, - asserts: func(t *testing.T, keyboard *tg.InlineKeyboardMarkup) { - assert.Len(t, keyboard.InlineKeyboard, 1) - assert.Len(t, keyboard.InlineKeyboard[0], 1) - assert.Equal(t, keyboard.InlineKeyboard[0][0].Text, "Satont") - assert.Equal(t, keyboard.InlineKeyboard[0][0].CallbackData, "channels_unfollow_"+channelId.String()) - }, - }, - { - name: "should return keyboard with 2 pages and next buttons", - setupMocks: func() { - sessionsMock.On("Get", ctx).Return(session) - follows := make([]*db_models2.Follow, 0, 20) - for i := 0; i < 20; i++ { - follows = append( - follows, &db_models2.Follow{ - ID: uuid.New(), - ChannelID: uuid.New(), - ChatID: dbChat.ID, - Channel: &db_models2.Channel{ - ID: uuid.New(), - ChannelID: strconv.Itoa(i), - }, - }, - ) - } - followsMock.On("GetByChatID", ctx, dbChat.ID, 9, 0). - Return(follows, nil) - followsMock.On("CountByChatID", ctx, dbChat.ID). - Return(len(follows), nil) - channelsIds := lo.Map( - follows, func(f *db_models2.Follow, _ int) string { - return f.Channel.ChannelID - }, - ) - twitchMock.On("GetChannelsByUserIds", channelsIds). - Return( - lo.Map( - follows, func(item *db_models2.Follow, _ int) helix.ChannelInformation { - return helix.ChannelInformation{ - BroadcasterID: item.Channel.ChannelID, - BroadcasterName: item.Channel.ChannelID, - } - }, - ), nil, - ) - }, - asserts: func(t *testing.T, keyboard *tg.InlineKeyboardMarkup) { - assert.Greater(t, len(keyboard.InlineKeyboard), 2) - assert.Contains( - t, - keyboard.InlineKeyboard[len(keyboard.InlineKeyboard)-1][0].CallbackData, - "channels_unfollow_next_page", - ) - }, - }, - { - name: "should return keyboard with few pages and next and prev buttons", - setupMocks: func() { - session.FollowsMenu.CurrentPage = 3 - sessionsMock.On("Get", ctx).Return(session) - follows := make([]*db_models2.Follow, 0, 15) - for i := 0; i < 15; i++ { - follows = append( - follows, &db_models2.Follow{ - ID: uuid.New(), - ChannelID: uuid.New(), - ChatID: dbChat.ID, - Channel: &db_models2.Channel{ - ID: uuid.New(), - ChannelID: strconv.Itoa(i), - }, - }, - ) - } - followsMock.On("GetByChatID", ctx, dbChat.ID, 9, 18). - Return(follows, nil) - followsMock.On("CountByChatID", ctx, dbChat.ID). - Return(100, nil) - channelsIds := lo.Map( - follows, func(f *db_models2.Follow, _ int) string { - return f.Channel.ChannelID - }, - ) - twitchMock.On("GetChannelsByUserIds", channelsIds). - Return( - lo.Map( - follows, func(item *db_models2.Follow, _ int) helix.ChannelInformation { - return helix.ChannelInformation{ - BroadcasterID: item.Channel.ChannelID, - BroadcasterName: item.Channel.ChannelID, - } - }, - ), nil, - ) - }, - asserts: func(t *testing.T, keyboard *tg.InlineKeyboardMarkup) { - assert.Greater(t, len(keyboard.InlineKeyboard), 2) - latestRow := keyboard.InlineKeyboard[len(keyboard.InlineKeyboard)-1] - assert.Equal(t, latestRow[0].CallbackData, "channels_unfollow_prev_page") - assert.Equal(t, latestRow[1].CallbackData, "channels_unfollow_next_page") - }, - }, - } - - for _, tt := range table { - t.Run( - tt.name, func(t *testing.T) { - tt.setupMocks() - - cmd := &FollowsCommand{ - CommandOpts: &tg_types.CommandOpts{ - Services: &types.Services{ - Follow: followsMock, - Twitch: twitchMock, - }, - SessionManager: sessionsMock, - }, - } - - keyboard, err := cmd.newKeyboard(ctx, followsMaxRows, followsPerRow) - assert.NoError(t, err) - tt.asserts(t, keyboard) - - sessionsMock.AssertExpectations(t) - followsMock.AssertExpectations(t) - twitchMock.AssertExpectations(t) - - sessionsMock.ExpectedCalls = nil - followsMock.ExpectedCalls = nil - twitchMock.ExpectedCalls = nil - }, - ) - } -} diff --git a/internal/telegram/commands/language_picker.go b/internal/telegram/commands/language_picker.go deleted file mode 100644 index b2ec16f6..00000000 --- a/internal/telegram/commands/language_picker.go +++ /dev/null @@ -1,109 +0,0 @@ -package commands - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - tgtypes "github.com/satont/twitch-notifier/internal/telegram/types" - "go.uber.org/zap" -) - -type LanguagePicker struct { - *tgtypes.CommandOpts -} - -func (c *LanguagePicker) buildKeyboard() (*tg.InlineKeyboardMarkup, error) { - layout := tg.NewButtonLayout[tg.InlineKeyboardButton](1) - - codes := c.Services.I18N.GetLanguagesCodes() - - for _, code := range codes { - layout.Add( - tg.NewInlineKeyboardButtonCallback( - fmt.Sprintf( - "%s %s", - c.Services.I18N.Translate("language.emoji", code, nil), - c.Services.I18N.Translate("language.name", code, nil), - ), - "language_picker_set_"+code, - ), - ) - } - - layout.Add(tg.NewInlineKeyboardButtonCallback("«", "start_command_menu")) - - markup := tg.NewInlineKeyboardMarkup(layout.Keyboard()...) - - return &markup, nil -} - -func (c *LanguagePicker) HandleCallback(ctx context.Context, msg *tgb.CallbackQueryUpdate) error { - keyboard, err := c.buildKeyboard() - if err != nil { - return msg.Answer().Text("internal error").DoVoid(ctx) - } - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat().ID, msg.Message.Message.ID). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -func (c *LanguagePicker) handleSetLanguage(ctx context.Context, msg *tgb.CallbackQueryUpdate) error { - chat := c.SessionManager.Get(ctx).Chat - if chat == nil { - return errors.New("no chat") - } - - lang := db_models.ChatLanguage( - strings.TrimPrefix(msg.CallbackQuery.Data, "language_picker_set_"), - ) - if !db_models.LanguageExists(lang) { - return errors.New("language not exists") - } - - _, err := c.Services.Chat.Update( - ctx, - msg.Message.Chat().ID.PeerID(), - db_models.ChatServiceTelegram, - &db.ChatUpdateQuery{ - Settings: &db.ChatUpdateSettingsQuery{ - ChatLanguage: &lang, - }, - }, - ) - if err != nil { - zap.S().Error(err) - return err - } - - chat.Settings.ChatLanguage = lang - - err = msg. - Answer(). - Text(c.Services.I18N.Translate("language.changed", lang.String(), nil)). - DoVoid(ctx) - if err != nil { - zap.S().Error(err) - return err - } - - return nil -} - -func NewLanguagePicker(opts *tgtypes.CommandOpts) { - picker := &LanguagePicker{opts} - - opts.Router.CallbackQuery(picker.HandleCallback, channelsAdminFilter, tgb.TextEqual("language_picker")) - opts.Router.CallbackQuery( - picker.handleSetLanguage, - channelsAdminFilter, - tgb.TextHasPrefix("language_picker_set_"), - ) -} diff --git a/internal/telegram/commands/language_picker_test.go b/internal/telegram/commands/language_picker_test.go deleted file mode 100644 index 7c8024f8..00000000 --- a/internal/telegram/commands/language_picker_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package commands - -import ( - "context" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - tg_types "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - - "github.com/google/uuid" - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/test_utils" - "github.com/satont/twitch-notifier/internal/test_utils/mocks" - i18nmocks "github.com/satont/twitch-notifier/pkg/i18n/mocks" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" -) - -//func TestLanguagePicker_buildKeyboard(t *testing.T) { -// t.Parallel() -// -// i18nMock := i18n.NewI18nMock() -// -// cmd := &LanguagePicker{ -// CommandOpts: &tgtypes.CommandOpts{ -// Services: &types.Services{ -// I18N: i18nMock, -// }, -// }, -// } -// -// i18nMock.On("GetLanguagesCodes").Return([]string{"en", "ru"}) -// -// englishFlag := "🇬🇧" -// englishName := "English" -// -// russianFlag := "🇷🇺" -// russianName := "Русский" -// -// i18nMock. -// On("Translate", "language.emoji", "en", map[string]string(nil)). -// Return(englishFlag) -// i18nMock. -// On("Translate", "language.name", "en", map[string]string(nil)). -// Return(englishName) -// -// i18nMock. -// On("Translate", "language.emoji", "ru", map[string]string(nil)). -// Return(russianFlag) -// i18nMock. -// On("Translate", "language.name", "ru", map[string]string(nil)). -// Return(russianName) -// -// keyboard, err := cmd.buildKeyboard() -// assert.NoError(t, err) -// -// assert.Equal(t, -// fmt.Sprintf("%s %s", englishFlag, englishName), -// keyboard.InlineKeyboard[0][0].Text, -// ) -// assert.Equal(t, -// "language_picker_set_en", -// keyboard.InlineKeyboard[0][0].CallbackData, -// ) -// -// assert.Equal(t, -// fmt.Sprintf("%s %s", russianFlag, russianName), -// keyboard.InlineKeyboard[1][0].Text, -// ) -// assert.Equal(t, -// "language_picker_set_ru", -// keyboard.InlineKeyboard[1][0].CallbackData, -// ) -// -// assert.Equal(t, -// "«", -// keyboard.InlineKeyboard[2][0].Text, -// ) -// assert.Equal(t, "start_command_menu", keyboard.InlineKeyboard[2][0].CallbackData) -// -// i18nMock.AssertExpectations(t) -//} - -func TestLanguagePicker_HandleCallback(t *testing.T) { - t.Parallel() - - ctx := context.Background() - - i18nMock := i18nmocks.NewI18nMock() - i18nMock.On("GetLanguagesCodes").Return([]string{"en"}) - - englishFlag := "🇬🇧" - englishName := "English" - - i18nMock. - On("Translate", "language.emoji", "en", map[string]string(nil)). - Return(englishFlag) - i18nMock. - On("Translate", "language.name", "en", map[string]string(nil)). - Return(englishName) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/editMessageReplyMarkup", test_utils.TelegramClientToken), - r.URL.Path, - ) - assert.NotEmpty(t, query.Get("reply_markup")) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - })) - - cmd := &LanguagePicker{ - CommandOpts: &tg_types.CommandOpts{ - Services: &types.Services{ - I18N: i18nMock, - }, - }, - } - - err := cmd.HandleCallback(ctx, &tgb.CallbackQueryUpdate{ - Client: test_utils.NewTelegramClient(server), - CallbackQuery: &tg.CallbackQuery{ - Message: &tg.MaybeInaccessibleMessage{ - InaccessibleMessage: &tg.InaccessibleMessage{MessageID: 1, Chat: tg.Chat{ID: tg.ChatID(1)}}, - }, - }, - }) - assert.NoError(t, err) -} - -func TestLanguagePicker_handleSetLanguage(t *testing.T) { - t.Parallel() - - ctx := context.Background() - - chat := &db_models.Chat{ - ID: uuid.New(), - ChatID: "1", - Settings: &db_models.ChatSettings{ - ChatLanguage: db_models.ChatLanguageEn, - }, - } - - sessionMock := tg_types.NewMockedSessionManager() - sessionMock.On("Get", ctx).Return(&tg_types.Session{ - Chat: chat, - }) - - i18nMock := i18nmocks.NewI18nMock() - i18nMock. - On("Translate", "language.changed", "ru", map[string]string(nil)). - Return("Now russian") - - chatService := &mocks.DbChatMock{} - chatService. - On("Update", ctx, "1", db_models.ChatServiceTelegram, mock.IsType(&db.ChatUpdateQuery{})). - Return((*db_models.Chat)(nil), nil) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal( - t, - fmt.Sprintf("/bot%s/answerCallbackQuery", test_utils.TelegramClientToken), - r.URL.Path, - ) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - })) - - cmd := &LanguagePicker{ - CommandOpts: &tg_types.CommandOpts{ - SessionManager: sessionMock, - Services: &types.Services{ - I18N: i18nMock, - Chat: chatService, - }, - }, - } - - err := cmd.handleSetLanguage(ctx, &tgb.CallbackQueryUpdate{ - Client: test_utils.NewTelegramClient(server), - CallbackQuery: &tg.CallbackQuery{ - Message: &tg.MaybeInaccessibleMessage{ - Message: &tg.Message{ - ID: 1, - Chat: tg.Chat{ - ID: tg.ChatID(1), - }, - }, - }, - Data: "language_picker_set_ru", - }, - }) - assert.NoError(t, err) - - assert.Equal(t, db_models.ChatLanguageRu, chat.Settings.ChatLanguage) - - sessionMock.AssertExpectations(t) - chatService.AssertExpectations(t) -} diff --git a/internal/telegram/commands/live.go b/internal/telegram/commands/live.go deleted file mode 100644 index 6d367550..00000000 --- a/internal/telegram/commands/live.go +++ /dev/null @@ -1,153 +0,0 @@ -package commands - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db/db_models" - tgtypes "github.com/satont/twitch-notifier/internal/telegram/types" - "go.uber.org/zap" -) - -type LiveCommand struct { - *tgtypes.CommandOpts -} - -type liveChannel struct { - Name string - Login string - StartedAt time.Time - Title string - Category string - Viewers int -} - -func (c *LiveCommand) getList(ctx context.Context) ([]*liveChannel, error) { - chat := c.SessionManager.Get(ctx).Chat - - follows, err := c.Services.Follow.GetByChatID(ctx, chat.ID, 0, 0) - if err != nil { - return nil, err - } - - if len(follows) == 0 { - return nil, nil - } - - channelsIds := lo.Map(follows, func(follow *db_models.Follow, _ int) string { - return follow.Channel.ChannelID - }) - - streams, err := c.Services.Twitch.GetStreamsByUserIds(channelsIds) - if err != nil { - return nil, err - } - - if len(streams) == 0 { - return nil, nil - } - - result := make([]*liveChannel, 0, len(streams)) - - for _, stream := range streams { - result = append(result, &liveChannel{ - Name: stream.UserName, - Login: stream.UserLogin, - StartedAt: stream.StartedAt, - Title: stream.Title, - Category: stream.GameName, - Viewers: stream.ViewerCount, - }) - } - - return result, nil -} - -func (c *LiveCommand) HandleCommand(ctx context.Context, msg *tgb.MessageUpdate) error { - list, err := c.getList(ctx) - if err != nil { - zap.S().Error(err) - return msg.Answer("internal error").DoVoid(ctx) - } - - if len(list) == 0 { - return msg.Answer("No one online").DoVoid(ctx) - } - - message := make([]string, 0, len(list)) - - for _, channel := range list { - channelMessage := make([]string, 0) - - channelMessage = append( - channelMessage, - fmt.Sprintf( - "🟢 %s - %v 👁️️", - tg.MD.Link( - channel.Name, - fmt.Sprintf("https://twitch.tv/%s", channel.Login), - ), - channel.Viewers, - ), - ) - - if channel.Category != "" { - channelMessage = append(channelMessage, fmt.Sprintf("🎮 %s", channel.Category)) - } - - if channel.Title != "" { - channelMessage = append(channelMessage, fmt.Sprintf("📝 %s", channel.Title)) - } - - since := time.Since(channel.StartedAt) - hour := int(since.Seconds() / 3600) - minute := int(since.Seconds()/60) % 60 - second := int(since.Seconds()) % 60 - - uptime := "⌛ " - if hour > 0 { - uptime += fmt.Sprintf("%vh ", hour) - } - - if minute > 0 { - uptime += fmt.Sprintf("%vm ", minute) - } - - if second > 0 { - uptime += fmt.Sprintf("%vs ", second) - } - - channelMessage = append(channelMessage, uptime) - - message = append( - message, - strings.Join(channelMessage, "\n"), - ) - } - - return msg. - Answer(strings.Join(message, "\n\n")). - ParseMode(tg.MD). - LinkPreviewOptions(tg.LinkPreviewOptions{IsDisabled: true}). - DoVoid(ctx) -} - -var liveCommandFilter = tgb.Command("live") - -func NewLiveCommand(opts *tgtypes.CommandOpts) { - cmd := &LiveCommand{ - CommandOpts: opts, - } - - messageFilter := []tgb.Filter{ - channelsAdminFilter, - liveCommandFilter, - } - - opts.Router.Message(cmd.HandleCommand, messageFilter...) -} diff --git a/internal/telegram/commands/live_test.go b/internal/telegram/commands/live_test.go deleted file mode 100644 index 15355695..00000000 --- a/internal/telegram/commands/live_test.go +++ /dev/null @@ -1,273 +0,0 @@ -package commands - -import ( - "context" - "fmt" - db_models2 "github.com/satont/twitch-notifier/internal/db/db_models" - tg_types2 "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - "io" - "net/http" - "net/http/httptest" - "net/url" - "testing" - "time" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/test_utils" - "github.com/satont/twitch-notifier/internal/test_utils/mocks" - - "github.com/google/uuid" - "github.com/nicklaw5/helix/v2" - "github.com/stretchr/testify/assert" -) - -func TestLiveCommand_GetList(t *testing.T) { - t.Parallel() - - chat := &db_models2.Chat{ - ID: uuid.New(), - ChatID: "1", - } - - ctx := context.Background() - - sessionManager := tg_types2.NewMockedSessionManager() - sessionManager.On("Get", ctx).Return(&tg_types2.Session{ - Chat: chat, - }) - - followMock := &mocks.DbFollowMock{} - twitchMock := &mocks.TwitchApiMock{} - - var now = func() time.Time { - return time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) - } - - follows := []*db_models2.Follow{ - { - ID: uuid.UUID{}, - ChannelID: uuid.UUID{}, - ChatID: uuid.UUID{}, - Channel: &db_models2.Channel{ - ChannelID: "1", - }, - Chat: nil, - }, - { - ID: uuid.UUID{}, - ChannelID: uuid.UUID{}, - ChatID: uuid.UUID{}, - Channel: &db_models2.Channel{ - ChannelID: "2", - }, - Chat: nil, - }, - } - - table := []struct { - name string - setupMocks func() - wantErr bool - wants any - }{ - { - name: "Should return empty list if no follows", - setupMocks: func() { - followMock.On("GetByChatID", ctx, chat.ID, 0, 0).Return([]*db_models2.Follow{}, nil) - }, - wantErr: false, - wants: []*liveChannel(nil), - }, - { - name: "Should return empty list if no channels online", - setupMocks: func() { - followMock.On("GetByChatID", ctx, chat.ID, 0, 0). - Return(follows, nil) - twitchMock. - On( - "GetStreamsByUserIds", - []string{"1", "2"}, - ).Return([]helix.Stream{}, nil) - }, - wantErr: false, - wants: []*liveChannel(nil), - }, - { - name: "Should return one channel", - setupMocks: func() { - followMock.On("GetByChatID", ctx, chat.ID, 0, 0). - Return(follows, nil) - twitchMock. - On( - "GetStreamsByUserIds", - []string{"1", "2"}, - ).Return([]helix.Stream{ - { - UserID: "1", - UserLogin: "satont", - UserName: "Satont", - GameName: "Dota 2", - Title: "Playing dota", - StartedAt: now(), - }, - }, nil) - }, - wantErr: false, - wants: []*liveChannel{ - { - Name: "Satont", - Login: "satont", - StartedAt: now(), - Title: "Playing dota", - Category: "Dota 2", - }, - }, - }, - } - - for _, tt := range table { - tt := tt - t.Run(tt.name, func(t *testing.T) { - tt.setupMocks() - - command := &LiveCommand{ - CommandOpts: &tg_types2.CommandOpts{ - SessionManager: sessionManager, - Services: &types.Services{ - Follow: followMock, - Twitch: twitchMock, - }, - }, - } - - list, err := command.getList(ctx) - assert.NoError(t, err) - assert.Equal(t, tt.wants, list) - - followMock.AssertExpectations(t) - twitchMock.AssertExpectations(t) - - followMock.ExpectedCalls = nil - twitchMock.ExpectedCalls = nil - }) - } -} - -func TestLiveCommand_HandleCommand(t *testing.T) { - t.Parallel() - - chat := &db_models2.Chat{ - ID: uuid.New(), - ChatID: "1", - } - - ctx := context.Background() - - sessionMock := tg_types2.NewMockedSessionManager() - followMock := &mocks.DbFollowMock{} - twitchMock := &mocks.TwitchApiMock{} - - var now = func() time.Time { - return time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) - } - - follows := []*db_models2.Follow{ - { - ID: uuid.UUID{}, - ChannelID: uuid.UUID{}, - ChatID: uuid.UUID{}, - Channel: &db_models2.Channel{ - ChannelID: "1", - }, - Chat: nil, - }, - { - ID: uuid.UUID{}, - ChannelID: uuid.UUID{}, - ChatID: uuid.UUID{}, - Channel: &db_models2.Channel{ - ChannelID: "2", - }, - Chat: nil, - }, - } - - sessionMock.On("Get", ctx).Return(&tg_types2.Session{ - Chat: chat, - }) - followMock.On("GetByChatID", ctx, chat.ID, 0, 0). - Return(follows, nil) - twitchMock. - On( - "GetStreamsByUserIds", - []string{"1", "2"}, - ).Return([]helix.Stream{ - { - UserID: "1", - UserLogin: "satont", - UserName: "Satont", - GameName: "Dota 2", - Title: "Playing dota", - StartedAt: now(), - }, - { - UserID: "2", - UserLogin: "sadisnamenya", - UserName: "SadisNaMenya", - GameName: "Dota 2", - Title: "Dotka", - StartedAt: now(), - }, - }, nil) - - expectedString1 := "🟢 [Satont](https://twitch.tv/satont) - 0 👁️️\n🎮 Dota 2\n📝 Playing dota\n⌛" - expectedString2 := "🟢 [SadisNaMenya](https://twitch.tv/sadisnamenya) - 0 👁️️\n🎮 Dota 2\n📝 Dotka\n" - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - assert.Contains(t, query.Get("text"), expectedString1) - assert.Contains(t, query.Get("text"), expectedString2) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - })) - defer server.Close() - - telegramClient := test_utils.NewTelegramClient(server) - - cmd := &LiveCommand{ - CommandOpts: &tg_types2.CommandOpts{ - SessionManager: sessionMock, - Services: &types.Services{ - Follow: followMock, - Twitch: twitchMock, - }, - }, - } - - err := cmd.HandleCommand(ctx, &tgb.MessageUpdate{ - Client: telegramClient, - Message: &tg.Message{ - Chat: tg.Chat{ - ID: 1, - }, - }, - }) - assert.NoError(t, err) - - sessionMock.AssertExpectations(t) - followMock.AssertExpectations(t) - twitchMock.AssertExpectations(t) -} diff --git a/internal/telegram/commands/start.go b/internal/telegram/commands/start.go deleted file mode 100644 index 3a59d977..00000000 --- a/internal/telegram/commands/start.go +++ /dev/null @@ -1,346 +0,0 @@ -package commands - -import ( - "context" - "fmt" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - tg_types "github.com/satont/twitch-notifier/internal/telegram/types" - "go.uber.org/zap" -) - -type StartCommand struct { - *tg_types.CommandOpts -} - -func (c *StartCommand) createCheckMark(value bool) string { - if value { - return "✅" - } - - return "❌" -} - -func (c *StartCommand) buildKeyboard(ctx context.Context) *tg.InlineKeyboardMarkup { - chat := c.SessionManager.Get(ctx).Chat - - layout := tg.NewButtonLayout[tg.InlineKeyboardButton](1) - - gameChangeNotificationsButton := c.Services.I18N.Translate( - "commands.start.game_change_notification_setting.button", - chat.Settings.ChatLanguage.String(), - nil, - ) - offlineNotificationsButton := c.Services.I18N.Translate( - "commands.start.offline_notification.button", - chat.Settings.ChatLanguage.String(), - nil, - ) - - titleChangeNotificationsButton := c.Services.I18N.Translate( - "commands.start.title_change_notification_setting.button", - chat.Settings.ChatLanguage.String(), - nil, - ) - - gameAndTitleChangeNotificationsButton := c.Services.I18N.Translate( - "commands.start.game_and_title_change_notification_setting.button", - chat.Settings.ChatLanguage.String(), - nil, - ) - - imageInNotificationButton := c.Services.I18N.Translate( - "commands.start.image_in_notification_setting.button", - chat.Settings.ChatLanguage.String(), - nil, - ) - - layout.Add( - tg.NewInlineKeyboardButtonCallback( - fmt.Sprintf( - "%s %s", - c.createCheckMark(chat.Settings.GameChangeNotification), - gameChangeNotificationsButton, - ), - "start_game_change_notification_setting", - ), - tg.NewInlineKeyboardButtonCallback( - fmt.Sprintf( - "%s %s", - c.createCheckMark(chat.Settings.OfflineNotification), - offlineNotificationsButton, - ), - "start_offline_notification", - ), - tg.NewInlineKeyboardButtonCallback( - fmt.Sprintf( - "%s %s", - c.createCheckMark(chat.Settings.TitleChangeNotification), - titleChangeNotificationsButton, - ), - "start_title_change_notification_setting", - ), - tg.NewInlineKeyboardButtonCallback( - fmt.Sprintf( - "%s %s", - c.createCheckMark(chat.Settings.GameAndTitleChangeNotification), - gameAndTitleChangeNotificationsButton, - ), - "start_game_and_title_change_notification_setting", - ), - tg.NewInlineKeyboardButtonCallback( - fmt.Sprintf( - "%s %s", - c.createCheckMark(chat.Settings.ImageInNotification), - imageInNotificationButton, - ), - "image_in_notification_setting", - ), - tg.NewInlineKeyboardButtonCallback( - c.Services.I18N.Translate( - "commands.start.language.button", - chat.Settings.ChatLanguage.String(), - nil, - ), - "language_picker", - ), - tg.NewInlineKeyboardButtonURL("Github", "https://github.com/Satont/twitch-notifier"), - ) - - markup := tg.NewInlineKeyboardMarkup(layout.Keyboard()...) - - return &markup -} - -func (c *StartCommand) HandleCommand(ctx context.Context, msg *tgb.MessageUpdate) error { - session := c.SessionManager.Get(ctx) - - keyBoard := c.buildKeyboard(ctx) - - description := c.Services.I18N.Translate( - "bot.description", - session.Chat.Settings.ChatLanguage.String(), - nil, - ) - - return msg.Answer(description).ReplyMarkup(keyBoard).DoVoid(ctx) -} - -func (c *StartCommand) handleCallback(ctx context.Context, msg *tgb.CallbackQueryUpdate) error { - keyboard := c.buildKeyboard(ctx) - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat().ID, msg.Message.MessageID()). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -func (c *StartCommand) handleImageInNotificationSettings( - ctx context.Context, - msg *tgb.CallbackQueryUpdate, -) error { - chat := c.SessionManager.Get(ctx).Chat - chat.Settings.ImageInNotification = !chat.Settings.ImageInNotification - - _, err := c.Services.Chat.Update( - ctx, - chat.ChatID, - db_models.ChatServiceTelegram, - &db.ChatUpdateQuery{ - Settings: &db.ChatUpdateSettingsQuery{ - ImageInNotification: &chat.Settings.ImageInNotification, - }, - }) - if err != nil { - zap.S().Error(err) - return msg.Answer().Text("internal error").DoVoid(ctx) - } - - keyboard := c.buildKeyboard(ctx) - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat().ID, msg.Message.MessageID()). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -func (c *StartCommand) handleTitleNotificationSettings( - ctx context.Context, - msg *tgb.CallbackQueryUpdate, -) error { - chat := c.SessionManager.Get(ctx).Chat - chat.Settings.TitleChangeNotification = !chat.Settings.TitleChangeNotification - - _, err := c.Services.Chat.Update( - ctx, - chat.ChatID, - db_models.ChatServiceTelegram, - &db.ChatUpdateQuery{ - Settings: &db.ChatUpdateSettingsQuery{ - TitleChangeNotification: &chat.Settings.TitleChangeNotification, - }, - }, - ) - - if err != nil { - zap.S().Error(err) - return msg.Answer().Text("internal error").DoVoid(ctx) - } - - keyboard := c.buildKeyboard(ctx) - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat().ID, msg.Message.MessageID()). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -func (c *StartCommand) handleGameNotificationSettings( - ctx context.Context, - msg *tgb.CallbackQueryUpdate, -) error { - chat := c.SessionManager.Get(ctx).Chat - - chat.Settings.GameChangeNotification = !chat.Settings.GameChangeNotification - - _, err := c.Services.Chat.Update( - ctx, - chat.ChatID, - db_models.ChatServiceTelegram, - &db.ChatUpdateQuery{ - Settings: &db.ChatUpdateSettingsQuery{ - GameChangeNotification: &chat.Settings.GameChangeNotification, - }, - }, - ) - if err != nil { - zap.S().Error(err) - return msg.Answer().Text("internal error").DoVoid(ctx) - } - - keyboard := c.buildKeyboard(ctx) - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat().ID, msg.Message.MessageID()). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -func (c *StartCommand) handleGameAndTitleNotificationSettings( - ctx context.Context, - msg *tgb.CallbackQueryUpdate, -) error { - chat := c.SessionManager.Get(ctx).Chat - chat.Settings.GameAndTitleChangeNotification = !chat.Settings.GameAndTitleChangeNotification - - _, err := c.Services.Chat.Update( - ctx, - chat.ChatID, - db_models.ChatServiceTelegram, - &db.ChatUpdateQuery{ - Settings: &db.ChatUpdateSettingsQuery{ - GameAndTitleChangeNotification: &chat.Settings.GameAndTitleChangeNotification, - }, - }, - ) - if err != nil { - zap.S().Error(err) - return msg.Answer().Text("internal error").DoVoid(ctx) - } - - keyboard := c.buildKeyboard(ctx) - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat().ID, msg.Message.MessageID()). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -func (c *StartCommand) handleOfflineNotificationSettings( - ctx context.Context, - msg *tgb.CallbackQueryUpdate, -) error { - chat := c.SessionManager.Get(ctx).Chat - - chat.Settings.OfflineNotification = !chat.Settings.OfflineNotification - - _, err := c.Services.Chat.Update( - ctx, - chat.ChatID, - db_models.ChatServiceTelegram, - &db.ChatUpdateQuery{ - Settings: &db.ChatUpdateSettingsQuery{ - OfflineNotification: &chat.Settings.OfflineNotification, - }, - }, - ) - if err != nil { - zap.S().Error(err) - return msg.Answer().Text("internal error").DoVoid(ctx) - } - - keyboard := c.buildKeyboard(ctx) - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat().ID, msg.Message.MessageID()). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -var ( - startCommandFilter = tgb.Command("start", - tgb.WithCommandAlias("help"), - tgb.WithCommandAlias("info"), - tgb.WithCommandAlias("settings"), - ) - startMenuFilter = tgb.TextEqual("start_command_menu") - gameChangeNotificationSettingFilter = tgb.TextEqual("start_game_change_notification_setting") - offlineNotificationSettingFilter = tgb.TextEqual("start_offline_notification") - titleNotificationSettingFilter = tgb.TextEqual("start_title_change_notification_setting") - gameAndTitleSettingFilter = tgb.TextEqual("start_game_and_title_change_notification_setting") - imageInNotificationSettingFilter = tgb.TextEqual("image_in_notification_setting") -) - -func NewStartCommand(opts *tg_types.CommandOpts) { - cmd := &StartCommand{ - CommandOpts: opts, - } - - messageFilter := []tgb.Filter{ - channelsAdminFilter, - startCommandFilter, - } - - opts.Router.Message(cmd.HandleCommand, messageFilter...) - opts.Router.ChannelPost(cmd.HandleCommand, messageFilter...) - - opts.Router.CallbackQuery(cmd.handleCallback, channelsAdminFilter, startMenuFilter) - opts.Router.CallbackQuery( - cmd.handleGameNotificationSettings, - channelsAdminFilter, - gameChangeNotificationSettingFilter, - ) - opts.Router.CallbackQuery( - cmd.handleOfflineNotificationSettings, - channelsAdminFilter, - offlineNotificationSettingFilter, - ) - opts.Router.CallbackQuery( - cmd.handleTitleNotificationSettings, - channelsAdminFilter, - titleNotificationSettingFilter, - ) - opts.Router.CallbackQuery( - cmd.handleGameAndTitleNotificationSettings, - channelsAdminFilter, - gameAndTitleSettingFilter, - ) - opts.Router.CallbackQuery( - cmd.handleImageInNotificationSettings, - channelsAdminFilter, - imageInNotificationSettingFilter, - ) -} diff --git a/internal/telegram/commands/start_test.go b/internal/telegram/commands/start_test.go deleted file mode 100644 index bf3a1f9a..00000000 --- a/internal/telegram/commands/start_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package commands - -import ( - "context" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/satont/twitch-notifier/internal/db/db_models" - tg_types "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - - "github.com/google/uuid" - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/test_utils" - i18nmocks "github.com/satont/twitch-notifier/pkg/i18n/mocks" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" -) - -func TestStartCommand_buildKeyboard(t *testing.T) { - t.Parallel() - - ctx := context.Background() - chat := &db_models.Chat{ - ID: uuid.New(), - ChatID: "1", - Settings: &db_models.ChatSettings{ - ChatLanguage: db_models.ChatLanguageEn, - }, - } - - i18 := i18nmocks.NewI18nMock() - i18. - On("Translate", mock.Anything, mock.Anything, mock.Anything). - Return("") - - sessionManager := tg_types.NewMockedSessionManager() - sessionManager.On("Get", ctx).Return(&tg_types.Session{ - Chat: chat, - }) - - cmd := &StartCommand{ - CommandOpts: &tg_types.CommandOpts{ - SessionManager: sessionManager, - Services: &types.Services{ - I18N: i18, - }, - }, - } - - keyboard := cmd.buildKeyboard(ctx) - - const buttons = 7 - assert.Equal(t, buttons, len(keyboard.InlineKeyboard)) - - assert.Equal( - t, - "start_game_change_notification_setting", - keyboard.InlineKeyboard[0][0].CallbackData, - ) - - assert.Equal( - t, - "start_offline_notification", - keyboard.InlineKeyboard[1][0].CallbackData, - ) - - assert.Equal( - t, - "start_title_change_notification_setting", - keyboard.InlineKeyboard[2][0].CallbackData, - ) - - assert.Equal( - t, - "start_game_and_title_change_notification_setting", - keyboard.InlineKeyboard[3][0].CallbackData, - ) - - assert.Equal( - t, - "image_in_notification_setting", - keyboard.InlineKeyboard[4][0].CallbackData, - ) - - assert.Equal( - t, - "language_picker", - keyboard.InlineKeyboard[5][0].CallbackData, - ) - - assert.Equal(t, "Github", keyboard.InlineKeyboard[6][0].Text) - assert.Equal(t, "https://github.com/Satont/twitch-notifier", keyboard.InlineKeyboard[6][0].URL) - - sessionManager.AssertExpectations(t) - i18.AssertNumberOfCalls(t, "Translate", buttons-1) -} - -func TestStartCommand_HandleCommand(t *testing.T) { - t.Parallel() - - ctx := context.Background() - chat := &db_models.Chat{ - ID: uuid.New(), - Settings: &db_models.ChatSettings{ - ChatLanguage: db_models.ChatLanguageEn, - }, - } - - i18 := i18nmocks.NewI18nMock() - i18. - On("Translate", mock.Anything, mock.Anything, mock.Anything). - Return("start command") - - sessionManager := tg_types.NewMockedSessionManager() - sessionManager.On("Get", ctx).Return(&tg_types.Session{ - Chat: chat, - }) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - assert.Equal(t, "start command", query.Get("text")) - assert.NotEmpty(t, query.Get("reply_markup")) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - })) - - cmd := &StartCommand{ - CommandOpts: &tg_types.CommandOpts{ - SessionManager: sessionManager, - Services: &types.Services{ - I18N: i18, - }, - }, - } - - err := cmd.HandleCommand(ctx, &tgb.MessageUpdate{ - Client: test_utils.NewTelegramClient(server), - Message: &tg.Message{ - Text: "/start", - }, - }) - assert.NoError(t, err) -} - -func TestStartCommand_createCheckMark(t *testing.T) { - t.Parallel() - - cmd := &StartCommand{} - - assert.Equal(t, "✅", cmd.createCheckMark(true)) - assert.Equal(t, "❌", cmd.createCheckMark(false)) -} diff --git a/internal/telegram/middlewares/chat.go b/internal/telegram/middlewares/chat.go deleted file mode 100644 index b6852527..00000000 --- a/internal/telegram/middlewares/chat.go +++ /dev/null @@ -1,37 +0,0 @@ -package middlewares - -import ( - "context" - "fmt" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/telegram/types" - "go.uber.org/zap" -) - -type ChatMiddleware struct { - *tg_types.MiddlewareOpts -} - -func (c *ChatMiddleware) Wrap(next tgb.Handler) tgb.Handler { - return tgb.HandlerFunc(func(ctx context.Context, update *tgb.Update) error { - chatId := fmt.Sprintf("%v", update.Chat().ID) - user, err := c.Services.Chat.GetByID(ctx, chatId, db_models.ChatServiceTelegram) - if err != nil { - zap.L().Error("failed to get chat", zap.Error(err)) - return nil - } - - if user == nil { - user, err = c.Services.Chat.Create(ctx, chatId, db_models.ChatServiceTelegram) - if err != nil { - zap.L().Error("failed to create chat", zap.Error(err)) - return nil - } - } - - c.SessionManager.Get(ctx).Chat = user - - return next.Handle(ctx, update) - }) -} diff --git a/internal/telegram/middlewares/logg.go b/internal/telegram/middlewares/logg.go deleted file mode 100644 index a525d52f..00000000 --- a/internal/telegram/middlewares/logg.go +++ /dev/null @@ -1,24 +0,0 @@ -package middlewares - -import ( - "context" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/types" - "go.uber.org/zap" - "time" -) - -type LoggMiddleware struct { - Services *types.Services -} - -func (c *LoggMiddleware) Wrap(next tgb.Handler) tgb.Handler { - return tgb.HandlerFunc(func(ctx context.Context, update *tgb.Update) error { - defer func(started time.Time) { - zap.L(). - Info("update handled", zap.Duration("duration", time.Since(started))) - }(time.Now()) - - return next.Handle(ctx, update) - }) -} diff --git a/internal/telegram/set_commands.go b/internal/telegram/set_commands.go deleted file mode 100644 index 93c4544f..00000000 --- a/internal/telegram/set_commands.go +++ /dev/null @@ -1,58 +0,0 @@ -package telegram - -import ( - "context" - "github.com/mr-linch/go-tg" - "go.uber.org/zap" - "strconv" -) - -var defaultCommands = []tg.BotCommand{ - { - Command: "follow", - Description: "Follow to notifications of some streamer", - }, - { - Command: "follows", - Description: "Show list of followed streamers", - }, - { - Command: "live", - Description: "Show list of live streamers", - }, - { - Command: "start", - Description: "Bot settings", - }, -} - -func (c *TelegramService) setMyCommands(ctx context.Context) { - err := c.Client. - SetMyCommands(defaultCommands). - Scope(tg.BotCommandScopeDefault{}). - DoVoid(ctx) - if err != nil { - zap.S().Fatalln("Can't set default commands", err) - } - - for _, admin := range c.services.Config.TelegramBotAdmins { - newCommands := append(defaultCommands, tg.BotCommand{ - Command: "broadcast", - Description: "Send message to all users", - }) - - chatID, err := strconv.Atoi(admin) - if err != nil { - zap.S().Errorw("Can't parse chat id", "chatID", admin) - return - } - - err = c.Client. - SetMyCommands(newCommands). - Scope(tg.BotCommandScopeChat{ChatID: tg.ChatID(chatID)}). - DoVoid(ctx) - if err != nil { - zap.S().Fatalln("Can't set admin commands", err) - } - } -} diff --git a/internal/telegram/telegram.go b/internal/telegram/telegram.go deleted file mode 100644 index 042fd3e6..00000000 --- a/internal/telegram/telegram.go +++ /dev/null @@ -1,92 +0,0 @@ -package telegram - -import ( - "context" - "github.com/hashicorp/go-retryablehttp" - "github.com/satont/twitch-notifier/internal/telegram/commands" - "github.com/satont/twitch-notifier/internal/telegram/middlewares" - "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - "time" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/mr-linch/go-tg/tgb/session" - "go.uber.org/zap" -) - -type TelegramService struct { - services *types.Services - poller *tgb.Poller - Client *tg.Client -} - -func NewTelegram(ctx context.Context, token string, services *types.Services) *TelegramService { - retryClient := retryablehttp.NewClient() - retryClient.RetryMax = 3 - retryClient.RetryWaitMax = 3600 * time.Second - retryClient.RetryWaitMin = 50 * time.Millisecond - retryClient.Logger = nil - - httpClient := retryClient.StandardClient() - - client := tg.New(token, tg.WithClientDoer(httpClient)) - - var sessionManager = session.NewManager(tg_types.Session{ - FollowsMenu: &tg_types.Menu{}, - Scene: "", - }) - - router := tgb.NewRouter(). - Use(sessionManager). - //Use(&middlewares.LoggMiddleware{ - // Services: services, - //}). - Use(&middlewares.ChatMiddleware{ - MiddlewareOpts: &tg_types.MiddlewareOpts{ - Services: services, - SessionManager: sessionManager, - }}) - - commandOpts := &tg_types.CommandOpts{ - Services: services, - Router: router, - SessionManager: sessionManager, - } - - router.Message(func(ctx context.Context, update *tgb.MessageUpdate) error { - sessionManager.Get(ctx).Scene = "" - return nil - }, tgb.Command("cancel")) - - commands.NewStartCommand(commandOpts) - commands.NewFollowCommand(commandOpts) - commands.NewFollowsCommand(commandOpts) - commands.NewLiveCommand(commandOpts) - commands.NewBroadcastCommand(commandOpts) - commands.NewLanguagePicker(commandOpts) - commands.NewChangeChannelId(commandOpts) - - poller := tgb.NewPoller(router, client) - - me, err := client.GetMe().Do(ctx) - if err != nil { - zap.S().Fatalw("failed to get bot info", "err", err) - } - - service := &TelegramService{ - poller: poller, - services: services, - Client: client, - } - - service.setMyCommands(ctx) - - zap.S().Infow("Telegram bot started", "id", me.ID, "username", me.Username) - - return service -} - -func (c *TelegramService) StartPolling(ctx context.Context) { - go c.poller.Run(ctx) -} diff --git a/internal/telegram/types/mocked_session.go b/internal/telegram/types/mocked_session.go deleted file mode 100644 index 1d5b07d8..00000000 --- a/internal/telegram/types/mocked_session.go +++ /dev/null @@ -1,48 +0,0 @@ -package tg_types - -import ( - "context" - - "github.com/mr-linch/go-tg/tgb" - "github.com/mr-linch/go-tg/tgb/session" - "github.com/stretchr/testify/mock" -) - -type MockedSessionManager[T Session] struct { - mock.Mock -} - -func (m *MockedSessionManager[T]) SetEqualFunc(fn func(t T, t2 T) bool) { - //TODO implement me - panic("implement me") -} - -func (m *MockedSessionManager[T]) Setup(opt session.ManagerOption, opts ...session.ManagerOption) { - //TODO implement me - panic("implement me") -} - -func (m *MockedSessionManager[T]) Get(ctx context.Context) *T { - args := m.Called(ctx) - - return args.Get(0).(*T) -} - -func (m *MockedSessionManager[T]) Reset(session *T) { - //TODO implement me - panic("implement me") -} - -func (m *MockedSessionManager[T]) Filter(fn func(t *T) bool) tgb.Filter { - //TODO implement me - panic("implement me") -} - -func (m *MockedSessionManager[T]) Wrap(next tgb.Handler) tgb.Handler { - //TODO implement me - panic("implement me") -} - -func NewMockedSessionManager() *MockedSessionManager[Session] { - return &MockedSessionManager[Session]{} -} diff --git a/internal/telegram/types/router.go b/internal/telegram/types/router.go deleted file mode 100644 index 6e57a30d..00000000 --- a/internal/telegram/types/router.go +++ /dev/null @@ -1,141 +0,0 @@ -package tg_types - -import ( - "context" - - "github.com/mr-linch/go-tg/tgb" - "github.com/stretchr/testify/mock" -) - -type Router interface { - Use(mws ...tgb.Middleware) *tgb.Router - Message(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router - EditedMessage(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router - ChannelPost(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router - EditedChannelPost(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router - InlineQuery(handler tgb.InlineQueryHandler, filters ...tgb.Filter) *tgb.Router - ChosenInlineResult(handler tgb.ChosenInlineResultHandler, filters ...tgb.Filter) *tgb.Router - CallbackQuery(handler tgb.CallbackQueryHandler, filters ...tgb.Filter) *tgb.Router - ShippingQuery(handler tgb.ShippingQueryHandler, filters ...tgb.Filter) *tgb.Router - PreCheckoutQuery(handler tgb.PreCheckoutQueryHandler, filters ...tgb.Filter) *tgb.Router - Poll(handler tgb.PollHandler, filters ...tgb.Filter) *tgb.Router - PollAnswer(handler tgb.PollAnswerHandler, filters ...tgb.Filter) *tgb.Router - MyChatMember(handler tgb.ChatMemberUpdatedHandler, filters ...tgb.Filter) *tgb.Router - ChatMember(handler tgb.ChatMemberUpdatedHandler, filters ...tgb.Filter) *tgb.Router - ChatJoinRequest(handler tgb.ChatJoinRequestHandler, filters ...tgb.Filter) *tgb.Router - Error(handler tgb.ErrorHandler) *tgb.Router - Update(handler tgb.HandlerFunc, filters ...tgb.Filter) *tgb.Router - Handle(ctx context.Context, update *tgb.Update) error -} - -type MockedRouter struct { - mock.Mock -} - -func (m *MockedRouter) Use(mws ...tgb.Middleware) *tgb.Router { - args := m.Called(mws) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) Message(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) EditedMessage(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) ChannelPost(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) EditedChannelPost(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) InlineQuery(handler tgb.InlineQueryHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) ChosenInlineResult(handler tgb.ChosenInlineResultHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) CallbackQuery(handler tgb.CallbackQueryHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) ShippingQuery(handler tgb.ShippingQueryHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) PreCheckoutQuery(handler tgb.PreCheckoutQueryHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) Poll(handler tgb.PollHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) PollAnswer(handler tgb.PollAnswerHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) MyChatMember(handler tgb.ChatMemberUpdatedHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) ChatMember(handler tgb.ChatMemberUpdatedHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) ChatJoinRequest(handler tgb.ChatJoinRequestHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) Error(handler tgb.ErrorHandler) *tgb.Router { - args := m.Called(handler) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) Update(handler tgb.HandlerFunc, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) Handle(ctx context.Context, update *tgb.Update) error { - args := m.Called(ctx, update) - - return args.Error(0) -} diff --git a/internal/telegram/types/session.go b/internal/telegram/types/session.go deleted file mode 100644 index bd8f3463..00000000 --- a/internal/telegram/types/session.go +++ /dev/null @@ -1,42 +0,0 @@ -package tg_types - -import ( - "context" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/types" - - "github.com/mr-linch/go-tg/tgb" - "github.com/mr-linch/go-tg/tgb/session" -) - -type SessionManager[T comparable] interface { - SetEqualFunc(fn func(t T, t2 T) bool) - Setup(opt session.ManagerOption, opts ...session.ManagerOption) - Get(ctx context.Context) *T - Reset(session *T) - Filter(fn func(t *T) bool) tgb.Filter - Wrap(next tgb.Handler) tgb.Handler -} - -type Menu struct { - CurrentPage int - TotalPages int -} - -type Session struct { - Chat *db_models.Chat - Scene string - - FollowsMenu *Menu -} - -type CommandOpts struct { - Services *types.Services - Router Router - SessionManager SessionManager[Session] -} - -type MiddlewareOpts struct { - Services *types.Services - SessionManager SessionManager[Session] -} diff --git a/internal/test_utils/mocks/db_channel.go b/internal/test_utils/mocks/db_channel.go deleted file mode 100644 index ea2c15cb..00000000 --- a/internal/test_utils/mocks/db_channel.go +++ /dev/null @@ -1,81 +0,0 @@ -package mocks - -import ( - "context" - - "github.com/satont/twitch-notifier/internal/db" - db_models2 "github.com/satont/twitch-notifier/internal/db/db_models" - - "github.com/stretchr/testify/mock" -) - -type DbChannelMock struct { - mock.Mock -} - -func (c *DbChannelMock) GetByID( - ctx context.Context, - id string, - service db_models2.ChannelService, -) (*db_models2.Channel, error) { - args := c.Called(ctx, id, service) - - return args.Get(0).(*db_models2.Channel), args.Error(1) -} - -func (c *DbChannelMock) GetByChannelID( - ctx context.Context, - channelID string, - service db_models2.ChannelService, -) (*db_models2.Channel, error) { - args := c.Called(ctx, channelID, service) - - return args.Get(0).(*db_models2.Channel), args.Error(1) -} - -func (c *DbChannelMock) GetFollowsByID( - ctx context.Context, - channelID string, - service db_models2.ChannelService, -) ([]*db_models2.Follow, error) { - args := c.Called(ctx, channelID, service) - - return args.Get(0).([]*db_models2.Follow), args.Error(1) -} - -func (c *DbChannelMock) Create( - ctx context.Context, - channelID string, - service db_models2.ChannelService, -) (*db_models2.Channel, error) { - args := c.Called(ctx, channelID, service) - - return args.Get(0).(*db_models2.Channel), args.Error(1) -} - -func (c *DbChannelMock) Update( - ctx context.Context, - channelID string, - service db_models2.ChannelService, - updateQuery *db.ChannelUpdateQuery, -) (*db_models2.Channel, error) { - args := c.Called(ctx, channelID, service, updateQuery) - - return args.Get(0).(*db_models2.Channel), args.Error(1) -} - -func (c *DbChannelMock) GetByIdOrCreate( - ctx context.Context, - channelID string, - service db_models2.ChannelService, -) (*db_models2.Channel, error) { - args := c.Called(ctx, channelID, service) - - return args.Get(0).(*db_models2.Channel), args.Error(1) -} - -func (c *DbChannelMock) GetAll(ctx context.Context) ([]*db_models2.Channel, error) { - args := c.Called(ctx) - - return args.Get(0).([]*db_models2.Channel), args.Error(1) -} diff --git a/internal/test_utils/mocks/db_chat.go b/internal/test_utils/mocks/db_chat.go deleted file mode 100644 index 933f6d9f..00000000 --- a/internal/test_utils/mocks/db_chat.go +++ /dev/null @@ -1,53 +0,0 @@ -package mocks - -import ( - "context" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - - "github.com/stretchr/testify/mock" -) - -type DbChatMock struct { - mock.Mock -} - -func (c *DbChatMock) GetByID( - ctx context.Context, - chatId string, - service db_models.ChatService, -) (*db_models.Chat, error) { - args := c.Called(ctx, chatId, service) - - return args.Get(0).(*db_models.Chat), args.Error(1) -} - -func (c *DbChatMock) Create( - ctx context.Context, - chatId string, - service db_models.ChatService, -) (*db_models.Chat, error) { - args := c.Called(ctx, chatId, service) - - return args.Get(0).(*db_models.Chat), args.Error(1) -} - -func (c *DbChatMock) Update( - ctx context.Context, - chatId string, - service db_models.ChatService, - query *db.ChatUpdateQuery, -) (*db_models.Chat, error) { - args := c.Called(ctx, chatId, service, query) - - return args.Get(0).(*db_models.Chat), args.Error(1) -} - -func (c *DbChatMock) GetAllByService( - ctx context.Context, - service db_models.ChatService, -) ([]*db_models.Chat, error) { - args := c.Called(ctx, service) - - return args.Get(0).([]*db_models.Chat), args.Error(1) -} diff --git a/internal/test_utils/mocks/db_follow.go b/internal/test_utils/mocks/db_follow.go deleted file mode 100644 index 67ee25c9..00000000 --- a/internal/test_utils/mocks/db_follow.go +++ /dev/null @@ -1,57 +0,0 @@ -package mocks - -import ( - "context" - "github.com/satont/twitch-notifier/internal/db/db_models" - - "github.com/google/uuid" - "github.com/stretchr/testify/mock" -) - -type DbFollowMock struct { - mock.Mock -} - -func (f *DbFollowMock) Create( - ctx context.Context, - channelID uuid.UUID, - chatID uuid.UUID, -) (*db_models.Follow, error) { - args := f.Called(ctx, channelID, chatID) - - return args.Get(0).(*db_models.Follow), args.Error(1) -} - -func (f *DbFollowMock) Delete(ctx context.Context, id uuid.UUID) error { - args := f.Called(ctx, id) - - return args.Error(0) -} - -func (f *DbFollowMock) GetByChatAndChannel( - ctx context.Context, - channelId uuid.UUID, - chatId uuid.UUID, -) (*db_models.Follow, error) { - args := f.Called(ctx, channelId, chatId) - - return args.Get(0).(*db_models.Follow), args.Error(1) -} - -func (f *DbFollowMock) GetByChannelID(ctx context.Context, channelId uuid.UUID) ([]*db_models.Follow, error) { - args := f.Called(ctx, channelId) - - return args.Get(0).([]*db_models.Follow), args.Error(1) -} - -func (f *DbFollowMock) GetByChatID(ctx context.Context, chatID uuid.UUID, limit, offset int) ([]*db_models.Follow, error) { - args := f.Called(ctx, chatID, limit, offset) - - return args.Get(0).([]*db_models.Follow), args.Error(1) -} - -func (f *DbFollowMock) CountByChatID(ctx context.Context, chatID uuid.UUID) (int, error) { - args := f.Called(ctx, chatID) - - return args.Int(0), args.Error(1) -} diff --git a/internal/test_utils/mocks/db_stream.go b/internal/test_utils/mocks/db_stream.go deleted file mode 100644 index f35a7b8e..00000000 --- a/internal/test_utils/mocks/db_stream.go +++ /dev/null @@ -1,52 +0,0 @@ -package mocks - -import ( - "context" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - - "github.com/google/uuid" - "github.com/stretchr/testify/mock" -) - -type DbStreamMock struct { - mock.Mock -} - -func (s *DbStreamMock) GetByID(ctx context.Context, streamId string) (*db_models.Stream, error) { - args := s.Called(ctx, streamId) - - return args.Get(0).(*db_models.Stream), args.Error(1) -} - -func (s *DbStreamMock) GetLatestByChannelID(ctx context.Context, channelEntityID uuid.UUID) (*db_models.Stream, error) { - args := s.Called(ctx, channelEntityID) - - return args.Get(0).(*db_models.Stream), args.Error(1) -} - -func (s *DbStreamMock) GetManyByChannelID(ctx context.Context, channelEntityID uuid.UUID, limit int) ([]*db_models.Stream, error) { - args := s.Called(ctx, channelEntityID, limit) - - return args.Get(0).([]*db_models.Stream), args.Error(1) -} - -func (s *DbStreamMock) UpdateOneByStreamID( - ctx context.Context, - streamID string, - updateQuery *db.StreamUpdateQuery, -) (*db_models.Stream, error) { - args := s.Called(ctx, streamID, updateQuery) - - return args.Get(0).(*db_models.Stream), args.Error(1) -} - -func (s *DbStreamMock) CreateOneByChannelID( - ctx context.Context, - channelEntityID uuid.UUID, - updateQuery *db.StreamUpdateQuery, -) (*db_models.Stream, error) { - args := s.Called(ctx, channelEntityID, updateQuery) - - return args.Get(0).(*db_models.Stream), args.Error(1) -} diff --git a/internal/test_utils/mocks/message_sender.go b/internal/test_utils/mocks/message_sender.go deleted file mode 100644 index 137256ad..00000000 --- a/internal/test_utils/mocks/message_sender.go +++ /dev/null @@ -1,19 +0,0 @@ -package mocks - -import ( - "context" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/message_sender" - - "github.com/stretchr/testify/mock" -) - -type MessageSenderMock struct { - mock.Mock -} - -func (m *MessageSenderMock) SendMessage(ctx context.Context, chat *db_models.Chat, opts *message_sender.MessageOpts) error { - args := m.Called(ctx, chat, opts) - - return args.Error(0) -} diff --git a/internal/test_utils/mocks/twitch_api_client.go b/internal/test_utils/mocks/twitch_api_client.go deleted file mode 100644 index 6128706f..00000000 --- a/internal/test_utils/mocks/twitch_api_client.go +++ /dev/null @@ -1,43 +0,0 @@ -package mocks - -import ( - "strings" - - "github.com/nicklaw5/helix/v2" - "github.com/stretchr/testify/mock" -) - -type TwitchApiMock struct { - mock.Mock -} - -func (m *TwitchApiMock) GetUser(id, login string) (*helix.User, error) { - args := m.Called(id, login) - return args.Get(0).(*helix.User), args.Error(1) -} - -func (m *TwitchApiMock) GetUsers(ids, logins []string) ([]helix.User, error) { - args := m.Called(ids, logins) - return args.Get(0).([]helix.User), args.Error(1) -} - -func (m *TwitchApiMock) GetStreamByUserId(id string) (*helix.Stream, error) { - args := m.Called(id) - return args.Get(0).(*helix.Stream), args.Error(1) -} - -func (m *TwitchApiMock) GetStreamsByUserIds(ids []string) ([]helix.Stream, error) { - args := m.Called(ids) - return args.Get(0).([]helix.Stream), args.Error(1) -} - -func (m *TwitchApiMock) GetChannelByUserId(id string) (*helix.ChannelInformation, error) { - strings.ReplaceAll(id, " ", "") - args := m.Called(id) - return args.Get(0).(*helix.ChannelInformation), args.Error(1) -} - -func (m *TwitchApiMock) GetChannelsByUserIds(ids []string) ([]helix.ChannelInformation, error) { - args := m.Called(ids) - return args.Get(0).([]helix.ChannelInformation), args.Error(1) -} diff --git a/internal/test_utils/telegram_client.go b/internal/test_utils/telegram_client.go deleted file mode 100644 index 0268445f..00000000 --- a/internal/test_utils/telegram_client.go +++ /dev/null @@ -1,21 +0,0 @@ -package test_utils - -import ( - "github.com/mr-linch/go-tg" - "net/http" - "net/http/httptest" -) - -const ( - TelegramClientToken = "1234:secret" - TelegramOkResponse = `{"ok":true}` -) - -func NewTelegramClient(server *httptest.Server) *tg.Client { - client := tg.New(TelegramClientToken, - tg.WithClientServerURL(server.URL), - tg.WithClientDoer(&http.Client{}), - ) - - return client -} diff --git a/internal/twitch/chunked_req.go b/internal/twitch/chunked_req.go deleted file mode 100644 index be48d3f0..00000000 --- a/internal/twitch/chunked_req.go +++ /dev/null @@ -1,63 +0,0 @@ -package twitch - -import ( - "errors" - "github.com/samber/lo" - "reflect" - "sync" -) - -type chunkedRequestData[Request any, Response any] struct { - ids []string - requestFn func(Request) (Response, error) - responseSelectorFn func(Response) interface{} - paramFn func(chunk []string) Request -} - -func getDataChunked[T, Req, Res any](req *chunkedRequestData[Req, Res]) ([]T, error) { - results := make([]T, 0, len(req.ids)) - - chunkedIds := lo.Chunk(req.ids, 100) - - wg := &sync.WaitGroup{} - mu := &sync.Mutex{} - errChan := make(chan error, len(chunkedIds)) - - for _, chunk := range chunkedIds { - wg.Add(1) - go func(chunk []string) { - defer wg.Done() - - data, err := req.requestFn(req.paramFn(chunk)) - - if err != nil { - errChan <- err - return - } - - resultValue := reflect.ValueOf(data) - - if reflect.Indirect(resultValue).FieldByName("ErrorMessage").String() != "" { - errChan <- errors.New(reflect.Indirect(resultValue).FieldByName("ErrorMessage").String()) - return - } - - selectedField := req.responseSelectorFn(data) - - mu.Lock() - results = append( - results, - selectedField.([]T)..., - ) - mu.Unlock() - }(chunk) - } - - wg.Wait() - - if len(errChan) > 0 { - return nil, <-errChan - } - - return results, nil -} diff --git a/internal/twitch/helpers/rate_limiter.go b/internal/twitch/helpers/rate_limiter.go deleted file mode 100644 index 0d4ac277..00000000 --- a/internal/twitch/helpers/rate_limiter.go +++ /dev/null @@ -1,28 +0,0 @@ -package helpers - -import ( - "fmt" - "github.com/nicklaw5/helix/v2" - "time" -) - -func RateLimitCallback(lastResponse *helix.Response) error { - if lastResponse.GetRateLimitRemaining() > 0 { - return nil - } - - var reset64 int64 - reset64 = int64(lastResponse.GetRateLimitReset()) - - currentTime := time.Now().Unix() - - if currentTime < reset64 { - timeDiff := time.Duration(reset64 - currentTime) - if timeDiff > 0 { - fmt.Printf("Waiting on rate limit to pass before sending next request (%d seconds)\n", timeDiff) - time.Sleep(timeDiff * time.Second) - } - } - - return nil -} diff --git a/internal/twitch/implementation.go b/internal/twitch/implementation.go deleted file mode 100644 index c33f16d4..00000000 --- a/internal/twitch/implementation.go +++ /dev/null @@ -1,165 +0,0 @@ -package twitch - -import ( - "github.com/nicklaw5/helix/v2" - "github.com/satont/twitch-notifier/internal/twitch/helpers" - "time" -) - -type twitchService struct { - apiClient *helix.Client -} - -func NewTwitchService(clientId string, clientSecret string) (Interface, error) { - apiClient, err := helix.NewClient(&helix.Options{ - ClientID: clientId, - ClientSecret: clientSecret, - RateLimitFunc: helpers.RateLimitCallback, - }) - - if err != nil { - return nil, err - } - - token, err := apiClient.RequestAppAccessToken([]string{}) - if err != nil { - panic(err) - } - apiClient.SetAppAccessToken(token.Data.AccessToken) - - go func() { - for { - newToken, tokenErr := apiClient.RequestAppAccessToken([]string{}) - if tokenErr != nil { - panic(tokenErr) - } - apiClient.SetAppAccessToken(newToken.Data.AccessToken) - time.Sleep(1 * time.Hour) - } - }() - - return &twitchService{ - apiClient: apiClient, - }, nil -} - -func (t *twitchService) GetUser(id, login string) (*helix.User, error) { - users, err := t.GetUsers([]string{id}, []string{login}) - if err != nil { - return nil, err - } - - if len(users) == 0 { - return nil, nil - } - - return &users[0], nil -} - -func (t *twitchService) GetUsers(ids, logins []string) ([]helix.User, error) { - var data []string - - isById := len(ids) > 0 && ids[0] != "" - - if isById { - data = ids - } else { - data = logins - } - - reqData := &chunkedRequestData[*helix.UsersParams, *helix.UsersResponse]{ - ids: data, - requestFn: t.apiClient.GetUsers, - responseSelectorFn: func(response *helix.UsersResponse) interface{} { - return response.Data.Users - }, - paramFn: func(chunk []string) *helix.UsersParams { - if isById { - return &helix.UsersParams{ - IDs: chunk, - } - } else { - return &helix.UsersParams{ - Logins: chunk, - } - } - }, - } - - users, err := getDataChunked[helix.User](reqData) - if err != nil { - return nil, err - } - - return users, nil -} - -func (t *twitchService) GetStreamByUserId(id string) (*helix.Stream, error) { - streams, err := t.GetStreamsByUserIds([]string{id}) - if err != nil { - return nil, err - } - - if len(streams) == 0 { - return nil, nil - } - - return &streams[0], nil -} - -func (t *twitchService) GetStreamsByUserIds(ids []string) ([]helix.Stream, error) { - reqData := &chunkedRequestData[*helix.StreamsParams, *helix.StreamsResponse]{ - ids: ids, - requestFn: t.apiClient.GetStreams, - responseSelectorFn: func(response *helix.StreamsResponse) interface{} { - return response.Data.Streams - }, - paramFn: func(chunk []string) *helix.StreamsParams { - return &helix.StreamsParams{ - UserIDs: chunk, - } - }, - } - - streams, err := getDataChunked[helix.Stream](reqData) - if err != nil { - return nil, err - } - - return streams, nil -} - -func (t *twitchService) GetChannelByUserId(id string) (*helix.ChannelInformation, error) { - channels, err := t.GetChannelsByUserIds([]string{id}) - if err != nil { - return nil, err - } - - if len(channels) == 0 { - return nil, nil - } - - return &channels[0], nil -} - -func (t *twitchService) GetChannelsByUserIds(ids []string) ([]helix.ChannelInformation, error) { - reqData := &chunkedRequestData[*helix.GetChannelInformationParams, *helix.GetChannelInformationResponse]{ - ids: ids, - requestFn: t.apiClient.GetChannelInformation, - responseSelectorFn: func(response *helix.GetChannelInformationResponse) interface{} { - return response.Data.Channels - }, - paramFn: func(chunk []string) *helix.GetChannelInformationParams { - return &helix.GetChannelInformationParams{ - BroadcasterIDs: chunk, - } - }, - } - - channels, err := getDataChunked[helix.ChannelInformation](reqData) - if err != nil { - return nil, err - } - - return channels, nil -} diff --git a/internal/twitch/implementation_test.go b/internal/twitch/implementation_test.go deleted file mode 100644 index 5e87d5ad..00000000 --- a/internal/twitch/implementation_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package twitch - -import ( - "github.com/nicklaw5/helix/v2" - "github.com/stretchr/testify/assert" - "net/http" - "net/http/httptest" - "testing" -) - -func newMockedApi(server *httptest.Server) (*twitchService, error) { - apiClient, err := helix.NewClient(&helix.Options{ - ClientID: "test", - APIBaseURL: server.URL, - }) - if err != nil { - return nil, err - } - - apiClient.SetAppAccessToken("test") - - return &twitchService{ - apiClient: apiClient, - }, nil -} - -func TestTwitchService_GetUser(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":[{"id":"1","login":"test"}]}`)) - })) - defer server.Close() - - twitchService, err := newMockedApi(server) - assert.NoError(t, err) - - user, err := twitchService.GetUser("1", "") - assert.NoError(t, err) - - assert.Equal(t, "1", user.ID) - assert.Equal(t, "test", user.Login) -} - -func TestTwitchService_GetUsers(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":[{"id":"1","login":"test"},{"id":"2","login":"test2"}]}`)) - })) - defer server.Close() - - twitchService, err := newMockedApi(server) - assert.NoError(t, err) - - expectedUsers := []helix.User{ - {ID: "1", Login: "test"}, - {ID: "2", Login: "test2"}, - } - - table := []struct { - name string - ids []string - logins []string - }{ - { - name: "ids", - ids: []string{"1", "2"}, - logins: []string{}, - }, - { - name: "logins", - ids: []string{}, - logins: []string{"test", "test2"}, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - users, err := twitchService.GetUsers(tt.ids, tt.logins) - assert.NoError(t, err) - - assert.Equal(t, expectedUsers, users) - }) - } -} - -func TestTwitchService_GetStreamByUserId(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":[{"id":"1","user_name":"test","game_name": "Dota 2"}]}`)) - })) - defer server.Close() - - twitchService, err := newMockedApi(server) - assert.NoError(t, err) - - stream, err := twitchService.GetStreamByUserId("1") - assert.NoError(t, err) - - assert.Equal(t, "1", stream.ID) - assert.Equal(t, "test", stream.UserName) - assert.Equal(t, "Dota 2", stream.GameName) -} - -func TestTwitchService_GetStreamsByUserId(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":[{"id":"1","user_name":"test","game_name": "Dota 2"}, {"id":"2","user_name":"test2","game_name": "Dota 3"}]}`)) - })) - defer server.Close() - - twitchService, err := newMockedApi(server) - assert.NoError(t, err) - - streams, err := twitchService.GetStreamsByUserIds([]string{"1", "2"}) - assert.NoError(t, err) - - assert.Equal(t, []helix.Stream{ - {ID: "1", UserName: "test", GameName: "Dota 2"}, - {ID: "2", UserName: "test2", GameName: "Dota 3"}, - }, streams) -} - -func TestTwitchService_GetChannelByUserId(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":[ - {"broadcaster_id":"1","broadcaster_name":"test","game_name": "Dota 2", "title": "tiitle"} - ]}`)) - })) - defer server.Close() - - twitchService, err := newMockedApi(server) - assert.NoError(t, err) - - channel, err := twitchService.GetChannelByUserId("1") - assert.NoError(t, err) - - assert.Equal(t, "1", channel.BroadcasterID) - assert.Equal(t, "test", channel.BroadcasterName) - assert.Equal(t, "Dota 2", channel.GameName) - assert.Equal(t, "tiitle", channel.Title) -} - -func TestTwitchService_GetChannelsByUserId(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":[ - {"broadcaster_id":"1","broadcaster_name":"test","game_name": "Dota 2", "title": "tiitle"}, - {"broadcaster_id":"2","broadcaster_name":"test2","game_name": "Dota 3", "title": "tiitle2"} - ]}`)) - })) - defer server.Close() - - twitchService, err := newMockedApi(server) - assert.NoError(t, err) - - channels, err := twitchService.GetChannelsByUserIds([]string{"1", "2"}) - assert.NoError(t, err) - - assert.Equal(t, []helix.ChannelInformation{ - {BroadcasterID: "1", BroadcasterName: "test", GameName: "Dota 2", Title: "tiitle"}, - {BroadcasterID: "2", BroadcasterName: "test2", GameName: "Dota 3", Title: "tiitle2"}, - }, channels) -} - -func TestTwitchService_GetChunkerError(t *testing.T) { - t.Parallel() - - table := []struct { - name string - server *httptest.Server - expectedErrorMessage string - }{ - { - name: "fail because twitch returns error code", - server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) - data := `{ - "error": "Forbidden", - "status": 403, - "message": "test" - }` - _, _ = w.Write([]byte(data)) - })), - expectedErrorMessage: "test", - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - twitchService, err := newMockedApi(tt.server) - assert.NoError(t, err) - - _, err = twitchService.GetChannelByUserId("1") - assert.Error(t, err) - assert.Equal(t, tt.expectedErrorMessage, err.Error()) - }) - } - -} diff --git a/internal/twitch/interface.go b/internal/twitch/interface.go deleted file mode 100644 index 289f1f76..00000000 --- a/internal/twitch/interface.go +++ /dev/null @@ -1,16 +0,0 @@ -package twitch - -import ( - "github.com/nicklaw5/helix/v2" -) - -type Interface interface { - GetUser(id, login string) (*helix.User, error) - GetUsers(ids, logins []string) ([]helix.User, error) - - GetStreamByUserId(id string) (*helix.Stream, error) - GetStreamsByUserIds(ids []string) ([]helix.Stream, error) - - GetChannelByUserId(id string) (*helix.ChannelInformation, error) - GetChannelsByUserIds(ids []string) ([]helix.ChannelInformation, error) -} diff --git a/internal/twitch_streams_cheker/thumbnail_builder.go b/internal/twitch_streams_cheker/thumbnail_builder.go deleted file mode 100644 index 53619739..00000000 --- a/internal/twitch_streams_cheker/thumbnail_builder.go +++ /dev/null @@ -1,60 +0,0 @@ -package twitch_streams_cheker - -import ( - "fmt" - "net/http" - "strings" - "time" -) - -type thumbNailBuilder struct { -} - -func newThumbNailBuilder() *thumbNailBuilder { - return &thumbNailBuilder{} -} - -func (c *thumbNailBuilder) checkValidity(url string, n int) (bool, error) { - client := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - - req, err := client.Get(url) - if err != nil { - return false, err - } - - if req.StatusCode != 200 && n == 5 { - return false, fmt.Errorf("url %s is not valid", url) - } else if req.StatusCode != 200 { - time.Sleep(5 * time.Second) - return c.checkValidity(url, n+1) - } else { - return true, nil - } -} - -func (c *thumbNailBuilder) Build(thumbNailUrl string, checkValidity bool) (string, error) { - thumbNail := thumbNailUrl - thumbNail = strings.Replace(thumbNail, "{width}", "1920", 1) - thumbNail = strings.Replace(thumbNail, "{height}", "1080", 1) - - if !checkValidity { - return thumbNail, nil - } - - valid, err := c.checkValidity(thumbNail, 0) - - if !valid || err != nil { - thumbNail = strings.Replace(thumbNail, "1920", "1280", 1) - thumbNail = strings.Replace(thumbNail, "1080", "720", 1) - } - - if err != nil { - return thumbNail, err - } - - return thumbNail, nil -} diff --git a/internal/twitch_streams_cheker/twitch_streams_cheker.go b/internal/twitch_streams_cheker/twitch_streams_cheker.go deleted file mode 100644 index 5de41198..00000000 --- a/internal/twitch_streams_cheker/twitch_streams_cheker.go +++ /dev/null @@ -1,461 +0,0 @@ -package twitch_streams_cheker - -import ( - "context" - "fmt" - "strings" - "sync" - "time" - - "github.com/mr-linch/go-tg" - "github.com/nicklaw5/helix/v2" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/message_sender" - "github.com/satont/twitch-notifier/internal/types" - "go.uber.org/zap" -) - -type TwitchStreamChecker struct { - services *types.Services - ticks int - tickTime *time.Duration - sender message_sender.MessageSenderInterface - thumbNailBuilder *thumbNailBuilder -} - -func NewTwitchStreamChecker( - services *types.Services, - sender message_sender.MessageSenderInterface, - tickTime *time.Duration, -) *TwitchStreamChecker { - checker := &TwitchStreamChecker{ - services: services, - tickTime: tickTime, - sender: sender, - thumbNailBuilder: newThumbNailBuilder(), - } - - return checker -} - -func (t *TwitchStreamChecker) check(ctx context.Context) { - channels, err := t.services.Channel.GetAll(ctx) - if err != nil { - zap.S().Error(err) - return - } - - channelsIDs := make([]string, 0, len(channels)) - for _, channel := range channels { - channelsIDs = append(channelsIDs, channel.ChannelID) - } - - twitchChannels, err := t.services.Twitch.GetChannelsByUserIds(channelsIDs) - if err != nil { - zap.S().Error(err) - return - } - - currentTwitchStreams, err := t.services.Twitch.GetStreamsByUserIds(channelsIDs) - if err != nil { - zap.S().Error(err) - return - } - - wg := &sync.WaitGroup{} - for _, channel := range channels { - wg.Add(1) - - go func(channel *db_models.Channel) { - defer wg.Done() - twitchChannel, twitchChannelOk := lo.Find( - twitchChannels, - func(item helix.ChannelInformation) bool { - return item.BroadcasterID == channel.ChannelID - }, - ) - if !twitchChannelOk { - return - } - - currentDBStream, err := t.services.Stream.GetLatestByChannelID(ctx, channel.ID) - if err != nil { - zap.S().Error(err) - return - } - - followers, err := t.services.Follow.GetByChannelID(ctx, channel.ID) - if err != nil { - zap.S().Error(err) - return - } - - twitchCurrentStream, twitchCurrentStreamOk := lo.Find( - currentTwitchStreams, - func(stream helix.Stream) bool { - return stream.UserID == channel.ChannelID - }, - ) - - if twitchCurrentStreamOk && twitchCurrentStream.Type != "live" { - return - } - - // if stream becomes offline - if !twitchCurrentStreamOk && currentDBStream != nil && currentDBStream.EndedAt == nil { - _, err = t.services.Stream.UpdateOneByStreamID( - ctx, - currentDBStream.ID, - &db.StreamUpdateQuery{ - IsLive: lo.ToPtr(false), - }, - ) - if err != nil { - zap.S().Error(err) - return - } - - // send message to all followers - for _, follower := range followers { - if !follower.Chat.Settings.OfflineNotification { - continue - } - - message := t.services.I18N.Translate( - "notifications.streams.nowOffline", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "channelLink": tg.MD.Link( - twitchChannel.BroadcasterName, - fmt.Sprintf("https://twitch.tv/%s", twitchChannel.BroadcasterName), - ), - "categories": strings.Join(currentDBStream.Categories, " -> "), - "duration": time.Now().UTC().Sub(currentDBStream.StartedAt). - Truncate(1 * time.Second). - String(), - }, - ) - unfollowButton := message_sender.KeyboardButton{ - Text: t.services.I18N.Translate( - "commands.unfollow.callbackButton", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "streamer": twitchChannel.BroadcasterName, - }, - ), - CallbackData: fmt.Sprintf("channels_unfollow_%v", channel.ID), - SkipInGroup: true, - } - - err = t.sender.SendMessage( - ctx, follower.Chat, &message_sender.MessageOpts{ - Text: message, - ParseMode: &tg.MD, - Buttons: [][]message_sender.KeyboardButton{{unfollowButton}}, - }, - ) - if err != nil { - zap.S().Error(err) - continue - } - } - } - - // if stream becomes online - if twitchCurrentStreamOk && currentDBStream == nil { - _, err = t.services.Stream.CreateOneByChannelID( - ctx, - channel.ID, - &db.StreamUpdateQuery{ - StreamID: twitchCurrentStream.ID, - IsLive: lo.ToPtr(true), - Category: lo.ToPtr(twitchCurrentStream.GameName), - Title: lo.ToPtr(twitchCurrentStream.Title), - }, - ) - if err != nil { - zap.S().Error(err) - return - } - - for _, follower := range followers { - message := t.services.I18N.Translate( - "notifications.streams.nowOnline", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "channelLink": tg.MD.Link( - twitchChannel.BroadcasterName, - fmt.Sprintf("https://twitch.tv/%s", twitchChannel.BroadcasterName), - ), - "category": twitchCurrentStream.GameName, - "title": twitchCurrentStream.Title, - }, - ) - - unfollowButton := message_sender.KeyboardButton{ - Text: t.services.I18N.Translate( - "commands.unfollow.callbackButton", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "streamer": twitchChannel.BroadcasterName, - }, - ), - CallbackData: fmt.Sprintf("channels_unfollow_%v", channel.ID), - SkipInGroup: true, - } - - thumbNail, err := t.thumbNailBuilder.Build(twitchCurrentStream.ThumbnailURL, true) - if err != nil { - zap.S().Error(err) - } - - err = t.sender.SendMessage( - ctx, follower.Chat, &message_sender.MessageOpts{ - Text: message, - ImageURL: lo.If( - follower.Chat.Settings.ImageInNotification, - fmt.Sprintf("%s?%d", thumbNail, time.Now().Unix()), - ).Else(""), - ParseMode: &tg.MD, - Buttons: [][]message_sender.KeyboardButton{{unfollowButton}}, - }, - ) - if err != nil { - zap.S().Error(err) - continue - } - } - } - - // stream is still online, need to check do we need to update title or category - if twitchCurrentStreamOk && currentDBStream != nil && - currentDBStream.ID == twitchCurrentStream.ID { - latestTitle := "" - if len(currentDBStream.Titles) > 0 { - latestTitle = currentDBStream.Titles[len(currentDBStream.Titles)-1] - } - latestCategory := "" - if len(currentDBStream.Categories) > 0 { - latestCategory = currentDBStream.Categories[len(currentDBStream.Categories)-1] - } - - // stream is online, and both title and category changed, so we need to send a complex notification - if twitchCurrentStream.GameName != latestCategory && - twitchCurrentStream.Title != latestTitle { - _, err = t.services.Stream.UpdateOneByStreamID( - ctx, - currentDBStream.ID, - &db.StreamUpdateQuery{ - Category: lo.ToPtr(twitchCurrentStream.GameName), - Title: lo.ToPtr(twitchCurrentStream.Title), - }, - ) - if err != nil { - zap.S().Error(err) - return - } - - thumbNail, err := t.thumbNailBuilder.Build(twitchCurrentStream.ThumbnailURL, false) - if err != nil { - zap.S().Error(err) - } - - for _, follower := range followers { - if !follower.Chat.Settings.GameAndTitleChangeNotification { - continue - } - - unfollowButton := message_sender.KeyboardButton{ - Text: t.services.I18N.Translate( - "commands.unfollow.callbackButton", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "streamer": twitchChannel.BroadcasterName, - }, - ), - CallbackData: fmt.Sprintf("channels_unfollow_%v", channel.ID), - SkipInGroup: true, - } - - err = t.sender.SendMessage( - ctx, follower.Chat, &message_sender.MessageOpts{ - Text: t.services.I18N.Translate( - "notifications.streams.titleAndCategoryChanged", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "channelLink": tg.MD.Link( - twitchChannel.BroadcasterName, - fmt.Sprintf( - "https://twitch.tv/%s", - twitchChannel.BroadcasterName, - ), - ), - "category": tg.MD.Bold(twitchCurrentStream.GameName), - "oldCategory": tg.MD.Bold(latestCategory), - "title": tg.MD.Bold(twitchCurrentStream.Title), - "oldTitle": tg.MD.Bold(latestTitle), - }, - ), - ParseMode: &tg.MD, - ImageURL: lo.If( - follower.Chat.Settings.ImageInNotification, - fmt.Sprintf("%s?%d", thumbNail, time.Now().Unix()), - ).Else(""), - Buttons: [][]message_sender.KeyboardButton{{unfollowButton}}, - }, - ) - if err != nil { - zap.S().Error(err) - continue - } - } - return - } - - if twitchCurrentStream.GameName != latestCategory { - _, err = t.services.Stream.UpdateOneByStreamID( - ctx, - currentDBStream.ID, - &db.StreamUpdateQuery{ - Category: lo.ToPtr(twitchCurrentStream.GameName), - }, - ) - if err != nil { - zap.S().Error(err) - return - } - - for _, follower := range followers { - if !follower.Chat.Settings.GameChangeNotification { - continue - } - - thumbNail, err := t.thumbNailBuilder.Build(twitchCurrentStream.ThumbnailURL, true) - if err != nil { - zap.S().Error(err) - } - - err = t.sender.SendMessage( - ctx, follower.Chat, &message_sender.MessageOpts{ - Text: t.services.I18N.Translate( - "notifications.streams.newCategory", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "channelLink": tg.MD.Link( - twitchChannel.BroadcasterName, - fmt.Sprintf( - "https://twitch.tv/%s", - twitchChannel.BroadcasterName, - ), - ), - "category": tg.MD.Bold(twitchCurrentStream.GameName), - "oldCategory": tg.MD.Bold(latestCategory), - }, - ), - ParseMode: &tg.MD, - ImageURL: lo.If( - follower.Chat.Settings.ImageInNotification, - fmt.Sprintf("%s?%d", thumbNail, time.Now().Unix()), - ).Else(""), - }, - ) - if err != nil { - zap.S().Error(err) - continue - } - } - return - } - - if twitchCurrentStream.Title != latestTitle { - _, err = t.services.Stream.UpdateOneByStreamID( - ctx, - currentDBStream.ID, - &db.StreamUpdateQuery{ - Title: lo.ToPtr(twitchCurrentStream.Title), - }, - ) - if err != nil { - zap.S().Error(err) - return - } - - for _, follower := range followers { - if !follower.Chat.Settings.TitleChangeNotification { - continue - } - - thumbNail, err := t.thumbNailBuilder.Build(twitchCurrentStream.ThumbnailURL, true) - if err != nil { - zap.S().Error(err) - } - - err = t.sender.SendMessage( - ctx, follower.Chat, &message_sender.MessageOpts{ - Text: t.services.I18N.Translate( - "notifications.streams.titleChanged", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "channelLink": tg.MD.Link( - twitchChannel.BroadcasterName, - fmt.Sprintf( - "https://twitch.tv/%s", - twitchChannel.BroadcasterName, - ), - ), - "category": twitchCurrentStream.GameName, - "title": tg.MD.Bold(twitchCurrentStream.Title), - "oldTitle": tg.MD.Bold(latestTitle), - }, - ), - ParseMode: &tg.MD, - ImageURL: lo.If( - follower.Chat.Settings.ImageInNotification, - fmt.Sprintf("%s?%d", thumbNail, time.Now().Unix()), - ).Else(""), - }, - ) - if err != nil { - zap.S().Error(err) - continue - } - } - } - - } - }(channel) - } - wg.Wait() -} - -func (t *TwitchStreamChecker) StartPolling(ctx context.Context) { - tickTime := lo. - IfF( - t.tickTime != nil, func() time.Duration { - return *t.tickTime - }, - ). - Else( - lo. - If(t.services.Config.AppEnv == "development", 10*time.Second). - Else(1 * time.Minute), - ) - ticker := time.NewTicker(tickTime) - - t.check(ctx) - - go func() { - for { - select { - case <-ticker.C: - t.ticks++ - t.check(ctx) - case <-ctx.Done(): - ticker.Stop() - return - } - } - }() -} diff --git a/internal/twitch_streams_cheker/twitch_streams_cheker_test.go b/internal/twitch_streams_cheker/twitch_streams_cheker_test.go deleted file mode 100644 index dc927af8..00000000 --- a/internal/twitch_streams_cheker/twitch_streams_cheker_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package twitch_streams_cheker - -import ( - "context" - "testing" - - "github.com/google/uuid" - "github.com/nicklaw5/helix/v2" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/test_utils/mocks" - "github.com/satont/twitch-notifier/internal/types" - i18nmocks "github.com/satont/twitch-notifier/pkg/i18n/mocks" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" -) - -func TestNewTwitchStreamChecker(t *testing.T) { - t.Parallel() - - services := &types.Services{} - - checker := NewTwitchStreamChecker(services, &mocks.MessageSenderMock{}, nil) - assert.IsType(t, &TwitchStreamChecker{}, checker) -} - -func TestTwitchStreamChecker_check(t *testing.T) { - t.Parallel() - - channelsMock := &mocks.DbChannelMock{} - twitchMock := &mocks.TwitchApiMock{} - senderMock := &mocks.MessageSenderMock{} - streamMock := &mocks.DbStreamMock{} - followMock := &mocks.DbFollowMock{} - i18nMock := i18nmocks.NewI18nMock() - - i18nMock. - On("Translate", mock.Anything, mock.Anything, mock.Anything). - Return("translated") - - ctx := context.Background() - - dbChannel := &db_models.Channel{ID: uuid.New(), ChannelID: "1"} - dbStream := &db_models.Stream{ - ID: "123", - Titles: []string{"title"}, - Categories: []string{"Dota 2"}, - } - dbChat := &db_models.Chat{ - ID: uuid.New(), - ChatID: "1", - Settings: &db_models.ChatSettings{ - ChatLanguage: db_models.ChatLanguageEn, - GameChangeNotification: true, - OfflineNotification: true, - ImageInNotification: true, - GameAndTitleChangeNotification: false, - }, - } - dbFollow := &db_models.Follow{ - ID: uuid.New(), - ChatID: dbChat.ID, - Chat: dbChat, - Channel: dbChannel, - ChannelID: dbChannel.ID, - } - twitchChannelInfo := &helix.ChannelInformation{BroadcasterID: "1", BroadcasterName: "Satont"} - twitchStream := &helix.Stream{ - ID: "123", - GameName: "Dota 2", - Title: "title", - UserID: "1", - Type: "live", - } - - table := []struct { - name string - setupMocks func() - }{ - { - name: "stream becomes offline, should call UpdateOneByStreamID with correct args", - setupMocks: func() { - twitchMock.On("GetChannelsByUserIds", []string{"1"}). - Return([]helix.ChannelInformation{ - *twitchChannelInfo, - }, nil) - channelsMock.On("GetAll", ctx).Return([]*db_models.Channel{ - dbChannel, - }, nil) - followMock.On("GetByChannelID", ctx, dbChannel.ID). - Return([]*db_models.Follow{dbFollow}, nil) - twitchMock.On("GetStreamsByUserIds", []string{"1"}).Return([]helix.Stream{}, nil) - streamMock.On("GetLatestByChannelID", ctx, dbChannel.ID).Return(dbStream, nil) - followMock.On("GetByChannelID", ctx, dbChannel.ID). - Return([]*db_models.Follow{dbFollow}, nil) - streamMock.On("UpdateOneByStreamID", ctx, dbStream.ID, &db.StreamUpdateQuery{ - IsLive: lo.ToPtr(false), - }).Return((*db_models.Stream)(nil), nil) - senderMock. - On("SendMessage", - ctx, - dbChat, - mock.Anything, - ). - Return(nil) - }, - }, - { - name: "stream becomes online, should call CreateOneByChannelID with correct args", - setupMocks: func() { - twitchMock.On("GetChannelsByUserIds", []string{"1"}). - Return([]helix.ChannelInformation{ - *twitchChannelInfo, - }, nil) - channelsMock.On("GetAll", ctx).Return([]*db_models.Channel{ - dbChannel, - }, nil) - followMock.On("GetByChannelID", ctx, dbChannel.ID). - Return([]*db_models.Follow{dbFollow}, nil) - twitchMock.On("GetStreamsByUserIds", []string{"1"}).Return([]helix.Stream{ - *twitchStream, - }, nil) - streamMock.On("GetLatestByChannelID", ctx, dbChannel.ID). - Return((*db_models.Stream)(nil), nil) - streamMock.On("CreateOneByChannelID", ctx, dbChannel.ID, &db.StreamUpdateQuery{ - StreamID: "123", - IsLive: lo.ToPtr(true), - Category: lo.ToPtr("Dota 2"), - Title: lo.ToPtr("title"), - }).Return((*db_models.Stream)(nil), nil) - senderMock. - On("SendMessage", - ctx, - dbChat, - mock.Anything, - ). - Return(nil) - }, - }, - { - name: "stream is still online, we should update category", - setupMocks: func() { - newHelixStream := &helix.Stream{ - ID: "123", - GameName: "Just Chatting", - Title: "title", - UserID: "1", - Type: "live", - } - - twitchMock.On("GetChannelsByUserIds", []string{"1"}). - Return([]helix.ChannelInformation{ - *twitchChannelInfo, - }, nil) - channelsMock.On("GetAll", ctx).Return([]*db_models.Channel{ - dbChannel, - }, nil) - followMock.On("GetByChannelID", ctx, dbChannel.ID). - Return([]*db_models.Follow{dbFollow}, nil) - twitchMock.On("GetStreamsByUserIds", []string{"1"}).Return([]helix.Stream{ - *newHelixStream, - }, nil) - streamMock.On("GetLatestByChannelID", ctx, dbChannel.ID).Return(dbStream, nil) - streamMock.On("UpdateOneByStreamID", ctx, dbStream.ID, &db.StreamUpdateQuery{ - Category: lo.ToPtr("Just Chatting"), - }).Return((*db_models.Stream)(nil), nil) - senderMock. - On("SendMessage", - ctx, - dbChat, - mock.Anything, - ). - Return(nil) - }, - }, - { - name: "stream is still online, we should update title", - setupMocks: func() { - newHelixStream := &helix.Stream{ - ID: "123", - GameName: "Dota 2", - Title: "title1", - UserID: "1", - Type: "live", - } - - twitchMock.On("GetChannelsByUserIds", []string{"1"}). - Return([]helix.ChannelInformation{ - *twitchChannelInfo, - }, nil) - channelsMock.On("GetAll", ctx).Return([]*db_models.Channel{ - dbChannel, - }, nil) - followMock.On("GetByChannelID", ctx, dbChannel.ID). - Return([]*db_models.Follow{dbFollow}, nil) - twitchMock.On("GetStreamsByUserIds", []string{"1"}).Return([]helix.Stream{ - *newHelixStream, - }, nil) - streamMock.On("GetLatestByChannelID", ctx, dbChannel.ID).Return(dbStream, nil) - streamMock.On("UpdateOneByStreamID", ctx, dbStream.ID, &db.StreamUpdateQuery{ - Title: lo.ToPtr("title1"), - }).Return((*db_models.Stream)(nil), nil) - senderMock. - On("SendMessage", - ctx, - dbChat, - mock.Anything, - ). - Return(nil) - }, - }, - { - name: "stream is still online, we should update title and category", - setupMocks: func() { - newHelixStream := &helix.Stream{ - ID: "123", - GameName: "Dota 3", - Title: "title1", - UserID: "1", - Type: "live", - } - - twitchMock.On("GetChannelsByUserIds", []string{"1"}). - Return([]helix.ChannelInformation{ - *twitchChannelInfo, - }, nil) - channelsMock.On("GetAll", ctx).Return([]*db_models.Channel{ - dbChannel, - }, nil) - followMock.On("GetByChannelID", ctx, dbChannel.ID). - Return([]*db_models.Follow{dbFollow}, nil) - twitchMock.On("GetStreamsByUserIds", []string{"1"}).Return([]helix.Stream{ - *newHelixStream, - }, nil) - streamMock.On("GetLatestByChannelID", ctx, dbChannel.ID).Return(dbStream, nil) - streamMock.On("UpdateOneByStreamID", ctx, dbStream.ID, &db.StreamUpdateQuery{ - Title: lo.ToPtr("title1"), - Category: lo.ToPtr("Dota 3"), - }).Return((*db_models.Stream)(nil), nil) - senderMock. - On("SendMessage", - ctx, - dbChat, - mock.Anything, - ). - Return(nil) - }, - }, - { - name: "we have record in database with some stream, and got new one. We should call send message", - setupMocks: func() { - newHelixStream := &helix.Stream{ - ID: "123456", - GameName: "Dota 2", - Title: "title1", - UserID: "1", - Type: "live", - } - - twitchMock.On("GetChannelsByUserIds", []string{"1"}). - Return([]helix.ChannelInformation{ - *twitchChannelInfo, - }, nil) - channelsMock.On("GetAll", ctx).Return([]*db_models.Channel{ - dbChannel, - }, nil) - followMock.On("GetByChannelID", ctx, dbChannel.ID). - Return([]*db_models.Follow{dbFollow}, nil) - twitchMock.On("GetStreamsByUserIds", []string{"1"}).Return([]helix.Stream{ - *newHelixStream, - }, nil) - streamMock.On("GetLatestByChannelID", ctx, dbChannel.ID). - Return((*db_models.Stream)(nil), nil) - streamMock.On("CreateOneByChannelID", ctx, dbChannel.ID, &db.StreamUpdateQuery{ - StreamID: newHelixStream.ID, - IsLive: lo.ToPtr(true), - Category: lo.ToPtr("Dota 2"), - Title: lo.ToPtr("title1"), - }).Return((*db_models.Stream)(nil), nil) - senderMock. - On("SendMessage", - ctx, - dbChat, - mock.Anything, - ). - Return(nil) - }, - }, - } - - for _, tt := range table { - tt := tt - t.Run(tt.name, func(t *testing.T) { - tt.setupMocks() - - checker := &TwitchStreamChecker{ - services: &types.Services{ - Channel: channelsMock, - Twitch: twitchMock, - Stream: streamMock, - Follow: followMock, - I18N: i18nMock, - }, - sender: senderMock, - } - - checker.check(ctx) - - channelsMock.AssertExpectations(t) - twitchMock.AssertExpectations(t) - senderMock.AssertExpectations(t) - streamMock.AssertExpectations(t) - followMock.AssertExpectations(t) - - channelsMock.ExpectedCalls = nil - twitchMock.ExpectedCalls = nil - streamMock.ExpectedCalls = nil - senderMock.ExpectedCalls = nil - followMock.ExpectedCalls = nil - }) - } -} diff --git a/internal/types/types.go b/internal/types/types.go deleted file mode 100644 index 451a3bd3..00000000 --- a/internal/types/types.go +++ /dev/null @@ -1,20 +0,0 @@ -package types - -import ( - "github.com/satont/twitch-notifier/internal/config" - db2 "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/message_sender" - "github.com/satont/twitch-notifier/internal/twitch" - "github.com/satont/twitch-notifier/pkg/i18n" -) - -type Services struct { - Config *config.Config - Twitch twitch.Interface - Chat db2.ChatInterface - Channel db2.ChannelInterface - Follow db2.FollowInterface - Stream db2.StreamInterface - I18N i18n.Interface - MessageSender message_sender.MessageSenderInterface -} diff --git a/locales/en.json b/locales/en.json index b085e55f..c7d5efdc 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2,7 +2,8 @@ "language": { "name": "English", "changed": "Language is set to english.", - "emoji": "🇬🇧" + "emoji": "🇬🇧", + "select": "Please select your language:" }, "bot": { @@ -29,9 +30,10 @@ "total": "You followed to notifications from {{ count }} channels. Click on streamer nickname to unfollow from notifications." }, - "unfollow": { - "callbackButton": "Unfollow {{ streamer }}" - }, + "unfollow": { + "callbackButton": "Unfollow {{ streamer }}", + "success": "Unfollowed from {{ streamer }}" + }, "start": { "game_change_notification_setting": { diff --git a/locales/ru.json b/locales/ru.json index ab6c1c17..a7e54405 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -2,7 +2,8 @@ "language": { "name": "Русский", "changed": "Язык установлен на русский.", - "emoji": "🇷🇺" + "emoji": "🇷🇺", + "select": "Пожалуйста, выберите ваш язык:" }, "bot": { "description": "Здравствуйте! Я буду уведомлять вас о начале трансляций Twitch." @@ -24,6 +25,10 @@ "follows": { "total": "Вы подписаны на уведомления {{ count }} каналов. Кликните на никнейм стримера, чтобы отписаться от уведомлений." }, + "unfollow": { + "callbackButton": "Отписаться от {{ streamer }}", + "success": "Вы отписались от {{ streamer }}" + }, "start": { "game_change_notification_setting": { "button": "Уведомление о смене категории" diff --git a/locales/uk.json b/locales/uk.json index a5cf3974..13219db7 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -2,7 +2,8 @@ "language": { "name": "Українська", "changed": "Мова змінена на українську.", - "emoji": "🇺🇦" + "emoji": "🇺🇦", + "select": "Будь ласка, оберіть вашу мову:" }, "bot": { "description": "Здраствуйте! Я буду сповіщати вас про початок Twitch трансляцій." @@ -24,6 +25,10 @@ "follows": { "total": "Ви підписані на сповіщення від {{ count }} каналів. Клацніть на нікнейм стрімера, щоб відписатись від сповіщень." }, + "unfollow": { + "callbackButton": "Відписатись від {{ streamer }}", + "success": "Ви відписались від {{ streamer }}" + }, "start": { "game_change_notification_setting": { "button": "Сповіщення про зміну категорії" diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql new file mode 100644 index 00000000..ccd637c8 --- /dev/null +++ b/migrations/0001_initial.sql @@ -0,0 +1,51 @@ +-- Migration number: 0001 2026-03-09T09:43:17.336Z +CREATE TABLE `channels` ( + `id` text PRIMARY KEY NOT NULL, + `channel_id` text NOT NULL, + `service` text DEFAULT 'twitch' NOT NULL, + `is_live` integer DEFAULT false NOT NULL, + `title` text, + `category` text, + `updated_at` text +); +--> statement-breakpoint +CREATE TABLE `chat_settings` ( + `id` text PRIMARY KEY NOT NULL, + `chat_id` text NOT NULL, + `game_change_notification` integer DEFAULT true NOT NULL, + `title_change_notification` integer DEFAULT false NOT NULL, + `game_and_title_change_notification` integer DEFAULT false NOT NULL, + `offline_notification` integer DEFAULT true NOT NULL, + `image_in_notification` integer DEFAULT true NOT NULL, + `language` text DEFAULT 'en' NOT NULL, + FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `chat_settings_chat_id_unique` ON `chat_settings` (`chat_id`);--> statement-breakpoint +CREATE TABLE `chats` ( + `id` text PRIMARY KEY NOT NULL, + `chat_id` text NOT NULL, + `service` text DEFAULT 'telegram' NOT NULL +); +--> statement-breakpoint +CREATE TABLE `follows` ( + `id` text PRIMARY KEY NOT NULL, + `channel_id` text NOT NULL, + `chat_id` text NOT NULL, + FOREIGN KEY (`channel_id`) REFERENCES `channels`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `streams` ( + `id` text PRIMARY KEY NOT NULL, + `channel_id` text NOT NULL, + `is_live` integer DEFAULT true NOT NULL, + `title` text, + `category` text, + `titles` text DEFAULT '[]' NOT NULL, + `categories` text DEFAULT '[]' NOT NULL, + `started_at` text, + `updated_at` text, + `ended_at` text, + FOREIGN KEY (`channel_id`) REFERENCES `channels`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/package.json b/package.json new file mode 100644 index 00000000..05d4d394 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "twitch-notifier", + "version": "2.0.0", + "type": "module", + "description": "Telegram bot for Twitch stream notifications on Cloudflare Workers", + "main": "src/index.ts", + "scripts": { + "dev": "bun run db:migrate:local && wrangler dev", + "deploy": "wrangler deploy", + "deploy:with-migrations": "./scripts/deploy.sh", + "postdeploy": "bun run db:migrate", + "db:create": "wrangler d1 migrations create", + "db:migrate": "wrangler d1 migrations apply twitch-notifier-db", + "db:migrate:local": "wrangler d1 migrations apply twitch-notifier-db --local", + "db:studio": "drizzle-kit studio" + }, + "keywords": [ + "telegram", + "twitch", + "notifications", + "serverless" + ], + "author": "Satont ", + "license": "MIT", + "dependencies": { + "@grammyjs/conversations": "2.1.1", + "@twurple/api": "8.0.3", + "@twurple/auth": "8.0.3", + "@twurple/eventsub-http": "8.0.3", + "drizzle-orm": "0.45.1", + "grammy": "1.41.1", + "hono": "^4.7.11", + "i18next": "25.8.14" + }, + "devDependencies": { + "@cloudflare/workers-types": "4.20260313.1", + "@types/node": "^22.10.6", + "drizzle-kit": "0.31.9", + "typescript": "^5.7.3", + "wrangler": "4.73.0" + } +} diff --git a/pkg/i18n/helpers.go b/pkg/i18n/helpers.go deleted file mode 100644 index c53fdfef..00000000 --- a/pkg/i18n/helpers.go +++ /dev/null @@ -1,15 +0,0 @@ -package i18n - -func GetNested[T any](v any, keys ...string) (T, bool) { - res := v - for _, key := range keys { - mp, ok := res.(map[string]any) - if !ok { - var e T - return e, false - } - res = mp[key] - } - a, ok := res.(T) - return a, ok -} diff --git a/pkg/i18n/helpers_test.go b/pkg/i18n/helpers_test.go deleted file mode 100644 index f4d0e4e1..00000000 --- a/pkg/i18n/helpers_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package i18n - -import ( - "github.com/stretchr/testify/assert" - "testing" -) - -func TestGetNested(t *testing.T) { - t.Parallel() - - data := map[string]any{ - "foo": "bar", - } - - res, ok := GetNested[string](data, "foo") - assert.True(t, ok, "expected to get a value") - assert.Equal(t, "bar", res, "expected to get a value") - - res, ok = GetNested[string](data, "bar") - assert.False(t, ok, "expected to be false") - assert.Equal(t, "", res, "expected to not get a value") - - res, ok = GetNested[string](nil, "foo") - assert.False(t, ok, "expected to be false") - assert.Equal(t, "", res, "expected to not get a value") -} diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go deleted file mode 100644 index be5ec86f..00000000 --- a/pkg/i18n/i18n.go +++ /dev/null @@ -1,81 +0,0 @@ -package i18n - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "text/template" -) - -type Interface interface { - Translate(key, language string, data map[string]string) string - GetLanguagesCodes() []string -} - -type I18n struct { - translations map[string]map[string]any -} - -var readFile = os.ReadFile -var readDir = os.ReadDir - -func NewI18n(localesPath string) (Interface, error) { - entries, err := os.ReadDir(localesPath) - if err != nil { - return nil, err - } - - translations := make(map[string]map[string]any) - - for _, entry := range entries { - if entry.IsDir() { - continue - } - - name := strings.Replace(entry.Name(), ".json", "", 1) - - fileContent := make(map[string]any) - f, err := readFile(filepath.Join(localesPath, entry.Name())) - if err != nil { - return nil, err - } - err = json.Unmarshal(f, &fileContent) - translations[name] = fileContent - } - - return &I18n{ - translations: translations, - }, nil -} - -func (i *I18n) Translate(key, language string, data map[string]string) string { - if data == nil { - data = make(map[string]string) - } - - str, _ := GetNested[string](i.translations[language], strings.Split(key, ".")...) - if str == "" { - str, _ = GetNested[string](i.translations["en"], strings.Split(key, ".")...) - } - - str = strings.ReplaceAll(str, "{{ ", "{{.") - - tmpl, err := template.New("t").Parse(str) - if err != nil { - return str - } - - res := &strings.Builder{} - _ = tmpl.Execute(res, data) - - return res.String() -} - -func (i *I18n) GetLanguagesCodes() []string { - var codes []string - for code, _ := range i.translations { - codes = append(codes, code) - } - return codes -} diff --git a/pkg/i18n/i18n_test.go b/pkg/i18n/i18n_test.go deleted file mode 100644 index 243429f0..00000000 --- a/pkg/i18n/i18n_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package i18n - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNewI18n(t *testing.T) { - t.Parallel() - - wd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - - localesPath := filepath.Join(wd, "test_locales") - - table := []struct { - translation string - lang string - data map[string]string - expected string - expectErr bool - localesPath string - patchReadFile bool - patchReadDir bool - }{ - { - translation: "hello", - lang: "en", - data: nil, - expected: "world", - localesPath: localesPath, - }, - { - translation: "nested.templated", - lang: "en", - data: map[string]string{ - "who": "world", - }, - expected: "hello world", - localesPath: localesPath, - }, - { - translation: "templated", - lang: "en", - data: map[string]string{ - "hello": "templated", - }, - expected: "hello templated", - localesPath: localesPath, - }, - { - translation: "expectEmptyString", - lang: "en", - data: nil, - expected: "", - localesPath: localesPath, - }, - { - translation: "expect error", - expectErr: true, - localesPath: "/tmp/somefreakingstupidnotifierlocalespath", - }, - { - translation: "expect readFile error", - expectErr: true, - patchReadFile: true, - }, - { - translation: "expect readDir error", - expectErr: true, - patchReadDir: true, - }, - } - - for _, tt := range table { - t.Run( - tt.translation, func(t *testing.T) { - if tt.patchReadFile { - readFile = func(string) ([]byte, error) { - return nil, os.ErrNotExist - } - defer func() { readFile = os.ReadFile }() - } - - if tt.patchReadDir { - readDir = func(string) ([]os.DirEntry, error) { - return nil, os.ErrNotExist - } - defer func() { readDir = os.ReadDir }() - } - - i18, err := NewI18n(tt.localesPath) - if tt.expectErr { - assert.Error(t, err) - return - } - - assert.Equal( - t, - tt.expected, - i18.Translate(tt.translation, tt.lang, tt.data), - ) - }, - ) - } -} - -func TestGetLanguagesCodes(t *testing.T) { - t.Parallel() - - wd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - i18, err := NewI18n(filepath.Join(wd, "test_locales")) - - assert.NoError(t, err) - assert.Equal(t, []string{"en"}, i18.GetLanguagesCodes()) -} diff --git a/pkg/i18n/mocks/i18_mock.go b/pkg/i18n/mocks/i18_mock.go deleted file mode 100644 index 2d90f8cd..00000000 --- a/pkg/i18n/mocks/i18_mock.go +++ /dev/null @@ -1,21 +0,0 @@ -package i18nmocks - -import "github.com/stretchr/testify/mock" - -type I18nMock struct { - mock.Mock -} - -func (m *I18nMock) Translate(key, language string, data map[string]string) string { - args := m.Called(key, language, data) - return args.String(0) -} - -func (m *I18nMock) GetLanguagesCodes() []string { - args := m.Called() - return args.Get(0).([]string) -} - -func NewI18nMock() *I18nMock { - return &I18nMock{} -} diff --git a/pkg/i18n/test_locales/en.json b/pkg/i18n/test_locales/en.json deleted file mode 100644 index 14a35c1d..00000000 --- a/pkg/i18n/test_locales/en.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "hello": "world", - "templated": "hello {{ hello }}", - "nested": { - "templated": "hello {{ who }}" - } -} diff --git a/src/bot/commands/broadcast.command.ts b/src/bot/commands/broadcast.command.ts new file mode 100644 index 00000000..5a801cc5 --- /dev/null +++ b/src/bot/commands/broadcast.command.ts @@ -0,0 +1,48 @@ +import { Composer } from 'grammy'; +import type { BotContext } from '../types'; +import type { Env } from '../../types/env'; + +export function createBroadcastCommand(env: Env) { + const broadcast = new Composer(); + + const isAdmin = (userId: number): boolean => { + const admins = env.TELEGRAM_BOT_ADMINS.split(',').map(id => parseInt(id.trim())); + return admins.includes(userId); + }; + + broadcast.command('broadcast', async (ctx) => { + const userId = ctx.from?.id; + if (!userId || !isAdmin(userId)) { + return; + } + + const text = ctx.message?.text?.replace('/broadcast', '').trim(); + if (!text) { + await ctx.reply('Usage: /broadcast '); + return; + } + + // Get all chats (only positive IDs = private chats/groups) + const allChats = await ctx.services.chatRepo.findAllByService('telegram'); + + let sent = 0; + let failed = 0; + + for (const chat of allChats) { + const chatIdNum = parseInt(chat.chatId); + if (chatIdNum <= 0) continue; // Skip channels/supergroups + + try { + await ctx.api.sendMessage(chatIdNum, text); + sent++; + } catch (error) { + console.error(`Failed to send to ${chat.chatId}:`, error); + failed++; + } + } + + await ctx.reply(`Broadcast completed!\nSent: ${sent}\nFailed: ${failed}`); + }); + + return broadcast; +} diff --git a/src/bot/commands/callback.handler.ts b/src/bot/commands/callback.handler.ts new file mode 100644 index 00000000..ac116206 --- /dev/null +++ b/src/bot/commands/callback.handler.ts @@ -0,0 +1,95 @@ +import { Composer } from 'grammy'; +import type { BotContext } from '../types'; +import type { SupportedLanguage } from '../../services/i18n.service'; +import { + sendSettingsMenu, + sendLanguagePicker, + handleToggleSetting, + handleUnfollow, + buildFollowsKeyboard +} from '../helpers'; + +export const callbackQueryHandler = new Composer(); + +callbackQueryHandler.on('callback_query:data', async (ctx) => { + const data = ctx.callbackQuery.data; + const chatId = ctx.chat?.id; + if (!chatId) return; + + let chat = await ctx.services.chatRepo.findByChatId(chatId, 'telegram'); + if (!chat || !chat.settings) return; + + // Handle toggle settings + if (data.startsWith('toggle_')) { + // Сразу отвечаем на callback, чтобы не было timeout + await ctx.answerCallbackQuery(); + + await handleToggleSetting(ctx, data, chat); + // Перезагрузить чат из БД чтобы получить актуальные настройки + chat = await ctx.services.chatRepo.findByChatId(chatId, 'telegram'); + if (!chat) return; + await sendSettingsMenu(ctx, chat); + return; // Важно! Выходим, чтобы не вызывать answerCallbackQuery дважды + } + + // Handle language picker + else if (data === 'language_picker') { + await sendLanguagePicker(ctx); + } + + // Handle language selection + else if (data.startsWith('language_picker_set_')) { + const lang = data.replace('language_picker_set_', '') as SupportedLanguage; + if (ctx.services.i18n.isValidLocale(lang)) { + await ctx.services.chatRepo.updateSettings(chat.id, { language: lang }); + ctx.session.language = lang; + + // Обновляем ctx.t() для использования нового языка + ctx.t = (key: string, params?: Record) => { + return ctx.services.i18n.t(lang, key, params); + }; + + // Перезагрузить чат из БД + chat = await ctx.services.chatRepo.findByChatId(chatId, 'telegram'); + if (!chat) return; + + await ctx.answerCallbackQuery( + ctx.services.i18n.t(lang, 'language.changed') + ); + + // Вернуться в главное меню с новым языком + await sendSettingsMenu(ctx, chat); + return; // Важно! Выходим, чтобы не вызывать answerCallbackQuery дважды + } + } + + // Handle back to main menu + else if (data === 'start_command_menu') { + await sendSettingsMenu(ctx, chat); + } + + // Handle unfollow + else if (data.startsWith('channels_unfollow_')) { + const channelId = data.replace('channels_unfollow_', ''); + await handleUnfollow(ctx, chat, channelId); + return; // handleUnfollow уже вызывает answerCallbackQuery + } + + // Handle pagination + else if (data === 'channels_unfollow_prev_page') { + if (ctx.session.followsMenu && ctx.session.followsMenu.currentPage > 1) { + ctx.session.followsMenu.currentPage--; + } + const keyboard = await buildFollowsKeyboard(ctx, chat.id); + await ctx.editMessageReplyMarkup({ reply_markup: keyboard }); + } + else if (data === 'channels_unfollow_next_page') { + if (ctx.session.followsMenu && ctx.session.followsMenu.currentPage < ctx.session.followsMenu.totalPages) { + ctx.session.followsMenu.currentPage++; + } + const keyboard = await buildFollowsKeyboard(ctx, chat.id); + await ctx.editMessageReplyMarkup({ reply_markup: keyboard }); + } + + await ctx.answerCallbackQuery(); +}); diff --git a/src/bot/commands/change-channel-id.command.ts b/src/bot/commands/change-channel-id.command.ts new file mode 100644 index 00000000..f7513f2e --- /dev/null +++ b/src/bot/commands/change-channel-id.command.ts @@ -0,0 +1,45 @@ +import { Composer } from 'grammy'; +import type { BotContext } from '../types'; +import type { Env } from '../../types/env'; + +export function createChangeChannelIdCommand(env: Env) { + const changeChannelId = new Composer(); + + const isAdmin = (userId: number): boolean => { + const admins = env.TELEGRAM_BOT_ADMINS.split(',').map(id => parseInt(id.trim())); + return admins.includes(userId); + }; + + changeChannelId.command('change_channel_id', async (ctx) => { + const userId = ctx.from?.id; + if (!userId || !isAdmin(userId)) { + return; + } + + const text = ctx.message?.text?.replace('/change_channel_id', '').trim(); + + if (!text) { + await ctx.reply('Usage: /change_channel_id '); + return; + } + + const parts = text.split(' '); + + if (parts.length !== 2) { + await ctx.reply('Usage: /change_channel_id '); + return; + } + + const [oldId, newId] = parts; + + try { + await ctx.services.channelRepo.updateChannelId(oldId, newId, 'twitch'); + await ctx.reply('Channel ID updated successfully!'); + } catch (error) { + console.error('Error updating channel ID:', error); + await ctx.reply('Error updating channel ID.'); + } + }); + + return changeChannelId; +} diff --git a/src/bot/commands/follow.command.ts b/src/bot/commands/follow.command.ts new file mode 100644 index 00000000..88450528 --- /dev/null +++ b/src/bot/commands/follow.command.ts @@ -0,0 +1,122 @@ +import { Composer } from 'grammy'; +import type { BotContext } from '../types'; + +export const followCommand = new Composer(); + +followCommand.command('follow', async (ctx) => { + const text = ctx.message?.text?.replace('/follow', '').trim(); + + if (!text) { + await ctx.reply( + ctx.t('commands.follow.enter') + ); + ctx.session.scene = 'follow'; + return; + } + + await handleFollow(ctx, text); +}); + +// Handle follow scene +followCommand.on('message:text', async (ctx, next) => { + if (ctx.session.scene === 'follow') { + await handleFollow(ctx, ctx.message.text); + ctx.session.scene = undefined; + return; + } + await next(); +}); + +async function handleFollow(ctx: BotContext, text: string) { + const chatId = ctx.chat?.id; + if (!chatId) return; + + const chat = await ctx.services.chatRepo.findByChatId(chatId, 'telegram'); + if (!chat) return; + + // Extract Twitch username from text or URL + const twitchLinkRegex = /(?:https?:\/\/)?(?:www\.)?twitch\.tv\/(\w+)/g; + const matches = Array.from(text.matchAll(twitchLinkRegex)); + + const usernames = matches.length > 0 + ? matches.map(m => m[1]) + : [text.trim()]; + + const results: string[] = []; + + for (const username of usernames) { + // Validate username + if (!/^[a-zA-Z0-9_]{3,25}$/.test(username)) { + results.push( + ctx.t( + 'commands.follow.errors.badUsername', + { streamer: username } + ) + ); + continue; + } + + try { + // Get Twitch user + const twitchUser = await ctx.services.twitch.getUserByLogin(username); + + if (!twitchUser) { + results.push( + ctx.t( + 'commands.follow.errors.streamerNotFound', + { streamer: username } + ) + ); + continue; + } + + // Get or create channel + let channel = await ctx.services.channelRepo.findByChannelId(twitchUser.id, 'twitch'); + if (!channel) { + channel = await ctx.services.channelRepo.create(twitchUser.id, 'twitch'); + } + + // Create follow + try { + await ctx.services.followRepo.create(chat.id, channel.id); + + // Subscribe to EventSub events for this channel + // Check if we already have subscriptions for this channel + const hasSubscriptions = await ctx.services.eventsub.hasActiveSubscriptions(twitchUser.id); + if (!hasSubscriptions) { + try { + await ctx.services.eventsub.subscribeToChannel(twitchUser.id); + console.log(`Subscribed to EventSub for channel ${twitchUser.id}`); + } catch (eventSubError) { + console.error(`Failed to subscribe to EventSub for ${twitchUser.id}:`, eventSubError); + // Don't fail the follow if EventSub subscription fails + } + } + + results.push( + ctx.t( + 'commands.follow.success', + { streamer: username } + ) + ); + } catch (error: any) { + // Check if it's FollowAlreadyExistsError by checking error name or message + if (error.constructor.name === 'FollowAlreadyExistsError' || error.message === 'Follow already exists') { + results.push( + ctx.t( + 'commands.follow.errors.alreadyFollowed', + { streamer: username } + ) + ); + } else { + throw error; + } + } + } catch (error) { + console.error('Error following user:', error); + results.push(`${username} - internal error`); + } + } + + await ctx.reply(results.join('\n')); +} diff --git a/src/bot/commands/follows.command.ts b/src/bot/commands/follows.command.ts new file mode 100644 index 00000000..2768816d --- /dev/null +++ b/src/bot/commands/follows.command.ts @@ -0,0 +1,37 @@ +import { Composer } from 'grammy'; +import type { BotContext } from '../types'; +import { buildFollowsKeyboard } from '../helpers'; + +export const followsCommand = new Composer(); + +followsCommand.command(['follows', 'unfollow'], async (ctx) => { + const chatId = ctx.chat?.id; + if (!chatId) return; + + const chat = await ctx.services.chatRepo.findByChatId(chatId, 'telegram'); + if (!chat) return; + + ctx.session.followsMenu = { + currentPage: 1, + totalPages: 1, + }; + + const totalFollows = await ctx.services.followRepo.countByChatId(chat.id); + + if (totalFollows === 0) { + await ctx.reply('You are not following any channels.'); + return; + } + + const keyboard = await buildFollowsKeyboard(ctx, chat.id); + + await ctx.reply( + ctx.t( + 'commands.follows.total', + { count: totalFollows.toString() } + ), + { + reply_markup: keyboard, + } + ); +}); diff --git a/src/bot/commands/index.ts b/src/bot/commands/index.ts new file mode 100644 index 00000000..a64b3652 --- /dev/null +++ b/src/bot/commands/index.ts @@ -0,0 +1,7 @@ +export { startCommand } from './start.command'; +export { followCommand } from './follow.command'; +export { followsCommand } from './follows.command'; +export { liveCommand } from './live.command'; +export { createBroadcastCommand } from './broadcast.command'; +export { createChangeChannelIdCommand } from './change-channel-id.command'; +export { callbackQueryHandler } from './callback.handler'; diff --git a/src/bot/commands/live.command.ts b/src/bot/commands/live.command.ts new file mode 100644 index 00000000..7545e79f --- /dev/null +++ b/src/bot/commands/live.command.ts @@ -0,0 +1,102 @@ +import { Composer } from 'grammy'; +import type { BotContext } from '../types'; + +export const liveCommand = new Composer(); + +liveCommand.command('live', async (ctx) => { + const chatId = ctx.chat?.id; + if (!chatId) return; + + const chat = await ctx.services.chatRepo.findByChatId(chatId, 'telegram'); + if (!chat) return; + + const follows = await ctx.services.followRepo.findByChatId(chat.id); + + if (follows.length === 0) { + await ctx.reply('You are not following any channels.'); + return; + } + + // Get all followed channel IDs + const channelIds: string[] = []; + for (const follow of follows) { + const channel = await ctx.services.channelRepo.findById(follow.channelId); + if (channel) { + channelIds.push(channel.channelId); + } + } + + if (channelIds.length === 0) { + await ctx.reply('No channels found.'); + return; + } + + // Get live streams + const liveChannels: Array<{ + name: string; + login: string; + startedAt: Date; + title: string; + category: string; + viewers: number; + }> = []; + + for (const channelId of channelIds) { + const stream = await ctx.services.twitch.getStreamByUserId(channelId); + if (stream) { + const user = await ctx.services.twitch.getUserById(channelId); + if (user) { + liveChannels.push({ + name: user.displayName, + login: user.name, + startedAt: stream.startDate, + title: stream.title, + category: stream.gameName, + viewers: stream.viewers, + }); + } + } + } + + if (liveChannels.length === 0) { + await ctx.reply('No one is online.'); + return; + } + + // Build message + const messages: string[] = []; + for (const channel of liveChannels) { + const channelMessage: string[] = []; + + channelMessage.push( + `🟢 ${channel.name} - ${channel.viewers} 👁️️` + ); + + if (channel.category) { + channelMessage.push(`🎮 ${channel.category}`); + } + + if (channel.title) { + channelMessage.push(`📝 ${channel.title}`); + } + + // Calculate uptime + const uptime = Date.now() - channel.startedAt.getTime(); + const hours = Math.floor(uptime / 3600000); + const minutes = Math.floor((uptime % 3600000) / 60000); + const seconds = Math.floor((uptime % 60000) / 1000); + + let uptimeStr = '⌛ '; + if (hours > 0) uptimeStr += `${hours}h `; + if (minutes > 0) uptimeStr += `${minutes}m `; + if (seconds > 0) uptimeStr += `${seconds}s `; + + channelMessage.push(uptimeStr); + messages.push(channelMessage.join('\n')); + } + + await ctx.reply(messages.join('\n\n'), { + parse_mode: 'HTML', + link_preview_options: { is_disabled: true }, + }); +}); diff --git a/src/bot/commands/start.command.ts b/src/bot/commands/start.command.ts new file mode 100644 index 00000000..51c3ff05 --- /dev/null +++ b/src/bot/commands/start.command.ts @@ -0,0 +1,28 @@ +import { Composer } from 'grammy'; +import type { BotContext } from '../types'; +import type { SupportedLanguage } from '../../services/i18n.service'; +import { sendSettingsMenu } from '../helpers'; + +export const startCommand = new Composer(); + +startCommand.command(['start', 'help', 'info', 'settings'], async (ctx) => { + const chatId = ctx.chat?.id; + if (!chatId) return; + + // Get or create chat in database + let chat = await ctx.services.chatRepo.findByChatId(chatId, 'telegram'); + if (!chat) { + await ctx.services.chatRepo.create(chatId.toString(), 'telegram'); + // Fetch the chat again to get it with settings + chat = await ctx.services.chatRepo.findByChatId(chatId, 'telegram'); + } + + // Update session language + if (chat?.settings) { + ctx.session.language = chat.settings.language as SupportedLanguage; + } + + if (chat) { + await sendSettingsMenu(ctx, chat); + } +}); diff --git a/src/bot/helpers.ts b/src/bot/helpers.ts new file mode 100644 index 00000000..df8d82eb --- /dev/null +++ b/src/bot/helpers.ts @@ -0,0 +1,232 @@ +import type { BotContext } from './types'; +import type { Chat } from '../domain/models'; +import { InlineKeyboard } from 'grammy'; + +export async function sendSettingsMenu(ctx: BotContext, chat: Chat) { + const settings = chat.settings; + if (!settings) return; + + const createCheckmark = (value: boolean) => value ? '✅' : '❌'; + + const keyboard = new InlineKeyboard() + .text( + `${createCheckmark(settings.gameChangeNotification)} ${ctx.t('commands.start.game_change_notification_setting.button')}`, + 'toggle_game_change' + ).row() + .text( + `${createCheckmark(settings.offlineNotification)} ${ctx.t('commands.start.offline_notification.button')}`, + 'toggle_offline' + ).row() + .text( + `${createCheckmark(settings.titleChangeNotification)} ${ctx.t('commands.start.title_change_notification_setting.button')}`, + 'toggle_title_change' + ).row() + .text( + `${createCheckmark(settings.gameAndTitleChangeNotification)} ${ctx.t('commands.start.game_and_title_change_notification_setting.button')}`, + 'toggle_game_and_title' + ).row() + .text( + `${createCheckmark(settings.imageInNotification)} ${ctx.t('commands.start.image_in_notification_setting.button')}`, + 'toggle_image' + ).row() + .text( + `🌐 ${ctx.t('commands.start.language.button')}`, + 'language_picker' + ).row() + .url('Github', 'https://github.com/Satont/twitch-notifier'); + + const description = ctx.t('bot.description'); + + if (ctx.callbackQuery) { + try { + await ctx.editMessageText(description, { reply_markup: keyboard }); + } catch (error: any) { + // Игнорируем ошибку "message is not modified" + if (!error?.message?.includes('message is not modified')) { + throw error; + } + } + } else { + await ctx.reply(description, { reply_markup: keyboard }); + } +} + +export async function sendLanguagePicker(ctx: BotContext) { + const keyboard = new InlineKeyboard(); + + const locales = ctx.services.i18n.getAvailableLocales(); + for (const locale of locales) { + const emoji = ctx.services.i18n.t(locale, 'language.emoji'); + const name = ctx.services.i18n.t(locale, 'language.name'); + keyboard.text(`${emoji} ${name}`, `language_picker_set_${locale}`).row(); + } + keyboard.text('«', 'start_command_menu'); + + const text = ctx.t('language.select'); + + if (ctx.callbackQuery) { + await ctx.editMessageText(text, { reply_markup: keyboard }); + } else { + await ctx.reply(text, { reply_markup: keyboard }); + } +} + +export async function buildFollowsKeyboard(ctx: BotContext, chatId: string): Promise { + const follows = await ctx.services.followRepo.findByChatId(chatId); + const keyboard = new InlineKeyboard(); + + for (const follow of follows) { + const channel = await ctx.services.channelRepo.findById(follow.channelId); + if (!channel) continue; + + const twitchUser = await ctx.services.twitch.getUserById(channel.channelId); + if (!twitchUser) continue; + + keyboard.text(twitchUser.displayName, `channels_unfollow_${channel.channelId}`).row(); + } + + // Add pagination buttons if needed + if (ctx.session.followsMenu) { + const { currentPage, totalPages } = ctx.session.followsMenu; + if (totalPages > 1) { + keyboard.text('«', 'channels_unfollow_prev_page'); + keyboard.text('»', 'channels_unfollow_next_page'); + } + } + + return keyboard; +} + +export async function handleToggleSetting(ctx: BotContext, data: string, chat: Chat) { + const chatId = ctx.chat?.id; + if (!chatId || !chat.settings) return; + + const updates: any = {}; + + switch (data) { + case 'toggle_game_change': + updates.gameChangeNotification = !chat.settings.gameChangeNotification; + chat.settings.gameChangeNotification = updates.gameChangeNotification; + + // Если включили game change, а title change тоже включен, то включаем game_and_title + if (updates.gameChangeNotification && chat.settings.titleChangeNotification) { + updates.gameAndTitleChangeNotification = true; + chat.settings.gameAndTitleChangeNotification = true; + } + // Если выключили game change, то выключаем game_and_title + if (!updates.gameChangeNotification) { + updates.gameAndTitleChangeNotification = false; + chat.settings.gameAndTitleChangeNotification = false; + } + break; + + case 'toggle_offline': + updates.offlineNotification = !chat.settings.offlineNotification; + chat.settings.offlineNotification = updates.offlineNotification; + break; + + case 'toggle_title_change': + updates.titleChangeNotification = !chat.settings.titleChangeNotification; + chat.settings.titleChangeNotification = updates.titleChangeNotification; + + // Если включили title change, а game change тоже включен, то включаем game_and_title + if (updates.titleChangeNotification && chat.settings.gameChangeNotification) { + updates.gameAndTitleChangeNotification = true; + chat.settings.gameAndTitleChangeNotification = true; + } + // Если выключили title change, то выключаем game_and_title + if (!updates.titleChangeNotification) { + updates.gameAndTitleChangeNotification = false; + chat.settings.gameAndTitleChangeNotification = false; + } + break; + + case 'toggle_game_and_title': + updates.gameAndTitleChangeNotification = !chat.settings.gameAndTitleChangeNotification; + chat.settings.gameAndTitleChangeNotification = updates.gameAndTitleChangeNotification; + + // Если включили game_and_title, включаем оба + if (updates.gameAndTitleChangeNotification) { + updates.gameChangeNotification = true; + updates.titleChangeNotification = true; + chat.settings.gameChangeNotification = true; + chat.settings.titleChangeNotification = true; + } + // Если выключили game_and_title, выключаем оба + else { + updates.gameChangeNotification = false; + updates.titleChangeNotification = false; + chat.settings.gameChangeNotification = false; + chat.settings.titleChangeNotification = false; + } + break; + + case 'toggle_image': + updates.imageInNotification = !chat.settings.imageInNotification; + chat.settings.imageInNotification = updates.imageInNotification; + break; + } + + if (Object.keys(updates).length > 0) { + await ctx.services.chatRepo.updateSettings(chat.id, updates); + } +} + +export async function handleUnfollow(ctx: BotContext, chat: Chat, channelIdFromCallback: string) { + const channel = await ctx.services.channelRepo.findById(channelIdFromCallback); + if (!channel) { + await ctx.answerCallbackQuery('Channel not found'); + return; + } + + const follow = await ctx.services.followRepo.findByChatAndChannel(chat.id, channel.id); + if (!follow) { + await ctx.answerCallbackQuery('Already unfollowed'); + return; + } + + const twitchUser = await ctx.services.twitch.getUserById(channel.channelId); + const streamerName = twitchUser?.displayName || channel.channelId; + + await ctx.services.followRepo.delete(follow.id); + + // Check if this channel still has followers + const remainingFollows = await ctx.services.followRepo.findByChannelId(channel.id); + + // If no followers remain, unsubscribe from EventSub + if (remainingFollows.length === 0) { + try { + await ctx.services.eventsub.unsubscribeFromChannel(channel.channelId); + console.log(`Unsubscribed from EventSub for channel ${channel.channelId}`); + } catch (error) { + console.error(`Failed to unsubscribe from EventSub for ${channel.channelId}:`, error); + // Don't fail the unfollow if EventSub unsubscription fails + } + } + + await ctx.answerCallbackQuery( + ctx.t('commands.unfollow.success', { + streamer: streamerName, + }) + ); + + // Update keyboard + const totalFollows = await ctx.services.followRepo.countByChatId(chat.id); + + if (totalFollows === 0) { + await ctx.editMessageText('You are not following any channels.'); + await ctx.editMessageReplyMarkup({ reply_markup: new InlineKeyboard() }); + return; + } + + const keyboard = await buildFollowsKeyboard(ctx, chat.id); + + await ctx.editMessageText( + ctx.t('commands.follows.total', { + count: totalFollows.toString(), + }), + { + reply_markup: keyboard, + } + ); +} diff --git a/src/bot/index.ts b/src/bot/index.ts new file mode 100644 index 00000000..686e5c58 --- /dev/null +++ b/src/bot/index.ts @@ -0,0 +1,75 @@ +import { Bot } from 'grammy'; +import { session } from 'grammy' +import type { Env } from '../types/env'; +import type { BotSession, BotContext } from './types'; +import { I18nService } from '../services/i18n.service'; +import { TwitchService } from '../services/twitch.service'; +import { EventSubService } from '../services/eventsub.service'; +import { DatabaseSessionStorage } from './storage'; +import type { IChatRepository, IChannelRepository, IFollowRepository, ISessionRepository } from '../db/repositories/interfaces'; +import { + startCommand, + followCommand, + followsCommand, + liveCommand, + createBroadcastCommand, + createChangeChannelIdCommand, + callbackQueryHandler +} from './commands'; + +export function createBot( + env: Env, + services: { + i18n: I18nService; + twitch: TwitchService; + eventsub: EventSubService; + chatRepo: IChatRepository; + channelRepo: IChannelRepository; + followRepo: IFollowRepository; + sessionRepo: ISessionRepository; + } +): Bot { + const bot = new Bot(env.TELEGRAM_TOKEN, { + client: { + timeoutSeconds: 60, // Увеличиваем timeout до 60 секунд + }, + }); + + // Use database session storage + const sessionStorage = new DatabaseSessionStorage( + services.sessionRepo, + 86400 // 24 hours TTL + ); + + bot.use(session({ + initial: (): BotSession => ({ + language: 'en', + followsMenu: { + currentPage: 1, + totalPages: 1, + }, + }), + storage: sessionStorage, + })) + + // Attach environment and services to context + bot.use(async (ctx, next) => { + ctx.env = env; + ctx.services = services; + await next(); + }); + + // Use i18n middleware + bot.use(services.i18n.middleware()); + + // Register commands + bot.use(startCommand); + bot.use(followCommand); + bot.use(followsCommand); + bot.use(liveCommand); + bot.use(createBroadcastCommand(env)); + bot.use(createChangeChannelIdCommand(env)); + bot.use(callbackQueryHandler); + + return bot; +} diff --git a/src/bot/storage.ts b/src/bot/storage.ts new file mode 100644 index 00000000..c3a2f443 --- /dev/null +++ b/src/bot/storage.ts @@ -0,0 +1,47 @@ +import type { StorageAdapter } from 'grammy'; +import type { ISessionRepository } from '../db/repositories/interfaces'; + +/** + * Storage adapter for Grammy sessions using database persistence + * Works with any ISessionRepository implementation (D1, PostgreSQL, etc.) + */ +export class DatabaseSessionStorage implements StorageAdapter { + constructor( + private sessionRepo: ISessionRepository, + private ttl?: number // Time to live in seconds + ) {} + + async read(key: string): Promise { + const value = await this.sessionRepo.get(key); + if (!value) return undefined; + + try { + return JSON.parse(value) as T; + } catch (error) { + console.error('Failed to parse session data:', error); + return undefined; + } + } + + async write(key: string, value: T): Promise { + const expiresAt = this.ttl ? Date.now() + this.ttl * 1000 : undefined; + await this.sessionRepo.set(key, JSON.stringify(value), expiresAt); + } + + async delete(key: string): Promise { + await this.sessionRepo.delete(key); + } + + async has(key: string): Promise { + const value = await this.sessionRepo.get(key); + return value !== undefined; + } + + /** + * Clean up expired sessions + * Should be called periodically (e.g., via cron job) + */ + async cleanup(): Promise { + await this.sessionRepo.cleanup(); + } +} diff --git a/src/bot/types.ts b/src/bot/types.ts new file mode 100644 index 00000000..33a44fc5 --- /dev/null +++ b/src/bot/types.ts @@ -0,0 +1,33 @@ +import type { Context, SessionFlavor } from 'grammy'; +import type { ConversationFlavor } from '@grammyjs/conversations'; +import type { SupportedLanguage } from '../services/i18n.service'; +import type { Env } from '../types/env'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import type { I18nService, TwitchService, EventSubService } from '../services'; +import type { IChatRepository, IChannelRepository, IFollowRepository } from '../db/repositories/interfaces'; + +export interface BotSession { + chatId?: number; + language: SupportedLanguage; + scene?: string; + followsMenu?: { + currentPage: number; + totalPages: number; + }; +} + +export type BotContext = Context & + SessionFlavor & + ConversationFlavor & { + t: (key: string, params?: Record) => string; + env: Env; + db: DrizzleD1Database; + services: { + i18n: I18nService; + twitch: TwitchService; + eventsub: EventSubService; + chatRepo: IChatRepository; + channelRepo: IChannelRepository; + followRepo: IFollowRepository; + }; + }; diff --git a/src/db/connection.ts b/src/db/connection.ts new file mode 100644 index 00000000..b9628d02 --- /dev/null +++ b/src/db/connection.ts @@ -0,0 +1,20 @@ +import type { DrizzleD1Database } from 'drizzle-orm/d1'; + +/** + * Database connection abstraction + * This allows us to support different database implementations (D1, PostgreSQL, etc.) + */ +export interface IDatabaseConnection { + getClient(): any; // Returns the underlying database client (DrizzleD1Database, etc.) +} + +/** + * Cloudflare D1 database connection + */ +export class CloudflareD1Connection implements IDatabaseConnection { + constructor(private client: DrizzleD1Database) {} + + getClient(): DrizzleD1Database { + return this.client; + } +} diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 00000000..1e560f7c --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,3 @@ +import { drizzle } from 'drizzle-orm/d1'; + +export default drizzle; diff --git a/src/db/repositories/cloudflare-kv/index.ts b/src/db/repositories/cloudflare-kv/index.ts new file mode 100644 index 00000000..84a81496 --- /dev/null +++ b/src/db/repositories/cloudflare-kv/index.ts @@ -0,0 +1 @@ +export * from './session.kv.repository'; diff --git a/src/db/repositories/cloudflare-kv/session.kv.repository.ts b/src/db/repositories/cloudflare-kv/session.kv.repository.ts new file mode 100644 index 00000000..692bb465 --- /dev/null +++ b/src/db/repositories/cloudflare-kv/session.kv.repository.ts @@ -0,0 +1,38 @@ +import type { KVNamespace } from '@cloudflare/workers-types'; +import type { ISessionRepository } from '../interfaces/session.repository.interface'; + +/** + * Cloudflare KV-based session repository + * Fast, distributed key-value storage perfect for sessions + */ +export class CloudflareKVSessionRepository implements ISessionRepository { + constructor(private readonly kv: KVNamespace) {} + + async get(key: string): Promise { + const value = await this.kv.get(key); + return value ?? undefined; + } + + async set(key: string, value: string, expiresAt?: number): Promise { + const options: { expirationTtl?: number } = {}; + + // Convert expiresAt (unix timestamp) to TTL in seconds + if (expiresAt) { + const ttl = Math.floor((expiresAt - Date.now()) / 1000); + if (ttl > 0) { + options.expirationTtl = ttl; + } + } + + await this.kv.put(key, value, options); + } + + async delete(key: string): Promise { + await this.kv.delete(key); + } + + async cleanup(): Promise { + // KV automatically cleans up expired keys, no manual cleanup needed + return; + } +} diff --git a/src/db/repositories/drizzle/channel.drizzle.repository.ts b/src/db/repositories/drizzle/channel.drizzle.repository.ts new file mode 100644 index 00000000..559edfc5 --- /dev/null +++ b/src/db/repositories/drizzle/channel.drizzle.repository.ts @@ -0,0 +1,65 @@ +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { eq, and } from 'drizzle-orm'; +import { randomUUID } from 'node:crypto'; +import { channels } from '../../schema'; +import type { NewChannel } from '../../schema'; +import { Channel, ChannelNotFoundError } from '../../../domain/models'; +import { DomainMapper } from '../../../domain/mapper'; +import type { IChannelRepository } from '../interfaces'; + +export class ChannelDrizzleRepository implements IChannelRepository { + constructor(private db: DrizzleD1Database) {} + + async findByChannelId(channelId: string, service: 'twitch' = 'twitch'): Promise { + const result = await this.db + .select() + .from(channels) + .where(and(eq(channels.channelId, channelId), eq(channels.service, service))) + .limit(1); + + return result[0] ? DomainMapper.toDomainChannel(result[0]) : undefined; + } + + async findById(id: string): Promise { + const result = await this.db + .select() + .from(channels) + .where(eq(channels.id, id)) + .limit(1); + + return result[0] ? DomainMapper.toDomainChannel(result[0]) : undefined; + } + + async create(channelId: string, service: 'twitch' = 'twitch'): Promise { + const id = randomUUID(); + const result = await this.db.insert(channels).values({ + id, + channelId, + service, + isLive: false, + }).returning(); + + return DomainMapper.toDomainChannel(result[0]); + } + + async update(id: string, data: Partial>): Promise { + const result = await this.db + .update(channels) + .set({ ...data, updatedAt: new Date().toISOString() }) + .where(eq(channels.id, id)) + .returning(); + + if (!result[0]) { + throw new ChannelNotFoundError(); + } + + return DomainMapper.toDomainChannel(result[0]); + } + + async updateChannelId(oldChannelId: string, newChannelId: string, service: 'twitch' = 'twitch'): Promise { + await this.db + .update(channels) + .set({ channelId: newChannelId, updatedAt: new Date().toISOString() }) + .where(and(eq(channels.channelId, oldChannelId), eq(channels.service, service))); + } +} diff --git a/src/db/repositories/drizzle/chat.drizzle.repository.ts b/src/db/repositories/drizzle/chat.drizzle.repository.ts new file mode 100644 index 00000000..ffa5cc37 --- /dev/null +++ b/src/db/repositories/drizzle/chat.drizzle.repository.ts @@ -0,0 +1,104 @@ +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { eq } from 'drizzle-orm'; +import { randomUUID } from 'node:crypto'; +import { chats, chatSettings } from '../../schema'; +import { Chat, ChatSettings } from '../../../domain/models'; +import { DomainMapper } from '../../../domain/mapper'; +import type { IChatRepository } from '../interfaces'; + +export class ChatDrizzleRepository implements IChatRepository { + constructor(private db: DrizzleD1Database) {} + + async findByChatId(chatId: number, service: 'telegram' = 'telegram'): Promise { + const chatIdStr = chatId.toString(); + + const chatResult = await this.db + .select() + .from(chats) + .where(eq(chats.chatId, chatIdStr)) + .limit(1); + + if (!chatResult[0]) return undefined; + + const settingsResult = await this.db + .select() + .from(chatSettings) + .where(eq(chatSettings.chatId, chatResult[0].id)) + .limit(1); + + return DomainMapper.toDomainChat({ + ...chatResult[0], + settings: settingsResult[0] || null + }); + } + + async findById(id: string): Promise { + const chatResult = await this.db + .select() + .from(chats) + .where(eq(chats.id, id)) + .limit(1); + + if (!chatResult[0]) return undefined; + + const settingsResult = await this.db + .select() + .from(chatSettings) + .where(eq(chatSettings.chatId, chatResult[0].id)) + .limit(1); + + return DomainMapper.toDomainChat({ + ...chatResult[0], + settings: settingsResult[0] || null + }); + } + + async findAllByService(service: 'telegram' = 'telegram'): Promise { + const chatResults = await this.db + .select() + .from(chats) + .where(eq(chats.service, service)); + + const chatsWithSettings: Chat[] = []; + + for (const chat of chatResults) { + const settingsResult = await this.db + .select() + .from(chatSettings) + .where(eq(chatSettings.chatId, chat.id)) + .limit(1); + + chatsWithSettings.push(DomainMapper.toDomainChat({ + ...chat, + settings: settingsResult[0] || null + })); + } + + return chatsWithSettings; + } + + async create(chatId: string, service: 'telegram' = 'telegram'): Promise { + const id = randomUUID(); + await this.db.insert(chats).values({ id, chatId, service }); + + // Create default settings + await this.db.insert(chatSettings).values({ + chatId: id, + language: 'en', + offlineNotification: true, + gameChangeNotification: false, + titleChangeNotification: false, + gameAndTitleChangeNotification: false, + imageInNotification: true, + }); + + return id; + } + + async updateSettings(chatId: string, settings: Partial): Promise { + await this.db + .update(chatSettings) + .set(settings) + .where(eq(chatSettings.chatId, chatId)); + } +} diff --git a/src/db/repositories/drizzle/follow.drizzle.repository.ts b/src/db/repositories/drizzle/follow.drizzle.repository.ts new file mode 100644 index 00000000..a8b22b28 --- /dev/null +++ b/src/db/repositories/drizzle/follow.drizzle.repository.ts @@ -0,0 +1,82 @@ +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { eq, and, count } from 'drizzle-orm'; +import { randomUUID } from 'node:crypto'; +import { follows } from '../../schema'; +import { Follow, FollowAlreadyExistsError, FollowNotFoundError } from '../../../domain/models'; +import { DomainMapper } from '../../../domain/mapper'; +import type { IFollowRepository } from '../interfaces'; + +export class FollowDrizzleRepository implements IFollowRepository { + constructor(private db: DrizzleD1Database) {} + + async findByChatAndChannel(chatId: string, channelId: string): Promise { + const result = await this.db + .select() + .from(follows) + .where(and(eq(follows.chatId, chatId), eq(follows.channelId, channelId))) + .limit(1); + + return result[0] ? DomainMapper.toDomainFollow(result[0]) : undefined; + } + + async findByChatId(chatId: string): Promise { + const results = await this.db + .select() + .from(follows) + .where(eq(follows.chatId, chatId)); + + return results.map(r => DomainMapper.toDomainFollow(r)); + } + + async create(chatId: string, channelId: string): Promise { + // Check if already exists + const existing = await this.findByChatAndChannel(chatId, channelId); + if (existing) { + throw new FollowAlreadyExistsError(); + } + + const id = randomUUID(); + await this.db.insert(follows).values({ id, chatId, channelId }); + return id; + } + + async delete(id: string): Promise { + const result = await this.db + .delete(follows) + .where(eq(follows.id, id)) + .returning(); + + if (result.length === 0) { + throw new FollowNotFoundError(); + } + } + + async findByChannelId(channelId: string): Promise { + const results = await this.db + .select() + .from(follows) + .where(eq(follows.channelId, channelId)); + + return results.map(r => DomainMapper.toDomainFollow(r)); + } + + async findByChatIdPaginated(chatId: string, limit: number, offset: number): Promise { + const results = await this.db + .select() + .from(follows) + .where(eq(follows.chatId, chatId)) + .limit(limit) + .offset(offset); + + return results.map(r => DomainMapper.toDomainFollow(r)); + } + + async countByChatId(chatId: string): Promise { + const result = await this.db + .select({ count: count() }) + .from(follows) + .where(eq(follows.chatId, chatId)); + + return result[0]?.count ?? 0; + } +} diff --git a/src/db/repositories/drizzle/index.ts b/src/db/repositories/drizzle/index.ts new file mode 100644 index 00000000..84a91a1e --- /dev/null +++ b/src/db/repositories/drizzle/index.ts @@ -0,0 +1,4 @@ +export * from './chat.drizzle.repository'; +export * from './channel.drizzle.repository'; +export * from './follow.drizzle.repository'; +export * from './stream.drizzle.repository'; diff --git a/src/db/repositories/drizzle/stream.drizzle.repository.ts b/src/db/repositories/drizzle/stream.drizzle.repository.ts new file mode 100644 index 00000000..6bc2cc0a --- /dev/null +++ b/src/db/repositories/drizzle/stream.drizzle.repository.ts @@ -0,0 +1,70 @@ +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { eq, desc } from 'drizzle-orm'; +import { streams } from '../../schema'; +import { Stream } from '../../../domain/models'; +import { DomainMapper } from '../../../domain/mapper'; +import type { IStreamRepository } from '../interfaces'; + +export class StreamDrizzleRepository implements IStreamRepository { + constructor(private db: DrizzleD1Database) {} + + async findLatestByChannelId(channelId: string): Promise { + const result = await this.db + .select() + .from(streams) + .where(eq(streams.channelId, channelId)) + .orderBy(desc(streams.startedAt)) + .limit(1); + + return result[0] ? DomainMapper.toDomainStream(result[0]) : undefined; + } + + async create(id: string, channelId: string, category: string, title: string): Promise { + try { + await this.db.insert(streams).values({ + id, + channelId, + isLive: true, + category, + title, + startedAt: new Date().toISOString(), + titles: [title] as any, + categories: [category] as any, + }); + } catch (error: any) { + // If the stream already exists (duplicate webhook), just return the id + if (error?.message?.includes('UNIQUE constraint failed') || + error?.message?.includes('already exists')) { + console.log(`Stream ${id} already exists, skipping insert`); + return id; + } + throw error; + } + + return id; + } + + async update(id: string, data: { isLive?: boolean; category?: string; title?: string; endedAt?: string }): Promise { + const result = await this.db + .update(streams) + .set(data) + .where(eq(streams.id, id)) + .returning(); + + if (!result[0]) { + throw new Error('Stream not found'); + } + + return DomainMapper.toDomainStream(result[0]); + } + + async findById(id: string): Promise { + const result = await this.db + .select() + .from(streams) + .where(eq(streams.id, id)) + .limit(1); + + return result[0] ? DomainMapper.toDomainStream(result[0]) : undefined; + } +} diff --git a/src/db/repositories/index.ts b/src/db/repositories/index.ts new file mode 100644 index 00000000..dbbbef31 --- /dev/null +++ b/src/db/repositories/index.ts @@ -0,0 +1,20 @@ +// Export interfaces +export * from './interfaces'; + +// Export Drizzle implementations +export * from './drizzle'; + +// Re-export commonly used types for convenience +export type { + IChatRepository, + IChannelRepository, + IFollowRepository, + IStreamRepository +} from './interfaces'; + +export type { + ChatDrizzleRepository, + ChannelDrizzleRepository, + FollowDrizzleRepository, + StreamDrizzleRepository +} from './drizzle'; diff --git a/src/db/repositories/interfaces/channel.repository.interface.ts b/src/db/repositories/interfaces/channel.repository.interface.ts new file mode 100644 index 00000000..1a570203 --- /dev/null +++ b/src/db/repositories/interfaces/channel.repository.interface.ts @@ -0,0 +1,10 @@ +import type { Channel } from '../../../domain/models'; +import type { NewChannel } from '../../schema'; + +export interface IChannelRepository { + findByChannelId(channelId: string, service: 'twitch'): Promise; + findById(id: string): Promise; + create(channelId: string, service: 'twitch'): Promise; + update(id: string, data: Partial>): Promise; + updateChannelId(oldChannelId: string, newChannelId: string, service: 'twitch'): Promise; +} diff --git a/src/db/repositories/interfaces/chat.repository.interface.ts b/src/db/repositories/interfaces/chat.repository.interface.ts new file mode 100644 index 00000000..94d0b4d8 --- /dev/null +++ b/src/db/repositories/interfaces/chat.repository.interface.ts @@ -0,0 +1,9 @@ +import type { Chat, ChatSettings } from '../../../domain/models'; + +export interface IChatRepository { + findByChatId(chatId: number, service: 'telegram'): Promise; + findById(id: string): Promise; + findAllByService(service: 'telegram'): Promise; + create(chatId: string, service: 'telegram'): Promise; + updateSettings(chatId: string, settings: Partial): Promise; +} diff --git a/src/db/repositories/interfaces/follow.repository.interface.ts b/src/db/repositories/interfaces/follow.repository.interface.ts new file mode 100644 index 00000000..24dcc014 --- /dev/null +++ b/src/db/repositories/interfaces/follow.repository.interface.ts @@ -0,0 +1,11 @@ +import type { Follow } from '../../../domain/models'; + +export interface IFollowRepository { + findByChatAndChannel(chatId: string, channelId: string): Promise; + findByChatId(chatId: string): Promise; + create(chatId: string, channelId: string): Promise; + delete(id: string): Promise; + findByChannelId(channelId: string): Promise; + findByChatIdPaginated(chatId: string, limit: number, offset: number): Promise; + countByChatId(chatId: string): Promise; +} diff --git a/src/db/repositories/interfaces/index.ts b/src/db/repositories/interfaces/index.ts new file mode 100644 index 00000000..c46f45e7 --- /dev/null +++ b/src/db/repositories/interfaces/index.ts @@ -0,0 +1,5 @@ +export * from './chat.repository.interface'; +export * from './channel.repository.interface'; +export * from './follow.repository.interface'; +export * from './stream.repository.interface'; +export * from './session.repository.interface'; diff --git a/src/db/repositories/interfaces/session.repository.interface.ts b/src/db/repositories/interfaces/session.repository.interface.ts new file mode 100644 index 00000000..13dcc9ec --- /dev/null +++ b/src/db/repositories/interfaces/session.repository.interface.ts @@ -0,0 +1,6 @@ +export interface ISessionRepository { + get(key: string): Promise; + set(key: string, value: string, expiresAt?: number): Promise; + delete(key: string): Promise; + cleanup(): Promise; // Remove expired sessions +} diff --git a/src/db/repositories/interfaces/stream.repository.interface.ts b/src/db/repositories/interfaces/stream.repository.interface.ts new file mode 100644 index 00000000..120cf1eb --- /dev/null +++ b/src/db/repositories/interfaces/stream.repository.interface.ts @@ -0,0 +1,15 @@ +import type { Stream } from '../../../domain/models'; + +export interface IStreamRepository { + findLatestByChannelId(channelId: string): Promise; + create(id: string, channelId: string, category: string, title: string): Promise; + update(id: string, data: { + isLive?: boolean; + category?: string; + title?: string; + endedAt?: string; + categories?: string[]; + titles?: string[]; + }): Promise; + findById(id: string): Promise; +} diff --git a/src/db/repository.factory.ts b/src/db/repository.factory.ts new file mode 100644 index 00000000..9557fe86 --- /dev/null +++ b/src/db/repository.factory.ts @@ -0,0 +1,44 @@ +import type { + IChatRepository, + IChannelRepository, + IFollowRepository, + IStreamRepository +} from './repositories/interfaces'; +import { + ChatDrizzleRepository, + ChannelDrizzleRepository, + FollowDrizzleRepository, + StreamDrizzleRepository +} from './repositories/drizzle'; +import type { IDatabaseConnection } from './connection'; + +export interface IRepositoryFactory { + createChatRepository(): IChatRepository; + createChannelRepository(): IChannelRepository; + createFollowRepository(): IFollowRepository; + createStreamRepository(): IStreamRepository; +} + +/** + * Factory for creating Drizzle-based repositories + * Works with any Drizzle-compatible database (D1, PostgreSQL, etc.) + */ +export class DrizzleRepositoryFactory implements IRepositoryFactory { + constructor(private connection: IDatabaseConnection) {} + + createChatRepository(): IChatRepository { + return new ChatDrizzleRepository(this.connection.getClient()); + } + + createChannelRepository(): IChannelRepository { + return new ChannelDrizzleRepository(this.connection.getClient()); + } + + createFollowRepository(): IFollowRepository { + return new FollowDrizzleRepository(this.connection.getClient()); + } + + createStreamRepository(): IStreamRepository { + return new StreamDrizzleRepository(this.connection.getClient()); + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 00000000..f95abbe0 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,108 @@ +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; +import { relations } from 'drizzle-orm'; +import { randomUUID } from 'node:crypto'; + +// Chat table +export const chats = sqliteTable('chats', { + id: text('id').primaryKey().$defaultFn(() => randomUUID()), + chatId: text('chat_id').notNull(), + service: text('service', { enum: ['telegram'] }).notNull().default('telegram'), +}); + +export const chatsRelations = relations(chats, ({ one, many }) => ({ + settings: one(chatSettings, { + fields: [chats.id], + references: [chatSettings.chatId], + }), + follows: many(follows), +})); + +// Chat Settings table +export const chatSettings = sqliteTable('chat_settings', { + id: text('id').primaryKey().$defaultFn(() => randomUUID()), + chatId: text('chat_id').notNull().unique().references(() => chats.id, { onDelete: 'cascade' }), + gameChangeNotification: integer('game_change_notification', { mode: 'boolean' }).notNull().default(true), + titleChangeNotification: integer('title_change_notification', { mode: 'boolean' }).notNull().default(false), + gameAndTitleChangeNotification: integer('game_and_title_change_notification', { mode: 'boolean' }).notNull().default(false), + offlineNotification: integer('offline_notification', { mode: 'boolean' }).notNull().default(true), + imageInNotification: integer('image_in_notification', { mode: 'boolean' }).notNull().default(true), + language: text('language', { enum: ['ru', 'en', 'uk'] }).notNull().default('en'), +}); + +export const chatSettingsRelations = relations(chatSettings, ({ one }) => ({ + chat: one(chats, { + fields: [chatSettings.chatId], + references: [chats.id], + }), +})); + +// Channel table +export const channels = sqliteTable('channels', { + id: text('id').primaryKey().$defaultFn(() => randomUUID()), + channelId: text('channel_id').notNull(), + service: text('service', { enum: ['twitch'] }).notNull().default('twitch'), + isLive: integer('is_live', { mode: 'boolean' }).notNull().default(false), + title: text('title'), + category: text('category'), + updatedAt: text('updated_at').$defaultFn(() => new Date().toISOString()), +}); + +export const channelsRelations = relations(channels, ({ many }) => ({ + follows: many(follows), + streams: many(streams), +})); + +// Follow table +export const follows = sqliteTable('follows', { + id: text('id').primaryKey().$defaultFn(() => randomUUID()), + channelId: text('channel_id').notNull().references(() => channels.id, { onDelete: 'cascade' }), + chatId: text('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }), +}); + +export const followsRelations = relations(follows, ({ one }) => ({ + channel: one(channels, { + fields: [follows.channelId], + references: [channels.id], + }), + chat: one(chats, { + fields: [follows.chatId], + references: [chats.id], + }), +})); + +// Stream table +export const streams = sqliteTable('streams', { + id: text('id').primaryKey(), // Twitch stream ID + channelId: text('channel_id').notNull().references(() => channels.id, { onDelete: 'cascade' }), + isLive: integer('is_live', { mode: 'boolean' }).notNull().default(true), + title: text('title'), + category: text('category'), + titles: text('titles', { mode: 'json' }).$type().notNull().default([]), + categories: text('categories', { mode: 'json' }).$type().notNull().default([]), + startedAt: text('started_at').$defaultFn(() => new Date().toISOString()), + updatedAt: text('updated_at').$defaultFn(() => new Date().toISOString()), + endedAt: text('ended_at'), +}); + +export const streamsRelations = relations(streams, ({ one }) => ({ + channel: one(channels, { + fields: [streams.channelId], + references: [channels.id], + }), +})); + +// Types for insert and select +export type Chat = typeof chats.$inferSelect; +export type NewChat = typeof chats.$inferInsert; + +export type ChatSettings = typeof chatSettings.$inferSelect; +export type NewChatSettings = typeof chatSettings.$inferInsert; + +export type Channel = typeof channels.$inferSelect; +export type NewChannel = typeof channels.$inferInsert; + +export type Follow = typeof follows.$inferSelect; +export type NewFollow = typeof follows.$inferInsert; + +export type Stream = typeof streams.$inferSelect; +export type NewStream = typeof streams.$inferInsert; diff --git a/src/domain/mapper.ts b/src/domain/mapper.ts new file mode 100644 index 00000000..67710e92 --- /dev/null +++ b/src/domain/mapper.ts @@ -0,0 +1,63 @@ +// Mappers to convert between database schema and domain models +import type { Chat as DbChat, ChatSettings as DbChatSettings, Channel as DbChannel, Follow as DbFollow, Stream as DbStream } from '../db/schema'; +import { Chat, ChatSettings, Channel, Follow, Stream } from './models'; +import type { SupportedLanguage } from './models'; + +export class DomainMapper { + static toDomainChat(dbChat: DbChat & { settings: DbChatSettings | null }): Chat { + return new Chat({ + id: dbChat.id, + chatId: dbChat.chatId, + service: dbChat.service, + settings: dbChat.settings ? this.toDomainChatSettings(dbChat.settings) : undefined, + }); + } + + static toDomainChatSettings(dbSettings: DbChatSettings): ChatSettings { + return new ChatSettings({ + id: dbSettings.id, + chatId: dbSettings.chatId, + gameChangeNotification: dbSettings.gameChangeNotification, + titleChangeNotification: dbSettings.titleChangeNotification, + gameAndTitleChangeNotification: dbSettings.gameAndTitleChangeNotification, + offlineNotification: dbSettings.offlineNotification, + imageInNotification: dbSettings.imageInNotification, + language: dbSettings.language as SupportedLanguage, + }); + } + + static toDomainChannel(dbChannel: DbChannel): Channel { + return new Channel({ + id: dbChannel.id, + channelId: dbChannel.channelId, + service: dbChannel.service, + isLive: dbChannel.isLive, + title: dbChannel.title ?? undefined, + category: dbChannel.category ?? undefined, + updatedAt: dbChannel.updatedAt ? new Date(dbChannel.updatedAt) : undefined, + }); + } + + static toDomainFollow(dbFollow: DbFollow): Follow { + return new Follow({ + id: dbFollow.id, + channelId: dbFollow.channelId, + chatId: dbFollow.chatId, + }); + } + + static toDomainStream(dbStream: DbStream): Stream { + return new Stream({ + id: dbStream.id, + channelId: dbStream.channelId, + isLive: dbStream.isLive, + title: dbStream.title ?? undefined, + category: dbStream.category ?? undefined, + titles: dbStream.titles, + categories: dbStream.categories, + startedAt: new Date(dbStream.startedAt!), + updatedAt: dbStream.updatedAt ? new Date(dbStream.updatedAt) : undefined, + endedAt: dbStream.endedAt ? new Date(dbStream.endedAt) : undefined, + }); + } +} diff --git a/src/domain/models.ts b/src/domain/models.ts new file mode 100644 index 00000000..7160242a --- /dev/null +++ b/src/domain/models.ts @@ -0,0 +1,174 @@ +// Domain models - business logic representations +// These are separate from database schema to allow flexibility + +export type ChatService = 'telegram'; +export type ChannelService = 'twitch'; +export type SupportedLanguage = 'en' | 'ru' | 'uk'; + +export class Chat { + id: string; + chatId: string; + service: ChatService; + settings?: ChatSettings; + follows?: Follow[]; + + constructor(data: { + id: string; + chatId: string; + service: ChatService; + settings?: ChatSettings; + follows?: Follow[]; + }) { + this.id = data.id; + this.chatId = data.chatId; + this.service = data.service; + this.settings = data.settings; + this.follows = data.follows; + } +} + +export class ChatSettings { + id: string; + chatId: string; + gameChangeNotification: boolean; + titleChangeNotification: boolean; + gameAndTitleChangeNotification: boolean; + offlineNotification: boolean; + imageInNotification: boolean; + language: SupportedLanguage; + + constructor(data: { + id: string; + chatId: string; + gameChangeNotification: boolean; + titleChangeNotification: boolean; + gameAndTitleChangeNotification: boolean; + offlineNotification: boolean; + imageInNotification: boolean; + language: SupportedLanguage; + }) { + this.id = data.id; + this.chatId = data.chatId; + this.gameChangeNotification = data.gameChangeNotification; + this.titleChangeNotification = data.titleChangeNotification; + this.gameAndTitleChangeNotification = data.gameAndTitleChangeNotification; + this.offlineNotification = data.offlineNotification; + this.imageInNotification = data.imageInNotification; + this.language = data.language; + } +} + +export class Channel { + id: string; + channelId: string; + service: ChannelService; + isLive: boolean; + title?: string; + category?: string; + updatedAt?: Date; + follows?: Follow[]; + streams?: Stream[]; + + constructor(data: { + id: string; + channelId: string; + service: ChannelService; + isLive: boolean; + title?: string; + category?: string; + updatedAt?: Date; + follows?: Follow[]; + streams?: Stream[]; + }) { + this.id = data.id; + this.channelId = data.channelId; + this.service = data.service; + this.isLive = data.isLive; + this.title = data.title; + this.category = data.category; + this.updatedAt = data.updatedAt; + this.follows = data.follows; + this.streams = data.streams; + } +} + +export class Follow { + id: string; + channelId: string; + chatId: string; + channel?: Channel; + chat?: Chat; + + constructor(data: { + id: string; + channelId: string; + chatId: string; + channel?: Channel; + chat?: Chat; + }) { + this.id = data.id; + this.channelId = data.channelId; + this.chatId = data.chatId; + this.channel = data.channel; + this.chat = data.chat; + } +} + +export class Stream { + id: string; + channelId: string; + isLive: boolean; + title?: string; + category?: string; + titles: string[]; + categories: string[]; + startedAt: Date; + updatedAt?: Date; + endedAt?: Date; + + constructor(data: { + id: string; + channelId: string; + isLive: boolean; + title?: string; + category?: string; + titles: string[]; + categories: string[]; + startedAt: Date; + updatedAt?: Date; + endedAt?: Date; + }) { + this.id = data.id; + this.channelId = data.channelId; + this.isLive = data.isLive; + this.title = data.title; + this.category = data.category; + this.titles = data.titles; + this.categories = data.categories; + this.startedAt = data.startedAt; + this.updatedAt = data.updatedAt; + this.endedAt = data.endedAt; + } +} + +// Errors +export class FollowAlreadyExistsError extends Error { + constructor() { + super('Follow already exists'); + this.name = 'FollowAlreadyExistsError'; + } +} + +export class FollowNotFoundError extends Error { + constructor() { + super('Follow not found'); + this.name = 'FollowNotFoundError'; + } +} + +export class ChannelNotFoundError extends Error { + constructor() { + super('Channel not found'); + this.name = 'ChannelNotFoundError'; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..3df2c98c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,81 @@ +import { Hono } from 'hono'; +import { webhookCallback } from 'grammy'; +import { drizzle } from 'drizzle-orm/d1'; +import type { Env } from './types'; +import { createBot } from './bot'; +import { + TelegramService, + I18nService, + TwitchService, + EventSubService, +} from '~/services'; +import { CloudflareD1Connection } from './db/connection'; +import { DrizzleRepositoryFactory } from './db/repository.factory'; +import { CloudflareKVSessionRepository } from './db/repositories/cloudflare-kv'; +import { handleTwitchWebhook } from './webhooks/twitch'; + +const app = new Hono<{ Bindings: Env }>(); + +// Health check +app.get('/', (c) => { + return c.json({ status: 'ok', service: 'twitch-notifier' }); +}); + +// Telegram webhook endpoint +app.post('/telegram-webhook', async (c) => { + const env = c.env; + + // Create database connection (serverless-agnostic) + const dbClient = drizzle(env.twitch_notifier_db); + const dbConnection = new CloudflareD1Connection(dbClient); + + // Create repository factory + const repositoryFactory = new DrizzleRepositoryFactory(dbConnection); + + // Create repositories + const chatRepo = repositoryFactory.createChatRepository(); + const channelRepo = repositoryFactory.createChannelRepository(); + const followRepo = repositoryFactory.createFollowRepository(); + const streamRepo = repositoryFactory.createStreamRepository(); + + // Create session repository using Cloudflare KV + const sessionRepo = new CloudflareKVSessionRepository(env.twitch_notifier_kv); + + // Initialize services + const i18nService = new I18nService(); + await i18nService.init(); // Initialize i18next + const twitchService = new TwitchService(env); + const telegramService = new TelegramService(env, i18nService); + const eventSubService = new EventSubService( + twitchService.getApiClient(), + env, + env.BASE_URL + ); + + // Create bot instance + const bot = createBot(env, { + i18n: i18nService, + twitch: twitchService, + eventsub: eventSubService, + chatRepo, + channelRepo, + followRepo, + sessionRepo, + }); + + // Handle webhook + const handler = webhookCallback(bot, 'hono'); + return handler(c); +}); + +// Twitch EventSub webhook endpoint +app.post('/twitch-webhook', async (c) => { + const env = c.env; + const db = drizzle(env.twitch_notifier_db); + + return await handleTwitchWebhook(c.req.raw, env, db, c.executionCtx); +}); + +console.log('App initialized'); + +export default app; diff --git a/src/services/eventsub.service.ts b/src/services/eventsub.service.ts new file mode 100644 index 00000000..083948de --- /dev/null +++ b/src/services/eventsub.service.ts @@ -0,0 +1,129 @@ +import { ApiClient } from '@twurple/api'; +import type { Env } from '../types/env'; + +export class EventSubService { + private apiClient: ApiClient; + private webhookUrl: string; + private secret: string; + + constructor(apiClient: ApiClient, env: Env, baseUrl: string) { + this.apiClient = apiClient; + this.webhookUrl = `${baseUrl}/twitch-webhook`; + this.secret = env.TWITCH_EVENTSUB_SECRET; + } + + /** + * Subscribe to all events for a broadcaster (stream.online, stream.offline, channel.update) + */ + async subscribeToChannel(broadcasterId: string): Promise { + try { + // Subscribe to stream online events + await this.apiClient.eventSub.subscribeToStreamOnlineEvents( + broadcasterId, + { + method: 'webhook', + callback: this.webhookUrl, + secret: this.secret, + } + ); + + // Subscribe to stream offline events + await this.apiClient.eventSub.subscribeToStreamOfflineEvents( + broadcasterId, + { + method: 'webhook', + callback: this.webhookUrl, + secret: this.secret, + } + ); + + // Subscribe to channel update events (title/category changes) + await this.apiClient.eventSub.subscribeToChannelUpdateEvents( + broadcasterId, + { + method: 'webhook', + callback: this.webhookUrl, + secret: this.secret, + } + ); + } catch (error) { + console.error(`Failed to subscribe to events for broadcaster ${broadcasterId}:`, error); + throw error; + } + } + + /** + * Unsubscribe from all events for a broadcaster + */ + async unsubscribeFromChannel(broadcasterId: string): Promise { + try { + // Get all subscriptions + const subscriptions = await this.apiClient.eventSub.getSubscriptions(); + + // Filter subscriptions for this broadcaster and our webhook URL + const broadcasterSubs = subscriptions.data.filter( + (sub) => { + const transportMethod = (sub as any).transport?.callback || (sub as any)._transport?.callback; + const broadcastId = (sub.condition as any).broadcaster_user_id; + return transportMethod === this.webhookUrl && broadcastId === broadcasterId; + } + ); + + // Delete each subscription + for (const sub of broadcasterSubs) { + await this.apiClient.eventSub.deleteSubscription(sub.id); + } + } catch (error) { + console.error(`Failed to unsubscribe from events for broadcaster ${broadcasterId}:`, error); + throw error; + } + } + + /** + * Check if we already have active subscriptions for a broadcaster + */ + async hasActiveSubscriptions(broadcasterId: string): Promise { + try { + const subscriptions = await this.apiClient.eventSub.getSubscriptions(); + + return subscriptions.data.some( + (sub) => { + const transportMethod = (sub as any).transport?.callback || (sub as any)._transport?.callback; + const broadcastId = (sub.condition as any).broadcaster_user_id; + return transportMethod === this.webhookUrl && broadcastId === broadcasterId && sub.status === 'enabled'; + } + ); + } catch (error) { + console.error(`Failed to check subscriptions for broadcaster ${broadcasterId}:`, error); + return false; + } + } + + /** + * Delete a specific subscription by ID + */ + async deleteSubscription(subscriptionId: string): Promise { + try { + await this.apiClient.eventSub.deleteSubscription(subscriptionId); + } catch (error) { + console.error(`Failed to delete subscription ${subscriptionId}:`, error); + throw error; + } + } + + /** + * Get all active subscriptions for our webhook + */ + async getActiveSubscriptions() { + try { + const subscriptions = await this.apiClient.eventSub.getSubscriptions(); + return subscriptions.data.filter((sub) => { + const transportMethod = (sub as any).transport?.callback || (sub as any)._transport?.callback; + return transportMethod === this.webhookUrl; + }); + } catch (error) { + console.error('Failed to get active subscriptions:', error); + return []; + } + } +} diff --git a/src/services/i18n.service.ts b/src/services/i18n.service.ts new file mode 100644 index 00000000..30d123f2 --- /dev/null +++ b/src/services/i18n.service.ts @@ -0,0 +1,84 @@ +import i18next from 'i18next'; +import type { MiddlewareFn } from 'grammy'; +import enLocale from '../../locales/en.json'; +import ruLocale from '../../locales/ru.json'; +import ukLocale from '../../locales/uk.json'; + +export type SupportedLanguage = 'en' | 'ru' | 'uk'; + +export class I18nService { + private i18n: typeof i18next; + private initialized = false; + + constructor() { + this.i18n = i18next.createInstance(); + } + + /** + * Initialize i18next instance with locales + * Must be called before using the service + */ + async init(): Promise { + if (this.initialized) return; + + await this.i18n.init({ + lng: 'en', + fallbackLng: 'en', + defaultNS: 'translation', + ns: ['translation'], + resources: { + en: { translation: enLocale }, + ru: { translation: ruLocale }, + uk: { translation: ukLocale }, + }, + interpolation: { + escapeValue: false, // Not needed for Telegram (no XSS risk) + }, + }); + + this.initialized = true; + } + + /** + * Get translated string + * @param locale - Language code + * @param key - Translation key (dot notation) + * @param params - Template parameters + */ + t(locale: SupportedLanguage, key: string, params?: Record): string { + if (!this.initialized) { + throw new Error('I18nService not initialized. Call init() first.'); + } + return this.i18n.t(key, { ...params, lng: locale }); + } + + /** + * Get Grammy middleware that attaches t() function to context + */ + middleware(): MiddlewareFn { + return async (ctx, next) => { + const language = ctx.session?.language || 'en'; + + // Attach t() function to context that uses session language + ctx.t = (key: string, params?: Record) => { + return this.t(language, key, params); + }; + + await next(); + }; + } + + /** + * Get all available locales + */ + getAvailableLocales(): SupportedLanguage[] { + return ['en', 'ru', 'uk']; + } + + /** + * Check if locale is supported + */ + isValidLocale(locale: string): locale is SupportedLanguage { + return ['en', 'ru', 'uk'].includes(locale); + } +} diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 00000000..686fd746 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,5 @@ +export * from './twitch.service'; +export * from './telegram.service'; +export * from './i18n.service'; +export * from './notification.service'; +export * from './eventsub.service'; diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts new file mode 100644 index 00000000..9fceac70 --- /dev/null +++ b/src/services/notification.service.ts @@ -0,0 +1,210 @@ +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import type { Env } from '../types/env'; +import { TelegramService } from './telegram.service'; +import { TwitchService } from './twitch.service'; +import { I18nService, type SupportedLanguage } from './i18n.service'; +import type { + IChatRepository, + IChannelRepository, + IFollowRepository, + IStreamRepository, +} from '../db/repositories/interfaces'; + +export interface StreamOnlineEventData { + channelId: string; + channelName: string; + streamId: string; + category: string; + title: string; + thumbnailUrl: string; +} + +export interface StreamOfflineEventData { + channelId: string; + channelName: string; +} + +export interface StreamCategoryChangeEventData { + channelId: string; + channelName: string; + oldCategory: string; + newCategory: string; +} + +export interface StreamTitleChangeEventData { + channelId: string; + channelName: string; + oldTitle: string; + newTitle: string; +} + +export class NotificationService { + constructor( + private env: Env, + private db: DrizzleD1Database, + private telegramService: TelegramService, + private twitchService: TwitchService, + private i18nService: I18nService, + private chatRepo: IChatRepository, + private channelRepo: IChannelRepository, + private followRepo: IFollowRepository, + private streamRepo: IStreamRepository + ) {} + + async handleStreamOnline(data: StreamOnlineEventData): Promise { + // Get or create channel + let channel = await this.channelRepo.findByChannelId(data.channelId, 'twitch'); + if (!channel) { + channel = await this.channelRepo.create(data.channelId, 'twitch'); + } + + // Create stream record + await this.streamRepo.create( + data.streamId, + channel.id, + data.category, + data.title + ); + + // Get all followers of this channel + const follows = await this.followRepo.findByChannelId(channel.id); + + // Send notifications to all followers + for (const follow of follows) { + try { + const chat = await this.chatRepo.findById(follow.chatId); + if (!chat || !chat.settings) continue; + + await this.telegramService.sendStreamOnlineNotification({ + chatId: parseInt(chat.chatId), + language: chat.settings.language as SupportedLanguage, + channelName: data.channelName, + channelUrl: `https://twitch.tv/${data.channelName}`, + category: data.category, + title: data.title, + thumbnailUrl: data.thumbnailUrl, + showImage: chat.settings.imageInNotification, + }); + } catch (error) { + console.error('Failed to send online notification:', error); + } + } + } + + async handleStreamOffline(data: StreamOfflineEventData): Promise { + const channel = await this.channelRepo.findByChannelId(data.channelId, 'twitch'); + if (!channel) return; + + // Get latest stream + const stream = await this.streamRepo.findLatestByChannelId(channel.id); + if (!stream || !stream.isLive) return; + + // Update stream as offline + await this.streamRepo.update(stream.id, { + isLive: false, + endedAt: new Date().toISOString(), + }); + + // Get all followers + const follows = await this.followRepo.findByChannelId(channel.id); + + // Send notifications to followers who want offline notifications + for (const follow of follows) { + try { + const chat = await this.chatRepo.findById(follow.chatId); + if (!chat || !chat.settings || !chat.settings.offlineNotification) continue; + + const duration = stream.startedAt + ? Math.floor((Date.now() - new Date(stream.startedAt).getTime()) / 1000) + : 0; + const hours = Math.floor(duration / 3600); + const minutes = Math.floor((duration % 3600) / 60); + const seconds = duration % 60; + const durationStr = `${hours}h ${minutes}m ${seconds}s`; + + await this.telegramService.sendStreamOfflineNotification({ + chatId: parseInt(chat.chatId), + language: chat.settings.language as SupportedLanguage, + channelName: data.channelName, + channelUrl: `https://twitch.tv/${data.channelName}`, + categories: stream.categories || [], + duration: durationStr, + }); + } catch (error) { + console.error('Failed to send offline notification:', error); + } + } + } + + async handleCategoryChange(data: StreamCategoryChangeEventData): Promise { + const channel = await this.channelRepo.findByChannelId(data.channelId, 'twitch'); + if (!channel) return; + + const stream = await this.streamRepo.findLatestByChannelId(channel.id); + if (!stream || !stream.isLive) return; + + // Update stream categories + const categories = [...(stream.categories || []), data.newCategory]; + await this.streamRepo.update(stream.id, { + category: data.newCategory, + categories, + }); + + // Get all followers + const follows = await this.followRepo.findByChannelId(channel.id); + + for (const follow of follows) { + try { + const chat = await this.chatRepo.findById(follow.chatId); + if (!chat || !chat.settings || !chat.settings.gameChangeNotification) continue; + + await this.telegramService.sendCategoryChangeNotification({ + chatId: parseInt(chat.chatId), + language: chat.settings.language as SupportedLanguage, + channelName: data.channelName, + channelUrl: `https://twitch.tv/${data.channelName}`, + oldCategory: data.oldCategory, + category: data.newCategory, + }); + } catch (error) { + console.error('Failed to send category change notification:', error); + } + } + } + + async handleTitleChange(data: StreamTitleChangeEventData): Promise { + const channel = await this.channelRepo.findByChannelId(data.channelId, 'twitch'); + if (!channel) return; + + const stream = await this.streamRepo.findLatestByChannelId(channel.id); + if (!stream || !stream.isLive) return; + + // Update stream titles + const titles = [...(stream.titles || []), data.newTitle]; + await this.streamRepo.update(stream.id, { + title: data.newTitle, + titles, + }); + + // Get all followers + const follows = await this.followRepo.findByChannelId(channel.id); + + for (const follow of follows) { + try { + const chat = await this.chatRepo.findById(follow.chatId); + if (!chat || !chat.settings || !chat.settings.titleChangeNotification) continue; + + await this.telegramService.sendTitleChangeNotification({ + chatId: parseInt(chat.chatId), + language: chat.settings.language as SupportedLanguage, + channelName: data.channelName, + channelUrl: `https://twitch.tv/${data.channelName}`, + oldTitle: data.oldTitle, + title: data.newTitle, + }); + } catch (error) { + console.error('Failed to send title change notification:', error); + } + } + } +} diff --git a/src/services/telegram.service.ts b/src/services/telegram.service.ts new file mode 100644 index 00000000..8401f0f1 --- /dev/null +++ b/src/services/telegram.service.ts @@ -0,0 +1,163 @@ +import { Bot, InputFile } from 'grammy'; +import type { Env } from '../types/env'; +import type { I18nService, SupportedLanguage } from './i18n.service'; +import { ThumbnailBuilder } from '../utils/thumbnail'; + +export interface StreamOnlineNotification { + chatId: number; + language: SupportedLanguage; + channelName: string; + channelUrl: string; + category: string; + title: string; + thumbnailUrl?: string; + showImage: boolean; +} + +export interface StreamOfflineNotification { + chatId: number; + language: SupportedLanguage; + channelName: string; + channelUrl: string; + categories: string[]; + duration: string; +} + +export interface CategoryChangeNotification { + chatId: number; + language: SupportedLanguage; + channelName: string; + channelUrl: string; + oldCategory: string; + category: string; +} + +export interface TitleChangeNotification { + chatId: number; + language: SupportedLanguage; + channelName: string; + channelUrl: string; + oldTitle: string; + title: string; +} + +export interface TitleAndCategoryChangeNotification { + chatId: number; + language: SupportedLanguage; + channelName: string; + channelUrl: string; + oldTitle: string; + title: string; + oldCategory: string; + category: string; +} + +export class TelegramService { + private bot: Bot; + private i18n: I18nService; + private thumbnailBuilder: ThumbnailBuilder; + + constructor(env: Env, i18n: I18nService) { + this.bot = new Bot(env.TELEGRAM_TOKEN, { client: { timeoutSeconds: 60 } }); + this.i18n = i18n; + this.thumbnailBuilder = new ThumbnailBuilder(); + } + + async sendStreamOnlineNotification(notification: StreamOnlineNotification): Promise { + const channelLink = `${notification.channelName}`; + + const text = this.i18n.t(notification.language, 'notifications.streams.nowOnline', { + channelLink, + category: notification.category, + title: notification.title, + }); + + if (notification.showImage && notification.thumbnailUrl) { + try { + const thumbnailUrl = this.thumbnailBuilder.build(notification.thumbnailUrl); + await this.bot.api.sendPhoto(notification.chatId, new InputFile(new URL(thumbnailUrl)), { + caption: text, + parse_mode: 'HTML', + }); + return; + } catch (error) { + // Fallback to text message if image fails + console.error('Failed to send photo:', error); + } + } + + await this.bot.api.sendMessage(notification.chatId, text, { + parse_mode: 'HTML', + link_preview_options: { is_disabled: false }, + }); + } + + async sendStreamOfflineNotification(notification: StreamOfflineNotification): Promise { + const channelLink = `${notification.channelName}`; + const categories = notification.categories.join(', '); + + const text = this.i18n.t(notification.language, 'notifications.streams.nowOffline', { + channelLink, + categories, + duration: notification.duration, + }); + + await this.bot.api.sendMessage(notification.chatId, text, { + parse_mode: 'HTML', + link_preview_options: { is_disabled: true }, + }); + } + + async sendCategoryChangeNotification(notification: CategoryChangeNotification): Promise { + const channelLink = `${notification.channelName}`; + + const text = this.i18n.t(notification.language, 'notifications.streams.newCategory', { + channelLink, + oldCategory: notification.oldCategory, + category: notification.category, + }); + + await this.bot.api.sendMessage(notification.chatId, text, { + parse_mode: 'HTML', + link_preview_options: { is_disabled: true }, + }); + } + + async sendTitleChangeNotification(notification: TitleChangeNotification): Promise { + const channelLink = `${notification.channelName}`; + + const text = this.i18n.t(notification.language, 'notifications.streams.titleChanged', { + channelLink, + oldTitle: notification.oldTitle, + title: notification.title, + }); + + await this.bot.api.sendMessage(notification.chatId, text, { + parse_mode: 'HTML', + link_preview_options: { is_disabled: true }, + }); + } + + async sendTitleAndCategoryChangeNotification( + notification: TitleAndCategoryChangeNotification + ): Promise { + const channelLink = `${notification.channelName}`; + + const text = this.i18n.t(notification.language, 'notifications.streams.titleAndCategoryChanged', { + channelLink, + oldTitle: notification.oldTitle, + title: notification.title, + oldCategory: notification.oldCategory, + category: notification.category, + }); + + await this.bot.api.sendMessage(notification.chatId, text, { + parse_mode: 'HTML', + link_preview_options: { is_disabled: true }, + }); + } + + getBot(): Bot { + return this.bot; + } +} diff --git a/src/services/twitch.service.ts b/src/services/twitch.service.ts new file mode 100644 index 00000000..dcd1935d --- /dev/null +++ b/src/services/twitch.service.ts @@ -0,0 +1,56 @@ +import { ApiClient } from '@twurple/api'; +import { AppTokenAuthProvider } from '@twurple/auth'; +import type { Env } from '../types/env'; + +export class TwitchService { + private apiClient: ApiClient; + private authProvider: AppTokenAuthProvider; + + constructor(env: Env) { + this.authProvider = new AppTokenAuthProvider( + env.TWITCH_CLIENT_ID, + env.TWITCH_CLIENT_SECRET + ); + this.apiClient = new ApiClient({ authProvider: this.authProvider }); + } + + async getUserByLogin(login: string) { + try { + return await this.apiClient.users.getUserByName(login); + } catch (error) { + return null; + } + } + + async getUserById(id: string) { + try { + return await this.apiClient.users.getUserById(id); + } catch (error) { + return null; + } + } + + async getStreamByUserId(userId: string) { + try { + return await this.apiClient.streams.getStreamByUserId(userId); + } catch (error) { + return null; + } + } + + async getGameById(gameId: string) { + try { + return await this.apiClient.games.getGameById(gameId); + } catch (error) { + return null; + } + } + + getApiClient() { + return this.apiClient; + } + + getAuthProvider() { + return this.authProvider; + } +} diff --git a/src/types/env.ts b/src/types/env.ts new file mode 100644 index 00000000..e5ecc41c --- /dev/null +++ b/src/types/env.ts @@ -0,0 +1,21 @@ +import type { D1Database, KVNamespace } from '@cloudflare/workers-types'; + +export interface Env { + // D1 Database + twitch_notifier_db: D1Database; + + // KV Namespace for sessions + twitch_notifier_kv: KVNamespace; + + // Secrets + TELEGRAM_TOKEN: string; + TWITCH_CLIENT_ID: string; + TWITCH_CLIENT_SECRET: string; + TELEGRAM_BOT_ADMINS: string; // comma-separated user IDs + TWITCH_EVENTSUB_SECRET: string; + BASE_URL: string; // Base URL for webhooks (e.g., https://your-worker.workers.dev) + BOT_INFO: string; + + // Variables + APP_ENV: 'development' | 'production'; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..c1532d6d --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1 @@ +export * from './env'; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..d4ab7a50 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export * from './thumbnail'; diff --git a/src/utils/thumbnail.ts b/src/utils/thumbnail.ts new file mode 100644 index 00000000..a0f783be --- /dev/null +++ b/src/utils/thumbnail.ts @@ -0,0 +1,12 @@ +export class ThumbnailBuilder { + /** + * Build thumbnail URL from Twitch template URL + * @param thumbnailUrl - Twitch thumbnail URL with {width} and {height} placeholders + * @returns Final thumbnail URL + */ + build(thumbnailUrl: string): string { + return thumbnailUrl + .replace('{width}', '1920') + .replace('{height}', '1080'); + } +} diff --git a/src/webhooks/twitch.ts b/src/webhooks/twitch.ts new file mode 100644 index 00000000..4443e576 --- /dev/null +++ b/src/webhooks/twitch.ts @@ -0,0 +1,198 @@ +import type { Env } from '../types/env'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { TwitchService } from '../services/twitch.service'; +import { TelegramService } from '../services/telegram.service'; +import { I18nService } from '../services/i18n.service'; +import { NotificationService } from '../services/notification.service'; +import { CloudflareD1Connection } from '../db/connection'; +import { DrizzleRepositoryFactory } from '../db/repository.factory'; +import { createHmac } from 'node:crypto'; + +interface EventSubNotification { + subscription: { + id: string; + type: string; + version: string; + status: string; + cost: number; + condition: Record; + transport: { + method: string; + callback: string; + }; + created_at: string; + }; + event: Record; +} + +interface EventSubVerification { + challenge: string; + subscription: { + id: string; + type: string; + version: string; + status: string; + cost: number; + condition: Record; + transport: { + method: string; + callback: string; + }; + created_at: string; + }; +} + +export async function handleTwitchWebhook( + request: Request, + env: Env, + db: DrizzleD1Database, + executionCtx: ExecutionContext +): Promise { + try { + // Verify the signature + const messageId = request.headers.get('Twitch-Eventsub-Message-Id'); + const timestamp = request.headers.get('Twitch-Eventsub-Message-Timestamp'); + const signature = request.headers.get('Twitch-Eventsub-Message-Signature'); + const messageType = request.headers.get('Twitch-Eventsub-Message-Type'); + + if (!messageId || !timestamp || !signature) { + return new Response('Missing required headers', { status: 400 }); + } + + const body = await request.text(); + + // Verify signature + const hmac = createHmac('sha256', env.TWITCH_EVENTSUB_SECRET); + hmac.update(messageId + timestamp + body); + const expectedSignature = 'sha256=' + hmac.digest('hex'); + + if (signature !== expectedSignature) { + return new Response('Invalid signature', { status: 403 }); + } + + const payload = JSON.parse(body); + + // Handle verification challenge + if (messageType === 'webhook_callback_verification') { + const verification = payload as EventSubVerification; + return new Response(verification.challenge, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + } + + // Handle notification + if (messageType === 'notification') { + const notification = payload as EventSubNotification; + + // Respond immediately to prevent Twitch from retrying due to timeout + executionCtx.waitUntil(processNotification(notification, env, db)); + + return new Response('OK', { status: 200 }); + } + + // Handle revocation + if (messageType === 'revocation') { + console.log('Subscription revoked:', payload); + return new Response('OK', { status: 200 }); + } + + return new Response('Unknown message type', { status: 400 }); + } catch (error) { + console.error('Error handling Twitch webhook:', error); + return new Response('Internal Server Error', { status: 500 }); + } +} + +async function processNotification( + notification: EventSubNotification, + env: Env, + db: DrizzleD1Database +): Promise { + try { + console.log('Received Twitch EventSub notification:', notification.subscription.type, notification); + + const i18nService = new I18nService(); + await i18nService.init(); + + const twitchService = new TwitchService(env); + const telegramService = new TelegramService(env, i18nService); + + const dbConnection = new CloudflareD1Connection(db); + const repositoryFactory = new DrizzleRepositoryFactory(dbConnection); + + const chatRepo = repositoryFactory.createChatRepository(); + const channelRepo = repositoryFactory.createChannelRepository(); + const followRepo = repositoryFactory.createFollowRepository(); + const streamRepo = repositoryFactory.createStreamRepository(); + + const notificationService = new NotificationService( + env, + db, + telegramService, + twitchService, + i18nService, + chatRepo, + channelRepo, + followRepo, + streamRepo + ); + + switch (notification.subscription.type) { + case 'stream.online': { + const event = notification.event; + const stream = await twitchService.getStreamByUserId(event.broadcaster_user_id); + if (stream) { + await notificationService.handleStreamOnline({ + channelId: event.broadcaster_user_id, + channelName: event.broadcaster_user_name, + streamId: stream.id, + category: stream.gameName, + title: stream.title, + thumbnailUrl: stream.thumbnailUrl, + }); + } + break; + } + + case 'stream.offline': { + const event = notification.event; + await notificationService.handleStreamOffline({ + channelId: event.broadcaster_user_id, + channelName: event.broadcaster_user_name, + }); + break; + } + + case 'channel.update': { + const event = notification.event; + const channel = await channelRepo.findByChannelId(event.broadcaster_user_id, 'twitch'); + if (!channel) break; + + const stream = await streamRepo.findLatestByChannelId(channel.id); + if (!stream || !stream.isLive) break; + + if (stream.category && event.category_name !== stream.category) { + await notificationService.handleCategoryChange({ + channelId: event.broadcaster_user_id, + channelName: event.broadcaster_user_name, + oldCategory: stream.category, + newCategory: event.category_name, + }); + } + + if (stream.title && event.title !== stream.title) { + await notificationService.handleTitleChange({ + channelId: event.broadcaster_user_id, + channelName: event.broadcaster_user_name, + oldTitle: stream.title, + newTitle: event.title, + }); + } + break; + } + } + } catch (error) { + console.error('Error processing Twitch notification:', error); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..a25639df --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022"], + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "types": ["@cloudflare/workers-types"], + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", ".wrangler"] +} diff --git a/wrangler.example.toml b/wrangler.example.toml new file mode 100644 index 00000000..86ae1332 --- /dev/null +++ b/wrangler.example.toml @@ -0,0 +1,21 @@ +name = "twitch-notifier" +main = "src/index.ts" +compatibility_date = "2026-03-06" +compatibility_flags = [ + "nodejs_compat" +] +workers_dev = true + +[observability] +enabled = true + +# D1 Database +[[d1_databases]] +binding = "DB" +database_name = "twitch-notifier-db" +database_id = "" # Will be filled after creating D1 database +migrations_dir = "./migrations" + +[[kv_namespaces]] +binding = "twitch-notifier-kv" +id = "1"