Add mps command for managing message pickup stations

ref #51
This commit is contained in:
joverfelt 2025-06-11 00:06:17 +00:00 committed by Martin Hebnes Pedersen
parent 361973623e
commit a31158c76c
8 changed files with 333 additions and 15 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ pat
pat*.pkg
docker-data/
.aider*
*.swp

View file

@ -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 youre 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.

View file

@ -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

View file

@ -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 {

123
internal/cmsapi/mps.go Normal file
View file

@ -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()
}

27
main.go
View file

@ -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
}
}

171
mps.go Normal file
View file

@ -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
}

View file

@ -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)
}
}