SlackのメッセージからIssueを作成する

## はじめに
弊社ではチケット管理をGithubやGitLabではなく[YouTrack](https://www.jetbrains.com/youtrack/)というアジャイルに特化した
タスク管理ツールを採用しているためそちらにIssueを作成します。

YouTrackを使われていなくてもGitHubなどに応用できるかと思います。

## なぜやるか

- Slackの依頼メッセージが埋もれてしまいがち
- 依頼メッセージからサクッとIssueを作りたい

## 完成イメージを見る

1. メッセージをhoverするとでてくるメニュー
![Screen Shot 2021-12-16 at 10.38.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563564/6a451c44-09ce-a26d-71d1-a9a2429ef4ec.png)

2. メッセージの`︙`アイコンを押したら出てくるメニュー
![Screen Shot 2021-12-16 at 10.41.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563564/cf6fbd5e-f00c-db80-58f7-20c6498b5ed2.png)

3. モーダルが開き、Issue contentにSlackのメッセージとそのURLが記入されている
![Screen Shot 2021-12-16 at 10.48.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563564/f27cd47a-c7cf-b42e-eb97-2ad07e89933e.png)

4. `Create`を押すと選択したYouTrackのプロジェクトに記入した内容のIssueができ、YouSlack(BOT)と作成者の個人チャットにIssueのURLが送信される
※ モーダル内の`YouTrack Project` ・・・ GitHubのリポジトリのようなやつ。APIをCallし全件取得して選択肢にセットします。

## 処理の流れ

```plantuml
actor Salckユーザー as U
participant Slack as S
participant GAS as G
participant YouTrack as Y

activate U
U -> S ++ : メッセージの︙からBotを開く
deactivate U
S -> G ++ : リクエスト送信 \n message_action
G -> Y ++ : プロジェクト全件取得
return すべてのプロジェクト
return view.open
activate U
S -> U : Modal表示
deactivate S

U -> S ++ : Modalからフォーム送信
deactivate U
S -> G ++ : リクエスト送信 \n view_submission
G-> Y ++ : Issue作成
return 作成したIssueデータ
return chat.postMessage
activate U
S -> U : メッセージ(DM)送信
deactivate U

```

## 準備 & 実装

### Slackアプリの作成

こちらのSlack公式の記事を参考にSlackアプリを作成します。
※Nodeアプリでは実装しませんのでSlackアプリの作成まで。
https://api.slack.com/lang/ja-jp/actions

### API側を準備

新規のGoogle Apps Scriptを用意します。
デフォルトのコードはいらないので全削除。

### Slackでモーダルを開かせる
Slackメッセージの`︙`アイコンをクリックしたあとモーダルが開くようなコードをGASで書いていきます。
※ [完成イメージ](https://qiita.com/KoeInoue/private/c96ed43ba043afbd3cda#%E5%AE%8C%E6%88%90%E3%82%A4%E3%83%A1%E3%83%BC%E3%82%B8%E3%82%92%E8%A6%8B%E3%82%8B)の1~3番

#### 定数置き場

新規Scriptファイルを作成、名前を`const.gs`とし、内容は以下コードを書きます。

```javascript:main.gs
// From Slack to GAS
// SlackからのリクエストについてくるToken
const API_TOKEN = 'xxxxxxxxxxxxxxxxxxx';

// From GAS to Slack
// SlackアプリページのOAuth & Permissions→Bot User OAuth Token
const AUTH_TOKEN = 'xoxb-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';

// Slack apiのリクエスト先、base url
const SLACK_API_URI = 'https://slack.com/api';

// いつも開いているYouTrackのドメイン部分
const YOUTRACK_URL = ''

// こちらからTokenを生成する
// https://www.jetbrains.com/help/youtrack/incloud/Manage-Permanent-Token.html
const YOUTRACK_TOKEN = 'perm:xxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
```

グローバル定数のため分かりやすいように大キャメルにしています。

#### Youtrack用のライブラリを準備

:::note warn
YouTrackを導入されている方向け
:::
Scriptファイルを新規作成して名前を適当に`lib-youtrack.gs`にします。
`lib-youtrack.gs`に[コチラ](https://github.com/siquylee/youtrack-gs/blob/master/dist/youtrack-1.3.2.js)のコードをコピー。
これで簡単にGASからYoutrackのAPIをたたけるようになります。

#### YouTrack API を叩くClass

:::note warn
YouTrackを導入されている方向け
GitLabなどお使いのタスク管理サービス用にClassを作成してください
:::

先程導入したライブラリを使ってYoutrackのAPIを叩くClassを作成します。
新規Scriptファイルを作成、名前を`youtrack.gs`とし、内容はひとまず以下コードを書きます。

```javascript:main.gs
class Youtrack {
  constructor() {
    const config = {
      baseUrl: `${YOUTRACK_URL}/youtrack`,
      token: YOUTRACK_TOKEN
    };
    this.client = new ytGAS.Youtrack(config);
  }

  async getProjects() {
    // すべてのYouTrackプロジェクトをリクエスト
    return await this.client.projects.all()
  }
}
```
contructorで先程設定した定数を使用してClient作成し、Youtrackプロジェクト取得のAPIをCallするメソッドを定義しています。

#### メインの処理からYouTrackのClassを使用する

新規Scriptファイルを作成、名前を`main.gs`とし、内容はひとまず以下コードを書きます。

```javascript:main.gs
// SlackからのPostリクエストを受け取る
function doPost (e) {
  try {
    // 先程作成したClassのインスタンスを作成
    const youtrack = new Youtrack();
    // リクエストボディを取得
    const payload = JSON.parse(e.parameter.payload);

    if (payload.type === 'view_submission') { // モーダルから送信された時
      return ContentService.createTextOutput();
    
    } else if (payload.type === 'message_action') { // Slackメッセージの︙アイコンからアプリが呼ばれた時
      // YouTrackのプロジェクト全件取得  
      youtrack.getProjects().then(pjs => {
        // TODO Open Slack modal.
      });
      return ContentService.createTextOutput();
    } else { // どっちでもない時
      return ContentService.createTextOutput();
    }

  } catch (error) {
    // わかりやすいようにエラーObjを整形
    const message = `[名前] ${error.name}
      [場所] ${error.fileName} (${error.lineNumber}行目)
      [メッセージ] ${error.message}
      [StackTrace] ${error.stack}`;

    // エラーメールを送信
    GmailApp.sendEmail("エラーメールの送信先アドレス", "GAS error", message);
  }
}
```

Slackメッセージの`︙`からアプリが呼ばれたときと、モーダルからの送信先が同じURLになるため
if文の箇所でSlackのpayloadのTypeによって処理を分けるようにしています。

`︙`からアプリが呼ばれたときにYouTrackのプロジェクトを取得します。

#### Slack apiを叩く用のClass作成

新規スクリプトファイルを作成、名前を`slack.gs`とします。

YouTrackのプロジェクトを受け取り、Modalのoptionにセットするメソッドを作成します。

```javascript:slack.gs
class Slack {
  /** 
   * postリクエストする共通メソッド
  */
  postRequest(url, payload) {
    return UrlFetchApp.fetch(
      url,
      {
        method: 'post',
        contentType: "application/x-www-form-urlencoded",
        payload: payload,
      }
    );
  }

  /** 
   * Slackにモーダルを開くメソッド
   * @param payload Slackからのリクエストbody
   * @param projects YouTrackのプロジェクト全件
  */
  showModal(payload, projects) {
    const modalView = new ModalView(payload, projects)
    const viewData = {
      token: AUTH_TOKEN,
      trigger_id: payload.trigger_id,
      view: JSON.stringify(modalView.getModalView())
    };

    this.postRequest(`${SLACK_API_URI}/views.open`, viewData)
  }
```

`ModalView`というClassからモーダルのUIデータを取得して
Slack apiの`views.open`にリクエストを送信しモーダルを開けます。

モーダルのUIデータは長くなってしまうため別Classに切り出しました。

#### ModalView Class

新規ファイルを`view.gs`作成

<details><summary>view.gsのソースコード</summary><div>

```javascript:view.gs
class ModalView {
  constructor(payload, projects) {
    const pjOptions = []
    projects.map((project) => {
      pjOptions.push({
        text: {
          type: 'plain_text',
          text: project.name,
          emoji: true
        },
        value: project.id
      });
    });

    this.payload = payload;

    this.project = {
      block_id: 'project',
      type: 'input',
      element: {
        action_id: 'project',
        type: 'static_select',
        placeholder: {
          type: 'plain_text',
          text: 'Select a project',
          emoji: true
        },
        options: pjOptions
      },
      label: {
        type: 'plain_text',
        text: 'Youtrack Project'
      }
    }
    this.type = {
      block_id: 'type',
      type: 'input',
      element: {
        action_id: 'type',
        type: 'static_select',
        placeholder: {
          type: 'plain_text',
          text: 'Select a type',
          emoji: true
        },
        initial_option: {
          text: {
            type: 'plain_text',
            text: 'Task',
            emoji: true
          },
          value: 'Task'
        },
        options: [
          {
            text: {
              type: 'plain_text',
              text: 'Bug',
              emoji: true
            },
            value: 'Bug'
          },
          {
            text: {
              type: 'plain_text',
              text: 'Task',
              emoji: true
            },
            value: 'Task'
          },
        ]
      },
      label: {
        type: 'plain_text',
        text: 'Issue Type'
      }
    }
    this.title = {
      block_id: 'title',
      type: 'input',
      element: {
        action_id: 'title',
        type: 'plain_text_input',
        multiline: false,
        placeholder: {
          type: "plain_text",
          text: "Issue title",
          emoji: true
        }
      },
      label: {
        type: 'plain_text',
        text: 'Issue title'
      }
    }
    this.content = {
      block_id: 'content',
      type: 'input',
      element: {
        action_id: 'content',
        type: 'plain_text_input',
        multiline: true,
        initial_value: `## Summary \n [Slack URL](https://pas-group-hq.slack.com/archives/${payload.channel.id}/p${payload.message_ts}) \n \`\`\` \n ${this.payload.message.text} \n \`\`\` \n ## Product Backlog \n - \n ## URL \n ## Branch \n ## Added Database`,
        placeholder: {
          type: "plain_text",
          text: "Issue content",
          emoji: true
        }
      },
      label: {
        type: 'plain_text',
        text: 'Issue content'
      }
    }
    this.priority = {
      block_id: 'priority',
      type: 'input',
      element: {
        action_id: 'priority',
        type: 'static_select',
        placeholder: {
          type: 'plain_text',
          text: 'Select a priority',
          emoji: true
        },
        initial_option: {
          text: {
            type: 'plain_text',
            text: 'Normal',
            emoji: true
          },
          value: 'Normal'
        },
        options: [
          {
            text: {
              type: 'plain_text',
              text: 'Show-stopper',
              emoji: true
            },
            value: 'Show-stopper'
          },
          {
            text: {
              type: 'plain_text',
              text: 'Critical',
              emoji: true
            },
            value: 'Critical'
          },
          {
            text: {
              type: 'plain_text',
              text: 'Major',
              emoji: true
            },
            value: 'Major'
          },
          {
            text: {
              type: 'plain_text',
              text: 'Normal',
              emoji: true
            },
            value: 'Normal'
          },
          {
            text: {
              type: 'plain_text',
              text: 'Minor',
              emoji: true
            },
            value: 'Minor'
          },
        ]
      },
      label: {
        type: 'plain_text',
        text: 'Issue priority'
      }
    }
  }

  getModalView() {
    return {
      type: 'modal',
      title: {
        type: 'plain_text',
        text: 'Create Issue on Youtrack'
      },
      callback_id: 'sys_req',
      submit: {
        type: 'plain_text',
        text: 'Create'
      },
      close: {
        type: "plain_text",
        text: "Cancel",
        emoji : true
      },
      blocks: [    
        this.project,
        this.title,
        this.content,
        this.type,
        this.priority,
      ]
    }
  }
}
```

</div></details>

こちらを参考にカスタマイズが可能です。
[Slack API Reference: Defining view objects](https://api.slack.com/reference/surfaces/views)

YouTrackのプロジェクトを選択肢にセット、
payloadからメッセージの内容とURLを生成しIssue contentの
デフォルトとして設定しています。
TypeやPriorityはYouTrackのIssueに設定できる項目になります。

#### メインの処理からSlackのClassを呼び、Modalを開く

```diff_javascript:main.gs
// SlackからのPostリクエストを受け取る
function doPost (e) {
  try {
+   // Slack Classのインスタンスを作成
+   const slack = new Slack();
    // YouTrack Classのインスタンスを作成
    const youtrack = new Youtrack();
    // リクエストボディを取得
    const payload = JSON.parse(e.parameter.payload);

    if (payload.type === 'view_submission') { // モーダルから送信された時
      return ContentService.createTextOutput();
    
    } else if (payload.type === 'message_action') { // Slackメッセージの︙アイコンからアプリが呼ばれた時
      // YouTrackのプロジェクト全件取得  
      youtrack.getProjects().then(pjs => {
+         // Slack modalを開く.
+         slack.showModal(payload, pjs)
      });
      return ContentService.createTextOutput();
    } else { // どっちでもない時
      return ContentService.createTextOutput();
    }

  } catch (error) {
    // わかりやすいようにエラーObjを整形
    const message = `[名前] ${error.name}
      [場所] ${error.fileName} (${error.lineNumber}行目)
      [メッセージ] ${error.message}
      [StackTrace] ${error.stack}`;

    // エラーメールを送信
    GmailApp.sendEmail("エラーメールの送信先アドレス", "GAS error", message);
  }
}
```

#### Modalの確認
[完成イメージ](https://qiita.com/KoeInoue/private/c96ed43ba043afbd3cda#%E5%AE%8C%E6%88%90%E3%82%A4%E3%83%A1%E3%83%BC%E3%82%B8%E3%82%92%E8%A6%8B%E3%82%8B)の1~3番は完了しました。

動作を確認してみましょう。

### SlackのModalから送信しYouTrackのIssueを作成する

YouTrack ClassにIssueを作成するメソッドを定義します。 

```diff_javascript:youtrack.gs
class Youtrack {

  ・・・

+  async createYoutrackIssue(payload) {
+    // モーダルから送信された各値を定義します
+    const values = payload.view.state.values
+    const pjId = values.project.project.selected_option.value
+    const type = values.type.type.selected_option.value
+    const title = values.title.title.value
+    const content = values.content.content.value
+    const priority = values.priority.priority.selected_option.value
+
+    // YouTrack APIのリクエストBodyを定義
+    const newIssue = {
+      summary: title,
+      description: content,
+      project: {
+        id: pjId
+      },
+      customFields: [
+        {
+          name: "Priority",
+          $type :"SingleEnumIssueCustomField",
+          value:{
+            name: priority
+          }
+        },
+        {
+          name: "Type",
+          $type :"SingleEnumIssueCustomField",
+          value:{
+            name: type
+          }
+        },
+      ]
+    }
+
+    // ライブラリを使用しAPIをコール、SlackにIssueのURLを贈りたいので生成します。
+    const issueUrl = await this.client.issues.create(newIssue).then(issue => {
+      return `${YOUTRACK_URL}/youtrack/issue/${issue.project.shortName}-${issue.numberInProject}`
+    }).catch(err => {
+      GmailApp.sendEmail("エラーメールの送信先アドレス", "GAS Youtrack error", JSON.stringify(err))
+    });
+
+    return issueUrl;
+  }
}
```

#### モーダルから送信された時、メインの処理からIssue作成のメソッドを呼ぶ

payloadのタイプが`view_submission`の場合
Issue作成のメソッドを呼び出しましょう。

```diff_javascript:main.gs
// SlackからのPostリクエストを受け取る
function doPost (e) {
  try {
    // Slack Classのインスタンスを作成
    const slack = new Slack();
    // YouTrack Classのインスタンスを作成
    const youtrack = new Youtrack();
    // リクエストボディを取得
    const payload = JSON.parse(e.parameter.payload);

    if (payload.type === 'view_submission') { // モーダルから送信された時
+     youtrack.createYoutrackIssue(payload).then(issueUrl => {
+       // ここでSlackのDMにIssueのURLを送る
+     })
      return ContentService.createTextOutput();
    
    } else if (payload.type === 'message_action') { // Slackメッセージの︙アイコンからアプリが呼ばれた時
      // YouTrackのプロジェクト全件取得  
      youtrack.getProjects().then(pjs => {
          // Slack modalを開く.
          slack.showModal(payload, pjs)
      });
      return ContentService.createTextOutput();
    } else { // どっちでもない時
      return ContentService.createTextOutput();
    }

  } catch (error) {
    ・・・
  }
}
```

#### SlackにDMでIssueのURLを送る
```diff_javascript:slack.gs
class Slack {

  ・・・
+ /** 
+  * ダイレクトメッセージにIssueのURLを送信する
+  * @param payload
+  * @param issueUrl Youtrackに作成されたIssueのURL
+ */
+ sendCreatedMessage(payload, issueUrl) {
+   // Slack APIのCall先
+   const url = `${SLACK_API_URI}/chat.postMessage`;
+   const data = {
+     token : AUTH_TOKEN,
+     channel : payload.user.id, // userのIDを入れるとDMに送信されます。
+     text : `<@${payload.user.id}> \n Created Issue Link : ${issueUrl}`,
+   };
+   
+   this.postRequest(url, data)
+
+   return this.postRequest(url, message);
+ }
}
```
[Slack API chat.postMessage](https://api.slack.com/methods/chat.postMessage)でメッセージの内容をカスタマイズできます。

#### メインの処理からIssueのURLを送信するメソッドを呼ぶ

```diff_javascript:main.gs
// SlackからのPostリクエストを受け取る
function doPost (e) {
  try {
    // Slack Classのインスタンスを作成
    const slack = new Slack();
    // YouTrack Classのインスタンスを作成
    const youtrack = new Youtrack();
    // リクエストボディを取得
    const payload = JSON.parse(e.parameter.payload);

    if (payload.type === 'view_submission') { // モーダルから送信された時
      youtrack.createYoutrackIssue(payload).then(issueUrl => {
+       slack.sendCreatedMessage(payload, issueUrl)
     })
      return ContentService.createTextOutput();
    
    } else if (payload.type === 'message_action') { // Slackメッセージの︙アイコンからアプリが呼ばれた時
      // YouTrackのプロジェクト全件取得  
      youtrack.getProjects().then(pjs => {
          // Slack modalを開く.
          slack.showModal(payload, pjs)
      });
      return ContentService.createTextOutput();
    } else { // どっちでもない時
      return ContentService.createTextOutput();
    }

  } catch (error) {
    ・・・
  }
}
```

### Issueの確認
実際にModalから送信しIssueが作成されたことを確認しましょう。

### セキュリティ
これで一通り実装が完了しましたが、セキュリティ面が心配なので簡単に認証します。

```diff_javascript:slack.gs
class Slack {

+ /** 
+  * Validate Request from Slack
+ */
+ validateRequest(payload) {
+   // Check token
+   if (payload.token !== API_TOKEN) {
+     return 'token error';
+   }
+
+   if (payload.team.id !== TEAM_ID) {
+     return 'team id error';
+   }
+ 
+   if (payload.team.domain !== TEAM_DOMAIN) {
+     return 'team domain error';
+   }
+
+   return null;
+ }

  ・・・

}
```

```diff_javascript:main.gs
// SlackからのPostリクエストを受け取る
function doPost (e) {
  try {
    // Slack Classのインスタンスを作成
    const slack = new Slack();
    // YouTrack Classのインスタンスを作成
    const youtrack = new Youtrack();
    // リクエストボディを取得
    const payload = JSON.parse(e.parameter.payload);

+   // Validation
+   const result = slack.validateRequest(payload)
+   if (result !== null) {
+     GmailApp.sendEmail("エラーメールの送信先アドレス", "GAS Slack BOT auth error", `YouSlack BOTで認証エラーが検出されました\n error: ${result}`)
+     return ContentService.createTextOutput();
+   }

  } catch (error) {
    ・・・
  }
}
```

以上、これで完了です！

## さいごに

ところどころコードが汚くて申し訳ありません。
閲覧いただきありがとうございました。