Neale Pickett
·
2023-04-11
providercommand.go
1// Provides a Puzzle interface that runs a command for each request
2package main
3
4import (
5 "bytes"
6 "context"
7 "encoding/json"
8 "fmt"
9 "io"
10 "log"
11 "os"
12 "os/exec"
13 "sort"
14 "strconv"
15 "strings"
16 "time"
17
18 "github.com/dirtbags/moth/v4/pkg/transpile"
19)
20
21// ProviderCommand specifies a command to run for the puzzle API
22type ProviderCommand struct {
23 Path string
24 Args []string
25}
26
27// Inventory runs with "action=inventory", and parses the output into a category list.
28func (pc ProviderCommand) Inventory() (inv []Category) {
29 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
30 defer cancel()
31
32 cmd := exec.CommandContext(ctx, pc.Path, pc.Args...)
33 cmd.Env = os.Environ()
34 cmd.Env = append(cmd.Env, "ACTION=inventory")
35
36 stdout, err := cmd.Output()
37 if err != nil {
38 log.Print(err)
39 return
40 }
41
42 for _, line := range strings.Split(string(stdout), "\n") {
43 line = strings.TrimSpace(line)
44 if line == "" {
45 continue
46 }
47 parts := strings.Split(line, " ")
48 if len(parts) < 2 {
49 log.Println("Skipping misformatted line:", line)
50 continue
51 }
52 name := parts[0]
53 puzzles := make([]int, 0, 10)
54 for _, pointsString := range parts[1:] {
55 points, err := strconv.Atoi(pointsString)
56 if err != nil {
57 log.Println(err)
58 continue
59 }
60 puzzles = append(puzzles, points)
61 }
62 sort.Ints(puzzles)
63 inv = append(inv, Category{name, puzzles})
64 }
65 return
66}
67
68// NullReadSeekCloser wraps a no-op Close method around an io.ReadSeeker.
69type NullReadSeekCloser struct {
70 io.ReadSeeker
71}
72
73// Close does nothing.
74func (f NullReadSeekCloser) Close() error {
75 return nil
76}
77
78// Open passes its arguments to the command with "action=open".
79func (pc ProviderCommand) Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error) {
80 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
81 defer cancel()
82
83 cmd := exec.CommandContext(ctx, pc.Path, pc.Args...)
84 cmd.Env = os.Environ()
85 cmd.Env = append(cmd.Env, "ACTION=open")
86 cmd.Env = append(cmd.Env, "CAT="+cat)
87 cmd.Env = append(cmd.Env, "POINTS="+strconv.Itoa(points))
88 cmd.Env = append(cmd.Env, "FILENAME="+path)
89
90 stdoutBytes, err := cmd.Output()
91 stdout := NullReadSeekCloser{bytes.NewReader(stdoutBytes)}
92 now := time.Now()
93 return stdout, now, err
94}
95
96// CheckAnswer passes its arguments to the command with "action=answer".
97// If the command exits successfully and sends "correct" to stdout,
98// nil is returned.
99func (pc ProviderCommand) CheckAnswer(cat string, points int, answer string) (bool, error) {
100 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
101 defer cancel()
102
103 cmd := exec.CommandContext(ctx, pc.Path, pc.Args...)
104 cmd.Env = os.Environ()
105 cmd.Env = append(cmd.Env, "ACTION=answer")
106 cmd.Env = append(cmd.Env, "CAT="+cat)
107 cmd.Env = append(cmd.Env, "POINTS="+strconv.Itoa(points))
108 cmd.Env = append(cmd.Env, "ANSWER="+answer)
109
110 stdout, err := cmd.Output()
111 if ee, ok := err.(*exec.ExitError); ok {
112 log.Printf("WARNING: %s: %s", pc.Path, string(ee.Stderr))
113 return false, err
114 } else if err != nil {
115 return false, err
116 }
117
118 ans := transpile.AnswerResponse{}
119 if err := json.Unmarshal(stdout, &ans); err != nil {
120 return false, err
121 }
122
123 return ans.Correct, nil
124}
125
126// Mothball just returns an error
127func (pc ProviderCommand) Mothball(cat string) (*bytes.Reader, error) {
128 return nil, fmt.Errorf("can't package a command-generated category")
129}
130
131// Maintain does nothing: a command puzzle ProviderCommand has no housekeeping
132func (pc ProviderCommand) Maintain(updateInterval time.Duration) {
133}