ほんじゃらねっと

ダイエット中プログラマのブログ

SlackをGo製ツールのGUIフロントエンドとして使う(#2: データの変更と削除)

Slackを自作ツールのフロントエンドとして使う、というテーマで書いた下記の記事の続きです。

blog.honjala.net

前回はSlack上で飲んだものと飲んだ量を登録する「水分摂取量記録アプリ」を作りながら、Slack APIの「ショートカット」「モーダル」を使ったボットとの連携方法を学びました。

今回は「データが登録できるようになったら次は登録した内容を変更したり削除したりしたいよね」ということ前回作成したアプリにデータ変更機能、削除機能を追加します。前回使用したSlack APIの「モーダル」に加えて「ブロックアクション」の使いながら既存データを変更するためのデータの持ち回り方法を学びます。

完成版のソースはこちらで確認できます:

GitHub - pirosuke/go-slack-bot-hydration at v1.1

機能の仕様を決める

コーディングに入る前に、データの変更・削除機能をどのように実装するかを決めておきましょう。

これまでの実装で記録した内容がメッセージとして投稿されるようにしましたので、そのメッセージから変更や削除を行えるようにするのが良さそうです。

Slackのメッセージから何らかの機能を呼び出す方法は2つあります:

  1. メッセージショートカットから機能を起動する
  2. メッセージに配置したボタン等のコンポーネントに対するユーザー操作(ブロックアクション)から起動する

メッセージショートカットとは

Creating and handling shortcuts | Slack

メッセージショートカットはその名の通りメッセージから実行できるショートカットのことです。前回使用したグローバルショートカットと同じく、あらかじめコールバックIDを指定したショートカットを登録しておくことで、ショートカットをクリックした時にボットにコールバックID付のリクエストを送信して処理を実行させることができる機能です。メッセージショートカットを使うとコールバックIDと一緒にそのショートカットが起動されたメッセージの情報が送信されるので、メッセージを変更したり、メッセージの内容を別のサービスに出力したりすることができます。

今回のアプリでも「水分記録修正」のようなメッセージショートカットを作成して変更機能を実行できそうですが、特定のメッセージにだけショートカットをつけることはできないので、水分記録のメッセージにだけ変更・削除機能をつけたい場合は使えません。

ブロックアクションとは

Reference: Interactive components payloads | Slack

ブロックアクションとは、メッセージのブロックに配置されたインタラクティブコンポーネント(ユーザーが操作可能なコンポーネント)に対するアクションのことです。

ユーザーが「ボタンをクリックする」等の操作(ブロックアクション)を行うとブロックアクションイベントが発生して、ボットに対してリクエストが送信されます。コンポーネントにはそれぞれアクションIDを設定することができ、ボット側はアクションIDを見てどのような処理を行うかを判断します。

インタラクティブコンポーネントはブロックキット(Block Kit)でメッセージ内に埋め込んで使用します。水分摂取記録をメッセージとして登録する時に「修正」「削除」というボタンを埋め込んでおくことで、そこからボットの修正機能・削除機能を呼び出すことができます。

f:id:piro_suke:20200714233208p:plain

メッセージに埋め込む形であれば水分摂取記録以外のメッセージには影響を与えないので、今回の実装で使えそうです。

変更・削除処理の流れを決める

実装面では、上記の変更・削除機能を起動するトリガーさえ決まれば、あとは前回実装した内容を流用するだけです。

処理の流れを決めて実装に入ってしまいましょう。

削除処理の流れ

変更よりも削除処理の方が簡単なので、先に削除処理の流れから決めてしまいます。

  1. ユーザーがメッセージの「削除ボタン」をクリックすると確認メッセージを表示する
  2. 確認メッセージが承諾されたらSlackがボットに削除リクエストを送信する
  3. ボットが水分摂取記録の作成ユーザーと削除リクエストユーザーがチェックして、別ユーザーの記録ならエラーメッセージを返す
  4. ボットがデータベースから水分記録データを削除する
  5. ボットがSlackのメッセージを削除する

動作イメージはこんな感じです。

f:id:piro_suke:20200718110210g:plain

変更処理の流れ

変更処理は水分摂取記録作成処理をできるだけ流用して作ります。

  1. ユーザーがメッセージの「修正ボタン」をクリックするとSlackがボットに修正用モーダル表示リクエストを送信する
  2. ボットが水分摂取記録の内容を初期値として表示する修正用モーダルデータを作成してモーダル表示リクエストをSlackに送信する
  3. Slackが修正用モーダルを表示する
  4. ユーザーがモーダルの内容を変更して「記録ボタン」をクリックする
  5. Slackがボットに修正リクエストを送信する
  6. ボットがデータベースの水分記録データを更新する
  7. ボットがSlackのメッセージ内容を更新する

動作イメージはこんな感じです。

f:id:piro_suke:20200718110239g:plain

実装する

水分摂取記録メッセージに修正・削除ボタンを追加する

まずは前回作成した水分摂取記録メッセージにトリガーとなる修正ボタン、削除ボタンを表示する処理を追加します。

変更前

前回作成した時は内容もそれほど大きくないので構造体を組み合わせてメッセージを作成しましたが...

go-slack-bot-hydration/slack_repository.go at v1.0 · pirosuke/go-slack-bot-hydration · GitHub

// PostHydrationAddResult posts hydration added result message.
func (repo *SlackRepository) PostHydrationAddResult(userName string, channel string, hydration models.Hydration, dailyAmount int64) ([]byte, error) {
    var err error
    var resp []byte
    titleSection := slack.TextSectionBlock{
        Type: "section",
        Text: slack.ContentBlock{
            Type: "mrkdwn",
            Text: "@" + userName + " が飲み物を飲みました\n本日の合計量は " + strconv.FormatInt(dailyAmount, 10) + "ml です",
        },
    }

    contentList := []slack.ContentBlock{
        {
            Type: "mrkdwn",
            Text: "*飲んだもの:*\n" + hydration.Drink,
        },
        {
            Type: "mrkdwn",
            Text: "*摂取量:*\n" + strconv.FormatInt(hydration.Amount, 10) + "ml",
        },
    }

    contentSection := slack.FieldsSectionBlock{
        Type:   "section",
        Fields: contentList,
    }

    var requestParams interface{}

    requestJSON := `{"channel": "", "blocks": []}`
    err = json.Unmarshal([]byte(requestJSON), &requestParams)
    if err != nil {
        return resp, err
    }

    jsonpointer.Set(requestParams, "/channel", channel)
    jsonpointer.Set(requestParams, "/blocks", []interface{}{
        titleSection,
        contentSection,
    })

    requestParamsJSON, err := json.Marshal(requestParams)
    if err != nil {
        return resp, err
    }

    resp, err = slack.PostJSON(repo.Token, "chat.postMessage", string(requestParamsJSON))

    return resp, err
}

オブジェクトを組み合わせる形だとブロックの追加や変更がとてもめんどくさいので、メッセージのビュー定義をJSONファイルに外出しして、それを読み込んで中身をちょこっと書き換えてSlackに送信する形に変更します。

変更後

変更前の内容をJSON形式にして、修正ボタンと削除ボタンを加えたビュー定義のJSONファイルを作成します。

go-slack-bot-hydration/result_message.json at v1.1 · pirosuke/go-slack-bot-hydration · GitHub

[
    {
        "type": "section",
        "text": {
            "type": "mrkdwn",
            "text": "@{{userName}} が飲み物を飲みました\n本日の合計量は {{dailyAmount}}ml です"
        }
    },
    {
        "type": "section",
        "fields": [
            {
                "type": "mrkdwn",
                "text": "*飲んだもの:*\n{{drink}}"
            },
            {
                "type": "mrkdwn",
                "text": "*摂取量:*\n{{amount}}ml"
            }
        ]
    },
    {
        "type": "divider"
    },
    {
        "type": "actions",
        "elements": [
            {
                "type": "button",
                "text": {
                    "type": "plain_text",
                    "text": "修正",
                    "emoji": true
                },
                "action_id": "hydration__update_drink",
                "value": "{{hydrationID}}"
            },
            {
                "type": "button",
                "text": {
                    "type": "plain_text",
                    "text": "削除",
                    "emoji": true
                },
                "style": "danger",
                "action_id": "hydration__delete_drink",
                "value": "{{hydrationID}}",
                "confirm": {
                    "title": {
                        "type": "plain_text",
                        "text": "削除確認"
                    },
                    "text": {
                        "type": "plain_text",
                        "text": "記録を削除しますか?"
                    },
                    "confirm": {
                        "type": "plain_text",
                        "text": "削除する"
                    },
                    "deny": {
                        "type": "plain_text",
                        "text": "キャンセル"
                    },
                    "style": "danger"
                }
            }
        ]
    }
]

JSON内文字列の「{{...}}」は後から置換してデータを埋め込む予定の箇所です。JSON操作でデータを置き換えていくのはちょっと面倒なので、文字列置換で済ませることにします。

前半部分は前回作成していたメッセージ内容を反映した内容で、後半の「type:divider」ブロックと「type:actions」ブロックが今回追加した部分です。

「type:divider」は単なる罫線表示ブロックです。

重要なのがブロックアクションを定義する「type:actions」ブロックです。

Reference: Layout blocks | Slack

「type:actions」ブロックは「elements」にインタラクティブコンポーネントの定義リストを持ちます。ここでは修正ボタン用と削除ボタン用の2つのインタラクティブコンポーネントを定義しています。

Reference: Block elements | Slack

インタラクティブコンポーネントで重要な要素が「action_id」と「value」です。

「action_id」はショートカットやモーダルのコールバックIDと同じようなもので、ボットにどのアクションを実行するかを知らせるためのものです。ここでは変更アクションIDに「hydration__update_drink」、削除アクションIDに「hydration__delete_drink」を渡すことにします。「value」は付加パラメータを送るためのもので、今回なら水分摂取記録のIDをvalueに設定しておくことで、どのメッセージを変更・削除するのかをボットに伝えることができます。

削除ボタンの定義にある要素「confirm」は確認ダイアログ表示用の要素です。このようにビュー設定で定義しておくだけで確認ダイアログが表示してくれます。

Reference: Composition objects | Slack

続いてSlackにメッセージを投稿する関数「PostHydrationAddResult」をビュー定義JSONを読み込んでリクエストで使用するように書き換えます。

go-slack-bot-hydration/slack_repository.go at v1.1 · pirosuke/go-slack-bot-hydration · GitHub

// PostHydrationAddResult posts hydration added result message.
func (repo *SlackRepository) PostHydrationAddResult(userName string, channel string, hydration models.Hydration, dailyAmount int64) ([]byte, error) {
    var err error
    var resp []byte
    var requestParams, view interface{}

    // 作成したビュー定義JSONを読み込む
    viewPath := filepath.Join(repo.ViewsDirPath, "result_message.json")
    if !file.FileExists(viewPath) {
        return resp, fmt.Errorf("View file does not exist: %s", viewPath)
    }

    requestJSON := `{"channel": "", "blocks": []}`
    err = json.Unmarshal([]byte(requestJSON), &requestParams)
    if err != nil {
        return resp, err
    }

    viewJSONTemplate, err := ioutil.ReadFile(viewPath)
    if err != nil {
        return resp, err
    }

    // 読み込んだJSON内の「{{...}}」を置換する
    viewParams := map[string]string{
        "hydrationID": strconv.FormatInt(hydration.ID, 10),
        "userName":    hydration.Username,
        "drink":       hydration.Drink,
        "amount":      strconv.FormatInt(hydration.Amount, 10),
        "dailyAmount": strconv.FormatInt(dailyAmount, 10),
    }

    viewJSON := replaceViewTemplateParams(string(viewJSONTemplate), viewParams)

    err = json.Unmarshal([]byte(viewJSON), &view)
    if err != nil {
        return resp, err
    }

    err = jsonpointer.Set(requestParams, "/blocks", view)
    if err != nil {
        return resp, err
    }

    err = jsonpointer.Set(requestParams, "/channel", channel)
    if err != nil {
        return resp, err
    }

    requestParamsJSON, err := json.Marshal(requestParams)
    if err != nil {
        return resp, err
    }

    // Slackのメッセージ投稿APIにリクエストを送信する
    resp, err = slack.PostJSON(repo.Token, "chat.postMessage", string(requestParamsJSON))

    return resp, err
}

// 文字列をマップの内容で置換するための関数
func replaceViewTemplateParams(srcText string, params map[string]string) string {
    destText := srcText

    for k, v := range params {
        destText = strings.ReplaceAll(destText, "{{"+k+"}}", v)
    }

    return destText
}

これで水分摂取記録を登録した際に表示されるメッセージに修正ボタンと削除ボタンが表示されるようになりました。

削除機能を実装する

ブロックアクションイベントリクエストを受け取る

削除処理の実装に進みます。ブロックアクションで発生したイベントリクエストは「type:block_actions」でボットに送信されます。

Reference: Interactive components payloads | Slack

ブロックアクションがどのコンポーネントから送信されたかは「アクションID(action_id)」で判別します。削除ボタンには「hydration__delete_drink」というアクションIDを設定しましたので、アクションID「hydration__delete_drink」用の処理分岐を追加します。

ブロックアクションリクエストが持つアクションIDと、ショートカットやモーダルからのリクエストが持つコールバックIDはどちらもリクエストの種類を判別するためのものですので、ここではまとめて「callbackID」として扱うことにします。

go-slack-bot-hydration/server.go at v1.1 · pirosuke/go-slack-bot-hydration · GitHub

   iRequestType, err := jsonpointer.Get(payload, "/type")
    if err != nil {
        c.Echo().Logger.Error(err)
        return c.String(http.StatusInternalServerError, "Error")
    }
    requestType := iRequestType.(string)

    var iCallbackID interface{}
    switch requestType {
    case "shortcut":
        iCallbackID, _ = jsonpointer.Get(payload, "/callback_id")
    case "view_submission":
        iCallbackID, _ = jsonpointer.Get(payload, "/view/callback_id")

    // type:block_actions時はaction_idをコールバックIDとして扱う
    case "block_actions":
        iCallbackID, _ = jsonpointer.Get(payload, "/actions/0/action_id")
    }

    callbackID := iCallbackID.(string)
    if len(callbackID) > 0 {
        switch callbackID {
        case "hydration__record_drink":
            return HandleOpenHydrationForm(c, appConfig, configsDirPath, payload)
        case "hydration__record_form":
            return HandleHydrationFormAddSubmission(c, appConfig, configsDirPath, payload)

        // 水分摂取記録削除用分岐を追加する
        case "hydration__delete_drink":
            return HandleHydrationDelete(c, appConfig, configsDirPath, payload)

        default:
            c.Echo().Logger.Warn("Unrecognized callbackID:", callbackID)
        }
    }

リクエストの内容を受け取ってステータス200を返す関数を追加しておきます。

// HandleHydrationDelete deletes hydration and deletes message.
func HandleHydrationDelete(c echo.Context, appConfig config.Config, configsDirPath string, payload interface{}) error {
    return c.String(http.StatusOK, "")
}

データベースから既存データを取得する機能を追加する

データを変更・削除する前に既存データの内容を取得してチェックや更新に使いたいので、 変更対象の水分摂取記録IDを持つデータを取得する処理を作成しておきます。

go-slack-bot-hydration/hydration_pg_repository.go at v1.1 · pirosuke/go-slack-bot-hydration · GitHub

// FetchOne fetches one hydration data.
func (repo *HydrationPgRepository) FetchOne(hydrationID int64) (models.Hydration, error) {
    var hydration models.Hydration
    var userName string
    var drink string
    var amount int64
    var modified time.Time

    // 対象データをselectして取得します。
    err := repo.conn.QueryRow(context.Background(), "select username, drink, amount, modified from hydrations where id = $1",
        hydrationID,
    ).Scan(
        &userName,
        &drink,
        &amount,
        &modified,
    )

    if err != nil {
        return hydration, err
    }

    // データを構造体に詰めて返します。
    hydration = models.Hydration{
        ID:       hydrationID,
        Username: userName,
        Drink:    drink,
        Amount:   amount,
        Modified: modified,
    }

    return hydration, nil
}

データベースの既存データを削除する機能を追加する

次にデータベースのデータを削除する機能を追加します。

データベースのデータ削除処理はこんな感じでdelete文を実行する関数を用意しておきます。

// Delete deletes hydration data.
func (repo *HydrationPgRepository) Delete(hydration models.Hydration) error {
    _, err := repo.conn.Exec(context.Background(), "delete from hydrations where id = $1 and username = $2",
        hydration.ID,
        hydration.Username,
    )
    return err
}

Slackのメッセージを削除する機能を追加する

次はSlack側のメッセージを削除する機能を作成します。

メッセージを削除する時はSlack APIの「chat.delete」を使います。

chat.delete method | Slack

「chat.delete」はメッセージを「チャンネル」と「メッセージタイムスタンプ」で特定します。どちらもリクエストに含まれるpayloadから取得できますので、それらをパラメータとして受け取って削除リクエストを送信する関数を用意します。

go-slack-bot-hydration/slack_repository.go at v1.1 · pirosuke/go-slack-bot-hydration · GitHub

// DeleteMessage deletes message.
func (repo *SlackRepository) DeleteMessage(channel string, ts string) ([]byte, error) {
    var err error
    var resp []byte
    var requestParams interface{}

    requestJSON := `{"channel": "", "ts": ""}`
    err = json.Unmarshal([]byte(requestJSON), &requestParams)
    if err != nil {
        return resp, err
    }

    err = jsonpointer.Set(requestParams, "/channel", channel)
    if err != nil {
        return resp, err
    }

    err = jsonpointer.Set(requestParams, "/ts", ts)
    if err != nil {
        return resp, err
    }

    requestParamsJSON, err := json.Marshal(requestParams)
    if err != nil {
        return resp, err
    }

    resp, err = slack.PostJSON(repo.Token, "chat.delete", string(requestParamsJSON))

    return resp, err

}

削除不可メッセージ表示用のアラートモーダル表示処理を作成する

別のユーザーが作成したデータを削除しようとした時に、そのメッセージが削除不可であることをリクエストユーザーに知らせるアラートモーダルを表示することにします。

色々なエラーメッセージで使い回せるように汎用的なアラート表示機能を作っておきましょう。

まずはビュー定義JSONを作成します。タイトルとメッセージと閉じるボタンを持つようにします。

alert_dialog.json

{
    "title": {
        "type": "plain_text",
        "text": "{{title}}"
    },
    "close": {
        "type": "plain_text",
        "text": "閉じる"
    },
    "blocks": [
        {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": "{{text}}"
            }
        }
    ],
    "type": "modal"
}

次にSlackにモーダルを送信する処理を作成します。モーダルを表示する処理はよく使うので「openView」という関数を作って汎用化しておきます。

// アラートモーダルを表示する関数
func (repo *SlackRepository) ShowAlert(triggerID string, title string, text string) ([]byte, error) {

    viewParams := map[string]string{
        "title": title,
        "text":  text,
    }

    var resp []byte

    // 作成したアラートモーダルのビュー定義を読み込む
    viewPath := filepath.Join(repo.ViewsDirPath, "alert_dialog.json")
    if !file.FileExists(viewPath) {
        return resp, fmt.Errorf("View file does not exist: %s", viewPath)
    }

    // 汎用モーダル表示関数を呼び出す
    return repo.openView(triggerID, viewPath, viewParams)
}

// モーダル表示用の汎用関数
func (repo *SlackRepository) openView(triggerID string, viewPath string, viewParams map[string]string) ([]byte, error) {
    var err error
    var resp []byte
    var requestParams, view interface{}

    requestJSON := `{"trigger_id": "", "view": {}}`
    err = json.Unmarshal([]byte(requestJSON), &requestParams)
    if err != nil {
        return resp, err
    }

    viewJSONTemplate, err := ioutil.ReadFile(viewPath)
    if err != nil {
        return resp, err
    }

    viewJSON := replaceViewTemplateParams(string(viewJSONTemplate), viewParams)
    //fmt.Println(viewJSON)

    err = json.Unmarshal([]byte(viewJSON), &view)
    if err != nil {
        return resp, err
    }

    err = jsonpointer.Set(requestParams, "/view", view)
    if err != nil {
        return resp, err
    }

    err = jsonpointer.Set(requestParams, "/trigger_id", triggerID)
    if err != nil {
        return resp, err
    }

    requestParamsJSON, err := json.Marshal(requestParams)
    if err != nil {
        return resp, err
    }

    resp, err = slack.PostJSON(repo.Token, "views.open", string(requestParamsJSON))

    return resp, err
}

削除ボタンクリック時の処理に削除処理を追加する

ここまでに作成した関数を使って、削除リクエストを処理する関数「HandleHydrationDelete」の削除処理を完成させます。

// HandleHydrationDelete deletes hydration and deletes message.
func HandleHydrationDelete(c echo.Context, appConfig config.Config, configsDirPath string, payload interface{}) error {

    // payloadから水分摂取記録ID、ユーザー名、メッセージタイムスタンプ、チャンネルIDを取得します。
    iHydrationID, _ := jsonpointer.Get(payload, "/actions/0/value")
    sHydrationID, _ := iHydrationID.(string)
    hydrationID, _ := strconv.ParseInt(sHydrationID, 10, 64)

    iUserName, _ := jsonpointer.Get(payload, "/user/username")
    userName, _ := iUserName.(string)

    iMessageTS, _ := jsonpointer.Get(payload, "/container/message_ts")
    messageTS, _ := iMessageTS.(string)

    iChannel, _ := jsonpointer.Get(payload, "/container/channel_id")
    channel, _ := iChannel.(string)

    // 非同期で削除処理を実行します。
    go func() {

        // データベースからデータを取得します。
        hydration, err := repo.FetchOne(hydrationID)
        if err != nil {
            c.Echo().Logger.Error(err)
            return
        }

        slackRepo := &repositories.SlackRepository{
            Token:        appConfig.Slack.Token,
            ViewsDirPath: filepath.Join(configsDirPath, "views"),
        }

        // データがリクエストを送信したユーザーが作成したものかをチェックします。
        if hydration.Username == userName {

            // データベースの水分摂取記録を削除します。
            err = repo.Delete(hydration)
            if err != nil {
                c.Echo().Logger.Error(err)
                return
            }

            // Slack側のメッセージを削除します。
            _, err = slackRepo.DeleteMessage(channel, messageTS)
            if err != nil {
                c.Echo().Logger.Error(err)
            }
        } else {
            // 水分摂取記録がリクエストユーザーが作成したものでない場合はアラートモーダルを表示します。
            iTriggerID, _ := jsonpointer.Get(payload, "/trigger_id")
            triggerID := iTriggerID.(string)
            _, err = slackRepo.ShowAlert(triggerID, "削除できません", "この記録を削除する権限がありません")
        }

    }()

    return c.String(http.StatusOK, "")
}

これで削除機能は完成です。

変更機能を実装する

続いて変更機能を実装します。削除機能は1リクエストで終わりですが、変更機能は「変更用のモーダルを表示する」「変更内容を受け取ってデータを更新する」という2つのリクエストを処理する必要があります。

データ変更用モーダルを表示する

ブロックアクションイベントリクエストを受け取る

削除処理と同じく、修正ボタンをクリックするとブロックアクションイベントのリクエストがSlackからボットに送信されます。変更処理の場合はアクションID「hydration__update_drink」のリクエストが送信されてきますので、それを受け取る処理を追加します。

go-slack-bot-hydration/server.go at v1.1 · pirosuke/go-slack-bot-hydration · GitHub

   var iCallbackID interface{}
    switch requestType {
    case "shortcut":
        iCallbackID, _ = jsonpointer.Get(payload, "/callback_id")
    case "view_submission":
        iCallbackID, _ = jsonpointer.Get(payload, "/view/callback_id")
    case "block_actions":
        iCallbackID, _ = jsonpointer.Get(payload, "/actions/0/action_id")
    }

    callbackID := iCallbackID.(string)
    if len(callbackID) > 0 {
        switch callbackID {
        case "hydration__record_drink":
            return HandleOpenHydrationForm(c, appConfig, configsDirPath, payload)
        case "hydration__record_form":
            return HandleHydrationFormAddSubmission(c, appConfig, configsDirPath, payload)

        // この分岐を追加します。
        case "hydration__update_drink":
            return HandleOpenHydrationUpdateForm(c, appConfig, configsDirPath, payload)

        case "hydration__delete_drink":
            return HandleHydrationDelete(c, appConfig, configsDirPath, payload)
        default:
            c.Echo().Logger.Warn("Unrecognized callbackID:", callbackID)
        }
    }

リクエストの内容を受け取ってステータス200を返す関数を追加しておきます。

// HandleOpenHydrationUpdateForm opens hydration edit form modal.
func HandleOpenHydrationUpdateForm(c echo.Context, appConfig config.Config, configsDirPath string, payload interface{}) error {
    return c.String(http.StatusOK, "")
}
データ変更用モーダルのビュー定義を作成する

続いてモーダルのビュー定義をJSONで作成します。

内容的には前回の記事で作成したデータ登録用ビューと同じなので、前回作成したものを変更してデータの登録と変更どちらでも使用できる形にしましょう。

データの登録用ビューと変更用ビューとでは下記の内容が異なります。

  • コールバックID: 登録用と変更用とで異なるコールバックIDを持つ
  • 既存データのID: データ更新の場合は既存データのID、メッセージタイムスタンプ、チャンネルが必要
  • 初期表示データ: データ更新の場合は既存データの内容(飲み物の種類、摂取量)を初期表示したい

コールバックIDはデータ登録の時から使用しているので、これをデータ登録か更新かで変更する形で対応できそうです。

既存データのIDはモーダルのブロックに「private_metadata」という、submit時にリクエストに追加される不可視の項目があるので、ここに入れておくのが良さそうです。

初期表示データは「type:input」ブロックに「initial_value(テキスト用)」「initial_option(セレクト用)」をセットすることで設定できます。「initial_option」は値だけでなく、「option」で指定したのと全く同じ内容をセットしないといけないようで、ちょっと使いにくいです。

Reference: View payloads | Slack

上記の内容を追加して、イベントの種類がデータの登録かデータの変更かによって切り替えたい箇所を「{{...}}」でパラメータ化したビュー定義JSONを作成します。

go-slack-bot-hydration/record_form.json at v1.1 · pirosuke/go-slack-bot-hydration · GitHub

{
    "callback_id": "{{callbackID}}",
    "title": {
        "type": "plain_text",
        "text": "水分摂取量記録"
    },
    "submit": {
        "type": "plain_text",
        "text": "記録する"
    },
    "private_metadata": "{{metadata}}",
    "blocks": [
        {
            "type": "input",
            "block_id": "drink",
            "element": {
                "type": "plain_text_input",
                "action_id": "drink",
                "placeholder": {
                    "type": "plain_text",
                    "text": "何飲んだ?"
                },
                "initial_value": "{{initialDrink}}"
            },
            "label": {
                "type": "plain_text",
                "text": "飲み物の種類"
            }
        },
        {
            "type": "input",
            "block_id": "amount",
            "element": {
                "type": "static_select",
                "action_id": "amount",
                "placeholder": {
                    "type": "plain_text",
                    "text": "選択してください",
                    "emoji": true
                },
                "initial_option": {
                    "text": {
                        "type": "plain_text",
                        "text": "{{initialAmount}}ml",
                        "emoji": true
                    },
                    "value": "{{initialAmount}}"
                },
                "options": [
                    {
                        "text": {
                            "type": "plain_text",
                            "text": "100ml",
                            "emoji": true
                        },
                        "value": "100"
                    },
                    {
                        "text": {
                            "type": "plain_text",
                            "text": "200ml",
                            "emoji": true
                        },
                        "value": "200"
                    }
                ]
            },
            "label": {
                "type": "plain_text",
                "text": "摂取量",
                "emoji": true
            }
        }
    ],
    "type": "modal"
}
モーダル表示リクエスト処理を追加・修正する

作成したビュー定義JSONを読み込んでSlackにデータ変更用モーダル表示リクエストを送信する処理を追加します。ついでに既存のデータ登録用モーダル表示処理も今回作成したビュー定義JSONを使用するように変更しておきます。

モーダル表示リクエストは削除処理の実装時に作成した汎用的な関数「openView」を使用します。

go-slack-bot-hydration/slack_repository.go at v1.1 · pirosuke/go-slack-bot-hydration · GitHub

// データ登録用モーダルの表示リクエスト
func (repo *SlackRepository) OpenHydrationAddView(triggerID string) ([]byte, error) {

    // ビュー定義JSONに埋め込む内容を作成します。
    viewParams := map[string]string{
        "callbackID":    "hydration__record_form",
        "metadata":      "",
        "initialDrink":  "",
        "initialAmount": "100",
    }

    // モーダル表示リクエストを送信します。
    return repo.openHydrationEditView(triggerID, viewParams)
}

// データ変更用モーダルの表示リクエスト
func (repo *SlackRepository) OpenHydrationUpdateView(triggerID string, channel string, ts string, hydration models.Hydration) ([]byte, error) {

    // ビュー定義JSONに埋め込む内容を作成します。
    // metadetaにはチャンネルIDとメッセージタイムスタンプと水分摂取記録IDをハイフン区切りで入れておきます。
    viewParams := map[string]string{
        "callbackID":    "hydration__update_form",
        "metadata":      channel + "-" + ts + "-" + strconv.FormatInt(hydration.ID, 10),
        "initialDrink":  hydration.Drink,
        "initialAmount": strconv.FormatInt(hydration.Amount, 10),
    }

    // モーダル表示リクエストを送信します。
    return repo.openHydrationEditView(triggerID, viewParams)
}

// データ登録・変更モーダル表示用共通関数
func (repo *SlackRepository) openHydrationEditView(triggerID string, viewParams map[string]string) ([]byte, error) {
    var resp []byte

    viewPath := filepath.Join(repo.ViewsDirPath, "record_form.json")
    if !file.FileExists(viewPath) {
        return resp, fmt.Errorf("View file does not exist: %s", viewPath)
    }

    // アラート表示で作成した汎用モーダルリクエスト関数を使用します。
    return repo.openView(triggerID, viewPath, viewParams)
}
修正ボタンクリック時の処理にモーダル表示処理を追加する

作成したモーダル表示リクエスト用関数をリクエストハンドラ関数に追加します。

// HandleOpenHydrationUpdateForm opens hydration edit form modal.
func HandleOpenHydrationUpdateForm(c echo.Context, appConfig config.Config, configsDirPath string, payload interface{}) error {
    // payloadからトリガーIDを取得します
    iTriggerID, _ := jsonpointer.Get(payload, "/trigger_id")
    triggerID := iTriggerID.(string)

    // payloadからユーザー名を取得します
    iUserName, _ := jsonpointer.Get(payload, "/user/username")
    userName, _ := iUserName.(string)

    // payloadから水分摂取記録IDを取得します
    iHydrationID, _ := jsonpointer.Get(payload, "/actions/0/value")
    sHydrationID, _ := iHydrationID.(string)
    hydrationID, _ := strconv.ParseInt(sHydrationID, 10, 64)

    // payloadからメッセージタイムスタンプとチャンネルIDを取得します
    iMessageTS, _ := jsonpointer.Get(payload, "/container/message_ts")
    iChannel, _ := jsonpointer.Get(payload, "/container/channel_id")
    messageTS, _ := iMessageTS.(string)
    channel, _ := iChannel.(string)

    // 非同期でモーダル表示リクエストを送信します。
    go func() {
        // データベースからデータを取得します。
        hydration, err := repo.FetchOne(hydrationID)
        if err != nil {
            c.Echo().Logger.Error(err)
            return
        }

        slackRepo := &repositories.SlackRepository{
            Token:        appConfig.Slack.Token,
            ViewsDirPath: filepath.Join(configsDirPath, "views"),
        }

        // データがリクエストを送信したユーザーが作成したものかをチェックします。
        if hydration.Username == userName {
            // データ変更モーダル表示リクエストを送信します。
            _, err = slackRepo.OpenHydrationUpdateView(triggerID, channel, messageTS, hydration)
            if err != nil {
                c.Echo().Logger.Error(err)
            }
        } else {
            // 水分摂取記録がリクエストユーザーが作成したものでない場合はアラートモーダルを表示します。
            _, err = slackRepo.ShowAlert(triggerID, "更新できません", "この記録を更新する権限がありません")
        }
    }()

    return c.String(http.StatusOK, "")
}

これで修正ボタンをクリックした時にデータ変更用モーダルが表示されるようになりました。

データ変更処理を実装する

最後にデータ変更用モーダルの入力内容を受け取ってデータを変更する機能を実装します。

データベースデータ更新処理を実装する

データベースのデータを更新するためのupdate用関数を実装します。

go-slack-bot-hydration/hydration_pg_repository.go at v1.1 · pirosuke/go-slack-bot-hydration · GitHub

// Update updates hydration data.
func (repo *HydrationPgRepository) Update(hydration models.Hydration) error {
    _, err := repo.conn.Exec(context.Background(), "update hydrations set drink = $1, amount = $2, modified = $3 where id = $4 and username = $5",
        hydration.Drink,
        hydration.Amount,
        hydration.Modified,
        hydration.ID,
        hydration.Username,
    )
    return err
}
Slackメッセージ更新処理を実装する

続いてSlackメッセージを更新するリクエストを送信する処理を実装します。メッセージの更新するにはSlack APIの「chat.update」に「チャンネル」「メッセージタイムスタンプ」とメッセージ内容を送信します。

chat.update method | Slack

// PostHydrationUpdateResult posts hydration added result message.
func (repo *SlackRepository) PostHydrationUpdateResult(userName string, channel string, ts string, hydration models.Hydration, dailyAmount int64) ([]byte, error) {
    var err error
    var resp []byte
    var requestParams, view interface{}

    viewPath := filepath.Join(repo.ViewsDirPath, "result_message.json")
    if !file.FileExists(viewPath) {
        return resp, fmt.Errorf("View file does not exist: %s", viewPath)
    }

    requestJSON := `{"channel": "", "ts": "", "blocks": []}`
    err = json.Unmarshal([]byte(requestJSON), &requestParams)
    if err != nil {
        return resp, err
    }

    viewJSONTemplate, err := ioutil.ReadFile(viewPath)
    if err != nil {
        return resp, err
    }

    viewParams := map[string]string{
        "hydrationID": strconv.FormatInt(hydration.ID, 10),
        "userName":    hydration.Username,
        "drink":       hydration.Drink,
        "amount":      strconv.FormatInt(hydration.Amount, 10),
        "dailyAmount": strconv.FormatInt(dailyAmount, 10),
    }

    viewJSON := replaceViewTemplateParams(string(viewJSONTemplate), viewParams)

    err = json.Unmarshal([]byte(viewJSON), &view)
    if err != nil {
        return resp, err
    }

    err = jsonpointer.Set(requestParams, "/blocks", view)
    if err != nil {
        return resp, err
    }

    err = jsonpointer.Set(requestParams, "/channel", channel)
    if err != nil {
        return resp, err
    }

    err = jsonpointer.Set(requestParams, "/ts", ts)
    if err != nil {
        return resp, err
    }

    requestParamsJSON, err := json.Marshal(requestParams)
    if err != nil {
        return resp, err
    }

    fmt.Println(string(requestParamsJSON))

    resp, err = slack.PostJSON(repo.Token, "chat.update", string(requestParamsJSON))
    fmt.Println(string(resp))

    return resp, err
}
データ変更用モーダルsubmit時の処理にデータ変更リクエスト送信処理を追加する

最後に、データ変更用モーダルをsubmitした時にデータ更新処理が走るようにリクエストをハンドルする処理を追加します。

go-slack-bot-hydration/server.go at v1.1 · pirosuke/go-slack-bot-hydration · GitHub

   callbackID := iCallbackID.(string)
    if len(callbackID) > 0 {
        switch callbackID {
        case "hydration__record_drink":
            return HandleOpenHydrationForm(c, appConfig, configsDirPath, payload)
        case "hydration__record_form":
            return HandleHydrationFormAddSubmission(c, appConfig, configsDirPath, payload)
        case "hydration__update_drink":
            return HandleOpenHydrationUpdateForm(c, appConfig, configsDirPath, payload)

        // データ更新リクエスト用の分岐を追加します
        case "hydration__update_form":
            return HandleHydrationFormUpdateSubmission(c, appConfig, configsDirPath, payload)

        case "hydration__delete_drink":
            return HandleHydrationDelete(c, appConfig, configsDirPath, payload)
        default:
            c.Echo().Logger.Warn("Unrecognized callbackID:", callbackID)
        }
    }
// HandleHydrationFormUpdateSubmission saves hydration and posts result message.
func HandleHydrationFormUpdateSubmission(c echo.Context, appConfig config.Config, configsDirPath string, payload interface{}) error {

    // payloadから入力データを取得します
    iDrink, _ := jsonpointer.Get(payload, "/view/state/values/drink/drink/value")
    iAmount, _ := jsonpointer.Get(payload, "/view/state/values/amount/amount/selected_option/value")

    iUserName, _ := jsonpointer.Get(payload, "/user/username")

    // payloadからprivate_metadataを取得し、チャンネル、メッセージタイムスタンプ、水分摂取記録IDに分割します
    iMetadata, _ := jsonpointer.Get(payload, "/view/private_metadata")
    metadata, _ := iMetadata.(string)
    metadataList := strings.Split(metadata, "-")

    channel := metadataList[0]
    messageTS := metadataList[1]
    hydrationID, _ := strconv.ParseInt(metadataList[2], 10, 64)

    drink, _ := iDrink.(string)
    amount, _ := iAmount.(string)
    userName, _ := iUserName.(string)

    intAmount, _ := strconv.ParseInt(amount, 10, 64)

    // 非同期で更新処理を実行します
    go func() {
        hydration := models.Hydration{
            ID:       hydrationID,
            Username: userName,
            Drink:    drink,
            Amount:   intAmount,
            Modified: time.Now(),
        }

        // データベースのデータを更新します。
        err := repo.Update(hydration)
        if err != nil {
            c.Echo().Logger.Error(err)
            return
        }

        dailyAmount, err := repo.FetchDailyAmount(userName)
        if err != nil {
            c.Echo().Logger.Error(err)
            return
        }

        slackRepo := &repositories.SlackRepository{
            Token:        appConfig.Slack.Token,
            ViewsDirPath: filepath.Join(configsDirPath, "views"),
        }

        // Slackのメッセージを更新します
        _, err = slackRepo.PostHydrationUpdateResult(userName, channel, messageTS, hydration, dailyAmount)
        if err != nil {
            c.Echo().Logger.Error(err)
            return
        }
    }()

    return c.String(http.StatusOK, "")
}

これで変更機能の実装が完了しました。

以上で削除機能、変更機能の実装完了です。

おわりに

長くなりましたが、これで前回内容と合わせてデータの「登録」「変更」「削除」「表示」の機能を一通り持つSlackアプリが完成しました。

ソースに出てくる関数はなるべくデータの種類が変わっても使い回せるように作っていますので、他のデータを扱うアプリを作る際もここで実装した内容を流用することで多少は作りやすくなるはずです。

Slackをフロントエンドとすることで、ブラウザでもPCアプリでもスマホでも使えるプラットフォームに自由に機能を追加できると考えると、いろいろ作ってみたくなりますね!