wulf-pulse/lib/services/msgraph-client.ts
lorentz 3f6b13572e feat(08-01): add getUserPhotoBytes() to MsGraphClient
- New public method fetches binary photo from Graph /users/{id}/photo/$value
- Returns { bytes, contentType } on 200, null on 404 (no photo)
- Throws on other non-2xx for upstream caller to map to 502/503
- Reuses getToken() OAuth2 cache; no retry (best-effort per D-26)
- Existing methods (getToken, fetchJson, getUsers, etc.) untouched
2026-05-07 20:39:42 -04:00

479 lines
16 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;
}
/**
* POST /users/{user}/messages — search by sender and/or subject in a date range.
* Requires Mail.ReadWrite application permission.
*/
async searchMailboxMessages(options: {
userEmail: string;
fromAddress?: string;
subject?: string;
maxResults?: number;
}): Promise<{ id: string; subject: string; from: string; receivedDateTime: string; isRead: boolean }[]> {
// Exchange Online requires KQL $search for nested properties like from/emailAddress/address
const kqlParts: string[] = [];
if (options.fromAddress) kqlParts.push(`from:${options.fromAddress}`);
if (options.subject) kqlParts.push(`subject:${options.subject}`);
if (!kqlParts.length) throw new Error('At least fromAddress or subject required');
const top = Math.min(options.maxResults ?? 50, 100);
const searchVal = `"${kqlParts.join(' ')}"`;
const qs = `$search=${encodeURIComponent(searchVal)}&$select=id,subject,from,receivedDateTime,isRead&$top=${top}`;
const url = `/users/${encodeURIComponent(options.userEmail)}/messages?${qs}`;
const token = await this.getToken();
const res = await fetch(`https://graph.microsoft.com/v1.0${url}`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
ConsistencyLevel: 'eventual',
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Graph API error ${res.status}: ${text}`);
}
const data = await res.json();
return (data.value ?? []).map((m: any) => ({
id: m.id,
subject: m.subject ?? '',
from: m.from?.emailAddress?.address ?? '',
receivedDateTime: m.receivedDateTime ?? '',
isRead: m.isRead ?? false,
}));
}
/**
* DELETE /users/{user}/messages/{messageId}
* Permanently deletes a message. Requires Mail.ReadWrite application permission.
*/
async deleteMailboxMessage(userEmail: string, messageId: string): Promise<void> {
const token = await this.getToken();
const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userEmail)}/messages/${encodeURIComponent(messageId)}`;
const res = await fetch(url, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 204) return;
if (res.status === 404) return; // already gone
const text = await res.text();
throw new Error(`Graph delete failed ${res.status}: ${text}`);
}
/**
* Move a message to Deleted Items (soft delete — recoverable).
* Requires Mail.ReadWrite application permission.
*/
async moveToDeletedItems(userEmail: string, messageId: string): Promise<void> {
const token = await this.getToken();
const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userEmail)}/messages/${encodeURIComponent(messageId)}/move`;
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ destinationId: 'deleteditems' }),
});
if (res.ok) return;
if (res.status === 404) return;
const text = await res.text();
throw new Error(`Graph move failed ${res.status}: ${text}`);
}
/**
* Fetch the raw photo bytes for a Microsoft Graph user.
* Returns { bytes, contentType } on 200, null when the user has no photo (404),
* and throws on any other non-2xx response so the caller can map to 502/503.
*
* Does NOT reuse fetchJson (which is JSON-only); issues its own fetch for binary data.
* No retry logic — photo fetches are best-effort per D-26.
*/
async getUserPhotoBytes(userId: string): Promise<{ bytes: ArrayBuffer; contentType: string } | null> {
const token = await this.getToken();
const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userId)}/photo/$value`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 404) {
return null;
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Graph photo error ${res.status} for user ${userId}: ${text}`);
}
const bytes = await res.arrayBuffer();
const contentType = res.headers.get('content-type') ?? 'image/jpeg';
return { bytes, contentType };
}
/**
* 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;
}
}