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/package.json +1 -1
- package/src/cli.mjs +70 -2
- package/src/commands/chat.mjs +2 -6
- package/src/commands/microsoft-auth.mjs +29 -0
- package/src/commands/plugin.mjs +481 -0
- package/src/commands/ui.mjs +6 -2
- package/src/commands/voice.mjs +845 -0
- package/src/config.mjs +23 -0
- package/src/constants.mjs +2 -1
- package/src/services/mail-router.mjs +298 -0
- package/src/services/microsoft-calendar.mjs +319 -0
- package/src/services/microsoft-mail.mjs +308 -0
- package/src/services/microsoft-oauth.mjs +345 -0
- package/src/services/ops-daemon.mjs +159 -5
- package/src/services/ops-pipeline.mjs +7 -8
- package/src/services/token-store.mjs +41 -14
- package/src/services/web-ui.mjs +187 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outlook Mail API wrapper via Microsoft Graph — zero dependencies.
|
|
3
|
+
* All calls via native fetch to Microsoft Graph REST API.
|
|
4
|
+
* Auto-refreshes tokens on 401.
|
|
5
|
+
*
|
|
6
|
+
* Output format matches google-gmail.mjs parsed shape for unified consumption.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { getMicrosoftAccessToken } from './microsoft-oauth.mjs';
|
|
12
|
+
import { NHA_DIR } from '../constants.mjs';
|
|
13
|
+
|
|
14
|
+
const GRAPH_BASE = 'https://graph.microsoft.com/v1.0/me';
|
|
15
|
+
const MAIL_DIR = path.join(NHA_DIR, 'ops', 'mail');
|
|
16
|
+
const INBOX_DIR = path.join(MAIL_DIR, 'inbox');
|
|
17
|
+
|
|
18
|
+
/** Authenticated fetch with auto-retry on 401 */
|
|
19
|
+
async function graphFetch(config, urlPath, options = {}) {
|
|
20
|
+
const token = await getMicrosoftAccessToken(config);
|
|
21
|
+
const url = urlPath.startsWith('http') ? urlPath : `${GRAPH_BASE}${urlPath}`;
|
|
22
|
+
|
|
23
|
+
let res = await fetch(url, {
|
|
24
|
+
...options,
|
|
25
|
+
headers: {
|
|
26
|
+
...options.headers,
|
|
27
|
+
'Authorization': `Bearer ${token}`,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Retry once on 401 (token may have expired between check and request)
|
|
32
|
+
if (res.status === 401) {
|
|
33
|
+
const newToken = await getMicrosoftAccessToken(config);
|
|
34
|
+
res = await fetch(url, {
|
|
35
|
+
...options,
|
|
36
|
+
headers: {
|
|
37
|
+
...options.headers,
|
|
38
|
+
'Authorization': `Bearer ${newToken}`,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
const err = await res.text();
|
|
45
|
+
throw new Error(`Microsoft Graph API ${res.status}: ${err}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return res.json();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* List messages matching a query.
|
|
53
|
+
* @param {object} config
|
|
54
|
+
* @param {string} query — Microsoft Graph $search or $filter compatible string.
|
|
55
|
+
* Supports natural language search (e.g., "from:boss@company.com subject:report").
|
|
56
|
+
* @param {number} maxResults — max messages (default 20)
|
|
57
|
+
* @returns {Promise<Array<object>>} parsed messages
|
|
58
|
+
*/
|
|
59
|
+
export async function listMessages(config, query = '', maxResults = 20) {
|
|
60
|
+
const params = new URLSearchParams({
|
|
61
|
+
'$top': String(maxResults),
|
|
62
|
+
'$orderby': 'receivedDateTime desc',
|
|
63
|
+
'$select': 'id,conversationId,from,toRecipients,ccRecipients,subject,receivedDateTime,bodyPreview,body,isRead,importance,flag,webLink,hasAttachments',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Microsoft Graph $search uses KQL (Keyword Query Language)
|
|
67
|
+
if (query) {
|
|
68
|
+
params.set('$search', `"${query}"`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const data = await graphFetch(config, `/messages?${params}`);
|
|
72
|
+
return (data.value || []).map(parseMessage);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get a full message by ID.
|
|
77
|
+
* @param {object} config
|
|
78
|
+
* @param {string} messageId
|
|
79
|
+
* @returns {Promise<object>} parsed message
|
|
80
|
+
*/
|
|
81
|
+
export async function getMessage(config, messageId) {
|
|
82
|
+
const data = await graphFetch(config, `/messages/${messageId}`);
|
|
83
|
+
return parseMessage(data);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get unread important emails (for daily planner).
|
|
88
|
+
* @param {object} config
|
|
89
|
+
* @param {number} maxResults
|
|
90
|
+
* @returns {Promise<Array>} parsed messages
|
|
91
|
+
*/
|
|
92
|
+
export async function getUnreadImportant(config, maxResults = 30) {
|
|
93
|
+
const params = new URLSearchParams({
|
|
94
|
+
'$top': String(maxResults),
|
|
95
|
+
'$orderby': 'receivedDateTime desc',
|
|
96
|
+
'$filter': 'isRead eq false',
|
|
97
|
+
'$select': 'id,conversationId,from,toRecipients,ccRecipients,subject,receivedDateTime,bodyPreview,body,isRead,importance,flag,webLink,hasAttachments',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const data = await graphFetch(config, `/messages?${params}`);
|
|
101
|
+
const messages = (data.value || []).map(parseMessage);
|
|
102
|
+
|
|
103
|
+
// Cache messages locally
|
|
104
|
+
cacheMessages(messages);
|
|
105
|
+
return messages;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get emails from today (read + unread).
|
|
110
|
+
*/
|
|
111
|
+
export async function getTodayEmails(config, maxResults = 50) {
|
|
112
|
+
const today = new Date();
|
|
113
|
+
today.setHours(0, 0, 0, 0);
|
|
114
|
+
const todayISO = today.toISOString();
|
|
115
|
+
|
|
116
|
+
const params = new URLSearchParams({
|
|
117
|
+
'$top': String(maxResults),
|
|
118
|
+
'$orderby': 'receivedDateTime desc',
|
|
119
|
+
'$filter': `receivedDateTime ge ${todayISO}`,
|
|
120
|
+
'$select': 'id,conversationId,from,toRecipients,ccRecipients,subject,receivedDateTime,bodyPreview,body,isRead,importance,flag,webLink,hasAttachments',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const data = await graphFetch(config, `/messages?${params}`);
|
|
124
|
+
const messages = (data.value || []).map(parseMessage);
|
|
125
|
+
|
|
126
|
+
cacheMessages(messages);
|
|
127
|
+
return messages;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Send an email via Microsoft Graph.
|
|
132
|
+
* @param {object} config
|
|
133
|
+
* @param {string} to — recipient email
|
|
134
|
+
* @param {string} subject
|
|
135
|
+
* @param {string} body — plain text body
|
|
136
|
+
* @param {object} opts — { cc, bcc, replyToMessageId, threadId }
|
|
137
|
+
*/
|
|
138
|
+
export async function sendEmail(config, to, subject, body, opts = {}) {
|
|
139
|
+
if (opts.replyToMessageId) {
|
|
140
|
+
// Reply to existing message
|
|
141
|
+
const replyBody = {
|
|
142
|
+
message: {
|
|
143
|
+
toRecipients: [{ emailAddress: { address: to } }],
|
|
144
|
+
},
|
|
145
|
+
comment: body,
|
|
146
|
+
};
|
|
147
|
+
return graphFetch(config, `/messages/${opts.replyToMessageId}/reply`, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: { 'Content-Type': 'application/json' },
|
|
150
|
+
body: JSON.stringify(replyBody),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const message = {
|
|
155
|
+
message: {
|
|
156
|
+
subject,
|
|
157
|
+
body: {
|
|
158
|
+
contentType: 'Text',
|
|
159
|
+
content: body,
|
|
160
|
+
},
|
|
161
|
+
toRecipients: to.split(',').map(addr => ({
|
|
162
|
+
emailAddress: { address: addr.trim() },
|
|
163
|
+
})),
|
|
164
|
+
},
|
|
165
|
+
saveToSentItems: true,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
if (opts.cc) {
|
|
169
|
+
message.message.ccRecipients = opts.cc.split(',').map(addr => ({
|
|
170
|
+
emailAddress: { address: addr.trim() },
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (opts.bcc) {
|
|
175
|
+
message.message.bccRecipients = opts.bcc.split(',').map(addr => ({
|
|
176
|
+
emailAddress: { address: addr.trim() },
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return graphFetch(config, '/sendMail', {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: { 'Content-Type': 'application/json' },
|
|
183
|
+
body: JSON.stringify(message),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Create a draft email.
|
|
189
|
+
*/
|
|
190
|
+
export async function createDraft(config, to, subject, body) {
|
|
191
|
+
const draft = {
|
|
192
|
+
subject,
|
|
193
|
+
body: {
|
|
194
|
+
contentType: 'Text',
|
|
195
|
+
content: body,
|
|
196
|
+
},
|
|
197
|
+
toRecipients: to.split(',').map(addr => ({
|
|
198
|
+
emailAddress: { address: addr.trim() },
|
|
199
|
+
})),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return graphFetch(config, '/messages', {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'Content-Type': 'application/json' },
|
|
205
|
+
body: JSON.stringify(draft),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get user's email profile.
|
|
211
|
+
*/
|
|
212
|
+
export async function getProfile(config) {
|
|
213
|
+
const data = await graphFetch(config, '');
|
|
214
|
+
return {
|
|
215
|
+
emailAddress: data.mail || data.userPrincipalName || '',
|
|
216
|
+
displayName: data.displayName || '',
|
|
217
|
+
id: data.id || '',
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Message Parser ─────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Parse Microsoft Graph message to unified format matching Gmail parser output.
|
|
225
|
+
*/
|
|
226
|
+
function parseMessage(raw) {
|
|
227
|
+
const from = raw.from?.emailAddress
|
|
228
|
+
? `${raw.from.emailAddress.name || ''} <${raw.from.emailAddress.address}>`
|
|
229
|
+
: '';
|
|
230
|
+
|
|
231
|
+
const to = (raw.toRecipients || [])
|
|
232
|
+
.map(r => `${r.emailAddress?.name || ''} <${r.emailAddress?.address}>`)
|
|
233
|
+
.join(', ');
|
|
234
|
+
|
|
235
|
+
// Extract plain text body (Graph returns HTML by default)
|
|
236
|
+
let body = '';
|
|
237
|
+
if (raw.body?.content) {
|
|
238
|
+
if (raw.body.contentType === 'text') {
|
|
239
|
+
body = raw.body.content;
|
|
240
|
+
} else {
|
|
241
|
+
// Strip HTML tags for plain text representation
|
|
242
|
+
body = raw.body.content
|
|
243
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
244
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
245
|
+
.replace(/<[^>]+>/g, ' ')
|
|
246
|
+
.replace(/ /g, ' ')
|
|
247
|
+
.replace(/&/g, '&')
|
|
248
|
+
.replace(/</g, '<')
|
|
249
|
+
.replace(/>/g, '>')
|
|
250
|
+
.replace(/"/g, '"')
|
|
251
|
+
.replace(/'/g, "'")
|
|
252
|
+
.replace(/\s+/g, ' ')
|
|
253
|
+
.trim();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Extract URLs from body
|
|
258
|
+
const urls = (body.match(/https?:\/\/[^\s<>"']+/g) || []).slice(0, 10);
|
|
259
|
+
|
|
260
|
+
// Map Microsoft importance to labels
|
|
261
|
+
const labels = [];
|
|
262
|
+
if (!raw.isRead) labels.push('UNREAD');
|
|
263
|
+
if (raw.importance === 'high') labels.push('IMPORTANT');
|
|
264
|
+
if (raw.flag?.flagStatus === 'flagged') labels.push('FLAGGED');
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
id: raw.id,
|
|
268
|
+
threadId: raw.conversationId || raw.id,
|
|
269
|
+
from,
|
|
270
|
+
to,
|
|
271
|
+
subject: raw.subject || '(no subject)',
|
|
272
|
+
date: raw.receivedDateTime || '',
|
|
273
|
+
snippet: (raw.bodyPreview || '').slice(0, 200),
|
|
274
|
+
body: body.slice(0, 5000), // cap for LLM context
|
|
275
|
+
urls,
|
|
276
|
+
labels,
|
|
277
|
+
isUnread: !raw.isRead,
|
|
278
|
+
isImportant: raw.importance === 'high',
|
|
279
|
+
sizeEstimate: 0, // Graph API doesn't expose this directly
|
|
280
|
+
provider: 'microsoft',
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Local Cache ────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
function cacheMessages(messages) {
|
|
287
|
+
fs.mkdirSync(INBOX_DIR, { recursive: true });
|
|
288
|
+
for (const msg of messages) {
|
|
289
|
+
const safeId = msg.id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 120);
|
|
290
|
+
const file = path.join(INBOX_DIR, `ms_${safeId}.json`);
|
|
291
|
+
fs.writeFileSync(file, JSON.stringify(msg, null, 2), { mode: 0o600 });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Load cached messages from disk.
|
|
297
|
+
* @returns {Array} cached messages
|
|
298
|
+
*/
|
|
299
|
+
export function loadCachedMessages() {
|
|
300
|
+
if (!fs.existsSync(INBOX_DIR)) return [];
|
|
301
|
+
return fs.readdirSync(INBOX_DIR)
|
|
302
|
+
.filter(f => f.startsWith('ms_') && f.endsWith('.json'))
|
|
303
|
+
.map(f => {
|
|
304
|
+
try { return JSON.parse(fs.readFileSync(path.join(INBOX_DIR, f), 'utf-8')); }
|
|
305
|
+
catch { return null; }
|
|
306
|
+
})
|
|
307
|
+
.filter(Boolean);
|
|
308
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microsoft OAuth 2.0 with PKCE — browser-based consent flow.
|
|
3
|
+
* Uses Microsoft identity platform (login.microsoftonline.com).
|
|
4
|
+
* Runs ephemeral local HTTP server for callback.
|
|
5
|
+
* Zero dependencies — uses Node.js native http + crypto.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import http from 'http';
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import { execSync } from 'child_process';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import { saveTokens, loadTokens, deleteTokens } from './token-store.mjs';
|
|
13
|
+
import { info, ok, fail, warn } from '../ui.mjs';
|
|
14
|
+
|
|
15
|
+
const SCOPES = [
|
|
16
|
+
'openid',
|
|
17
|
+
'offline_access',
|
|
18
|
+
'User.Read',
|
|
19
|
+
'Mail.Read',
|
|
20
|
+
'Mail.Send',
|
|
21
|
+
'Calendars.ReadWrite',
|
|
22
|
+
'Tasks.ReadWrite',
|
|
23
|
+
].join(' ');
|
|
24
|
+
|
|
25
|
+
const CALLBACK_PORTS = [19852, 19853, 19854, 19855, 19856];
|
|
26
|
+
|
|
27
|
+
const PROVIDER = 'microsoft';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generate PKCE code_verifier and code_challenge.
|
|
31
|
+
*/
|
|
32
|
+
function generatePKCE() {
|
|
33
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
34
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
35
|
+
return { verifier, challenge };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Open URL in user's default browser.
|
|
40
|
+
*/
|
|
41
|
+
function openBrowser(url) {
|
|
42
|
+
const platform = os.platform();
|
|
43
|
+
try {
|
|
44
|
+
if (platform === 'darwin') execSync(`open "${url}"`);
|
|
45
|
+
else if (platform === 'win32') execSync(`start "" "${url}"`);
|
|
46
|
+
else execSync(`xdg-open "${url}"`);
|
|
47
|
+
} catch {
|
|
48
|
+
warn('Could not open browser automatically.');
|
|
49
|
+
info(`Open this URL manually:\n\n ${url}\n`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Start ephemeral HTTP server and wait for OAuth callback.
|
|
55
|
+
* @returns {Promise<{code: string, port: number}>}
|
|
56
|
+
*/
|
|
57
|
+
function waitForCallback(state, port) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const server = http.createServer((req, res) => {
|
|
60
|
+
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
61
|
+
if (url.pathname !== '/callback') {
|
|
62
|
+
res.writeHead(404);
|
|
63
|
+
res.end('Not found');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const code = url.searchParams.get('code');
|
|
68
|
+
const returnedState = url.searchParams.get('state');
|
|
69
|
+
const error = url.searchParams.get('error');
|
|
70
|
+
const errorDesc = url.searchParams.get('error_description');
|
|
71
|
+
|
|
72
|
+
if (error) {
|
|
73
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
74
|
+
res.end(`<html><body><h2>Authorization failed</h2><p>${error}: ${errorDesc || ''}</p><p>You can close this tab.</p></body></html>`);
|
|
75
|
+
server.close();
|
|
76
|
+
reject(new Error(`OAuth error: ${error} — ${errorDesc || ''}`));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!code || returnedState !== state) {
|
|
81
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
82
|
+
res.end('<html><body><h2>Invalid callback</h2><p>Missing code or state mismatch.</p></body></html>');
|
|
83
|
+
server.close();
|
|
84
|
+
reject(new Error('Invalid OAuth callback'));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
89
|
+
res.end(`
|
|
90
|
+
<html>
|
|
91
|
+
<head><style>body{font-family:monospace;background:#0a0a0a;color:#00ff41;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
92
|
+
.box{text-align:center;border:1px solid #00ff41;padding:40px;border-radius:8px}</style></head>
|
|
93
|
+
<body><div class="box">
|
|
94
|
+
<h2>NHA Connected</h2>
|
|
95
|
+
<p>Microsoft account linked successfully.</p>
|
|
96
|
+
<p style="color:#666">You can close this tab and return to the terminal.</p>
|
|
97
|
+
</div></body></html>
|
|
98
|
+
`);
|
|
99
|
+
|
|
100
|
+
server.close();
|
|
101
|
+
resolve({ code, port });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
server.listen(port, '127.0.0.1');
|
|
105
|
+
server.on('error', () => reject(new Error(`Port ${port} in use`)));
|
|
106
|
+
|
|
107
|
+
// 5 minute timeout
|
|
108
|
+
setTimeout(() => {
|
|
109
|
+
server.close();
|
|
110
|
+
reject(new Error('OAuth timeout — no callback received within 5 minutes'));
|
|
111
|
+
}, 300_000);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Exchange authorization code for tokens via Microsoft identity platform.
|
|
117
|
+
*/
|
|
118
|
+
async function exchangeCode(code, codeVerifier, clientId, clientSecret, redirectUri, tenantId) {
|
|
119
|
+
const params = new URLSearchParams({
|
|
120
|
+
client_id: clientId,
|
|
121
|
+
scope: SCOPES,
|
|
122
|
+
code,
|
|
123
|
+
redirect_uri: redirectUri,
|
|
124
|
+
grant_type: 'authorization_code',
|
|
125
|
+
code_verifier: codeVerifier,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (clientSecret) {
|
|
129
|
+
params.set('client_secret', clientSecret);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const res = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
135
|
+
body: params.toString(),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
const err = await res.text();
|
|
140
|
+
throw new Error(`Token exchange failed: ${err}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return res.json();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Refresh access token using refresh_token.
|
|
148
|
+
*/
|
|
149
|
+
export async function refreshMicrosoftToken(clientId, clientSecret, refreshToken, tenantId) {
|
|
150
|
+
const params = new URLSearchParams({
|
|
151
|
+
client_id: clientId,
|
|
152
|
+
scope: SCOPES,
|
|
153
|
+
refresh_token: refreshToken,
|
|
154
|
+
grant_type: 'refresh_token',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (clientSecret) {
|
|
158
|
+
params.set('client_secret', clientSecret);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const res = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
164
|
+
body: params.toString(),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (!res.ok) {
|
|
168
|
+
const err = await res.text();
|
|
169
|
+
throw new Error(`Microsoft token refresh failed: ${err}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const data = await res.json();
|
|
173
|
+
return {
|
|
174
|
+
access_token: data.access_token,
|
|
175
|
+
refresh_token: data.refresh_token || refreshToken,
|
|
176
|
+
expires_at: Date.now() + (data.expires_in * 1000),
|
|
177
|
+
scope: data.scope,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Fetch authenticated user's profile from Microsoft Graph.
|
|
183
|
+
*/
|
|
184
|
+
async function getUserProfile(accessToken) {
|
|
185
|
+
const res = await fetch('https://graph.microsoft.com/v1.0/me', {
|
|
186
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
187
|
+
});
|
|
188
|
+
if (!res.ok) return null;
|
|
189
|
+
return res.json();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get a valid Microsoft access token — refreshes automatically if expired.
|
|
194
|
+
* @param {object} config — NHA config with microsoft.clientId etc.
|
|
195
|
+
* @returns {Promise<string>} access_token
|
|
196
|
+
*/
|
|
197
|
+
export async function getMicrosoftAccessToken(config) {
|
|
198
|
+
let tokens = loadTokens(PROVIDER);
|
|
199
|
+
if (!tokens) throw new Error('Not authenticated with Microsoft. Run: nha microsoft auth');
|
|
200
|
+
|
|
201
|
+
const expired = Date.now() >= (tokens.expires_at || 0) - 300_000;
|
|
202
|
+
if (expired) {
|
|
203
|
+
const clientId = config.microsoft?.clientId;
|
|
204
|
+
const clientSecret = config.microsoft?.clientSecret || '';
|
|
205
|
+
const tenantId = config.microsoft?.tenantId || 'common';
|
|
206
|
+
if (!clientId) throw new Error('Microsoft client ID not configured');
|
|
207
|
+
|
|
208
|
+
const refreshed = await refreshMicrosoftToken(clientId, clientSecret, tokens.refresh_token, tenantId);
|
|
209
|
+
tokens = {
|
|
210
|
+
...tokens,
|
|
211
|
+
access_token: refreshed.access_token,
|
|
212
|
+
refresh_token: refreshed.refresh_token,
|
|
213
|
+
expires_at: refreshed.expires_at,
|
|
214
|
+
scope: refreshed.scope,
|
|
215
|
+
};
|
|
216
|
+
saveTokens(tokens, PROVIDER);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return tokens.access_token;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Run the full Microsoft OAuth consent flow.
|
|
224
|
+
* @param {object} config — NHA config
|
|
225
|
+
*/
|
|
226
|
+
export async function runMicrosoftAuthFlow(config) {
|
|
227
|
+
const clientId = config.microsoft?.clientId;
|
|
228
|
+
const clientSecret = config.microsoft?.clientSecret || '';
|
|
229
|
+
const tenantId = config.microsoft?.tenantId || 'common';
|
|
230
|
+
|
|
231
|
+
if (!clientId) {
|
|
232
|
+
fail('Microsoft OAuth client ID not configured.');
|
|
233
|
+
info('Get credentials from Azure Portal:');
|
|
234
|
+
info(' 1. Go to https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps');
|
|
235
|
+
info(' 2. Register an application (Personal accounts or Org + Personal)');
|
|
236
|
+
info(' 3. Add a "Mobile and desktop" platform with redirect URI');
|
|
237
|
+
info(' http://127.0.0.1:19852/callback');
|
|
238
|
+
info(' 4. Under API permissions, add: Mail.Read, Mail.Send, Calendars.ReadWrite, Tasks.ReadWrite, User.Read');
|
|
239
|
+
info(' 5. Run:');
|
|
240
|
+
info(' nha config set microsoft-client-id YOUR_CLIENT_ID');
|
|
241
|
+
info(' nha config set microsoft-client-secret YOUR_CLIENT_SECRET (optional for public clients)');
|
|
242
|
+
info(' nha config set microsoft-tenant common (or your tenant ID)');
|
|
243
|
+
info(' 6. Run: nha microsoft auth');
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Find available port
|
|
248
|
+
let port = 0;
|
|
249
|
+
for (const p of CALLBACK_PORTS) {
|
|
250
|
+
try {
|
|
251
|
+
const srv = http.createServer();
|
|
252
|
+
await new Promise((resolve, reject) => {
|
|
253
|
+
srv.listen(p, '127.0.0.1', () => { srv.close(); resolve(true); });
|
|
254
|
+
srv.on('error', () => reject());
|
|
255
|
+
});
|
|
256
|
+
port = p;
|
|
257
|
+
break;
|
|
258
|
+
} catch { continue; }
|
|
259
|
+
}
|
|
260
|
+
if (!port) {
|
|
261
|
+
fail('No available port for OAuth callback (tried 19852-19856)');
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
266
|
+
const { verifier, challenge } = generatePKCE();
|
|
267
|
+
const state = crypto.randomBytes(32).toString('hex');
|
|
268
|
+
|
|
269
|
+
const authUrl = new URL(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`);
|
|
270
|
+
authUrl.searchParams.set('client_id', clientId);
|
|
271
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
272
|
+
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
273
|
+
authUrl.searchParams.set('response_mode', 'query');
|
|
274
|
+
authUrl.searchParams.set('scope', SCOPES);
|
|
275
|
+
authUrl.searchParams.set('state', state);
|
|
276
|
+
authUrl.searchParams.set('code_challenge', challenge);
|
|
277
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
278
|
+
authUrl.searchParams.set('prompt', 'consent');
|
|
279
|
+
|
|
280
|
+
info('Opening browser for Microsoft authorization...');
|
|
281
|
+
openBrowser(authUrl.toString());
|
|
282
|
+
info('Waiting for authorization (5 min timeout)...\n');
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const { code } = await waitForCallback(state, port);
|
|
286
|
+
info('Authorization code received. Exchanging for tokens...');
|
|
287
|
+
|
|
288
|
+
const tokenData = await exchangeCode(code, verifier, clientId, clientSecret, redirectUri, tenantId);
|
|
289
|
+
const profile = await getUserProfile(tokenData.access_token);
|
|
290
|
+
|
|
291
|
+
const tokens = {
|
|
292
|
+
access_token: tokenData.access_token,
|
|
293
|
+
refresh_token: tokenData.refresh_token,
|
|
294
|
+
expires_at: Date.now() + (tokenData.expires_in * 1000),
|
|
295
|
+
scope: tokenData.scope,
|
|
296
|
+
email: profile?.mail || profile?.userPrincipalName || 'unknown',
|
|
297
|
+
displayName: profile?.displayName || '',
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
saveTokens(tokens, PROVIDER);
|
|
301
|
+
ok(`Microsoft account connected: ${tokens.email}`);
|
|
302
|
+
ok('Outlook Mail + Calendar + Tasks access granted.');
|
|
303
|
+
info('Run "nha plan" to generate your first daily plan.');
|
|
304
|
+
return true;
|
|
305
|
+
} catch (err) {
|
|
306
|
+
fail(err.message);
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Show Microsoft connection status.
|
|
313
|
+
*/
|
|
314
|
+
export function showMicrosoftStatus() {
|
|
315
|
+
const tokens = loadTokens(PROVIDER);
|
|
316
|
+
if (!tokens) {
|
|
317
|
+
info('Not connected to Microsoft. Run: nha microsoft auth');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const expired = Date.now() >= tokens.expires_at;
|
|
322
|
+
console.log(`\n Microsoft Account: ${tokens.email || 'unknown'}`);
|
|
323
|
+
if (tokens.displayName) console.log(` Display Name: ${tokens.displayName}`);
|
|
324
|
+
console.log(` Token Status: ${expired ? '\x1b[0;31mexpired\x1b[0m' : '\x1b[0;32mactive\x1b[0m'}`);
|
|
325
|
+
console.log(` Expires: ${new Date(tokens.expires_at).toLocaleString()}`);
|
|
326
|
+
console.log(` Scopes: ${tokens.scope || 'unknown'}\n`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Revoke tokens and delete local storage.
|
|
331
|
+
*/
|
|
332
|
+
export async function revokeMicrosoftAuth() {
|
|
333
|
+
const tokens = loadTokens(PROVIDER);
|
|
334
|
+
if (!tokens) {
|
|
335
|
+
info('No Microsoft tokens found.');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Microsoft doesn't have a simple revoke endpoint like Google.
|
|
340
|
+
// Best effort: sign out via logout URL (this invalidates the session in browser).
|
|
341
|
+
// The refresh token will expire naturally. Local deletion is the primary action.
|
|
342
|
+
deleteTokens(PROVIDER);
|
|
343
|
+
ok('Microsoft account disconnected. Local tokens deleted.');
|
|
344
|
+
info('To fully revoke access, visit: https://account.live.com/consent/Manage');
|
|
345
|
+
}
|