Compare commits
10 Commits
8d8a1516bd
...
53b74b0981
| Author | SHA1 | Date |
|---|---|---|
|
|
53b74b0981 | |
|
|
d31e569d5a | |
|
|
8935329d60 | |
|
|
ecb9fa4a5f | |
|
|
32e51c4cfe | |
|
|
b408ebc2b7 | |
|
|
a76f3677b4 | |
|
|
2005d7521a | |
|
|
3f1259f288 | |
|
|
b69af43840 |
14
config.yaml
14
config.yaml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
12
main.go
|
|
@ -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)
|
||||
}()
|
||||
|
|
|
|||
Loading…
Reference in New Issue