fix: signal connector send-only via JSON-RPC (OpenClaw owns receive loop)

This commit is contained in:
James 2026-02-18 03:28:47 -05:00
parent b69af43840
commit 3f1259f288
1 changed files with 200 additions and 0 deletions

200
connector_signal.go Normal file
View File

@ -0,0 +1,200 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
)
// SignalConfig holds signal-cli connector configuration
type SignalConfig struct {
Enabled bool `yaml:"enabled"`
APIURL string `yaml:"api_url"` // e.g. http://localhost:8080
Number string `yaml:"number"` // bot number, e.g. +31634481877
Contacts map[string]string `yaml:"contacts"` // phone -> display name
PollInterval int `yaml:"poll_interval"` // seconds, default 5
}
// SignalConnector listens for incoming Signal messages and posts them to Fully.
// Outgoing sync messages (Johan replying from his phone) auto-clear the alert.
type SignalConnector struct {
cfg SignalConfig
alerts map[string][]string // sender number -> []Fully alertIDs
mu sync.Mutex
stopCh chan struct{}
}
func NewSignalConnector(cfg SignalConfig) *SignalConnector {
if cfg.APIURL == "" {
cfg.APIURL = "http://localhost:8080"
}
if cfg.PollInterval <= 0 {
cfg.PollInterval = 5
}
return &SignalConnector{
cfg: cfg,
alerts: make(map[string][]string),
stopCh: make(chan struct{}),
}
}
func (c *SignalConnector) Start() {
// Incoming Signal messages are consumed by OpenClaw's daemon — we can't poll.
// Incoming→Fully flow is handled by James session logic when messages arrive.
// This connector is used for outgoing sends only (via Send()).
log.Printf("[signal] Started (send-only mode, incoming handled by OpenClaw)")
}
func (c *SignalConnector) Stop() {
close(c.stopCh)
}
// --- signal-cli envelope types ---
type signalEnvelope struct {
Envelope struct {
Source string `json:"source"`
SourceName string `json:"sourceName"`
// Incoming message
DataMessage *signalDataMessage `json:"dataMessage"`
// Outgoing sync (Johan replied from his own device)
SyncMessage *struct {
SentMessage *struct {
Destination string `json:"destination"`
Message string `json:"message"`
Timestamp int64 `json:"timestamp"`
} `json:"sentMessage"`
} `json:"syncMessage"`
} `json:"envelope"`
}
type signalDataMessage struct {
Message string `json:"message"`
Timestamp int64 `json:"timestamp"`
}
// receive fetches pending messages from signal-cli HTTP daemon
func (c *SignalConnector) receive() []signalEnvelope {
url := c.cfg.APIURL + "/v1/receive/" + c.cfg.Number
resp, err := http.Get(url)
if err != nil {
log.Printf("[signal] receive error: %v", err)
return nil
}
defer resp.Body.Close()
if resp.StatusCode == 204 {
return nil // no messages
}
body, _ := io.ReadAll(resp.Body)
if len(body) == 0 {
return nil
}
var envelopes []signalEnvelope
if err := json.Unmarshal(body, &envelopes); err != nil {
log.Printf("[signal] parse error: %v", err)
return nil
}
return envelopes
}
func (c *SignalConnector) processMessages() {
envelopes := c.receive()
for _, env := range envelopes {
e := env.Envelope
// Outgoing sync — Johan replied from his own phone → clear Fully alert
if e.SyncMessage != nil && e.SyncMessage.SentMessage != nil {
dest := e.SyncMessage.SentMessage.Destination
if dest != "" {
c.clearAlerts(dest)
log.Printf("[signal] Johan sent to %s — cleared alerts", c.resolveName(dest))
}
continue
}
// Skip our own bot number echoing back
if e.Source == c.cfg.Number {
continue
}
// Incoming data message
if e.DataMessage == nil {
continue
}
msg := strings.TrimSpace(e.DataMessage.Message)
if msg == "" {
continue
}
name := e.SourceName
if name == "" {
name = c.resolveName(e.Source)
}
log.Printf("[signal] Incoming from %s (%s): %s", name, e.Source, truncateStr(msg, 80))
alertText := fmt.Sprintf("📱 %s: %s", name, truncateStr(msg, 200))
alertID := postFullyAlert(alertText, "info", "signal:"+e.Source)
c.mu.Lock()
c.alerts[e.Source] = append(c.alerts[e.Source], alertID)
c.mu.Unlock()
}
}
func (c *SignalConnector) clearAlerts(sender string) {
c.mu.Lock()
defer c.mu.Unlock()
for _, id := range c.alerts[sender] {
if id != "" {
removeFullyAlert(id)
}
}
delete(c.alerts, sender)
}
func (c *SignalConnector) resolveName(number string) string {
if name, ok := c.cfg.Contacts[number]; ok {
return name
}
return number
}
// Send sends a Signal message to a recipient number via JSON-RPC
func (c *SignalConnector) Send(recipient, message string) error {
payload, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0",
"method": "send",
"params": map[string]interface{}{
"recipient": recipient,
"message": message,
},
"id": fmt.Sprintf("%d", time.Now().UnixMilli()),
})
url := c.cfg.APIURL + "/api/v1/rpc"
resp, err := http.Post(url, "application/json", bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("signal send: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var rpcResp struct {
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(body, &rpcResp) == nil && rpcResp.Error != nil {
return fmt.Errorf("signal rpc error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
}
return nil
}