feat: added support for eQSL
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// eqslImportURL is eQSL.cc's ADIF import endpoint. It accepts a form-encoded
|
||||
// POST (or URL params) with the account credentials and the ADIF content.
|
||||
const eqslImportURL = "https://www.eQSL.cc/qslcard/ImportADIF.cfm"
|
||||
|
||||
// eqslResultRe extracts "Result: X out of Y records added" from the reply.
|
||||
var eqslResultRe = regexp.MustCompile(`(?i)result:\s*(\d+)\s+out of\s+(\d+)\s+records added`)
|
||||
|
||||
// eqslPost performs the import POST and returns the raw response body. eQSL
|
||||
// replies HTTP 200 with a plain-text/HTML body for both success and errors;
|
||||
// callers classify it via the markers below.
|
||||
func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string) (string, error) {
|
||||
form := url.Values{}
|
||||
form.Set("EQSL_USER", user)
|
||||
form.Set("EQSL_PSWD", pswd)
|
||||
form.Set("ADIFData", adif)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, eqslImportURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("eqsl: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("eqsl: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
||||
msg := strings.TrimSpace(string(body))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return msg, fmt.Errorf("eqsl: http %d: %s", resp.StatusCode, msg)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// authErrEQSL returns a reason when the response signals bad credentials, else
|
||||
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
|
||||
func authErrEQSL(body string) string {
|
||||
if strings.Contains(strings.ToLower(body), "no match on eqsl") {
|
||||
return "invalid username or password"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// eqslRecordWithNickname prepends the APP_EQSL_QTH_NICKNAME tag to an ADIF
|
||||
// record when nick is set, so eQSL files the QSO under the right QTH profile
|
||||
// (required when the account has more than one). ADIF field order is free, so
|
||||
// prepending before the rest of the record is valid.
|
||||
func eqslRecordWithNickname(record, nick string) string {
|
||||
nick = strings.TrimSpace(nick)
|
||||
if nick == "" {
|
||||
return record
|
||||
}
|
||||
return fmt.Sprintf("<APP_EQSL_QTH_NICKNAME:%d>%s%s", len(nick), nick, record)
|
||||
}
|
||||
|
||||
// UploadEQSL pushes one ADIF record to eQSL.cc for the given account. qthNick
|
||||
// is the optional eQSL QTH nickname.
|
||||
//
|
||||
// eQSL replies with text: "Result: 1 out of 1 records added" on success,
|
||||
// "Bad record: Duplicate" for an already-present QSO (treated as success so
|
||||
// retries are idempotent), or "Error: No match on eQSL_User/eQSL_Pswd" for bad
|
||||
// credentials.
|
||||
func UploadEQSL(ctx context.Context, client *http.Client, user, pswd, qthNick, adifRecord string) (UploadResult, error) {
|
||||
user = strings.ToUpper(strings.TrimSpace(user))
|
||||
if user == "" {
|
||||
return UploadResult{}, fmt.Errorf("eqsl: username (callsign) not set")
|
||||
}
|
||||
if strings.TrimSpace(pswd) == "" {
|
||||
return UploadResult{}, fmt.Errorf("eqsl: password not set")
|
||||
}
|
||||
if strings.TrimSpace(adifRecord) == "" {
|
||||
return UploadResult{}, fmt.Errorf("eqsl: empty adif record")
|
||||
}
|
||||
|
||||
body, err := eqslPost(ctx, client, user, pswd, eqslRecordWithNickname(adifRecord, qthNick))
|
||||
if err != nil {
|
||||
return UploadResult{OK: false, Message: body}, err
|
||||
}
|
||||
|
||||
b := strings.ToLower(body)
|
||||
if reason := authErrEQSL(body); reason != "" {
|
||||
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: %s", reason)
|
||||
}
|
||||
if strings.Contains(b, "duplicate") {
|
||||
return UploadResult{OK: true, Message: "already in logbook"}, nil
|
||||
}
|
||||
if m := eqslResultRe.FindStringSubmatch(body); m != nil {
|
||||
added, _ := strconv.Atoi(m[1])
|
||||
if added >= 1 {
|
||||
return UploadResult{OK: true, Message: strings.TrimSpace(m[0])}, nil
|
||||
}
|
||||
// "0 out of N" — eQSL accepted nothing; surface why if it said so.
|
||||
reason := eqslReason(body)
|
||||
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
|
||||
}
|
||||
reason := eqslReason(body)
|
||||
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
|
||||
}
|
||||
|
||||
// eqslReason trims an eQSL reply to a short human-readable reason: the first
|
||||
// "Error:" / "Warning:" / "Bad record:" line if present, else the whole body
|
||||
// (capped), else a generic phrase.
|
||||
func eqslReason(body string) string {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
l := strings.TrimSpace(line)
|
||||
ll := strings.ToLower(l)
|
||||
if strings.HasPrefix(ll, "error:") || strings.HasPrefix(ll, "warning:") || strings.Contains(ll, "bad record") {
|
||||
return l
|
||||
}
|
||||
}
|
||||
b := strings.TrimSpace(body)
|
||||
if b == "" {
|
||||
return "upload rejected"
|
||||
}
|
||||
if len(b) > 200 {
|
||||
b = b[:200]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestEQSL validates the configured eQSL credentials with a REAL request: it
|
||||
// posts an empty ADIF so nothing is inserted, then checks for the bad-login
|
||||
// marker. Anything else means the credentials were accepted.
|
||||
func TestEQSL(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
|
||||
user := strings.ToUpper(strings.TrimSpace(cfg.Username))
|
||||
if user == "" {
|
||||
return "", fmt.Errorf("eqsl: username (callsign) not set")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Password) == "" {
|
||||
return "", fmt.Errorf("eqsl: password not set")
|
||||
}
|
||||
body, err := eqslPost(ctx, client, user, cfg.Password, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if reason := authErrEQSL(body); reason != "" {
|
||||
return "", fmt.Errorf("eqsl: %s", reason)
|
||||
}
|
||||
return fmt.Sprintf("Credentials accepted — %s", user), nil
|
||||
}
|
||||
@@ -32,6 +32,7 @@ const (
|
||||
ServiceClublog Service = "clublog" // Club Log real-time upload
|
||||
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
|
||||
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
|
||||
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
|
||||
)
|
||||
|
||||
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
||||
@@ -67,6 +68,7 @@ type ServiceConfig struct {
|
||||
Password string `json:"password"` // Club Log account / LoTW website password
|
||||
Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign
|
||||
Code string `json:"code"` // HRDLog: account upload code
|
||||
QTHNickname string `json:"qth_nickname"` // eQSL: QTH nickname (when the account has several)
|
||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||
@@ -84,6 +86,8 @@ func (c ServiceConfig) normalised() ServiceConfig {
|
||||
c.Email = strings.TrimSpace(c.Email)
|
||||
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
||||
c.Code = strings.TrimSpace(c.Code)
|
||||
c.Username = strings.TrimSpace(c.Username)
|
||||
c.QTHNickname = strings.TrimSpace(c.QTHNickname)
|
||||
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
|
||||
c.TQSLPath = strings.TrimSpace(c.TQSLPath)
|
||||
c.StationLocation = strings.TrimSpace(c.StationLocation)
|
||||
@@ -115,6 +119,7 @@ type ExternalServices struct {
|
||||
Clublog ServiceConfig `json:"clublog"`
|
||||
LoTW ServiceConfig `json:"lotw"`
|
||||
HRDLog ServiceConfig `json:"hrdlog"`
|
||||
EQSL ServiceConfig `json:"eqsl"`
|
||||
}
|
||||
|
||||
// UploadResult is the outcome of a single upload attempt.
|
||||
|
||||
@@ -116,6 +116,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
||||
cfg.Clublog = cfg.Clublog.normalised()
|
||||
cfg.LoTW = cfg.LoTW.normalised()
|
||||
cfg.HRDLog = cfg.HRDLog.normalised()
|
||||
cfg.EQSL = cfg.EQSL.normalised()
|
||||
m.cfg = cfg
|
||||
}
|
||||
|
||||
@@ -156,6 +157,10 @@ func (m *Manager) OnQSOLogged(id int64) {
|
||||
if h := cfg.HRDLog; h.AutoUpload && h.Callsign != "" && h.Code != "" {
|
||||
m.route(ServiceHRDLog, id, h)
|
||||
}
|
||||
// eQSL — needs the account username (callsign) + password.
|
||||
if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
|
||||
m.route(ServiceEQSL, id, e)
|
||||
}
|
||||
}
|
||||
|
||||
// route sends a logged QSO down the configured timing path: queue it for the
|
||||
@@ -198,6 +203,9 @@ func (m *Manager) onCloseServices() []Service {
|
||||
if h := cfg.HRDLog; h.AutoUpload && h.UploadMode == ModeOnClose && h.Callsign != "" && h.Code != "" {
|
||||
out = append(out, ServiceHRDLog)
|
||||
}
|
||||
if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
|
||||
out = append(out, ServiceEQSL)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -251,6 +259,12 @@ func (m *Manager) FlushOnClose() int {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
case ServiceEQSL:
|
||||
for _, id := range ids {
|
||||
if m.upload(svc, id, cfg.EQSL) {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return uploaded
|
||||
@@ -319,6 +333,8 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
owner = cfg.ForceStationCallsign
|
||||
case ServiceClublog, ServiceHRDLog:
|
||||
owner = cfg.Callsign
|
||||
case ServiceEQSL:
|
||||
owner = cfg.Username
|
||||
}
|
||||
if owner != "" && m.deps.StationCallOf != nil {
|
||||
qcall := m.deps.StationCallOf(id)
|
||||
@@ -374,6 +390,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
return false
|
||||
}
|
||||
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
|
||||
case ServiceEQSL:
|
||||
// eQSL keeps the QSO's own station call; the account is identified by
|
||||
// the Username + Password, with an optional QTH nickname.
|
||||
record, ok := m.deps.BuildADIF(id, "")
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return false
|
||||
}
|
||||
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user