Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion docs/docs/cmd/teams/chat/chat-message-send.mdx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import Global from '/docs/cmd/_global.mdx';

# teams chat message send
Expand All @@ -24,6 +26,9 @@ m365 teams chat message send [options]

`-m, --message <message>`
: The message to send

`--contentType [contentType]`
: The content type of the message. Allowed values are `text` and `html`. Default is `text`.
```

<Global />
Expand All @@ -32,12 +37,29 @@ m365 teams chat message send [options]

A new chat conversation will be created if no existing conversation with the participants specified with emails is found.

## Permissions

<Tabs>
<TabItem value="Delegated">

| Resource | Permissions |
|-----------------|-----------------------------|
| Microsoft Graph | Chat.Read, ChatMessage.Send |

</TabItem>
<TabItem value="Application">

This command does not support application permissions.

</TabItem>
</Tabs>

## Examples

Send a message to a Microsoft Teams chat conversation by id

```sh
m365 teams chat message send --chatId 19:2da4c29f6d7041eca70b638b43d45437@thread.v2 --message "Welcome to Teams"
m365 teams chat message send --chatId 19:2da4c29f6d7041eca70b638b43d45437@thread.v2 --message "<b>Welcome</b> to Teams" --contentType html
```

Send a message to a single person
Expand Down
141 changes: 76 additions & 65 deletions src/m365/teams/commands/chat/chat-message-send.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ describe(commands.CHAT_MESSAGE_SEND, () => {
auth.connection.active = true;
sinon.stub(accessToken, 'assertAccessTokenType').returns();
commandInfo = cli.getCommandInfo(command);

sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => settingName === settingsNames.prompt ? false : defaultValue);
});

beforeEach(() => {
Expand Down Expand Up @@ -101,7 +103,6 @@ describe(commands.CHAT_MESSAGE_SEND, () => {
request.get,
request.post,
accessToken.getUserNameFromAccessToken,
cli.getSettingWithDefaultValue,
cli.handleMultipleResultsFound
]);
});
Expand All @@ -120,14 +121,6 @@ describe(commands.CHAT_MESSAGE_SEND, () => {
});

it('fails validation if chatId and chatName and userEmails are not specified', async () => {
sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => {
if (settingName === settingsNames.prompt) {
return false;
}

return defaultValue;
});

const actual = await command.validate({
options: {
message: "Hello World"
Expand Down Expand Up @@ -187,14 +180,6 @@ describe(commands.CHAT_MESSAGE_SEND, () => {
});

it('fails validation if chatId and chatName properties are both defined', async () => {
sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => {
if (settingName === settingsNames.prompt) {
return false;
}

return defaultValue;
});

const actual = await command.validate({
options: {
chatId: '19:8b081ef6-4792-4def-b2c9-c363a1bf41d5_5031bb31-22c0-4f6f-9f73-91d34ab2b32d',
Expand All @@ -206,14 +191,6 @@ describe(commands.CHAT_MESSAGE_SEND, () => {
});

it('fails validation if chatId and userEmails properties are both defined', async () => {
sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => {
if (settingName === settingsNames.prompt) {
return false;
}

return defaultValue;
});

const actual = await command.validate({
options: {
chatId: '19:8b081ef6-4792-4def-b2c9-c363a1bf41d5_5031bb31-22c0-4f6f-9f73-91d34ab2b32d',
Expand All @@ -225,14 +202,6 @@ describe(commands.CHAT_MESSAGE_SEND, () => {
});

it('fails validation if chatName and userEmails properties are both defined', async () => {
sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => {
if (settingName === settingsNames.prompt) {
return false;
}

return defaultValue;
});

const actual = await command.validate({
options: {
chatName: 'test',
Expand All @@ -244,14 +213,6 @@ describe(commands.CHAT_MESSAGE_SEND, () => {
});

it('fails validation if all three mutually exclusive properties are defined', async () => {
sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => {
if (settingName === settingsNames.prompt) {
return false;
}

return defaultValue;
});

const actual = await command.validate({
options: {
chatId: '19:8b081ef6-4792-4def-b2c9-c363a1bf41d5_5031bb31-22c0-4f6f-9f73-91d34ab2b32d',
Expand All @@ -264,17 +225,20 @@ describe(commands.CHAT_MESSAGE_SEND, () => {
});

it('fails validation if message is not specified', async () => {
sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => {
if (settingName === settingsNames.prompt) {
return false;
const actual = await command.validate({
options: {
chatId: "19:8b081ef6-4792-4def-b2c9-c363a1bf41d5_5031bb31-22c0-4f6f-9f73-91d34ab2b32d@unq.gbl.spaces"
}
}, commandInfo);
assert.notStrictEqual(actual, true);
});

return defaultValue;
});

it('fails validation if contentType is not valid', async () => {
const actual = await command.validate({
options: {
chatId: "19:8b081ef6-4792-4def-b2c9-c363a1bf41d5_5031bb31-22c0-4f6f-9f73-91d34ab2b32d@unq.gbl.spaces"
chatId: '19:8b081ef6-4792-4def-b2c9-c363a1bf41d5_5031bb31-22c0-4f6f-9f73-91d34ab2b32d@unq.gbl.spaces',
contentType: 'Invalid',
message: 'Hello World'
}
}, commandInfo);
assert.notStrictEqual(actual, true);
Expand Down Expand Up @@ -324,7 +288,19 @@ describe(commands.CHAT_MESSAGE_SEND, () => {
await command.action(logger, {
options: {
chatId: "19:82fe7758-5bb3-4f0d-a43f-e555fd399c6f_8c0a1a67-50ce-4114-bb6c-da9c5dbcf6ca@unq.gbl.spaces",
message: "Hello World"
message: "Hello World",
contentType: "text"
}
});
assert(loggerLogSpy.notCalled);
});

it('sends chat message using chatId and contentType', async () => {
await command.action(logger, {
options: {
chatId: "19:82fe7758-5bb3-4f0d-a43f-e555fd399c6f_8c0a1a67-50ce-4114-bb6c-da9c5dbcf6ca@unq.gbl.spaces",
message: "<p>Hello World</p>",
contentType: "html"
}
});
assert(loggerLogSpy.notCalled);
Expand Down Expand Up @@ -404,14 +380,6 @@ describe(commands.CHAT_MESSAGE_SEND, () => {
});

it('fails sending message with multiple found chat conversations by chatName', async () => {
sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => {
if (settingName === settingsNames.prompt) {
return false;
}

return defaultValue;
});

await assert.rejects(command.action(logger, {
options: {
chatName: "Just a conversation with same name",
Expand All @@ -433,14 +401,6 @@ describe(commands.CHAT_MESSAGE_SEND, () => {
});

it('fails sending message with multiple found chat conversations by userEmails', async () => {
sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => {
if (settingName === settingsNames.prompt) {
return false;
}

return defaultValue;
});

await assert.rejects(command.action(logger, {
options: {
userEmails: "AlexW@M365x214355.onmicrosoft.com,NateG@M365x214355.onmicrosoft.com",
Expand Down Expand Up @@ -503,6 +463,57 @@ describe(commands.CHAT_MESSAGE_SEND, () => {
assert(loggerLogSpy.notCalled);
});

it('sends chat messages using HTML content type', async () => {
sinonUtil.restore(request.post);
const postStub = sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `https://graph.microsoft.com/v1.0/chats/19:82fe7758-5bb3-4f0d-a43f-e555fd399c6f_8c0a1a67-50ce-4114-bb6c-da9c5dbcf6ca@unq.gbl.spaces/messages`) {
return messageSentResponse;
}

throw 'Invalid request';
});

await command.action(logger, {
options: {
chatId: '19:82fe7758-5bb3-4f0d-a43f-e555fd399c6f_8c0a1a67-50ce-4114-bb6c-da9c5dbcf6ca@unq.gbl.spaces',
message: '<b>Hello World</b>',
contentType: 'html'
}
});

assert.deepStrictEqual(postStub.firstCall.args[0].data, {
body: {
contentType: 'html',
content: '<b>Hello World</b>'
}
});
});

it('sends chat messages using text content type when not specified', async () => {
sinonUtil.restore(request.post);
const postStub = sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `https://graph.microsoft.com/v1.0/chats/19:82fe7758-5bb3-4f0d-a43f-e555fd399c6f_8c0a1a67-50ce-4114-bb6c-da9c5dbcf6ca@unq.gbl.spaces/messages`) {
return messageSentResponse;
}

throw 'Invalid request';
});

await command.action(logger, {
options: {
chatId: '19:82fe7758-5bb3-4f0d-a43f-e555fd399c6f_8c0a1a67-50ce-4114-bb6c-da9c5dbcf6ca@unq.gbl.spaces',
message: 'Hello World'
}
});

assert.deepStrictEqual(postStub.firstCall.args[0].data, {
body: {
contentType: 'text',
content: 'Hello World'
}
});
});

// The following test is used to test the retry mechanism in use because of an intermittent Graph issue.
it('fails sending chat message when maximum of 3 retries with 404 intermittent failure have occurred', async () => {
sinonUtil.restore(request.post);
Expand Down
21 changes: 17 additions & 4 deletions src/m365/teams/commands/chat/chat-message-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ interface Options extends GlobalOptions {
userEmails?: string;
chatName?: string;
message: string;
contentType?: string;
}

class TeamsChatMessageSendCommand extends GraphDelegatedCommand {
private readonly contentTypes = ['text', 'html'];

public get name(): string {
return commands.CHAT_MESSAGE_SEND;
}
Expand All @@ -45,7 +48,8 @@ class TeamsChatMessageSendCommand extends GraphDelegatedCommand {
Object.assign(this.telemetryProperties, {
chatId: typeof args.options.chatId !== 'undefined',
userEmails: typeof args.options.userEmails !== 'undefined',
chatName: typeof args.options.chatName !== 'undefined'
chatName: typeof args.options.chatName !== 'undefined',
contentType: args.options.contentType ?? 'text'
});
});
}
Expand All @@ -63,6 +67,10 @@ class TeamsChatMessageSendCommand extends GraphDelegatedCommand {
},
{
option: '-m, --message <message>'
},
{
option: '--contentType [contentType]',
autocomplete: this.contentTypes
}
);
}
Expand All @@ -81,6 +89,10 @@ class TeamsChatMessageSendCommand extends GraphDelegatedCommand {
}
}

if (args.options.contentType && !this.contentTypes.includes(args.options.contentType)) {
return `'${args.options.contentType}' is not a valid value for option contentType. Allowed values are ${this.contentTypes.join(', ')}.`;
}

return true;
}
);
Expand All @@ -93,7 +105,7 @@ class TeamsChatMessageSendCommand extends GraphDelegatedCommand {
public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {
try {
const chatId = await this.getChatId(logger, args);
await this.sendChatMessage(chatId as string, args);
await this.sendChatMessage(chatId, args);
}
catch (err: any) {
this.handleRejectedODataJsonPromise(err);
Expand All @@ -112,7 +124,7 @@ class TeamsChatMessageSendCommand extends GraphDelegatedCommand {

private async ensureChatIdByUserEmails(userEmailsOption: string): Promise<string> {
const userEmails = userEmailsOption.trim().toLowerCase().split(',').filter(e => e && e !== '');
const currentUserEmail = accessToken.getUserNameFromAccessToken(auth.connection.accessTokens[this.resource].accessToken).toLowerCase();
const currentUserEmail = accessToken.getUserNameFromAccessToken(auth.connection.accessTokens[auth.defaultResource].accessToken).toLowerCase();
const existingChats = await chatUtil.findExistingChatsByParticipants([currentUserEmail, ...userEmails]);

if (!existingChats || existingChats.length === 0) {
Expand Down Expand Up @@ -189,11 +201,12 @@ class TeamsChatMessageSendCommand extends GraphDelegatedCommand {
url: `${this.resource}/v1.0/chats/${chatId}/messages`,
headers: {
accept: 'application/json;odata.metadata=none',
'content-type': 'application/json;odata=nometadata'
'content-type': 'application/json'
},
responseType: 'json',
data: {
body: {
contentType: args.options.contentType || 'text',
content: args.options.message
}
}
Expand Down
3 changes: 1 addition & 2 deletions src/m365/teams/commands/chat/chatUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import { formatting } from '../../../../utils/formatting.js';
import { odata } from '../../../../utils/odata.js';

export const chatUtil = {

/**
* Finds existing Microsoft Teams chats by participants, using the Microsoft Graph
* @param expectedMemberEmails a string array of participant emailaddresses
* @param expectedMemberEmails a string array of participant email addresses
* @param logger a logger to pipe into the graph request odata helper.
*/
async findExistingChatsByParticipants(expectedMemberEmails: string[]): Promise<Chat[]> {
Expand Down