diff --git a/github/GithubApp.ts b/github/GithubApp.ts index d949115..fc2c87a 100644 --- a/github/GithubApp.ts +++ b/github/GithubApp.ts @@ -10,22 +10,144 @@ import { import { App } from "@rocket.chat/apps-engine/definition/App"; import { IAppInfo } from "@rocket.chat/apps-engine/definition/metadata"; import { GithubCommand } from "./commands/GithubCommand"; -import { IUIKitResponse, UIKitBlockInteractionContext, UIKitViewCloseInteractionContext } from '@rocket.chat/apps-engine/definition/uikit'; +import { + IUIKitResponse, + UIKitBlockInteractionContext, + UIKitViewCloseInteractionContext, + UIKitViewSubmitInteractionContext, +} from "@rocket.chat/apps-engine/definition/uikit"; import { ExecuteViewClosedHandler } from "./handlers/ExecuteViewClosedHandler"; import { ExecuteBlockActionHandler } from "./handlers/ExecuteBlockActionHandler"; +import { ExecuteViewSubmitHandler } from "./handlers/ExecuteViewSubmitHandler"; +import { IUser } from "@rocket.chat/apps-engine/definition/users"; +import { + IAuthData, + IOAuth2Client, + IOAuth2ClientOptions, +} from "@rocket.chat/apps-engine/definition/oauth2/IOAuth2"; +import { createOAuth2Client } from "@rocket.chat/apps-engine/definition/oauth2/OAuth2"; +import { createSectionBlock } from "./lib/blocks"; +import { sendDirectMessage, sendNotification } from "./lib/message"; +import { OAuth2Client } from "@rocket.chat/apps-engine/server/oauth2/OAuth2Client"; +import { deleteOathToken } from "./processors/deleteOAthToken"; +import { ProcessorsEnum } from "./enum/Processors"; +import { + ApiSecurity, + ApiVisibility, +} from "@rocket.chat/apps-engine/definition/api"; +import { githubWebHooks } from "./endpoints/githubEndpoints"; +import { IJobContext } from "@rocket.chat/apps-engine/definition/scheduler"; +import { IRoom } from "@rocket.chat/apps-engine/definition/rooms"; +import { clearInteractionRoomData, getInteractionRoomData } from "./persistance/roomInteraction"; export class GithubApp extends App { constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) { super(info, logger, accessors); } - public async executeBlockActionHandler(context: UIKitBlockInteractionContext, read: IRead, http: IHttp, persistence: IPersistence, modify: IModify): Promise { - const handler = new ExecuteBlockActionHandler(this, read, http, modify, persistence); + public async authorizationCallback( + token: IAuthData, + user: IUser, + read: IRead, + modify: IModify, + http: IHttp, + persistence: IPersistence + ) { + const deleteTokenTask = { + id: ProcessorsEnum.REMOVE_GITHUB_LOGIN, + when: "7 days", + data: { + user: user, + config: this.oauth2Config, + }, + }; + let text = `GitHub Authentication Succesfull 🚀`; + let interactionData = await getInteractionRoomData(read.getPersistenceReader(),user.id) ; + + if (token) { + // await registerAuthorizedUser(read, persistence, user); + await modify.getScheduler().scheduleOnce(deleteTokenTask); + } else { + text = `Authentication Failure 😔`; + } + if(interactionData && interactionData.roomId){ + let roomId = interactionData.roomId as string; + let room = await read.getRoomReader().getById(roomId) as IRoom; + await clearInteractionRoomData(persistence,user.id); + await sendNotification(read,modify,user,room,text); + }else{ + await sendDirectMessage(read, modify, user, text, persistence); + } + + } + public oauth2ClientInstance: IOAuth2Client; + public oauth2Config: IOAuth2ClientOptions = { + alias: "github-app", + accessTokenUri: "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/login/oauth/access_token", + authUri: "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/login/oauth/authorize", + refreshTokenUri: "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/login/oauth/access_token", + revokeTokenUri: `https://api.github.com/applications/client_id/token`, + authorizationCallback: this.authorizationCallback.bind(this), + defaultScopes: ["users", "repo"], + }; + public getOauth2ClientInstance(): IOAuth2Client { + if (!this.oauth2ClientInstance) { + this.oauth2ClientInstance = createOAuth2Client( + this, + this.oauth2Config + ); + } + return this.oauth2ClientInstance; + } + + public async executeBlockActionHandler( + context: UIKitBlockInteractionContext, + read: IRead, + http: IHttp, + persistence: IPersistence, + modify: IModify + ): Promise { + const handler = new ExecuteBlockActionHandler( + this, + read, + http, + modify, + persistence + ); + return await handler.run(context); + } + + public async executeViewClosedHandler( + context: UIKitViewCloseInteractionContext, + read: IRead, + http: IHttp, + persistence: IPersistence, + modify: IModify + ) { + const handler = new ExecuteViewClosedHandler( + this, + read, + http, + modify, + persistence + ); return await handler.run(context); } - public async executeViewClosedHandler(context: UIKitViewCloseInteractionContext, read: IRead, http: IHttp, persistence: IPersistence, modify: IModify) { - const handler = new ExecuteViewClosedHandler(this, read, http, modify, persistence); + public async executeViewSubmitHandler( + context: UIKitViewSubmitInteractionContext, + read: IRead, + http: IHttp, + persistence: IPersistence, + modify: IModify + ) { + const handler = new ExecuteViewSubmitHandler( + this, + read, + http, + modify, + persistence + ); return await handler.run(context); } @@ -33,6 +155,41 @@ export class GithubApp extends App { configuration: IConfigurationExtend ): Promise { const gitHubCommand: GithubCommand = new GithubCommand(this); - await configuration.slashCommands.provideSlashCommand(gitHubCommand); + await Promise.all([ + configuration.slashCommands.provideSlashCommand(gitHubCommand), + this.getOauth2ClientInstance().setup(configuration), + ]); + configuration.scheduler.registerProcessors([ + { + id: ProcessorsEnum.REMOVE_GITHUB_LOGIN, + processor: async (jobContext, read, modify, http, persis) => { + let user = jobContext.user as IUser; + let config = jobContext.config as IOAuth2ClientOptions; + try { + await deleteOathToken({ + user, + config, + read, + modify, + http, + persis, + }); + } catch (e) { + await sendDirectMessage( + read, + modify, + user, + e.message, + persis + ); + } + }, + }, + ]); + configuration.api.provideApi({ + visibility: ApiVisibility.PUBLIC, + security: ApiSecurity.UNSECURE, + endpoints: [new githubWebHooks(this)], + }); } } diff --git a/github/commands/GithubCommand.ts b/github/commands/GithubCommand.ts index b38cd90..cf3c242 100644 --- a/github/commands/GithubCommand.ts +++ b/github/commands/GithubCommand.ts @@ -8,12 +8,24 @@ import { IPersistence, IRead, } from "@rocket.chat/apps-engine/definition/accessors"; - +import { ProcessorsEnum } from "../enum/Processors"; import { GithubApp } from "../GithubApp"; import { initiatorMessage } from "../lib/initiatorMessage"; import { helperMessage } from "../lib/helperMessage"; import { basicQueryMessage } from "../helpers/basicQueryMessage"; import { pullDetailsModal } from "../modals/pullDetailsModal"; +import { authorize } from "../oath2/authentication"; +import { SubcommandEnum } from "../enum/Subcommands"; +import { getAccessTokenForUser,revokeUserAccessToken } from "../persistance/auth"; +import { IUser } from "@rocket.chat/apps-engine/definition/users"; +import { removeToken } from "../persistance/auth"; +import { getWebhookUrl } from "../helpers/getWebhookURL"; +import { githubWebHooks } from "../endpoints/githubEndpoints"; +import { sendDirectMessage, sendNotification } from "../lib/message"; +import { createSubscription, deleteSubscription, updateSubscription } from "../helpers/githubSDK"; +import { Subscription } from "../persistance/subscriptions"; +import { ISubscription } from "../definitions/subscription"; +import { subsciptionsModal } from "../modals/subscriptionsModal"; export class GithubCommand implements ISlashCommand { @@ -50,24 +62,177 @@ export class GithubCommand implements ISlashCommand { sender: sender, arguments: command, }; - await initiatorMessage({ data, read, persistence, modify, http }); + if(command[0].includes('/')){ + await initiatorMessage({ data, read, persistence, modify, http }); + }else{ + switch(command[0]){ + case SubcommandEnum.LOGIN : { + await authorize(this.app, read, modify, context.getSender(),room, persistence); + break; + } + case SubcommandEnum.LOGOUT : { + let accessToken = await getAccessTokenForUser(read,context.getSender(),this.app.oauth2Config); + if(accessToken && accessToken?.token){ + await revokeUserAccessToken(read, sender, persistence, http, this.app.oauth2Config); + await sendNotification(read,modify,context.getSender(),room,"Logged out successfully !"); + }else{ + await sendNotification(read,modify,context.getSender(),room,"You are not logged in !"); + } + break; + } + case SubcommandEnum.TEST : { + //test command + break; + } + case SubcommandEnum.SUBSCRIBE :{ + //modal + let accessToken = await getAccessTokenForUser(read,context.getSender(),this.app.oauth2Config); + if(accessToken && accessToken.token){ + const triggerId= context.getTriggerId(); + if(triggerId){ + const modal = await subsciptionsModal({modify,read,persistence,http,slashcommandcontext:context}); + await modify.getUiController().openModalView(modal,{triggerId},context.getSender()); + }else{ + console.log("Inavlid Trigger ID !"); + } + }else{ + await sendNotification(read,modify,context.getSender(),room,"Login to subscribe to repository events ! `/github login`"); + } + break; + } + default:{ + await helperMessage({room,read, persistence, modify, http}); + break; + } + } + } break; } case 2 : { + const repository = command[0]; const query = command[1]; - await basicQueryMessage ({query,repository,room,read,persistence,modify,http}); - break; + + switch(query){ + case SubcommandEnum.SUBSCRIBE : { + //sub + let accessToken = await getAccessTokenForUser(read,context.getSender(),this.app.oauth2Config); + if(accessToken && accessToken?.token){ + try { + let events: Array =["pull_request","push","issues","deployment_status","star"]; + //if hook exists we set its take its hook id and add our aditional events to it + let eventSusbcriptions = new Map;//this helps us mark the new events to be added + for(let event of events){ + eventSusbcriptions.set(event,false); + } + let url = await getWebhookUrl(this.app); + let subsciptionStorage = new Subscription(persistence,read.getPersistenceReader()); + let user = await context.getSender(); + let repositorySubscriptions = await subsciptionStorage.getSubscriptionsByRepo(repository,user.id); + let hookId = ""; + for(let susbcription of repositorySubscriptions){ + if(hookId==""){ + hookId=susbcription.webhookId; + } + eventSusbcriptions.set(susbcription.event,true); + } + let newEvents:Array=[]; + for(let [event,value] of eventSusbcriptions){ + if(!value){ + newEvents.push(event); + } + } + let createdEntry = false ; + if(hookId==""){ + let response = await createSubscription(http,repository,url,accessToken.token,events); + hookId=response.id; + }else{ + if(newEvents.length){ + let response = await updateSubscription(http,repository,accessToken.token,hookId,events); + hookId=response.id; + } + } + for(let event of events){ + createdEntry = await subsciptionStorage.createSubscription(repository,event,hookId,room,context.getSender()); + } + if(!createdEntry){ + throw new Error("Error creating new susbcription entry"); + } + + await sendNotification(read,modify,context.getSender(),room,`Subscibed to ${repository} ✔️`); + + } catch (error) { + console.log("SubcommandError",error); + } + } else{ + await sendNotification(read,modify,context.getSender(),room,"Login to subscribe to repository events ! `/github login`"); + } + break; + } + case SubcommandEnum.UNSUBSCRIBE : { + //unsub + let accessToken = await getAccessTokenForUser(read,context.getSender(),this.app.oauth2Config); + if(accessToken && accessToken?.token){ + try { + let user = await context.getSender(); + let events: Array =["pull_request","push","issues","deployment_status","star"]; + let subsciptionStorage = new Subscription(persistence,read.getPersistenceReader()) + let oldSubscriptions = await subsciptionStorage.getSubscriptionsByRepo(repository,user.id); + await subsciptionStorage.deleteSubscriptionsByRepoUser(repository, room.id, user.id); + let hookId = ""; + //check if any subscription events of the repo is left in any other room + let eventSubscriptions = new Map; + for (let subsciption of oldSubscriptions) { + eventSubscriptions.set(subsciption.event, false); + if(hookId == ""){ + hookId = subsciption.webhookId; + } + } + let updatedsubscriptions = await subsciptionStorage.getSubscriptionsByRepo(repository, user.id); + if (updatedsubscriptions.length == 0) { + await deleteSubscription(http, repository, accessToken.token, hookId); + } else { + for (let subsciption of updatedsubscriptions) { + eventSubscriptions.set(subsciption.event, true); + } + let updatedEvents: Array = []; + let sameEvents = true; + for (let [event, present] of eventSubscriptions) { + sameEvents = sameEvents && present; + if (present) { + updatedEvents.push(event); + } + } + if (updatedEvents.length && !sameEvents) { + let response = await updateSubscription(http, repository, accessToken.token, hookId, updatedEvents); + } + } + + await sendNotification(read, modify, user, room, `Unsubscribed to ${repository} 🔕`); + + } catch (error) { + console.log("SubcommandError",error); + } + } else{ + await sendNotification(read,modify,context.getSender(),room,"Login to subscribe to repository events ! `/github login`"); + } + break; + } + default:{ + await basicQueryMessage ({query,repository,room,read,persistence,modify,http}); + break; + } + } + break; } - case 3 :{ - const data = { + case 3 : { + const data = { repository:command[0], query:command[1], number:command[2] } const triggerId= context.getTriggerId(); if(triggerId){ - console.log(triggerId); const modal = await pullDetailsModal({data,modify,read,persistence,http,slashcommandcontext:context}); await modify.getUiController().openModalView(modal,{triggerId},context.getSender()); }else{ @@ -82,5 +247,4 @@ export class GithubCommand implements ISlashCommand { } - } -} \ No newline at end of file + }} diff --git a/github/definitions/repositorySubscriptions.ts b/github/definitions/repositorySubscriptions.ts new file mode 100644 index 0000000..7f964fa --- /dev/null +++ b/github/definitions/repositorySubscriptions.ts @@ -0,0 +1,9 @@ +import { IUser } from "@rocket.chat/apps-engine/definition/users"; + +//subsciptions which will be saved in the apps local storage +export interface IRepositorySubscriptions{ + webhookId : string, + repoName : string, + user : IUser , + events : Array +} \ No newline at end of file diff --git a/github/definitions/subscription.ts b/github/definitions/subscription.ts new file mode 100644 index 0000000..7daa4b7 --- /dev/null +++ b/github/definitions/subscription.ts @@ -0,0 +1,8 @@ +//subsciptions which will be saved in the apps local storage +export interface ISubscription{ + webhookId : string, + user: string, + repoName : string, + room : string, + event: string +} \ No newline at end of file diff --git a/github/endpoints/githubEndpoints.ts b/github/endpoints/githubEndpoints.ts new file mode 100644 index 0000000..6091c47 --- /dev/null +++ b/github/endpoints/githubEndpoints.ts @@ -0,0 +1,108 @@ +import { ApiEndpoint } from "@rocket.chat/apps-engine/definition/api"; +import { + IRead, + IHttp, + IModify, + IPersistence, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { + IApiEndpointInfo, + IApiEndpoint, + IApiRequest, + IApiResponse, +} from "@rocket.chat/apps-engine/definition/api"; +import { Subscription } from "../persistance/subscriptions"; +import { ISubscription } from "../definitions/subscription"; +import { IRoom } from "@rocket.chat/apps-engine/definition/rooms"; +export class githubWebHooks extends ApiEndpoint { + public path = "githubwebhook"; + + public async post( + request: IApiRequest, + endpoint: IApiEndpointInfo, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence + ): Promise { + let event: string = request.headers["x-github-event"] as string; + let payload: any; + if ( + request.headers["content-type"] === + "application/x-www-form-urlencoded" + ) { + payload = JSON.parse(request.content.payload); + } else { + payload = request.content; + } + + let subsciptionStorage = new Subscription( + persis, + read.getPersistenceReader() + ); + + const subsciptions: Array = + await subsciptionStorage.getSubscribedRooms( + payload.repository.full_name, + event + ); + if (!subsciptions || subsciptions.length == 0) { + return this.success(); + } + const eventCaps = event.toUpperCase(); + let messageText = "newEvent !"; + + if (event == "push") { + messageText = `*New Commits to* *[${payload.repository.full_name}](${payload.repository.html_url}) by ${payload.pusher.name}*`; + } else if (event == "pull_request") { + if(payload.action == "opened"){ + messageText = `*[New Pull Reqeust](${payload.pull_request.html_url})* *|* *#${payload.pull_request.number} ${payload.pull_request.title}* by *[${payload.user.login}](${payload.user.html_url})* *|* *[${payload.repository.full_name}]*`; + }else if(payload.action == "closed" && payload.pull_request.merged ){ + messageText = `*[Merged Pull Reqeust](${payload.pull_request.html_url})* *|* *#${payload.pull_request.number} ${payload.pull_request.title}* by *[${payload.user.login}](${payload.user.html_url})* *|* *[${payload.repository.full_name}]*`; + }else if(payload.action == "closed" && !payload.pull_request.merged){ + messageText = `*[Closed Pull Reqeust](${payload.pull_request.html_url})* *|* *#${payload.pull_request.number} ${payload.pull_request.title}* by *[${payload.user.login}](${payload.user.html_url})* *|* *[${payload.repository.full_name}]*`; + }else if(payload.action =="reopened"){ + messageText = `*[ReOpened Pull Reqeust](${payload.pull_request.html_url})* *|* *#${payload.pull_request.number} ${payload.pull_request.title}* by *[${payload.user.login}](${payload.user.html_url})* *|* *[${payload.repository.full_name}]*`; + }else{ + return this.success(); + } + } else if (event == "issues") { + if(payload.action == "opened"){ + messageText = `*[New Issue](${payload.issue.html_url})* *|* *#${payload.issue.number}* *${payload.issue.title}* *|* *[${payload.repository.full_name}](${payload.repository.html_url})* `; + }else if(payload.action == "closed"){ + messageText = `*[Issue Closed](${payload.issue.html_url})* *|* *#${payload.issue.number}* *${payload.issue.title}* *|* *[${payload.repository.full_name}](${payload.repository.html_url})* `; + }else if(payload.action == "reopened"){ + messageText = `*[ReOpened Issue](${payload.issue.html_url})* *|* *#${payload.issue.number}* *${payload.issue.title}* *|* *[${payload.repository.full_name}](${payload.repository.html_url})* `; + }else{ + return this.success(); + } + } else if (event == "deployment_status") { + messageText = `*Deployment Status ${payload.deployment_status.state}* *|* *${payload.repository.full_name}*`; + } else if (event == "star"){ + if(payload?.action == "created"){ + messageText = `*New Stars on* *${payload.repository.full_name}* *|* *${payload.repository.stargazers_count}* ⭐`; + }else{ + return this.success(); + } + } + for (let subsciption of subsciptions) { + let roomId = subsciption.room; + if (!roomId) { + continue; + } + const room: IRoom = (await read + .getRoomReader() + .getById(roomId)) as IRoom; + const textSender = await modify + .getCreator() + .startMessage() + .setText(messageText); + if (room) { + textSender.setRoom(room); + } + await modify.getCreator().finish(textSender); + } + + return this.success(); + } +} diff --git a/github/enum/Modals.ts b/github/enum/Modals.ts index 2ef412d..f7e9184 100644 --- a/github/enum/Modals.ts +++ b/github/enum/Modals.ts @@ -1,6 +1,8 @@ export enum ModalsEnum { PULL_VIEW = 'pull-view', CODE_VIEW = 'code-view', + CODE_VIEW_LABEL = 'Code Changes', + CODE_INPUT='code-input', VIEW_FILE_ACTION = 'view-file-task', VIEW_FILE_ACTION_LABEL = 'View File', VIEW_DIFFS_ACTION_LABEL = 'View Changes', @@ -9,4 +11,33 @@ export enum ModalsEnum { MERGE_PULL_REQUEST_LABEL = 'Merge', COMMENT_PR_ACTION = 'comment-pull-request', COMMENT_PR_LABEL = 'Comment', + SUBSCRIPTION_TITLE='GitHub Subscriptions', + SUBSCRIPTION_VIEW = 'subscriptions-view', + ADD_SUBSCRIPTION_VIEW = 'add-subscription-view', + DELETE_SUBSCRIPTION_VIEW ='delete-subscription-view', + OPEN_ADD_SUBSCRIPTIONS_MODAL='open-add-subscriptions', + OPEN_UPDATE_SUBSCRIPTIONS_MODAL='open-update-subscriptions', + OPEN_DELETE_SUBSCRIPTIONS_MODAL='open-update-subscriptions', + OPEN_ADD_SUBSCRIPTIONS_LABEL='Add Subsciptions', + OPEN_UPDATE_SUBSCRIPTIONS_LABEL='Update Subscriptions', + OPEN_DELETE_SUBSCRIPTIONS_LABEL='Delete Subscriptions', + DELETE_SUBSCRIPTION_ACTION='delete-subscription', + DELETE_SUBSCRIPTION_LABEL='Unsubscribe', + ADD_SUBSCRIPTION_ACTION='add-subscription', + ADD_SUBSCRIPTION_LABEL='Subscribe', + UPDATE_SUBSCRIPTION_ACTION='update-subscription', + UPDATE_SUBSCRIPTION_LABEL='Update', + DELETE_SUBSCIPTIONS_TITLE='Unsubscribe to Repositories', + ADD_SUBSCIPTIONS_TITLE='New Subscription', + OPEN_REPO_ACTION='open-repo', + OPEN_REPO_LABEL='Open', + REPO_NAME_INPUT='repo-name-input', + REPO_NAME_LABEL='Enter Full Repository Name', + REPO_NAME_PLACEHOLDER='Owner/Repository', + REPO_NAME_INPUT_ACTION='repo-name-input-action', + ADD_SUBSCRIPTION_EVENT_LABEL='Add Events', + ADD_SUBSCRIPTION_EVENT_OPTIONS='add-repo-subscription-events', + ADD_SUBSCRIPTION_EVENT_INPUT='add-repo-subscription-events', + SUBSCRIPTION_REFRESH_LABEL="Refresh", + SUBSCRIPTION_REFRESH_ACTION="subscription-refresh", } \ No newline at end of file diff --git a/github/enum/Processors.ts b/github/enum/Processors.ts new file mode 100644 index 0000000..367260e --- /dev/null +++ b/github/enum/Processors.ts @@ -0,0 +1,3 @@ +export enum ProcessorsEnum { + REMOVE_GITHUB_LOGIN = 'remove_github_token', +} \ No newline at end of file diff --git a/github/enum/Subcommands.ts b/github/enum/Subcommands.ts new file mode 100644 index 0000000..5311664 --- /dev/null +++ b/github/enum/Subcommands.ts @@ -0,0 +1,8 @@ +export enum SubcommandEnum { + LOGIN = 'login', + LOGOUT = 'logout', + HELP = 'help', + SUBSCRIBE = 'subscribe', + UNSUBSCRIBE = 'unsubscribe', + TEST = 'test' +} \ No newline at end of file diff --git a/github/handlers/ExecuteBlockActionHandler.ts b/github/handlers/ExecuteBlockActionHandler.ts index 261a9e9..9d013e0 100644 --- a/github/handlers/ExecuteBlockActionHandler.ts +++ b/github/handlers/ExecuteBlockActionHandler.ts @@ -14,82 +14,176 @@ import { IUIKitResponse, UIKitBlockInteractionContext, } from "@rocket.chat/apps-engine/definition/uikit"; - +import { AddSubscriptionModal } from "../modals/addSubscriptionsModal"; +import { deleteSubsciptionsModal } from "../modals/deleteSubscriptions"; +import { deleteSubscription, updateSubscription } from "../helpers/githubSDK"; +import { Subscription } from "../persistance/subscriptions"; +import { getAccessTokenForUser } from "../persistance/auth"; +import { GithubApp } from "../GithubApp"; +import { IAuthData } from "@rocket.chat/apps-engine/definition/oauth2/IOAuth2"; +import { storeInteractionRoomData, getInteractionRoomData } from "../persistance/roomInteraction"; +import { sendNotification } from "../lib/message"; +import { subsciptionsModal } from "../modals/subscriptionsModal"; export class ExecuteBlockActionHandler { constructor( - private readonly app: IApp, + private readonly app: GithubApp, private readonly read: IRead, private readonly http: IHttp, private readonly modify: IModify, private readonly persistence: IPersistence - ) {} + ) { } public async run( context: UIKitBlockInteractionContext ): Promise { const data = context.getInteractionData(); - const { actionId } = data; - - switch (actionId) { - case "githubDataSelect": { - try { - const param = data.value; - let query: String = ""; - let lengthOfRepoString: number = 0; - if (param && param.length) { - let i = param.length - 1; - for ( - ; - i >= 0 && data.value && data.value[i] != "/"; - i-- - ) { - query = data.value[i] + query; + try { + const { actionId } = data; + switch (actionId) { + case "githubDataSelect": { + try { + const param = data.value; + let query: String = ""; + let lengthOfRepoString: number = 0; + if (param && param.length) { + let i = param.length - 1; + for ( + ; + i >= 0 && data.value && data.value[i] != "/"; + i-- + ) { + query = data.value[i] + query; + } + lengthOfRepoString = i; } - lengthOfRepoString = i; - } - const repository = param?.substring( - 0, - lengthOfRepoString - ) as String; + const repository = param?.substring( + 0, + lengthOfRepoString + ) as String; - const room: IRoom = context.getInteractionData() - .room as IRoom; + const room: IRoom = context.getInteractionData() + .room as IRoom; + await basicQueryMessage({ + query, + repository, + room, + read: this.read, + persistence: this.persistence, + modify: this.modify, + http: this.http, + }); - await basicQueryMessage({ - query, - repository, - room, + return { + success: true, + }; + } catch (err) { + console.error(err); + return { + success: false, + }; + } + break; + } + case ModalsEnum.VIEW_FILE_ACTION: { + const codeModal = await fileCodeModal({ + data, + modify: this.modify, read: this.read, persistence: this.persistence, - modify: this.modify, http: this.http, + uikitcontext: context, }); + return context + .getInteractionResponder() + .openModalViewResponse(codeModal); + } + case ModalsEnum.OPEN_ADD_SUBSCRIPTIONS_MODAL: { + const addSubscriptionModal = await AddSubscriptionModal({ + modify: this.modify, + read: this.read, + persistence: this.persistence, + http: this.http, + uikitcontext: context + }) + return context + .getInteractionResponder() + .openModalViewResponse(addSubscriptionModal); + } + case ModalsEnum.OPEN_DELETE_SUBSCRIPTIONS_MODAL: { + const addSubscriptionModal = await deleteSubsciptionsModal({ + modify: this.modify, + read: this.read, + persistence: this.persistence, + http: this.http, + uikitcontext: context + }) + return context + .getInteractionResponder() + .openModalViewResponse(addSubscriptionModal); + } + case ModalsEnum.DELETE_SUBSCRIPTION_ACTION: { - return { - success: true, - }; - } catch (err) { - console.error(err); - return { - success: false, - }; + + let { user, room } = await context.getInteractionData(); + let accessToken = await getAccessTokenForUser(this.read, user, this.app.oauth2Config) as IAuthData; + let value: string = context.getInteractionData().value as string; + let splitted = value.split(','); + if (splitted.length == 2 && accessToken.token) { + let repoName = splitted[0]; + let hookId = splitted[1]; + let roomId; + if (room?.id) { + roomId = room.id; + await storeInteractionRoomData(this.persistence, user.id, roomId); + } else { + roomId = (await getInteractionRoomData(this.read.getPersistenceReader(), user.id)).roomId; + } + //delete the susbscriptions for persistance + let subscriptionStorage = new Subscription(this.persistence, this.read.getPersistenceReader()); + let oldSubscriptions = await subscriptionStorage.getSubscriptionsByRepo(repoName, user.id); + await subscriptionStorage.deleteSubscriptionsByRepoUser(repoName, roomId, user.id); + //check if any subscription events of the repo is left in any other room + let eventSubscriptions = new Map; + for (let subsciption of oldSubscriptions) { + eventSubscriptions.set(subsciption.event, false); + } + let updatedsubscriptions = await subscriptionStorage.getSubscriptionsByRepo(repoName, user.id); + if (updatedsubscriptions.length == 0) { + await deleteSubscription(this.http, repoName, accessToken.token, hookId); + } else { + for (let subsciption of updatedsubscriptions) { + eventSubscriptions.set(subsciption.event, true); + } + let updatedEvents: Array = []; + let sameEvents = true; + for (let [event, present] of eventSubscriptions) { + sameEvents = sameEvents && present; + if (present) { + updatedEvents.push(event); + } + } + if (updatedEvents.length && !sameEvents) { + let response = await updateSubscription(this.http, repoName, accessToken.token, hookId, updatedEvents); + } + } + let userRoom = await this.read.getRoomReader().getById(roomId) as IRoom; + await sendNotification(this.read, this.modify, user, userRoom, `Unsubscribed to ${repoName} 🔕`); + } + + const modal = await deleteSubsciptionsModal({ modify: this.modify, read: this.read, persistence: this.persistence, http: this.http, uikitcontext: context }); + await this.modify.getUiController().updateModalView(modal, { triggerId: context.getInteractionData().triggerId }, context.getInteractionData().user); + break; + } + case ModalsEnum.SUBSCRIPTION_REFRESH_ACTION:{ + const modal = await subsciptionsModal({ modify: this.modify, read: this.read, persistence: this.persistence, http: this.http, uikitcontext: context }); + await this.modify.getUiController().updateModalView(modal, { triggerId: context.getInteractionData().triggerId }, context.getInteractionData().user); + break; } - break; - } - case ModalsEnum.VIEW_FILE_ACTION: { - const codeModal = await fileCodeModal({ - data, - modify: this.modify, - read: this.read, - persistence: this.persistence, - http: this.http, - uikitcontext: context, - }); - return context - .getInteractionResponder() - .openModalViewResponse(codeModal); } + + } catch (error) { + console.log(error); } return context.getInteractionResponder().successResponse(); diff --git a/github/handlers/ExecuteViewClosedHandler.ts b/github/handlers/ExecuteViewClosedHandler.ts index f299f78..07a58f2 100644 --- a/github/handlers/ExecuteViewClosedHandler.ts +++ b/github/handlers/ExecuteViewClosedHandler.ts @@ -21,7 +21,10 @@ export class ExecuteViewClosedHandler { public async run(context: UIKitViewCloseInteractionContext) { const { view } = context.getInteractionData(); switch (view.id) { - case ModalsEnum.PULL_VIEW || ModalsEnum.CODE_VIEW: + case ModalsEnum.PULL_VIEW || + ModalsEnum.CODE_VIEW || + ModalsEnum.ADD_SUBSCRIPTION_VIEW || + ModalsEnum.SUBSCRIPTION_VIEW: const modal = await pullDetailsModal({ modify: this.modify, read: this.read, @@ -29,16 +32,14 @@ export class ExecuteViewClosedHandler { http: this.http, uikitcontext: context, }); - await this.modify - .getUiController() - .updateModalView( - modal, - { - triggerId: context.getInteractionData() - .triggerId as string, - }, - context.getInteractionData().user - ); + await this.modify.getUiController().updateModalView( + modal, + { + triggerId: context.getInteractionData() + .triggerId as string, + }, + context.getInteractionData().user + ); break; } return { success: true } as any; diff --git a/github/handlers/ExecuteViewSubmitHandler.ts b/github/handlers/ExecuteViewSubmitHandler.ts new file mode 100644 index 0000000..1e99dd7 --- /dev/null +++ b/github/handlers/ExecuteViewSubmitHandler.ts @@ -0,0 +1,116 @@ +import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import { UIKitViewSubmitInteractionContext } from '@rocket.chat/apps-engine/definition/uikit'; +import { ModalsEnum } from '../enum/Modals'; +import { sendMessage, sendNotification } from '../lib/message'; +import { getInteractionRoomData } from '../persistance/roomInteraction'; +import { Subscription } from '../persistance/subscriptions'; +import { GithubApp } from '../GithubApp'; +import { getWebhookUrl } from '../helpers/getWebhookURL'; +import { addSubscribedEvents, createSubscription, updateSubscription } from '../helpers/githubSDK'; +import { getAccessTokenForUser } from '../persistance/auth'; +import { subsciptionsModal } from '../modals/subscriptionsModal'; + + +export class ExecuteViewSubmitHandler { + constructor( + private readonly app: GithubApp, + private readonly read: IRead, + private readonly http: IHttp, + private readonly modify: IModify, + private readonly persistence: IPersistence, + ) { } + + public async run(context: UIKitViewSubmitInteractionContext) { + const { user, view } = context.getInteractionData(); + + try { + switch (view.id) { + case ModalsEnum.ADD_SUBSCRIPTION_VIEW: + if (user.id) { + const { roomId } = await getInteractionRoomData(this.read.getPersistenceReader(), user.id); + if (roomId) { + let room = await this.read.getRoomReader().getById(roomId) as IRoom; + const repository = view.state?.[ModalsEnum.REPO_NAME_INPUT]?.[ModalsEnum.REPO_NAME_INPUT_ACTION]; + const events = view.state?.[ModalsEnum.ADD_SUBSCRIPTION_EVENT_INPUT]?.[ModalsEnum.ADD_SUBSCRIPTION_EVENT_OPTIONS]; + + if (typeof (repository) == undefined || typeof (events) == undefined) { + + await sendNotification(this.read, this.modify, user, room, "Invalid Input !"); + } else { + let accessToken = await getAccessTokenForUser(this.read, user, this.app.oauth2Config); + if (!accessToken) { + + await sendNotification(this.read, this.modify, user, room, "Login To Github !"); + } else { + //if we have a webhook for the repo and our room requires the same event,we just make our entries to the apps storage instead of making a new hook + //if we have a hook but we dont have all the events, we send in a patch request, + + let url = await getWebhookUrl(this.app); + + let subsciptionStorage = new Subscription(this.persistence, this.read.getPersistenceReader()); + let subscribedEvents = new Map; + let hookId = ""; + + + let subscriptions = await subsciptionStorage.getSubscriptionsByRepo(repository, user.id); + if (subscriptions && subscriptions.length) { + for (let subscription of subscriptions) { + subscribedEvents.set(subscription.event, true); + if (hookId == "") { + hookId = subscription.webhookId; + } + } + } + let additionalEvents = 0; + for (let event of events) { + if (!subscribedEvents.has(event)) { + additionalEvents++; + subscribedEvents.set(event, true); + } + } + let response: any; + //if hook is null we create a new hook, else we add more events to the new hook + if (hookId == "") { + response = await createSubscription(this.http, repository, url, accessToken.token, events); + } else { + //if hook is already present, we just need to send a patch request to add new events to existing hook + let newEvents: Array = []; + for (let [event, present] of subscribedEvents) { + newEvents.push(event); + } + if (additionalEvents && newEvents.length) { + response = await updateSubscription(this.http, repository, accessToken.token, hookId, newEvents); + } + } + let createdEntry = false; + //subscribe rooms to hook events + for (let event of events) { + createdEntry = await subsciptionStorage.createSubscription(repository, event, response?.id, room, user); + } + if (!createdEntry) { + throw new Error("Error creating new subscription entry"); + } + await sendNotification(this.read, this.modify, user, room, `Subscibed to ${repository} ✔️`); + } + + } + const modal = await subsciptionsModal({ modify: this.modify, read: this.read, persistence: this.persistence, http: this.http, uikitcontext: context }); + await this.modify.getUiController().updateModalView(modal, { triggerId: context.getInteractionData().triggerId }, context.getInteractionData().user); + return context.getInteractionResponder().successResponse(); + } + } + break; + default: + break; + } + + } catch (error) { + console.log('error : ', error); + } + + return { + success: true, + }; + } +} \ No newline at end of file diff --git a/github/helpers/basicQueryMessage.ts b/github/helpers/basicQueryMessage.ts index b82fd6d..71eec6d 100644 --- a/github/helpers/basicQueryMessage.ts +++ b/github/helpers/basicQueryMessage.ts @@ -21,7 +21,7 @@ export async function basicQueryMessage({ http, }: { query: String, - repository:String, + repository: String, room: IRoom; read: IRead; persistence: IPersistence; @@ -31,22 +31,22 @@ export async function basicQueryMessage({ switch (query) { case "issues": { - await issueListMessage({repository,room,read,persistence,modify,http}); + await issueListMessage({ repository, room, read, persistence, modify, http }); break; } case "contributors": { - await contributorListMessage({repository,room,read,persistence,modify,http}); + await contributorListMessage({ repository, room, read, persistence, modify, http }); break; } case "pulls": { - await pullRequestListMessage({repository,room,read,persistence,modify,http}); + await pullRequestListMessage({ repository, room, read, persistence, modify, http }); break; } case "repo": { - await repoDataMessage({repository,room,read,persistence,modify,http}) + await repoDataMessage({ repository, room, read, persistence, modify, http }) break; } - default: - await helperMessage({room,read,persistence,modify,http}); + default: + await helperMessage({ room, read, persistence, modify, http }); } } diff --git a/github/helpers/getWebhookURL.ts b/github/helpers/getWebhookURL.ts new file mode 100644 index 0000000..fa2a869 --- /dev/null +++ b/github/helpers/getWebhookURL.ts @@ -0,0 +1,12 @@ +import { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api'; +import { GithubApp } from '../GithubApp'; + +export async function getWebhookUrl(app: GithubApp): Promise { + const accessors = app.getAccessors(); + const webhookEndpoint = accessors.providedApiEndpoints.find((endpoint) => endpoint.path === 'githubwebhook') as IApiEndpointMetadata; + let siteUrl: string = await accessors.environmentReader.getServerSettings().getValueById('Site_Url') as string; + if (siteUrl.charAt(siteUrl.length - 1) === '/') { + siteUrl = siteUrl.substring(0, siteUrl.length - 1); + } + return siteUrl + webhookEndpoint.computedPath; +} \ No newline at end of file diff --git a/github/helpers/githubSDK.ts b/github/helpers/githubSDK.ts new file mode 100644 index 0000000..be4513d --- /dev/null +++ b/github/helpers/githubSDK.ts @@ -0,0 +1,155 @@ +import { IHttp } from "@rocket.chat/apps-engine/definition/accessors"; + +const BaseHost = "/"; +const BaseApiHost = "https://api.github.com/repos/"; + +async function postReqeust( + http: IHttp, + accessToken: String, + url: string, + data: any +): Promise { + const response = await http.post(url, { + headers: { + Authorization: `token ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "Rocket.Chat-Apps-Engine", + }, + data, + }); + + // If it isn't a 2xx code, something wrong happened + if (!response.statusCode.toString().startsWith("2")) { + throw response; + } + + return JSON.parse(response.content || "{}"); +} + +async function deleteReqeust( + http: IHttp, + accessToken: String, + url: string +): Promise { + const response = await http.del(url, { + headers: { + Authorization: `token ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "Rocket.Chat-Apps-Engine", + }, + }); + + // If it isn't a 2xx code, something wrong happened + if (!response.statusCode.toString().startsWith("2")) { + throw response; + } + + return JSON.parse(response.content || "{}"); +} + +async function patchReqeust( + http: IHttp, + accessToken: String, + url: string, + data: any +): Promise { + const response = await http.patch(url, { + headers: { + Authorization: `token ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "Rocket.Chat-Apps-Engine", + }, + data, + }); + + // If it isn't a 2xx code, something wrong happened + if (!response.statusCode.toString().startsWith("2")) { + throw response; + } + + return JSON.parse(response.content || "{}"); +} + +export async function createSubscription( + http: IHttp, + repoName: string, + webhookUrl: string, + access_token: string, + events: Array +) { + return postReqeust(http, access_token, BaseApiHost + repoName + "/hooks", { + active: true, + events: events, + config: { + url: webhookUrl, + content_type: "json", + }, + }); +} + +export async function deleteSubscription( + http: IHttp, + repoName: string, + access_token: string, + hookId: string +) { + return deleteReqeust( + http, + access_token, + BaseApiHost + repoName + "/hooks/" + hookId + ); +} + +export async function updateSubscription( + http: IHttp, + repoName: string, + access_token: string, + hookId: string, + events: Array +) { + return patchReqeust( + http, + access_token, + BaseApiHost + repoName + "/hooks/" + hookId, + { + active: true, + events: events, + } + ); +} + +export async function addSubscribedEvents( + http: IHttp, + repoName: string, + access_token: string, + hookId: string, + events: Array +) { + return patchReqeust( + http, + access_token, + BaseApiHost + repoName + "/hooks/" + hookId, + { + active: true, + add_events: events, + } + ); +} + +export async function removeSubscribedEvents( + http: IHttp, + repoName: string, + access_token: string, + hookId: string, + events: Array +) { + return patchReqeust( + http, + access_token, + BaseApiHost + repoName + "/hooks/" + hookId, + { + active: true, + add_events: events, + } + ); +} diff --git a/github/lib/blocks.ts b/github/lib/blocks.ts new file mode 100644 index 0000000..3743da7 --- /dev/null +++ b/github/lib/blocks.ts @@ -0,0 +1,30 @@ +import { IModify } from '@rocket.chat/apps-engine/definition/accessors'; +import { AccessoryElements, BlockBuilder } from '@rocket.chat/apps-engine/definition/uikit'; + +export interface IButton { + text: string; + url?: string; + actionId?: string; +} + +export async function createSectionBlock(modify: IModify, sectionText: string, button?: IButton): Promise { + const blocks = modify.getCreator().getBlockBuilder(); + + blocks.addSectionBlock({ + text: blocks.newMarkdownTextObject(sectionText), + }); + + if (button) { + blocks.addActionsBlock({ + elements: [ + blocks.newButtonElement({ + actionId: button.actionId, + text: blocks.newPlainTextObject(button.text), + url: button.url, + }), + ], + }); + } + + return blocks; +} \ No newline at end of file diff --git a/github/lib/helperMessage.ts b/github/lib/helperMessage.ts index f883b7e..d3b403a 100644 --- a/github/lib/helperMessage.ts +++ b/github/lib/helperMessage.ts @@ -22,12 +22,16 @@ export async function helperMessage({ let helperMessageString = ` Github App - 1) See Interactive Button interface to fetch repository data -> /github GithubUsername/RepositoryName - 2) Get details of a Repository -> /github GithubUsername/RepositoryName repo - 3) Get Issues of a Repository -> /github GithubUsername/RepositoryName issues - 4) Get Contributors of a Repository -> /github GithubUsername/RepositoryName contributors - 5) Get Recent Pull Request of a Repository -> /github GithubUsername/RepositoryName pulls - 6) Review a Pull Request -> /github GithubUsername/RepositoryName pulls pullNumber + 1) See Interactive Button interface to fetch repository data -> /github Username/RepositoryName + 2) Get details of a Repository -> /github Username/RepositoryName repo + 3) Get Issues of a Repository -> /github Username/RepositoryName issues + 4) Get Contributors of a Repository -> /github Username/RepositoryName contributors + 5) Get Recent Pull Request of a Repository -> /github Username/RepositoryName pulls + 6) Review a Pull Request -> /github Username/RepositoryName pulls pullNumber + 7) Login to GitHub -> /github login + 8) View/Add/Delete/Update Repository Subscriptions -> /github subscribe + 9) Subscribe to all repository events -> /github Username/RepositoryName subscribe + 10) Unsubscribe to all repository events -> /github Username/RepositoryName unsubscribe `; diff --git a/github/lib/initiatorMessage.ts b/github/lib/initiatorMessage.ts index 702bb65..5c3f332 100644 --- a/github/lib/initiatorMessage.ts +++ b/github/lib/initiatorMessage.ts @@ -25,8 +25,8 @@ export async function initiatorMessage({ .getCreator() .startMessage() .setRoom(data.room) - .setText(`Hey _${data.sender.username}_ !`); - + .setText(`Hey ${data.sender.username} !`); + if (data.room.type !== "l") { await modify .getNotifier() diff --git a/github/lib/message.ts b/github/lib/message.ts new file mode 100644 index 0000000..dcf872f --- /dev/null +++ b/github/lib/message.ts @@ -0,0 +1,97 @@ +import { IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import { IRoom, RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import { BlockBuilder, IBlock } from '@rocket.chat/apps-engine/definition/uikit'; +import { IUser } from '@rocket.chat/apps-engine/definition/users'; +import { NotificationsController } from './notifications'; + +export async function getDirect(read: IRead, modify: IModify, appUser: IUser, username: string): Promise { + const usernames = [appUser.username, username]; + let room: IRoom; + try { + room = await read.getRoomReader().getDirectByUsernames(usernames); + } catch (error) { + console.log(error); + return; + } + + if (room) { + return room; + } else { + let roomId: string; + + // Create direct room between botUser and username + const newRoom = modify.getCreator().startRoom() + .setType(RoomType.DIRECT_MESSAGE) + .setCreator(appUser) + .setMembersToBeAddedByUsernames(usernames); + roomId = await modify.getCreator().finish(newRoom); + return await read.getRoomReader().getById(roomId); + } +} + +export async function sendMessage( + modify: IModify, + room: IRoom, + sender: IUser, + message: string, + blocks?: BlockBuilder | [IBlock], +): Promise { + + const msg = modify.getCreator().startMessage() + .setSender(sender) + .setRoom(room) + .setGroupable(false) + .setParseUrls(false) + .setText(message); + + if (blocks !== undefined) { + msg.setBlocks(blocks); + } + + return await modify.getCreator().finish(msg); +} + +export async function shouldSendMessage(read: IRead, persistence: IPersistence, user: IUser): Promise { + const notificationsController = new NotificationsController(read, persistence, user); + const notificationStatus = await notificationsController.getNotificationsStatus(); + + return notificationStatus ? notificationStatus.status : true; +} + +export async function sendNotification(read: IRead, modify: IModify, user: IUser, room: IRoom, message: string, blocks?: BlockBuilder): Promise { + const appUser = await read.getUserReader().getAppUser() as IUser; + + const msg = modify.getCreator().startMessage() + .setSender(appUser) + .setRoom(room) + .setText(message); + + if (blocks) { + msg.setBlocks(blocks); + } + + return read.getNotifier().notifyUser(user, msg.getMessage()); +} + +export async function sendDirectMessage( + read: IRead, + modify: IModify, + user: IUser, + message: string, + persistence: IPersistence, + blocks?: BlockBuilder | [IBlock], +): Promise { + const appUser = await read.getUserReader().getAppUser() as IUser; + const targetRoom = await getDirect(read, modify, appUser, user.username) as IRoom; + + const shouldSend = await shouldSendMessage(read, persistence, user); + + if (!shouldSend) { return ''; } + + return await sendMessage(modify, targetRoom, appUser, message, blocks); +} + +export function isUserHighHierarchy(user: IUser): boolean { + const clearanceList = ['admin', 'owner', 'moderator']; + return user.roles.some((role) => clearanceList.includes(role)); +} \ No newline at end of file diff --git a/github/lib/notifications.ts b/github/lib/notifications.ts new file mode 100644 index 0000000..69d62f1 --- /dev/null +++ b/github/lib/notifications.ts @@ -0,0 +1,58 @@ +import { IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import { RocketChatAssociationModel, RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations'; +import { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; + +interface INotificationsStatus { + status: boolean; +} + +export class NotificationsController { + private read: IRead; + private persistence: IPersistence; + private association: RocketChatAssociationRecord; + private userAssociation: RocketChatAssociationRecord; + + constructor(read: IRead, persistence: IPersistence, user: IUser) { + this.read = read; + this.persistence = persistence; + this.association = new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + `github-notifications`, + ); + + this.userAssociation = new RocketChatAssociationRecord( + RocketChatAssociationModel.USER, + user.id, + ); + } + + public async getNotificationsStatus(): Promise { + + const [record] = await this.read + .getPersistenceReader() + .readByAssociations([this.association, this.userAssociation]); + + return record as INotificationsStatus; + } + + public async setNotificationsStatus(status: boolean): Promise { + await this.persistence.createWithAssociations({ status } , [this.association, this.userAssociation]); + return status; + } + + public async updateNotificationsStatus(status: boolean) { + const notificationsStatus = await this.getNotificationsStatus(); + + if (!notificationsStatus) { + return await this.setNotificationsStatus(status); + } + + await this.persistence.updateByAssociations([this.association, this.userAssociation], { status }); + + return status; + } + + public async deleteNotifications(): Promise { + await this.persistence.removeByAssociations([this.association, this.userAssociation]); + } +} \ No newline at end of file diff --git a/github/modals/addSubscriptionsModal.ts b/github/modals/addSubscriptionsModal.ts new file mode 100644 index 0000000..c0b2154 --- /dev/null +++ b/github/modals/addSubscriptionsModal.ts @@ -0,0 +1,170 @@ +import { + IHttp, + IModify, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { TextObjectType } from "@rocket.chat/apps-engine/definition/uikit/blocks"; +import { IUIKitModalViewParam } from "@rocket.chat/apps-engine/definition/uikit/UIKitInteractionResponder"; +import { IUser } from "@rocket.chat/apps-engine/definition/users"; +import { ModalsEnum } from "../enum/Modals"; +import { AppEnum } from "../enum/App"; +// import { getRoomTasks, getUIData, persistUIData } from '../lib/persistence'; +import { SlashCommandContext } from "@rocket.chat/apps-engine/definition/slashcommands"; +import { + UIKitBlockInteractionContext, + UIKitInteractionContext, +} from "@rocket.chat/apps-engine/definition/uikit"; +import { + storeInteractionRoomData, + getInteractionRoomData, +} from "../persistance/roomInteraction"; +import { Subscription } from "../persistance/subscriptions"; +import { ISubscription } from "../definitions/subscription"; + +export async function AddSubscriptionModal({ + modify, + read, + persistence, + http, + slashcommandcontext, + uikitcontext, +}: { + modify: IModify; + read: IRead; + persistence: IPersistence; + http: IHttp; + slashcommandcontext?: SlashCommandContext; + uikitcontext?: UIKitInteractionContext; +}): Promise { + const viewId = ModalsEnum.ADD_SUBSCRIPTION_VIEW; + const block = modify.getCreator().getBlockBuilder(); + const room = + slashcommandcontext?.getRoom() || + uikitcontext?.getInteractionData().room; + const user = + slashcommandcontext?.getSender() || + uikitcontext?.getInteractionData().user; + + if (user?.id) { + let roomId; + + if (room?.id) { + roomId = room.id; + await storeInteractionRoomData(persistence, user.id, roomId); + } else { + roomId = ( + await getInteractionRoomData( + read.getPersistenceReader(), + user.id + ) + ).roomId; + } + + let subsciptionStorage = new Subscription( + persistence, + read.getPersistenceReader() + ); + let roomSubsciptions: Array = + await subsciptionStorage.getSubscriptions(roomId); + + // shows indentations in input blocks but not inn section block + block.addInputBlock({ + blockId: ModalsEnum.REPO_NAME_INPUT, + label: { + text: ModalsEnum.REPO_NAME_LABEL, + type: TextObjectType.PLAINTEXT, + }, + element: block.newPlainTextInputElement({ + actionId: ModalsEnum.REPO_NAME_INPUT_ACTION, + placeholder: { + text: ModalsEnum.REPO_NAME_PLACEHOLDER, + type: TextObjectType.PLAINTEXT, + }, + }), + }); + + let newMultiStaticElemnt = block.newMultiStaticElement({ + actionId: ModalsEnum.ADD_SUBSCRIPTION_EVENT_OPTIONS, + options: [ + { + value: "issues", + text: { + type: TextObjectType.PLAINTEXT, + text: "New Issues", + emoji: true, + }, + }, + { + value: "pull_request", + text: { + type: TextObjectType.PLAINTEXT, + text: "New Pull Request", + emoji: true, + }, + }, + { + value: "push", + text: { + type: TextObjectType.PLAINTEXT, + text: "New Commits", + emoji: true, + }, + }, + { + value: "deployment_status", + text: { + type: TextObjectType.PLAINTEXT, + text: "Deployment", + emoji: true, + }, + }, + { + value: "star", + text: { + type: TextObjectType.PLAINTEXT, + text: "New Stars", + emoji: true, + }, + }, + ], + placeholder: { + type: TextObjectType.PLAINTEXT, + text: "Select Events", + }, + }); + + block.addInputBlock({ + label: { + text: ModalsEnum.ADD_SUBSCRIPTION_EVENT_LABEL, + type: TextObjectType.PLAINTEXT, + }, + element: newMultiStaticElemnt, + blockId: ModalsEnum.ADD_SUBSCRIPTION_EVENT_INPUT, + }); + } + + block.addDividerBlock(); + + return { + id: viewId, + title: { + type: TextObjectType.PLAINTEXT, + text: ModalsEnum.ADD_SUBSCIPTIONS_TITLE, + }, + close: block.newButtonElement({ + text: { + type: TextObjectType.PLAINTEXT, + text: "Close", + }, + }), + submit: block.newButtonElement({ + actionId: ModalsEnum.ADD_SUBSCRIPTION_ACTION, + text: { + type: TextObjectType.PLAINTEXT, + text: "Subscribe", + }, + }), + blocks: block.getBlocks(), + }; +} diff --git a/github/modals/deleteSubscriptions.ts b/github/modals/deleteSubscriptions.ts new file mode 100644 index 0000000..5b34806 --- /dev/null +++ b/github/modals/deleteSubscriptions.ts @@ -0,0 +1,115 @@ +import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import { ITextObject, TextObjectType } from '@rocket.chat/apps-engine/definition/uikit/blocks'; +import { IUIKitModalViewParam } from '@rocket.chat/apps-engine/definition/uikit/UIKitInteractionResponder'; +import { IUser } from '@rocket.chat/apps-engine/definition/users'; +import { ModalsEnum } from '../enum/Modals'; +import { AppEnum } from '../enum/App'; +// import { getRoomTasks, getUIData, persistUIData } from '../lib/persistence'; +import { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands'; +import { UIKitBlockInteractionContext, UIKitInteractionContext } from '@rocket.chat/apps-engine/definition/uikit'; +import { getInteractionRoomData, storeInteractionRoomData } from '../persistance/roomInteraction'; +import { Subscription } from '../persistance/subscriptions'; +import { ISubscription } from '../definitions/subscription'; +import { IRepositorySubscriptions } from '../definitions/repositorySubscriptions'; + +export async function deleteSubsciptionsModal({ modify, read, persistence, http, slashcommandcontext, uikitcontext }: { modify: IModify, read: IRead, persistence: IPersistence, http: IHttp ,slashcommandcontext?: SlashCommandContext, uikitcontext?: UIKitInteractionContext }): Promise { + const viewId = ModalsEnum.DELETE_SUBSCRIPTION_VIEW; + + const block = modify.getCreator().getBlockBuilder(); + + const room = slashcommandcontext?.getRoom() || uikitcontext?.getInteractionData().room; + const user = slashcommandcontext?.getSender() || uikitcontext?.getInteractionData().user; + + if (user?.id) { + let roomId; + if (room?.id) { + roomId = room.id; + await storeInteractionRoomData(persistence, user.id, roomId); + } else { + roomId = (await getInteractionRoomData(read.getPersistenceReader(), user.id)).roomId; + } + + let subsciptionStorage = new Subscription(persistence,read.getPersistenceReader()); + let roomSubsciptions: Array = await subsciptionStorage.getSubscriptions(roomId); + + block.addDividerBlock(); + + let repositoryData = new Map; + for (let subsciption of roomSubsciptions) { + + let repoName = subsciption.repoName; + let userId = subsciption.user; + let event = subsciption.event; + let user = await read.getUserReader().getById(userId); + + if(repositoryData.has(repoName)){ + let repoData = repositoryData.get(repoName) as IRepositorySubscriptions; + repoData.events.push(event); + repoData.user=user; + repositoryData.set(repoName,repoData); + }else{ + let events:Array = []; + events.push(event); + let repoData:IRepositorySubscriptions={ + webhookId:subsciption.webhookId, + events:events, + user:user, + repoName:repoName + }; + repositoryData.set(repoName,repoData); + } + + } + let index=1; + for (let repository of repositoryData.values()) { + + let repoName = repository.repoName; + let repoUser = repository.user; + let events = repository.events; + if(repoUser.id == user.id){ + block.addSectionBlock({ + text: { text: `${index}) ${repoName}`, type: TextObjectType.PLAINTEXT}, + accessory: block.newButtonElement({ + actionId: ModalsEnum.DELETE_SUBSCRIPTION_ACTION, + text: { + text: ModalsEnum.DELETE_SUBSCRIPTION_LABEL, + type: TextObjectType.PLAINTEXT + }, + value: repoName + "," + repository.webhookId, + }) + }); + + }else{ + block.addSectionBlock({ + text: { text: `${index}) ${repoName}`, type: TextObjectType.PLAINTEXT}, + }); + + } + + let eventList : Array=[]; + eventList.push(block.newPlainTextObject("Events : ")); + for(let event of events){ + eventList.push(block.newPlainTextObject(`${event} `)); + } + block.addContextBlock({ elements: eventList}); + index++; + } + } + + block.addDividerBlock(); + + return { + id: viewId, + title: { + type: TextObjectType.PLAINTEXT, + text: ModalsEnum.DELETE_SUBSCIPTIONS_TITLE, + }, + close: block.newButtonElement({ + text: { + type: TextObjectType.PLAINTEXT, + text: 'Close', + }, + }), + blocks: block.getBlocks(), + }; +} \ No newline at end of file diff --git a/github/modals/fileCodeModal.ts b/github/modals/fileCodeModal.ts index 0151548..cc7dfca 100644 --- a/github/modals/fileCodeModal.ts +++ b/github/modals/fileCodeModal.ts @@ -1,51 +1,68 @@ - -import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; -import { TextObjectType } from '@rocket.chat/apps-engine/definition/uikit/blocks'; -import { IUIKitModalViewParam } from '@rocket.chat/apps-engine/definition/uikit/UIKitInteractionResponder'; -import { IUser } from '@rocket.chat/apps-engine/definition/users'; -import { ModalsEnum } from '../enum/Modals'; -import { AppEnum } from '../enum/App'; +import { + IHttp, + IModify, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { TextObjectType } from "@rocket.chat/apps-engine/definition/uikit/blocks"; +import { IUIKitModalViewParam } from "@rocket.chat/apps-engine/definition/uikit/UIKitInteractionResponder"; +import { IUser } from "@rocket.chat/apps-engine/definition/users"; +import { ModalsEnum } from "../enum/Modals"; +import { AppEnum } from "../enum/App"; // import { getRoomTasks, getUIData, persistUIData } from '../lib/persistence'; -import { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands'; -import { UIKitBlockInteractionContext, UIKitInteractionContext } from '@rocket.chat/apps-engine/definition/uikit'; -import { type } from 'os'; +import { SlashCommandContext } from "@rocket.chat/apps-engine/definition/slashcommands"; +import { + UIKitBlockInteractionContext, + UIKitInteractionContext, +} from "@rocket.chat/apps-engine/definition/uikit"; + -export async function fileCodeModal({ data, modify, read, persistence, http, slashcommandcontext, uikitcontext }: { data, modify: IModify, read: IRead, persistence: IPersistence, http: IHttp ,slashcommandcontext?: SlashCommandContext, uikitcontext?: UIKitInteractionContext }): Promise { +export async function fileCodeModal({ + data, + modify, + read, + persistence, + http, + slashcommandcontext, + uikitcontext, +}: { + data; + modify: IModify; + read: IRead; + persistence: IPersistence; + http: IHttp; + slashcommandcontext?: SlashCommandContext; + uikitcontext?: UIKitInteractionContext; +}): Promise { const viewId = ModalsEnum.CODE_VIEW; const block = modify.getCreator().getBlockBuilder(); - const room = slashcommandcontext?.getRoom() || uikitcontext?.getInteractionData().room; - const user = slashcommandcontext?.getSender() || uikitcontext?.getInteractionData().user; - + const room = + slashcommandcontext?.getRoom() || + uikitcontext?.getInteractionData().room; + const user = + slashcommandcontext?.getSender() || + uikitcontext?.getInteractionData().user; + if (user?.id) { let roomId; - - const pullRawData = await http.get( - data.value - ); - + const pullRawData = await http.get(data.value); const pullData = pullRawData.content; - block.addSectionBlock({ - text: { text: `${pullData}`, type: TextObjectType.MARKDOWN} - - }) + text: { text: `${pullData}`, type: TextObjectType.MARKDOWN }, + }); - //shows indentations in input blocks but not inn section block + // shows indentations in input blocks but not inn section block // block.addInputBlock({ - // blockId: ModalsEnum.TASK_BLOCK, - // label: { text: ModalsEnum.TASK_INPUT_LABEL, type: TextObjectType.PLAINTEXT }, + // blockId: ModalsEnum.CODE_VIEW, + // label: { text: ModalsEnum.CODE_VIEW_LABEL, type: TextObjectType.PLAINTEXT }, // element: block.newPlainTextInputElement({ // initialValue : `${pullData}`, // multiline:true, - // actionId: ModalsEnum.TASK_INPUT, + // actionId: ModalsEnum.CODE_INPUT, // }) // }); - - block.addDividerBlock(); - - } block.addDividerBlock(); @@ -54,15 +71,21 @@ export async function fileCodeModal({ data, modify, read, persistence, http, sla elements: [ block.newButtonElement({ actionId: ModalsEnum.MERGE_PULL_REQUEST_ACTION, - text: { text: ModalsEnum.MERGE_PULL_REQUEST_LABEL, type: TextObjectType.PLAINTEXT }, - value: room?.id + text: { + text: ModalsEnum.MERGE_PULL_REQUEST_LABEL, + type: TextObjectType.PLAINTEXT, + }, + value: room?.id, }), block.newButtonElement({ actionId: ModalsEnum.COMMENT_PR_ACTION, - text: { text: ModalsEnum.COMMENT_PR_LABEL, type: TextObjectType.PLAINTEXT }, - value: room?.id + text: { + text: ModalsEnum.COMMENT_PR_LABEL, + type: TextObjectType.PLAINTEXT, + }, + value: room?.id, }), - ] + ], }); return { @@ -74,9 +97,9 @@ export async function fileCodeModal({ data, modify, read, persistence, http, sla close: block.newButtonElement({ text: { type: TextObjectType.PLAINTEXT, - text: 'Close', + text: "Close", }, }), - blocks: block.getBlocks() + blocks: block.getBlocks(), }; -} \ No newline at end of file +} diff --git a/github/modals/pullDetailsModal.ts b/github/modals/pullDetailsModal.ts index 37e7f1e..abe6ba3 100644 --- a/github/modals/pullDetailsModal.ts +++ b/github/modals/pullDetailsModal.ts @@ -1,93 +1,140 @@ -import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; -import { TextObjectType } from '@rocket.chat/apps-engine/definition/uikit/blocks'; -import { IUIKitModalViewParam } from '@rocket.chat/apps-engine/definition/uikit/UIKitInteractionResponder'; -import { IUser } from '@rocket.chat/apps-engine/definition/users'; -import { ModalsEnum } from '../enum/Modals'; -import { AppEnum } from '../enum/App'; +import { + IHttp, + IModify, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { TextObjectType } from "@rocket.chat/apps-engine/definition/uikit/blocks"; +import { IUIKitModalViewParam } from "@rocket.chat/apps-engine/definition/uikit/UIKitInteractionResponder"; +import { IUser } from "@rocket.chat/apps-engine/definition/users"; +import { ModalsEnum } from "../enum/Modals"; +import { AppEnum } from "../enum/App"; // import { getRoomTasks, getUIData, persistUIData } from '../lib/persistence'; -import { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands'; -import { UIKitBlockInteractionContext, UIKitInteractionContext } from '@rocket.chat/apps-engine/definition/uikit'; +import { SlashCommandContext } from "@rocket.chat/apps-engine/definition/slashcommands"; +import { + UIKitBlockInteractionContext, + UIKitInteractionContext, +} from "@rocket.chat/apps-engine/definition/uikit"; -export async function pullDetailsModal({ data, modify, read, persistence, http, slashcommandcontext, uikitcontext }: { data?, modify: IModify, read: IRead, persistence: IPersistence, http: IHttp ,slashcommandcontext?: SlashCommandContext, uikitcontext?: UIKitInteractionContext }): Promise { +export async function pullDetailsModal({ + data, + modify, + read, + persistence, + http, + slashcommandcontext, + uikitcontext, +}: { + data?; + modify: IModify; + read: IRead; + persistence: IPersistence; + http: IHttp; + slashcommandcontext?: SlashCommandContext; + uikitcontext?: UIKitInteractionContext; +}): Promise { const viewId = ModalsEnum.PULL_VIEW; const block = modify.getCreator().getBlockBuilder(); - const room = slashcommandcontext?.getRoom() || uikitcontext?.getInteractionData().room; - const user = slashcommandcontext?.getSender() || uikitcontext?.getInteractionData().user; - + const room = + slashcommandcontext?.getRoom() || + uikitcontext?.getInteractionData().room; + const user = + slashcommandcontext?.getSender() || + uikitcontext?.getInteractionData().user; + if (user?.id) { let roomId; - + const pullRawData = await http.get( `https://api.github.com/repos/${data?.repository}/pulls/${data?.number}` ); const pullData = pullRawData.data; - + const pullRequestFilesRaw = await http.get( `https://api.github.com/repos/${data?.repository}/pulls/${data?.number}/files` ); - - const pullRequestFiles= pullRequestFilesRaw.data; - + + const pullRequestFiles = pullRequestFilesRaw.data; + block.addSectionBlock({ - text: { text: `#${pullData?.title}`, type: TextObjectType.PLAINTEXT }, + text: { + text: `#${pullData?.title}`, + type: TextObjectType.PLAINTEXT, + }, accessory: block.newButtonElement({ actionId: ModalsEnum.VIEW_FILE_ACTION, text: { text: ModalsEnum.VIEW_DIFFS_ACTION_LABEL, - type: TextObjectType.PLAINTEXT + type: TextObjectType.PLAINTEXT, }, - value: pullData["diff_url"] - }) - }) - block.addContextBlock({ elements: [ block.newPlainTextObject(`Author: ${pullData?.user?.login} | `),block.newPlainTextObject(`State : ${pullData?.state} | `),block.newPlainTextObject(`Mergeable : ${pullData?.mergeable}`) ]}); + value: pullData["diff_url"], + }), + }); + block.addContextBlock({ + elements: [ + block.newPlainTextObject(`Author: ${pullData?.user?.login} | `), + block.newPlainTextObject(`State : ${pullData?.state} | `), + block.newPlainTextObject(`Mergeable : ${pullData?.mergeable}`), + ], + }); block.addDividerBlock(); - - let index=1; - + let index = 1; for (let file of pullRequestFiles) { - let fileName = file["filename"]; let rawUrl = file["raw_url"]; let status = file["status"]; let addition = file["additions"]; let deletions = file["deletions"]; - block.addSectionBlock({ - text: { text: `${index} ${fileName}`, type: TextObjectType.PLAINTEXT }, - accessory: block.newButtonElement({ - actionId: ModalsEnum.VIEW_FILE_ACTION, - text: { - text: ModalsEnum.VIEW_FILE_ACTION_LABEL, - type: TextObjectType.PLAINTEXT - }, - value: rawUrl - }) - }); - block.addContextBlock({ elements: [ block.newPlainTextObject(`Status: ${status} | `),block.newPlainTextObject(`Additions : ${addition} | `),block.newPlainTextObject(`Deletions : ${deletions}`) ]}); - - index++; + block.addSectionBlock({ + text: { + text: `${index} ${fileName}`, + type: TextObjectType.PLAINTEXT, + }, + accessory: block.newButtonElement({ + actionId: ModalsEnum.VIEW_FILE_ACTION, + text: { + text: ModalsEnum.VIEW_FILE_ACTION_LABEL, + type: TextObjectType.PLAINTEXT, + }, + value: rawUrl, + }), + }); + block.addContextBlock({ + elements: [ + block.newPlainTextObject(`Status: ${status} | `), + block.newPlainTextObject(`Additions : ${addition} | `), + block.newPlainTextObject(`Deletions : ${deletions}`), + ], + }); + + index++; } } - - block.addActionsBlock({ elements: [ block.newButtonElement({ actionId: ModalsEnum.MERGE_PULL_REQUEST_ACTION, - text: { text: ModalsEnum.MERGE_PULL_REQUEST_LABEL, type: TextObjectType.PLAINTEXT }, - value: room?.id + text: { + text: ModalsEnum.MERGE_PULL_REQUEST_LABEL, + type: TextObjectType.PLAINTEXT, + }, + value: room?.id, }), block.newButtonElement({ actionId: ModalsEnum.COMMENT_PR_ACTION, - text: { text: ModalsEnum.COMMENT_PR_LABEL, type: TextObjectType.PLAINTEXT }, - value: room?.id + text: { + text: ModalsEnum.COMMENT_PR_LABEL, + type: TextObjectType.PLAINTEXT, + }, + value: room?.id, }), - ] + ], }); return { @@ -99,9 +146,9 @@ export async function pullDetailsModal({ data, modify, read, persistence, http, close: block.newButtonElement({ text: { type: TextObjectType.PLAINTEXT, - text: 'Close', + text: "Close", }, }), blocks: block.getBlocks(), }; -} \ No newline at end of file +} diff --git a/github/modals/subscriptionsModal.ts b/github/modals/subscriptionsModal.ts new file mode 100644 index 0000000..e91831b --- /dev/null +++ b/github/modals/subscriptionsModal.ts @@ -0,0 +1,127 @@ +import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import { ITextObject, TextObjectType } from '@rocket.chat/apps-engine/definition/uikit/blocks'; +import { IUIKitModalViewParam } from '@rocket.chat/apps-engine/definition/uikit/UIKitInteractionResponder'; +import { IUser } from '@rocket.chat/apps-engine/definition/users'; +import { ModalsEnum } from '../enum/Modals'; +import { AppEnum } from '../enum/App'; +// import { getRoomTasks, getUIData, persistUIData } from '../lib/persistence'; +import { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands'; +import { UIKitBlockInteractionContext, UIKitInteractionContext } from '@rocket.chat/apps-engine/definition/uikit'; +import { getInteractionRoomData, storeInteractionRoomData } from '../persistance/roomInteraction'; +import { Subscription } from '../persistance/subscriptions'; +import { ISubscription } from '../definitions/subscription'; +import { IRepositorySubscriptions } from '../definitions/repositorySubscriptions'; + +export async function subsciptionsModal({ modify, read, persistence, http, slashcommandcontext, uikitcontext }: { modify: IModify, read: IRead, persistence: IPersistence, http: IHttp ,slashcommandcontext?: SlashCommandContext, uikitcontext?: UIKitInteractionContext }): Promise { + const viewId = ModalsEnum.SUBSCRIPTION_VIEW; + + const block = modify.getCreator().getBlockBuilder(); + + const room = slashcommandcontext?.getRoom() || uikitcontext?.getInteractionData().room; + const user = slashcommandcontext?.getSender() || uikitcontext?.getInteractionData().user; + + if (user?.id) { + let roomId; + if (room?.id) { + roomId = room.id; + await storeInteractionRoomData(persistence, user.id, roomId); + } else { + roomId = (await getInteractionRoomData(read.getPersistenceReader(), user.id)).roomId; + } + + let subsciptionStorage = new Subscription(persistence,read.getPersistenceReader()); + let roomSubsciptions: Array = await subsciptionStorage.getSubscriptions(roomId); + + block.addDividerBlock(); + + + let repositoryData = new Map; + for (let subsciption of roomSubsciptions) { + + let repoName = subsciption.repoName; + let userId = subsciption.user; + let event = subsciption.event; + let user = await read.getUserReader().getById(userId); + + if(repositoryData.has(repoName)){ + let repoData = repositoryData.get(repoName) as IRepositorySubscriptions; + repoData.events.push(event); + repoData.user=user; + repositoryData.set(repoName,repoData); + }else{ + let events:Array = []; + events.push(event); + let repoData:IRepositorySubscriptions={ + webhookId:subsciption.webhookId, + events:events, + user:user, + repoName:repoName + }; + repositoryData.set(repoName,repoData); + } + + } + let index=1; + for (let repository of repositoryData.values()) { + let repoName = repository.repoName; + let repoUser = repository.user; + let events = repository.events; + block.addSectionBlock({ + text: { text: `${index}) ${repoName}`, type: TextObjectType.PLAINTEXT}, + accessory: block.newButtonElement({ + actionId: ModalsEnum.OPEN_REPO_ACTION, + text: { + text: ModalsEnum.OPEN_REPO_LABEL, + type: TextObjectType.PLAINTEXT + }, + value: repository.webhookId, + url:`https://github.com/${repoName}` + }) + }); + let eventList : Array=[]; + eventList.push(block.newPlainTextObject("Events : ")); + for(let event of events){ + eventList.push(block.newPlainTextObject(`${event} `)); + } + block.addContextBlock({ elements: eventList}); + index++; + } + } + + block.addDividerBlock(); + + block.addActionsBlock({ + elements: [ + block.newButtonElement({ + actionId: ModalsEnum.OPEN_ADD_SUBSCRIPTIONS_MODAL, + text: { text: ModalsEnum.OPEN_ADD_SUBSCRIPTIONS_LABEL, type: TextObjectType.PLAINTEXT }, + value: room?.id + }), + block.newButtonElement({ + actionId: ModalsEnum.OPEN_DELETE_SUBSCRIPTIONS_MODAL, + text: { text: ModalsEnum.OPEN_DELETE_SUBSCRIPTIONS_LABEL, type: TextObjectType.PLAINTEXT }, + value: room?.id + }), + block.newButtonElement({ + actionId: ModalsEnum.SUBSCRIPTION_REFRESH_ACTION, + text: { text: ModalsEnum.SUBSCRIPTION_REFRESH_LABEL, type: TextObjectType.PLAINTEXT }, + value: room?.id + }), + ] + }); + + return { + id: viewId, + title: { + type: TextObjectType.PLAINTEXT, + text: ModalsEnum.SUBSCRIPTION_TITLE, + }, + close: block.newButtonElement({ + text: { + type: TextObjectType.PLAINTEXT, + text: 'Close', + }, + }), + blocks: block.getBlocks(), + }; +} \ No newline at end of file diff --git a/github/oath2/authentication.ts b/github/oath2/authentication.ts new file mode 100644 index 0000000..4108fd2 --- /dev/null +++ b/github/oath2/authentication.ts @@ -0,0 +1,33 @@ +import { + 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 { GithubApp } from "../GithubApp"; +import { IButton, createSectionBlock } from "../lib/blocks"; +import { sendNotification } from "../lib/message"; +import { storeInteractionRoomData } from "../persistance/roomInteraction"; + +export async function authorize( + app: GithubApp, + read: IRead, + modify: IModify, + user: IUser, + room: IRoom, + persistence: IPersistence +): Promise { + const url = await app + .getOauth2ClientInstance() + .getUserAuthorizationUrl(user); + + const button: IButton = { + text: "GitHub Login", + url: url.toString(), + }; + const message = `Login to GitHub`; + const block = await createSectionBlock(modify, message, button); + await storeInteractionRoomData(persistence,user.id,room.id); + await sendNotification(read, modify, user, room, message, block); +} diff --git a/github/persistance/auth.ts b/github/persistance/auth.ts new file mode 100644 index 0000000..a3f4b10 --- /dev/null +++ b/github/persistance/auth.ts @@ -0,0 +1,128 @@ +import { + IHttp, + IHttpRequest, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { + RocketChatAssociationModel, + RocketChatAssociationRecord, +} from "@rocket.chat/apps-engine/definition/metadata"; +import { + IAuthData, + IOAuth2ClientOptions, +} from "@rocket.chat/apps-engine/definition/oauth2/IOAuth2"; +import { IUser } from "@rocket.chat/apps-engine/definition/users"; +import { URL } from "url"; + +// const assoc = new RocketChatAssociationRecord(RocketChatAssociationModel.MISC, 'users'); + +// export async function create(read: IRead, persistence: IPersistence, user: IUser): Promise { +// const users = await getAllUsers(read); + +// if (!users) { +// await persistence.createWithAssociation([user], assoc); +// return; +// } + +// if (!isUserPresent(users, user)) { +// users.push(user); +// await persistence.updateByAssociation(assoc, users); +// } +// } + +// export async function remove(read: IRead, persistence: IPersistence, user: IUser): Promise { +// const users = await getAllUsers(read); + +// if (!users || !isUserPresent(users, user)) { +// // @NOTE do nothing +// return; +// } + +// const idx = users.findIndex((u: IUser) => u.id === user.id); +// users.splice(idx, 1); +// await persistence.updateByAssociation(assoc, users); +// } + +// export async function getAllUsers(read: IRead): Promise { +// const data = await read.getPersistenceReader().readByAssociation(assoc); +// return (data.length ? data[0] as IUser[] : []); +// } + +// function isUserPresent(users: IUser[], targetUser: IUser): boolean { +// return users.some((user) => user.id === targetUser.id); +// } + +/** + * This function needed to be copied from the apps engine due to difficulties trying to + * get access to the auth client from inside a job processor. + * @NOTE It relies on hardcoded information (config alias's suffix) to work and it might break if + * the value changes + */ + +export async function getAccessTokenForUser( + read: IRead, + user: IUser, + config: IOAuth2ClientOptions +): Promise { + const associations = [ + new RocketChatAssociationRecord( + RocketChatAssociationModel.USER, + user.id + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + `${config.alias}-oauth-connection` + ), + ]; + + const [result] = (await read + .getPersistenceReader() + .readByAssociations(associations)) as unknown as Array< + IAuthData | undefined + >; + return result; +} + +export async function removeToken({ + userId, + persis, + config, +}: { + userId: string; + persis: IPersistence; + config: IOAuth2ClientOptions; +}): Promise { + const [result] = (await persis.removeByAssociations([ + new RocketChatAssociationRecord( + RocketChatAssociationModel.USER, + userId + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + `${config.alias}-oauth-connection` + ), + ])) as unknown as Array; + + return result; +} + +export async function revokeUserAccessToken( + read: IRead, + user: IUser, + persis: IPersistence, + http: IHttp, + config: IOAuth2ClientOptions +): Promise { + try { + const tokenInfo = await getAccessTokenForUser(read, user, config); + if (!tokenInfo?.token) { + throw new Error("No access token available for this user."); + } + await removeToken({ userId: user.id, persis, config }); + return true; + } catch (error) { + console.log("revokeTokenError : ", error); + return false; + } +} diff --git a/github/persistance/roomInteraction.ts b/github/persistance/roomInteraction.ts new file mode 100644 index 0000000..c0b9abb --- /dev/null +++ b/github/persistance/roomInteraction.ts @@ -0,0 +1,51 @@ +import { + IPersistence, + IPersistenceRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { + RocketChatAssociationModel, + RocketChatAssociationRecord, +} from "@rocket.chat/apps-engine/definition/metadata"; + +//functions needed ro persist room data while modal and other UI interactions + +export const storeInteractionRoomData = async ( + persistence: IPersistence, + userId: string, + roomId: string +): Promise => { + const association = new RocketChatAssociationRecord( + RocketChatAssociationModel.USER, + `${userId}#RoomId` + ); + await persistence.updateByAssociation( + association, + { roomId: roomId }, + true + ); +}; + +export const getInteractionRoomData = async ( + persistenceRead: IPersistenceRead, + userId: string +): Promise => { + const association = new RocketChatAssociationRecord( + RocketChatAssociationModel.USER, + `${userId}#RoomId` + ); + const result = (await persistenceRead.readByAssociation( + association + )) as Array; + return result && result.length ? result[0] : null; +}; + +export const clearInteractionRoomData = async ( + persistence: IPersistence, + userId: string +): Promise => { + const association = new RocketChatAssociationRecord( + RocketChatAssociationModel.USER, + `${userId}#RoomId` + ); + await persistence.removeByAssociation(association); +}; diff --git a/github/persistance/subscriptions.ts b/github/persistance/subscriptions.ts new file mode 100644 index 0000000..db45e77 --- /dev/null +++ b/github/persistance/subscriptions.ts @@ -0,0 +1,237 @@ +import { + IPersistence, + IPersistenceRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { + RocketChatAssociationModel, + RocketChatAssociationRecord, +} from "@rocket.chat/apps-engine/definition/metadata"; +import { IRoom } from "@rocket.chat/apps-engine/definition/rooms"; +import { IUser } from "@rocket.chat/apps-engine/definition/users"; +import { ISubscription } from "../definitions/subscription"; + +export class Subscription { + constructor( + private readonly persistence: IPersistence, + private readonly persistenceRead: IPersistenceRead + ) {} + + public async createSubscription( + repoName: string, + event: string, + webhookId: string, + room: IRoom, + user: IUser + ): Promise { + try { + const associations: Array = [ + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + `subscription` + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + `repo:${repoName}` + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.ROOM, + room.id + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + event + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.USER, + `${user.id}` + ), + ]; + let subscriptionRecord: ISubscription = { + webhookId: webhookId, + user: user.id, + repoName: repoName, + room: room.id, + event: event, + }; + await this.persistence.updateByAssociations( + associations, + subscriptionRecord, + true + ); + } catch (error) { + console.warn("Subsciption Error :", error); + return false; + } + return true; + } + + public async getSubscribedRooms( + repoName: string, + event: string + ): Promise> { + try { + const associations: Array = [ + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + `subscription` + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + `repo:${repoName}` + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + event + ), + ]; + let subsciptions: Array = + (await this.persistenceRead.readByAssociations( + associations + )) as Array; + return subsciptions; + } catch (error) { + console.warn("Get Subscribed Rooms Error :", error); + let subsciptions: Array = []; + return subsciptions; + } + } + + public async getSubscriptions( + roomId: string + ): Promise> { + try { + const associations: Array = [ + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + `subscription` + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.ROOM, + roomId + ), + ]; + let subsciptions: Array = + (await this.persistenceRead.readByAssociations( + associations + )) as Array; + return subsciptions; + } catch (error) { + console.warn("Get Subsciption Error :", error); + let subsciptions: Array = []; + return subsciptions; + } + } + + public async deleteSubscriptions( + repoName: string, + event: string, + roomId: string + ): Promise { + try { + const associations: Array = [ + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + `subscription` + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + `repo:${repoName}` + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.ROOM, + roomId + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + event + ), + ]; + await this.persistence.removeByAssociations(associations); + } catch (error) { + console.warn("Delete Subsciption Error :", error); + return false; + } + return true; + } + public async deleteSubscriptionsByRepoUser( + repoName: string, + roomId: string, + userId: string + ): Promise { + try { + const associations: Array = [ + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + `subscription` + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + `repo:${repoName}` + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.USER, + `${userId}` + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.ROOM, + roomId + ), + ]; + await this.persistence.removeByAssociations(associations); + } catch (error) { + console.warn("Delete Subsciption Error :", error); + return false; + } + return true; + } + + public async deleteAllRoomSubscriptions(roomId: string): Promise { + try { + const associations: Array = [ + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + `subscription` + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.ROOM, + roomId + ), + ]; + await this.persistence.removeByAssociations(associations); + } catch (error) { + console.warn("Delete All Room Subsciption Error :", error); + return false; + } + return true; + } + + public async getSubscriptionsByRepo( + repoName: string, + userId: string + ): Promise> { + let subsciptions: Array = []; + try { + const associations: Array = [ + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + `subscription` + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.MISC, + `repo:${repoName}` + ), + new RocketChatAssociationRecord( + RocketChatAssociationModel.USER, + `${userId}` + ), + ]; + subsciptions = (await this.persistenceRead.readByAssociations( + associations + )) as Array; + } catch (error) { + console.warn("Get Subsciptions By Repo Error :", error); + return subsciptions; + } + return subsciptions; + } +} diff --git a/github/processors/deleteOAthToken.ts b/github/processors/deleteOAthToken.ts new file mode 100644 index 0000000..5e7d1c5 --- /dev/null +++ b/github/processors/deleteOAthToken.ts @@ -0,0 +1,49 @@ +import { + IHttp, + IModify, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; + +import { sendDirectMessage } from "../lib/message"; +import { IJobContext } from "@rocket.chat/apps-engine/definition/scheduler"; +import { IUser } from "@rocket.chat/apps-engine/definition/users"; +import { IRoom } from "@rocket.chat/apps-engine/definition/rooms"; +import { + getAccessTokenForUser, + revokeUserAccessToken, +} from "../persistance/auth"; +import { IOAuth2ClientOptions } from "@rocket.chat/apps-engine/definition/oauth2/IOAuth2"; + +export async function deleteOathToken({ + user, + config, + read, + modify, + http, + persis, +}: { + user: IUser; + config: IOAuth2ClientOptions; + read: IRead; + modify: IModify; + http: IHttp; + persis: IPersistence; +}) { + try { + let token = await getAccessTokenForUser(read, user, config); + if (token?.token) { + await revokeUserAccessToken(read, user, persis, http, config); + } + token = await getAccessTokenForUser(read, user, config); + await sendDirectMessage( + read, + modify, + user, + "GitHub Token Expired, Login to GitHub Again ! `/github login`", + persis + ); + } catch (error) { + console.log("deleteOathToken error : ", error); + } +}