Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Rocket.Chat to Teams outbound messaging #4

Merged
merged 3 commits into from Aug 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 14 additions & 1 deletion TeamsBridgeApp.ts
Expand Up @@ -4,18 +4,22 @@ import {
IConfigurationModify,
IHttp,
ILogger,
IModify,
IPersistence,
IRead,
} from '@rocket.chat/apps-engine/definition/accessors';
import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api';
import { App } from '@rocket.chat/apps-engine/definition/App';
import { IMessage, IPostMessageSent } from '@rocket.chat/apps-engine/definition/messages';
import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import { ISetting } from '@rocket.chat/apps-engine/definition/settings';
import { AppSetting, settings } from './config/Settings';
import { AuthenticationEndpoint } from './endpoints/AuthenticationEndpoint';
import { handlePostMessageSentAsync } from './lib/PostMessageSentHandler';
import { LoginTeamsSlashCommand } from './slashcommands/LoginTeamsSlashCommand';
import { SetupVerificationSlashCommand } from './slashcommands/SetupVerificationSlashCommand';

export class TeamsBridgeApp extends App {
export class TeamsBridgeApp extends App implements IPostMessageSent {
constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) {
super(info, logger, accessors);
}
Expand Down Expand Up @@ -43,4 +47,13 @@ export class TeamsBridgeApp extends App {
http: IHttp): Promise<void> {
console.log(`onSettingUpdated for setting ${setting.id} with new value ${setting.value}`);
}

public async executePostMessageSent(
message: IMessage,
read: IRead,
http: IHttp,
persistence: IPersistence,
modify: IModify): Promise<void> {
await handlePostMessageSentAsync(message, read, http, persistence, modify);
}
}
4 changes: 3 additions & 1 deletion app.json
Expand Up @@ -16,5 +16,7 @@
"Communication",
"Omnichannel"
],
"implements": []
"implements": [
"IPostMessageSent"
]
}
25 changes: 21 additions & 4 deletions docs/user.md
Expand Up @@ -24,10 +24,27 @@ The required action for you to authorize the TeamsBridge App is called `Embedded
- Click `Yes` if you see the login page ask authorization for specific permissions from you.
- If you successfully login to Teams and authorize the TeamsBridge App, the web pages will show `Login to Teams succeed! You can close this window now.`. You can just close the window.

## Send Direct Message to a collaborator who use Microsoft Teams
## Collaboration Experience

Document under development.
One of the most important goals of Rocket.Chat TeamsBridge App is to build a smooth user experience for both users on Rocket.Chat and Microsoft Teams. To achieve this, TeamsBridge App introduces a concept called `Dummy User` in Rocket.Chat world. Each Rocket.Chat `Dummy User` represents a real user on Microsoft Teams. When a Rocket.Chat user that has already `embedded login` to his Teams account sends a message to a `Dummy User`, the message will be delivered to Microsoft Teams world with the original sender's Teams account as the sender and the `Dummy User`'s corresponding Teams account as the receiver. As a result, from the Rocket.Chat users' perspective, they are just collaborating with someone on Rocket.Chat. Meanwhile, from the Teams users' perspective, they are just messaging someone on Teams. With the `Dummy User` approach, the TeamsBridge App delivers messages between Rocket.Chat and Microsoft Teams while keeping the orginal collaboration experience for users on both platforms.

## Send Message to a group chat with participant(s) who use Microsoft Teams
### Send one on one Direct Message to a collaborator who use Microsoft Teams

Document under development.
To send a one on one Direct Message from Rocket.Chat to a collaborator who use Microsoft Teams, the Rocket.Chat user just need to search the `Dummy User` that represent the Teams user in Rocket.Chat client and send a message to them. The message will be delivered to Microsoft Teams world with the original sender's Teams account as the sender and the `Dummy User`'s corresponding Teams account as the receiver.

### Receive one on one Direct Message from a collaborator who use Microsoft Teams

This feature is under development and will be available soon.

### Send Message to a group chat with participant(s) who use Microsoft Teams

Currently, this is NOT a supported scenario, which is under developer and will be added soon in the future.

### Receive Message in a group chat with participant(s) who use Microsoft Teams

This feature is under development and will be available soon.

### Supported Message Types

Currently, the following message types are supported:
- Text Message
18 changes: 15 additions & 3 deletions endpoints/AuthenticationEndpoint.ts
Expand Up @@ -15,8 +15,8 @@ import { IApiResponseJSON } from "@rocket.chat/apps-engine/definition/api/IRespo
import { IApp } from "@rocket.chat/apps-engine/definition/IApp";
import { AppSetting } from "../config/Settings";
import { AuthenticationEndpointPath } from "../lib/Const";
import { getUserAccessTokenAsync } from "../lib/MicrosoftGraphApi";
import { persistUserAccessTokenAsync } from "../lib/PersistHelper";
import { getUserAccessTokenAsync, getUserProfileAsync } from "../lib/MicrosoftGraphApi";
import { persistUserAccessTokenAsync, persistUserAsync } from "../lib/PersistHelper";
import { getRocketChatAppEndpointUrl } from "../lib/UrlHelper";

export class AuthenticationEndpoint extends ApiEndpoint {
Expand Down Expand Up @@ -58,7 +58,19 @@ export class AuthenticationEndpoint extends ApiEndpoint {
aadClientId,
aadClientSecret);

await persistUserAccessTokenAsync(persis, rocketChatUserId, response.accessToken, response.refreshToken as string);
const userAccessToken = response.accessToken;

const teamsUserProfile = await getUserProfileAsync(http, userAccessToken);

await persistUserAccessTokenAsync(
persis,
rocketChatUserId,
userAccessToken,
response.refreshToken as string,
response.expiresIn,
response.extExpiresIn);

await persistUserAsync(persis, rocketChatUserId, teamsUserProfile.id);

// TODO: setup token refresh mechenism in future PR
// TODO: setup incoming message webhook in future PR
Expand Down
52 changes: 49 additions & 3 deletions lib/Const.ts
@@ -1,13 +1,33 @@
import { UserModel } from "./PersistHelper";

export const AuthenticationEndpointPath: string = 'auth';

export const MicrosoftBaseUrl: string = 'https://login.microsoftonline.com';
const LoginBaseUrl: string = 'https://login.microsoftonline.com';
const GraphApiBaseUrl: string = 'https://graph.microsoft.com';
export const SupportDocumentUrl: string = 'https://github.com/RocketChat/Apps.teams.bridge/blob/main/docs/support.md';

const GraphApiVersion = {
V1: 'v1.0',
Beta: 'beta',
}

const GraphApiEndpoint = {
Profile: 'me',
Chat: 'chats',
Message: (threadId: string) => `chats/${threadId}/messages`,
};

export const LoginMessageText: string =
'To start cross platform collaboration, you need to login to Microsoft with your Teams account or guest account. '
+ 'You\'ll be able to keep using Rocket.Chat, but you\'ll also be able to chat with colleagues using Microsoft Teams. '
+ 'Please click this button to login Teams:';
export const LoginRequiredHintMessageText: string =
'The Rocket.Chat user you are messaging represents a colleague in your organization using Microsoft Teams. '
+ 'The message can NOT be delivered to the user on Microsoft Teams before you start cross platform collaboration for your account. '
+ 'For details, see:';

export const LoginButtonText: string = 'Login Teams';
export const SupportDocumentButtonText: string = 'Support Document';

export const AuthenticationScopes = [
'offline_access',
Expand All @@ -22,9 +42,35 @@ export const AuthenticationScopes = [
];

export const getMicrosoftTokenUrl = (aadTenantId: string) => {
return `${MicrosoftBaseUrl}/${aadTenantId}/oauth2/v2.0/token`;
return `${LoginBaseUrl}/${aadTenantId}/oauth2/v2.0/token`;
};

export const getMicrosoftAuthorizeUrl = (aadTenantId: string) => {
return `${MicrosoftBaseUrl}/${aadTenantId}/oauth2/v2.0/authorize`;
return `${LoginBaseUrl}/${aadTenantId}/oauth2/v2.0/authorize`;
};

export const getGraphApiProfileUrl = () => {
return `${GraphApiBaseUrl}/${GraphApiVersion.V1}/${GraphApiEndpoint.Profile}`;
};

export const getGraphApiChatUrl = () => {
return `${GraphApiBaseUrl}/${GraphApiVersion.V1}/${GraphApiEndpoint.Chat}`;
};

export const getGraphApiMessageUrl = (threadId: string) => {
return `${GraphApiBaseUrl}/${GraphApiVersion.V1}/${GraphApiEndpoint.Message(threadId)}`;
};

export const TestEnvironment = {
// Set enable to true for local testing with mock data
enable: true,
// Put url here when running locally & using tunnel service such as Ngrok to expose the localhost port to the internet
tunnelServiceUrl: '',
mockDummyUsers: [
{
// Mock dummy user for alexw.l4cf.onmicrosoft.com
rocketChatUserId: 'v4ECCH3pTAE6nBXyJ',
teamsUserId: 'ffa3322f-670c-4887-b193-a04cca6073f8',
}
] as UserModel[],
};
147 changes: 145 additions & 2 deletions lib/MicrosoftGraphApi.ts
Expand Up @@ -3,7 +3,13 @@ import {
IHttp,
IHttpRequest,
} from "@rocket.chat/apps-engine/definition/accessors";
import { AuthenticationScopes, getMicrosoftTokenUrl } from "./Const";
import {
AuthenticationScopes,
getGraphApiChatUrl,
getGraphApiMessageUrl,
getGraphApiProfileUrl,
getMicrosoftTokenUrl
} from "./Const";

export interface TokenResponse {
tokenType: string;
Expand All @@ -13,6 +19,22 @@ export interface TokenResponse {
refreshToken?: string;
};

export interface TeamsUserProfile {
displayName: string;
givenName: string;
surname: string;
mail: string;
id: string;
};

export interface CreateThreadResponse {
threadId: string;
};

export interface SendMessageResponse {
messageId: string;
};

export const getApplicationAccessTokenAsync = async (
http: IHttp,
aadTenantId: string,
Expand Down Expand Up @@ -68,7 +90,7 @@ export const getUserAccessTokenAsync = async (

const httpRequest: IHttpRequest = {
headers: {
"Content-Type": "application/x-www-form-urlencoded"
'Content-Type': 'application/x-www-form-urlencoded'
},
content: body
};
Expand Down Expand Up @@ -96,3 +118,124 @@ export const getUserAccessTokenAsync = async (
throw new Error(`Get application access token failed with http status code ${response.statusCode}.`);
}
};

export const getUserProfileAsync = async (http: IHttp, userAccessToken: string) : Promise<TeamsUserProfile> => {
const url = getGraphApiProfileUrl();
const httpRequest: IHttpRequest = {
headers: {
'Authorization': `Bearer ${userAccessToken}`,
},
};

const response = await http.get(url, httpRequest);

if (response.statusCode === HttpStatusCode.OK) {
const responseBody = response.content;
if (responseBody === undefined) {
throw new Error('Get user profile failed!');
}

const jsonBody = JSON.parse(responseBody);
const result : TeamsUserProfile = {
displayName: jsonBody.displayName,
givenName: jsonBody.givenName,
surname: jsonBody.surname,
mail: jsonBody.mail,
id: jsonBody.id,
};

return result;
} else {
throw new Error(`Get user profile failed with http status code ${response.statusCode}.`);
}
};

export const createOneOnOneChatThreadAsync = async (
http: IHttp,
senderUserTeamsId: string,
receiverUserTeamsId: string,
userAccessToken: string) : Promise<CreateThreadResponse> => {
const url = getGraphApiChatUrl();

const body = {
'chatType': 'oneOnOne',
'members': [
{
'@odata.type': '#microsoft.graph.aadUserConversationMember',
'roles': ['owner'],
'user@odata.bind': `https://graph.microsoft.com/v1.0/users('${senderUserTeamsId}')`,
},
{
'@odata.type': '#microsoft.graph.aadUserConversationMember',
'roles': ['owner'],
'user@odata.bind': `https://graph.microsoft.com/v1.0/users('${receiverUserTeamsId}')`,
},
],
}

const httpRequest: IHttpRequest = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userAccessToken}`,
},
content: JSON.stringify(body)
};

const response = await http.post(url, httpRequest);

if (response.statusCode === HttpStatusCode.CREATED) {
const responseBody = response.content;
if (responseBody === undefined) {
throw new Error('Create one on one chat thread failed!');
}

const jsonBody = JSON.parse(responseBody);
const result : CreateThreadResponse = {
threadId: jsonBody.id,
};

return result;
} else {
throw new Error(`Create one on one chat thread failed with http status code ${response.statusCode}.`);
}
};

export const sendTextMessageToChatThreadAsync = async (
http: IHttp,
textMessage: string,
threadId: string,
userAccessToken: string) : Promise<SendMessageResponse> => {
const url = getGraphApiMessageUrl(threadId);

const body = {
'body' : {
'content': textMessage
}
}

const httpRequest: IHttpRequest = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userAccessToken}`,
},
content: JSON.stringify(body)
};

const response = await http.post(url, httpRequest);

if (response.statusCode === HttpStatusCode.CREATED) {
const responseBody = response.content;
if (responseBody === undefined) {
throw new Error('Send message to chat thread failed!');
}

const jsonBody = JSON.parse(responseBody);
const result : SendMessageResponse = {
messageId: jsonBody.id,
};

return result;
} else {
throw new Error(`Send message to chat thread failed with http status code ${response.statusCode}.`);
}
};