diff --git a/docs/docs/cmd/teams/meeting/meeting-create.mdx b/docs/docs/cmd/teams/meeting/meeting-create.mdx new file mode 100644 index 00000000000..7169a3821cd --- /dev/null +++ b/docs/docs/cmd/teams/meeting/meeting-create.mdx @@ -0,0 +1,241 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# teams meeting create + +Create a new online meeting + +## Usage + +```sh +m365 teams meeting create [options] +``` + +## Options + +```md definition-list +`-s --startTime [startTime]` +: The start time of the meeting. If not specified, the startTime will be set to the current time. + +`-e --endTime [endTime]` +: The end time of the meeting. If not specified, the endTime will be set to one hour after the startTime. + +`--subject [subject]` +: The subject of the meeting. + +`-p, --participants [participants]` +: A comma-separated list of participant UPNs. + +`--organizerEmail [organizerEmail]` +: The organizer's email address. Requires application permissions. + +`-r, --recordAutomatically` +: When using this flag, the meeting will be recorded automatically. +``` + + + +## Examples + +Create a new online meeting for the currently logged-in user, starting immediately and ending after one hour. + +```sh +m365 teams meeting create +``` + +Create a new online meeting for the currently logged-in user, with a specified start date and a duration of one hour. + +```sh +m365 teams meeting create --startTime "2023-09-21T13:30:00Z" +``` + +Create a new online meeting for the currently logged-in user, with specified start and end dates. + +```sh +m365 teams meeting create --startTime "2023-09-21T13:30:00Z" --endTime "2023-09-21T23:55:00Z" +``` + +Create a new online meeting for the currently logged-in user, with a specified subject. + +```sh +m365 teams meeting create --startTime "2023-09-21T13:30:00Z" --endTime "2023-09-21T23:55:00Z" --subject "Test Subject" +``` + +Create a new online meeting for the currently logged-in user, with a specified subject and a list of participants. + +```sh +m365 teams meeting create --subject "Test Subject" --participantEmails "john.doe@contoso.com,olga.manager@contoso.com" +``` + +Create a new online meeting for the currently logged-in user, with a specified subject, a list of participants, and automatic meeting recording. + +```sh +m365 teams meeting create --subject "Test Subject" --participantEmails "john.doe@contoso.com,olga.manager@contoso.com" --recordAutomatically +``` + +Create a new online meeting for a selected organizer, with a specified subject, a list of participants, and automatic meeting recording. This option is available for app-only permissions. + +```sh +m365 teams meeting create --organizerEmail "john.doe@contoso.com" --subject "Test Subject" --participantEmails "olga.manager@contoso.com" --recordAutomatically +``` + +## Response + + + + + ```json + [ + { + "id": "abc", + "creationDateTime": "2023-07-25T19:29:32.033109Z", + "startDateTime": "2023-07-17T03:00:00Z", + "endDateTime": "2023-07-17T04:00:00Z", + "joinUrl": "https://teams.microsoft.com/l/meetup-join/abc", + "joinWebUrl": "https://teams.microsoft.com/l/meetup-join/abc", + "meetingCode": "12345", + "subject": "Subject", + "isBroadcast": false, + "autoAdmittedUsers": "unknownFutureValue", + "outerMeetingAutoAdmittedUsers": null, + "isEntryExitAnnounced": false, + "allowedPresenters": "everyone", + "allowMeetingChat": "enabled", + "shareMeetingChatHistoryDefault": "none", + "allowTeamworkReactions": true, + "allowAttendeeToEnableMic": true, + "allowAttendeeToEnableCamera": true, + "recordAutomatically": false, + "anonymizeIdentityForRoles": [], + "capabilities": [], + "videoTeleconferenceId": null, + "externalId": null, + "iCalUid": null, + "meetingType": null, + "allowParticipantsToChangeName": false, + "allowRecording": true, + "allowTranscription": true, + "meetingMigrationMode": null, + "broadcastSettings": null, + "audioConferencing": null, + "meetingInfo": null, + "participants": { + "organizer": { + "upn": "john.doe@contoso.com", + "role": "presenter", + "identity": { + "application": null, + "device": null, + "user": { + "id": "b2091e18-7882-4efe-b7d1-90703f5a5c65", + "displayName": null, + "tenantId": "ad4f158a-97c7-4914-a9bd-038ecde40ff3", + "identityProvider": "AAD" + } + } + }, + "attendees": [ + { + "upn": "adele.vance@contoso.com", + "role": "attendee", + "identity": { + "application": null, + "device": null, + "user": { + "id": "52bd2d9c-2d89-416f-96c4-ca94245e22c8", + "displayName": null, + "tenantId": "ad4f158a-97c7-4914-a9bd-038ecde40ff3", + "identityProvider": "AAD" + } + } + } + ] + }, + "lobbyBypassSettings": { + "scope": "unknownFutureValue", + "isDialInBypassEnabled": false + }, + "joinMeetingIdSettings": { + "isPasscodeRequired": true, + "joinMeetingId": "12345", + "passcode": "Z3GYtQ" + }, + "chatInfo": { + "threadId": "abc", + "messageId": "0", + "replyChainMessageId": null + }, + "joinInformation": { + "content": "textContent", + "contentType": "html" + }, + "watermarkProtection": { + "isEnabledForContentSharing": false, + "isEnabledForVideo": false + } + } + ] + ``` + + + + + ```text + endDateTime : 2023-10-05T13:57:09.1889486Z + joinUrl : https://teams.microsoft.com/l/meetup-join/abc + recordAutomatically: false + startDateTime : 2023-10-05T12:57:09.1889486Z + subject : null + ``` + + + + + ```csv + @odata.context,id,creationDateTime,startDateTime,endDateTime,joinUrl,joinWebUrl,meetingCode,isBroadcast,autoAdmittedUsers,isEntryExitAnnounced,allowedPresenters,allowMeetingChat,shareMeetingChatHistoryDefault,allowTeamworkReactions,allowAttendeeToEnableMic,allowAttendeeToEnableCamera,recordAutomatically,allowParticipantsToChangeName,allowRecording,allowTranscription +https://graph.microsoft.com/v1.0/$metadata#users('id')/onlineMeetings/$entity,abc,2023-10-05T12:56:20.9466495Z,2023-10-05T12:56:20.6594121Z,2023-10-05T13:56:20.6594121Z,https://teams.microsoft.com/l/meetup-join/abc,https://teams.microsoft.com/l/meetup-join/abc,388012423843,,everyoneInCompany,1,everyone,enabled,none,1,1,1,,,1,1 + ``` + + + + + ```md +# teams meeting create --organizerEmail "john.doe@contoso.com" + +Date: 05/10/2023 + +## abc + + Property | Value +---------|------- +@odata.context | https://graph.microsoft.com/v1.0/$metadata#users('id')/onlineMeetings/$entity +id | abc +creationDateTime | 2023-10-05T12:56:00.6719952Z +startDateTime | 2023-10-05T12:56:00.2303956Z +endDateTime | 2023-10-05T13:56:00.2303956Z +joinUrl | https://teams.microsoft.com/l/meetup-join/abc +joinWebUrl | https://teams.microsoft.com/l/meetup-join/abc +meetingCode | 12345 +isBroadcast | false +autoAdmittedUsers | everyoneInCompany +isEntryExitAnnounced | true +allowedPresenters | everyone +allowMeetingChat | enabled +shareMeetingChatHistoryDefault | none +allowTeamworkReactions | true +allowAttendeeToEnableMic | true +allowAttendeeToEnableCamera | true +recordAutomatically | false +allowParticipantsToChangeName | false +allowRecording | true +allowTranscription | true + ``` + + + + + +## Remarks + +To create an online meeting for a specific organizer, use the **--organizerEmail** parameter along with app-only permissions. The registered app should have the **OnlineMeetings.ReadWrite.All** permissions, and a special policy should be assigned to the user specified in the **organizerEmail** option. You can find more information on how to assign this policy to a user [here](https://learn.microsoft.com/en-us/graph/cloud-communication-online-meeting-application-access-policy). \ No newline at end of file diff --git a/docs/src/config/sidebars.js b/docs/src/config/sidebars.js index 28f8a409d37..6a69a7662ae 100644 --- a/docs/src/config/sidebars.js +++ b/docs/src/config/sidebars.js @@ -3746,6 +3746,11 @@ const sidebars = { }, { meeting: [ + { + type: 'doc', + label: 'meeting create', + id: 'cmd/teams/meeting/meeting-create' + }, { type: 'doc', label: 'meeting get', diff --git a/src/m365/teams/commands.ts b/src/m365/teams/commands.ts index 7ff1d3c7e5d..8f16b9abae4 100644 --- a/src/m365/teams/commands.ts +++ b/src/m365/teams/commands.ts @@ -29,6 +29,7 @@ export default { GUESTSETTINGS_LIST: `${prefix} guestsettings list`, GUESTSETTINGS_SET: `${prefix} guestsettings set`, MEETING_ATTENDANCEREPORT_LIST: `${prefix} meeting attendancereport list`, + MEETING_CREATE: `${prefix} meeting create`, MEETING_GET: `${prefix} meeting get`, MEETING_LIST: `${prefix} meeting list`, MEETING_TRANSCRIPT_LIST: `${prefix} meeting transcript list`, diff --git a/src/m365/teams/commands/meeting/meeting-create.spec.ts b/src/m365/teams/commands/meeting/meeting-create.spec.ts new file mode 100644 index 00000000000..4f6ee32ae24 --- /dev/null +++ b/src/m365/teams/commands/meeting/meeting-create.spec.ts @@ -0,0 +1,506 @@ +import assert from 'assert'; +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 { aadUser } from '../../../../utils/aadUser.js'; +import { accessToken } from '../../../../utils/accessToken.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-create.js'; + +describe(commands.MEETING_CREATE, () => { + const startTime = '2022-04-04T03:00:00Z'; + const endTime = '2022-04-04T04:00:00Z'; + const subject = 'test subject'; + const participants = 'abc@email.com,abc2@email.com'; + const organizerEmail = 'organizer@email.com'; + + // #region responses + const meeting = `{"@odata.context":"https://graph.microsoft.com/v1.0/$metadata#users('1af1bc3a-eb29-4e90-b38a-7ff729d4ac00')/onlineMeetings/$entity","id":"MSoxYWYxYmMzYS1lYjI5LTRlOTAtYjM4YS03ZmY3MjlkNGFjMDAqMCoqMTk6bWVldGluZ19NekkyTnpoak4yTXRaVEk1WmkwMFlUaGpMVGd6WTJNdFl6RTFNamRpTUdSbFpUQTNAdGhyZWFkLnYy","creationDateTime":"2023-10-03T19:13:29.9677485Z","startDateTime":"2023-10-03T19:13:29.596964Z","endDateTime":"2023-10-03T20:13:29.596964Z","joinUrl":"https://teams.microsoft.com/l/meetup-join/19%3ameeting_MzI2N…+4px%3bfont-family%3a%27Segoe+UI%27%2c%27Helvetica+Neue%27%2cHelvetica%2cArial%2csans-serif%3b%22%3e%0d%0a%0d%0a%3c%2fdiv%3e%0d%0a%3cdiv+style%3d%22font-size%3a+12px%3b%22%3e%0d%0a%0d%0a%3c%2fdiv%3e%0d%0a%0d%0a%3c%2fdiv%3e%0d%0a%3cdiv+style%3d%22width%3a100%25%3b%22%3e%0d%0a++++%3cspan+style%3d%22white-space%3anowrap%3bcolor%3a%235F5F5F%3bopacity%3a.36%3b%22%3e________________________________________________________________________________%3c%2fspan%3e%0d%0a%3c%2fdiv%3e","contentType":"html"}}`; + + // #endregion + + 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.service.connected = true; + auth.service.accessTokens[auth.defaultResource] = { + expiresOn: 'abc', + accessToken: 'abc' + }; + commandInfo = Cli.getCommandInfo(command); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); + }); + + 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, + request.post, + aadUser.getUserIdByEmail + ]); + }); + + after(() => { + sinon.restore(); + auth.service.connected = false; + auth.service.accessTokens = {}; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.MEETING_CREATE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('completes validation when no parameter is provided', async () => { + const actual = await command.validate({ options: { startTime: undefined, endTime: undefined, subject: undefined, participants: undefined, organizerEmail: undefined, recordAutomatically: undefined } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('completes validation when only startTime is provided and it is a valid ISODateTime', async () => { + const actual = await command.validate({ options: { startTime: startTime } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('completes validation when the startTime and endTime are provided and they are valid ISODateTime', async () => { + const actual = await command.validate({ options: { startTime: startTime, endTime: endTime } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('completes validation when only subject is provided', async () => { + const actual = await command.validate({ options: { subject: subject } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('completes validation when only organizerEmail is provided', async () => { + const actual = await command.validate({ options: { organizerEmail: organizerEmail } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('completes validation when only participants parameter is provided', async () => { + const actual = await command.validate({ options: { participants: participants } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation when the startTime is not a valid ISODateTime', async () => { + const actual = await command.validate({ options: { startTime: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when the correct startTime is provided and the endTime is not a valid ISODateTime', async () => { + const actual = await command.validate({ options: { startTime: startTime, endTime: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when the correct endTime is provided and the startTime is not provided', async () => { + const actual = await command.validate({ options: { endTime: endTime } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + + it('fails validation when endTime is before startTime', async () => { + const actual = await command.validate({ options: { startTime: '2023-01-01', endTime: '2022-12-31' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when the organizerEmail is not a valid', async () => { + const actual = await command.validate({ options: { organizerEmail: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when the participants is not a valid', async () => { + const actual = await command.validate({ options: { participants: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when the participants are not separated by comma', async () => { + const actual = await command.validate({ options: { participants: 'abc@email.com|abc2@email.com' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when the participants has incorrect email', async () => { + const actual = await command.validate({ options: { participants: 'abc@email.com,foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when startDateTime is behind endDateTime', async () => { + const actual = await command.validate({ options: { startDateTime: '2023-01-01', endDateTime: '2022-12-31' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('throws an error when the organizerEmail is not filled in when signed in using app-only authentication', async () => { + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + await assert.rejects(command.action(logger, { options: { verbose: true } }), + new CommandError(`The option 'organizerEmail' is required when creating a meeting using app only permissions`)); + }); + + it('create a meeting for the currently logged in user', async () => { + let calledUrl = ''; + let calledData = ''; + + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/me/onlineMeetings') { + calledUrl = opts.url; + calledData = opts.data; + return meeting; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { + options: { + verbose: true + } + }); + + assert.strictEqual(calledUrl, 'https://graph.microsoft.com/v1.0/me/onlineMeetings'); + assert.deepEqual(calledData, {}); + assert(loggerLogSpy.calledWith(meeting)); + }); + + it('create a meeting with defined startDate for the currently logged in user', async () => { + let calledUrl = ''; + let calledData = ''; + + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/me/onlineMeetings') { + calledUrl = opts.url; + calledData = opts.data; + return meeting; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { + options: { + verbose: true, + startTime: startTime + } + }); + + assert.strictEqual(calledUrl, 'https://graph.microsoft.com/v1.0/me/onlineMeetings'); + assert.deepEqual(calledData, { startDateTime: startTime }); + assert(loggerLogSpy.calledWith(meeting)); + }); + + it('create a meeting with defined startDate and endDate for the currently logged in user', async () => { + let calledUrl = ''; + let calledData = ''; + + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/me/onlineMeetings') { + calledUrl = opts.url; + calledData = opts.data; + return meeting; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { + options: { + verbose: true, + startTime: startTime, + endTime: endTime + } + }); + + assert.strictEqual(calledUrl, 'https://graph.microsoft.com/v1.0/me/onlineMeetings'); + assert.deepEqual(calledData, { startDateTime: startTime, endDateTime: endTime }); + assert(loggerLogSpy.calledWith(meeting)); + }); + + it('create a meeting with defined startDate, endDate and subject for the currently logged in user', async () => { + let calledUrl = ''; + let calledData = ''; + + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/me/onlineMeetings') { + calledUrl = opts.url; + calledData = opts.data; + return meeting; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { + options: { + verbose: true, + startTime: startTime, + endTime: endTime, + subject: subject + } + }); + + assert.strictEqual(calledUrl, 'https://graph.microsoft.com/v1.0/me/onlineMeetings'); + assert.deepEqual(calledData, { startDateTime: startTime, endDateTime: endTime, subject: subject }); + assert(loggerLogSpy.calledWith(meeting)); + }); + + it('create a meeting with defined startDate, endDate, subject, and participants for the currently logged in user', async () => { + let calledUrl = ''; + let calledData = ''; + + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/me/onlineMeetings') { + calledUrl = opts.url; + calledData = opts.data; + return meeting; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { + options: { + verbose: true, + startTime: startTime, + endTime: endTime, + subject: subject, + participants: participants + } + }); + + assert.strictEqual(calledUrl, 'https://graph.microsoft.com/v1.0/me/onlineMeetings'); + assert.deepEqual(calledData, { + startDateTime: startTime, endDateTime: endTime, subject: subject, participants: { + attendees: [ + { + "upn": "abc@email.com" + }, + { + "upn": "abc2@email.com" + } + ] + } + }); + assert(loggerLogSpy.calledWith(meeting)); + }); + + it('create a meeting with defined startDate, endDate, subject, participants and recordAutomatically for the currently logged in user', async () => { + let calledUrl = ''; + let calledData = ''; + + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/me/onlineMeetings') { + calledUrl = opts.url; + calledData = opts.data; + return meeting; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { + options: { + verbose: true, + startTime: startTime, + endTime: endTime, + subject: subject, + participants: participants, + recordAutomatically: true + } + }); + + assert.strictEqual(calledUrl, 'https://graph.microsoft.com/v1.0/me/onlineMeetings'); + assert.deepEqual(calledData, { + startDateTime: startTime, endDateTime: endTime, subject: subject, participants: { + attendees: [ + { + "upn": "abc@email.com" + }, + { + "upn": "abc2@email.com" + } + ] + }, recordAutomatically: true + }); + assert(loggerLogSpy.calledWith(meeting)); + }); + + + it('create a meeting with defined startDate, endDate, subject, participants and recordAutomatically for the specified organizerEmail when app only authorization', async () => { + let calledUrl = ''; + let calledData = ''; + const testOrganizerId = '12345678-7882-4efe-b7d1-90703f5a5c65'; + + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + 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 'organizer%40email.com'&$select=id`) { + return { value: [{ id: testOrganizerId }] }; + } + throw 'Invalid request: ' + opts.url; + }); + + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${testOrganizerId}/onlineMeetings`) { + calledUrl = opts.url; + calledData = opts.data; + return meeting; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { + options: { + verbose: true, + startTime: startTime, + endTime: endTime, + subject: subject, + participants: participants, + recordAutomatically: true, + organizerEmail: organizerEmail + } + }); + + assert.strictEqual(calledUrl, `https://graph.microsoft.com/v1.0/users/${testOrganizerId}/onlineMeetings`); + assert.deepEqual(calledData, { + startDateTime: startTime, endDateTime: endTime, subject: subject, participants: { + attendees: [ + { + "upn": "abc@email.com" + }, + { + "upn": "abc2@email.com" + } + ] + }, recordAutomatically: true + }); + assert(loggerLogSpy.calledWith(meeting)); + }); + + it('handles error correctly when organizer Id is not found', async () => { + const testOrganizerId = '12345678-7882-4efe-b7d1-90703f5a5c65'; + + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + 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 'organizer%40email.com'&$select=id`) { + return { value: [] }; + } + throw 'Invalid request: ' + opts.url; + }); + + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${testOrganizerId}/onlineMeetings`) { + return meeting; + } + + throw 'Invalid request: ' + opts.url; + }); + + await assert.rejects(command.action(logger, { + options: { + verbose: true, + startTime: startTime, + endTime: endTime, + subject: subject, + participants: participants, + recordAutomatically: true, + organizerEmail: organizerEmail + } + }), new CommandError(`The specified user with email organizer@email.com does not exist`) + ); + }); + + it('handles error forbidden correctly', async () => { + + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/me/onlineMeetings') { + throw { + response: { + status: 403 + }, + message: 'Forbidden' + }; + } + + throw 'Invalid request: ' + opts.url; + }); + + + await assert.rejects(command.action(logger, { + options: { + verbose: true, + startTime: startTime, + endTime: endTime, + subject: subject, + participants: participants, + recordAutomatically: true + } + }), new CommandError(`Forbidden. You do not have permission to perform this action. Please verify the command's details for more information.`) + ); + }); + + it('handles error correctly', async () => { + + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/me/onlineMeetings') { + + throw { + response: { + status: 404 + }, + message: 'Error message' + }; + } + + throw 'Invalid request: ' + opts.url; + }); + + + await assert.rejects(command.action(logger, { + options: { + verbose: true, + startTime: startTime, + endTime: endTime, + subject: subject, + participants: participants, + recordAutomatically: true + } + }), new CommandError('Error message') + ); + }); +}); \ No newline at end of file diff --git a/src/m365/teams/commands/meeting/meeting-create.ts b/src/m365/teams/commands/meeting/meeting-create.ts new file mode 100644 index 00000000000..c49dbfbfbc7 --- /dev/null +++ b/src/m365/teams/commands/meeting/meeting-create.ts @@ -0,0 +1,206 @@ +import auth from '../../../../Auth.js'; +import GlobalOptions from '../../../../GlobalOptions.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { aadUser } from '../../../../utils/aadUser.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 request, { CliRequestOptions } from '../../../../request.js'; +import { OnlineMeeting } from '@microsoft/microsoft-graph-types'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + startTime?: string; + endTime?: string; + subject?: string; + participants?: string; + organizerEmail?: string; + recordAutomatically?: boolean; +} + +class TeamsMeetingCreateCommand extends GraphCommand { + public get name(): string { + return commands.MEETING_CREATE; + } + + public get description(): string { + return 'Create a new online meeting'; + } + + public defaultProperties(): string[] | undefined { + return ['subject', 'startDateTime', 'endDateTime', 'joinUrl', 'recordAutomatically']; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + startTime: typeof args.options.startTime !== 'undefined', + endTime: typeof args.options.endTime !== 'undefined', + subject: typeof args.options.subject !== 'undefined', + participants: typeof args.options.participants !== 'undefined', + organizerEmail: typeof args.options.organizerEmail !== 'undefined', + recordAutomatically: !!args.options.recordAutomatically + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-s --startTime [startTime]' + }, + { + option: '-e --endTime [endTime]' + }, + { + option: '-s --subject [subject]' + }, + { + option: '-p --participants [participants]' + }, + { + option: '--organizerEmail [organizerEmail]' + }, + { + option: '-r --recordAutomatically' + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.startTime && !validation.isValidISODateTime(args.options.startTime)) { + return `'${args.options.startTime}' is not a valid ISO date string for startTime.`; + } + if (args.options.endTime && !validation.isValidISODateTime(args.options.endTime)) { + return `'${args.options.endTime}' is not a valid ISO date string for endTime.`; + } + if (args.options.startTime && args.options.endTime && new Date(args.options.startTime) >= new Date(args.options.endTime)) { + return 'startTime value must be before endTime.'; + } + if (args.options.endTime && !args.options.startTime) { + return 'startTime should be specified when endTime is specified.'; + } + if (args.options.participants) { + if (args.options.participants.indexOf(',') === -1 && !validation.isValidUserPrincipalName(args.options.participants)) { + return `${args.options.participants} contains invalid UPN.`; + } + const participants = args.options.participants.trim().toLowerCase().split(',').filter(e => e && e !== ''); + if (!participants || participants.length === 0 || participants.some(e => !validation.isValidUserPrincipalName(e))) { + return `${args.options.participants} contains one or more invalid UPN.`; + } + } + if (args.options.organizerEmail && !validation.isValidUserPrincipalName(args.options.organizerEmail)) { + return `'${args.options.organizerEmail}' is not a valid email for organizerEmail.`; + } + return true; + } + ); + } + + /** + * Executes the command + * @param logger Logger instance + * @param args Command arguments + */ + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + const isAppOnlyAccessToken = accessToken.isAppOnlyAccessToken(auth.service.accessTokens[this.resource].accessToken)!; + if (isAppOnlyAccessToken && !args.options.organizerEmail) { + throw `The option 'organizerEmail' is required when creating a meeting using app only permissions`; + } + const graphBaseUrl = await this.getGraphBaseUrl(args.options); + const meeting = await this.createMeeting(logger, graphBaseUrl, args.options); + await logger.log(meeting); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + /** + * Gets the base MS Graph URL for the request + * @param options + * @returns correct MS Graph URL for the request + */ + private async getGraphBaseUrl(options: Options): Promise { + let requestUrl = `${this.resource}/v1.0/`; + if (options.organizerEmail) { + const organizerId = await aadUser.getUserIdByEmail(options.organizerEmail); + requestUrl += `users/${organizerId}`; + } + else { + requestUrl += 'me'; + } + return requestUrl; + } + + /** + * Creates a new online meeting + * @param logger + * @param graphBaseUrl + * @param options + * @returns MS Graph online meeting response + */ + private async createMeeting(logger: Logger, graphBaseUrl: string, options: Options): Promise { + if (this.verbose) { + logger.logToStderr(`Creation of a meeting...`); + } + const requestData: any = {}; + if (options.participants) { + const attendees = options.participants.trim().toLowerCase().split(',').map(p => ({ + upn: p.trim() + })); + requestData.participants = { attendees }; + } + if (options.startTime) { + requestData.startDateTime = options.startTime; + } + if (options.endTime) { + requestData.endDateTime = options.endTime; + } + if (options.subject) { + requestData.subject = options.subject; + } + if (options.recordAutomatically !== undefined) { + requestData.recordAutomatically = options.recordAutomatically; + } + const requestOption: CliRequestOptions = { + headers: { + accept: 'application/json', + 'content-type': 'application/json' + }, + responseType: 'json', + method: 'POST', + url: `${graphBaseUrl}/onlineMeetings`, + data: requestData + }; + + try { + const requestResponse = await request.post(requestOption); + return requestResponse; + } + catch (error: any) { + if (error.response.status === 403) { + throw `Forbidden. You do not have permission to perform this action. Please verify the command's details for more information.`; + } + + throw error.message; + } + } +} + +export default new TeamsMeetingCreateCommand(); \ No newline at end of file