nothumanallowed 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.mjs CHANGED
@@ -52,6 +52,11 @@ const DEFAULT_CONFIG = {
52
52
  clientId: '',
53
53
  clientSecret: '',
54
54
  },
55
+ microsoft: {
56
+ clientId: '',
57
+ clientSecret: '',
58
+ tenantId: 'common',
59
+ },
55
60
  ops: {
56
61
  enabled: false,
57
62
  planTime: '07:00',
@@ -68,6 +73,15 @@ const DEFAULT_CONFIG = {
68
73
  terminal: true,
69
74
  },
70
75
  },
76
+ plugins: {
77
+ autoRun: true,
78
+ directory: '',
79
+ },
80
+ voice: {
81
+ preferWhisper: false,
82
+ speechSynthesis: true,
83
+ language: '',
84
+ },
71
85
  };
72
86
 
73
87
  /**
@@ -174,11 +188,20 @@ export function setConfigValue(key, value) {
174
188
  'knowledge': 'features.knowledgeEnabled',
175
189
  'google-client-id': 'google.clientId',
176
190
  'google-client-secret': 'google.clientSecret',
191
+ 'microsoft-client-id': 'microsoft.clientId',
192
+ 'microsoft-client-secret': 'microsoft.clientSecret',
193
+ 'microsoft-tenant': 'microsoft.tenantId',
194
+ 'microsoft-tenant-id': 'microsoft.tenantId',
177
195
  'plan-time': 'ops.planTime',
178
196
  'summary-time': 'ops.summaryTime',
179
197
  'meeting-alert': 'ops.meetingAlertMinutes',
180
198
  'telegram-webhook': 'ops.webhooks.telegram',
181
199
  'discord-webhook': 'ops.webhooks.discord',
200
+ 'plugin-autorun': 'plugins.autoRun',
201
+ 'plugin-dir': 'plugins.directory',
202
+ 'voice-whisper': 'voice.preferWhisper',
203
+ 'voice-speech': 'voice.speechSynthesis',
204
+ 'voice-language': 'voice.language',
182
205
  };
183
206
 
184
207
  const resolved = aliases[key] || key;
package/src/constants.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import os from 'os';
2
2
  import path from 'path';
3
3
 
4
- export const VERSION = '3.1.0';
4
+ export const VERSION = '5.0.0';
5
5
  export const BASE_URL = 'https://nothumanallowed.com/cli';
6
6
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
7
7
 
@@ -9,6 +9,7 @@ export const NHA_DIR = path.join(os.homedir(), '.nha');
9
9
  export const CORE_DIR = path.join(NHA_DIR, 'core');
10
10
  export const AGENTS_DIR = path.join(NHA_DIR, 'agents');
11
11
  export const EXTENSIONS_DIR = path.join(NHA_DIR, 'extensions');
12
+ export const PLUGINS_DIR = path.join(NHA_DIR, 'plugins');
12
13
  export const SESSIONS_DIR = path.join(NHA_DIR, 'sessions');
13
14
  export const MEMORY_DIR = path.join(NHA_DIR, 'memory');
14
15
  export const CONFIG_FILE = path.join(NHA_DIR, 'config.json');
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Unified Mail + Calendar router — routes to Google or Microsoft based on auth state.
3
+ *
4
+ * Detects which provider is authenticated and routes all mail/calendar calls
5
+ * through the appropriate service. If both are authenticated, prefers the
6
+ * provider specified in config, falling back to the first authenticated one.
7
+ *
8
+ * All functions return the same unified data shapes defined by google-gmail.mjs
9
+ * and google-calendar.mjs parsers (Microsoft services normalize to the same shape).
10
+ *
11
+ * Zero dependencies — pure routing layer.
12
+ */
13
+
14
+ import { getAuthenticatedProviders, loadTokens } from './token-store.mjs';
15
+
16
+ // Lazy imports to avoid loading both provider modules at startup
17
+ let _google = null;
18
+ let _microsoft = null;
19
+ let _googleCal = null;
20
+ let _microsoftCal = null;
21
+
22
+ async function getGoogleMail() {
23
+ if (!_google) _google = await import('./google-gmail.mjs');
24
+ return _google;
25
+ }
26
+
27
+ async function getMicrosoftMail() {
28
+ if (!_microsoft) _microsoft = await import('./microsoft-mail.mjs');
29
+ return _microsoft;
30
+ }
31
+
32
+ async function getGoogleCalendar() {
33
+ if (!_googleCal) _googleCal = await import('./google-calendar.mjs');
34
+ return _googleCal;
35
+ }
36
+
37
+ async function getMicrosoftCalendar() {
38
+ if (!_microsoftCal) _microsoftCal = await import('./microsoft-calendar.mjs');
39
+ return _microsoftCal;
40
+ }
41
+
42
+ /**
43
+ * Determine which mail provider to use.
44
+ * @param {object} config — NHA config
45
+ * @returns {'google' | 'microsoft' | null}
46
+ */
47
+ export function detectMailProvider(config) {
48
+ const providers = getAuthenticatedProviders();
49
+
50
+ // If a preferred provider is set in config and authenticated, use it
51
+ const preferred = config.ops?.mailProvider;
52
+ if (preferred && providers[preferred]) return preferred;
53
+
54
+ // Otherwise, use whichever is authenticated (Google first for backward compat)
55
+ if (providers.google) return 'google';
56
+ if (providers.microsoft) return 'microsoft';
57
+
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * Check if any mail provider is authenticated.
63
+ */
64
+ export function hasMailProvider() {
65
+ const providers = getAuthenticatedProviders();
66
+ return providers.google || providers.microsoft;
67
+ }
68
+
69
+ /**
70
+ * Get authentication status for display purposes.
71
+ */
72
+ export function getProviderStatus() {
73
+ const providers = getAuthenticatedProviders();
74
+ const result = [];
75
+
76
+ if (providers.google) {
77
+ const tokens = loadTokens('google');
78
+ result.push({ provider: 'google', email: tokens?.email || 'unknown', active: true });
79
+ }
80
+ if (providers.microsoft) {
81
+ const tokens = loadTokens('microsoft');
82
+ result.push({ provider: 'microsoft', email: tokens?.email || 'unknown', active: true });
83
+ }
84
+
85
+ return result;
86
+ }
87
+
88
+ // ── Mail Functions ─────────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * List messages matching a query.
92
+ */
93
+ export async function listMessages(config, query = '', maxResults = 20) {
94
+ const provider = detectMailProvider(config);
95
+ if (!provider) throw new Error('No mail provider authenticated. Run: nha google auth OR nha microsoft auth');
96
+
97
+ if (provider === 'microsoft') {
98
+ const ms = await getMicrosoftMail();
99
+ return ms.listMessages(config, query, maxResults);
100
+ }
101
+
102
+ const gm = await getGoogleMail();
103
+ return gm.listMessages(config, query || 'is:unread', maxResults);
104
+ }
105
+
106
+ /**
107
+ * Get a full message by ID.
108
+ */
109
+ export async function getMessage(config, messageId) {
110
+ const provider = detectMailProvider(config);
111
+ if (!provider) throw new Error('No mail provider authenticated.');
112
+
113
+ if (provider === 'microsoft') {
114
+ const ms = await getMicrosoftMail();
115
+ return ms.getMessage(config, messageId);
116
+ }
117
+
118
+ const gm = await getGoogleMail();
119
+ return gm.getMessage(config, messageId);
120
+ }
121
+
122
+ /**
123
+ * Get unread important emails (for daily planner).
124
+ */
125
+ export async function getUnreadImportant(config, maxResults = 30) {
126
+ const provider = detectMailProvider(config);
127
+ if (!provider) throw new Error('No mail provider authenticated.');
128
+
129
+ if (provider === 'microsoft') {
130
+ const ms = await getMicrosoftMail();
131
+ return ms.getUnreadImportant(config, maxResults);
132
+ }
133
+
134
+ const gm = await getGoogleMail();
135
+ return gm.getUnreadImportant(config, maxResults);
136
+ }
137
+
138
+ /**
139
+ * Get today's emails.
140
+ */
141
+ export async function getTodayEmails(config, maxResults = 50) {
142
+ const provider = detectMailProvider(config);
143
+ if (!provider) throw new Error('No mail provider authenticated.');
144
+
145
+ if (provider === 'microsoft') {
146
+ const ms = await getMicrosoftMail();
147
+ return ms.getTodayEmails(config, maxResults);
148
+ }
149
+
150
+ const gm = await getGoogleMail();
151
+ return gm.getTodayEmails(config, maxResults);
152
+ }
153
+
154
+ /**
155
+ * Send an email.
156
+ */
157
+ export async function sendEmail(config, to, subject, body, opts = {}) {
158
+ const provider = detectMailProvider(config);
159
+ if (!provider) throw new Error('No mail provider authenticated.');
160
+
161
+ if (provider === 'microsoft') {
162
+ const ms = await getMicrosoftMail();
163
+ return ms.sendEmail(config, to, subject, body, opts);
164
+ }
165
+
166
+ const gm = await getGoogleMail();
167
+ return gm.sendEmail(config, to, subject, body, opts);
168
+ }
169
+
170
+ /**
171
+ * Create a draft email.
172
+ */
173
+ export async function createDraft(config, to, subject, body) {
174
+ const provider = detectMailProvider(config);
175
+ if (!provider) throw new Error('No mail provider authenticated.');
176
+
177
+ if (provider === 'microsoft') {
178
+ const ms = await getMicrosoftMail();
179
+ return ms.createDraft(config, to, subject, body);
180
+ }
181
+
182
+ const gm = await getGoogleMail();
183
+ return gm.createDraft(config, to, subject, body);
184
+ }
185
+
186
+ /**
187
+ * Get user's email profile.
188
+ */
189
+ export async function getProfile(config) {
190
+ const provider = detectMailProvider(config);
191
+ if (!provider) throw new Error('No mail provider authenticated.');
192
+
193
+ if (provider === 'microsoft') {
194
+ const ms = await getMicrosoftMail();
195
+ return ms.getProfile(config);
196
+ }
197
+
198
+ const gm = await getGoogleMail();
199
+ return gm.getProfile(config);
200
+ }
201
+
202
+ // ── Calendar Functions ────────────────────────────────────────────────────
203
+
204
+ /**
205
+ * Get today's events from all calendars.
206
+ */
207
+ export async function getTodayEvents(config) {
208
+ const provider = detectMailProvider(config);
209
+ if (!provider) throw new Error('No mail provider authenticated.');
210
+
211
+ if (provider === 'microsoft') {
212
+ const ms = await getMicrosoftCalendar();
213
+ return ms.getTodayEvents(config);
214
+ }
215
+
216
+ const gc = await getGoogleCalendar();
217
+ return gc.getTodayEvents(config);
218
+ }
219
+
220
+ /**
221
+ * Get upcoming events (next N hours).
222
+ */
223
+ export async function getUpcomingEvents(config, hours = 2) {
224
+ const provider = detectMailProvider(config);
225
+ if (!provider) throw new Error('No mail provider authenticated.');
226
+
227
+ if (provider === 'microsoft') {
228
+ const ms = await getMicrosoftCalendar();
229
+ return ms.getUpcomingEvents(config, hours);
230
+ }
231
+
232
+ const gc = await getGoogleCalendar();
233
+ return gc.getUpcomingEvents(config, hours);
234
+ }
235
+
236
+ /**
237
+ * Get events for a specific date.
238
+ */
239
+ export async function getEventsForDate(config, date) {
240
+ const provider = detectMailProvider(config);
241
+ if (!provider) throw new Error('No mail provider authenticated.');
242
+
243
+ if (provider === 'microsoft') {
244
+ const ms = await getMicrosoftCalendar();
245
+ return ms.getEventsForDate(config, date);
246
+ }
247
+
248
+ const gc = await getGoogleCalendar();
249
+ return gc.getEventsForDate(config, date);
250
+ }
251
+
252
+ /**
253
+ * Create a new calendar event.
254
+ */
255
+ export async function createEvent(config, event, calendarId = 'primary') {
256
+ const provider = detectMailProvider(config);
257
+ if (!provider) throw new Error('No mail provider authenticated.');
258
+
259
+ if (provider === 'microsoft') {
260
+ const ms = await getMicrosoftCalendar();
261
+ return ms.createEvent(config, event, calendarId);
262
+ }
263
+
264
+ const gc = await getGoogleCalendar();
265
+ return gc.createEvent(config, event, calendarId);
266
+ }
267
+
268
+ /**
269
+ * Update an existing calendar event.
270
+ */
271
+ export async function updateEvent(config, calendarId, eventId, patch) {
272
+ const provider = detectMailProvider(config);
273
+ if (!provider) throw new Error('No mail provider authenticated.');
274
+
275
+ if (provider === 'microsoft') {
276
+ const ms = await getMicrosoftCalendar();
277
+ return ms.updateEvent(config, calendarId, eventId, patch);
278
+ }
279
+
280
+ const gc = await getGoogleCalendar();
281
+ return gc.updateEvent(config, calendarId, eventId, patch);
282
+ }
283
+
284
+ /**
285
+ * List events for a date range.
286
+ */
287
+ export async function listEvents(config, calendarId = 'primary', timeMin, timeMax) {
288
+ const provider = detectMailProvider(config);
289
+ if (!provider) throw new Error('No mail provider authenticated.');
290
+
291
+ if (provider === 'microsoft') {
292
+ const ms = await getMicrosoftCalendar();
293
+ return ms.listEvents(config, calendarId, timeMin, timeMax);
294
+ }
295
+
296
+ const gc = await getGoogleCalendar();
297
+ return gc.listEvents(config, calendarId, timeMin, timeMax);
298
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Outlook Calendar API wrapper via Microsoft Graph — zero dependencies.
3
+ * All calls via native fetch to Microsoft Graph REST API.
4
+ *
5
+ * Output format matches google-calendar.mjs parsed shape for unified consumption.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { getMicrosoftAccessToken } from './microsoft-oauth.mjs';
11
+ import { NHA_DIR } from '../constants.mjs';
12
+
13
+ const GRAPH_BASE = 'https://graph.microsoft.com/v1.0/me';
14
+ const CAL_DIR = path.join(NHA_DIR, 'ops', 'calendar');
15
+
16
+ /** Authenticated fetch with auto-retry on 401 */
17
+ async function graphFetch(config, urlPath, options = {}) {
18
+ const token = await getMicrosoftAccessToken(config);
19
+ const url = urlPath.startsWith('http') ? urlPath : `${GRAPH_BASE}${urlPath}`;
20
+
21
+ let res = await fetch(url, {
22
+ ...options,
23
+ headers: {
24
+ ...options.headers,
25
+ 'Authorization': `Bearer ${token}`,
26
+ 'Prefer': 'outlook.timezone="' + Intl.DateTimeFormat().resolvedOptions().timeZone + '"',
27
+ },
28
+ });
29
+
30
+ if (res.status === 401) {
31
+ const newToken = await getMicrosoftAccessToken(config);
32
+ res = await fetch(url, {
33
+ ...options,
34
+ headers: {
35
+ ...options.headers,
36
+ 'Authorization': `Bearer ${newToken}`,
37
+ 'Prefer': 'outlook.timezone="' + Intl.DateTimeFormat().resolvedOptions().timeZone + '"',
38
+ },
39
+ });
40
+ }
41
+
42
+ if (!res.ok) {
43
+ const err = await res.text();
44
+ throw new Error(`Microsoft Calendar API ${res.status}: ${err}`);
45
+ }
46
+
47
+ return res.json();
48
+ }
49
+
50
+ /**
51
+ * List all calendars the user has access to.
52
+ */
53
+ export async function listCalendars(config) {
54
+ const data = await graphFetch(config, '/calendars');
55
+ return (data.value || []).map(c => ({
56
+ id: c.id,
57
+ summary: c.name,
58
+ primary: c.isDefaultCalendar || false,
59
+ timeZone: c.owner?.address ? undefined : undefined, // Graph doesn't expose per-calendar TZ the same way
60
+ accessRole: c.canEdit ? 'writer' : 'reader',
61
+ }));
62
+ }
63
+
64
+ /**
65
+ * List events for a date range.
66
+ * @param {object} config
67
+ * @param {string} calendarId — calendar ID or 'primary' for default
68
+ * @param {Date} timeMin
69
+ * @param {Date} timeMax
70
+ * @returns {Promise<Array>} parsed events
71
+ */
72
+ export async function listEvents(config, calendarId = 'primary', timeMin, timeMax) {
73
+ const calPath = calendarId === 'primary'
74
+ ? '/calendar/calendarView'
75
+ : `/calendars/${calendarId}/calendarView`;
76
+
77
+ const params = new URLSearchParams({
78
+ startDateTime: timeMin.toISOString(),
79
+ endDateTime: timeMax.toISOString(),
80
+ '$top': '50',
81
+ '$orderby': 'start/dateTime',
82
+ '$select': 'id,subject,body,location,start,end,isAllDay,attendees,organizer,onlineMeeting,webLink,showAs,responseStatus',
83
+ });
84
+
85
+ const data = await graphFetch(config, `${calPath}?${params}`);
86
+ const events = (data.value || []).map(parseEvent);
87
+
88
+ // Cache events
89
+ cacheEvents(timeMin, events);
90
+ return events;
91
+ }
92
+
93
+ /**
94
+ * Get today's events from all calendars.
95
+ */
96
+ export async function getTodayEvents(config) {
97
+ const now = new Date();
98
+ const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
99
+ const endOfDay = new Date(startOfDay.getTime() + 86400000);
100
+
101
+ const calendars = await listCalendars(config);
102
+ const allEvents = [];
103
+
104
+ for (const cal of calendars) {
105
+ try {
106
+ const events = await listEvents(config, cal.id, startOfDay, endOfDay);
107
+ for (const e of events) {
108
+ e.calendarName = cal.summary;
109
+ allEvents.push(e);
110
+ }
111
+ } catch { /* skip failed calendars */ }
112
+ }
113
+
114
+ // Sort by start time
115
+ allEvents.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
116
+ return allEvents;
117
+ }
118
+
119
+ /**
120
+ * Get events for a specific date.
121
+ */
122
+ export async function getEventsForDate(config, date) {
123
+ const d = new Date(date);
124
+ const startOfDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
125
+ const endOfDay = new Date(startOfDay.getTime() + 86400000);
126
+ return listEvents(config, 'primary', startOfDay, endOfDay);
127
+ }
128
+
129
+ /**
130
+ * Get upcoming events (next N hours).
131
+ * @param {number} hours — look-ahead hours (default 2)
132
+ */
133
+ export async function getUpcomingEvents(config, hours = 2) {
134
+ const now = new Date();
135
+ const end = new Date(now.getTime() + hours * 3600000);
136
+ return listEvents(config, 'primary', now, end);
137
+ }
138
+
139
+ /**
140
+ * Create a new event.
141
+ * @param {object} config
142
+ * @param {object} event — { summary, start, end, description, location, attendees }
143
+ * @param {string} calendarId
144
+ */
145
+ export async function createEvent(config, event, calendarId = 'primary') {
146
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
147
+
148
+ const startStr = String(event.start);
149
+ const endStr = String(event.end);
150
+
151
+ // Normalize datetime — Microsoft Graph expects ISO format or { dateTime, timeZone }
152
+ const startDT = (startStr.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(startStr))
153
+ ? new Date(startStr).toISOString().replace('Z', '')
154
+ : startStr;
155
+ const endDT = (endStr.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(endStr))
156
+ ? new Date(endStr).toISOString().replace('Z', '')
157
+ : endStr;
158
+
159
+ const body = {
160
+ subject: event.summary,
161
+ body: {
162
+ contentType: 'Text',
163
+ content: event.description || '',
164
+ },
165
+ start: { dateTime: startDT, timeZone: tz },
166
+ end: { dateTime: endDT, timeZone: tz },
167
+ };
168
+
169
+ if (event.location) {
170
+ body.location = { displayName: event.location };
171
+ }
172
+
173
+ if (event.attendees?.length) {
174
+ body.attendees = event.attendees.map(email => ({
175
+ emailAddress: { address: email },
176
+ type: 'required',
177
+ }));
178
+ }
179
+
180
+ const calPath = calendarId === 'primary'
181
+ ? '/calendar/events'
182
+ : `/calendars/${calendarId}/events`;
183
+
184
+ return graphFetch(config, calPath, {
185
+ method: 'POST',
186
+ headers: { 'Content-Type': 'application/json' },
187
+ body: JSON.stringify(body),
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Update an existing event (partial).
193
+ */
194
+ export async function updateEvent(config, calendarId, eventId, patch) {
195
+ // Translate Google-style patch to Microsoft Graph format
196
+ const body = {};
197
+
198
+ if (patch.summary) body.subject = patch.summary;
199
+ if (patch.description) body.body = { contentType: 'Text', content: patch.description };
200
+ if (patch.location) body.location = { displayName: patch.location };
201
+
202
+ if (patch.start) {
203
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
204
+ if (typeof patch.start === 'object' && patch.start.dateTime) {
205
+ body.start = { dateTime: patch.start.dateTime.replace('Z', ''), timeZone: tz };
206
+ } else {
207
+ body.start = { dateTime: new Date(patch.start).toISOString().replace('Z', ''), timeZone: tz };
208
+ }
209
+ }
210
+
211
+ if (patch.end) {
212
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
213
+ if (typeof patch.end === 'object' && patch.end.dateTime) {
214
+ body.end = { dateTime: patch.end.dateTime.replace('Z', ''), timeZone: tz };
215
+ } else {
216
+ body.end = { dateTime: new Date(patch.end).toISOString().replace('Z', ''), timeZone: tz };
217
+ }
218
+ }
219
+
220
+ const calPath = calendarId === 'primary'
221
+ ? `/calendar/events/${eventId}`
222
+ : `/calendars/${calendarId}/events/${eventId}`;
223
+
224
+ return graphFetch(config, calPath, {
225
+ method: 'PATCH',
226
+ headers: { 'Content-Type': 'application/json' },
227
+ body: JSON.stringify(body),
228
+ });
229
+ }
230
+
231
+ // ── Event Parser ───────────────────────────────────────────────────────────
232
+
233
+ /**
234
+ * Parse Microsoft Graph event to unified format matching Google Calendar output.
235
+ */
236
+ function parseEvent(raw) {
237
+ const isAllDay = raw.isAllDay || false;
238
+
239
+ // Microsoft returns { dateTime, timeZone } objects for start/end
240
+ let start = '';
241
+ let end = '';
242
+
243
+ if (isAllDay) {
244
+ // All-day events: use date portion only (YYYY-MM-DD)
245
+ start = raw.start?.dateTime?.split('T')[0] || '';
246
+ end = raw.end?.dateTime?.split('T')[0] || '';
247
+ } else {
248
+ // Timed events: use full ISO datetime
249
+ start = raw.start?.dateTime || '';
250
+ end = raw.end?.dateTime || '';
251
+ }
252
+
253
+ // Build online meeting link (Teams or other)
254
+ const meetingLink = raw.onlineMeeting?.joinUrl || '';
255
+
256
+ return {
257
+ id: raw.id,
258
+ summary: raw.subject || '(no title)',
259
+ description: raw.body?.content
260
+ ? raw.body.content
261
+ .replace(/<[^>]+>/g, ' ')
262
+ .replace(/&nbsp;/g, ' ')
263
+ .replace(/\s+/g, ' ')
264
+ .trim()
265
+ .slice(0, 2000)
266
+ : '',
267
+ location: raw.location?.displayName || '',
268
+ start,
269
+ end,
270
+ isAllDay,
271
+ status: raw.showAs || 'busy',
272
+ attendees: (raw.attendees || []).map(a => ({
273
+ email: a.emailAddress?.address || '',
274
+ name: a.emailAddress?.name || '',
275
+ responseStatus: mapResponseStatus(a.status?.response),
276
+ self: false,
277
+ })),
278
+ organizer: raw.organizer?.emailAddress?.address || '',
279
+ hangoutLink: meetingLink, // unified field for any online meeting link
280
+ htmlLink: raw.webLink || '',
281
+ calendarName: '',
282
+ provider: 'microsoft',
283
+ };
284
+ }
285
+
286
+ /**
287
+ * Map Microsoft response status to Google-compatible values.
288
+ */
289
+ function mapResponseStatus(msStatus) {
290
+ const map = {
291
+ none: 'needsAction',
292
+ organizer: 'accepted',
293
+ tentativelyAccepted: 'tentative',
294
+ accepted: 'accepted',
295
+ declined: 'declined',
296
+ notResponded: 'needsAction',
297
+ };
298
+ return map[msStatus] || 'needsAction';
299
+ }
300
+
301
+ // ── Local Cache ────────────────────────────────────────────────────────────
302
+
303
+ function cacheEvents(date, events) {
304
+ fs.mkdirSync(CAL_DIR, { recursive: true });
305
+ const dateStr = date instanceof Date ? date.toISOString().split('T')[0] : String(date);
306
+ const file = path.join(CAL_DIR, `ms-events-${dateStr}.json`);
307
+ fs.writeFileSync(file, JSON.stringify(events, null, 2), { mode: 0o600 });
308
+ }
309
+
310
+ /**
311
+ * Load cached events for a date.
312
+ */
313
+ export function loadCachedEvents(date) {
314
+ const dateStr = date instanceof Date ? date.toISOString().split('T')[0] : String(date);
315
+ const file = path.join(CAL_DIR, `ms-events-${dateStr}.json`);
316
+ if (!fs.existsSync(file)) return null;
317
+ try { return JSON.parse(fs.readFileSync(file, 'utf-8')); }
318
+ catch { return null; }
319
+ }