Node.jsとFirebaseを使ってRedmineチケットの更新を通知してくれるSlack Appのバックエンドメモ

## 通知イメージ

![スクリーンショット 2020-05-24 17.48.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/298815/b7048f87-9c38-b525-b72a-13de0f5b6a7d.png)


## root

### package.json

```json
{
  "name": "redticket",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "serve": "firebase emulators:start",
    "deploy": "firebase deploy",
    "logs": "firebase functions:log"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "babel-eslint": "^10.1.0",
    "eslint": "^7.0.0",
    "eslint-plugin-promise": "^4.2.1",
    "firebase-tools": "^8.2.0",
    "prettier": "^2.0.5"
  }
}
```

### firebase.json

```json
{
  "hosting": {
    "public": "public",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
    "rewrites": [
      {
        "source": "**",
        "function": "api"
      }
    ]
  }
}
```

### .prettierrc

```json
{
  "trailingComma": "es5",
  "tabWidth": 2,
  "semi": false,
  "singleQuote": true,
  "printWidth": 100,
  "arrowParens": "avoid"
}
```

### .eslintrc

```json
{
  "extends": ["eslint:recommended"],
  "plugins": [],
  "parser": "babel-eslint",
  "parserOptions": {},
  "env": {
    "es6": true,
    "node": true
  },
  "globals": {},
  "rules": {
    "semi": ["error", "never"],
    "arrow-parens": "off"
  }
}
```

## functions

### package,json

```json
{
  "name": "functions",
  "description": "Cloud Functions for Firebase",
  "scripts": {},
  "engines": {
    "node": "8"
  },
  "dependencies": {
    "@slack/web-api": "^5.8.1",
    "axios": "^0.19.2",
    "express": "^4.17.1",
    "firebase-admin": "^8.10.0",
    "firebase-functions": "^3.6.1"
  },
  "devDependencies": {
    "firebase-functions-test": "^0.2.0"
  },
  "private": true
}
```

### index.js

```js
const admin = require('firebase-admin')
const axios = require('axios')
const serviceAccount = require('./serviceAccount.json') // Firestoreの秘密鍵
const Express = require('express')
const functions = require('firebase-functions')
const { WebClient } = require('@slack/web-api')
const client = new WebClient('[Bot User OAuth Access Token]')

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: 'https://xxxxxxx-xxxxx.firebaseio.com',
})
const usersCollection = admin.firestore().collection('users')
const redmineBaseURL = 'http://redmine.url'

const api = Express()
api.use(Express.json())
api.use(Express.urlencoded({ extended: true }))

api.post('/redmine', async (req, res) => {
  try {
    const ticketUrl = req.body.payload.url
    const ticket = req.body.payload.issue
    const comment = req.body.payload.journal
    const watchers = [...ticket.watchers]
    const query = usersCollection.where(
      'redmine_mail',
      'in',
      watchers.map(watcher => watcher.mail)
    )
    query.get().then(querySnapshot => {
      if (!querySnapshot.empty) {
        querySnapshot.docs.forEach(doc => {
          client.chat.postMessage({
            channel: doc.data().channel_id,
            text: ``,
            blocks: [
              {
                type: 'section',
                text: {
                  type: 'mrkdwn',
                  text: `*Subject:*\n${ticket.subject}\n\n*URL:*\n${ticketUrl}\n\n*Description:*\n${ticket.description}`,
                },
              },
              {
                type: 'divider',
              },
              {
                type: 'section',
                text: {
                  type: 'mrkdwn',
                  text: `*Latest comment:*\n${comment.notes}\n\n*Comment by:*\n${comment.author.firstname} ${comment.author.lastname}`,
                },
              },
            ],
          })
        })
      }
    })
  } catch (err) {
    console.error(err)
  } finally {
    res.status(200).send()
  }
})

api.post('/redticket', async (req, res) => {
  try {
    res.send('') // Error avoid

    await client.chat.postMessage({
      channel: req.body.channel_id,
      text: `🏃: Start /redticket ${req.body.text}`,
    })
    const mode = req.body.text.trim() !== '' ? req.body.text.trim().split(' ')[0] : ''
    switch (mode) {
      case '':
      case 'help': {
        await client.chat.postMessage({
          channel: req.body.channel_id,
          text: '',
          blocks: [
            {
              type: 'section',
              text: {
                type: 'mrkdwn',
                text:
                  "Hello👋 I will explain how to use RedTicket. The first step is to initialize the user data. The command is `/redticket init` 💻\n\nThe next step is to register your redmine account! You will need redmine's API token to register your account. The command is `/redticket verify [API Token]`. Let's sign up for an account right away 🏃 You can check your registered user data at any time at `/redticket check` 👌\n\nIf you need help, try send the `/redticket` or `/redticket help` 📖",
              },
            },
            {
              type: 'divider',
            },
          ],
        })
        break
      }

      case 'init': {
        await client.chat.postMessage({
          channel: req.body.channel_id,
          text: `🏃: Checking the existence of the user data`,
        })
        const userInfo = await usersCollection.doc(`${req.body.team_id}${req.body.user_id}`).get()
        const setDocument = async () => {
          const data = {
            team_id: req.body.team_id,
            team_domain: req.body.team_domain,
            user_id: req.body.user_id,
            channel_id: req.body.channel_id,
            user_name: req.body.user_name,
          }
          await client.chat.postMessage({
            channel: req.body.channel_id,
            text: `🏃: Creating your user data.`,
          })
          return await usersCollection
            .doc(`${req.body.team_id}${req.body.user_id}`)
            .set(data)
            .then(async () => {
              const userInfo = await usersCollection
                .doc(`${req.body.team_id}${req.body.user_id}`)
                .get()
              return userInfo.data()
            })
        }
        const docRef = userInfo.data() === undefined ? await setDocument() : userInfo.data()
        await client.chat.postMessage({
          channel: req.body.channel_id,
          text: `🏃: Your user data.\n\n${JSON.stringify(docRef, null, '\t')}`,
        })
        break
      }

      case 'check': {
        await client.chat.postMessage({
          channel: req.body.channel_id,
          text: `🏃: Reading your user data`,
        })
        const userInfo = await usersCollection.doc(`${req.body.team_id}${req.body.user_id}`).get()
        const docRef = userInfo.data()
        await client.chat.postMessage({
          channel: req.body.channel_id,
          text: `🏃: Your user data.\n\n${JSON.stringify(docRef, null, '\t')}`,
        })
        break
      }

      case 'verify': {
        if (req.body.text.trim().split(' ').length <= 1) {
          await client.chat.postMessage({
            channel: req.body.channel_id,
            text: `🙅: Need Redmine API Token.`,
          })
          break
        }
        await client.chat.postMessage({
          channel: req.body.channel_id,
          text: `🏃: Verify the Redmine API Token.`,
        })

        const redmineUserInfo = await axios.get(`${redmineBaseURL}/my/account.json`, {
          params: {
            key: req.body.text.trim().split(' ')[1],
          },
        })

        await client.chat.postMessage({
          channel: req.body.channel_id,
          text: `🏃: Your user data in Redmine.\n\n${JSON.stringify(
            redmineUserInfo.data.user,
            null,
            '\t'
          )}`,
        })

        const userInfo = await usersCollection.doc(`${req.body.team_id}${req.body.user_id}`).get()
        const docRef = userInfo.data()
        if (!userInfo.exists) {
          throw new Error('No such your data')
        }
        docRef.redmine_login = redmineUserInfo.data.user.login
        docRef.redmine_mail = redmineUserInfo.data.user.mail

        const updatedUserData = await usersCollection
          .doc(`${req.body.team_id}${req.body.user_id}`)
          .update(docRef)
          .then(async () => {
            const userInfo = await usersCollection
              .doc(`${req.body.team_id}${req.body.user_id}`)
              .get()
            return userInfo.data()
          })

        await client.chat.postMessage({
          channel: req.body.channel_id,
          text: `🏃: Your user data.\n\n${JSON.stringify(updatedUserData, null, '\t')}`,
        })
        break
      }

      case 'project': {
        await client.chat.postMessage({
          channel: req.body.channel_id,
          text: `🏃: Verify the Redmine API Token.`,
        })

        break
      }

      default:
        throw new Error('Invalid parameters.')
    }
  } catch (error) {
    await client.chat.postMessage({ channel: req.body.channel_id, text: `🙅: ${error}` })
  } finally {
    await client.chat.postMessage({
      channel: req.body.channel_id,
      text: `🎉: Done /redticket ${req.body.text}`,
    })
  }
})

exports.api = functions.https.onRequest(api)
```

リファクタしないと。