aduio mail
This commit is contained in:
@@ -23,6 +23,7 @@ import (
|
||||
"hamlog/internal/clublog"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/email"
|
||||
"hamlog/internal/extsvc"
|
||||
"hamlog/internal/integrations/udp"
|
||||
"hamlog/internal/operating"
|
||||
@@ -85,9 +86,24 @@ const (
|
||||
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
|
||||
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
|
||||
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
|
||||
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
|
||||
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
|
||||
|
||||
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
|
||||
|
||||
// E-mail / SMTP — send QSO recordings to the correspondent.
|
||||
keyEmailEnabled = "email.enabled"
|
||||
keyEmailHost = "email.smtp_host"
|
||||
keyEmailPort = "email.smtp_port"
|
||||
keyEmailUser = "email.smtp_user"
|
||||
keyEmailPassword = "email.smtp_password"
|
||||
keyEmailFrom = "email.from"
|
||||
keyEmailEncryption = "email.encryption" // "ssl" | "starttls" | "none"
|
||||
keyEmailAuth = "email.auth" // "1" → SMTP requires authorization (send user/password)
|
||||
keyEmailAutoSend = "email.auto_send" // "1" → auto-send recording on log when an e-mail is known
|
||||
keyEmailSubject = "email.subject"
|
||||
keyEmailBody = "email.body"
|
||||
|
||||
// clublogAppAPIKey is OpsLog's ClubLog API key, also used for the country
|
||||
// file download. Visible in the binary but must not be exposed publicly.
|
||||
clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38"
|
||||
@@ -1033,9 +1049,17 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) {
|
||||
a.applyDXCCNumber(&q)
|
||||
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
|
||||
a.applyQSLDefaults(&q)
|
||||
// Fill the contacted operator's e-mail from the (cached) lookup so the
|
||||
// recording can be auto-sent. Cheap: the entry already looked the call up.
|
||||
if strings.TrimSpace(q.Email) == "" && a.lookup != nil {
|
||||
if lr, e := a.lookup.Lookup(a.ctx, q.Callsign); e == nil && lr.Email != "" {
|
||||
q.Email = lr.Email
|
||||
}
|
||||
}
|
||||
id, err := a.qso.Add(a.ctx, q)
|
||||
if err == nil {
|
||||
a.saveQSORecording(q.Callsign)
|
||||
q.ID = id
|
||||
a.saveQSORecording(&q)
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
}
|
||||
@@ -1667,6 +1691,8 @@ type AudioSettings struct {
|
||||
PTTMethod string `json:"ptt_method"` // "none" (VOX) | "rts" | "dtr"
|
||||
PTTPort string `json:"ptt_port"` // COM port for serial PTT
|
||||
Format string `json:"format"` // "wav" | "mp3"
|
||||
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
|
||||
MicGain int `json:"mic_gain"` // mic mix level %, default 100
|
||||
}
|
||||
|
||||
// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
|
||||
@@ -1676,13 +1702,14 @@ func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { return audio.Li
|
||||
|
||||
// GetAudioSettings returns the stored audio config (preroll defaults to 8s).
|
||||
func (a *App) GetAudioSettings() (AudioSettings, error) {
|
||||
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav"}
|
||||
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100}
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
|
||||
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat)
|
||||
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat,
|
||||
keyAudioFromGain, keyAudioMicGain)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -1704,6 +1731,12 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
|
||||
out.PrerollSeconds = n
|
||||
}
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyAudioFromGain]); n > 0 && n <= 400 {
|
||||
out.FromGain = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyAudioMicGain]); n > 0 && n <= 400 {
|
||||
out.MicGain = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -1727,6 +1760,12 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
||||
if format != "mp3" {
|
||||
format = "wav"
|
||||
}
|
||||
if s.FromGain <= 0 || s.FromGain > 400 {
|
||||
s.FromGain = 100
|
||||
}
|
||||
if s.MicGain <= 0 || s.MicGain > 400 {
|
||||
s.MicGain = 100
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyAudioFromRadio: s.FromRadio,
|
||||
keyAudioToRadio: s.ToRadio,
|
||||
@@ -1738,6 +1777,8 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
||||
keyAudioPTTMethod: pttMethod,
|
||||
keyAudioPTTPort: strings.TrimSpace(s.PTTPort),
|
||||
keyAudioFormat: format,
|
||||
keyAudioFromGain: strconv.Itoa(s.FromGain),
|
||||
keyAudioMicGain: strconv.Itoa(s.MicGain),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -1777,7 +1818,15 @@ func (a *App) startQSORecorderIfEnabled() {
|
||||
applog.Printf("qso-rec: start failed: %v", err)
|
||||
return
|
||||
}
|
||||
applog.Printf("qso-rec: running (preroll %ds, mix=%v)", cfg.PrerollSeconds, cfg.RecordingDevice != "" && cfg.RecordingDevice != cfg.FromRadio)
|
||||
fromGain, micGain := float64(cfg.FromGain)/100, float64(cfg.MicGain)/100
|
||||
if cfg.FromGain == 0 {
|
||||
fromGain = 1
|
||||
}
|
||||
if cfg.MicGain == 0 {
|
||||
micGain = 1
|
||||
}
|
||||
a.qsoRec.SetGains(fromGain, micGain)
|
||||
applog.Printf("qso-rec: running (preroll %ds, mix=%v, gains rx=%.2f mic=%.2f)", cfg.PrerollSeconds, cfg.RecordingDevice != "" && cfg.RecordingDevice != cfg.FromRadio, fromGain, micGain)
|
||||
}
|
||||
|
||||
// qsoRecDir returns the configured recordings folder, defaulting to
|
||||
@@ -1792,23 +1841,66 @@ func (a *App) qsoRecDir() string {
|
||||
return d
|
||||
}
|
||||
|
||||
// saveQSORecording finalises the active recording (if any) into a WAV named
|
||||
// after the callsign. Called right after a QSO is inserted (manual + UDP).
|
||||
func (a *App) saveQSORecording(call string) {
|
||||
// saveQSORecording finalises the active recording (if any) into a file named
|
||||
// CALL_BAND_MODE_YYYYMMDD_HHMMSS.ext, stores the filename on the QSO (so it can
|
||||
// be e-mailed later), and auto-sends it to the contacted operator when enabled
|
||||
// and an e-mail is known. Called right after a QSO is inserted (manual + UDP);
|
||||
// q must have its ID set.
|
||||
// recordableMode reports whether a QSO mode is worth an audio recording —
|
||||
// only voice (SSB/AM/FM) and CW. Digital modes (FT8/FT4/RTTY/PSK/JT…) carry no
|
||||
// useful audio, so they are never recorded.
|
||||
func recordableMode(mode string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "DV", "CW":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) saveQSORecording(q *qso.QSO) {
|
||||
if a.qsoRec == nil || !a.qsoRec.Active() {
|
||||
return
|
||||
}
|
||||
if !recordableMode(q.Mode) {
|
||||
a.qsoRec.DiscardQSO() // digital mode — drop the buffered audio
|
||||
return
|
||||
}
|
||||
ext := "wav"
|
||||
if cfg, _ := a.GetAudioSettings(); cfg.Format == "mp3" {
|
||||
ext = "mp3"
|
||||
}
|
||||
name := fmt.Sprintf("%s_%s.%s", sanitizeFilename(call), time.Now().UTC().Format("20060102_150405"), ext)
|
||||
parts := []string{sanitizeFilename(q.Callsign)}
|
||||
if b := strings.TrimSpace(q.Band); b != "" {
|
||||
parts = append(parts, sanitizeFilename(b))
|
||||
}
|
||||
if m := strings.TrimSpace(q.Mode); m != "" {
|
||||
parts = append(parts, sanitizeFilename(m))
|
||||
}
|
||||
parts = append(parts, time.Now().UTC().Format("20060102_150405"))
|
||||
name := strings.Join(parts, "_") + "." + ext
|
||||
path := filepath.Join(a.qsoRecDir(), name)
|
||||
if err := a.qsoRec.SaveQSO(path); err != nil {
|
||||
applog.Printf("qso-rec: save failed: %v", err)
|
||||
return
|
||||
}
|
||||
applog.Printf("qso-rec: saved %s", path)
|
||||
|
||||
// Remember the recording on the QSO so it can be e-mailed later.
|
||||
if q.ID != 0 {
|
||||
if q.Extras == nil {
|
||||
q.Extras = map[string]string{}
|
||||
}
|
||||
q.Extras["APP_OPSLOG_RECORDING"] = name
|
||||
if err := a.qso.Update(a.ctx, *q); err != nil {
|
||||
applog.Printf("qso-rec: store recording path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-send to the correspondent when enabled and an e-mail is known.
|
||||
if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(q.Email) != "" {
|
||||
qc := *q
|
||||
go func() { _ = a.sendRecordingEmail(qc, path) }()
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeFilename makes a callsign safe for a filename (slashes etc.).
|
||||
@@ -1829,10 +1921,15 @@ func sanitizeFilename(s string) string {
|
||||
|
||||
// QSOAudioBegin starts accumulating a QSO recording (seeded with the pre-roll).
|
||||
// Called by the entry strip when a callsign is first entered.
|
||||
func (a *App) QSOAudioBegin() {
|
||||
if a.qsoRec != nil {
|
||||
a.qsoRec.BeginQSO()
|
||||
// QSOAudioBegin starts accumulating a recording for the current QSO. It
|
||||
// returns true when a recording is actually running (recorder enabled and
|
||||
// capturing), so the UI can show a "REC" indicator.
|
||||
func (a *App) QSOAudioBegin() bool {
|
||||
if a.qsoRec == nil {
|
||||
return false
|
||||
}
|
||||
a.qsoRec.BeginQSO()
|
||||
return a.qsoRec.Active()
|
||||
}
|
||||
|
||||
// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
|
||||
@@ -1846,6 +1943,184 @@ func (a *App) QSOAudioCancel() {
|
||||
// RestartQSORecorder applies new audio settings to the running recorder.
|
||||
func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() }
|
||||
|
||||
// ── E-mail / SMTP (send QSO recordings) ───────────────────────────────
|
||||
|
||||
const (
|
||||
defaultEmailSubject = "Our QSO recording — {CALL}"
|
||||
defaultEmailBody = "Hi,\n\nGreat to work you! Please find attached the audio recording of our QSO.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
|
||||
)
|
||||
|
||||
// EmailSettings is the user's SMTP config + auto-send + message templates.
|
||||
type EmailSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"smtp_host"`
|
||||
Port int `json:"smtp_port"`
|
||||
User string `json:"smtp_user"`
|
||||
Password string `json:"smtp_password"`
|
||||
From string `json:"from"`
|
||||
Encryption string `json:"encryption"` // "ssl" | "starttls" | "none"
|
||||
Auth bool `json:"auth"` // SMTP requires authorization
|
||||
AutoSend bool `json:"auto_send"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// GetEmailSettings returns the stored SMTP config (with sensible defaults).
|
||||
func (a *App) GetEmailSettings() (EmailSettings, error) {
|
||||
out := EmailSettings{Port: 587, Encryption: "starttls", Auth: true, Subject: defaultEmailSubject, Body: defaultEmailBody}
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyEmailEnabled, keyEmailHost, keyEmailPort, keyEmailUser, keyEmailPassword,
|
||||
keyEmailFrom, keyEmailEncryption, keyEmailAuth, keyEmailAutoSend, keyEmailSubject, keyEmailBody)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Enabled = m[keyEmailEnabled] == "1"
|
||||
out.Host = m[keyEmailHost]
|
||||
if p, _ := strconv.Atoi(m[keyEmailPort]); p > 0 {
|
||||
out.Port = p
|
||||
}
|
||||
out.User = m[keyEmailUser]
|
||||
out.Password = m[keyEmailPassword]
|
||||
out.From = m[keyEmailFrom]
|
||||
if e := m[keyEmailEncryption]; e == "ssl" || e == "starttls" || e == "none" {
|
||||
out.Encryption = e
|
||||
}
|
||||
out.Auth = m[keyEmailAuth] != "0" // default true (unset → required)
|
||||
out.AutoSend = m[keyEmailAutoSend] == "1"
|
||||
if s := m[keyEmailSubject]; s != "" {
|
||||
out.Subject = s
|
||||
}
|
||||
if b := m[keyEmailBody]; b != "" {
|
||||
out.Body = b
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveEmailSettings persists the SMTP config.
|
||||
func (a *App) SaveEmailSettings(s EmailSettings) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
enc := s.Encryption
|
||||
if enc != "ssl" && enc != "none" {
|
||||
enc = "starttls"
|
||||
}
|
||||
if s.Port <= 0 {
|
||||
s.Port = 587
|
||||
}
|
||||
b2s := func(b bool) string {
|
||||
if b {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyEmailEnabled: b2s(s.Enabled),
|
||||
keyEmailHost: strings.TrimSpace(s.Host),
|
||||
keyEmailPort: strconv.Itoa(s.Port),
|
||||
keyEmailUser: strings.TrimSpace(s.User),
|
||||
keyEmailPassword: s.Password,
|
||||
keyEmailFrom: strings.TrimSpace(s.From),
|
||||
keyEmailEncryption: enc,
|
||||
keyEmailAuth: b2s(s.Auth),
|
||||
keyEmailAutoSend: b2s(s.AutoSend),
|
||||
keyEmailSubject: s.Subject,
|
||||
keyEmailBody: s.Body,
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) emailConfig(s EmailSettings) email.Config {
|
||||
return email.Config{Host: s.Host, Port: s.Port, User: s.User, Password: s.Password, From: s.From, Encryption: s.Encryption, Auth: s.Auth}
|
||||
}
|
||||
|
||||
// TestEmail sends a test message to `to` (defaults to the From address) to
|
||||
// validate the SMTP configuration.
|
||||
func (a *App) TestEmail(to string) error {
|
||||
s, _ := a.GetEmailSettings()
|
||||
if to == "" {
|
||||
to = s.From
|
||||
}
|
||||
if to == "" {
|
||||
to = s.User
|
||||
}
|
||||
return email.Send(a.emailConfig(s), to,
|
||||
"OpsLog SMTP test", "This is a test message from OpsLog — your SMTP settings work. 73", "")
|
||||
}
|
||||
|
||||
// fillTemplate substitutes {CALL} {DATE} {BAND} {MODE} {MYCALL} in a string.
|
||||
func (a *App) fillTemplate(tmpl string, q qso.QSO) string {
|
||||
myCall := ""
|
||||
if a.profiles != nil {
|
||||
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||
myCall = p.Callsign
|
||||
}
|
||||
}
|
||||
r := strings.NewReplacer(
|
||||
"{CALL}", q.Callsign,
|
||||
"{DATE}", q.QSODate.UTC().Format("2006-01-02 15:04 UTC"),
|
||||
"{BAND}", q.Band,
|
||||
"{MODE}", q.Mode,
|
||||
"{MYCALL}", myCall,
|
||||
)
|
||||
return r.Replace(tmpl)
|
||||
}
|
||||
|
||||
// sendRecordingEmail e-mails a QSO recording to the contacted operator.
|
||||
func (a *App) sendRecordingEmail(q qso.QSO, attachPath string) error {
|
||||
s, _ := a.GetEmailSettings()
|
||||
to := strings.TrimSpace(q.Email)
|
||||
if to == "" {
|
||||
return fmt.Errorf("no e-mail address for %s", q.Callsign)
|
||||
}
|
||||
subject := s.Subject
|
||||
if subject == "" {
|
||||
subject = defaultEmailSubject
|
||||
}
|
||||
body := s.Body
|
||||
if body == "" {
|
||||
body = defaultEmailBody
|
||||
}
|
||||
err := email.Send(a.emailConfig(s), to, a.fillTemplate(subject, q), a.fillTemplate(body, q), attachPath)
|
||||
if err != nil {
|
||||
applog.Printf("email: send recording to %s failed: %v", to, err)
|
||||
} else {
|
||||
applog.Printf("email: recording sent to %s (%s)", to, q.Callsign)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// SendQSORecordingEmail e-mails the stored recording for a QSO id (right-click
|
||||
// "Send recording by e-mail"). Errors if the QSO has no recording or e-mail.
|
||||
func (a *App) SendQSORecordingEmail(id int64) error {
|
||||
if a.qso == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := ""
|
||||
if q.Extras != nil {
|
||||
name = q.Extras["APP_OPSLOG_RECORDING"]
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("no recording stored for this QSO")
|
||||
}
|
||||
path := filepath.Join(a.qsoRecDir(), name)
|
||||
if _, e := os.Stat(path); e != nil {
|
||||
return fmt.Errorf("recording file missing: %s", name)
|
||||
}
|
||||
return a.sendRecordingEmail(q, path)
|
||||
}
|
||||
|
||||
// ── ClubLog Country File (cty.xml) exceptions ─────────────────────────
|
||||
|
||||
// ClublogCtyInfo is the UI status of the ClubLog exception data.
|
||||
@@ -3389,7 +3664,8 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert qso: %w", err)
|
||||
}
|
||||
a.saveQSORecording(q.Callsign)
|
||||
q.ID = id
|
||||
a.saveQSORecording(&q)
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
}
|
||||
@@ -4567,6 +4843,7 @@ func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) erro
|
||||
if c := strings.TrimSpace(comment); c != "" {
|
||||
cmd += " " + c
|
||||
}
|
||||
applog.Printf("cluster: send spot — freqKHz=%v → command %q", freqKHz, cmd)
|
||||
return a.SendClusterCommand(cmd)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user