Compare commits
4 Commits
587676be58
...
2507a1531e
| Author | SHA1 | Date | |
|---|---|---|---|
| 2507a1531e | |||
| 2d4d7c2813 | |||
| 4005371767 | |||
| 0d5fcabebd |
14
.drone.yml
14
.drone.yml
@@ -10,15 +10,21 @@ trigger:
|
|||||||
- pull_request
|
- pull_request
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: lint
|
- name: deps
|
||||||
image: golangci/golangci-lint:v1.59-alpine
|
image: golang:1.24-alpine
|
||||||
commands:
|
commands:
|
||||||
- golangci-lint run ./...
|
- go mod download
|
||||||
|
- go mod tidy
|
||||||
|
- name: lint
|
||||||
|
image: golangci/golangci-lint:v1.64-alpine
|
||||||
|
commands:
|
||||||
|
- golangci-lint run --timeout=5m --verbose ./...
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
image: golang:1.24-alpine
|
image: golang:1.24-alpine
|
||||||
commands:
|
commands:
|
||||||
- go test -race -cover ./...
|
- apk add --no-cache build-base
|
||||||
|
- CGO_ENABLED=1 go test -race -cover ./...
|
||||||
# Шаг сборки и публикации будет добавлен позже
|
# Шаг сборки и публикации будет добавлен позже
|
||||||
# - name: build-and-publish
|
# - name: build-and-publish
|
||||||
# image: plugins/docker
|
# image: plugins/docker
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,7 @@
|
|||||||
# Binary
|
# Binary
|
||||||
bot
|
bot
|
||||||
|
!cmd/bot
|
||||||
|
!internal/bot
|
||||||
yamusic-bot
|
yamusic-bot
|
||||||
|
|
||||||
# Data
|
# Data
|
||||||
|
|||||||
69
cmd/bot/main.go
Normal file
69
cmd/bot/main.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
telegramClient := bot.NewTelegramClientAdapter(tgAPI, cfg.TelegramCacheChatID)
|
||||||
|
|
||||||
|
trackProcessor := processor.NewTrackProcessor(db, yandexClient, downloaderComponent, taggerComponent, telegramClient)
|
||||||
|
|
||||||
|
adminHandler := admin.NewHandler(db, telegramClient, yandexClient, startTime)
|
||||||
|
inlineHandler := bot.NewInlineHandler(yandexClient, trackProcessor, telegramClient)
|
||||||
|
|
||||||
|
// 5. Создание и запуск приложения
|
||||||
|
app := bot.NewApp(cfg, tgAPI, db, adminHandler, inlineHandler)
|
||||||
|
app.Run(ctx)
|
||||||
|
|
||||||
|
slog.Info("Bot stopped.")
|
||||||
|
}
|
||||||
77
internal/bot/app.go
Normal file
77
internal/bot/app.go
Normal 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
121
internal/bot/handler.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
internal/bot/telegram.go
Normal file
64
internal/bot/telegram.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TelegramClientAdapter адаптирует библиотеку tgbotapi под наш интерфейс interfaces.TelegramClient.
|
||||||
|
type TelegramClientAdapter struct {
|
||||||
|
api *tgbotapi.BotAPI
|
||||||
|
cacheChatID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTelegramClientAdapter создает новый адаптер.
|
||||||
|
func NewTelegramClientAdapter(api *tgbotapi.BotAPI, cacheChatID int64) *TelegramClientAdapter {
|
||||||
|
return &TelegramClientAdapter{
|
||||||
|
api: api,
|
||||||
|
cacheChatID: cacheChatID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendAudioToCacheChannel загружает аудиофайл в кэш-канал и возвращает его FileID.
|
||||||
|
func (t *TelegramClientAdapter) SendAudioToCacheChannel(ctx context.Context, audioPath, title, performer string) (string, error) {
|
||||||
|
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 {
|
||||||
|
inlineConfig := tgbotapi.InlineConfig{
|
||||||
|
InlineQueryID: queryID,
|
||||||
|
Results: results,
|
||||||
|
CacheTime: 1, // Кэшируем результат на стороне Telegram на 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 {
|
||||||
|
msg := tgbotapi.NewMessage(chatID, text)
|
||||||
|
if _, err := t.api.Send(msg); err != nil {
|
||||||
|
return fmt.Errorf("failed to send message: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user