diff --git a/connector_m365.go b/connector_m365.go index bd85b35..b165742 100644 --- a/connector_m365.go +++ b/connector_m365.go @@ -52,6 +52,10 @@ type M365Connector struct { // State stateFile string state m365State + + // Fully alert tracking: conversationID → []alertID + fullyAlerts map[string][]string + fullyAlertsMu sync.Mutex } type m365State struct { @@ -77,10 +81,11 @@ func NewM365Connector(cfg M365Config, webhookFn func()) *M365Connector { stateFile := filepath.Join(home, ".message-center", "m365-state.json") c := &M365Connector{ - cfg: cfg, - webhookFn: webhookFn, - stop: make(chan struct{}), - stateFile: stateFile, + cfg: cfg, + webhookFn: webhookFn, + stop: make(chan struct{}), + stateFile: stateFile, + fullyAlerts: make(map[string][]string), } c.loadState() return c @@ -338,6 +343,10 @@ func (c *M365Connector) poll() { } func (c *M365Connector) sendM365Webhook(typ, summary string) { + c.sendM365WebhookRich(typ, summary, nil) +} + +func (c *M365Connector) sendM365WebhookRich(typ, summary string, messages interface{}) { webhookURL := c.cfg.WebhookURL if webhookURL == "" { webhookURL = config.Webhook.URL @@ -346,10 +355,17 @@ func (c *M365Connector) sendM365Webhook(typ, summary string) { return } - payload := map[string]string{ + payload := map[string]interface{}{ "event": "m365", "type": typ, - "summary": summary, + "summary": stripEmails(summary), + } + if messages != nil { + // Strip emails from message details before sending to LLM + sanitized, _ := json.Marshal(messages) + var clean interface{} + json.Unmarshal([]byte(stripEmails(string(sanitized))), &clean) + payload["messages"] = clean } data, _ := json.Marshal(payload) @@ -374,6 +390,152 @@ func (c *M365Connector) sendM365Webhook(typ, summary string) { resp.Body.Close() } +// --- Fully alert management --- + +// postFullyAlert sends an alert to the Fully tablet and returns the alert ID +func postFullyAlert(message, priority, group string) string { + payload, _ := json.Marshal(map[string]string{ + "message": message, + "priority": priority, + "group": group, + }) + resp, err := http.Post("http://localhost:9202/api/alerts", "application/json", bytes.NewReader(payload)) + if err != nil { + log.Printf("[m365] Fully alert error: %v", err) + return "" + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + var result struct { + ID string `json:"id"` + } + json.Unmarshal(body, &result) + log.Printf("[m365] Fully alert posted: %s", truncateStr(message, 80)) + return result.ID +} + +// removeFullyAlert deletes an alert from the Fully tablet +func removeFullyAlert(alertID string) { + req, _ := http.NewRequest("DELETE", "http://localhost:9202/api/alerts/"+alertID, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Printf("[m365] Fully alert delete error: %v", err) + return + } + resp.Body.Close() + log.Printf("[m365] Fully alert removed: %s", alertID) +} + +// m365Fallback builds a simple formatted message when LLM is unavailable +func m365Fallback(prefix, from, subject, content string) string { + text := subject + if text == "" { + text = truncateStr(content, 100) + } + return fmt.Sprintf("%s %s: %s", prefix, from, text) +} + +// summarizeM365 uses K2.5 completions API to generate a 1-sentence summary +func summarizeM365(prefix, from, subject, content string) string { + if !config.Triage.Enabled || config.Triage.Provider.APIKey == "" { + return m365Fallback(prefix, from, subject, content) + } + + // Build the input line + var input string + if subject != "" && content != "" { + input = fmt.Sprintf("Email from %s, Subject: %s, Body: %s", from, subject, truncateStr(content, 200)) + } else if subject != "" { + input = fmt.Sprintf("Email from %s, Subject: %s", from, subject) + } else if content != "" { + input = fmt.Sprintf("%s says: %s", from, truncateStr(content, 200)) + } else { + return m365Fallback(prefix, from, subject, content) + } + + // Few-shot completions prompt — K2.5 follows this pattern reliably + prompt := fmt.Sprintf(`Input: Jane says: Can we move the standup to 3pm tomorrow? +Alert: 💬 Jane wants to reschedule standup to 3pm tomorrow + +Input: Email from Win Alert, Subject: Vonahi NCA New Business, Body: Congratulations to Vishnu for the win. +Alert: 📧 Win Alert: Vonahi NCA new business closed with Vishnu + +Input: %s +Alert: %s`, input, prefix) + + reqBody := map[string]interface{}{ + "model": config.Triage.Provider.Model, + "prompt": prompt, + "temperature": 0.0, + "max_tokens": 40, + "stop": []string{"\n"}, + } + + data, _ := json.Marshal(reqBody) + apiURL := strings.TrimRight(config.Triage.Provider.BaseURL, "/") + "/completions" + req, err := http.NewRequest("POST", apiURL, bytes.NewReader(data)) + if err != nil { + return m365Fallback(prefix, from, subject, content) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+config.Triage.Provider.APIKey) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("[m365] Summary LLM error: %v", err) + return m365Fallback(prefix, from, subject, content) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + log.Printf("[m365] Summary LLM error %d: %s", resp.StatusCode, truncateStr(string(body), 100)) + return m365Fallback(prefix, from, subject, content) + } + + var apiResp struct { + Choices []struct { + Text string `json:"text"` + } `json:"choices"` + } + json.Unmarshal(body, &apiResp) + if len(apiResp.Choices) > 0 { + summary := strings.TrimSpace(apiResp.Choices[0].Text) + if summary != "" && !strings.HasPrefix(summary, "The user") { + return prefix + " " + summary + } + } + + return m365Fallback(prefix, from, subject, content) +} + +// trackFullyAlert records an alert ID for a conversation +func (c *M365Connector) trackFullyAlert(convID, alertID string) { + if alertID == "" { + return + } + c.fullyAlertsMu.Lock() + defer c.fullyAlertsMu.Unlock() + c.fullyAlerts[convID] = append(c.fullyAlerts[convID], alertID) +} + +// clearFullyAlerts removes all Fully alerts for a conversation (Johan replied) +func (c *M365Connector) clearFullyAlerts(convID string) { + c.fullyAlertsMu.Lock() + alerts := c.fullyAlerts[convID] + delete(c.fullyAlerts, convID) + c.fullyAlertsMu.Unlock() + + for _, id := range alerts { + removeFullyAlert(id) + } + if len(alerts) > 0 { + log.Printf("[m365] Cleared %d Fully alert(s) for conversation (Johan replied)", len(alerts)) + } +} + // --- Email poller --- func (c *M365Connector) pollEmail() bool { @@ -383,7 +545,7 @@ func (c *M365Connector) pollEmail() bool { return false } - u := "https://graph.microsoft.com/v1.0/me/messages?$top=20&$filter=isRead+eq+false&$select=subject,from,receivedDateTime,isRead,conversationId" + u := "https://graph.microsoft.com/v1.0/me/messages?$top=20&$filter=isRead+eq+false&$select=subject,from,receivedDateTime,isRead,conversationId,bodyPreview" req, _ := http.NewRequest("GET", u, nil) req.Header.Set("Authorization", "Bearer "+token) @@ -404,6 +566,7 @@ func (c *M365Connector) pollEmail() bool { Value []struct { ID string `json:"id"` Subject string `json:"subject"` + BodyPreview string `json:"bodyPreview"` ReceivedDateTime string `json:"receivedDateTime"` From struct { EmailAddress struct { @@ -425,8 +588,14 @@ func (c *M365Connector) pollEmail() bool { seenSet[id] = true } + type emailDetail struct { + From string `json:"from"` + Subject string `json:"subject"` + BodyPreview string `json:"bodyPreview"` + } var newEmails []string var summaries []string + var details []emailDetail for _, msg := range result.Value { if !seenSet[msg.ID] { newEmails = append(newEmails, msg.ID) @@ -435,6 +604,11 @@ func (c *M365Connector) pollEmail() bool { from = msg.From.EmailAddress.Address } summaries = append(summaries, fmt.Sprintf("%s: %s", from, msg.Subject)) + details = append(details, emailDetail{ + From: from, + Subject: msg.Subject, + BodyPreview: truncateStr(msg.BodyPreview, 200), + }) } } @@ -449,7 +623,12 @@ func (c *M365Connector) pollEmail() bool { if len(newEmails) > 0 { summary := fmt.Sprintf("%d new unread email(s): %s", len(newEmails), strings.Join(summaries, "; ")) log.Printf("[m365] %s", summary) - c.sendM365Webhook("email", summary) + c.sendM365WebhookRich("email", summary, details) + // Post AI-summarized alerts to Fully per email (grouped by sender) + for _, d := range details { + msg := summarizeM365("📧", d.From, d.Subject, d.BodyPreview) + postFullyAlert(msg, "info", "email:"+d.From) + } return true } @@ -459,9 +638,21 @@ func (c *M365Connector) pollEmail() bool { // --- Teams chat poller --- var htmlTagRe = regexp.MustCompile(`<[^>]*>`) +var emailRe = regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`) + +func stripEmails(s string) string { + return emailRe.ReplaceAllString(s, "[email]") +} func stripHTMLTags(s string) string { - return strings.TrimSpace(htmlTagRe.ReplaceAllString(s, "")) + s = htmlTagRe.ReplaceAllString(s, "") + s = strings.ReplaceAll(s, " ", " ") + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, """, "\"") + s = strings.ReplaceAll(s, "'", "'") + return strings.TrimSpace(s) } func (c *M365Connector) pollTeams() bool { @@ -498,47 +689,99 @@ func (c *M365Connector) pollTeams() bool { return false } + // Whitelisted Teams channels for Fully alerts (channel conv ID → display name) + // DMs (1:1 chats ending in @unq.gbl.spaces) are always included + teamsChannelWhitelist := map[string]string{ + // Product and Engineering Org + "19:o6FHAkOQFnCYaM9A9tvLZk-3d6PMahG9JDW_Vh54x8g1@thread.tacv2": "PE - All", + "19:a6cb2387d54a498f93699e07f07540a3@thread.tacv2": "PE - Leadership", + "19:b5937a229fe746eb8c287ae0e6cae49d@thread.tacv2": "Pratik Directs", + "19:e0d5a7a1a7174b47aa9f781a24bb2ba9@thread.tacv2": "PE - People Managers", + "19:Ysc-g52Y9D1v9MGC6AtBXC9FeOy3eWJcPzze17qcDBA1@thread.tacv2": "Cyber Resilience ENG", + // Kaseya Leadership Announcements + "19:V7R25uWAm-CHiqqvc1NBP36hfpa4NzwgNeSPNbhCDck1@thread.tacv2": "Leadership Announcements", + } + + type teamsDetail struct { + From string `json:"from"` + Content string `json:"content"` + ConvID string `json:"-"` + ChannelName string `json:"-"` + } hasNew := false var summaries []string + var details []teamsDetail for _, conv := range convResult.Conversations { if strings.HasPrefix(conv.ID, "48:") { continue } + // Check whitelist: allow DMs + group chats + whitelisted channels + // DMs: @unq.gbl.spaces or @unq + // Group chats: @thread.v2 or @thread.skype + // Channels (need whitelist): @thread.tacv2 + isDM := strings.Contains(conv.ID, "@unq.gbl.spaces") || strings.Contains(conv.ID, "@unq") + isGroupChat := strings.HasSuffix(conv.ID, "@thread.v2") || strings.HasSuffix(conv.ID, "@thread.skype") + channelName, isWhitelisted := teamsChannelWhitelist[conv.ID] + if !isDM && !isGroupChat && !isWhitelisted { + // Still track last seen to avoid replaying when whitelisted later + msgs := c.fetchTeamsMessages(token, conv.ID) + if msgs != nil && len(msgs) > 0 { + c.state.LastTeamsMsgs[conv.ID] = msgs[0].ID + } + continue + } + msgs := c.fetchTeamsMessages(token, conv.ID) if msgs == nil { continue } lastSeen := c.state.LastTeamsMsgs[conv.ID] - foundNew := false + johanReplied := false + var convDetails []teamsDetail for _, msg := range msgs { if msg.ID == lastSeen { break } - // Skip if sender is us - sender := msg.From - if strings.Contains(strings.ToLower(sender), strings.ToLower(c.cfg.UserEmail)) { - continue - } - if strings.EqualFold(sender, "Johan Jongsma") { + isJohan := strings.Contains(strings.ToLower(msg.From), strings.ToLower(c.cfg.UserEmail)) || + strings.EqualFold(msg.DisplayName, "Johan Jongsma") + + if isJohan { + johanReplied = true continue } + content := stripHTMLTags(msg.Content) if content == "" { continue } - summaries = append(summaries, fmt.Sprintf("%s: %s", msg.DisplayName, truncateStr(content, 80))) - foundNew = true + convDetails = append(convDetails, teamsDetail{ + From: msg.DisplayName, + Content: truncateStr(content, 300), + ConvID: conv.ID, + ChannelName: channelName, + }) } if len(msgs) > 0 { c.state.LastTeamsMsgs[conv.ID] = msgs[0].ID } - if foundNew { + // If Johan replied in this conversation, clear existing Fully alerts + if johanReplied { + c.clearFullyAlerts(conv.ID) + // Don't alert for messages in a conversation Johan already addressed + continue + } + + if len(convDetails) > 0 { + for _, d := range convDetails { + summaries = append(summaries, fmt.Sprintf("%s: %s", d.From, truncateStr(d.Content, 80))) + details = append(details, d) + } hasNew = true } } @@ -548,7 +791,16 @@ func (c *M365Connector) pollTeams() bool { if hasNew { summary := fmt.Sprintf("%d new Teams message(s): %s", len(summaries), strings.Join(summaries, "; ")) log.Printf("[m365] %s", summary) - c.sendM365Webhook("teams", summary) + c.sendM365WebhookRich("teams", summary, details) + // Post AI-summarized alerts to Fully per message + for _, d := range details { + msg := summarizeM365("💬", d.From, "", d.Content) + if d.ChannelName != "" { + msg = "[" + d.ChannelName + "] " + msg + } + alertID := postFullyAlert(msg, "info", d.ConvID) + c.trackFullyAlert(d.ConvID, alertID) + } return true } @@ -674,6 +926,68 @@ func (c *M365Connector) pollCalendar() bool { } log.Printf("[m365] %s", summary) - c.sendM365Webhook("calendar", summary) + + // Push next upcoming meeting to Fully dashboard + c.pushNextMeeting(result.Value) + + // Send calendar events in webhook for rich processing + type calDetail struct { + Subject string `json:"subject"` + Start string `json:"start"` + } + var calDetails []calDetail + for _, evt := range result.Value { + if !evt.IsCancelled { + calDetails = append(calDetails, calDetail{ + Subject: stripEmails(evt.Subject), + Start: evt.Start.DateTime, + }) + } + } + c.sendM365WebhookRich("calendar", summary, calDetails) return true } + +func (c *M365Connector) pushNextMeeting(events []struct { + Subject string `json:"subject"` + IsCancelled bool `json:"isCancelled"` + Start struct { + DateTime string `json:"dateTime"` + } `json:"start"` +}) { + now := time.Now() + for _, evt := range events { + if evt.IsCancelled { + continue + } + // Parse start time (Graph returns UTC without Z suffix) + startStr := evt.Start.DateTime + if !strings.HasSuffix(startStr, "Z") { + startStr += "Z" + } + start, err := time.Parse(time.RFC3339, startStr) + if err != nil { + start, err = time.Parse("2006-01-02T15:04:05Z", startStr) + if err != nil { + continue + } + } + if start.After(now) { + // Push to Fully dashboard + payload := map[string]string{ + "title": evt.Subject, + "time": start.Format(time.RFC3339), + "id": fmt.Sprintf("m365-%d", start.Unix()), + } + data, _ := json.Marshal(payload) + resp, err := http.Post("http://localhost:9202/api/meeting", "application/json", bytes.NewReader(data)) + if err != nil { + log.Printf("[m365] Failed to push meeting to Fully: %v", err) + } else { + resp.Body.Close() + log.Printf("[m365] Pushed next meeting to Fully: %s at %s", evt.Subject, start.Local().Format("3:04 PM")) + } + return + } + } +}