mirror of
https://github.com/tiennm99/claude-code-routine-cron.git
synced 2026-05-19 17:28:39 +00:00
119 lines
3.1 KiB
Go
119 lines
3.1 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"text/template"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
headerVersion = "2023-06-01"
|
|
headerBeta = "experimental-cc-routine-2026-04-01"
|
|
)
|
|
|
|
// FireClient POSTs to the Anthropic /fire endpoint with the headers and body
|
|
// shape required by the Claude Code routines beta. Each call creates a new
|
|
// session; the daemon never retries on failure.
|
|
type FireClient struct {
|
|
URL string
|
|
Token string
|
|
HTTP *http.Client
|
|
Template *template.Template
|
|
TZ *time.Location
|
|
Log *slog.Logger
|
|
NowFunc func() time.Time // overridable for tests; defaults to time.Now
|
|
}
|
|
|
|
type fireRequest struct {
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
type fireResponse struct {
|
|
Type string `json:"type"`
|
|
ClaudeCodeSessionID string `json:"claude_code_session_id"`
|
|
ClaudeCodeSessionURL string `json:"claude_code_session_url"`
|
|
}
|
|
|
|
// Fire renders the text template, POSTs to the routine /fire endpoint, and
|
|
// logs the outcome. Returns nil on transport-level success regardless of HTTP
|
|
// status — non-2xx is logged at error level so the daemon stays up.
|
|
// Returns a non-nil error only for unrecoverable client-side problems
|
|
// (template render failure or request build failure).
|
|
func (c *FireClient) Fire(ctx context.Context, cronExpr string) error {
|
|
now := c.now()
|
|
text, err := c.renderText(now, cronExpr)
|
|
if err != nil {
|
|
return fmt.Errorf("render text: %w", err)
|
|
}
|
|
|
|
body, err := json.Marshal(fireRequest{Text: text})
|
|
if err != nil {
|
|
return fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("build request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
|
req.Header.Set("anthropic-version", headerVersion)
|
|
req.Header.Set("anthropic-beta", headerBeta)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
c.Log.Error("fire request failed", "cron", cronExpr, "err", err.Error())
|
|
return nil
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
c.Log.Error("fire non-2xx response",
|
|
"cron", cronExpr,
|
|
"status", resp.StatusCode,
|
|
"body", string(respBody))
|
|
return nil
|
|
}
|
|
|
|
var fr fireResponse
|
|
if err := json.Unmarshal(respBody, &fr); err != nil {
|
|
c.Log.Warn("fire 2xx but body unparseable",
|
|
"cron", cronExpr,
|
|
"status", resp.StatusCode,
|
|
"body", string(respBody))
|
|
return nil
|
|
}
|
|
c.Log.Info("fire ok",
|
|
"cron", cronExpr,
|
|
"session_url", fr.ClaudeCodeSessionURL,
|
|
"session_id", fr.ClaudeCodeSessionID)
|
|
return nil
|
|
}
|
|
|
|
func (c *FireClient) now() time.Time {
|
|
if c.NowFunc != nil {
|
|
return c.NowFunc()
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
func (c *FireClient) renderText(now time.Time, cronExpr string) (string, error) {
|
|
data := map[string]any{
|
|
"Now": now,
|
|
"LocalTime": now.In(c.TZ).Format("2006-01-02 15:04 MST"),
|
|
"Cron": cronExpr,
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := c.Template.Execute(&buf, data); err != nil {
|
|
return "", err
|
|
}
|
|
return buf.String(), nil
|
|
}
|