Block Kitを用いたSlack通知をGoで実装してみる

この記事はフラー Advent Calendar 2020 の8日目の記事です。7日目は
@masaya82
さんで チェックボックスのアクセシビリティ対応をしたお話 - Qiita でした。



今年7月、フラー株式会社へ転職をいたしました。
現在はサーバサイドエンジニアとして働いており、主にGoを用いたウェブアプリケーションの開発を行っております。

その中でつい先日、Slack通知を行う機能を設ける必要が出てきました。
GoでのJSONの扱い方と合わせて諸々知見を得たので、今回はGoで実装するSlack投稿に関して書きたいと思います。

目指してる成果物

新型コロナウイルスの影響で殺伐とした社会になってしまった今だからこそ
Slackに癒し画像を投稿したいと思います。

さてSlackのメッセージを装飾する方法として、現状AttachmentsとBlock Kitの二つがあります。
前者のAttachmentsですが、どうやらレガシーなものになってしまっているようです(See Transforming your legacy message compositions with blocks | Slack)
そんなわけでBlock Kitを用いて構築していこうと思います。

通知は行いたいけどどんな見た目にしようか…?
そもそもどの程度の自由度あるの…?

そんな悩みを簡単に解決する術をSlackさんは提示してくださっております。

その名もBlock Kit Builder

Slack Block Kit を用いた装飾をUIフレームワークを用いて簡単に作成できるものになります。
画面左側でBlockを選択すると、真ん中にイメージが追加され、右側にはJSON形式の出力がされる…うん、便利。


今回目指すべき投稿はこんな感じにしようかと

f:id:inoriko711:20201206144041p:plain
Slack投稿したい内容

いいね、癒されますね。

癒し画像は、私の娘が愛してやまないふわたろうの画像を使用致します。
飼い主のタナイキさんにもご協力いただきました!ありがとうございます!

前準備

投稿先のSlackチャンネルとIncoming Webhookの準備

投稿する先のSlackチャンネルを準備してください。

またそのチャンネルにIncoming Webhookのアプリを追加してください。
該当チャンネルの詳細から「その他」>「アプリを追加する」からIncoming Webhookを選択すればOKです。

追加した際にWebhook URLが提示されるのでこれを使用します。

実装

では実装に入ります

ただ一つの文を送る機能の実装

とりあえず、Block Kit云々言う前に、ひとこと物申す機能を実装しようと思います。
先程取得したWebhook URLにBodyにJSONを持ったPOSTを投げればできます。

肝心のJSONデータはこんな感じ

{
	"username" : "通知上で表示されるユーザ名",
	"icon_url" : "通知上で表示されるユーザ画像",  // icon_emoji で指定するとSlack絵文字が使用できます
	"text" : "通知内容"
}

ではこれを送るコードを実装しましょう

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"io/ioutil"
	"net/http"
)

func main() {
	// 投稿先SlackURL
	slack := "https://投稿先SlackURL" 

	// BodyのJSONの準備
	bodyJSON, err := json.Marshal(map[string]interface{}{
		"username": "inoriko711",
		"icon_url": "https://iconURL",
		"text":     "テスト",
	})
	if err != nil {
		fmt.Println(err)
		return
	}

	// POST通信の実施
	resp, err := http.Post(slack, "application/json", bytes.NewReader(bodyJSON))
	if err != nil {
		fmt.Println(err)
		return
	}
	defer resp.Body.Close()

	// responce Bodyを最後まで読んでエラーがないことの確認
	if _, err := io.Copy(ioutil.Discard, resp.Body); err != nil {
		fmt.Println(err)
	}
}

go run [ファイル名].go で実行!
…でけた

f:id:inoriko711:20201206231658p:plain

Block Kitの箇所を準備する

Block Kit Builderで作成されたJSONですが、思いの外複雑な上、必須パラメータが欠けると問答無用で投稿できなくなります。
(Blockが欠けるとかじゃないです。全て投稿できなくなるんです。)

さらに、私、自慢じゃないですけどtypoが多いです。いちいちJSONのキー名を手打ちするのはしんどいです。

そのため各Block毎に構造体を作成して、その構造体に沿ってさえいれば問題なく投稿できるような仕組みを準備します。

HeaderのBlockを準備する

Header部分はこちら

f:id:inoriko711:20201206214013p:plain
Header画像

該当JSONはこんな感じです。

{
	"type": "header",
	"text": {
		"type": "plain_text",
		"text": ":star2:癒しタイム:star2:",
		"emoji": true
	}
}

Headerの要素に関する詳しい説明はこちらReference: Layout blocks | Slackに書かれてあります。
Fieldとしてはtypeとtextが必須、block_idは任意。型としてはtypeとblock_idはstring、textはオブジェクトです。

そのためGoコードとしては以下の型を準備します。

type HeaderBlock struct {
	Type    string      `json:"type"`
	Text    interface{} `json:"text"`
	BlockID string      `json:"block_id,omitempty"`
}

`json:"キー名"で入力しておくことで、Go側でよしなにしてくれます。便利。
またomitemptyを付与しておくことでコード上で何も指定されなかった(nilの)際、JSONに直すときにキー毎省いてくれます。とっても便利。

先程textの値の型はオブジェクトだって申し上げた舌の根も乾かぬうちにアレですが、よくよく読むと値はTextObjectしか持たないようです。

と言うわけで
TextObjectの型を準備して、先程準備したHeaderBlockのTextの型を*TextObjectにしてしまいましょう。

type TextObject struct {
	Type     string `json:"type"`
	Text     string `json:"text"`
	Emoji    bool   `json:"emoji,omitempty"`
	Verbatim bool   `json:"verbatim,omitempty"`
}
type HeaderBlock struct {
	Type    string      `json:"type"`
	Text    *TextObject `json:"text"`
	BlockID string      `json:"block_id,omitempty"`
}

今回は特にBlockIDやVerbatimを使用する予定はないのですが、今後のことを考えて実装してしまいます。

以下同じようなことを繰り返すので、十分わかったよって人は飛ばしちゃってください。

Sectionのplain textブロックを準備する

「本日の癒し」の箇所です。シンプルそうですね。
こちらを参考に作ります

type SectionBlocks struct {
	Type      string      `json:"type"`
	Text      *TextObject `json:"text"`
	BlockID   string      `json:"block_id,omitempty"`
	Fields    string      `json:"fields,omitempty"`
	Accessory interface{} `json:"accessory,omitempty"`
}

…あまりシンプルじゃなかった

Imageのnotilteブロックを準備する

お待ちかねのふわたろうの画像の箇所です!
こちらを参考に作ります

type ImageBlock struct {
	Type     string      `json:"type"`
	ImageURL string      `json:"image_url"`
	AltText  string      `json:"alt_text"`
	Title    *TextObject `json:"title,omitempty"`
	BlockID  string      `json:"block_id,omitempty"`
}
ActionsのButtonブロックを準備する

↓この箇所です。ボタンを押すとそれぞれのTwitterへ遷移します。
f:id:inoriko711:20201206233527p:plain

こちらを参考に作ります。

type ActionBlock struct {
	Type     string        `json:"type"`
	Elements []interface{} `json:"elements"`
	BlockID  string        `json:"block_id,omitempty"`
}

またelementsとしてButton elementを使用するため、こちらの構造体も準備します。

type ButtonElement struct {
	Type     string      `json:"type"`
	Text     interface{} `json:"text"`
	ActionId string      `json:"action_id,omitempty"`
	URL      string      `json:"url,omitempty"`
	Value    string      `json:"value,omitempty"`
	Style    string      `json:"style,omitempty"`
	Confirm  interface{} `json:"confirm,omitempty"`
	}
Contextのtext and imagesブロックを準備する

一番下のブロックです。
f:id:inoriko711:20201206233643p:plain

こちらを参考に作ります。

type ContextBlock struct {
	Type     string        `json:"type"`
	Elements []interface{} `json:"elements"`
	BlockID  string        `json:"block_id,omitempty"`
}
準備した構造体に値を突っ込む

長々と準備しました構造体に値をセットします。

func buildBlocks() []interface{} {
	return []interface{}{
		&HeaderBlock{
			Type: "header",
			Text: &TextObject{
				Type: "plain_text",
				Text: ":star2:癒しタイム:star2:",
			},
		},
		&SectionBlocks{
			Type: "section",
			Text: &TextObject{
				Type: "mrkdwn",
				Text: "本日の癒し",
			},
		},
		&ImageBlock{
			Type:     "image",
			ImageURL: "https://imageURL",
			AltText:  "fuwataro",
		},
		&ActionBlock{
			Type: "actions",
			Elements: []interface{}{
				&ButtonElement{
					Type: "button",
					Text: &TextObject{
						Type:  "plain_text",
						Text:  "ふわたろう",
						Emoji: true,
					},
					URL:   "https://twitter.com/huwataro_",
					Value: "fuwataro Twitter",
				},
				&ButtonElement{
					Type: "button",
					Text: &TextObject{
						Type:  "plain_text",
						Text:  "inoriko",
						Emoji: true,
					},
					URL:   "https://twitter.com/inoriko711",
					Value: "inoriko Twitter",
				},
				&ButtonElement{
					Type: "button",
					Text: &TextObject{
						Type:  "plain_text",
						Text:  "Fuller, Inc.",
						Emoji: true,
					},
					URL:   "https://twitter.com/fuller_inc",
					Value: "fuller  Twitter",
				},
			},
		},
		&ContextBlock{
			Type: "context",
			Elements: []interface{}{
				&ImageBlock{
					Type:     "image",
					ImageURL: "https://imageURL",
					AltText:  "inoriko711",
				},
				&TextObject{
					Type:  "plain_text",
					Text:  "Author: inoriko711",
					Emoji: true,
				},
			},
		},
	}
}
実行してみる

body JSONにblocks要素を付与。

// BodyのJSONの準備
bodyJSON, err := json.Marshal(map[string]interface{}{
	"username": "inoriko711",
	"icon_url": "https://iconURL",
	"text":     "癒し画像のお届け",
	"blocks":   blocks,
	})

go run 実行!!!!
f:id:inoriko711:20201205152512p:plain
投稿できました。


投稿内容を可変にする

折角作った投稿機能、より汎用性を高めるべく、以下の値を可変にしようと思います。

  • Slack通知先
  • 通知アプリアイコン画
  • 通知アプリユーザ名

また毎度同じ内容を投稿し続けるのも気が引けます。
そのため以下の値も可変にしようと思います。

  • 通知本文
  • 癒し画像
  • 癒し画像タイトル
  • 投稿者画像
  • 投稿者画像タイトル
  • 投稿者名

実装

本当であれば編集画面を準備してあれこれやりたいのですが、残念ながら今回そんな余力はありませんでした。
今回のところは可変データを保持するJSONファイルを準備して、それを読み込んであれこれするに留めておきます。

準備したJSONデータ

{
    "slack_url": "投稿先SlackURL",
    "username": "可変テストユーザ名",
    "icon_url": "通知で用いるアイコン",
    "slack_notice_data": {
        "text": "通知本文",
        "healing_image_url": "癒し画像URL",
        "healing_image_text": "癒し画像タイトル",
        "author_image_url": "投稿者画像URL",
        "author_image_text": "投稿者画像タイトル",
        "author_name": "投稿者名"
    }
}


JSONを扱うので、例によって構造体の準備

// Slack通知可変箇所のデータ構造体
type SlackApp struct {
	SlackURL            string           `json:"slack_url"`
	Username            string           `json:"username"`
	IconURL             string           `json:"icon_url"`
	SlackNoticeDataType *SlackNoticeData `json:"slack_notice_data"`
}

// Slack投稿内容可変箇所のデータ構造体
type SlackNoticeData struct {
	Text             string `json:"text"`
	HealingImageURL  string `json:"healing_image_url"`
	HealingImageText string `json:"healing_image_text"`
	AuthorImageURL   string `json:"author_image_url"`
	AuthorImageText  string `json:"author_image_text"`
	AuthorName       string `json:"author_name"`
}

そしてJSONファイルを読み込んで値を取得する箇所

	// ファイルから読み込む
	dataJSON, err := ioutil.ReadFile("./data/data.json")
	if err != nil {
		fmt.Println(err)
		return
	}

	// ファイルから読み込んだデータをSlackApp型で持つ
	var data SlackApp
	err = json.Unmarshal(dataJSON, &data)
	if err != nil {
		fmt.Println(err)
		return
	}

あとは可変にしたい箇所に、読み込んだ値を充てていけば完成です!


実行

良き

f:id:inoriko711:20201206230239p:plain

最後に

今回作成したコードは以下で公開してます
GitHub - inoriko711/slack_notice


おまけ

今日お誕生日の人、おめでとうございます。
f:id:inoriko711:20201206224947p:plain