Initial commit: email-openclaw bridge v1
Full pipeline: IMAP ingress -> OpenClaw dispatch -> callback -> SMTP reply. SQLite stateful storage with idempotency, threading, and retry logic. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
package ai_client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"thuanle.me/claw-email-bridge/internal/config"
|
||||
"thuanle.me/claw-email-bridge/internal/database"
|
||||
"thuanle.me/claw-email-bridge/internal/logging"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Retry backoff schedule: 1s, 5s, 15s.
|
||||
var retryBackoffs = []time.Duration{1 * time.Second, 5 * time.Second, 15 * time.Second}
|
||||
|
||||
// openClawRequest is the payload sent to OpenClaw API.
|
||||
type openClawRequest struct {
|
||||
Input string `json:"input"`
|
||||
SessionID string `json:"session_id"`
|
||||
History []historyEntry `json:"history,omitempty"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
type historyEntry struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// Dispatcher sends tasks to OpenClaw and handles retry logic.
|
||||
type Dispatcher struct {
|
||||
cfg *config.Config
|
||||
db *gorm.DB
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewDispatcher creates a dispatcher with a direct HTTP client (no system proxy).
|
||||
func NewDispatcher(cfg *config.Config, db *gorm.DB) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil, // Direct connection, no proxy inheritance.
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch sends a task to OpenClaw with retry.
|
||||
// This should be called asynchronously (go dispatcher.Dispatch(task)).
|
||||
func (d *Dispatcher) Dispatch(task *database.Task) {
|
||||
log := logging.TaskLogger(task.TaskUUID, task.ThreadID, task.MessageID)
|
||||
|
||||
// Build history from completed tasks in the same thread.
|
||||
history, err := d.buildHistory(task.ThreadID)
|
||||
if err != nil {
|
||||
log.Error("openclaw: failed to build history", "error", err)
|
||||
// Continue without history — non-fatal.
|
||||
}
|
||||
|
||||
// Build callback URL.
|
||||
callbackURL := fmt.Sprintf("http://%s/callback", d.cfg.ListenAddr)
|
||||
|
||||
payload := openClawRequest{
|
||||
Input: task.BodyPlain,
|
||||
SessionID: task.ThreadID,
|
||||
History: history,
|
||||
CallbackURL: callbackURL,
|
||||
Metadata: map[string]string{"task_uuid": task.TaskUUID},
|
||||
}
|
||||
|
||||
// Update status to AI_PROCESSING before first attempt.
|
||||
task.Status = database.StatusAIProcessing
|
||||
if err := database.UpdateTask(d.db, task); err != nil {
|
||||
log.Error("openclaw: failed to update status to AI_PROCESSING", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Retry loop: up to 3 attempts.
|
||||
for attempt := 1; attempt <= len(retryBackoffs); attempt++ {
|
||||
task.AttemptOpenClaw = attempt
|
||||
|
||||
log.Info("openclaw: dispatching",
|
||||
"attempt", attempt,
|
||||
"status", task.Status,
|
||||
)
|
||||
|
||||
err := d.callAPI(payload)
|
||||
if err == nil {
|
||||
// Success — task stays AI_PROCESSING, waiting for callback.
|
||||
log.Info("openclaw: dispatch successful", "attempt", attempt)
|
||||
if err := database.UpdateTask(d.db, task); err != nil {
|
||||
log.Error("openclaw: failed to save attempt count", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn("openclaw: dispatch failed",
|
||||
"attempt", attempt,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
// Last attempt — mark as FAILED.
|
||||
if attempt == len(retryBackoffs) {
|
||||
errMsg := err.Error()
|
||||
task.Status = database.StatusFailed
|
||||
task.LastError = &errMsg
|
||||
if err := database.UpdateTask(d.db, task); err != nil {
|
||||
log.Error("openclaw: failed to save FAILED status", "error", err)
|
||||
}
|
||||
log.Error("openclaw: all retries exhausted",
|
||||
"attempt", attempt,
|
||||
"status", database.StatusFailed,
|
||||
"error", errMsg,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Wait before retry.
|
||||
backoff := retryBackoffs[attempt-1]
|
||||
log.Info("openclaw: retrying after backoff", "backoff", backoff)
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
}
|
||||
|
||||
// callAPI makes a single HTTP POST to OpenClaw.
|
||||
func (d *Dispatcher) callAPI(payload openClawRequest) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, d.cfg.OpenClawURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if d.cfg.OpenClawAPIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+d.cfg.OpenClawAPIKey)
|
||||
}
|
||||
|
||||
resp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read response body for error details (limit to 1KB).
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return fmt.Errorf("openclaw returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// buildHistory fetches completed tasks in the thread and formats them as history entries.
|
||||
func (d *Dispatcher) buildHistory(threadID string) ([]historyEntry, error) {
|
||||
tasks, err := database.ThreadHistory(d.db, threadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var history []historyEntry
|
||||
for _, t := range tasks {
|
||||
// User message.
|
||||
history = append(history, historyEntry{
|
||||
Role: "user",
|
||||
Content: t.BodyPlain,
|
||||
})
|
||||
// AI response.
|
||||
if t.AIResponse != "" {
|
||||
history = append(history, historyEntry{
|
||||
Role: "assistant",
|
||||
Content: t.AIResponse,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return history, nil
|
||||
}
|
||||
Reference in New Issue
Block a user