import { NodeHtmlMarkdown } from 'node-html-markdown'; export interface CalendarEvent { id: string; subject: string; start: string; // "HH:MM" end: string; // "HH:MM" body: string; attendees: { name: string; email: string }[]; } interface AppTokenEntry { accessToken: string; expiresAt: number; // ms } let appTokenCache: AppTokenEntry | null = null; const tenantId = process.env.AZURE_TENANT_ID ?? ''; const graphClientId = process.env.AZURE_GRAPH_CLIENT_ID ?? ''; const graphClientSecret = process.env.AZURE_GRAPH_CLIENT_SECRET ?? ''; async function getAppToken(): Promise { if (appTokenCache && appTokenCache.expiresAt > Date.now() + 60_000) { return appTokenCache.accessToken; } if (!tenantId || !graphClientId || !graphClientSecret) { throw new Error(`Graph config incomplete: tenantId=${!!tenantId} clientId=${!!graphClientId} secret=${!!graphClientSecret}`); } const body = new URLSearchParams({ client_id: graphClientId, client_secret: graphClientSecret, grant_type: 'client_credentials', scope: 'https://graph.microsoft.com/.default', }); const res = await fetch( `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body }, ); if (!res.ok) { const detail = await res.text(); console.error(`[graph] app token exchange failed ${res.status}: ${detail}`); throw new Error(`Graph app token exchange failed (${res.status}): ${detail}`); } const json = await res.json() as { access_token: string; expires_in: number }; appTokenCache = { accessToken: json.access_token, expiresAt: Date.now() + json.expires_in * 1000, }; console.log(`[graph] app token obtained, expires_in=${json.expires_in}s`); return appTokenCache.accessToken; } function toHHMM(dateTime: string): string { // Graph returns local time when Prefer: outlook.timezone header is set return dateTime.slice(11, 16); } export async function getCalendarEvents( userEmail: string, date: string, ): Promise { console.log(`[graph] getCalendarEvents email=${userEmail} date=${date}`); const graphToken = await getAppToken(); const start = `${date}T00:00:00`; const end = `${date}T23:59:59`; const select = 'id,subject,start,end,body,attendees,isAllDay'; const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userEmail)}/calendarView` + `?startDateTime=${encodeURIComponent(start)}` + `&endDateTime=${encodeURIComponent(end)}` + `&$select=${select}` + `&$top=50`; const timezone = process.env.CALENDAR_TIMEZONE ?? 'Europe/Berlin'; const res = await fetch(url, { headers: { Authorization: `Bearer ${graphToken}`, 'Content-Type': 'application/json', 'Prefer': `outlook.timezone="${timezone}"`, }, }); if (!res.ok) { const detail = await res.text(); console.error(`[graph] calendarView failed ${res.status}: ${detail}`); throw new Error(`Graph calendarView failed (${res.status}): ${detail}`); } console.log(`[graph] calendarView OK`); const CAUTION_BANNER = /CAUTION:\s*This email is from outside the organization\.[^]*?content is safe\./gi; function cleanBody(raw: string, contentType: string): string { const md = contentType === 'html' ? NodeHtmlMarkdown.translate(raw) : raw; return md .replace(CAUTION_BANNER, '') .replace(/(?:\\?[-_=*~]){10,}/g, '\n\n---\n\n') .replace(/\n{3,}/g, '\n\n') .trim(); } type GraphEvent = { id: string; subject: string; start: { dateTime: string }; end: { dateTime: string }; body: { contentType: string; content: string }; isAllDay: boolean; attendees: { emailAddress: { name: string; address: string }; type: string }[]; }; const data = await res.json() as { value: GraphEvent[] }; return data.value .filter((e) => !e.isAllDay) .map((e) => ({ id: e.id, subject: e.subject ?? '', start: toHHMM(e.start.dateTime), end: toHHMM(e.end.dateTime), body: cleanBody(e.body?.content ?? '', e.body?.contentType ?? 'text'), attendees: (e.attendees ?? []) .filter((a) => a.emailAddress.address.toLowerCase() !== userEmail.toLowerCase()) .map((a) => ({ name: a.emailAddress.name, email: a.emailAddress.address })), })) .sort((a, b) => a.start.localeCompare(b.start)); }