From 95cb27e53a9d83eaf6cdf40252fa08bdc7c921f3 Mon Sep 17 00:00:00 2001 From: beo3000 Date: Wed, 6 May 2026 20:56:48 +0200 Subject: [PATCH] feat(scripts): add graph meetings helper with safe fallbacks --- scripts/lib/graph-meetings.js | 92 ++++++++++++++++++++++ scripts/test/graph-meetings.test.js | 114 ++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 scripts/lib/graph-meetings.js create mode 100644 scripts/test/graph-meetings.test.js diff --git a/scripts/lib/graph-meetings.js b/scripts/lib/graph-meetings.js new file mode 100644 index 0000000..56599cf --- /dev/null +++ b/scripts/lib/graph-meetings.js @@ -0,0 +1,92 @@ +const { ConfidentialClientApplication } = require('@azure/msal-node'); +const { Client } = require('@microsoft/microsoft-graph-client'); +const { readFileSync } = require('node:fs'); +const { resolve } = require('node:path'); + +function loadEnv() { + const envPath = resolve(__dirname, '..', '.env'); + const content = readFileSync(envPath, 'utf-8'); + const vars = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const [key, ...rest] = trimmed.split('='); + vars[key.trim()] = rest.join('=').trim(); + } + return vars; +} + +async function buildGraphClient(env = loadEnv()) { + const cca = new ConfidentialClientApplication({ + auth: { + clientId: env.AZURE_CLIENT_ID, + clientSecret: env.AZURE_CLIENT_SECRET, + authority: `https://login.microsoftonline.com/${env.AZURE_TENANT_ID}` + } + }); + const tokenResponse = await cca.acquireTokenByClientCredential({ + scopes: ['https://graph.microsoft.com/.default'] + }); + return Client.init({ + authProvider: (done) => done(null, tokenResponse.accessToken), + defaultVersion: 'v1.0' + }); +} + +async function safeGet(client, path, options = {}) { + try { + let req = client.api(path); + if (options.version && typeof req.version === 'function') req = req.version(options.version); + if (options.query && typeof req.query === 'function') req = req.query(options.query); + return await req.get(); + } catch (err) { + if (err.statusCode === 404 || err.statusCode === 403) return null; + throw err; + } +} + +async function resolveOnlineMeeting(client, userId, joinWebUrl) { + const path = `/users/${userId}/onlineMeetings`; + const resp = await safeGet(client, path, { + query: { $filter: `JoinWebUrl eq '${joinWebUrl}'` } + }); + const list = resp?.value || []; + return list[0] || null; +} + +async function fetchTranscriptVtt(client, userId, meetingId) { + const list = await safeGet(client, `/users/${userId}/onlineMeetings/${meetingId}/transcripts`); + const items = list?.value || []; + if (items.length === 0) return null; + const latest = [...items].sort((a, b) => + new Date(b.createdDateTime) - new Date(a.createdDateTime) + )[0]; + const contentPath = `/users/${userId}/onlineMeetings/${meetingId}/transcripts/${latest.id}/content`; + return await safeGet(client, contentPath); +} + +async function fetchAiInsights(client, userId, meetingId) { + const path = `/users/${userId}/onlineMeetings/${meetingId}/aiInsights`; + const resp = await safeGet(client, path, { version: 'beta' }); + const items = resp?.value || []; + return items[0] || null; +} + +async function fetchRecordingUrl(client, userId, meetingId) { + const list = await safeGet(client, `/users/${userId}/onlineMeetings/${meetingId}/recordings`); + const items = list?.value || []; + if (items.length === 0) return null; + const latest = [...items].sort((a, b) => + new Date(b.createdDateTime) - new Date(a.createdDateTime) + )[0]; + return latest.recordingContentUrl || latest.contentCorrelationId || null; +} + +module.exports = { + loadEnv, + buildGraphClient, + resolveOnlineMeeting, + fetchTranscriptVtt, + fetchAiInsights, + fetchRecordingUrl +}; diff --git a/scripts/test/graph-meetings.test.js b/scripts/test/graph-meetings.test.js new file mode 100644 index 0000000..a6a3bc2 --- /dev/null +++ b/scripts/test/graph-meetings.test.js @@ -0,0 +1,114 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + resolveOnlineMeeting, + fetchTranscriptVtt, + fetchAiInsights, + fetchRecordingUrl +} = require('../lib/graph-meetings.js'); + +function fakeClient(routes) { + return { + api(path) { + const handler = routes[path]; + const builder = { + query() { return builder; }, + select() { return builder; }, + version() { return builder; }, + get: async () => { + if (handler === undefined) throw Object.assign(new Error('Not found'), { statusCode: 404 }); + if (typeof handler === 'function') return handler(); + return handler; + } + }; + return builder; + } + }; +} + +describe('resolveOnlineMeeting', () => { + it('resolves meeting by joinWebUrl', async () => { + const client = fakeClient({ + "/users/u/onlineMeetings": { value: [{ id: 'M1', joinWebUrl: 'https://teams/j' }] } + }); + const m = await resolveOnlineMeeting(client, 'u', 'https://teams/j'); + assert.equal(m.id, 'M1'); + }); + + it('returns null when no meeting matches', async () => { + const client = fakeClient({ + "/users/u/onlineMeetings": { value: [] } + }); + const m = await resolveOnlineMeeting(client, 'u', 'https://teams/j'); + assert.equal(m, null); + }); +}); + +describe('fetchTranscriptVtt', () => { + it('returns latest transcript content as string', async () => { + const client = fakeClient({ + "/users/u/onlineMeetings/M1/transcripts": { + value: [ + { id: 'T1', createdDateTime: '2026-05-06T09:00:00Z' }, + { id: 'T2', createdDateTime: '2026-05-06T10:00:00Z' } + ] + }, + "/users/u/onlineMeetings/M1/transcripts/T2/content": 'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nY\n' + }); + const vtt = await fetchTranscriptVtt(client, 'u', 'M1'); + assert.ok(vtt.startsWith('WEBVTT')); + }); + + it('returns null when no transcripts exist', async () => { + const client = fakeClient({ + "/users/u/onlineMeetings/M1/transcripts": { value: [] } + }); + const vtt = await fetchTranscriptVtt(client, 'u', 'M1'); + assert.equal(vtt, null); + }); +}); + +describe('fetchAiInsights', () => { + it('returns insights when available', async () => { + const client = fakeClient({ + "/users/u/onlineMeetings/M1/aiInsights": { + value: [{ id: 'I1', actionItems: [], meetingNotes: [], mentions: [] }] + } + }); + const out = await fetchAiInsights(client, 'u', 'M1'); + assert.ok(out); + assert.equal(out.id, 'I1'); + }); + + it('returns null on 404', async () => { + const client = fakeClient({}); + const out = await fetchAiInsights(client, 'u', 'M1'); + assert.equal(out, null); + }); +}); + +describe('fetchRecordingUrl', () => { + it('returns latest recording url', async () => { + const client = fakeClient({ + "/users/u/onlineMeetings/M1/recordings": { + value: [{ id: 'R1', recordingContentUrl: 'https://teams/play/R1', createdDateTime: '2026-05-06T11:00:00Z' }] + } + }); + const out = await fetchRecordingUrl(client, 'u', 'M1'); + assert.equal(out, 'https://teams/play/R1'); + }); + + it('returns null when no recording', async () => { + const client = fakeClient({ + "/users/u/onlineMeetings/M1/recordings": { value: [] } + }); + const out = await fetchRecordingUrl(client, 'u', 'M1'); + assert.equal(out, null); + }); + + it('returns null on permission denied', async () => { + const client = fakeClient({}); + const out = await fetchRecordingUrl(client, 'u', 'M1'); + assert.equal(out, null); + }); +});