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

Merge remote-tracking branch 'origin/master' into support/import-plugins

Yuki Takei 4 лет назад
Родитель
Сommit
bf3eb47ff4
25 измененных файлов с 1422 добавлено и 952 удалено
  1. 3 7
      packages/app/resource/locales/en_US/admin/admin.json
  2. 3 6
      packages/app/resource/locales/ja_JP/admin/admin.json
  3. 311 313
      packages/app/resource/locales/zh_CN/admin/admin.json
  4. 5 1
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  5. 144 0
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  6. 5 1
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  7. 21 1
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  8. 2 0
      packages/app/src/server/models/slack-app-integration.js
  9. 22 0
      packages/app/src/server/models/vo/slackbot-error.js
  10. 75 11
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  11. 76 63
      packages/app/src/server/routes/apiv3/slack-integration.js
  12. 48 0
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  13. 37 38
      packages/app/src/server/service/slack-command-handler/create.js
  14. 66 0
      packages/app/src/server/service/slack-command-handler/respond-if-slackbot-error.js
  15. 199 30
      packages/app/src/server/service/slack-command-handler/search.js
  16. 10 0
      packages/app/src/server/service/slack-command-handler/slack-command-handler.js
  17. 173 2
      packages/app/src/server/service/slack-command-handler/togetter.js
  18. 35 461
      packages/app/src/server/service/slackbot.ts
  19. 9 0
      packages/slack/src/index.ts
  20. 3 0
      packages/slack/src/utils/get-supported-growi-actions-regexps.ts
  21. 30 7
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  22. 46 8
      packages/slackbot-proxy/src/controllers/slack.ts
  23. 17 1
      packages/slackbot-proxy/src/entities/relation.ts
  24. 80 0
      packages/slackbot-proxy/src/services/RelationsService.ts
  25. 2 2
      packages/slackbot-proxy/src/services/SelectGrowiService.ts

+ 3 - 7
packages/app/resource/locales/en_US/admin/admin.json

@@ -137,21 +137,14 @@
       "tab_switch_desc2": "By invalidating, you can make page transition as the only object for forward/back command of the browser.",
       "tab_switch_desc2": "By invalidating, you can make page transition as the only object for forward/back command of the browser.",
       "attach_title_header": "Add h1 section when create new page automatically",
       "attach_title_header": "Add h1 section when create new page automatically",
       "attach_title_header_desc": "Add page path to the first line as h1 section when create new page.",
       "attach_title_header_desc": "Add page path to the first line as h1 section when create new page.",
-
       "list_num_s": "Number of list displayed on modals",
       "list_num_s": "Number of list displayed on modals",
       "list_num_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
       "list_num_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
-
       "list_num_m": "Number of list displayed on article pages included other contents",
       "list_num_m": "Number of list displayed on article pages included other contents",
       "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages.",
       "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages.",
-
       "list_num_l": "Number of list displayed on 'Search' pages",
       "list_num_l": "Number of list displayed on 'Search' pages",
       "list_num_desc_l": "Set number of list per page such as 'Search' pages.",
       "list_num_desc_l": "Set number of list per page such as 'Search' pages.",
-
       "list_num_xl": "Number of list displayed on article pages",
       "list_num_xl": "Number of list displayed on article pages",
       "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
       "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
-
-
-
       "stale_notification": "Display notification on stale pages",
       "stale_notification": "Display notification on stale pages",
       "stale_notification_desc": "Displays the notification to pages more than 1 year since the last update.",
       "stale_notification_desc": "Displays the notification to pages more than 1 year since the last update.",
       "show_all_reply_comments": "Show all reply comments",
       "show_all_reply_comments": "Show all reply comments",
@@ -326,6 +319,9 @@
       "install_complete_if_checked": "Confirm that \"Install your app\" is checked.",
       "install_complete_if_checked": "Confirm that \"Install your app\" is checked.",
       "invite_bot_to_channel": "Invite GROWI bot to channel by calling @example.",
       "invite_bot_to_channel": "Invite GROWI bot to channel by calling @example.",
       "register_secret_and_token": "Set Signing Secret and Bot Token",
       "register_secret_and_token": "Set Signing Secret and Bot Token",
+      "manage_commands": "Manage 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",
       "test_connection": "Test Connection",
       "test_connection": "Test Connection",
       "test_connection_by_pressing_button": "Press the button to test the connection",
       "test_connection_by_pressing_button": "Press the button to test the connection",
       "error_check_logs_below": "An error has occurred. Please check the logs below.",
       "error_check_logs_below": "An error has occurred. Please check the logs below.",

+ 3 - 6
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -137,19 +137,14 @@
       "tab_switch_desc2": "無効化することで、ページ遷移のみを戻る/進む操作の対象にすることができます。",
       "tab_switch_desc2": "無効化することで、ページ遷移のみを戻る/進む操作の対象にすることができます。",
       "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
       "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
       "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
       "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
-
       "list_num_s": "モーダルに表示されるリスト数",
       "list_num_s": "モーダルに表示されるリスト数",
       "list_num_desc_s": "モーダルにおける <ページリスト> <タイムライン> <更新履歴> <添付ファイル>での、1ページあたりの表示数を設定します。",
       "list_num_desc_s": "モーダルにおける <ページリスト> <タイムライン> <更新履歴> <添付ファイル>での、1ページあたりの表示数を設定します。",
-
       "list_num_m": "ユーザーページに表示されるリスト数",
       "list_num_m": "ユーザーページに表示されるリスト数",
       "list_num_desc_m": "ユーザーページにおける <Bookmarks> <Recently Created>での、1ページあたりの表示数を設定します。",
       "list_num_desc_m": "ユーザーページにおける <Bookmarks> <Recently Created>での、1ページあたりの表示数を設定します。",
-
       "list_num_l": "検索ページに表示されるリスト数",
       "list_num_l": "検索ページに表示されるリスト数",
       "list_num_desc_l": "<Search>での、1ページあたりの表示数を設定します。",
       "list_num_desc_l": "<Search>での、1ページあたりの表示数を設定します。",
-
       "list_num_xl": "Not FoundページやTrashページに表示されるリスト数",
       "list_num_xl": "Not FoundページやTrashページに表示されるリスト数",
       "list_num_desc_xl": "記事エリアにおける<Not Found> <Trash>での、1ページあたりの表示数を設定します。",
       "list_num_desc_xl": "記事エリアにおける<Not Found> <Trash>での、1ページあたりの表示数を設定します。",
-
       "stale_notification": "古いページに通知を表示する",
       "stale_notification": "古いページに通知を表示する",
       "stale_notification_desc": "最後の更新から1年を超えるページへの通知を表示します。",
       "stale_notification_desc": "最後の更新から1年を超えるページへの通知を表示します。",
       "show_all_reply_comments": "返信コメントを全て表示する",
       "show_all_reply_comments": "返信コメントを全て表示する",
@@ -323,6 +318,9 @@
       "install_complete_if_checked": "Install your app の右側に緑色のチェックがつけばワークスペースへのインストール完了です。",
       "install_complete_if_checked": "Install your app の右側に緑色のチェックがつけばワークスペースへのインストール完了です。",
       "invite_bot_to_channel": "GROWI bot を使いたいチャンネルに @example を使用して招待します。",
       "invite_bot_to_channel": "GROWI bot を使いたいチャンネルに @example を使用して招待します。",
       "register_secret_and_token": "Signing Secret と Bot Token を登録する",
       "register_secret_and_token": "Signing Secret と Bot Token を登録する",
+      "manage_commands": "使用可能なGROWIコマンドを設定する",
+      "multiple_growi_command": "複数のGROWIに対して送信できるコマンド",
+      "single_growi_command": "一つのGROWIに対して送信できるコマンド",
       "test_connection": "連携状況のテストをする",
       "test_connection": "連携状況のテストをする",
       "test_connection_by_pressing_button": "以下のテストボタンを押して、Slack連携が完了しているかの確認をしましょう",
       "test_connection_by_pressing_button": "以下のテストボタンを押して、Slack連携が完了しているかの確認をしましょう",
       "error_check_logs_below": "エラーが発生しました。下記のログを確認してください。",
       "error_check_logs_below": "エラーが発生しました。下記のログを確認してください。",
@@ -334,7 +332,6 @@
       "integration_is_not_complete": "連携は完了していません<br>下の連携手順を進めてください",
       "integration_is_not_complete": "連携は完了していません<br>下の連携手順を進めてください",
       "integration_successful": "連携は完了しています。",
       "integration_successful": "連携は完了しています。",
       "integration_some_ws_is_not_complete": "連携に失敗している ワークスペースがあります。"
       "integration_some_ws_is_not_complete": "連携に失敗している ワークスペースがあります。"
-
     },
     },
     "custom_bot_with_proxy_integration": "Custom bot with proxy 連携",
     "custom_bot_with_proxy_integration": "Custom bot with proxy 連携",
     "official_bot_integration": "Official bot 連携",
     "official_bot_integration": "Official bot 連携",

+ 311 - 313
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -1,84 +1,84 @@
 {
 {
-  "mailer_setup_required":"<a href='/admin/app'>Email settings</a> are required to send.",
-	"admin_top": {
-		"management_wiki": "管理Wiki",
-		"system_information": "系统信息",
-		"wiki_administrator": "只有wiki管理员可以访问此页",
-		"assign_administrator": "您可以使用“授予管理员访问权限”按钮在“用户管理”页上将所选用户指定为wiki管理员",
-		"list_of_installed_plugins": "已安装插件列表",
-		"package_name": "包名称",
-		"specified_version": "指定版本",
-		"installed_version": "已安装版本",
-		"list_of_env_vars": "环境变量列表",
-		"env_var_priority": "对于安全性以外的环境变量,优先获取数据库的值。",
-		"about_security": "检查安全环境变量的<a href='/admin/security'>安全设置</a>。"
-	},
-	"app_setting": {
-		"site_name": "网站名称 ",
-		"sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
-		"header_content": "此处输入的内容将显示在标题等中。",
-		"site_url_desc": "用于网站URL设置。",
-		"site_url_warn": "某些功能不起作用,因为未设置网站URL。",
-		"siteurl_help": "网站完整URL起始于 <code>http://</code> or <code>https://</code>.",
-		"confidential_name": "内部名称",
-		"confidential_example": "ex):仅供内部使用",
-		"default_language": "新用户的默认语言",
-		"default_mail_visibility": "新用户的默认电子邮件可见性",
-		"file_uploading": "文件上传",
-		"enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
-		"attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
-		"update": "更新",
-		"mail_settings": "邮件设置",
+  "mailer_setup_required": "<a href='/admin/app'>Email settings</a> are required to send.",
+  "admin_top": {
+    "management_wiki": "管理Wiki",
+    "system_information": "系统信息",
+    "wiki_administrator": "只有wiki管理员可以访问此页",
+    "assign_administrator": "您可以使用“授予管理员访问权限”按钮在“用户管理”页上将所选用户指定为wiki管理员",
+    "list_of_installed_plugins": "已安装插件列表",
+    "package_name": "包名称",
+    "specified_version": "指定版本",
+    "installed_version": "已安装版本",
+    "list_of_env_vars": "环境变量列表",
+    "env_var_priority": "对于安全性以外的环境变量,优先获取数据库的值。",
+    "about_security": "检查安全环境变量的<a href='/admin/security'>安全设置</a>。"
+  },
+  "app_setting": {
+    "site_name": "网站名称 ",
+    "sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
+    "header_content": "此处输入的内容将显示在标题等中。",
+    "site_url_desc": "用于网站URL设置。",
+    "site_url_warn": "某些功能不起作用,因为未设置网站URL。",
+    "siteurl_help": "网站完整URL起始于 <code>http://</code> or <code>https://</code>.",
+    "confidential_name": "内部名称",
+    "confidential_example": "ex):仅供内部使用",
+    "default_language": "新用户的默认语言",
+    "default_mail_visibility": "新用户的默认电子邮件可见性",
+    "file_uploading": "文件上传",
+    "enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
+    "attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
+    "update": "更新",
+    "mail_settings": "邮件设置",
     "mailer_is_not_set_up": "邮件设置尚未完成。",
     "mailer_is_not_set_up": "邮件设置尚未完成。",
-    "transmission_method":"传送方法",
-    "smtp_label":"SMTP",
-    "ses_label":"SES(AWS)",
-		"from_e-mail_address": "邮件发出地址",
+    "transmission_method": "传送方法",
+    "smtp_label": "SMTP",
+    "ses_label": "SES(AWS)",
+    "from_e-mail_address": "邮件发出地址",
     "send_test_email": "发送测试邮件",
     "send_test_email": "发送测试邮件",
     "success_to_send_test_email": "成功发送了一封测试邮件",
     "success_to_send_test_email": "成功发送了一封测试邮件",
     "smtp_settings": "SMTP 设置",
     "smtp_settings": "SMTP 设置",
-		"host": "服务器",
-		"port": "端口号",
-		"user": "用户名",
+    "host": "服务器",
+    "port": "端口号",
+    "user": "用户名",
     "initialize_mail_settings": "重置邮件设置",
     "initialize_mail_settings": "重置邮件设置",
     "initialize_mail_modal_header": "重置邮件设置",
     "initialize_mail_modal_header": "重置邮件设置",
     "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
     "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
-    "file_upload_settings":"文件上传设置",
-    "file_upload_method":"文件上传方法",
-    "file_delivery_method":"File Delivery Method",
-    "file_delivery_method_redirect":"Redirect",
-    "file_delivery_method_relay":"Internal System Relay",
-    "file_delivery_method_redirect_info":"Redirect: It redirects to a signed URL without GROWI server, it gives excellent performance.",
-    "file_delivery_method_relay_info":"Internal System Relay: The GROWI server delivers to clients, it provides complete security.",
+    "file_upload_settings": "文件上传设置",
+    "file_upload_method": "文件上传方法",
+    "file_delivery_method": "File Delivery Method",
+    "file_delivery_method_redirect": "Redirect",
+    "file_delivery_method_relay": "Internal System Relay",
+    "file_delivery_method_redirect_info": "Redirect: It redirects to a signed URL without GROWI server, it gives excellent performance.",
+    "file_delivery_method_relay_info": "Internal System Relay: The GROWI server delivers to clients, it provides complete security.",
     "gcs_label": "GCP(GCS)",
     "gcs_label": "GCP(GCS)",
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
     "gridfs_label": "MongoDB(GridFS)",
-    "ses_settings":"SES设置",
+    "ses_settings": "SES设置",
     "test_connection": "测试邮件服务器连接",
     "test_connection": "测试邮件服务器连接",
-		"": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
-		"change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
-		"region": "Region",
-		"bucket_name": "Bucket name",
-		"custom_endpoint": "Custom endpoint",
-		"custom_endpoint_change": "输入对象存储服务(如MinIO)端点的URL,MinIO具有与S3兼容的API。如果为空,则使用Amazon S3。",
-		"plugin_settings": "插件设置",
-		"enable_plugin_loading": "启用插件加载",
-		"load_plugins": "加载插件",
-		"enable": "启用",
-		"disable": "停用",
-		"use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <cod>{{variable}}</code> 启用。",
+    "": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
+    "change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
+    "region": "Region",
+    "bucket_name": "Bucket name",
+    "custom_endpoint": "Custom endpoint",
+    "custom_endpoint_change": "输入对象存储服务(如MinIO)端点的URL,MinIO具有与S3兼容的API。如果为空,则使用Amazon S3。",
+    "plugin_settings": "插件设置",
+    "enable_plugin_loading": "启用插件加载",
+    "load_plugins": "加载插件",
+    "enable": "启用",
+    "disable": "停用",
+    "use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <cod>{{variable}}</code> 启用。",
     "note_for_the_only_env_option": "The GCS settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
     "note_for_the_only_env_option": "The GCS settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
   },
   },
-	"markdown_setting": {
-		"lineBreak_header": "换行设置",
-		"lineBreak_desc": "您可以更改换行设置。",
-		"lineBreak_options": {
-			"enable_lineBreak": "启用换行符",
-			"enable_lineBreak_desc": "HTML中将文本页中的换行符转换为<code>&lt;br&gt;</code>",
-			"enable_lineBreak_for_comment": "注释中启用换行符",
-			"enable_lineBreak_for_comment_desc": "HTML中将注释中的换行符转换为<code>&lt;br&gt;</code>"
-		},
+  "markdown_setting": {
+    "lineBreak_header": "换行设置",
+    "lineBreak_desc": "您可以更改换行设置。",
+    "lineBreak_options": {
+      "enable_lineBreak": "启用换行符",
+      "enable_lineBreak_desc": "HTML中将文本页中的换行符转换为<code>&lt;br&gt;</code>",
+      "enable_lineBreak_for_comment": "注释中启用换行符",
+      "enable_lineBreak_for_comment_desc": "HTML中将注释中的换行符转换为<code>&lt;br&gt;</code>"
+    },
     "indent_header": "缩进设置",
     "indent_header": "缩进设置",
     "indent_desc": "您可以更改缩进设置。",
     "indent_desc": "您可以更改缩进设置。",
     "indent_options": {
     "indent_options": {
@@ -88,184 +88,179 @@
       "disallow_indent_change_desc": "您可以不允许用户更改缩进值。"
       "disallow_indent_change_desc": "您可以不允许用户更改缩进值。"
     },
     },
     "presentation_header": "演示文稿设置",
     "presentation_header": "演示文稿设置",
-		"presentation_desc": "您可以更改演示文稿设置。",
-		"presentation_options": {
-			"page_break_setting": "分页设置",
-			"preset_one_separator": "预设 1",
-			"preset_one_separator_desc": "3 空行",
-			"preset_one_separator_value": "\\n\\n\\n",
-			"preset_two_separator": "预设 2",
-			"preset_two_separator_desc": "5 连字符",
-			"preset_two_separator_value": "-----",
-			"custom_separator": "自定义",
-			"custom_separator_desc": "正则表达式"
-		},
-		"xss_header": "阻止XSS(跨站点脚本)设置",
-		"xss_desc": "您可以更改标记文本中HTML标记的处理方式。",
-		"xss_options": {
-			"enable_xss_prevention": "启用XSS预防",
-			"remove_all_tags": "删除所有标记",
-			"remove_all_tags_desc": "Stripe all HTML tags and attributes",
-			"recommended_setting": "推荐设置",
-			"custom_whitelist": "自定义白名单",
-			"tag_names": "标记名",
-			"tag_attributes": "标记属性",
-			"import_recommended": "导入建议 {{target}}"
-		}
-	},
-	"customize_setting": {
+    "presentation_desc": "您可以更改演示文稿设置。",
+    "presentation_options": {
+      "page_break_setting": "分页设置",
+      "preset_one_separator": "预设 1",
+      "preset_one_separator_desc": "3 空行",
+      "preset_one_separator_value": "\\n\\n\\n",
+      "preset_two_separator": "预设 2",
+      "preset_two_separator_desc": "5 连字符",
+      "preset_two_separator_value": "-----",
+      "custom_separator": "自定义",
+      "custom_separator_desc": "正则表达式"
+    },
+    "xss_header": "阻止XSS(跨站点脚本)设置",
+    "xss_desc": "您可以更改标记文本中HTML标记的处理方式。",
+    "xss_options": {
+      "enable_xss_prevention": "启用XSS预防",
+      "remove_all_tags": "删除所有标记",
+      "remove_all_tags_desc": "Stripe all HTML tags and attributes",
+      "recommended_setting": "推荐设置",
+      "custom_whitelist": "自定义白名单",
+      "tag_names": "标记名",
+      "tag_attributes": "标记属性",
+      "import_recommended": "导入建议 {{target}}"
+    }
+  },
+  "customize_setting": {
     "layout": "布局",
     "layout": "布局",
     "layout_options": {
     "layout_options": {
       "default": "默认内容宽度 ",
       "default": "默认内容宽度 ",
       "expanded": "内容宽度100% "
       "expanded": "内容宽度100% "
     },
     },
-		"theme": "主体",
-		"behavior": "行为",
-		"behavior_desc": {
-			"growi_text1": "<code>/page</code> and <code>/page/</code> 都显示同一页。",
-			"growi_text2": "<code>/nonexistent_page</code> 显示编辑表单",
-			"growi_text3": "如果使用GROWI增强布局,则所有页面都显示子页面列表",
-			"crowi_text1": "<code>/page</code> 显示页面",
-			"crowi_text2": "<code>/page/</code> 显示子页列表",
-			"crowi_text3": "如果portal应用于<code>/page/</code>,则会显示portal和子页面列表",
-			"crowi_text4": "<code>/nonexistent_page</code> 显示编辑表单<",
-			"crowi_text5": "<code>/nonexistent_page/</code> 子页列表"
-		},
-		"theme_desc": {
-			"light_and_dark": "明暗模式",
-			"unique": "只有一种模式"
-		},
-		"function": "功能",
-		"function_desc": "您可以选择函数的有效/无效",
-		"function_options": {
-			"timeline": "时间线函数",
-			"timeline_desc1": "您可以显示子页的时间线。",
-			"timeline_desc2": "如果有许多子页,则在加载页时性能会降低。",
-			"timeline_desc3": "通过使列表页无效,可以加快列表页的显示速度。",
-			"tab_switch": "在浏览器中保存选项卡切换",
-			"tab_switch_desc1": "在浏览器中保存编辑选项卡和历史选项卡切换,并使其成为浏览器的前向/后向命令的对象。",
-			"tab_switch_desc2": "通过失效,您可以将页面转换作为浏览器的前向/后向命令的唯一对象。",
-			"attach_title_header": "自动创建新页面时添加h1节",
+    "theme": "主体",
+    "behavior": "行为",
+    "behavior_desc": {
+      "growi_text1": "<code>/page</code> and <code>/page/</code> 都显示同一页。",
+      "growi_text2": "<code>/nonexistent_page</code> 显示编辑表单",
+      "growi_text3": "如果使用GROWI增强布局,则所有页面都显示子页面列表",
+      "crowi_text1": "<code>/page</code> 显示页面",
+      "crowi_text2": "<code>/page/</code> 显示子页列表",
+      "crowi_text3": "如果portal应用于<code>/page/</code>,则会显示portal和子页面列表",
+      "crowi_text4": "<code>/nonexistent_page</code> 显示编辑表单<",
+      "crowi_text5": "<code>/nonexistent_page/</code> 子页列表"
+    },
+    "theme_desc": {
+      "light_and_dark": "明暗模式",
+      "unique": "只有一种模式"
+    },
+    "function": "功能",
+    "function_desc": "您可以选择函数的有效/无效",
+    "function_options": {
+      "timeline": "时间线函数",
+      "timeline_desc1": "您可以显示子页的时间线。",
+      "timeline_desc2": "如果有许多子页,则在加载页时性能会降低。",
+      "timeline_desc3": "通过使列表页无效,可以加快列表页的显示速度。",
+      "tab_switch": "在浏览器中保存选项卡切换",
+      "tab_switch_desc1": "在浏览器中保存编辑选项卡和历史选项卡切换,并使其成为浏览器的前向/后向命令的对象。",
+      "tab_switch_desc2": "通过失效,您可以将页面转换作为浏览器的前向/后向命令的唯一对象。",
+      "attach_title_header": "自动创建新页面时添加h1节",
       "attach_title_header_desc": "创建新页面时,将页面路径作为h1节添加到第一行",
       "attach_title_header_desc": "创建新页面时,将页面路径作为h1节添加到第一行",
-
       "list_num_s": "Number of list displayed on modals",
       "list_num_s": "Number of list displayed on modals",
       "list_num_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
       "list_num_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
-
       "list_num_m": "Number of list displayed on article pages included other contents",
       "list_num_m": "Number of list displayed on article pages included other contents",
       "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages.",
       "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages.",
-
       "list_num_l": "Number of list displayed on 'Search' pages",
       "list_num_l": "Number of list displayed on 'Search' pages",
       "list_num_desc_l": "Set number of list per page such as 'Search' pages.",
       "list_num_desc_l": "Set number of list per page such as 'Search' pages.",
-
       "list_num_xl": "Number of list displayed on article pages",
       "list_num_xl": "Number of list displayed on article pages",
       "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
       "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
-
-			"stale_notification": "在过期页上显示通知",
-			"stale_notification_desc": "显示自上次更新以来超过1年的页面通知。",
-			"show_all_reply_comments": "显示所有回复评论",
-			"show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。"
-		},
-		"code_highlight": "代码突出显示",
-		"nocdn_desc": "当强制应用环境变量<code>NO_CDN=true</code><br>Github样式时,此函数被禁用。",
-		"custom_title": "自定义标题",
-		"custom_title_detail": "您可以自定义<code>&lt;title&gt;</code>标记。<br><code>&123;&123;sitename&&125;&125;</code>将自动替换为应用程序名称,并且<code>&123;&123;page&&125;&125;</code>将替换为页面名称/路径。",
-		"custom_title_detail_placeholder1": "<code>&#123;&#123;站点名称&#125;&#125;</code>-此wiki的站点名称。",
-		"custom_title_detail_placeholder2": "<code>&#123;&#123;页名&#125;&#125;</code>-当前页的页名。",
-		"custom_title_detail_placeholder3": "<code>&#123;&#123;页面路径&#125;&#125;</code>-当前页面的页面路径。",
-		"custom_header": "自定义HTML标题",
-		"custom_header_detail": "您可以自定义应用所有页面的HTML标题。您的自定义脚本将插入<code>&lt;header&gt;</code>中,但位于其他<code>&lt;script&gt;</code>标记之上。<br>重新链接页面以查看更改。",
-		"custom_css": "自定义CSS",
-		"write_css": "您可以编写应用于整个系统的CSS。",
-		"ctrl_space": "Ctrl+Space 自动完成",
-		"custom_script": "定制纸条",
-		"write_java": "您可以编写应用于整个系统的Javascript。",
-		"reflect_change": "您需要重新加载页面以反映更改。"
-	},
-	"importer_management": {
-		"beta_warning": "这个函数是Beta。",
-		"import_from": "Import from {{from}}",
-		"import_growi_archive": "Import GROWI archive",
-		"growi_settings": {
-			"description_of_import_mode": {
-				"about": "When you import data with the same name as an existing one, choose from the following three modes below.",
-				"insert": "Insert: Skip importing the data.",
-				"upsert": "Upsert: Overwrite and update the existing data with imported data.",
-				"flash_and_insert": "Flash and Insert: After deleting the existing data completely, import the data"
-			},
-			"growi_archive_file": "GROWI Archive File",
-			"uploaded_data": "Uploaded Data",
-			"extracted_file": "Extracted File",
-			"collection": "Collection",
-			"upload": "Upload",
-			"discard": "Discard uploaded data",
-			"errors": {
+      "stale_notification": "在过期页上显示通知",
+      "stale_notification_desc": "显示自上次更新以来超过1年的页面通知。",
+      "show_all_reply_comments": "显示所有回复评论",
+      "show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。"
+    },
+    "code_highlight": "代码突出显示",
+    "nocdn_desc": "当强制应用环境变量<code>NO_CDN=true</code><br>Github样式时,此函数被禁用。",
+    "custom_title": "自定义标题",
+    "custom_title_detail": "您可以自定义<code>&lt;title&gt;</code>标记。<br><code>&123;&123;sitename&&125;&125;</code>将自动替换为应用程序名称,并且<code>&123;&123;page&&125;&125;</code>将替换为页面名称/路径。",
+    "custom_title_detail_placeholder1": "<code>&#123;&#123;站点名称&#125;&#125;</code>-此wiki的站点名称。",
+    "custom_title_detail_placeholder2": "<code>&#123;&#123;页名&#125;&#125;</code>-当前页的页名。",
+    "custom_title_detail_placeholder3": "<code>&#123;&#123;页面路径&#125;&#125;</code>-当前页面的页面路径。",
+    "custom_header": "自定义HTML标题",
+    "custom_header_detail": "您可以自定义应用所有页面的HTML标题。您的自定义脚本将插入<code>&lt;header&gt;</code>中,但位于其他<code>&lt;script&gt;</code>标记之上。<br>重新链接页面以查看更改。",
+    "custom_css": "自定义CSS",
+    "write_css": "您可以编写应用于整个系统的CSS。",
+    "ctrl_space": "Ctrl+Space 自动完成",
+    "custom_script": "定制纸条",
+    "write_java": "您可以编写应用于整个系统的Javascript。",
+    "reflect_change": "您需要重新加载页面以反映更改。"
+  },
+  "importer_management": {
+    "beta_warning": "这个函数是Beta。",
+    "import_from": "Import from {{from}}",
+    "import_growi_archive": "Import GROWI archive",
+    "growi_settings": {
+      "description_of_import_mode": {
+        "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
+        "insert": "Insert: Skip importing the data.",
+        "upsert": "Upsert: Overwrite and update the existing data with imported data.",
+        "flash_and_insert": "Flash and Insert: After deleting the existing data completely, import the data"
+      },
+      "growi_archive_file": "GROWI Archive File",
+      "uploaded_data": "Uploaded Data",
+      "extracted_file": "Extracted File",
+      "collection": "Collection",
+      "upload": "Upload",
+      "discard": "Discard uploaded data",
+      "errors": {
         "versions_not_met": "this growi and the uploaded data versions are not met",
         "versions_not_met": "this growi and the uploaded data versions are not met",
-				"at_least_one": "Select one or more collections.",
-				"page_and_revision": "'Pages' and 'Revisions' must be imported both.",
-				"depends": "'{{target}}' must be selected when '{{condition}}' is selected."
-			},
-			"configuration": {
-				"pages": {
-					"overwrite_author": {
-						"label": "Overwrite page's author with the current user",
-						"desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
-					},
-					"set_public_to_page": {
-						"label": "Set 'Public' to the pages that is '{{from}}'",
-						"desc": "Make sure that this configuration makes all <b>'{{from}}'</b> pages readable from <span class=\"text-danger\">ANY users</span>."
-					},
-					"initialize_meta_datas": {
-						"label": "Initialize page's like, read users and comment count",
-						"desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
-					},
-					"initialize_hackmd_related_datas": {
-						"label": "Initialize HackMD related data",
-						"desc": "Recommended to check this unless there is important drafts on HackMD."
-					}
-				},
-				"revisions": {
-					"overwrite_author": {
-						"label": "Overwrite revision's author with the current user",
-						"desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
-					}
-				}
-			}
-		},
-		"esa_settings": {
-			"team_name": "Team name",
-			"access_token": "Access token",
-			"test_connection": "Test connection to esa"
-		},
-		"qiita_settings": {
-			"team_name": "Team name",
-			"access_token": "Access token",
-			"test_connection": "Test connection to qiita:team"
-		},
+        "at_least_one": "Select one or more collections.",
+        "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
+        "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
+      },
+      "configuration": {
+        "pages": {
+          "overwrite_author": {
+            "label": "Overwrite page's author with the current user",
+            "desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
+          },
+          "set_public_to_page": {
+            "label": "Set 'Public' to the pages that is '{{from}}'",
+            "desc": "Make sure that this configuration makes all <b>'{{from}}'</b> pages readable from <span class=\"text-danger\">ANY users</span>."
+          },
+          "initialize_meta_datas": {
+            "label": "Initialize page's like, read users and comment count",
+            "desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
+          },
+          "initialize_hackmd_related_datas": {
+            "label": "Initialize HackMD related data",
+            "desc": "Recommended to check this unless there is important drafts on HackMD."
+          }
+        },
+        "revisions": {
+          "overwrite_author": {
+            "label": "Overwrite revision's author with the current user",
+            "desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
+          }
+        }
+      }
+    },
+    "esa_settings": {
+      "team_name": "Team name",
+      "access_token": "Access token",
+      "test_connection": "Test connection to esa"
+    },
+    "qiita_settings": {
+      "team_name": "Team name",
+      "access_token": "Access token",
+      "test_connection": "Test connection to qiita:team"
+    },
     "import": "Import",
     "import": "Import",
     "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment",
     "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment",
-    "prepare_new_account_for_migration":"Prepare new account for migration",
-    "archive_data_import_detail":"More details? Click here.",
-    "admin_archive_data_import_guide_url":"https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
-		"page_skip": "Pages with a name that already exists on GROWI are not imported",
-		"Directory_hierarchy_tag": "Directory hierarchy tag"
-	},
-	"export_management": {
-		"exporting_collection_list": "正在导出集合列表",
-		"exported_data_list": "导出的存档数据列表",
-		"export_collections": "导出集合",
-		"check_all": "全部检查",
-		"uncheck_all": "全部取消选中",
-		"desc_password_seed": "<p>还原用户数据时,不要忘记将当前的<code>密码种子设置到新的GROWI系统,否则用户将无法使用其密码登录。<br><br><strong>提示:</strong><br>当前的<code>密码种子将存储在<code>meta.json格式</code>在导出的zip压缩包中。</p>",
-		"create_new_archive_data": "创建新的存档数据",
-		"export": "导出",
-		"cancel": "取消",
-		"file": "文件",
-		"growi_version": "Growi Version",
-		"collections": "Collections",
-		"exported_at": "Exported At",
-		"export_menu": "导出菜单",
-		"download": "下载",
-		"delete": "删除"
+    "prepare_new_account_for_migration": "Prepare new account for migration",
+    "archive_data_import_detail": "More details? Click here.",
+    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
+    "page_skip": "Pages with a name that already exists on GROWI are not imported",
+    "Directory_hierarchy_tag": "Directory hierarchy tag"
+  },
+  "export_management": {
+    "exporting_collection_list": "正在导出集合列表",
+    "exported_data_list": "导出的存档数据列表",
+    "export_collections": "导出集合",
+    "check_all": "全部检查",
+    "uncheck_all": "全部取消选中",
+    "desc_password_seed": "<p>还原用户数据时,不要忘记将当前的<code>密码种子设置到新的GROWI系统,否则用户将无法使用其密码登录。<br><br><strong>提示:</strong><br>当前的<code>密码种子将存储在<code>meta.json格式</code>在导出的zip压缩包中。</p>",
+    "create_new_archive_data": "创建新的存档数据",
+    "export": "导出",
+    "cancel": "取消",
+    "file": "文件",
+    "growi_version": "Growi Version",
+    "collections": "Collections",
+    "exported_at": "Exported At",
+    "export_menu": "导出菜单",
+    "download": "下载",
+    "delete": "删除"
   },
   },
   "slack_integration": {
   "slack_integration": {
     "selecting_bot_types": {
     "selecting_bot_types": {
@@ -304,8 +299,8 @@
     "delete": "取消",
     "delete": "取消",
     "integration_procedure": "协作程序",
     "integration_procedure": "协作程序",
     "custom_bot_without_proxy_settings": "Custom Bot without proxy 设置",
     "custom_bot_without_proxy_settings": "Custom Bot without proxy 设置",
-    "integration_failed":"联动失败",
-    "reset":"重置",
+    "integration_failed": "联动失败",
+    "reset": "重置",
     "reset_all_settings": "重置所有设置",
     "reset_all_settings": "重置所有设置",
     "delete_slackbot_settings": "删除 Slack Bot 设置",
     "delete_slackbot_settings": "删除 Slack Bot 设置",
     "slackbot_settings_notice": "Slak 工作区集成过程已被删除。 <br> 你确定吗?",
     "slackbot_settings_notice": "Slak 工作区集成过程已被删除。 <br> 你确定吗?",
@@ -333,6 +328,9 @@
       "install_complete_if_checked": "确认已选中 \"Install your app\"。",
       "install_complete_if_checked": "确认已选中 \"Install your app\"。",
       "invite_bot_to_channel": "通过调用 @example 邀请 GROWI Bot 进行频道。",
       "invite_bot_to_channel": "通过调用 @example 邀请 GROWI Bot 进行频道。",
       "register_secret_and_token": "设置签名秘密和BOT令牌",
       "register_secret_and_token": "设置签名秘密和BOT令牌",
+      "manage_commands": "管理 GROWI 命令",
+      "multiple_growi_command": "可以一次发送到多个 GROWI 实例的命令",
+      "single_growi_command": "可以一次发送到一个 GROWI 实例的命令",
       "test_connection": "测试连接",
       "test_connection": "测试连接",
       "test_connection_by_pressing_button": "按下按钮以测试连接",
       "test_connection_by_pressing_button": "按下按钮以测试连接",
       "error_check_logs_below": "发生了错误。请检查以下日志。",
       "error_check_logs_below": "发生了错误。请检查以下日志。",
@@ -357,93 +355,93 @@
       "custom_bot_with_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
       "custom_bot_with_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
     }
     }
   },
   },
-	"user_management": {
-		"invite_users": "临时发布新用户",
-		"click_twice_same_checkbox": "您应该至少选中一个复选框。",
-		"invite_modal": {
-			"emails": "电子邮件",
-      "description1":"通过电子邮件地址临时发布新用户。",
-      "description2":"将为首次登录生成一个临时密码。",
-      "mail_setting_link":"<i class='icon-settings mr-2'></i><a href='/admin/app'>Email settings</a>",
-			"valid_email": "需要有效的电子邮件地址",
-			"invite_thru_email": "发送邀请电子邮件",
-			"temporary_password": "创建的用户具有临时密码",
-			"send_new_password": "请将新密码发送给用户。",
-			"send_temporary_password": "如果你没有发送电子邮件邀请,请复制此屏幕上的临时密码并联系邀请人。",
+  "user_management": {
+    "invite_users": "临时发布新用户",
+    "click_twice_same_checkbox": "您应该至少选中一个复选框。",
+    "invite_modal": {
+      "emails": "电子邮件",
+      "description1": "通过电子邮件地址临时发布新用户。",
+      "description2": "将为首次登录生成一个临时密码。",
+      "mail_setting_link": "<i class='icon-settings mr-2'></i><a href='/admin/app'>Email settings</a>",
+      "valid_email": "需要有效的电子邮件地址",
+      "invite_thru_email": "发送邀请电子邮件",
+      "temporary_password": "创建的用户具有临时密码",
+      "send_new_password": "请将新密码发送给用户。",
+      "send_temporary_password": "如果你没有发送电子邮件邀请,请复制此屏幕上的临时密码并联系邀请人。",
       "send_email": "你也可以从用户表中的下拉菜单中发送或重新发送邀请邮件。",
       "send_email": "你也可以从用户表中的下拉菜单中发送或重新发送邀请邮件。",
-			"existing_email": "以下电子邮件已存在",
+      "existing_email": "以下电子邮件已存在",
       "issue": "Issue"
       "issue": "Issue"
-		},
-		"user_table": {
-			"administrator": "管理员",
-			"edit_menu": "编辑菜单",
-			"reset_password": "重置密码",
-			"administrator_menu": "管理员菜单",
-			"accept": "接受",
-			"deactivate_account": "停用帐户",
-			"your_own": "您不能停用自己的帐户",
-			"remove_admin_access": "删除管理员访问权限",
-			"cannot_remove": "您不能从管理员中删除自己",
-			"give_admin_access": "授予管理员访问权限",
+    },
+    "user_table": {
+      "administrator": "管理员",
+      "edit_menu": "编辑菜单",
+      "reset_password": "重置密码",
+      "administrator_menu": "管理员菜单",
+      "accept": "接受",
+      "deactivate_account": "停用帐户",
+      "your_own": "您不能停用自己的帐户",
+      "remove_admin_access": "删除管理员访问权限",
+      "cannot_remove": "您不能从管理员中删除自己",
+      "give_admin_access": "授予管理员访问权限",
       "send_invitation_email": "发送邀请邮件",
       "send_invitation_email": "发送邀请邮件",
       "resend_invitation_email": "重发邀请函"
       "resend_invitation_email": "重发邀请函"
-		},
-		"reset_password": "重置密码",
-		"reset_password_modal": {
-			"password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
-			"password_reset_message": "Let the user know the new password below and strongly recommend to change another one immediately.",
-			"send_new_password": "Please send the new password to the user.",
-			"target_user": "Target User",
-			"new_password": "New Password"
-		},
-		"external_account": "外部账户管理",
-		"external_accounts": "外部账户",
-		"create_external_account": "创建外部账户",
+    },
+    "reset_password": "重置密码",
+    "reset_password_modal": {
+      "password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
+      "password_reset_message": "Let the user know the new password below and strongly recommend to change another one immediately.",
+      "send_new_password": "Please send the new password to the user.",
+      "target_user": "Target User",
+      "new_password": "New Password"
+    },
+    "external_account": "外部账户管理",
+    "external_accounts": "外部账户",
+    "create_external_account": "创建外部账户",
     "external_account_list": "外部账户列表",
     "external_account_list": "外部账户列表",
-    "external_account_none":"No External Account",
-		"invite": "邀请",
-		"invited": "已邀请用户",
-		"back_to_user_management": "返回用户管理",
-		"authentication_provider": "身份认证",
-		"manage": "管理",
-		"password_setting": "密码设置",
-		"password_setting_help": "是否设置了密码?",
-		"set": "是",
-		"unset": "否",
-		"related_username": "相关用户的",
-		"cannot_invite_maximum_users": "邀请的用户数不能超过最大值。",
-		"current_users": "当前用户:"
-	},
-	"user_group_management": {
-		"create_group": "创建新组",
-		"deny_create_group": "不能用当前设置创建新组。",
-		"group_name": "组名",
-		"group_example": "e.g.:第1组",
-		"add_modal": {
-			"add_user": "将用户添加到创建的组",
-			"search_option": "搜索选项",
-			"enable_option": "启用{{option}",
-			"forward_match": "Forword匹配",
-			"partial_match": "部分匹配",
-			"backward_match": "向后匹配"
-		},
-		"group_list": "组列表",
-		"back_to_list": "返回组列表",
-		"basic_info": "基本信息",
-		"user_list": "用户列表",
-		"created_group": "已创建组",
-		"is_loading_data": "获取数据。。。",
-		"no_pages": "组没有查看权限的页面。",
-		"remove_from_group": "删除此用户",
-		"delete_modal": {
-			"header": "删除组",
-			"desc": "删除后,将无法检索已删除的组及其私人页。",
-			"dropdown_desc": "为私人页选择操作",
-			"select_group": "选择组",
-			"no_groups": "没有可选择的组",
-			"publish_pages": "全部发布",
-			"delete_pages": "全部删除",
-			"transfer_pages": "转移到另一组"
-		}
-	}
+    "external_account_none": "No External Account",
+    "invite": "邀请",
+    "invited": "已邀请用户",
+    "back_to_user_management": "返回用户管理",
+    "authentication_provider": "身份认证",
+    "manage": "管理",
+    "password_setting": "密码设置",
+    "password_setting_help": "是否设置了密码?",
+    "set": "是",
+    "unset": "否",
+    "related_username": "相关用户的",
+    "cannot_invite_maximum_users": "邀请的用户数不能超过最大值。",
+    "current_users": "当前用户:"
+  },
+  "user_group_management": {
+    "create_group": "创建新组",
+    "deny_create_group": "不能用当前设置创建新组。",
+    "group_name": "组名",
+    "group_example": "e.g.:第1组",
+    "add_modal": {
+      "add_user": "将用户添加到创建的组",
+      "search_option": "搜索选项",
+      "enable_option": "启用{{option}",
+      "forward_match": "Forword匹配",
+      "partial_match": "部分匹配",
+      "backward_match": "向后匹配"
+    },
+    "group_list": "组列表",
+    "back_to_list": "返回组列表",
+    "basic_info": "基本信息",
+    "user_list": "用户列表",
+    "created_group": "已创建组",
+    "is_loading_data": "获取数据。。。",
+    "no_pages": "组没有查看权限的页面。",
+    "remove_from_group": "删除此用户",
+    "delete_modal": {
+      "header": "删除组",
+      "desc": "删除后,将无法检索已删除的组及其私人页。",
+      "dropdown_desc": "为私人页选择操作",
+      "select_group": "选择组",
+      "no_groups": "没有可选择的组",
+      "publish_pages": "全部发布",
+      "delete_pages": "全部删除",
+      "transfer_pages": "转移到另一组"
+    }
+  }
 }
 }

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

@@ -103,7 +103,9 @@ const CustomBotWithProxySettings = (props) => {
 
 
       <div className="mx-3">
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
         {slackAppIntegrations.map((slackAppIntegration, i) => {
-          const { tokenGtoP, tokenPtoG, _id } = slackAppIntegration;
+          const {
+            tokenGtoP, tokenPtoG, _id, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+          } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
           return (
             <React.Fragment key={slackAppIntegration._id}>
             <React.Fragment key={slackAppIntegration._id}>
@@ -125,6 +127,8 @@ const CustomBotWithProxySettings = (props) => {
                 slackAppIntegrationId={slackAppIntegration._id}
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
                 tokenPtoG={tokenPtoG}
+                supportedCommandsForBroadcastUse={supportedCommandsForBroadcastUse}
+                supportedCommandsForSingleUse={supportedCommandsForSingleUse}
                 onUpdateTokens={onUpdateTokens}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
                 onSubmitForm={onSubmitForm}
               />
               />

+ 144 - 0
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx

@@ -0,0 +1,144 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+import loggerFactory from '~/utils/logger';
+
+import { toastSuccess, toastError } from '../../../client/util/apiNotification';
+
+const logger = loggerFactory('growi:SlackIntegration:ManageCommandsProcess');
+
+const ManageCommandsProcess = ({
+  apiv3Put, slackAppIntegrationId, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+}) => {
+  const { t } = useTranslation();
+  const [selectedCommandsForBroadcastUse, setSelectedCommandsForBroadcastUse] = useState(new Set(supportedCommandsForBroadcastUse));
+  const [selectedCommandsForSingleUse, setSelectedCommandsForSingleUse] = useState(new Set(supportedCommandsForSingleUse));
+
+  const toggleCheckboxForBroadcast = (e) => {
+    const { target } = e;
+    const { name, checked } = target;
+
+    setSelectedCommandsForBroadcastUse((prevState) => {
+      const selectedCommands = new Set(prevState);
+      if (checked) {
+        selectedCommands.add(name);
+      }
+      else {
+        selectedCommands.delete(name);
+      }
+
+      return selectedCommands;
+    });
+  };
+
+  const toggleCheckboxForSingleUse = (e) => {
+    const { target } = e;
+    const { name, checked } = target;
+
+    setSelectedCommandsForSingleUse((prevState) => {
+      const selectedCommands = new Set(prevState);
+      if (checked) {
+        selectedCommands.add(name);
+      }
+      else {
+        selectedCommands.delete(name);
+      }
+
+      return selectedCommands;
+    });
+  };
+
+  const updateCommandsHandler = async() => {
+    try {
+      await apiv3Put(`/slack-integration-settings/${slackAppIntegrationId}/supported-commands`, {
+        supportedCommandsForBroadcastUse: Array.from(selectedCommandsForBroadcastUse),
+        supportedCommandsForSingleUse: Array.from(selectedCommandsForSingleUse),
+      });
+      toastSuccess(t('toaster.update_successed', { target: 'Token' }));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  };
+
+
+  return (
+    <div className="py-4 px-5">
+      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.manage_commands')}</p>
+      <div className="d-flex flex-column align-items-center">
+
+        <div>
+          <p className="font-weight-bold mb-0">Multiple GROWI</p>
+          <p className="text-muted mb-2">{t('admin:slack_integration.accordion.multiple_growi_command')}</p>
+          <div className="custom-control custom-checkbox">
+            <div className="row mb-5">
+              {defaultSupportedCommandsNameForBroadcastUse.map((commandName) => {
+                return (
+                  <div className="col-sm-6 my-1" key={commandName}>
+                    <input
+                      type="checkbox"
+                      className="custom-control-input"
+                      id={commandName}
+                      name={commandName}
+                      value={commandName}
+                      checked={selectedCommandsForBroadcastUse.has(commandName)}
+                      onChange={toggleCheckboxForBroadcast}
+                    />
+                    <label className="text-capitalize custom-control-label ml-3" htmlFor={commandName}>
+                      {commandName}
+                    </label>
+                  </div>
+                );
+              })}
+            </div>
+          </div>
+
+          <p className="font-weight-bold mb-0">Single GROWI</p>
+          <p className="text-muted mb-2">{t('admin:slack_integration.accordion.single_growi_command')}</p>
+          <div className="custom-control custom-checkbox">
+            <div className="row mb-5">
+              {defaultSupportedCommandsNameForSingleUse.map((commandName) => {
+                return (
+                  <div className="col-sm-6 my-1" key={commandName}>
+                    <input
+                      type="checkbox"
+                      className="custom-control-input"
+                      id={commandName}
+                      name={commandName}
+                      value={commandName}
+                      checked={selectedCommandsForSingleUse.has(commandName)}
+                      onChange={toggleCheckboxForSingleUse}
+                    />
+                    <label className="text-capitalize custom-control-label ml-3" htmlFor={commandName}>
+                      {commandName}
+                    </label>
+                  </div>
+                );
+              })}
+            </div>
+          </div>
+        </div>
+      </div>
+      <div className="row">
+        <button
+          type="button"
+          className="btn btn-primary mx-auto"
+          onClick={updateCommandsHandler}
+        >
+          { t('Update') }
+        </button>
+      </div>
+    </div>
+  );
+};
+
+ManageCommandsProcess.propTypes = {
+  apiv3Put: PropTypes.func,
+  slackAppIntegrationId: PropTypes.string.isRequired,
+  supportedCommandsForBroadcastUse: PropTypes.arrayOf(PropTypes.string),
+  supportedCommandsForSingleUse: PropTypes.arrayOf(PropTypes.string),
+};
+
+export default ManageCommandsProcess;

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

@@ -69,7 +69,9 @@ const OfficialBotSettings = (props) => {
 
 
       <div className="mx-3">
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
         {slackAppIntegrations.map((slackAppIntegration, i) => {
-          const { tokenGtoP, tokenPtoG, _id } = slackAppIntegration;
+          const {
+            tokenGtoP, tokenPtoG, _id, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+          } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
           return (
             <React.Fragment key={slackAppIntegration._id}>
             <React.Fragment key={slackAppIntegration._id}>
@@ -91,6 +93,8 @@ const OfficialBotSettings = (props) => {
                 slackAppIntegrationId={slackAppIntegration._id}
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
                 tokenPtoG={tokenPtoG}
+                supportedCommandsForBroadcastUse={supportedCommandsForBroadcastUse}
+                supportedCommandsForSingleUse={supportedCommandsForSingleUse}
                 onUpdateTokens={onUpdateTokens}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
                 onSubmitForm={onSubmitForm}
               />
               />

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

@@ -11,6 +11,7 @@ import AppContainer from '~/client/services/AppContainer';
 import Accordion from '../Common/Accordion';
 import Accordion from '../Common/Accordion';
 import { addLogs } from './slak-integration-util';
 import { addLogs } from './slak-integration-util';
 import MessageBasedOnConnection from './MessageBasedOnConnection';
 import MessageBasedOnConnection from './MessageBasedOnConnection';
+import ManageCommandsProcess from './ManageCommandsProcess';
 
 
 const logger = loggerFactory('growi:SlackIntegration:WithProxyAccordionsWrapper');
 const logger = loggerFactory('growi:SlackIntegration:WithProxyAccordionsWrapper');
 
 
@@ -287,7 +288,6 @@ const WithProxyAccordions = (props) => {
       props.onSubmitForm();
       props.onSubmitForm();
     }
     }
   };
   };
-
   const submitFormFailed = () => {
   const submitFormFailed = () => {
     setIsLatestConnectionSuccess(false);
     setIsLatestConnectionSuccess(false);
   };
   };
@@ -309,6 +309,15 @@ const WithProxyAccordions = (props) => {
       />,
       />,
     },
     },
     '③': {
     '③': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
+        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
+      />,
+    },
+    '④': {
       title: 'test_connection',
       title: 'test_connection',
       content: <TestProcess
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
         apiv3Post={props.appContainer.apiv3.post}
@@ -344,6 +353,15 @@ const WithProxyAccordions = (props) => {
       content: <RegisteringProxyUrlProcess />,
       content: <RegisteringProxyUrlProcess />,
     },
     },
     '⑤': {
     '⑤': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
+        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
+      />,
+    },
+    '⑥': {
       title: 'test_connection',
       title: 'test_connection',
       content: <TestProcess
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
         apiv3Post={props.appContainer.apiv3.post}
@@ -392,6 +410,8 @@ WithProxyAccordions.propTypes = {
   slackAppIntegrationId: PropTypes.string.isRequired,
   slackAppIntegrationId: PropTypes.string.isRequired,
   tokenPtoG: PropTypes.string,
   tokenPtoG: PropTypes.string,
   tokenGtoP: PropTypes.string,
   tokenGtoP: PropTypes.string,
+  supportedCommandsForBroadcastUse: PropTypes.arrayOf(PropTypes.string),
+  supportedCommandsForSingleUse: PropTypes.arrayOf(PropTypes.string),
 };
 };
 
 
 export default WithProxyAccordionsWrapper;
 export default WithProxyAccordionsWrapper;

+ 2 - 0
packages/app/src/server/models/slack-app-integration.js

@@ -4,6 +4,8 @@ const mongoose = require('mongoose');
 const schema = new mongoose.Schema({
 const schema = new mongoose.Schema({
   tokenGtoP: { type: String, required: true, unique: true },
   tokenGtoP: { type: String, required: true, unique: true },
   tokenPtoG: { type: String, required: true, unique: true },
   tokenPtoG: { type: String, required: true, unique: true },
+  supportedCommandsForBroadcastUse: { type: [String], default: [] },
+  supportedCommandsForSingleUse: { type: [String], default: [] },
 });
 });
 
 
 class SlackAppIntegration {
 class SlackAppIntegration {

+ 22 - 0
packages/app/src/server/models/vo/slackbot-error.js

@@ -0,0 +1,22 @@
+/**
+ * Error class for slackbot service
+ */
+class SlackbotError extends Error {
+
+  constructor({
+    method, to, popupMessage, mainMessage,
+  } = {}) {
+    super();
+    this.method = method;
+    this.to = to;
+    this.popupMessage = popupMessage;
+    this.mainMessage = mainMessage;
+  }
+
+  static isSlackbotError(obj) {
+    return obj instanceof this;
+  }
+
+}
+
+module.exports = SlackbotError;

+ 75 - 11
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -2,11 +2,13 @@ import loggerFactory from '~/utils/logger';
 
 
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const express = require('express');
 const express = require('express');
-const { body, query } = require('express-validator');
+const { body, query, param } = require('express-validator');
 const axios = require('axios');
 const axios = require('axios');
 const urljoin = require('url-join');
 const urljoin = require('url-join');
 
 
-const { getConnectionStatus, getConnectionStatuses, sendSuccessMessage } = require('@growi/slack');
+const {
+  getConnectionStatus, getConnectionStatuses, sendSuccessMessage, defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse,
+} = require('@growi/slack');
 
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
@@ -60,6 +62,11 @@ module.exports = (crowi) => {
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
         .isURL({ require_tld: false }),
         .isURL({ require_tld: false }),
     ],
     ],
+    updateSupportedCommands: [
+      body('supportedCommandsForSingleUse').toArray(),
+      body('supportedCommandsForBroadcastUse').toArray(),
+      param('id').isMongoId().withMessage('id is required'),
+    ],
     RelationTest: [
     RelationTest: [
       body('slackAppIntegrationId').isMongoId(),
       body('slackAppIntegrationId').isMongoId(),
       body('channel').trim().isString(),
       body('channel').trim().isString(),
@@ -106,17 +113,17 @@ module.exports = (crowi) => {
     return result.data;
     return result.data;
   }
   }
 
 
-  async function postRelationTest(token) {
+  async function requestToProxyServer(token, method, endpoint, body) {
     const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
     const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
     if (proxyUri == null) {
     if (proxyUri == null) {
       throw new Error('Proxy URL is not registered');
       throw new Error('Proxy URL is not registered');
     }
     }
 
 
-    const result = await axios.get(urljoin(proxyUri, '/g2s/relation-test'), {
-      headers: {
-        'x-growi-gtop-tokens': token,
-      },
-    });
+    const headers = {
+      'x-growi-gtop-tokens': token,
+    };
+
+    const result = await axios[method](urljoin(proxyUri, endpoint), body, { headers });
 
 
     return result.data;
     return result.data;
   }
   }
@@ -401,9 +408,13 @@ module.exports = (crowi) => {
     }
     }
 
 
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
-
     try {
     try {
-      const slackAppTokens = await SlackAppIntegration.create({ tokenGtoP, tokenPtoG });
+      const slackAppTokens = await SlackAppIntegration.create({
+        tokenGtoP,
+        tokenPtoG,
+        supportedCommandsForBroadcastUse: defaultSupportedCommandsNameForBroadcastUse,
+        supportedCommandsForSingleUse: defaultSupportedCommandsNameForSingleUse,
+      });
       return res.apiv3(slackAppTokens, 200);
       return res.apiv3(slackAppTokens, 200);
     }
     }
     catch (error) {
     catch (error) {
@@ -488,6 +499,49 @@ module.exports = (crowi) => {
 
 
   });
   });
 
 
+  /**
+   * @swagger
+   *
+   *    /slack-integration-settings/:id/supported-commands:
+   *      put:
+   *        tags: [SlackIntegration]
+   *        operationId: putSupportedCommands
+   *        summary: /slack-integration-settings/:id/supported-commands
+   *        description: update supported commands
+   *        responses:
+   *          200:
+   *            description: Succeeded to update supported commands
+   */
+  router.put('/:id/supported-commands', loginRequiredStrictly, adminRequired, csrf, validator.updateSupportedCommands, apiV3FormValidator, async(req, res) => {
+    const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = req.body;
+    const { id } = req.params;
+
+    try {
+      const slackAppIntegration = await SlackAppIntegration.findByIdAndUpdate(
+        id,
+        { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse },
+        { new: true },
+      );
+
+      await requestToProxyServer(
+        slackAppIntegration.tokenGtoP,
+        'put',
+        '/g2s/supported-commands',
+        {
+          supportedCommandsForBroadcastUse: slackAppIntegration.supportedCommandsForBroadcastUse,
+          supportedCommandsForSingleUse: slackAppIntegration.supportedCommandsForSingleUse,
+        },
+      );
+
+      return res.apiv3({ slackAppIntegration });
+    }
+    catch (error) {
+      const msg = 'Error occured in updating Custom bot setting';
+      logger.error('Error', error);
+      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+    }
+  });
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -523,7 +577,17 @@ module.exports = (crowi) => {
         const msg = 'Could not find SlackAppIntegration by id';
         const msg = 'Could not find SlackAppIntegration by id';
         return res.apiv3Err(new ErrorV3(msg, 'find-slackAppIntegration-failed'), 400);
         return res.apiv3Err(new ErrorV3(msg, 'find-slackAppIntegration-failed'), 400);
       }
       }
-      const result = await postRelationTest(slackAppIntegration.tokenGtoP);
+
+      const result = await requestToProxyServer(
+        slackAppIntegration.tokenGtoP,
+        'post',
+        '/g2s/relation-test',
+        {
+          supportedCommandsForBroadcastUse: slackAppIntegration.supportedCommandsForBroadcastUse,
+          supportedCommandsForSingleUse: slackAppIntegration.supportedCommandsForSingleUse,
+        },
+      );
+
       slackBotToken = result.slackBotToken;
       slackBotToken = result.slackBotToken;
       if (slackBotToken == null) {
       if (slackBotToken == null) {
         const msg = 'Could not find slackBotToken by relation';
         const msg = 'Could not find slackBotToken by relation';

+ 76 - 63
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -4,11 +4,12 @@ const express = require('express');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const urljoin = require('url-join');
 const urljoin = require('url-join');
 
 
-const { verifySlackRequest, generateWebClient } = require('@growi/slack');
+const { verifySlackRequest, generateWebClient, getSupportedGrowiActionsRegExps } = require('@growi/slack');
 
 
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
 const router = express.Router();
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
+const { respondIfSlackbotError } = require('../../service/slack-command-handler/respond-if-slackbot-error');
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   this.app = crowi.express;
   this.app = crowi.express;
@@ -43,6 +44,55 @@ module.exports = (crowi) => {
     next();
     next();
   }
   }
 
 
+  async function checkCommandPermission(req, res, next) {
+    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+
+    const relation = await SlackAppIntegration.findOne({ tokenPtoG });
+    const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = relation;
+    const supportedCommands = supportedCommandsForBroadcastUse.concat(supportedCommandsForSingleUse);
+    const supportedGrowiActionsRegExps = getSupportedGrowiActionsRegExps(supportedCommands);
+
+    // get command name from req.body
+    let command = '';
+    let actionId = '';
+    let callbackId = '';
+    let payload;
+    if (req.body.payload) {
+      payload = JSON.parse(req.body.payload);
+    }
+
+    if (req.body.text == null && !payload) { // when /relation-test
+      return next();
+    }
+
+    if (!payload) { // when request is to /commands
+      command = req.body.text.split(' ')[0];
+    }
+    else if (payload.actions) { // when request is to /interactions && block_actions
+      actionId = payload.actions[0].action_id;
+    }
+    else { // when request is to /interactions && view_submission
+      callbackId = payload.view.callback_id;
+    }
+
+    let isActionSupported = false;
+    supportedGrowiActionsRegExps.forEach((regexp) => {
+      if (regexp.test(actionId) || regexp.test(callbackId)) {
+        isActionSupported = true;
+      }
+    });
+
+    // validate
+    if (command && !supportedCommands.includes(command)) {
+      return res.status(403).send(`It is not allowed to run '${command}' command to this GROWI.`);
+    }
+    if ((actionId || callbackId) && !isActionSupported) {
+      return res.status(403).send(`It is not allowed to run '${command}' command to this GROWI.`);
+    }
+
+    next();
+  }
+
   const addSigningSecretToReq = (req, res, next) => {
   const addSigningSecretToReq = (req, res, next) => {
     req.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:signingSecret');
     req.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:signingSecret');
     return next();
     return next();
@@ -104,18 +154,19 @@ module.exports = (crowi) => {
     const command = args[0];
     const command = args[0];
 
 
     try {
     try {
-      await crowi.slackBotService.handleCommand(command, client, body, args);
+      await crowi.slackBotService.handleCommandRequest(command, client, body, args);
     }
     }
-    catch (error) {
-      logger.error(error);
+    catch (err) {
+      await respondIfSlackbotError(client, body, err);
     }
     }
+
   }
   }
 
 
   router.post('/commands', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
   router.post('/commands', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
     return handleCommands(req, res);
     return handleCommands(req, res);
   });
   });
 
 
-  router.post('/proxied/commands', verifyAccessTokenFromProxy, async(req, res) => {
+  router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
     const { body } = req;
     const { body } = req;
 
 
     // eslint-disable-next-line max-len
     // eslint-disable-next-line max-len
@@ -127,61 +178,6 @@ module.exports = (crowi) => {
     return handleCommands(req, res);
     return handleCommands(req, res);
   });
   });
 
 
-
-  const handleBlockActions = async(client, payload) => {
-    const { action_id: actionId } = payload.actions[0];
-
-    switch (actionId) {
-      case 'shareSingleSearchResult': {
-        await crowi.slackBotService.shareSinglePage(client, payload);
-        break;
-      }
-      case 'dismissSearchResults': {
-        await crowi.slackBotService.dismissSearchResults(client, payload);
-        break;
-      }
-      case 'showNextResults': {
-        const parsedValue = JSON.parse(payload.actions[0].value);
-
-        const { body, args, offset } = parsedValue;
-        const newOffset = offset + 10;
-        await crowi.slackBotService.showEphemeralSearchResults(client, body, args, newOffset);
-        break;
-      }
-      case 'togetterShowMore': {
-        const parsedValue = JSON.parse(payload.actions[0].value);
-        const togetterHandler = require('../../service/slack-command-handler/togetter')(crowi);
-
-        const { body, args, limit } = parsedValue;
-        const newLimit = limit + 1;
-        await togetterHandler.handleCommand(client, body, args, newLimit);
-        break;
-      }
-      case 'togetter:createPage': {
-        await crowi.slackBotService.togetterCreatePageInGrowi(client, payload);
-        break;
-      }
-      case 'togetter:cancel': {
-        await crowi.slackBotService.togetterCancel(client, payload);
-        break;
-      }
-      default:
-        break;
-    }
-  };
-
-  const handleViewSubmission = async(client, payload) => {
-    const { callback_id: callbackId } = payload.view;
-
-    switch (callbackId) {
-      case 'createPage':
-        await crowi.slackBotService.createPageInGrowi(client, payload);
-        break;
-      default:
-        break;
-    }
-  };
-
   async function handleInteractions(req, res) {
   async function handleInteractions(req, res) {
 
 
     // Send response immediately to avoid opelation_timeout error
     // Send response immediately to avoid opelation_timeout error
@@ -206,10 +202,20 @@ module.exports = (crowi) => {
     try {
     try {
       switch (type) {
       switch (type) {
         case 'block_actions':
         case 'block_actions':
-          await handleBlockActions(client, payload);
+          try {
+            await crowi.slackBotService.handleBlockActionsRequest(client, payload);
+          }
+          catch (err) {
+            await respondIfSlackbotError(client, req.body, err);
+          }
           break;
           break;
         case 'view_submission':
         case 'view_submission':
-          await handleViewSubmission(client, payload);
+          try {
+            await crowi.slackBotService.handleViewSubmissionRequest(client, payload);
+          }
+          catch (err) {
+            await respondIfSlackbotError(client, req.body, err);
+          }
           break;
           break;
         default:
         default:
           break;
           break;
@@ -225,9 +231,16 @@ module.exports = (crowi) => {
     return handleInteractions(req, res);
     return handleInteractions(req, res);
   });
   });
 
 
-  router.post('/proxied/interactions', verifyAccessTokenFromProxy, async(req, res) => {
+  router.post('/proxied/interactions', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
     return handleInteractions(req, res);
     return handleInteractions(req, res);
   });
   });
 
 
+  router.get('/supported-commands', verifyAccessTokenFromProxy, async(req, res) => {
+    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+    const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+
+    return res.send(slackAppIntegration);
+  });
+
   return router;
   return router;
 };
 };

+ 48 - 0
packages/app/src/server/service/slack-command-handler/create-page-service.js

@@ -0,0 +1,48 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:CreatePageService');
+const { reshapeContentsBody } = require('@growi/slack');
+const mongoose = require('mongoose');
+const pathUtils = require('growi-commons').pathUtils;
+const SlackbotError = require('../../models/vo/slackbot-error');
+
+class CreatePageService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  async createPageInGrowi(client, payload, path, channelId, contentsBody) {
+    const Page = this.crowi.model('Page');
+    const reshapedContentsBody = reshapeContentsBody(contentsBody);
+    try {
+      // sanitize path
+      const sanitizedPath = this.crowi.xss.process(path);
+      const normalizedPath = pathUtils.normalizePath(sanitizedPath);
+
+      // generate a dummy id because Operation to create a page needs ObjectId
+      const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
+      const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
+
+      // Send a message when page creation is complete
+      const growiUri = this.crowi.appService.getSiteUrl();
+      await client.chat.postEphemeral({
+        channel: channelId,
+        user: payload.user.id,
+        text: `The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`,
+      });
+    }
+    catch (err) {
+      logger.error('Failed to create page in GROWI.', err);
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Cannot create new page to existed path.',
+        mainMessage: `Cannot create new page to existed path\n *Contents* :memo:\n ${reshapedContentsBody}`,
+      });
+    }
+  }
+
+}
+
+module.exports = CreatePageService;

+ 37 - 38
packages/app/src/server/service/slack-command-handler/create.js

@@ -4,51 +4,50 @@ const { markdownSectionBlock, inputSectionBlock } = require('@growi/slack');
 
 
 const logger = loggerFactory('growi:service:SlackCommandHandler:create');
 const logger = loggerFactory('growi:service:SlackCommandHandler:create');
 
 
-module.exports = () => {
+module.exports = (crowi) => {
+  const CreatePageService = require('./create-page-service');
+  const createPageService = new CreatePageService(crowi);
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
   const handler = new BaseSlackCommandHandler();
 
 
   handler.handleCommand = async(client, body) => {
   handler.handleCommand = async(client, body) => {
-    try {
-      await client.views.open({
-        trigger_id: body.trigger_id,
-
-        view: {
-          type: 'modal',
-          callback_id: 'createPage',
-          title: {
-            type: 'plain_text',
-            text: 'Create Page',
-          },
-          submit: {
-            type: 'plain_text',
-            text: 'Submit',
-          },
-          close: {
-            type: 'plain_text',
-            text: 'Cancel',
-          },
-          blocks: [
-            markdownSectionBlock('Create new page.'),
-            inputSectionBlock('path', 'Path', 'path_input', false, '/path'),
-            inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
-          ],
-          private_metadata: JSON.stringify({ channelId: body.channel_id }),
+    await client.views.open({
+      trigger_id: body.trigger_id,
+
+      view: {
+        type: 'modal',
+        callback_id: 'create:createPage',
+        title: {
+          type: 'plain_text',
+          text: 'Create Page',
+        },
+        submit: {
+          type: 'plain_text',
+          text: 'Submit',
+        },
+        close: {
+          type: 'plain_text',
+          text: 'Cancel',
         },
         },
-      });
-    }
-    catch (err) {
-      logger.error('Failed to create a page.');
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed To Create',
         blocks: [
         blocks: [
-          markdownSectionBlock(`*Failed to create new page.*\n ${err}`),
+          markdownSectionBlock('Create new page.'),
+          inputSectionBlock('path', 'Path', 'path_input', false, '/path'),
+          inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
         ],
         ],
-      });
-      throw err;
-    }
+        private_metadata: JSON.stringify({ channelId: body.channel_id }),
+      },
+    });
+  };
+
+  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
+    await this[handlerMethodName](client, payload);
+  };
+
+  handler.createPage = async function(client, payload) {
+    const path = payload.view.state.values.path.path_input.value;
+    const channelId = JSON.parse(payload.view.private_metadata).channelId;
+    const contentsBody = payload.view.state.values.contents.contents_input.value;
+    await createPageService.createPageInGrowi(client, payload, path, channelId, contentsBody);
   };
   };
 
 
   return handler;
   return handler;

+ 66 - 0
packages/app/src/server/service/slack-command-handler/respond-if-slackbot-error.js

@@ -0,0 +1,66 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:SlackCommandHandler:slack-bot-response');
+const { markdownSectionBlock } = require('@growi/slack');
+const SlackbotError = require('../../models/vo/slackbot-error');
+
+async function respondIfSlackbotError(client, body, err) {
+  // check if the request is to /commands OR /interactions
+  const isInteraction = !body.channel_id;
+
+  // throw non-SlackbotError
+  if (!SlackbotError.isSlackbotError(err)) {
+    logger.error(`A non-SlackbotError error occured.\n${err.toString()}`);
+    throw err;
+  }
+
+  // for both postMessage and postEphemeral
+  let toChannel;
+  // for only postEphemeral
+  let toUser;
+  // decide which channel to send to
+  switch (err.to) {
+    case 'dm':
+      toChannel = isInteraction ? JSON.parse(body.payload).user.id : body.user_id;
+      toUser = toChannel;
+      break;
+    case 'channel':
+      toChannel = isInteraction ? JSON.parse(body.payload).channel.id : body.channel_id;
+      toUser = isInteraction ? JSON.parse(body.payload).user.id : body.user_id;
+      break;
+    default:
+      logger.error('The "to" property of SlackbotError must be "dm" or "channel".');
+      break;
+  }
+
+  // argumentObj object to pass to postMessage OR postEphemeral
+  let argumentsObj = {};
+  switch (err.method) {
+    case 'postMessage':
+      argumentsObj = {
+        channel: toChannel,
+        text: err.popupMessage,
+        blocks: [
+          markdownSectionBlock(err.mainMessage),
+        ],
+      };
+      break;
+    case 'postEphemeral':
+      argumentsObj = {
+        channel: toChannel,
+        user: toUser,
+        text: err.popupMessage,
+        blocks: [
+          markdownSectionBlock(err.mainMessage),
+        ],
+      };
+      break;
+    default:
+      logger.error('The "method" property of SlackbotError must be "postMessage" or "postEphemeral".');
+      break;
+  }
+
+  await client.chat[err.method](argumentsObj);
+}
+
+module.exports = { respondIfSlackbotError };

+ 199 - 30
packages/app/src/server/service/slack-command-handler/search.js

@@ -4,6 +4,8 @@ const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
 
 const { markdownSectionBlock, divider } = require('@growi/slack');
 const { markdownSectionBlock, divider } = require('@growi/slack');
 const { formatDistanceStrict } = require('date-fns');
 const { formatDistanceStrict } = require('date-fns');
+const axios = require('axios');
+const SlackbotError = require('../../models/vo/slackbot-error');
 
 
 const PAGINGLIMIT = 10;
 const PAGINGLIMIT = 10;
 
 
@@ -18,19 +20,16 @@ module.exports = (crowi) => {
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Failed to get search results.', err);
       logger.error('Failed to get search results.', err);
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed To Search',
-        blocks: [
-          markdownSectionBlock('*Failed to search.*\n Hint\n `/growi search [keyword]`'),
-        ],
+      throw new SlackbotError({
+        method: 'postEphemeral',
+        to: 'channel',
+        popupMessage: 'Failed To Search',
+        mainMessage: '*Failed to search.*\n Hint\n `/growi search [keyword]`',
       });
       });
-      throw new Error('/growi command:search: Failed to search');
     }
     }
 
 
-    const appUrl = this.crowi.appService.getSiteUrl();
-    const appTitle = this.crowi.appService.getAppTitle();
+    const appUrl = crowi.appService.getSiteUrl();
+    const appTitle = crowi.appService.getAppTitle();
 
 
     const {
     const {
       pages, offset, resultsTotal,
       pages, offset, resultsTotal,
@@ -83,7 +82,7 @@ module.exports = (crowi) => {
           },
           },
           accessory: {
           accessory: {
             type: 'button',
             type: 'button',
-            action_id: 'shareSingleSearchResult',
+            action_id: 'search:shareSinglePageResult',
             text: {
             text: {
               type: 'plain_text',
               type: 'plain_text',
               text: 'Share',
               text: 'Share',
@@ -121,7 +120,7 @@ module.exports = (crowi) => {
             text: 'Dismiss',
             text: 'Dismiss',
           },
           },
           style: 'danger',
           style: 'danger',
-          action_id: 'dismissSearchResults',
+          action_id: 'search:dismissSearchResults',
         },
         },
       ],
       ],
     };
     };
@@ -134,33 +133,203 @@ module.exports = (crowi) => {
             type: 'plain_text',
             type: 'plain_text',
             text: 'Next',
             text: 'Next',
           },
           },
-          action_id: 'showNextResults',
+          action_id: 'search:showNextResults',
           value: JSON.stringify({ offset, body, args }),
           value: JSON.stringify({ offset, body, args }),
         },
         },
       );
       );
     }
     }
     blocks.push(actionBlocks);
     blocks.push(actionBlocks);
 
 
+    await client.chat.postEphemeral({
+      channel: body.channel_id,
+      user: body.user_id,
+      text: 'Successed To Search',
+      blocks,
+    });
+  };
+
+  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
+    await this[handlerMethodName](client, payload);
+  };
+
+  handler.shareSinglePageResult = async function(client, payload) {
+    const { channel, user, actions } = payload;
+
+    const appUrl = crowi.appService.getSiteUrl();
+    const appTitle = crowi.appService.getAppTitle();
+
+    const channelId = channel.id;
+    const action = actions[0]; // shareSinglePage action must have button action
+
+    // restore page data from value
+    const { page, href, pathname } = JSON.parse(action.value);
+    const { updatedAt, commentCount } = page;
+
+    // share
+    const now = new Date();
+    return client.chat.postMessage({
+      channel: channelId,
+      blocks: [
+        { type: 'divider' },
+        markdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
+        {
+          type: 'context',
+          elements: [
+            {
+              type: 'mrkdwn',
+              text: `<${decodeURI(appUrl)}|*${appTitle}*>  |  Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}  |  Shared by *${user.username}*`,
+            },
+          ],
+        },
+      ],
+    });
+  };
+
+  handler.showNextResults = async function(client, payload) {
+    const parsedValue = JSON.parse(payload.actions[0].value);
+
+    const { body, args, offsetNum } = parsedValue;
+    const newOffsetNum = offsetNum + 10;
+    let searchResult;
     try {
     try {
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Successed To Search',
-        blocks,
-      });
+      searchResult = await this.retrieveSearchResults(client, body, args, newOffsetNum);
     }
     }
     catch (err) {
     catch (err) {
-      logger.error('Failed to post ephemeral message.', err);
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed to post ephemeral message.',
-        blocks: [
-          markdownSectionBlock(err.toString()),
-        ],
+      logger.error('Failed to get search results.', err);
+      throw new SlackbotError({
+        method: 'postEphemeral',
+        to: 'channel',
+        popupMessage: 'Failed To Search',
+        mainMessage: '*Failed to search.*\n Hint\n `/growi search [keyword]`',
       });
       });
-      throw new Error(err);
     }
     }
+
+    const appUrl = crowi.appService.getSiteUrl();
+    const appTitle = crowi.appService.getAppTitle();
+
+    const {
+      pages, offset, resultsTotal,
+    } = searchResult;
+
+    const keywords = this.getKeywords(args);
+
+
+    let searchResultsDesc;
+
+    switch (resultsTotal) {
+      case 1:
+        searchResultsDesc = `*${resultsTotal}* page is found.`;
+        break;
+
+      default:
+        searchResultsDesc = `*${resultsTotal}* pages are found.`;
+        break;
+    }
+
+
+    const contextBlock = {
+      type: 'context',
+      elements: [
+        {
+          type: 'mrkdwn',
+          text: `keyword(s) : *"${keywords}"*  |  Current: ${offset + 1} - ${offset + pages.length}  |  Total ${resultsTotal} pages`,
+        },
+      ],
+    };
+
+    const now = new Date();
+    const blocks = [
+      markdownSectionBlock(`:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`),
+      contextBlock,
+      { type: 'divider' },
+      // create an array by map and extract
+      ...pages.map((page) => {
+        const { path, updatedAt, commentCount } = page;
+        // generate URL
+        const url = new URL(path, appUrl);
+        const { href, pathname } = url;
+
+        return {
+          type: 'section',
+          text: {
+            type: 'mrkdwn',
+            text: `${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
+              + `\n    Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}`,
+          },
+          accessory: {
+            type: 'button',
+            action_id: 'shareSingleSearchResult',
+            text: {
+              type: 'plain_text',
+              text: 'Share',
+            },
+            value: JSON.stringify({ page, href, pathname }),
+          },
+        };
+      }),
+      { type: 'divider' },
+      contextBlock,
+    ];
+
+    // DEFAULT show "Share" button
+    // const actionBlocks = {
+    //   type: 'actions',
+    //   elements: [
+    //     {
+    //       type: 'button',
+    //       text: {
+    //         type: 'plain_text',
+    //         text: 'Share',
+    //       },
+    //       style: 'primary',
+    //       action_id: 'shareSearchResults',
+    //     },
+    //   ],
+    // };
+    const actionBlocks = {
+      type: 'actions',
+      elements: [
+        {
+          type: 'button',
+          text: {
+            type: 'plain_text',
+            text: 'Dismiss',
+          },
+          style: 'danger',
+          action_id: 'search:dismissSearchResults',
+        },
+      ],
+    };
+    // show "Next" button if next page exists
+    if (resultsTotal > offset + PAGINGLIMIT) {
+      actionBlocks.elements.unshift(
+        {
+          type: 'button',
+          text: {
+            type: 'plain_text',
+            text: 'Next',
+          },
+          action_id: 'search:showNextResults',
+          value: JSON.stringify({ offset, body, args }),
+        },
+      );
+    }
+    blocks.push(actionBlocks);
+
+    await client.chat.postEphemeral({
+      channel: body.channel_id,
+      user: body.user_id,
+      text: 'Successed To Search',
+      blocks,
+    });
+  };
+
+  handler.dismissSearchResults = async function(client, payload) {
+    const { response_url: responseUrl } = payload;
+
+    return axios.post(responseUrl, {
+      delete_original: true,
+    });
   };
   };
 
 
   handler.retrieveSearchResults = async function(client, body, args, offset = 0) {
   handler.retrieveSearchResults = async function(client, body, args, offset = 0) {
@@ -174,12 +343,12 @@ module.exports = (crowi) => {
           markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
           markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
         ],
         ],
       });
       });
-      return;
+      return { pages: [] };
     }
     }
 
 
     const keywords = this.getKeywords(args);
     const keywords = this.getKeywords(args);
 
 
-    const { searchService } = this.crowi;
+    const { searchService } = crowi;
     const options = { limit: 10, offset };
     const options = { limit: 10, offset };
     const results = await searchService.searchKeyword(keywords, null, {}, options);
     const results = await searchService.searchKeyword(keywords, null, {}, options);
     const resultsTotal = results.meta.total;
     const resultsTotal = results.meta.total;

+ 10 - 0
packages/app/src/server/service/slack-command-handler/slack-command-handler.js

@@ -10,6 +10,16 @@ class BaseSlackCommandHandler {
    */
    */
   handleCommand(client, body, ...opt) { throw new Error('Implement this') }
   handleCommand(client, body, ...opt) { throw new Error('Implement this') }
 
 
+  /**
+   * Handle /interactions endpoint 'block_actions'
+   */
+  handleBlockActions(client, payload, handlerMethodName) { throw new Error('Implement this') }
+
+  /**
+   * Handle /interactions endpoint 'view_submission'
+   */
+  handleViewSubmission(client, payload, handlerMethodName) { throw new Error('Implement this') }
+
 }
 }
 
 
 module.exports = BaseSlackCommandHandler;
 module.exports = BaseSlackCommandHandler;

+ 173 - 2
packages/app/src/server/service/slack-command-handler/togetter.js

@@ -1,9 +1,16 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:SlackBotService:togetter');
 const {
 const {
-  inputBlock, actionsBlock, buttonElement, markdownSectionBlock,
+  inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider,
 } = require('@growi/slack');
 } = require('@growi/slack');
-const { format } = require('date-fns');
+const { parse, format } = require('date-fns');
+const axios = require('axios');
+const SlackbotError = require('../../models/vo/slackbot-error');
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
+  const CreatePageService = require('./create-page-service');
+  const createPageService = new CreatePageService(crowi);
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
   const handler = new BaseSlackCommandHandler();
 
 
@@ -23,6 +30,170 @@ module.exports = (crowi) => {
     return;
     return;
   };
   };
 
 
+  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
+    await this[handlerMethodName](client, payload);
+  };
+
+  handler.cancel = async function(client, payload) {
+    const responseUrl = payload.response_url;
+    axios.post(responseUrl, {
+      delete_original: true,
+    });
+  };
+
+  handler.createPage = async function(client, payload) {
+    let result = [];
+    const channel = payload.channel.id;
+    try {
+      // validate form
+      const { path, oldest, latest } = await this.togetterValidateForm(client, payload);
+      // get messages
+      result = await this.togetterGetMessages(client, payload, channel, path, latest, oldest);
+      // clean messages
+      const cleanedContents = await this.togetterCleanMessages(result.messages);
+
+      const contentsBody = cleanedContents.join('');
+      // create and send url message
+      await this.togetterCreatePageAndSendPreview(client, payload, path, channel, contentsBody);
+    }
+    catch (err) {
+      logger.error('Error occured by togetter.');
+      throw err;
+    }
+  };
+
+  handler.togetterValidateForm = async function(client, payload) {
+    const grwTzoffset = crowi.appService.getTzoffset() * 60;
+    const path = payload.state.values.page_path.page_path.value;
+    let oldest = payload.state.values.oldest.oldest.value;
+    let latest = payload.state.values.latest.latest.value;
+    oldest = oldest.trim();
+    latest = latest.trim();
+    if (!path) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Page path is required.',
+        mainMessage: 'Page path is required.',
+      });
+    }
+    /**
+     * RegExp for datetime yyyy/MM/dd-HH:mm
+     * @see https://regex101.com/r/XbxdNo/1
+     */
+    const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
+
+    if (!regexpDatetime.test(oldest)) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
+        mainMessage: 'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
+      });
+    }
+    if (!regexpDatetime.test(latest)) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Datetime format for latest must be yyyy/MM/dd-HH:mm',
+        mainMessage: 'Datetime format for latest must be yyyy/MM/dd-HH:mm',
+      });
+    }
+    oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
+    // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
+    latest = parse(latest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
+
+    if (oldest > latest) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Oldest datetime must be older than the latest date time.',
+        mainMessage: 'Oldest datetime must be older than the latest date time.',
+      });
+    }
+
+    return { path, oldest, latest };
+  };
+
+  handler.togetterGetMessages = async function(client, payload, channel, path, latest, oldest) {
+    const result = await client.conversations.history({
+      channel,
+      latest,
+      oldest,
+      limit: 100,
+      inclusive: true,
+    });
+
+    // return if no message found
+    if (!result.messages.length) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'No message found from togetter command. Try different datetime.',
+        mainMessage: 'No message found from togetter command. Try different datetime.',
+      });
+    }
+    return result;
+  };
+
+  handler.togetterCleanMessages = async function(messages) {
+    const cleanedContents = [];
+    let lastMessage = {};
+    const grwTzoffset = crowi.appService.getTzoffset() * 60;
+    messages
+      .sort((a, b) => {
+        return a.ts - b.ts;
+      })
+      .forEach((message) => {
+        // increment contentsBody while removing the same headers
+        // exclude header
+        const lastMessageTs = Math.floor(lastMessage.ts / 60);
+        const messageTs = Math.floor(message.ts / 60);
+        if (lastMessage.user === message.user && lastMessageTs === messageTs) {
+          cleanedContents.push(`${message.text}\n`);
+        }
+        // include header
+        else {
+          const ts = (parseInt(message.ts) - grwTzoffset) * 1000;
+          const time = format(new Date(ts), 'h:mm a');
+          cleanedContents.push(`${message.user}  ${time}\n${message.text}\n`);
+          lastMessage = message;
+        }
+      });
+    return cleanedContents;
+  };
+
+  handler.togetterCreatePageAndSendPreview = async function(client, payload, path, channel, contentsBody) {
+    try {
+      await createPageService.createPageInGrowi(client, payload, path, channel, contentsBody);
+      // send preview to dm
+      await client.chat.postMessage({
+        channel: payload.user.id,
+        text: 'Preview from togetter command',
+        blocks: [
+          markdownSectionBlock('*Preview*'),
+          divider(),
+          markdownSectionBlock(contentsBody),
+          divider(),
+        ],
+      });
+      // dismiss message
+      const responseUrl = payload.response_url;
+      axios.post(responseUrl, {
+        delete_original: true,
+      });
+    }
+    catch (err) {
+      logger.error('Error occurred while creating a page.', err);
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Error occurred while creating a page.',
+        mainMessage: 'Error occurred while creating a page.',
+      });
+    }
+  };
+
   handler.togetterMessageBlocks = function(messages, body, args, limit) {
   handler.togetterMessageBlocks = function(messages, body, args, limit) {
     return [
     return [
       markdownSectionBlock('Select the oldest and latest datetime of the messages to use.'),
       markdownSectionBlock('Select the oldest and latest datetime of the messages to use.'),

+ 35 - 461
packages/app/src/server/service/slackbot.ts

@@ -1,19 +1,13 @@
-
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { S2sMessagingService } from './s2s-messaging/base';
 import { S2sMessagingService } from './s2s-messaging/base';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 
 const logger = loggerFactory('growi:service:SlackBotService');
 const logger = loggerFactory('growi:service:SlackBotService');
-const mongoose = require('mongoose');
-const axios = require('axios');
 
 
-const { markdownSectionBlock, divider } = require('@growi/slack');
-const { reshapeContentsBody } = require('@growi/slack');
-const { formatDistanceStrict, parse, format } = require('date-fns');
+const { markdownSectionBlock } = require('@growi/slack');
 
 
 const S2sMessage = require('../models/vo/s2s-message');
 const S2sMessage = require('../models/vo/s2s-message');
 
 
-const PAGINGLIMIT = 10;
 
 
 class SlackBotService implements S2sMessageHandlable {
 class SlackBotService implements S2sMessageHandlable {
 
 
@@ -76,485 +70,65 @@ class SlackBotService implements S2sMessageHandlable {
   /**
   /**
    * Handle /commands endpoint
    * Handle /commands endpoint
    */
    */
-  async handleCommand(command, client, body, ...opt) {
-    const module = `./slack-command-handler/${command}`;
-    try {
-      const handler = require(module)(this.crowi);
-      await handler.handleCommand(client, body, ...opt);
-    }
-    catch (err) {
-      this.notCommand(client, body);
-    }
-  }
-
-  async notCommand(client, body) {
-    logger.error('Invalid first argument');
-    client.chat.postEphemeral({
-      channel: body.channel_id,
-      user: body.user_id,
-      text: 'No command',
-      blocks: [
-        markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
-      ],
-    });
-    return;
-  }
-
-  generatePageLinkMrkdwn(pathname, href) {
-    return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
-  }
-
-  appendSpeechBaloon(mrkdwn, commentCount) {
-    return (commentCount != null && commentCount > 0)
-      ? `${mrkdwn}   :speech_balloon: ${commentCount}`
-      : mrkdwn;
-  }
-
-  generateLastUpdateMrkdwn(updatedAt, baseDate) {
-    if (updatedAt != null) {
-      // cast to date
-      const date = new Date(updatedAt);
-      return formatDistanceStrict(date, baseDate);
-    }
-    return '';
-  }
-
-
-  async shareSinglePage(client, payload) {
-    const { channel, user, actions } = payload;
-
-    const appUrl = this.crowi.appService.getSiteUrl();
-    const appTitle = this.crowi.appService.getAppTitle();
-
-    const channelId = channel.id;
-    const action = actions[0]; // shareSinglePage action must have button action
-
-    // restore page data from value
-    const { page, href, pathname } = JSON.parse(action.value);
-    const { updatedAt, commentCount } = page;
-
-    // share
-    const now = new Date();
-    return client.chat.postMessage({
-      channel: channelId,
-      blocks: [
-        { type: 'divider' },
-        markdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
-        {
-          type: 'context',
-          elements: [
-            {
-              type: 'mrkdwn',
-              text: `<${decodeURI(appUrl)}|*${appTitle}*>  |  Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}  |  Shared by *${user.username}*`,
-            },
-          ],
-        },
-      ],
-    });
-  }
-
-  async dismissSearchResults(client, payload) {
-    const { response_url: responseUrl } = payload;
-
-    return axios.post(responseUrl, {
-      delete_original: true,
-    });
-  }
-
-  async showEphemeralSearchResults(client, body, args, offsetNum) {
-    let searchResult;
-    try {
-      searchResult = await this.retrieveSearchResults(client, body, args, offsetNum);
-    }
-    catch (err) {
-      logger.error('Failed to get search results.', err);
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed To Search',
-        blocks: [
-          markdownSectionBlock('*Failed to search.*\n Hint\n `/growi search [keyword]`'),
-        ],
-      });
-      throw new Error('/growi command:search: Failed to search');
-    }
-
-    const appUrl = this.crowi.appService.getSiteUrl();
-    const appTitle = this.crowi.appService.getAppTitle();
-
-    const {
-      pages, offset, resultsTotal,
-    } = searchResult;
-
-    const keywords = this.getKeywords(args);
-
-
-    let searchResultsDesc;
-
-    switch (resultsTotal) {
-      case 1:
-        searchResultsDesc = `*${resultsTotal}* page is found.`;
-        break;
-
-      default:
-        searchResultsDesc = `*${resultsTotal}* pages are found.`;
-        break;
-    }
-
-
-    const contextBlock = {
-      type: 'context',
-      elements: [
-        {
-          type: 'mrkdwn',
-          text: `keyword(s) : *"${keywords}"*  |  Current: ${offset + 1} - ${offset + pages.length}  |  Total ${resultsTotal} pages`,
-        },
-      ],
-    };
-
-    const now = new Date();
-    const blocks = [
-      markdownSectionBlock(`:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`),
-      contextBlock,
-      { type: 'divider' },
-      // create an array by map and extract
-      ...pages.map((page) => {
-        const { path, updatedAt, commentCount } = page;
-        // generate URL
-        const url = new URL(path, appUrl);
-        const { href, pathname } = url;
-
-        return {
-          type: 'section',
-          text: {
-            type: 'mrkdwn',
-            text: `${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
-              + `\n    Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}`,
-          },
-          accessory: {
-            type: 'button',
-            action_id: 'shareSingleSearchResult',
-            text: {
-              type: 'plain_text',
-              text: 'Share',
-            },
-            value: JSON.stringify({ page, href, pathname }),
-          },
-        };
-      }),
-      { type: 'divider' },
-      contextBlock,
-    ];
-
-    // DEFAULT show "Share" button
-    // const actionBlocks = {
-    //   type: 'actions',
-    //   elements: [
-    //     {
-    //       type: 'button',
-    //       text: {
-    //         type: 'plain_text',
-    //         text: 'Share',
-    //       },
-    //       style: 'primary',
-    //       action_id: 'shareSearchResults',
-    //     },
-    //   ],
-    // };
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const actionBlocks: any = {
-      type: 'actions',
-      elements: [
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: 'Dismiss',
-          },
-          style: 'danger',
-          action_id: 'dismissSearchResults',
-        },
-      ],
-    };
-    // show "Next" button if next page exists
-    if (resultsTotal > offset + PAGINGLIMIT) {
-      actionBlocks.elements.unshift(
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: 'Next',
-          },
-          action_id: 'showNextResults',
-          value: JSON.stringify({ offset, body, args }),
-        },
-      );
-    }
-    blocks.push(actionBlocks);
-
+  async handleCommandRequest(command, client, body, ...opt) {
+    let module;
     try {
     try {
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Successed To Search',
-        blocks,
-      });
+      module = `./slack-command-handler/${command}`;
     }
     }
     catch (err) {
     catch (err) {
-      logger.error('Failed to post ephemeral message.', err);
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed to post ephemeral message.',
-        blocks: [
-          markdownSectionBlock(err.toString()),
-        ],
-      });
-      throw new Error(err);
+      await this.notCommand(client, body);
     }
     }
-  }
-
-  async retrieveSearchResults(client, body, args, offset = 0) {
-    const firstKeyword = args[1];
-    if (firstKeyword == null) {
-      client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Input keywords',
-        blocks: [
-          markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
-        ],
-      });
-      return;
-    }
-
-    const keywords = this.getKeywords(args);
-
-    const { searchService } = this.crowi;
-    const options = { limit: 10, offset };
-    const results = await searchService.searchKeyword(keywords, null, {}, options);
-    const resultsTotal = results.meta.total;
-
-    // no search results
-    if (results.data.length === 0) {
-      logger.info(`No page found with "${keywords}"`);
-      client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: `No page found with "${keywords}"`,
-        blocks: [
-          markdownSectionBlock(`*No page that matches your keyword(s) "${keywords}".*`),
-          markdownSectionBlock(':mag: *Help: Searching*'),
-          divider(),
-          markdownSectionBlock('`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body'),
-          divider(),
-          markdownSectionBlock('`"This is GROWI"` (surround with double quotes) \n Search pages that include the phrase "This is GROWI"'),
-          divider(),
-          markdownSectionBlock('`-keyword` \n Exclude pages that include keyword in the title or body'),
-          divider(),
-          markdownSectionBlock('`prefix:/user/` \n Search only the pages that the title start with /user/'),
-          divider(),
-          markdownSectionBlock('`-prefix:/user/` \n Exclude the pages that the title start with /user/'),
-          divider(),
-          markdownSectionBlock('`tag:wiki` \n Search for pages with wiki tag'),
-          divider(),
-          markdownSectionBlock('`-tag:wiki` \n Exclude pages with wiki tag'),
-        ],
-      });
-      return { pages: [] };
-    }
-
-    const pages = results.data.map((data) => {
-      const { path, updated_at: updatedAt, comment_count: commentCount } = data._source;
-      return { path, updatedAt, commentCount };
-    });
 
 
-    return {
-      pages, offset, resultsTotal,
-    };
-  }
-
-  getKeywords(args) {
-    const keywordsArr = args.slice(1);
-    const keywords = keywordsArr.join(' ');
-    return keywords;
-  }
-
-  // Submit action in create Modal
-  async createPage(client, payload, path, channelId, contentsBody) {
-    const Page = this.crowi.model('Page');
-    const pathUtils = require('growi-commons').pathUtils;
-    const reshapedContentsBody = reshapeContentsBody(contentsBody);
     try {
     try {
-      // sanitize path
-      const sanitizedPath = this.crowi.xss.process(path);
-      const normalizedPath = pathUtils.normalizePath(sanitizedPath);
-
-      // generate a dummy id because Operation to create a page needs ObjectId
-      const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
-      const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
-
-      // Send a message when page creation is complete
-      const growiUri = this.crowi.appService.getSiteUrl();
-      await client.chat.postEphemeral({
-        channel: channelId,
-        user: payload.user.id,
-        text: `The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`,
-      });
+      const handler = require(module)(this.crowi);
+      await handler.handleCommand(client, body, ...opt);
     }
     }
     catch (err) {
     catch (err) {
-      client.chat.postMessage({
-        channel: payload.user.id,
-        blocks: [
-          markdownSectionBlock(`Cannot create new page to existed path\n *Contents* :memo:\n ${reshapedContentsBody}`)],
-      });
-      logger.error('Failed to create page in GROWI.');
       throw err;
       throw err;
     }
     }
   }
   }
 
 
-  async createPageInGrowi(client, payload) {
-    const path = payload.view.state.values.path.path_input.value;
-    const channelId = JSON.parse(payload.view.private_metadata).channelId;
-    const contentsBody = payload.view.state.values.contents.contents_input.value;
-    await this.createPage(client, payload, path, channelId, contentsBody);
-  }
-
-  async togetterCreatePageInGrowi(client, payload) {
-    let result = [];
-    const channel = payload.channel.id;
+  async handleBlockActionsRequest(client, payload) {
+    const { action_id: actionId } = payload.actions[0];
+    const commandName = actionId.split(':')[0];
+    const handlerMethodName = actionId.split(':')[1];
+    const module = `./slack-command-handler/${commandName}`;
     try {
     try {
-      // validate form
-      const { path, oldest, latest } = await this.togetterValidateForm(client, payload);
-      // get messages
-      result = await this.togetterGetMessages(client, payload, channel, path, latest, oldest);
-      // clean messages
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      const cleanedContents = await this.togetterCleanMessages((result as any).messages);
-
-      const contentsBody = cleanedContents.join('');
-      // create and send url message
-      await this.togetterCreatePageAndSendPreview(client, payload, path, channel, contentsBody);
+      const handler = require(module)(this.crowi);
+      await handler.handleBlockActions(client, payload, handlerMethodName);
     }
     }
     catch (err) {
     catch (err) {
-      await client.chat.postMessage({
-        channel: payload.user.id,
-        text: err.message,
-        blocks: [
-          markdownSectionBlock(err.message),
-        ],
-      });
-      return;
-    }
-  }
-
-  async togetterGetMessages(client, payload, channel, path, latest, oldest) {
-    const result = await client.conversations.history({
-      channel,
-      latest,
-      oldest,
-      limit: 100,
-      inclusive: true,
-    });
-
-    // return if no message found
-    if (!result.messages.length) {
-      throw new Error('No message found from togetter command. Try again.');
-    }
-    return result;
-  }
-
-  async togetterValidateForm(client, payload) {
-    const grwTzoffset = this.crowi.appService.getTzoffset() * 60;
-    const path = payload.state.values.page_path.page_path.value;
-    let oldest = payload.state.values.oldest.oldest.value;
-    let latest = payload.state.values.latest.latest.value;
-    oldest = oldest.trim();
-    latest = latest.trim();
-    if (!path) {
-      throw new Error('Page path is required.');
-    }
-    /**
-     * RegExp for datetime yyyy/MM/dd-HH:mm
-     * @see https://regex101.com/r/XbxdNo/1
-     */
-    const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
-
-    if (!regexpDatetime.test(oldest)) {
-      throw new Error('Datetime format for oldest must be yyyy/MM/dd-HH:mm');
-    }
-    if (!regexpDatetime.test(latest)) {
-      throw new Error('Datetime format for latest must be yyyy/MM/dd-HH:mm');
-    }
-    oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
-    // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
-    latest = parse(latest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
-
-    if (oldest > latest) {
-      throw new Error('Oldest datetime must be older than the latest date time.');
+      throw err;
     }
     }
-
-    return { path, oldest, latest };
-  }
-
-  async togetterCleanMessages(messages) {
-    const cleanedContents: string[] = [];
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    let lastMessage: any = {};
-    const grwTzoffset = this.crowi.appService.getTzoffset() * 60;
-    messages
-      .sort((a, b) => {
-        return a.ts - b.ts;
-      })
-      .forEach((message) => {
-        // increment contentsBody while removing the same headers
-        // exclude header
-        const lastMessageTs = Math.floor(lastMessage.ts / 60);
-        const messageTs = Math.floor(message.ts / 60);
-        if (lastMessage.user === message.user && lastMessageTs === messageTs) {
-          cleanedContents.push(`${message.text}\n`);
-        }
-        // include header
-        else {
-          const ts = (parseInt(message.ts) - grwTzoffset) * 1000;
-          const time = format(new Date(ts), 'h:mm a');
-          cleanedContents.push(`${message.user}  ${time}\n${message.text}\n`);
-          lastMessage = message;
-        }
-      });
-    return cleanedContents;
+    return;
   }
   }
 
 
-  async togetterCreatePageAndSendPreview(client, payload, path, channel, contentsBody) {
+  async handleViewSubmissionRequest(client, payload) {
+    const { callback_id: callbackId } = payload.view;
+    const commandName = callbackId.split(':')[0];
+    const handlerMethodName = callbackId.split(':')[1];
+    const module = `./slack-command-handler/${commandName}`;
     try {
     try {
-      await this.createPage(client, payload, path, channel, contentsBody);
-      // send preview to dm
-      await client.chat.postMessage({
-        channel: payload.user.id,
-        text: 'Preview from togetter command',
-        blocks: [
-          markdownSectionBlock('*Preview*'),
-          divider(),
-          markdownSectionBlock(contentsBody),
-          divider(),
-        ],
-      });
-      // dismiss message
-      const responseUrl = payload.response_url;
-      axios.post(responseUrl, {
-        delete_original: true,
-      });
+      const handler = require(module)(this.crowi);
+      await handler.handleBlockActions(client, payload, handlerMethodName);
     }
     }
     catch (err) {
     catch (err) {
-      throw new Error('Error occurred while creating a page.');
+      throw err;
     }
     }
+    return;
   }
   }
 
 
-  async togetterCancel(client, payload) {
-    const responseUrl = payload.response_url;
-    axios.post(responseUrl, {
-      delete_original: true,
+  async notCommand(client, body) {
+    logger.error('Invalid first argument');
+    client.chat.postEphemeral({
+      channel: body.channel_id,
+      user: body.user_id,
+      text: 'No command',
+      blocks: [
+        markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
+      ],
     });
     });
+    return;
   }
   }
 
 
 }
 }

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

@@ -8,6 +8,14 @@ export const supportedGrowiCommands: string[] = [
   'help',
   'help',
 ];
 ];
 
 
+export const defaultSupportedCommandsNameForBroadcastUse: string[] = [
+  'search',
+];
+
+export const defaultSupportedCommandsNameForSingleUse: string[] = [
+  'create',
+];
+
 export * from './interfaces/growi-command';
 export * from './interfaces/growi-command';
 export * from './interfaces/request-between-growi-and-proxy';
 export * from './interfaces/request-between-growi-and-proxy';
 export * from './interfaces/request-from-slack';
 export * from './interfaces/request-from-slack';
@@ -16,6 +24,7 @@ export * from './middlewares/verify-growi-to-slack-request';
 export * from './middlewares/verify-slack-request';
 export * from './middlewares/verify-slack-request';
 export * from './utils/block-kit-builder';
 export * from './utils/block-kit-builder';
 export * from './utils/check-communicable';
 export * from './utils/check-communicable';
+export * from './utils/get-supported-growi-actions-regexps';
 export * from './utils/post-ephemeral-errors';
 export * from './utils/post-ephemeral-errors';
 export * from './utils/reshape-contents-body';
 export * from './utils/reshape-contents-body';
 export * from './utils/slash-command-parser';
 export * from './utils/slash-command-parser';

+ 3 - 0
packages/slack/src/utils/get-supported-growi-actions-regexps.ts

@@ -0,0 +1,3 @@
+export const getSupportedGrowiActionsRegExps = (supportedGrowiCommands: string[]): RegExp[] => {
+  return supportedGrowiCommands.map(command => new RegExp(`^${command}:\\w+`));
+};

+ 30 - 7
packages/slackbot-proxy/src/controllers/growi-to-slack.ts

@@ -1,8 +1,9 @@
 import {
 import {
-  Controller, Get, Post, Inject, Req, Res, UseBefore, PathParams,
+  Controller, Get, Post, Inject, Req, Res, UseBefore, PathParams, Put,
 } from '@tsed/common';
 } from '@tsed/common';
 import axios from 'axios';
 import axios from 'axios';
 import createError from 'http-errors';
 import createError from 'http-errors';
+import { addHours } from 'date-fns';
 
 
 import { WebAPICallResult } from '@slack/web-api';
 import { WebAPICallResult } from '@slack/web-api';
 
 
@@ -26,9 +27,6 @@ import { SectionBlockPayloadDelegator } from '~/services/growi-uri-injector/Sect
 
 
 const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');
 const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');
 
 
-// temporarily save for selection to growi
-const temporarySinglePostCommands = ['create', 'togetter'];
-
 @Controller('/g2s')
 @Controller('/g2s')
 export class GrowiToSlackCtrl {
 export class GrowiToSlackCtrl {
 
 
@@ -94,7 +92,24 @@ export class GrowiToSlackCtrl {
     return res.send({ connectionStatuses });
     return res.send({ connectionStatuses });
   }
   }
 
 
-  @Get('/relation-test')
+  @Put('/supported-commands')
+  @UseBefore(verifyGrowiToSlackRequest)
+  async putSupportedCommands(@Req() req: GrowiReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
+    // asserted (tokenGtoPs.length > 0) by verifyGrowiToSlackRequest
+    const { tokenGtoPs } = req;
+    const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = req.body;
+
+    if (tokenGtoPs.length !== 1) {
+      throw createError(400, 'installation is invalid');
+    }
+
+    const tokenGtoP = tokenGtoPs[0];
+    const relation = await this.relationRepository.update({ tokenGtoP }, { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse });
+
+    return res.send({ relation });
+  }
+
+  @Post('/relation-test')
   @UseBefore(verifyGrowiToSlackRequest)
   @UseBefore(verifyGrowiToSlackRequest)
   async postRelation(@Req() req: GrowiReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
   async postRelation(@Req() req: GrowiReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
     const { tokenGtoPs } = req;
     const { tokenGtoPs } = req;
@@ -170,6 +185,9 @@ export class GrowiToSlackCtrl {
 
 
     logger.debug('relation test is success', order);
     logger.debug('relation test is success', order);
 
 
+    // temporary cache for 48 hours
+    const expiredAtCommands = addHours(new Date(), 48);
+
     // Transaction is not considered because it is used infrequently,
     // Transaction is not considered because it is used infrequently,
     const response = await this.relationRepository.createQueryBuilder('relation')
     const response = await this.relationRepository.createQueryBuilder('relation')
       .insert()
       .insert()
@@ -178,10 +196,15 @@ export class GrowiToSlackCtrl {
         tokenGtoP: order.tokenGtoP,
         tokenGtoP: order.tokenGtoP,
         tokenPtoG: order.tokenPtoG,
         tokenPtoG: order.tokenPtoG,
         growiUri: order.growiUrl,
         growiUri: order.growiUrl,
-        siglePostCommands: temporarySinglePostCommands,
+        supportedCommandsForBroadcastUse: req.body.supportedCommandsForBroadcastUse,
+        supportedCommandsForSingleUse: req.body.supportedCommandsForSingleUse,
+        expiredAtCommands,
       })
       })
       // https://github.com/typeorm/typeorm/issues/1090#issuecomment-634391487
       // https://github.com/typeorm/typeorm/issues/1090#issuecomment-634391487
-      .orUpdate({ conflict_target: ['installation', 'growiUri'], overwrite: ['tokenGtoP', 'tokenPtoG', 'siglePostCommands'] })
+      .orUpdate({
+        conflict_target: ['installation', 'growiUri'],
+        overwrite: ['tokenGtoP', 'tokenPtoG', 'supportedCommandsForBroadcastUse', 'supportedCommandsForSingleUse'],
+      })
       .execute();
       .execute();
 
 
     // Find the generated relation
     // Find the generated relation

+ 46 - 8
packages/slackbot-proxy/src/controllers/slack.ts

@@ -7,7 +7,7 @@ import axios from 'axios';
 import { WebAPICallResult } from '@slack/web-api';
 import { WebAPICallResult } from '@slack/web-api';
 
 
 import {
 import {
-  markdownSectionBlock, GrowiCommand, parseSlashCommand, postEphemeralErrors, verifySlackRequest,
+  markdownSectionBlock, GrowiCommand, parseSlashCommand, postEphemeralErrors, verifySlackRequest, generateWebClient,
 } from '@growi/slack';
 } from '@growi/slack';
 
 
 import { Relation } from '~/entities/relation';
 import { Relation } from '~/entities/relation';
@@ -21,6 +21,7 @@ import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-gro
 import { InstallerService } from '~/services/InstallerService';
 import { InstallerService } from '~/services/InstallerService';
 import { SelectGrowiService } from '~/services/SelectGrowiService';
 import { SelectGrowiService } from '~/services/SelectGrowiService';
 import { RegisterService } from '~/services/RegisterService';
 import { RegisterService } from '~/services/RegisterService';
+import { RelationsService } from '~/services/RelationsService';
 import { UnregisterService } from '~/services/UnregisterService';
 import { UnregisterService } from '~/services/UnregisterService';
 import { InvalidUrlError } from '../models/errors';
 import { InvalidUrlError } from '../models/errors';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -50,6 +51,9 @@ export class SlackCtrl {
   @Inject()
   @Inject()
   registerService: RegisterService;
   registerService: RegisterService;
 
 
+  @Inject()
+  relationsService: RelationsService;
+
   @Inject()
   @Inject()
   unregisterService: UnregisterService;
   unregisterService: UnregisterService;
 
 
@@ -159,21 +163,55 @@ export class SlackCtrl {
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
     res.send();
     res.send();
 
 
-    body.growiUris = [];
-    relations.forEach((relation) => {
-      if (relation.siglePostCommands.includes(growiCommand.growiCommandType)) {
-        body.growiUris.push(relation.growiUri);
+    const baseDate = new Date();
+
+    const relationsForSingleUse:Relation[] = [];
+    await Promise.all(relations.map(async(relation) => {
+      const isSupported = await this.relationsService.isSupportedGrowiCommandForSingleUse(relation, growiCommand.growiCommandType, baseDate);
+      if (isSupported) {
+        relationsForSingleUse.push(relation);
       }
       }
-    });
+    }));
 
 
-    if (body.growiUris != null && body.growiUris.length > 0) {
+    let isCommandPermitted = false;
+
+    if (relationsForSingleUse.length > 0) {
+      isCommandPermitted = true;
+      body.growiUrisForSingleUse = relationsForSingleUse.map(v => v.growiUri);
       return this.selectGrowiService.process(growiCommand, authorizeResult, body);
       return this.selectGrowiService.process(growiCommand, authorizeResult, body);
     }
     }
 
 
+    const relationsForBroadcastUse:Relation[] = [];
+    await Promise.all(relations.map(async(relation) => {
+      const isSupported = await this.relationsService.isSupportedGrowiCommandForBroadcastUse(relation, growiCommand.growiCommandType, baseDate);
+      if (isSupported) {
+        relationsForBroadcastUse.push(relation);
+      }
+    }));
+
     /*
     /*
      * forward to GROWI server
      * forward to GROWI server
      */
      */
-    this.sendCommand(growiCommand, relations, body);
+    if (relationsForBroadcastUse.length > 0) {
+      isCommandPermitted = true;
+      this.sendCommand(growiCommand, relationsForBroadcastUse, body);
+    }
+
+    if (!isCommandPermitted) {
+      const botToken = relations[0].installation?.data.bot?.token;
+
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      const client = generateWebClient(botToken!);
+
+      return client.chat.postEphemeral({
+        text: 'Error occured.',
+        channel: body.channel_id,
+        user: body.user_id,
+        blocks: [
+          markdownSectionBlock(`It is not allowed to run *'${growiCommand.growiCommandType}'* command to this GROWI.`),
+        ],
+      });
+    }
   }
   }
 
 
   @Post('/interactions')
   @Post('/interactions')

+ 17 - 1
packages/slackbot-proxy/src/entities/relation.ts

@@ -1,6 +1,7 @@
 import {
 import {
   Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, ManyToOne, Index,
   Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, ManyToOne, Index,
 } from 'typeorm';
 } from 'typeorm';
+import { differenceInMilliseconds } from 'date-fns';
 import { Installation } from './installation';
 import { Installation } from './installation';
 
 
 @Entity()
 @Entity()
@@ -31,6 +32,21 @@ export class Relation {
   growiUri: string;
   growiUri: string;
 
 
   @Column('simple-array')
   @Column('simple-array')
-  siglePostCommands: string[];
+  supportedCommandsForBroadcastUse: string[];
+
+  @Column('simple-array')
+  supportedCommandsForSingleUse: string[];
+
+  @CreateDateColumn()
+  expiredAtCommands: Date;
+
+  isExpiredCommands():boolean {
+    const now = Date.now();
+    return this.expiredAtCommands.getTime() < now;
+  }
+
+  getDistanceInMillisecondsToExpiredAt(baseDate:Date):number {
+    return differenceInMilliseconds(this.expiredAtCommands, baseDate);
+  }
 
 
 }
 }

+ 80 - 0
packages/slackbot-proxy/src/services/RelationsService.ts

@@ -0,0 +1,80 @@
+import { Inject, Service } from '@tsed/di';
+import axios from 'axios';
+import { addHours } from 'date-fns';
+
+import { Relation } from '~/entities/relation';
+import { RelationRepository } from '~/repositories/relation';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('slackbot-proxy:services:RelationsService');
+
+@Service()
+export class RelationsService {
+
+  @Inject()
+  relationRepository: RelationRepository;
+
+  async getSupportedGrowiCommands(relation:Relation):Promise<any> {
+    // generate API URL
+    const url = new URL('/_api/v3/slack-integration/supported-commands', relation.growiUri);
+    return axios.get(url.toString(), {
+      headers: {
+        'x-growi-ptog-tokens': relation.tokenPtoG,
+      },
+    });
+  }
+
+  async syncSupportedGrowiCommands(relation:Relation): Promise<Relation> {
+    const res = await this.getSupportedGrowiCommands(relation);
+    const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = res.data;
+    relation.supportedCommandsForBroadcastUse = supportedCommandsForBroadcastUse;
+    relation.supportedCommandsForSingleUse = supportedCommandsForSingleUse;
+    relation.expiredAtCommands = addHours(new Date(), 48);
+
+    return this.relationRepository.save(relation);
+  }
+
+  async syncRelation(relation:Relation, baseDate:Date):Promise<Relation|null> {
+    const distanceMillisecondsToExpiredAt = relation.getDistanceInMillisecondsToExpiredAt(baseDate);
+
+    if (distanceMillisecondsToExpiredAt < 0) {
+      try {
+        return await this.syncSupportedGrowiCommands(relation);
+      }
+      catch (err) {
+        logger.error(err);
+        return null;
+      }
+    }
+
+    // 24 hours
+    if (distanceMillisecondsToExpiredAt < 1000 * 60 * 60 * 24) {
+      try {
+        this.syncSupportedGrowiCommands(relation);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    }
+
+    return relation;
+  }
+
+  async isSupportedGrowiCommandForSingleUse(relation:Relation, growiCommandType:string, baseDate:Date):Promise<boolean> {
+    const syncedRelation = await this.syncRelation(relation, baseDate);
+    if (syncedRelation == null) {
+      return false;
+    }
+    return relation.supportedCommandsForSingleUse.includes(growiCommandType);
+  }
+
+  async isSupportedGrowiCommandForBroadcastUse(relation:Relation, growiCommandType:string, baseDate:Date):Promise<boolean> {
+    const syncedRelation = await this.syncRelation(relation, baseDate);
+    if (syncedRelation == null) {
+      return false;
+    }
+    return relation.supportedCommandsForBroadcastUse.includes(growiCommandType);
+  }
+
+}

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

@@ -21,7 +21,7 @@ export class SelectGrowiService implements GrowiCommandProcessor {
   @Inject()
   @Inject()
   relationRepository: RelationRepository;
   relationRepository: RelationRepository;
 
 
-  async process(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, body: {[key:string]:string } & {growiUris:string[]}): Promise<void> {
+  async process(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, body: {[key:string]:string } & {growiUrisForSingleUse:string[]}): Promise<void> {
     const { botToken } = authorizeResult;
     const { botToken } = authorizeResult;
 
 
     if (botToken == null) {
     if (botToken == null) {
@@ -60,7 +60,7 @@ export class SelectGrowiService implements GrowiCommandProcessor {
             element: {
             element: {
               type: 'static_select',
               type: 'static_select',
               action_id: 'growi_app',
               action_id: 'growi_app',
-              options: body.growiUris.map((growiUri) => {
+              options: body.growiUrisForSingleUse.map((growiUri) => {
                 return ({
                 return ({
                   text: {
                   text: {
                     type: 'plain_text',
                     type: 'plain_text',