mirror of
https://github.com/la5nta/pat
synced 2026-08-07 22:32:06 -04:00
parent
361973623e
commit
a31158c76c
8 changed files with 333 additions and 15 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@ pat
|
|||
pat*.pkg
|
||||
docker-data/
|
||||
.aider*
|
||||
*.swp
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
account.go
15
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 {
|
||||
|
|
|
|||
123
internal/cmsapi/mps.go
Normal file
123
internal/cmsapi/mps.go
Normal 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
27
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
171
mps.go
Normal file
171
mps.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue