Преглед изворни кода

Merge branch 'feat/growi-bot' into feat/5519-5574-implement-relation-entity

itizawa пре 5 година
родитељ
комит
31acbab88f
40 измењених фајлова са 561 додато и 334 уклоњено
  1. 1 0
      .eslintignore
  2. 41 5
      .github/workflows/ci-slackbot-proxy.yml
  3. 3 0
      .github/workflows/ci.yml
  4. 15 2
      CHANGES.md
  5. 1 2
      package.json
  6. 2 0
      packages/slack/.eslintignore
  7. 1 1
      packages/slack/.gitignore
  8. 2 2
      packages/slack/package.json
  9. 31 3
      packages/slack/src/utils/slash-command-parser.test.ts
  10. 8 9
      packages/slack/src/utils/slash-command-parser.ts
  11. 1 1
      packages/slack/tsconfig.build.json
  12. 3 3
      packages/slack/tsconfig.json
  13. 2 0
      packages/slackbot-proxy/.eslintignore
  14. 3 0
      packages/slackbot-proxy/config/ci/.env.local.for-ci
  15. 2 0
      packages/slackbot-proxy/package.json
  16. 1 0
      packages/slackbot-proxy/src/Server.ts
  17. 8 1
      packages/slackbot-proxy/src/controllers/slack.ts
  18. 0 8
      packages/slackbot-proxy/src/entities/installation.ts
  19. 1 1
      packages/slackbot-proxy/src/entities/order.ts
  20. 16 0
      packages/slackbot-proxy/src/services/RecieveService.ts
  21. 3 3
      packages/slackbot-proxy/tsconfig.json
  22. 10 3
      resource/locales/en_US/admin/admin.json
  23. 11 4
      resource/locales/ja_JP/admin/admin.json
  24. 10 3
      resource/locales/zh_CN/admin/admin.json
  25. 35 0
      src/client/js/components/Admin/App/AppSetting.jsx
  26. 6 1
      src/client/js/components/Admin/Notification/ManageGlobalNotification.jsx
  27. 51 25
      src/client/js/components/Admin/SlackIntegration/AccessTokenSettings.jsx
  28. 21 10
      src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx
  29. 38 17
      src/client/js/components/Admin/SlackIntegration/SlackIntegration.jsx
  30. 25 6
      src/client/js/components/Admin/Users/UserInviteModal.jsx
  31. 10 0
      src/client/js/services/AdminAppContainer.js
  32. 7 7
      src/server/crowi/index.js
  33. 1 0
      src/server/models/config.js
  34. 5 0
      src/server/models/user.js
  35. 7 0
      src/server/routes/apiv3/app-settings.js
  36. 83 3
      src/server/routes/apiv3/slack-bot.js
  37. 25 30
      src/server/routes/apiv3/slack-integration.js
  38. 12 0
      src/server/service/config-loader.js
  39. 58 146
      src/server/service/slackbot.js
  40. 1 38
      yarn.lock

+ 1 - 0
.eslintignore

@@ -1,6 +1,7 @@
 /.github/**
 /.vscode/**
 /node_modules/**
+/packages/**
 /public/**
 /src/client/js/legacy/thirdparty-js/**
 /src/client/js/util/reveal/plugins/markdown.js

+ 41 - 5
.github/workflows/ci-bot-proxy.yml → .github/workflows/ci-slackbot-proxy.yml

@@ -1,4 +1,4 @@
-name: Node CI for growi-bot-proxy
+name: Node CI for slackbot-proxy
 
 on:
   push:
@@ -6,14 +6,15 @@ on:
       - release/**
       - tmp/**
     paths:
-      - .github/workflows/ci-bot-proxy.yml
-      - packages/growi-bot-proxy/*
+      - .github/workflows/ci-slackbot-proxy.yml
+      - packages/slack/*
+      - packages/slackbot-proxy/*
       - package.json
       - yarn.lock
 
 defaults:
   run:
-    working-directory: packages/growi-bot-proxy
+    working-directory: packages/slackbot-proxy
 
 jobs:
 
@@ -79,6 +80,16 @@ jobs:
       matrix:
         node-version: [14.x]
 
+    services:
+      mysql:
+        image: mysql:8.0
+        ports:
+          - 3306
+        options: --health-cmd "mysqladmin ping" --health-interval 5s --health-timeout 2s --health-retries 3
+        env:
+          MYSQL_ALLOW_EMPTY_PASSWORD: yes
+          MYSQL_DATABASE: growi-slackbot-proxy
+
     steps:
     - uses: actions/checkout@v2
     - name: Use Node.js ${{ matrix.node-version }}
@@ -114,8 +125,15 @@ jobs:
         yarn list --depth=0
     - name: yarn dev:ci
       run: |
+        cp config/ci/.env.local.for-ci .env.local
         yarn dev:ci
-
+      env:
+        TYPEORM_CONNECTION: mysql
+        TYPEORM_HOST: localhost
+        TYPEORM_PORT: ${{ job.services.mysql.ports[3306] }}
+        TYPEORM_DATABASE: growi-slackbot-proxy
+        TYPEORM_USERNAME: root
+        TYPEORM_PASSWORD:
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
       if: failure()
@@ -134,6 +152,16 @@ jobs:
       matrix:
         node-version: [14.x]
 
+    services:
+      mysql:
+        image: mysql:8.0
+        ports:
+          - 3306
+        options: --health-cmd "mysqladmin ping" --health-interval 5s --health-timeout 2s --health-retries 3
+        env:
+          MYSQL_ALLOW_EMPTY_PASSWORD: yes
+          MYSQL_DATABASE: growi-slackbot-proxy
+
     steps:
     - uses: actions/checkout@v2
     - name: Use Node.js ${{ matrix.node-version }}
@@ -187,7 +215,15 @@ jobs:
         yarn list --production --depth=0
     - name: yarn start:prod:ci
       run: |
+        cp config/ci/.env.local.for-ci .env.local
         yarn start:prod:ci
+      env:
+        TYPEORM_CONNECTION: mysql
+        TYPEORM_HOST: localhost
+        TYPEORM_PORT: ${{ job.services.mysql.ports[3306] }}
+        TYPEORM_DATABASE: growi-slackbot-proxy
+        TYPEORM_USERNAME: root
+        TYPEORM_PASSWORD:
     - name: Upload report as artifact
       uses: actions/upload-artifact@v2
       with:

+ 3 - 0
.github/workflows/ci.yml

@@ -7,6 +7,9 @@ on:
       - tmp/**
     paths:
       - .github/workflows/ci.yml
+      - .eslint*
+      - .prettier*
+      - .stylelint*
       - config
       - resource
       - src

+ 15 - 2
CHANGES.md

@@ -1,11 +1,24 @@
 # CHANGES
 
-## v4.2.14-RC
+## v4.2.15-EC
 
+* Support: Update libs
+    * eslint-config-weseek
+
+## v4.2.14
+
+* Feature: Add an option to restrict publishing email property for new users
+* Improvement: Invite modal in admin page without email server settings
+* Improvement: Global notification settings in admin page without email server settings
+* Fix: Can create pages on the share route
+    * Introduced by v4.2.8
+* Fix: Pages restrected by group are excluded for recurrence operation
+    * Introduced by v4.2.8
+* Fix: Rename and duplicate to descendants path does not work correctly
+    * Introduced by v4.2.8
 * Support: Update libs
     * bunyan
     * browser-bunyan
-    * eslint-config-weseek
 
 ## v4.2.13
 

+ 1 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.2.14-RC",
+  "version": "4.2.15-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -84,7 +84,6 @@
     "@kobalab/socket.io-session": "^1.0.3",
     "@promster/express": "^5.0.1",
     "@promster/server": "^6.0.0",
-    "@slack/bolt": "^3.0.0",
     "@slack/events-api": "^3.0.0",
     "@slack/web-api": "^6.1.0",
     "JSONStream": "^1.3.5",

+ 2 - 0
packages/slack/.eslintignore

@@ -0,0 +1,2 @@
+/lib/**
+/node_modules/**

+ 1 - 1
packages/slack/.gitignore

@@ -1 +1 @@
-/dist/
+/lib/

+ 2 - 2
packages/slack/package.json

@@ -2,8 +2,8 @@
   "name": "@growi/slack",
   "version": "0.9.0-RC",
   "license": "MIT",
-  "main": "dist/index.js",
-  "files": ["dist"],
+  "main": "lib/index.js",
+  "files": ["lib"],
   "scripts": {
     "build": "yarn tsc",
     "tsc": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",

+ 31 - 3
packages/slack/src/utils/slash-command-parser.test.ts

@@ -12,7 +12,7 @@ describe('parse SlashCommand', () => {
   describe('without growiCommandType', () => {
     test('throws InvalidGrowiCommandError', () => {
       // setup
-      const slashCommandText = '/growi';
+      const slashCommandText = '';
       const slashCommand = new SlashCommandMock(slashCommandText);
 
       // when/then
@@ -24,7 +24,7 @@ describe('parse SlashCommand', () => {
 
   test('returns a GrowiCommand instance with empty growiCommandArgs', () => {
     // setup
-    const slashCommandText = '/growi search';
+    const slashCommandText = 'search';
     const slashCommand = new SlashCommandMock(slashCommandText);
 
     // when
@@ -36,9 +36,37 @@ describe('parse SlashCommand', () => {
     expect(result.growiCommandArgs).toStrictEqual([]);
   });
 
+  test('returns a GrowiCommand instance with space growiCommandType', () => {
+    // setup
+    const slashCommandText = '   search   ';
+    const slashCommand = new SlashCommandMock(slashCommandText);
+
+    // when
+    const result = parse(slashCommand);
+
+    // then
+    expect(result.text).toBe(slashCommandText);
+    expect(result.growiCommandType).toBe('search');
+    expect(result.growiCommandArgs).toStrictEqual([]);
+  });
+
+  test('returns a GrowiCommand instance with space growiCommandArgs', () => {
+    // setup
+    const slashCommandText = '   search hoge   ';
+    const slashCommand = new SlashCommandMock(slashCommandText);
+
+    // when
+    const result = parse(slashCommand);
+
+    // then
+    expect(result.text).toBe(slashCommandText);
+    expect(result.growiCommandType).toBe('search');
+    expect(result.growiCommandArgs).toStrictEqual(['hoge']);
+  });
+
   test('returns a GrowiCommand instance', () => {
     // setup
-    const slashCommandText = '/growi search keyword1 keyword2';
+    const slashCommandText = 'search keyword1 keyword2';
     const slashCommand = new SlashCommandMock(slashCommandText);
 
     // when

+ 8 - 9
packages/slack/src/utils/slash-command-parser.ts

@@ -1,18 +1,17 @@
-import { SlashCommand } from '@slack/bolt';
+import { GrowiCommand } from '../interfaces/growi-command';
+import { InvalidGrowiCommandError } from '../models/errors';
 
-import { GrowiCommand } from '~/interfaces/growi-command';
-import { InvalidGrowiCommandError } from '~/models/errors';
+export const parse = (slashCommand:{[key:string]:string}): GrowiCommand => {
+  const trimmedText = slashCommand.text.trim();
+  const splitted = trimmedText.split(' ');
 
-export const parse = (slashCommand: SlashCommand): GrowiCommand => {
-  const splitted = slashCommand.text.split(' ');
-
-  if (splitted.length < 2) {
+  if (splitted[0] === '') {
     throw new InvalidGrowiCommandError('The SlashCommand.text does not specify GrowiCommand type');
   }
 
   return {
     text: slashCommand.text,
-    growiCommandType: splitted[1],
-    growiCommandArgs: splitted.slice(2),
+    growiCommandType: splitted[0],
+    growiCommandArgs: splitted.slice(1),
   };
 };

+ 1 - 1
packages/slack/tsconfig.build.json

@@ -1,7 +1,7 @@
 {
   "extends": "./tsconfig.json",
   "compilerOptions": {
-    "outDir": "dist",
+    "outDir": "lib",
     "declaration": true,
     "noResolve": false,
     "preserveConstEnums": true,

+ 3 - 3
packages/slack/tsconfig.json

@@ -20,10 +20,10 @@
 
     /* Module Resolution Options */
     "moduleResolution": "node",
-    "baseUrl": ".",
+    "baseUrl": "src",
     "paths": {
-      "~/*": ["src/*"],
-      "^/*": ["./*"],
+      "~/*": ["./*"],
+      "^/*": ["../*"],
     },
     "typeRoots": [
       "../../node_modules/@types",

+ 2 - 0
packages/slackbot-proxy/.eslintignore

@@ -0,0 +1,2 @@
+/dist/**
+/node_modules/**

+ 3 - 0
packages/slackbot-proxy/config/ci/.env.local.for-ci

@@ -0,0 +1,3 @@
+SLACK_CLIENT_ID=dummy
+SLACK_CLIENT_SECRET=dummy
+SLACK_SIGNING_SECRET=dummy

+ 2 - 0
packages/slackbot-proxy/package.json

@@ -17,7 +17,9 @@
     "test:lint:fix": "eslint src --ext .ts --fix"
   },
   "dependencies": {
+    "@growi/slack": "0.9.0-RC",
     "@slack/oauth": "^2.0.1",
+    "@slack/web-api": "^6.1.0",
     "@tsed/common": "^6.34.3",
     "@tsed/di": "^6.34.3",
     "@tsed/platform-express": "^6.34.3",

+ 1 - 0
packages/slackbot-proxy/src/Server.ts

@@ -18,6 +18,7 @@ const connectionOptions: ConnectionOptions = {
   name: 'default',
   type: process.env.TYPEORM_CONNECTION,
   host: process.env.TYPEORM_HOST,
+  port: process.env.TYPEORM_PORT,
   database: process.env.TYPEORM_DATABASE,
   username: process.env.TYPEORM_USERNAME,
   password: process.env.TYPEORM_PASSWORD,

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

@@ -8,6 +8,7 @@ import { Order } from '~/entities/order';
 import { InstallationRepository } from '~/repositories/installation';
 import { OrderRepository } from '~/repositories/order';
 import { InstallerService } from '~/services/InstallerService';
+import { ReceiveService } from '~/services/RecieveService';
 
 
 @Controller('/slack')
@@ -22,6 +23,9 @@ export class SlackCtrl {
   @Inject()
   orderRepository: OrderRepository;
 
+  @Inject()
+  receiveService: ReceiveService;
+
   @Get('/testsave')
   testsave(): void {
     const installation = new Installation();
@@ -62,9 +66,12 @@ export class SlackCtrl {
   }
 
   @Post('/events')
-  async handleEvent(@BodyParams() body: any, @Res() res: Res): Promise<string> {
+  async handleEvent(@BodyParams() body:{[key:string]:string}, @Res() res: Res): Promise<string> {
     // Send response immediately to avoid opelation_timeout error
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
+
+    const slackInput = this.receiveService.receiveContentsFromSlack(body);
+    console.log('Controller/events', slackInput);
     res.send();
 
     const installation = await this.installationRepository.findByID('1');

+ 0 - 8
packages/slackbot-proxy/src/entities/installation.ts

@@ -6,8 +6,6 @@ import {
 } from 'typeorm';
 
 import { Installation as SlackInstallation } from '@slack/oauth';
-import { Order } from './order';
-import { Relation } from './relation';
 
 @Entity()
 export class Installation {
@@ -34,12 +32,6 @@ export class Installation {
   @Column({ nullable: true, unique: true })
   enterpriseId?: string;
 
-  @OneToMany(() => Order, order => order.installation)
-  orders?: Order[];
-
-  @OneToMany(() => Relation, relation => relation.installation)
-  relations?: Relation[];
-
   setData(slackInstallation: SlackInstallation): void {
     this.data = slackInstallation;
 

+ 1 - 1
packages/slackbot-proxy/src/entities/order.ts

@@ -16,7 +16,7 @@ export class Order {
   @UpdateDateColumn()
   readonly updatedAt: Date;
 
-  @ManyToOne(() => Installation, installation => installation.orders)
+  @ManyToOne(() => Installation)
   readonly installation: number;
 
   @OneToOne(() => Relation, relation => relation.order)

+ 16 - 0
packages/slackbot-proxy/src/services/RecieveService.ts

@@ -0,0 +1,16 @@
+import { Service } from '@tsed/di';
+import { parse } from '@growi/slack/lib/utils/slash-command-parser';
+
+@Service()
+export class ReceiveService {
+
+  receiveContentsFromSlack(body:{[key:string]:string}) : string {
+    const parseBody = parse(body);
+    if (parseBody.growiCommandType === 'register') {
+      console.log('register action occured');
+      return 'register action occurd';
+    }
+    return 'return receiveContentsFromSlack';
+  }
+
+}

+ 3 - 3
packages/slackbot-proxy/tsconfig.json

@@ -20,10 +20,10 @@
 
     /* Module Resolution Options */
     "moduleResolution": "node",
-    "baseUrl": ".",
+    "baseUrl": "src",
     "paths": {
-      "~/*": ["src/*"],
-      "^/*": ["./*"],
+      "~/*": ["./*"],
+      "^/*": ["../*"],
     },
     "typeRoots": [
       "../../node_modules/@types",

+ 10 - 3
resource/locales/en_US/admin/admin.json

@@ -1,4 +1,5 @@
 {
+  "mailer_setup_required":"<a href='/admin/app'>Email settings</a> are required to send.",
   "admin_top": {
     "management_wiki": "Management Wiki",
     "system_information": "System information",
@@ -22,6 +23,7 @@
     "confidential_name": "Confidential name",
     "confidential_example": "ex): internal use only",
     "default_language": "Default language for new users",
+    "default_mail_visibility": "Disclose e-mail for new users",
     "file_uploading": "File uploading",
     "enable_files_except_image": "Enabling this option will allow upload of any file type. Without this option, only image file upload is supported.",
     "attach_enable": "You can attach files other than image files if you enable this option.",
@@ -254,6 +256,7 @@
   },
   "slack_integration": {
     "bot_reset_successful": "Bot settings have been reset.",
+    "copied_to_clipboard": "Copied to clipboard",
     "modal": {
       "warning": "Warning",
       "sure_change_bot_type": "Are you sure you want to change the bot type?",
@@ -272,16 +275,20 @@
     }
   },
   "user_management": {
-    "invite_users": "Invite new users",
+    "invite_users": "Temporarily issue a new user",
     "click_twice_same_checkbox": "You should check at least one checkbox.",
     "invite_modal": {
-      "emails": "Emails (Possible to invite multiple people with new lines)",
+      "emails": "Emails (Possible to issue multiple people with new lines)",
+      "description1":"Temporarily issue new users by email addresses.",
+      "description2":"A temporary password will be generated for the first login.",
       "invite_thru_email": "Send invitation email",
+      "mail_setting_link":"<i class='icon-settings mr-2'></i><a href='/admin/app'>Email settings</a>",
       "valid_email": "Valid email address is required",
       "temporary_password": "The created user has a temporary password",
       "send_new_password": "Please send the new password to the user.",
       "send_temporary_password": "Be sure to copy the temporary password ON THIS SCREEN and send it to the user.",
-      "existing_email": "The following emails already exist"
+      "existing_email": "The following emails already exist",
+      "issue": "Issue"
     },
     "user_table": {
       "administrator": "Administrator",

+ 11 - 4
resource/locales/ja_JP/admin/admin.json

@@ -1,4 +1,5 @@
 {
+  "mailer_setup_required":"送信するには <a href='/admin/app'>メールの設定</a> が必要です。",
   "admin_top": {
     "management_wiki": "Wiki管理",
     "system_information": "システム情報",
@@ -22,6 +23,7 @@
     "confidential_name": "コンフィデンシャル表示",
     "confidential_example": "例: 社外秘",
     "default_language": "新規ユーザーのデフォルト設定言語",
+    "default_mail_visibility": "新規ユーザーの初期メール公開設定",
     "file_uploading": "ファイルアップロード",
     "enable_files_except_image": "画像以外のファイルアップロードを許可",
     "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
@@ -252,6 +254,7 @@
   },
   "slack_integration": {
     "bot_reset_successful": "Botの設定を消去しました。",
+    "copied_to_clipboard": "クリップボードにコピーされました。",
     "modal": {
       "warning": "注意",
       "sure_change_bot_type": "Botの種類を変更しますか?",
@@ -270,16 +273,20 @@
     }
   },
   "user_management": {
-    "invite_users": "新規ユーザーの招待",
+    "invite_users": "新規ユーザーの仮発行",
     "click_twice_same_checkbox": "少なくとも一つはチェックしてください。",
     "invite_modal": {
-      "emails": "メールアドレス (複数行入力で複数人招待可能)",
-      "invite_thru_email": "招待をメールで送信",
+      "emails": "メールアドレス (複数行入力で複数人発行可能)",
+      "description1":"メールアドレスを使用して新規ユーザーを仮発行します。",
+      "description2":"初回のログイン時に使用する仮パスワードが生成されます。",
+      "invite_thru_email": "招待メールを送信する",
+      "mail_setting_link":"<i class='icon-settings mr-2'></i><a href='/admin/app'>メールの設定</a>",
       "valid_email": "メールアドレスを入力してください。",
       "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
       "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
       "send_temporary_password": "招待メールを送っていない場合、この画面で必ず仮パスワードをコピーし、招待者へ連絡してください。",
-      "existing_email": "以下のEmailはすでに存在しています。"
+      "existing_email": "以下のEmailはすでに存在しています。",
+      "issue": "発行"
     },
     "user_table": {
       "administrator": "管理者",

+ 10 - 3
resource/locales/zh_CN/admin/admin.json

@@ -1,4 +1,5 @@
 {
+  "mailer_setup_required":"<a href='/admin/app'>Email settings</a> are required to send.",
 	"admin_top": {
 		"management_wiki": "管理Wiki",
 		"system_information": "系统信息",
@@ -22,6 +23,7 @@
 		"confidential_name": "内部名称",
 		"confidential_example": "ex):仅供内部使用",
 		"default_language": "新用户的默认语言",
+		"default_mail_visibility": "新用户的默认电子邮件可见性",
 		"file_uploading": "文件上传",
 		"enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
 		"attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
@@ -262,6 +264,7 @@
   },
   "slack_integration": {
     "bot_reset_successful": "删除了BOT设置。",
+    "copied_to_clipboard": "它已复制到剪贴板。",
     "modal": {
       "warning": "警告",
       "sure_change_bot_type": "您确定要更改设置吗?",
@@ -280,16 +283,20 @@
     }
   },
 	"user_management": {
-		"invite_users": "邀请新用户",
+		"invite_users": "临时发布新用户",
 		"click_twice_same_checkbox": "您应该至少选中一个复选框。",
 		"invite_modal": {
 			"emails": "电子邮件",
-			"invite_thru_email": "发送邀请电子邮件",
+      "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": "请确保复制此屏幕上的临时密码并将其发送给用户。",
-			"existing_email": "以下电子邮件已存在"
+			"existing_email": "以下电子邮件已存在",
+      "issue": "Issue"
 		},
 		"user_table": {
 			"administrator": "管理员",

+ 35 - 0
src/client/js/components/Admin/App/AppSetting.jsx

@@ -103,6 +103,41 @@ class AppSetting extends React.Component {
           </div>
         </div>
 
+        <div className="row form-group mb-5">
+          <label
+            className="text-left text-md-right col-md-3 col-form-label"
+          >
+            {t('admin:app_setting.default_mail_visibility')}
+          </label>
+          <div className="col-md-6 py-2">
+
+            <div className="custom-control custom-radio custom-control-inline">
+              <input
+                type="radio"
+                id="radio-email-show"
+                className="custom-control-input"
+                name="mailVisibility"
+                checked={adminAppContainer.state.isEmailPublishedForNewUser === true}
+                onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(true) }}
+              />
+              <label className="custom-control-label" htmlFor="radio-email-show">{t('Show')}</label>
+            </div>
+
+            <div className="custom-control custom-radio custom-control-inline">
+              <input
+                type="radio"
+                id="radio-email-hide"
+                className="custom-control-input"
+                name="mailVisibility"
+                checked={adminAppContainer.state.isEmailPublishedForNewUser === false}
+                onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(false) }}
+              />
+              <label className="custom-control-label" htmlFor="radio-email-hide">{t('Hide')}</label>
+            </div>
+
+          </div>
+        </div>
+
         <div className="row form-group mb-5">
           <label
             className="text-left text-md-right col-md-3 col-form-label"

+ 6 - 1
src/client/js/components/Admin/Notification/ManageGlobalNotification.jsx

@@ -96,7 +96,9 @@ class ManageGlobalNotification extends React.Component {
 
 
   render() {
-    const { t } = this.props;
+    const { t, appContainer } = this.props;
+    const { isMailerSetup } = appContainer.config;
+
     return (
       <React.Fragment>
 
@@ -179,7 +181,10 @@ class ManageGlobalNotification extends React.Component {
                     />
 
                   </div>
+
                   <p className="p-2">
+                    {/* eslint-disable-next-line react/no-danger */}
+                    {!isMailerSetup && <span className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('admin:mailer_setup_required') }} />}
                     <b>Hint: </b>
                     <a href="https://ifttt.com/create" target="blank">{t('notification_setting.email.ifttt_link')}
                       <i className="icon-share-alt" />

+ 51 - 25
src/client/js/components/Admin/SlackIntegration/AccessTokenSettings.jsx

@@ -1,39 +1,65 @@
-/* eslint-disable no-console */
 import React from 'react';
+import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { toastSuccess } from '../../../util/apiNotification';
 
-function AccessTokenSettings() {
-
+const AccessTokenSettings = (props) => {
   const { t } = useTranslation('admin');
-  function discardHandler() {
-    console.log('Discard button pressed');
-  }
 
-  function generateHandler() {
-    console.log('Generate button pressed');
-  }
+  const onClickDiscardButton = () => {
+    if (props.onClickDiscardButton != null) {
+      props.onClickDiscardButton();
+    }
+  };
+
+  const onClickGenerateToken = () => {
+    if (props.onClickGenerateToken != null) {
+      props.onClickGenerateToken();
+    }
+  };
+
+  const accessToken = props.accessToken ? props.accessToken : '';
 
   return (
-    <>
-      <div className="form-group row my-5">
-        <label className="text-left text-md-right col-md-3 col-form-label">Access Token</label>
-        <div className="col-md-6">
-          <input className="form-control" type="text" placeholder="access-token" />
+    <div className="row">
+      <div className="col-lg-12">
+
+        <h2 className="admin-setting-header">Access Token</h2>
+
+        <div className="form-group row my-5">
+          <label className="text-left text-md-right col-md-3 col-form-label">Access Token</label>
+          <div className="col-md-6">
+            {accessToken.length === 0 ? (
+              <input className="form-control" type="text" value={accessToken} readOnly />
+            ) : (
+              <CopyToClipboard text={accessToken} onCopy={() => toastSuccess(t('slack_integration.copied_to_clipboard'))}>
+                <input className="form-control" type="text" value={accessToken} readOnly />
+              </CopyToClipboard>
+            )}
+          </div>
         </div>
-      </div>
 
-      <div className="row">
-        <div className="mx-auto">
-          <button type="button" className="btn btn-outline-secondary text-nowrap mx-1" onClick={discardHandler}>
-            {t('slack_integration.access_token_settings.discard')}
-          </button>
-          <button type="button" className="btn btn-primary text-nowrap mx-1" onClick={generateHandler}>
-            {t('slack_integration.access_token_settings.generate')}
-          </button>
+        <div className="row">
+          <div className="mx-auto">
+            <button type="button" className="btn btn-outline-secondary text-nowrap mx-1" onClick={onClickDiscardButton} disabled={accessToken.length === 0}>
+              {t('slack_integration.access_token_settings.discard')}
+            </button>
+            <button type="button" className="btn btn-primary text-nowrap mx-1" onClick={onClickGenerateToken}>
+              {t('slack_integration.access_token_settings.generate')}
+            </button>
+          </div>
         </div>
+
       </div>
-    </>
+    </div>
   );
-}
+};
+
+AccessTokenSettings.propTypes = {
+  accessToken: PropTypes.string,
+  onClickDiscardButton: PropTypes.func,
+  onClickGenerateToken: PropTypes.func,
+};
 
 export default AccessTokenSettings;

+ 21 - 10
src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx

@@ -21,30 +21,41 @@ const CustomBotWithoutProxySettings = (props) => {
   // get site name from this GROWI
   // eslint-disable-next-line no-unused-vars
   const [siteName, setSiteName] = useState('');
+  // eslint-disable-next-line no-unused-vars
+  const [isSetupSlackBot, setIsSetupSlackBot] = useState(null);
+  const [isConnectedToSlack, setIsConnectedToSlack] = useState(null);
   const currentBotType = 'custom-bot-without-proxy';
 
-  const getSlackWSInWithoutProxy = useCallback(async() => {
-    try {
-      const res = await appContainer.apiv3.get('/slack-integration/custom-bot-without-proxy/slack-workspace-name');
-      setSlackWSNameInWithoutProxy(res.data.slackWorkSpaceName);
-    }
-    catch (err) {
-      toastError(err);
+  useEffect(() => {
+    const fetchData = async() => {
+      try {
+        const res = await appContainer.apiv3.get('/slack-integration/custom-bot-without-proxy/slack-workspace-name');
+        setSlackWSNameInWithoutProxy(res.data.slackWorkSpaceName);
+      }
+      catch (err) {
+        toastError(err);
+      }
+    };
+    setSlackWSNameInWithoutProxy(null);
+    if (isConnectedToSlack) {
+      fetchData();
     }
-  }, [appContainer]);
+  }, [appContainer, isConnectedToSlack]);
 
   const fetchData = useCallback(async() => {
     try {
       await adminAppContainer.retrieveAppSettingsData();
       const res = await appContainer.apiv3.get('/slack-integration/');
       const {
-        slackSigningSecret, slackBotToken, slackSigningSecretEnvVars, slackBotTokenEnvVars,
+        slackSigningSecret, slackBotToken, slackSigningSecretEnvVars, slackBotTokenEnvVars, isSetupSlackBot, isConnectedToSlack,
       } = res.data.slackBotSettingParams.customBotWithoutProxySettings;
       setSlackSigningSecret(slackSigningSecret);
       setSlackBotToken(slackBotToken);
       setSlackSigningSecretEnv(slackSigningSecretEnvVars);
       setSlackBotTokenEnv(slackBotTokenEnvVars);
       setSiteName(adminAppContainer.state.title);
+      setIsSetupSlackBot(isSetupSlackBot);
+      setIsConnectedToSlack(isConnectedToSlack);
     }
     catch (err) {
       toastError(err);
@@ -62,7 +73,7 @@ const CustomBotWithoutProxySettings = (props) => {
         slackBotToken,
         currentBotType,
       });
-      getSlackWSInWithoutProxy();
+      fetchData();
       toastSuccess(t('toaster.update_successed', { target: t('admin:slack_integration.custom_bot_without_proxy_settings') }));
     }
     catch (err) {

+ 38 - 17
src/client/js/components/Admin/SlackIntegration/SlackIntegration.jsx

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
 import AppContainer from '../../../services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
+
 import AccessTokenSettings from './AccessTokenSettings';
 import OfficialBotSettings from './OfficialBotSettings';
 import CustomBotWithoutProxySettings from './CustomBotWithoutProxySettings';
@@ -16,12 +17,14 @@ const SlackIntegration = (props) => {
   const { t } = useTranslation();
   const [currentBotType, setCurrentBotType] = useState(null);
   const [selectedBotType, setSelectedBotType] = useState(null);
+  const [accessToken, setAccessToken] = useState('');
 
   const fetchData = useCallback(async() => {
     try {
       const response = await appContainer.apiv3.get('slack-integration/');
-      const { currentBotType } = response.data.slackBotSettingParams;
+      const { currentBotType, accessToken } = response.data.slackBotSettingParams;
       setCurrentBotType(currentBotType);
+      setAccessToken(accessToken);
     }
     catch (err) {
       toastError(err);
@@ -43,11 +46,11 @@ const SlackIntegration = (props) => {
     setSelectedBotType(clickedBotType);
   };
 
-  const handleCancelBotChange = () => {
+  const cancelBotChangeHandler = () => {
     setSelectedBotType(null);
   };
 
-  const handleChangeCurrentBotSettings = async() => {
+  const changeCurrentBotSettingsHandler = async() => {
     try {
       const res = await appContainer.apiv3.put('slack-integration/custom-bot-without-proxy', {
         slackSigningSecret: '',
@@ -63,6 +66,27 @@ const SlackIntegration = (props) => {
     }
   };
 
+  const generateTokenHandler = async() => {
+    try {
+      await appContainer.apiv3.put('slack-integration/access-token');
+      fetchData();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  const discardTokenHandler = async() => {
+    try {
+      await appContainer.apiv3.delete('slack-integration/access-token');
+      fetchData();
+      toastSuccess(t('admin:slack_integration.bot_reset_successful'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
   let settingsComponent = null;
 
   switch (currentBotType) {
@@ -81,20 +105,17 @@ const SlackIntegration = (props) => {
 
   return (
     <>
-      <div className="container">
-        <ConfirmBotChangeModal
-          isOpen={selectedBotType != null}
-          onConfirmClick={handleChangeCurrentBotSettings}
-          onCancelClick={handleCancelBotChange}
-        />
-      </div>
-
-      <div className="row">
-        <div className="col-lg-12">
-          <h2 className="admin-setting-header">Access Token</h2>
-          <AccessTokenSettings />
-        </div>
-      </div>
+      <ConfirmBotChangeModal
+        isOpen={selectedBotType != null}
+        onConfirmClick={changeCurrentBotSettingsHandler}
+        onCancelClick={cancelBotChangeHandler}
+      />
+
+      <AccessTokenSettings
+        accessToken={accessToken}
+        onClickDiscardButton={discardTokenHandler}
+        onClickGenerateToken={generateTokenHandler}
+      />
 
       <div className="row my-5">
         <div className="card-deck mx-auto">

+ 25 - 6
src/client/js/components/Admin/Users/UserInviteModal.jsx

@@ -46,7 +46,12 @@ class UserInviteModal extends React.Component {
 
     return (
       <>
-        <label> {t('admin:user_management.invite_modal.emails')}</label>
+        <label>{t('admin:user_management.invite_modal.emails')}</label>
+        <p>
+          {t('admin:user_management.invite_modal.description1')}
+          <br />
+          {t('admin:user_management.invite_modal.description2')}
+        </p>
         <textarea
           className="form-control"
           placeholder="e.g.&#13;&#10;user1@growi.org&#13;&#10;user2@growi.org"
@@ -74,15 +79,29 @@ class UserInviteModal extends React.Component {
   }
 
   renderModalFooter() {
-    const { t } = this.props;
+    const { t, appContainer } = this.props;
+    const { isMailerSetup } = appContainer.config;
 
     return (
       <>
         <div className="col text-left custom-control custom-checkbox custom-checkbox-info text-left" onChange={this.handleCheckBox}>
-          <input type="checkbox" id="sendEmail" className="custom-control-input" name="sendEmail" defaultChecked={this.state.sendEmail} />
+          <input
+            type="checkbox"
+            id="sendEmail"
+            className="custom-control-input"
+            name="sendEmail"
+            defaultChecked={this.state.sendEmail}
+            disabled={!isMailerSetup}
+          />
           <label className="custom-control-label" htmlFor="sendEmail">
             {t('admin:user_management.invite_modal.invite_thru_email')}
           </label>
+          {isMailerSetup
+            // eslint-disable-next-line react/no-danger
+            ? <p className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('admin:user_management.invite_modal.mail_setting_link') }} />
+            // eslint-disable-next-line react/no-danger
+            : <p className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('admin:mailer_setup_required') }} />
+          }
         </div>
         <div>
           <button
@@ -90,7 +109,7 @@ class UserInviteModal extends React.Component {
             className="btn btn-outline-secondary mr-2"
             onClick={this.onToggleModal}
           >
-            Cancel
+            {t('Cancel')}
           </button>
 
           <button
@@ -99,7 +118,7 @@ class UserInviteModal extends React.Component {
             onClick={this.handleSubmit}
             disabled={!this.validEmail()}
           >
-            Invite
+            {t('admin:user_management.invite_modal.issue')}
           </button>
         </div>
       </>
@@ -119,7 +138,7 @@ class UserInviteModal extends React.Component {
           className="btn btn-outline-secondary"
           onClick={this.onToggleModal}
         >
-          Close
+          {t('Close')}
         </button>
       </>
     );

+ 10 - 0
src/client/js/services/AdminAppContainer.js

@@ -19,6 +19,7 @@ export default class AdminAppContainer extends Container {
       title: this.dummyTitle,
       confidential: '',
       globalLang: '',
+      isEmailPublishedForNewUser: true,
       fileUpload: '',
 
       siteUrl: '',
@@ -78,6 +79,7 @@ export default class AdminAppContainer extends Container {
       title: appSettingsParams.title,
       confidential: appSettingsParams.confidential,
       globalLang: appSettingsParams.globalLang,
+      isEmailPublishedForNewUser: appSettingsParams.isEmailPublishedForNewUser,
       fileUpload: appSettingsParams.fileUpload,
       siteUrl: appSettingsParams.siteUrl,
       envSiteUrl: appSettingsParams.envSiteUrl,
@@ -144,6 +146,13 @@ export default class AdminAppContainer extends Container {
     this.setState({ globalLang });
   }
 
+  /**
+   * Change isEmailPublishedForNewUser
+   */
+  changeIsEmailPublishedForNewUserShow(isEmailPublishedForNewUser) {
+    this.setState({ isEmailPublishedForNewUser });
+  }
+
   /**
    * Change fileUpload
    */
@@ -309,6 +318,7 @@ export default class AdminAppContainer extends Container {
       title: this.state.title,
       confidential: this.state.confidential,
       globalLang: this.state.globalLang,
+      isEmailPublishedForNewUser: this.state.isEmailPublishedForNewUser,
       fileUpload: this.state.fileUpload,
     });
     const { appSettingParams } = response.data;

+ 7 - 7
src/server/crowi/index.js

@@ -57,7 +57,7 @@ function Crowi(rootdir) {
   this.syncPageStatusService = null;
   this.cdnResourcesService = new CdnResourcesService();
   this.interceptorManager = new InterceptorManager();
-  this.boltService = null;
+  this.slackBotService = null;
   this.xss = new Xss();
 
   this.tokens = null;
@@ -118,7 +118,7 @@ Crowi.prototype.init = async function() {
     this.setupImport(),
     this.setupPageService(),
     this.setupSyncPageStatusService(),
-    this.setupBoltService(),
+    this.setupSlackBotService(),
   ]);
 
   // globalNotification depends on slack and mailer
@@ -670,15 +670,15 @@ Crowi.prototype.setupSyncPageStatusService = async function() {
   }
 };
 
-Crowi.prototype.setupBoltService = async function() {
-  const BoltService = require('../service/bolt');
-  if (this.boltService == null) {
-    this.boltService = new BoltService(this);
+Crowi.prototype.setupSlackBotService = async function() {
+  const SlackBotService = require('../service/slackbot');
+  if (this.slackBotService == null) {
+    this.slackBotService = new SlackBotService(this);
   }
 
   // add as a message handler
   if (this.s2sMessagingService != null) {
-    this.s2sMessagingService.addMessageHandler(this.boltService);
+    this.s2sMessagingService.addMessageHandler(this.slackBotService);
   }
 };
 

+ 1 - 0
src/server/models/config.js

@@ -229,6 +229,7 @@ module.exports = function(crowi) {
       isAclEnabled: crowi.aclService.isAclEnabled(),
       isSearchServiceConfigured: crowi.searchService.isConfigured,
       isSearchServiceReachable: crowi.searchService.isReachable,
+      isMailerSetup: crowi.mailService.isMailerSetup,
       globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     };
 

+ 5 - 0
src/server/models/user.js

@@ -268,6 +268,7 @@ module.exports = function(crowi) {
     this.name = name;
     this.username = username;
     this.status = STATUS_ACTIVE;
+    this.isEmailPublished = crowi.configManager.getConfig('crowi', 'customize:isEmailPublishedForNewUser');
 
     this.save((err, userData) => {
       userEvent.emit('activated', userData);
@@ -651,6 +652,10 @@ module.exports = function(crowi) {
     }
 
     const configManager = crowi.configManager;
+
+    // Default email show/hide is up to the administrator
+    newUser.isEmailPublished = configManager.getConfig('crowi', 'customize:isEmailPublishedForNewUser');
+
     const globalLang = configManager.getConfig('crowi', 'app:globalLang');
     if (globalLang != null) {
       newUser.lang = globalLang;

+ 7 - 0
src/server/routes/apiv3/app-settings.js

@@ -38,6 +38,9 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          globalLang:
  *            type: string
  *            description: language set when create user
+ *          isEmailPublishedForNewUser:
+ *            type: boolean
+ *            description: default email show/hide setting when create user
  *          fileUpload:
  *            type: boolean
  *            description: enable upload file except image file
@@ -154,6 +157,7 @@ module.exports = (crowi) => {
       body('title').trim(),
       body('confidential'),
       body('globalLang').isIn(listLocaleIds()),
+      body('isEmailPublishedForNewUser').isBoolean(),
       body('fileUpload').isBoolean(),
     ],
     siteUrlSetting: [
@@ -219,6 +223,7 @@ module.exports = (crowi) => {
       title: crowi.configManager.getConfig('crowi', 'app:title'),
       confidential: crowi.configManager.getConfig('crowi', 'app:confidential'),
       globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
+      isEmailPublishedForNewUser: crowi.configManager.getConfig('crowi', 'customize:isEmailPublishedForNewUser'),
       fileUpload: crowi.configManager.getConfig('crowi', 'app:fileUpload'),
       siteUrl: crowi.configManager.getConfig('crowi', 'app:siteUrl'),
       envSiteUrl: crowi.configManager.getConfigFromEnvVars('crowi', 'app:siteUrl'),
@@ -289,6 +294,7 @@ module.exports = (crowi) => {
       'app:title': req.body.title,
       'app:confidential': req.body.confidential,
       'app:globalLang': req.body.globalLang,
+      'customize:isEmailPublishedForNewUser': req.body.isEmailPublishedForNewUser,
       'app:fileUpload': req.body.fileUpload,
     };
 
@@ -298,6 +304,7 @@ module.exports = (crowi) => {
         title: crowi.configManager.getConfig('crowi', 'app:title'),
         confidential: crowi.configManager.getConfig('crowi', 'app:confidential'),
         globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
+        isEmailPublishedForNewUser: crowi.configManager.getConfig('crowi', 'customize:isEmailPublishedForNewUser'),
         fileUpload: crowi.configManager.getConfig('crowi', 'app:fileUpload'),
       };
       return res.apiv3({ appSettingParams });

+ 83 - 3
src/server/routes/apiv3/slack-bot.js

@@ -9,8 +9,6 @@ const router = express.Router();
 
 module.exports = (crowi) => {
   this.app = crowi.express;
-  const { boltService } = crowi;
-  const requestHandler = boltService.receiver.requestHandler.bind(boltService.receiver);
 
 
   // Check if the access token is correct
@@ -40,8 +38,90 @@ module.exports = (crowi) => {
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
     res.send();
 
-    await requestHandler(req.body);
+    const { body } = req;
+    const args = body.text.split(' ');
+    const command = args[0];
+
+    try {
+      switch (command) {
+        case 'search':
+          await crowi.slackBotService.showEphemeralSearchResults(body, args);
+          break;
+        case 'create':
+          await crowi.slackBotService.createModal(body);
+          break;
+        default:
+          await crowi.slackBotService.notCommand(body);
+          break;
+      }
+    }
+    catch (error) {
+      logger.error(error);
+      return res.send(error.message);
+    }
+  });
+
+  const handleBlockActions = async(payload) => {
+    const { action_id: actionId } = payload.actions[0];
+
+    switch (actionId) {
+      case 'shareSearchResults': {
+        await crowi.slackBotService.shareSearchResults(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(body, args, newOffset);
+        break;
+      }
+      default:
+        break;
+    }
+  };
+
+  const handleViewSubmission = async(payload) => {
+    const { callback_id: callbackId } = payload.view;
+
+    switch (callbackId) {
+      case 'createPage':
+        await crowi.slackBotService.createPageInGrowi(payload);
+        break;
+      default:
+        break;
+    }
+  };
+
+  router.post('/interactive', verificationRequestUrl, async(req, res) => {
+
+    // Send response immediately to avoid opelation_timeout error
+    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
+    res.send();
+
+    const payload = JSON.parse(req.body.payload);
+    const { type } = payload;
+
+    try {
+      switch (type) {
+        case 'block_actions':
+          await handleBlockActions(payload);
+          break;
+        case 'view_submission':
+          await handleViewSubmission(payload);
+          break;
+        default:
+          break;
+      }
+    }
+    catch (error) {
+      logger.error(error);
+      return res.send(error.message);
+    }
+
   });
 
+
   return router;
 };

+ 25 - 30
src/server/routes/apiv3/slack-integration.js

@@ -3,7 +3,6 @@ const loggerFactory = require('@alias/logger');
 const logger = loggerFactory('growi:routes:apiv3:notification-setting');
 const express = require('express');
 const { body } = require('express-validator');
-const { WebClient } = require('@slack/web-api');
 const crypto = require('crypto');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -87,6 +86,7 @@ module.exports = (crowi) => {
    */
   router.get('/', accessTokenParser, loginRequiredStrictly, adminRequired, async(req, res) => {
     const slackBotSettingParams = {
+      accessToken: crowi.configManager.getConfig('crowi', 'slackbot:access-token'),
       currentBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType'),
       // TODO impl when creating official bot
       officialBotSettings: {
@@ -100,6 +100,8 @@ module.exports = (crowi) => {
         slackBotTokenEnvVars: crowi.configManager.getConfigFromEnvVars('crowi', 'slackbot:token'),
         slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:signingSecret'),
         slackBotToken: crowi.configManager.getConfig('crowi', 'slackbot:token'),
+        isSetupSlackBot: crowi.slackBotService.isSetupSlackBot,
+        isConnectedToSlack: crowi.slackBotService.isConnectedToSlack,
       },
       // TODO imple when creating with proxy
       customBotWithProxySettings: {
@@ -140,9 +142,9 @@ module.exports = (crowi) => {
       try {
         await updateSlackBotSettings(requestParams);
 
-        // initialize bolt service
-        crowi.boltService.initialize();
-        crowi.boltService.publishUpdatedMessage();
+        // initialize slack service
+        await crowi.slackBotService.initialize();
+        crowi.slackBotService.publishUpdatedMessage();
 
         const slackIntegrationSettingsParams = {
           currentBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType'),
@@ -152,7 +154,7 @@ module.exports = (crowi) => {
       catch (error) {
         const msg = 'Error occured in updating Slack bot setting';
         logger.error('Error', error);
-        return res.apiv3Err(new ErrorV3(msg, 'update-SlackIntegrationSetting-failed'));
+        return res.apiv3Err(new ErrorV3(msg, 'update-SlackIntegrationSetting-failed'), 500);
       }
     });
 
@@ -188,9 +190,9 @@ module.exports = (crowi) => {
       try {
         await updateSlackBotSettings(requestParams);
 
-        // initialize bolt service
-        crowi.boltService.initialize();
-        crowi.boltService.publishUpdatedMessage();
+        // initialize slack service
+        await crowi.slackBotService.initialize();
+        crowi.slackBotService.publishUpdatedMessage();
 
         // TODO Impl to delete AccessToken both of Proxy and GROWI when botType changes.
         const customBotWithoutProxySettingParams = {
@@ -220,25 +222,18 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to get slack ws name for custom bot without proxy
    */
-  router.get('/custom-bot-without-proxy/slack-workspace-name', async(req, res) => {
-    // get ws name in custom bot from slackbot token
-    const slackBotToken = crowi.configManager.getConfig('crowi', 'slackbot:token');
+  router.get('/custom-bot-without-proxy/slack-workspace-name', loginRequiredStrictly, adminRequired, async(req, res) => {
 
-    let slackWorkSpaceName = null;
-    if (slackBotToken != null) {
-      const web = new WebClient(slackBotToken);
-      try {
-        const slackTeamInfo = await web.team.info();
-        slackWorkSpaceName = slackTeamInfo.team.name;
-      }
-      catch (error) {
-        const msg = 'Error occured in slack_bot_token';
-        logger.error('Error', msg);
-        return res.apiv3Err(new ErrorV3(msg, 'get-SlackWorkSpaceName-failed'));
-      }
+    try {
+      const slackWorkSpaceName = await crowi.slackBotService.getSlackChannelName();
+      return res.apiv3({ slackWorkSpaceName });
+    }
+    catch (error) {
+      const msg = 'Error occured in slack_bot_token';
+      logger.error('Error', error);
+      return res.apiv3Err(new ErrorV3(msg, 'get-SlackWorkSpaceName-failed'), 500);
     }
 
-    return res.apiv3({ slackWorkSpaceName });
   });
 
   /**
@@ -260,9 +255,9 @@ module.exports = (crowi) => {
       const accessToken = generateAccessToken(req.user);
       await updateSlackBotSettings({ 'slackbot:access-token': accessToken });
 
-      // initialize bolt service
-      crowi.boltService.initialize();
-      crowi.boltService.publishUpdatedMessage();
+      // initialize slack service
+      await crowi.slackBotService.initialize();
+      crowi.slackBotService.publishUpdatedMessage();
 
       return res.apiv3({ accessToken });
     }
@@ -291,9 +286,9 @@ module.exports = (crowi) => {
     try {
       await updateSlackBotSettings({ 'slackbot:access-token': null });
 
-      // initialize bolt service
-      crowi.boltService.initialize();
-      crowi.boltService.publishUpdatedMessage();
+      // initialize slack service
+      await crowi.slackBotService.initialize();
+      crowi.slackBotService.publishUpdatedMessage();
 
       return res.apiv3({});
     }

+ 12 - 0
src/server/service/config-loader.js

@@ -410,12 +410,24 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: null,
   },
+  SLACK_BOT_ACCESS_TOKEN: {
+    ns:      'crowi',
+    key:     'slackbot:access-token',
+    type:    TYPES.STRING,
+    default: null,
+  },
   SLACK_BOT_TYPE: {
     ns:      'crowi',
     key:     'slackbot:currentBotType', // 'official-bot' || 'custom-bot-without-proxy' || 'custom-bot-with-proxy'
     type:    TYPES.STRING,
     default: null,
   },
+  DEFAULT_EMAIL_PUBLISHED: {
+    ns:      'crowi',
+    key:     'customize:isEmailPublishedForNewUser',
+    type:    TYPES.BOOLEAN,
+    default: true,
+  },
 };
 
 class ConfigLoader {

+ 58 - 146
src/server/service/bolt.js → src/server/service/slackbot.js

@@ -1,97 +1,44 @@
-const logger = require('@alias/logger')('growi:service:BoltService');
+const logger = require('@alias/logger')('growi:service:SlackBotService');
 const mongoose = require('mongoose');
 
 const PAGINGLIMIT = 10;
 
-class BoltReciever {
-
-  init(app) {
-    this.bolt = app;
-  }
-
-  async requestHandler(body) {
-    if (this.bolt === undefined) {
-      throw new Error('Slack Bot service is not setup');
-    }
-
-    let ackCalled = false;
-
-    const payload = body.payload;
-    let reqBody;
-
-    if (payload != null) {
-      reqBody = JSON.parse(payload);
-    }
-    else {
-      reqBody = body;
-    }
-
-    const event = {
-      body: reqBody,
-      ack: (response) => {
-        if (ackCalled) {
-          return;
-        }
-
-        ackCalled = true;
-
-        if (response instanceof Error) {
-          const message = response.message || 'Error occurred';
-          throw new Error(message);
-        }
-        return;
-      },
-    };
-
-    await this.bolt.processEvent(event);
-  }
-
-}
-
-const { App } = require('@slack/bolt');
 const { WebClient, LogLevel } = require('@slack/web-api');
+
 const S2sMessage = require('../models/vo/s2s-message');
 const S2sMessageHandlable = require('./s2s-messaging/handlable');
 
-class BoltService extends S2sMessageHandlable {
+class SlackBotService extends S2sMessageHandlable {
 
   constructor(crowi) {
     super();
 
     this.crowi = crowi;
     this.s2sMessagingService = crowi.s2sMessagingService;
-    this.receiver = new BoltReciever();
+
     this.client = null;
+    this.searchService = null;
+
+    this.isSetupSlackBot = false;
+    this.isConnectedToSlack = false;
 
-    this.isBoltSetup = false;
     this.lastLoadedAt = null;
 
     this.initialize();
   }
 
-  initialize() {
-    this.isBoltSetup = false;
+  async initialize() {
+    this.isSetupSlackBot = false;
 
     const token = this.crowi.configManager.getConfig('crowi', 'slackbot:token');
-    const signingSecret = this.crowi.configManager.getConfig('crowi', 'slackbot:signingSecret');
-
-    this.client = new WebClient(token, { logLevel: LogLevel.DEBUG });
 
-    if (token == null || signingSecret == null) {
-      this.bolt = null;
-      return;
+    if (token != null) {
+      this.client = new WebClient(token, { logLevel: LogLevel.DEBUG });
+      logger.debug('SlackBot: setup is done');
+      this.isSetupSlackBot = true;
+      await this.sendAuthTest();
     }
 
-    this.bolt = new App({
-      token,
-      signingSecret,
-      receiver: this.receiver,
-    });
-    this.setupRoute();
-
-    logger.debug('SlackBot: setup is done');
-
-    this.isBoltSetup = true;
     this.lastLoadedAt = new Date();
   }
 
@@ -100,7 +47,7 @@ class BoltService extends S2sMessageHandlable {
    */
   shouldHandleS2sMessage(s2sMessage) {
     const { eventName, updatedAt } = s2sMessage;
-    if (eventName !== 'boltServiceUpdated' || updatedAt == null) {
+    if (eventName !== 'slackBotServiceUpdated' || updatedAt == null) {
       return false;
     }
 
@@ -114,7 +61,7 @@ class BoltService extends S2sMessageHandlable {
   async handleS2sMessage() {
     const { configManager } = this.crowi;
 
-    logger.info('Reset bolt by pubsub notification');
+    logger.info('Reset slack bot by pubsub notification');
     await configManager.loadConfigs();
     this.initialize();
   }
@@ -123,7 +70,7 @@ class BoltService extends S2sMessageHandlable {
     const { s2sMessagingService } = this;
 
     if (s2sMessagingService != null) {
-      const s2sMessage = new S2sMessage('boltServiceUpdated', { updatedAt: new Date() });
+      const s2sMessage = new S2sMessage('slackBotServiceUpdated', { updatedAt: new Date() });
 
       try {
         await s2sMessagingService.publish(s2sMessage);
@@ -134,65 +81,18 @@ class BoltService extends S2sMessageHandlable {
     }
   }
 
+  async sendAuthTest() {
+    this.isConnectedToSlack = false;
 
-  setupRoute() {
-    this.bolt.command('/growi', async({
-      command, client, body, ack,
-    }) => {
-      await ack();
-      const args = command.text.split(' ');
-      const firstArg = args[0];
-
-      switch (firstArg) {
-        case 'search':
-          await this.showEphemeralSearchResults(command, args);
-          break;
-
-        case 'create':
-          await this.createModal(command, client, body);
-          break;
-
-        default:
-          this.notCommand(command);
-          break;
-      }
-    });
-
-    this.bolt.view('createPage', async({
-      ack, view, body, client,
-    }) => {
-      await ack();
-      await this.createPageInGrowi(view, body);
-    });
-
-    this.bolt.action('showNextResults', async({
-      ack, action,
-    }) => {
-      await ack();
-      const parsedValue = JSON.parse(action.value);
-
-      const command = parsedValue.command;
-      const args = parsedValue.args;
-      const offset = parsedValue.offset;
-
-      const newOffset = offset + 10;
-      this.showEphemeralSearchResults(command, args, newOffset);
-    });
-
-    this.bolt.action('shareSearchResults', async({
-      body, ack, say, action,
-    }) => {
-      await ack();
-      await say(action.value);
-    });
-
+    await this.client.api.test();
+    this.isConnectedToSlack = true;
   }
 
-  notCommand(command) {
+  notCommand(body) {
     logger.error('Invalid first argument');
     this.client.chat.postEphemeral({
-      channel: command.channel_id,
-      user: command.user_id,
+      channel: body.channel_id,
+      user: body.user_id,
       blocks: [
         this.generateMarkdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
       ],
@@ -206,12 +106,12 @@ class BoltService extends S2sMessageHandlable {
     return keywords;
   }
 
-  async getSearchResultPaths(command, args, offset = 0) {
+  async getSearchResultPaths(body, args, offset = 0) {
     const firstKeyword = args[1];
     if (firstKeyword == null) {
       this.client.chat.postEphemeral({
-        channel: command.channel_id,
-        user: command.user_id,
+        channel: body.channel_id,
+        user: body.user_id,
         blocks: [
           this.generateMarkdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
         ],
@@ -230,8 +130,8 @@ class BoltService extends S2sMessageHandlable {
     if (results.data.length === 0) {
       logger.info(`No page found with "${keywords}"`);
       this.client.chat.postEphemeral({
-        channel: command.channel_id,
-        user: command.user_id,
+        channel: body.channel_id,
+        user: body.user_id,
         blocks: [
           this.generateMarkdownSectionBlock(`*No page that matches your keyword(s) "${keywords}".*`),
           this.generateMarkdownSectionBlock(':mag: *Help: Searching*'),
@@ -263,10 +163,22 @@ class BoltService extends S2sMessageHandlable {
     };
   }
 
-  async showEphemeralSearchResults(command, args, offsetNum) {
+  async getSlackChannelName() {
+    const slackTeamInfo = await this.client.team.info();
+    return slackTeamInfo.team.name;
+  }
+
+  shareSearchResults(payload) {
+    this.client.chat.postMessage({
+      channel: payload.channel.id,
+      text: payload.actions[0].value,
+    });
+  }
+
+  async showEphemeralSearchResults(body, args, offsetNum) {
     const {
       resultPaths, offset, resultsTotal,
-    } = await this.getSearchResultPaths(command, args, offsetNum);
+    } = await this.getSearchResultPaths(body, args, offsetNum);
 
     const keywords = this.getKeywords(args);
 
@@ -327,13 +239,13 @@ class BoltService extends S2sMessageHandlable {
               text: 'Next',
             },
             action_id: 'showNextResults',
-            value: JSON.stringify({ offset, command, args }),
+            value: JSON.stringify({ offset, body, args }),
           },
         );
       }
       await this.client.chat.postEphemeral({
-        channel: command.channel_id,
-        user: command.user_id,
+        channel: body.channel_id,
+        user: body.user_id,
         blocks: [
           this.generateMarkdownSectionBlock(keywordsAndDesc),
           this.generateMarkdownSectionBlock(`${urls.join('\n')}`),
@@ -344,8 +256,8 @@ class BoltService extends S2sMessageHandlable {
     catch {
       logger.error('Failed to get search results.');
       await this.client.chat.postEphemeral({
-        channel: command.channel_id,
-        user: command.user_id,
+        channel: body.channel_id,
+        user: body.user_id,
         blocks: [
           this.generateMarkdownSectionBlock('*Failed to search.*\n Hint\n `/growi search [keyword]`'),
         ],
@@ -354,9 +266,9 @@ class BoltService extends S2sMessageHandlable {
     }
   }
 
-  async createModal(command, client, body) {
+  async createModal(body) {
     try {
-      await client.views.open({
+      await this.client.views.open({
         trigger_id: body.trigger_id,
 
         view: {
@@ -385,8 +297,8 @@ class BoltService extends S2sMessageHandlable {
     catch (err) {
       logger.error('Failed to create a page.');
       await this.client.chat.postEphemeral({
-        channel: command.channel_id,
-        user: command.user_id,
+        channel: body.channel_id,
+        user: body.user_id,
         blocks: [
           this.generateMarkdownSectionBlock(`*Failed to create new page.*\n ${err}`),
         ],
@@ -396,14 +308,14 @@ class BoltService extends S2sMessageHandlable {
   }
 
   // Submit action in create Modal
-  async createPageInGrowi(view, body) {
+  async createPageInGrowi(payload) {
     const Page = this.crowi.model('Page');
     const pathUtils = require('growi-commons').pathUtils;
 
-    const contentsBody = view.state.values.contents.contents_input.value;
+    const contentsBody = payload.view.state.values.contents.contents_input.value;
 
     try {
-      let path = view.state.values.path.path_input.value;
+      let path = payload.view.state.values.path.path_input.value;
       // sanitize path
       path = this.crowi.xss.process(path);
       path = pathUtils.normalizePath(path);
@@ -414,7 +326,7 @@ class BoltService extends S2sMessageHandlable {
     }
     catch (err) {
       this.client.chat.postMessage({
-        channel: body.user.id,
+        channel: payload.user.id,
         blocks: [
           this.generateMarkdownSectionBlock(`Cannot create new page to existed path\n *Contents* :memo:\n ${contentsBody}`)],
       });
@@ -461,4 +373,4 @@ class BoltService extends S2sMessageHandlable {
 
 }
 
-module.exports = BoltService;
+module.exports = SlackBotService;

+ 1 - 38
yarn.lock

@@ -1843,27 +1843,6 @@
   dependencies:
     type-detect "4.0.8"
 
-"@slack/bolt@^3.0.0":
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/@slack/bolt/-/bolt-3.0.0.tgz#3fb9cf4669178727b0345feb91a9c99038243784"
-  integrity sha512-AM+ZHVWkn9tqI0JKk6yzTnQxpJuk/b3umaPyiW0BtM7nt37ZUli07xL8MvhlPBD7blh9Ow1VRwJw/ufzASUNsQ==
-  dependencies:
-    "@slack/logger" "^3.0.0"
-    "@slack/oauth" "^2.0.0"
-    "@slack/socket-mode" "1.0.0"
-    "@slack/types" "^2.0.0"
-    "@slack/web-api" "^6.0.0"
-    "@types/express" "^4.16.1"
-    "@types/node" ">=12"
-    "@types/promise.allsettled" "^1.0.3"
-    "@types/tsscmp" "^1.0.0"
-    axios "^0.21.1"
-    express "^4.16.4"
-    please-upgrade-node "^3.2.0"
-    promise.allsettled "^1.0.2"
-    raw-body "^2.3.3"
-    tsscmp "^1.0.6"
-
 "@slack/bolt@^3.3.0":
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/@slack/bolt/-/bolt-3.3.0.tgz#9bfb9252091f845ab20cac20d6801edae794a169"
@@ -1941,22 +1920,6 @@
     jsonwebtoken "^8.5.1"
     lodash.isstring "^4.0.1"
 
-"@slack/socket-mode@1.0.0":
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/@slack/socket-mode/-/socket-mode-1.0.0.tgz#b774e2e19b90b6a33f652afbd2cf2189eb7d497c"
-  integrity sha512-uDhFWljDsDjS1aME2gxzmbLvBBmPfOsEipI1+dPWEQdkcfGtM0Cg/TnqjbarHm58yNwAV6iS/RebJMC6CkeGEw==
-  dependencies:
-    "@slack/logger" "^2.0.0"
-    "@slack/web-api" "^5.14.0"
-    "@types/node" ">=12.0.0"
-    "@types/p-queue" "^2.3.2"
-    "@types/ws" "^7.2.5"
-    eventemitter3 "^3.1.0"
-    finity "^0.5.4"
-    p-cancelable "^1.1.0"
-    p-queue "^2.4.2"
-    ws "^7.3.1"
-
 "@slack/socket-mode@^1.0.0":
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/@slack/socket-mode/-/socket-mode-1.0.2.tgz#c805a627aa6528b888ad236872d082b40ba4247a"
@@ -1983,7 +1946,7 @@
   resolved "https://registry.yarnpkg.com/@slack/types/-/types-2.0.0.tgz#7b938ab576cd1d6c9ff9ad67a96f8058d101af10"
   integrity sha512-Nu4jWC39mDY5egAX4oElwOypdu8Cx9tmR7bo3ghaHYaC7mkKM1+b+soanW5s2ssu4yOLxMdFExMh6wlR34B6CA==
 
-"@slack/web-api@^5.14.0", "@slack/web-api@^5.7.0":
+"@slack/web-api@^5.7.0":
   version "5.15.0"
   resolved "https://registry.yarnpkg.com/@slack/web-api/-/web-api-5.15.0.tgz#6bcf1d0a833c0e87e45150c2fd1f9657e3ec0b0b"
   integrity sha512-tjQ8Zqv/Fmj9SOL9yIEd7IpTiKfKHi9DKAkfRVeotoX0clMr3SqQtBqO+KZMX27gm7dmgJsQaDKlILyzdCO+IA==