137 lines
4.4 KiB
TypeScript
137 lines
4.4 KiB
TypeScript
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<string> {
|
|
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<CalendarEvent[]> {
|
|
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));
|
|
}
|