diff --git a/config.yaml b/config.yaml index cc73b47..258264a 100644 --- a/config.yaml +++ b/config.yaml @@ -39,6 +39,10 @@ connectors: enabled: true name: whatsapp base_url: http://localhost:8030 + sms: + enabled: true + name: sms + gateway_url: http://localhost:9877 webhook: enabled: true diff --git a/connector_sms.go b/connector_sms.go new file mode 100644 index 0000000..52029ed --- /dev/null +++ b/connector_sms.go @@ -0,0 +1,329 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sync" + "time" +) + +// SmsConnector wraps the ClawdNode Gateway HTTP API for SMS access +type SmsConnector struct { + name string + gatewayURL string // e.g., http://localhost:9877 + wsURL string // e.g., ws://localhost:9878 (for future real-time) + ctx chan struct{} + callback func() + mu sync.Mutex + lastSeenID int64 // Track highest SMS ID seen + seenIDs map[string]bool // Track actioned message IDs +} + +// ClawdNode SMS message format (from phone) +type ClawdNodeSmsMessage struct { + ID int64 `json:"id"` + Address string `json:"address"` + ContactName string `json:"contactName"` + Body string `json:"body"` + Date int64 `json:"date"` + Read bool `json:"read"` + Type int `json:"type"` // 1=inbox, 2=sent + ThreadID int64 `json:"threadId"` +} + +type ClawdNodeSmsThread struct { + ThreadID int64 `json:"threadId"` + Address string `json:"address"` + ContactName string `json:"contactName"` + LastMessage string `json:"lastMessage"` + LastDate int64 `json:"lastDate"` + MessageCount int `json:"messageCount"` +} + +// ClawdNode command response +type CommandResponse struct { + Success bool `json:"success"` + CommandID string `json:"commandId"` + Error string `json:"error,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +func NewSmsConnector(name, gatewayURL string) *SmsConnector { + return &SmsConnector{ + name: name, + gatewayURL: gatewayURL, + seenIDs: make(map[string]bool), + } +} + +func (s *SmsConnector) Name() string { + return s.name +} + +// sendCommand sends a command to the phone via ClawdNode gateway +func (s *SmsConnector) sendCommand(command string, params map[string]interface{}) (*CommandResponse, error) { + body := map[string]interface{}{ + "command": command, + "params": params, + "wait": true, + "timeout": 15000, + } + data, _ := json.Marshal(body) + + resp, err := http.Post(s.gatewayURL+"/command", "application/json", bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("gateway request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("gateway status %d: %s", resp.StatusCode, string(b)) + } + + var cmdResp CommandResponse + if err := json.NewDecoder(resp.Body).Decode(&cmdResp); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + if !cmdResp.Success { + return &cmdResp, fmt.Errorf("command failed: %s", cmdResp.Error) + } + + return &cmdResp, nil +} + +// fetchMessages gets SMS messages from the phone +func (s *SmsConnector) fetchMessages(limit int, since int64) ([]ClawdNodeSmsMessage, error) { + params := map[string]interface{}{ + "limit": limit, + } + if since > 0 { + params["since"] = since + } + // Only inbox messages (type=1) + params["type"] = 1 + + resp, err := s.sendCommand("sms.list", params) + if err != nil { + return nil, err + } + + var wrapper struct { + Messages []ClawdNodeSmsMessage `json:"messages"` + } + if err := json.Unmarshal(resp.Data, &wrapper); err != nil { + return nil, fmt.Errorf("decode messages: %w", err) + } + + return wrapper.Messages, nil +} + +func (s *SmsConnector) convertMessage(msg ClawdNodeSmsMessage) UnifiedMessage { + fromName := msg.ContactName + if fromName == "" { + fromName = msg.Address + } + + return UnifiedMessage{ + ID: fmt.Sprintf("%s:%d", s.name, msg.ID), + Source: s.name, + SourceUID: fmt.Sprintf("%d", msg.ID), + From: msg.Address, + FromName: fromName, + To: "me", // Inbox messages are to us + Subject: "", // SMS has no subject + Timestamp: time.UnixMilli(msg.Date), + Body: msg.Body, + Seen: msg.Read, + Attachments: []AttachmentMeta{}, + SourceExtra: msg.ThreadID, + } +} + +func (s *SmsConnector) FetchNew() ([]UnifiedMessage, error) { + // Fetch recent inbox messages (last 48h) + since := time.Now().Add(-48 * time.Hour).UnixMilli() + messages, err := s.fetchMessages(100, since) + if err != nil { + return nil, err + } + + s.mu.Lock() + defer s.mu.Unlock() + + var result []UnifiedMessage + for _, msg := range messages { + id := fmt.Sprintf("%s:%d", s.name, msg.ID) + if s.seenIDs[id] { + continue + } + // Only unread messages count as "new" + if !msg.Read { + result = append(result, s.convertMessage(msg)) + } + } + + return result, nil +} + +func (s *SmsConnector) FetchSince(since time.Time) ([]UnifiedMessage, error) { + sinceMs := since.UnixMilli() + messages, err := s.fetchMessages(100, sinceMs) + if err != nil { + return nil, err + } + + var result []UnifiedMessage + for _, msg := range messages { + if msg.Date >= sinceMs { + result = append(result, s.convertMessage(msg)) + } + } + + return result, nil +} + +func (s *SmsConnector) FetchOne(sourceID string) (*UnifiedMessage, error) { + // sourceID is the SMS ID (numeric string) + var id int64 + fmt.Sscanf(sourceID, "%d", &id) + + resp, err := s.sendCommand("sms.read", map[string]interface{}{ + "id": id, + }) + if err != nil { + return nil, err + } + + var msg ClawdNodeSmsMessage + if err := json.Unmarshal(resp.Data, &msg); err != nil { + return nil, fmt.Errorf("decode message: %w", err) + } + + um := s.convertMessage(msg) + return &um, nil +} + +func (s *SmsConnector) Archive(sourceID string) error { + // SMS doesn't have archive — mark as seen locally + s.mu.Lock() + s.seenIDs[fmt.Sprintf("%s:%s", s.name, sourceID)] = true + s.mu.Unlock() + return nil +} + +func (s *SmsConnector) Delete(sourceID string) error { + var id int64 + fmt.Sscanf(sourceID, "%d", &id) + + _, err := s.sendCommand("sms.delete", map[string]interface{}{ + "id": id, + }) + return err +} + +func (s *SmsConnector) Reply(sourceID string, body string, attachments []string) error { + // Get original message to find the phone number + msg, err := s.FetchOne(sourceID) + if err != nil { + return fmt.Errorf("fetch original: %w", err) + } + + _, err = s.sendCommand("sms.send", map[string]interface{}{ + "to": msg.From, + "body": body, + }) + return err +} + +func (s *SmsConnector) MarkSeen(sourceID string) error { + s.mu.Lock() + s.seenIDs[fmt.Sprintf("%s:%s", s.name, sourceID)] = true + s.mu.Unlock() + return nil +} + +func (s *SmsConnector) GetAttachment(sourceID string, filename string) ([]byte, error) { + return nil, fmt.Errorf("SMS does not support attachments") +} + +func (s *SmsConnector) Start(callback func()) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.ctx = make(chan struct{}) + s.callback = callback + + // Initialize seen set with current messages + messages, err := s.fetchMessages(50, 0) + if err != nil { + log.Printf("[%s] Initial fetch failed (phone may be disconnected): %v", s.name, err) + } else { + for _, msg := range messages { + s.seenIDs[fmt.Sprintf("%s:%d", s.name, msg.ID)] = true + } + log.Printf("[%s] Initialized with %d existing messages", s.name, len(messages)) + } + + go s.pollLoop() + + return nil +} + +func (s *SmsConnector) pollLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + log.Printf("[%s] Started polling for new SMS", s.name) + + for { + select { + case <-s.ctx: + return + case <-ticker.C: + s.checkForNew() + } + } +} + +func (s *SmsConnector) checkForNew() { + // Fetch recent unread inbox messages + since := time.Now().Add(-1 * time.Hour).UnixMilli() + messages, err := s.fetchMessages(20, since) + if err != nil { + // Don't spam logs if phone is disconnected + return + } + + hasNew := false + s.mu.Lock() + for _, msg := range messages { + id := fmt.Sprintf("%s:%d", s.name, msg.ID) + if !s.seenIDs[id] && !msg.Read { + hasNew = true + s.seenIDs[id] = true + log.Printf("[%s] New SMS from %s (%s): %s", s.name, + msg.ContactName, msg.Address, truncateStr(msg.Body, 50)) + } + } + callback := s.callback + s.mu.Unlock() + + if hasNew && callback != nil { + go callback() + } +} + +func (s *SmsConnector) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.ctx != nil { + close(s.ctx) + } +} diff --git a/main.go b/main.go index affaad4..01d1c08 100644 --- a/main.go +++ b/main.go @@ -54,6 +54,11 @@ type ConnectorsConfig struct { Name string `yaml:"name"` BaseURL string `yaml:"base_url"` } `yaml:"whatsapp"` + SMS struct { + Enabled bool `yaml:"enabled"` + Name string `yaml:"name"` + GatewayURL string `yaml:"gateway_url"` + } `yaml:"sms"` } type WebhookConfig struct { @@ -148,6 +153,22 @@ func main() { log.Printf("Registered WhatsApp connector: %s -> %s", name, baseURL) } + // Register SMS connector (via ClawdNode Gateway) + if config.Connectors.SMS.Enabled { + name := config.Connectors.SMS.Name + if name == "" { + name = "sms" + } + gatewayURL := config.Connectors.SMS.GatewayURL + if gatewayURL == "" { + gatewayURL = "http://localhost:9877" + } + + connector := NewSmsConnector(name, gatewayURL) + store.RegisterConnector(connector) + log.Printf("Registered SMS connector: %s -> %s", name, gatewayURL) + } + // Start all connectors with webhook callback webhookCallback := func() { if config.Webhook.Enabled {