add workflow to build
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/user/ytdlp-navidrome/internal/config"
|
||||
"github.com/user/ytdlp-navidrome/internal/preview"
|
||||
"github.com/user/ytdlp-navidrome/internal/ytdlp"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Bot represents the Matrix bot.
|
||||
type Bot struct {
|
||||
cfg *config.Config
|
||||
client *mautrix.Client
|
||||
sessionMgr *preview.Manager
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
// New creates a new Bot.
|
||||
func New(cfg *config.Config) *Bot {
|
||||
return &Bot{
|
||||
cfg: cfg,
|
||||
sessionMgr: preview.NewManager(cfg),
|
||||
startTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Start connects to Matrix and starts the event listener.
|
||||
func (b *Bot) Start(ctx context.Context) error {
|
||||
client, err := mautrix.NewClient(b.cfg.MatrixHomeserver, id.UserID(b.cfg.MatrixUserID), b.cfg.MatrixAccessToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Matrix client: %w", err)
|
||||
}
|
||||
b.client = client
|
||||
|
||||
// Set device ID
|
||||
if b.cfg.MatrixDeviceID != "" {
|
||||
client.DeviceID = id.DeviceID(b.cfg.MatrixDeviceID)
|
||||
}
|
||||
|
||||
// Ensure tmp dir exists
|
||||
if err := os.MkdirAll(b.cfg.TmpDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create tmp dir %s: %w", b.cfg.TmpDir, err)
|
||||
}
|
||||
|
||||
// Ensure music collection dir exists
|
||||
if err := os.MkdirAll(b.cfg.MusicCollectionDir(), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create music dir %s: %w", b.cfg.MusicCollectionDir(), err)
|
||||
}
|
||||
|
||||
log.Printf("Connected as %s to %s", b.cfg.MatrixUserID, b.cfg.MatrixHomeserver)
|
||||
|
||||
// Start preview cleanup in background
|
||||
go b.sessionMgr.CleanupOldPreviews(ctx)
|
||||
|
||||
// Start syncing
|
||||
syncer := client.Syncer.(*mautrix.DefaultSyncer)
|
||||
syncer.OnEventType(event.EventMessage, b.handleMessage)
|
||||
syncer.OnEventType(event.EventEncrypted, b.handleEncrypted)
|
||||
|
||||
return client.Sync()
|
||||
}
|
||||
|
||||
func (b *Bot) isAllowed(userID string) bool {
|
||||
if len(b.cfg.AllowedUsers) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, allowed := range b.cfg.AllowedUsers {
|
||||
if allowed == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *Bot) handleMessage(ctx context.Context, evt *event.Event) {
|
||||
if evt.Sender == "" {
|
||||
return
|
||||
}
|
||||
|
||||
sender := evt.Sender.String()
|
||||
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
|
||||
if !ok || content == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore messages from self
|
||||
if sender == b.cfg.MatrixUserID {
|
||||
return
|
||||
}
|
||||
|
||||
// Check access
|
||||
if !b.isAllowed(sender) {
|
||||
return
|
||||
}
|
||||
|
||||
// In group chats, only respond to messages that mention the bot
|
||||
// If we're in a DM, respond always.
|
||||
isDM := b.isDirectMessage(ctx, evt.RoomID)
|
||||
if !isDM {
|
||||
// Group chat — check if bot is mentioned or reply to bot's message
|
||||
if !b.isMentioned(content) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(content.Body)
|
||||
b.processMessage(ctx, evt.RoomID, sender, body)
|
||||
}
|
||||
|
||||
func (b *Bot) handleEncrypted(ctx context.Context, evt *event.Event) {
|
||||
// E2EE not supported in MVP
|
||||
log.Printf("Received encrypted event from %s, ignoring (E2EE not enabled)", evt.Sender)
|
||||
}
|
||||
|
||||
func (b *Bot) isDirectMessage(ctx context.Context, roomID id.RoomID) bool {
|
||||
// Fetch room members — if only 2, it's a DM
|
||||
members, err := b.client.JoinedMembers(ctx, roomID)
|
||||
if err != nil {
|
||||
// If we can't fetch, assume DM to be safe
|
||||
return true
|
||||
}
|
||||
return len(members.Joined) <= 2
|
||||
}
|
||||
|
||||
func (b *Bot) isMentioned(content *event.MessageEventContent) bool {
|
||||
// Check for @bot:example.com mention in body
|
||||
mention := "@" + strings.SplitN(b.cfg.MatrixUserID, ":", 2)[0]
|
||||
return strings.Contains(content.Body, mention)
|
||||
}
|
||||
|
||||
func (b *Bot) processMessage(ctx context.Context, roomID id.RoomID, sender, body string) {
|
||||
session := b.sessionMgr.GetSession(sender)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(body, "/search "):
|
||||
b.handleSearch(ctx, roomID, sender, strings.TrimPrefix(body, "/search "))
|
||||
case body == "/help":
|
||||
b.handleHelp(ctx, roomID)
|
||||
case body == "/status":
|
||||
b.handleStatus(ctx, roomID)
|
||||
case body == "/proxy":
|
||||
b.handleProxy(ctx, roomID)
|
||||
case session != nil && session.State == preview.StateSearchResults:
|
||||
b.handleResultSelection(ctx, roomID, sender, body, session)
|
||||
case session != nil && session.State == preview.StatePreviewSent:
|
||||
b.handleConfirmDecision(ctx, roomID, sender, body, session)
|
||||
default:
|
||||
b.sendText(ctx, roomID, "Неизвестная команда. Напишите /help для справки.")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) handleSearch(ctx context.Context, roomID id.RoomID, sender, query string) {
|
||||
log.Printf("search user=%s query=%q", sender, query)
|
||||
|
||||
proxy := ""
|
||||
if b.cfg.ProxyEnabled {
|
||||
proxy = b.cfg.ProxyURL
|
||||
}
|
||||
|
||||
results, err := ytdlp.RunSearch(
|
||||
ctx,
|
||||
b.cfg.YtdlpPath,
|
||||
query,
|
||||
b.cfg.SearchLimit,
|
||||
proxy,
|
||||
int(b.cfg.SearchTimeout.Seconds()),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("search error user=%s query=%q err=%v", sender, query, err)
|
||||
b.sendText(ctx, roomID, fmt.Sprintf("❌ Ошибка поиска: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
b.sendText(ctx, roomID, "😕 Ничего не найдено по запросу: "+query)
|
||||
return
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🔍 Найдено %d результатов:\n\n", len(results)))
|
||||
for i, r := range results {
|
||||
duration := r.Duration
|
||||
if duration == "" || duration == "0:00" {
|
||||
duration = "?"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. %s — %s [%s]\n", i+1, r.Channel, r.Title, duration))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\nОтветьте номером (1-%d) для прослушивания превью.", len(results)))
|
||||
|
||||
b.sessionMgr.SetSearchResults(sender, query, results)
|
||||
b.sendText(ctx, roomID, sb.String())
|
||||
}
|
||||
|
||||
func (b *Bot) handleResultSelection(ctx context.Context, roomID id.RoomID, sender, body string, session *preview.UserSession) {
|
||||
idx, err := strconv.Atoi(strings.TrimSpace(body))
|
||||
if err != nil || idx < 1 || idx > len(session.Results) {
|
||||
b.sendText(ctx, roomID, fmt.Sprintf("Пожалуйста, введите номер от 1 до %d.", len(session.Results)))
|
||||
return
|
||||
}
|
||||
|
||||
result := session.Results[idx-1]
|
||||
log.Printf("preview user=%s video_id=%s title=%q", sender, result.ID, result.Title)
|
||||
|
||||
proxy := ""
|
||||
if b.cfg.ProxyEnabled {
|
||||
proxy = b.cfg.ProxyURL
|
||||
}
|
||||
|
||||
b.sendText(ctx, roomID, fmt.Sprintf("⏳ Скачиваю превью: %s — %s...", result.Channel, result.Title))
|
||||
|
||||
previewPath, err := ytdlp.DownloadPreview(
|
||||
ctx,
|
||||
b.cfg.YtdlpPath,
|
||||
result.ID,
|
||||
b.cfg.TmpDir,
|
||||
b.cfg.AudioFormat,
|
||||
b.cfg.AudioQualityPreview,
|
||||
int(b.cfg.DownloadTimeout.Seconds()),
|
||||
proxy,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("preview error user=%s video_id=%s err=%v", sender, result.ID, err)
|
||||
b.sendText(ctx, roomID, fmt.Sprintf("❌ Ошибка скачивания превью: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Upload and send as m.audio
|
||||
fileInfo, err := os.Stat(previewPath)
|
||||
if err != nil {
|
||||
log.Printf("preview stat error: %v", err)
|
||||
b.sendText(ctx, roomID, "❌ Ошибка чтения превью-файла.")
|
||||
return
|
||||
}
|
||||
|
||||
// Read the file for upload
|
||||
fileBytes, err := os.ReadFile(previewPath)
|
||||
if err != nil {
|
||||
log.Printf("preview read error: %v", err)
|
||||
b.sendText(ctx, roomID, "❌ Ошибка чтения файла превью.")
|
||||
return
|
||||
}
|
||||
|
||||
uploadResp, err := b.client.UploadMedia(ctx, mautrix.ReqUploadMedia{
|
||||
ContentBytes: fileBytes,
|
||||
ContentType: "audio/opus",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("upload error: %v", err)
|
||||
b.sendText(ctx, roomID, "❌ Ошибка загрузки аудио в Matrix.")
|
||||
return
|
||||
}
|
||||
|
||||
// Send as m.audio message
|
||||
filename := filepath.Base(previewPath)
|
||||
_, err = b.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
|
||||
MsgType: event.MsgAudio,
|
||||
Body: filename,
|
||||
URL: uploadResp.ContentURI.CUString(),
|
||||
Info: &event.FileInfo{
|
||||
MimeType: "audio/opus",
|
||||
Size: int(fileInfo.Size()),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("send audio error: %v", err)
|
||||
b.sendText(ctx, roomID, "❌ Ошибка отправки аудио в чат.")
|
||||
return
|
||||
}
|
||||
|
||||
b.sessionMgr.SetPreviewSent(sender, result.ID, previewPath)
|
||||
b.sendText(ctx, roomID, "Добавить в коллекцию Navidrome? (да/нет)")
|
||||
}
|
||||
|
||||
func (b *Bot) handleConfirmDecision(ctx context.Context, roomID id.RoomID, sender, body string, session *preview.UserSession) {
|
||||
body = strings.TrimSpace(strings.ToLower(body))
|
||||
|
||||
switch {
|
||||
case body == "да", body == "yes", body == "добавить":
|
||||
b.handleConfirmDownload(ctx, roomID, sender, session)
|
||||
case body == "нет", body == "no", body == "отмена":
|
||||
b.handleCancelPreview(ctx, roomID, sender, session)
|
||||
default:
|
||||
b.sendText(ctx, roomID, "Пожалуйста, ответьте \"да\" или \"нет\".")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) handleConfirmDownload(ctx context.Context, roomID id.RoomID, sender string, session *preview.UserSession) {
|
||||
log.Printf("download user=%s video_id=%s", sender, session.VideoID)
|
||||
|
||||
proxy := ""
|
||||
if b.cfg.ProxyEnabled {
|
||||
proxy = b.cfg.ProxyURL
|
||||
}
|
||||
|
||||
b.sendText(ctx, roomID, "⏳ Скачиваю трек в коллекцию...")
|
||||
|
||||
_, err := ytdlp.DownloadFinal(
|
||||
ctx,
|
||||
b.cfg.YtdlpPath,
|
||||
session.VideoID,
|
||||
b.cfg.MusicCollectionDir(),
|
||||
b.cfg.AudioFormat,
|
||||
b.cfg.AudioQualityFinal,
|
||||
int(b.cfg.DownloadTimeout.Seconds()),
|
||||
proxy,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("download error user=%s video_id=%s err=%v", sender, session.VideoID, err)
|
||||
b.sendText(ctx, roomID, fmt.Sprintf("❌ Ошибка скачивания: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up preview
|
||||
if session.PreviewPath != "" {
|
||||
os.Remove(session.PreviewPath)
|
||||
}
|
||||
|
||||
b.sessionMgr.ResetSession(sender)
|
||||
// The actual file was downloaded by yt-dlp with the template pattern
|
||||
b.sendText(ctx, roomID, "✅ Трек добавлен в коллекцию Navidrome.")
|
||||
}
|
||||
|
||||
func (b *Bot) handleCancelPreview(ctx context.Context, roomID id.RoomID, sender string, session *preview.UserSession) {
|
||||
if session.PreviewPath != "" {
|
||||
os.Remove(session.PreviewPath)
|
||||
}
|
||||
b.sessionMgr.ResetSession(sender)
|
||||
b.sendText(ctx, roomID, "❌ Отменено.")
|
||||
}
|
||||
|
||||
func (b *Bot) handleHelp(ctx context.Context, roomID id.RoomID) {
|
||||
help := `🎵 ytdlp-navidrome бот
|
||||
|
||||
Доступные команды:
|
||||
|
||||
/search <запрос> — поиск музыки
|
||||
<номер> — выбрать результат для превью
|
||||
да/нет — подтвердить или отменить добавление
|
||||
/status — статус сервиса
|
||||
/proxy — статус прокси
|
||||
/help — эта справка`
|
||||
b.sendText(ctx, roomID, help)
|
||||
}
|
||||
|
||||
func (b *Bot) handleStatus(ctx context.Context, roomID id.RoomID) {
|
||||
uptime := time.Since(b.startTime).Round(time.Second)
|
||||
msg := fmt.Sprintf("Статус:\nВремя работы: %s\nВерсия: MVP", uptime)
|
||||
b.sendText(ctx, roomID, msg)
|
||||
}
|
||||
|
||||
func (b *Bot) handleProxy(ctx context.Context, roomID id.RoomID) {
|
||||
status := "отключен"
|
||||
if b.cfg.ProxyEnabled {
|
||||
status = "включен"
|
||||
}
|
||||
msg := fmt.Sprintf("Прокси:\nСтатус: %s\nURL: %s", status, b.cfg.ProxyURL)
|
||||
b.sendText(ctx, roomID, msg)
|
||||
}
|
||||
|
||||
func (b *Bot) sendText(ctx context.Context, roomID id.RoomID, text string) {
|
||||
_, err := b.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: text,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("send text error room=%s err=%v", roomID, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user