chore: auto-commit uncommitted changes
This commit is contained in:
parent
3e8b3000fe
commit
8d8a1516bd
|
|
@ -52,6 +52,10 @@ type M365Connector struct {
|
||||||
// State
|
// State
|
||||||
stateFile string
|
stateFile string
|
||||||
state m365State
|
state m365State
|
||||||
|
|
||||||
|
// Fully alert tracking: conversationID → []alertID
|
||||||
|
fullyAlerts map[string][]string
|
||||||
|
fullyAlertsMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type m365State struct {
|
type m365State struct {
|
||||||
|
|
@ -77,10 +81,11 @@ func NewM365Connector(cfg M365Config, webhookFn func()) *M365Connector {
|
||||||
stateFile := filepath.Join(home, ".message-center", "m365-state.json")
|
stateFile := filepath.Join(home, ".message-center", "m365-state.json")
|
||||||
|
|
||||||
c := &M365Connector{
|
c := &M365Connector{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
webhookFn: webhookFn,
|
webhookFn: webhookFn,
|
||||||
stop: make(chan struct{}),
|
stop: make(chan struct{}),
|
||||||
stateFile: stateFile,
|
stateFile: stateFile,
|
||||||
|
fullyAlerts: make(map[string][]string),
|
||||||
}
|
}
|
||||||
c.loadState()
|
c.loadState()
|
||||||
return c
|
return c
|
||||||
|
|
@ -338,6 +343,10 @@ func (c *M365Connector) poll() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *M365Connector) sendM365Webhook(typ, summary string) {
|
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
|
webhookURL := c.cfg.WebhookURL
|
||||||
if webhookURL == "" {
|
if webhookURL == "" {
|
||||||
webhookURL = config.Webhook.URL
|
webhookURL = config.Webhook.URL
|
||||||
|
|
@ -346,10 +355,17 @@ func (c *M365Connector) sendM365Webhook(typ, summary string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]string{
|
payload := map[string]interface{}{
|
||||||
"event": "m365",
|
"event": "m365",
|
||||||
"type": typ,
|
"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)
|
data, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
|
@ -374,6 +390,152 @@ func (c *M365Connector) sendM365Webhook(typ, summary string) {
|
||||||
resp.Body.Close()
|
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 ---
|
// --- Email poller ---
|
||||||
|
|
||||||
func (c *M365Connector) pollEmail() bool {
|
func (c *M365Connector) pollEmail() bool {
|
||||||
|
|
@ -383,7 +545,7 @@ func (c *M365Connector) pollEmail() bool {
|
||||||
return false
|
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, _ := http.NewRequest("GET", u, nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
|
@ -404,6 +566,7 @@ func (c *M365Connector) pollEmail() bool {
|
||||||
Value []struct {
|
Value []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
|
BodyPreview string `json:"bodyPreview"`
|
||||||
ReceivedDateTime string `json:"receivedDateTime"`
|
ReceivedDateTime string `json:"receivedDateTime"`
|
||||||
From struct {
|
From struct {
|
||||||
EmailAddress struct {
|
EmailAddress struct {
|
||||||
|
|
@ -425,8 +588,14 @@ func (c *M365Connector) pollEmail() bool {
|
||||||
seenSet[id] = true
|
seenSet[id] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type emailDetail struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
BodyPreview string `json:"bodyPreview"`
|
||||||
|
}
|
||||||
var newEmails []string
|
var newEmails []string
|
||||||
var summaries []string
|
var summaries []string
|
||||||
|
var details []emailDetail
|
||||||
for _, msg := range result.Value {
|
for _, msg := range result.Value {
|
||||||
if !seenSet[msg.ID] {
|
if !seenSet[msg.ID] {
|
||||||
newEmails = append(newEmails, msg.ID)
|
newEmails = append(newEmails, msg.ID)
|
||||||
|
|
@ -435,6 +604,11 @@ func (c *M365Connector) pollEmail() bool {
|
||||||
from = msg.From.EmailAddress.Address
|
from = msg.From.EmailAddress.Address
|
||||||
}
|
}
|
||||||
summaries = append(summaries, fmt.Sprintf("%s: %s", from, msg.Subject))
|
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 {
|
if len(newEmails) > 0 {
|
||||||
summary := fmt.Sprintf("%d new unread email(s): %s", len(newEmails), strings.Join(summaries, "; "))
|
summary := fmt.Sprintf("%d new unread email(s): %s", len(newEmails), strings.Join(summaries, "; "))
|
||||||
log.Printf("[m365] %s", summary)
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -459,9 +638,21 @@ func (c *M365Connector) pollEmail() bool {
|
||||||
// --- Teams chat poller ---
|
// --- Teams chat poller ---
|
||||||
|
|
||||||
var htmlTagRe = regexp.MustCompile(`<[^>]*>`)
|
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 {
|
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 {
|
func (c *M365Connector) pollTeams() bool {
|
||||||
|
|
@ -498,47 +689,99 @@ func (c *M365Connector) pollTeams() bool {
|
||||||
return false
|
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
|
hasNew := false
|
||||||
var summaries []string
|
var summaries []string
|
||||||
|
var details []teamsDetail
|
||||||
|
|
||||||
for _, conv := range convResult.Conversations {
|
for _, conv := range convResult.Conversations {
|
||||||
if strings.HasPrefix(conv.ID, "48:") {
|
if strings.HasPrefix(conv.ID, "48:") {
|
||||||
continue
|
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)
|
msgs := c.fetchTeamsMessages(token, conv.ID)
|
||||||
if msgs == nil {
|
if msgs == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSeen := c.state.LastTeamsMsgs[conv.ID]
|
lastSeen := c.state.LastTeamsMsgs[conv.ID]
|
||||||
foundNew := false
|
johanReplied := false
|
||||||
|
var convDetails []teamsDetail
|
||||||
|
|
||||||
for _, msg := range msgs {
|
for _, msg := range msgs {
|
||||||
if msg.ID == lastSeen {
|
if msg.ID == lastSeen {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// Skip if sender is us
|
isJohan := strings.Contains(strings.ToLower(msg.From), strings.ToLower(c.cfg.UserEmail)) ||
|
||||||
sender := msg.From
|
strings.EqualFold(msg.DisplayName, "Johan Jongsma")
|
||||||
if strings.Contains(strings.ToLower(sender), strings.ToLower(c.cfg.UserEmail)) {
|
|
||||||
continue
|
if isJohan {
|
||||||
}
|
johanReplied = true
|
||||||
if strings.EqualFold(sender, "Johan Jongsma") {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
content := stripHTMLTags(msg.Content)
|
content := stripHTMLTags(msg.Content)
|
||||||
if content == "" {
|
if content == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
summaries = append(summaries, fmt.Sprintf("%s: %s", msg.DisplayName, truncateStr(content, 80)))
|
convDetails = append(convDetails, teamsDetail{
|
||||||
foundNew = true
|
From: msg.DisplayName,
|
||||||
|
Content: truncateStr(content, 300),
|
||||||
|
ConvID: conv.ID,
|
||||||
|
ChannelName: channelName,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(msgs) > 0 {
|
if len(msgs) > 0 {
|
||||||
c.state.LastTeamsMsgs[conv.ID] = msgs[0].ID
|
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
|
hasNew = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -548,7 +791,16 @@ func (c *M365Connector) pollTeams() bool {
|
||||||
if hasNew {
|
if hasNew {
|
||||||
summary := fmt.Sprintf("%d new Teams message(s): %s", len(summaries), strings.Join(summaries, "; "))
|
summary := fmt.Sprintf("%d new Teams message(s): %s", len(summaries), strings.Join(summaries, "; "))
|
||||||
log.Printf("[m365] %s", summary)
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -674,6 +926,68 @@ func (c *M365Connector) pollCalendar() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[m365] %s", summary)
|
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
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue