Browse Source

merge master

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

+ 1 - 0
.eslintignore

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

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

@@ -0,0 +1,240 @@
+name: Node CI for slackbot-proxy
+
+on:
+  push:
+    branches-ignore:
+      - release/**
+      - rc/**
+      - 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 }}

+ 30 - 18
.github/workflows/ci.yml

@@ -1,10 +1,22 @@
-name: Node CI
+name: Node CI for growi
 
 on:
   push:
     branches-ignore:
       - release/**
+      - rc/**
       - tmp/**
+    paths:
+      - .github/workflows/ci.yml
+      - packages/app/**
+      - .eslint*
+      - .prettier*
+      - .stylelint*
+      - config/**
+      - resource/**
+      - src/**
+      - package.json
+      - yarn.lock
 
 jobs:
 
@@ -25,7 +37,7 @@ jobs:
       id: cache-dependencies
       uses: actions/cache@v2
       with:
-        path: node_modules
+        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'
@@ -42,7 +54,7 @@ jobs:
     - name: Install dependencies
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
       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
       run: |
         echo -n "node " && node -v
@@ -57,7 +69,7 @@ jobs:
       if: failure()
       with:
         type: ${{ job.status }}
-        job_name: '*test (${{ matrix.node-version }})*'
+        job_name: '*Node CI for growi - lint (${{ matrix.node-version }})*'
         channel: '#ci'
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
@@ -90,7 +102,7 @@ jobs:
       id: cache-dependencies
       uses: actions/cache@v2
       with:
-        path: node_modules
+        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'
@@ -107,7 +119,7 @@ jobs:
     - name: Install dependencies
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
       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
       run: |
         echo -n "node " && node -v
@@ -129,7 +141,7 @@ jobs:
       if: failure()
       with:
         type: ${{ job.status }}
-        job_name: '*test (${{ matrix.node-version }})*'
+        job_name: '*Node CI for growi - test (${{ matrix.node-version }})*'
         channel: '#ci'
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
@@ -152,7 +164,7 @@ jobs:
       id: cache-dependencies
       uses: actions/cache@v2
       with:
-        path: node_modules
+        path: '**/node_modules'
         key: ${{ runner.OS }}-node_modules_dev-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
     - name: Get Date
       id: date
@@ -185,7 +197,7 @@ jobs:
     - name: Install dependencies
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
       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
       run: |
         echo -n "node " && node -v
@@ -200,7 +212,7 @@ jobs:
       if: failure()
       with:
         type: ${{ job.status }}
-        job_name: '*build-dev (${{ matrix.node-version }})*'
+        job_name: '*Node CI for growi - build-dev (${{ matrix.node-version }})*'
         channel: '#ci'
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
@@ -239,7 +251,7 @@ jobs:
     - name: Cache/Restore node_modules
       uses: actions/cache@v2
       with:
-        path: node_modules
+        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 }}
@@ -257,19 +269,19 @@ jobs:
           ${{ runner.os }}-yarn-
     - name: Install dependencies
       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
     - name: Print dependencies
       run: |
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         yarn list --depth=0
-    - name: yarn build:prod:analyze
+    - name: Build
       run: |
-        yarn build:prod:analyze
-    - name: yarn install --production
+        yarn lerna run build --scope @growi/slack
+        yarn lerna run build --scope @growi/app
+    - name: lerna bootstrap --production
       run: |
-        yarn install --production
+        npx lerna bootstrap -- --production
     - name: Print dependencies
       run: |
         echo -n "node " && node -v
@@ -300,7 +312,7 @@ jobs:
       if: failure()
       with:
         type: ${{ job.status }}
-        job_name: '*build-prod (${{ matrix.node-version }})*'
+        job_name: '*Node CI for growi - build-prod (${{ matrix.node-version }})*'
         channel: '#ci'
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 2 - 2
.github/workflows/list-unhealthy-branches.yml

@@ -37,7 +37,7 @@ jobs:
         status: custom
         payload: |
           {
-            text: '<!channel> There is some *illegal named branches* on GitHub.',
+            text: '<!channel> There is some branches *with illegal names* on GitHub.',
             channel: '#ci',
             attachments: ${{ steps.list-branches.outputs.SLACK_ATTACHMENTS_ILLEGAL }}
           }
@@ -51,7 +51,7 @@ jobs:
         status: custom
         payload: |
           {
-            text: '<!channel> There is some *illegal named branches* on GitHub.',
+            text: '<!channel> There is some branches *that are no longer updated* on GitHub.',
             channel: '#ci',
             attachments: ${{ steps.list-branches.outputs.SLACK_ATTACHMENTS_INACTIVE }}
           }

+ 37 - 35
.github/workflows/release-rc.yml

@@ -14,36 +14,24 @@ jobs:
     steps:
     - uses: actions/checkout@v2
 
-    - 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: Build Docker Image
-      run: |
-        CACHE_REF=weseek/growi-cache:4
-        docker buildx build \
-          --tag growi \
-          --platform linux/amd64 \
-          --load \
-          --cache-from=type=registry,ref=$CACHE_REF \
-          --cache-to=type=registry,ref=$CACHE_REF,mode=max \
-          --file ./docker/Dockerfile .
-
-    - name: Get SemVer
+    - name: Setup semver
+      id: semver
       run: |
         semver=`npm run version --silent`
-        echo "SEMVER=$semver" >> $GITHUB_ENV
+        echo "::set-output name=SEMVER::$semver"
 
-    - name: Docker Tags by SemVer
-      uses: weseek/ghaction-docker-tags-by-semver@v1.0.3
+    - name: Docker meta
+      id: meta
+      uses: docker/metadata-action@v3
       with:
-        source: growi
-        target: weseek/growi
-        semver: ${{ env.SEMVER }}
-        publish: true
+        images: weseek/growi,ghcr.io/weseek/growi
+        tags: |
+          type=raw,value=${{ steps.semver.outputs.SEMVER }}
+          type=raw,value=${{ steps.semver.outputs.SEMVER }}.{{sha}}
+
+    - 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
@@ -52,15 +40,29 @@ jobs:
         username: wsmoogle
         password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
 
-    - name: Docker Tags by SemVer in Github Container Registry
-      uses: weseek/ghaction-docker-tags-by-semver@v1.0.3
+    - name: Set up Docker Buildx
+      uses: docker/setup-buildx-action@v1
+
+    - name: Cache Docker layers
+      uses: actions/cache@v2
+      with:
+        path: /tmp/.buildx-cache
+        key: ${{ runner.os }}-buildx-app-${{ github.sha }}
+        restore-keys: |
+          ${{ runner.os }}-buildx-app-
+
+    - name: Build and push
+      uses: docker/build-push-action@v2
       with:
-        source: growi
-        target: ghcr.io/weseek/growi
-        semver: ${{ env.SEMVER }}
-        publish: true
+        context: .
+        file: ./docker/Dockerfile
+        platforms: linux/amd64
+        push: true
+        cache-from: type=local,src=/tmp/.buildx-cache
+        cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
+        tags: ${{ steps.meta.outputs.tags }}
 
-    - name: Check whether workspace is clean
+    - name: Move cache
       run: |
-        STATUS=`git status --porcelain`
-        if [ -z "$STATUS" ]; then exit 0; else exit 1; fi
+        rm -rf /tmp/.buildx-cache
+        mv /tmp/.buildx-cache-new /tmp/.buildx-cache

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

@@ -0,0 +1,87 @@
+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: Setup semver
+      id: semver
+      working-directory: ./packages/slackbot-proxy
+      run: |
+        semver=`npm run version --silent`
+        echo "::set-output name=SEMVER::$semver"
+
+    - name: Docker meta
+      id: meta
+      uses: docker/metadata-action@v3
+      with:
+        images: weseek/growi-slackbot-proxy,ghcr.io/weseek/growi-slackbot-proxy,asia.gcr.io/${{ secrets.GCP_PRJ_ID_SLACKBOT_PROXY }}/growi-slackbot-proxy
+        tags: |
+          type=raw,value=latest
+          type=raw,value=${{ steps.semver.outputs.SEMVER }}
+
+    - 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: Set up Docker Buildx
+      uses: docker/setup-buildx-action@v1
+
+    - name: Cache Docker layers
+      uses: actions/cache@v2
+      with:
+        path: /tmp/.buildx-cache
+        key: ${{ runner.os }}-buildx-slackbot-proxy-${{ github.sha }}
+        restore-keys: |
+          ${{ runner.os }}-buildx-slackbot-proxy-
+
+    - name: Build and push
+      uses: docker/build-push-action@v2
+      with:
+        context: .
+        file: ./packages/slackbot-proxy/docker/Dockerfile
+        platforms: linux/amd64
+        push: true
+        cache-from: type=local,src=/tmp/.buildx-cache
+        cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
+        tags: ${{ steps.meta.outputs.tags }}
+
+    - name: Move cache
+      run: |
+        rm -rf /tmp/.buildx-cache
+        mv /tmp/.buildx-cache-new /tmp/.buildx-cache
+
+    - 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

+ 49 - 39
.github/workflows/release.yml

@@ -3,7 +3,8 @@ name: Release
 on:
   push:
     branches:
-      - release/**
+      - release/current
+      - release/*.*.*
 
 jobs:
   github-release:
@@ -66,36 +67,62 @@ jobs:
         git fetch --tags
         git checkout refs/tags/v${{ needs.github-release.outputs.RELEASE_VERSION }}
 
-    - name: Determine suffix
+    - name: Setup suffix
+      id: suffix
       run: |
         [[ ${{ matrix.flavor }} = "nocdn" ]] && suffix="-nocdn" || suffix=""
-        echo "SUFFIX=$suffix" >> $GITHUB_ENV
+        echo "::set-output name=SUFFIX::$suffix"
 
-    - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@v1
+    - name: Docker meta
+      id: meta
+      uses: docker/metadata-action@v3
+      with:
+        images: weseek/growi,ghcr.io/weseek/growi
+        flavor: |
+          suffix=${{ steps.suffix.outputs.SUFFIX }}
+        tags: |
+          type=raw,value=latest
+          type=semver,value=${{ needs.github-release.outputs.RELEASE_VERSION }},pattern={{major}}
+          type=semver,value=${{ needs.github-release.outputs.RELEASE_VERSION }},pattern={{major}}.{{minor}}
+          type=semver,value=${{ needs.github-release.outputs.RELEASE_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
 
     - name: Login to docker.io registry
       run: |
         echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
 
-    - name: Build Docker Image
-      run: |
-        docker buildx build \
-          --tag growi${{ env.SUFFIX }} \
-          --build-arg flavor=${{ matrix.flavor }} \
-          --platform linux/amd64 \
-          --load \
-          --file ./docker/Dockerfile .
-
-    - name: Docker Tags by SemVer
-      uses: weseek/ghaction-docker-tags-by-semver@v1.0.5
+    - 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: Set up Docker Buildx
+      uses: docker/setup-buildx-action@v1
+
+    - name: Cache Docker layers
+      uses: actions/cache@v2
+      with:
+        path: /tmp/.buildx-cache
+        key: ${{ runner.os }}-buildx-app-${{ github.sha }}
+        restore-keys: |
+          ${{ runner.os }}-buildx-app-
+
+    - name: Build and push
+      uses: docker/build-push-action@v2
       with:
-        source: growi${{ env.SUFFIX }}
-        target: weseek/growi
-        semver: ${{ needs.github-release.outputs.RELEASE_VERSION }}
-        suffix: ${{ env.SUFFIX }}
-        additional-tags: 'latest'
-        publish: true
+        context: .
+        file: ./docker/Dockerfile
+        platforms: linux/amd64
+        push: true
+        cache-from: type=local,src=/tmp/.buildx-cache
+        cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
+        tags: ${{ steps.meta.outputs.tags }}
+
+    - name: Move cache
+      run: |
+        rm -rf /tmp/.buildx-cache
+        mv /tmp/.buildx-cache-new /tmp/.buildx-cache
 
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v2
@@ -105,23 +132,6 @@ jobs:
         repository: weseek/growi
         readme-filepath: ./docker/README.md
 
-    - 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: Docker Tags by SemVer in Github Container Registry
-      uses: weseek/ghaction-docker-tags-by-semver@v1.0.3
-      with:
-        source: growi${{ env.SUFFIX }}
-        target: ghcr.io/weseek/growi
-        semver: ${{ needs.github-release.outputs.RELEASE_VERSION }}
-        suffix: ${{ env.SUFFIX }}
-        additional-tags: 'latest'
-        publish: true
-
     - name: Slack Notification
       uses: weseek/ghaction-release-slack-notification@master
       with:

+ 2 - 6
.vscode/settings.json

@@ -1,10 +1,7 @@
 {
-  // 既定の改行文字。LF の場合には \n を CRLF の場合には \r\n を使用してください。
   "files.eol": "\n",
-  // 指定した構文に対してプロファイルを定義するか、特定の規則がある独自のプロファイルをご使用ください。
-  "emmet.syntaxProfiles": {
-    "javascript": "jsx"
-  },
+
+  "eslint.workingDirectories": [{ "mode": "auto" }],
 
   // 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
@@ -16,7 +13,6 @@
   "[scss]": {
     "editor.formatOnSave": true
   },
-  "stylelint.autoFixOnSave": true,
 
   // for vscode-eslint
   "[javascript]": {

+ 30 - 4
CHANGES.md

@@ -1,14 +1,41 @@
 # 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)
 * Support: Upgrade libs
     * connect-mongo
+    * i18next
     * migrate-mongo
     * mongoose
     * stream-to-promise
+    * validator
     * ws
+    * nodemailer
+    * i18next-express-middleware
+    * growi-commons
+    * growi-plugin-attachment-refs
+    * growi-plugin-lsx
 
 ## v4.2.20
 
@@ -36,14 +63,14 @@
 * Fix: Global notification to Slack does not encode spaces of page path
 * Support: Upgrade libs
     * @google-cloud/storage
-    * @slack/web-api
-    * @slack/webhook
 
 ## v4.2.17
 
 * Improvement: Invoke garbage collection when reindex all pages by elasticsearch
 * Improvement: Hide Sidebar at shared pages
 * Fix: No unsaved alert is displayed without difference the latest markdown and editor value
+* Support: Update libs
+    * eslint-config-weseek
 
 ## v4.2.16
 
@@ -61,7 +88,6 @@
     * reactstrap
 
 
-
 ## v4.2.14
 
 * Feature: Add an option to restrict publishing email property for new users

+ 1 - 17
bin/generate-plugin-definitions-source.js

@@ -20,25 +20,9 @@ const OUT = helpers.root('tmp/plugins/plugin-definitions.js');
 
 
 // list plugin names
-let pluginNames = pluginUtils.listPluginNames(helpers.root());
+const pluginNames = pluginUtils.listPluginNames(helpers.root());
 logger.info('Detected plugins: ', pluginNames);
 
-// add from PLUGIN_NAMES_TOBE_LOADED when development
-if (process.env.NODE_ENV === 'development'
-    && process.env.PLUGIN_NAMES_TOBE_LOADED !== undefined
-    && process.env.PLUGIN_NAMES_TOBE_LOADED.length > 0) {
-  const pluginNamesDev = process.env.PLUGIN_NAMES_TOBE_LOADED.split(',');
-
-  logger.info('Detected plugins from PLUGIN_NAMES_TOBE_LOADED: ', pluginNamesDev);
-
-  // merge and remove duplicates
-  if (pluginNamesDev.length > 0) {
-    pluginNames = pluginNames.concat(pluginNamesDev);
-    pluginNames = Array.from(new Set(pluginNames));
-  }
-}
-
-
 // get definitions
 const definitions = pluginNames
   .map((name) => {

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

@@ -2,5 +2,5 @@
 
 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 - 5
config/env.dev.js

@@ -12,16 +12,15 @@ module.exports = {
   HACKMD_URI_FOR_SERVER: 'http://hackmd:3000',
   // DRAWIO_URI: 'http://localhost:8080/?offline=1&https=0',
   // S2SMSG_PUBSUB_SERVER_TYPE: 'nchan',
-  PLUGIN_NAMES_TOBE_LOADED: [
-    // 'growi-plugin-lsx',
-    // 'growi-plugin-pukiwiki-like-linker',
-    // 'growi-plugin-attachment-refs',
-  ],
   // PUBLISH_OPEN_API: true,
   // USER_UPPER_LIMIT: 0,
   // DEV_HTTPS: true,
   // FORCE_WIKI_MODE: 'private', // 'public', 'private', undefined
   // 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_APP_ID_FOR_GROWI_CLOUD: '012345',
 };

+ 80 - 27
docker/Dockerfile

@@ -13,16 +13,20 @@ LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 ENV appDir /opt/growi
 
 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
 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
 RUN yarn install --production
 
+# make artifacts
+RUN tar cf node_modules.tar node_modules \
+  packages/app/node_modules \
+  packages/slack/node_modules
+
 
 
 ##
 ## prebuilder-default
 ##
 FROM node:14-slim AS prebuilder-default
-LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 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,89 @@ COPY docker/nocdn/env.prod.js ${appDir}/config/
 ##
 ## builder
 ##
+# FROM prebuilder-${flavor}
 FROM prebuilder-${flavor} AS builder
 
 ENV appDir /opt/growi
 
 WORKDIR ${appDir}
 
+COPY ./package.json ./
+COPY ./yarn.lock ./
+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
-RUN yarn build:prod
+RUN yarn lerna run build
+
+# make artifacts
+RUN tar cf packages.tar \
+  package.json \
+  yarn.lock \
+  config \
+  public \
+  resource \
+  src \
+  tmp \
+  packages/app/package.json \
+  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
 ##
-FROM node:14-alpine
+FROM node:14-slim
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 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 \
-  ${appDir}/node_modules ${appDir}/node_modules
+  ${appDir}/node_modules.tar ${appDir}/
 COPY --from=builder --chown=node:node \
-  ${appDir} ${appDir}
+  ${appDir}/packages.tar ${appDir}/
 
+# extract artifacts as 'node' user
+USER node
 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
 EXPOSE 3000
 
-ENTRYPOINT ["/sbin/tini", "-e", "143", "--", "/docker-entrypoint.sh"]
+ENTRYPOINT ["/tini", "-e", "143", "--", "/docker-entrypoint.sh"]
 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
 ------------------------------------------------
 
-* [`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?

+ 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 -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"
+}

+ 10 - 206
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.2.21-RC",
+  "version": "4.3.0-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -19,6 +19,11 @@
   "bugs": {
     "url": "https://github.com/weseek/growi/issues"
   },
+  "private": true,
+  "workspaces": {
+    "packages": ["packages/*"],
+    "nohoist": ["**/slackbot-proxy/bootstrap"]
+  },
   "scripts": {
     "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\"",
@@ -28,16 +33,14 @@
     "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:poll": "npm run build:dev:app:watch:poll",
-    "build:dev": "npm run build:dev:app",
-    "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:dev": "yarn build:dev:app",
+    "build:slack": "lerna run build --scope @growi/slack",
     "build": "npm run build:dev:watch",
     "build:poll": "npm run build:dev:watch:poll",
     "clean:app": "rimraf -- public/js public/styles",
     "clean:report": "rimraf -- report",
     "clean": "npm-run-all -p clean:*",
     "console": "env-cmd -f config/env.dev.js node --experimental-repl-await src/server/console.js",
-    "heroku-postbuild": "sh bin/heroku/install-packages.sh && npm run build:prod",
     "lint:js:fix": "eslint \"**/*.{js,jsx}\" --fix",
     "lint:js": "eslint \"**/*.{js,jsx}\"",
     "lint:styles:fix": "stylelint --fix src/client/styles/scss/**/*.scss",
@@ -51,8 +54,7 @@
     "migrate:down": "migrate-mongo down -f config/migrate.js",
     "plugin:def": "node bin/generate-plugin-definitions-source.js",
     "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: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: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",
     "prelint:swagger2openapi": "npm run build:apiv3:jsdoc",
     "preserver:prod": "npm run migrate",
     "prestart": "npm run build:prod",
@@ -71,207 +73,9 @@
     "webpack": "webpack"
   },
   "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.4",
-    "helmet": "^3.13.0",
-    "i18next": "^19.0.0",
-    "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.0.0",
-    "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": "^12.0.0",
-    "ws": "^7.4.6",
-    "xss": "^1.0.6"
   },
   "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": {
     "@root": ".",

+ 222 - 0
packages/app/package.json

@@ -0,0 +1,222 @@
+{
+  "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",
+    "prebuild": "cd ../../ && yarn plugin:def && yarn resource"
+  },
+  "// 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.2",
+    "growi-plugin-lsx": "^4.0.3",
+    "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 },
+      },
+    ],
+  },
+};

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

@@ -0,0 +1 @@
+src/public/bootstrap

+ 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

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

@@ -0,0 +1,110 @@
+# 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
+
+# make artifacts
+RUN tar cf node_modules.tar node_modules \
+  packages/slackbot-proxy/node_modules \
+  packages/slack/node_modules
+
+
+##
+## 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 dependent packages
+COPY --from=deps-resolver-dev \
+  ${appDir}/node_modules.tar ${appDir}/
+
+# extract node_modules.tar
+RUN tar xf node_modules.tar
+RUN rm node_modules.tar
+
+COPY ./package.json ./
+COPY ./lerna.json ./
+COPY ./tsconfig.base.json ./
+# copy all related packages
+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)

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

@@ -0,0 +1,73 @@
+{
+  "name": "@growi/slackbot-proxy",
+  "version": "0.9.1-RC",
+  "license": "MIT",
+  "scripts": {
+    "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
+    "cp:public": "cp -RT ./src/public ./dist/public",
+    "cp:views": "cp -RT ./src/views ./dist/views",
+    "cp:bootstrap": "cp -RT ./node_modules/bootstrap/dist ./dist/public/bootstrap",
+    "cp:bootstrap:dev": "cp -RT ./node_modules/bootstrap/dist ./src/public/bootstrap",
+    "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",
+    "postbuild": "yarn cp:public && yarn cp:views && yarn cp:bootstrap",
+    "predev": "yarn cp:bootstrap:dev",
+    "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",
+    "bootstrap": "^5.0.2",
+    "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.authorizeResult);
+    logger.debug('receive interaction', req.body);
+
+    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>');
+      },
+    });
+  }
+
+}

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

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

+ 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;
+        },
+      },
+    });
+  }
+
+}

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

@@ -0,0 +1,123 @@
+import { Inject, Service } from '@tsed/di';
+import { WebClient, LogLevel, Block } 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';
+const isOfficialMode = process.env.OFFICIAL_MODE === 'true';
+
+@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 replyToSlack(client: WebClient, channel: string, user: string, text: string, blocks: Array<Block>): Promise<void> {
+    await client.chat.postEphemeral({
+      channel,
+      user,
+      // Recommended including 'text' to provide a fallback when using blocks
+      // refer to https://api.slack.com/methods/chat.postEphemeral#text_usage
+      text,
+      blocks,
+    });
+    return;
+  }
+
+  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';
+      const blocks = [
+        generateMarkdownSectionBlock(invalidErrorMsg),
+      ];
+      await this.replyToSlack(client, channel, payload.user.id, 'Invalid URL', blocks);
+      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 });
+
+    if (isOfficialMode) {
+      const blocks = [
+        generateMarkdownSectionBlock('Successfully registered with the proxy! Please check test connection in your GROWI'),
+      ];
+      await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
+      return;
+
+    }
+
+    const blocks = [
+      generateMarkdownSectionBlock('Please enter and update the following Proxy URL to slack bot setting form in your GROWI'),
+      generateMarkdownSectionBlock(`Proxy URL: ${serverUri}`),
+    ];
+    await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
+    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;

+ 9 - 0
packages/slackbot-proxy/src/views/commons/head.ejs

@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html lang="en">
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width,initial-scale=1">
+  <meta name="description" content="GROWI is a wiki that can be written in Markdown and easy to share information with everyone.">
+  <link rel="shortcut icon" href="/images/growi-bot.png">
+  <title>GROWI Official Bot</title>
+  <link href="/bootstrap/css/bootstrap.min.css" rel="stylesheet" >
+</html>

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

@@ -0,0 +1,95 @@
+<%- include('commons/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>

+ 79 - 0
packages/slackbot-proxy/src/views/term.ejs

@@ -0,0 +1,79 @@
+<%- include('commons/head'); %>
+
+<body class="pt-5 m-3 w-50 mx-auto d-flex flex-column">
+  <h1 class="text-center">Terms of Service </h1>
+    <h2 class="text-center">At First</h2>
+      <p>This Terms of Use Agreement(hereinafter referred to as the "Agreement") stipulates the terms and conditions of use for the services provided by WESEEK,Inc. on GROWI Slack-Bot(hereinafter referred to as the "Services"). All registered users(hereinafter referred to as "Users") are required to follow these Terms of Service.</p>
+
+  <h2 class="text-center">Application of Terms</h2>
+    <p>This Agreement shall apply to all relationships between the User and WESEEK,Inc. regarding the use of the Service.  In addition to this Agreement, WESEEK,Inc. may make various provisions regarding the Service, such as rules for use (hereinafter referred to as "Individual Provisions"). These individual regulations may be called by any name. These Individual Regulations, regardless of their names, shall constitute a part of these Terms. In the event that the provisions of these Terms of Use conflict with the provisions of the Individual Provisions of the preceding article, the provisions of the Individual Provisions shall take precedence unless otherwise specified in the Individual Provisions.</p>
+
+  <h2 class="text-center">User Registration</h2>
+   <p>Registration for this service shall be completed when the applicant agrees to these Terms of Use, applies for registration in accordance with the method specified by WESEEK,Inc. approves the application. If WESEEK,Inc. determines that the applicant has any of the following reasons, WESEEK,Inc. may not approve the application for registration, and WESEEK,Inc. shall not be obligated to disclose any of the reasons.</p>
+    <ul>
+      <li>If the applicant has provided false information in the application for registration.</li>
+      <li>If the application is from a person who has violated this agreement.</li>
+      <li>In addition, when WESEEK,Inc. deems that the registration is not appropriate.</li>
+    </ul>
+
+  <h2 class="text-center">Account</h2>
+    <p>Users are responsible for maintaining the privacy and security of their accounts. We will not be liable for any damage or loss caused by your failure to protect your login information, including your password.</p>
+
+  <h2 class="text-center">Prohibited Matters</h2>
+    <p>Users shall not commit any of the following acts when using the Service.</p>
+    <ul>
+      <li>Acts that violate laws or public order and morals</li>
+      <li>Acts related to criminal acts</li>
+      <li>Acts that destroy or interfere with the functions of the servers or networks of WESEEK,Inc., other users, or other third parties.</li>
+      <li>Acts that may interfere with the operation of WESEEK,Inc.'s services.</li>
+      <li>Unauthorized access or attempts to do so.</li>
+      <li>Acts that collect or accumulate personal information about other users.</li>
+      <li>Acts of using this service for illegal purposes.</li>
+      <li>Actions that cause disadvantage, damage, or discomfort to other users of this service or other third parties.</li>
+      <li>Act of impersonating other users.</li>
+      <li>Advertising, solicitation, or business activities on this service that are not authorized by WESEEK,Inc..</li>
+      <li>Directly or indirectly providing benefits to antisocial forces in relation to WESEEK,Inc.'s services.</li>
+      <li>Any other acts that WESEEK,Inc. deems inappropriate.</li>
+    </ul>
+
+  <h2 class="text-center">Suspension of Provision of the Service, etc.</h2>
+    <p>WESEEK,Inc. may suspend or interrupt the provision of all or part of the Service without prior notice to the User if WESEEK,Inc. deems any of the following to be the case When performing maintenance, inspection, or updating of the computer system for this service</p>
+    <ul>
+      <li>In the event that the provision of the Service becomes difficult due to force majeure such as earthquakes, lightning, fire, power outages, or natural disasters.</li>
+      <li>When a computer or communication line is stopped due to an accident</li>
+      <li>In any other cases where WESEEK,Inc. deems it difficult to provide the Service.</li>
+    </ul>
+    <p>WESEEK,Inc. shall not be liable for any disadvantage or damage incurred by the User or any third party due to the suspension or interruption of the provision of the Service.</p>
+
+  <h2 class="text-center">Restriction of Use and Cancellation of Registration</h2>
+    <p>In the event that a User falls under any of the following, WESEEK,Inc. may, without prior notice, restrict the User from using all or part of the Service, or cancel the User's registration.</P>
+    <ul>
+      <li>If the user violates any of the provisions of this agreement.</li>
+      <li>When it is found that there is a false fact in the registered information.</li>
+      <li>When there is no response to communication from WESEEK,Inc. for a certain period of time.</li>
+      <li>When there is no use of this service for a certain period of time after the last use.</li>
+      <li>In any other cases where WESEEK,Inc. deems the use of the Service to be inappropriate.</li>
+    </ul>
+    <p>WESEEK,Inc. shall not be liable for any damages incurred by the User as a result of any action taken by WESEEK,Inc. in accordance with this Article.</p>
+
+  <h2 class="text-center">No Warranty and Disclaimer</h2>
+    <p>WESEEK,Inc does not warrant, expressly or impliedly, that the Service is free from defects in fact or in law (including defects in safety, reliability, accuracy, completeness, effectiveness, fitness for a particular purpose, security, errors or bugs, infringement of rights, etc.).
+      WESEEK,Inc shall not be liable for any damages incurred by the User due to the Service. WESEEK,Inc shall not be liable for any transactions, communications, or disputes that may arise between the User and other users or third parties in relation to the Service.</p>
+  <h2 class="text-center">Modification of Service Contents, etc.</h2>
+    <p>WESEEK,Inc. may change the contents of the Service or discontinue the provision of the Service without notice to the User, and shall not be liable for any damages incurred by the User as a result of such changes.</p>
+
+  <h2 class="text-center">Modification of the Terms of Service, etx.</h2>
+    <p>WESEEK,Inc. may change the Terms of Use at any time without notice to the User, if WESEEK,Inc. deems it necessary. In the event that a User begins to use the Service after a change to the Terms, the User shall be deemed to have agreed to the changed Terms.</p>
+
+  <h2 class="text-center">Handling of Personal Information</h2>
+    <p>WESEEK,Inc. shall handle personal information obtained through the use of the Service in an appropriate manner in accordance with WESEEK,Inc.'s "Privacy Policy".</p>
+
+  <h2 class="text-center">Notification or Communication</h2>
+    <p>Notification or communication between the User and WESEEK,Inc. shall be conducted in a manner determined by WESEEK,Inc.. Unless the User notifies WESEEK,Inc. of a change in the method specified separately by WESEEK,Inc., WESEEK,Inc. will assume that the currently registered contact information is valid and send notifications or communications to said contact information, and these notifications or communications will be deemed to have reached the User at the time they are sent.</p>
+
+  <h2 class="text-center">Prohibition of Transfer of Rights and Obligations</h2>
+    <p>The User may not assign or pledge to a third party his/her position in the User Agreement or rights or obligations under this Agreement without the prior written consent of WESEEK,Inc..</p>
+
+  <h2 class="text-center">Governing Law</h2>
+    <p>These Terms of Use shall be governed by and construed in accordance with the laws of Japan. In the event of a dispute regarding the Service, the court having jurisdiction over the location of the head office of WESEEK,Inc. shall have exclusive jurisdiction.</p>
+</body>

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

@@ -0,0 +1,45 @@
+<%- include('commons/head'); %>
+
+<body class="pt-3">
+  <div class="container border p-5">
+    <div class="text-center">
+      <h1>GROWI Bot</h1>
+      <h4>GROWI is a wiki that can be written in Markdown and easy to share information with everyone.</h4>
+
+      <div>
+        <img height="300" width="300" alt="GROWi Bot" src="/images/growi-bot.png" />
+      </div>
+      <div class="m-3">
+        <a href=<%- url %>>
+          <img alt="Add to Slack" height="40" width="139" src="/images/add-to-slack.png"/>
+        </a>
+      </div>
+      <div class="d-flex justify-content-evenly my-3">
+        <% if (isOfficialMode) { %>
+          <a href="/privacy">
+            Privacy Policy
+          </a>
+          <a href="/term">
+            Term of Service
+          </a>
+        <% } %>
+      </div>
+    </div>
+    <div class="m-3">
+      <div class="d-flex flex-column align-items-center justify-content-center">
+          <ul>
+            With GROWI for Slack, you can:
+            <ul>
+              <li>Search pages for multiple GROWI apps.</li>
+              <li>Create pages easily.</li>
+            </ul>
+            </li>
+          </ul>
+        <div class="mt-3">
+          GROWI is open-source software developed by WESEEK, Inc and we are looking for contributors who can work with us.<br>
+          Please <a href="https://growi-slackin.weseek.co.jp/">join</a> Slack and feel free to talk to WESEEK members!
+        </div>
+      </div>
+    </div>
+  </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


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