Browse Source

Merge branch 'master' into dependabot/npm_and_yarn/striptags-3.2.0

itizawa 4 years ago
parent
commit
e7b2906f74
100 changed files with 3654 additions and 288 deletions
  1. 1 0
      .eslintignore
  2. 239 0
      .github/workflows/ci-slackbot-proxy.yml
  3. 31 16
      .github/workflows/ci.yml
  4. 0 2
      .github/workflows/release-rc.yml
  5. 68 0
      .github/workflows/release-slackbot-proxy.yml
  6. 2 1
      .github/workflows/release.yml
  7. 2 6
      .vscode/settings.json
  8. 25 5
      CHANGES.md
  9. 2 2
      bin/github-actions/update-readme.sh
  10. 4 0
      config/env.dev.js
  11. 77 27
      docker/Dockerfile
  12. 0 13
      docker/Dockerfile.dockerignore
  13. 4 6
      docker/README.md
  14. 0 7
      docker/bin/remove-except-artifacts.sh
  15. 1 1
      docker/docker-entrypoint.sh
  16. 8 0
      lerna.json
  17. 12 202
      package.json
  18. 221 0
      packages/app/package.json
  19. 2 0
      packages/slack/.eslintignore
  20. 31 0
      packages/slack/.eslintrc.js
  21. 0 0
      packages/slack/.gitignore
  22. 61 0
      packages/slack/jest.config.js
  23. 38 0
      packages/slack/package.json
  24. 22 0
      packages/slack/src/index.ts
  25. 4 0
      packages/slack/src/interfaces/connection-status.ts
  26. 5 0
      packages/slack/src/interfaces/growi-command.ts
  27. 17 0
      packages/slack/src/interfaces/request-between-growi-and-proxy.ts
  28. 9 0
      packages/slack/src/interfaces/request-from-slack.ts
  29. 30 0
      packages/slack/src/middlewares/verify-growi-to-slack-request.ts
  30. 56 0
      packages/slack/src/middlewares/verify-slack-request.ts
  31. 3 0
      packages/slack/src/models/errors.ts
  32. 31 0
      packages/slack/src/utils/block-creater.ts
  33. 126 0
      packages/slack/src/utils/check-communicable.ts
  34. 11 0
      packages/slack/src/utils/logger/index.ts
  35. 40 0
      packages/slack/src/utils/post-ephemeral-errors.ts
  36. 93 0
      packages/slack/src/utils/reshape-contents-body.ts
  37. 75 0
      packages/slack/src/utils/slash-command-parser.test.ts
  38. 17 0
      packages/slack/src/utils/slash-command-parser.ts
  39. 12 0
      packages/slack/src/utils/webclient-factory.ts
  40. 17 0
      packages/slack/tsconfig.build.json
  41. 13 0
      packages/slack/tsconfig.json
  42. 1 0
      packages/slackbot-proxy/.dockerignore
  43. 2 0
      packages/slackbot-proxy/.env
  44. 6 0
      packages/slackbot-proxy/.env.development
  45. 2 0
      packages/slackbot-proxy/.eslintignore
  46. 31 0
      packages/slackbot-proxy/.eslintrc.js
  47. 0 0
      packages/slackbot-proxy/.gitignore
  48. 3 0
      packages/slackbot-proxy/config/ci/.env.local.for-ci
  49. 33 0
      packages/slackbot-proxy/docker-compose.dev.yml
  50. 103 0
      packages/slackbot-proxy/docker/Dockerfile
  51. 66 0
      packages/slackbot-proxy/docker/README.md
  52. 69 0
      packages/slackbot-proxy/package.json
  53. 187 0
      packages/slackbot-proxy/src/Server.ts
  54. 16 0
      packages/slackbot-proxy/src/config/logger/config.dev.ts
  55. 16 0
      packages/slackbot-proxy/src/config/logger/config.prod.ts
  56. 10 0
      packages/slackbot-proxy/src/config/swagger/config.dev.ts
  57. 5 0
      packages/slackbot-proxy/src/config/swagger/config.prod.ts
  58. 252 0
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  59. 20 0
      packages/slackbot-proxy/src/controllers/privacy.ts
  60. 318 0
      packages/slackbot-proxy/src/controllers/slack.ts
  61. 35 0
      packages/slackbot-proxy/src/controllers/top.ts
  62. 43 0
      packages/slackbot-proxy/src/entities/installation.ts
  63. 40 0
      packages/slackbot-proxy/src/entities/order.ts
  64. 36 0
      packages/slackbot-proxy/src/entities/relation.ts
  65. 22 0
      packages/slackbot-proxy/src/filters/ResourceNotFoundFilter.ts
  66. 28 0
      packages/slackbot-proxy/src/index.ts
  67. 4 0
      packages/slackbot-proxy/src/interfaces/growi-to-slack/growi-req.ts
  68. 6 0
      packages/slackbot-proxy/src/interfaces/slack-to-growi/growi-command-processor.ts
  69. 7 0
      packages/slackbot-proxy/src/interfaces/slack-to-growi/slack-oauth-req.ts
  70. 14 0
      packages/slackbot-proxy/src/middlewares/slack-to-growi/add-signing-secret-to-req.ts
  71. 28 0
      packages/slackbot-proxy/src/middlewares/slack-to-growi/add-webclient-response-to-res.ts
  72. 134 0
      packages/slackbot-proxy/src/middlewares/slack-to-growi/authorizer.ts
  73. 45 0
      packages/slackbot-proxy/src/middlewares/slack-to-growi/extract-growi-uri-from-req.ts
  74. 9 0
      packages/slackbot-proxy/src/models/errors.ts
  75. BIN
      packages/slackbot-proxy/src/public/images/add-to-slack.png
  76. BIN
      packages/slackbot-proxy/src/public/images/growi-bot.png
  77. 23 0
      packages/slackbot-proxy/src/repositories/installation.ts
  78. 10 0
      packages/slackbot-proxy/src/repositories/order.ts
  79. 10 0
      packages/slackbot-proxy/src/repositories/relation.ts
  80. 73 0
      packages/slackbot-proxy/src/services/InstallerService.ts
  81. 115 0
      packages/slackbot-proxy/src/services/RegisterService.ts
  82. 114 0
      packages/slackbot-proxy/src/services/SelectGrowiService.ts
  83. 75 0
      packages/slackbot-proxy/src/services/UnregisterService.ts
  84. 19 0
      packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjectionButtonDelegator.ts
  85. 7 0
      packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjector.ts
  86. 18 0
      packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjectorFactory.ts
  87. 10 0
      packages/slackbot-proxy/src/utils/extractGrowiUriFromView.ts
  88. 7 0
      packages/slackbot-proxy/src/utils/injectGrowiUriToView.ts
  89. 17 0
      packages/slackbot-proxy/src/utils/logger/index.ts
  90. 97 0
      packages/slackbot-proxy/src/views/privacy.ejs
  91. 20 0
      packages/slackbot-proxy/src/views/top.ejs
  92. 11 0
      packages/slackbot-proxy/tsconfig.base.json
  93. 17 0
      packages/slackbot-proxy/tsconfig.build.json
  94. 10 0
      packages/slackbot-proxy/tsconfig.json
  95. BIN
      public/images/slack-integration/growi-bot-kun-icon.png
  96. BIN
      public/images/slack-integration/growi-register-modal.png
  97. BIN
      public/images/slack-integration/growi-register-sentence.png
  98. BIN
      public/images/slack-integration/growi-set-proxy-url.png
  99. BIN
      public/images/slack-integration/impossible.png
  100. BIN
      public/images/slack-integration/possible.png

+ 1 - 0
.eslintignore

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

+ 239 - 0
.github/workflows/ci-slackbot-proxy.yml

@@ -0,0 +1,239 @@
+name: Node CI for slackbot-proxy
+
+on:
+  push:
+    branches-ignore:
+      - release/**
+      - tmp/**
+    paths:
+      - .github/workflows/ci-slackbot-proxy.yml
+      - packages/slack/**
+      - packages/slackbot-proxy/**
+      - package.json
+      - yarn.lock
+
+jobs:
+
+  test:
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        node-version: [14.x]
+
+    steps:
+    - uses: actions/checkout@v2
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ matrix.node-version }}
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: '**/node_modules'
+        key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+    - name: Get yarn cache dir
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      id: cache-yarn
+      run: echo "::set-output name=dir::$(yarn cache dir)"
+    - name: Cache/Restore yarn cache
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      uses: actions/cache@v2
+      with:
+        path: ${{ steps.cache-yarn.outputs.dir }}
+        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
+    - name: Install dependencies
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      run: |
+        npx lerna bootstrap
+    - name: Print dependencies
+      run: |
+        echo -n "node " && node -v
+        echo -n "npm " && npm -v
+        yarn list --depth=0
+    - name: yarn test
+      run: |
+        yarn lerna run test
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi-bot-proxy - test (${{ matrix.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+  launch-dev:
+    runs-on: ubuntu-latest
+
+    strategy:
+      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 }}
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ matrix.node-version }}
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: '**/node_modules'
+        key: ${{ runner.OS }}-node_modules_dev-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+    - name: Get yarn cache dir
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      id: cache-yarn
+      run: echo "::set-output name=dir::$(yarn cache dir)"
+    - name: Cache/Restore yarn cache
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      uses: actions/cache@v2
+      with:
+        path: ${{ steps.cache-yarn.outputs.dir }}
+        key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          ${{ runner.os }}-yarn-
+    - name: lerna bootstrap
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      run: |
+        npx lerna bootstrap
+    - name: Print dependencies
+      run: |
+        echo -n "node " && node -v
+        echo -n "npm " && npm -v
+        yarn list --depth=0
+    - name: yarn dev:ci
+      working-directory: ./packages/slackbot-proxy
+      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()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi-bot-proxy - launch-dev (${{ matrix.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+  launch-prod:
+    runs-on: ubuntu-latest
+
+    strategy:
+      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 }}
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ matrix.node-version }}
+    - name: Get Date
+      id: date
+      run: |
+        echo ::set-output name=YmdH::$(date '+%Y%m%d%H')
+        echo ::set-output name=Ymd::$(date '+%Y%m%d')
+        echo ::set-output name=Ym::$(date '+%Y%m')
+        echo ::set-output name=Y::$(date '+%Y')
+    - name: Cache/Restore node_modules
+      uses: actions/cache@v2
+      with:
+        path: '**/node_modules'
+        key: ${{ runner.OS }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.YmdH }}
+        restore-keys: |
+          ${{ runner.os }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.Ymd }}
+          ${{ runner.os }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.Ym }}
+          ${{ runner.os }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.Y }}
+    - name: Get yarn cache dir
+      id: cache-yarn
+      run: echo "::set-output name=dir::$(yarn cache dir)"
+    - name: Cache/Restore yarn cache
+      uses: actions/cache@v2
+      with:
+        path: ${{ steps.cache-yarn.outputs.dir }}
+        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          ${{ runner.os }}-yarn-
+    - name: lerna bootstrap
+      run: |
+        npx lerna bootstrap
+    - name: Print dependencies
+      run: |
+        echo -n "node " && node -v
+        echo -n "npm " && npm -v
+        yarn list --depth=0
+    - name: lerna run build
+      run: |
+        yarn lerna run build
+    - name: lerna bootstrap --production
+      run: |
+        npx lerna bootstrap -- --production
+    - name: Print dependencies
+      run: |
+        echo -n "node " && node -v
+        echo -n "npm " && npm -v
+        yarn list --production --depth=0
+    - name: yarn start:prod:ci
+      working-directory: ./packages/slackbot-proxy
+      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:
+        name: Report
+        path: report
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi-bot-proxy - launch-prod (${{ matrix.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 31 - 16
.github/workflows/ci.yml

@@ -1,10 +1,22 @@
-name: Node CI
+name: Node CI for growi
 
 
 on:
 on:
   push:
   push:
     branches-ignore:
     branches-ignore:
       - release/**
       - release/**
       - tmp/**
       - tmp/**
+    paths:
+      - .github/workflows/ci.yml
+      - packages/app/**
+      - packages/app-for-hoisting/**
+      - .eslint*
+      - .prettier*
+      - .stylelint*
+      - config/**
+      - resource/**
+      - src/**
+      - package.json
+      - yarn.lock
 
 
 jobs:
 jobs:
 
 
@@ -25,7 +37,7 @@ jobs:
       id: cache-dependencies
       id: cache-dependencies
       uses: actions/cache@v2
       uses: actions/cache@v2
       with:
       with:
-        path: node_modules
+        path: '**/node_modules'
         key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
         key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
     - name: Get yarn cache dir
     - name: Get yarn cache dir
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
@@ -42,7 +54,7 @@ jobs:
     - name: Install dependencies
     - name: Install dependencies
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
       run: |
       run: |
-        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs react-images@1.0.0 react-motion
+        npx lerna bootstrap
     - name: Print dependencies
     - name: Print dependencies
       run: |
       run: |
         echo -n "node " && node -v
         echo -n "node " && node -v
@@ -57,7 +69,7 @@ jobs:
       if: failure()
       if: failure()
       with:
       with:
         type: ${{ job.status }}
         type: ${{ job.status }}
-        job_name: '*test (${{ matrix.node-version }})*'
+        job_name: '*Node CI for growi - lint (${{ matrix.node-version }})*'
         channel: '#ci'
         channel: '#ci'
         isCompactMode: true
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
@@ -90,7 +102,7 @@ jobs:
       id: cache-dependencies
       id: cache-dependencies
       uses: actions/cache@v2
       uses: actions/cache@v2
       with:
       with:
-        path: node_modules
+        path: '**/node_modules'
         key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
         key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
     - name: Get yarn cache dir
     - name: Get yarn cache dir
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
@@ -107,7 +119,7 @@ jobs:
     - name: Install dependencies
     - name: Install dependencies
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
       run: |
       run: |
-        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs react-images@1.0.0 react-motion
+        npx lerna bootstrap
     - name: Print dependencies
     - name: Print dependencies
       run: |
       run: |
         echo -n "node " && node -v
         echo -n "node " && node -v
@@ -129,7 +141,7 @@ jobs:
       if: failure()
       if: failure()
       with:
       with:
         type: ${{ job.status }}
         type: ${{ job.status }}
-        job_name: '*test (${{ matrix.node-version }})*'
+        job_name: '*Node CI for growi - test (${{ matrix.node-version }})*'
         channel: '#ci'
         channel: '#ci'
         isCompactMode: true
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
@@ -152,7 +164,7 @@ jobs:
       id: cache-dependencies
       id: cache-dependencies
       uses: actions/cache@v2
       uses: actions/cache@v2
       with:
       with:
-        path: node_modules
+        path: '**/node_modules'
         key: ${{ runner.OS }}-node_modules_dev-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
         key: ${{ runner.OS }}-node_modules_dev-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
     - name: Get Date
     - name: Get Date
       id: date
       id: date
@@ -185,7 +197,8 @@ jobs:
     - name: Install dependencies
     - name: Install dependencies
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
       run: |
       run: |
-        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs react-images@1.0.0 react-motion
+        npx lerna bootstrap
+        yarn lerna add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs react-images@1.0.0 react-motion --scope @growi/app --scope @growi/app-for-hoisting
     - name: Print dependencies
     - name: Print dependencies
       run: |
       run: |
         echo -n "node " && node -v
         echo -n "node " && node -v
@@ -200,7 +213,7 @@ jobs:
       if: failure()
       if: failure()
       with:
       with:
         type: ${{ job.status }}
         type: ${{ job.status }}
-        job_name: '*build-dev (${{ matrix.node-version }})*'
+        job_name: '*Node CI for growi - build-dev (${{ matrix.node-version }})*'
         channel: '#ci'
         channel: '#ci'
         isCompactMode: true
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
@@ -239,7 +252,7 @@ jobs:
     - name: Cache/Restore node_modules
     - name: Cache/Restore node_modules
       uses: actions/cache@v2
       uses: actions/cache@v2
       with:
       with:
-        path: node_modules
+        path: '**/node_modules'
         key: ${{ runner.OS }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.YmdH }}
         key: ${{ runner.OS }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.YmdH }}
         restore-keys: |
         restore-keys: |
           ${{ runner.os }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.Ymd }}
           ${{ runner.os }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.Ymd }}
@@ -257,8 +270,10 @@ jobs:
           ${{ runner.os }}-yarn-
           ${{ runner.os }}-yarn-
     - name: Install dependencies
     - name: Install dependencies
       run: |
       run: |
-        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
-        yarn add -D react-images@1.0.0 react-motion
+        npx lerna bootstrap
+        yarn lerna add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs --scope @growi/app --scope @growi/app-for-hoisting
+        yarn lerna add -D react-images@1.0.0 react-motion --scope @growi/app --scope @growi/app-for-hoisting
+        yarn lerna run build --scope @growi/slack
     - name: Print dependencies
     - name: Print dependencies
       run: |
       run: |
         echo -n "node " && node -v
         echo -n "node " && node -v
@@ -267,9 +282,9 @@ jobs:
     - name: yarn build:prod:analyze
     - name: yarn build:prod:analyze
       run: |
       run: |
         yarn build:prod:analyze
         yarn build:prod:analyze
-    - name: yarn install --production
+    - name: lerna bootstrap --production
       run: |
       run: |
-        yarn install --production
+        npx lerna bootstrap -- --production
     - name: Print dependencies
     - name: Print dependencies
       run: |
       run: |
         echo -n "node " && node -v
         echo -n "node " && node -v
@@ -300,7 +315,7 @@ jobs:
       if: failure()
       if: failure()
       with:
       with:
         type: ${{ job.status }}
         type: ${{ job.status }}
-        job_name: '*build-prod (${{ matrix.node-version }})*'
+        job_name: '*Node CI for growi - build-prod (${{ matrix.node-version }})*'
         channel: '#ci'
         channel: '#ci'
         isCompactMode: true
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 0 - 2
.github/workflows/release-rc.yml

@@ -28,8 +28,6 @@ jobs:
           --tag growi \
           --tag growi \
           --platform linux/amd64 \
           --platform linux/amd64 \
           --load \
           --load \
-          --cache-from=type=registry,ref=$CACHE_REF \
-          --cache-to=type=registry,ref=$CACHE_REF,mode=max \
           --file ./docker/Dockerfile .
           --file ./docker/Dockerfile .
 
 
     - name: Get SemVer
     - name: Get SemVer

+ 68 - 0
.github/workflows/release-slackbot-proxy.yml

@@ -0,0 +1,68 @@
+name: Release Docker Image for @growi/slackbot-proxy
+
+on:
+  push:
+    branches:
+      - release/slackbot-proxy/**
+
+jobs:
+
+  build-and-push-image:
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - name: Get version
+      working-directory: ./packages/slackbot-proxy
+      run: |
+        export RELEASE_VERSION=`npm run version --silent`
+        echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_ENV
+
+    - name: Set up Docker Buildx
+      uses: docker/setup-buildx-action@v1
+
+    - name: Login to docker.io registry
+      run: |
+        echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
+
+    - name: Login to GitHub Container Registry
+      uses: docker/login-action@v1
+      with:
+        registry: ghcr.io
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+
+    - name: Setup gcloud
+      uses: google-github-actions/setup-gcloud@master
+      with:
+        project_id: ${{ secrets.GCP_PRJ_ID_SLACKBOT_PROXY }}
+        service_account_key: ${{ secrets.GCP_SA_KEY_SLACKBOT_PROXY }}
+        export_default_credentials: true
+
+    - name: Configure docker for gcloud
+      run: |
+        gcloud auth configure-docker --quiet
+
+    - name: Build and push
+      uses: docker/build-push-action@v2
+      with:
+        context: .
+        file: ./packages/slackbot-proxy/docker/Dockerfile
+        platforms: linux/amd64
+        push: true
+        tags: |
+          weseek/growi-slackbot-proxy:latest
+          weseek/growi-slackbot-proxy:${{ env.RELEASE_VERSION }}
+          ghcr.io/weseek/growi-slackbot-proxy:latest
+          ghcr.io/weseek/growi-slackbot-proxy:${{ env.RELEASE_VERSION }}
+          asia.gcr.io/${{ secrets.GCP_PRJ_ID_SLACKBOT_PROXY }}/growi-slackbot-proxy:latest
+          asia.gcr.io/${{ secrets.GCP_PRJ_ID_SLACKBOT_PROXY }}/growi-slackbot-proxy:${{ env.RELEASE_VERSION }}
+
+    - name: Update Docker Hub Description
+      uses: peter-evans/dockerhub-description@v2
+      with:
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+        repository: weseek/growi-slackbot-proxy
+        readme-filepath: ./packages/slackbot-proxy/docker/README.md

+ 2 - 1
.github/workflows/release.yml

@@ -3,7 +3,8 @@ name: Release
 on:
 on:
   push:
   push:
     branches:
     branches:
-      - release/**
+      - release/current
+      - release/*.*.*
 
 
 jobs:
 jobs:
   github-release:
   github-release:

+ 2 - 6
.vscode/settings.json

@@ -1,10 +1,7 @@
 {
 {
-  // 既定の改行文字。LF の場合には \n を CRLF の場合には \r\n を使用してください。
   "files.eol": "\n",
   "files.eol": "\n",
-  // 指定した構文に対してプロファイルを定義するか、特定の規則がある独自のプロファイルをご使用ください。
-  "emmet.syntaxProfiles": {
-    "javascript": "jsx"
-  },
+
+  "eslint.workingDirectories": [{ "mode": "auto" }],
 
 
   // use stylelint-plus
   // use stylelint-plus
   // see https://qiita.com/y-w/items/bd7f11013fe34b69f0df#vs-code%E3%81%A8%E7%B5%84%E3%81%BF%E5%90%88%E3%82%8F%E3%81%9B%E3%82%8B
   // see https://qiita.com/y-w/items/bd7f11013fe34b69f0df#vs-code%E3%81%A8%E7%B5%84%E3%81%BF%E5%90%88%E3%82%8F%E3%81%9B%E3%82%8B
@@ -16,7 +13,6 @@
   "[scss]": {
   "[scss]": {
     "editor.formatOnSave": true
     "editor.formatOnSave": true
   },
   },
-  "stylelint.autoFixOnSave": true,
 
 
   // for vscode-eslint
   // for vscode-eslint
   "[javascript]": {
   "[javascript]": {

+ 25 - 5
CHANGES.md

@@ -1,7 +1,27 @@
 # CHANGES
 # CHANGES
 
 
-## v4.2.21-RC
+## v4.3.0-RC
 
 
+* Support: Upgrade libs
+    * striptags
+
+### BREAKING CHANGES
+
+None.
+
+Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/43x.html>
+
+### Updates
+
+* Feature: New Slack Integration with Slack Bot
+    * Searching GROWI pages from Slack
+    * Creating GROWI pages from Slack
+        * Easy record conversations
+
+## v4.2.21
+
+* Improvement: Headers style on built-in editor
+* Improvement: Codemirror is now scrollable one editor height of empty space into view at the bottom of the editor
 * Improvement: Upgrade mongodb driver to fix [NODE-2784](https://jira.mongodb.org/browse/NODE-2784)
 * Improvement: Upgrade mongodb driver to fix [NODE-2784](https://jira.mongodb.org/browse/NODE-2784)
 * Support: Upgrade libs
 * Support: Upgrade libs
     * connect-mongo
     * connect-mongo
@@ -12,7 +32,8 @@
     * validator
     * validator
     * ws
     * ws
     * nodemailer
     * nodemailer
-    * striptags
+    * i18next-express-middleware
+    * growi-commons
 
 
 ## v4.2.20
 ## v4.2.20
 
 
@@ -40,14 +61,14 @@
 * Fix: Global notification to Slack does not encode spaces of page path
 * Fix: Global notification to Slack does not encode spaces of page path
 * Support: Upgrade libs
 * Support: Upgrade libs
     * @google-cloud/storage
     * @google-cloud/storage
-    * @slack/web-api
-    * @slack/webhook
 
 
 ## v4.2.17
 ## v4.2.17
 
 
 * Improvement: Invoke garbage collection when reindex all pages by elasticsearch
 * Improvement: Invoke garbage collection when reindex all pages by elasticsearch
 * Improvement: Hide Sidebar at shared pages
 * Improvement: Hide Sidebar at shared pages
 * Fix: No unsaved alert is displayed without difference the latest markdown and editor value
 * Fix: No unsaved alert is displayed without difference the latest markdown and editor value
+* Support: Update libs
+    * eslint-config-weseek
 
 
 ## v4.2.16
 ## v4.2.16
 
 
@@ -65,7 +86,6 @@
     * reactstrap
     * reactstrap
 
 
 
 
-
 ## v4.2.14
 ## v4.2.14
 
 
 * Feature: Add an option to restrict publishing email property for new users
 * Feature: Add an option to restrict publishing email property for new users

+ 2 - 2
bin/github-actions/update-readme.sh

@@ -2,5 +2,5 @@
 
 
 cd docker
 cd docker
 
 
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.2\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.2-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.3\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.3-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md

+ 4 - 0
config/env.dev.js

@@ -22,6 +22,10 @@ module.exports = {
   // DEV_HTTPS: true,
   // DEV_HTTPS: true,
   // FORCE_WIKI_MODE: 'private', // 'public', 'private', undefined
   // FORCE_WIKI_MODE: 'private', // 'public', 'private', undefined
   // PROMSTER_ENABLED: true,
   // PROMSTER_ENABLED: true,
+  // SLACK_SIGNING_SECRET: '',
+  // SLACK_BOT_TOKEN: '',
+  SALT_FOR_GTOP_TOKEN: 'proxy',
+  SALT_FOR_PTOG_TOKEN: 'growi',
   // GROWI_CLOUD_URI: 'http://growi.cloud',
   // GROWI_CLOUD_URI: 'http://growi.cloud',
   // GROWI_APP_ID_FOR_GROWI_CLOUD: '012345',
   // GROWI_APP_ID_FOR_GROWI_CLOUD: '012345',
 };
 };

+ 77 - 27
docker/Dockerfile

@@ -13,16 +13,20 @@ LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 ENV appDir /opt/growi
 ENV appDir /opt/growi
 
 
 WORKDIR ${appDir}
 WORKDIR ${appDir}
-COPY ./package.json ./
-COPY ./yarn.lock ./
+COPY ./package.json .
+COPY ./yarn.lock .
+COPY ./lerna.json .
+COPY ./packages/app/package.json packages/app/
+COPY ./packages/slack/package.json packages/slack/
 
 
 # setup
 # setup
 RUN yarn config set network-timeout 300000
 RUN yarn config set network-timeout 300000
-RUN yarn
-# install official plugins
-RUN yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
-# install peerDependencies
-RUN yarn add -D react-images@1.0.0 react-motion
+RUN npx lerna bootstrap
+
+# make artifacts
+RUN tar cf node_modules.tar node_modules \
+  packages/app/node_modules \
+  packages/slack/node_modules
 
 
 
 
 
 
@@ -34,24 +38,29 @@ FROM deps-resolver AS deps-resolver-prod
 # shrink dependencies for production
 # shrink dependencies for production
 RUN yarn install --production
 RUN yarn install --production
 
 
+# make artifacts
+RUN tar cf node_modules.tar node_modules \
+  packages/app/node_modules \
+  packages/slack/node_modules
+
 
 
 
 
 ##
 ##
 ## prebuilder-default
 ## prebuilder-default
 ##
 ##
 FROM node:14-slim AS prebuilder-default
 FROM node:14-slim AS prebuilder-default
-LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 
 ENV appDir /opt/growi
 ENV appDir /opt/growi
 
 
-COPY --from=deps-resolver ${appDir}/node_modules ${appDir}/node_modules
+WORKDIR ${appDir}
 
 
-# copy all files except the files listed in Dockerfile.dockerignore
-COPY . ${appDir}
+# copy dependent packages
+COPY --from=deps-resolver \
+  ${appDir}/node_modules.tar ${appDir}/
 
 
-# overwirte package.json and yarn.lock
-COPY --from=deps-resolver ${appDir}/package.json ${appDir}/package.json
-COPY --from=deps-resolver ${appDir}/yarn.lock ${appDir}/yarn.lock
+# extract node_modules.tar
+RUN tar xf node_modules.tar
+RUN rm node_modules.tar
 
 
 
 
 
 
@@ -68,45 +77,86 @@ COPY docker/nocdn/env.prod.js ${appDir}/config/
 ##
 ##
 ## builder
 ## builder
 ##
 ##
+# FROM prebuilder-${flavor}
 FROM prebuilder-${flavor} AS builder
 FROM prebuilder-${flavor} AS builder
 
 
 ENV appDir /opt/growi
 ENV appDir /opt/growi
 
 
 WORKDIR ${appDir}
 WORKDIR ${appDir}
 
 
+COPY ./package.json ./
+COPY ./lerna.json ./
+COPY ./tsconfig.base.json ./
+COPY ./babel.config.js ./
+COPY ./bin ./bin
+COPY ./config ./config
+COPY ./public ./public
+COPY ./resource ./resource
+COPY ./src ./src
+COPY ./tmp ./tmp
+# copy all related packages
+COPY packages/slack packages/slack
+COPY packages/app packages/app
+
 # build
 # build
-RUN yarn build:prod
+RUN yarn lerna run build
+
+# make artifacts
+RUN tar cf packages.tar \
+  package.json \
+  config \
+  public \
+  resource \
+  src \
+  tmp \
+  packages/slack/package.json \
+  packages/slack/dist
 
 
-# remove except artifacts
-WORKDIR /tmp
-RUN --mount=target=. sh docker/bin/remove-except-artifacts.sh
-WORKDIR ${appDir}
 
 
 
 
 
 
 ##
 ##
 ## release
 ## release
 ##
 ##
-FROM node:14-alpine
+FROM node:14-slim
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 
 ENV appDir /opt/growi
 ENV appDir /opt/growi
 
 
-# install tini
-RUN apk add tini su-exec
+# Add gosu
+# see: https://github.com/tianon/gosu/blob/1.13/INSTALL.md
+RUN set -eux; \
+	apt-get update; \
+	apt-get install -y gosu; \
+	rm -rf /var/lib/apt/lists/*; \
+# verify that the binary works
+	gosu nobody true
 
 
-COPY docker/docker-entrypoint.sh /
-RUN chmod 700 /docker-entrypoint.sh
+# Add Tini
+ENV TINI_VERSION v0.19.0
+ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
+RUN chmod +x /tini
 
 
 COPY --from=deps-resolver-prod --chown=node:node \
 COPY --from=deps-resolver-prod --chown=node:node \
-  ${appDir}/node_modules ${appDir}/node_modules
+  ${appDir}/node_modules.tar ${appDir}/
 COPY --from=builder --chown=node:node \
 COPY --from=builder --chown=node:node \
-  ${appDir} ${appDir}
+  ${appDir}/packages.tar ${appDir}/
 
 
+# extract artifacts as 'node' user
+USER node
 WORKDIR ${appDir}
 WORKDIR ${appDir}
+RUN tar xf node_modules.tar
+RUN tar xf packages.tar
+RUN rm node_modules.tar packages.tar
+
+USER root
+
+COPY docker/docker-entrypoint.sh /
+RUN chmod 700 /docker-entrypoint.sh
+RUN chown node:node ${appDir}
 
 
 VOLUME /data
 VOLUME /data
 EXPOSE 3000
 EXPOSE 3000
 
 
-ENTRYPOINT ["/sbin/tini", "-e", "143", "--", "/docker-entrypoint.sh"]
+ENTRYPOINT ["/tini", "-e", "143", "--", "/docker-entrypoint.sh"]
 CMD ["yarn", "server:prod"]
 CMD ["yarn", "server:prod"]

+ 0 - 13
docker/Dockerfile.dockerignore

@@ -1,13 +0,0 @@
-.git
-.github
-.vscode
-node_modules
-src/linter-checker
-src/test
-.editorconfig
-.eslint*
-.gitignore
-.prettier*
-.stylelint*
-app.json
-Procfile

+ 4 - 6
docker/README.md

@@ -10,12 +10,10 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`4.2.0`, `4.2`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.2.0/docker/Dockerfile)
-* [`4.2.0-nocdn`, `4.2-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.2.0/docker/Dockerfile)
-* [`4.1.10`, `4.1` (Dockerfile)](https://github.com/weseek/growi/blob/v4.1.10/docker/Dockerfile)
-* [`4.1.10-nocdn`, `4.1-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.1.10/docker/Dockerfile)
-* [`3.8.0`, `3.8`, `3` (Dockerfile)](https://github.com/weseek/growi/blob/v3.8.0/docker/Dockerfile)
-* [`3.8.0-nocdn`, `3.8-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v3.8.0/docker/Dockerfile)
+* [`4.3.0`, `4.3`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.0/docker/Dockerfile)
+* [`4.3.0-nocdn`, `4.3-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.0/docker/Dockerfile)
+* [`4.2.0`, `4.2` (Dockerfile)](https://github.com/weseek/growi/blob/v4.2.0/docker/Dockerfile)
+* [`4.2.0-nocdn`, `4.2-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.2.0/docker/Dockerfile)
 
 
 
 
 What is GROWI?
 What is GROWI?

+ 0 - 7
docker/bin/remove-except-artifacts.sh

@@ -1,7 +0,0 @@
-#!/bin/sh
-
-set -e
-
-rm -rf \
-  ${appDir}/docker \
-  ${appDir}/node_modules \

+ 1 - 1
docker/docker-entrypoint.sh

@@ -11,4 +11,4 @@ fi
 chown -R node:node /data/uploads
 chown -R node:node /data/uploads
 chown -h node:node $appDir/public/uploads
 chown -h node:node $appDir/public/uploads
 
 
-su-exec node $@
+gosu node $@

+ 8 - 0
lerna.json

@@ -0,0 +1,8 @@
+{
+  "npmClient": "yarn",
+  "useWorkspaces": true,
+  "packages": [
+    "packages/*"
+  ],
+  "version": "independent"
+}

+ 12 - 202
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "4.2.21-RC",
+  "version": "4.3.0-RC",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -19,6 +19,13 @@
   "bugs": {
   "bugs": {
     "url": "https://github.com/weseek/growi/issues"
     "url": "https://github.com/weseek/growi/issues"
   },
   },
+  "private": true,
+  "workspaces": {
+    "packages": [
+      "packages/*"
+    ],
+    "nohoist": []
+  },
   "scripts": {
   "scripts": {
     "build:api:jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
     "build:api:jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
     "build:apiv3:jsdoc": "cross-env API_VERSION=3 npm run build:api:jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "build:apiv3:jsdoc": "cross-env API_VERSION=3 npm run build:api:jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
@@ -28,7 +35,8 @@
     "build:dev:app": "env-cmd -f config/env.dev.js webpack --config config/webpack.dev.js --progress",
     "build:dev:app": "env-cmd -f config/env.dev.js webpack --config config/webpack.dev.js --progress",
     "build:dev:watch": "npm run build:dev:app:watch",
     "build:dev:watch": "npm run build:dev:app:watch",
     "build:dev:watch:poll": "npm run build:dev:app:watch:poll",
     "build:dev:watch:poll": "npm run build:dev:app:watch:poll",
-    "build:dev": "npm run build:dev:app",
+    "build:dev": "yarn build:dev:app",
+    "build:slack": "lerna run build --scope @growi/slack",
     "build:prod:analyze": "cross-env ANALYZE=1 npm run build:prod",
     "build:prod:analyze": "cross-env ANALYZE=1 npm run build:prod",
     "build:prod": "env-cmd -f config/env.prod.js webpack --config config/webpack.prod.js --profile --bail",
     "build:prod": "env-cmd -f config/env.prod.js webpack --config config/webpack.prod.js --profile --bail",
     "build": "npm run build:dev:watch",
     "build": "npm run build:dev:watch",
@@ -51,7 +59,7 @@
     "migrate:down": "migrate-mongo down -f config/migrate.js",
     "migrate:down": "migrate-mongo down -f config/migrate.js",
     "plugin:def": "node bin/generate-plugin-definitions-source.js",
     "plugin:def": "node bin/generate-plugin-definitions-source.js",
     "prebuild:dev:watch": "npm run prebuild:dev",
     "prebuild:dev:watch": "npm run prebuild:dev",
-    "prebuild:dev": "npm run clean:app && env-cmd -f config/env.dev.js npm run plugin:def && env-cmd -f config/env.dev.js npm run resource",
+    "prebuild:dev": "npm run clean:app && env-cmd -f config/env.dev.js npm run plugin:def && env-cmd -f config/env.dev.js npm run resource && yarn build:slack",
     "prebuild:prod": "npm run clean && env-cmd -f config/env.prod.js npm run plugin:def && env-cmd -f config/env.prod.js npm run resource",
     "prebuild:prod": "npm run clean && env-cmd -f config/env.prod.js npm run plugin:def && env-cmd -f config/env.prod.js npm run resource",
     "prelint:swagger2openapi": "npm run build:apiv3:jsdoc",
     "prelint:swagger2openapi": "npm run build:apiv3:jsdoc",
     "preserver:prod": "npm run migrate",
     "preserver:prod": "npm run migrate",
@@ -71,207 +79,9 @@
     "webpack": "webpack"
     "webpack": "webpack"
   },
   },
   "dependencies": {
   "dependencies": {
-    "//": [
-      "check-node-version: see https://github.com/parshap/check-node-version/issues/35",
-      "openid-client: Node.js 12 or higher is required for openid-client@3 and above.",
-      "string-width: 5.0.0 or above uses ESM."
-    ],
-    "@google-cloud/storage": "^5.8.5",
-    "@kobalab/socket.io-session": "^1.0.3",
-    "@promster/express": "^5.0.1",
-    "@promster/server": "^6.0.0",
-    "@slack/web-api": "^6.2.3",
-    "@slack/webhook": "^6.0.0",
-    "JSONStream": "^1.3.5",
-    "archiver": "^5.3.0",
-    "array.prototype.flatmap": "^1.2.2",
-    "async-canvas-to-blob": "^1.0.3",
-    "aws-sdk": "^2.88.0",
-    "axios": "^0.21.1",
-    "body-parser": "^1.18.2",
-    "bunyan": "^1.8.15",
-    "bunyan-format": "^0.2.1",
-    "check-node-version": "^4.0.2",
-    "connect-flash": "~0.1.1",
-    "connect-mongo": "^4.4.1",
-    "connect-redis": "^4.0.4",
-    "cookie-parser": "^1.4.3",
-    "cross-env": "^7.0.0",
-    "csrf": "^3.1.0",
-    "date-fns": "^2.0.0",
-    "detect-indent": "^6.0.0",
-    "diff": "^5.0.0",
-    "elasticsearch": "^16.0.0",
-    "entities": "^2.0.0",
-    "env-cmd": "^10.0.1",
-    "esa-nodejs": "^0.0.7",
-    "escape-string-regexp": "^2.0.0",
-    "express": "^4.16.1",
-    "express-bunyan-logger": "^1.3.3",
-    "express-form": "~0.12.0",
-    "express-mongo-sanitize": "^2.1.0",
-    "express-session": "^1.16.1",
-    "express-validator": "^6.1.1",
-    "express-webpack-assets": "^0.1.0",
-    "graceful-fs": "^4.1.11",
-    "growi-commons": "^5.0.3",
-    "helmet": "^3.13.0",
-    "i18next": "^20.3.2",
-    "i18next-express-middleware": "^1.4.1",
-    "i18next-node-fs-backend": "^2.1.0",
-    "i18next-sprintf-postprocessor": "^0.2.2",
-    "is-iso-date": "^0.0.1",
-    "lucene-query-parser": "^1.2.0",
-    "md5": "^2.2.1",
-    "method-override": "^3.0.0",
-    "migrate-mongo": "^8.2.2",
-    "mkdirp": "^1.0.3",
-    "module-alias": "^2.0.6",
-    "mongoose": "5.12.13",
-    "mongoose-gridfs": "^1.2.42",
-    "mongoose-paginate-v2": "^1.3.9",
-    "mongoose-unique-validator": "^2.0.3",
-    "multer": "~1.4.0",
-    "multer-autoreap": "^1.0.3",
-    "nodemailer": "^6.6.2",
-    "nodemailer-ses-transport": "~1.5.0",
-    "npm-run-all": "^4.1.2",
-    "openid-client": "=2.5.0",
-    "package-installed-version-sync": "^2.1.0",
-    "passport": "^0.4.0",
-    "passport-github": "^1.1.0",
-    "passport-google-oauth20": "^2.0.0",
-    "passport-http": "^0.3.0",
-    "passport-ldapauth": "^2.0.0",
-    "passport-local": "^1.0.0",
-    "passport-saml": "^1.0.0",
-    "passport-twitter": "^1.0.4",
-    "prom-client": "^13.0.0",
-    "react-card-flip": "^1.0.10",
-    "react-image-crop": "^8.3.0",
-    "reconnecting-websocket": "^4.4.0",
-    "redis": "^3.0.2",
-    "rimraf": "^3.0.0",
-    "socket.io": "^2.3.0",
-    "stream-to-promise": "^3.0.0",
-    "string-width": "=4.2.2",
-    "swig-templates": "^2.0.2",
-    "uglifycss": "^0.0.29",
-    "universal-bunyan": "^0.9.2",
-    "unzipper": "^0.10.5",
-    "url-join": "^4.0.0",
-    "validator": "^13.6.0",
-    "ws": "^7.4.6",
-    "xss": "^1.0.6"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "//": [
-      "@handsontable/react: v3 requires handsontable >= 7.0.0.",
-      "handsontable: v7.0.0 or above is no loger MIT lisence."
-    ],
-    "@alienfast/i18next-loader": "^1.0.16",
-    "@atlaskit/drawer": "^5.3.7",
-    "@atlaskit/navigation-next": "^8.0.5",
-    "@babel/core": "^7.4.5",
-    "@babel/plugin-proposal-class-properties": "^7.8.3",
-    "@babel/plugin-proposal-optional-chaining": "^7.9.0",
-    "@babel/polyfill": "^7.4.4",
-    "@babel/preset-env": "^7.4.5",
-    "@babel/preset-react": "^7.0.0",
-    "@browser-bunyan/console-formatted-stream": "^1.6.2",
-    "@handsontable/react": "=2.1.0",
-    "autoprefixer": "^9.0.0",
-    "babel-eslint": "^10.0.1",
-    "babel-loader": "^8.0.6",
-    "babel-plugin-lodash": "^3.3.4",
-    "babel-plugin-transform-imports": "^2.0.0",
-    "bootstrap": "^4.5.0",
-    "browser-bunyan": "^1.6.3",
-    "browser-sync": "^2.26.3",
-    "bunyan-debug": "^2.0.0",
-    "cli": "~1.0.1",
-    "codemirror": "^5.48.4",
-    "colors": "^1.2.5",
-    "connect-browser-sync": "^2.1.0",
-    "core-js": "=2.6.9",
-    "css-loader": "^3.0.0",
-    "csv-to-markdown-table": "^1.0.1",
-    "diff2html": "^3.1.2",
-    "eazy-logger": "^3.0.2",
-    "eslint": "^6.0.1",
-    "eslint-config-weseek": "^1.0.4",
-    "eslint-plugin-import": "^2.18.0",
-    "eslint-plugin-jest": "^23.0.3",
-    "eslint-plugin-react": "^7.14.2",
-    "eslint-plugin-react-hooks": "^4.0.4",
-    "file-loader": "^5.0.2",
-    "handsontable": "=6.2.2",
-    "hard-source-webpack-plugin": "^0.13.1",
-    "i18next-browser-languagedetector": "^4.0.1",
-    "imports-loader": "^0.8.0",
-    "jest": "^25.1.0",
-    "jest-date-mock": "^1.0.8",
-    "jquery-slimscroll": "^1.3.8",
-    "jquery-ui": "^1.12.1",
-    "jquery.cookie": "~1.4.1",
-    "load-css-file": "^1.0.0",
-    "lodash-webpack-plugin": "^0.11.5",
-    "markdown-it": "^10.0.0",
-    "markdown-it-blockdiag": "^1.1.1",
-    "markdown-it-drawio-viewer": "^1.3.1",
-    "markdown-it-emoji": "^1.4.0",
-    "markdown-it-footnote": "^3.0.1",
-    "markdown-it-mathjax": "^2.0.0",
-    "markdown-it-named-headers": "^0.0.4",
-    "markdown-it-plantuml": "^1.3.0",
-    "markdown-it-task-checkbox": "^1.0.6",
-    "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
-    "markdown-table": "^1.1.1",
-    "mini-css-extract-plugin": "^0.9.0",
-    "morgan": "^1.9.0",
-    "node-dev": "^4.0.0",
-    "node-sass": "^4.14.1",
-    "normalize-path": "^3.0.0",
-    "null-loader": "^3.0.0",
-    "on-headers": "^1.0.1",
-    "optimize-css-assets-webpack-plugin": "^5.0.3",
-    "penpal": "^4.0.0",
-    "plantuml-encoder": "^1.2.5",
-    "postcss-loader": "^3.0.0",
-    "prettier": "^1.19.1",
-    "react": "^16.8.3",
-    "react-bootstrap-typeahead": "^3.4.7",
-    "react-codemirror2": "^6.0.0",
-    "react-copy-to-clipboard": "^5.0.1",
-    "react-dom": "^16.8.3",
-    "react-dropzone": "^11.2.4",
-    "react-frame-component": "^4.0.0",
-    "react-hotkeys": "^2.0.0",
-    "react-i18next": "^11.1.0",
-    "react-waypoint": "^9.0.0",
-    "reactstrap": "^8.9.0",
-    "replacestream": "^4.0.3",
-    "reveal.js": "^3.5.0",
-    "rs-i18n": "^0.0.9",
-    "sass-loader": "^8.0.0",
-    "simple-load-script": "^1.0.2",
-    "socket.io-client": "^2.3.0",
-    "sticky-events": "^3.1.3",
-    "style-loader": "^1.0.0",
-    "styled-components": "^5.0.1",
-    "stylelint": "^13.2.0",
-    "stylelint-config-recess-order": "^2.0.1",
-    "swagger-jsdoc": "^3.4.0",
-    "swagger2openapi": "^5.3.1",
-    "terser-webpack-plugin": "^4.1.0",
-    "throttle-debounce": "^2.0.0",
-    "toastr": "^2.1.2",
-    "unstated": "^2.1.1",
-    "webpack": "^4.39.3",
-    "webpack-assets-manifest": "^3.1.1",
-    "webpack-bundle-analyzer": "^3.0.2",
-    "webpack-cli": "^3.3.7",
-    "webpack-merge": "^4.2.2"
+    "lerna": "^4.0.0"
   },
   },
   "_moduleAliases": {
   "_moduleAliases": {
     "@root": ".",
     "@root": ".",

+ 221 - 0
packages/app/package.json

@@ -0,0 +1,221 @@
+{
+  "name": "@growi/app",
+  "version": "0.9.0-RC",
+  "license": "MIT",
+  "scripts": {
+    "build": "cd ../../ && env-cmd -f config/env.prod.js webpack --config config/webpack.prod.js --profile --bail"
+  },
+  "// comments for dependencies": {
+    "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",
+    "string-width": "5.0.0 or above uses ESM."
+  },
+  "dependencies": {
+    "@google-cloud/storage": "^5.8.5",
+    "@growi/slack": "^0.9.0-RC",
+    "@kobalab/socket.io-session": "^1.0.3",
+    "@promster/express": "^5.0.1",
+    "@promster/server": "^6.0.0",
+    "@slack/events-api": "^3.0.0",
+    "@slack/web-api": "^6.2.3",
+    "@slack/webhook": "^6.0.0",
+    "JSONStream": "^1.3.5",
+    "archiver": "^5.3.0",
+    "array.prototype.flatmap": "^1.2.2",
+    "async-canvas-to-blob": "^1.0.3",
+    "aws-sdk": "^2.88.0",
+    "axios": "^0.21.1",
+    "body-parser": "^1.18.2",
+    "browser-bunyan": "^1.6.3",
+    "bunyan": "^1.8.15",
+    "bunyan-format": "^0.2.1",
+    "check-node-version": "^4.1.0",
+    "connect-flash": "~0.1.1",
+    "connect-mongo": "^4.4.1",
+    "connect-redis": "^4.0.4",
+    "cookie-parser": "^1.4.5",
+    "cross-env": "^7.0.0",
+    "csrf": "^3.1.0",
+    "date-fns": "^2.0.0",
+    "detect-indent": "^6.0.0",
+    "diff": "^5.0.0",
+    "elasticsearch": "^16.0.0",
+    "entities": "^2.0.0",
+    "env-cmd": "^10.0.1",
+    "esa-nodejs": "^0.0.7",
+    "escape-string-regexp": "^2.0.0",
+    "express": "^4.16.1",
+    "express-bunyan-logger": "^1.3.3",
+    "express-form": "~0.12.0",
+    "express-mongo-sanitize": "^2.1.0",
+    "express-session": "^1.16.1",
+    "express-validator": "^6.1.1",
+    "express-webpack-assets": "^0.1.0",
+    "graceful-fs": "^4.1.11",
+    "growi-commons": "^5.0.4",
+    "growi-plugin-attachment-refs": "^2.0.1",
+    "growi-plugin-lsx": "^4.0.2",
+    "growi-plugin-pukiwiki-like-linker": "^3.1.0",
+    "helmet": "^3.13.0",
+    "i18next": "^20.3.2",
+    "i18next-express-middleware": "^2.0.0",
+    "i18next-node-fs-backend": "^2.1.0",
+    "i18next-sprintf-postprocessor": "^0.2.2",
+    "is-iso-date": "^0.0.1",
+    "lucene-query-parser": "^1.2.0",
+    "md5": "^2.2.1",
+    "method-override": "^3.0.0",
+    "migrate-mongo": "^8.2.2",
+    "mkdirp": "^1.0.3",
+    "module-alias": "^2.0.6",
+    "mongoose": "5.12.13",
+    "mongoose-gridfs": "^1.2.42",
+    "mongoose-paginate-v2": "^1.3.9",
+    "mongoose-unique-validator": "^2.0.3",
+    "multer": "~1.4.0",
+    "multer-autoreap": "^1.0.3",
+    "nodemailer": "^6.6.2",
+    "nodemailer-ses-transport": "~1.5.0",
+    "npm-run-all": "^4.1.2",
+    "openid-client": "=2.5.0",
+    "package-installed-version-sync": "^2.1.0",
+    "passport": "^0.4.0",
+    "passport-github": "^1.1.0",
+    "passport-google-oauth20": "^2.0.0",
+    "passport-http": "^0.3.0",
+    "passport-ldapauth": "^2.0.0",
+    "passport-local": "^1.0.0",
+    "passport-saml": "^1.0.0",
+    "passport-twitter": "^1.0.4",
+    "prom-client": "^13.0.0",
+    "react-card-flip": "^1.0.10",
+    "react-image-crop": "^8.3.0",
+    "reconnecting-websocket": "^4.4.0",
+    "redis": "^3.0.2",
+    "rimraf": "^3.0.0",
+    "socket.io": "^2.3.0",
+    "stream-to-promise": "^3.0.0",
+    "string-width": "=4.2.2",
+    "swig-templates": "^2.0.2",
+    "uglifycss": "^0.0.29",
+    "universal-bunyan": "^0.9.2",
+    "unzipper": "^0.10.5",
+    "url-join": "^4.0.0",
+    "validator": "^13.6.0",
+    "ws": "^7.4.6",
+    "xss": "^1.0.6"
+  },
+  "// comments for defDependencies": {
+    "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
+    "handsontable": "v7.0.0 or above is no loger MIT lisence."
+  },
+  "devDependencies": {
+    "@alienfast/i18next-loader": "^1.0.16",
+    "@atlaskit/drawer": "^5.3.7",
+    "@atlaskit/navigation-next": "^8.0.5",
+    "@babel/core": "^7.4.5",
+    "@babel/plugin-proposal-class-properties": "^7.8.3",
+    "@babel/plugin-proposal-optional-chaining": "^7.9.0",
+    "@babel/polyfill": "^7.4.4",
+    "@babel/preset-env": "^7.4.5",
+    "@babel/preset-react": "^7.0.0",
+    "@handsontable/react": "=2.1.0",
+    "@types/compression": "^1.7.0",
+    "@types/express": "^4.17.11",
+    "@types/multer": "^1.4.5",
+    "@types/node": "^14.14.35",
+    "autoprefixer": "^9.0.0",
+    "babel-eslint": "^10.0.1",
+    "babel-loader": "^8.0.6",
+    "babel-plugin-lodash": "^3.3.4",
+    "babel-plugin-transform-imports": "^2.0.0",
+    "bootstrap": "^4.5.0",
+    "browser-bunyan": "^1.6.3",
+    "browser-sync": "^2.26.3",
+    "bunyan-debug": "^2.0.0",
+    "cli": "~1.0.1",
+    "codemirror": "^5.48.4",
+    "colors": "^1.2.5",
+    "connect-browser-sync": "^2.1.0",
+    "core-js": "=2.6.9",
+    "css-loader": "^3.0.0",
+    "csv-to-markdown-table": "^1.0.1",
+    "diff2html": "^3.1.2",
+    "eazy-logger": "^3.0.2",
+    "eslint": "^6.0.1",
+    "eslint-config-weseek": "^1.0.8",
+    "eslint-plugin-import": "^2.18.0",
+    "eslint-plugin-jest": "^23.0.3",
+    "eslint-plugin-react": "^7.14.2",
+    "eslint-plugin-react-hooks": "^4.0.4",
+    "file-loader": "^5.0.2",
+    "handsontable": "=6.2.2",
+    "hard-source-webpack-plugin": "^0.13.1",
+    "i18next-browser-languagedetector": "^4.0.1",
+    "imports-loader": "^0.8.0",
+    "jest": "^25.1.0",
+    "jest-date-mock": "^1.0.8",
+    "jquery-slimscroll": "^1.3.8",
+    "jquery-ui": "^1.12.1",
+    "jquery.cookie": "~1.4.1",
+    "load-css-file": "^1.0.0",
+    "lodash-webpack-plugin": "^0.11.5",
+    "markdown-it": "^10.0.0",
+    "markdown-it-blockdiag": "^1.1.1",
+    "markdown-it-drawio-viewer": "^1.3.1",
+    "markdown-it-emoji": "^1.4.0",
+    "markdown-it-footnote": "^3.0.1",
+    "markdown-it-mathjax": "^2.0.0",
+    "markdown-it-named-headers": "^0.0.4",
+    "markdown-it-plantuml": "^1.3.0",
+    "markdown-it-task-checkbox": "^1.0.6",
+    "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
+    "markdown-table": "^1.1.1",
+    "mini-css-extract-plugin": "^0.9.0",
+    "morgan": "^1.9.0",
+    "node-dev": "^4.0.0",
+    "node-sass": "^4.14.1",
+    "normalize-path": "^3.0.0",
+    "null-loader": "^3.0.0",
+    "on-headers": "^1.0.1",
+    "optimize-css-assets-webpack-plugin": "^5.0.3",
+    "penpal": "^4.0.0",
+    "plantuml-encoder": "^1.2.5",
+    "postcss-loader": "^3.0.0",
+    "prettier": "^1.19.1",
+    "react": "^16.8.3",
+    "react-bootstrap-typeahead": "^3.4.7",
+    "react-codemirror2": "^6.0.0",
+    "react-copy-to-clipboard": "^5.0.1",
+    "react-dom": "^16.8.3",
+    "react-dropzone": "^11.2.4",
+    "react-frame-component": "^4.0.0",
+    "react-hotkeys": "^2.0.0",
+    "react-i18next": "^11.1.0",
+    "react-images": "1.0.0",
+    "react-motion": "^0.5.2",
+    "react-waypoint": "^9.0.0",
+    "reactstrap": "^8.9.0",
+    "replacestream": "^4.0.3",
+    "reveal.js": "^3.5.0",
+    "rs-i18n": "^0.0.9",
+    "sass-loader": "^8.0.0",
+    "simple-load-script": "^1.0.2",
+    "socket.io-client": "^2.3.0",
+    "sticky-events": "^3.1.3",
+    "style-loader": "^1.0.0",
+    "styled-components": "^5.0.1",
+    "stylelint": "^13.2.0",
+    "stylelint-config-recess-order": "^2.0.1",
+    "swagger-jsdoc": "^3.4.0",
+    "swagger2openapi": "^5.3.1",
+    "terser-webpack-plugin": "^4.1.0",
+    "throttle-debounce": "^2.0.0",
+    "toastr": "^2.1.2",
+    "unstated": "^2.1.1",
+    "webpack": "^4.39.3",
+    "webpack-assets-manifest": "^3.1.1",
+    "webpack-bundle-analyzer": "^3.0.2",
+    "webpack-cli": "^3.3.7",
+    "webpack-merge": "^4.2.2"
+  }
+}

+ 2 - 0
packages/slack/.eslintignore

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

+ 31 - 0
packages/slack/.eslintrc.js

@@ -0,0 +1,31 @@
+module.exports = {
+  parser: '@typescript-eslint/parser',
+  extends: [
+    'weseek',
+    'weseek/typescript',
+    'plugin:jest/recommended',
+  ],
+  env: {
+    jquery: true,
+    'jest/globals': true,
+  },
+  globals: {
+  },
+  plugins: [
+    'jest',
+  ],
+  rules: {
+    'import/prefer-default-export': 'off',
+    '@typescript-eslint/no-explicit-any': 'off',
+    indent: [
+      'error',
+      2,
+      {
+        SwitchCase: 1,
+        ArrayExpression: 'first',
+        FunctionDeclaration: { body: 1, parameters: 2 },
+        FunctionExpression: { body: 1, parameters: 2 },
+      },
+    ],
+  },
+};

+ 0 - 0
packages/slack/.gitignore


+ 61 - 0
packages/slack/jest.config.js

@@ -0,0 +1,61 @@
+// For a detailed explanation regarding each configuration property, visit:
+// https://jestjs.io/docs/en/configuration.html
+
+const MODULE_NAME_MAPPING = {
+  '^\\^/(.+)$': '<rootDir>/$1',
+  '^~/(.+)$': '<rootDir>/src/$1',
+};
+
+module.exports = {
+
+  preset: 'ts-jest/presets/js-with-ts',
+
+  moduleNameMapper: MODULE_NAME_MAPPING,
+
+  // Automatically clear mock calls and instances between every test
+  clearMocks: true,
+
+  // Indicates whether the coverage information should be collected while executing the test
+  collectCoverage: true,
+
+  // An array of glob patterns indicating a set of files for which coverage information should be collected
+  // collectCoverageFrom: undefined,
+
+  // The directory where Jest should output its coverage files
+  coverageDirectory: 'coverage',
+
+  // An array of regexp pattern strings used to skip coverage collection
+  coveragePathIgnorePatterns: [
+    '/node_modules/',
+  ],
+
+  // An object that configures minimum threshold enforcement for coverage results
+  // TODO: activate -- 2020.03.24 Yuki Takei
+  // coverageThreshold: {
+  //   global: {
+  //     branches: 70,
+  //     functions: 70,
+  //     lines: 70,
+  //     statements: 70,
+  //   },
+  // },
+
+  // An array of file extensions your modules use
+  moduleFileExtensions: [
+    'js',
+    'json',
+    'jsx',
+    'ts',
+    'tsx',
+    'node',
+  ],
+
+  // The test environment that will be used for testing
+  testEnvironment: 'node',
+
+  // The glob patterns Jest uses to detect test files
+  testMatch: [
+    '**/src/**/__tests__/**/*.[jt]s?(x)',
+    '**/src/**/?(*.)+(spec|test).[tj]s?(x)',
+  ],
+};

+ 38 - 0
packages/slack/package.json

@@ -0,0 +1,38 @@
+{
+  "name": "@growi/slack",
+  "version": "0.9.0-RC",
+  "license": "MIT",
+  "main": "dist/index.js",
+  "typings": "dist/index.d.ts",
+  "scripts": {
+    "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
+    "tsc": "tsc -p tsconfig.build.json",
+    "tsc:w": "yarn tsc -w",
+    "test": "yarn test:lint && yarn test:coverage",
+    "test:unit": "cross-env NODE_ENV=test jest --passWithNoTests",
+    "test:coverage": "yarn test:unit",
+    "test:lint": "eslint src --ext .ts",
+    "test:lint:fix": "eslint src --ext .ts --fix"
+  },
+  "dependencies": {
+    "axios": "^0.21.1",
+    "browser-bunyan": "^1.6.3",
+    "bunyan": "^1.8.15",
+    "dotenv-flow": "^3.2.0",
+    "extensible-custom-error": "^0.0.7",
+    "universal-bunyan": "^0.9.2"
+  },
+  "devDependencies": {
+    "@types/express": "^4.17.11",
+    "@types/jest": "^26.0.22",
+    "@typescript-eslint/eslint-plugin": "^4.18.0",
+    "@typescript-eslint/parser": "^4.18.0",
+    "browser-bunyan": "^1.6.3",
+    "cross-env": "^7.0.0",
+    "eslint-import-resolver-typescript": "^2.4.0",
+    "eslint-plugin-jest": "^24.3.2",
+    "ts-jest": "^26.5.4",
+    "tsc-alias": "^1.2.9",
+    "typescript": "^4.2.3"
+  }
+}

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

@@ -0,0 +1,22 @@
+export const supportedSlackCommands: string[] = [
+  '/growi',
+];
+
+export const supportedGrowiCommands: string[] = [
+  'search',
+  'create',
+  'help',
+];
+
+export * from './interfaces/growi-command';
+export * from './interfaces/request-between-growi-and-proxy';
+export * from './interfaces/request-from-slack';
+export * from './models/errors';
+export * from './middlewares/verify-growi-to-slack-request';
+export * from './middlewares/verify-slack-request';
+export * from './utils/block-creater';
+export * from './utils/check-communicable';
+export * from './utils/post-ephemeral-errors';
+export * from './utils/reshape-contents-body';
+export * from './utils/slash-command-parser';
+export * from './utils/webclient-factory';

+ 4 - 0
packages/slack/src/interfaces/connection-status.ts

@@ -0,0 +1,4 @@
+export type ConnectionStatus = {
+  error?: Error,
+  workspaceName?: string,
+}

+ 5 - 0
packages/slack/src/interfaces/growi-command.ts

@@ -0,0 +1,5 @@
+export type GrowiCommand = {
+  text: string,
+  growiCommandType: string,
+  growiCommandArgs: string[],
+};

+ 17 - 0
packages/slack/src/interfaces/request-between-growi-and-proxy.ts

@@ -0,0 +1,17 @@
+import { Request } from 'express';
+
+export type RequestFromGrowi = Request & {
+  // appended by GROWI
+  headers:{'x-growi-gtop-tokens'?:string},
+
+  // will be extracted from header
+  tokenGtoPs: string[],
+};
+
+export type RequestFromProxy = Request & {
+  // appended by Proxy
+  headers:{'x-growi-ptog-token'?:string},
+
+  // will be extracted from header
+  tokenPtoG: string[],
+};

+ 9 - 0
packages/slack/src/interfaces/request-from-slack.ts

@@ -0,0 +1,9 @@
+import { Request } from 'express';
+
+export type RequestFromSlack = Request & {
+  // appended by slack
+  headers:{'x-slack-signature'?:string, 'x-slack-request-timestamp':number},
+
+  // appended by GROWI or slackbot-proxy
+  slackSigningSecret?:string,
+};

+ 30 - 0
packages/slack/src/middlewares/verify-growi-to-slack-request.ts

@@ -0,0 +1,30 @@
+import { Response, NextFunction } from 'express';
+
+import loggerFactory from '../utils/logger';
+import { RequestFromGrowi } from '../interfaces/request-between-growi-and-proxy';
+
+const logger = loggerFactory('@growi/slack:middlewares:verify-slack-request');
+
+/**
+ * Verify if the request came from slack
+ * See: https://api.slack.com/authentication/verifying-requests-from-slack
+ */
+export const verifyGrowiToSlackRequest = (req: RequestFromGrowi, res: Response, next: NextFunction): Record<string, any> | void => {
+  const str = req.headers['x-growi-gtop-tokens'];
+
+  if (str == null) {
+    const message = 'The value of header \'x-growi-gtop-tokens\' must not be empty.';
+    logger.warn(message, { body: req.body });
+    return res.status(400).send({ message });
+  }
+
+  const tokens = str.split(',').map(value => value.trim());
+  if (tokens.length === 0) {
+    const message = 'The value of header \'x-growi-gtop-tokens\' must include at least one or more tokens.';
+    logger.warn(message, { body: req.body });
+    return res.status(400).send({ message });
+  }
+
+  req.tokenGtoPs = tokens;
+  return next();
+};

+ 56 - 0
packages/slack/src/middlewares/verify-slack-request.ts

@@ -0,0 +1,56 @@
+import { createHmac, timingSafeEqual } from 'crypto';
+import { stringify } from 'qs';
+import { Response, NextFunction } from 'express';
+
+import loggerFactory from '../utils/logger';
+import { RequestFromSlack } from '../interfaces/request-from-slack';
+
+const logger = loggerFactory('@growi/slack:middlewares:verify-slack-request');
+
+/**
+ * Verify if the request came from slack
+ * See: https://api.slack.com/authentication/verifying-requests-from-slack
+ */
+export const verifySlackRequest = (req: RequestFromSlack, res: Response, next: NextFunction): Record<string, any> | void => {
+  const signingSecret = req.slackSigningSecret;
+
+  if (signingSecret == null) {
+    const message = 'No signing secret.';
+    logger.warn(message, { body: req.body });
+    return res.status(400).send({ message });
+  }
+
+  // take out slackSignature and timestamp from header
+  const slackSignature = req.headers['x-slack-signature'];
+  const timestamp = req.headers['x-slack-request-timestamp'];
+
+  if (slackSignature == null || timestamp == null) {
+    const message = 'Forbidden. Enter from Slack workspace';
+    logger.warn(message, { body: req.body });
+    return res.status(403).send({ message });
+  }
+
+  // protect against replay attacks
+  const time = Math.floor(new Date().getTime() / 1000);
+  if (Math.abs(time - timestamp) > 300) {
+    const message = 'Verification failed.';
+    logger.warn(message, { body: req.body });
+    return res.status(403).send({ message });
+  }
+
+  // generate growi signature
+  const sigBaseString = `v0:${timestamp}:${stringify(req.body, { format: 'RFC1738' })}`;
+  const hasher = createHmac('sha256', signingSecret);
+  hasher.update(sigBaseString, 'utf8');
+  const hashedSigningSecret = hasher.digest('hex');
+  const growiSignature = `v0=${hashedSigningSecret}`;
+
+  // compare growiSignature and slackSignature
+  if (timingSafeEqual(Buffer.from(growiSignature, 'utf8'), Buffer.from(slackSignature, 'utf8'))) {
+    return next();
+  }
+
+  const message = 'Verification failed.';
+  logger.warn(message, { body: req.body });
+  return res.status(403).send({ message });
+};

+ 3 - 0
packages/slack/src/models/errors.ts

@@ -0,0 +1,3 @@
+import ExtensibleCustomError from 'extensible-custom-error';
+
+export class InvalidGrowiCommandError extends ExtensibleCustomError {}

+ 31 - 0
packages/slack/src/utils/block-creater.ts

@@ -0,0 +1,31 @@
+import { SectionBlock, InputBlock } from '@slack/types';
+
+export const generateMarkdownSectionBlock = (blocks:string):SectionBlock => {
+  return {
+    type: 'section',
+    text: {
+      type: 'mrkdwn',
+      text: blocks,
+    },
+  };
+};
+
+export const generateInputSectionBlock = (blockId:string, labelText:string, actionId:string, isMultiline:boolean, placeholder:string):InputBlock => {
+  return {
+    type: 'input',
+    block_id: blockId,
+    label: {
+      type: 'plain_text',
+      text: labelText,
+    },
+    element: {
+      type: 'plain_text_input',
+      action_id: actionId,
+      multiline: isMultiline,
+      placeholder: {
+        type: 'plain_text',
+        text: placeholder,
+      },
+    },
+  };
+};

+ 126 - 0
packages/slack/src/utils/check-communicable.ts

@@ -0,0 +1,126 @@
+import axios, { AxiosError } from 'axios';
+
+import { WebClient } from '@slack/web-api';
+
+import { generateWebClient } from './webclient-factory';
+import { ConnectionStatus } from '../interfaces/connection-status';
+
+/**
+ * Check whether the HTTP server responds or not.
+ *
+ * @param serverUri Server URI to connect
+ * @returns AxiosError when error is occured
+ */
+export const connectToHttpServer = async(serverUri: string): Promise<void|AxiosError> => {
+  try {
+    await axios.get(serverUri, { maxRedirects: 0, timeout: 3000 });
+  }
+  catch (err) {
+    return err as AxiosError;
+  }
+};
+
+/**
+ * Check whether the Slack API server responds or not.
+ *
+ * @returns AxiosError when error is occured
+ */
+export const connectToSlackApiServer = async(): Promise<void|AxiosError> => {
+  return connectToHttpServer('https://slack.com/api/');
+};
+
+/**
+ * Test Slack API
+ * @param client
+ */
+const testSlackApiServer = async(client: WebClient): Promise<any> => {
+  const result = await client.api.test();
+
+  if (!result.ok) {
+    throw new Error(result.error);
+  }
+
+  return result;
+};
+
+const checkSlackScopes = (resultTestSlackApiServer: any) => {
+  const slackScopes = resultTestSlackApiServer.response_metadata.scopes;
+  const correctScopes = ['commands', 'team:read', 'chat:write'];
+  const isPassedScopeCheck = correctScopes.every(e => slackScopes.includes(e));
+
+  if (!isPassedScopeCheck) {
+    throw new Error('The scopes is not appropriate. Required scopes is [\'commands\', \'team:read\', \'chat:write\']');
+  }
+};
+
+/**
+ * Retrieve Slack workspace name
+ * @param client
+ */
+const retrieveWorkspaceName = async(client: WebClient): Promise<string> => {
+  const result = await client.team.info();
+
+  if (!result.ok) {
+    throw new Error(result.error);
+  }
+
+  return (result as any).team?.name;
+};
+
+/**
+ * @param token bot OAuth token
+ * @returns
+ */
+export const getConnectionStatus = async(token:string): Promise<ConnectionStatus> => {
+  const client = generateWebClient(token);
+  const status: ConnectionStatus = {};
+
+  try {
+    // try to connect
+    const resultTestSlackApiServer = await testSlackApiServer(client);
+    // check scope
+    await checkSlackScopes(resultTestSlackApiServer);
+    // retrieve workspace name
+    status.workspaceName = await retrieveWorkspaceName(client);
+  }
+  catch (err) {
+    status.error = err;
+  }
+
+  return status;
+};
+
+/**
+ * Get token string to ConnectionStatus map
+ * @param keys Array of bot OAuth token or specific key
+ * @param botTokenResolver function to convert from key to token
+ * @returns
+ */
+export const getConnectionStatuses = async(keys: string[], botTokenResolver?: (key: string) => string): Promise<{[key: string]: ConnectionStatus}> => {
+  const map = keys
+    .reduce<Promise<Map<string, ConnectionStatus>>>(
+      async(acc, key) => {
+        let token = key;
+        if (botTokenResolver != null) {
+          token = botTokenResolver(key);
+        }
+        const status: ConnectionStatus = await getConnectionStatus(token);
+
+        (await acc).set(key, status);
+        return acc;
+      },
+      // define initial accumulator
+      Promise.resolve(new Map<string, ConnectionStatus>()),
+    );
+
+  // convert to object
+  return Object.fromEntries(await map);
+};
+
+export const sendSuccessMessage = async(token:string, channel:string, appSiteUrl:string): Promise<void> => {
+  const client = generateWebClient(token);
+  await client.chat.postMessage({
+    channel,
+    text: `Successfully tested with ${appSiteUrl}.`,
+  });
+};

+ 11 - 0
packages/slack/src/utils/logger/index.ts

@@ -0,0 +1,11 @@
+import Logger from 'bunyan';
+import { createLogger } from 'universal-bunyan';
+
+const loggerFactory = function(name: string): Logger {
+  return createLogger({
+    name,
+    config: { default: 'info' },
+  });
+};
+
+export default loggerFactory;

+ 40 - 0
packages/slack/src/utils/post-ephemeral-errors.ts

@@ -0,0 +1,40 @@
+import { WebAPICallResult } from '@slack/web-api';
+
+import { generateMarkdownSectionBlock } from './block-creater';
+import { generateWebClient } from './webclient-factory';
+
+export const postEphemeralErrors = async(
+  rejectedResults: PromiseRejectedResult[],
+  channelId: string,
+  userId: string,
+  botToken: string,
+): Promise<WebAPICallResult|void> => {
+
+  if (rejectedResults.length > 0) {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const client = generateWebClient(botToken);
+
+    return client.chat.postEphemeral({
+      text: 'Error occured.',
+      channel: channelId,
+      user: userId,
+      blocks: [
+        generateMarkdownSectionBlock('*Error occured:*'),
+        ...rejectedResults.map((rejectedResult) => {
+          const reason = rejectedResult.reason.toString();
+          const resData = rejectedResult.reason.response?.data;
+          const resDataMessage = resData?.message || resData?.toString();
+
+          let errorMessage = reason;
+          if (resDataMessage != null) {
+            errorMessage += `\n  Cause: ${resDataMessage}`;
+          }
+
+          return generateMarkdownSectionBlock(errorMessage);
+        }),
+      ],
+    });
+  }
+
+  return;
+};

+ 93 - 0
packages/slack/src/utils/reshape-contents-body.ts

@@ -0,0 +1,93 @@
+/**
+ * RegExp for Slack message header
+ * @type {RegExp}
+ * @see https://regex101.com/r/wk24Z0/1
+ */
+const regexpMessageHeader = new RegExp(/.+\s\s[\d]{1,2}:[\d]{2}(\s[AP]{1}M)?$/);
+
+/**
+ * RegExp for Slack message Time with/without AM, PM
+ * @type {RegExp}
+ * @see https://regex101.com/r/Tz3ZPG/1
+ */
+const regexpTime = new RegExp(/\s\s[\d]{1,2}:[\d]{2}(\s[AP]{1}M)?$/);
+
+/**
+ * RegExp for Slack message Time without AM, PM
+ * @type {RegExp}
+ * @see https://regex101.com/r/e1Yi6t/1
+ */
+const regexpShortTime = new RegExp(/^[\d]{1,2}:[\d]{2}$/);
+
+/**
+ * RegExp for Slack message reaction
+ * @type {RegExp}
+ * @see https://regex101.com/r/LQX3s2/1
+ */
+const regexpReaction = new RegExp(/^:[+\w-]+:$/);
+
+// Remove everything before the first Header
+const devideLinesBeforeAfterFirstHeader = (lines: string[]) => {
+  let i = 0;
+  while (!regexpMessageHeader.test(lines[i]) && i <= lines.length) {
+    i++;
+  }
+  const linesBeforeFirstHeader = lines.slice(0, i);
+  const linesAfterFirstHeader = lines.slice(i);
+  return { linesBeforeFirstHeader, linesAfterFirstHeader };
+};
+
+// Reshape linesAfterFirstHeader
+export const reshapeContentsBody = (str: string): string => {
+  const splitted = str.split('\n');
+  const { linesBeforeFirstHeader, linesAfterFirstHeader } = devideLinesBeforeAfterFirstHeader(splitted);
+  if (linesAfterFirstHeader.length === 0) {
+    return linesBeforeFirstHeader.join('');
+  }
+
+  let didReactionRemoved = false;
+  const reshapedArray = linesAfterFirstHeader.map((line) => {
+    let copyline = line;
+    // Check 1: Did a reaction removed last time?
+    if (didReactionRemoved) {
+      // remove reaction count
+      copyline = '';
+      didReactionRemoved = false;
+    }
+    // Check 2: Is this line a header?
+    else if (regexpMessageHeader.test(copyline)) {
+      // extract time from line
+      const matched = copyline.match(regexpTime);
+      let time = '';
+      if (matched !== null && matched.length > 0) {
+        time = matched[0];
+      }
+      // ##*username*  HH:mm AM
+      copyline = '\n## **'.concat(copyline);
+      copyline = copyline.replace(regexpTime, '**'.concat(time));
+    }
+    // Check 3: Is this line a short time(HH:mm)?
+    else if (regexpShortTime.test(copyline)) {
+      // --HH:mm--
+      copyline = '--'.concat(copyline, '--');
+    }
+    // Check 4: Is this line a reaction?
+    else if (regexpReaction.test(copyline)) {
+      // remove reaction
+      copyline = '';
+      didReactionRemoved = true;
+    }
+    return copyline;
+  });
+  // remove all blanks
+  const blanksRemoved = reshapedArray.filter(line => line !== '');
+  // add <div> to the first line & add </div> to the last line
+  blanksRemoved[0] = '\n<div class="grw-togetter">\n'.concat(blanksRemoved[0]);
+  blanksRemoved.push('</div>');
+  // Add 2 spaces and 1 enter to all lines
+  const completedArray = blanksRemoved.map(line => line.concat('  \n'));
+  // join all
+  const contentsBeforeFirstHeader = linesBeforeFirstHeader.join('');
+  const contentsAfterFirstHeader = completedArray.join('');
+  return contentsBeforeFirstHeader.concat(contentsAfterFirstHeader);
+};

+ 75 - 0
packages/slack/src/utils/slash-command-parser.test.ts

@@ -0,0 +1,75 @@
+import { InvalidGrowiCommandError } from '../models/errors';
+
+import { parseSlashCommand } from './slash-command-parser';
+
+describe('parseSlashCommand', () => {
+
+  describe('without growiCommandType', () => {
+    test('throws InvalidGrowiCommandError', () => {
+      // setup
+      const text = '';
+      const slashCommand = { text };
+
+      // when/then
+      expect(() => {
+        parseSlashCommand(slashCommand);
+      }).toThrowError(InvalidGrowiCommandError);
+    });
+  });
+
+  test('returns a GrowiCommand instance with empty growiCommandArgs', () => {
+    // setup
+    const text = 'search';
+    const slashCommand = { text };
+
+    // when
+    const result = parseSlashCommand(slashCommand);
+
+    // then
+    expect(result.text).toBe(text);
+    expect(result.growiCommandType).toBe('search');
+    expect(result.growiCommandArgs).toStrictEqual([]);
+  });
+
+  test('returns a GrowiCommand instance with space growiCommandType', () => {
+    // setup
+    const text = '   search   ';
+    const slashCommand = { text };
+
+    // when
+    const result = parseSlashCommand(slashCommand);
+
+    // then
+    expect(result.text).toBe(text);
+    expect(result.growiCommandType).toBe('search');
+    expect(result.growiCommandArgs).toStrictEqual([]);
+  });
+
+  test('returns a GrowiCommand instance with space growiCommandArgs', () => {
+    // setup
+    const text = '   search hoge   ';
+    const slashCommand = { text };
+
+    // when
+    const result = parseSlashCommand(slashCommand);
+
+    // then
+    expect(result.text).toBe(text);
+    expect(result.growiCommandType).toBe('search');
+    expect(result.growiCommandArgs).toStrictEqual(['hoge']);
+  });
+
+  test('returns a GrowiCommand instance', () => {
+    // setup
+    const text = 'search keyword1 keyword2';
+    const slashCommand = { text };
+
+    // when
+    const result = parseSlashCommand(slashCommand);
+
+    // then
+    expect(result.text).toBe(text);
+    expect(result.growiCommandType).toBe('search');
+    expect(result.growiCommandArgs).toStrictEqual(['keyword1', 'keyword2']);
+  });
+});

+ 17 - 0
packages/slack/src/utils/slash-command-parser.ts

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

+ 12 - 0
packages/slack/src/utils/webclient-factory.ts

@@ -0,0 +1,12 @@
+import { LogLevel, WebClient } from '@slack/web-api';
+
+const isProduction = process.env.NODE_ENV === 'production';
+
+/**
+ * Generate WebClilent instance
+ * @param token Slack Bot Token or Proxy Server URI
+ * @returns
+ */
+export const generateWebClient = (token: string, serverUri?: string, headers?:{[key:string]:string}): WebClient => {
+  return new WebClient(token, { slackApiUrl: serverUri, logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO, headers });
+};

+ 17 - 0
packages/slack/tsconfig.build.json

@@ -0,0 +1,17 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "rootDir": "./src",
+    "outDir": "dist",
+    "declaration": true,
+    "noResolve": false,
+    "preserveConstEnums": true,
+    "sourceMap": true,
+    "noEmit": false,
+    "inlineSources": true,
+
+    "baseUrl": ".",
+    "paths": {
+    }
+  }
+}

+ 13 - 0
packages/slack/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "baseUrl": ".",
+    "paths": {
+    }
+  },
+  "exclude": [
+    "node_modules",
+    "dist",
+    "**/*.test.ts"
+  ]
+}

+ 1 - 0
packages/slackbot-proxy/.dockerignore

@@ -0,0 +1 @@
+node_modules

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

@@ -0,0 +1,2 @@
+SLACK_INSTALLPROVIDER_STATE_SECRET=change-it
+OFFICIAL_MODE=false

+ 6 - 0
packages/slackbot-proxy/.env.development

@@ -0,0 +1,6 @@
+SERVER_URI=http://localhost:8080
+TYPEORM_CONNECTION=mysql
+TYPEORM_HOST=mysql
+TYPEORM_DATABASE=growi-slackbot-proxy
+TYPEORM_USERNAME=growi-slackbot-proxy
+TYPEORM_PASSWORD=YrkUi7rCW46Z2N6EXSFUBwaQTUR8biCU

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

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

+ 31 - 0
packages/slackbot-proxy/.eslintrc.js

@@ -0,0 +1,31 @@
+module.exports = {
+  parser: '@typescript-eslint/parser',
+  extends: [
+    'weseek',
+    'weseek/typescript',
+    'plugin:jest/recommended',
+  ],
+  env: {
+    jquery: true,
+    'jest/globals': true,
+  },
+  globals: {
+  },
+  plugins: [
+    'jest',
+  ],
+  rules: {
+    'import/prefer-default-export': 'off',
+    '@typescript-eslint/no-explicit-any': 'off',
+    indent: [
+      'error',
+      2,
+      {
+        SwitchCase: 1,
+        ArrayExpression: 'first',
+        FunctionDeclaration: { body: 1, parameters: 2 },
+        FunctionExpression: { body: 1, parameters: 2 },
+      },
+    ],
+  },
+};

+ 0 - 0
packages/slackbot-proxy/.gitignore


+ 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

+ 33 - 0
packages/slackbot-proxy/docker-compose.dev.yml

@@ -0,0 +1,33 @@
+version: '3'
+services:
+
+  mysql:
+    image: mysql:8.0
+    restart: unless-stopped
+    ports:
+      - 3306:3306
+    environment:
+      - MYSQL_ALLOW_EMPTY_PASSWORD=yes
+      - MYSQL_USER=growi-slackbot-proxy
+      - MYSQL_PASSWORD=YrkUi7rCW46Z2N6EXSFUBwaQTUR8biCU
+      - MYSQL_DATABASE=growi-slackbot-proxy
+    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
+    volumes:
+      - /data/db
+
+  phpmyadmin:
+    depends_on:
+      - mysql
+    image: phpmyadmin
+    restart: unless-stopped
+    ports:
+      - 13306:80
+    environment:
+      - PMA_HOST=mysql
+      - PMA_USER=growi-slackbot-proxy
+      - PMA_PASSWORD=YrkUi7rCW46Z2N6EXSFUBwaQTUR8biCU
+
+networks:
+  default:
+    external:
+      name: growi_devcontainer_default

+ 103 - 0
packages/slackbot-proxy/docker/Dockerfile

@@ -0,0 +1,103 @@
+# syntax = docker/dockerfile:1.2
+
+##
+## deps-resolver-base
+##
+FROM node:14-slim AS deps-resolver-base
+
+ENV appDir /opt
+
+WORKDIR ${appDir}
+COPY ./package.json ./
+COPY ./yarn.lock ./
+COPY ./lerna.json ./
+COPY ./packages/slack/package.json ./packages/slack/package.json
+COPY ./packages/slackbot-proxy/package.json ./packages/slackbot-proxy/package.json
+
+# setup
+RUN yarn config set network-timeout 300000
+
+
+
+##
+## deps-resolver-dev
+##
+FROM deps-resolver-base AS deps-resolver-dev
+RUN npx lerna bootstrap
+
+
+
+##
+## deps-resolver-prod
+##
+FROM deps-resolver-base AS deps-resolver-prod
+RUN npx lerna bootstrap -- --production
+# make artifacts
+RUN tar cf dependencies.tar node_modules packages/slackbot-proxy/node_modules
+
+
+##
+## builder
+##
+FROM node:14-slim AS builder
+
+ENV appDir /opt
+
+WORKDIR ${appDir}
+
+COPY --from=deps-resolver-dev ${appDir}/node_modules node_modules
+
+# copy all related packages
+COPY packages/slack packages/slack
+COPY packages/slackbot-proxy packages/slackbot-proxy
+
+COPY ./package.json ./
+COPY ./lerna.json ./
+COPY ./tsconfig.base.json ./
+COPY ./packages/slack ./packages/slack
+COPY ./packages/slackbot-proxy ./packages/slackbot-proxy
+
+# build
+RUN yarn lerna run build
+
+# make artifacts
+RUN tar cf packages.tar \
+  packages/slack/package.json \
+  packages/slack/dist \
+  packages/slackbot-proxy/package.json \
+  packages/slackbot-proxy/.env \
+  packages/slackbot-proxy/dist
+
+
+
+##
+## release
+##
+FROM node:14-slim
+LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
+
+ENV NODE_ENV production
+
+ENV appDir /opt
+
+# copy artifacts
+COPY --from=deps-resolver-prod --chown=node:node \
+  ${appDir}/dependencies.tar ${appDir}/
+COPY --from=builder --chown=node:node \
+  ${appDir}/packages.tar ${appDir}/
+
+RUN chown node:node ${appDir}
+
+USER node
+
+# extract artifacts
+WORKDIR ${appDir}
+RUN tar xf dependencies.tar
+RUN tar xf packages.tar
+RUN rm dependencies.tar packages.tar
+
+WORKDIR ${appDir}/packages/slackbot-proxy
+
+EXPOSE 8080
+
+CMD ["node", "-r", "dotenv-flow/config", "dist/index.js"]

+ 66 - 0
packages/slackbot-proxy/docker/README.md

@@ -0,0 +1,66 @@
+
+GROWI Slackbot Proxy Server Official docker image
+==============================================
+
+[![Node CI for slackbot-proxy](https://github.com/weseek/growi/actions/workflows/ci-slackbot-proxy.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-slackbot-proxy.yml) [![docker-pulls](https://img.shields.io/docker/pulls/weseek/growi-slackbot-proxy.svg)](https://hub.docker.com/r/weseek/growi-slackbot-proxy/)
+
+
+Supported tags and respective Dockerfile links
+------------------------------------------------
+
+* [`1.0.0`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/master/packages/slackbot-proxy/docker/Dockerfile)
+
+
+What is GROWI Slackbot Proxy Server?
+----------------------------------
+
+The proxy server produced by GROWI Development Team, provides the backend for Slack App (Bot) to integrate GROWI Apps and Slack workspaces.
+
+see: (TBD)
+
+
+Requirements
+-------------
+
+* MySQL (>= 8.0)
+
+### Optional Dependencies
+
+* 
+
+
+Usage
+-----
+
+Create `.env.production.local`
+
+```
+```
+
+```bash
+docker run -d \
+    -e TYPEORM_CONNECTION=mysql \
+    -e TYPEORM_HOST=mysqlserver \
+    -e TYPEORM_DATABASE=growi-slackbot-proxy \
+    -e TYPEORM_USERNAME=growi-slackbot-proxy \
+    -e TYPEORM_PASSWORD=CHANGE-IT \
+    -e SLACK_CLIENT_ID=000000000000.0000000000000 \
+    -e SLACK_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
+    -e SLACK_SIGNING_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
+    weseek/growi-slackbot-proxy
+```
+
+and go to `http://localhost:8080/` .
+
+### docker-compose
+
+(TBD)
+
+Configuration
+-----------
+
+(TBD)
+
+### Environment Variables
+
+(TBD)

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

@@ -0,0 +1,69 @@
+{
+  "name": "@growi/slackbot-proxy",
+  "version": "0.9.1-RC",
+  "license": "MIT",
+  "scripts": {
+    "build": "yarn tsc && tsc-alias -p tsconfig.build.json && yarn postbuild",
+    "postbuild": "yarn cp:public && yarn cp:views",
+    "cp:public": "mkdir -p ./dist/public && cp -r ./src/public ./dist",
+    "cp:views": "mkdir -p ./dist/views && cp -r ./src/views ./dist",
+    "tsc": "tsc -p tsconfig.build.json",
+    "tsc:w": "yarn tsc -w",
+    "dev:ci": "yarn dev --ci",
+    "dev": "cross-env NODE_ENV=development ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config src/index.ts",
+    "start:prod:ci": "yarn start:prod --ci",
+    "start:prod": "cross-env NODE_ENV=production node -r dotenv-flow/config dist/index.js",
+    "test": "yarn test:lint && yarn test:coverage",
+    "test:unit": "cross-env NODE_ENV=test jest --passWithNoTests",
+    "test:coverage": "yarn test:unit",
+    "test:lint": "eslint src --ext .ts",
+    "test:lint:fix": "eslint src --ext .ts --fix",
+    "version": "node -p \"require('./package.json').version\""
+  },
+  "// comments for dependencies": {
+    "express-graceful-exit": "0.5.2 includes a typings file error: https://github.com/emostar/express-graceful-exit/issues/24"
+  },
+  "dependencies": {
+    "@godaddy/terminus": "^4.8.0",
+    "@growi/slack": "^0.9.0-RC",
+    "@slack/oauth": "^2.0.1",
+    "@slack/web-api": "^6.1.0",
+    "@tsed/common": "^6.43.0",
+    "@tsed/di": "^6.43.0",
+    "@tsed/platform-express": "^6.43.0",
+    "@tsed/swagger": "^6.43.0",
+    "@tsed/typeorm": "^6.43.0",
+    "axios": "^0.21.1",
+    "browser-bunyan": "^1.6.3",
+    "bunyan": "^1.8.15",
+    "compression": "^1.7.4",
+    "cookie-parser": "^1.4.5",
+    "cross-env": "^7.0.0",
+    "dotenv-flow": "^3.2.0",
+    "express-bunyan-logger": "^1.3.3",
+    "express-graceful-exit": "=0.5.0",
+    "extensible-custom-error": "^0.0.7",
+    "helmet": "^4.6.0",
+    "method-override": "^3.0.0",
+    "mysql2": "^2.2.5",
+    "typeorm": "^0.2.31",
+    "universal-bunyan": "^0.9.2"
+  },
+  "devDependencies": {
+    "@tsed/core": "^6.43.0",
+    "@tsed/exceptions": "^6.43.0",
+    "@tsed/json-mapper": "^6.43.0",
+    "@tsed/schema": "^6.43.0",
+    "@typescript-eslint/eslint-plugin": "^4.18.0",
+    "@typescript-eslint/parser": "^4.18.0",
+    "browser-bunyan": "^1.6.3",
+    "eslint-import-resolver-typescript": "^2.4.0",
+    "morgan": "^1.10.0",
+    "ts-jest": "^26.5.4",
+    "ts-node": "^9.1.1",
+    "ts-node-dev": "^1.1.6",
+    "tsc-alias": "^1.2.9",
+    "tsconfig-paths": "^3.9.0",
+    "typescript": "^4.2.3"
+  }
+}

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

@@ -0,0 +1,187 @@
+import { Configuration, Inject, InjectorService } from '@tsed/di';
+import { HttpServer, PlatformApplication } from '@tsed/common';
+import '@tsed/platform-express'; // !! DO NOT MODIFY !!
+import '@tsed/typeorm'; // !! DO NOT MODIFY !! -- https://github.com/tsedio/tsed/issues/1332#issuecomment-837840612
+import '@tsed/swagger';
+
+import bodyParser from 'body-parser';
+import compress from 'compression';
+import cookieParser from 'cookie-parser';
+import methodOverride from 'method-override';
+import helmet from 'helmet';
+import { Express } from 'express';
+import expressBunyanLogger from 'express-bunyan-logger';
+import gracefulExit from 'express-graceful-exit';
+
+import { ConnectionOptions } from 'typeorm';
+import { createTerminus } from '@godaddy/terminus';
+
+import swaggerSettingsForDev from '~/config/swagger/config.dev';
+import swaggerSettingsForProd from '~/config/swagger/config.prod';
+import './filters/ResourceNotFoundFilter';
+import loggerFactory from '~/utils/logger';
+
+export const rootDir = __dirname;
+const isProduction = process.env.NODE_ENV === 'production';
+
+const logger = loggerFactory('slackbot-proxy:server');
+
+
+const connectionOptions: ConnectionOptions = {
+  // The 'name' property must be set. Otherwise, the 'name' will be '0' and won't work well. -- 2021.04.05 Yuki Takei
+  // see: https://github.com/TypedProject/tsed/blob/7630cda20a1f6fa3a692ecc3e6cd51d37bc3c45f/packages/typeorm/src/utils/createConnection.ts#L10
+  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,
+  synchronize: true,
+} as ConnectionOptions;
+
+const swaggerSettings = isProduction ? swaggerSettingsForProd : swaggerSettingsForDev;
+const helmetOptions = isProduction ? {} : {
+  contentSecurityPolicy: {
+    directives: {
+      defaultSrc: ['\'self\''],
+      styleSrc: ['\'self\'', '\'unsafe-inline\''],
+      imgSrc: ['\'self\'', 'data:', 'validator.swagger.io'],
+      scriptSrc: ['\'self\'', 'https: \'unsafe-inline\''],
+    },
+  },
+};
+
+@Configuration({
+  rootDir,
+  acceptMimes: ['application/json'],
+  httpPort: process.env.PORT || 8080,
+  httpsPort: false, // CHANGE
+  // disable RequestLogger of @tsed/logger
+  logger: { logRequest: false },
+  mount: {
+    '/': [
+      `${rootDir}/controllers/*.ts`,
+      `${rootDir}/middlewares/*.ts`,
+    ],
+  },
+  middlewares: [
+    helmet(helmetOptions),
+  ],
+  componentsScan: [
+    `${rootDir}/services/*.ts`,
+  ],
+  typeorm: [
+    {
+      ...connectionOptions,
+      entities: [
+        `${rootDir}/entities/*{.ts,.js}`,
+      ],
+      migrations: [
+        `${rootDir}/migrations/*{.ts,.js}`,
+      ],
+      subscribers: [
+        `${rootDir}/subscribers/*{.ts,.js}`,
+      ],
+    } as ConnectionOptions,
+  ],
+  swagger: swaggerSettings,
+  exclude: [
+    '**/*.spec.ts',
+  ],
+  viewsDir: `${rootDir}/views`,
+  views: {
+    root: `${rootDir}/views`,
+    viewEngine: 'ejs',
+    extensions: {
+      ejs: 'ejs',
+    },
+  },
+  statics: {
+    '/': [
+      {
+        root: `${rootDir}/public`,
+      },
+    ],
+  },
+})
+export class Server {
+
+  @Inject()
+  app: PlatformApplication<Express>;
+
+  @Configuration()
+  settings: Configuration;
+
+  @Inject()
+  injector: InjectorService;
+
+  $beforeInit(): Promise<any> | void {
+    const serverUri = process.env.SERVER_URI;
+
+    if (serverUri === undefined) {
+      throw new Error('The environment variable \'SERVER_URI\' must be defined.');
+    }
+
+    const server = this.injector.get<HttpServer>(HttpServer);
+
+    // init express-graceful-exit
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    gracefulExit.init(server!);
+  }
+
+  $beforeRoutesInit(): void {
+    const expressApp = this.app.getApp();
+
+    this.app
+      .use(gracefulExit.middleware(expressApp))
+      .use(cookieParser())
+      .use(compress({}))
+      .use(methodOverride())
+      .use(bodyParser.json())
+      .use(bodyParser.urlencoded({
+        extended: true,
+      }));
+
+    this.setupLogger();
+  }
+
+  $beforeListen(): void {
+    const expressApp = this.app.getApp();
+    const server = this.injector.get<HttpServer>(HttpServer);
+
+    // init terminus
+    createTerminus(server, {
+      onSignal: async() => {
+        logger.info('server is starting cleanup');
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        gracefulExit.gracefulExitHandler(expressApp, server!);
+      },
+      onShutdown: async() => {
+        logger.info('cleanup finished, server is shutting down');
+      },
+    });
+  }
+
+  /**
+   * Setup logger for requests
+   */
+  private setupLogger(): void {
+    // use bunyan
+    if (isProduction) {
+      const logger = loggerFactory('express');
+
+      this.app.use(expressBunyanLogger({
+        logger,
+        excludes: ['*'],
+      }));
+    }
+    // use morgan
+    else {
+      // eslint-disable-next-line @typescript-eslint/no-var-requires
+      const morgan = require('morgan');
+      this.app.use(morgan('dev'));
+    }
+  }
+
+}

+ 16 - 0
packages/slackbot-proxy/src/config/logger/config.dev.ts

@@ -0,0 +1,16 @@
+import { UniversalBunyanConfig } from 'universal-bunyan';
+
+const config: UniversalBunyanConfig = {
+  default: 'info',
+
+  // 'express-session': 'debug',
+
+  /*
+   * configure level for server
+   */
+  // 'express:*': 'debug',
+  // 'slackbot-proxy:*': 'debug',
+
+};
+
+export default config;

+ 16 - 0
packages/slackbot-proxy/src/config/logger/config.prod.ts

@@ -0,0 +1,16 @@
+import { UniversalBunyanConfig } from 'universal-bunyan';
+
+const config: UniversalBunyanConfig = {
+  default: 'info',
+
+  // 'express-session': 'debug',
+
+  /*
+   * configure level for server
+   */
+  // 'express:*': 'debug',
+  // 'slackbot-proxy:*': 'debug',
+
+};
+
+export default config;

+ 10 - 0
packages/slackbot-proxy/src/config/swagger/config.dev.ts

@@ -0,0 +1,10 @@
+import { SwaggerSettings } from '@tsed/swagger';
+
+const settings: SwaggerSettings[] = [
+  {
+    path: '/docs',
+    specVersion: '3.0.1',
+  },
+];
+
+export default settings;

+ 5 - 0
packages/slackbot-proxy/src/config/swagger/config.prod.ts

@@ -0,0 +1,5 @@
+import { SwaggerSettings } from '@tsed/swagger';
+
+const settings: SwaggerSettings[] = [];
+
+export default settings;

+ 252 - 0
packages/slackbot-proxy/src/controllers/growi-to-slack.ts

@@ -0,0 +1,252 @@
+import {
+  Controller, Get, Post, Inject, Req, Res, UseBefore, PathParams,
+} from '@tsed/common';
+import axios from 'axios';
+
+import { WebAPICallOptions, WebAPICallResult } from '@slack/web-api';
+
+import {
+  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, generateWebClient,
+} from '@growi/slack';
+
+import { WebclientRes, AddWebclientResponseToRes } from '~/middlewares/slack-to-growi/add-webclient-response-to-res';
+
+import { GrowiReq } from '~/interfaces/growi-to-slack/growi-req';
+import { InstallationRepository } from '~/repositories/installation';
+import { RelationRepository } from '~/repositories/relation';
+import { OrderRepository } from '~/repositories/order';
+
+import { InstallerService } from '~/services/InstallerService';
+import loggerFactory from '~/utils/logger';
+import { findInjectorByType } from '~/services/growi-uri-injector/GrowiUriInjectorFactory';
+import { injectGrowiUriToView } from '~/utils/injectGrowiUriToView';
+
+
+const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');
+
+// temporarily save for selection to growi
+const temporarySinglePostCommands = ['create'];
+
+@Controller('/g2s')
+export class GrowiToSlackCtrl {
+
+  @Inject()
+  installerService: InstallerService;
+
+  @Inject()
+  installationRepository: InstallationRepository;
+
+  @Inject()
+  relationRepository: RelationRepository;
+
+  @Inject()
+  orderRepository: OrderRepository;
+
+  async requestToGrowi(growiUrl:string, tokenPtoG:string):Promise<void> {
+    const url = new URL('/_api/v3/slack-integration/proxied/commands', growiUrl);
+    await axios.post(url.toString(), {
+      type: 'url_verification',
+      challenge: 'this_is_my_challenge_token',
+    },
+    {
+      headers: {
+        'x-growi-ptog-tokens': tokenPtoG,
+      },
+    });
+  }
+
+  @Get('/connection-status')
+  @UseBefore(verifyGrowiToSlackRequest)
+  async getConnectionStatuses(@Req() req: GrowiReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
+    // asserted (tokenGtoPs.length > 0) by verifyGrowiToSlackRequest
+    const { tokenGtoPs } = req;
+
+    // retrieve Relation with Installation
+    const relations = await this.relationRepository.createQueryBuilder('relation')
+      .where('relation.tokenGtoP IN (:...tokens)', { tokens: tokenGtoPs })
+      .leftJoinAndSelect('relation.installation', 'installation')
+      .getMany();
+
+    logger.debug(`${relations.length} relations found`, relations);
+
+    // key: tokenGtoP, value: botToken
+    const botTokenResolverMapping: {[tokenGtoP:string]:string} = {};
+
+    relations.forEach((relation) => {
+      const botToken = relation.installation?.data?.bot?.token;
+      if (botToken != null) {
+        botTokenResolverMapping[relation.tokenGtoP] = botToken;
+      }
+    });
+
+    const connectionStatuses = await getConnectionStatuses(Object.keys(botTokenResolverMapping), (tokenGtoP:string) => botTokenResolverMapping[tokenGtoP]);
+    return res.send({ connectionStatuses });
+  }
+
+  @Get('/relation-test')
+  @UseBefore(verifyGrowiToSlackRequest)
+  async postRelation(@Req() req: GrowiReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
+    const { tokenGtoPs } = req;
+
+    if (tokenGtoPs.length !== 1) {
+      return res.status(400).send({ message: 'installation is invalid' });
+    }
+
+    const tokenGtoP = tokenGtoPs[0];
+
+    // retrieve relation with Installation
+    const relation = await this.relationRepository.createQueryBuilder('relation')
+      .where('tokenGtoP = :token', { token: tokenGtoP })
+      .leftJoinAndSelect('relation.installation', 'installation')
+      .getOne();
+
+    // Returns the result of the test if it already exists
+    if (relation != null) {
+      logger.debug('relation found', relation);
+
+      const token = relation.installation.data.bot?.token;
+      if (token == null) {
+        return res.status(400).send({ message: 'installation is invalid' });
+      }
+
+      try {
+        await this.requestToGrowi(relation.growiUri, relation.tokenPtoG);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.status(400).send({ message: `failed to request to GROWI. err: ${err.message}` });
+      }
+
+      const status = await getConnectionStatus(token);
+      if (status.error != null) {
+        return res.status(400).send({ message: `failed to get connection. err: ${status.error}` });
+      }
+
+      return res.send({ relation, slackBotToken: token });
+    }
+
+    // retrieve latest Order with Installation
+    const order = await this.orderRepository.createQueryBuilder('order')
+      .orderBy('order.createdAt', 'DESC')
+      .where('tokenGtoP = :token', { token: tokenGtoP })
+      .leftJoinAndSelect('order.installation', 'installation')
+      .getOne();
+
+    if (order == null || order.isExpired()) {
+      return res.status(400).send({ message: 'order has expired or does not exist.' });
+    }
+
+    // Access the GROWI URL saved in the Order record and check if the GtoP token is valid.
+    try {
+      await this.requestToGrowi(order.growiUrl, order.tokenPtoG);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.status(400).send({ message: `failed to request to GROWI. err: ${err.message}` });
+    }
+
+    logger.debug('order found', order);
+
+    const token = order.installation.data.bot?.token;
+    if (token == null) {
+      return res.status(400).send({ message: 'installation is invalid' });
+    }
+
+    const status = await getConnectionStatus(token);
+    if (status.error != null) {
+      return res.status(400).send({ message: `failed to get connection. err: ${status.error}` });
+    }
+
+    logger.debug('relation test is success', order);
+
+    // Transaction is not considered because it is used infrequently,
+    const createdRelation = await this.relationRepository.save({
+      installation: order.installation,
+      tokenGtoP: order.tokenGtoP,
+      tokenPtoG: order.tokenPtoG,
+      growiUri: order.growiUrl,
+      siglePostCommands: temporarySinglePostCommands,
+    });
+
+    return res.send({ relation: createdRelation, slackBotToken: token });
+  }
+
+  injectGrowiUri(req:GrowiReq, growiUri:string):WebAPICallOptions {
+
+    if (req.body.view != null) {
+      injectGrowiUriToView(req.body, growiUri);
+    }
+
+    if (req.body.blocks != null) {
+      const parsedBlocks = JSON.parse(req.body.blocks as string);
+
+      parsedBlocks.forEach((parsedBlock) => {
+        if (parsedBlock.type !== 'actions') {
+          return;
+        }
+        parsedBlock.elements.forEach((element) => {
+          const growiUriInjector = findInjectorByType(element.type);
+          if (growiUriInjector != null) {
+            growiUriInjector.inject(element, growiUri);
+          }
+        });
+
+        return;
+      });
+
+      req.body.blocks = JSON.stringify(parsedBlocks);
+    }
+
+    const opt = req.body;
+    opt.headers = req.headers;
+
+    return opt;
+  }
+
+  @Post('/:method')
+  @UseBefore(AddWebclientResponseToRes, verifyGrowiToSlackRequest)
+  async postResult(
+    @PathParams('method') method: string, @Req() req: GrowiReq, @Res() res: WebclientRes,
+  ): Promise<void|string|Res|WebAPICallResult> {
+    const { tokenGtoPs } = req;
+
+    if (tokenGtoPs.length !== 1) {
+      return res.webClientErr('tokenGtoPs is invalid', 'invalid_tokenGtoP');
+    }
+
+    const tokenGtoP = tokenGtoPs[0];
+
+    // retrieve relation with Installation
+    const relation = await this.relationRepository.createQueryBuilder('relation')
+      .where('tokenGtoP = :token', { token: tokenGtoP })
+      .leftJoinAndSelect('relation.installation', 'installation')
+      .getOne();
+
+    if (relation == null) {
+      return res.webClientErr('relation is invalid', 'invalid_relation');
+    }
+
+    const token = relation.installation.data.bot?.token;
+    if (token == null) {
+      return res.webClientErr('installation is invalid', 'invalid_installation');
+    }
+
+    const client = generateWebClient(token);
+
+    try {
+      const opt = this.injectGrowiUri(req, relation.growiUri);
+
+      await client.apiCall(method, opt);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.webClientErr(`failed to send to slack. err: ${err.message}`, 'fail_api_call');
+    }
+
+    logger.debug('send to slack is success');
+
+    // required to return ok for apiCall
+    return res.webClient();
+  }
+
+}

+ 20 - 0
packages/slackbot-proxy/src/controllers/privacy.ts

@@ -0,0 +1,20 @@
+import { Controller, PlatformRouter } from '@tsed/common';
+import { Request, Response } from 'express';
+
+const isOfficialMode = process.env.OFFICIAL_MODE === 'true';
+
+@Controller('/privacy')
+export class PrivacyCtrl {
+
+  constructor(router: PlatformRouter) {
+    if (isOfficialMode) {
+      router.get('/', this.getPrivacy);
+    }
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  getPrivacy(req: Request, res: Response): string|void {
+    res.render('privacy.ejs');
+  }
+
+}

+ 318 - 0
packages/slackbot-proxy/src/controllers/slack.ts

@@ -0,0 +1,318 @@
+import {
+  BodyParams, Controller, Get, Inject, Post, Req, Res, UseBefore,
+} from '@tsed/common';
+
+import axios from 'axios';
+
+import { WebAPICallResult } from '@slack/web-api';
+
+import {
+  generateMarkdownSectionBlock, GrowiCommand, parseSlashCommand, postEphemeralErrors, verifySlackRequest,
+} from '@growi/slack';
+
+import { Relation } from '~/entities/relation';
+import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
+import { InstallationRepository } from '~/repositories/installation';
+import { RelationRepository } from '~/repositories/relation';
+import { OrderRepository } from '~/repositories/order';
+import { AddSigningSecretToReq } from '~/middlewares/slack-to-growi/add-signing-secret-to-req';
+import { AuthorizeCommandMiddleware, AuthorizeInteractionMiddleware } from '~/middlewares/slack-to-growi/authorizer';
+import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-growi-uri-from-req';
+import { InstallerService } from '~/services/InstallerService';
+import { SelectGrowiService } from '~/services/SelectGrowiService';
+import { RegisterService } from '~/services/RegisterService';
+import { UnregisterService } from '~/services/UnregisterService';
+import { InvalidUrlError } from '../models/errors';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('slackbot-proxy:controllers:slack');
+
+
+@Controller('/slack')
+export class SlackCtrl {
+
+  @Inject()
+  installerService: InstallerService;
+
+  @Inject()
+  installationRepository: InstallationRepository;
+
+  @Inject()
+  relationRepository: RelationRepository;
+
+  @Inject()
+  orderRepository: OrderRepository;
+
+  @Inject()
+  selectGrowiService: SelectGrowiService;
+
+  @Inject()
+  registerService: RegisterService;
+
+  @Inject()
+  unregisterService: UnregisterService;
+
+  /**
+   * Send command to specified GROWIs
+   * @param growiCommand
+   * @param relations
+   * @param body
+   * @returns
+   */
+  private async sendCommand(growiCommand: GrowiCommand, relations: Relation[], body: any) {
+    if (relations.length === 0) {
+      throw new Error('relations must be set');
+    }
+    const botToken = relations[0].installation?.data.bot?.token; // relations[0] should be exist
+
+    const promises = relations.map((relation: Relation) => {
+      // generate API URL
+      const url = new URL('/_api/v3/slack-integration/proxied/commands', relation.growiUri);
+      return axios.post(url.toString(), {
+        ...body,
+        growiCommand,
+      }, {
+        headers: {
+          'x-growi-ptog-tokens': relation.tokenPtoG,
+        },
+      });
+    });
+
+    // pickup PromiseRejectedResult only
+    const results = await Promise.allSettled(promises);
+    const rejectedResults: PromiseRejectedResult[] = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
+
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      return postEphemeralErrors(rejectedResults, body.channel_id, body.user_id, botToken!);
+    }
+    catch (err) {
+      logger.error(err);
+    }
+  }
+
+  @Post('/commands')
+  @UseBefore(AddSigningSecretToReq, verifySlackRequest, AuthorizeCommandMiddleware)
+  async handleCommand(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
+    const { body, authorizeResult } = req;
+
+    if (body.text == null) {
+      return 'No text.';
+    }
+
+    const growiCommand = parseSlashCommand(body);
+
+    // register
+    if (growiCommand.growiCommandType === 'register') {
+      // 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();
+
+      return this.registerService.process(growiCommand, authorizeResult, body as {[key:string]:string});
+    }
+
+    // unregister
+    if (growiCommand.growiCommandType === 'unregister') {
+      if (growiCommand.growiCommandArgs.length === 0) {
+        return 'GROWI Urls is required.';
+      }
+      if (!growiCommand.growiCommandArgs.every(v => v.match(/^(https?:\/\/)/))) {
+        return 'GROWI Urls must be urls.';
+      }
+
+      // 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();
+
+      return this.unregisterService.process(growiCommand, authorizeResult, body as {[key:string]:string});
+    }
+
+    const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
+    const relations = await this.relationRepository.createQueryBuilder('relation')
+      .where('relation.installationId = :id', { id: installation?.id })
+      .leftJoinAndSelect('relation.installation', 'installation')
+      .getMany();
+
+    if (relations.length === 0) {
+      return res.json({
+        blocks: [
+          generateMarkdownSectionBlock('*No relation found.*'),
+          generateMarkdownSectionBlock('Run `/growi register` first.'),
+        ],
+      });
+    }
+
+    // status
+    if (growiCommand.growiCommandType === 'status') {
+      return res.json({
+        blocks: [
+          generateMarkdownSectionBlock('*Found Relations to GROWI.*'),
+          ...relations.map(relation => generateMarkdownSectionBlock(`GROWI url: ${relation.growiUri}.`)),
+        ],
+      });
+    }
+
+    // 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();
+
+    body.growiUris = [];
+    relations.forEach((relation) => {
+      if (relation.siglePostCommands.includes(growiCommand.growiCommandType)) {
+        body.growiUris.push(relation.growiUri);
+      }
+    });
+
+    if (body.growiUris != null && body.growiUris.length > 0) {
+      return this.selectGrowiService.process(growiCommand, authorizeResult, body);
+    }
+
+    /*
+     * forward to GROWI server
+     */
+    this.sendCommand(growiCommand, relations, body);
+  }
+
+  @Post('/interactions')
+  @UseBefore(AuthorizeInteractionMiddleware, ExtractGrowiUriFromReq)
+  async handleInteraction(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
+    logger.info('receive interaction', req.body);
+    logger.info('receive interaction', req.authorizeResult);
+
+    const { body, authorizeResult } = req;
+
+    // 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();
+
+    // pass
+    if (body.ssl_check != null) {
+      return;
+    }
+
+    const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
+
+    const payload = JSON.parse(body.payload);
+    const callBackId = payload?.view?.callback_id;
+
+    // register
+    if (callBackId === 'register') {
+      try {
+        await this.registerService.insertOrderRecord(installation, authorizeResult.botToken, payload);
+      }
+      catch (err) {
+        if (err instanceof InvalidUrlError) {
+          logger.info(err.message);
+          return;
+        }
+        logger.error(err);
+      }
+
+      await this.registerService.notifyServerUriToSlack(authorizeResult.botToken, payload);
+      return;
+    }
+
+    // unregister
+    if (callBackId === 'unregister') {
+      await this.unregisterService.unregister(installation, authorizeResult, payload);
+      return;
+    }
+
+    // forward to GROWI server
+    if (callBackId === 'select_growi') {
+      const selectedGrowiInformation = await this.selectGrowiService.handleSelectInteraction(installation, payload);
+      return this.sendCommand(selectedGrowiInformation.growiCommand, [selectedGrowiInformation.relation], selectedGrowiInformation.sendCommandBody);
+    }
+
+    /*
+    * forward to GROWI server
+    */
+    const relation = await this.relationRepository.findOne({ installation, growiUri: req.growiUri });
+
+    if (relation == null) {
+      logger.error('*No relation found.*');
+      return;
+    }
+
+    try {
+      // generate API URL
+      const url = new URL('/_api/v3/slack-integration/proxied/interactions', req.growiUri);
+      await axios.post(url.toString(), {
+        ...body,
+      }, {
+        headers: {
+          'x-growi-ptog-tokens': relation.tokenPtoG,
+        },
+      });
+    }
+    catch (err) {
+      logger.error(err);
+    }
+  }
+
+  @Post('/events')
+  async handleEvent(@BodyParams() body:{[key:string]:string}, @Res() res: Res): Promise<void|string> {
+    // eslint-disable-next-line max-len
+    // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
+    if (body.type === 'url_verification') {
+      return body.challenge;
+    }
+
+    logger.info('receive event', body);
+
+    return;
+  }
+
+  @Get('/oauth_redirect')
+  async handleOauthRedirect(@Req() req: Req, @Res() res: Res): Promise<void> {
+
+    if (req.query.state === '') {
+      res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
+      res.end('<html>'
+      + '<head><meta name="viewport" content="width=device-width,initial-scale=1"></head>'
+      + '<body style="text-align:center; padding-top:20%;">'
+      + '<h1>Illegal state, try it again.</h1>'
+      + '<a href="/">'
+      + 'Go to install page'
+      + '</a>'
+      + '</body></html>');
+    }
+
+    await this.installerService.installer.handleCallback(req, res, {
+      success: (installation, metadata, req, res) => {
+        logger.info('Success to install', { installation, metadata });
+
+        const appPageUrl = `https://slack.com/apps/${installation.appId}`;
+
+        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+        res.end('<html>'
+        + '<head><meta name="viewport" content="width=device-width,initial-scale=1"></head>'
+        + '<body style="text-align:center; padding-top:20%;">'
+        + '<h1>Congratulations!</h1>'
+        + '<p>GROWI Bot installation has succeeded.</p>'
+        + `<a href="${appPageUrl}">`
+        + 'Access to Slack App detail page.'
+        + '</a>'
+        + '</body></html>');
+      },
+      failure: (error, installOptions, req, res) => {
+        res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
+        res.end('<html>'
+        + '<head><meta name="viewport" content="width=device-width,initial-scale=1"></head>'
+        + '<body style="text-align:center; padding-top:20%;">'
+        + '<h1>GROWI Bot installation failed</h1>'
+        + '<p>Please contact administrators of your workspace</p>'
+        + 'Reference: <a href="https://slack.com/help/articles/222386767-Manage-app-installation-settings-for-your-workspace">'
+        + 'Manage app installation settings for your workspace'
+        + '</a>'
+        + '</body></html>');
+      },
+    });
+  }
+
+}

+ 35 - 0
packages/slackbot-proxy/src/controllers/top.ts

@@ -0,0 +1,35 @@
+import {
+  Controller, Get, Inject, View,
+} from '@tsed/common';
+
+import { InstallerService } from '~/services/InstallerService';
+
+const isOfficialMode = process.env.OFFICIAL_MODE === 'true';
+
+
+@Controller('/')
+export class TopCtrl {
+
+  @Inject()
+  installerService: InstallerService;
+
+  @Get('/')
+  @View('top.ejs')
+  async getTopPage(): Promise<any> {
+    const url = await this.installerService.installer.generateInstallUrl({
+      // Add the scopes your app needs
+      scopes: [
+        'channels:history',
+        'commands',
+        'groups:history',
+        'im:history',
+        'mpim:history',
+        'chat:write',
+        'team:read',
+      ],
+    });
+
+    return { url, isOfficialMode };
+  }
+
+}

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

@@ -0,0 +1,43 @@
+import {
+  Required,
+} from '@tsed/schema';
+import {
+  Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn,
+} from 'typeorm';
+
+import { Installation as SlackInstallation } from '@slack/oauth';
+
+@Entity()
+export class Installation {
+
+  @PrimaryGeneratedColumn()
+  readonly id: number;
+
+  @Column({ type: 'json' })
+  @Required()
+  data: SlackInstallation;
+
+  @CreateDateColumn()
+  readonly createdAt: Date;
+
+  @UpdateDateColumn()
+  readonly updatedAt: Date;
+
+  @Column({ nullable: true })
+  isEnterpriseInstall?: boolean;
+
+  @Column({ nullable: true, unique: true })
+  teamId?: string;
+
+  @Column({ nullable: true, unique: true })
+  enterpriseId?: string;
+
+  setData(slackInstallation: SlackInstallation): void {
+    this.data = slackInstallation;
+
+    this.isEnterpriseInstall = slackInstallation.isEnterpriseInstall;
+    this.teamId = slackInstallation.team?.id;
+    this.enterpriseId = slackInstallation.enterprise?.id;
+  }
+
+}

+ 40 - 0
packages/slackbot-proxy/src/entities/order.ts

@@ -0,0 +1,40 @@
+import {
+  Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, ManyToOne,
+} from 'typeorm';
+import { Installation } from './installation';
+
+@Entity()
+export class Order {
+
+  @PrimaryGeneratedColumn()
+  readonly id: number;
+
+  @CreateDateColumn()
+  readonly createdAt: Date;
+
+  @UpdateDateColumn()
+  readonly updatedAt: Date;
+
+  @ManyToOne(() => Installation)
+  readonly installation: Installation;
+
+  @Column({ nullable: true, default: false })
+  isCompleted?: boolean;
+
+  @Column()
+  growiUrl: string;
+
+  @Column()
+  tokenGtoP: string;
+
+  @Column()
+  tokenPtoG: string;
+
+  isExpired():boolean {
+    const now = Date.now();
+    const expiredAt = this.createdAt.getTime() + 600000;
+
+    return expiredAt < now;
+  }
+
+}

+ 36 - 0
packages/slackbot-proxy/src/entities/relation.ts

@@ -0,0 +1,36 @@
+import {
+  Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, ManyToOne, Index,
+} from 'typeorm';
+import { Installation } from './installation';
+
+@Entity()
+@Index(['installation', 'growiUri'], { unique: true })
+export class Relation {
+
+  @PrimaryGeneratedColumn()
+  readonly id: number;
+
+  @CreateDateColumn()
+  readonly createdAt: Date;
+
+  @UpdateDateColumn()
+  readonly updatedAt: Date;
+
+  @ManyToOne(() => Installation)
+  readonly installation: Installation;
+
+  @Column()
+  @Index({ unique: true })
+  tokenGtoP: string;
+
+  @Column()
+  @Index()
+  tokenPtoG: string;
+
+  @Column()
+  growiUri: string;
+
+  @Column('simple-array')
+  siglePostCommands: string[];
+
+}

+ 22 - 0
packages/slackbot-proxy/src/filters/ResourceNotFoundFilter.ts

@@ -0,0 +1,22 @@
+import {
+  Catch, ExceptionFilterMethods, PlatformContext, PlatformResponse, ResourceNotFound,
+} from '@tsed/common';
+
+@Catch(ResourceNotFound)
+export class ResourceNotFoundFilter implements ExceptionFilterMethods {
+
+  async catch(exception: ResourceNotFound, ctx: PlatformContext): Promise<PlatformResponse<any>> {
+    const { response } = ctx;
+
+    const obj = {
+      status: exception.status,
+      message: exception.message,
+      url: exception.url,
+    };
+
+    return response
+      .status(exception.status)
+      .body(obj);
+  }
+
+}

+ 28 - 0
packages/slackbot-proxy/src/index.ts

@@ -0,0 +1,28 @@
+import { $log } from '@tsed/common';
+import { PlatformExpress } from '@tsed/platform-express';
+
+import { Server } from './Server';
+
+function hasProcessFlag(flag: string): boolean {
+  return process.argv.join('').indexOf(flag) > -1;
+}
+
+async function bootstrap() {
+  try {
+    $log.debug('Start server...');
+    const platform = await PlatformExpress.bootstrap(Server);
+
+    await platform.listen();
+    $log.debug('Server initialized');
+
+    if (hasProcessFlag('ci')) {
+      $log.info('"--ci" flag is detected. Exit process.');
+      process.exit();
+    }
+  }
+  catch (er) {
+    $log.error(er);
+  }
+}
+
+bootstrap();

+ 4 - 0
packages/slackbot-proxy/src/interfaces/growi-to-slack/growi-req.ts

@@ -0,0 +1,4 @@
+import { Req } from '@tsed/common';
+import { RequestFromGrowi } from '@growi/slack';
+
+export type GrowiReq = Req & RequestFromGrowi;

+ 6 - 0
packages/slackbot-proxy/src/interfaces/slack-to-growi/growi-command-processor.ts

@@ -0,0 +1,6 @@
+import { AuthorizeResult } from '@slack/oauth';
+import { GrowiCommand } from '@growi/slack';
+
+export interface GrowiCommandProcessor {
+  process(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, body: {[key:string]:string}): Promise<void>
+}

+ 7 - 0
packages/slackbot-proxy/src/interfaces/slack-to-growi/slack-oauth-req.ts

@@ -0,0 +1,7 @@
+import { AuthorizeResult } from '@slack/oauth';
+import { Req } from '@tsed/common';
+
+export type SlackOauthReq = Req & {
+  authorizeResult: AuthorizeResult,
+  growiUri?: string,
+};

+ 14 - 0
packages/slackbot-proxy/src/middlewares/slack-to-growi/add-signing-secret-to-req.ts

@@ -0,0 +1,14 @@
+import { RequestFromSlack } from '@growi/slack';
+import {
+  IMiddleware, Middleware, Next, Req, Res,
+} from '@tsed/common';
+
+@Middleware()
+export class AddSigningSecretToReq implements IMiddleware {
+
+  use(@Req() req: Req & RequestFromSlack, @Res() res: Res, @Next() next: Next): void {
+    req.slackSigningSecret = process.env.SLACK_SIGNING_SECRET;
+    next();
+  }
+
+}

+ 28 - 0
packages/slackbot-proxy/src/middlewares/slack-to-growi/add-webclient-response-to-res.ts

@@ -0,0 +1,28 @@
+import {
+  IMiddleware, Middleware, Next, Req, Res,
+} from '@tsed/common';
+
+
+export type WebclientRes = Res & {
+  webClient: () => void,
+  webClientErr: (message?:string, errorCode?:string) => void
+};
+
+
+@Middleware()
+export class AddWebclientResponseToRes implements IMiddleware {
+
+  use(@Req() req: Req, @Res() res: WebclientRes, @Next() next: Next): void {
+
+    res.webClient = () => {
+      return res.send({ ok: true });
+    };
+
+    res.webClientErr = (error?:string, errorCode?:string) => {
+      return res.send({ ok: false, error, errorCode });
+    };
+
+    next();
+  }
+
+}

+ 134 - 0
packages/slackbot-proxy/src/middlewares/slack-to-growi/authorizer.ts

@@ -0,0 +1,134 @@
+import { AuthorizeResult, InstallationQuery } from '@slack/oauth';
+import {
+  IMiddleware, Inject, Middleware, Req, Res,
+} from '@tsed/common';
+
+import Logger from 'bunyan';
+
+import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
+import { InstallationRepository } from '~/repositories/installation';
+import { InstallerService } from '~/services/InstallerService';
+import loggerFactory from '~/utils/logger';
+
+@Middleware()
+export class AuthorizeCommandMiddleware implements IMiddleware {
+
+  @Inject()
+  installerService: InstallerService;
+
+  @Inject()
+  installationRepository: InstallationRepository;
+
+  private logger: Logger;
+
+  constructor() {
+    this.logger = loggerFactory('slackbot-proxy:middlewares:AuthorizeCommandMiddleware');
+  }
+
+  async use(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void> {
+    const { body } = req;
+
+    // extract id from body
+    const teamId = body.team_id;
+    const enterpriseId = body.enterprise_id;
+    const isEnterpriseInstall = body.is_enterprise_install === 'true';
+
+    if (teamId == null && enterpriseId == null) {
+      res.writeHead(400, 'No installation found');
+      return res.end();
+    }
+
+    // create query from body
+    const query: InstallationQuery<boolean> = {
+      teamId,
+      enterpriseId,
+      isEnterpriseInstall,
+    };
+
+    let result: AuthorizeResult;
+    try {
+      result = await this.installerService.installer.authorize(query);
+
+      if (result.botToken == null) {
+        res.writeHead(403, `The installation for the team(${teamId || enterpriseId}) has no botToken`);
+        return res.end();
+      }
+    }
+    catch (e) {
+      this.logger.error(e.message);
+
+      res.writeHead(500, e.message);
+      return res.end();
+    }
+
+    // set authorized data
+    req.authorizeResult = result;
+  }
+
+}
+
+
+@Middleware()
+export class AuthorizeInteractionMiddleware implements IMiddleware {
+
+  @Inject()
+  installerService: InstallerService;
+
+  @Inject()
+  installationRepository: InstallationRepository;
+
+  private logger: Logger;
+
+  constructor() {
+    this.logger = loggerFactory('slackbot-proxy:middlewares:AuthorizeInteractionMiddleware');
+  }
+
+  async use(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void> {
+    const { body } = req;
+
+    if (body.payload == null) {
+      // do nothing
+      this.logger.info('body does not have payload');
+      return;
+    }
+
+    const payload = JSON.parse(body.payload);
+
+    // extract id from body
+    const teamId = payload.team?.id;
+    const enterpriseId = payload.enterprise?.id;
+    const isEnterpriseInstall = payload.is_enterprise_install === 'true';
+
+    if (teamId == null && enterpriseId == null) {
+      res.writeHead(400, 'No installation found');
+      return res.end();
+    }
+
+    // create query from body
+    const query: InstallationQuery<boolean> = {
+      teamId,
+      enterpriseId,
+      isEnterpriseInstall,
+    };
+
+    let result: AuthorizeResult;
+    try {
+      result = await this.installerService.installer.authorize(query);
+
+      if (result.botToken == null) {
+        res.writeHead(403, `The installation for the team(${teamId || enterpriseId}) has no botToken`);
+        return res.end();
+      }
+    }
+    catch (e) {
+      this.logger.error(e.message);
+
+      res.writeHead(500, e.message);
+      return res.end();
+    }
+
+    // set authorized data
+    req.authorizeResult = result;
+  }
+
+}

+ 45 - 0
packages/slackbot-proxy/src/middlewares/slack-to-growi/extract-growi-uri-from-req.ts

@@ -0,0 +1,45 @@
+import {
+  IMiddleware, Middleware, Next, Req, Res,
+} from '@tsed/common';
+import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
+import { growiUriInjectorFactory } from '~/services/growi-uri-injector/GrowiUriInjectorFactory';
+import { extractGrowiUriFromView } from '~/utils/extractGrowiUriFromView';
+
+@Middleware()
+export class ExtractGrowiUriFromReq implements IMiddleware {
+
+  use(@Req() req: Req & SlackOauthReq, @Res() res: Res, @Next() next: Next): void {
+
+    // There is no payload in the request from slack
+    if (req.body.payload == null) {
+      return next();
+    }
+
+    const payload = JSON.parse(req.body.payload);
+
+    // extract for modal
+    if (payload.view != null) {
+      const extractedValues = extractGrowiUriFromView(payload.view);
+      req.growiUri = extractedValues.growiUri;
+      payload.view.private_metadata = extractedValues.originalData;
+    }
+    else {
+      // break when uri is found
+      for (const type of Object.keys(growiUriInjectorFactory)) {
+        const growiUriInjector = growiUriInjectorFactory[type]();
+        const extractedValues = growiUriInjector.extract(payload.actions[0]);
+
+        if (extractedValues.growiUri != null) {
+          req.growiUri = extractedValues.growiUri;
+          payload.actions[0].value = JSON.stringify(extractedValues.originalData);
+          break;
+        }
+      }
+    }
+
+    req.body.payload = JSON.stringify(payload);
+
+    return next();
+  }
+
+}

+ 9 - 0
packages/slackbot-proxy/src/models/errors.ts

@@ -0,0 +1,9 @@
+import ExtensibleCustomError from 'extensible-custom-error';
+
+export class InvalidUrlError extends ExtensibleCustomError {
+
+  constructor(url: string) {
+    super(`Invalid URL: ${url}`);
+  }
+
+}

BIN
packages/slackbot-proxy/src/public/images/add-to-slack.png


BIN
packages/slackbot-proxy/src/public/images/growi-bot.png


+ 23 - 0
packages/slackbot-proxy/src/repositories/installation.ts

@@ -0,0 +1,23 @@
+import {
+  Repository, EntityRepository,
+} from 'typeorm';
+
+import { Installation } from '~/entities/installation';
+
+@EntityRepository(Installation)
+export class InstallationRepository extends Repository<Installation> {
+
+  findByID(id: string): Promise<Installation | undefined> {
+    return this.findOne(id);
+  }
+
+  async findByTeamIdOrEnterpriseId(teamIdOrEnterpriseId:string): Promise<Installation|undefined> {
+    return this.findOne({
+      where: [
+        { teamId: teamIdOrEnterpriseId },
+        { enterpriseId: teamIdOrEnterpriseId, isEnterpriseInstall: true },
+      ],
+    });
+  }
+
+}

+ 10 - 0
packages/slackbot-proxy/src/repositories/order.ts

@@ -0,0 +1,10 @@
+import {
+  Repository, EntityRepository,
+} from 'typeorm';
+
+import { Order } from '~/entities/order';
+
+@EntityRepository(Order)
+export class OrderRepository extends Repository<Order> {
+
+}

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

@@ -0,0 +1,10 @@
+import {
+  Repository, EntityRepository,
+} from 'typeorm';
+
+import { Relation } from '~/entities/relation';
+
+@EntityRepository(Relation)
+export class RelationRepository extends Repository<Relation> {
+
+}

+ 73 - 0
packages/slackbot-proxy/src/services/InstallerService.ts

@@ -0,0 +1,73 @@
+import {
+  Installation as SlackInstallation, InstallationQuery, InstallProvider,
+} from '@slack/oauth';
+import { Inject, Service } from '@tsed/di';
+
+import { Installation } from '~/entities/installation';
+import { InstallationRepository } from '~/repositories/installation';
+
+@Service()
+export class InstallerService {
+
+  installer: InstallProvider;
+
+  @Inject()
+  private readonly repository: InstallationRepository;
+
+  $onInit(): Promise<any> | void {
+    const clientId = process.env.SLACK_CLIENT_ID;
+    const clientSecret = process.env.SLACK_CLIENT_SECRET;
+    const stateSecret = process.env.SLACK_INSTALLPROVIDER_STATE_SECRET;
+
+    if (clientId === undefined) {
+      throw new Error('The environment variable \'SLACK_CLIENT_ID\' must be defined.');
+    }
+    if (clientSecret === undefined) {
+      throw new Error('The environment variable \'SLACK_CLIENT_SECRET\' must be defined.');
+    }
+
+    const { repository } = this;
+
+    this.installer = new InstallProvider({
+      clientId,
+      clientSecret,
+      stateSecret,
+      installationStore: {
+        // upsert
+        storeInstallation: async(slackInstallation: SlackInstallation<'v1' | 'v2', boolean>) => {
+          const teamIdOrEnterpriseId = slackInstallation.team?.id || slackInstallation.enterprise?.id;
+
+          if (teamIdOrEnterpriseId == null) {
+            throw new Error('teamId or enterpriseId is required.');
+          }
+
+          const existedInstallation = await repository.findByTeamIdOrEnterpriseId(teamIdOrEnterpriseId);
+
+          if (existedInstallation != null) {
+            existedInstallation.setData(slackInstallation);
+            await repository.save(existedInstallation);
+            return;
+          }
+
+          const installation = new Installation();
+          installation.setData(slackInstallation);
+          await repository.save(installation);
+          return;
+        },
+        fetchInstallation: async(installQuery: InstallationQuery<boolean>) => {
+          const id = installQuery.enterpriseId || installQuery.teamId;
+
+          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+          const installation = await repository.findByTeamIdOrEnterpriseId(id!);
+
+          if (installation == null) {
+            throw new Error('Failed fetching installation');
+          }
+
+          return installation.data;
+        },
+      },
+    });
+  }
+
+}

+ 115 - 0
packages/slackbot-proxy/src/services/RegisterService.ts

@@ -0,0 +1,115 @@
+import { Inject, Service } from '@tsed/di';
+import { WebClient, LogLevel } from '@slack/web-api';
+import { generateInputSectionBlock, GrowiCommand, generateMarkdownSectionBlock } from '@growi/slack';
+import { AuthorizeResult } from '@slack/oauth';
+import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
+import { OrderRepository } from '~/repositories/order';
+import { Installation } from '~/entities/installation';
+import { InvalidUrlError } from '../models/errors';
+
+const isProduction = process.env.NODE_ENV === 'production';
+
+@Service()
+export class RegisterService implements GrowiCommandProcessor {
+
+  @Inject()
+  orderRepository: OrderRepository;
+
+  async process(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, body: {[key:string]:string}): Promise<void> {
+    const { botToken } = authorizeResult;
+
+    const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
+    await client.views.open({
+      trigger_id: body.trigger_id,
+      view: {
+        type: 'modal',
+        callback_id: 'register',
+        title: {
+          type: 'plain_text',
+          text: 'Register Credentials',
+        },
+        submit: {
+          type: 'plain_text',
+          text: 'Submit',
+        },
+        close: {
+          type: 'plain_text',
+          text: 'Close',
+        },
+        private_metadata: JSON.stringify({ channel: body.channel_name }),
+
+        blocks: [
+          generateInputSectionBlock('growiUrl', 'GROWI domain', 'contents_input', false, 'https://example.com'),
+          generateInputSectionBlock('tokenPtoG', 'Access Token Proxy to GROWI', 'contents_input', false, 'jBMZvpk.....'),
+          generateInputSectionBlock('tokenGtoP', 'Access Token GROWI to Proxy', 'contents_input', false, 'sdg15av.....'),
+        ],
+      },
+    });
+  }
+
+  async insertOrderRecord(
+      installation: Installation | undefined,
+      // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+      botToken: string | undefined, payload: any,
+  ): Promise<void> {
+    const inputValues = payload.view.state.values;
+    const growiUrl = inputValues.growiUrl.contents_input.value;
+    const tokenPtoG = inputValues.tokenPtoG.contents_input.value;
+    const tokenGtoP = inputValues.tokenGtoP.contents_input.value;
+
+    const { channel } = JSON.parse(payload.view.private_metadata);
+
+    const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
+
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-unused-vars
+      const url = new URL(growiUrl);
+    }
+    catch (error) {
+      const invalidErrorMsg = 'Please enter a valid URL';
+
+      await client.chat.postEphemeral({
+        channel,
+        user: payload.user.id,
+        // Recommended including 'text' to provide a fallback when using blocks
+        // refer to https://api.slack.com/methods/chat.postEphemeral#text_usage
+        text: 'Invalid URL',
+        blocks: [
+          generateMarkdownSectionBlock(invalidErrorMsg),
+        ],
+      });
+
+      throw new InvalidUrlError(growiUrl);
+    }
+
+    this.orderRepository.save({
+      installation, growiUrl, tokenPtoG, tokenGtoP,
+    });
+  }
+
+  async notifyServerUriToSlack(
+      // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+      botToken: string | undefined, payload: any,
+  ): Promise<void> {
+
+    const { channel } = JSON.parse(payload.view.private_metadata);
+
+    const serverUri = process.env.SERVER_URI;
+
+    const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
+
+    await client.chat.postEphemeral({
+      channel,
+      user: payload.user.id,
+      // Recommended including 'text' to provide a fallback when using blocks
+      // refer to https://api.slack.com/methods/chat.postEphemeral#text_usage
+      text: 'Proxy URL',
+      blocks: [
+        generateMarkdownSectionBlock('Please enter and update the following Proxy URL to slack bot setting form in your GROWI'),
+        generateMarkdownSectionBlock(`Proxy URL: ${serverUri}`),
+      ],
+    });
+    return;
+  }
+
+}

+ 114 - 0
packages/slackbot-proxy/src/services/SelectGrowiService.ts

@@ -0,0 +1,114 @@
+import { Inject, Service } from '@tsed/di';
+
+import { GrowiCommand, generateWebClient } from '@growi/slack';
+import { AuthorizeResult } from '@slack/oauth';
+
+import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
+import { Installation } from '~/entities/installation';
+import { Relation } from '~/entities/relation';
+import { RelationRepository } from '~/repositories/relation';
+
+
+export type SelectedGrowiInformation = {
+  relation: Relation,
+  growiCommand: GrowiCommand,
+  sendCommandBody: any,
+}
+
+@Service()
+export class SelectGrowiService implements GrowiCommandProcessor {
+
+  @Inject()
+  relationRepository: RelationRepository;
+
+  async process(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, body: {[key:string]:string } & {growiUris:string[]}): Promise<void> {
+    const { botToken } = authorizeResult;
+
+    if (botToken == null) {
+      throw new Error('botToken is required.');
+    }
+
+    const client = generateWebClient(botToken);
+
+    await client.views.open({
+      trigger_id: body.trigger_id,
+      view: {
+        type: 'modal',
+        callback_id: 'select_growi',
+        title: {
+          type: 'plain_text',
+          text: 'Select GROWI Url',
+        },
+        submit: {
+          type: 'plain_text',
+          text: 'Submit',
+        },
+        close: {
+          type: 'plain_text',
+          text: 'Close',
+        },
+        private_metadata: JSON.stringify({ body, growiCommand }),
+
+        blocks: [
+          {
+            type: 'input',
+            block_id: 'select_growi',
+            label: {
+              type: 'plain_text',
+              text: 'GROWI App',
+            },
+            element: {
+              type: 'static_select',
+              action_id: 'growi_app',
+              options: body.growiUris.map((growiUri) => {
+                return ({
+                  text: {
+                    type: 'plain_text',
+                    text: growiUri,
+                  },
+                  value: growiUri,
+                });
+              }),
+            },
+          },
+        ],
+      },
+    });
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  async handleSelectInteraction(installation:Installation | undefined, payload:any): Promise<SelectedGrowiInformation> {
+    const { trigger_id: triggerId } = payload;
+    const { state, private_metadata: privateMetadata } = payload?.view;
+    const { value: growiUri } = state?.values?.select_growi?.growi_app?.selected_option;
+
+    const parsedPrivateMetadata = JSON.parse(privateMetadata);
+    const { growiCommand, body: sendCommandBody } = parsedPrivateMetadata;
+
+    if (growiCommand == null || sendCommandBody == null) {
+      // TODO: postEphemeralErrors
+      throw new Error('growiCommand and body params are required in private_metadata.');
+    }
+
+    // ovverride trigger_id
+    sendCommandBody.trigger_id = triggerId;
+
+    const relation = await this.relationRepository.createQueryBuilder('relation')
+      .where('relation.growiUri =:growiUri', { growiUri })
+      .andWhere('relation.installationId = :id', { id: installation?.id })
+      .leftJoinAndSelect('relation.installation', 'installation')
+      .getOne();
+
+    if (relation == null) {
+      // TODO: postEphemeralErrors
+      throw new Error('No relation found.');
+    }
+
+    return {
+      relation,
+      growiCommand,
+      sendCommandBody,
+    };
+  }
+
+}

+ 75 - 0
packages/slackbot-proxy/src/services/UnregisterService.ts

@@ -0,0 +1,75 @@
+import { Inject, Service } from '@tsed/di';
+import { WebClient, LogLevel } from '@slack/web-api';
+import { GrowiCommand, generateMarkdownSectionBlock } from '@growi/slack';
+import { AuthorizeResult } from '@slack/oauth';
+import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
+import { RelationRepository } from '~/repositories/relation';
+import { Installation } from '~/entities/installation';
+
+const isProduction = process.env.NODE_ENV === 'production';
+
+@Service()
+export class UnregisterService implements GrowiCommandProcessor {
+
+  @Inject()
+  relationRepository: RelationRepository;
+
+  async process(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, body: {[key:string]:string}): Promise<void> {
+    const { botToken } = authorizeResult;
+    const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
+    const growiUrls = growiCommand.growiCommandArgs;
+    await client.views.open({
+      trigger_id: body.trigger_id,
+      view: {
+        type: 'modal',
+        callback_id: 'unregister',
+        title: {
+          type: 'plain_text',
+          text: 'Unregister Credentials',
+        },
+        submit: {
+          type: 'plain_text',
+          text: 'Submit',
+        },
+        close: {
+          type: 'plain_text',
+          text: 'Close',
+        },
+        private_metadata: JSON.stringify({ channel: body.channel_name, growiUrls }),
+
+        blocks: [
+          ...growiUrls.map(growiCommandArg => generateMarkdownSectionBlock(`GROWI url: ${growiCommandArg}.`)),
+        ],
+      },
+    });
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  async unregister(installation: Installation | undefined, authorizeResult: AuthorizeResult, payload: any):Promise<void> {
+    const { botToken } = authorizeResult;
+    const { channel, growiUrls } = JSON.parse(payload.view.private_metadata);
+    const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
+
+    const deleteResult = await this.relationRepository.createQueryBuilder('relation')
+      .where('relation.growiUri IN (:uris)', { uris: growiUrls })
+      .andWhere('relation.installationId = :installationId', { installationId: installation?.id })
+      .delete()
+      .execute();
+
+    await client.chat.postEphemeral({
+      channel,
+      user: payload.user.id,
+      // Recommended including 'text' to provide a fallback when using blocks
+      // refer to https://api.slack.com/methods/chat.postEphemeral#text_usage
+      text: 'Delete Relations',
+      blocks: [
+        generateMarkdownSectionBlock(`Deleted ${deleteResult.affected} Relations.`),
+      ],
+    });
+
+    return;
+
+  }
+
+
+}

+ 19 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjectionButtonDelegator.ts

@@ -0,0 +1,19 @@
+import { GrowiUriInjector } from './GrowiUriInjector';
+
+export class GrowiUriInjectionButtonDelegator implements GrowiUriInjector {
+
+  inject(element: {value:string}, growiUri:string): void {
+    const parsedValue = JSON.parse(element.value);
+    const originalData = JSON.stringify(parsedValue);
+    element.value = JSON.stringify({ growiUri, originalData });
+  }
+
+  extract(action: {value:string}): {growiUri?:string, originalData:any} {
+    const parsedValues = JSON.parse(action.value);
+    if (parsedValues.originalData != null) {
+      parsedValues.originalData = JSON.parse(parsedValues.originalData);
+    }
+    return parsedValues;
+  }
+
+}

+ 7 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjector.ts

@@ -0,0 +1,7 @@
+
+export interface GrowiUriInjector {
+
+  inject(body: any, growiUri:string): void;
+
+  extract(body: any):any;
+}

+ 18 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjectorFactory.ts

@@ -0,0 +1,18 @@
+import { GrowiUriInjector } from './GrowiUriInjector';
+import { GrowiUriInjectionButtonDelegator } from './GrowiUriInjectionButtonDelegator';
+
+/**
+ * Instanciate GrowiUriInjector
+ */
+export const growiUriInjectorFactory = {
+  button: (): GrowiUriInjector => {
+    return new GrowiUriInjectionButtonDelegator();
+  },
+};
+
+export const findInjectorByType = (type:string): null|GrowiUriInjector => {
+  if (!Object.keys(growiUriInjectorFactory).includes(type)) {
+    return null;
+  }
+  return growiUriInjectorFactory[type]();
+};

+ 10 - 0
packages/slackbot-proxy/src/utils/extractGrowiUriFromView.ts

@@ -0,0 +1,10 @@
+export const extractGrowiUriFromView = (view:{'private_metadata': string}): {growiUri?:string, originalData:{[key:string]:any}} => {
+  const parsedValues = JSON.parse(view.private_metadata);
+  if (parsedValues.originalData != null) {
+    parsedValues.originalData = JSON.parse(parsedValues.originalData);
+  }
+  else {
+    parsedValues.originalData = view.private_metadata;
+  }
+  return parsedValues;
+};

+ 7 - 0
packages/slackbot-proxy/src/utils/injectGrowiUriToView.ts

@@ -0,0 +1,7 @@
+export const injectGrowiUriToView = (body: {view:string}, growiUri:string): void => {
+  const parsedView = JSON.parse(body.view);
+  const originalData = JSON.stringify(parsedView.private_metadata);
+
+  parsedView.private_metadata = JSON.stringify({ growiUri, originalData });
+  body.view = JSON.stringify(parsedView);
+};

+ 17 - 0
packages/slackbot-proxy/src/utils/logger/index.ts

@@ -0,0 +1,17 @@
+import Logger from 'bunyan';
+import { createLogger } from 'universal-bunyan';
+
+import configForDev from '~/config/logger/config.dev';
+import configForProd from '~/config/logger/config.prod';
+
+const isProduction = process.env.NODE_ENV === 'production';
+const config = isProduction ? configForProd : configForDev;
+
+const loggerFactory = function(name: string): Logger {
+  return createLogger({
+    name,
+    config,
+  });
+};
+
+export default loggerFactory;

+ 97 - 0
packages/slackbot-proxy/src/views/privacy.ejs

@@ -0,0 +1,97 @@
+<head>
+  <meta name="viewport" content="width=device-width,initial-scale=1">
+</head>
+
+<body style="max-width: 600px; padding-top:100px; margin: 0 auto;">
+  <h1 style="text-align:center;">Privacy Policy</h1>
+  <h2 style="text-align:center;">At First</h2>
+  <p>
+    Your privacy is critically important to us. At GROWI Official Bot we have a few fundamental principles:
+  </p>
+  <ul>
+    <li>We don’t ask you for personal information unless we truly need it.</li>
+    <li>We don’t share your personal information with anyone except to comply with the law, develop our products, or protect our rights.</li>
+    <li>We don’t store personal information on our servers unless required for the on-going operation of the service.</li>
+  </ul>
+  <p>
+    If you have questions about deleting or correcting your personal data please contact support.
+  </p>
+  <p>
+    WESEEK, Inc. operates slack bot about GROWI. – henceforth referred to as "GROWI Official Bot". It is slack bot’s policy to respect your privacy regarding any information we may collect while operating our service.
+  </p>
+  <h2 style="text-align:center;">What Personal Data Do We Receive?</h2>
+  <p>
+    Personal information is information about an identified or identifiable individual, or about an identifiable individual, including information that WESEEK, Inc. can associate with an individual.
+  </p>
+  <p>
+    When using or operating the GROWI Official Bot, we may collect or process the following categories of personal information on your behalf.
+  </p>
+  <h2 style="text-align:center;">Protection of specific personal information</h2>
+  <p>
+    WESEEK, Inc. provides potentially personally identifiable information and personally identifiable information.
+  </p>
+  <ul>
+    <li>
+      We will only disclose it to the information of employees, contractors, and related organizations who need to know that information to process on behalf of WESEEK, Inc. or to provide the services available on the GROWI Official Bot.
+    </li>
+    <li>
+      Those who have agreed not to disclose it to others. Some of these employees, contractors, and related organizations may be located outside of their home country.
+    </li>
+  </ul>
+  <p>
+    By using GROWI Official Bot, you agree to transfer such information to them. As mentioned above, other than employees, contractors, and related organizations, WESEEK, Inc. does not lend or sell personally identifiable or personally identifiable information to third parties.
+  </p>
+  <p>
+    WESEEK, Inc. will take all reasonable steps to protect personally identifiable information and personally identifiable information from unauthorized access, use, modification or destruction.
+  </p>
+  <h2 style="text-align:center;">Other information to collect</h2>
+  <p>
+    order to enable mutual communication between your GROWI and Slack, we may collect, retain and process the following information that does not fall within the definition of personal information.
+  </p>
+  <ul>
+    <li>
+      Slack workspace information
+      <ul>
+        <li>
+          Includes workspace name, team ID, bot token associated with the workspace, and more.
+        </li>
+      </ul>
+    </li>
+    <li>
+      GROWI information
+      <ul>
+        <li>
+          Includes GROWI URIs for communicating with Slack, access tokens, and more.
+        </li>
+      </ul>
+    </li>
+    <li>
+      Information about communication
+      <ul>
+        <li>
+          Contains information about communication between Slack and GROWI.
+        </li>
+      </ul>
+    </li>
+  </ul>
+  <h2 style="text-align:center;">
+    Business Transfers
+  </h2>
+  <p>
+    If WESEEK, Inc. or substantially all of its assets, were acquired, or in the unlikely event that WESEEK, Inc.
+    goes out of business or enters bankruptcy, user information would be one of the assets that is transferred or acquired by a third party.
+  </p>
+  <p>
+    You acknowledge that such transfers may occur, and that any acquirer of WESEEK, Inc. may continue to use your personal information as set forth in this policy.
+  </p>
+  <h2 style="text-align:center;">
+    Privacy Policy Changes
+  </h2>
+  <p>
+    Although most changes are likely to be minor, WESEEK, Inc. may change its Privacy Policy from time to time, and in WESEEK, Inc.’s sole discretion.
+    WESEEK, Inc. encourages visitors to frequently check this page for any changes to its Privacy Policy.
+  </p>
+  <p>
+    Your continued use of this site after any change in this Privacy Policy will constitute your acceptance of such change.
+  </p>
+</body>

+ 20 - 0
packages/slackbot-proxy/src/views/top.ejs

@@ -0,0 +1,20 @@
+<head>
+  <meta name="viewport" content="width=device-width,initial-scale=1">
+</head>
+
+<body style="padding-top:100px; text-align:center;">
+  <h1 >GROWI Bot</h1>
+  <div>
+    <img height="300" width="300" alt="GROWi Bot" src="/images/growi-bot.png" />
+  </div>
+  <div style="display:flex; justify-content: space-around; max-width: 500px; margin:30px auto;">
+    <a href=<%- url %>>
+      <img alt="Add to Slack" height="40" width="139" src="/images/add-to-slack.png"/>
+    </a>
+    <% if (isOfficialMode) { %>
+      <a href="/privacy">
+        Privacy Policy
+      </a>
+    <% } %>
+  </div>
+</body>

+ 11 - 0
packages/slackbot-proxy/tsconfig.base.json

@@ -0,0 +1,11 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+  },
+  "exclude": [
+    "node_modules",
+    "config",
+    "dist",
+    "**/*.test.ts"
+  ]
+}

+ 17 - 0
packages/slackbot-proxy/tsconfig.build.json

@@ -0,0 +1,17 @@
+{
+  "extends": "./tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "./src",
+    "outDir": "dist",
+    "declaration": true,
+    "noResolve": false,
+    "preserveConstEnums": true,
+    "sourceMap": true,
+    "noEmit": false,
+    "inlineSources": true,
+    "baseUrl": "./src",
+    "paths": {
+      "~/*": ["./*"]
+    }
+  }
+}

+ 10 - 0
packages/slackbot-proxy/tsconfig.json

@@ -0,0 +1,10 @@
+{
+  "extends": "./tsconfig.base.json",
+  "compilerOptions": {
+    "baseUrl": ".",
+    "paths": {
+      "~/*": ["./src/*"],
+      "@growi/*": ["../*/src"]
+    }
+  }
+}

BIN
public/images/slack-integration/growi-bot-kun-icon.png


BIN
public/images/slack-integration/growi-register-modal.png


BIN
public/images/slack-integration/growi-register-sentence.png


BIN
public/images/slack-integration/growi-set-proxy-url.png


BIN
public/images/slack-integration/impossible.png


BIN
public/images/slack-integration/possible.png


Some files were not shown because too many files changed in this diff