330 lines
7.7 KiB
Go
330 lines
7.7 KiB
Go
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)
|
|
}
|
|
}
|