]> git.giacomolli.uk - onechat/blobdiff - chatgpt.go
Make intentional error discards explicit
[onechat] / chatgpt.go
index ad40abd8d365627ff7d4720ebc394b4112f3857e..77481905822674f27c9bc4bd888933d046514c51 100644 (file)
@@ -133,55 +133,6 @@ func (a *ChatGPTAuth) Authorize(ctx context.Context, header http.Header) error {
        return nil
 }
 
        return nil
 }
 
-// Login runs the device code login. It writes the link and the code for the
-// user to out, waits until the user approves the code, and writes the tokens
-// to the token file.
-func (a *ChatGPTAuth) Login(ctx context.Context, out io.Writer) error {
-       a.mutex.Lock()
-       defer a.mutex.Unlock()
-
-       var code ChatGPTDeviceCode
-
-       err := a.postJSON(ctx, "/api/accounts/deviceauth/usercode", map[string]string{
-               "client_id": chatgptClientID,
-       }, &code)
-       if err != nil {
-               return fmt.Errorf("request device code (device code login must be allowed in the ChatGPT security settings): %w", err)
-       }
-
-       userCode := cmp.Or(code.UserCode, code.UserCodeAlt)
-       if code.DeviceAuthID == "" || userCode == "" {
-               return errors.New("device code response has no device ID or user code")
-       }
-
-       _, err = fmt.Fprintf(out, "Open %s/codex/device on any device and enter this code:\n\n    %s\n\nWaiting for the approval...\n", a.issuer, userCode)
-       if err != nil {
-               return fmt.Errorf("write device code: %w", err)
-       }
-
-       grant, err := a.poll(ctx, code.DeviceAuthID, userCode, chatgptPollInterval(code.Interval))
-       if err != nil {
-               return err
-       }
-
-       tokens, err := a.exchange(ctx, grant)
-       if err != nil {
-               return err
-       }
-
-       err = a.write(tokens)
-       if err != nil {
-               return err
-       }
-
-       _, err = fmt.Fprintln(out, "Logged in.")
-       if err != nil {
-               return fmt.Errorf("write login result: %w", err)
-       }
-
-       return nil
-}
-
 // read reads the token file.
 func (a *ChatGPTAuth) read() (ChatGPTTokenFile, error) {
        data, err := os.ReadFile(a.path)
 // read reads the token file.
 func (a *ChatGPTAuth) read() (ChatGPTTokenFile, error) {
        data, err := os.ReadFile(a.path)
@@ -252,6 +203,102 @@ func (a *ChatGPTAuth) postJSON(ctx context.Context, path string, body, result an
        return err
 }
 
        return err
 }
 
+// write replaces the token file atomically. The file has mode 0600.
+func (a *ChatGPTAuth) write(tokens ChatGPTTokenFile) error {
+       data, err := json.Marshal(tokens)
+       if err != nil {
+               return fmt.Errorf("encode tokens: %w", err)
+       }
+
+       err = writeFileAtomic(a.path, append(data, '\n'))
+       if err != nil {
+               return fmt.Errorf("write token file: %w", err)
+       }
+
+       return nil
+}
+
+// post posts body to the path of the login service and decodes a 200 answer
+// into result. It returns the HTTP status, or 0 when no answer came. The error
+// holds the status, but not the body, which can hold a token.
+func (a *ChatGPTAuth) post(ctx context.Context, path, contentType string, body []byte, result any) (status int, err error) {
+       request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.issuer+path, bytes.NewReader(body))
+       if err != nil {
+               return 0, fmt.Errorf("create request: %w", err)
+       }
+
+       request.Header.Set("Content-Type", contentType)
+
+       response, err := a.http.Do(request)
+       if err != nil {
+               return 0, fmt.Errorf("send request: %w", err)
+       }
+
+       defer func() {
+               err = errors.Join(err, response.Body.Close())
+       }()
+
+       if response.StatusCode != http.StatusOK {
+               return response.StatusCode, fmt.Errorf("HTTP %d", response.StatusCode)
+       }
+
+       err = json.NewDecoder(response.Body).Decode(result)
+       if err != nil {
+               return response.StatusCode, fmt.Errorf("decode answer: %w", err)
+       }
+
+       return response.StatusCode, nil
+}
+
+// Login runs the device code login. It writes the link and the code for the
+// user to out, waits until the user approves the code, and writes the tokens to
+// the token file.
+func (a *ChatGPTAuth) Login(ctx context.Context, out io.Writer) error {
+       a.mutex.Lock()
+       defer a.mutex.Unlock()
+
+       var code ChatGPTDeviceCode
+
+       err := a.postJSON(ctx, "/api/accounts/deviceauth/usercode", map[string]string{
+               "client_id": chatgptClientID,
+       }, &code)
+       if err != nil {
+               return fmt.Errorf("request device code (device code login must be allowed in the ChatGPT security settings): %w", err)
+       }
+
+       userCode := cmp.Or(code.UserCode, code.UserCodeAlt)
+       if code.DeviceAuthID == "" || userCode == "" {
+               return errors.New("device code response has no device ID or user code")
+       }
+
+       _, err = fmt.Fprintf(out, "Open %s/codex/device on any device and enter this code:\n\n    %s\n\nWaiting for the approval...\n", a.issuer, userCode)
+       if err != nil {
+               return fmt.Errorf("write device code: %w", err)
+       }
+
+       grant, err := a.poll(ctx, code.DeviceAuthID, userCode, chatgptPollInterval(code.Interval))
+       if err != nil {
+               return err
+       }
+
+       tokens, err := a.exchange(ctx, grant)
+       if err != nil {
+               return err
+       }
+
+       err = a.write(tokens)
+       if err != nil {
+               return err
+       }
+
+       _, err = fmt.Fprintln(out, "Logged in.")
+       if err != nil {
+               return fmt.Errorf("write login result: %w", err)
+       }
+
+       return nil
+}
+
 // poll asks for the grant of the device code until the user approves it or the
 // login times out.
 func (a *ChatGPTAuth) poll(ctx context.Context, deviceID, userCode string, interval time.Duration) (ChatGPTDeviceGrant, error) {
 // poll asks for the grant of the device code until the user approves it or the
 // login times out.
 func (a *ChatGPTAuth) poll(ctx context.Context, deviceID, userCode string, interval time.Duration) (ChatGPTDeviceGrant, error) {
@@ -328,74 +375,6 @@ func (a *ChatGPTAuth) exchange(ctx context.Context, grant ChatGPTDeviceGrant) (C
        return tokens, nil
 }
 
        return tokens, nil
 }
 
-// write replaces the token file atomically. The file has mode 0600.
-func (a *ChatGPTAuth) write(tokens ChatGPTTokenFile) error {
-       data, err := json.Marshal(tokens)
-       if err != nil {
-               return fmt.Errorf("encode tokens: %w", err)
-       }
-
-       err = writeFileAtomic(a.path, append(data, '\n'))
-       if err != nil {
-               return fmt.Errorf("write token file: %w", err)
-       }
-
-       return nil
-}
-
-// post posts body to the path of the login service and decodes a 200 answer
-// into result. It returns the HTTP status, or 0 when no answer came. The error
-// holds the status, but not the body, which can hold a token.
-func (a *ChatGPTAuth) post(ctx context.Context, path, contentType string, body []byte, result any) (status int, err error) {
-       request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.issuer+path, bytes.NewReader(body))
-       if err != nil {
-               return 0, fmt.Errorf("create request: %w", err)
-       }
-
-       request.Header.Set("Content-Type", contentType)
-
-       response, err := a.http.Do(request)
-       if err != nil {
-               return 0, fmt.Errorf("send request: %w", err)
-       }
-
-       defer func() {
-               err = errors.Join(err, response.Body.Close())
-       }()
-
-       if response.StatusCode != http.StatusOK {
-               return response.StatusCode, fmt.Errorf("HTTP %d", response.StatusCode)
-       }
-
-       err = json.NewDecoder(response.Body).Decode(result)
-       if err != nil {
-               return response.StatusCode, fmt.Errorf("decode answer: %w", err)
-       }
-
-       return response.StatusCode, nil
-}
-
-// chatgptLogin runs "onechat login": the device code login into the token file
-// of the data directory. It holds the lock of the token file, so it does not
-// run while the daemon runs.
-func chatgptLogin(ctx context.Context, out io.Writer) (err error) {
-       dir, err := dataDir()
-       if err != nil {
-               return err
-       }
-
-       tokens, err := lockTokens(dir)
-       if err != nil {
-               return err
-       }
-
-       defer func() {
-               err = errors.Join(err, tokens.Close())
-       }()
-
-       return newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer).Login(ctx, out)
-}
-
 // chatgptTokenExpiry returns the expiry of a JWT from its exp claim.
 func chatgptTokenExpiry(token string) (time.Time, bool) {
        var claims struct {
 // chatgptTokenExpiry returns the expiry of a JWT from its exp claim.
 func chatgptTokenExpiry(token string) (time.Time, bool) {
        var claims struct {
@@ -409,17 +388,6 @@ func chatgptTokenExpiry(token string) (time.Time, bool) {
        return time.Unix(int64(*claims.Exp), 0), true
 }
 
        return time.Unix(int64(*claims.Exp), 0), true
 }
 
-// chatgptPollInterval returns the poll interval of a device code, given in
-// seconds as a number or a string.
-func chatgptPollInterval(raw json.RawMessage) time.Duration {
-       seconds, err := strconv.ParseFloat(strings.Trim(string(raw), `"`), 64)
-       if err != nil || seconds < 0 {
-               return chatgptDefaultPollInterval
-       }
-
-       return time.Duration(seconds * float64(time.Second))
-}
-
 // chatgptAccountID returns the ChatGPT account ID of a JWT, or "" when it has
 // none.
 func chatgptAccountID(token string) string {
 // chatgptAccountID returns the ChatGPT account ID of a JWT, or "" when it has
 // none.
 func chatgptAccountID(token string) string {
@@ -441,6 +409,17 @@ func chatgptAccountID(token string) string {
        return auth.AccountID
 }
 
        return auth.AccountID
 }
 
+// chatgptPollInterval returns the poll interval of a device code, given in
+// seconds as a number or a string.
+func chatgptPollInterval(raw json.RawMessage) time.Duration {
+       seconds, err := strconv.ParseFloat(strings.Trim(string(raw), `"`), 64)
+       if err != nil || seconds < 0 {
+               return chatgptDefaultPollInterval
+       }
+
+       return time.Duration(seconds * float64(time.Second))
+}
+
 // chatgptDecodeClaims decodes the payload of a JWT into claims, without a check
 // of the signature. The token comes from the login service over HTTPS, and the
 // client reads only the expiry and the account ID from it.
 // chatgptDecodeClaims decodes the payload of a JWT into claims, without a check
 // of the signature. The token comes from the login service over HTTPS, and the
 // client reads only the expiry and the account ID from it.
@@ -457,3 +436,24 @@ func chatgptDecodeClaims(token string, claims any) bool {
 
        return json.Unmarshal(payload, claims) == nil
 }
 
        return json.Unmarshal(payload, claims) == nil
 }
+
+// chatgptLogin runs "onechat login": the device code login into the token file
+// of the data directory. It holds the lock of the token file, so it does not
+// run while the daemon runs.
+func chatgptLogin(ctx context.Context, out io.Writer) (err error) {
+       dir, err := dataDir()
+       if err != nil {
+               return err
+       }
+
+       tokens, err := lockTokens(dir)
+       if err != nil {
+               return err
+       }
+
+       defer func() {
+               err = errors.Join(err, tokens.Close())
+       }()
+
+       return newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer).Login(ctx, out)
+}