diff --git a/.gitignore b/.gitignore index 2302873..087870b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ pat pat*.pkg docker-data/ .aider* +*.swp diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f7da6c..6896c0f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,7 @@ We welcome your contributions. To make the process as seamless as possible, we ask for the following: - Go ahead and fork the project and make your changes. We encourage pull requests to discuss code changes. + - Base your changes off the "develop" branch, not master - When you’re ready to create a pull request, be sure to: - Run `go fmt` - Consider squashing your commits into a single commit. `git rebase -i`. It's okay to force update your pull request. diff --git a/README.md b/README.md index 7aca1ad..74066c4 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Copyright (c) 2020 Martin Hebnes Pedersen LA5NTA ### Contributors (alphabetical) +* AB3E - Justin Overfelt * DL1THM - Torsten Harenberg * HB9GPA - Matthias Renner * K0RET - Ryan Turner diff --git a/account.go b/account.go index b898cf8..ed62c64 100644 --- a/account.go +++ b/account.go @@ -41,18 +41,9 @@ func accountHandle(ctx context.Context, args []string) { } func passwordRecoveryEmailHandle(ctx context.Context, args []string) error { - mycall, password := fOptions.MyCall, config.SecureLoginPassword - if password == "" { - select { - case <-ctx.Done(): - return ctx.Err() - case resp := <-promptHub.Prompt(ctx, PromptKindPassword, "Enter account password for "+mycall): - if resp.Err != nil { - return resp.Err - } - password = resp.Value - } - } + mycall := fOptions.MyCall + password := getPasswordForCallsign(ctx, mycall) + arg, _ := shiftArgs(args) if arg != "" { if err := cmsapi.PasswordRecoveryEmailSet(ctx, mycall, password, arg); err != nil { diff --git a/internal/cmsapi/mps.go b/internal/cmsapi/mps.go new file mode 100644 index 0000000..ee0c051 --- /dev/null +++ b/internal/cmsapi/mps.go @@ -0,0 +1,123 @@ +package cmsapi + +import ( + "context" + "encoding/json" + "net/url" + "regexp" + "strconv" + "time" +) + +const ( + PathMPSAdd = "/mps/add" + PathMPSDelete = "/mps/delete" + PathMPSGet = "/mps/get" + PathMPSList = "/mps/list" +) + +// MessagePickupStationRecord represents an MPS record +type MessagePickupStationRecord struct { + Callsign string `json:"callsign"` + MpsCallsign string `json:"mpsCallsign"` + Timestamp DotNetTime `json:"timestamp"` +} + +// DotNetTime handles .NET-style JSON date serialization +type DotNetTime struct{ time.Time } + +// UnmarshalJSON implements custom JSON unmarshaling for .NET date format +func (t *DotNetTime) UnmarshalJSON(b []byte) error { + var str string + if err := json.Unmarshal(b, &str); err != nil { + return err + } + + // Handle .NET date format: \/Date(milliseconds)\/ + re := regexp.MustCompile(`\/Date\((-?\d+)\)\/`) + matches := re.FindStringSubmatch(str) + if len(matches) == 2 { + millis, err := strconv.ParseInt(matches[1], 10, 64) + if err != nil { + return err + } + t.Time = time.Unix(millis/1000, (millis%1000)*1000000) + return nil + } + + // Fall back to RFC3339 format + parsedTime, err := time.Parse(time.RFC3339, str) + if err == nil { + t.Time = parsedTime + return nil + } + + // Fall back to RFC1123 format + parsedTime, err = time.Parse(time.RFC1123, str) + if err == nil { + t.Time = parsedTime + return nil + } + + return err +} + +// MPSAdd adds an entry to the MPS table +func MPSAdd(ctx context.Context, requester, callsign, password, mpsCallsign string) error { + params := url.Values{ + "requester": []string{requester}, + "callsign": []string{callsign}, + "password": []string{password}, + "mpsCallsign": []string{mpsCallsign}, + } + var resp struct{ ResponseStatus responseStatus } + if err := getJSON(ctx, PathMPSAdd, params, &resp); err != nil { + return err + } + return resp.ResponseStatus.errorOrNil() +} + +// MPSDelete deletes all MPS records for the specified callsign +func MPSDelete(ctx context.Context, requester, callsign, password string) error { + params := url.Values{ + "requester": []string{requester}, + "callsign": []string{callsign}, + "password": []string{password}, + } + var resp struct{ ResponseStatus responseStatus } + if err := getJSON(ctx, PathMPSDelete, params, &resp); err != nil { + return err + } + return resp.ResponseStatus.errorOrNil() +} + +// MPSGet returns all MPS records for the specified callsign +func MPSGet(ctx context.Context, requester, callsign string) ([]MessagePickupStationRecord, error) { + params := url.Values{ + "requester": []string{requester}, + "callsign": []string{callsign}, + } + var resp struct { + MpsList []MessagePickupStationRecord `json:"mpsList"` + ResponseStatus responseStatus + } + if err := getJSON(ctx, PathMPSGet, params, &resp); err != nil { + return nil, err + } + return resp.MpsList, resp.ResponseStatus.errorOrNil() +} + +// MPSList returns all MPS records +func MPSList(ctx context.Context, requester string) ([]MessagePickupStationRecord, error) { + params := url.Values{ + "requester": []string{requester}, + } + var resp struct { + MpsList []MessagePickupStationRecord `json:"mpsList"` + ResponseStatus responseStatus + } + if err := getJSON(ctx, PathMPSList, params, &resp); err != nil { + return nil, err + } + return resp.MpsList, resp.ResponseStatus.errorOrNil() +} diff --git a/main.go b/main.go index f15d8c3..62aec1c 100644 --- a/main.go +++ b/main.go @@ -170,6 +170,13 @@ var commands = []Command{ Example: accountExample, HandleFunc: accountHandle, }, + { + Str: "mps", + Desc: "Manage message pickup stations.", + Usage: mpsUsage, + Example: mpsExample, + HandleFunc: mpsHandle, + }, { Str: "configure", Desc: "Open configuration file for editing.", @@ -736,3 +743,23 @@ func openMessage(path string) (*fbb.Message, error) { } return mailbox.OpenMessage(path) } + +// getPasswordForCallsign gets the password for the specified callsign +// It tries the configured SecureLoginPassword first, then prompts if not available +func getPasswordForCallsign(ctx context.Context, callsign string) string { + password := config.SecureLoginPassword + if password != "" { + return password + } + + select { + case <-ctx.Done(): + return "" + case resp := <-promptHub.Prompt(ctx, PromptKindPassword, "Enter account password for "+callsign): + if resp.Err != nil { + log.Printf("Password prompt error: %v", resp.Err) + return "" + } + return resp.Value + } +} diff --git a/mps.go b/mps.go new file mode 100644 index 0000000..eb74120 --- /dev/null +++ b/mps.go @@ -0,0 +1,171 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/la5nta/pat/internal/cmsapi" +) + +const ( + mpsUsage = `subcommand [options] + +subcommands: + list [--all] List message pickup stations for your callsign, or all MPS with --all + clear Delete all message pickup stations for your callsign + add [CALLSIGN] Add a message pickup station` + + mpsExample = ` + list List your message pickup stations + list --all List all message pickup stations + clear Delete all your message pickup stations + add W1AW Add W1AW as a message pickup station` +) + +func mpsHandle(ctx context.Context, args []string) { + mycall := fOptions.MyCall + if mycall == "" { + fmt.Println("ERROR: MyCall not configured") + os.Exit(1) + } + + switch cmd, args := shiftArgs(args); cmd { + case "list": + option, _ := shiftArgs(args) + if option == "--all" { + err := mpsListAllHandle(ctx, mycall) + + if err != nil { + fmt.Println("ERROR:", err) + os.Exit(1) + } + } else if err := mpsListMineHandle(ctx, mycall); err != nil { + fmt.Println("ERROR:", err) + os.Exit(1) + } + case "clear": + if err := mpsClearHandle(ctx, mycall); err != nil { + fmt.Println("ERROR:", err) + os.Exit(1) + } + case "add": + addCall, _ := shiftArgs(args) + if err := mpsAddHandle(ctx, mycall, addCall); err != nil { + fmt.Println("ERROR:", err) + os.Exit(1) + } + default: + fmt.Println("Missing argument, try 'mps help'.") + } +} + +func mpsListAllHandle(ctx context.Context, mycall string) error { + const interval = 30 * time.Minute + + var mpsList []cmsapi.MessagePickupStationRecord + var listErr error + + err := doIfElapsed("mps_list", interval, func() error { + mpsList, listErr = cmsapi.MPSList(ctx, mycall) + return listErr + }) + + if err != nil { + if !errors.Is(err, errRateLimited) { + return fmt.Errorf("failed to retrieve MPS list: %w", listErr) + } + + return errors.New("rate limit: MPS list can only be called once every 30 minutes") + } + + if len(mpsList) == 0 { + fmt.Println("No message pickup stations found.") + return nil + } + + mpsCounts := make(map[string]int64) + for _, mps := range mpsList { + mpsCounts[mps.MpsCallsign]++ + } + + // Print header + fmt.Printf("%-12.12s %s\n", "mps callsign", "# of users") + + // Print MPS records + for mpsCall, count := range mpsCounts { + fmt.Printf("%-12.12s %d\n", mpsCall, count) + } + + return nil +} + +func mpsListMineHandle(ctx context.Context, mycall string) error { + mpsList, err := cmsapi.MPSGet(ctx, mycall, mycall) + if err != nil { + return fmt.Errorf("failed to retrieve your MPS records: %w", err) + } + + if len(mpsList) == 0 { + fmt.Println("No message pickup stations configured for your callsign.") + return nil + } + + fmtStr := "%-12.12s %s\n" + + // Print header + fmt.Printf(fmtStr, "mps callsign", "timestamp") + + // Print MPS records + for _, mps := range mpsList { + fmt.Printf(fmtStr, mps.MpsCallsign, mps.Timestamp.Format("2006-01-02 15:04:05")) + } + return nil +} + +func mpsClearHandle(ctx context.Context, mycall string) error { + password := getPasswordForCallsign(ctx, mycall) + if password == "" { + return fmt.Errorf("password required for clear operation") + } + + mpsList, err := cmsapi.MPSGet(ctx, mycall, mycall) + if err != nil { + return fmt.Errorf("failed to retrieve your MPS records for display before clear: %w", err) + } + + if err := cmsapi.MPSDelete(ctx, mycall, mycall, password); err != nil { + return fmt.Errorf("failed to clear MPS records: %w", err) + } + + fmt.Println("All message pickup stations deleted successfully.") + fmt.Println("Previous message pickup stations:") + for _, station := range mpsList { + fmt.Println(station.MpsCallsign) + } + + return nil +} + +func mpsAddHandle(ctx context.Context, mycall, mpsCallsign string) error { + // Validate callsign format + mpsCallsign = strings.ToUpper(strings.TrimSpace(mpsCallsign)) + if mpsCallsign == "" { + return fmt.Errorf("MPS callsign cannot be empty") + } + + password := getPasswordForCallsign(ctx, mycall) + if password == "" { + return fmt.Errorf("password required for add operation") + } + + if err := cmsapi.MPSAdd(ctx, mycall, mycall, password, mpsCallsign); err != nil { + return fmt.Errorf("failed to add MPS station: %w", err) + } + + fmt.Printf("Message pickup station %s added successfully.\n", mpsCallsign) + return nil +} diff --git a/winlink_api.go b/winlink_api.go index 90f247d..d2b490a 100644 --- a/winlink_api.go +++ b/winlink_api.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -15,6 +16,8 @@ import ( "github.com/la5nta/pat/internal/directories" ) +var errRateLimited error = errors.New("call was rate-limited") + // doIfElapsed implements a per-callsign rate limited function. func doIfElapsed(name string, t time.Duration, fn func() error) error { filePath := filepath.Join(directories.StateDir(), "."+name+"_"+fOptions.MyCall+".json") @@ -28,7 +31,7 @@ func doIfElapsed(name string, t time.Duration, fn func() error) error { json.NewDecoder(file).Decode(&lastUpdated) if since := time.Since(lastUpdated); since < t { debug.Printf("Skipping %q (last run: %s ago)", name, since.Truncate(time.Minute)) - return nil + return errRateLimited } if err := fn(); err != nil { @@ -57,7 +60,7 @@ func postVersionUpdate() { Comments: fmt.Sprintf("%s - %s/%s", buildinfo.GitRev, runtime.GOOS, runtime.GOARCH), }.Post() }) - if err != nil { + if err != nil && !errors.Is(err, errRateLimited) { debug.Printf("Failed to post version update: %v", err) } } @@ -80,7 +83,7 @@ func checkPasswordRecoveryEmailIsSet(ctx context.Context) { fmt.Println("") return nil }) - if err != nil { + if err != nil && !errors.Is(err, errRateLimited) { debug.Printf("Failed to check if password recovery email is set: %v", err) } }