SlackのスタンプでZubeにバグカード作ってみた

Shintaro Uchiyama
JDSC Tech Blog
Published in
20 min readJan 8, 2021

概要

Slackでシステムの話をしていると「これってバグじゃないすかね?」
みたいなやりとりが結構発生しませんか?

そこからタスク管理ツールにカードを作成するのも面倒ですし
いろんな話が並行しすぎてSlackのスレッドに埋もれてしまうこともしばしばあったので、
Slackのスタンプをポチッと押せば自動でタスクを作成してくれる仕組みを作ってみました!

構成

現在自身の携わる案件ではタスク管理ツールにZube、インフラ環境にGCPを用いているのでこのようなアーキテクチャ構成にしました

最初はもっと楽に作れると思っていましたが、
slackから受信する「slackボタン押したよー」の通知は
3秒以内に200 Responseしないと何回か再送されることがわかり
Cloud Pub/Subで非同期にカード作成方針に切り替えました
(※Slack APIはGAEで捌き、実際の処理はFunctionsで行う)

あとは、バグ情報の格納先にFirestore(Datastore mode)を使用し
機密情報はSecret Managerに、個別の情報が記載されたyamlファイルはGCSに格納しました

実現した機能

今回実装した機能は大きく以下2点

1. 特定のスタンプを押したらZubeにカード作成

  • Slackメッセージの1行目をタイトル、全文を説明、対象メッセージのSlackリンクを末尾に記載
  • 特定のSlackチャネルで特定のスタンプを押した場合のみ反応させる
  • 対象メッセージが作成済みの場合は再度作成しない

2. 特定のスタンプを外したらZubeのカード削除

  • 対象のZubeカードを削除(Archiev)
  • 特定のSlackチャネルで特定のスタンプを押した場合のみ反応させる

手順

ざっくり実現した手順を以下記載します
全般的にGolangで書いてあります

↑リポジトリ貼り付けますので細かい部分はこちら参照くださいREADME.md見ていけばある程度わかるはず・・・

SlackのSigning Secret取得

SlackにAppを作成し、Basic InformationからSigning Secretを取得し
GCP SecretManagerに登録しておく

SlackからのAPI Event受付口作成

Slackと連携するAPIエンドポイントをGoogleAppEngineに用意します
※web framework ginを使用してます

r := gin.Default()
r.Use(logMiddleWare())
r.Use(errorMiddleWare())
r.POST("/events", eventHandler.Create)

API Endpoint認証用の処理

まずはSlackで取得したSigning Secretを用いてheaderを認証し
requestBodyを取得する

func (h EventHandler) Create(c *gin.Context) {
// ここで認証とrequest body取得してます
slackEvent, bodyByte, err := h.verifyApplication.Verify(c.Request.Header, c.Request.Body)
if err != nil {
_ = c.Error(fmt.Errorf("error found in verify: %w", err)).SetType(gin.ErrorTypePublic)
return
}

SecretManagerからSlack Signing Secret取得して認証。
requestBodyをParseしてSlack Eventを取得

func (a VerifyApplication) Verify(header http.Header, body io.ReadCloser) (*slackevents.EventsAPIEvent, []byte, error) {
slackSigningSecret, err := a.secretManager.GetSecret("slack-signing-secret")
if err != nil {
return nil, nil, fmt.Errorf("fetch slack signing secret error: %w", err)
}
// ここで認証&requestBody取得
bodyByte, err := a.slackEvent.Verify(header, body, string(slackSigningSecret))
if err != nil {
return nil, nil, fmt.Errorf("slack secret verifier error: %w", err)
}

eventsAPIEvent, err := slackevents.ParseEvent(bodyByte, slackevents.OptionNoVerifyToken())
if err != nil {
return nil, nil, fmt.Errorf("slack event parse error: %w", err)
}
return &eventsAPIEvent, bodyByte, nil
}

実際に認証してる箇所はこんな感じ

import (
"github.com/slack-go/slack"
)
...
func (e EventSlack) Verify(header http.Header, body io.ReadCloser, slackSigningSecret string) ([]byte, error) {
verifier, err := slack.NewSecretsVerifier(header, slackSigningSecret)
if err != nil {
return nil, fmt.Errorf("slack secret verifier error: %w", err)
}

bodyReader := io.TeeReader(body, &verifier)
bodyByte, err := ioutil.ReadAll(bodyReader)
if err != nil {
return nil, fmt.Errorf("read request body error: %w", err)
}

if err := verifier.Ensure(); err != nil {
return nil, fmt.Errorf("ensure slack secret verifier error: %w", err)
}
return bodyByte, nil
}

取得したSlackEventがAPIエンドポイントの認証の場合
送られてきたchallenge patrmeterを返却することで認証することができる

func (h EventHandler) Create(c *gin.Context) {
slackEvent, bodyByte, err := h.verifyApplication.Verify(c.Request.Header, c.Request.Body)
if err != nil {
_ = c.Error(fmt.Errorf("error found in verify: %w", err)).SetType(gin.ErrorTypePublic)
return
}

switch slackEvent.Type {
case slackevents.URLVerification:
var challengeResponse *slackevents.ChallengeResponse
err = json.Unmarshal(bodyByte, &challengeResponse)
if err != nil {
_ = c.Error(fmt.Errorf("slack url verification error: %w", err)).SetType(gin.ErrorTypePrivate)
return
}
c.JSON(http.StatusOK, challengeResponse.Challenge)
...

実際にAPI Endpointを認証してみる
作成したアプリケーションをGAEにデプロイし
生成されたURLをSlackアプリのEvent Subscriptionに貼り付け検証!

成功すると「Verified」って出るはずです!

Slackスタンプ押下・外した時の処理

スタンプ押下と外した際にエンドポイントに通知が来るように
SlackアプリのEvent Subscriptionで以下の通りEventを追加

また、ここが面倒なんですが
SlackからのEventにはSlackでのメッセージ本文が入っておらず
別途取得する必要があるため、OAuth & PermissionsでScope追加

Cloud FunctionsでSlackからメッセージ取得するためにToken取得

ここまで来ればあとはアプリケーションを書いていくのみ!
細かい部分はリポジトリを参照いただければ良いですが、
先ほど書いたHandlerでスタンプ押したり外したEventを受け取り
Zubeカード作成、削除のためのCloud Pub/Subを呼び出す!

func (h EventHandler) Create(c *gin.Context) {
...
switch slackEvent.Type {
...
case slackevents.CallbackEvent:
switch event := slackEvent.InnerEvent.Data.(type) {
case *slackevents.ReactionAddedEvent:
// 想定外のスタンプは除外
if _, ok := targetReactions[event.Reaction]; !ok {
logrus.Info("not target add reaction")
c.JSON(http.StatusOK, nil)
return
}
// Pub/Sub呼び出す
err = h.taskApplication.CallCreate(event)
if err != nil {
_ = c.Error(fmt.Errorf("call create error: %w", err)).SetType(gin.ErrorTypePrivate)
return
}
c.JSON(http.StatusOK, nil)
case *slackevents.ReactionRemovedEvent:
...

Cloud Functionsでカード作成・削除

あとは実際にZubeにカードを作成する処理を作成していくのみ
Pub/Subから情報を受け取りCloud Functionsで実際に処理を行う

全て書いていくと大変なので以下ポイントのみ記載します

  1. Slackからメッセージ取得
  2. Zubeにカード作成

Slackからメッセージ取得

取得したSlack Access Tokenを用いてメッセージを取得
Zubeの説明末尾に対象SlackURLを貼り付けたいので、URL生成箇所でこちょこちょしてます。

import (
"github.com/slack-go/slack"
)
...
func NewSlack(slackAccessToken string) *Slack {
return &Slack{
client: slack.New(slackAccessToken),
}
}
...
func (s Slack) GetMessage(channel string, timestamp string) (domain.SlackMessage, error) {
conversationReplies, _, _, err := s.client.GetConversationReplies(
&slack.GetConversationRepliesParameters{
ChannelID: channel,
Inclusive: true,
Timestamp: timestamp,
},
)
if err != nil {
return domain.SlackMessage{}, fmt.Errorf("fetch conversation history error: %w", err)
}

if len(conversationReplies) == 0 {
return domain.SlackMessage{}, errors.New("message not found")
}

conversationReply := conversationReplies[0]
text := conversationReply.Text
title, body := text, text
index := strings.Index(text, "\n")
// 1行目はタイトルにしたい
if index > -1 {
title = text[:index]
}

var linkUrl string
if conversationReply.ThreadTimestamp != "" {
// スレッドにもリンクで飛べるように情報付与
// 環境変数SLACK_URLに各自SlackのURLセットしておいてください
linkUrl = fmt.Sprintf(
"%s/%s/p%s?thread_ts=%s&cid=%s",
os.Getenv("SLACK_URL"),
channel,
strings.Replace(timestamp, ".", "", -1),
conversationReply.ThreadTimestamp,
channel,
)
} else {
linkUrl = fmt.Sprintf(
"%s/%s/p%s",
os.Getenv("SLACK_URL"),
channel,
strings.Replace(timestamp, ".", "", -1),
)
}
body = fmt.Sprintf("%s \n %s", body, linkUrl)
return *domain.NewSlackMessage(title, body), nil
}

Zubeにカード作成

ZubeのAPIを叩くためにMy SettingsでPrivate Keyを作成する

作成したPrivate KeyをもとにRefresh Tokenを生成し
Zube APIでAccess Tokenを取得する

type Zube struct {
accessToken string
clientID string
}
func NewZube(zubePrivateKey []byte) (*Zube, error) {
signKey, err := jwt.ParseRSAPrivateKeyFromPEM(zubePrivateKey)
if err != nil {
return nil, fmt.Errorf("load signKey error: %w", err)
}

clientID := os.Getenv("CLIENT_ID")
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.StandardClaims{
IssuedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
Issuer: clientID,
})
// refreshTokenを取得!
refreshToken, err := token.SignedString(signKey)
if err != nil {
return nil, fmt.Errorf("get token string error: %w", err)
}
// こんな感じでRequestするとAccess Token取得できる
httpClient := &http.Client{}
httpReq, err := http.NewRequest("POST", "https://zube.io/api/users/tokens", nil)
if err != nil {
return nil, fmt.Errorf("zube token http request error: %w", err)
}
httpReq.Header.Add("Authorization", fmt.Sprintf("Bearer %s", refreshToken))
httpReq.Header.Add("X-Client-ID", clientID)
httpReq.Header.Add("Accept", "application/json")

resp, err := httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("access token erquest error: %w", err)
}

bodyByte, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read access token response error: %w", err)
}

var response ZubeTokenResponse
if err := json.Unmarshal(bodyByte, &response); err != nil {
return nil, fmt.Errorf("unmarshal access token response error: %w", err)
}

return &Zube{
accessToken: response.AccessToken,
clientID: os.Getenv("CLIENT_ID"),
}, nil
}

あとは取得したAccessTokenをHeaderにセットすれば
大体Zube関連の情報は操作できる!
以下カード作成している箇所

func (z Zube) Create(task domain.Task) (int, error) {
requestByte, err := json.Marshal(CreateCardRequest{
ProjectId: task.Project().ID(),
WorkspaceId: task.Project().WorkspaceID(),
Title: task.Title(),
Body: task.Body(),
LabelIds: task.Labels(),
})
if err != nil {
return 0, fmt.Errorf("createCardRequest error: %w", err)
}

httpReq, err := http.NewRequest("POST", "https://zube.io/api/cards", bytes.NewReader(requestByte))

if err != nil {
return 0, fmt.Errorf("zube create http request error: %w", err)
}
// この辺でAccess Tokenセット
httpReq.Header.Add("Authorization", fmt.Sprintf("Bearer %s", z.accessToken))
httpReq.Header.Add("X-Client-ID", z.clientID)
httpReq.Header.Add("Content-Type", "application/json")

httpClient := &http.Client{}
resp, _ := httpClient.Do(httpReq)
bodyByte, _ := ioutil.ReadAll(resp.Body)

var response CreateCardResponse
if err := json.Unmarshal(bodyByte, &response); err != nil {
return 0, fmt.Errorf("unmarshal zube response error: %w", err)
}
return response.ID, nil
}

ZubeのプロジェクトIDやワークスペースID、ラベルIDなどは
Zube APIから取得してこないとわからないので、以下コマンドラインツールを作ってお手手で探してきました😫

https://github.com/shintaro-uchiyama/slack-suite/tree/main/functions/slack_event/cmd/get_zube_projects

この辺の外部連携箇所さえ抑えれば
あとはやりたいことを実現できるはず!
(細かいところはレポジトリ見てみてください🙇‍♂️

まとめ

夜更かしした時に数日で書いたので怪しい部分も多々ありますが、とりあえず動くものができました

長くなるので今回は記載していませんが
今回は自分なりに以下課題感を持って取り組みました

  1. 軽量DDD(ドメイン駆動設計)による実装
  2. Layered Architectureで各層を分離
  3. Golang DIツール(Wire)の導入

細かい部分は貼り付けたリポジトリを参照いただければ幸いです🙇‍♂️

採用やってます!We’re Hiring!

JDSC社内には各ロールが揃っていますので、他のSIerや自社開発している企業とはレベルが違ったスピード開発が可能になっています。

そういう開発に興味があるエンジニアを絶賛募集中です!

採用LPは以下URLです。
https://jdsc.ai/recruit/

--

--