Compare commits

...

10 Commits

4 changed files with 280 additions and 16 deletions

View File

@ -9,7 +9,7 @@ accounts:
host: 127.0.0.1
port: 1143
username: tj@jongsma.me
password: ${PROTON_BRIDGE_PASSWORD}
password: ${TANYA_BRIDGE_PASSWORD}
tls: starttls
watch:
- INBOX
@ -17,7 +17,7 @@ accounts:
host: 127.0.0.1
port: 1025
username: tj@jongsma.me
password: ${PROTON_BRIDGE_PASSWORD}
password: ${TANYA_BRIDGE_PASSWORD}
from: tj@jongsma.me
johan_jongsma_me:
host: 127.0.0.1
@ -49,16 +49,6 @@ webhook:
url: http://localhost:18789/hooks/messages
token: "kuma-alert-token-2026"
m365:
enabled: true
poll_interval: 60
tenant_id: "a1cd3436-6062-4169-a1bd-79efdcfd8a5e"
client_id: "1fec8e78-bce4-4aaf-ab1b-5451cc387264"
token_file: "~/.message-center/m365-token.json"
user_email: "johan.jongsma@kaseya.com"
webhook_url: "http://localhost:18789/hooks/m365"
webhook_token: "kuma-alert-token-2026"
triage:
enabled: true
prompt_file: /home/johan/clawd/config/email-triage-prompt.md

View File

@ -62,6 +62,7 @@ type m365State struct {
LastEmailIDs []string `json:"last_email_ids"`
LastTeamsMsgs map[string]string `json:"last_teams_messages"`
LastCalendarHash string `json:"last_calendar_hash"`
EmailAlerts map[string]string `json:"email_alerts"` // msgID → Fully alertID
}
type m365TokenFile struct {
@ -588,10 +589,29 @@ func (c *M365Connector) pollEmail() bool {
seenSet[id] = true
}
// Senders whose emails are silently tracked (marked seen) but never posted to Fully dashboard.
silentSenders := []string{
"winalert@kaseya.com",
"lostalert@kaseya.com",
"standard.instrumentation@kaseya.com",
"noreply@salesforce.com",
}
isSilent := func(addr string) bool {
addr = strings.ToLower(strings.TrimSpace(addr))
for _, s := range silentSenders {
if addr == s {
return true
}
}
return false
}
type emailDetail struct {
From string `json:"from"`
Address string `json:"address"`
Subject string `json:"subject"`
BodyPreview string `json:"bodyPreview"`
Silent bool `json:"silent"`
}
var newEmails []string
var summaries []string
@ -599,19 +619,40 @@ func (c *M365Connector) pollEmail() bool {
for _, msg := range result.Value {
if !seenSet[msg.ID] {
newEmails = append(newEmails, msg.ID)
addr := strings.ToLower(strings.TrimSpace(msg.From.EmailAddress.Address))
from := msg.From.EmailAddress.Name
if from == "" {
from = msg.From.EmailAddress.Address
from = addr
}
summaries = append(summaries, fmt.Sprintf("%s: %s", from, msg.Subject))
details = append(details, emailDetail{
From: from,
Address: addr,
Subject: msg.Subject,
BodyPreview: truncateStr(msg.BodyPreview, 200),
Silent: isSilent(addr),
})
}
}
// Build current unread set for reconciliation
currentSet := make(map[string]bool)
for _, msg := range result.Value {
currentSet[msg.ID] = true
}
// Auto-remove Fully alerts for emails no longer in unread inbox (deleted or read)
if c.state.EmailAlerts == nil {
c.state.EmailAlerts = make(map[string]string)
}
for msgID, alertID := range c.state.EmailAlerts {
if !currentSet[msgID] {
log.Printf("[m365] Email %s gone from inbox, removing Fully alert %s", msgID[:16], alertID)
removeFullyAlert(alertID)
delete(c.state.EmailAlerts, msgID)
}
}
// Update state with current unread IDs
newIDs := make([]string, 0, len(result.Value))
for _, msg := range result.Value {
@ -624,11 +665,20 @@ func (c *M365Connector) pollEmail() bool {
summary := fmt.Sprintf("%d new unread email(s): %s", len(newEmails), strings.Join(summaries, "; "))
log.Printf("[m365] %s", summary)
c.sendM365WebhookRich("email", summary, details)
// Post AI-summarized alerts to Fully per email (grouped by sender)
for _, d := range details {
// Post AI-summarized alerts to Fully per email; track alert IDs for auto-removal.
// Silent senders (noise/automated) are skipped — tracked as seen but never shown on tablet.
for i, d := range details {
if d.Silent {
log.Printf("[m365] Suppressing Fully alert for silent sender: %s", d.Address)
continue
}
msg := summarizeM365("📧", d.From, d.Subject, d.BodyPreview)
postFullyAlert(msg, "info", "email:"+d.From)
alertID := postFullyAlert(msg, "info", "email:"+d.From)
if alertID != "" && i < len(newEmails) {
c.state.EmailAlerts[newEmails[i]] = alertID
}
}
c.saveState()
return true
}
@ -754,6 +804,16 @@ func (c *M365Connector) pollTeams() bool {
continue
}
// Skip messages older than 24h — prevents old channel messages from surfacing
if msg.ComposeTime != "" {
if t, err := time.Parse(time.RFC3339Nano, msg.ComposeTime); err == nil {
if time.Since(t) > 24*time.Hour {
log.Printf("[m365] Skipping old Teams message from %s (age: %s)", msg.DisplayName, time.Since(t).Round(time.Minute))
continue
}
}
}
content := stripHTMLTags(msg.Content)
if content == "" {
continue
@ -812,6 +872,7 @@ type teamsMsg struct {
From string
DisplayName string
Content string
ComposeTime string
}
func (c *M365Connector) fetchTeamsMessages(token, convID string) []teamsMsg {
@ -854,6 +915,7 @@ func (c *M365Connector) fetchTeamsMessages(token, convID string) []teamsMsg {
From: m.From,
DisplayName: m.IMDisplayName,
Content: m.Content,
ComposeTime: m.ComposeTime,
})
}

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
}

12
main.go
View File

@ -61,6 +61,7 @@ type ConnectorsConfig struct {
Name string `yaml:"name"`
GatewayURL string `yaml:"gateway_url"`
} `yaml:"sms"`
Signal SignalConfig `yaml:"signal"`
}
type WebhookConfig struct {
@ -171,6 +172,14 @@ func main() {
log.Printf("Registered SMS connector: %s -> %s", name, gatewayURL)
}
// Register Signal connector
var signalConnector *SignalConnector
if config.Connectors.Signal.Enabled {
signalConnector = NewSignalConnector(config.Connectors.Signal)
signalConnector.Start()
log.Printf("Signal connector started (%s, poll: %ds)", config.Connectors.Signal.Number, config.Connectors.Signal.PollInterval)
}
// Log triage config
if config.Triage.Enabled {
log.Printf("Email triage enabled (model: %s, prompt: %s)", config.Triage.Provider.Model, config.Triage.PromptFile)
@ -243,6 +252,9 @@ func main() {
if m365Connector != nil {
m365Connector.Stop()
}
if signalConnector != nil {
signalConnector.Stop()
}
store.StopAll()
server.Shutdown(nil)
}()