1490 lines
37 KiB
Go
1490 lines
37 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
|
|
}
|
|
|
|
// ============================================
|
|
// STATUS (key-value status indicators)
|
|
// ============================================
|
|
|
|
type StatusItem struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
Type string `json:"type,omitempty"` // info, success, warning, error
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type StatusStore struct {
|
|
Items map[string]StatusItem `json:"items"`
|
|
mu sync.RWMutex
|
|
path string
|
|
}
|
|
|
|
func NewStatusStore(path string) (*StatusStore, error) {
|
|
store := &StatusStore{path: path, Items: make(map[string]StatusItem)}
|
|
|
|
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 map[string]StatusItem `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 *StatusStore) save() error {
|
|
wrapper := struct {
|
|
Items map[string]StatusItem `json:"items"`
|
|
}{Items: s.Items}
|
|
data, err := json.MarshalIndent(wrapper, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(s.path, data, 0644)
|
|
}
|
|
|
|
func (s *StatusStore) Set(key, value, itemType string) StatusItem {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
item := StatusItem{
|
|
Key: key,
|
|
Value: value,
|
|
Type: itemType,
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if item.Type == "" {
|
|
item.Type = "info"
|
|
}
|
|
s.Items[key] = item
|
|
s.save()
|
|
return item
|
|
}
|
|
|
|
func (s *StatusStore) Get(key string) (StatusItem, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
item, ok := s.Items[key]
|
|
return item, ok
|
|
}
|
|
|
|
func (s *StatusStore) List() map[string]StatusItem {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.Items
|
|
}
|
|
|
|
func (s *StatusStore) Delete(key string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, ok := s.Items[key]; ok {
|
|
delete(s.Items, key)
|
|
s.save()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ============================================
|
|
// EMAIL TRIAGE
|
|
// ============================================
|
|
|
|
type EmailTriageEntry struct {
|
|
ID string `json:"id"`
|
|
Account string `json:"account"` // proton, johan
|
|
From string `json:"from"`
|
|
Subject string `json:"subject"`
|
|
Action string `json:"action"` // archived, deleted, flagged, kept
|
|
Reason string `json:"reason,omitempty"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
}
|
|
|
|
type EmailTriageStore struct {
|
|
Entries []EmailTriageEntry `json:"entries"`
|
|
mu sync.RWMutex
|
|
path string
|
|
}
|
|
|
|
func NewEmailTriageStore(path string) (*EmailTriageStore, error) {
|
|
store := &EmailTriageStore{path: path, Entries: []EmailTriageEntry{}}
|
|
|
|
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 {
|
|
Entries []EmailTriageEntry `json:"entries"`
|
|
}
|
|
if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Entries != nil {
|
|
store.Entries = wrapper.Entries
|
|
return store, nil
|
|
}
|
|
|
|
return store, store.save()
|
|
}
|
|
|
|
func (s *EmailTriageStore) save() error {
|
|
wrapper := struct {
|
|
Entries []EmailTriageEntry `json:"entries"`
|
|
}{Entries: s.Entries}
|
|
data, err := json.MarshalIndent(wrapper, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(s.path, data, 0644)
|
|
}
|
|
|
|
func (s *EmailTriageStore) Add(entry EmailTriageEntry) (EmailTriageEntry, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
entry.ID = uuid.New().String()[:8]
|
|
entry.Timestamp = time.Now()
|
|
|
|
// Prepend (newest first)
|
|
s.Entries = append([]EmailTriageEntry{entry}, s.Entries...)
|
|
|
|
// Keep only last 500 entries
|
|
if len(s.Entries) > 500 {
|
|
s.Entries = s.Entries[:500]
|
|
}
|
|
|
|
return entry, s.save()
|
|
}
|
|
|
|
func (s *EmailTriageStore) List(account string, limit int) []EmailTriageEntry {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if account == "" {
|
|
if limit > 0 && limit < len(s.Entries) {
|
|
return s.Entries[:limit]
|
|
}
|
|
return s.Entries
|
|
}
|
|
|
|
// Filter by account
|
|
var filtered []EmailTriageEntry
|
|
for _, e := range s.Entries {
|
|
if e.Account == account {
|
|
filtered = append(filtered, e)
|
|
if limit > 0 && len(filtered) >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func (s *EmailTriageStore) Clear(account string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if account == "" {
|
|
s.Entries = []EmailTriageEntry{}
|
|
} else {
|
|
var filtered []EmailTriageEntry
|
|
for _, e := range s.Entries {
|
|
if e.Account != account {
|
|
filtered = append(filtered, e)
|
|
}
|
|
}
|
|
s.Entries = filtered
|
|
}
|
|
return s.save()
|
|
}
|
|
|
|
// ============================================
|
|
// 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, gatewayToken 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 - use /chat?session= (OpenClaw Control UI format)
|
|
// Include token for auto-authentication
|
|
if agentConfig.ID == "main" {
|
|
agent.Default = true
|
|
agent.URL = baseURL + "/chat?session=agent:main:main&token=" + gatewayToken
|
|
} else {
|
|
// Control UI reads session param on /chat route
|
|
agent.URL = baseURL + "/chat?session=agent:" + agentConfig.ID + ":main&token=" + gatewayToken
|
|
}
|
|
|
|
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")
|
|
gatewayToken := flag.String("gateway-token", "2dee57cc3ce2947c27ce9e848d5c3e95cc452f25a1477462", "OpenClaw gateway auth token")
|
|
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)
|
|
}
|
|
|
|
statusStore, err := NewStatusStore(filepath.Join(*dir, "data", "status.json"))
|
|
if err != nil {
|
|
log.Fatalf("Failed to initialize status store: %v", err)
|
|
}
|
|
|
|
emailTriageStore, err := NewEmailTriageStore(filepath.Join(*dir, "data", "email-triage.json"))
|
|
if err != nil {
|
|
log.Fatalf("Failed to initialize email triage 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)
|
|
}
|
|
})
|
|
|
|
// ========== STATUS API ==========
|
|
mux.HandleFunc("/api/status", 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{}{
|
|
"status": statusStore.List(),
|
|
})
|
|
|
|
case "POST", "PUT":
|
|
var item struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
Type string `json:"type"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
|
|
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if item.Key == "" || item.Value == "" {
|
|
http.Error(w, `{"error": "key and value required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
result := statusStore.Set(item.Key, item.Value, item.Type)
|
|
json.NewEncoder(w).Encode(result)
|
|
|
|
default:
|
|
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// Status by key
|
|
mux.HandleFunc("/api/status/", func(w http.ResponseWriter, r *http.Request) {
|
|
cors(w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
key := strings.TrimPrefix(r.URL.Path, "/api/status/")
|
|
if key == "" {
|
|
http.Error(w, `{"error": "status key required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case "GET":
|
|
if item, ok := statusStore.Get(key); ok {
|
|
json.NewEncoder(w).Encode(item)
|
|
} else {
|
|
http.Error(w, `{"error": "status not found"}`, http.StatusNotFound)
|
|
}
|
|
|
|
case "DELETE":
|
|
if statusStore.Delete(key) {
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "key": key})
|
|
} else {
|
|
http.Error(w, `{"error": "status not found"}`, http.StatusNotFound)
|
|
}
|
|
|
|
default:
|
|
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// ========== EMAIL TRIAGE API ==========
|
|
mux.HandleFunc("/api/email-triage", func(w http.ResponseWriter, r *http.Request) {
|
|
cors(w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case "GET":
|
|
account := r.URL.Query().Get("account")
|
|
limit := 100
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if n, err := json.Number(l).Int64(); err == nil {
|
|
limit = int(n)
|
|
}
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"entries": emailTriageStore.List(account, limit),
|
|
})
|
|
|
|
case "POST":
|
|
var entry EmailTriageEntry
|
|
if err := json.NewDecoder(r.Body).Decode(&entry); err != nil {
|
|
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if entry.Account == "" || entry.Action == "" {
|
|
http.Error(w, `{"error": "account and action required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
newEntry, err := emailTriageStore.Add(entry)
|
|
if err != nil {
|
|
http.Error(w, `{"error": "failed to save entry"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(newEntry)
|
|
|
|
case "DELETE":
|
|
account := r.URL.Query().Get("account")
|
|
if err := emailTriageStore.Clear(account); err != nil {
|
|
http.Error(w, `{"error": "failed to clear"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "cleared", "account": account})
|
|
|
|
default:
|
|
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// ========== CLAUDE USAGE API ==========
|
|
claudeUsageFile := "/home/johan/clawd/memory/claude-usage.json"
|
|
claudeHistoryFile := filepath.Join(*dir, "data", "claude-usage-history.json")
|
|
|
|
mux.HandleFunc("/api/claude-usage", func(w http.ResponseWriter, r *http.Request) {
|
|
cors(w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
if r.Method != "GET" {
|
|
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Read current usage
|
|
result := make(map[string]interface{})
|
|
if data, err := os.ReadFile(claudeUsageFile); err == nil {
|
|
json.Unmarshal(data, &result)
|
|
}
|
|
|
|
// Read history
|
|
var history []map[string]interface{}
|
|
if data, err := os.ReadFile(claudeHistoryFile); err == nil {
|
|
json.Unmarshal(data, &history)
|
|
}
|
|
result["history"] = history
|
|
|
|
json.NewEncoder(w).Encode(result)
|
|
})
|
|
|
|
mux.HandleFunc("/api/claude-usage/record", func(w http.ResponseWriter, r *http.Request) {
|
|
cors(w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
if r.Method != "POST" {
|
|
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var entry map[string]interface{}
|
|
if err := json.NewDecoder(r.Body).Decode(&entry); err != nil {
|
|
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Read existing history
|
|
var history []map[string]interface{}
|
|
if data, err := os.ReadFile(claudeHistoryFile); err == nil {
|
|
json.Unmarshal(data, &history)
|
|
}
|
|
|
|
// Add timestamp if missing
|
|
if _, ok := entry["timestamp"]; !ok {
|
|
entry["timestamp"] = time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
history = append(history, entry)
|
|
|
|
// Keep last 2000 entries
|
|
if len(history) > 2000 {
|
|
history = history[len(history)-2000:]
|
|
}
|
|
|
|
data, _ := json.MarshalIndent(history, "", " ")
|
|
os.WriteFile(claudeHistoryFile, data, 0644)
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "recorded"})
|
|
})
|
|
|
|
// ========== 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, *gatewayToken)
|
|
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(" Email Triage:\n")
|
|
log.Printf(" GET /api/email-triage - list email triage log (newest first)\n")
|
|
log.Printf(" GET /api/email-triage?account=X - filter by account (proton, johan)\n")
|
|
log.Printf(" POST /api/email-triage - add entry {account, from, subject, action, reason}\n")
|
|
log.Printf(" DELETE /api/email-triage - clear all entries\n")
|
|
log.Printf(" DELETE /api/email-triage?account=X - clear entries for account\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)
|
|
}
|
|
}
|