Просмотр исходного кода

feat: Slackbot unfurl (#4720)

* Implemented link_shared event handler for unfurling

* Modified error handling

* Modified comment

* Worked

* Remove unnecessary line

* Modified method name

* Improved process

* Deleted unnecessary code

* Deleted unnecessary if statement

* Improved types

* Revert some changes

* Implemented route

* Modified

* Improved query & Added validator & Modified isPrivate -> isPublic

* Revert "Improved query & Added validator & Modified isPrivate -> isPublic"

This reverts commit d0af7823f11bb0ab17d8f67244cdb90d31e1c268.

* Improved query & Added validator & Modified isPrivate -> isPublic

* isPrivate -> isPublic

* Refactored

* Renamed

* Modified comments

* Fixed queryBuilder

* Fixed

* Implemented growi side events handler & unfurl handler

* Deleted hard code

* Modified

* Refactored

* Fixed lint error

* Modified

* Modified validator

* WIP

* Modified data type

* Modified

* Modified

* Worked

* Modified

* Modified

* Added private keyword

* Improved appearance & Improved error handling

* Removed unncessary import

* Worked without verifying slack request

* Modified

* Added verify middleware

* Added comments

* Renamed & Added new attribute

* Added without proxy config for event actions

* Implemented permission check process

* Implemented crud

* Modified

* Added validators

* Added comments

* Fixed validators

* Added comment

* Default to false

* Modified target url

* WIP

* WIP

* Worked

* Improved a global middleware

* wip

* props

* wip

* impl

* fix

* whitespace fix

* wip

* wip success showing

* state, function refactor

* fb fix

* wip

* wip

* fb fix

* i18n

* capitalization tweak

* fb fix

* wip fb fix

* wip

* refactor

* tweak

* fb fix

* fb fix

* Changed the way to inject rawBody to req

* Improved section name

* Set default as false

* Fix

* Fixed

* Improved translation

Co-authored-by: Yuki Takei <yuki@weseek.co.jp>
Co-authored-by: Steven Fukase <fukasesteven@gmail.com>
Haku Mizuki 4 лет назад
Родитель
Сommit
fc23ba0c64
30 измененных файлов с 905 добавлено и 210 удалено
  1. 8 5
      packages/app/resource/locales/en_US/admin/admin.json
  2. 8 5
      packages/app/resource/locales/ja_JP/admin/admin.json
  3. 8 5
      packages/app/resource/locales/zh_CN/admin/admin.json
  4. 2 1
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  5. 2 0
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx
  6. 5 4
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  7. 238 122
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  8. 56 23
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx
  9. 11 1
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  10. 5 2
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  11. 12 0
      packages/app/src/server/crowi/express-init.js
  12. 1 0
      packages/app/src/server/interfaces/slack-integration/events.ts
  13. 32 0
      packages/app/src/server/interfaces/slack-integration/link-shared-unfurl.ts
  14. 11 0
      packages/app/src/server/models/page.js
  15. 5 1
      packages/app/src/server/models/slack-app-integration.js
  16. 42 29
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  17. 70 1
      packages/app/src/server/routes/apiv3/slack-integration.js
  18. 6 0
      packages/app/src/server/service/config-loader.ts
  19. 12 0
      packages/app/src/server/service/slack-event-handler/base-event-handler.ts
  20. 177 0
      packages/app/src/server/service/slack-event-handler/link-shared.ts
  21. 17 2
      packages/app/src/server/service/slack-integration.ts
  22. 6 0
      packages/slack/src/index.ts
  23. 4 0
      packages/slack/src/interfaces/growi-bot-event.ts
  24. 7 0
      packages/slack/src/interfaces/growi-event-processor.ts
  25. 10 2
      packages/slack/src/middlewares/verify-slack-request.ts
  26. 14 3
      packages/slackbot-proxy/src/controllers/slack.ts
  27. 4 2
      packages/slackbot-proxy/src/middlewares/slack-to-growi/url-verification.ts
  28. 4 0
      packages/slackbot-proxy/src/repositories/relation.ts
  29. 126 0
      packages/slackbot-proxy/src/services/LinkSharedService.ts
  30. 2 2
      packages/slackbot-proxy/src/services/RegisterService.ts

+ 8 - 5
packages/app/resource/locales/en_US/admin/admin.json

@@ -339,16 +339,19 @@
       "install_complete_if_checked": "Confirm that \"Install your app\" is checked.",
       "invite_bot_to_channel": "Invite GROWI bot to channel by calling @example.",
       "register_secret_and_token": "Set Signing Secret and Bot Token",
-      "manage_commands": "Manage GROWI commands",
+      "manage_permission": "Manage Permission",
+      "growi_commands": "GROWI Commands",
       "multiple_growi_command": "Commands that could be sent to multiple GROWI instances at once",
       "single_growi_command": "Commands that could be sent to single GROWI instance at a time",
-      "allowed_channels_description": "Enter the allowed channel ID or channel name for \"{{commandName}}\" command. Only channel IDs are allowed for private channels. Separate each channel with \",\" . Users will be able to use \"{{commandName}}\" command from channels written here.",
+      "allowed_channels_description": "Input allowed channels for \"{{keyName}}\" command. Separate each channel with \",\" . Users can will be able to use \"{{keyName}}\" command from channels written here.",
+      "unfurl_description": "Show GROWI page contents when page links have been shared on Slack",
+      "unfurl_allowed_channels_description": "Input allowed channel IDs for \"unfurl\" . Separate each channel with \",\" . GROWI public page links or permanent links sent in specified channels will show the content in the message.",
       "allow_all": "Allow all",
       "deny_all": "Deny all",
       "allow_specified": "Allow specified",
-      "allow_all_long": "Allow all (The command is allowed from any channel)",
-      "deny_all_long": "Deny all (The command is denied from any channel)",
-      "allow_specified_long": "Allow specified (The command is allowed from only specified channels)",
+      "allow_all_long": "Allow all (Allowed from any channel)",
+      "deny_all_long": "Deny all (Denied from any channel)",
+      "allow_specified_long": "Allow specified (Allowed from only specified channels)",
       "test_connection": "Test Connection",
       "test_connection_by_pressing_button": "Press the button to test the connection",
       "test_connection_only_public_channel":"Please test connection in a public channel",

+ 8 - 5
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -338,16 +338,19 @@
       "install_complete_if_checked": "Install your app の右側に緑色のチェックがつけばワークスペースへのインストール完了です。",
       "invite_bot_to_channel": "GROWI bot を使いたいチャンネルに @example を使用して招待します。",
       "register_secret_and_token": "Signing Secret と Bot Token を登録する",
-      "manage_commands": "使用可能なGROWIコマンドを設定する",
+      "manage_permission": "権限を設定する",
+      "growi_commands": "GROWI コマンド",
       "multiple_growi_command": "複数のGROWIに対して送信できるコマンド",
       "single_growi_command": "一つのGROWIに対して送信できるコマンド",
-      "allowed_channels_description": "\"{{commandName}}\" コマンドの使用を許可するチャンネルの ID またはチャンネル名を \",\" 区切りで入力してください。プライベートチャンネルの場合はチャンネル ID を入力する必要があります。ユーザーはここに記入されているチャンネルから \"{{commandName}}\" コマンドを使用することができます。",
+      "allowed_channels_description": "\"{{keyName}}\" コマンドの使用を許可するチャンネルを \",\" 区切りで入力してください。ユーザーはここに記入されているチャンネルから \"{{keyName}}\" コマンドを使用することができます。",
+      "unfurl_description": "Slack で GROWI のリンクを共有したときにページの内容を表示する",
+      "unfurl_allowed_channels_description": "\"unfurl\" の使用を許可するチャンネルの ID を \",\" 区切りで入力してください。ここに記入されているチャンネルで GROWI の ページリンクを共有するとページの内容が表示されます。",
       "allow_all": "全てのチャンネルを許可",
       "deny_all": "全てのチャンネルを拒否",
       "allow_specified": "特定のチャンネルを許可",
-      "allow_all-long": "全て許可 (このコマンドは全てのチャンネルから使用することができます)",
-      "deny_all-long": "全て拒否 (このコマンドはどのチャンネルからも使用することはできません)",
-      "allow_specified-long": "特定のチャンネルを許可 (テキストボックスに入力されたチャンネルのみ許可されます)",
+      "allow_all_long": "全て許可 (全てのチャンネルから使用することができます)",
+      "deny_all_long": "全て拒否 (どのチャンネルからも使用することはできません)",
+      "allow_specified_long": "特定のチャンネルを許可 (テキストボックスに入力されたチャンネルのみ許可されます)",
       "test_connection": "連携状況のテストをする",
       "test_connection_by_pressing_button": "以下のテストボタンを押して、Slack連携が完了しているかの確認をしましょう",
       "test_connection_only_public_channel":"連携テストは public チャンネルで確認してください",

+ 8 - 5
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -348,16 +348,19 @@
       "install_complete_if_checked": "确认已选中 \"Install your app\"。",
       "invite_bot_to_channel": "通过调用 @example 邀请 GROWI Bot 进行频道。",
       "register_secret_and_token": "设置签名秘密和BOT令牌",
-      "manage_commands": "管理 GROWI 命令",
+      "manage_permission": "设置权限",
+      "growi_commands": "GROWI 命令",
       "multiple_growi_command": "可以一次发送到多个 GROWI 实例的命令",
       "single_growi_command": "可以一次发送到一个 GROWI 实例的命令",
-      "allowed_channels_description": "为 \"{{commandName}}\" 命令输入允许的频道ID或频道名称。对于私人频道,只允许输入频道ID。每个频道之间用\",\"隔开。用户可以从这里写的频道中使用 \"{{commandName}}\" 命令。",
+      "allowed_channels_description": "为 \"{{keyName}}\" 命令输入允许的通道。每个通道之间用 \",\" 隔开。用户可以从这里写入的通道中使用 \"{{keyName}}\"。",
+      "unfurl_description": "在 Slack 中共享 GROWI 链接时显示页面内容",
+      "unfurl_allowed_channels_description": "为 \"unfurl\" 输入允许的通道ID。每个频道用 \",\"分开。在指定频道中发送的GROWI公共页面链接或永久链接将显示消息中的内容。",
       "allow_all": "允许所有",
       "deny_all": "拒绝所有",
       "allow_specified": "允许指定",
-      "allow_all_long": "允许所有(允许从任何通道发出命令)",
-      "deny_all_long": "拒绝所有(该命令被拒绝于任何通道)",
-      "allow_specified_long": "允许指定(该命令只允许来自指定的通道)",
+      "allow_all_long": "允许所有(允许从任何渠道)",
+      "deny_all_long": "拒绝所有(拒绝来自任何渠道)",
+      "allow_specified_long": "允许指定(只允许来自指定的渠道)",
       "test_connection": "测试连接",
       "test_connection_by_pressing_button": "按下按钮以测试连接",
       "test_connection_only_public_channel":"请在一个公共频道中测试连接",

+ 2 - 1
packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -127,7 +127,7 @@ const CustomBotWithProxySettings = (props) => {
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
           const {
-            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
+            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions,
           } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
@@ -150,6 +150,7 @@ const CustomBotWithProxySettings = (props) => {
                 tokenPtoG={tokenPtoG}
                 permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
                 permissionsForSingleUseCommands={permissionsForSingleUseCommands}
+                permissionsForSlackEventActions={permissionsForSlackEventActions}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
               />

+ 2 - 0
packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx

@@ -51,6 +51,7 @@ const CustomBotWithoutProxySettings = (props) => {
           onTestConnectionInvoked={props.onTestConnectionInvoked}
           onUpdatedSecretToken={props.onUpdatedSecretToken}
           commandPermission={props.commandPermission}
+          eventActionsPermission={props.eventActionsPermission}
         />
       </div>
     </>
@@ -71,6 +72,7 @@ CustomBotWithoutProxySettings.propTypes = {
   onTestConnectionInvoked: PropTypes.func.isRequired,
   connectionStatuses: PropTypes.object.isRequired,
   commandPermission: PropTypes.object,
+  eventActionsPermission: PropTypes.object,
 };
 
 export default CustomBotWithoutProxySettingsWrapper;

+ 5 - 4
packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx

@@ -21,7 +21,7 @@ export const botInstallationStep = {
 const CustomBotWithoutProxySettingsAccordion = (props) => {
   const {
     appContainer, activeStep, onTestConnectionInvoked,
-    slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv, commandPermission,
+    slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv, commandPermission, eventActionsPermission,
   } = props;
   const successMessage = 'Successfully sent to Slack workspace.';
 
@@ -125,10 +125,11 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
       <Accordion
         defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CONNECTION_TEST)}
         // eslint-disable-next-line max-len
-        title={<><span className="mr-2">④</span>{t('admin:slack_integration.accordion.manage_commands')}</>}
+        title={<><span className="mr-2">④</span>{t('admin:slack_integration.accordion.manage_permission')}</>}
       >
         <ManageCommandsProcessWithoutProxy
-          commandPermission={props.commandPermission}
+          commandPermission={commandPermission}
+          eventActionsPermission={eventActionsPermission}
           apiv3Put={props.appContainer.apiv3.put}
         />
       </Accordion>
@@ -200,7 +201,7 @@ CustomBotWithoutProxySettingsAccordion.propTypes = {
   slackBotToken: PropTypes.string,
   slackBotTokenEnv: PropTypes.string,
   commandPermission: PropTypes.object,
-
+  eventActionsPermission: PropTypes.object,
 };
 
 export default CustomBotWithoutProxySettingsAccordionWrapper;

+ 238 - 122
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx

@@ -1,7 +1,7 @@
 import React, { useCallback, useState } from 'react';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
-import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse, defaultSupportedSlackEventActions } from '@growi/slack';
 import loggerFactory from '~/utils/logger';
 
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
@@ -19,6 +19,11 @@ const CommandUsageTypes = {
   SINGLE_USE: 'singleUse',
 };
 
+const EventTypes = {
+  LINK_SHARING: 'linkSharing',
+};
+
+
 // A utility function that returns the new state but identical to the previous state
 const getUpdatedChannelsList = (prevState, commandName, value) => {
   // string to array
@@ -62,9 +67,110 @@ const getPermissionTypeFromValue = (value) => {
   logger.error('The value type must be boolean or string[]');
 };
 
+const PermissionSettingForEachPermissionTypeComponent = ({
+  keyName, onUpdatePermissions, onUpdateChannels, singleCommandDescription, allowedChannelsDescription, currentPermissionType, permissionSettings,
+}) => {
+  const { t } = useTranslation();
+  const hiddenClass = currentPermissionType === PermissionTypes.ALLOW_SPECIFIED ? '' : 'd-none';
+
+  const permission = permissionSettings[keyName];
+  if (permission === undefined) logger.error('Must be implemented');
+  const textareaDefaultValue = Array.isArray(permission) ? permission.join(',') : '';
+
+
+  return (
+    <div className="my-1 mb-2">
+      <div className="row align-items-center mb-3">
+        <p className="col-md-5 text-md-right mb-2">
+          <strong className="text-capitalize">{keyName}</strong>
+          {singleCommandDescription && (
+            <small className="form-text text-muted small">
+              { singleCommandDescription }
+            </small>
+          )}
+        </p>
+        <div className="col dropdown">
+          <button
+            className="btn btn-outline-secondary dropdown-toggle text-right col-12 col-md-auto"
+            type="button"
+            id="dropdownMenuButton"
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            aria-expanded="true"
+          >
+            <span className="float-left">
+              {currentPermissionType === PermissionTypes.ALLOW_ALL
+              && t('admin:slack_integration.accordion.allow_all')}
+              {currentPermissionType === PermissionTypes.DENY_ALL
+              && t('admin:slack_integration.accordion.deny_all')}
+              {currentPermissionType === PermissionTypes.ALLOW_SPECIFIED
+              && t('admin:slack_integration.accordion.allow_specified')}
+            </span>
+          </button>
+          <div className="dropdown-menu">
+            <button
+              className="dropdown-item"
+              type="button"
+              name={keyName}
+              value={PermissionTypes.ALLOW_ALL}
+              onClick={onUpdatePermissions}
+            >
+              {t('admin:slack_integration.accordion.allow_all_long')}
+            </button>
+            <button
+              className="dropdown-item"
+              type="button"
+              name={keyName}
+              value={PermissionTypes.DENY_ALL}
+              onClick={onUpdatePermissions}
+            >
+              {t('admin:slack_integration.accordion.deny_all_long')}
+            </button>
+            <button
+              className="dropdown-item"
+              type="button"
+              name={keyName}
+              value={PermissionTypes.ALLOW_SPECIFIED}
+              onClick={onUpdatePermissions}
+            >
+              {t('admin:slack_integration.accordion.allow_specified_long')}
+            </button>
+          </div>
+        </div>
+      </div>
+      <div className={`row ${hiddenClass}`}>
+        <div className="col-md-7 offset-md-5">
+          <textarea
+            className="form-control"
+            type="textarea"
+            name={keyName}
+            defaultValue={textareaDefaultValue}
+            onChange={onUpdateChannels}
+          />
+          <p className="form-text text-muted small">
+            {t(allowedChannelsDescription, { keyName })}
+          </p>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+PermissionSettingForEachPermissionTypeComponent.propTypes = {
+  keyName: PropTypes.string,
+  usageType: PropTypes.string,
+  currentPermissionType: PropTypes.string,
+  singleCommandDescription: PropTypes.string,
+  onUpdatePermissions: PropTypes.func,
+  onUpdateChannels: PropTypes.func,
+  allowedChannelsDescription: PropTypes.string,
+  permissionSettings: PropTypes.object,
+};
+
+
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 const ManageCommandsProcess = ({
-  apiv3Put, slackAppIntegrationId, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
+  apiv3Put, slackAppIntegrationId, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions,
 }) => {
   const { t } = useTranslation();
 
@@ -75,6 +181,9 @@ const ManageCommandsProcess = ({
     note: permissionsForSingleUseCommands.note,
     keep: permissionsForSingleUseCommands.keep,
   });
+  const [permissionsForEventsState, setPermissionsForEventsState] = useState({
+    unfurl: permissionsForSlackEventActions.unfurl,
+  });
   const [currentPermissionTypes, setCurrentPermissionTypes] = useState(() => {
     const initialState = {};
     Object.entries(permissionsForBroadcastUseCommandsState).forEach((entry) => {
@@ -85,14 +194,28 @@ const ManageCommandsProcess = ({
       const [commandName, value] = entry;
       initialState[commandName] = getPermissionTypeFromValue(value);
     });
+    Object.entries(permissionsForEventsState).forEach((entry) => {
+      const [commandName, value] = entry;
+      initialState[commandName] = getPermissionTypeFromValue(value);
+    });
     return initialState;
   });
 
-  const updatePermissionsForBroadcastUseCommandsState = useCallback((e) => {
+
+  const handleUpdateSingleUsePermissions = useCallback((e) => {
     const { target } = e;
     const { name: commandName, value } = target;
+    setPermissionsForSingleUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
+    setCurrentPermissionTypes((prevState) => {
+      const newState = { ...prevState };
+      newState[commandName] = value;
+      return newState;
+    });
+  }, []);
 
-    // update state
+  const handleUpdateBroadcastUsePermissions = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
     setPermissionsForBroadcastUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
     setCurrentPermissionTypes((prevState) => {
       const newState = { ...prevState };
@@ -101,12 +224,10 @@ const ManageCommandsProcess = ({
     });
   }, []);
 
-  const updatePermissionsForSingleUseCommandsState = useCallback((e) => {
+  const handleUpdateEventsPermissions = useCallback((e) => {
     const { target } = e;
     const { name: commandName, value } = target;
-
-    // update state
-    setPermissionsForSingleUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
+    setPermissionsForEventsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
     setCurrentPermissionTypes((prevState) => {
       const newState = { ...prevState };
       newState[commandName] = value;
@@ -114,25 +235,32 @@ const ManageCommandsProcess = ({
     });
   }, []);
 
-  const updateChannelsListForBroadcastUseCommandsState = useCallback((e) => {
+  const handleUpdateSingleUseChannels = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
+    setPermissionsForSingleUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
+  }, []);
+
+  const handleUpdateBroadcastUseChannels = useCallback((e) => {
     const { target } = e;
     const { name: commandName, value } = target;
-    // update state
     setPermissionsForBroadcastUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
   }, []);
 
-  const updateChannelsListForSingleUseCommandsState = useCallback((e) => {
+  const handleUpdateEventsChannels = useCallback((e) => {
     const { target } = e;
     const { name: commandName, value } = target;
-    // update state
-    setPermissionsForSingleUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
+    setPermissionsForEventsState(prev => getUpdatedChannelsList(prev, commandName, value));
   }, []);
 
-  const updateCommandsHandler = async(e) => {
+
+  const updateSettingsHandler = async(e) => {
     try {
-      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/supported-commands`, {
+      // TODO: add new attribute 78975
+      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/permissions`, {
         permissionsForBroadcastUseCommands: permissionsForBroadcastUseCommandsState,
         permissionsForSingleUseCommands: permissionsForSingleUseCommandsState,
+        permissionsForSlackEventActions: permissionsForEventsState,
       });
       toastSuccess(t('toaster.update_successed', { target: 'Token' }));
     }
@@ -142,141 +270,128 @@ const ManageCommandsProcess = ({
     }
   };
 
-  const PermissionSettingForEachCommandComponent = ({ commandName, commandUsageType }) => {
-    const hiddenClass = currentPermissionTypes[commandName] === PermissionTypes.ALLOW_SPECIFIED ? '' : 'd-none';
-    const isCommandBroadcastUse = commandUsageType === CommandUsageTypes.BROADCAST_USE;
+  const PermissionSettingsForEachCategoryComponent = ({
+    currentPermissionTypes,
+    usageType,
+    menuItem,
+  }) => {
+    const permissionMap = {
+      broadcastUse: permissionsForBroadcastUseCommandsState,
+      singleUse: permissionsForSingleUseCommandsState,
+      linkSharing: permissionsForEventsState,
+    };
 
-    const permissionSettings = isCommandBroadcastUse ? permissionsForBroadcastUseCommandsState : permissionsForSingleUseCommandsState;
-    const permission = permissionSettings[commandName];
-    if (permission === undefined) logger.error('Must be implemented');
-
-    const textareaDefaultValue = Array.isArray(permission) ? permission.join(',') : '';
+    const {
+      title,
+      description,
+      defaultCommandsName,
+      singleCommandDescription,
+      updatePermissionsHandler,
+      updateChannelsHandler,
+      allowedChannelsDescription,
+    } = menuItem;
 
     return (
-      <div className="my-1 mb-2">
-        <div className="row align-items-center mb-3">
-          <p className="col-md-5 text-md-right text-capitalize mb-2"><strong>{commandName}</strong></p>
-          <div className="col dropdown">
-            <button
-              className="btn btn-outline-secondary dropdown-toggle text-right col-12 col-md-auto"
-              type="button"
-              id="dropdownMenuButton"
-              data-toggle="dropdown"
-              aria-haspopup="true"
-              aria-expanded="true"
-            >
-              <span className="float-left">
-                {currentPermissionTypes[commandName] === PermissionTypes.ALLOW_ALL
-                && t('admin:slack_integration.accordion.allow_all')}
-                {currentPermissionTypes[commandName] === PermissionTypes.DENY_ALL
-                && t('admin:slack_integration.accordion.deny_all')}
-                {currentPermissionTypes[commandName] === PermissionTypes.ALLOW_SPECIFIED
-                && t('admin:slack_integration.accordion.allow_specified')}
-              </span>
-            </button>
-            <div className="dropdown-menu">
-              <button
-                className="dropdown-item"
-                type="button"
-                name={commandName}
-                value={PermissionTypes.ALLOW_ALL}
-                onClick={isCommandBroadcastUse ? updatePermissionsForBroadcastUseCommandsState : updatePermissionsForSingleUseCommandsState}
-              >
-                {t('admin:slack_integration.accordion.allow_all_long')}
-              </button>
-              <button
-                className="dropdown-item"
-                type="button"
-                name={commandName}
-                value={PermissionTypes.DENY_ALL}
-                onClick={isCommandBroadcastUse ? updatePermissionsForBroadcastUseCommandsState : updatePermissionsForSingleUseCommandsState}
-              >
-                {t('admin:slack_integration.accordion.deny_all_long')}
-              </button>
-              <button
-                className="dropdown-item"
-                type="button"
-                name={commandName}
-                value={PermissionTypes.ALLOW_SPECIFIED}
-                onClick={isCommandBroadcastUse ? updatePermissionsForBroadcastUseCommandsState : updatePermissionsForSingleUseCommandsState}
-              >
-                {t('admin:slack_integration.accordion.allow_specified_long')}
-              </button>
+      <>
+        {(title || description) && (
+          <div className="row">
+            <div className="col-md-7 offset-md-2">
+              { title && <p className="font-weight-bold mb-1">{title}</p> }
+              { description && <p className="text-muted">{description}</p> }
             </div>
           </div>
-        </div>
-        <div className={`row ${hiddenClass}`}>
-          <div className="col-md-7 offset-md-5">
-            <textarea
-              className="form-control"
-              type="textarea"
-              name={commandName}
-              defaultValue={textareaDefaultValue}
-              onChange={isCommandBroadcastUse ? updateChannelsListForBroadcastUseCommandsState : updateChannelsListForSingleUseCommandsState}
-            />
-            <p className="form-text text-muted small">
-              {t('admin:slack_integration.accordion.allowed_channels_description', { commandName })}
-              <br />
-            </p>
-          </div>
-        </div>
-      </div>
-    );
-  };
+        )}
 
-  PermissionSettingForEachCommandComponent.propTypes = {
-    commandName: PropTypes.string,
-    commandUsageType: PropTypes.string,
-  };
-
-  const PermissionSettingsForEachCommandTypeComponent = ({ commandUsageType }) => {
-    const isCommandBroadcastUse = commandUsageType === CommandUsageTypes.BROADCAST_USE;
-    const defaultCommandsName = isCommandBroadcastUse ? defaultSupportedCommandsNameForBroadcastUse : defaultSupportedCommandsNameForSingleUse;
-    return (
-      <>
-        <div className="row">
-          <div className="col-md-7 offset-md-2">
-            <p className="font-weight-bold mb-1">{isCommandBroadcastUse ? 'Multiple GROWI' : 'Single GROWI'}</p>
-            <p className="text-muted">
-              {isCommandBroadcastUse
-                ? t('admin:slack_integration.accordion.multiple_growi_command')
-                : t('admin:slack_integration.accordion.single_growi_command')}
-            </p>
-          </div>
-        </div>
         <div className="custom-control custom-checkbox">
           <div className="row mb-5 d-block">
-            {defaultCommandsName.map((commandName) => {
-              // eslint-disable-next-line max-len
-              return <PermissionSettingForEachCommandComponent key={`${commandName}-component`} commandName={commandName} commandUsageType={commandUsageType} />;
-            })}
+            {defaultCommandsName.map(keyName => (
+              <PermissionSettingForEachPermissionTypeComponent
+                key={`${keyName}-component`}
+                keyName={keyName}
+                usageType={usageType}
+                permissionSettings={permissionMap[usageType]}
+                currentPermissionType={currentPermissionTypes[keyName]}
+                singleCommandDescription={singleCommandDescription}
+                onUpdatePermissions={updatePermissionsHandler}
+                onUpdateChannels={updateChannelsHandler}
+                allowedChannelsDescription={allowedChannelsDescription}
+              />
+            ))}
           </div>
         </div>
       </>
     );
   };
 
-  PermissionSettingsForEachCommandTypeComponent.propTypes = {
-    commandUsageType: PropTypes.string,
+
+  PermissionSettingsForEachCategoryComponent.propTypes = {
+    currentPermissionTypes: PropTypes.object,
+    usageType: PropTypes.string,
+    menuItem: PropTypes.object,
   };
 
+  // Using i18n in allowedChannelsDescription will cause interpolation error
+  const menuMap = {
+    broadcastUse: {
+      title: 'Multiple GROWI',
+      description: t('admin:slack_integration.accordion.multiple_growi_command'),
+      defaultCommandsName: defaultSupportedCommandsNameForBroadcastUse,
+      updatePermissionsHandler: handleUpdateBroadcastUsePermissions,
+      updateChannelsHandler: handleUpdateBroadcastUseChannels,
+      allowedChannelsDescription: 'admin:slack_integration.accordion.allowed_channels_description',
+    },
+    singleUse: {
+      title: 'Single GROWI',
+      description: t('admin:slack_integration.accordion.single_growi_command'),
+      defaultCommandsName: defaultSupportedCommandsNameForSingleUse,
+      updatePermissionsHandler: handleUpdateSingleUsePermissions,
+      updateChannelsHandler: handleUpdateSingleUseChannels,
+      allowedChannelsDescription: 'admin:slack_integration.accordion.allowed_channels_description',
+    },
+    linkSharing: {
+      defaultCommandsName: defaultSupportedSlackEventActions,
+      updatePermissionsHandler: handleUpdateEventsPermissions,
+      updateChannelsHandler: handleUpdateEventsChannels,
+      singleCommandDescription: t('admin:slack_integration.accordion.unfurl_description'),
+      allowedChannelsDescription: 'admin:slack_integration.accordion.unfurl_allowed_channels_description',
+    },
+  };
 
   return (
     <div className="py-4 px-5">
-      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.manage_commands')}</p>
+      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.growi_commands')}</p>
       <div className="row d-flex flex-column align-items-center">
+        <div className="col-8">
+          {Object.values(CommandUsageTypes).map(commandUsageType => (
+            <PermissionSettingsForEachCategoryComponent
+              key={commandUsageType}
+              currentPermissionTypes={currentPermissionTypes}
+              usageType={commandUsageType}
+              menuItem={menuMap[commandUsageType]}
+            />
+          ))}
+        </div>
+      </div>
 
+      <p className="mb-4 font-weight-bold">Events</p>
+      <div className="row d-flex flex-column align-items-center">
         <div className="col-8">
-          {Object.values(CommandUsageTypes).map((commandUsageType) => {
-            return <PermissionSettingsForEachCommandTypeComponent key={commandUsageType} commandUsageType={commandUsageType} />;
-          })}
+          {Object.values(EventTypes).map(EventType => (
+            <PermissionSettingsForEachCategoryComponent
+              key={EventType}
+              currentPermissionTypes={currentPermissionTypes}
+              usageType={EventType}
+              menuItem={menuMap[EventType]}
+            />
+          ))}
         </div>
       </div>
+
       <div className="row">
         <button
           type="submit"
           className="btn btn-primary mx-auto"
-          onClick={updateCommandsHandler}
+          onClick={updateSettingsHandler}
         >
           { t('Update') }
         </button>
@@ -290,6 +405,7 @@ ManageCommandsProcess.propTypes = {
   slackAppIntegrationId: PropTypes.string.isRequired,
   permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
   permissionsForSingleUseCommands: PropTypes.object.isRequired,
+  permissionsForSlackEventActions: PropTypes.object.isRequired,
 };
 
 export default ManageCommandsProcess;

+ 56 - 23
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx

@@ -1,7 +1,7 @@
 import React, { useCallback, useEffect, useState } from 'react';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
-import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse, defaultSupportedSlackEventActions } from '@growi/slack';
 import loggerFactory from '~/utils/logger';
 
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
@@ -49,7 +49,7 @@ const getUpdatedPermissionSettings = (commandPermissionObj, commandName, value)
 };
 
 
-const PermissionSettingForEachCommandComponent = ({
+const SinglePermissionSettingComponent = ({
   commandName, editingCommandPermission, onPermissionTypeClicked, onPermissionListChanged,
 }) => {
   const { t } = useTranslation();
@@ -144,7 +144,7 @@ const PermissionSettingForEachCommandComponent = ({
   );
 };
 
-PermissionSettingForEachCommandComponent.propTypes = {
+SinglePermissionSettingComponent.propTypes = {
   commandName: PropTypes.string,
   editingCommandPermission: PropTypes.object,
   onPermissionTypeClicked: PropTypes.func,
@@ -153,18 +153,10 @@ PermissionSettingForEachCommandComponent.propTypes = {
 
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
+const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission, eventActionsPermission }) => {
   const { t } = useTranslation();
   const [editingCommandPermission, setEditingCommandPermission] = useState({});
-
-  const updatePermissionsCommandsState = useCallback((e) => {
-    const { target } = e;
-    const { name: commandName, value } = target;
-
-    // update state
-    setEditingCommandPermission(commandPermissionObj => getUpdatedPermissionSettings(commandPermissionObj, commandName, value));
-  }, []);
-
+  const [editingEventActionsPermission, setEditingEventActionsPermission] = useState({});
 
   useEffect(() => {
     if (commandPermission == null) {
@@ -174,21 +166,43 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
     setEditingCommandPermission(updatedState);
   }, [commandPermission]);
 
-  const updateChannelsListState = useCallback((e) => {
+  useEffect(() => {
+    if (eventActionsPermission == null) {
+      return;
+    }
+    const updatedState = { ...eventActionsPermission };
+    setEditingEventActionsPermission(updatedState);
+  }, [eventActionsPermission]);
+
+  const updatePermissionsCommandsState = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
+    setEditingCommandPermission(commandPermissionObj => getUpdatedPermissionSettings(commandPermissionObj, commandName, value));
+  }, []);
+
+  const updatePermissionsEventsState = useCallback((e) => {
+    const { target } = e;
+    const { name: actionName, value } = target;
+    setEditingEventActionsPermission(eventActionPermissionObj => getUpdatedPermissionSettings(eventActionPermissionObj, actionName, value));
+  }, []);
+
+  const updateCommandsChannelsListState = useCallback((e) => {
     const { target } = e;
     const { name: commandName, value } = target;
-    // update state
-    setEditingCommandPermission((commandPermissionObj) => {
-      return {
-        ...getUpdatedChannelsList(commandPermissionObj, commandName, value),
-      };
-    });
+    setEditingCommandPermission(commandPermissionObj => ({ ...getUpdatedChannelsList(commandPermissionObj, commandName, value) }));
+  }, []);
+
+  const updateEventsChannelsListState = useCallback((e) => {
+    const { target } = e;
+    const { name: actionName, value } = target;
+    setEditingEventActionsPermission(eventActionPermissionObj => ({ ...getUpdatedChannelsList(eventActionPermissionObj, actionName, value) }));
   }, []);
 
   const updateCommandsHandler = async(e) => {
     try {
       await apiv3Put('/slack-integration-settings/without-proxy/update-permissions', {
         commandPermission: editingCommandPermission,
+        eventActionsPermission: editingEventActionsPermission,
       });
       toastSuccess(t('toaster.update_successed', { target: 'the permission for commands' }));
     }
@@ -200,7 +214,7 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
 
   return (
     <div className="py-4 px-5">
-      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.manage_commands')}</p>
+      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.growi_commands')}</p>
       <div className="row d-flex flex-column align-items-center">
         <div className="col-8">
           <div className="custom-control custom-checkbox">
@@ -208,12 +222,12 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
               { defaultCommandsName.map((commandName) => {
                 // eslint-disable-next-line max-len
                 return (
-                  <PermissionSettingForEachCommandComponent
+                  <SinglePermissionSettingComponent
                     key={`${commandName}-component`}
                     commandName={commandName}
                     editingCommandPermission={editingCommandPermission}
                     onPermissionTypeClicked={updatePermissionsCommandsState}
-                    onPermissionListChanged={updateChannelsListState}
+                    onPermissionListChanged={updateCommandsChannelsListState}
                   />
                 );
               })}
@@ -221,6 +235,24 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
           </div>
         </div>
       </div>
+      <p className="mb-4 font-weight-bold">Events</p>
+      <div className="row d-flex flex-column align-items-center">
+        <div className="col-8">
+          <div className="custom-control custom-checkbox">
+            <div className="row mb-5 d-block">
+              { defaultSupportedSlackEventActions.map(actionName => (
+                <SinglePermissionSettingComponent
+                  key={`${actionName}-component`}
+                  commandName={actionName}
+                  editingCommandPermission={editingEventActionsPermission}
+                  onPermissionTypeClicked={updatePermissionsEventsState}
+                  onPermissionListChanged={updateEventsChannelsListState}
+                />
+              ))}
+            </div>
+          </div>
+        </div>
+      </div>
       <div className="row">
         <button
           type="submit"
@@ -237,6 +269,7 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
 ManageCommandsProcessWithoutProxy.propTypes = {
   apiv3Put: PropTypes.func,
   commandPermission: PropTypes.object,
+  eventActionsPermission: PropTypes.object,
 };
 
 export default ManageCommandsProcessWithoutProxy;

+ 11 - 1
packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx

@@ -28,6 +28,7 @@ const SlackIntegration = (props) => {
   const [slackSigningSecretEnv, setSlackSigningSecretEnv] = useState('');
   const [slackBotTokenEnv, setSlackBotTokenEnv] = useState('');
   const [commandPermission, setCommandPermission] = useState(null);
+  const [eventActionsPermission, setEventActionsPermission] = useState(null);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState(false);
   const [slackAppIntegrations, setSlackAppIntegrations] = useState();
   const [proxyServerUri, setProxyServerUri] = useState();
@@ -41,7 +42,14 @@ const SlackIntegration = (props) => {
     try {
       const { data } = await appContainer.apiv3.get('/slack-integration-settings');
       const {
-        slackSigningSecret, slackBotToken, slackSigningSecretEnvVars, slackBotTokenEnvVars, slackAppIntegrations, proxyServerUri, commandPermission,
+        slackSigningSecret,
+        slackBotToken,
+        slackSigningSecretEnvVars,
+        slackBotTokenEnvVars,
+        slackAppIntegrations,
+        proxyServerUri,
+        commandPermission,
+        eventActionsPermission,
       } = data.settings;
 
       setErrorMsg(data.errorMsg);
@@ -55,6 +63,7 @@ const SlackIntegration = (props) => {
       setSlackAppIntegrations(slackAppIntegrations);
       setProxyServerUri(proxyServerUri);
       setCommandPermission(commandPermission);
+      setEventActionsPermission(eventActionsPermission);
     }
     catch (err) {
       toastError(err);
@@ -154,6 +163,7 @@ const SlackIntegration = (props) => {
           onUpdatedSecretToken={changeSecretAndToken}
           connectionStatuses={connectionStatuses}
           commandPermission={commandPermission}
+          eventActionsPermission={eventActionsPermission}
         />
       );
       break;

+ 5 - 2
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -340,12 +340,13 @@ const WithProxyAccordions = (props) => {
       />,
     },
     '③': {
-      title: 'manage_commands',
+      title: 'manage_permission',
       content: <ManageCommandsProcess
         apiv3Put={props.appContainer.apiv3.put}
         slackAppIntegrationId={props.slackAppIntegrationId}
         permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
         permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
+        permissionsForSlackEventActions={props.permissionsForSlackEventActions}
       />,
     },
     '④': {
@@ -384,12 +385,13 @@ const WithProxyAccordions = (props) => {
       content: <RegisteringProxyUrlProcess />,
     },
     '⑤': {
-      title: 'manage_commands',
+      title: 'manage_permission',
       content: <ManageCommandsProcess
         apiv3Put={props.appContainer.apiv3.put}
         slackAppIntegrationId={props.slackAppIntegrationId}
         permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
         permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
+        permissionsForSlackEventActions={props.permissionsForSlackEventActions}
       />,
     },
     '⑥': {
@@ -443,6 +445,7 @@ WithProxyAccordions.propTypes = {
   tokenGtoP: PropTypes.string,
   permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
   permissionsForSingleUseCommands: PropTypes.object.isRequired,
+  permissionsForSlackEventActions: PropTypes.object.isRequired,
 };
 
 export default WithProxyAccordionsWrapper;

+ 12 - 0
packages/app/src/server/crowi/express-init.js

@@ -99,6 +99,18 @@ module.exports = function(crowi, app) {
   app.set('view engine', 'html');
   app.set('views', crowi.viewsDir);
   app.use(methodOverride());
+
+  // inject rawBody to req
+  app.use((req, res, next) => {
+    if (!req.is('multipart/form-data')) {
+      req.rawBody = '';
+      req.on('data', (chunk) => {
+        req.rawBody += chunk;
+      });
+    }
+
+    next();
+  });
   app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
   app.use(bodyParser.json({ limit: '50mb' }));
   app.use(cookieParser());

+ 1 - 0
packages/app/src/server/interfaces/slack-integration/events.ts

@@ -0,0 +1 @@
+export type EventActionsPermission = Map<string, boolean | string[]>

+ 32 - 0
packages/app/src/server/interfaces/slack-integration/link-shared-unfurl.ts

@@ -0,0 +1,32 @@
+export type PrivateData = {
+  isPublic: false,
+  isPermalink: boolean,
+  id: string,
+  path: string,
+}
+
+export type PublicData = {
+  isPublic: true,
+  isPermalink: boolean,
+  id: string,
+  path: string,
+  pageBody: string,
+  updatedAt: Date,
+  commentCount: number,
+}
+
+export type DataForUnfurl = PrivateData | PublicData;
+
+export type UnfurlEventLink = {
+  url: string,
+  domain: string,
+}
+
+export type UnfurlRequestEvent = {
+  channel: string,
+
+  // eslint-disable-next-line camelcase
+  message_ts: string,
+
+  links: UnfurlEventLink[],
+}

+ 11 - 0
packages/app/src/server/models/page.js

@@ -263,6 +263,17 @@ class PageQueryBuilder {
     return this;
   }
 
+  addConditionToListByPageIdsArray(pageIds) {
+    this.query = this.query
+      .and({
+        _id: {
+          $in: pageIds,
+        },
+      });
+
+    return this;
+  }
+
   populateDataToList(userPublicFields) {
     this.query = this.query
       .populate({

+ 5 - 1
packages/app/src/server/models/slack-app-integration.js

@@ -1,6 +1,6 @@
 const crypto = require('crypto');
 const mongoose = require('mongoose');
-const { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } = require('@growi/slack');
+const { defaultSupportedSlackEventActions } = require('@growi/slack');
 
 
 const schema = new mongoose.Schema({
@@ -9,6 +9,10 @@ const schema = new mongoose.Schema({
   isPrimary: { type: Boolean, unique: true, sparse: true },
   permissionsForBroadcastUseCommands: Map,
   permissionsForSingleUseCommands: Map,
+  permissionsForSlackEventActions: {
+    type: Map,
+    default: new Map(defaultSupportedSlackEventActions.map(action => [action, false])),
+  },
 });
 
 class SlackAppIntegration {

+ 42 - 29
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -1,4 +1,4 @@
-import { SlackbotType } from '@growi/slack';
+import { SlackbotType, defaultSupportedSlackEventActions } from '@growi/slack';
 
 import loggerFactory from '~/utils/logger';
 
@@ -70,9 +70,15 @@ module.exports = (crowi) => {
     makePrimary: [
       param('id').isMongoId().withMessage('id is required'),
     ],
-    updateSupportedCommands: [
-      body('supportedCommandsForSingleUse').toArray(),
-      body('supportedCommandsForBroadcastUse').toArray(),
+    updatePermissionsWithoutProxy: [
+      body('commandPermission').exists(),
+      body('eventActionsPermission').exists(),
+      param('id').isMongoId().withMessage('id is required'),
+    ],
+    updatePermissionsWithProxy: [
+      body('permissionsForBroadcastUseCommands').exists(),
+      body('permissionsForSingleUseCommands').exists(),
+      body('permissionsForSlackEventActions').exists(),
       param('id').isMongoId().withMessage('id is required'),
     ],
     relationTest: [
@@ -106,6 +112,7 @@ module.exports = (crowi) => {
       'slackbot:withoutProxy:botToken': null,
       'slackbot:proxyUri': null,
       'slackbot:withoutProxy:commandPermission': null,
+      'slackbot:withoutProxy:eventActionsPermission': null,
     };
 
     return updateSlackBotSettings(params);
@@ -175,6 +182,7 @@ module.exports = (crowi) => {
       settings.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret');
       settings.slackBotToken = configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken');
       settings.commandPermission = configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission');
+      settings.eventActionsPermission = configManager.getConfig('crowi', 'slackbot:withoutProxy:eventActionsPermission');
     }
     else {
       settings.proxyServerUri = slackIntegrationService.proxyUriForCurrentType;
@@ -251,9 +259,18 @@ module.exports = (crowi) => {
         commandPermission[commandName] = true;
       });
 
-      const requestParams = { 'slackbot:withoutProxy:commandPermission': commandPermission };
+      // default event actions permission value
+      const eventActionsPermission = {};
+      defaultSupportedSlackEventActions.forEach((action) => {
+        eventActionsPermission[action] = false;
+      });
+
+      const params = {
+        'slackbot:withoutProxy:commandPermission': commandPermission,
+        'slackbot:withoutProxy:eventActionsPermission': eventActionsPermission,
+      };
       try {
-        await updateSlackBotSettings(requestParams);
+        await updateSlackBotSettings(params);
         crowi.slackIntegrationService.publishUpdatedMessage();
       }
       catch (error) {
@@ -361,11 +378,6 @@ module.exports = (crowi) => {
     try {
       await updateSlackBotSettings(requestParams);
       crowi.slackIntegrationService.publishUpdatedMessage();
-
-      const customBotWithoutProxySettingParams = {
-        slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret'),
-        slackBotToken: crowi.configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken'),
-      };
       return res.apiv3();
     }
     catch (error) {
@@ -389,19 +401,21 @@ module.exports = (crowi) => {
    *             description: Succeeded to put CustomBotWithoutProxy permissions.
    */
 
-  router.put('/without-proxy/update-permissions', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+  router.put('/without-proxy/update-permissions', loginRequiredStrictly, adminRequired, csrf, validator.updatePermissionsWithoutProxy, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
     if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const msg = 'Not CustomBotWithoutProxy';
       return res.apiv3Err(new ErrorV3(msg, 'not-customBotWithoutProxy'), 400);
     }
 
-    const { commandPermission } = req.body;
-    const requestParams = {
+    // TODO: look here 78978
+    const { commandPermission, eventActionsPermission } = req.body;
+    const params = {
       'slackbot:withoutProxy:commandPermission': commandPermission,
+      'slackbot:withoutProxy:eventActionsPermission': eventActionsPermission,
     };
     try {
-      await updateSlackBotSettings(requestParams);
+      await updateSlackBotSettings(params);
       crowi.slackIntegrationService.publishUpdatedMessage();
       return res.apiv3();
     }
@@ -438,21 +452,16 @@ module.exports = (crowi) => {
 
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
     try {
-      const initialSupportedCommandsForBroadcastUse = new Map();
-      const initialSupportedCommandsForSingleUse = new Map();
-
-      defaultSupportedCommandsNameForBroadcastUse.forEach((commandName) => {
-        initialSupportedCommandsForBroadcastUse.set(commandName, true);
-      });
-      defaultSupportedCommandsNameForSingleUse.forEach((commandName) => {
-        initialSupportedCommandsForSingleUse.set(commandName, true);
-      });
+      const initialSupportedCommandsForBroadcastUse = new Map(defaultSupportedCommandsNameForBroadcastUse.map(command => [command, true]));
+      const initialSupportedCommandsForSingleUse = new Map(defaultSupportedCommandsNameForSingleUse.map(command => [command, true]));
+      const initialPermissionsForSlackEventActions = new Map(defaultSupportedSlackEventActions.map(action => [action, true]));
 
       const slackAppTokens = await SlackAppIntegration.create({
         tokenGtoP,
         tokenPtoG,
         permissionsForBroadcastUseCommands: initialSupportedCommandsForBroadcastUse,
         permissionsForSingleUseCommands: initialSupportedCommandsForSingleUse,
+        permissionsForSlackEvents: initialPermissionsForSlackEventActions,
         isPrimary: count === 0,
       });
       return res.apiv3(slackAppTokens, 200);
@@ -595,23 +604,26 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *    /slack-integration-settings/slack-app-integrations/:id/supported-commands:
+   *    /slack-integration-settings/slack-app-integrations/:id/permissions:
    *      put:
    *        tags: [SlackIntegration]
    *        operationId: putSupportedCommands
-   *        summary: /slack-integration-settings/:id/supported-commands
+   *        summary: /slack-integration-settings/:id/permissions
    *        description: update supported commands
    *        responses:
    *          200:
    *            description: Succeeded to update supported commands
    */
   // eslint-disable-next-line max-len
-  router.put('/slack-app-integrations/:id/supported-commands', loginRequiredStrictly, adminRequired, csrf, validator.updateSupportedCommands, apiV3FormValidator, async(req, res) => {
-    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = req.body;
+  router.put('/slack-app-integrations/:id/permissions', loginRequiredStrictly, adminRequired, csrf, validator.updatePermissionsWithProxy, apiV3FormValidator, async(req, res) => {
+    // TODO: look here 78975
+    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions } = req.body;
     const { id } = req.params;
 
     const updatePermissionsForBroadcastUseCommands = new Map(Object.entries(permissionsForBroadcastUseCommands));
     const updatePermissionsForSingleUseCommands = new Map(Object.entries(permissionsForSingleUseCommands));
+    const newPermissionsForSlackEventActions = new Map(Object.entries(permissionsForSlackEventActions));
+
 
     try {
       const slackAppIntegration = await SlackAppIntegration.findByIdAndUpdate(
@@ -619,6 +631,7 @@ module.exports = (crowi) => {
         {
           permissionsForBroadcastUseCommands: updatePermissionsForBroadcastUseCommands,
           permissionsForSingleUseCommands: updatePermissionsForSingleUseCommands,
+          permissionsForSlackEventActions: newPermissionsForSlackEventActions,
         },
         { new: true },
       );
@@ -641,7 +654,7 @@ module.exports = (crowi) => {
     catch (error) {
       const msg = `Error occured in updating settings. Cause: ${error.message}`;
       logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-supported-commands-failed'), 500);
+      return res.apiv3Err(new ErrorV3(msg, 'update-permissions-failed'), 500);
     }
   });
 

+ 70 - 1
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -4,15 +4,17 @@ import {
 import createError from 'http-errors';
 import loggerFactory from '~/utils/logger';
 import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
+import ErrorV3 from '../../models/vo/error-apiv3';
 
 const express = require('express');
 const mongoose = require('mongoose');
-const urljoin = require('url-join');
+const { body } = require('express-validator');
 
 const {
   verifySlackRequest, parseSlashCommand, InteractionPayloadAccessor, respond,
 } = require('@growi/slack');
 
+
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
@@ -183,6 +185,18 @@ module.exports = (crowi) => {
     return next();
   };
 
+  const verifyUrlMiddleware = (req, res, next) => {
+    const { body } = req;
+
+    // eslint-disable-next-line max-len
+    // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
+    if (body.type === 'url_verification') {
+      return res.send({ challenge: body.challenge });
+    }
+
+    next();
+  };
+
   const parseSlackInteractionRequest = (req, res, next) => {
     if (req.body.payload == null) {
       return next(new Error('The payload is not in the request from slack or proxy.'));
@@ -371,6 +385,61 @@ module.exports = (crowi) => {
     return res.apiv3({ permissionsForBroadcastUseCommands, permissionsForSingleUseCommands });
   });
 
+  router.post('/events', verifyUrlMiddleware, addSigningSecretToReq, verifySlackRequest, async(req, res) => {
+    const { event } = req.body;
+
+    const growiBotEvent = {
+      eventType: event.type,
+      event,
+    };
+
+    try {
+      const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
+      // convert permission object to map
+      const permission = new Map(Object.entries(crowi.configManager.getConfig('crowi', 'slackbot:withoutProxy:eventActionsPermission')));
+
+      await crowi.slackIntegrationService.handleEventsRequest(client, growiBotEvent, permission);
+
+      return res.apiv3({});
+    }
+    catch (err) {
+      logger.error('Error occurred while handling event request.', err);
+      return res.apiv3Err(new ErrorV3('Error occurred while handling event request.'));
+    }
+  });
+
+  const validator = {
+    validateEventRequest: [
+      body('growiBotEvent').exists(),
+      body('data').exists(),
+    ],
+  };
+
+  router.post('/proxied/events', verifyAccessTokenFromProxy, validator.validateEventRequest, async(req, res) => {
+    const { growiBotEvent, data } = req.body;
+
+    try {
+      const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+      const SlackAppIntegration = mongoose.model('SlackAppIntegration');
+      const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+
+      if (slackAppIntegration == null) {
+        throw new Error('No SlackAppIntegration exists that corresponds to the tokenPtoG specified.');
+      }
+
+      const client = await slackIntegrationService.generateClientBySlackAppIntegration(slackAppIntegration);
+      const { permissionsForSlackEventActions } = slackAppIntegration;
+
+      await crowi.slackIntegrationService.handleEventsRequest(client, growiBotEvent, permissionsForSlackEventActions, data);
+
+      return res.apiv3({});
+    }
+    catch (err) {
+      logger.error('Error occurred while handling event request.', err);
+      return res.apiv3Err(new ErrorV3('Error occurred while handling event request.'));
+    }
+  });
+
   // error handler
   router.use(async(err, req, res, next) => {
     const responseUrl = getResponseUrl(req);

+ 6 - 0
packages/app/src/server/service/config-loader.ts

@@ -493,6 +493,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: null,
   },
+  SLACKBOT_WITHOUT_PROXY_EVENT_ACTIONS_PERMISSION: {
+    ns:      'crowi',
+    key:     'slackbot:withoutProxy:eventActionsPermission',
+    type:    ValueType.STRING,
+    default: null,
+  },
   SLACKBOT_WITH_PROXY_SALT_FOR_GTOP: {
     ns:      'crowi',
     key:     'slackbot:withProxy:saltForGtoP',

+ 12 - 0
packages/app/src/server/service/slack-event-handler/base-event-handler.ts

@@ -0,0 +1,12 @@
+import { WebClient } from '@slack/web-api';
+import { GrowiBotEvent } from '@growi/slack';
+
+import { EventActionsPermission } from '../../interfaces/slack-integration/events';
+
+export interface SlackEventHandler<T> {
+
+  shouldHandle(eventType: string, permission: EventActionsPermission, channel?: string): boolean
+
+  handleEvent(client: WebClient, growiBotEvent: GrowiBotEvent<T>, data?: any): Promise<void>
+
+}

+ 177 - 0
packages/app/src/server/service/slack-event-handler/link-shared.ts

@@ -0,0 +1,177 @@
+import urljoin from 'url-join';
+import { format } from 'date-fns';
+import {
+  MessageAttachment, LinkUnfurls, WebClient,
+} from '@slack/web-api';
+import { GrowiBotEvent } from '@growi/slack';
+import { SlackEventHandler } from './base-event-handler';
+import {
+  DataForUnfurl, PublicData, UnfurlEventLink, UnfurlRequestEvent,
+} from '../../interfaces/slack-integration/link-shared-unfurl';
+import loggerFactory from '~/utils/logger';
+import { EventActionsPermission } from '~/server/interfaces/slack-integration/events';
+
+const logger = loggerFactory('growi:service:SlackEventHandler:link-shared');
+
+export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEvent> {
+
+  crowi!: any;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  shouldHandle(eventType: string, permission: EventActionsPermission, channel: string): boolean {
+    if (eventType !== 'link_shared') return false;
+
+    const unfurlPermission = permission.get('unfurl');
+
+    if (!Array.isArray(unfurlPermission)) {
+      return unfurlPermission as boolean;
+    }
+
+    return unfurlPermission.includes(channel);
+  }
+
+  async handleEvent(client: WebClient, growiBotEvent: GrowiBotEvent<UnfurlRequestEvent>, data?: {origin: string}): Promise<void> {
+    const { event } = growiBotEvent;
+    const origin = data?.origin || this.crowi.appService.getSiteUrl();
+    const { channel, message_ts: ts, links } = event;
+
+    let unfurlData: DataForUnfurl[];
+    try {
+      unfurlData = await this.generateUnfurlsObject(links);
+    }
+    catch (err) {
+      logger.error('Failed to generate unfurl data:', err);
+      throw err;
+    }
+
+    // unfurl
+    const unfurlResults = await Promise.allSettled(unfurlData.map(async(data: DataForUnfurl) => {
+      const toUrl = urljoin(origin, data.id);
+
+      let targetUrl;
+      if (data.isPermalink) {
+        targetUrl = urljoin(origin, data.id);
+      }
+      else {
+        targetUrl = urljoin(origin, data.path);
+      }
+
+      let unfurls: LinkUnfurls;
+
+      if (data.isPublic === false) {
+        unfurls = {
+          [targetUrl]: {
+            text: 'Page is not public.',
+          },
+        };
+      }
+      else {
+        unfurls = this.generateLinkUnfurls(data as PublicData, targetUrl, toUrl);
+      }
+
+      await client.chat.unfurl({
+        channel,
+        ts,
+        unfurls,
+      });
+    }));
+
+    this.logErrorRejectedResults(unfurlResults);
+  }
+
+  // builder method for unfurl parameter
+  generateLinkUnfurls(body: PublicData, growiTargetUrl: string, toUrl: string): LinkUnfurls {
+    const { pageBody: text, updatedAt, commentCount } = body;
+
+    const updatedAtFormatted = format(updatedAt, 'yyyy-MM-dd HH:mm');
+    const footer = `updated at: ${updatedAtFormatted}  comments: ${commentCount}`;
+
+    const attachment: MessageAttachment = {
+      title: body.path,
+      title_link: toUrl, // permalink
+      text,
+      footer,
+    };
+
+    const unfurls: LinkUnfurls = {
+      [growiTargetUrl]: attachment,
+    };
+    return unfurls;
+  }
+
+  async generateUnfurlsObject(links: UnfurlEventLink[]): Promise<DataForUnfurl[]> {
+    // generate paths array
+    const pathOrIds: string[] = links.map((link) => {
+      const { url: growiTargetUrl } = link;
+      const urlObject = new URL(growiTargetUrl);
+
+      return decodeURI(urlObject.pathname);
+    });
+
+    const idRegExp = /^\/[0-9a-z]{24}$/;
+    const paths = pathOrIds.filter(pathOrId => !idRegExp.test(pathOrId));
+    const ids = pathOrIds.filter(pathOrId => idRegExp.test(pathOrId)).map(id => id.replace('/', '')); // remove a slash
+
+    // get pages with revision
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const pageQueryBuilderByPaths = new PageQueryBuilder(Page.find());
+    const pagesByPaths = await pageQueryBuilderByPaths
+      .addConditionToListByPathsArray(paths)
+      .query
+      .populate('revision')
+      .lean()
+      .exec();
+
+    const pageQueryBuilderByIds = new PageQueryBuilder(Page.find());
+    const pagesByIds = await pageQueryBuilderByIds
+      .addConditionToListByPageIdsArray(ids)
+      .query
+      .populate('revision')
+      .lean()
+      .exec();
+
+    const unfurlDataFromNormalLinks = this.generateDataForUnfurl(pagesByPaths, false);
+    const unfurlDataFromPermalinks = this.generateDataForUnfurl(pagesByIds, true);
+
+    return [...unfurlDataFromNormalLinks, ...unfurlDataFromPermalinks];
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  private generateDataForUnfurl(pages: any, isPermalink: boolean): DataForUnfurl[] {
+    const Page = this.crowi.model('Page');
+    const unfurlData: DataForUnfurl[] = [];
+
+    pages.forEach((page) => {
+      // not send non-public page
+      if (page.grant !== Page.GRANT_PUBLIC) {
+        return unfurlData.push({
+          isPublic: false, isPermalink, id: page._id.toString(), path: page.path,
+        });
+      }
+
+      // public page
+      const { updatedAt, commentCount } = page;
+      const { body } = page.revision;
+      unfurlData.push({
+        isPublic: true, isPermalink, id: page._id.toString(), path: page.path, pageBody: body, updatedAt, commentCount,
+      });
+    });
+
+    return unfurlData;
+  }
+
+  // Promise util method to output rejected results
+  private logErrorRejectedResults<T>(results: PromiseSettledResult<T>[]): void {
+    const rejectedResults: PromiseRejectedResult[] = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
+
+    rejectedResults.forEach((rejected, i) => {
+      logger.error(`Error occurred (count: ${i}): `, rejected.reason.toString());
+    });
+  }
+
+}

+ 17 - 2
packages/app/src/server/service/slack-integration.ts

@@ -5,7 +5,7 @@ import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
 
 import {
   generateWebClient, GrowiCommand, InteractionPayloadAccessor, markdownSectionBlock, SlackbotType,
-  RespondUtil,
+  RespondUtil, GrowiBotEvent,
 } from '@growi/slack';
 
 import loggerFactory from '~/utils/logger';
@@ -16,7 +16,8 @@ import ConfigManager from './config-manager';
 import { S2sMessagingService } from './s2s-messaging/base';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
 import { SlackCommandHandlerError } from '../models/vo/slack-command-handler-error';
-
+import { LinkSharedEventHandler } from './slack-event-handler/link-shared';
+import { EventActionsPermission } from '../interfaces/slack-integration/events';
 
 const logger = loggerFactory('growi:service:SlackBotService');
 
@@ -34,10 +35,13 @@ export class SlackIntegrationService implements S2sMessageHandlable {
 
   lastLoadedAt?: Date;
 
+  linkSharedHandler!: LinkSharedEventHandler;
+
   constructor(crowi) {
     this.crowi = crowi;
     this.configManager = crowi.configManager;
     this.s2sMessagingService = crowi.s2sMessagingService;
+    this.linkSharedHandler = new LinkSharedEventHandler(crowi);
 
     this.initialize();
   }
@@ -306,4 +310,15 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil);
   }
 
+  async handleEventsRequest(client: WebClient, growiBotEvent: GrowiBotEvent<any>, permission: EventActionsPermission, data?: any): Promise<void> {
+    const { eventType } = growiBotEvent;
+    const { channel = '' } = growiBotEvent.event; // only channelId
+
+    if (this.linkSharedHandler.shouldHandle(eventType, permission, channel)) {
+      return this.linkSharedHandler.handleEvent(client, growiBotEvent, data);
+    }
+
+    logger.error(`Any event actions are not permitted, or, a handler for '${eventType}' event is not implemented`);
+  }
+
 }

+ 6 - 0
packages/slack/src/index.ts

@@ -22,10 +22,16 @@ export const defaultSupportedCommandsNameForSingleUse: string[] = [
   'keep',
 ];
 
+export const defaultSupportedSlackEventActions: string[] = [
+  'unfurl',
+];
+
 export * from './interfaces/channel';
 export * from './interfaces/growi-command-processor';
 export * from './interfaces/growi-interaction-processor';
+export * from './interfaces/growi-event-processor';
 export * from './interfaces/growi-command';
+export * from './interfaces/growi-bot-event';
 export * from './interfaces/request-between-growi-and-proxy';
 export * from './interfaces/request-from-slack';
 export * from './interfaces/response-url';

+ 4 - 0
packages/slack/src/interfaces/growi-bot-event.ts

@@ -0,0 +1,4 @@
+export interface GrowiBotEvent<T> {
+  eventType: string,
+  event: T,
+}

+ 7 - 0
packages/slack/src/interfaces/growi-event-processor.ts

@@ -0,0 +1,7 @@
+import { WebClient } from '@slack/web-api';
+
+export interface GrowiEventProcessor {
+  shouldHandleEvent(eventType: string): boolean;
+
+  processEvent(client: WebClient, event: any): Promise<void>;
+}

+ 10 - 2
packages/slack/src/middlewares/verify-slack-request.ts

@@ -12,7 +12,7 @@ const logger = loggerFactory('@growi/slack:middlewares:verify-slack-request');
  * Verify if the request came from slack
  * See: https://api.slack.com/authentication/verifying-requests-from-slack
  */
-export const verifySlackRequest = (req: RequestFromSlack, res: Response, next: NextFunction): Record<string, any> | void => {
+export const verifySlackRequest = (req: RequestFromSlack & { rawBody: any }, res: Response, next: NextFunction): Record<string, any> | void => {
   const signingSecret = req.slackSigningSecret;
 
   if (signingSecret == null) {
@@ -39,8 +39,16 @@ export const verifySlackRequest = (req: RequestFromSlack, res: Response, next: N
     return next(createError(403, message));
   }
 
+  // use req.rawBody for Events API
+  // reference: https://stackoverflow.com/questions/64794287/how-to-verify-a-request-from-slack-events-api
+  let sigBaseString: string;
+  if (req.body.event != null) {
+    sigBaseString = `v0:${timestamp}:${req.rawBody}`;
+  }
+  else {
+    sigBaseString = `v0:${timestamp}:${stringify(req.body, { format: 'RFC1738' })}`;
+  }
   // generate growi signature
-  const sigBaseString = `v0:${timestamp}:${stringify(req.body, { format: 'RFC1738' })}`;
   const hasher = createHmac('sha256', signingSecret);
   hasher.update(sigBaseString, 'utf8');
   const hashedSigningSecret = hasher.digest('hex');

+ 14 - 3
packages/slackbot-proxy/src/controllers/slack.ts

@@ -28,6 +28,7 @@ import { UrlVerificationMiddleware } from '~/middlewares/slack-to-growi/url-veri
 import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-growi-uri-from-req';
 import { InstallerService } from '~/services/InstallerService';
 import { SelectGrowiService } from '~/services/SelectGrowiService';
+import { LinkSharedService } from '~/services/LinkSharedService';
 import { RegisterService } from '~/services/RegisterService';
 import { RelationsService } from '~/services/RelationsService';
 import { UnregisterService } from '~/services/UnregisterService';
@@ -90,6 +91,9 @@ export class SlackCtrl {
   @Inject()
   unregisterService: UnregisterService;
 
+  @Inject()
+  linkSharedService: LinkSharedService;
+
   /**
    * Send command to specified GROWIs
    * @param growiCommand
@@ -396,20 +400,27 @@ export class SlackCtrl {
   }
 
   @Post('/events')
-  @UseBefore(UrlVerificationMiddleware, AuthorizeEventsMiddleware)
+  @UseBefore(UrlVerificationMiddleware, AddSigningSecretToReq, verifySlackRequest, AuthorizeEventsMiddleware)
   async handleEvent(@Req() req: SlackOauthReq): Promise<void> {
     const { authorizeResult } = req;
     const client = generateWebClient(authorizeResult.botToken);
+    const { event } = req.body;
 
-    if (req.body.event.type === 'app_home_opened') {
+    // send welcome message
+    if (event.type === 'app_home_opened') {
       try {
-        await postWelcomeMessageOnce(client, req.body.event.channel);
+        await postWelcomeMessageOnce(client, event.channel);
       }
       catch (err) {
         logger.error('Failed to post welcome message', err);
       }
     }
 
+    // unfurl
+    if (this.linkSharedService.shouldHandleEvent(event.type)) {
+      await this.linkSharedService.processEvent(client, event);
+    }
+
     return;
   }
 

+ 4 - 2
packages/slackbot-proxy/src/middlewares/slack-to-growi/url-verification.ts

@@ -1,5 +1,5 @@
 import {
-  IMiddleware, Middleware, Req, Res,
+  IMiddleware, Middleware, Req, Res, Next,
 } from '@tsed/common';
 import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
 
@@ -7,7 +7,7 @@ import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
 @Middleware()
 export class UrlVerificationMiddleware implements IMiddleware {
 
-  async use(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void> {
+  async use(@Req() req: SlackOauthReq, @Res() res: Res, @Next() next: Next): Promise<void> {
 
     // eslint-disable-next-line max-len
     // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
@@ -15,6 +15,8 @@ export class UrlVerificationMiddleware implements IMiddleware {
       res.send(req.body.challenge);
       return;
     }
+
+    next();
   }
 
 }

+ 4 - 0
packages/slackbot-proxy/src/repositories/relation.ts

@@ -7,4 +7,8 @@ import { Relation } from '~/entities/relation';
 @EntityRepository(Relation)
 export class RelationRepository extends Repository<Relation> {
 
+  async findAllByGrowiUris(growiUris: string[]): Promise<Relation[]> {
+    return this.find({ where: growiUris.map(uri => ({ growiUri: uri })) });
+  }
+
 }

+ 126 - 0
packages/slackbot-proxy/src/services/LinkSharedService.ts

@@ -0,0 +1,126 @@
+import axios from 'axios';
+import { Inject, Service } from '@tsed/di';
+import { GrowiEventProcessor, REQUEST_TIMEOUT_FOR_PTOG } from '@growi/slack';
+import { WebClient } from '@slack/web-api';
+import loggerFactory from '~/utils/logger';
+import { RelationRepository } from '~/repositories/relation';
+
+const logger = loggerFactory('slackbot-proxy:services:LinkSharedService');
+
+type LinkSharedEventLink = {
+  url: string,
+  domain: string,
+}
+
+// aliases
+type GrowiOrigin = string;
+type TokenPtoG = string;
+
+export type LinkSharedRequestEvent = {
+  channel: string,
+
+  // eslint-disable-next-line camelcase
+  message_ts: string,
+
+  links: LinkSharedEventLink[],
+}
+
+type PrivateData = {
+  isPublic: false,
+  path: string,
+}
+
+type PublicData = {
+  isPublic: true,
+  path: string,
+  pageBody: string,
+  updatedAt: string,
+  commentCount: number,
+}
+
+export type DataForLinkShared = PrivateData | PublicData;
+
+@Service()
+export class LinkSharedService implements GrowiEventProcessor {
+
+  @Inject()
+  relationRepository: RelationRepository;
+
+  shouldHandleEvent(eventType: string): boolean {
+    return eventType === 'link_shared';
+  }
+
+  async processEvent(client: WebClient, event: LinkSharedRequestEvent): Promise<void> {
+    const { links } = event;
+
+    const origins: string[] = links.map((link: LinkSharedEventLink) => (new URL(link.url)).origin);
+    const originToTokenPtoGMap: Map<GrowiOrigin, TokenPtoG> = await this.generateOriginToTokenPtoGMapFromOrigins(origins); // get tokenPtoG at once
+
+    // forward to GROWI
+    const result = await this.forwardToEachGrowiOrigin(origins, event, originToTokenPtoGMap);
+
+    // log error
+    this.logErrorRejectedResults(result);
+  }
+
+  // generate Map<GrowiOrigin, TokenPtoG>
+  async generateOriginToTokenPtoGMapFromOrigins(origins: GrowiOrigin[]): Promise<Map<GrowiOrigin, TokenPtoG>> {
+    const originToTokenPtoGMap: Map<GrowiOrigin, TokenPtoG> = new Map();
+
+    // get relations using origins at once
+    const relations = await this.relationRepository.findAllByGrowiUris(origins);
+
+    // increment map using relation.growiUri & relation.tokenPtoG
+    relations.forEach((relation) => {
+      originToTokenPtoGMap.set(relation.growiUri, relation.tokenPtoG);
+    });
+
+    return originToTokenPtoGMap;
+  }
+
+  async forwardToEachGrowiOrigin(
+      origins: string[], event: LinkSharedRequestEvent, originToTokenPtoGMap: Map<GrowiOrigin, TokenPtoG>,
+  ): Promise<PromiseSettledResult<void>[]> {
+    return Promise.allSettled(origins.map(async(origin) => {
+      const requestBody = {
+        growiBotEvent: {
+          eventType: 'link_shared',
+          event,
+        },
+        data: {
+          origin,
+        },
+      };
+      try {
+        // ensure tokenPtoG exists
+        const tokenPtoG = originToTokenPtoGMap.get(origin);
+        if (tokenPtoG == null) throw new Error('tokenPtoG is null');
+
+        const url = new URL('/_api/v3/slack-integration/proxied/events', origin);
+
+        await axios.post(url.toString(),
+          requestBody,
+          {
+            headers: {
+              'x-growi-ptog-tokens': tokenPtoG,
+            },
+            timeout: REQUEST_TIMEOUT_FOR_PTOG,
+          });
+      }
+      catch (err) {
+        logger.error(`Error occurred while request to growi (origin=${origin}):`, err);
+        throw err;
+      }
+    }));
+  }
+
+  // Promise util method to output rejected results
+  private logErrorRejectedResults<T>(results: PromiseSettledResult<T>[]): void {
+    const rejectedResults: PromiseRejectedResult[] = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
+
+    rejectedResults.forEach((rejected, i) => {
+      logger.error(`Error occurred (count: ${i}): `, rejected.reason.toString());
+    });
+  }
+
+}

+ 2 - 2
packages/slackbot-proxy/src/services/RegisterService.ts

@@ -167,7 +167,7 @@ export class RegisterService implements GrowiCommandProcessor<RegisterCommandBod
       blocks.push(markdownSectionBlock('The request has been successfully accepted. However, registration has *NOT been completed* yet.'));
       blocks.push(markdownHeaderBlock(':arrow_right: 3. Test Connection'));
       blocks.push(markdownSectionBlock('*Test Connection* to complete the registration in your GROWI.'));
-      blocks.push(markdownHeaderBlock(':white_large_square: 4. (Opt) Manage GROWI commands'));
+      blocks.push(markdownHeaderBlock(':white_large_square: 4. (Opt) Manage Permission'));
       blocks.push(markdownSectionBlock('Modify permission settings if you need.'));
       await respond(responseUrl, {
         text: 'Proxy URL',
@@ -186,7 +186,7 @@ export class RegisterService implements GrowiCommandProcessor<RegisterCommandBod
     blocks.push(markdownSectionBlock(`Proxy URL: ${serverUri}`));
     blocks.push(markdownHeaderBlock(':arrow_right: 5. Test Connection'));
     blocks.push(markdownSectionBlock('And *Test Connection* to complete the registration in your GROWI.'));
-    blocks.push(markdownHeaderBlock(':white_large_square: 6. (Opt) Manage GROWI commands'));
+    blocks.push(markdownHeaderBlock(':white_large_square: 6. (Opt) Manage Permission'));
     blocks.push(markdownSectionBlock('Modify permission settings if you need.'));
     await respond(responseUrl, {
       text: 'Proxy URL',