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 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 readExcelM365(channel, projectRoot) {
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
- const colMap = channel.column_map;
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 < response.values.length; i++) {
134
- const row = response.values[i];
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 = { readExcelM365, AuthRequiredError, clearTokenCache };
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.48.0",
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"