Skip to content

Commit d3e1ce3

Browse files
committed
feat: add screener command for managing The Screener (clearances)
Adds 'hey screener' with subcommands: - list: show pending screener items - approve <id>: screen in to Imbox - deny <id>: screen out - spam <id>: mark as spam - feed <id>: screen in to The Feed - trail <id>: screen in to Paper Trail Uses the /clearances HTML endpoint for listing and PATCH /clearances/<id> for actions, since the SDK doesn't have a Clearances service yet.
1 parent 7ef135c commit d3e1ce3

2 files changed

Lines changed: 350 additions & 0 deletions

File tree

internal/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ func newRootCmd() *cobra.Command {
138138
root.AddCommand(newCompletionCommand())
139139
root.AddCommand(newDoctorCommand())
140140
root.AddCommand(newConfigCommand().cmd)
141+
root.AddCommand(newScreenerCommand().cmd)
141142

142143
return root
143144
}

internal/cmd/screener.go

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"net/url"
9+
"regexp"
10+
"strings"
11+
12+
"github.com/spf13/cobra"
13+
14+
"github.com/basecamp/hey-cli/internal/output"
15+
)
16+
17+
// screenerItem represents a pending item in The Screener.
18+
type screenerItem struct {
19+
PostingID string `json:"posting_id"`
20+
Sender string `json:"sender"`
21+
Subject string `json:"subject"`
22+
}
23+
24+
type screenerCommand struct {
25+
cmd *cobra.Command
26+
}
27+
28+
func newScreenerCommand() *screenerCommand {
29+
sc := &screenerCommand{}
30+
sc.cmd = &cobra.Command{
31+
Use: "screener",
32+
Short: "Manage The Screener (pending sender approvals)",
33+
Annotations: map[string]string{
34+
"agent_notes": "List pending screener items and approve/deny senders. Use approve/deny/spam/feed/trail subcommands with posting ID.",
35+
},
36+
Example: ` hey screener
37+
hey screener approve 123456
38+
hey screener deny 123456
39+
hey screener spam 123456
40+
hey screener feed 123456
41+
hey screener trail 123456`,
42+
RunE: sc.list,
43+
}
44+
45+
sc.cmd.AddCommand(&cobra.Command{
46+
Use: "approve <posting-id>",
47+
Short: "Screen in a sender to Imbox",
48+
Args: cobra.ExactArgs(1),
49+
RunE: sc.approve,
50+
})
51+
sc.cmd.AddCommand(&cobra.Command{
52+
Use: "deny <posting-id>",
53+
Short: "Screen out a sender",
54+
Args: cobra.ExactArgs(1),
55+
RunE: sc.deny,
56+
})
57+
sc.cmd.AddCommand(&cobra.Command{
58+
Use: "spam <posting-id>",
59+
Short: "Mark a sender as spam",
60+
Args: cobra.ExactArgs(1),
61+
RunE: sc.markSpam,
62+
})
63+
sc.cmd.AddCommand(&cobra.Command{
64+
Use: "feed <posting-id>",
65+
Short: "Screen in a sender to The Feed",
66+
Args: cobra.ExactArgs(1),
67+
RunE: sc.feed,
68+
})
69+
sc.cmd.AddCommand(&cobra.Command{
70+
Use: "trail <posting-id>",
71+
Short: "Screen in a sender to Paper Trail",
72+
Args: cobra.ExactArgs(1),
73+
RunE: sc.trail,
74+
})
75+
76+
return sc
77+
}
78+
79+
func (sc *screenerCommand) list(cmd *cobra.Command, args []string) error {
80+
if err := requireAuth(); err != nil {
81+
return err
82+
}
83+
84+
items, feedBoxID, trailBoxID, err := fetchScreenerItems(cmd.Context())
85+
if err != nil {
86+
return err
87+
}
88+
_ = feedBoxID
89+
_ = trailBoxID
90+
91+
if writer.IsStyled() {
92+
if len(items) == 0 {
93+
fmt.Fprintln(cmd.OutOrStdout(), "The Screener is empty.")
94+
return nil
95+
}
96+
table := newTable(cmd.OutOrStdout())
97+
table.addRow([]string{"Posting ID", "Sender", "Subject"})
98+
for _, item := range items {
99+
table.addRow([]string{item.PostingID, item.Sender, item.Subject})
100+
}
101+
table.print()
102+
fmt.Fprintf(cmd.OutOrStdout(), "\n%d pending\n", len(items))
103+
return nil
104+
}
105+
106+
return writeOK(items,
107+
output.WithSummary(fmt.Sprintf("%d pending", len(items))),
108+
output.WithBreadcrumbs(
109+
output.Breadcrumb{
110+
Action: "approve",
111+
Command: "hey screener approve <id>",
112+
Description: "Screen in to Imbox",
113+
},
114+
output.Breadcrumb{
115+
Action: "deny",
116+
Command: "hey screener deny <id>",
117+
Description: "Screen out sender",
118+
},
119+
),
120+
)
121+
}
122+
123+
func (sc *screenerCommand) approve(cmd *cobra.Command, args []string) error {
124+
if err := requireAuth(); err != nil {
125+
return err
126+
}
127+
return patchClearance(cmd, args[0], url.Values{
128+
"status": {"approved"},
129+
}, "Screened in to Imbox")
130+
}
131+
132+
func (sc *screenerCommand) deny(cmd *cobra.Command, args []string) error {
133+
if err := requireAuth(); err != nil {
134+
return err
135+
}
136+
return patchClearance(cmd, args[0], url.Values{
137+
"status": {"denied"},
138+
}, "Screened out")
139+
}
140+
141+
func (sc *screenerCommand) markSpam(cmd *cobra.Command, args []string) error {
142+
if err := requireAuth(); err != nil {
143+
return err
144+
}
145+
return patchClearance(cmd, args[0], url.Values{
146+
"status": {"denied"},
147+
"spam": {"true"},
148+
}, "Marked as spam")
149+
}
150+
151+
func (sc *screenerCommand) feed(cmd *cobra.Command, args []string) error {
152+
if err := requireAuth(); err != nil {
153+
return err
154+
}
155+
156+
_, feedBoxID, _, err := fetchScreenerItems(cmd.Context())
157+
if err != nil {
158+
return err
159+
}
160+
if feedBoxID == "" {
161+
return fmt.Errorf("could not determine Feed box ID")
162+
}
163+
164+
return patchClearance(cmd, args[0], url.Values{
165+
"status": {"approved"},
166+
"designation_box_id": {feedBoxID},
167+
}, "Screened in to Feed")
168+
}
169+
170+
func (sc *screenerCommand) trail(cmd *cobra.Command, args []string) error {
171+
if err := requireAuth(); err != nil {
172+
return err
173+
}
174+
175+
_, _, trailBoxID, err := fetchScreenerItems(cmd.Context())
176+
if err != nil {
177+
return err
178+
}
179+
if trailBoxID == "" {
180+
return fmt.Errorf("could not determine Paper Trail box ID")
181+
}
182+
183+
return patchClearance(cmd, args[0], url.Values{
184+
"status": {"approved"},
185+
"designation_box_id": {trailBoxID},
186+
}, "Screened in to Paper Trail")
187+
}
188+
189+
// fetchScreenerItems parses the /clearances HTML page to extract pending items
190+
// and the feed/trail box IDs from form hidden inputs.
191+
func fetchScreenerItems(ctx context.Context) ([]screenerItem, string, string, error) {
192+
body, err := authenticatedGet(ctx, "/clearances")
193+
if err != nil {
194+
return nil, "", "", err
195+
}
196+
197+
var items []screenerItem
198+
var feedBoxID, trailBoxID string
199+
200+
// Extract emails as posting identifiers
201+
// Pattern: forms POSTing to /clearances/<id>
202+
postingIDs := regexp.MustCompile(`action="/clearances/(\d+)"`).FindAllStringSubmatch(body, -1)
203+
seen := map[string]bool{}
204+
var uniqueIDs []string
205+
for _, m := range postingIDs {
206+
if !seen[m[1]] {
207+
seen[m[1]] = true
208+
uniqueIDs = append(uniqueIDs, m[1])
209+
}
210+
}
211+
212+
// Extract emails
213+
emailRe := regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`)
214+
allEmails := emailRe.FindAllString(body, -1)
215+
216+
// Filter out the user's own emails (they appear in nav/header)
217+
senderEmails := []string{}
218+
for _, e := range allEmails {
219+
lower := strings.ToLower(e)
220+
if lower != "erik.dahl@hey.com" && lower != "erik@parrotapp.com" {
221+
senderEmails = append(senderEmails, e)
222+
}
223+
}
224+
225+
// Extract subjects
226+
subjectRe := regexp.MustCompile(`<span[^>]*>([^<]*(?:Re:|Fwd:|We'|Your |Hi |Hello|Thank|Welcome|Confirm|Order|Invoice|Receipt|Upgrade)[^<]*)</span>`)
227+
subjects := subjectRe.FindAllStringSubmatch(body, -1)
228+
var subjectTexts []string
229+
for _, s := range subjects {
230+
text := strings.TrimSpace(s[1])
231+
if text != "" {
232+
subjectTexts = append(subjectTexts, text)
233+
}
234+
}
235+
236+
// Extract feed/trail box IDs from hidden form inputs
237+
feedRe := regexp.MustCompile(`data-clearances-target="feedboxButton"[^<]*<input[^>]*name="designation_box_id"[^>]*value="(\d+)"`)
238+
if m := feedRe.FindStringSubmatch(body); m != nil {
239+
feedBoxID = m[1]
240+
}
241+
// Alternative: find feedbox button form
242+
if feedBoxID == "" {
243+
// Look for the pattern: feedboxButton form has designation_box_id
244+
feedForms := regexp.MustCompile(`feedboxButton.*?designation_box_id.*?value="(\d+)"`).FindStringSubmatch(body)
245+
if feedForms != nil {
246+
feedBoxID = feedForms[1]
247+
}
248+
}
249+
250+
trailRe := regexp.MustCompile(`data-clearances-target="trailboxButton"[^<]*<input[^>]*name="designation_box_id"[^>]*value="(\d+)"`)
251+
if m := trailRe.FindStringSubmatch(body); m != nil {
252+
trailBoxID = m[1]
253+
}
254+
if trailBoxID == "" {
255+
trailForms := regexp.MustCompile(`trailboxButton.*?designation_box_id.*?value="(\d+)"`).FindStringSubmatch(body)
256+
if trailForms != nil {
257+
trailBoxID = trailForms[1]
258+
}
259+
}
260+
261+
// Build items: match posting IDs with senders and subjects
262+
for i, id := range uniqueIDs {
263+
item := screenerItem{PostingID: id}
264+
if i < len(senderEmails) {
265+
item.Sender = senderEmails[i]
266+
}
267+
if i < len(subjectTexts) {
268+
item.Subject = subjectTexts[i]
269+
}
270+
items = append(items, item)
271+
}
272+
273+
return items, feedBoxID, trailBoxID, nil
274+
}
275+
276+
// patchClearance sends a PATCH request to /clearances/<id> with the given form values.
277+
func patchClearance(cmd *cobra.Command, postingID string, values url.Values, successMsg string) error {
278+
ctx := cmd.Context()
279+
values.Set("_method", "patch")
280+
281+
reqURL := cfg.BaseURL + "/clearances/" + postingID
282+
req, err := http.NewRequestWithContext(ctx, "PATCH", reqURL, strings.NewReader(values.Encode()))
283+
if err != nil {
284+
return err
285+
}
286+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
287+
288+
if err := authMgr.AuthenticateRequest(ctx, req); err != nil {
289+
return err
290+
}
291+
292+
client := &http.Client{
293+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
294+
return http.ErrUseLastResponse // Don't follow redirects
295+
},
296+
}
297+
resp, err := client.Do(req)
298+
if err != nil {
299+
return err
300+
}
301+
defer resp.Body.Close()
302+
303+
if resp.StatusCode != 302 && resp.StatusCode != 200 {
304+
bodyBytes, _ := io.ReadAll(resp.Body)
305+
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(bodyBytes))
306+
}
307+
308+
if writer.IsStyled() {
309+
fmt.Fprintf(cmd.OutOrStdout(), "%s (posting %s)\n", successMsg, postingID)
310+
return nil
311+
}
312+
313+
return writeOK(map[string]string{
314+
"posting_id": postingID,
315+
"action": successMsg,
316+
})
317+
}
318+
319+
// authenticatedGet makes an authenticated GET request and returns the response body as a string.
320+
func authenticatedGet(ctx context.Context, path string) (string, error) {
321+
reqURL := cfg.BaseURL + path
322+
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
323+
if err != nil {
324+
return "", err
325+
}
326+
req.Header.Set("Accept", "text/html")
327+
328+
if err := authMgr.AuthenticateRequest(ctx, req); err != nil {
329+
return "", err
330+
}
331+
332+
client := &http.Client{}
333+
resp, err := client.Do(req)
334+
if err != nil {
335+
return "", err
336+
}
337+
defer resp.Body.Close()
338+
339+
if resp.StatusCode != 200 {
340+
return "", fmt.Errorf("unexpected status %d for %s", resp.StatusCode, path)
341+
}
342+
343+
bodyBytes, err := io.ReadAll(resp.Body)
344+
if err != nil {
345+
return "", err
346+
}
347+
348+
return string(bodyBytes), nil
349+
}

0 commit comments

Comments
 (0)