Skip to content

Commit 3237bca

Browse files
author
Rajath Agasthya
committed
Initial version of global entry notifier
* Location defaults to AUS airport. * Support MacOS native notifications.
1 parent 9c39cd9 commit 3237bca

File tree

3 files changed

+173
-1
lines changed

3 files changed

+173
-1
lines changed

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,29 @@
11
# global-entry-notifier
2-
Script to notify of available Global Entry enrollment appointments at a particular location
2+
Script to notify of available Global Entry enrollment appointments at a particular location. Only supports running on MacOS.
3+
4+
## Prerequisites
5+
6+
* MacOS (for notifications).
7+
* Install [Go](https://go.dev) (if building from source).
8+
9+
## Usage
10+
11+
```shell
12+
$ ./global-entry-notifier -h
13+
Usage of ./global-entry-notifier:
14+
-days int
15+
number of days from today to filter slots; use 1 for current day (default 1)
16+
-interval duration
17+
polling interval for available slots e.g. 1m, 1h, 1h10m, 1d, 1d1h10m (default 1m0s)
18+
-limit int
19+
number of slots to notify (default 1)
20+
-location-id int
21+
ID of the Global Entry Enrollment Center; defaults to AUS airport (default 7820)
22+
```
23+
24+
1. Find your desired enrollment
25+
center [here](https://ttp.cbp.dhs.gov/schedulerapi/locations/?temporary=false&inviteOnly=false&operational=true&serviceName=Global%20Entry)
26+
and copy its `id` field. For example: ID
27+
of San Francisco Global Entry Enrollment Center is `5446`.
28+
2. Use the binary from [releases page](https://github.com/rajathagasthya/global-entry-notifier/releases) or build binary from source using `go build -o global-entry-notifier ./main.go`.
29+
3. Run the binary with the arguments shown above. Use `Ctrl+C` to exit.

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/rajathagasthya/global-entry-notifier
2+
3+
go 1.21.6

main.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"flag"
6+
"fmt"
7+
"log"
8+
"net/http"
9+
"net/url"
10+
"os"
11+
"os/exec"
12+
"os/signal"
13+
"syscall"
14+
"time"
15+
)
16+
17+
type Slot struct {
18+
LocationId int `json:"locationId"`
19+
StartTimestamp Time `json:"startTimestamp"`
20+
EndTimestamp Time `json:"endTimestamp"`
21+
Active bool `json:"active"`
22+
}
23+
24+
type Time struct {
25+
time.Time `json:"-"`
26+
}
27+
28+
// UnmarshalJSON implements the json.Unmarshaller interface.
29+
func (t *Time) UnmarshalJSON(data []byte) error {
30+
if string(data) == "null" {
31+
return nil
32+
}
33+
var str string
34+
err := json.Unmarshal(data, &str)
35+
if err != nil {
36+
return err
37+
}
38+
pt, err := time.LoadLocation("America/Los_Angeles")
39+
if err != nil {
40+
return err
41+
}
42+
t.Time, err = time.ParseInLocation("2006-01-02T15:04", str, pt)
43+
return err
44+
}
45+
46+
func getSlots(u *url.URL) ([]Slot, error) {
47+
resp, err := http.Get(u.String())
48+
if err != nil {
49+
return nil, fmt.Errorf("response error: %w", err)
50+
}
51+
defer resp.Body.Close()
52+
53+
var slots []Slot
54+
err = json.NewDecoder(resp.Body).Decode(&slots)
55+
if err != nil {
56+
return nil, fmt.Errorf("error decoding response: %w", err)
57+
}
58+
return slots, nil
59+
}
60+
61+
func filterSlots(slots []Slot, days int) []Slot {
62+
if len(slots) == 0 || days < 0 {
63+
return nil
64+
}
65+
var result []Slot
66+
deadline := time.Now().AddDate(0, 0, int(days))
67+
for _, s := range slots {
68+
if s.StartTimestamp.IsZero() {
69+
continue
70+
}
71+
if s.Active && (s.StartTimestamp.Before(deadline) || s.StartTimestamp.Equal(deadline)) {
72+
result = append(result, s)
73+
}
74+
}
75+
return result
76+
}
77+
78+
func notify(slots []Slot) error {
79+
if len(slots) == 0 {
80+
return nil
81+
}
82+
for _, s := range slots {
83+
script := fmt.Sprintf("display notification \"%s\" with title \"Global Entry Slot Available\" sound name \"Purr\"", s.StartTimestamp.Format("Mon Jan 2 15:04"))
84+
cmd := exec.Command("/usr/bin/osascript", "-e", script)
85+
cmd.Stderr = os.Stderr
86+
if err := cmd.Run(); err != nil {
87+
return fmt.Errorf("failed to notify: %w", err)
88+
}
89+
}
90+
return nil
91+
}
92+
93+
func main() {
94+
locationID := flag.Int("location-id", 7820, "ID of the Global Entry Enrollment Center; defaults to AUS airport")
95+
limit := flag.Int("limit", 1, "number of slots to notify")
96+
days := flag.Int("days", 1, "number of days from today to filter slots; use 1 for current day")
97+
interval := flag.Duration("interval", 1*time.Minute, "polling interval for available slots e.g. 1m, 1h, 1h10m, 1d, 1d1h10m")
98+
flag.Parse()
99+
100+
if *locationID < 1 {
101+
log.Fatal("location-id cannot be < 1")
102+
}
103+
if *days < 1 {
104+
log.Fatal("days cannot be < 1")
105+
}
106+
if *limit < 1 {
107+
log.Fatal("limit cannot be < 1")
108+
}
109+
110+
// List of locations can be found at https://ttp.cbp.dhs.gov/schedulerapi/locations/?temporary=false&inviteOnly=false&operational=true&serviceName=Global%20Entry
111+
u, err := url.Parse(fmt.Sprintf("https://ttp.cbp.dhs.gov/schedulerapi/slots?orderBy=soonest&limit=%d&locationId=%d", *limit, *locationID))
112+
if err != nil {
113+
log.Fatalf("error parsing url: %v", err)
114+
}
115+
116+
fmt.Printf("Waiting for available appointments at location %d\n", *locationID)
117+
118+
sigs := make(chan os.Signal, 1)
119+
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
120+
121+
tickDuration := *interval
122+
ticker := time.NewTicker(tickDuration)
123+
defer ticker.Stop()
124+
for {
125+
select {
126+
case <-sigs:
127+
log.Println("shutting down on signal")
128+
return
129+
case <-ticker.C:
130+
slots, err := getSlots(u)
131+
if err != nil {
132+
log.Fatal(err)
133+
}
134+
if len(slots) == 0 {
135+
break
136+
}
137+
if err := notify(filterSlots(slots, *days)); err != nil {
138+
log.Fatal(err)
139+
}
140+
}
141+
}
142+
}

0 commit comments

Comments
 (0)