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) } }