From 3f1259f2883832975481ae68321e628d0c32f1bb Mon Sep 17 00:00:00 2001 From: James Date: Wed, 18 Feb 2026 03:28:47 -0500 Subject: [PATCH] fix: signal connector send-only via JSON-RPC (OpenClaw owns receive loop) --- connector_signal.go | 200 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 connector_signal.go diff --git a/connector_signal.go b/connector_signal.go new file mode 100644 index 0000000..9fa0a82 --- /dev/null +++ b/connector_signal.go @@ -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 +}