diff --git a/.eslintrc.js b/.eslintrc.js index 49a2758..6cab48e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -136,5 +136,12 @@ module.exports = { sourceType: 'module', }, }, + { + files: [' *.test.ts', '*.test.tsx', 'gulpfile.js', 'jest.config.ts'], + rules: { + 'node/no-unpublished-import': 'off', + '@typescript-eslint/ban-ts-comment': 'warn', + }, + }, ], }; diff --git a/__tests__/__integration__/mock.test.ts b/__tests__/__integration__/mock.test.ts index 5f4af2a..964919d 100644 --- a/__tests__/__integration__/mock.test.ts +++ b/__tests__/__integration__/mock.test.ts @@ -1,15 +1,15 @@ /* -* This program and the accompanying materials are made available under the terms of the -* Eclipse Public License v2.0 which accompanies this distribution, and is available at -* https://www.eclipse.org/legal/epl-v20.html -* -* SPDX-License-Identifier: EPL-2.0 -* -* Copyright Contributors to the Zowe Project. -*/ + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ describe('jest executes integration test', () => { - it('assert 1=1', async () => { - expect(1).toBe(1); - }); + it('assert 1=1', async () => { + expect(1).toBe(1); + }); }); diff --git a/__tests__/beforeTests.ts b/__tests__/beforeTests.ts index 764f663..60506b7 100644 --- a/__tests__/beforeTests.ts +++ b/__tests__/beforeTests.ts @@ -9,3 +9,12 @@ */ jest.setTimeout(50000); + +if (process.env.TEST_DEBUG === 'true') { + process.env = { + ...process.env, + COMMONBOT_LOG_CONSOLE_SILENT: 'false', + COMMONBOT_LOG_LEVEL: 'debug', + COMMONBOT_LOG_FILE_PATH: './__tests__/__results__', + }; +} diff --git a/__tests__/common/mocks/MockCommonBot.ts b/__tests__/common/mocks/MockCommonBot.ts new file mode 100644 index 0000000..dc86a98 --- /dev/null +++ b/__tests__/common/mocks/MockCommonBot.ts @@ -0,0 +1,58 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +import { CommonBot } from '../../../src/CommonBot'; +import { IChatToolType, IBotOption } from '../../../src/types'; + +export class MockCommonBot { + public static MSTEAMS_BOT: CommonBot = (() => { + const bot: CommonBot = jest.createMockFromModule('../../../src/CommonBot'); + bot.getOption = () => { + return { + chatTool: { + type: IChatToolType.MSTEAMS, + option: { + botId: 'dummybot', + }, + }, + } as IBotOption; + }; + return bot; + })(); + + public static MATTERMOST_BOT: CommonBot = (() => { + const bot: CommonBot = jest.createMockFromModule('../../../src/CommonBot'); + bot.getOption = () => { + return { + chatTool: { + type: IChatToolType.MATTERMOST, + }, + } as IBotOption; + }; + return bot; + })(); + + public static SLACK_BOT: CommonBot = (() => { + const bot: CommonBot = jest.createMockFromModule('../../../src/CommonBot'); + bot.getOption = () => { + return { + chatTool: { + type: IChatToolType.SLACK, + option: { + socketMode: true, + appToken: 'mock_apptoken', + token: 'mock_token', + }, + }, + } as IBotOption; + }; + return bot; + })(); +} diff --git a/__tests__/common/mocks/MockContexts.ts b/__tests__/common/mocks/MockContexts.ts new file mode 100644 index 0000000..51e4fba --- /dev/null +++ b/__tests__/common/mocks/MockContexts.ts @@ -0,0 +1,96 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +import { CommonBot } from '../../../src/CommonBot'; +import { IChatContextData, IChattingContext, IChattingType, IPayload, IPayloadType, IUser } from '../../../src/types'; + +export class MockContexts { + private static mockMsg: IPayload = { + type: IPayloadType.MESSAGE, + data: 'mock_message', + }; + + private static mockUser: IUser = { + id: '123abc_def', + name: 'mock_user', + email: 'mock_user@mocks.me', + }; + + private static publicChatting: IChattingContext = { + bot: {} as CommonBot, + type: IChattingType.PUBLIC_CHANNEL, + user: this.mockUser, + channel: { name: 'mock_chan', id: '123_team_chan_321' }, + tenant: { name: 'mock_tenant', id: '123_mock_tentant_id_321' }, + team: { name: 'mock_team', id: '123_mock_team_id_321' }, + }; + + /** + * Provides a simple Mattermost bot with all fields populated by some mocked defaults. + * + * Automation wishing to test specific IChatContextData values should use this as a base and then modify the respective properties + * + * @param bot + * @returns + */ + static MM_SIMPLE_CTX: IChatContextData = { + payload: this.mockMsg, + context: { + chatting: this.publicChatting, + chatTool: { + rootId: 'mock_root_id', + }, + }, + }; + + static SLACK_SIMPLE_CTX: IChatContextData = { + payload: this.mockMsg, + context: { + chatting: this.publicChatting, + chatTool: { + rootId: 'mock_root_id', + option: { + chatTool: { + socketMode: true, + }, + socketMode: true, + }, + }, + }, + }; + + static SLACK_PERSONAL_DM_CTX: IChatContextData = Object.assign({}, this.SLACK_SIMPLE_CTX, { + context: { + chatting: { + type: IChattingType.PERSONAL, + channel: { + id: 'mock_dm_id', + name: 'mock_dm_name', + }, + }, + }, + }); + + static MSTEAMS_CHATTOOL_CONTEXT = { + context: { + _activity: { + recipient: 'mock_msteams_recipient', + }, + }, + }; + + static MSTEAMS_SIMPLE_CTX: IChatContextData = { + payload: this.mockMsg, + context: { + chatting: this.publicChatting, + chatTool: this.MSTEAMS_CHATTOOL_CONTEXT, + }, + }; +} diff --git a/__tests__/common/msteams/MsteamsConstants.ts b/__tests__/common/msteams/MsteamsConstants.ts new file mode 100644 index 0000000..5c67915 --- /dev/null +++ b/__tests__/common/msteams/MsteamsConstants.ts @@ -0,0 +1,23 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +export class MsteamsConstants { + public static EMPTY_ADAPTIVE_CARD: Record = { + type: 'AdaptiveCard', + fallbackText: '', + msteams: { + width: 'Full', + }, + body: [], + actions: [], + $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', + version: '1.4', + }; +} diff --git a/__tests__/plugins/mattermost/__unit__/MattermostMiddleware.test.ts b/__tests__/plugins/mattermost/__unit__/MattermostMiddleware.test.ts new file mode 100644 index 0000000..c4b8d5c --- /dev/null +++ b/__tests__/plugins/mattermost/__unit__/MattermostMiddleware.test.ts @@ -0,0 +1,66 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +import { MattermostClient } from '../../../../src/plugins/mattermost/MattermostClient'; +import { MattermostMiddleware } from '../../../../src/plugins/mattermost/MattermostMiddleware'; +import { IChannel, IChattingType, IMattermostOption, IMessageType } from '../../../../src/types'; +import { MockCommonBot } from '../../../common/mocks/MockCommonBot'; +import { MockContexts } from '../../../common/mocks/MockContexts'; + +describe('Middleware Tests', () => { + it('Mattermost DirectMessage', async () => { + const ctx = MockContexts.MM_SIMPLE_CTX; + const testMsg = { + type: IMessageType.PLAIN_TEXT, + message: 'Test DM', + }; + const testChanId: IChannel = { + id: 'mock_id', + name: 'mock_name', + chattingType: IChattingType.PERSONAL, + }; + + const middleware = new MattermostMiddleware(MockCommonBot.MATTERMOST_BOT); + const client = new MattermostClient(middleware, { + protocol: 'https', + tlsCertificate: '', + } as IMattermostOption); + + client.createDmChannel = jest.fn(() => { + return new Promise((resolve) => { + resolve(testChanId); + }); + }); + client.sendMessage = jest.fn(() => { + return new Promise((resolve) => { + resolve(); + }); + }); + + (middleware as any).client = client; + + await middleware.sendDirectMessage(ctx, [testMsg]); + expect(client.createDmChannel).toHaveBeenCalledWith(ctx.context.chatting.user, null); + expect(client.sendMessage).toHaveBeenCalledWith(testMsg.message, testChanId.id, ctx.context.chatTool.rootId); + + // Case where CreateDm fails + jest.clearAllMocks(); + + client.createDmChannel = jest.fn(() => { + return new Promise((resolve) => { + resolve(null as any); + }); + }); + + await middleware.sendDirectMessage(ctx, [testMsg]); + expect(client.createDmChannel).toHaveBeenCalledWith(ctx.context.chatting.user, null); + expect(client.sendMessage).toHaveBeenCalledTimes(0); + }); +}); diff --git a/__tests__/plugins/msteams/__unit__/MsteamsMiddleware.test.ts b/__tests__/plugins/msteams/__unit__/MsteamsMiddleware.test.ts new file mode 100644 index 0000000..0d878fd --- /dev/null +++ b/__tests__/plugins/msteams/__unit__/MsteamsMiddleware.test.ts @@ -0,0 +1,299 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +import { Activity, Attachment, BotFrameworkAdapter } from 'botbuilder'; +import { BotActivityHandler } from '../../../../src/plugins/msteams/BotActivityHandler'; +import { MsteamsMiddleware } from '../../../../src/plugins/msteams/MsteamsMiddleware'; +import { IChatContextData, IMessage, IMessageType } from '../../../../src/types'; +import { MockCommonBot } from '../../../common/mocks/MockCommonBot'; +import { MockContexts } from '../../../common/mocks/MockContexts'; +import { MsteamsConstants } from '../../../common/msteams/MsteamsConstants'; +// eslint-disable-next-line node/no-extraneous-import +import { ConnectorClient } from 'botframework-connector'; // required, secondary import by botbuilder + +describe('MsTeams Middleware Tests', () => { + // TODO: This is a copy of structure in source, remove it? Remove tests? + type MessageData = { + textMessage?: string; + mentions?: Record; + attachments?: Attachment[]; + }; + + const middlewareMock: { + botActivityHandler: BotActivityHandler; + botFrameworkAdapter: BotFrameworkAdapter; + buildActivity: (msgData: MessageData) => Partial; + processMessages: (messages: IMessage[]) => MessageData; + sendDirectMessage: (chatContextData: IChatContextData, messages: IMessage[]) => Promise; + } = new MsteamsMiddleware(MockCommonBot.MSTEAMS_BOT) as any; + + it('MsTeams buildActivity', async () => { + const testCases: MessageData[] = [ + { + textMessage: 'test', + }, + { + textMessage: 'two', + mentions: { user: 'mock_user' }, + }, + { + attachments: [ + { + contentType: 'mimetype/gif', + content: 'abcdef', + }, + ], + }, + { + textMessage: '', + attachments: [ + { + contentType: 'mimetype/gif', + content: 'abcdef', + }, + ], + }, + { + textMessage: 'with a text message', + attachments: [ + { + contentType: 'mimetype/gif', + content: 'abcdef', + }, + ], + }, + { + attachments: [ + { + contentType: 'mimetype/gif', + content: 'abcdef', + }, + ], + mentions: { user: 'mock_user' }, + }, + { + textMessage: '', + mentions: { user: 'mock_user' }, + }, + ]; + + for (const message of testCases) { + const activ = middlewareMock.buildActivity(message); + expect(activ).toMatchSnapshot(); + } + }); + + it('MSTeams processMessages', async () => { + // Basic test cases + const testCases: IMessage[][] = [ + [ + { type: IMessageType.PLAIN_TEXT, message: 'hi' }, + { type: IMessageType.PLAIN_TEXT, message: 'over' }, + ], + [ + { type: IMessageType.PLAIN_TEXT, message: 'there', mentions: [{ mentioned: { id: 'mock_id' } }] }, + { type: IMessageType.PLAIN_TEXT, message: 'name', mentions: [{ mentioned: { id: 'another_id', name: 'mock_name' } }] }, + ], + [{ type: IMessageType.PLAIN_TEXT, message: 'there', mentions: [{ mentioned: { id: '', name: 'mock_name' } }] }], + [{ type: IMessageType.MSTEAMS_DIALOG_OPEN, message: 'open the dialog', mentions: [{ mentioned: { id: 'mock_id' } }] }], + [], + ]; + + for (const test of testCases) { + const msgData = middlewareMock.processMessages(test); + expect(msgData).toMatchSnapshot(); + } + + // Cases where there's valid channelIds + middlewareMock.botActivityHandler.findChannelByName = jest.fn((name) => { + return { id: 'mocked_found_id', name: name + '_echo' }; + }); + + middlewareMock.botActivityHandler.findChannelById = jest.fn((id) => { + return { id: id + '_echo', name: 'mocked_found_name' }; + }); + + for (const test of testCases) { + const msgData = middlewareMock.processMessages(test); + expect(msgData).toMatchSnapshot(); + } + }); + + describe('SendDirectMessage', () => { + // context may be modified in tests, store and restore it + const origSimpleCtx = MockContexts.MSTEAMS_SIMPLE_CTX; + + const testCases: IMessage[][] = [ + [ + { type: IMessageType.PLAIN_TEXT, message: 'hi' }, + { type: IMessageType.PLAIN_TEXT, message: 'over' }, + ], + [{ type: IMessageType.PLAIN_TEXT, message: '' }], + [{ type: IMessageType.MSTEAMS_ADAPTIVE_CARD, message: MsteamsConstants.EMPTY_ADAPTIVE_CARD }], + [ + { type: IMessageType.MSTEAMS_ADAPTIVE_CARD, message: MsteamsConstants.EMPTY_ADAPTIVE_CARD }, + { type: IMessageType.PLAIN_TEXT, message: 'follow-up message' }, + ], + [{ type: IMessageType.MSTEAMS_DIALOG_OPEN, message: 'TODO: dialog open' }], + [], + ]; + + beforeEach(() => { + MockContexts.MSTEAMS_SIMPLE_CTX = origSimpleCtx; + const mockConnector: ConnectorClient = { conversations: { createConversation: () => {}, sendToConversation: () => {} } } as any; + jest.spyOn(mockConnector.conversations, 'createConversation').mockImplementation(() => { + return { id: 'mock_convo_id' }; + }); + jest.spyOn(mockConnector.conversations, 'sendToConversation').mockReturnValue(); + const mockMap = new Map(); + mockMap.set('abc', 'def'); + jest.spyOn(middlewareMock.botActivityHandler, 'getServiceUrl').mockReturnValue(mockMap); + jest.spyOn(middlewareMock.botActivityHandler, 'findChannelByName').mockReturnValue({ id: 'mock_chan_id', name: 'mock_name' }); + jest.spyOn(middlewareMock.botActivityHandler, 'findChannelById').mockReturnValue({ id: 'mock_chan_id', name: 'mock_name' }); + jest.spyOn(middlewareMock.botActivityHandler, 'findServiceUrl').mockReturnValue('mock_svc_url'); + jest.spyOn(middlewareMock.botFrameworkAdapter, 'createConnectorClient').mockReturnValue(mockConnector); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // by default, w/ beforeEach all cases are good. + it('Success cases', async () => { + for (const test of testCases) { + const ctx: IChatContextData = MockContexts.MSTEAMS_SIMPLE_CTX; + const sent = await middlewareMock.sendDirectMessage(ctx, test); + expect(sent).toBe(true); + } + }); + + // -------- + // modify conditions to trigger various failures/branches below + // -------- + it('getServiceUrl empty', async () => { + jest.spyOn(middlewareMock.botActivityHandler, 'getServiceUrl').mockReturnValue(new Map()); + for (const test of testCases) { + const ctx: IChatContextData = MockContexts.MSTEAMS_SIMPLE_CTX; + const sent = await middlewareMock.sendDirectMessage(ctx, test); + expect(sent).toBe(false); + } + }); + + it('empty channel name and id => findChannelById', async () => { + // clone ctx, we'll be modifying it in test + const ctx: IChatContextData = MockContexts.MSTEAMS_SIMPLE_CTX; + ctx.context.chatting.channel.id = ''; + ctx.context.chatting.channel.name = ''; + for (const test of testCases) { + const sent = await middlewareMock.sendDirectMessage(ctx, test); + expect(sent).toBe(true); + } + }); + + it('condition findChannelByName', async () => { + const ctx: IChatContextData = MockContexts.MSTEAMS_SIMPLE_CTX; + ctx.context.chatting.channel.id = ''; + ctx.context.chatting.channel.name = 'mock_name'; + + for (const test of testCases) { + const sent = await middlewareMock.sendDirectMessage(ctx, test); + expect(sent).toBe(true); + } + jest.spyOn(middlewareMock.botActivityHandler, 'findChannelByName').mockReturnValue({ id: null! }); + + for (const test of testCases) { + const sent = await middlewareMock.sendDirectMessage(ctx, test); + expect(sent).toBe(false); + } + }); + + it('channel info returns empty', async () => { + const ctx: IChatContextData = MockContexts.MSTEAMS_SIMPLE_CTX; + jest.spyOn(middlewareMock.botActivityHandler, 'findChannelById').mockReturnValue(null!); + + ctx.context.chatTool.context = null; + for (const test of testCases) { + const sent = await middlewareMock.sendDirectMessage(ctx, test); + expect(sent).toBe(false); + } + ctx.context.chatTool.context = { _activity: null }; + for (const test of testCases) { + const sent = await middlewareMock.sendDirectMessage(ctx, test); + expect(sent).toBe(false); + } + ctx.context.chatTool.context._activity = { serviceUrl: null }; + + for (const test of testCases) { + const sent = await middlewareMock.sendDirectMessage(ctx, test); + expect(sent).toBe(false); + } + + ctx.context.chatTool.context._activity.serviceUrl = 'some_svc_url'; + ctx.context.chatTool.context._activity.recipient = 'bot_id'; // incidentially nuked by nulling 'above' paths + + for (const test of testCases) { + const sent = await middlewareMock.sendDirectMessage(ctx, test); + expect(sent).toBe(true); + } + }); + + it('empty or null serviceUrl', async () => { + const ctx: IChatContextData = MockContexts.MSTEAMS_SIMPLE_CTX; + jest.spyOn(middlewareMock.botActivityHandler, 'findServiceUrl').mockReturnValue(null!); + + for (const test of testCases) { + const sent = await middlewareMock.sendDirectMessage(ctx, test); + expect(sent).toBe(false); + } + }); + + it('empty msteams _activity.recipient', async () => { + const ctx: IChatContextData = MockContexts.MSTEAMS_SIMPLE_CTX; + + ctx.context.chatTool.context = null; + for (const test of testCases) { + const sent = await middlewareMock.sendDirectMessage(ctx, test); + expect(sent).toBe(false); + } + + ctx.context.chatTool.context = { _activity: null }; + for (const test of testCases) { + const sent = await middlewareMock.sendDirectMessage(ctx, test); + expect(sent).toBe(false); + } + ctx.context.chatTool.context._activity = { recipient: null }; + for (const test of testCases) { + const sent = await middlewareMock.sendDirectMessage(ctx, test); + expect(sent).toBe(false); + } + }); + + it('something throws an error', async () => { + const ctx: IChatContextData = MockContexts.MSTEAMS_SIMPLE_CTX; + + jest.spyOn(middlewareMock.botFrameworkAdapter, 'createConnectorClient').mockImplementation(() => { + throw new Error('something bad happened'); + }); + for (const test of testCases) { + const sent = await middlewareMock.sendDirectMessage(ctx, test); + expect(sent).toBe(false); + } + + // earlier call + jest.spyOn(middlewareMock, 'processMessages').mockImplementation(() => { + throw new Error('something bad happened'); + }); + for (const test of testCases) { + const sent = await middlewareMock.sendDirectMessage(ctx, test); + expect(sent).toBe(false); + } + }); + }); +}); diff --git a/__tests__/plugins/msteams/__unit__/__snapshots__/MsteamsMiddleware.test.ts.snap b/__tests__/plugins/msteams/__unit__/__snapshots__/MsteamsMiddleware.test.ts.snap new file mode 100644 index 0000000..5efbd1c --- /dev/null +++ b/__tests__/plugins/msteams/__unit__/__snapshots__/MsteamsMiddleware.test.ts.snap @@ -0,0 +1,204 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MsTeams Middleware Tests MSTeams processMessages 1`] = ` +{ + "attachments": [], + "mentions": [], + "textMessage": " +hi +over", +} +`; + +exports[`MsTeams Middleware Tests MSTeams processMessages 2`] = ` +{ + "attachments": [], + "mentions": [ + { + "mentioned": { + "id": "mock_id", + }, + }, + { + "mentioned": { + "id": "another_id", + "name": "mock_name", + }, + }, + ], + "textMessage": " +there +name", +} +`; + +exports[`MsTeams Middleware Tests MSTeams processMessages 3`] = ` +{ + "attachments": [], + "mentions": [], + "textMessage": " +there", +} +`; + +exports[`MsTeams Middleware Tests MSTeams processMessages 4`] = ` +{ + "attachments": [], + "mentions": [ + { + "mentioned": { + "id": "mock_id", + }, + }, + ], + "textMessage": " +"open the dialog"", +} +`; + +exports[`MsTeams Middleware Tests MSTeams processMessages 5`] = ` +{ + "attachments": [], + "mentions": [], + "textMessage": "", +} +`; + +exports[`MsTeams Middleware Tests MSTeams processMessages 6`] = ` +{ + "attachments": [], + "mentions": [], + "textMessage": " +hi +over", +} +`; + +exports[`MsTeams Middleware Tests MSTeams processMessages 7`] = ` +{ + "attachments": [], + "mentions": [ + { + "mentioned": { + "id": "mock_id", + "name": "mocked_found_name", + }, + }, + { + "mentioned": { + "id": "another_id", + "name": "mocked_found_name", + }, + }, + ], + "textMessage": " +there +name", +} +`; + +exports[`MsTeams Middleware Tests MSTeams processMessages 8`] = ` +{ + "attachments": [], + "mentions": [ + { + "mentioned": { + "id": "mocked_found_id", + "name": "mock_name", + }, + }, + ], + "textMessage": " +there", +} +`; + +exports[`MsTeams Middleware Tests MSTeams processMessages 9`] = ` +{ + "attachments": [], + "mentions": [ + { + "mentioned": { + "id": "mock_id", + "name": "mocked_found_name", + }, + }, + ], + "textMessage": " +"open the dialog"", +} +`; + +exports[`MsTeams Middleware Tests MSTeams processMessages 10`] = ` +{ + "attachments": [], + "mentions": [], + "textMessage": "", +} +`; + +exports[`MsTeams Middleware Tests MsTeams buildActivity 1`] = ` +{ + "entities": undefined, + "inputHint": "acceptingInput", + "text": "test", + "type": "message", +} +`; + +exports[`MsTeams Middleware Tests MsTeams buildActivity 2`] = ` +{ + "entities": { + "user": "mock_user", + }, + "inputHint": "acceptingInput", + "text": "two", + "type": "message", +} +`; + +exports[`MsTeams Middleware Tests MsTeams buildActivity 3`] = ` +{ + "entities": undefined, + "inputHint": "acceptingInput", + "text": undefined, + "type": "message", +} +`; + +exports[`MsTeams Middleware Tests MsTeams buildActivity 4`] = ` +{ + "attachmentLayout": "list", + "attachments": [ + { + "content": "abcdef", + "contentType": "mimetype/gif", + }, + ], + "entities": undefined, + "inputHint": "acceptingInput", + "type": "message", +} +`; + +exports[`MsTeams Middleware Tests MsTeams buildActivity 5`] = ` +{ + "entities": undefined, + "inputHint": "acceptingInput", + "text": "with a text message", + "type": "message", +} +`; + +exports[`MsTeams Middleware Tests MsTeams buildActivity 6`] = ` +{ + "entities": { + "user": "mock_user", + }, + "inputHint": "acceptingInput", + "text": undefined, + "type": "message", +} +`; + +exports[`MsTeams Middleware Tests MsTeams buildActivity 7`] = `{}`; diff --git a/__tests__/plugins/slack/__unit__/SlackMiddleware.test.ts b/__tests__/plugins/slack/__unit__/SlackMiddleware.test.ts new file mode 100644 index 0000000..53ac992 --- /dev/null +++ b/__tests__/plugins/slack/__unit__/SlackMiddleware.test.ts @@ -0,0 +1,211 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +import { SlackMiddleware } from '../../../../src/plugins/slack/SlackMiddleware'; +import { IChatContextData, IMessage, IMessageType } from '../../../../src/types'; +import { MockCommonBot } from '../../../common/mocks/MockCommonBot'; +import { MockContexts } from '../../../common/mocks/MockContexts'; +import { App } from '@slack/bolt'; +import { WebClient } from '@slack/web-api'; + +// disable outgoing API calls in unit test, which caused the tests to exit rc=1 despite passing +jest.spyOn(WebClient.prototype, 'apiCall').mockImplementation(() => { + return new Promise((resolve) => { + resolve({} as any); + }); +}); + +afterAll(() => { + jest.restoreAllMocks(); +}); + +describe('Slack Middleware Tests', () => { + const middlewareMock: { + app: App; + sendDirectMessage: (chatContextData: IChatContextData, messages: IMessage[]) => Promise; + } = new SlackMiddleware(MockCommonBot.SLACK_BOT) as any; + + it('Slack sendDirectMessage text', async () => { + const ctx = MockContexts.SLACK_PERSONAL_DM_CTX; + const testMsg = { + type: IMessageType.PLAIN_TEXT, + message: 'Test DM', + }; + + middlewareMock.app.client.chat.postMessage = jest.fn((args: any) => { + return '' as any; + }); + + const sent = await middlewareMock.sendDirectMessage(ctx, [testMsg]); + + expect(middlewareMock.app.client.chat.postMessage).toBeCalledWith({ + channel: ctx.context.chatting.channel.id, + text: testMsg.message, + }); + expect(sent).toBe(true); + }); + + it('Slack extended sendDirectMessage tests', async () => { + // Basic test cases + const testCases: IMessage[][] = [ + [ + { type: IMessageType.PLAIN_TEXT, message: 'hi' }, + { type: IMessageType.PLAIN_TEXT, message: 'over' }, + ], + [ + { + type: IMessageType.SLACK_BLOCK, + message: { + channel: 'default_channel', + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'a link', + }, + }, + ], + }, + }, + ], + [ + { + type: IMessageType.SLACK_VIEW_OPEN, + message: { + channel: 'default_channel', + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'a link', + }, + }, + ], + }, + }, + ], + [ + { + type: IMessageType.SLACK_VIEW_UPDATE, + message: { + channel: 'default_channel', + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'a link', + }, + }, + ], + }, + }, + ], + ]; + + middlewareMock.app.client.chat.postMessage = jest.fn((args: any) => { + return '' as any; + }); + middlewareMock.app.client.views.open = jest.fn((args: any) => { + return '' as any; + }); + middlewareMock.app.client.views.update = jest.fn((args: any) => { + return '' as any; + }); + + // run 3 tests against all cases: + // - peronal channel + // - public channel + // - public channel w/ failed api response from slack + for (const test of testCases) { + const pCtx = MockContexts.SLACK_PERSONAL_DM_CTX; + const sCtx = MockContexts.SLACK_SIMPLE_CTX; + const sentP = await middlewareMock.sendDirectMessage(pCtx, test); + const lastTest = test[test.length - 1]; + if (lastTest.type === IMessageType.PLAIN_TEXT) { + expect(middlewareMock.app.client.chat.postMessage).toBeCalledTimes(test.length); + expect(middlewareMock.app.client.chat.postMessage).lastCalledWith({ + channel: pCtx.context.chatting.channel.id, + text: lastTest.message, + }); + } + // message channel will be replaced with dm + if (lastTest.type === IMessageType.SLACK_VIEW_OPEN) { + expect(middlewareMock.app.client.views.open).toBeCalledTimes(test.length); + expect(middlewareMock.app.client.views.open).lastCalledWith({ + ...lastTest.message, + channel: pCtx.context.chatting.channel.id, + }); + } + if (lastTest.type === IMessageType.SLACK_VIEW_UPDATE) { + expect(middlewareMock.app.client.views.update).toBeCalledTimes(test.length); + expect(middlewareMock.app.client.views.update).lastCalledWith(lastTest.message); + } + expect(sentP).toBe(true); + + jest.clearAllMocks(); + + middlewareMock.app.client.conversations.open = jest.fn(() => { + return new Promise((resolve) => { + resolve({ + channel: { + id: sCtx.context.chatting.channel.id, + }, + ok: true, + }); + }); + }); + + const sentS = await middlewareMock.sendDirectMessage(sCtx, test); + if (lastTest.type === IMessageType.PLAIN_TEXT) { + expect(middlewareMock.app.client.chat.postMessage).toBeCalledTimes(test.length); + expect(middlewareMock.app.client.chat.postMessage).lastCalledWith({ + channel: sCtx.context.chatting.channel.id, + text: lastTest.message, + }); + } + // message channel will be replaced with dm + if (lastTest.type === IMessageType.SLACK_VIEW_OPEN) { + expect(middlewareMock.app.client.views.open).toBeCalledTimes(test.length); + expect(middlewareMock.app.client.views.open).lastCalledWith({ + ...lastTest.message, + channel: sCtx.context.chatting.channel.id, + }); + } + if (lastTest.type === IMessageType.SLACK_VIEW_UPDATE) { + expect(middlewareMock.app.client.views.update).toBeCalledTimes(test.length); + expect(middlewareMock.app.client.views.update).lastCalledWith(lastTest.message); + } + + expect(middlewareMock.app.client.conversations.open).toBeCalledTimes(1); + expect(middlewareMock.app.client.conversations.open).lastCalledWith({ + users: sCtx.context.chatting.user.id, + }); + expect(sentS).toBe(true); + + jest.clearAllMocks(); + + middlewareMock.app.client.conversations.open = jest.fn(() => { + return new Promise((resolve) => { + resolve({ + ok: false, + }); + }); + }); + + const sendF = await middlewareMock.sendDirectMessage(sCtx, test); + expect(sendF).toBe(false); + + jest.clearAllMocks(); + } + }); +}); diff --git a/__tests__/test-tsconfig.json b/__tests__/test-tsconfig.json new file mode 100644 index 0000000..de4520f --- /dev/null +++ b/__tests__/test-tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "types": ["node", "jest"], + "target": "es2015", + "module": "commonjs", + "declaration": true, + "moduleResolution": "node", + "noImplicitAny": true, + "outDir": "./lib", + "preserveConstEnums": true, + "removeComments": false, + "pretty": true, + "sourceMap": true, + "newLine": "lf" + }, + "include": ["./**/*.ts", "../src/**/__tests__/**/*.ts"], + "exclude": ["lib", "__results__/", "__snapshots__", "node_modules"] +} diff --git a/jest.config.ts b/jest.config.ts index 602201b..1566d42 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -8,7 +8,6 @@ * Copyright Contributors to the Zowe Project. */ -// eslint-disable-next-line node/no-unpublished-import import type { Config } from 'jest'; import * as fs from 'fs-extra'; @@ -30,7 +29,6 @@ const config: Config = { roots: [''], setupFilesAfterEnv: ['./__tests__/beforeTests.ts'], testEnvironment: 'node', - testResultsProcessor: 'jest-sonar-reporter', transform: { '^.+\\.ts?$': [ diff --git a/package.json b/package.json index cbbe678..af025a2 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@zowe/bot", - "version": "1.0.0", - "description": "IBM Common Bot Framework", - "author": "IBM", + "version": "1.0.1", + "description": "Zowe Common Bot Framework", + "author": "Zowe", "license": "EPL-2.0", "private": true, "type": "commonjs", @@ -73,5 +73,8 @@ }, "jestSonar": { "reportPath": "__tests__/__results__/jest-sonar" + }, + "engines": { + "node": "^16.0.0" } } diff --git a/sonar-project.properties b/sonar-project.properties index 7e2b580..fb82797 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -7,7 +7,7 @@ sonar.links.scm=https://github.com/zowe/bot sonar.sources=. sonar.tests=__tests__/ sonar.test.inclusions=**/*.test.ts -sonar.exclusions=**/*.js,.github/**/* +sonar.exclusions=**/*.js,.github/**/*,__tests__/**/* sonar.testExecutionReportPaths=__tests__/__results__/jest-sonar/test-report.xml sonar.javascript.lcov.reportPaths=__tests__/__results__/unit/coverage/lcov.info diff --git a/src/CommonBot.ts b/src/CommonBot.ts index dd6c76c..6f37ba1 100644 --- a/src/CommonBot.ts +++ b/src/CommonBot.ts @@ -183,6 +183,22 @@ export class CommonBot { return this.router; } + // Send message to channel + async sendDirectMessage(chatContextData: IChatContextData, messages: IMessage[]): Promise { + // Print start log + logger.start(this.sendDirectMessage, this); + + try { + return await this.middleware.sendDirectMessage(chatContextData, messages); + } catch (err) { + // Print exception stack + logger.error(logger.getErrorStack(new Error(err.name), err)); + } finally { + // Print end log + logger.end(this.send, this); + } + } + // Send message to channel async send(chatContextData: IChatContextData, messages: IMessage[]): Promise { // Print start log diff --git a/src/Middleware.ts b/src/Middleware.ts index 9f3e601..09e4a91 100644 --- a/src/Middleware.ts +++ b/src/Middleware.ts @@ -37,6 +37,23 @@ export class Middleware { } } + async sendDirectMessage(chatContextData: IChatContextData, messages: IMessage[]): Promise { + // Print start log + logger.start(this.sendDirectMessage, this); + + try { + logger.debug('sendDirectMessage in base middleware'); + return true; + } catch (err) { + // Print exception stack + logger.error(logger.getErrorStack(new Error(err.name), err)); + return false; + } finally { + // Print end log + logger.end(this.sendDirectMessage, this); + } + } + // Send message back to channel // eslint-disable-next-line @typescript-eslint/no-unused-vars async send(chatContextData: IChatContextData, messages: IMessage[]): Promise { diff --git a/src/plugins/mattermost/MattermostClient.ts b/src/plugins/mattermost/MattermostClient.ts index c418488..9eeedc6 100644 --- a/src/plugins/mattermost/MattermostClient.ts +++ b/src/plugins/mattermost/MattermostClient.ts @@ -296,6 +296,38 @@ export class MattermostClient { } } + async createDmChannel(user: IUser, botUser: IUser): Promise { + logger.start(this.createDmChannel, this); + + try { + const body = [user.id, botUser.id]; + + logger.silly(`Create DM Channel Request Body: ${body}`); + const response = await this.post(`${this.mattermostServerBaseUrl}/channels/direct`).send(body); + + // 2xx + if (response.statusCode >= 200 && response.statusCode < 300) { + const channel = { + id: response.body.id, + name: response.body.display_name, + chattingType: this.getChattingType(response.body.type), + }; + return channel; + } else { + logger.error(`Failed to create direct message channel with user ${user.name}:${user.id}`); + logger.debug(`Response: ${response.status}, ${response.body}`); + logger.silly(`Response dump:\n-------\n${Util.dumpResponse(response)}`); + return null; + } + } catch (error) { + logger.error(Util.dumpObject(error)); + // Print exception stack + logger.error(logger.getErrorStack(new Error(error.name), error)); + } finally { + logger.end(this.getChannelById, this); + } + } + private onError(error: Error): void { this.connectionStatus = IConnectionStatus.ERROR; logger.error(`On event error: ${error}`); diff --git a/src/plugins/mattermost/MattermostMiddleware.ts b/src/plugins/mattermost/MattermostMiddleware.ts index b890cb2..d506901 100644 --- a/src/plugins/mattermost/MattermostMiddleware.ts +++ b/src/plugins/mattermost/MattermostMiddleware.ts @@ -79,6 +79,48 @@ export class MattermostMiddleware extends Middleware { } } + async sendDirectMessage(chatContextData: IChatContextData, messages: IMessage[]): Promise { + logger.start(this.sendDirectMessage, this); + + try { + logger.debug('Creating a dm chat channel'); + const dmChannel = await this.client.createDmChannel(chatContextData.context.chatting.user, this.botUser); + + if (dmChannel == null) { + return false; + } + + // Get chat context data + logger.debug(`Chat tool data sent to Mattermost server: ${Util.dumpObject(chatContextData.context.chatTool, 2)}`); + + for (const msg of messages) { + // Process view to open dialog. + if (msg.type === IMessageType.MATTERMOST_DIALOG_OPEN) { + await this.client.openDialog(msg.message); + break; + } + + // Send message back to dm channel + if (chatContextData.context.chatTool !== null) { + // Conversation message + logger.info('Send conversation message ...'); + this.client.sendMessage(msg.message, dmChannel.id, chatContextData.context.chatTool.rootId); + } else { + // Proactive message + logger.info('Send proactive message ...'); + this.client.sendMessage(msg.message, dmChannel.id, ''); + } + return true; + } + } catch (err) { + // Print exception stack + logger.error(logger.getErrorStack(new Error(err.name), err)); + } finally { + // Print end log + logger.end(this.send, this); + } + } + // Send message back to Mattermost channel async send(chatContextData: IChatContextData, messages: IMessage[]): Promise { // Print start log diff --git a/src/plugins/msteams/MsteamsMiddleware.ts b/src/plugins/msteams/MsteamsMiddleware.ts index eac6bca..a570052 100644 --- a/src/plugins/msteams/MsteamsMiddleware.ts +++ b/src/plugins/msteams/MsteamsMiddleware.ts @@ -21,6 +21,7 @@ import { MessageFactory, ConversationAccount, Entity, + ChannelInfo, } from 'botbuilder'; import { CommonBot } from '../../CommonBot'; import { Middleware } from '../../Middleware'; @@ -97,10 +98,14 @@ export class MsteamsMiddleware extends Middleware { // Listen for incoming requests option.messagingApp.app.post(option.messagingApp.option.basePath, (req: Request, res: Response) => { - this.botFrameworkAdapter.processActivity(req, res, async (context) => { - // Process bot activity - await this.botActivityHandler.run(context); - }); + this.botFrameworkAdapter + .processActivity(req, res, async (context) => { + // Process bot activity + await this.botActivityHandler.run(context); + }) + .catch((error) => { + logger.error(logger.getErrorStack(new Error(error), error)); + }); }); } catch (err) { // Print exception stack @@ -111,6 +116,159 @@ export class MsteamsMiddleware extends Middleware { } } + private buildActivity(msgData: MessageData): Partial { + let firstActivity: Partial; + if (msgData.textMessage !== '') { + firstActivity = MessageFactory.text(msgData.textMessage); + firstActivity.entities = msgData.mentions; + } else if (msgData.attachments != null && msgData.attachments.length > 0) { + firstActivity = MessageFactory.attachment(msgData.attachments[0]); + firstActivity.entities = msgData.mentions; + } else { + firstActivity = {}; + } + return firstActivity; + } + + private processMessages(messages: IMessage[]): MessageData { + // Get text and attachment part of the message to be sent + let txtMsg = ''; + const mentions: Record[] = []; // eslint-disable-line @typescript-eslint/no-explicit-any + const attachments: Attachment[] = []; + for (const message of messages) { + if (message.type === IMessageType.PLAIN_TEXT) { + txtMsg = `${txtMsg}\n${message.message}`; + } else if (message.type === IMessageType.MSTEAMS_ADAPTIVE_CARD) { + attachments.push(CardFactory.adaptiveCard(message.message)); + } else { + logger.error(`Unsupported type "${message.type}" for the message: ${JSON.stringify(message, null, 2)}`); + txtMsg = `${txtMsg}\n${JSON.stringify(message.message)}`; + } + + // Find channel ID by name or name by ID and merge all mentioned channels + // Need to be enhance later to support sending all messages via single response or sending all message one by one via multiple messages + if (message.mentions !== undefined && message.mentions.length > 0) { + for (const mention of message.mentions) { + if (mention.mentioned.id.trim() === '' && mention.mentioned.name.trim() !== '') { + const channelInfo = this.botActivityHandler.findChannelByName(mention.mentioned.name); + logger.debug(`Channel info for mention ${mention.mentioned.name}: ${JSON.stringify(channelInfo, null, 2)}`); + if (channelInfo !== null) { + mention.mentioned.id = channelInfo.id; + } + } else { + const channelInfo = this.botActivityHandler.findChannelById(mention.mentioned.id); + logger.debug(`Channel info for mention ${mention.mentioned.name}: ${JSON.stringify(channelInfo, null, 2)}`); + if (channelInfo !== null) { + mention.mentioned.name = channelInfo.name; + } + } + + // if both id and name are found then push to the mentions + if (mention.mentioned.id !== '' && mention.mentioned.name !== '') { + mentions.push(mention); + } + } + + logger.debug(`message.mentions: ${JSON.stringify(message.mentions, null, 2)}`); + } + } + + logger.debug(`mentions: ${JSON.stringify(mentions, null, 2)}`); + + return { + mentions: mentions, + textMessage: txtMsg, + attachments: attachments, + }; + } + + async sendDirectMessage(chatContextData: IChatContextData, messages: IMessage[]): Promise { + logger.start(this.sendDirectMessage); + logger.debug('Sending direct message ...'); + try { + const msgData = this.processMessages(messages); + + // Check cached service URL + if (this.botActivityHandler.getServiceUrl().size === 0) { + logger.error( + `The cached MS Teams service URL is empty! ` + + `You must talk with your bot in your MS Teams client first to cache the service URL.`, + ); + return false; + } + + // Find channel and get service URL + let channelInfo: ChannelInfo; + let serviceUrl: string; + if (chatContextData.context.chatting.channel.id === '' && chatContextData.context.chatting.channel.name !== '') { + channelInfo = this.botActivityHandler.findChannelByName(chatContextData.context.chatting.channel.name); + } else { + channelInfo = this.botActivityHandler.findChannelById(chatContextData.context.chatting.channel.id); + } + logger.silly(`Source channel info: ${JSON.stringify(channelInfo, null, 2)}`); + if (channelInfo == null || channelInfo.id == null) { + if (chatContextData.context.chatTool?.context?._activity?.serviceUrl == null) { + logger.error(`Could not find channel info, and could not find serviceUrl because of it.`); + return false; + } + serviceUrl = chatContextData.context.chatTool.context._activity.serviceUrl; + } else { + serviceUrl = this.botActivityHandler.findServiceUrl(channelInfo.id); + } + + logger.silly(`Service URL: ${serviceUrl}`); + + if (serviceUrl == null || serviceUrl.trim() === '') { + logger.error(`MS Teams service URL does not exist for the channel ${JSON.stringify(channelInfo, null, 2)}`); + return false; + } + + // Create connector client + const connectorClient = this.botFrameworkAdapter.createConnectorClient(serviceUrl); + + const targetMember = { + id: chatContextData.context.chatting.user.id, + }; + + if (chatContextData.context.chatTool?.context?._activity?.recipient == null) { + logger.error(`Couldn't find the MSTeams BotId`); + logger.silly(`ChatTool Context: ${chatContextData.context.chatTool}`); + return false; + } + + const conversationParameters = { + isGroup: false, + members: [targetMember], + bot: chatContextData.context.chatTool.context._activity.recipient, + channelData: { + tenant: { + id: chatContextData.context.chatting.tenant.id, + }, + }, + }; + + // Send proactive message + // Note this function can't send multiple attachments + const conversationResourceResponse = await connectorClient.conversations.createConversation(conversationParameters); + + logger.silly(`Conversation Response: ${Util.dumpObject(conversationResourceResponse)}`); + + const dmContext: Partial = this.buildActivity(msgData); + dmContext.id = conversationResourceResponse.id; + + await connectorClient.conversations.sendToConversation(conversationResourceResponse.id, dmContext); + + return true; + } catch (err) { + // Print exception stack + logger.error(logger.getErrorStack(new Error(err.name), err)); + return false; + } finally { + // Print end log + logger.end(this.sendDirectMessage, this); + } + } + // Send message back to MS Teams channel async send(chatContextData: IChatContextData, messages: IMessage[]): Promise { // Print start log @@ -121,60 +279,19 @@ export class MsteamsMiddleware extends Middleware { logger.debug(`Chat context data sent to MS Teams: ${Util.dumpObject(chatContextData, 2)}`); // Get text and attachment part of the message to be sent - let textMessage = ''; - const mentions: Record[] = []; // eslint-disable-line @typescript-eslint/no-explicit-any - const attachments: Attachment[] = []; - for (const message of messages) { - if (message.type === IMessageType.PLAIN_TEXT) { - textMessage = `${textMessage}\n${message.message}`; - } else if (message.type === IMessageType.MSTEAMS_ADAPTIVE_CARD) { - attachments.push(CardFactory.adaptiveCard(message.message)); - } else { - logger.error(`Unsupported type "${message.type}" for the message: ${JSON.stringify(message, null, 2)}`); - textMessage = `${textMessage}\n${JSON.stringify(message.message)}`; - } - - // Find channel ID by name or name by ID and merge all mentioned channels - // Need to be enhance later to support sending all messages via single response or sending all message one by one via multiple messages - if (message.mentions !== undefined && message.mentions.length > 0) { - for (const mention of message.mentions) { - if (mention.mentioned.id.trim() === '' && mention.mentioned.name.trim() !== '') { - const channelInfo = this.botActivityHandler.findChannelByName(mention.mentioned.name); - logger.debug(`Channel info for mention ${mention.mentioned.name}: ${JSON.stringify(channelInfo, null, 2)}`); - if (channelInfo !== null) { - mention.mentioned.id = channelInfo.id; - } - } else { - const channelInfo = this.botActivityHandler.findChannelById(mention.mentioned.id); - logger.debug(`Channel info for mention ${mention.mentioned.name}: ${JSON.stringify(channelInfo, null, 2)}`); - if (channelInfo !== null) { - mention.mentioned.name = channelInfo.name; - } - } - - // if both id and name are found then push to the mentions - if (mention.mentioned.id !== '' && mention.mentioned.name !== '') { - mentions.push(mention); - } - } - - logger.debug(`message.mentions: ${JSON.stringify(message.mentions, null, 2)}`); - } - } - - logger.debug(`mentions: ${JSON.stringify(mentions, null, 2)}`); + const msgData = this.processMessages(messages); // Get activity - let activity: string | Partial = null; - if (textMessage !== '' && attachments.length === 0) { + let activity: string | Partial; + if (msgData.textMessage !== '' && msgData.attachments.length === 0) { // Pure text - activity = MessageFactory.text(textMessage); - } else if (textMessage === '' && attachments.length > 0) { + activity = MessageFactory.text(msgData.textMessage); + } else if (msgData.textMessage === '' && msgData.attachments.length > 0) { // Adaptive card - activity = { attachments: attachments }; - } else if (textMessage !== '' && attachments.length > 0) { + activity = { attachments: msgData.attachments }; + } else if (msgData.textMessage !== '' && msgData.attachments.length > 0) { // Pure text + adaptive card - activity = { text: textMessage, attachments: attachments }; + activity = { text: msgData.textMessage, attachments: msgData.attachments }; } else { activity = ''; logger.warn(`The message to be sent is empty!`); @@ -183,12 +300,7 @@ export class MsteamsMiddleware extends Middleware { // Send message back to channel if (activity !== '') { - if ( - chatContextData.context.chatTool !== null && - chatContextData.context.chatTool !== undefined && - chatContextData.context.chatTool.context !== null && - chatContextData.context.chatTool.context !== undefined - ) { + if (chatContextData.context.chatTool != null && chatContextData.context.chatTool.context != null) { // Conversation message logger.info('Send conversation message ...'); @@ -214,7 +326,7 @@ export class MsteamsMiddleware extends Middleware { } // Find channel - let channelInfo = null; + let channelInfo: ChannelInfo; if (chatContextData.context.chatting.channel.id === '' && chatContextData.context.chatting.channel.name !== '') { channelInfo = this.botActivityHandler.findChannelByName(chatContextData.context.chatting.channel.name); } else { @@ -229,7 +341,7 @@ export class MsteamsMiddleware extends Middleware { } // Get service URL - const serviceUrl = this.botActivityHandler.findServiceUrl(channelInfo.id); + const serviceUrl = this.botActivityHandler.findServiceUrl(channelInfo.id!); logger.info(`Service URL: ${serviceUrl}`); if (serviceUrl === '') { logger.error(`MS Teams service URL does not exist for the channel ${JSON.stringify(channelInfo, null, 2)}`); @@ -244,14 +356,7 @@ export class MsteamsMiddleware extends Middleware { // Send proactive message will fail, more details please look at below // Reference: // how to @someone in MS Teams: https://docs.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations?tabs=typescript - let firstActivity: Partial = null; - if (textMessage !== '') { - firstActivity = MessageFactory.text(textMessage); - firstActivity.entities = mentions; - } else if (attachments.length > 0) { - firstActivity = MessageFactory.attachment(attachments[0]); - firstActivity.entities = mentions; - } + const firstActivity: Partial = this.buildActivity(msgData); logger.debug(`firstActivity: ${JSON.stringify(firstActivity, null, 2)}`); const conversationParameters = { isGroup: true, @@ -275,15 +380,17 @@ export class MsteamsMiddleware extends Middleware { const conversationResourceResponse = await connectorClient.conversations.createConversation(conversationParameters); // Create the rest not sended Activity - let restActivity: Partial = null; - if (textMessage !== '' && attachments.length > 0) { - restActivity = { attachments: attachments }; - restActivity.entities = mentions; - } else if (textMessage === '' && attachments.length > 1) { + let restActivity: Partial; + if (msgData.textMessage !== '' && msgData.attachments.length > 0) { + restActivity = { attachments: msgData.attachments }; + restActivity.entities = msgData.mentions; + } else if (msgData.textMessage === '' && msgData.attachments.length > 1) { // Remove the first attachment since it's already been sended. - attachments.shift(); - restActivity = { attachments: attachments }; - restActivity.entities = mentions; + msgData.attachments.shift(); + restActivity = { attachments: msgData.attachments }; + restActivity.entities = msgData.mentions; + } else { + restActivity = {}; } logger.debug(`restActivity: ${JSON.stringify(restActivity, null, 2)}`); // Create the conversationReference @@ -310,3 +417,9 @@ export class MsteamsMiddleware extends Middleware { } } } + +type MessageData = { + textMessage: string; + mentions: Record; + attachments: Attachment[]; +}; diff --git a/src/plugins/slack/SlackMiddleware.ts b/src/plugins/slack/SlackMiddleware.ts index d390567..a3a627e 100644 --- a/src/plugins/slack/SlackMiddleware.ts +++ b/src/plugins/slack/SlackMiddleware.ts @@ -46,6 +46,7 @@ const logger = Logger.getInstance(); export class SlackMiddleware extends Middleware { private app: App; private botName = ''; + private botId = ''; private users: Map; private channels: Map; @@ -157,6 +158,7 @@ export class SlackMiddleware extends Middleware { const botUserInfo = await slackEvent.client.users.info({ user: slackEvent.context.botUserId }); logger.debug(`Bot user info: ${JSON.stringify(botUserInfo)}`); this.botName = botUserInfo.user.real_name; + this.botId = botUserInfo.user.id; } // Search the user from cached users. @@ -193,6 +195,7 @@ export class SlackMiddleware extends Middleware { if ((>slackEvent.message).blocks !== undefined) { // eslint-disable-line @typescript-eslint/no-explicit-any const messageBlocks = (>slackEvent.message).blocks; // eslint-disable-line @typescript-eslint/no-explicit-any + logger.silly(`Slack Message Blocks: ${messageBlocks}`); for (const block of messageBlocks) { // Get the rich_text block if (block.type === 'rich_text' && block.elements !== undefined) { @@ -201,7 +204,7 @@ export class SlackMiddleware extends Middleware { logger.debug(`Find block rich_text_section to get the raw message`); for (const richTextElement of element.elements) { // Only consider user, link && text element. - if (richTextElement.type === 'user' && richTextElement.user_i === slackEvent.context.botUserId) { + if (richTextElement.type === 'user' && richTextElement.user_id === slackEvent.context.botUserId) { rawMessage = `${rawMessage}@${this.botName}`; } else if (richTextElement.type === 'text') { rawMessage = rawMessage + richTextElement.text; @@ -233,6 +236,10 @@ export class SlackMiddleware extends Middleware { if (message.indexOf(`@${this.botName}`) === -1) { message = `@${this.botName} ${message}`; } + } else { + if (message.indexOf(`${this.botId}`) !== -1) { + message = ``; + } } const chatContextData: IChatContextData = { @@ -568,15 +575,65 @@ export class SlackMiddleware extends Middleware { // Add the user addUser(id: string, user: IUser): boolean { - let result = true; if (id === undefined || id.trim() === '') { - result = false; - return result; + return false; } this.users.set(id, user); - result = true; - return result; + return true; + } + + async sendDirectMessage(chatContextData: IChatContextData, messages: IMessage[]): Promise { + // Print start log + logger.start(this.sendDirectMessage, this); + let channelId: string; + if (chatContextData.context.chatting.type === IChattingType.PERSONAL) { + channelId = chatContextData.context.chatting.channel.id; + } else { + const openResp = await this.app.client.conversations.open({ + users: chatContextData.context.chatting.user.id, + }); + if (openResp.ok) { + channelId = openResp.channel.id; + } else { + logger.error('Unable open a DM for user ' + chatContextData.context.chatting.user.name); + logger.debug(`Response: ${openResp.ok}s`); + logger.silly(`Response dump:\n-------\n${Util.dumpObject(openResp)}`); + return false; + } + } + try { + for (const msg of messages) { + logger.debug(`msg: ${JSON.stringify(msg, null, 2)}`); + if (msg.type === IMessageType.SLACK_VIEW_OPEN) { + if (msg.message.channel != null) { + msg.message.channel = channelId; + } + await this.app.client.views.open({ ...msg.message }); + } else if (msg.type === IMessageType.SLACK_VIEW_UPDATE) { + if (msg.message.channel != null) { + msg.message.channel = channelId; + } + await this.app.client.views.update(msg.message); + } else if (msg.type === IMessageType.PLAIN_TEXT) { + await this.app.client.chat.postMessage({ + channel: channelId, + text: msg.message, + }); + } else { + logger.info('No message send to commonbot, ignoring.'); + logger.debug(`Empty message: ${Util.dumpObject(msg)}`); + // TODO: should this return false? Whats the expectation of someone calling api with an empty msg? + } + } + return true; + } catch (err) { + // Print exception stack + logger.error(logger.getErrorStack(new Error(err.name), err)); + } finally { + // Print end log + logger.end(this.sendDirectMessage, this); + } } // get channel by id diff --git a/tsconfig.json b/tsconfig.json index 1a93efd..93a1140 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,110 +1,108 @@ { - "compilerOptions": { - /* Visit https://aka.ms/tsconfig.json to read more about this file */ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ - /* Projects */ - // "incremental": true, /* Enable incremental compilation */ - "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + /* Projects */ + // "incremental": true, /* Enable incremental compilation */ + "composite": true /* Enable constraints that allow a TypeScript project to be used with project references. */, + // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - /* Language and Environment */ - "target": "es2015", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ - // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + /* Language and Environment */ + "target": "es2015" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ + // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - /* Modules */ - "module": "commonjs", /* Specify what module code is generated. */ - // "rootDir": "./src", /* Specify the root folder within your source files. */ - "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": ".", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "resolveJsonModule": true, /* Enable importing .json files */ - // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ + /* Modules */ + "module": "commonjs" /* Specify what module code is generated. */, + // "rootDir": "./src", /* Specify the root folder within your source files. */ + "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, + // "baseUrl": ".", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "resolveJsonModule": true, /* Enable importing .json files */ + // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ - /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ - /* Emit */ - "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ - "outDir": "./dist", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - "newLine": "lf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ - "declarationDir": "./dist/types", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + /* Emit */ + "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./dist" /* Specify an output folder for all emitted files. */, + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + "newLine": "lf" /* Set the newline character for emitting files. */, + // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + "preserveConstEnums": true /* Disable erasing `const enum` declarations in generated code. */, + "declarationDir": "./dist/types" /* Specify the output directory for generated declaration files. */, + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, - /* Type Checking */ - // "strict": true, /* Enable all strict type-checking options. */ - "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ - // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ - // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + /* Type Checking */ + // "strict": true, /* Enable all strict type-checking options. */ + "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied `any` type.. */, + // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ + // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ - }, - "exclude": [ - // "dist", - // "node_modules", - ], - "include": [ - "./src/**/*" - ] + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "exclude": [ + // "dist", + // "node_modules", + "**/__tests__/*", + "**/*.test.ts" + ], + "include": ["./src/**/*"] } - -