lexicon devex tutorial
1package main
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "os"
9
10 _ "github.com/joho/godotenv/autoload"
11
12 "tangled.org/bnewbold.net/atwork-cli/placeatwork"
13
14 "github.com/bluesky-social/indigo/api/agnostic"
15 comatproto "github.com/bluesky-social/indigo/api/atproto"
16 appbsky "github.com/bluesky-social/indigo/api/bsky"
17 "github.com/bluesky-social/indigo/atproto/atclient"
18 "github.com/bluesky-social/indigo/atproto/identity"
19 "github.com/bluesky-social/indigo/atproto/syntax"
20 "github.com/urfave/cli/v3"
21)
22
23func main() {
24 app := cli.Command{
25 Name: "atwork-cli",
26 Usage: "example CLI project for at://work lexicons",
27 Commands: []*cli.Command{
28 &cli.Command{
29 Name: "search",
30 Usage: "queries public API for listings",
31 ArgsUsage: "<query>",
32 Flags: []cli.Flag{
33 &cli.StringFlag{
34 Name: "host",
35 Value: "https://atwork.place",
36 },
37 },
38 Action: runSearch,
39 },
40 &cli.Command{
41 Name: "import-bsky-profile",
42 Usage: "creates a new atwork profile using Bluesky profile data",
43 Flags: []cli.Flag{
44 &cli.StringFlag{
45 Name: "username",
46 Sources: cli.EnvVars("ATP_USERNAME"),
47 },
48 &cli.StringFlag{
49 Name: "password",
50 Sources: cli.EnvVars("ATP_PASSWORD"),
51 },
52 },
53 Action: runImportProfile,
54 },
55 &cli.Command{
56 Name: "upload-resume",
57 Usage: "uploads resume PDF and adds to atwork profile",
58 ArgsUsage: "<pdf-path>",
59 Flags: []cli.Flag{
60 &cli.StringFlag{
61 Name: "username",
62 Sources: cli.EnvVars("ATP_USERNAME"),
63 },
64 &cli.StringFlag{
65 Name: "password",
66 Sources: cli.EnvVars("ATP_PASSWORD"),
67 },
68 },
69 Action: runUploadResume,
70 },
71 },
72 }
73
74 if err := app.Run(context.Background(), os.Args); err != nil {
75 fmt.Fprintf(os.Stderr, "error: %v\n", err)
76 os.Exit(1)
77 }
78}
79
80func runSearch(ctx context.Context, cmd *cli.Command) error {
81 if !cmd.Args().Present() {
82 return fmt.Errorf("requires a search query")
83 }
84 searchQuery := cmd.Args().First()
85
86 c := atclient.NewAPIClient(cmd.String("host"))
87
88 resp, err := placeatwork.SearchListings(ctx, c, searchQuery)
89 if err != nil {
90 return err
91 }
92
93 for _, hit := range resp.Listings {
94 if hit.Value == nil {
95 continue
96 }
97 listing := *hit.Value
98 aturi, err := syntax.ParseATURI(hit.Uri)
99 if err != nil {
100 continue
101 }
102 fmt.Printf("%s: %s\n", aturi.Authority(), listing.Title)
103 if listing.ApplyLink != nil {
104 fmt.Printf("\tApply: %s\n", *listing.ApplyLink)
105 }
106 }
107
108 return nil
109}
110
111func runImportProfile(ctx context.Context, cmd *cli.Command) error {
112
113 if !cmd.IsSet("username") || !cmd.IsSet("password") {
114 return fmt.Errorf("authentication required (username and password)")
115 }
116
117 username, err := syntax.ParseAtIdentifier(cmd.String("username"))
118 if err != nil {
119 return fmt.Errorf("invalid username: %w", err)
120 }
121 dir := identity.DefaultDirectory()
122
123 c, err := atclient.LoginWithPassword(ctx, dir, *username, cmd.String("password"), "", nil)
124 if err != nil {
125 return err
126 }
127
128 // fetch existing bsky profile
129 resp, err := agnostic.RepoGetRecord(ctx, c, "", "app.bsky.actor.profile", c.AccountDID.String(), "self")
130 if err != nil {
131 return fmt.Errorf("failed fetching bsky profile record: %s", err)
132 }
133
134 var bskyProfile appbsky.ActorProfile
135 if err := json.Unmarshal(*resp.Value, &bskyProfile); err != nil {
136 return err
137 }
138
139 // create new atwork profile record
140 atworkProfile := placeatwork.Profile{
141 LexiconTypeID: "place.atwork.profile",
142 Avatar: bskyProfile.Avatar,
143 Banner: bskyProfile.Banner,
144 Description: bskyProfile.Description,
145 DisplayName: bskyProfile.DisplayName,
146 }
147
148 // create profile record (fails if one already exists)
149 if err := c.Post(ctx, syntax.NSID("com.atproto.repo.createRecord"), map[string]any{
150 "repo": c.AccountDID,
151 "collection": atworkProfile.LexiconTypeID,
152 "rkey": "self",
153 "record": atworkProfile,
154 }, nil); err != nil {
155 return err
156 }
157
158 return nil
159}
160
161func runUploadResume(ctx context.Context, cmd *cli.Command) error {
162
163 if !cmd.Args().Present() {
164 return fmt.Errorf("requires resume PDF path as argument")
165 }
166 resumePath := cmd.Args().First()
167
168 if !cmd.IsSet("username") || !cmd.IsSet("password") {
169 return fmt.Errorf("authentication required (username and password)")
170 }
171
172 username, err := syntax.ParseAtIdentifier(cmd.String("username"))
173 if err != nil {
174 return fmt.Errorf("invalid username: %w", err)
175 }
176 dir := identity.DefaultDirectory()
177
178 c, err := atclient.LoginWithPassword(ctx, dir, *username, cmd.String("password"), "", nil)
179 if err != nil {
180 return err
181 }
182
183 // fetch existing at://work profile
184 getResp, err := agnostic.RepoGetRecord(ctx, c, "", "place.atwork.profile", c.AccountDID.String(), "self")
185 if err != nil {
186 return fmt.Errorf("failed fetching profile record: %s", err)
187 }
188
189 var atworkProfile placeatwork.Profile
190 if err := json.Unmarshal(*getResp.Value, &atworkProfile); err != nil {
191 return err
192 }
193
194 // upload resume PDF as blob
195 resumeFile, err := os.Open(resumePath)
196 if err != nil {
197 return err
198 }
199 defer resumeFile.Close()
200
201 // update record
202 req := atclient.NewAPIRequest(http.MethodPost, syntax.NSID("com.atproto.repo.uploadBlob"), resumeFile)
203 req.Headers.Set("Accept", "application/json")
204 req.Headers.Set("Content-Type", "application/pdf")
205
206 resp, err := c.Do(ctx, req)
207 if err != nil {
208 return err
209 }
210 defer resp.Body.Close()
211
212 if !(resp.StatusCode >= 200 && resp.StatusCode < 300) {
213 return fmt.Errorf("blob upload failed")
214 }
215
216 var blobResp comatproto.RepoUploadBlob_Output
217 if err := json.NewDecoder(resp.Body).Decode(&blobResp); err != nil {
218 return fmt.Errorf("failed decoding JSON response body: %w", err)
219 }
220
221 // update profile with blob ref
222 atworkProfile.Resume = blobResp.Blob
223 if err := c.Post(ctx, syntax.NSID("com.atproto.repo.putRecord"), map[string]any{
224 "repo": c.AccountDID,
225 "collection": atworkProfile.LexiconTypeID,
226 "rkey": "self",
227 "record": atworkProfile,
228 }, nil); err != nil {
229 return err
230 }
231
232 return nil
233}