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) } }