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

Merge branch 'feat/growi-bot' into feat/growi-bot-reshape-slack-conversation-for-create-command

hakumizuki 4 лет назад
Родитель
Сommit
0d6db099c2

+ 52 - 34
packages/slackbot-proxy/src/controllers/slack.ts

@@ -7,7 +7,7 @@ import axios from 'axios';
 import { WebAPICallResult } from '@slack/web-api';
 
 import {
-  generateMarkdownSectionBlock, parseSlashCommand, postEphemeralErrors, verifySlackRequest,
+  generateMarkdownSectionBlock, GrowiCommand, parseSlashCommand, postEphemeralErrors, verifySlackRequest,
 } from '@growi/slack';
 
 import { Relation } from '~/entities/relation';
@@ -19,7 +19,7 @@ import { AddSigningSecretToReq } from '~/middlewares/slack-to-growi/add-signing-
 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 { SelectRequestService } from '~/services/SelectRequestService';
+import { SelectGrowiService } from '~/services/SelectGrowiService';
 import { RegisterService } from '~/services/RegisterService';
 import { UnregisterService } from '~/services/UnregisterService';
 import { InvalidUrlError } from '../models/errors';
@@ -45,7 +45,7 @@ export class SlackCtrl {
   orderRepository: OrderRepository;
 
   @Inject()
-  selectRequestService: SelectRequestService;
+  selectGrowiService: SelectGrowiService;
 
   @Inject()
   registerService: RegisterService;
@@ -74,6 +74,45 @@ export class SlackCtrl {
       + '</a>';
   }
 
+  /**
+   * 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> {
@@ -113,7 +152,10 @@ export class SlackCtrl {
     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.find({ installation });
+    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({
@@ -146,37 +188,13 @@ export class SlackCtrl {
     });
 
     if (body.growiUris != null && body.growiUris.length > 0) {
-      return this.selectRequestService.process(growiCommand, authorizeResult, body);
+      return this.selectGrowiService.process(growiCommand, authorizeResult, body);
     }
 
     /*
      * forward to GROWI server
      */
-    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');
-    const botToken = installation?.data.bot?.token;
-
-    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);
-    }
+    this.sendCommand(growiCommand, relations, body);
   }
 
   @Post('/interactions')
@@ -206,7 +224,7 @@ export class SlackCtrl {
     // register
     if (callBackId === 'register') {
       try {
-        await this.registerService.insertOrderRecord(this.orderRepository, installation, authorizeResult.botToken, payload);
+        await this.registerService.insertOrderRecord(installation, authorizeResult.botToken, payload);
       }
       catch (err) {
         if (err instanceof InvalidUrlError) {
@@ -222,14 +240,14 @@ export class SlackCtrl {
 
     // unregister
     if (callBackId === 'unregister') {
-      await this.unregisterService.unregister(this.relationRepository, installation, authorizeResult, payload);
+      await this.unregisterService.unregister(installation, authorizeResult, payload);
       return;
     }
 
     // forward to GROWI server
     if (callBackId === 'select_growi') {
-      await this.selectRequestService.forwardRequest(this.relationRepository, installation, payload);
-      return;
+      const selectedGrowiInformation = await this.selectGrowiService.handleSelectInteraction(installation, payload);
+      return this.sendCommand(selectedGrowiInformation.growiCommand, [selectedGrowiInformation.relation], selectedGrowiInformation.sendCommandBody);
     }
 
     /*

+ 6 - 3
packages/slackbot-proxy/src/services/RegisterService.ts

@@ -1,4 +1,4 @@
-import { Service } from '@tsed/di';
+import { Inject, Service } from '@tsed/di';
 import { WebClient, LogLevel } from '@slack/web-api';
 import { generateInputSectionBlock, GrowiCommand, generateMarkdownSectionBlock } from '@growi/slack';
 import { AuthorizeResult } from '@slack/oauth';
@@ -12,6 +12,9 @@ const isProduction = process.env.NODE_ENV === 'production';
 @Service()
 export class RegisterService implements GrowiCommandProcessor {
 
+  @Inject()
+  orderRepository: OrderRepository;
+
   async process(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, body: {[key:string]:string}): Promise<void> {
     const { botToken } = authorizeResult;
 
@@ -45,7 +48,7 @@ export class RegisterService implements GrowiCommandProcessor {
   }
 
   async insertOrderRecord(
-      orderRepository: OrderRepository, installation: Installation | undefined,
+      installation: Installation | undefined,
       // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
       botToken: string | undefined, payload: any,
   ): Promise<void> {
@@ -79,7 +82,7 @@ export class RegisterService implements GrowiCommandProcessor {
       throw new InvalidUrlError(growiUrl);
     }
 
-    orderRepository.save({
+    this.orderRepository.save({
       installation, growiUrl, tokenPtoG, tokenGtoP,
     });
   }

+ 30 - 23
packages/slackbot-proxy/src/services/SelectRequestService.ts → packages/slackbot-proxy/src/services/SelectGrowiService.ts

@@ -1,16 +1,25 @@
-import { Service } from '@tsed/di';
-import axios from 'axios';
+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 { RelationRepository } from '~/repositories/relation';
 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 SelectRequestService implements GrowiCommandProcessor {
+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;
@@ -28,7 +37,7 @@ export class SelectRequestService implements GrowiCommandProcessor {
         callback_id: 'select_growi',
         title: {
           type: 'plain_text',
-          text: 'Slect Growi url',
+          text: 'Select GROWI Url',
         },
         submit: {
           type: 'plain_text',
@@ -68,40 +77,38 @@ export class SelectRequestService implements GrowiCommandProcessor {
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  async forwardRequest(relationRepository:RelationRepository, installation:Installation | undefined, payload:any):Promise<void> {
+  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 } = parsedPrivateMetadata;
+    const { growiCommand, body: sendCommandBody } = parsedPrivateMetadata;
 
-    if (growiCommand == null || body == null) {
-      throw new Error('growiCommand and body are required.');
+    if (growiCommand == null || sendCommandBody == null) {
+      // TODO: postEphemeralErrors
+      throw new Error('growiCommand and body params are required in private_metadata.');
     }
 
     // ovverride trigger_id
-    body.trigger_id = triggerId;
+    sendCommandBody.trigger_id = triggerId;
 
-    const relation = await relationRepository.findOne({ installation, growiUri });
+    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.');
     }
 
-    /*
-     * forward to GROWI server
-     */
-    // generate API URL
-    const url = new URL('/_api/v3/slack-integration/proxied/commands', relation.growiUri);
-    await axios.post(url.toString(), {
-      ...body,
+    return {
+      relation,
       growiCommand,
-    }, {
-      headers: {
-        'x-growi-ptog-tokens': relation.tokenPtoG,
-      },
-    });
+      sendCommandBody,
+    };
   }
 
 }

+ 6 - 3
packages/slackbot-proxy/src/services/UnregisterService.ts

@@ -1,4 +1,4 @@
-import { Service } from '@tsed/di';
+import { Inject, Service } from '@tsed/di';
 import { WebClient, LogLevel } from '@slack/web-api';
 import { GrowiCommand, generateMarkdownSectionBlock } from '@growi/slack';
 import { AuthorizeResult } from '@slack/oauth';
@@ -11,6 +11,9 @@ 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 });
@@ -42,12 +45,12 @@ export class UnregisterService implements GrowiCommandProcessor {
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  async unregister(relationRepository:RelationRepository, installation:Installation | undefined, authorizeResult: AuthorizeResult, payload: any):Promise<void> {
+  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 relationRepository.createQueryBuilder('relation')
+    const deleteResult = await this.relationRepository.createQueryBuilder('relation')
       .where('relation.growiUri IN (:uris)', { uris: growiUrls })
       .andWhere('relation.installationId = :installationId', { installationId: installation?.id })
       .delete()

+ 7 - 8
public/images/slack-integration/slackbot-difficulty-level-easy.svg

@@ -1,8 +1,11 @@
 <svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60">
   <defs>
     <style>
+      .cls-1, .cls-4 {
+        fill: none;
+      }
+
       .cls-1 {
-        fill: #fff;
         stroke: #81d5b8;
         stroke-width: 3px;
       }
@@ -14,17 +17,13 @@
       .cls-3 {
         stroke: none;
       }
-
-      .cls-4 {
-        fill: none;
-      }
     </style>
   </defs>
-  <g id="Group_4362" data-name="Group 4362" transform="translate(-538 -44)">
-    <g id="Ellipse_101" data-name="Ellipse 101" class="cls-1" transform="translate(538 44)">
+  <g id="Group_4413" data-name="Group 4413" transform="translate(-914.02 -70.04)">
+    <g id="Ellipse_115" data-name="Ellipse 115" class="cls-1" transform="translate(914.02 70.04)">
       <circle class="cls-3" cx="30" cy="30" r="30"/>
       <circle class="cls-4" cx="30" cy="30" r="28.5"/>
     </g>
-    <path id="Path_708" data-name="Path 708" class="cls-2" d="M1.456,0H8.9V-1.984H3.824V-5.152h4.16V-7.136H3.824V-9.872h4.9V-11.84H1.456ZM13.52-4.88l.352-1.3c.352-1.232.7-2.576,1.008-3.872h.064c.352,1.28.672,2.64,1.04,3.872l.352,1.3ZM17.68,0h2.48L16.352-11.84H13.568L9.776,0h2.4l.832-3.04h3.84Zm7.408.224c2.736,0,4.352-1.648,4.352-3.584a3.271,3.271,0,0,0-2.384-3.216L25.5-7.232c-1.008-.4-1.856-.7-1.856-1.552,0-.784.672-1.248,1.712-1.248a3.777,3.777,0,0,1,2.512.976l1.2-1.488a5.254,5.254,0,0,0-3.712-1.52c-2.4,0-4.1,1.488-4.1,3.424a3.43,3.43,0,0,0,2.4,3.184l1.584.672c1.056.448,1.776.72,1.776,1.6,0,.832-.656,1.36-1.888,1.36a4.658,4.658,0,0,1-3.008-1.312L20.768-1.5A6.309,6.309,0,0,0,25.088.224ZM33.232,0H35.6V-4.336l3.568-7.5H36.7L35.52-8.96c-.336.88-.688,1.712-1.056,2.624H34.4c-.368-.912-.688-1.744-1.024-2.624l-1.184-2.88H29.68l3.552,7.5Z" transform="translate(549.872 85.316) rotate(-11)"/>
+    <path id="Path_713" data-name="Path 713" class="cls-2" d="M1.456,0H8.9V-1.984H3.824V-5.152h4.16V-7.136H3.824V-9.872h4.9V-11.84H1.456ZM13.52-4.88l.352-1.3c.352-1.232.7-2.576,1.008-3.872h.064c.352,1.28.672,2.64,1.04,3.872l.352,1.3ZM17.68,0h2.48L16.352-11.84H13.568L9.776,0h2.4l.832-3.04h3.84Zm7.408.224c2.736,0,4.352-1.648,4.352-3.584a3.271,3.271,0,0,0-2.384-3.216L25.5-7.232c-1.008-.4-1.856-.7-1.856-1.552,0-.784.672-1.248,1.712-1.248a3.777,3.777,0,0,1,2.512.976l1.2-1.488a5.254,5.254,0,0,0-3.712-1.52c-2.4,0-4.1,1.488-4.1,3.424a3.43,3.43,0,0,0,2.4,3.184l1.584.672c1.056.448,1.776.72,1.776,1.6,0,.832-.656,1.36-1.888,1.36a4.658,4.658,0,0,1-3.008-1.312L20.768-1.5A6.309,6.309,0,0,0,25.088.224ZM33.232,0H35.6V-4.336l3.568-7.5H36.7L35.52-8.96c-.336.88-.688,1.712-1.056,2.624H34.4c-.368-.912-.688-1.744-1.024-2.624l-1.184-2.88H29.68l3.552,7.5Z" transform="translate(926.213 110.632) rotate(-11)"/>
   </g>
 </svg>

+ 3 - 20
public/images/slack-integration/slackbot-difficulty-level-hard.svg

@@ -2,29 +2,12 @@
   <defs>
     <style>
       .cls-1 {
-        fill: #fff;
-        stroke: #ff8080;
-        stroke-width: 3px;
-      }
-
-      .cls-2 {
         fill: #ff8080;
       }
-
-      .cls-3 {
-        stroke: none;
-      }
-
-      .cls-4 {
-        fill: none;
-      }
     </style>
   </defs>
-  <g id="Group_4364" data-name="Group 4364" transform="translate(-782.69 -44.039)">
-    <g id="Ellipse_103" data-name="Ellipse 103" class="cls-1" transform="translate(782.69 44.039)">
-      <circle class="cls-3" cx="30" cy="30" r="30"/>
-      <circle class="cls-4" cx="30" cy="30" r="28.5"/>
-    </g>
-    <path id="Path_710" data-name="Path 710" class="cls-2" d="M1.456,0H3.824V-5.12H8.3V0h2.352V-11.84H8.3v4.656H3.824V-11.84H1.456ZM15.792-4.88l.352-1.3c.352-1.232.7-2.576,1.008-3.872h.064c.352,1.28.672,2.64,1.04,3.872l.352,1.3ZM19.952,0h2.48L18.624-11.84H15.84L12.048,0h2.4l.832-3.04h3.84Zm6.24-9.968h1.536c1.52,0,2.352.432,2.352,1.712,0,1.264-.832,1.9-2.352,1.9H26.192ZM32.912,0,30.144-4.848A3.389,3.389,0,0,0,32.4-8.256c0-2.72-1.968-3.584-4.448-3.584H23.824V0h2.368V-4.48H27.84L30.272,0Zm1.824,0h3.376C41.6,0,43.84-1.984,43.84-5.968c0-4-2.24-5.872-5.856-5.872H34.736ZM37.1-1.9V-9.952h.736c2.208,0,3.584,1.088,3.584,3.984,0,2.88-1.376,4.064-3.584,4.064Z" transform="translate(792.315 85.277) rotate(-11)"/>
+  <g id="Group_4411" data-name="Group 4411" transform="translate(-996.298 -59)">
+    <path id="Ellipse_114" data-name="Ellipse 114" class="cls-1" d="M30,3A27.008,27.008,0,0,0,19.491,54.879,27.008,27.008,0,0,0,40.509,5.121,26.828,26.828,0,0,0,30,3m0-3A30,30,0,1,1,0,30,30,30,0,0,1,30,0Z" transform="translate(996.298 59)"/>
+    <path id="Path_711" data-name="Path 711" class="cls-1" d="M1.456,0H3.824V-5.12H8.3V0h2.352V-11.84H8.3v4.656H3.824V-11.84H1.456ZM15.792-4.88l.352-1.3c.352-1.232.7-2.576,1.008-3.872h.064c.352,1.28.672,2.64,1.04,3.872l.352,1.3ZM19.952,0h2.48L18.624-11.84H15.84L12.048,0h2.4l.832-3.04h3.84Zm6.24-9.968h1.536c1.52,0,2.352.432,2.352,1.712,0,1.264-.832,1.9-2.352,1.9H26.192ZM32.912,0,30.144-4.848A3.389,3.389,0,0,0,32.4-8.256c0-2.72-1.968-3.584-4.448-3.584H23.824V0h2.368V-4.48H27.84L30.272,0Zm1.824,0h3.376C41.6,0,43.84-1.984,43.84-5.968c0-4-2.24-5.872-5.856-5.872H34.736ZM37.1-1.9V-9.952h.736c2.208,0,3.584,1.088,3.584,3.984,0,2.88-1.376,4.064-3.584,4.064Z" transform="translate(1005.923 100.238) rotate(-11)"/>
   </g>
 </svg>

+ 7 - 8
public/images/slack-integration/slackbot-difficulty-level-normal.svg

@@ -1,8 +1,11 @@
 <svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60">
   <defs>
     <style>
+      .cls-1, .cls-4 {
+        fill: none;
+      }
+
       .cls-1 {
-        fill: #fff;
         stroke: #ffce60;
         stroke-width: 3px;
       }
@@ -14,17 +17,13 @@
       .cls-3 {
         stroke: none;
       }
-
-      .cls-4 {
-        fill: none;
-      }
     </style>
   </defs>
-  <g id="Group_4363" data-name="Group 4363" transform="translate(-664.69 -44.039)">
-    <g id="Ellipse_102" data-name="Ellipse 102" class="cls-1" transform="translate(664.69 44.039)">
+  <g id="Group_4412" data-name="Group 4412" transform="translate(-1074.69 -65.26)">
+    <g id="Ellipse_114" data-name="Ellipse 114" class="cls-1" transform="translate(1074.69 65.26)">
       <circle class="cls-3" cx="30" cy="30" r="30"/>
       <circle class="cls-4" cx="30" cy="30" r="28.5"/>
     </g>
-    <path id="Path_709" data-name="Path 709" class="cls-2" d="M1,0h1.54V-3.267c0-.935-.121-1.958-.2-2.838H2.4l.825,1.749L5.577,0h1.65V-8.14H5.687v3.245c0,.924.121,2,.209,2.849H5.841l-.814-1.76L2.662-8.14H1ZM12.474.154c2.156,0,3.641-1.617,3.641-4.257S14.63-8.294,12.474-8.294,8.833-6.754,8.833-4.1,10.318.154,12.474.154Zm0-1.408c-1.21,0-1.98-1.111-1.98-2.849s.77-2.794,1.98-2.794,1.98,1.045,1.98,2.794S13.684-1.254,12.474-1.254Zm6.864-5.6h1.056c1.045,0,1.617.3,1.617,1.177s-.572,1.309-1.617,1.309H19.338ZM23.958,0l-1.9-3.333a2.33,2.33,0,0,0,1.551-2.343c0-1.87-1.353-2.464-3.058-2.464H17.71V0h1.628V-3.08h1.133L22.143,0Zm1.254,0h1.463V-3.4c0-.77-.132-1.9-.209-2.673h.044l.649,1.914L28.424-.737h.935l1.254-3.421.66-1.914h.044c-.077.77-.2,1.9-.2,2.673V0H32.6V-8.14H30.8L29.447-4.334c-.176.506-.319,1.045-.5,1.573H28.9c-.165-.528-.319-1.067-.495-1.573L27.016-8.14h-1.8ZM36.135-3.355l.242-.891c.242-.847.484-1.771.693-2.662h.044c.242.88.462,1.815.715,2.662l.242.891ZM38.995,0H40.7L38.082-8.14H36.168L33.561,0h1.65l.572-2.09h2.64Zm2.662,0h4.928V-1.364h-3.3V-8.14H41.657Z" transform="translate(672.836 83.813) rotate(-11)"/>
+    <path id="Path_712" data-name="Path 712" class="cls-2" d="M1,0h1.54V-3.267c0-.935-.121-1.958-.2-2.838H2.4l.825,1.749L5.577,0h1.65V-8.14H5.687v3.245c0,.924.121,2,.209,2.849H5.841l-.814-1.76L2.662-8.14H1ZM12.474.154c2.156,0,3.641-1.617,3.641-4.257S14.63-8.294,12.474-8.294,8.833-6.754,8.833-4.1,10.318.154,12.474.154Zm0-1.408c-1.21,0-1.98-1.111-1.98-2.849s.77-2.794,1.98-2.794,1.98,1.045,1.98,2.794S13.684-1.254,12.474-1.254Zm6.864-5.6h1.056c1.045,0,1.617.3,1.617,1.177s-.572,1.309-1.617,1.309H19.338ZM23.958,0l-1.9-3.333a2.33,2.33,0,0,0,1.551-2.343c0-1.87-1.353-2.464-3.058-2.464H17.71V0h1.628V-3.08h1.133L22.143,0Zm1.254,0h1.463V-3.4c0-.77-.132-1.9-.209-2.673h.044l.649,1.914L28.424-.737h.935l1.254-3.421.66-1.914h.044c-.077.77-.2,1.9-.2,2.673V0H32.6V-8.14H30.8L29.447-4.334c-.176.506-.319,1.045-.5,1.573H28.9c-.165-.528-.319-1.067-.495-1.573L27.016-8.14h-1.8ZM36.135-3.355l.242-.891c.242-.847.484-1.771.693-2.662h.044c.242.88.462,1.815.715,2.662l.242.891ZM38.995,0H40.7L38.082-8.14H36.168L33.561,0h1.65l.572-2.09h2.64Zm2.662,0h4.928V-1.364h-3.3V-8.14H41.657Z" transform="translate(1082.836 105.033) rotate(-11)"/>
   </g>
 </svg>

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

@@ -27,6 +27,7 @@ const SlackIntegration = (props) => {
   const [slackAppIntegrations, setSlackAppIntegrations] = useState();
   const [proxyServerUri, setProxyServerUri] = useState();
   const [connectionStatuses, setConnectionStatuses] = useState({});
+  const [isLoading, setIsLoading] = useState(true);
 
 
   const fetchSlackIntegrationData = useCallback(async() => {
@@ -44,6 +45,7 @@ const SlackIntegration = (props) => {
       setSlackBotTokenEnv(slackBotTokenEnvVars);
       setSlackAppIntegrations(slackAppIntegrations);
       setProxyServerUri(proxyServerUri);
+      setIsLoading(false);
     }
     catch (err) {
       toastError(err);
@@ -157,6 +159,14 @@ const SlackIntegration = (props) => {
       break;
   }
 
+  if (isLoading) {
+    return (
+      <div className="text-muted text-center">
+        <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+      </div>
+    );
+  }
+
   return (
     <>
       <ConfirmBotChangeModal

+ 22 - 3
src/client/js/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -35,7 +35,7 @@ const BotCreateProcess = () => {
   );
 };
 
-const BotInstallProcess = () => {
+const BotInstallProcessForOfficialBot = () => {
   const { t } = useTranslation();
   return (
     <div className="my-5 d-flex flex-column align-items-center">
@@ -57,6 +57,25 @@ const BotInstallProcess = () => {
   );
 };
 
+const BotInstallProcessForCustomBotWithProxy = () => {
+  const { t } = useTranslation();
+  return (
+    <div className="container w-75 py-5">
+      <p>1. {t('admin:slack_integration.accordion.select_install_your_app')}</p>
+      <img src="/images/slack-integration/slack-bot-install-your-app-introduction.png" className="border border-light img-fluid mb-5" />
+      <p>2. {t('admin:slack_integration.accordion.select_install_to_workspace')}</p>
+      <img src="/images/slack-integration/slack-bot-install-to-workspace.png" className="border border-light img-fluid mb-5" />
+      <p>3. {t('admin:slack_integration.accordion.click_allow')}</p>
+      <img src="/images/slack-integration/slack-bot-install-your-app-transition-destination.png" className="border border-light img-fluid mb-5" />
+      <p>4. {t('admin:slack_integration.accordion.install_complete_if_checked')}</p>
+      <img src="/images/slack-integration/slack-bot-install-your-app-complete.png" className="border border-light img-fluid mb-5" />
+      <p>5. {t('admin:slack_integration.accordion.invite_bot_to_channel')}</p>
+      <img src="/images/slack-integration/slack-bot-install-to-workspace-joined-bot.png" className="border border-light img-fluid mb-1" />
+      <img src="/images/slack-integration/slack-bot-install-your-app-introduction-to-channel.png" className="border border-light img-fluid" />
+    </div>
+  );
+};
+
 const RegisteringProxyUrlProcess = () => {
   const { t } = useTranslation();
   return (
@@ -272,7 +291,7 @@ const WithProxyAccordions = (props) => {
   const officialBotIntegrationProcedure = {
     '①': {
       title: 'install_bot_to_slack',
-      content: <BotInstallProcess />,
+      content: <BotInstallProcessForOfficialBot />,
     },
     '②': {
       title: 'register_for_growi_official_bot_proxy_service',
@@ -307,7 +326,7 @@ const WithProxyAccordions = (props) => {
     },
     '②': {
       title: 'install_bot_to_slack',
-      content: <BotInstallProcess />,
+      content: <BotInstallProcessForCustomBotWithProxy />,
     },
     '③': {
       title: 'register_for_growi_official_bot_proxy_service',