wulf-pulse/lib/services/msgraph-client.ts
lorentz c518eefdb2 feat: Morning NOC Summary adaptive card for Teams
- Add MorningSummaryService with Zabbix aggregation and adaptive card builder
- Add webhook delivery system with Teams incoming webhooks
- Add admin UI at /admin/morning-summary for webhook/config management
- Add API routes: /send, /test, /webhooks, /webhooks/[id], /config, /history
- Register morning-summary cron job in SyncScheduler (Mon-Fri 6:30 AM)
- Add outages_only filter (Unavailable triggers only)
- Fix host resolution: use getTriggerEnabledHosts to exclude disabled hosts
- Fix resolved events: event.get value:1 scoped to window with r_eventid filter
- Remove emojis from fact rows and section headers in card
- Remove Open Zabbix button (duplicate of View Problems)
- Add migrations: morning_summary_config + morning_summaries tables
- Add outages_only column to morning_summary_config
2026-03-11 09:34:51 -04:00

372 lines
12 KiB
TypeScript

/**
* Microsoft Graph API Client
* Client credentials flow for application permissions
* Reports.Read.All, User.Read.All, Calendars.Read
*/
export interface GraphUser {
id: string;
displayName: string | null;
mail: string | null;
userPrincipalName: string | null;
jobTitle: string | null;
department: string | null;
accountEnabled: boolean | null;
}
export interface TeamsActivityRow {
userPrincipalName: string;
lastActivityDate: string;
teamChatMessageCount: number;
privateChatMessageCount: number;
callCount: number;
meetingCount: number;
meetingsOrganizedCount: number;
meetingsAttendedCount: number;
audioDurationSeconds: number;
}
export interface EmailActivityRow {
userPrincipalName: string;
lastActivityDate: string;
sendCount: number;
receiveCount: number;
readCount: number;
}
export interface UserMessage {
createdDateTime: string;
fromUserId: string | null;
}
export interface CalendarEvent {
id: string;
subject: string;
start: { dateTime: string; timeZone: string };
end: { dateTime: string; timeZone: string };
attendees: Array<{
emailAddress: { address: string; name: string };
type: string;
}>;
isOnlineMeeting: boolean;
isCancelled: boolean;
}
export interface MsGraphClientConfig {
tenantId: string;
clientId: string;
clientSecret: string;
}
export class MsGraphClient {
private config: MsGraphClientConfig;
private accessToken: string | null = null;
private tokenExpiry: number = 0;
constructor(config: MsGraphClientConfig) {
this.config = config;
}
private async getToken(): Promise<string> {
if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
return this.accessToken;
}
const url = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token`;
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
scope: 'https://graph.microsoft.com/.default',
});
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Graph token request failed: ${res.status} ${text}`);
}
const data = await res.json();
this.accessToken = data.access_token;
this.tokenExpiry = Date.now() + data.expires_in * 1000;
return this.accessToken!;
}
private async fetchJson<T>(path: string, retryCount = 0): Promise<T> {
const token = await this.getToken();
const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
});
// Retry on 429 rate limit — respect Retry-After header (minimum 30s)
if (res.status === 429 && retryCount < 4) {
const retryAfter = Math.max(30, parseInt(res.headers.get('Retry-After') || '30', 10));
await new Promise(r => setTimeout(r, retryAfter * 1000));
return this.fetchJson(path, retryCount + 1);
}
if (!res.ok) {
const text = await res.text();
throw new Error(`Graph API error ${res.status} for ${path}: ${text}`);
}
return res.json();
}
private async fetchCsv(path: string): Promise<string> {
const token = await this.getToken();
const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'text/csv',
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Graph API error ${res.status} for ${path}: ${text}`);
}
return res.text();
}
/**
* Parse CSV — handles BOM prefix on first header column
*/
private parseCsv(csv: string): Record<string, string>[] {
const lines = csv.split('\n').filter(l => l.trim());
if (lines.length < 2) return [];
// Strip BOM from first header if present
const rawHeaders = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, '').replace(/^\uFEFF/, ''));
const rows: Record<string, string>[] = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim().replace(/^"|"$/g, ''));
const row: Record<string, string> = {};
rawHeaders.forEach((h, idx) => {
row[h] = values[idx] ?? '';
});
rows.push(row);
}
return rows;
}
private parseInt0(v: string): number {
const n = parseInt(v);
return isNaN(n) ? 0 : n;
}
/**
* Get all users in the tenant (paginated)
*/
async getUsers(): Promise<GraphUser[]> {
const users: GraphUser[] = [];
let url: string | null = '/users?$select=id,displayName,mail,userPrincipalName,jobTitle,department,accountEnabled&$top=999';
while (url) {
const data: { value: GraphUser[]; '@odata.nextLink'?: string } = await this.fetchJson(url);
users.push(...data.value);
if (data['@odata.nextLink']) {
url = data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '');
} else {
url = null;
}
}
return users;
}
/**
* Get the tenant's verified email domains (used to identify external attendees)
*/
async getOrganizationDomains(): Promise<string[]> {
try {
const data = await this.fetchJson<{
value: Array<{ verifiedDomains: Array<{ name: string; isDefault: boolean }> }>;
}>('/organization?$select=verifiedDomains');
return (data.value[0]?.verifiedDomains ?? []).map(d => d.name.toLowerCase());
} catch {
return [];
}
}
/**
* Get Teams user activity report — includes audio duration in seconds
* period: 'D7' | 'D30' | 'D90'
*/
async getTeamsActivity(period: string): Promise<TeamsActivityRow[]> {
const csv = await this.fetchCsv(`/reports/getTeamsUserActivityUserDetail(period='${period}')`);
const rows = this.parseCsv(csv);
return rows
.filter(r => r['User Principal Name'])
.map(r => ({
userPrincipalName: r['User Principal Name'] || '',
lastActivityDate: r['Last Activity Date'] || '',
teamChatMessageCount: this.parseInt0(r['Team Chat Message Count']),
privateChatMessageCount: this.parseInt0(r['Private Chat Message Count']),
callCount: this.parseInt0(r['Call Count']),
meetingCount: this.parseInt0(r['Meeting Count']),
meetingsOrganizedCount: this.parseInt0(r['Meetings Organized Count']),
meetingsAttendedCount: this.parseInt0(r['Meetings Attended Count']),
// "Audio Duration In Seconds" is the pre-computed seconds column
audioDurationSeconds: this.parseInt0(r['Audio Duration In Seconds']),
}));
}
/**
* Get Email activity report
* period: 'D7' | 'D30' | 'D90'
*/
async getEmailActivity(period: string): Promise<EmailActivityRow[]> {
const csv = await this.fetchCsv(`/reports/getEmailActivityUserDetail(period='${period}')`);
const rows = this.parseCsv(csv);
return rows
.filter(r => r['User Principal Name'])
.map(r => ({
userPrincipalName: r['User Principal Name'] || '',
lastActivityDate: r['Last Activity Date'] || '',
sendCount: this.parseInt0(r['Send Count']),
receiveCount: this.parseInt0(r['Receive Count']),
readCount: this.parseInt0(r['Read Count']),
}));
}
/**
* Get messages sent by a user in a date range, across all their chats.
* Requires Chat.Read.All application permission.
*
* Strategy (based on Graph API docs):
* 1. GET /users/{id}/chats with $expand=lastMessagePreview to find recently active chats.
* Stop paging when the chat's last message preview is older than startDate.
* 2. For each recent chat, GET /chats/{id}/messages with:
* $filter=lastModifiedDateTime gt {start} and lastModifiedDateTime lt {end}
* $orderby=lastModifiedDateTime desc (newest first)
*
* Returns only messages sent by this user (from.user.id match).
*/
async getUserMessages(
userId: string,
startDate: Date,
endDate: Date
): Promise<UserMessage[]> {
const messages: UserMessage[] = [];
const startIso = startDate.toISOString().slice(0, 19) + 'Z';
const endIso = endDate.toISOString().slice(0, 19) + 'Z';
const startMs = startDate.getTime();
// Step 1: get chats sorted by most recent activity, stop when too old
const chatIds: string[] = [];
let chatUrl: string | null =
`/users/${encodeURIComponent(userId)}/chats` +
`?$expand=lastMessagePreview&$orderby=lastMessagePreview/createdDateTime desc&$top=50`;
while (chatUrl && chatIds.length < 200) {
const data: {
value: Array<{ id: string; lastMessagePreview?: { createdDateTime?: string } }>;
'@odata.nextLink'?: string;
} = await this.fetchJson(chatUrl);
let hitOldChat = false;
for (const chat of data.value) {
const lastMsgTime = chat.lastMessagePreview?.createdDateTime
? new Date(chat.lastMessagePreview.createdDateTime).getTime()
: null;
if (lastMsgTime !== null && lastMsgTime < startMs) {
hitOldChat = true;
break;
}
chatIds.push(chat.id);
}
chatUrl = hitOldChat
? null
: data['@odata.nextLink']
? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '')
: null;
}
// Step 2: for each recent chat, get messages in the date range
for (const chatId of chatIds) {
let msgUrl: string | null =
`/chats/${encodeURIComponent(chatId)}/messages` +
`?$filter=lastModifiedDateTime gt ${startIso} and lastModifiedDateTime lt ${endIso}` +
`&$orderby=lastModifiedDateTime desc&$top=50`;
let pageCount = 0;
try {
while (msgUrl && pageCount < 20) {
const data: {
value: Array<{ createdDateTime: string; from?: { user?: { id: string } } }>;
'@odata.nextLink'?: string;
} = await this.fetchJson(msgUrl);
pageCount++;
for (const msg of data.value) {
if (msg.from?.user?.id !== userId) continue;
messages.push({ createdDateTime: msg.createdDateTime, fromUserId: userId });
}
msgUrl = data['@odata.nextLink']
? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '')
: null;
}
} catch {
// Skip inaccessible chats (403 on meeting threads, 429 exhausted, etc.)
}
}
return messages;
}
/**
* Get calendar events for a user in a date range (paginated).
* Returns empty array and logs if the mailbox is not Exchange Online (graceful degradation).
*/
async getUserCalendarEvents(
userId: string,
startDate: Date,
endDate: Date
): Promise<CalendarEvent[]> {
const start = startDate.toISOString();
const end = endDate.toISOString();
const events: CalendarEvent[] = [];
let url: string | null =
`/users/${encodeURIComponent(userId)}/calendarView` +
`?startDateTime=${start}&endDateTime=${end}` +
`&$select=id,subject,start,end,attendees,isOnlineMeeting,isCancelled` +
`&$top=100`;
while (url) {
try {
const data: { value: CalendarEvent[]; '@odata.nextLink'?: string } =
await this.fetchJson(url);
events.push(...data.value.filter(e => !e.isCancelled));
url = data['@odata.nextLink']
? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '')
: null;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// Guests and on-prem mailboxes don't support REST API — skip silently
if (msg.includes('MailboxNotEnabledForRESTAPI') || msg.includes('404')) {
break;
}
throw err;
}
}
return events;
}
}