diff --git a/NotionApp.ts b/NotionApp.ts index 613ac8a..50da671 100644 --- a/NotionApp.ts +++ b/NotionApp.ts @@ -1,12 +1,99 @@ import { IAppAccessors, + IConfigurationExtend, + IEnvironmentRead, + IHttp, ILogger, + IModify, + IPersistence, + IRead, } from "@rocket.chat/apps-engine/definition/accessors"; import { App } from "@rocket.chat/apps-engine/definition/App"; import { IAppInfo } from "@rocket.chat/apps-engine/definition/metadata"; +import { settings } from "./config/settings"; +import { OAuth2Client } from "./src/authorization/OAuth2Client"; +import { NotionCommand } from "./src/commands/NotionCommand"; +import { NotionSDK } from "./src/lib/NotionSDK"; +import { + ApiSecurity, + ApiVisibility, +} from "@rocket.chat/apps-engine/definition/api"; +import { WebHookEndpoint } from "./src/endpoints/webhook"; +import { ElementBuilder } from "./src/lib/ElementBuilder"; +import { BlockBuilder } from "./src/lib/BlockBuilder"; +import { + IUIKitResponse, + UIKitBlockInteractionContext, +} from "@rocket.chat/apps-engine/definition/uikit"; +import { RoomInteractionStorage } from "./src/storage/RoomInteraction"; +import { OAuth2Action } from "./enum/OAuth2"; +import { IAppUtils } from "./definition/lib/IAppUtils"; export class NotionApp extends App { + private oAuth2Client: OAuth2Client; + private NotionSdk: NotionSDK; + private elementBuilder: ElementBuilder; + private blockBuilder: BlockBuilder; constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) { super(info, logger, accessors); } + + public async initialize( + configurationExtend: IConfigurationExtend, + environmentRead: IEnvironmentRead + ): Promise { + await configurationExtend.slashCommands.provideSlashCommand( + new NotionCommand(this) + ); + await Promise.all( + settings.map((setting) => { + configurationExtend.settings.provideSetting(setting); + }) + ); + + await configurationExtend.api.provideApi({ + visibility: ApiVisibility.PUBLIC, + security: ApiSecurity.UNSECURE, + endpoints: [new WebHookEndpoint(this)], + }); + + this.oAuth2Client = new OAuth2Client(this); + this.NotionSdk = new NotionSDK(this.getAccessors().http); + this.elementBuilder = new ElementBuilder(this.getID()); + this.blockBuilder = new BlockBuilder(this.getID()); + } + + public getOAuth2Client(): OAuth2Client { + return this.oAuth2Client; + } + public getUtils(): IAppUtils { + return { + NotionSdk: this.NotionSdk, + elementBuilder: this.elementBuilder, + blockBuilder: this.blockBuilder, + }; + } + + public async executeBlockActionHandler( + context: UIKitBlockInteractionContext, + read: IRead, + http: IHttp, + persistence: IPersistence, + modify: IModify + ): Promise { + // Todo[Week 2]: Make a Interface and Class + const { actionId, user, room } = context.getInteractionData(); + + if (actionId == OAuth2Action.CONNECT_TO_WORKSPACE) { + const persistenceRead = read.getPersistenceReader(); + const roomId = room?.id as string; + const roomInteraction = new RoomInteractionStorage( + persistence, + persistenceRead + ); + await roomInteraction.storeInteractionRoomId(user.id, roomId); + } + + return context.getInteractionResponder().successResponse(); + } } diff --git a/config/settings.ts b/config/settings.ts new file mode 100644 index 0000000..719022e --- /dev/null +++ b/config/settings.ts @@ -0,0 +1,38 @@ +import { + ISetting, + SettingType, +} from "@rocket.chat/apps-engine/definition/settings"; + +// The settings that will be available for the App +// warning(AppsEngine Error): Having OAuth2Setting in enums folder causing an error in deployment reason not known. +export enum OAuth2Setting { + CLIENT_ID = "notion-client-id", + CLIENT_SECRET = "notion-client-secret", +} + +export const settings: Array = [ + { + id: OAuth2Setting.CLIENT_ID, + type: SettingType.STRING, + packageValue: "", + required: true, + public: false, + section: "CredentialSettings", + i18nLabel: "ClientIdLabel", + i18nPlaceholder: "ClientIdPlaceholder", + hidden: false, + multiline: false, + }, + { + id: OAuth2Setting.CLIENT_SECRET, + type: SettingType.PASSWORD, + packageValue: "", + required: true, + public: false, + section: "CredentialSettings", + i18nLabel: "ClientSecretLabel", + i18nPlaceholder: "ClientSecretPlaceholder", + hidden: false, + multiline: false, + }, +]; diff --git a/definition/authorization/ICredential.ts b/definition/authorization/ICredential.ts new file mode 100644 index 0000000..cbdb300 --- /dev/null +++ b/definition/authorization/ICredential.ts @@ -0,0 +1,5 @@ +export interface ICredential { + clientId: string; + clientSecret: string; + siteUrl: string; +} diff --git a/definition/authorization/IOAuth2Storage.ts b/definition/authorization/IOAuth2Storage.ts new file mode 100644 index 0000000..dd5e330 --- /dev/null +++ b/definition/authorization/IOAuth2Storage.ts @@ -0,0 +1,39 @@ +import { NotionOwnerType, NotionTokenType } from "../../enum/Notion"; + +export interface IOAuth2Storage { + connectUserToWorkspace( + tokenInfo: ITokenInfo, + userId: string + ): Promise; + getCurrentWorkspace(userId: string): Promise; + disconnectUserFromCurrentWorkspace(userId: string): Promise; +} + +export interface ITokenInfo { + access_token: string; + token_type: NotionTokenType.TOKEN_TYPE; + bot_id: string; + workspace_icon: string | null; + workspace_id: string; + workspace_name: string | null; + owner: INotionOwner; + duplicated_template_id: string | null; +} + +interface INotionOwner { + type: NotionOwnerType.USER; + user: INotionUser; +} + +interface INotionUser { + object: NotionOwnerType.USER; + id: string; + name: string | null; + avatar_url: string | null; + type: NotionOwnerType.PERSON; + person: INotionPerson; +} + +interface INotionPerson { + email: string; +} diff --git a/definition/authorization/IOAuthClient.ts b/definition/authorization/IOAuthClient.ts new file mode 100644 index 0000000..e42aac6 --- /dev/null +++ b/definition/authorization/IOAuthClient.ts @@ -0,0 +1,35 @@ +import { + IHttp, + IModify, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { IRoom } from "@rocket.chat/apps-engine/definition/rooms"; +import { IUser } from "@rocket.chat/apps-engine/definition/users"; + +export interface IOAuth2Client { + connect( + room: IRoom, + sender: IUser, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence + ): Promise; + + disconnect( + room: IRoom, + sender: IUser, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence + ): Promise; + + getAuthorizationUrl( + user: IUser, + read: IRead, + modify: IModify, + room: IRoom + ): Promise; +} diff --git a/definition/command/ICommandUtility.ts b/definition/command/ICommandUtility.ts new file mode 100644 index 0000000..a9f58a4 --- /dev/null +++ b/definition/command/ICommandUtility.ts @@ -0,0 +1,37 @@ +import { + IHttp, + IModify, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { IRoom } from "@rocket.chat/apps-engine/definition/rooms"; +import { IUser } from "@rocket.chat/apps-engine/definition/users"; +import { NotionApp } from "../../NotionApp"; + +export interface ICommandUtility { + app: NotionApp; + params: Array; + sender: IUser; + room: IRoom; + read: IRead; + modify: IModify; + http: IHttp; + persis: IPersistence; + triggerId?: string; + threadId?: string; + + resolveCommand(): Promise; +} + +export interface ICommandUtilityParams { + app: NotionApp; + params: Array; + sender: IUser; + room: IRoom; + read: IRead; + modify: IModify; + http: IHttp; + persis: IPersistence; + triggerId?: string; + threadId?: string; +} diff --git a/definition/errors/IError.ts b/definition/errors/IError.ts new file mode 100644 index 0000000..27989cf --- /dev/null +++ b/definition/errors/IError.ts @@ -0,0 +1,4 @@ +export interface IError extends Error { + statusCode: number; + additionalInfo?: string; +} diff --git a/definition/lib/IAppUtils.ts b/definition/lib/IAppUtils.ts new file mode 100644 index 0000000..7fdc5c8 --- /dev/null +++ b/definition/lib/IAppUtils.ts @@ -0,0 +1,9 @@ +import { BlockBuilder } from "../../src/lib/BlockBuilder"; +import { ElementBuilder } from "../../src/lib/ElementBuilder"; +import { NotionSDK } from "../../src/lib/NotionSDK"; + +export interface IAppUtils { + NotionSdk: NotionSDK; + elementBuilder: ElementBuilder; + blockBuilder: BlockBuilder; +} diff --git a/definition/lib/INotion.ts b/definition/lib/INotion.ts new file mode 100644 index 0000000..7a933a3 --- /dev/null +++ b/definition/lib/INotion.ts @@ -0,0 +1,19 @@ +import { IHttp } from "@rocket.chat/apps-engine/definition/accessors"; +import { URL } from "url"; +import { ITokenInfo } from "../authorization/IOAuth2Storage"; +import { ClientError } from "../../errors/Error"; +import { NotionApi } from "../../enum/Notion"; + +export interface INotion { + baseUrl: string; + NotionVersion: string; +} + +export interface INotionSDK extends INotion { + http: IHttp; + createToken( + redirectUrl: URL, + code: string, + credentials: string + ): Promise; +} diff --git a/definition/lib/IRoomInteraction.ts b/definition/lib/IRoomInteraction.ts new file mode 100644 index 0000000..44b2e08 --- /dev/null +++ b/definition/lib/IRoomInteraction.ts @@ -0,0 +1,5 @@ +export interface IRoomInteractionStorage { + storeInteractionRoomId(userId: string, roomId: string): Promise; + getInteractionRoomId(userId: string): Promise; + clearInteractionRoomId(userId: string): Promise; +} diff --git a/definition/ui-kit/Block/IActionBlock.ts b/definition/ui-kit/Block/IActionBlock.ts new file mode 100644 index 0000000..107e3c5 --- /dev/null +++ b/definition/ui-kit/Block/IActionBlock.ts @@ -0,0 +1,3 @@ +import { ActionsBlock } from "@rocket.chat/ui-kit"; + +export type ActionBlockParam = Pick; diff --git a/definition/ui-kit/Block/IBlockBuilder.ts b/definition/ui-kit/Block/IBlockBuilder.ts new file mode 100644 index 0000000..a796150 --- /dev/null +++ b/definition/ui-kit/Block/IBlockBuilder.ts @@ -0,0 +1,20 @@ +import { + SectionBlock, + ActionsBlock, + PreviewBlockBase, + PreviewBlockWithThumb, + ContextBlock, +} from "@rocket.chat/ui-kit"; +import { SectionBlockParam } from "./ISectionBlock"; +import { ActionBlockParam } from "./IActionBlock"; +import { PreviewBlockParam } from "./IPreviewBlock"; +import { ContextBlockParam } from "./IContextBlock"; + +export interface IBlockBuilder { + createSectionBlock(param: SectionBlockParam): SectionBlock; + createActionBlock(param: ActionBlockParam): ActionsBlock; + createPreviewBlock( + param: PreviewBlockParam + ): PreviewBlockBase | PreviewBlockWithThumb; + createContextBlock(param: ContextBlockParam): ContextBlock; +} diff --git a/definition/ui-kit/Block/IContextBlock.ts b/definition/ui-kit/Block/IContextBlock.ts new file mode 100644 index 0000000..ca46b7a --- /dev/null +++ b/definition/ui-kit/Block/IContextBlock.ts @@ -0,0 +1,7 @@ +import { ImageParam } from "../Element/IImageElement"; +import { ImageElement } from "@rocket.chat/ui-kit"; + +export type ContextBlockParam = { + contextElements: Array; + blockId?: string; +}; diff --git a/definition/ui-kit/Block/IPreviewBlock.ts b/definition/ui-kit/Block/IPreviewBlock.ts new file mode 100644 index 0000000..8aaac79 --- /dev/null +++ b/definition/ui-kit/Block/IPreviewBlock.ts @@ -0,0 +1,8 @@ +import { PreviewBlockWithThumb } from "@rocket.chat/ui-kit"; + +export type PreviewBlockParam = Partial< + Pick +> & { + title: Array; + description: Array; +}; diff --git a/definition/ui-kit/Block/ISectionBlock.ts b/definition/ui-kit/Block/ISectionBlock.ts new file mode 100644 index 0000000..e1940f1 --- /dev/null +++ b/definition/ui-kit/Block/ISectionBlock.ts @@ -0,0 +1,6 @@ +import { SectionBlock } from "@rocket.chat/ui-kit"; + +export type SectionBlockParam = Pick & { + text?: string; + fields?: Array; +}; diff --git a/definition/ui-kit/Element/IButtonElement.ts b/definition/ui-kit/Element/IButtonElement.ts new file mode 100644 index 0000000..9d1c26e --- /dev/null +++ b/definition/ui-kit/Element/IButtonElement.ts @@ -0,0 +1,5 @@ +import { ButtonElement } from "@rocket.chat/ui-kit"; + +export type ButtonParam = Pick & { + text: string; +}; diff --git a/definition/ui-kit/Element/IElementBuilder.ts b/definition/ui-kit/Element/IElementBuilder.ts new file mode 100644 index 0000000..de7d947 --- /dev/null +++ b/definition/ui-kit/Element/IElementBuilder.ts @@ -0,0 +1,14 @@ +import { ButtonStyle } from "@rocket.chat/apps-engine/definition/uikit"; +import { ButtonElement, ImageElement } from "@rocket.chat/ui-kit"; +import { ButtonParam } from "./IButtonElement"; +import { ImageParam } from "./IImageElement"; + +export interface IElementBuilder { + addButton( + param: ButtonParam, + interaction: ElementInteractionParam + ): ButtonElement; + addImage(param: ImageParam): ImageElement; +} + +export type ElementInteractionParam = { blockId: string; actionId: string }; diff --git a/definition/ui-kit/Element/IImageElement.ts b/definition/ui-kit/Element/IImageElement.ts new file mode 100644 index 0000000..8f0769a --- /dev/null +++ b/definition/ui-kit/Element/IImageElement.ts @@ -0,0 +1,3 @@ +import { ImageElement } from "@rocket.chat/ui-kit"; + +export type ImageParam = Pick; diff --git a/enum/CommandParam.ts b/enum/CommandParam.ts new file mode 100644 index 0000000..7f34d19 --- /dev/null +++ b/enum/CommandParam.ts @@ -0,0 +1,4 @@ +export enum CommandParam { + CONNECT = "connect", + DISCONNECT = "disconnect", +} diff --git a/enum/Error.ts b/enum/Error.ts new file mode 100644 index 0000000..e6bdb3a --- /dev/null +++ b/enum/Error.ts @@ -0,0 +1,8 @@ +export enum ErrorName { + BAD_REQUEST = "Bad Request", + UNAUTHORIZED = "Unauthorized", + SERVER_ERROR = "Internal Server Error", + FORBIDDEN = "Forbidden", + MANY_REQUESTS = "Too Many Requests", + NOT_FOUND = "Not Found", +} diff --git a/enum/Notion.ts b/enum/Notion.ts new file mode 100644 index 0000000..e0a6d66 --- /dev/null +++ b/enum/Notion.ts @@ -0,0 +1,21 @@ +export enum NotionTokenType { + TOKEN_TYPE = "bearer", + CURRENT_WORKSPACE = "notion_current_workspace", +} + +export enum NotionOwnerType { + USER = "user", + PERSON = "person", + BOT = "bot", +} + +export enum NotionApi { + BASE_URL = "https://api.notion.com/v1", + VERSION = "2022-06-28", + USER_AGENT = "Rocket.Chat-Apps-Engine", + CONTENT_TYPE = "application/json", +} + +export enum Notion { + WEBSITE_URL = "https://www.notion.so", +} diff --git a/enum/OAuth2.ts b/enum/OAuth2.ts new file mode 100644 index 0000000..dd70e1b --- /dev/null +++ b/enum/OAuth2.ts @@ -0,0 +1,28 @@ +export enum OAuth2Locator { + authUri = "https://api.notion.com/v1/oauth/authorize?owner=user&response_type=code&", + accessTokenUrl = "https://api.notion.com/v1/oauth/token", + refreshTokenUrl = "https://api.notion.com/v1/oauth/token", + redirectUrlPath = "/api/apps/public/fb6e4e74-f99d-41b6-96da-2486e9aafea8/webhook", +} + +export enum OAuth2Content { + success = "https://github-production-user-asset-6210df.s3.amazonaws.com/65061890/243671111-9964efff-3b23-4223-aadd-5f4be441037c.svg", + failed = "https://open.rocket.chat/assets/logo.png", +} + +export enum OAuth2Credential { + TYPE = "Basic", + GRANT_TYPE = "authorization_code", + FORMAT = "base64", + CLIENT_ID = "client_id", + REDIRECT_URI = "redirect_uri", + STATE = "state", +} + +export enum OAuth2Block { + CONNECT_TO_WORKSPACE = "connect-to-workspace-block", +} + +export enum OAuth2Action { + CONNECT_TO_WORKSPACE = "connect-to-workspace-action", +} diff --git a/enum/Settings.ts b/enum/Settings.ts new file mode 100644 index 0000000..3e8d9d1 --- /dev/null +++ b/enum/Settings.ts @@ -0,0 +1,4 @@ +export enum ServerSetting { + SITE_URL = "Site_Url", + USER_ROLE_ADMIN = "admin", +} diff --git a/errors/Error.ts b/errors/Error.ts new file mode 100644 index 0000000..953979f --- /dev/null +++ b/errors/Error.ts @@ -0,0 +1,95 @@ +/* + - This file contains all the errors that can be handle by the application includes: 400, 401, 403, 404, 429, 500 + - The error name is the type of the error + - The message is the message that will be logged in the console + - The status is the status code of the error + - The additionalInfo is the additional information that will be logged in the console +*/ +import { IError } from "../definition/errors/IError"; +import { HttpStatusCode } from "@rocket.chat/apps-engine/definition/accessors"; +import { ErrorName } from "../enum/Error"; + +class Error implements IError { + name: string; + message: string; + statusCode: number; + additionalInfo?: string; + + constructor( + name: string, + message: string, + statusCode: number, + additionalInfo?: string + ) { + this.name = name; + this.message = message; + this.statusCode = statusCode; + this.additionalInfo = additionalInfo; + } +} + +export class ClientError extends Error { + constructor(message: string, additionalInfo?: string) { + super( + ErrorName.BAD_REQUEST, + message, + HttpStatusCode.BAD_REQUEST, + additionalInfo + ); + } +} + +export class NotFoundError extends Error { + constructor(message: string, additionalInfo?: string) { + super( + ErrorName.NOT_FOUND, + message, + HttpStatusCode.NOT_FOUND, + additionalInfo + ); + } +} + +export class UnAuthorizedError extends Error { + constructor(message: string, additionalInfo?: string) { + super( + ErrorName.UNAUTHORIZED, + message, + HttpStatusCode.UNAUTHORIZED, + additionalInfo + ); + } +} + +export class ForbiddenError extends Error { + constructor(message: string, additionalInfo?: string) { + super( + ErrorName.FORBIDDEN, + message, + HttpStatusCode.FORBIDDEN, + additionalInfo + ); + } +} + +export class ServerError extends Error { + constructor(message: string, additionalInfo?: string) { + super( + ErrorName.SERVER_ERROR, + message, + HttpStatusCode.INTERNAL_SERVER_ERROR, + additionalInfo + ); + } +} + +export class ManyRequestsError extends Error { + constructor(message: string, additionalInfo?: string) { + super( + ErrorName.MANY_REQUESTS, + message, + HttpStatusCode.TOO_MANY_REQUESTS, + additionalInfo + ); + } +} diff --git a/i18n/en.json b/i18n/en.json new file mode 100644 index 0000000..dca28f1 --- /dev/null +++ b/i18n/en.json @@ -0,0 +1,9 @@ +{ + "ClientIdLabel": "notion-client-id", + "ClientIdPlaceholder": "paste your clientId here", + "ClientSecretLabel": "notion-client-secret", + "ClientSecretPlaceholder": "Shhh! This is super secret", + "CredentialsSettings": "Authorization Settings", + "NotionCommandParams": "connect | disconnect | workspace | create | schema | comment", + "NotionCommandDescription": "Create Notion pages and database from Rocket.Chat" +} diff --git a/src/authorization/OAuth2Client.ts b/src/authorization/OAuth2Client.ts new file mode 100644 index 0000000..f620806 --- /dev/null +++ b/src/authorization/OAuth2Client.ts @@ -0,0 +1,122 @@ +import { NotionApp } from "../../NotionApp"; +import { + IHttp, + IModify, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { IOAuth2Client } from "../../definition/authorization/IOAuthClient"; +import { IUser } from "@rocket.chat/apps-engine/definition/users"; +import { OAuth2Storage } from "./OAuth2Storage"; +import { sendNotification } from "../helper/message"; +import { getCredentials } from "../helper/getCredential"; +import { OAuth2Credential, OAuth2Locator } from "../../enum/OAuth2"; +import { URL } from "url"; +import { getConnectBlock } from "../helper/getConnectBlock"; +import { IRoom } from "@rocket.chat/apps-engine/definition/rooms"; + +export class OAuth2Client implements IOAuth2Client { + constructor(private readonly app: NotionApp) {} + public async connect( + room: IRoom, + sender: IUser, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence + ) { + const { blockBuilder, elementBuilder } = this.app.getUtils(); + const authorizationUrl = await this.getAuthorizationUrl( + sender, + read, + modify, + room + ); + + if (!authorizationUrl) { + return; + } + + const message = `Hey **${sender.username}**!👋 Connect your Notion Workspace`; + const blocks = await getConnectBlock( + this.app, + message, + authorizationUrl + ); + + await sendNotification(read, modify, sender, room, { + blocks, + }); + } + + public async disconnect( + room: IRoom, + sender: IUser, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence + ) { + const persistenceRead = read.getPersistenceReader(); + const oAuthStorage = new OAuth2Storage(persis, persistenceRead); + const { blockBuilder, elementBuilder } = this.app.getUtils(); + + const userId = sender.id; + const tokenInfo = await oAuthStorage.getCurrentWorkspace(userId); + + if (tokenInfo) { + await oAuthStorage.disconnectUserFromCurrentWorkspace(userId); + const message = `👋 You are disconnected from the Workspace **${tokenInfo.workspace_name}**`; + await sendNotification(read, modify, sender, room, { message }); + return; + } + + const authorizationUrl = await this.getAuthorizationUrl( + sender, + read, + modify, + room + ); + + if (!authorizationUrl) { + return; + } + + const message = `👋 You are not Connected to **Workspace**!`; + const blocks = await getConnectBlock( + this.app, + message, + authorizationUrl + ); + await sendNotification(read, modify, sender, room, { + blocks, + }); + } + + public async getAuthorizationUrl( + user: IUser, + read: IRead, + modify: IModify, + room: IRoom + ): Promise { + const userId = user.id; + const credentials = await getCredentials(read, modify, user, room); + + if (!credentials) { + return null; + } + + const { clientId, siteUrl } = credentials; + + const redirectUrl = new URL(OAuth2Locator.redirectUrlPath, siteUrl); + const authorizationUrl = new URL(OAuth2Locator.authUri); + authorizationUrl.searchParams.set(OAuth2Credential.CLIENT_ID, clientId); + authorizationUrl.searchParams.set( + OAuth2Credential.REDIRECT_URI, + redirectUrl.toString() + ); + authorizationUrl.searchParams.set(OAuth2Credential.STATE, userId); + + return authorizationUrl.toString(); + } +} diff --git a/src/authorization/OAuth2Storage.ts b/src/authorization/OAuth2Storage.ts new file mode 100644 index 0000000..8db7579 --- /dev/null +++ b/src/authorization/OAuth2Storage.ts @@ -0,0 +1,76 @@ +import { + IPersistence, + IPersistenceRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { + IOAuth2Storage, + ITokenInfo, +} from "../../definition/authorization/IOAuth2Storage"; +import { + RocketChatAssociationModel, + RocketChatAssociationRecord, +} from "@rocket.chat/apps-engine/definition/metadata"; +import { NotionTokenType } from "../../enum/Notion"; + +export class OAuth2Storage implements IOAuth2Storage { + constructor( + private readonly persistence: IPersistence, + private readonly persistenceRead: IPersistenceRead + ) {} + public async connectUserToWorkspace( + tokenInfo: ITokenInfo, + userId: string + ): Promise { + await this.persistence.updateByAssociations( + [ + new RocketChatAssociationRecord( // user association + RocketChatAssociationModel.USER, + userId + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + NotionTokenType.CURRENT_WORKSPACE + ), + ], + tokenInfo, + true + ); + + return; + } + + public async getCurrentWorkspace( + userId: string + ): Promise { + const [tokenInfo] = (await this.persistenceRead.readByAssociations([ + new RocketChatAssociationRecord( // user association + RocketChatAssociationModel.USER, + userId + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + NotionTokenType.CURRENT_WORKSPACE + ), + ])) as ITokenInfo[]; + + return tokenInfo; + } + + public async disconnectUserFromCurrentWorkspace( + userId: string + ): Promise { + const [removedTokenInfo] = (await this.persistence.removeByAssociations( + [ + new RocketChatAssociationRecord( // user association + RocketChatAssociationModel.USER, + userId + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + NotionTokenType.CURRENT_WORKSPACE + ), + ] + )) as ITokenInfo[]; + return removedTokenInfo; + } +} diff --git a/src/commands/CommandUtility.ts b/src/commands/CommandUtility.ts new file mode 100644 index 0000000..72815f0 --- /dev/null +++ b/src/commands/CommandUtility.ts @@ -0,0 +1,84 @@ +import { IUser } from "@rocket.chat/apps-engine/definition/users"; +import { NotionApp } from "../../NotionApp"; +import { IRoom } from "@rocket.chat/apps-engine/definition/rooms"; +import { + IHttp, + IModify, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { + ICommandUtility, + ICommandUtilityParams, +} from "../../definition/command/ICommandUtility"; +import { CommandParam } from "../../enum/CommandParam"; + +export class CommandUtility implements ICommandUtility { + public app: NotionApp; + public params: Array; + public sender: IUser; + public room: IRoom; + public read: IRead; + public modify: IModify; + public http: IHttp; + public persis: IPersistence; + public triggerId?: string; + public threadId?: string; + + constructor(props: ICommandUtilityParams) { + this.app = props.app; + this.params = props.params; + this.sender = props.sender; + this.room = props.room; + this.read = props.read; + this.modify = props.modify; + this.http = props.http; + this.persis = props.persis; + this.triggerId = props.triggerId; + this.threadId = props.threadId; + } + + public async resolveCommand(): Promise { + switch (this.params.length) { + case 0: { + break; + } + case 1: { + await this.handleSingleParam(); + break; + } + default: { + } + } + } + + private async handleSingleParam(): Promise { + const oAuth2ClientInstance = await this.app.getOAuth2Client(); + switch (this.params[0].toLowerCase()) { + case CommandParam.CONNECT: { + await oAuth2ClientInstance.connect( + this.room, + this.sender, + this.read, + this.modify, + this.http, + this.persis + ); + break; + } + case CommandParam.DISCONNECT: { + await oAuth2ClientInstance.disconnect( + this.room, + this.sender, + this.read, + this.modify, + this.http, + this.persis + ); + break; + } + default: { + } + } + } +} diff --git a/src/commands/NotionCommand.ts b/src/commands/NotionCommand.ts new file mode 100644 index 0000000..62886a5 --- /dev/null +++ b/src/commands/NotionCommand.ts @@ -0,0 +1,52 @@ +import { + ISlashCommand, + SlashCommandContext, +} from "@rocket.chat/apps-engine/definition/slashcommands"; +import { NotionApp } from "../../NotionApp"; +import { + IHttp, + IModify, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { ICommandUtilityParams } from "../../definition/command/ICommandUtility"; +import { CommandUtility } from "./CommandUtility"; + +export class NotionCommand implements ISlashCommand { + constructor(private readonly app: NotionApp) {} + + public command: string = "notion"; + public i18nParamsExample: string = "NotionCommandParams"; + public i18nDescription: string = "NotionCommandDescription"; + public providesPreview: boolean = false; + + public async executor( + context: SlashCommandContext, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence + ): Promise { + const params = context.getArguments(); + const sender = context.getSender(); + const room = context.getRoom(); + const triggerId = context.getTriggerId(); + const threadId = context.getThreadId(); + + const commandUtilityParams: ICommandUtilityParams = { + params, + sender, + room, + triggerId, + threadId, + read, + modify, + http, + persis, + app: this.app, + }; + + const commandUtility = new CommandUtility(commandUtilityParams); + await commandUtility.resolveCommand(); + } +} diff --git a/src/endpoints/webhook.ts b/src/endpoints/webhook.ts new file mode 100644 index 0000000..ab7e1fa --- /dev/null +++ b/src/endpoints/webhook.ts @@ -0,0 +1,130 @@ +import { + HttpStatusCode, + IHttp, + IModify, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { + ApiEndpoint, + IApiEndpointInfo, + IApiRequest, + IApiResponse, +} from "@rocket.chat/apps-engine/definition/api"; +import { IRoom } from "@rocket.chat/apps-engine/definition/rooms"; +import { URL } from "url"; +import { + OAuth2Content, + OAuth2Credential, + OAuth2Locator, +} from "../../enum/OAuth2"; +import { ClientError } from "../../errors/Error"; +import { OAuth2Storage } from "../authorization/OAuth2Storage"; +import { getCredentials } from "../helper/getCredential"; +import { sendNotification } from "../helper/message"; +import { BlockBuilder } from "../lib/BlockBuilder"; +import { NotionSDK } from "../lib/NotionSDK"; +import { RoomInteractionStorage } from "../storage/RoomInteraction"; +import { getConnectPreview } from "../helper/getConnectLayout"; +import { getAuthPageTemplate } from "../helper/getAuthPageTemplate"; + +export class WebHookEndpoint extends ApiEndpoint { + public path: string = "webhook"; + public url_path: string = OAuth2Locator.redirectUrlPath; + public accessTokenUrl: string = OAuth2Locator.accessTokenUrl; + public async get( + request: IApiRequest, + endpoint: IApiEndpointInfo, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence + ): Promise { + const { code, state, error } = request.query; + + const failedTemplate = getAuthPageTemplate( + "Something Went Wrong", + OAuth2Content.failed, + "🚫 Something went wrong while Connecting to Workspace", + "PLEASE TRY AGAIN IN CASE IT STILL DOES NOT WORK, CONTACT ADMINISTRATOR" + ); + + // incase when user leaves in between the auth process + if (error) { + this.app.getLogger().warn(error); + return { + status: HttpStatusCode.UNAUTHORIZED, + content: failedTemplate, + }; + } + + const user = await read.getUserReader().getById(state); + // incase when user changed the state in authUrl + if (!user) { + this.app + .getLogger() + .warn(`User not found before access token request`); + return { + status: HttpStatusCode.NON_AUTHORITATIVE_INFORMATION, + content: failedTemplate, + }; + } + + const persistenceRead = read.getPersistenceReader(); + const roomInteraction = new RoomInteractionStorage( + persis, + persistenceRead + ); + const roomId = await roomInteraction.getInteractionRoomId(user.id); + const room = (await read.getRoomReader().getById(roomId)) as IRoom; + + const appCredentials = await getCredentials(read, modify, user, room); + // incase there is no credentials in between auth interaction + if (!appCredentials) { + return { + status: HttpStatusCode.UNAUTHORIZED, + content: failedTemplate, + }; + } + + const { clientId, clientSecret, siteUrl } = appCredentials; + const redirectUrl = new URL(this.url_path, siteUrl); + const credentials = new Buffer(`${clientId}:${clientSecret}`).toString( + OAuth2Credential.FORMAT + ); + + const notionSDK = new NotionSDK(http); + const response = await notionSDK.createToken( + redirectUrl, + code, + credentials + ); + + // incase there is some error in creation of Token from Notion + if (response instanceof ClientError) { + this.app.getLogger().warn(response.message); + return { + status: response.statusCode, + content: failedTemplate, + }; + } + + const successTemplate = getAuthPageTemplate( + "Connected to Workspace", + OAuth2Content.success, + `👋 Connected to ${response.workspace_name}❗`, + "YOU CAN NOW CLOSE THIS WINDOW" + ); + + const oAuth2Storage = new OAuth2Storage(persis, persistenceRead); + await oAuth2Storage.connectUserToWorkspace(response, state); + + const connectPreview = getConnectPreview(this.app.getID(), response); + await sendNotification(read, modify, user, room, { + blocks: [connectPreview], + }); + await roomInteraction.clearInteractionRoomId(user.id); + + return this.success(successTemplate); + } +} diff --git a/src/helper/getAuthPageTemplate.ts b/src/helper/getAuthPageTemplate.ts new file mode 100644 index 0000000..b7d5550 --- /dev/null +++ b/src/helper/getAuthPageTemplate.ts @@ -0,0 +1,61 @@ +export function getAuthPageTemplate( + title: string, + imgUrl: string, + message: string, + info: string +) { + const template = ` + + + ${title} + + + +
+ Image +

+ ${message} +

+

+ ${info} +

+
+ + + `; + + return template; +} diff --git a/src/helper/getConnectBlock.ts b/src/helper/getConnectBlock.ts new file mode 100644 index 0000000..4bc3433 --- /dev/null +++ b/src/helper/getConnectBlock.ts @@ -0,0 +1,31 @@ +import { ButtonStyle } from "@rocket.chat/apps-engine/definition/uikit"; +import { Block } from "@rocket.chat/ui-kit"; +import { NotionApp } from "../../NotionApp"; +import { OAuth2Action, OAuth2Block } from "../../enum/OAuth2"; + +export async function getConnectBlock( + app: NotionApp, + message: string, + url: string +): Promise> { + const { elementBuilder, blockBuilder } = app.getUtils(); + const buttonElement = elementBuilder.addButton( + { + text: "Connect to Workspace", + style: ButtonStyle.PRIMARY, + url, + }, + { + blockId: OAuth2Block.CONNECT_TO_WORKSPACE, + actionId: OAuth2Action.CONNECT_TO_WORKSPACE, + } + ); + const actionBlock = blockBuilder.createActionBlock({ + elements: [buttonElement], + }); + const textBlock = blockBuilder.createSectionBlock({ + text: message, + }); + + return [textBlock, actionBlock]; +} diff --git a/src/helper/getConnectLayout.ts b/src/helper/getConnectLayout.ts new file mode 100644 index 0000000..85b1e64 --- /dev/null +++ b/src/helper/getConnectLayout.ts @@ -0,0 +1,45 @@ +import { ITokenInfo } from "../../definition/authorization/IOAuth2Storage"; +import { PreviewBlockWithPreview, PreviewBlock } from "@rocket.chat/ui-kit"; +import { ElementBuilder } from "../lib/ElementBuilder"; +import { BlockBuilder } from "../lib/BlockBuilder"; +import { Notion } from "../../enum/Notion"; + +export function getConnectPreview( + appId: string, + tokenInfo: ITokenInfo +): Exclude { + const { workspace_name, workspace_icon, owner } = tokenInfo; + const { name, avatar_url } = owner.user; + + const elementBuilder = new ElementBuilder(appId); + const blockBuilder = new BlockBuilder(appId); + + const workspace_icon_url = workspace_icon?.startsWith("/") + ? `${Notion.WEBSITE_URL}${workspace_icon}` + : workspace_icon?.startsWith("http") + ? `${workspace_icon}` + : undefined; + const thumb = workspace_icon_url ? { url: workspace_icon_url } : undefined; + const title = [ + `**📚 [**${workspace_name}**](${Notion.WEBSITE_URL})**`, + "**👋 Connected to Workspace**", + ]; + const description = [""]; + const avatarElement = elementBuilder.addImage({ + imageUrl: avatar_url as string, + altText: name as string, + }); + const avatarName = `**${name}**`; + const footer = blockBuilder.createContextBlock({ + contextElements: [avatarElement, avatarName], + }); + + const connectPreview = blockBuilder.createPreviewBlock({ + title, + description, + footer, + thumb, + }); + + return connectPreview; +} diff --git a/src/helper/getCredential.ts b/src/helper/getCredential.ts new file mode 100644 index 0000000..7bd5c16 --- /dev/null +++ b/src/helper/getCredential.ts @@ -0,0 +1,57 @@ +import { IModify, IRead } from "@rocket.chat/apps-engine/definition/accessors"; +import { ICredential } from "../../definition/authorization/ICredential"; +import { OAuth2Setting } from "../../config/settings"; +import { ServerSetting } from "../../enum/Settings"; +import { sendNotification } from "./message"; +import { IUser } from "@rocket.chat/apps-engine/definition/users"; +import { IRoom } from "@rocket.chat/apps-engine/definition/rooms"; + +export async function getCredentials( + read: IRead, + modify: IModify, + user: IUser, + room: IRoom +): Promise { + const clientId = (await read + .getEnvironmentReader() + .getSettings() + .getValueById(OAuth2Setting.CLIENT_ID)) as string; + const clientSecret = (await read + .getEnvironmentReader() + .getSettings() + .getValueById(OAuth2Setting.CLIENT_SECRET)) as string; + let siteUrl = (await read + .getEnvironmentReader() + .getServerSettings() + .getValueById(ServerSetting.SITE_URL)) as string; + + if ( + !( + clientId.trim().length && + clientSecret.trim().length && + siteUrl.trim().length + ) + ) { + // based on user role send relevant notification + let message: string; + if (user.roles.includes(ServerSetting.USER_ROLE_ADMIN)) { + message = `Please Configure the App and Ensure the \`SiteUrl\` is correct in the Server Settings. + \xa0\xa0• Go to **NotionApp** Settings and add \`ClientId\` and \`ClientSecret\` Generated from a Notion Public Integration + `; + } else { + message = `🚫 Something Went Wrong, Please Contact the Admin!`; + } + + await sendNotification(read, modify, user, room, { + message, + }); + + return null; + } + + if (siteUrl.endsWith("/")) { + siteUrl = siteUrl.substring(0, siteUrl.length - 1); + } + + return { clientId, clientSecret, siteUrl }; +} diff --git a/src/helper/message.ts b/src/helper/message.ts new file mode 100644 index 0000000..634b1cd --- /dev/null +++ b/src/helper/message.ts @@ -0,0 +1,28 @@ +import { IModify, IRead } from "@rocket.chat/apps-engine/definition/accessors"; +import { IRoom } from "@rocket.chat/apps-engine/definition/rooms"; +import { IUser } from "@rocket.chat/apps-engine/definition/users"; +import { Block } from "@rocket.chat/ui-kit"; + +export async function sendNotification( + read: IRead, + modify: IModify, + user: IUser, + room: IRoom, + content: { message?: string; blocks?: Array } +): Promise { + const appUser = (await read.getUserReader().getAppUser()) as IUser; + const { message, blocks } = content; + const messageBuilder = modify + .getCreator() + .startMessage() + .setSender(appUser) + .setRoom(room) + .setGroupable(false); + + if (message) { + messageBuilder.setText(message); + } else if (blocks) { + messageBuilder.setBlocks(blocks); + } + return read.getNotifier().notifyUser(user, messageBuilder.getMessage()); +} diff --git a/src/lib/BlockBuilder.ts b/src/lib/BlockBuilder.ts new file mode 100644 index 0000000..b399f1c --- /dev/null +++ b/src/lib/BlockBuilder.ts @@ -0,0 +1,89 @@ +import { ActionBlockParam } from "../../definition/ui-kit/Block/IActionBlock"; +import { IBlockBuilder } from "../../definition/ui-kit/Block/IBlockBuilder"; +import { ContextBlockParam } from "../../definition/ui-kit/Block/IContextBlock"; +import { PreviewBlockParam } from "../../definition/ui-kit/Block/IPreviewBlock"; +import { SectionBlockParam } from "../../definition/ui-kit/Block/ISectionBlock"; +import { + SectionBlock, + LayoutBlockType, + TextObjectType, + TextObject, + ActionsBlock, + PreviewBlockBase, + PreviewBlockWithThumb, + ContextBlock, +} from "@rocket.chat/ui-kit"; + +export class BlockBuilder implements IBlockBuilder { + constructor(private readonly appId: string) {} + public createSectionBlock(param: SectionBlockParam): SectionBlock { + const { text, blockId, fields, accessory } = param; + const sectionBlock: SectionBlock = { + appId: this.appId, + blockId, + type: LayoutBlockType.SECTION, + text: { + type: TextObjectType.MRKDWN, + text: text ? text : "", + }, + accessory, + fields: fields ? this.createTextObjects(fields) : undefined, + }; + return sectionBlock; + } + public createActionBlock(param: ActionBlockParam): ActionsBlock { + const { elements } = param; + const actionBlock: ActionsBlock = { + type: LayoutBlockType.ACTIONS, + elements: elements, + }; + return actionBlock; + } + + private createTextObjects(fields: Array): Array { + return fields.map((field) => { + return { + type: TextObjectType.MRKDWN, + text: field, + }; + }); + } + + public createPreviewBlock( + param: PreviewBlockParam + ): PreviewBlockBase | PreviewBlockWithThumb { + const { title, description, footer, thumb } = param; + const previewBlock: PreviewBlockBase | PreviewBlockWithThumb = { + type: LayoutBlockType.PREVIEW, + title: this.createTextObjects(title), + description: this.createTextObjects(description), + footer, + thumb, + }; + + return previewBlock; + } + + public createContextBlock(param: ContextBlockParam): ContextBlock { + const { contextElements, blockId } = param; + + const elements = contextElements.map((element) => { + if (typeof element === "string") { + return { + type: TextObjectType.MRKDWN, + text: element, + } as TextObject; + } else { + return element; + } + }); + + const contextBlock: ContextBlock = { + type: LayoutBlockType.CONTEXT, + elements, + blockId, + }; + + return contextBlock; + } +} diff --git a/src/lib/ElementBuilder.ts b/src/lib/ElementBuilder.ts new file mode 100644 index 0000000..4088967 --- /dev/null +++ b/src/lib/ElementBuilder.ts @@ -0,0 +1,48 @@ +import { + ElementInteractionParam, + IElementBuilder, +} from "../../definition/ui-kit/Element/IElementBuilder"; +import { + ButtonElement, + BlockElementType, + TextObjectType, + ImageElement, +} from "@rocket.chat/ui-kit"; +import { ButtonParam } from "../../definition/ui-kit/Element/IButtonElement"; +import { ImageParam } from "../../definition/ui-kit/Element/IImageElement"; + +export class ElementBuilder implements IElementBuilder { + constructor(private readonly appId: string) {} + public addButton( + param: ButtonParam, + interaction: ElementInteractionParam + ): ButtonElement { + const { text, url, value, style } = param; + const { blockId, actionId } = interaction; + const button: ButtonElement = { + type: BlockElementType.BUTTON, + text: { + type: TextObjectType.PLAIN_TEXT, + text, + }, + appId: this.appId, + blockId, + actionId, + url, + value, + style, + }; + return button; + } + + public addImage(param: ImageParam): ImageElement { + const { imageUrl, altText } = param; + const image: ImageElement = { + type: BlockElementType.IMAGE, + imageUrl, + altText, + }; + return image; + } + +} diff --git a/src/lib/NotionSDK.ts b/src/lib/NotionSDK.ts new file mode 100644 index 0000000..1012fda --- /dev/null +++ b/src/lib/NotionSDK.ts @@ -0,0 +1,50 @@ +import { INotionSDK } from "../../definition/lib/INotion"; +import { IHttp } from "@rocket.chat/apps-engine/definition/accessors"; +import { URL } from "url"; +import { ITokenInfo } from "../../definition/authorization/IOAuth2Storage"; +import { ClientError } from "../../errors/Error"; +import { NotionApi } from "../../enum/Notion"; +import { OAuth2Credential, OAuth2Locator } from "../../enum/OAuth2"; +import { AppsEngineException } from "@rocket.chat/apps-engine/definition/exceptions"; + +export class NotionSDK implements INotionSDK { + baseUrl: string; + NotionVersion: string; + http: IHttp; + constructor(http: IHttp) { + this.baseUrl = NotionApi.BASE_URL; + this.NotionVersion = NotionApi.VERSION; + this.http = http; + } + + public async createToken( + redirectUrl: URL, + code: string, + credentials: string + ): Promise { + try { + const response = await this.http.post(OAuth2Locator.accessTokenUrl, { + data: { + grant_type: OAuth2Credential.GRANT_TYPE, + redirect_uri: redirectUrl.toString(), + code, + }, + headers: { + Authorization: `Basic ${credentials}`, + "Content-Type": NotionApi.CONTENT_TYPE, + "User-Agent": NotionApi.USER_AGENT, + }, + }); + + if (!response.statusCode.toString().startsWith("2")) { + return new ClientError( + `Error while Creating token: ${response.statusCode}`, + response.content + ); + } + return response.data as ITokenInfo; + } catch (err) { + throw new AppsEngineException(err as string); + } + } +} diff --git a/src/storage/RoomInteraction.ts b/src/storage/RoomInteraction.ts new file mode 100644 index 0000000..58d241e --- /dev/null +++ b/src/storage/RoomInteraction.ts @@ -0,0 +1,49 @@ +import { + RocketChatAssociationModel, + RocketChatAssociationRecord, +} from "@rocket.chat/apps-engine/definition/metadata"; +import { IRoomInteractionStorage } from "../../definition/lib/IRoomInteraction"; +import { + IPersistence, + IPersistenceRead, +} from "@rocket.chat/apps-engine/definition/accessors"; + +export class RoomInteractionStorage implements IRoomInteractionStorage { + constructor( + private readonly persistence: IPersistence, + private readonly persistenceRead: IPersistenceRead + ) {} + public async storeInteractionRoomId( + userId: string, + roomId: string + ): Promise { + const association = new RocketChatAssociationRecord( + RocketChatAssociationModel.USER, + `${userId}#RoomId` + ); + await this.persistence.updateByAssociation( + association, + { roomId: roomId }, + true + ); + } + + public async getInteractionRoomId(userId: string): Promise { + const association = new RocketChatAssociationRecord( + RocketChatAssociationModel.USER, + `${userId}#RoomId` + ); + const [result] = (await this.persistenceRead.readByAssociation( + association + )) as Array<{ roomId: string }>; + return result.roomId; + } + + public async clearInteractionRoomId(userId: string): Promise { + const association = new RocketChatAssociationRecord( + RocketChatAssociationModel.USER, + `${userId}#RoomId` + ); + await this.persistence.removeByAssociation(association); + } +}