Compare commits

...

9 Commits

Author SHA1 Message Date
1852c5025e add rate limiter to telegram
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-24 12:40:42 +03:00
afacaf4f14 add reading tags from pre-cached
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-24 07:51:03 +03:00
eef26aba0a feat(ci): add build cache
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-06-23 22:19:01 +03:00
14c54ee737 feat(admin): add cache warming from local directory via /warm --from-dir
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-23 21:52:57 +03:00
e13557059c Add LICENSE, update README.md, .drone.yml
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-23 21:30:30 +03:00
2507a1531e trying to fix dockerci lint step
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-23 18:38:28 +03:00
2d4d7c2813 fix gitignore
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-23 16:38:45 +03:00
4005371767 fixed gitignore
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-23 16:29:45 +03:00
0d5fcabebd fix drone ci lint
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-23 14:04:46 +03:00
13 changed files with 709 additions and 39 deletions

View File

@@ -5,31 +5,103 @@ name: default
trigger: trigger:
branch: branch:
- master - master
- dev
event: event:
- push - push
- pull_request - pull_request
# --- НАЧАЛО БЛОКА ЛОКАЛЬНОГО КЭШИРОВАНИЯ ---
volumes:
- name: go-cache # Логическое имя кэша для Go
host:
# Путь на сервере, где запущен Drone Agent.
# Убедитесь, что эта директория существует и у Drone есть права на запись.
path: /var/cache/drone/gocache
- name: lint-cache # Отдельный кэш для линтера
host:
path: /var/cache/drone/lintcache
# --- КОНЕЦ БЛОКА ЛОКАЛЬНОГО КЭШИРОВАНИЯ ---
steps: steps:
- name: lint # Общие шаги для всех веток
image: golangci/golangci-lint:v1.59-alpine - name: deps
image: golang:1.24-alpine
volumes:
# Монтируем наш go-cache в стандартный GOPATH контейнера
- name: go-cache
path: /go
commands: commands:
- golangci-lint run ./... # Теперь go mod download будет сразу использовать и сохранять кэш на хост-машине
- go mod download
- go mod tidy
- name: lint
image: golangci/golangci-lint:v1.64-alpine
volumes:
# Монтируем кэш линтера
- name: lint-cache
path: /root/.cache
commands:
# Линтер автоматически подхватит кэш из /root/.cache
- golangci-lint run --timeout=5m --verbose ./...
- name: test - name: test
image: golang:1.24-alpine image: golang:1.24-alpine
volumes:
# Также монтируем go-cache, чтобы тесты использовали скачанные модули и кэш сборки
- name: go-cache
path: /go
commands: commands:
- go test -race -cover ./... - apk add --no-cache build-base
# Шаг сборки и публикации будет добавлен позже # CGO_ENABLED=1 go test теперь будет значительно быстрее при повторных запусках
# - name: build-and-publish - CGO_ENABLED=1 go test -race -cover ./...
# image: plugins/docker
# settings: # Шаги сборки и публикации остаются почти без изменений.
# repo: gitea.mrixs.me/mrixs/yamusic-bot # Кэширование Docker-слоев (`cache_from`) - это отдельный механизм, и его стоит оставить.
# registry: gitea.mrixs.me # Он дополняет кэширование зависимостей, ускоряя саму сборку Docker-образа.
# username: - name: build-and-publish-master
# from_secret: gitea_username image: plugins/docker
# password: settings:
# from_secret: gitea_password repo: gitea.mrixs.me/mrixs/yamusic-bot
# auto_tag: true registry: gitea.mrixs.me
# platforms: username:
# - linux/amd64 from_secret: gitea_username
# - linux/arm64 password:
from_secret: gitea_password
auto_tag: true
# Оставляем кэширование Docker-слоев, это очень эффективно
cache_from:
- gitea.mrixs.me/mrixs/yamusic-bot:latest
platforms:
- linux/amd64
- linux/arm64
- linux/arm/v7
when:
branch:
- master
event:
- push
- name: build-and-publish-dev
image: plugins/docker
settings:
repo: gitea.mrixs.me/mrixs/yamusic-bot
registry: gitea.mrixs.me
username:
from_secret: gitea_username
password:
from_secret: gitea_password
tags:
- dev
- dev-${DRONE_COMMIT_SHA:0:7}
cache_from:
- gitea.mrixs.me/mrixs/yamusic-bot:dev
platforms:
- linux/amd64
- linux/arm64
- linux/arm/v7
when:
branch:
- dev
event:
- push

2
.gitignore vendored
View File

@@ -1,5 +1,7 @@
# Binary # Binary
bot bot
!cmd/bot
!internal/bot
yamusic-bot yamusic-bot
# Data # Data

7
LICENSE Normal file
View File

@@ -0,0 +1,7 @@
Copyright 2025 Vladimir Zagainov
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

108
README.md
View File

@@ -1,17 +1,109 @@
# Yandex.Music Downloader Bot # Yandex.Music Downloader Bot
[![Build Status](https://drone.mrixs.me/api/badges/Mrixs/yamusic-bot/status.svg)](https://drone.mrixs.me/Mrixs/yamusic-bot) [![Build Status](https://drone.mrixs.me/api/badges/Mrixs/yamusic-bot/status.svg?branch=dev)](https://drone.mrixs.me/Mrixs/yamusic-bot?branch=dev) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Удобный и быстрый Telegram-бот для получения аудиофайлов треков из сервиса Yandex.Music по ссылке. Удобный и быстрый Telegram-бот для получения аудиофайлов из сервиса Yandex.Music. Работает в inline-режиме, позволяя отправлять музыку в любой чат. Поддерживает поиск, а также ссылки на треки, альбомы и исполнителей.
## Конфигурация ## 🚀 Основные возможности
_(Будет заполнено позже)_ * **Inline-режим:** Используйте бота в любом чате, просто упомянув его `@username`.
* **Поддержка ссылок:** Распознает и обрабатывает ссылки на треки, альбомы и страницы исполнителей.
* **Поиск:** Если введенный текст не является ссылкой, бот выполнит поиск треков по этому тексту.
* **Метаданные:** Автоматически встраивает в аудиофайлы ID3-теги (название, исполнитель, альбом, год) и обложку.
* **Кэширование:** Мгновенная отправка уже обработанных треков благодаря системе кэширования на базе SQLite и приватного Telegram-канала.
* **Администрирование:** Предоставляет набор команд для администраторов для мониторинга и управления ботом.
## Запуск ## ⚙️ Конфигурация
_(Будет заполнено позже)_ Бот настраивается с помощью переменных окружения.
## Использование | Переменная | Описание | Пример | Обязательно |
| ------------------------- | ------------------------------------------------------ | ----------------------------- | ----------- |
| `TELEGRAM_BOT_TOKEN` | Токен, полученный от @BotFather. | `12345:ABC-DEF` | **Да** |
| `TELEGRAM_ADMIN_IDS` | Список Telegram ID администраторов через запятую. | `1234567,9876543` | **Да** |
| `TELEGRAM_CACHE_CHAT_ID` | ID приватного канала/чата для хранения файлов. | `-100123456789` | **Да** |
| `YANDEX_MUSIC_TOKEN` | OAuth-токен для доступа к Yandex.Music API. | `y0_...` | **Да** |
| `DATABASE_PATH` | Путь к файлу базы данных SQLite. | `/data/bot.db` | Нет (`/data/bot.db`) |
| `LOG_LEVEL` | Уровень логирования (`debug`, `info`, `warn`, `error`). | `info` | Нет (`info`) |
| `PROCESSOR_WORKERS` | Количество воркеров для обработки треков. | `4` | Нет (`4`) |
| `YANDEX_API_RATE_LIMIT` | Запросов в секунду к Yandex API. | `5` | Нет (`5`) |
_(Будет заполнено позже)_ ## ▶️ Запуск
### С помощью Docker 🐳
Рекомендуемый способ запуска. Убедитесь, что у вас установлен Docker.
1. Создайте директорию для хранения базы данных: `mkdir -p ./data`
2. Запустите контейнер:
```bash
docker run -d --name yamusic-bot \
-v $(pwd)/data:/data \
-e TELEGRAM_BOT_TOKEN="ВАШ_ТОКЕН" \
-e TELEGRAM_ADMIN_IDS="ВАШ_ID,ID_ДРУГА" \
-e TELEGRAM_CACHE_CHAT_ID="-100..." \
-e YANDEX_MUSIC_TOKEN="y0_..." \
--restart always \
gitea.mrixs.me/mrixs/yamusic-bot:dev
```
*Примечание: Используйте тег `:latest` для стабильной версии из ветки `master` или `:dev` для последней сборки из ветки `dev`.*
### С помощью Docker Compose
1. Создайте файл `docker-compose.yml`:
```yaml
version: '3.8'
services:
bot:
image: gitea.mrixs.me/mrixs/yamusic-bot:dev
container_name: yamusic-bot
restart: always
volumes:
- ./data:/data
environment:
- TELEGRAM_BOT_TOKEN=
- TELEGRAM_ADMIN_IDS=
- TELEGRAM_CACHE_CHAT_ID=
- YANDEX_MUSIC_TOKEN=
- LOG_LEVEL=info
```
2. Заполните переменные окружения в файле или создайте рядом `.env` файл.
3. Запустите: `docker-compose up -d`
## 🕹️ Использование
### Для пользователей
Начните вводить в любом чате `@bot_username`, а затем вставьте ссылку или напишите поисковый запрос.
- **Ссылка на трек:** `@bot_username https://music.yandex.ru/album/123/track/456`
- **Ссылка на альбом:** `@bot_username https://music.yandex.ru/album/123`
- **Поиск:** `@bot_username Rammstein - Sonne`
### Для администраторов
Отправьте команду в личные сообщения боту.
- `/help` — Показать список команд.
- `/stats` — Показать статистику работы бота.
- `/find <yandex_track_id>` — Найти трек в кэше по ID.
- `/warm <URL>` — "Прогреть" кэш для альбома или исполнителя.
## 🗺️ План разработки (Dev Roadmap)
| Статус | Задача | Комментарий |
| :----: | -------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| ✅ | Базовый CI/CD пайплайн (lint, test) | Настроен в `.drone.yml`. |
| ✅ | Основная логика обработки URL (трек, альбом, артист) | Реализовано в `internal/bot/handler.go`. |
| ✅ | Система кэширования (SQLite + Telegram) | Реализовано в `internal/storage` и `internal/processor`. |
| ✅ | Базовая структура проекта и конфигурация | Вся структура соответствует ТЗ. |
| ✅ | Административные команды `/help`, `/stats`, `/find` | Основной функционал реализован в `internal/admin/handler.go`. |
| ✅ | Публикация Docker-образов в CI/CD | Шаги `build-and-publish-*` активны в `.drone.yml` для `master` и `dev`. |
| ⏳ | Расширение тестового покрытия | Есть тесты для `storage`, но нужны для `processor`, `bot`, `admin`. |
| ⏳ | Финализация документации | Этот `README.md` является частью задачи. |
| ❌ | Реализация логики команды `/warm` | Существует только заглушка, фоновая обработка не реализована. |
| ❌ | Ограничение частоты запросов (Rate Limiting) к Yandex API | Требуется внедрение `rate.Limiter`. |
| ❌ | Поддержка текстового поиска и коротких URL | Задача из нового ТЗ, требуется реализация в `handler` и `yamusic client`. |
## 📄 Лицензия
Проект распространяется под лицензией MIT. См. файл `LICENSE` для получения дополнительной информации.

85
cmd/bot/main.go Normal file
View File

@@ -0,0 +1,85 @@
// yamusic-bot/cmd/bot/main.go
package main
import (
"context"
"log/slog"
"time"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/admin"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/bot"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/config"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/processor"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/storage"
"gitea.mrixs.me/Mrixs/yamusic-bot/pkg/downloader"
"gitea.mrixs.me/Mrixs/yamusic-bot/pkg/logging"
"gitea.mrixs.me/Mrixs/yamusic-bot/pkg/tagger"
"gitea.mrixs.me/Mrixs/yamusic-bot/pkg/yamusic"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"golang.org/x/time/rate"
)
func main() {
startTime := time.Now()
ctx := context.Background()
// 1. Инициализация конфигурации
cfg := config.New()
// 2. Инициализация логгера
logger := logging.NewLogger(cfg.LogLevel)
slog.SetDefault(logger)
slog.Info("Starting Yandex.Music Downloader Bot...", "version", "1.0")
// 3. Инициализация зависимостей
db, err := storage.NewSQLiteStorage(ctx, cfg.DatabasePath)
if err != nil {
slog.Error("Failed to initialize storage", "error", err)
return
}
yandexClient, err := yamusic.NewApiClient(cfg.YandexMusicToken)
if err != nil {
slog.Error("Failed to initialize yandex client", "error", err)
return
}
tgAPI, err := tgbotapi.NewBotAPI(cfg.TelegramBotToken)
if err != nil {
slog.Error("Failed to initialize telegram bot api", "error", err)
return
}
tgAPI.Debug = cfg.LogLevel == "debug"
slog.Info("Authorized on account", "username", tgAPI.Self.UserName)
// 4. Инициализация компонентов
downloaderComponent := downloader.NewHTTPDownloader()
taggerComponent := tagger.NewID3Tagger()
// Создаем БЫСТРЫЙ лимитер для общих вызовов API (в секунду)
fastLimiter := rate.NewLimiter(rate.Limit(cfg.TelegramAPIRateLimit), cfg.TelegramAPIRateLimit)
slog.Info("General Telegram API rate limit set", "requests_per_second", cfg.TelegramAPIRateLimit)
// Создаем МЕДЛЕННЫЙ лимитер для кэш-канала (в минуту)
// rate.Limit измеряется в событиях/секунду, поэтому конвертируем
// Burst size = 1, чтобы избежать отправки пачки сообщений и долгого ожидания
cacheRPS := float64(cfg.TelegramCacheRateLimitPerMinute) / 60.0
cacheLimiter := rate.NewLimiter(rate.Limit(cacheRPS), 1)
slog.Info("Cache channel Telegram API rate limit set", "requests_per_minute", cfg.TelegramCacheRateLimitPerMinute)
// Передаем оба лимитера в адаптер
telegramClient := bot.NewTelegramClientAdapter(tgAPI, cfg.TelegramCacheChatID, fastLimiter, cacheLimiter)
trackProcessor := processor.NewTrackProcessor(db, yandexClient, downloaderComponent, taggerComponent, telegramClient)
adminHandler := admin.NewHandler(db, telegramClient, yandexClient, taggerComponent, startTime)
inlineHandler := bot.NewInlineHandler(yandexClient, trackProcessor, telegramClient)
// 5. Создание и запуск приложения
app := bot.NewApp(cfg, tgAPI, db, adminHandler, inlineHandler)
app.Run(ctx)
slog.Info("Bot stopped.")
}

8
example.env Normal file
View File

@@ -0,0 +1,8 @@
TELEGRAM_BOT_TOKEN=aaa:bbb
TELEGRAM_ADMIN_IDS=ccc
TELEGRAM_CACHE_CHAT_ID=ddd
YANDEX_MUSIC_TOKEN=eee
DATABASE_PATH="/data/bot.db"
LOG_LEVEL="info"
PROCESSOR_WORKERS=4
YANDEX_API_RATE_LIMIT=5

View File

@@ -4,6 +4,9 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath"
"strings"
"time" "time"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/interfaces" "gitea.mrixs.me/Mrixs/yamusic-bot/internal/interfaces"
@@ -15,15 +18,17 @@ type Handler struct {
storage interfaces.TrackStorage storage interfaces.TrackStorage
telegram interfaces.TelegramClient telegram interfaces.TelegramClient
yandex interfaces.YandexMusicClient yandex interfaces.YandexMusicClient
tagger interfaces.Tagger
startTime time.Time startTime time.Time
} }
// NewHandler создает новый обработчик команд администратора. // NewHandler создает новый обработчик команд администратора.
func NewHandler(storage interfaces.TrackStorage, telegram interfaces.TelegramClient, yandex interfaces.YandexMusicClient, startTime time.Time) *Handler { func NewHandler(storage interfaces.TrackStorage, telegram interfaces.TelegramClient, yandex interfaces.YandexMusicClient, tagger interfaces.Tagger, startTime time.Time) *Handler {
return &Handler{ return &Handler{
storage: storage, storage: storage,
telegram: telegram, telegram: telegram,
yandex: yandex, yandex: yandex,
tagger: tagger,
startTime: startTime, startTime: startTime,
} }
} }
@@ -56,7 +61,8 @@ func (h *Handler) handleHelp(ctx context.Context, chatID int64) {
"/help - Показать это сообщение\n" + "/help - Показать это сообщение\n" +
"/stats - Показать статистику бота\n" + "/stats - Показать статистику бота\n" +
"/find <yandex_track_id> - Найти трек в кэше по ID\n" + "/find <yandex_track_id> - Найти трек в кэше по ID\n" +
"/warm <URL> - \"Прогреть\" кэш для альбома или исполнителя (в разработке)" "/warm <URL> - \"Прогреть\" кэш для альбома или исполнителя (в разработке)\n" +
"/warm --from-dir <path> - Прогреть кэш из локальной директории внутри контейнера"
if err := h.telegram.SendMessage(ctx, chatID, helpText); err != nil { if err := h.telegram.SendMessage(ctx, chatID, helpText); err != nil {
slog.Error("Failed to send help message", "error", err, "chat_id", chatID) slog.Error("Failed to send help message", "error", err, "chat_id", chatID)
@@ -110,8 +116,100 @@ func (h *Handler) handleFind(ctx context.Context, chatID int64, trackID string)
} }
} }
func (h *Handler) handleWarm(ctx context.Context, chatID int64, url string) { func (h *Handler) handleWarm(ctx context.Context, chatID int64, args string) {
if err := h.telegram.SendMessage(ctx, chatID, "Команда /warm находится в разработке."); err != nil { const fromDirPrefix = "--from-dir "
if strings.HasPrefix(args, fromDirPrefix) {
dirPath := strings.TrimPrefix(args, fromDirPrefix)
h.handleWarmFromDir(ctx, chatID, dirPath)
return
}
// Здесь будет логика для прогрева по URL
if err := h.telegram.SendMessage(ctx, chatID, "Прогрев по URL находится в разработке."); err != nil {
slog.Error("Failed to send 'warm in development' message", "error", err, "chat_id", chatID) slog.Error("Failed to send 'warm in development' message", "error", err, "chat_id", chatID)
} }
} }
// handleWarmFromDir запускает фоновую задачу прогрева кэша из локальной директории.
func (h *Handler) handleWarmFromDir(ctx context.Context, chatID int64, dirPath string) {
msg := fmt.Sprintf("Принято в обработку. Начинаю прогрев кэша из директории: `%s`", dirPath)
if err := h.telegram.SendMessage(ctx, chatID, msg); err != nil {
slog.Error("Failed to send 'warm from dir started' message", "error", err, "chat_id", chatID)
return
}
go func() {
slog.Info("Starting cache warming from directory", "path", dirPath)
files, err := os.ReadDir(dirPath)
if err != nil {
slog.Error("Failed to read directory for warming", "path", dirPath, "error", err)
errMsg := fmt.Sprintf("Ошибка: не удалось прочитать директорию `%s`. Убедитесь, что она существует и доступна.", dirPath)
_ = h.telegram.SendMessage(context.Background(), chatID, errMsg)
return
}
var addedCount, skippedCount, errorCount int
totalFiles := len(files)
for i, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".mp3") {
continue
}
trackID := strings.TrimSuffix(file.Name(), ".mp3")
fullPath := filepath.Join(dirPath, file.Name())
// 1. Проверяем, есть ли трек в кэше
_, err := h.storage.Get(ctx, trackID)
if err == nil {
slog.Debug("Skipping already cached track", "track_id", trackID)
skippedCount++
continue
}
// 2. Читаем метатеги из файла
title, artist, err := h.tagger.ReadMetadata(fullPath)
if err != nil {
slog.Warn("Failed to read metadata from file, using fallback", "path", fullPath, "error", err)
title = "" // Используем ID как заголовок
artist = "" // Используем заглушку как исполнителя
}
// 3. Загружаем в Telegram с корректными метаданными
slog.Debug("Uploading track to cache channel", "track_id", trackID, "path", fullPath)
fileID, err := h.telegram.SendAudioToCacheChannel(ctx, fullPath, title, artist)
if err != nil {
slog.Error("Failed to upload pre-cached file", "track_id", trackID, "error", err)
errorCount++
continue
}
// 4. Сохраняем в БД
err = h.storage.Set(ctx, trackID, fileID)
if err != nil {
slog.Error("Failed to save pre-cached file to storage", "track_id", trackID, "error", err)
errorCount++
continue
}
addedCount++
slog.Info("Successfully cached track from local file", "track_id", trackID, "file_id", fileID)
// Опционально: отправляем прогресс каждые N файлов
if (i+1)%1000 == 0 {
progressMsg := fmt.Sprintf("Прогресс: обработано %d из %d файлов...", i+1, totalFiles)
_ = h.telegram.SendMessage(context.Background(), chatID, progressMsg)
}
}
finalMsg := fmt.Sprintf(
"✅ Прогрев кэша из директории `%s` завершен.\n\n"+
"Новых треков добавлено: %d\n"+
"Треков пропущено (уже в кэше): %d\n"+
"Ошибок при обработке: %d",
dirPath, addedCount, skippedCount, errorCount,
)
_ = h.telegram.SendMessage(context.Background(), chatID, finalMsg)
slog.Info("Finished cache warming from directory", "path", dirPath, "added", addedCount, "skipped", skippedCount, "errors", errorCount)
}()
}

77
internal/bot/app.go Normal file
View File

@@ -0,0 +1,77 @@
package bot
import (
"context"
"log/slog"
"os"
"os/signal"
"slices"
"syscall"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/admin"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/config"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/interfaces"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// App - главное приложение бота.
type App struct {
cfg *config.Config
api *tgbotapi.BotAPI
storage interfaces.TrackStorage
adminHandler *admin.Handler
inlineHandler *InlineHandler
}
// NewApp создает новый экземпляр приложения.
func NewApp(cfg *config.Config, api *tgbotapi.BotAPI, storage interfaces.TrackStorage, adminHandler *admin.Handler, inlineHandler *InlineHandler) *App {
return &App{
cfg: cfg,
api: api,
storage: storage,
adminHandler: adminHandler,
inlineHandler: inlineHandler,
}
}
// Run запускает основной цикл бота.
func (a *App) Run(ctx context.Context) {
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer cancel()
u := tgbotapi.NewUpdate(0)
u.Timeout = 60
updates := a.api.GetUpdatesChan(u)
slog.Info("Bot is running and waiting for updates...")
for {
select {
case update := <-updates:
a.handleUpdate(ctx, update)
case <-ctx.Done():
slog.Info("Shutting down...")
a.api.StopReceivingUpdates()
if err := a.storage.Close(); err != nil {
slog.Error("Failed to close storage", "error", err)
}
return
}
}
}
func (a *App) handleUpdate(ctx context.Context, update tgbotapi.Update) {
if update.InlineQuery != nil {
go a.inlineHandler.HandleInlineQuery(ctx, update.InlineQuery)
} else if update.Message != nil && update.Message.IsCommand() {
if a.isAdmin(update.Message.From.ID) {
go a.adminHandler.HandleCommand(ctx, update.Message)
} else {
slog.Warn("Unauthorized command attempt", "user_id", update.Message.From.ID)
}
}
}
func (a *App) isAdmin(userID int64) bool {
return slices.Contains(a.cfg.TelegramAdminIDs, userID)
}

121
internal/bot/handler.go Normal file
View File

@@ -0,0 +1,121 @@
package bot
import (
"context"
"log/slog"
"strconv"
"sync"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/interfaces"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/model"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/processor"
"gitea.mrixs.me/Mrixs/yamusic-bot/pkg/yamusic"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// InlineHandler обрабатывает inline-запросы.
type InlineHandler struct {
yandex interfaces.YandexMusicClient
processor *processor.TrackProcessor
telegram interfaces.TelegramClient
}
// NewInlineHandler создает новый обработчик inline-запросов.
func NewInlineHandler(yandex interfaces.YandexMusicClient, processor *processor.TrackProcessor, telegram interfaces.TelegramClient) *InlineHandler {
return &InlineHandler{
yandex: yandex,
processor: processor,
telegram: telegram,
}
}
// HandleInlineQuery обрабатывает входящий inline-запрос.
func (h *InlineHandler) HandleInlineQuery(ctx context.Context, query *tgbotapi.InlineQuery) {
slog.Info("Handling inline query", "user_id", query.From.ID, "query", query.Query)
urlInfo, err := yamusic.ParseYandexURL(query.Query)
if err != nil {
h.answerWithError(ctx, query.ID, "Неверный формат ссылки. Поддерживаются ссылки на треки, альбомы и исполнителей Yandex.Music.")
return
}
var trackInfos []*model.TrackInfo
switch urlInfo.Type {
case "track":
info, err := h.yandex.GetTrackInfo(ctx, urlInfo.TrackID)
if err != nil {
slog.Error("Failed to get track info", "track_id", urlInfo.TrackID, "error", err)
h.answerWithError(ctx, query.ID, "Не удалось получить информацию о треке.")
return
}
trackInfos = append(trackInfos, info)
case "album":
infos, err := h.yandex.GetAlbumTrackInfos(ctx, urlInfo.AlbumID)
if err != nil {
slog.Error("Failed to get album info", "album_id", urlInfo.AlbumID, "error", err)
h.answerWithError(ctx, query.ID, "Не удалось получить информацию об альбоме.")
return
}
trackInfos = infos
case "artist":
infos, err := h.yandex.GetArtistTrackInfos(ctx, urlInfo.ArtistID)
if err != nil {
slog.Error("Failed to get artist info", "artist_id", urlInfo.ArtistID, "error", err)
h.answerWithError(ctx, query.ID, "Не удалось получить информацию об исполнителе.")
return
}
trackInfos = infos
}
if len(trackInfos) == 0 {
h.answerWithError(ctx, query.ID, "По этой ссылке ничего не найдено.")
return
}
h.processAndAnswer(ctx, query.ID, trackInfos)
}
func (h *InlineHandler) processAndAnswer(ctx context.Context, queryID string, trackInfos []*model.TrackInfo) {
var wg sync.WaitGroup
resultsChan := make(chan interface{}, len(trackInfos))
for i, info := range trackInfos {
wg.Add(1)
go func(trackInfo *model.TrackInfo, resultID int) {
defer wg.Done()
fileID, err := h.processor.Process(ctx, trackInfo)
if err != nil {
slog.Error("Failed to process track", "track_id", trackInfo.YandexTrackID, "error", err)
return
}
result := tgbotapi.NewInlineQueryResultAudio(strconv.Itoa(resultID), fileID, trackInfo.Title)
result.Performer = trackInfo.Artist
resultsChan <- result
}(info, i)
}
wg.Wait()
close(resultsChan)
var finalResults []interface{}
for result := range resultsChan {
finalResults = append(finalResults, result)
}
if len(finalResults) == 0 {
slog.Warn("No results to send after processing", "query_id", queryID)
h.answerWithError(ctx, queryID, "Не удалось обработать ни один трек.")
return
}
if err := h.telegram.AnswerInlineQuery(ctx, queryID, finalResults); err != nil {
slog.Error("Failed to send final answer to inline query", "error", err)
}
}
func (h *InlineHandler) answerWithError(ctx context.Context, queryID, message string) {
article := tgbotapi.NewInlineQueryResultArticle(queryID, "Ошибка", message)
if err := h.telegram.AnswerInlineQuery(ctx, queryID, []interface{}{article}); err != nil {
slog.Error("Failed to answer with error", "error", err)
}
}

87
internal/bot/telegram.go Normal file
View File

@@ -0,0 +1,87 @@
package bot
import (
"context"
"fmt"
"log/slog"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"golang.org/x/time/rate"
)
// TelegramClientAdapter адаптирует библиотеку tgbotapi под наш интерфейс interfaces.TelegramClient.
type TelegramClientAdapter struct {
api *tgbotapi.BotAPI
cacheChatID int64
fastLimiter *rate.Limiter // Для общих быстрых запросов
cacheLimiter *rate.Limiter // Для медленных запросов в кэш-канал
}
// NewTelegramClientAdapter создает новый адаптер.
func NewTelegramClientAdapter(api *tgbotapi.BotAPI, cacheChatID int64, fastLimiter, cacheLimiter *rate.Limiter) *TelegramClientAdapter {
return &TelegramClientAdapter{
api: api,
cacheChatID: cacheChatID,
fastLimiter: fastLimiter,
cacheLimiter: cacheLimiter,
}
}
// SendAudioToCacheChannel загружает аудиофайл в кэш-канал и возвращает его FileID.
// ИСПОЛЬЗУЕТ МЕДЛЕННЫЙ ЛИМИТЕР.
func (t *TelegramClientAdapter) SendAudioToCacheChannel(ctx context.Context, audioPath, title, performer string) (string, error) {
// Ждем, пока МЕДЛЕННЫЙ лимитер разрешит выполнить запрос
if err := t.cacheLimiter.Wait(ctx); err != nil {
return "", err
}
audio := tgbotapi.NewAudio(t.cacheChatID, tgbotapi.FilePath(audioPath))
audio.Title = title
audio.Performer = performer
msg, err := t.api.Send(audio)
if err != nil {
return "", fmt.Errorf("failed to send audio to cache channel: %w", err)
}
if msg.Audio == nil {
return "", fmt.Errorf("sent message does not contain audio")
}
slog.Debug("Audio sent to cache channel", "file_id", msg.Audio.FileID)
return msg.Audio.FileID, nil
}
// AnswerInlineQuery отвечает на inline-запрос.
// ИСПОЛЬЗУЕТ БЫСТРЫЙ ЛИМИТЕР.
func (t *TelegramClientAdapter) AnswerInlineQuery(ctx context.Context, queryID string, results []interface{}) error {
// Ждем, пока БЫСТРЫЙ лимитер разрешит выполнить запрос
if err := t.fastLimiter.Wait(ctx); err != nil {
return err
}
inlineConfig := tgbotapi.InlineConfig{
InlineQueryID: queryID,
Results: results,
CacheTime: 1,
}
if _, err := t.api.Request(inlineConfig); err != nil {
return fmt.Errorf("failed to answer inline query: %w", err)
}
return nil
}
// SendMessage отправляет текстовое сообщение.
// ИСПОЛЬЗУЕТ БЫСТРЫЙ ЛИМИТЕР.
func (t *TelegramClientAdapter) SendMessage(ctx context.Context, chatID int64, text string) error {
// Ждем, пока БЫСТРЫЙ лимитер разрешит выполнить запрос
if err := t.fastLimiter.Wait(ctx); err != nil {
return err
}
msg := tgbotapi.NewMessage(chatID, text)
if _, err := t.api.Send(msg); err != nil {
return fmt.Errorf("failed to send message: %w", err)
}
return nil
}

View File

@@ -10,15 +10,17 @@ import (
// Config содержит всю конфигурацию приложения, получаемую из переменных окружения. // Config содержит всю конфигурацию приложения, получаемую из переменных окружения.
type Config struct { type Config struct {
TelegramBotToken string `env:"TELEGRAM_BOT_TOKEN,required"` TelegramBotToken string `env:"TELEGRAM_BOT_TOKEN,required"`
TelegramAdminIDsRaw string `env:"TELEGRAM_ADMIN_IDS,required"` TelegramAdminIDsRaw string `env:"TELEGRAM_ADMIN_IDS,required"`
TelegramCacheChatID int64 `env:"TELEGRAM_CACHE_CHAT_ID,required"` TelegramCacheChatID int64 `env:"TELEGRAM_CACHE_CHAT_ID,required"`
YandexMusicToken string `env:"YANDEX_MUSIC_TOKEN"` YandexMusicToken string `env:"YANDEX_MUSIC_TOKEN"`
DatabasePath string `env:"DATABASE_PATH" envDefault:"/data/bot.db"` DatabasePath string `env:"DATABASE_PATH" envDefault:"/data/bot.db"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"` LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
ProcessorWorkers int `env:"PROCESSOR_WORKERS" envDefault:"4"` ProcessorWorkers int `env:"PROCESSOR_WORKERS" envDefault:"4"`
YandexAPIRateLimit int `env:"YANDEX_API_RATE_LIMIT" envDefault:"5"` YandexAPIRateLimit int `env:"YANDEX_API_RATE_LIMIT" envDefault:"5"`
TelegramAdminIDs []int64 `env:"-"` // Это поле будет заполнено после парсинга TelegramAPIRateLimit int `env:"TELEGRAM_API_RATE_LIMIT" envDefault:"25"` // Общий лимит в секунду
TelegramCacheRateLimitPerMinute int `env:"TELEGRAM_CACHE_RATE_LIMIT_PER_MINUTE" envDefault:"20"` // Лимит для кэш-канала в минуту
TelegramAdminIDs []int64 `env:"-"`
} }
// New загружает конфигурацию из переменных окружения и парсит необходимые поля. // New загружает конфигурацию из переменных окружения и парсит необходимые поля.

View File

@@ -34,6 +34,7 @@ type TelegramClient interface {
// Tagger определяет методы для работы с метаданными аудиофайлов. // Tagger определяет методы для работы с метаданными аудиофайлов.
type Tagger interface { type Tagger interface {
WriteTags(filePath string, coverPath string, info *model.TrackInfo) error WriteTags(filePath string, coverPath string, info *model.TrackInfo) error
ReadMetadata(filePath string) (title, artist string, err error)
} }
// FileDownloader определяет метод для скачивания файла. // FileDownloader определяет метод для скачивания файла.

View File

@@ -17,6 +17,24 @@ func NewID3Tagger() *ID3Tagger {
return &ID3Tagger{} return &ID3Tagger{}
} }
// ReadMetadata читает основные метаданные (название, исполнитель) из аудиофайла.
func (t *ID3Tagger) ReadMetadata(filePath string) (string, string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return "", "", fmt.Errorf("failed to open mp3 file for reading tags: %w", err)
}
defer tag.Close()
title := tag.Title()
artist := tag.Artist()
if title == "" || artist == "" {
return "", "", fmt.Errorf("title or artist tag is empty")
}
return title, artist, nil
}
// WriteTags записывает метаданные и обложку в указанный аудиофайл. // WriteTags записывает метаданные и обложку в указанный аудиофайл.
func (t *ID3Tagger) WriteTags(filePath string, coverPath string, info *model.TrackInfo) error { func (t *ID3Tagger) WriteTags(filePath string, coverPath string, info *model.TrackInfo) error {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})