feat: add SMS connector via ClawdNode Gateway
This commit is contained in:
parent
8e3845876b
commit
80bdc5fb6a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
21
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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue