viepilot 2.48.0 → 2.49.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/CHANGELOG.md +22 -0
- package/lib/intake/adapters/excel-m365.cjs +172 -6
- package/lib/intake/classifier.cjs +2 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.49.0] - 2026-05-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **ENH-083** `vp-intake` — SharePoint sharing link + xlsx direct download support (Phase 124):
|
|
14
|
+
- **SharePoint anonymous sharing link** adapter: URLs matching `/:x:/g/` or `/:x:/r/` are
|
|
15
|
+
resolved via the SharePoint WOPI viewer page — `FileGetUrl` (temp-auth token) is scraped
|
|
16
|
+
from the HTML and used to download the xlsx file without OAuth credentials
|
|
17
|
+
- **xlsx parser** using SheetJS (`xlsx` npm package): downloaded xlsx Buffer is parsed to
|
|
18
|
+
`rows[][]` matching the Graph API response format; falls back to first sheet when the
|
|
19
|
+
configured sheet name is not found
|
|
20
|
+
- **`sharing_url` field** in `channels.json` (`excel_m365` type): optional alternative to
|
|
21
|
+
`workbook_id` for SharePoint "Anyone with the link" files; no credentials required
|
|
22
|
+
- **Redirect-following HTTP client** (`httpsGetRaw`): browser User-Agent, follows 301/302/307
|
|
23
|
+
chains needed for SharePoint sharing link flow
|
|
24
|
+
- **Routing logic in `readExcelM365`**: `sharing_url` present → anonymous WOPI flow;
|
|
25
|
+
`workbook_id` present → Graph API (OAuth); neither present → `AuthRequiredError` with guide
|
|
26
|
+
- **BUG_KEYWORDS extended**: `performance`, `slow`, `timeout`, `hang`, `freeze`,
|
|
27
|
+
`unresponsive`, `chậm`, `treo`, `tắc`, `không phản hồi` — "performance issue" now correctly
|
|
28
|
+
classifies as BUG instead of UNCLEAR
|
|
29
|
+
- **19 new tests** (1897 total): keyword expansion (8 tests), `isSharingLink` detection (5
|
|
30
|
+
tests), routing logic (3 tests), `parseXlsxBuffer` (3 tests)
|
|
31
|
+
|
|
10
32
|
## [2.48.0] - 2026-05-17
|
|
11
33
|
|
|
12
34
|
### Added
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
4
5
|
const path = require('path');
|
|
5
6
|
const https = require('https');
|
|
6
7
|
|
|
@@ -16,6 +17,9 @@ Microsoft 365 / Excel setup:
|
|
|
16
17
|
5. In channels.json, set "workbook_id" to the file's drive item ID
|
|
17
18
|
(visible in the file's URL on SharePoint/OneDrive)
|
|
18
19
|
6. Re-run vp-intake
|
|
20
|
+
|
|
21
|
+
For SharePoint sharing links ("Anyone with the link"), use "sharing_url" in channels.json
|
|
22
|
+
instead of "workbook_id" — no credentials required.
|
|
19
23
|
`;
|
|
20
24
|
|
|
21
25
|
class AuthRequiredError extends Error {
|
|
@@ -35,6 +39,8 @@ function colLetterToIndex(letter) {
|
|
|
35
39
|
return index - 1;
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
38
44
|
function httpsPost(url, body, headers) {
|
|
39
45
|
return new Promise((resolve, reject) => {
|
|
40
46
|
const payload = typeof body === 'string' ? body : JSON.stringify(body);
|
|
@@ -73,12 +79,46 @@ function httpsGet(url, headers) {
|
|
|
73
79
|
});
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
/** Follow redirects and return {status, headers, body} */
|
|
83
|
+
function httpsGetRaw(url, headers = {}, maxRedirects = 6) {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
function doGet(currentUrl, remaining) {
|
|
86
|
+
const urlObj = new URL(currentUrl);
|
|
87
|
+
const req = https.request({
|
|
88
|
+
hostname: urlObj.hostname,
|
|
89
|
+
path: urlObj.pathname + urlObj.search,
|
|
90
|
+
method: 'GET',
|
|
91
|
+
headers: {
|
|
92
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
93
|
+
...headers,
|
|
94
|
+
},
|
|
95
|
+
}, (res) => {
|
|
96
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && remaining > 0) {
|
|
97
|
+
let nextUrl = res.headers.location;
|
|
98
|
+
if (!nextUrl.startsWith('http')) {
|
|
99
|
+
nextUrl = `${urlObj.protocol}//${urlObj.host}${nextUrl}`;
|
|
100
|
+
}
|
|
101
|
+
return doGet(nextUrl, remaining - 1);
|
|
102
|
+
}
|
|
103
|
+
const chunks = [];
|
|
104
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
105
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
|
|
106
|
+
});
|
|
107
|
+
req.on('error', reject);
|
|
108
|
+
req.end();
|
|
109
|
+
}
|
|
110
|
+
doGet(url, maxRedirects);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
76
114
|
function encodeFormData(obj) {
|
|
77
115
|
return Object.entries(obj)
|
|
78
116
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
79
117
|
.join('&');
|
|
80
118
|
}
|
|
81
119
|
|
|
120
|
+
// ─── Graph API (client credentials) ──────────────────────────────────────────
|
|
121
|
+
|
|
82
122
|
async function getAccessToken(creds) {
|
|
83
123
|
const now = Math.floor(Date.now() / 1000);
|
|
84
124
|
if (_tokenCache && _tokenCache.expires_at > now + 60) {
|
|
@@ -101,7 +141,7 @@ async function getAccessToken(creds) {
|
|
|
101
141
|
return res.access_token;
|
|
102
142
|
}
|
|
103
143
|
|
|
104
|
-
async function
|
|
144
|
+
async function readViaGraphApi(channel, projectRoot) {
|
|
105
145
|
const root = projectRoot || process.cwd();
|
|
106
146
|
const credPath = path.join(root, CREDENTIALS_REL);
|
|
107
147
|
|
|
@@ -126,12 +166,104 @@ async function readExcelM365(channel, projectRoot) {
|
|
|
126
166
|
const response = await httpsGet(url, { Authorization: `Bearer ${token}` });
|
|
127
167
|
|
|
128
168
|
if (!response.values || response.values.length === 0) return [];
|
|
169
|
+
return parseRowsWithColumnMap(response.values, channel.column_map);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── SharePoint sharing link (anonymous WOPI) ─────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/** Detect sharing link type: /:x:/g/ or /:x:/r/ paths */
|
|
175
|
+
function isSharingLink(url) {
|
|
176
|
+
return /\/:x:\/[gr]\//.test(url);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolve a SharePoint anonymous sharing link to a direct download URL.
|
|
181
|
+
* Technique: load WOPI viewer page → scrape FileGetUrl (temp auth token embedded in HTML).
|
|
182
|
+
*/
|
|
183
|
+
async function resolveSharePointDownloadUrl(sharingUrl) {
|
|
184
|
+
// First request: follow redirects to the WOPI viewer page
|
|
185
|
+
const { status, body } = await httpsGetRaw(sharingUrl);
|
|
186
|
+
if (status !== 200) {
|
|
187
|
+
throw new Error(`SharePoint sharing link returned HTTP ${status}. The link may be expired or restricted.`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const html = body.toString('utf8');
|
|
191
|
+
|
|
192
|
+
// Scrape FileGetUrl — SharePoint embeds a temp-auth download URL in the viewer HTML
|
|
193
|
+
const match = html.match(/"FileGetUrl"\s*:\s*"([^"]+)"/);
|
|
194
|
+
if (!match) {
|
|
195
|
+
throw new Error('Could not extract download URL from SharePoint viewer page. The file may require sign-in.');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Unescape JSON unicode escapes (\\u0026 → &)
|
|
199
|
+
return match[1].replace(/\\u([\da-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Download xlsx bytes from a URL (no auth, temp token already embedded) */
|
|
203
|
+
async function downloadXlsx(downloadUrl) {
|
|
204
|
+
const { status, body } = await httpsGetRaw(downloadUrl, {
|
|
205
|
+
Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,*/*',
|
|
206
|
+
});
|
|
207
|
+
if (status !== 200) {
|
|
208
|
+
throw new Error(`Failed to download xlsx: HTTP ${status}`);
|
|
209
|
+
}
|
|
210
|
+
return body; // Buffer
|
|
211
|
+
}
|
|
129
212
|
|
|
130
|
-
|
|
213
|
+
// ─── xlsx parser (SheetJS) ────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Parse xlsx Buffer → array of row arrays (like response.values from Graph API).
|
|
217
|
+
* Uses the `xlsx` npm package (SheetJS).
|
|
218
|
+
*/
|
|
219
|
+
function parseXlsxBuffer(buffer, sheetName) {
|
|
220
|
+
let XLSX;
|
|
221
|
+
try {
|
|
222
|
+
XLSX = require('xlsx');
|
|
223
|
+
} catch {
|
|
224
|
+
throw new Error(
|
|
225
|
+
'The "xlsx" package is required to parse xlsx files from sharing links.\n' +
|
|
226
|
+
'Install it: npm install xlsx'
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
|
231
|
+
|
|
232
|
+
let sheet;
|
|
233
|
+
if (sheetName && workbook.SheetNames.includes(sheetName)) {
|
|
234
|
+
sheet = workbook.Sheets[sheetName];
|
|
235
|
+
} else {
|
|
236
|
+
// Default to first sheet; warn if requested sheet not found
|
|
237
|
+
if (sheetName) {
|
|
238
|
+
process.stderr.write(`[vp-intake] Sheet "${sheetName}" not found. Using "${workbook.SheetNames[0]}" instead.\n`);
|
|
239
|
+
}
|
|
240
|
+
sheet = workbook.Sheets[workbook.SheetNames[0]];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!sheet) return [];
|
|
244
|
+
|
|
245
|
+
// Convert to array of arrays (raw values)
|
|
246
|
+
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' });
|
|
247
|
+
return rows;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function readViaSharingLink(channel) {
|
|
251
|
+
const sharingUrl = channel.sharing_url;
|
|
252
|
+
const downloadUrl = await resolveSharePointDownloadUrl(sharingUrl);
|
|
253
|
+
const xlsxBuffer = await downloadXlsx(downloadUrl);
|
|
254
|
+
const rows = parseXlsxBuffer(xlsxBuffer, channel.sheet_name || null);
|
|
255
|
+
|
|
256
|
+
if (!rows || rows.length === 0) return [];
|
|
257
|
+
return parseRowsWithColumnMap(rows, channel.column_map);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Common row → ticket mapper ───────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
function parseRowsWithColumnMap(values, colMap) {
|
|
131
263
|
const tickets = [];
|
|
132
264
|
|
|
133
|
-
for (let i = 1; i <
|
|
134
|
-
const row =
|
|
265
|
+
for (let i = 1; i < values.length; i++) {
|
|
266
|
+
const row = values[i];
|
|
135
267
|
const get = (field) => {
|
|
136
268
|
const col = colMap[field];
|
|
137
269
|
if (!col) return '';
|
|
@@ -147,7 +279,7 @@ async function readExcelM365(channel, projectRoot) {
|
|
|
147
279
|
date: get('date'),
|
|
148
280
|
status: get('status'),
|
|
149
281
|
_source_row: i,
|
|
150
|
-
_channel_id: channel.id,
|
|
282
|
+
_channel_id: channel ? channel.id : undefined,
|
|
151
283
|
};
|
|
152
284
|
|
|
153
285
|
if (!ticket.title && !ticket.description) continue;
|
|
@@ -157,6 +289,40 @@ async function readExcelM365(channel, projectRoot) {
|
|
|
157
289
|
return tickets;
|
|
158
290
|
}
|
|
159
291
|
|
|
292
|
+
// ─── Public entry point ───────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
async function readExcelM365(channel, projectRoot) {
|
|
295
|
+
// Sharing link mode: no credentials required
|
|
296
|
+
if (channel.sharing_url) {
|
|
297
|
+
if (isSharingLink(channel.sharing_url)) {
|
|
298
|
+
return readViaSharingLink(channel);
|
|
299
|
+
}
|
|
300
|
+
throw new Error(
|
|
301
|
+
`sharing_url "${channel.sharing_url}" is not a SharePoint sharing link.\n` +
|
|
302
|
+
'Sharing links look like: https://xxx.sharepoint.com/:x:/g/personal/xxx/EdXXX...\n' +
|
|
303
|
+
'For direct document links (Doc.aspx?sourcedoc=...), use workbook_id + credentials instead.'
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Graph API mode: requires workbook_id + credentials
|
|
308
|
+
if (!channel.workbook_id) {
|
|
309
|
+
throw new AuthRequiredError(
|
|
310
|
+
'excel_m365 channel requires either "sharing_url" (for SharePoint sharing links) or ' +
|
|
311
|
+
'"workbook_id" (for Graph API).' + SETUP_GUIDE
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return readViaGraphApi(channel, projectRoot);
|
|
316
|
+
}
|
|
317
|
+
|
|
160
318
|
function clearTokenCache() { _tokenCache = null; }
|
|
161
319
|
|
|
162
|
-
module.exports = {
|
|
320
|
+
module.exports = {
|
|
321
|
+
readExcelM365,
|
|
322
|
+
AuthRequiredError,
|
|
323
|
+
clearTokenCache,
|
|
324
|
+
// exported for testing
|
|
325
|
+
isSharingLink,
|
|
326
|
+
resolveSharePointDownloadUrl,
|
|
327
|
+
parseXlsxBuffer,
|
|
328
|
+
};
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
const BUG_KEYWORDS = [
|
|
4
4
|
'bug', 'error', 'fail', 'crash', 'broken', 'fix', 'exception', 'regression',
|
|
5
5
|
'wrong', 'incorrect', 'unexpected', 'null', 'undefined', '500', '404',
|
|
6
|
+
'performance', 'slow', 'timeout', 'hang', 'freeze', 'unresponsive',
|
|
6
7
|
// Vietnamese
|
|
7
8
|
'lỗi', 'sự cố', 'không hoạt động', 'hỏng', 'sai', 'vá lỗi', 'bị lỗi',
|
|
9
|
+
'chậm', 'treo', 'tắc', 'không phản hồi',
|
|
8
10
|
];
|
|
9
11
|
|
|
10
12
|
const ENH_KEYWORDS = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viepilot",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.49.0",
|
|
4
4
|
"description": "**Autonomous Vibe Coding Framework / Bộ khung phát triển tự động có kiểm soát**",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -74,7 +74,8 @@
|
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
76
|
"docx": "^9.0.0",
|
|
77
|
-
"pptxgenjs": "^3.12.0"
|
|
77
|
+
"pptxgenjs": "^3.12.0",
|
|
78
|
+
"xlsx": "^0.18.5"
|
|
78
79
|
},
|
|
79
80
|
"optionalDependencies": {
|
|
80
81
|
"@googleapis/slides": "^1.0.0"
|