- 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
372 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|