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 }