fix: signal connector send-only via JSON-RPC (OpenClaw owns receive loop)
This commit is contained in:
parent
b69af43840
commit
3f1259f288
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue