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