فهرست منبع

Merge pull request #3954 from weseek/feat/growi-bot

Release
itizawa 5 سال پیش
والد
کامیت
74426bd073

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

@@ -17,5 +17,6 @@ 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';

+ 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];
+      }
+      // </div><div class="slack-talk-bubble">##*username*  HH:mm AM
+      copyline = '</div>\n<div class="slack-talk-bubble">\n\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 !== '');
+  // delete the first </div> and add </div> to the last row
+  blanksRemoved[0] = blanksRemoved[0].replace(/<\/div>/g, '');
+  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);
+};

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

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

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

@@ -88,6 +88,21 @@ const helmetOptions = isProduction ? {} : {
   exclude: [
     '**/*.spec.ts',
   ],
+  viewsDir: `${rootDir}/views`,
+  views: {
+    root: `${rootDir}/views`,
+    viewEngine: 'ejs',
+    extensions: {
+      ejs: 'ejs',
+    },
+  },
+  statics: {
+    '/': [
+      {
+        root: `${rootDir}/public`,
+      },
+    ],
+  },
 })
 export class Server {
 

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

+ 49 - 52
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;
@@ -53,25 +53,43 @@ export class SlackCtrl {
   @Inject()
   unregisterService: UnregisterService;
 
-  @Get('/install')
-  async install(): Promise<string> {
-    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',
-      ],
+  /**
+   * 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,
+        },
+      });
     });
 
-    return `<a href="${url}">`
-      // eslint-disable-next-line max-len
-      + '<img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcSet="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" />'
-      + '</a>';
+    // 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')
@@ -113,7 +131,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 +167,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 +203,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 +219,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);
     }
 
     /*
@@ -280,7 +277,7 @@ export class SlackCtrl {
       + '<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="/slack/install">'
+      + '<a href="/">'
       + 'Go to install page'
       + '</a>'
       + '</body></html>');

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

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

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


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


+ 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()

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

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

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

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

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

+ 12 - 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() => {
@@ -48,6 +49,9 @@ const SlackIntegration = (props) => {
     catch (err) {
       toastError(err);
     }
+    finally {
+      setIsLoading(false);
+    }
   }, [appContainer.apiv3]);
 
   const resetAllSettings = async() => {
@@ -157,6 +161,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

+ 23 - 5
src/client/js/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -35,12 +35,11 @@ const BotCreateProcess = () => {
   );
 };
 
-const BotInstallProcess = () => {
+const BotInstallProcessForOfficialBot = () => {
   const { t } = useTranslation();
   return (
     <div className="my-5 d-flex flex-column align-items-center">
-      {/* TODO: Insert install link */}
-      <button type="button" className="btn btn-primary text-nowrap" onClick={() => window.open('https://api.slack.com/apps', '_blank')}>
+      <button type="button" className="btn btn-primary text-nowrap" onClick={() => window.open('https://slackbot-proxy.growi.org/', '_blank')}>
         {t('admin:slack_integration.accordion.install_now')}
         <i className="fa fa-external-link ml-2" aria-hidden="true" />
       </button>
@@ -57,6 +56,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 +290,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 +325,7 @@ const WithProxyAccordions = (props) => {
     },
     '②': {
       title: 'install_bot_to_slack',
-      content: <BotInstallProcess />,
+      content: <BotInstallProcessForCustomBotWithProxy />,
     },
     '③': {
       title: 'register_for_growi_official_bot_proxy_service',

+ 3 - 1
src/server/routes/apiv3/slack-integration.js

@@ -33,7 +33,9 @@ module.exports = (crowi) => {
 
     if (slackAppIntegrationCount === 0) {
       return res.status(403).send({
-        message: 'The access token that identifies the request source is slackbot-proxy is invalid. Did you setup with `/growi register`?',
+        message: 'The access token that identifies the request source is slackbot-proxy is invalid. Did you setup with `/growi register`.\n'
+        + 'Or did you delete registration for GROWI ? if so, the link with GROWI has been disconnected. '
+        + 'Please unregister the information registered in the proxy and setup `/growi register` again.',
       });
     }
 

+ 8 - 4
src/server/service/slackbot.js

@@ -1,8 +1,11 @@
+
 const logger = require('@alias/logger')('growi:service:SlackBotService');
 const mongoose = require('mongoose');
 
 const PAGINGLIMIT = 10;
 
+const { reshapeContentsBody } = require('@growi/slack');
+
 const S2sMessage = require('../models/vo/s2s-message');
 const S2sMessageHandlable = require('./s2s-messaging/handlable');
 
@@ -171,10 +174,11 @@ class SlackBotService extends S2sMessageHandlable {
       return;
     }
 
-    const base = this.crowi.appService.getSiteUrl();
+    const appUrl = this.crowi.appService.getSiteUrl();
+    const appTitle = this.crowi.appService.getAppTitle();
 
     const urls = resultPaths.map((path) => {
-      const url = new URL(path, base);
+      const url = new URL(path, appUrl);
       return `<${decodeURI(url.href)} | ${decodeURI(url.pathname)}>`;
     });
 
@@ -235,6 +239,7 @@ class SlackBotService extends S2sMessageHandlable {
         user: body.user_id,
         text: 'Successed To Search',
         blocks: [
+          this.generateMarkdownSectionBlock(`<${decodeURI(appUrl)}|*${appTitle}*>`),
           this.generateMarkdownSectionBlock(keywordsAndDesc),
           this.generateMarkdownSectionBlock(`${urls.join('\n')}`),
           actionBlocks,
@@ -302,8 +307,7 @@ class SlackBotService extends S2sMessageHandlable {
   async createPageInGrowi(client, payload) {
     const Page = this.crowi.model('Page');
     const pathUtils = require('growi-commons').pathUtils;
-
-    const contentsBody = payload.view.state.values.contents.contents_input.value;
+    const contentsBody = reshapeContentsBody(payload.view.state.values.contents.contents_input.value);
 
     try {
       let path = payload.view.state.values.path.path_input.value;