From a169553b9db1cb3a6a55b043a3a1c45a8568d1ae Mon Sep 17 00:00:00 2001 From: mkm17 Date: Sat, 7 Oct 2023 00:06:10 +0200 Subject: [PATCH] Adds "teams meeting create" command. Closes #1345 --- docs/docs/cmd/teams/meeting/meeting-add.mdx | 241 ++++++++ docs/src/config/sidebars.js | 5 + src/m365/teams/commands.ts | 1 + .../commands/meeting/meeting-add.spec.ts | 559 ++++++++++++++++++ .../teams/commands/meeting/meeting-add.ts | 216 +++++++ 5 files changed, 1022 insertions(+) create mode 100644 docs/docs/cmd/teams/meeting/meeting-add.mdx create mode 100644 src/m365/teams/commands/meeting/meeting-add.spec.ts create mode 100644 src/m365/teams/commands/meeting/meeting-add.ts diff --git a/docs/docs/cmd/teams/meeting/meeting-add.mdx b/docs/docs/cmd/teams/meeting/meeting-add.mdx new file mode 100644 index 00000000000..7169a3821cd --- /dev/null +++ b/docs/docs/cmd/teams/meeting/meeting-add.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 f404bd25a4e..542978cace2 100644 --- a/docs/src/config/sidebars.js +++ b/docs/src/config/sidebars.js @@ -3804,6 +3804,11 @@ const sidebars = { }, { meeting: [ + { + type: 'doc', + label: 'meeting add', + id: 'cmd/teams/meeting/meeting-add' + }, { type: 'doc', label: 'meeting get', diff --git a/src/m365/teams/commands.ts b/src/m365/teams/commands.ts index 7ff1d3c7e5d..5aff8ea3d7d 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_ADD: `${prefix} meeting add`, 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-add.spec.ts b/src/m365/teams/commands/meeting/meeting-add.spec.ts new file mode 100644 index 00000000000..0270dc8e882 --- /dev/null +++ b/src/m365/teams/commands/meeting/meeting-add.spec.ts @@ -0,0 +1,559 @@ +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-add.js'; + +describe(commands.MEETING_ADD, () => { + const startTime = '2022-04-04T03:00:00Z'; + const endTime = '2022-04-04T04:00:00Z'; + const subject = 'test subject'; + const participantUserNames = '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_ADD); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('completes validation when no parameters are provided', async () => { + const actual = await command.validate({ options: { startTime: undefined, endTime: undefined, subject: undefined, participantUserNames: undefined, organizerEmail: undefined, recordAutomatically: undefined } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('completes validation when only the startTime parameter 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 both the startTime and endTime parameters are provided, and they are valid ISODateTimes', async () => { + const actual = await command.validate({ options: { startTime: startTime, endTime: endTime } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('completes validation when only the subject parameter is provided', async () => { + const actual = await command.validate({ options: { subject: subject } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('completes validation when only the organizerEmail parameter is provided', async () => { + const actual = await command.validate({ options: { organizerEmail: organizerEmail } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('completes validation when only the participantUserNames parameter is provided', async () => { + const actual = await command.validate({ options: { participantUserNames: participantUserNames } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('completes validation when the correct endTime is provided, and the startTime is not provided', async () => { + const fakeTimers = sinon.useFakeTimers(new Date('2020-01-01T12:00:00.000Z')); + + const actual = await command.validate({ options: { endTime: endTime } }, commandInfo); + assert.strictEqual(actual, true); + + fakeTimers.restore(); + }); + + 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 endTime is before the 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 only the endTime is provided and occurs before the current time.', async () => { + const fakeTimers = sinon.useFakeTimers(new Date('2020-01-01T12:00:00.000Z')); + const actual = await command.validate({ options: { endTime: '1990-12-31' } }, commandInfo); + assert.notStrictEqual(actual, true); + + fakeTimers.restore(); + }); + + it('fails validation when the organizerEmail parameter is not a valid email address', async () => { + const actual = await command.validate({ options: { organizerEmail: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when the participantUserNames is not a valid', async () => { + const actual = await command.validate({ options: { participantUserNames: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when the participantUserNames are not separated by comma', async () => { + const actual = await command.validate({ options: { participantUserNames: 'abc@email.com|abc2@email.com' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when the participantUserNames has incorrect format', async () => { + const actual = await command.validate({ options: { participantUserNames: 'abc@email.com,foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when the startDate is after the endDate', async () => { + const actual = await command.validate({ options: { startTime: '2023-01-01', endTime: '2022-12-31' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('throws an error when the organizerEmail is not provided while 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('throws an error when the organizerEmail parameter is set and delegated permissions are used', async () => { + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); + + await assert.rejects(command.action(logger, { + options: { + verbose: true, + organizerEmail: organizerEmail + } + }), new CommandError(`The option 'organizerEmail' is not supported when creating a meeting using delegated 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 a defined startDate for the currently logged-in user', async () => { + const fakeTimers = sinon.useFakeTimers(new Date('2020-01-01T12:00:00.000Z')); + + 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, + endTime: endTime + } + }); + + assert.strictEqual(calledUrl, 'https://graph.microsoft.com/v1.0/me/onlineMeetings'); + assert.deepEqual(calledData, { startDateTime: '2020-01-01T12:00:00.000Z', endDateTime: endTime }); + assert(loggerLogSpy.calledWith(meeting)); + + fakeTimers.restore(); + }); + + it('create a meeting with a defined endDate and current date as 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 participantUserNames 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, + participantUserNames: participantUserNames + } + }); + + 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, participantUserNames 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, + participantUserNames: participantUserNames, + 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, participantUserNames 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, + participantUserNames: participantUserNames, + 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 appropriately 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, + participantUserNames: participantUserNames, + recordAutomatically: true, + organizerEmail: organizerEmail + } + }), new CommandError(`The specified user with email organizer@email.com does not exist`) + ); + }); + + it('handles the forbidden error 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. You do not have permission to perform this action. Please verify the command's details for more information." + }; + } + + throw 'Invalid request: ' + opts.url; + }); + + + await assert.rejects(command.action(logger, { + options: { + verbose: true, + startTime: startTime, + endTime: endTime, + subject: subject, + participantUserNames: participantUserNames, + 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 appropriately', 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, + participantUserNames: participantUserNames, + recordAutomatically: true + } + }), new CommandError('Error message') + ); + }); +}); \ No newline at end of file diff --git a/src/m365/teams/commands/meeting/meeting-add.ts b/src/m365/teams/commands/meeting/meeting-add.ts new file mode 100644 index 00000000000..fe0cf521318 --- /dev/null +++ b/src/m365/teams/commands/meeting/meeting-add.ts @@ -0,0 +1,216 @@ +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; + participantUserNames?: string; + organizerEmail?: string; + recordAutomatically?: boolean; +} + +class TeamsMeetingAddCommand extends GraphCommand { + public get name(): string { + return commands.MEETING_ADD; + } + + public get description(): string { + return 'Creates a new online meeting'; + } + + 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', + participantUserNames: typeof args.options.participantUserNames !== '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: '--subject [subject]' + }, + { + option: '-p, --participantUserNames [participantUserNames]' + }, + { + 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 'The startTime value must be before endTime.'; + } + + if (args.options.endTime && !args.options.startTime && new Date() >= new Date(args.options.endTime)) { + return 'When only the endTime is specified, it needs to be after the current time.'; + } + + if (args.options.participantUserNames) { + + if (args.options.participantUserNames.indexOf(',') === -1 && !validation.isValidUserPrincipalName(args.options.participantUserNames)) { + return `${args.options.participantUserNames} contains invalid UPN.`; + } + + const participants = args.options.participantUserNames.trim().toLowerCase().split(',').filter(e => e && e !== ''); + + if (!participants || participants.length === 0 || participants.some(e => !validation.isValidUserPrincipalName(e))) { + return `${args.options.participantUserNames} 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`; + } + + if (!isAppOnlyAccessToken && args.options.organizerEmail) { + throw `The option 'organizerEmail' is not supported when creating a meeting using delegated permissions`; + } + + let graphBaseUrl = `${this.resource}/v1.0/`; + + if (args.options.organizerEmail) { + const organizerId = await aadUser.getUserIdByEmail(args.options.organizerEmail); + graphBaseUrl = `${graphBaseUrl}users/${organizerId}`; + } + else { + graphBaseUrl = `${graphBaseUrl}me`; + } + + const meeting = await this.createMeeting(logger, graphBaseUrl, args.options); + await logger.log(meeting); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + /** + * 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) { + await logger.logToStderr(`Creating the meeting...`); + } + + const requestData: any = {}; + + if (options.participantUserNames) { + const attendees = options.participantUserNames.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.startTime) { + requestData.startDateTime = new Date().toISOString(); + } + } + + if (options.subject) { + requestData.subject = options.subject; + } + + if (options.recordAutomatically !== undefined) { + requestData.recordAutomatically = true; + } + + const requestOptions: CliRequestOptions = { + headers: { + accept: 'application/json;odata.metadata=none', + 'content-type': 'application/json' + }, + responseType: 'json', + url: `${graphBaseUrl}/onlineMeetings`, + data: requestData + }; + + try { + const requestResponse = await request.post(requestOptions); + return requestResponse; + } + catch (error: any) { + throw error.message; + } + } +} + +export default new TeamsMeetingAddCommand(); \ No newline at end of file