1066 lines
26 KiB
Go
1066 lines
26 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ============================================
|
|
// TASKS
|
|
// ============================================
|
|
|
|
type Task struct {
|
|
ID string `json:"id"`
|
|
Text string `json:"text,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
Priority string `json:"priority"`
|
|
Status string `json:"status,omitempty"`
|
|
Owner string `json:"owner,omitempty"` // "johan" or "james"
|
|
Domain string `json:"domain,omitempty"` // Kaseya, inou, Sophia, ClawdNode, Infrastructure, etc.
|
|
Notes string `json:"notes,omitempty"`
|
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
type TaskStore struct {
|
|
Tasks []Task `json:"tasks"`
|
|
mu sync.RWMutex
|
|
path string
|
|
}
|
|
|
|
func NewTaskStore(path string) (*TaskStore, error) {
|
|
store := &TaskStore{path: path, Tasks: []Task{}}
|
|
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return store, store.save()
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var wrapper struct {
|
|
Tasks []Task `json:"tasks"`
|
|
}
|
|
if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Tasks != nil {
|
|
store.Tasks = wrapper.Tasks
|
|
return store, nil
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &store.Tasks); err != nil {
|
|
store.Tasks = []Task{}
|
|
return store, store.save()
|
|
}
|
|
|
|
return store, nil
|
|
}
|
|
|
|
func (s *TaskStore) save() error {
|
|
wrapper := struct {
|
|
Tasks []Task `json:"tasks"`
|
|
}{Tasks: s.Tasks}
|
|
data, err := json.MarshalIndent(wrapper, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(s.path, data, 0644)
|
|
}
|
|
|
|
func (s *TaskStore) Add(task Task) (Task, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
task.ID = uuid.New().String()[:8]
|
|
task.CreatedAt = time.Now()
|
|
if task.Priority == "" {
|
|
task.Priority = "medium"
|
|
}
|
|
if task.Status == "" {
|
|
task.Status = "pending"
|
|
}
|
|
if task.Owner == "" {
|
|
task.Owner = "james"
|
|
}
|
|
|
|
s.Tasks = append(s.Tasks, task)
|
|
return task, s.save()
|
|
}
|
|
|
|
func (s *TaskStore) Update(id string, updates Task) (Task, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, task := range s.Tasks {
|
|
if task.ID == id {
|
|
if updates.Title != "" {
|
|
s.Tasks[i].Title = updates.Title
|
|
}
|
|
if updates.Text != "" {
|
|
s.Tasks[i].Text = updates.Text
|
|
}
|
|
if updates.Priority != "" {
|
|
s.Tasks[i].Priority = updates.Priority
|
|
}
|
|
if updates.Status != "" {
|
|
s.Tasks[i].Status = updates.Status
|
|
}
|
|
if updates.Owner != "" {
|
|
s.Tasks[i].Owner = updates.Owner
|
|
}
|
|
if updates.Domain != "" {
|
|
s.Tasks[i].Domain = updates.Domain
|
|
}
|
|
if updates.Notes != "" {
|
|
s.Tasks[i].Notes = updates.Notes
|
|
}
|
|
s.Tasks[i].Updated = time.Now().Format(time.RFC3339)
|
|
s.save()
|
|
return s.Tasks[i], true
|
|
}
|
|
}
|
|
return Task{}, false
|
|
}
|
|
|
|
func (s *TaskStore) List() []Task {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.Tasks
|
|
}
|
|
|
|
func (s *TaskStore) Delete(id string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, task := range s.Tasks {
|
|
if task.ID == id {
|
|
s.Tasks = append(s.Tasks[:i], s.Tasks[i+1:]...)
|
|
s.save()
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ============================================
|
|
// NEWS
|
|
// ============================================
|
|
|
|
type NewsItem struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Type string `json:"type"` // info, success, warning, error
|
|
Source string `json:"source,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
}
|
|
|
|
type NewsStore struct {
|
|
Items []NewsItem `json:"items"`
|
|
mu sync.RWMutex
|
|
path string
|
|
}
|
|
|
|
func NewNewsStore(path string) (*NewsStore, error) {
|
|
store := &NewsStore{path: path, Items: []NewsItem{}}
|
|
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return store, store.save()
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var wrapper struct {
|
|
Items []NewsItem `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Items != nil {
|
|
store.Items = wrapper.Items
|
|
return store, nil
|
|
}
|
|
|
|
return store, store.save()
|
|
}
|
|
|
|
func (s *NewsStore) save() error {
|
|
wrapper := struct {
|
|
Items []NewsItem `json:"items"`
|
|
}{Items: s.Items}
|
|
data, err := json.MarshalIndent(wrapper, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(s.path, data, 0644)
|
|
}
|
|
|
|
func (s *NewsStore) Add(item NewsItem) (NewsItem, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
item.ID = uuid.New().String()[:8]
|
|
item.Timestamp = time.Now()
|
|
if item.Type == "" {
|
|
item.Type = "info"
|
|
}
|
|
|
|
// Prepend (newest first)
|
|
s.Items = append([]NewsItem{item}, s.Items...)
|
|
|
|
// Keep only last 50 items
|
|
if len(s.Items) > 50 {
|
|
s.Items = s.Items[:50]
|
|
}
|
|
|
|
return item, s.save()
|
|
}
|
|
|
|
func (s *NewsStore) List(limit int) []NewsItem {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if limit > 0 && limit < len(s.Items) {
|
|
return s.Items[:limit]
|
|
}
|
|
return s.Items
|
|
}
|
|
|
|
func (s *NewsStore) Delete(id string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, item := range s.Items {
|
|
if item.ID == id {
|
|
s.Items = append(s.Items[:i], s.Items[i+1:]...)
|
|
s.save()
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *NewsStore) Clear() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.Items = []NewsItem{}
|
|
return s.save()
|
|
}
|
|
|
|
// ============================================
|
|
// BRIEFINGS
|
|
// ============================================
|
|
|
|
type Briefing struct {
|
|
ID string `json:"id"`
|
|
Date string `json:"date"`
|
|
Title string `json:"title"`
|
|
Weather string `json:"weather,omitempty"`
|
|
Markets string `json:"markets,omitempty"`
|
|
News string `json:"news,omitempty"`
|
|
Tasks string `json:"tasks,omitempty"`
|
|
Summary string `json:"summary,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type BriefingStore struct {
|
|
Briefings []Briefing `json:"briefings"`
|
|
mu sync.RWMutex
|
|
path string
|
|
}
|
|
|
|
func NewBriefingStore(path string) (*BriefingStore, error) {
|
|
store := &BriefingStore{path: path, Briefings: []Briefing{}}
|
|
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return store, store.save()
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var wrapper struct {
|
|
Briefings []Briefing `json:"briefings"`
|
|
}
|
|
if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Briefings != nil {
|
|
store.Briefings = wrapper.Briefings
|
|
return store, nil
|
|
}
|
|
|
|
return store, store.save()
|
|
}
|
|
|
|
func (s *BriefingStore) save() error {
|
|
wrapper := struct {
|
|
Briefings []Briefing `json:"briefings"`
|
|
}{Briefings: s.Briefings}
|
|
data, err := json.MarshalIndent(wrapper, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(s.path, data, 0644)
|
|
}
|
|
|
|
func (s *BriefingStore) Add(b Briefing) (Briefing, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
b.ID = uuid.New().String()[:8]
|
|
b.CreatedAt = time.Now()
|
|
if b.Date == "" {
|
|
b.Date = time.Now().Format("2006-01-02")
|
|
}
|
|
if b.Title == "" {
|
|
b.Title = "Morning Brief"
|
|
}
|
|
|
|
s.Briefings = append(s.Briefings, b)
|
|
return b, s.save()
|
|
}
|
|
|
|
func (s *BriefingStore) List(limit int) []Briefing {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
// Sort by date descending
|
|
sorted := make([]Briefing, len(s.Briefings))
|
|
copy(sorted, s.Briefings)
|
|
sort.Slice(sorted, func(i, j int) bool {
|
|
return sorted[i].CreatedAt.After(sorted[j].CreatedAt)
|
|
})
|
|
|
|
if limit > 0 && limit < len(sorted) {
|
|
return sorted[:limit]
|
|
}
|
|
return sorted
|
|
}
|
|
|
|
func (s *BriefingStore) Get(id string) (Briefing, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, b := range s.Briefings {
|
|
if b.ID == id {
|
|
return b, true
|
|
}
|
|
}
|
|
return Briefing{}, false
|
|
}
|
|
|
|
func (s *BriefingStore) Delete(id string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, b := range s.Briefings {
|
|
if b.ID == id {
|
|
s.Briefings = append(s.Briefings[:i], s.Briefings[i+1:]...)
|
|
s.save()
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ============================================
|
|
// DELIVERIES
|
|
// ============================================
|
|
|
|
type Delivery struct {
|
|
ID string `json:"id"`
|
|
Carrier string `json:"carrier,omitempty"` // UPS, FedEx, USPS, etc.
|
|
Retailer string `json:"retailer,omitempty"` // Amazon, Nordstrom, etc.
|
|
Description string `json:"description"` // What's in the package
|
|
TrackingNumber string `json:"tracking_number,omitempty"`
|
|
TrackingURL string `json:"tracking_url,omitempty"`
|
|
ExpectedDate string `json:"expected_date,omitempty"` // "2026-02-03" or "Monday"
|
|
Status string `json:"status"` // shipped, in_transit, out_for_delivery, delayed, delivered
|
|
Notes string `json:"notes,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type DeliveryStore struct {
|
|
Deliveries []Delivery `json:"deliveries"`
|
|
mu sync.RWMutex
|
|
path string
|
|
}
|
|
|
|
func NewDeliveryStore(path string) (*DeliveryStore, error) {
|
|
store := &DeliveryStore{path: path, Deliveries: []Delivery{}}
|
|
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return store, store.save()
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var wrapper struct {
|
|
Deliveries []Delivery `json:"deliveries"`
|
|
}
|
|
if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Deliveries != nil {
|
|
store.Deliveries = wrapper.Deliveries
|
|
return store, nil
|
|
}
|
|
|
|
return store, store.save()
|
|
}
|
|
|
|
func (s *DeliveryStore) save() error {
|
|
wrapper := struct {
|
|
Deliveries []Delivery `json:"deliveries"`
|
|
}{Deliveries: s.Deliveries}
|
|
data, err := json.MarshalIndent(wrapper, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(s.path, data, 0644)
|
|
}
|
|
|
|
func (s *DeliveryStore) Add(d Delivery) (Delivery, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
d.ID = uuid.New().String()[:8]
|
|
d.CreatedAt = time.Now()
|
|
d.UpdatedAt = time.Now()
|
|
if d.Status == "" {
|
|
d.Status = "shipped"
|
|
}
|
|
|
|
s.Deliveries = append(s.Deliveries, d)
|
|
return d, s.save()
|
|
}
|
|
|
|
func (s *DeliveryStore) Update(id string, updates Delivery) (Delivery, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, d := range s.Deliveries {
|
|
if d.ID == id {
|
|
if updates.Carrier != "" {
|
|
s.Deliveries[i].Carrier = updates.Carrier
|
|
}
|
|
if updates.Retailer != "" {
|
|
s.Deliveries[i].Retailer = updates.Retailer
|
|
}
|
|
if updates.Description != "" {
|
|
s.Deliveries[i].Description = updates.Description
|
|
}
|
|
if updates.TrackingNumber != "" {
|
|
s.Deliveries[i].TrackingNumber = updates.TrackingNumber
|
|
}
|
|
if updates.TrackingURL != "" {
|
|
s.Deliveries[i].TrackingURL = updates.TrackingURL
|
|
}
|
|
if updates.ExpectedDate != "" {
|
|
s.Deliveries[i].ExpectedDate = updates.ExpectedDate
|
|
}
|
|
if updates.Status != "" {
|
|
s.Deliveries[i].Status = updates.Status
|
|
}
|
|
if updates.Notes != "" {
|
|
s.Deliveries[i].Notes = updates.Notes
|
|
}
|
|
s.Deliveries[i].UpdatedAt = time.Now()
|
|
s.save()
|
|
return s.Deliveries[i], true
|
|
}
|
|
}
|
|
return Delivery{}, false
|
|
}
|
|
|
|
func (s *DeliveryStore) List() []Delivery {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
// Return non-delivered items, sorted by expected date
|
|
var active []Delivery
|
|
for _, d := range s.Deliveries {
|
|
if d.Status != "delivered" {
|
|
active = append(active, d)
|
|
}
|
|
}
|
|
|
|
sort.Slice(active, func(i, j int) bool {
|
|
return active[i].ExpectedDate < active[j].ExpectedDate
|
|
})
|
|
|
|
return active
|
|
}
|
|
|
|
func (s *DeliveryStore) ListAll() []Delivery {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.Deliveries
|
|
}
|
|
|
|
func (s *DeliveryStore) Get(id string) (Delivery, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, d := range s.Deliveries {
|
|
if d.ID == id {
|
|
return d, true
|
|
}
|
|
}
|
|
return Delivery{}, false
|
|
}
|
|
|
|
func (s *DeliveryStore) Delete(id string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, d := range s.Deliveries {
|
|
if d.ID == id {
|
|
s.Deliveries = append(s.Deliveries[:i], s.Deliveries[i+1:]...)
|
|
s.save()
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Find by tracking number or description match
|
|
func (s *DeliveryStore) FindByTracking(tracking string) (Delivery, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, d := range s.Deliveries {
|
|
if d.TrackingNumber == tracking {
|
|
return d, true
|
|
}
|
|
}
|
|
return Delivery{}, false
|
|
}
|
|
|
|
// ============================================
|
|
// AGENTS (reads from OpenClaw config)
|
|
// ============================================
|
|
|
|
type Agent struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Emoji string `json:"emoji"`
|
|
URL string `json:"url"`
|
|
Default bool `json:"default,omitempty"`
|
|
}
|
|
|
|
func getAgents(gatewayIP string, gatewayPort string) []Agent {
|
|
// Read OpenClaw config
|
|
home, _ := os.UserHomeDir()
|
|
configPath := filepath.Join(home, ".openclaw", "openclaw.json")
|
|
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
log.Printf("Failed to read openclaw config: %v", err)
|
|
return []Agent{}
|
|
}
|
|
|
|
var config struct {
|
|
Agents struct {
|
|
List []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Identity struct {
|
|
Name string `json:"name"`
|
|
Emoji string `json:"emoji"`
|
|
} `json:"identity"`
|
|
} `json:"list"`
|
|
} `json:"agents"`
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &config); err != nil {
|
|
log.Printf("Failed to parse openclaw config: %v", err)
|
|
return []Agent{}
|
|
}
|
|
|
|
var agents []Agent
|
|
baseURL := "http://" + gatewayIP + ":" + gatewayPort
|
|
|
|
for _, agentConfig := range config.Agents.List {
|
|
agent := Agent{
|
|
ID: agentConfig.ID,
|
|
Name: agentConfig.Identity.Name,
|
|
Emoji: agentConfig.Identity.Emoji,
|
|
}
|
|
|
|
// Set defaults if missing
|
|
if agent.Name == "" {
|
|
agent.Name = agentConfig.Name
|
|
}
|
|
if agent.Name == "" {
|
|
agent.Name = agentConfig.ID
|
|
}
|
|
if agent.Emoji == "" {
|
|
agent.Emoji = "🤖"
|
|
}
|
|
|
|
// Build URL - main is default
|
|
if agentConfig.ID == "main" {
|
|
agent.Default = true
|
|
agent.URL = baseURL + "/"
|
|
} else {
|
|
agent.URL = baseURL + "/agents/" + agentConfig.ID
|
|
}
|
|
|
|
agents = append(agents, agent)
|
|
}
|
|
|
|
// Sort: default first, then alphabetically
|
|
sort.Slice(agents, func(i, j int) bool {
|
|
if agents[i].Default != agents[j].Default {
|
|
return agents[i].Default
|
|
}
|
|
return agents[i].ID < agents[j].ID
|
|
})
|
|
|
|
return agents
|
|
}
|
|
|
|
// ============================================
|
|
// MAIN
|
|
// ============================================
|
|
|
|
func main() {
|
|
port := flag.String("port", "9200", "port to listen on")
|
|
dir := flag.String("dir", ".", "directory to serve")
|
|
gatewayIP := flag.String("gateway-ip", "192.168.1.16", "OpenClaw gateway IP")
|
|
gatewayPort := flag.String("gateway-port", "18789", "OpenClaw gateway port")
|
|
flag.Parse()
|
|
|
|
// Initialize stores
|
|
taskStore, err := NewTaskStore(filepath.Join(*dir, "data", "tasks.json"))
|
|
if err != nil {
|
|
log.Fatalf("Failed to initialize task store: %v", err)
|
|
}
|
|
|
|
briefingStore, err := NewBriefingStore(filepath.Join(*dir, "data", "briefings.json"))
|
|
if err != nil {
|
|
log.Fatalf("Failed to initialize briefing store: %v", err)
|
|
}
|
|
|
|
newsStore, err := NewNewsStore(filepath.Join(*dir, "data", "news.json"))
|
|
if err != nil {
|
|
log.Fatalf("Failed to initialize news store: %v", err)
|
|
}
|
|
|
|
deliveryStore, err := NewDeliveryStore(filepath.Join(*dir, "data", "deliveries.json"))
|
|
if err != nil {
|
|
log.Fatalf("Failed to initialize delivery store: %v", err)
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
// CORS middleware helper
|
|
cors := func(w http.ResponseWriter) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
}
|
|
|
|
// ========== TASKS API ==========
|
|
mux.HandleFunc("/api/tasks", func(w http.ResponseWriter, r *http.Request) {
|
|
cors(w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case "GET":
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"tasks": taskStore.List(),
|
|
})
|
|
|
|
case "POST":
|
|
var task Task
|
|
if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
|
|
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if task.Text == "" && task.Title == "" {
|
|
http.Error(w, `{"error": "text or title required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
newTask, err := taskStore.Add(task)
|
|
if err != nil {
|
|
http.Error(w, `{"error": "failed to save task"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(newTask)
|
|
|
|
default:
|
|
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// Task by ID (DELETE, PATCH)
|
|
mux.HandleFunc("/api/tasks/", func(w http.ResponseWriter, r *http.Request) {
|
|
cors(w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
|
|
if id == "" {
|
|
http.Error(w, `{"error": "task id required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case "DELETE":
|
|
if taskStore.Delete(id) {
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "id": id})
|
|
} else {
|
|
http.Error(w, `{"error": "task not found"}`, http.StatusNotFound)
|
|
}
|
|
|
|
case "PATCH", "PUT":
|
|
var updates Task
|
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
|
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if updated, ok := taskStore.Update(id, updates); ok {
|
|
json.NewEncoder(w).Encode(updated)
|
|
} else {
|
|
http.Error(w, `{"error": "task not found"}`, http.StatusNotFound)
|
|
}
|
|
|
|
default:
|
|
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// ========== NEWS API ==========
|
|
mux.HandleFunc("/api/news", func(w http.ResponseWriter, r *http.Request) {
|
|
cors(w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case "GET":
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"items": newsStore.List(20),
|
|
})
|
|
|
|
case "POST":
|
|
var item NewsItem
|
|
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
|
|
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if item.Title == "" {
|
|
http.Error(w, `{"error": "title required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
newItem, err := newsStore.Add(item)
|
|
if err != nil {
|
|
http.Error(w, `{"error": "failed to save news"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(newItem)
|
|
|
|
case "DELETE":
|
|
// DELETE /api/news clears all news
|
|
if err := newsStore.Clear(); err != nil {
|
|
http.Error(w, `{"error": "failed to clear news"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "cleared"})
|
|
|
|
default:
|
|
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// News by ID
|
|
mux.HandleFunc("/api/news/", func(w http.ResponseWriter, r *http.Request) {
|
|
cors(w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/news/")
|
|
if id == "" {
|
|
http.Error(w, `{"error": "news id required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case "DELETE":
|
|
if newsStore.Delete(id) {
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "id": id})
|
|
} else {
|
|
http.Error(w, `{"error": "news item not found"}`, http.StatusNotFound)
|
|
}
|
|
|
|
default:
|
|
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// ========== BRIEFINGS API ==========
|
|
mux.HandleFunc("/api/briefings", func(w http.ResponseWriter, r *http.Request) {
|
|
cors(w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case "GET":
|
|
limit := 30 // default
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"briefings": briefingStore.List(limit),
|
|
})
|
|
|
|
case "POST":
|
|
var b Briefing
|
|
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
|
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
newBriefing, err := briefingStore.Add(b)
|
|
if err != nil {
|
|
http.Error(w, `{"error": "failed to save briefing"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(newBriefing)
|
|
|
|
default:
|
|
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// Briefing by ID
|
|
mux.HandleFunc("/api/briefings/", func(w http.ResponseWriter, r *http.Request) {
|
|
cors(w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/briefings/")
|
|
if id == "" {
|
|
http.Error(w, `{"error": "briefing id required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case "GET":
|
|
if b, ok := briefingStore.Get(id); ok {
|
|
json.NewEncoder(w).Encode(b)
|
|
} else {
|
|
http.Error(w, `{"error": "briefing not found"}`, http.StatusNotFound)
|
|
}
|
|
|
|
case "DELETE":
|
|
if briefingStore.Delete(id) {
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "id": id})
|
|
} else {
|
|
http.Error(w, `{"error": "briefing not found"}`, http.StatusNotFound)
|
|
}
|
|
|
|
default:
|
|
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// ========== DELIVERIES API ==========
|
|
mux.HandleFunc("/api/deliveries", func(w http.ResponseWriter, r *http.Request) {
|
|
cors(w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case "GET":
|
|
// ?all=true returns everything including delivered
|
|
if r.URL.Query().Get("all") == "true" {
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"deliveries": deliveryStore.ListAll(),
|
|
})
|
|
} else {
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"deliveries": deliveryStore.List(),
|
|
})
|
|
}
|
|
|
|
case "POST":
|
|
var d Delivery
|
|
if err := json.NewDecoder(r.Body).Decode(&d); err != nil {
|
|
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if d.Description == "" {
|
|
http.Error(w, `{"error": "description required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
newDelivery, err := deliveryStore.Add(d)
|
|
if err != nil {
|
|
http.Error(w, `{"error": "failed to save delivery"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(newDelivery)
|
|
|
|
default:
|
|
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// Delivery by ID
|
|
mux.HandleFunc("/api/deliveries/", func(w http.ResponseWriter, r *http.Request) {
|
|
cors(w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/deliveries/")
|
|
if id == "" {
|
|
http.Error(w, `{"error": "delivery id required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case "GET":
|
|
if d, ok := deliveryStore.Get(id); ok {
|
|
json.NewEncoder(w).Encode(d)
|
|
} else {
|
|
http.Error(w, `{"error": "delivery not found"}`, http.StatusNotFound)
|
|
}
|
|
|
|
case "PATCH", "PUT":
|
|
var updates Delivery
|
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
|
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if updated, ok := deliveryStore.Update(id, updates); ok {
|
|
json.NewEncoder(w).Encode(updated)
|
|
} else {
|
|
http.Error(w, `{"error": "delivery not found"}`, http.StatusNotFound)
|
|
}
|
|
|
|
case "DELETE":
|
|
if deliveryStore.Delete(id) {
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "id": id})
|
|
} else {
|
|
http.Error(w, `{"error": "delivery not found"}`, http.StatusNotFound)
|
|
}
|
|
|
|
default:
|
|
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// ========== AGENTS API ==========
|
|
mux.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) {
|
|
cors(w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
if r.Method == "GET" {
|
|
agents := getAgents(*gatewayIP, *gatewayPort)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"agents": agents,
|
|
"gateway_ip": *gatewayIP,
|
|
"gateway_port": *gatewayPort,
|
|
})
|
|
} else {
|
|
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// Static files (fallback)
|
|
fs := http.FileServer(http.Dir(*dir))
|
|
mux.Handle("/", fs)
|
|
|
|
server := &http.Server{
|
|
Addr: "0.0.0.0:" + *port,
|
|
Handler: mux,
|
|
}
|
|
|
|
// Graceful shutdown
|
|
go func() {
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
|
<-sigChan
|
|
log.Println("Shutting down gracefully...")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
server.Shutdown(ctx)
|
|
}()
|
|
|
|
log.Printf("James Dashboard serving %s on http://0.0.0.0:%s\n", *dir, *port)
|
|
log.Printf("API endpoints:\n")
|
|
log.Printf(" Tasks:\n")
|
|
log.Printf(" GET /api/tasks - list all tasks\n")
|
|
log.Printf(" POST /api/tasks - add task {title, priority, status, owner, notes}\n")
|
|
log.Printf(" PATCH /api/tasks/:id - update task\n")
|
|
log.Printf(" DELETE /api/tasks/:id - remove task\n")
|
|
log.Printf(" News:\n")
|
|
log.Printf(" GET /api/news - list news (newest first, max 20)\n")
|
|
log.Printf(" POST /api/news - add news {title, body, type, source, url}\n")
|
|
log.Printf(" DELETE /api/news - clear all news\n")
|
|
log.Printf(" DELETE /api/news/:id - remove news item\n")
|
|
log.Printf(" Briefings:\n")
|
|
log.Printf(" GET /api/briefings - list briefings (newest first)\n")
|
|
log.Printf(" POST /api/briefings - add briefing {title, date, weather, markets, news, summary}\n")
|
|
log.Printf(" GET /api/briefings/:id - get single briefing\n")
|
|
log.Printf(" DELETE /api/briefings/:id - remove briefing\n")
|
|
log.Printf(" Deliveries:\n")
|
|
log.Printf(" GET /api/deliveries - list active deliveries (excludes delivered)\n")
|
|
log.Printf(" GET /api/deliveries?all=true - list all deliveries\n")
|
|
log.Printf(" POST /api/deliveries - add delivery {description, carrier, retailer, tracking_number, expected_date, status}\n")
|
|
log.Printf(" GET /api/deliveries/:id - get single delivery\n")
|
|
log.Printf(" PATCH /api/deliveries/:id - update delivery\n")
|
|
log.Printf(" DELETE /api/deliveries/:id - remove delivery\n")
|
|
log.Printf(" Agents:\n")
|
|
log.Printf(" GET /api/agents - list OpenClaw agents (reads from config)\n")
|
|
log.Printf(" Gateway: http://%s:%s\n", *gatewayIP, *gatewayPort)
|
|
|
|
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
|
log.Fatal(err)
|
|
}
|
|
}
|