Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[NEW][APPS] Allow Rocket.Chat Apps to register custom action buttons #23679

Merged
merged 13 commits into from
Dec 20, 2021
Merged
5 changes: 5 additions & 0 deletions app/apps/client/communication/websockets.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Emitter } from '@rocket.chat/emitter';

import { slashCommands, APIClient } from '../../../utils';
import { CachedCollectionManager } from '../../../ui-cached-collection';
import { loadButtons } from '../../../ui-message/client/ActionButtonSyncer';

export const AppEvents = Object.freeze({
APP_ADDED: 'app/added',
Expand All @@ -14,6 +15,7 @@ export const AppEvents = Object.freeze({
COMMAND_DISABLED: 'command/disabled',
COMMAND_UPDATED: 'command/updated',
COMMAND_REMOVED: 'command/removed',
ACTIONS_CHANGED: 'actions/changed',
});

export class AppWebsocketReceiver extends Emitter {
Expand All @@ -36,6 +38,7 @@ export class AppWebsocketReceiver extends Emitter {
this.streamer.on(AppEvents.COMMAND_UPDATED, this.onCommandAddedOrUpdated);
this.streamer.on(AppEvents.COMMAND_REMOVED, this.onCommandRemovedOrDisabled);
this.streamer.on(AppEvents.COMMAND_DISABLED, this.onCommandRemovedOrDisabled);
this.streamer.on(AppEvents.COMMAND_DISABLED, this.onActionsChanged);
}

registerListener(event, listener) {
Expand All @@ -55,4 +58,6 @@ export class AppWebsocketReceiver extends Emitter {
onCommandRemovedOrDisabled = (command) => {
delete slashCommands.commands[command];
}

onActionsChanged = () => loadButtons();
}
4 changes: 4 additions & 0 deletions app/apps/server/bridges/activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,8 @@ export class AppActivationBridge extends ActivationBridge {

await this.orch.getNotifier().appStatusUpdated(app.getID(), status);
}

protected async actionsChanged(): Promise<void> {
await this.orch.getNotifier().actionsChanged();
}
}
16 changes: 16 additions & 0 deletions app/apps/server/communication/endpoints/actionButtonsHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { AppManager } from '@rocket.chat/apps-engine/server/AppManager';

import { API } from '../../../../api/server';
import { AppsRestApi } from '../rest';

export const actionButtonsHandler = (apiManager: AppsRestApi): [Record<string, any>, Record<string, Function>] => [{
authRequired: false,
}, {
get(): any {
const manager = apiManager._manager as AppManager;

const buttons = manager.getUIActionButtonManager().getAllActionButtons();

return API.v1.success(buttons);
},
}];
6 changes: 5 additions & 1 deletion app/apps/server/communication/rest.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { getWorkspaceAccessToken, getUserCloudAccessToken } from '../../../cloud
import { settings } from '../../../settings/server';
import { Info } from '../../../utils';
import { Users } from '../../../models/server';
import { Settings } from '../../../models/server/raw';
import { Apps } from '../orchestrator';
import { formatAppInstanceForRest } from '../../lib/misc/formatAppInstanceForRest';
import { Settings } from '../../../models/server/raw';
import { actionButtonsHandler } from './endpoints/actionButtonsHandler';

const appsEngineVersionForMarketplace = Info.marketplaceApiVersion.replace(/-.*/g, '');
const getDefaultHeaders = () => ({
Expand Down Expand Up @@ -61,6 +62,9 @@ export class AppsRestApi {
return API.v1.failure();
};

this.api.addRoute('actionButtons', ...actionButtonsHandler(this));

// WE NEED TO MOVE EACH ENDPOINT HANDLER TO IT'S OWN FILE
this.api.addRoute('', { authRequired: true, permissionsRequired: ['manage-apps'] }, {
get() {
const baseUrl = orchestrator.getMarketplaceUrl();
Expand Down
41 changes: 40 additions & 1 deletion app/apps/server/communication/uikit.js
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,47 @@ const appsRoutes = (orch) => (req, res) => {
break;
}

case UIKitIncomingInteractionType.ACTION_BUTTON: {
const {
type,
actionId,
triggerId,
rid,
mid,
payload: {
context,
},
} = req.body;

const room = orch.getConverters().get('rooms').convertById(rid);
const user = orch.getConverters().get('users').convertToApp(req.user);
const message = mid && orch.getConverters().get('messages').convertById(mid);

const action = {
type,
appId,
actionId,
triggerId,
user,
room,
message,
payload: {
context,
},
};

try {
const result = Promise.await(orch.triggerEvent('IUIKitInteractionHandler', action));

res.send(result);
} catch (e) {
res.status(500).send(e.message);
}
break;
}

default: {
res.status(500).send({ error: 'Unknown action' });
res.status(400).send({ error: 'Unknown action' });
}
}

Expand Down
10 changes: 10 additions & 0 deletions app/apps/server/communication/websockets.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const AppEvents = Object.freeze({
COMMAND_DISABLED: 'command/disabled',
COMMAND_UPDATED: 'command/updated',
COMMAND_REMOVED: 'command/removed',
ACTIONS_CHANGED: 'actions/changed',
});

export class AppServerListener {
Expand All @@ -26,6 +27,7 @@ export class AppServerListener {
this.engineStreamer.on(AppEvents.APP_REMOVED, this.onAppRemoved.bind(this));
this.engineStreamer.on(AppEvents.APP_UPDATED, this.onAppUpdated.bind(this));
this.engineStreamer.on(AppEvents.APP_ADDED, this.onAppAdded.bind(this));
this.engineStreamer.on(AppEvents.ACTIONS_CHANGED, this.onActionsChanged.bind(this));

this.engineStreamer.on(AppEvents.APP_SETTING_UPDATED, this.onAppSettingUpdated.bind(this));
this.engineStreamer.on(AppEvents.COMMAND_ADDED, this.onCommandAdded.bind(this));
Expand Down Expand Up @@ -102,6 +104,10 @@ export class AppServerListener {
async onCommandRemoved(command) {
this.clientStreamer.emitWithoutBroadcast(AppEvents.COMMAND_REMOVED, command);
}

async onActionsChanged() {
this.clientStreamer.emitWithoutBroadcast(AppEvents.ACTIONS_CHANGED);
}
}

export class AppServerNotifier {
Expand Down Expand Up @@ -177,4 +183,8 @@ export class AppServerNotifier {
this.engineStreamer.emit(AppEvents.COMMAND_REMOVED, command);
this.clientStreamer.emitWithoutBroadcast(AppEvents.COMMAND_REMOVED, command);
}

async actionsChanged() {
this.clientStreamer.emitWithoutBroadcast(AppEvents.ACTIONS_CHANGED);
}
}
51 changes: 51 additions & 0 deletions app/ui-message/client/ActionButtonSyncer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Meteor } from 'meteor/meteor';
import { IUIActionButton, UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui';

import { APIClient } from '../../utils/client';
import * as TabBar from './actionButtons/tabbar';
import * as MessageAction from './actionButtons/messageAction';
import * as MessageBox from './actionButtons/messageBox';

let registeredButtons: Array<IUIActionButton> = [];

export const addButton = (button: IUIActionButton): void => {
switch (button.context) {
case UIActionButtonContext.MESSAGE_ACTION:
MessageAction.onAdded(button);
break;
case UIActionButtonContext.ROOM_ACTION:
TabBar.onAdded(button);
break;
case UIActionButtonContext.MESSAGE_BOX_ACTION:
MessageBox.onAdded(button);
break;
}

registeredButtons.push(Object.freeze(button));
};

export const removeButton = (button: IUIActionButton): void => {
switch (button.context) {
case UIActionButtonContext.MESSAGE_ACTION:
MessageAction.onRemoved(button);
break;
case UIActionButtonContext.ROOM_ACTION:
TabBar.onRemoved(button);
break;
case UIActionButtonContext.MESSAGE_BOX_ACTION:
MessageBox.onRemoved(button);
break;
}
};

export const loadButtons = (): Promise<void> => APIClient.get('apps/actionButtons')
.then((value: Array<IUIActionButton>) => {
registeredButtons.forEach((button) => removeButton(button));
registeredButtons = [];
value.map(addButton);
return registeredButtons;
})
.then(console.log)
.catch(console.error);

Meteor.startup(() => loadButtons());
16 changes: 15 additions & 1 deletion app/ui-message/client/ActionManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { modal } from '../../ui-utils/client/lib/modal';
import { APIClient } from '../../utils';
import { UIKitInteractionTypes } from '../../../definition/UIKit';
import * as banners from '../../../client/lib/banners';
import { dispatchToastMessage } from '../../../client/lib/toast';
import { t } from '../../utils/client';

const events = new Emitter();

Expand All @@ -23,6 +25,8 @@ export const off = (...args) => {

const TRIGGER_TIMEOUT = 5000;

const TRIGGER_TIMEOUT_ERROR = 'TRIGGER_TIMEOUT_ERROR';

const triggersId = new Map();

const instances = new Map();
Expand Down Expand Up @@ -160,7 +164,7 @@ export const triggerAction = async ({ type, actionId, appId, rid, mid, viewId, c

const payload = rest.payload || rest;

setTimeout(reject, TRIGGER_TIMEOUT, triggerId);
setTimeout(reject, TRIGGER_TIMEOUT, [TRIGGER_TIMEOUT_ERROR, { triggerId, appId }]);

const { type: interactionType, ...data } = await APIClient.post(
`apps/ui.interaction/${ appId }`,
Expand All @@ -172,6 +176,16 @@ export const triggerAction = async ({ type, actionId, appId, rid, mid, viewId, c

export const triggerBlockAction = (options) => triggerAction({ type: UIKitIncomingInteractionType.BLOCK, ...options });

export const triggerActionButtonAction = (options) =>
triggerAction({ type: UIKitIncomingInteractionType.ACTION_BUTTON, ...options }).catch(async (reason) => {
if (Array.isArray(reason) && reason[0] === TRIGGER_TIMEOUT_ERROR) {
dispatchToastMessage({
type: 'error',
message: t('UIKit_Interaction_Timeout'),
});
}
});

export const triggerSubmitView = async ({ viewId, ...options }) => {
const close = () => {
const instance = instances.get(viewId);
Expand Down
49 changes: 49 additions & 0 deletions app/ui-message/client/actionButtons/lib/applyButtonFilters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/* Style disabled as having some arrow functions in one-line hurts readability */
/* eslint-disable arrow-body-style */

import { Meteor } from 'meteor/meteor';
import { IUIActionButton, RoomTypeFilter } from '@rocket.chat/apps-engine/definition/ui';

import { hasAtLeastOnePermission, hasPermission, hasRole } from '../../../../authorization/client';
import {
IRoom,
isDirectMessageRoom,
isMultipleDirectMessageRoom,
isOmnichannelRoom,
isPrivateDiscussion,
isPrivateTeamRoom,
isPublicDiscussion,
isPublicTeamRoom,
} from '../../../../../definition/IRoom';

export const applyAuthFilter = (button: IUIActionButton, room?: IRoom): boolean => {
const { hasAllPermissions, hasOnePermission, hasAllRoles, hasOneRole } = button.when || {};

const hasAllPermissionsResult = hasAllPermissions ? hasPermission(hasAllPermissions) : true;
const hasOnePermissionResult = hasOnePermission ? hasAtLeastOnePermission(hasOnePermission) : true;
const hasAllRolesResult = hasAllRoles ? hasAllRoles.every((role) => hasRole(Meteor.userId(), role, room?._id)) : true;
const hasOneRoleResult = hasOneRole ? hasRole(Meteor.userId(), hasOneRole, room?._id) : true;

return hasAllPermissionsResult && hasOnePermissionResult && hasAllRolesResult && hasOneRoleResult;
};

const enumToFilter: {[k in RoomTypeFilter]: (room: IRoom) => boolean} = {
[RoomTypeFilter.PUBLIC_CHANNEL]: (room) => room.t === 'c',
[RoomTypeFilter.PRIVATE_CHANNEL]: (room) => room.t === 'p',
[RoomTypeFilter.PUBLIC_TEAM]: isPublicTeamRoom,
[RoomTypeFilter.PRIVATE_TEAM]: isPrivateTeamRoom,
[RoomTypeFilter.PUBLIC_DISCUSSION]: isPublicDiscussion,
[RoomTypeFilter.PRIVATE_DISCUSSION]: isPrivateDiscussion,
[RoomTypeFilter.DIRECT]: isDirectMessageRoom,
[RoomTypeFilter.DIRECT_MULTIPLE]: isMultipleDirectMessageRoom,
[RoomTypeFilter.LIVE_CHAT]: isOmnichannelRoom,
};

export const applyRoomFilter = (button: IUIActionButton, room: IRoom): boolean => {
const { roomTypes } = button.when || {};
return !roomTypes || roomTypes.some((filter): boolean => enumToFilter[filter]?.(room));
};

export const applyButtonFilters = (button: IUIActionButton, room?: IRoom): boolean => {
return applyAuthFilter(button, room) && (!room || applyRoomFilter(button, room));
};
32 changes: 32 additions & 0 deletions app/ui-message/client/actionButtons/messageAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { IUIActionButton } from '@rocket.chat/apps-engine/definition/ui';

import { Utilities } from '../../../apps/lib/misc/Utilities';
import { MessageAction, messageArgs } from '../../../ui-utils/client';
import { t } from '../../../utils/client';
import { triggerActionButtonAction } from '../ActionManager';
import { applyButtonFilters } from './lib/applyButtonFilters';

const getIdForActionButton = ({ appId, actionId }: IUIActionButton): string => `${ appId }/${ actionId }`;

// eslint-disable-next-line no-void
export const onAdded = (button: IUIActionButton): void => void MessageAction.addButton({
id: getIdForActionButton(button),
// icon: button.icon || '',
label: t(Utilities.getI18nKeyForApp(button.labelI18n, button.appId)),
context: button.when?.messageActionContext || ['message', 'message-mobile', 'threads', 'starred'],
condition({ room }: any) {
return applyButtonFilters(button, room);
},
async action() {
const { msg } = messageArgs(this);
triggerActionButtonAction({
rid: msg.rid,
mid: msg._id,
actionId: button.actionId,
appId: button.appId,
payload: { context: button.context },
});
},
});

export const onRemoved = (button: IUIActionButton): void => MessageAction.removeButton(getIdForActionButton(button));
36 changes: 36 additions & 0 deletions app/ui-message/client/actionButtons/messageBox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Session } from 'meteor/session';
import { IUIActionButton } from '@rocket.chat/apps-engine/definition/ui';

import { Rooms } from '../../../models/client';
import { messageBox } from '../../../ui-utils/client';
import { applyButtonFilters } from './lib/applyButtonFilters';
import { triggerActionButtonAction } from '../ActionManager';
import { t } from '../../../utils/client';
import { Utilities } from '../../../apps/lib/misc/Utilities';

const getIdForActionButton = ({ appId, actionId }: IUIActionButton): string => `${ appId }/${ actionId }`;

const APP_GROUP = 'Create_new';

// eslint-disable-next-line no-void
export const onAdded = (button: IUIActionButton): void => void messageBox.actions.add(
APP_GROUP,
t(Utilities.getI18nKeyForApp(button.labelI18n, button.appId)),
{
id: getIdForActionButton(button),
// icon: button.icon || '',
condition() {
return applyButtonFilters(button, Rooms.findOne(Session.get('openedRoom')));
},
action() {
triggerActionButtonAction({
rid: Session.get('openedRoom'),
actionId: button.actionId,
appId: button.appId,
payload: { context: button.context },
});
},
});

// eslint-disable-next-line no-void
export const onRemoved = (button: IUIActionButton): void => void messageBox.actions.remove(APP_GROUP, new RegExp(getIdForActionButton(button)));
Loading