feat(hamqth): QSO upload to the HamQTH logbook — the 8th external service
qso_realtime.php with the account's callbook credentials (which the external-services config falls back to when its own are blank), one ADIF record per QSO, prg=OpsLog. HTTP status IS the answer: 200 saved, 400 rejected (a duplicate counts as delivered, like HRDLog's insert 0), 403 credentials. Sent-state lives in APP_OPSLOG_HAMQTH_SENT extras like HAMLOG.online — ADIF names no HamQTH field. Auto-upload on log, on-close batch, right-click Send to, QSL Manager backlog, and a Test button that authenticates against the callbook login, which cannot touch the log. Fixes a real mis-route on the way: manual 'Send to HAMLOG.online' had no branch in runManualUpload and fell through to QRZ.com — the selection was uploaded to the wrong service with the QRZ key. Both extras-stamped services now have their own branch.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HamQTH real-time QSO upload.
|
||||
//
|
||||
// One POST per QSO to qso_realtime.php with the account username/password —
|
||||
// the same credentials the HamQTH callbook lookup uses. The answer is the
|
||||
// HTTP status code, not a body format: 200 saved, 400 rejected (bad band,
|
||||
// duplicate…, reason in the body), 403 wrong credentials, 500 server error.
|
||||
const hamqthUploadURL = "https://www.hamqth.com/qso_realtime.php"
|
||||
|
||||
// hamqthLoginURL is the callbook session login — the one authenticated HamQTH
|
||||
// endpoint that cannot change anything in the log, which is what the settings
|
||||
// Test button must call.
|
||||
const hamqthLoginURL = "https://www.hamqth.com/xml.php"
|
||||
|
||||
// UploadHamQTH pushes one ADIF record to the HamQTH online logbook.
|
||||
func UploadHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||
return uploadHamQTHTo(ctx, client, hamqthUploadURL, cfg, adifRecord)
|
||||
}
|
||||
|
||||
func uploadHamQTHTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||
user := strings.TrimSpace(cfg.Username)
|
||||
switch {
|
||||
case user == "":
|
||||
return UploadResult{}, fmt.Errorf("hamqth: username not set")
|
||||
case cfg.Password == "":
|
||||
return UploadResult{}, fmt.Errorf("hamqth: password not set")
|
||||
}
|
||||
rec := strings.TrimSpace(adifRecord)
|
||||
if rec == "" {
|
||||
return UploadResult{}, fmt.Errorf("hamqth: empty ADIF record")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("u", user)
|
||||
form.Set("p", cfg.Password)
|
||||
// c: the logbook callsign when the account holds several; empty means the
|
||||
// account's own call, which is the common case.
|
||||
if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" {
|
||||
form.Set("c", c)
|
||||
}
|
||||
form.Set("adif", rec)
|
||||
form.Set("prg", "OpsLog")
|
||||
form.Set("cmd", "insert")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return UploadResult{}, 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 UploadResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
msg := strings.TrimSpace(string(body))
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
return UploadResult{OK: true}, nil
|
||||
case http.StatusBadRequest:
|
||||
// "Rejected" covers duplicates too. A duplicate is a SUCCESS for our
|
||||
// purposes — the QSO is in the logbook, retrying it forever isn't —
|
||||
// same treatment HRDLog's <insert>0 gets.
|
||||
if strings.Contains(strings.ToLower(msg), "dupl") {
|
||||
return UploadResult{OK: true, Ignored: true, Message: "already in logbook"}, nil
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "QSO rejected"
|
||||
}
|
||||
return UploadResult{OK: false, Message: msg}, nil
|
||||
case http.StatusForbidden:
|
||||
return UploadResult{}, fmt.Errorf("hamqth: wrong username or password")
|
||||
default:
|
||||
if msg != "" && len(msg) < 200 {
|
||||
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg)
|
||||
}
|
||||
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHamQTH verifies the credentials against the callbook session login —
|
||||
// authenticated, and unable to touch the log.
|
||||
func TestHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
|
||||
user := strings.TrimSpace(cfg.Username)
|
||||
if user == "" || cfg.Password == "" {
|
||||
return "", fmt.Errorf("hamqth: set the username and password first")
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("u", user)
|
||||
q.Set("p", cfg.Password)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hamqthLoginURL+"?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
s := string(body)
|
||||
if strings.Contains(s, "<session_id>") {
|
||||
return fmt.Sprintf("Connected to HamQTH as %s.", user), nil
|
||||
}
|
||||
if i := strings.Index(s, "<error>"); i >= 0 {
|
||||
e := s[i+len("<error>"):]
|
||||
if j := strings.Index(e, "</error>"); j >= 0 {
|
||||
return "", fmt.Errorf("hamqth: %s", strings.TrimSpace(e[:j]))
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("hamqth: unexpected answer — check the username and password")
|
||||
}
|
||||
Reference in New Issue
Block a user