From 83c25ff95af4a6906ee1eb82928a9502f090dc40 Mon Sep 17 00:00:00 2001 From: Nanddeep Nachan Date: Tue, 9 Apr 2024 11:07:10 +0000 Subject: [PATCH] Adds command 'teams meeting transcript get'. Closes #3908 --- .../teams/meeting/meeting-transcript-get.mdx | 132 +++++++ docs/src/config/sidebars.ts | 5 + src/m365/teams/MeetingTranscript.ts | 7 + src/m365/teams/commands.ts | 1 + .../meeting/meeting-transcript-get.spec.ts | 358 ++++++++++++++++++ .../meeting/meeting-transcript-get.ts | 196 ++++++++++ 6 files changed, 699 insertions(+) create mode 100644 docs/docs/cmd/teams/meeting/meeting-transcript-get.mdx create mode 100644 src/m365/teams/MeetingTranscript.ts create mode 100644 src/m365/teams/commands/meeting/meeting-transcript-get.spec.ts create mode 100644 src/m365/teams/commands/meeting/meeting-transcript-get.ts diff --git a/docs/docs/cmd/teams/meeting/meeting-transcript-get.mdx b/docs/docs/cmd/teams/meeting/meeting-transcript-get.mdx new file mode 100644 index 00000000000..e16da34fafd --- /dev/null +++ b/docs/docs/cmd/teams/meeting/meeting-transcript-get.mdx @@ -0,0 +1,132 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# teams meeting transcript get + +Downloads a transcript for a given meeting + +## Usage + +```sh +m365 teams meeting transcript get [options] +``` + +## Options + +```md definition-list +`-u, --userId [userId]` +: The id of the user, omit to get meeting transcript for the currently signed-in user. Use either `id`, `userName` or `email`, but not multiple. + +`-n, --userName [userName]` +: The name of the user, omit to get the meeting transcript for the currently signed-in user. Use either `id`, `userName` or `email`, but not multiple. + +`--email [email]` +: The email of the user, omit to get the meeting transcript for the currently signed-in user. Use either `id`, `userName` or `email`, but not multiple. + +`-m, --meetingId ` +: The Id of the meeting. + +`-i, --id ` +: The Id of the transcript. + +`-f, --outputFile [outputFile]` +: Destination path for the report file. +``` + + + +## Examples + +Gets the specified transcript made for the current signed in user and Microsoft Teams meeting with given id. + +```sh +m365 teams meeting transcript get --meetingId MSo1N2Y5ZGFjYy03MWJmLTQ3NDMtYjQxMy01M2EdFGkdRWHJlQ --id MSMjMCMjNzU3ODc2ZDYtOTcwMi00MDhkLWFkNDItOTE2ZDNmZjkwZGY4 +``` + +Saves the specified transcript made for a given user and Microsoft Teams meeting with the given id. + +```sh +m365 teams meeting transcript get --userName garthf@contoso.com --meetingId MSo1N2Y5ZGFjYy03MWJmLTQ3NDMtYjQxMy01M2EdFGkdRWHJlQ --id MSMjMCMjNzU3ODc2ZDYtOTcwMi00MDhkLWFkNDItOTE2ZDNmZjkwZGY4 --outputFile c:/Transcript.vtt +``` + +## Remarks + +:::warning + +This command is based on a Microsoft Graph API that is currently in preview and is subject to change once the API reached general availability. + +::: + +:::warning + +To run this command with application permissions, tenant administrators must create an application access policy and grant it to a user. This authorizes the app configured in the policy to fetch online meetings and/or online meeting artifacts on behalf of that user. For more details, click [here](https://learn.microsoft.com/graph/cloud-communication-online-meeting-application-access-policy). + +::: + +## Response + + + + + ```json + { + "id": "MSMjMCMjNmU2OTc2OTUtZWNmMC00MTE2LWEyNzYtYjcyOTE5NTBiNzRj", + "meetingId": "MSplMTI1MWIxMC0xYmE0LTQ5ZTMtYjM1YS05MzNlM2YyMTc3MmIqMCoqMTk6bWVldGluZ19OREJpWVROa05XVXRaakptWlMwMFl6QTRMVGd3TlRRdE16WTNaR014T1Rjek1tUTBAdGhyZWFkLnYy", + "meetingOrganizerId": "e1251b10-1ba4-49e3-b35a-933e3f21772b", + "transcriptContentUrl": "https://graph.microsoft.com/beta/users/e1251b10-1ba4-49e3-b35a-933e3f21772b/onlineMeetings/MSplMTI1MWIxMC0xYmE0LTQ5ZTMtYjM1YS05MzNlM2YyMTc3MmIqMCoqMTk6bWVldGluZ19OREJpWVROa05XVXRaakptWlMwMFl6QTRMVGd3TlRRdE16WTNaR014T1Rjek1tUTBAdGhyZWFkLnYy/transcripts/MSMjMCMjNmU2OTc2OTUtZWNmMC00MTE2LWEyNzYtYjcyOTE5NTBiNzRj/content", + "createdDateTime": "2024-04-08T05:26:21.1936844Z", + "meetingOrganizer": { + "application": null, + "device": null, + "user": { + "id": "e1251b10-1ba4-49e3-b35a-933e3f21772b", + "displayName": null, + "userIdentityType": "aadUser", + "tenantId": "de348bc7-1aeb-4406-8cb3-97db021cadb4" + } + } + } + ``` + + + + + ```text + createdDateTime : 2024-04-08T05:26:21.1936844Z + id : MSMjMCMjNmU2OTc2OTUtZWNmMC00MTE2LWEyNzYtYjcyOTE5NTBiNzRj + meetingId : MSplMTI1MWIxMC0xYmE0LTQ5ZTMtYjM1YS05MzNlM2YyMTc3MmIqMCoqMTk6bWVldGluZ19OREJpWVROa05XVXRaakptWlMwMFl6QTRMVGd3TlRRdE16WTNaR014T1Rjek1tUTBAdGhyZWFkLnYy + meetingOrganizer : {"application":null,"device":null,"user":{"id":"e1251b10-1ba4-49e3-b35a-933e3f21772b","displayName":null,"userIdentityType":"aadUser","tenantId":"de348bc7-1aeb-4406-8cb3-97db021cadb4"}} + meetingOrganizerId : e1251b10-1ba4-49e3-b35a-933e3f21772b + transcriptContentUrl: https://graph.microsoft.com/beta/users/e1251b10-1ba4-49e3-b35a-933e3f21772b/onlineMeetings/MSplMTI1MWIxMC0xYmE0LTQ5ZTMtYjM1YS05MzNlM2YyMTc3MmIqMCoqMTk6bWVldGluZ19OREJpWVROa05XVXRaakptWlMwMFl6QTRMVGd3TlRRdE16WTNaR014T1Rjek1tUTBAdGhyZWFkLnYy/transcripts/MSMjMCMjNmU2OTc2OTUtZWNmMC00MTE2LWEyNzYtYjcyOTE5NTBiNzRj/content + ``` + + + + + ```csv + id,meetingId,meetingOrganizerId,transcriptContentUrl,createdDateTime + MSMjMCMjNmU2OTc2OTUtZWNmMC00MTE2LWEyNzYtYjcyOTE5NTBiNzRj,MSplMTI1MWIxMC0xYmE0LTQ5ZTMtYjM1YS05MzNlM2YyMTc3MmIqMCoqMTk6bWVldGluZ19OREJpWVROa05XVXRaakptWlMwMFl6QTRMVGd3TlRRdE16WTNaR014T1Rjek1tUTBAdGhyZWFkLnYy,e1251b10-1ba4-49e3-b35a-933e3f21772b,https://graph.microsoft.com/beta/users/e1251b10-1ba4-49e3-b35a-933e3f21772b/onlineMeetings/MSplMTI1MWIxMC0xYmE0LTQ5ZTMtYjM1YS05MzNlM2YyMTc3MmIqMCoqMTk6bWVldGluZ19OREJpWVROa05XVXRaakptWlMwMFl6QTRMVGd3TlRRdE16WTNaR014T1Rjek1tUTBAdGhyZWFkLnYy/transcripts/MSMjMCMjNmU2OTc2OTUtZWNmMC00MTE2LWEyNzYtYjcyOTE5NTBiNzRj/content,2024-04-08T05:26:21.1936844Z + ``` + + + + + ```md + # teams meeting transcript get --meetingId "MSplMTI1MWIxMC0xYmE0LTQ5ZTMtYjM1YS05MzNlM2YyMTc3MmIqMCoqMTk6bWVldGluZ19OREJpWVROa05XVXRaakptWlMwMFl6QTRMVGd3TlRRdE16WTNaR014T1Rjek1tUTBAdGhyZWFkLnYy" --id "MSMjMCMjNmU2OTc2OTUtZWNmMC00MTE2LWEyNzYtYjcyOTE5NTBiNzRj" + + Date: 4/9/2024 + + ## MSMjMCMjNmU2OTc2OTUtZWNmMC00MTE2LWEyNzYtYjcyOTE5NTBiNzRj + + Property | Value + ---------|------- + id | MSMjMCMjNmU2OTc2OTUtZWNmMC00MTE2LWEyNzYtYjcyOTE5NTBiNzRj + meetingId | MSplMTI1MWIxMC0xYmE0LTQ5ZTMtYjM1YS05MzNlM2YyMTc3MmIqMCoqMTk6bWVldGluZ19OREJpWVROa05XVXRaakptWlMwMFl6QTRMVGd3TlRRdE16WTNaR014T1Rjek1tUTBAdGhyZWFkLnYy + meetingOrganizerId | e1251b10-1ba4-49e3-b35a-933e3f21772b + transcriptContentUrl | https://graph.microsoft.com/beta/users/e1251b10-1ba4-49e3-b35a-933e3f21772b/onlineMeetings/MSplMTI1MWIxMC0xYmE0LTQ5ZTMtYjM1YS05MzNlM2YyMTc3MmIqMCoqMTk6bWVldGluZ19OREJpWVROa05XVXRaakptWlMwMFl6QTRMVGd3TlRRdE16WTNaR014T1Rjek1tUTBAdGhyZWFkLnYy/transcripts/MSMjMCMjNmU2OTc2OTUtZWNmMC00MTE2LWEyNzYtYjcyOTE5NTBiNzRj/content + createdDateTime | 2024-04-08T05:26:21.1936844Z + ``` + + + diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 66cbf1c45c2..519bde6106b 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -4142,6 +4142,11 @@ const sidebars: SidebarsConfig = { label: 'meeting attendancereport list', id: 'cmd/teams/meeting/meeting-attendancereport-list' }, + { + type: 'doc', + label: 'meeting transcript get', + id: 'cmd/teams/meeting/meeting-transcript-get' + }, { type: 'doc', label: 'meeting transcript list', diff --git a/src/m365/teams/MeetingTranscript.ts b/src/m365/teams/MeetingTranscript.ts new file mode 100644 index 00000000000..ff73a72423f --- /dev/null +++ b/src/m365/teams/MeetingTranscript.ts @@ -0,0 +1,7 @@ +export interface MeetingTranscript { + id: string; + meetingId: string; + meetingOrganizerId: string; + transcriptContentUrl: string; + createdDateTime: Date; +} \ No newline at end of file diff --git a/src/m365/teams/commands.ts b/src/m365/teams/commands.ts index 7ef0ea0c6d7..dae836e0619 100644 --- a/src/m365/teams/commands.ts +++ b/src/m365/teams/commands.ts @@ -33,6 +33,7 @@ export default { MEETING_LIST: `${prefix} meeting list`, MEETING_ATTENDANCEREPORT_GET: `${prefix} meeting attendancereport get`, MEETING_ATTENDANCEREPORT_LIST: `${prefix} meeting attendancereport list`, + MEETING_TRANSCRIPT_GET: `${prefix} meeting transcript get`, MEETING_TRANSCRIPT_LIST: `${prefix} meeting transcript list`, MEMBERSETTINGS_LIST: `${prefix} membersettings list`, MEMBERSETTINGS_SET: `${prefix} membersettings set`, diff --git a/src/m365/teams/commands/meeting/meeting-transcript-get.spec.ts b/src/m365/teams/commands/meeting/meeting-transcript-get.spec.ts new file mode 100644 index 00000000000..5f975fc1e66 --- /dev/null +++ b/src/m365/teams/commands/meeting/meeting-transcript-get.spec.ts @@ -0,0 +1,358 @@ +import assert from 'assert'; +import fs from 'fs'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { CommandError } from '../../../../Command.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { Logger } from '../../../../cli/Logger.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { entraUser } from '../../../../utils/entraUser.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command from './meeting-transcript-get.js'; +import { settingsNames } from '../../../../settingsNames.js'; +import { PassThrough } from 'stream'; + +describe(commands.MEETING_TRANSCRIPT_GET, () => { + const userId = '68be84bf-a585-4776-80b3-30aa5207aa21'; + const userName = 'user@tenant.com'; + const email = 'user@tenant.com'; + const meetingId = 'MSo5MWZmMmUxNy04NGRlLTQ1NWEtODgxNS01MmIyMTY4M2Y2NGUqMCoqMTk6bWVldGluZ19ZMlEzTlRRMFpEWXRaamMzWkMwMFlUVmhMVGt4TTJJdFpURmtNMkUwTUdGak1qVmpAdGhyZWFkLnYy'; + const id = 'MSMjMCMjZDAwYWU3NjUtNmM2Yi00NjQxLTgwMWQtMTkzMmFmMjEzNzdh'; + const outputFile = 'c:\transcript.vtt'; + const meetingTranscriptResponse = { + "id": "MSMjMCMjZDAwYWU3NjUtNmM2Yi00NjQxLTgwMWQtMTkzMmFmMjEzNzdh", + "meetingId": "MSo5MWZmMmUxNy04NGRlLTQ1NWEtODgxNS01MmIyMTY4M2Y2NGUqMCoqMTk6bWVldGluZ19ZMlEzTlRRMFpEWXRaamMzWkMwMFlUVmhMVGt4TTJJdFpURmtNMkUwTUdGak1qVmpAdGhyZWFkLnYy", + "meetingOrganizerId": "68be84bf-a585-4776-80b3-30aa5207aa21", + "transcriptContentUrl": "https://graph.microsoft.com/beta/users/68be84bf-a585-4776-80b3-30aa5207aa21/onlineMeetings/MSo5MWZmMmUxNy04NGRlLTQ1NWEtODgxNS01MmIyMTY4M2Y2NGUqMCoqMTk6bWVldGluZ19ZMlEzTlRRMFpEWXRaamMzWkMwMFlUVmhMVGt4TTJJdFpURmtNMkUwTUdGak1qVmpAdGhyZWFkLnYy/transcripts/MSMjMCMjZDAwYWU3NjUtNmM2Yi00NjQxLTgwMWQtMTkzMmFmMjEzNzdh/content", + "createdDateTime": "2021-09-17T06:09:24.8968037Z" + }; + + let log: string[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.connection.active = true; + auth.connection.accessTokens[auth.defaultResource] = { + expiresOn: 'abc', + accessToken: 'abc' + }; + commandInfo = cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + }); + + afterEach(() => { + sinonUtil.restore([ + accessToken.isAppOnlyAccessToken, + request.get, + entraUser.getUserIdByEmail, + entraUser.getUserIdByUpn, + cli.executeCommandWithOutput, + cli.getSettingWithDefaultValue, + fs.createWriteStream + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + auth.connection.accessTokens = {}; + }); + + it('has a correct name', () => { + assert.strictEqual(command.name, commands.MEETING_TRANSCRIPT_GET); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation when the userId is not a valid GUID', async () => { + const actual = await command.validate({ options: { userId: 'foo', meetingId: meetingId, id: id } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when the userName is not valid', async () => { + const actual = await command.validate({ options: { userName: 'foo', meetingId: meetingId, id: id } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when the email is not valid', async () => { + const actual = await command.validate({ options: { email: 'foo', meetingId: meetingId, id: id } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('succeeds validation when the userId, meetingId, and id are valid', async () => { + const actual = await command.validate({ options: { userId: userId, meetingId: meetingId, id: id } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('succeeds validation when the userName, meetingId, and id are valid', async () => { + const actual = await command.validate({ options: { userName: userName, meetingId: meetingId, id: id } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('succeeds validation when the email, meetingId, and id are valid', async () => { + const actual = await command.validate({ options: { email: email, meetingId: meetingId, id: id } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation when the userId, email, and userName are given', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { userId: userId, userName: userName, email: email, meetingId: meetingId, id: id } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when the userId and email are given', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { userId: userId, email: email, meetingId: meetingId, id: id } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when the userId and userName are given', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { userId: userId, userName: userName, meetingId: meetingId, id: id } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when the userName and email are given', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { userId: userId, email: email, meetingId: meetingId, id: id } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if path doesn\'t exist', async () => { + sinon.stub(fs, 'existsSync').returns(false); + const actual = await command.validate({ options: { meetingId: meetingId, id: id, outputFile: 'abc' } }, commandInfo); + sinonUtil.restore(fs.existsSync); + assert.notStrictEqual(actual, true); + }); + + it('retrieves transcript correctly for the given meetingId for the current user', async () => { + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/beta/me/onlineMeetings/${meetingId}/transcripts/${id}`) { + return meetingTranscriptResponse; + } + throw 'Invalid request.'; + }); + + await command.action(logger, { options: { meetingId: meetingId, id: id } }); + assert(loggerLogSpy.calledWith(meetingTranscriptResponse)); + }); + + it('retrieves transcript correctly for the given id, meetingId, and userID', async () => { + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/beta/users/${userId}/onlineMeetings/${meetingId}/transcripts/${id}`) { + return meetingTranscriptResponse; + } + + throw 'Invalid request.'; + }); + + await command.action(logger, { options: { userId: userId, meetingId: meetingId, id: id } }); + + assert(loggerLogSpy.calledWith(meetingTranscriptResponse)); + }); + + it('retrieves transcript correctly for the given id, meetingId, and userName', async () => { + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/beta/users/${userName}/onlineMeetings/${meetingId}/transcripts/${id}`) { + return meetingTranscriptResponse; + } + + throw 'Invalid request.'; + }); + + await command.action(logger, { options: { userName: userName, meetingId: meetingId, id: id } }); + + assert(loggerLogSpy.calledWith(meetingTranscriptResponse)); + }); + + it('retrieves transcript correctly for the given id, meetingId, and email (verbose)', async () => { + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users?$filter=mail eq '${formatting.encodeQueryParameter(email)}'&$select=id`) { + return { + value: [ + { + id: userId + }] + }; + } + + if (opts.url === `https://graph.microsoft.com/beta/users/${userId}/onlineMeetings/${meetingId}/transcripts/${id}`) { + return meetingTranscriptResponse; + } + + throw 'Invalid request.'; + }); + + await command.action(logger, { options: { verbose: true, email: email, meetingId: meetingId, id: id } }); + assert(loggerLogSpy.calledWith(meetingTranscriptResponse)); + }); + + it('downloads a transcript when outputFile is specified (verbose)', async () => { + const mockResponse = `{"data": 123}`; + const responseStream = new PassThrough(); + responseStream.write(mockResponse); + responseStream.end(); //Mark that we pushed all the data. + + const writeStream = new PassThrough(); + const fsStub = sinon.stub(fs, 'createWriteStream').returns(writeStream as any); + + setTimeout(() => { + writeStream.emit('close'); + }, 0); + + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/beta/me/onlineMeetings/${meetingId}/transcripts/${id}/content?$format=text/vtt`) { + return { + data: responseStream + }; + } + + throw 'Invalid request.'; + }); + + try { + await command.action(logger, { options: { verbose: true, meetingId: meetingId, id: id, outputFile: outputFile } }); + assert(fsStub.calledOnce); + } + finally { + sinonUtil.restore([ + fs.createWriteStream + ]); + } + }); + + it('correctly handles error when the meeting transcript not found', async () => { + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/beta/me/onlineMeetings/${meetingId}/transcripts/${id}`) { + return; + } + + throw 'The specified meeting transcript was not found'; + }); + + await assert.rejects(command.action(logger, { options: { meetingId: meetingId, id: id } }), + new CommandError(`The specified meeting transcript was not found`)); + }); + + it(`handles error when saving the transcript to file fails`, async () => { + const mockResponse = `{"data": 123}`; + const responseStream = new PassThrough(); + responseStream.write(mockResponse); + responseStream.end(); //Mark that we pushed all the data. + + const writeStream = new PassThrough(); + sinon.stub(fs, 'createWriteStream').returns(writeStream as any); + + setTimeout(() => { + writeStream.emit('error', "An error has occurred"); + }, 0); + + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/beta/me/onlineMeetings/${meetingId}/transcripts/${id}/content?$format=text/vtt`) { + return { + data: responseStream + }; + } + + throw 'Invalid request.'; + }); + + await assert.rejects(command.action(logger, { options: { meetingId: meetingId, id: id, outputFile: outputFile } }), + new CommandError('An error has occurred')); + }); + + it('correctly handles error when throwing request', async () => { + const errorMessage = 'An error has occurred'; + + sinon.stub(request, 'get').rejects({ error: { error: { message: errorMessage } } }); + + await assert.rejects(command.action(logger, { options: { verbose: true, meetingId: meetingId, id: id } } as any), + new CommandError(errorMessage)); + }); + + it('correctly handles error when options are missing', async () => { + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + await assert.rejects(command.action(logger, { options: { meetingId: meetingId, id: id } } as any), + new CommandError(`The option 'userId', 'userName' or 'email' is required when retrieving meeting transcript using app only permissions`)); + }); + + it('correctly handles error when options are missing with a delegated token', async () => { + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); + + await assert.rejects(command.action(logger, { options: { userId: userId, meetingId: meetingId, id: id } } as any), + new CommandError(`The options 'userId', 'userName', and 'email' cannot be used while retrieving meeting transcript using delegated permissions`)); + }); +}); \ No newline at end of file diff --git a/src/m365/teams/commands/meeting/meeting-transcript-get.ts b/src/m365/teams/commands/meeting/meeting-transcript-get.ts new file mode 100644 index 00000000000..586393c9d8c --- /dev/null +++ b/src/m365/teams/commands/meeting/meeting-transcript-get.ts @@ -0,0 +1,196 @@ +import auth from '../../../../Auth.js'; +import GlobalOptions from '../../../../GlobalOptions.js'; +import { Logger } from '../../../../cli/Logger.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { entraUser } from '../../../../utils/entraUser.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import { validation } from '../../../../utils/validation.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; +import { MeetingTranscript } from '../../MeetingTranscript.js'; +import fs from 'fs'; +import path from 'path'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + userId?: string; + userName?: string; + email?: string; + meetingId: string; + id: string; + outputFile?: string; +} + +class TeamsMeetingTranscriptGetCommand extends GraphCommand { + public get name(): string { + return commands.MEETING_TRANSCRIPT_GET; + } + + public get description(): string { + return 'Downloads a transcript for a given meeting'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + this.#initOptionSets(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + userId: typeof args.options.userId !== 'undefined', + userName: typeof args.options.userName !== 'undefined', + email: typeof args.options.email !== 'undefined', + outputFile: typeof args.options.outputFile !== 'undefined' + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-u, --userId [userId]' + }, + { + option: '-n, --userName [userName]' + }, + { + option: '--email [email]' + }, + { + option: '-m, --meetingId ' + }, + { + option: '-i, --id ' + }, + { + option: '-f, --outputFile [outputFile]' + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.userId && !validation.isValidGuid(args.options.userId)) { + return `${args.options.userId} is not a valid Guid`; + } + + if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { + return `${args.options.userName} is not a valid user principal name (UPN)`; + } + + if (args.options.email && !validation.isValidUserPrincipalName(args.options.email)) { + return `${args.options.email} is not a valid email`; + } + + if (args.options.outputFile && !fs.existsSync(path.dirname(args.options.outputFile))) { + return 'Specified path where to save the file does not exits'; + } + + return true; + } + ); + } + + #initOptionSets(): void { + this.optionSets.push({ + options: ['userId', 'userName', 'email'], + runsWhen: (args) => args.options.userId || args.options.userName || args.options.email + }); + } + + public async commandAction(logger: Logger, args: any): Promise { + try { + const isAppOnlyAccessToken: boolean | undefined = accessToken.isAppOnlyAccessToken(auth.connection.accessTokens[this.resource].accessToken); + if (this.verbose) { + await logger.logToStderr(`Retrieving transcript for the given meeting...`); + } + + let requestUrl: string = `${this.resource}/beta/`; + if (isAppOnlyAccessToken) { + if (!args.options.userId && !args.options.userName && !args.options.email) { + throw `The option 'userId', 'userName' or 'email' is required when retrieving meeting transcript using app only permissions`; + } + + requestUrl += 'users/'; + if (args.options.userId) { + requestUrl += args.options.userId; + } + else if (args.options.userName) { + requestUrl += args.options.userName; + } + else if (args.options.email) { + if (this.verbose) { + await logger.logToStderr(`Getting user ID for user with email '${args.options.email}'.`); + } + const userId: string = await entraUser.getUserIdByEmail(args.options.email!); + requestUrl += userId; + } + } + else { + if (args.options.userId || args.options.userName || args.options.email) { + throw `The options 'userId', 'userName', and 'email' cannot be used while retrieving meeting transcript using delegated permissions`; + } + + requestUrl += `me`; + } + + requestUrl += `/onlineMeetings/${args.options.meetingId}/transcripts/${args.options.id}`; + + if (args.options.outputFile) { + requestUrl += '/content?$format=text/vtt'; + } + + const requestOptions: CliRequestOptions = { + url: requestUrl, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: args.options.outputFile ? 'stream' : 'json' + }; + + const meetingTranscript = await request.get(requestOptions); + + if (meetingTranscript) { + if (args.options.outputFile) { + // Not possible to use async/await for this promise + await new Promise((resolve, reject) => { + const writer = fs.createWriteStream(args.options.outputFile as string); + (meetingTranscript as any).data.pipe(writer); + + writer.on('error', err => { + reject(err); + }); + + writer.on('close', async () => { + const filePath = args.options.outputFile as string; + if (this.verbose) { + await logger.logToStderr(`File saved to path ${filePath}`); + } + return resolve(); + }); + }); + } + else { + await logger.log(meetingTranscript); + } + } + else { + throw `The specified meeting transcript was not found`; + } + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new TeamsMeetingTranscriptGetCommand(); \ No newline at end of file