- Implemented a retry mechanism for external calls, allowing up to 3 attempts before failing. - Enhanced error handling to send failure notifications when setup steps fail, including detailed error messages. - Updated RunSummary model to include status, error step, and error message fields for better tracking of run outcomes. - Modified database schema to store failure metadata for runs. - Updated README.md to reflect changes in error handling and notification behavior.
173 lines
4.7 KiB
Go
173 lines
4.7 KiB
Go
package notify
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/mail"
|
|
"net/smtp"
|
|
"strings"
|
|
"time"
|
|
|
|
"ncore-hnr/internal/model"
|
|
)
|
|
|
|
const manualNeededSubject = "nCore HnR manual work"
|
|
const failureSubject = "nCore HnR check failed"
|
|
|
|
type Sender interface {
|
|
SendManualNeeded(results []model.ActionResult) error
|
|
SendFailure(step string, failure error) error
|
|
}
|
|
|
|
type NotificationNTFY struct {
|
|
URL string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
func (n NotificationNTFY) SendManualNeeded(results []model.ActionResult) error {
|
|
body, ok := manualNeededText(results)
|
|
if strings.TrimSpace(n.URL) == "" || !ok {
|
|
return nil
|
|
}
|
|
return n.sendMessage(manualNeededSubject, body)
|
|
}
|
|
|
|
func (n NotificationNTFY) SendFailure(step string, failure error) error {
|
|
if strings.TrimSpace(n.URL) == "" {
|
|
return nil
|
|
}
|
|
_, body := FailureMessage(step, failure)
|
|
return n.sendMessage(failureSubject, body)
|
|
}
|
|
|
|
func (n NotificationNTFY) sendMessage(subject string, body string) error {
|
|
client := n.HTTPClient
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 15 * time.Second}
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodPost, n.URL, bytes.NewBufferString(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
|
req.Header.Set("Title", subject)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("notify returned %s", resp.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type NotificationSMTP struct {
|
|
Host string
|
|
Port string
|
|
Username string
|
|
Password string
|
|
From string
|
|
To string
|
|
}
|
|
|
|
func (s NotificationSMTP) SendManualNeeded(results []model.ActionResult) error {
|
|
body, ok := manualNeededText(results)
|
|
if strings.TrimSpace(s.Host) == "" || !ok {
|
|
return nil
|
|
}
|
|
return s.sendMessage(manualNeededSubject, body)
|
|
}
|
|
|
|
func (s NotificationSMTP) SendFailure(step string, failure error) error {
|
|
if strings.TrimSpace(s.Host) == "" {
|
|
return nil
|
|
}
|
|
_, body := FailureMessage(step, failure)
|
|
return s.sendMessage(failureSubject, body)
|
|
}
|
|
|
|
func (s NotificationSMTP) sendMessage(subject string, body string) error {
|
|
host := strings.TrimSpace(s.Host)
|
|
addr := net.JoinHostPort(host, strings.TrimSpace(s.Port))
|
|
|
|
from, err := mail.ParseAddress(strings.TrimSpace(s.From))
|
|
if err != nil {
|
|
return fmt.Errorf("parse smtp from: %w", err)
|
|
}
|
|
recipients, err := mail.ParseAddressList(strings.TrimSpace(s.To))
|
|
if err != nil {
|
|
return fmt.Errorf("parse smtp to: %w", err)
|
|
}
|
|
|
|
to := make([]string, 0, len(recipients))
|
|
for _, recipient := range recipients {
|
|
to = append(to, recipient.Address)
|
|
}
|
|
|
|
var auth smtp.Auth
|
|
if strings.TrimSpace(s.Username) != "" || strings.TrimSpace(s.Password) != "" {
|
|
auth = smtp.PlainAuth("", strings.TrimSpace(s.Username), strings.TrimSpace(s.Password), host)
|
|
}
|
|
|
|
message := strings.Builder{}
|
|
message.WriteString(fmt.Sprintf("From: %s\r\n", from.String()))
|
|
message.WriteString(fmt.Sprintf("To: %s\r\n", formatAddressList(recipients)))
|
|
message.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
|
message.WriteString("MIME-Version: 1.0\r\n")
|
|
message.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
|
message.WriteString("\r\n")
|
|
message.WriteString(body)
|
|
|
|
if err := smtp.SendMail(addr, auth, from.Address, to, []byte(message.String())); err != nil {
|
|
return fmt.Errorf("send smtp notification: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ManualNeededMessage(results []model.ActionResult) (string, string, bool) {
|
|
body, ok := manualNeededText(results)
|
|
return manualNeededSubject, body, ok
|
|
}
|
|
|
|
func FailureMessage(step string, failure error) (string, string) {
|
|
var body strings.Builder
|
|
body.WriteString("nCore HnR check failed after retries.\n")
|
|
body.WriteString(fmt.Sprintf("Step: %s\n", strings.TrimSpace(step)))
|
|
if failure != nil {
|
|
body.WriteString(fmt.Sprintf("Error: %s\n", failure.Error()))
|
|
}
|
|
body.WriteString("Action: review the CronJob logs. Failed nCore/qBittorrent setup steps do not clear unresolved torrent state; the next cron run will retry.\n")
|
|
return failureSubject, body.String()
|
|
}
|
|
|
|
func manualNeededText(results []model.ActionResult) (string, bool) {
|
|
var body strings.Builder
|
|
manualCount := 0
|
|
body.WriteString("nCore HnR torrents need manual work:\n")
|
|
for _, result := range results {
|
|
if !result.ManualNeeded {
|
|
continue
|
|
}
|
|
manualCount++
|
|
body.WriteString(fmt.Sprintf("- %s", result.Torrent.Name))
|
|
if result.QBit != nil {
|
|
body.WriteString(fmt.Sprintf(" (qBit: %s, %.1f%%)", result.QBit.State, result.QBit.Progress*100))
|
|
}
|
|
body.WriteString("\n")
|
|
}
|
|
return body.String(), manualCount > 0
|
|
}
|
|
|
|
func formatAddressList(addresses []*mail.Address) string {
|
|
formatted := make([]string, 0, len(addresses))
|
|
for _, address := range addresses {
|
|
formatted = append(formatted, address.String())
|
|
}
|
|
return strings.Join(formatted, ", ")
|
|
}
|