feat: add SMS connector via ClawdNode Gateway

This commit is contained in:
James 2026-02-08 06:21:11 -05:00
parent 8e3845876b
commit 80bdc5fb6a
3 changed files with 354 additions and 0 deletions

View File

@ -39,6 +39,10 @@ connectors:
enabled: true enabled: true
name: whatsapp name: whatsapp
base_url: http://localhost:8030 base_url: http://localhost:8030
sms:
enabled: true
name: sms
gateway_url: http://localhost:9877
webhook: webhook:
enabled: true enabled: true

329
connector_sms.go Normal file
View File

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

21
main.go
View File

@ -54,6 +54,11 @@ type ConnectorsConfig struct {
Name string `yaml:"name"` Name string `yaml:"name"`
BaseURL string `yaml:"base_url"` BaseURL string `yaml:"base_url"`
} `yaml:"whatsapp"` } `yaml:"whatsapp"`
SMS struct {
Enabled bool `yaml:"enabled"`
Name string `yaml:"name"`
GatewayURL string `yaml:"gateway_url"`
} `yaml:"sms"`
} }
type WebhookConfig struct { type WebhookConfig struct {
@ -148,6 +153,22 @@ func main() {
log.Printf("Registered WhatsApp connector: %s -> %s", name, baseURL) 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 // Start all connectors with webhook callback
webhookCallback := func() { webhookCallback := func() {
if config.Webhook.Enabled { if config.Webhook.Enabled {