|
@@ -4,13 +4,15 @@ import {
|
|
|
|
|
|
|
|
import axios from 'axios';
|
|
import axios from 'axios';
|
|
|
|
|
|
|
|
-import { WebAPICallResult, WebClient } from '@slack/web-api';
|
|
|
|
|
|
|
+import { WebAPICallResult } from '@slack/web-api';
|
|
|
import { Installation } from '@slack/oauth';
|
|
import { Installation } from '@slack/oauth';
|
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
import {
|
|
|
- markdownSectionBlock, GrowiCommand, parseSlashCommand, postEphemeralErrors, verifySlackRequest, generateWebClient,
|
|
|
|
|
|
|
+ markdownSectionBlock, GrowiCommand, parseSlashCommand, respondRejectedErrors, generateWebClient,
|
|
|
InvalidGrowiCommandError, requiredScopes, postWelcomeMessage, REQUEST_TIMEOUT_FOR_PTOG,
|
|
InvalidGrowiCommandError, requiredScopes, postWelcomeMessage, REQUEST_TIMEOUT_FOR_PTOG,
|
|
|
|
|
+ parseSlackInteractionRequest, verifySlackRequest,
|
|
|
|
|
+ respond,
|
|
|
} from '@growi/slack';
|
|
} from '@growi/slack';
|
|
|
|
|
|
|
|
import { Relation } from '~/entities/relation';
|
|
import { Relation } from '~/entities/relation';
|
|
@@ -24,19 +26,18 @@ import {
|
|
|
} from '~/middlewares/slack-to-growi/authorizer';
|
|
} from '~/middlewares/slack-to-growi/authorizer';
|
|
|
import { UrlVerificationMiddleware } from '~/middlewares/slack-to-growi/url-verification';
|
|
import { UrlVerificationMiddleware } from '~/middlewares/slack-to-growi/url-verification';
|
|
|
import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-growi-uri-from-req';
|
|
import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-growi-uri-from-req';
|
|
|
|
|
+import { JoinToConversationMiddleware } from '~/middlewares/slack-to-growi/join-to-conversation';
|
|
|
import { InstallerService } from '~/services/InstallerService';
|
|
import { InstallerService } from '~/services/InstallerService';
|
|
|
import { SelectGrowiService } from '~/services/SelectGrowiService';
|
|
import { SelectGrowiService } from '~/services/SelectGrowiService';
|
|
|
import { RegisterService } from '~/services/RegisterService';
|
|
import { RegisterService } from '~/services/RegisterService';
|
|
|
import { RelationsService } from '~/services/RelationsService';
|
|
import { RelationsService } from '~/services/RelationsService';
|
|
|
import { UnregisterService } from '~/services/UnregisterService';
|
|
import { UnregisterService } from '~/services/UnregisterService';
|
|
|
-import { InvalidUrlError } from '../models/errors';
|
|
|
|
|
import loggerFactory from '~/utils/logger';
|
|
import loggerFactory from '~/utils/logger';
|
|
|
-import { JoinToConversationMiddleware } from '~/middlewares/slack-to-growi/join-to-conversation';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const logger = loggerFactory('slackbot-proxy:controllers:slack');
|
|
const logger = loggerFactory('slackbot-proxy:controllers:slack');
|
|
|
|
|
|
|
|
-const postNotAllowedMessage = async(client:WebClient, channelId:string, userId:string, disallowedGrowiUrls:Set<string>, commandName:string):Promise<void> => {
|
|
|
|
|
|
|
+const postNotAllowedMessage = async(responseUrl, disallowedGrowiUrls:Set<string>, commandName:string):Promise<void> => {
|
|
|
|
|
|
|
|
const linkUrlList = Array.from(disallowedGrowiUrls).map((growiUrl) => {
|
|
const linkUrlList = Array.from(disallowedGrowiUrls).map((growiUrl) => {
|
|
|
return '\n'
|
|
return '\n'
|
|
@@ -46,10 +47,8 @@ const postNotAllowedMessage = async(client:WebClient, channelId:string, userId:s
|
|
|
const growiDocsLink = 'https://docs.growi.org/en/admin-guide/upgrading/43x.html';
|
|
const growiDocsLink = 'https://docs.growi.org/en/admin-guide/upgrading/43x.html';
|
|
|
|
|
|
|
|
|
|
|
|
|
- await client.chat.postEphemeral({
|
|
|
|
|
|
|
+ await respond(responseUrl, {
|
|
|
text: 'Error occured.',
|
|
text: 'Error occured.',
|
|
|
- channel: channelId,
|
|
|
|
|
- user: userId,
|
|
|
|
|
blocks: [
|
|
blocks: [
|
|
|
markdownSectionBlock('*None of GROWI permitted the command.*'),
|
|
markdownSectionBlock('*None of GROWI permitted the command.*'),
|
|
|
markdownSectionBlock(`*'${commandName}'* command was not allowed.`),
|
|
markdownSectionBlock(`*'${commandName}'* command was not allowed.`),
|
|
@@ -103,7 +102,6 @@ export class SlackCtrl {
|
|
|
throw new Error('relations must be set');
|
|
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) => {
|
|
const promises = relations.map((relation: Relation) => {
|
|
|
// generate API URL
|
|
// generate API URL
|
|
|
const url = new URL('/_api/v3/slack-integration/proxied/commands', relation.growiUri);
|
|
const url = new URL('/_api/v3/slack-integration/proxied/commands', relation.growiUri);
|
|
@@ -123,8 +121,7 @@ export class SlackCtrl {
|
|
|
const rejectedResults: PromiseRejectedResult[] = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
|
|
const rejectedResults: PromiseRejectedResult[] = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
|
|
|
- return postEphemeralErrors(rejectedResults, body.channel_id, body.user_id, botToken!);
|
|
|
|
|
|
|
+ return respondRejectedErrors(rejectedResults, growiCommand.responseUrl);
|
|
|
}
|
|
}
|
|
|
catch (err) {
|
|
catch (err) {
|
|
|
logger.error(err);
|
|
logger.error(err);
|
|
@@ -137,8 +134,20 @@ export class SlackCtrl {
|
|
|
async handleCommand(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
|
|
async handleCommand(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
|
|
|
const { body, authorizeResult } = req;
|
|
const { body, authorizeResult } = req;
|
|
|
|
|
|
|
|
- let growiCommand;
|
|
|
|
|
|
|
+ // retrieve bot token
|
|
|
|
|
+ const { botToken } = authorizeResult;
|
|
|
|
|
+ if (botToken == null) {
|
|
|
|
|
+ const serverUri = process.env.SERVER_URI;
|
|
|
|
|
+ res.json({
|
|
|
|
|
+ blocks: [
|
|
|
|
|
+ markdownSectionBlock('*Installation might be failed.*'),
|
|
|
|
|
+ markdownSectionBlock(`Access to ${serverUri} and re-install GROWI App`),
|
|
|
|
|
+ ],
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
|
|
+ // parse /growi command
|
|
|
|
|
+ let growiCommand: GrowiCommand;
|
|
|
try {
|
|
try {
|
|
|
growiCommand = parseSlashCommand(body);
|
|
growiCommand = parseSlashCommand(body);
|
|
|
}
|
|
}
|
|
@@ -155,21 +164,18 @@ export class SlackCtrl {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Send response immediately to avoid opelation_timeout error
|
|
|
|
|
+ // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
|
|
|
|
|
+ res.json();
|
|
|
|
|
+
|
|
|
// register
|
|
// register
|
|
|
- if (growiCommand.growiCommandType === 'register') {
|
|
|
|
|
- return this.registerService.process(growiCommand, authorizeResult, body as {[key:string]:string});
|
|
|
|
|
|
|
+ if (this.registerService.shouldHandleCommand(growiCommand)) {
|
|
|
|
|
+ return this.registerService.processCommand(growiCommand, authorizeResult, body);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// unregister
|
|
// 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.';
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return this.unregisterService.process(growiCommand, authorizeResult, body as {[key:string]:string});
|
|
|
|
|
|
|
+ if (this.unregisterService.shouldHandleCommand(growiCommand)) {
|
|
|
|
|
+ return this.unregisterService.processCommand(growiCommand, authorizeResult);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
|
|
const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
|
|
@@ -181,7 +187,7 @@ export class SlackCtrl {
|
|
|
.getMany();
|
|
.getMany();
|
|
|
|
|
|
|
|
if (relations.length === 0) {
|
|
if (relations.length === 0) {
|
|
|
- return res.json({
|
|
|
|
|
|
|
+ return respond(growiCommand.responseUrl, {
|
|
|
blocks: [
|
|
blocks: [
|
|
|
markdownSectionBlock('*No relation found.*'),
|
|
markdownSectionBlock('*No relation found.*'),
|
|
|
markdownSectionBlock('Run `/growi register` first.'),
|
|
markdownSectionBlock('Run `/growi register` first.'),
|
|
@@ -191,7 +197,7 @@ export class SlackCtrl {
|
|
|
|
|
|
|
|
// status
|
|
// status
|
|
|
if (growiCommand.growiCommandType === 'status') {
|
|
if (growiCommand.growiCommandType === 'status') {
|
|
|
- return res.json({
|
|
|
|
|
|
|
+ return respond(growiCommand.responseUrl, {
|
|
|
blocks: [
|
|
blocks: [
|
|
|
markdownSectionBlock('*Found Relations to GROWI.*'),
|
|
markdownSectionBlock('*Found Relations to GROWI.*'),
|
|
|
...relations.map(relation => markdownSectionBlock(`GROWI url: ${relation.growiUri}`)),
|
|
...relations.map(relation => markdownSectionBlock(`GROWI url: ${relation.growiUri}`)),
|
|
@@ -199,11 +205,10 @@ export class SlackCtrl {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Send response immediately to avoid opelation_timeout error
|
|
|
|
|
- // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
|
|
|
|
|
- res.json({
|
|
|
|
|
- response_type: 'ephemeral',
|
|
|
|
|
- text: 'Processing your request ...',
|
|
|
|
|
|
|
+ await respond(growiCommand.responseUrl, {
|
|
|
|
|
+ blocks: [
|
|
|
|
|
+ markdownSectionBlock(`Processing your request *"/growi ${growiCommand.text}"* ...`),
|
|
|
|
|
+ ],
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
const baseDate = new Date();
|
|
const baseDate = new Date();
|
|
@@ -238,15 +243,32 @@ export class SlackCtrl {
|
|
|
|
|
|
|
|
// when all of GROWI disallowed
|
|
// when all of GROWI disallowed
|
|
|
if (relations.length === disallowedGrowiUrls.size) {
|
|
if (relations.length === disallowedGrowiUrls.size) {
|
|
|
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
|
|
|
- const client = generateWebClient(authorizeResult.botToken!);
|
|
|
|
|
- return postNotAllowedMessage(client, body.channel_id, body.user_id, disallowedGrowiUrls, growiCommand.growiCommandType);
|
|
|
|
|
|
|
+ const linkUrlList = Array.from(disallowedGrowiUrls).map((growiUrl) => {
|
|
|
|
|
+ return '\n'
|
|
|
|
|
+ + `• ${new URL('/admin/slack-integration', growiUrl).toString()}`;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const growiDocsLink = 'https://docs.growi.org/en/admin-guide/upgrading/43x.html';
|
|
|
|
|
+
|
|
|
|
|
+ return respond(growiCommand.responseUrl, {
|
|
|
|
|
+ text: 'Command not permitted.',
|
|
|
|
|
+ blocks: [
|
|
|
|
|
+ markdownSectionBlock('*None of GROWI permitted the command.*'),
|
|
|
|
|
+ markdownSectionBlock(`*'${growiCommand.growiCommandType}'* command was not allowed.`),
|
|
|
|
|
+ markdownSectionBlock(
|
|
|
|
|
+ `To use this command, modify settings from following pages: ${linkUrlList}`,
|
|
|
|
|
+ ),
|
|
|
|
|
+ markdownSectionBlock(
|
|
|
|
|
+ `Or, if your GROWI version is 4.3.0 or below, upgrade GROWI to use commands and permission settings: ${growiDocsLink}`,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// select GROWI
|
|
// select GROWI
|
|
|
if (allowedRelationsForSingleUse.length > 0) {
|
|
if (allowedRelationsForSingleUse.length > 0) {
|
|
|
body.growiUrisForSingleUse = allowedRelationsForSingleUse.map(v => v.growiUri);
|
|
body.growiUrisForSingleUse = allowedRelationsForSingleUse.map(v => v.growiUri);
|
|
|
- return this.selectGrowiService.process(growiCommand, authorizeResult, body);
|
|
|
|
|
|
|
+ return this.selectGrowiService.processCommand(growiCommand, authorizeResult, body);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// forward to GROWI server
|
|
// forward to GROWI server
|
|
@@ -257,73 +279,51 @@ export class SlackCtrl {
|
|
|
|
|
|
|
|
|
|
|
|
|
@Post('/interactions')
|
|
@Post('/interactions')
|
|
|
- @UseBefore(AuthorizeInteractionMiddleware, ExtractGrowiUriFromReq)
|
|
|
|
|
|
|
+ @UseBefore(AddSigningSecretToReq, verifySlackRequest, parseSlackInteractionRequest, AuthorizeInteractionMiddleware, ExtractGrowiUriFromReq)
|
|
|
async handleInteraction(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
|
|
async handleInteraction(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
|
|
|
logger.info('receive interaction', req.authorizeResult);
|
|
logger.info('receive interaction', req.authorizeResult);
|
|
|
logger.debug('receive interaction', req.body);
|
|
logger.debug('receive interaction', req.body);
|
|
|
|
|
|
|
|
- const { body, authorizeResult } = req;
|
|
|
|
|
|
|
+ const {
|
|
|
|
|
+ body, authorizeResult, interactionPayload, interactionPayloadAccessor,
|
|
|
|
|
+ } = req;
|
|
|
|
|
|
|
|
// pass
|
|
// pass
|
|
|
if (body.ssl_check != null) {
|
|
if (body.ssl_check != null) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- const payload:any = JSON.parse(body.payload);
|
|
|
|
|
- const callbackId:string = payload?.view?.callback_id;
|
|
|
|
|
- const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
|
|
|
|
|
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
|
|
|
- const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
|
|
|
|
|
-
|
|
|
|
|
- // 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);
|
|
|
|
|
|
|
+ if (interactionPayload == null) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // register
|
|
|
|
|
+ const registerResult = await this.registerService.processInteraction(authorizeResult, interactionPayload, interactionPayloadAccessor);
|
|
|
|
|
+ if (registerResult.isTerminated) return;
|
|
|
// unregister
|
|
// unregister
|
|
|
- if (callbackId === 'unregister') {
|
|
|
|
|
- await this.unregisterService.unregister(installation, authorizeResult, payload);
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- let privateMeta:any;
|
|
|
|
|
-
|
|
|
|
|
- if (payload.view != null) {
|
|
|
|
|
- privateMeta = JSON.parse(payload?.view?.private_metadata);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const channelName = payload.channel?.name || privateMeta?.body?.channel_name || privateMeta?.channelName;
|
|
|
|
|
|
|
+ const unregisterResult = await this.unregisterService.processInteraction(authorizeResult, interactionPayload, interactionPayloadAccessor);
|
|
|
|
|
+ if (unregisterResult.isTerminated) return;
|
|
|
|
|
|
|
|
- // forward to GROWI server
|
|
|
|
|
- if (callbackId === 'select_growi') {
|
|
|
|
|
- // 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();
|
|
|
|
|
|
|
+ // immediate response to slack
|
|
|
|
|
+ res.send();
|
|
|
|
|
|
|
|
- const selectedGrowiInformation = await this.selectGrowiService.handleSelectInteraction(installation, payload);
|
|
|
|
|
|
|
+ // select growi
|
|
|
|
|
+ const selectGrowiResult = await this.selectGrowiService.processInteraction(authorizeResult, interactionPayload, interactionPayloadAccessor);
|
|
|
|
|
+ const selectedGrowiInformation = selectGrowiResult.result;
|
|
|
|
|
+ if (!selectGrowiResult.isTerminated && selectedGrowiInformation != null) {
|
|
|
return this.sendCommand(selectedGrowiInformation.growiCommand, [selectedGrowiInformation.relation], selectedGrowiInformation.sendCommandBody);
|
|
return this.sendCommand(selectedGrowiInformation.growiCommand, [selectedGrowiInformation.relation], selectedGrowiInformation.sendCommandBody);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// check permission
|
|
// check permission
|
|
|
|
|
+ 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')
|
|
const relations = await this.relationRepository.createQueryBuilder('relation')
|
|
|
.where('relation.installationId = :id', { id: installation?.id })
|
|
.where('relation.installationId = :id', { id: installation?.id })
|
|
|
.leftJoinAndSelect('relation.installation', 'installation')
|
|
.leftJoinAndSelect('relation.installation', 'installation')
|
|
|
.getMany();
|
|
.getMany();
|
|
|
|
|
|
|
|
if (relations.length === 0) {
|
|
if (relations.length === 0) {
|
|
|
- return res.json({
|
|
|
|
|
|
|
+ return respond(interactionPayloadAccessor.getResponseUrl(), {
|
|
|
blocks: [
|
|
blocks: [
|
|
|
markdownSectionBlock('*No relation found.*'),
|
|
markdownSectionBlock('*No relation found.*'),
|
|
|
markdownSectionBlock('Run `/growi register` first.'),
|
|
markdownSectionBlock('Run `/growi register` first.'),
|
|
@@ -331,24 +331,25 @@ export class SlackCtrl {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const actionId:string = payload?.actions?.[0].action_id;
|
|
|
|
|
|
|
+ const { actionId, callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
|
|
|
|
|
+
|
|
|
|
|
+ const privateMeta = interactionPayloadAccessor.getViewPrivateMetaData();
|
|
|
|
|
+
|
|
|
|
|
+ const channelName = interactionPayload.channel?.name || privateMeta?.body?.channel_name || privateMeta?.channelName;
|
|
|
const permission = await this.relationsService.checkPermissionForInteractions(relations, actionId, callbackId, channelName);
|
|
const permission = await this.relationsService.checkPermissionForInteractions(relations, actionId, callbackId, channelName);
|
|
|
const {
|
|
const {
|
|
|
allowedRelations, disallowedGrowiUrls, commandName, rejectedResults,
|
|
allowedRelations, disallowedGrowiUrls, commandName, rejectedResults,
|
|
|
} = permission;
|
|
} = permission;
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
|
|
|
- await postEphemeralErrors(rejectedResults, payload.channel.id, payload.user.id, authorizeResult.botToken!);
|
|
|
|
|
|
|
+ await respondRejectedErrors(rejectedResults, interactionPayloadAccessor.getResponseUrl());
|
|
|
}
|
|
}
|
|
|
catch (err) {
|
|
catch (err) {
|
|
|
logger.error(err);
|
|
logger.error(err);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (relations.length === disallowedGrowiUrls.size) {
|
|
if (relations.length === disallowedGrowiUrls.size) {
|
|
|
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
|
|
|
- const client = generateWebClient(authorizeResult.botToken!);
|
|
|
|
|
- return postNotAllowedMessage(client, payload.channel.id, payload.user.id, disallowedGrowiUrls, commandName);
|
|
|
|
|
|
|
+ return postNotAllowedMessage(interactionPayloadAccessor.getResponseUrl(), disallowedGrowiUrls, commandName);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
/*
|