zudo-slack-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

投稿と Block Kit

chat.postMessage の要点 — チャンネルの指定方法、スレッド化、blocks + text のフォールバック、主要なブロック種別、メッセージサイズの限界、色付きアクセントバー、リンクボタン、unfurl フラグ、cron からの安全な投稿

チャンネルの指定方法

chat.postMessagechannel には「メッセージの送信先となるチャンネル、プライベート グループ、あるいは IM チャンネルを表すエンコード済み ID またはチャンネル名」を渡す (chat.postMessage)。実務では 名前ではなくエンコード済み ID(C0123456789)を使うのが望ましい。ID はチャンネル名の変更を またいでも変わらず、名前ベースの解決には、アプリがすでにそのチャンネルのメンバーであることを 前提とするものもあるからだ。

メンバーシップの要件はチャンネル種別によって異なる。 chat:write.public スコープを もつ bot は、パブリックチャンネルに参加しないまま投稿できる — このスコープは参加の手順を 省くためにこそ存在する。これがない場合、bot は投稿前に conversations.join でパブリック チャンネルに参加する必要がある。プライベートチャンネルにはこうした近道はない。明示的に 招待されていないプライベートチャンネルへの投稿を許すスコープは存在しない。

thread_ts によるスレッド化

thread_ts(返信自身の ts ではなく、親メッセージの ts)を渡すと、そのメッセージはトップ レベルの投稿ではなくスレッド返信になる。押さえておきたい点が 2 つある。

  • 必ず親の ts を指定する。 返信の ts を指定してもさらに入れ子にはならない — Slack は スレッドを 1 階層に平坦化するので、返信はいずれにせよ親と同じスレッドに入る。とはいえ任意の 入れ子を前提としたコードは結果を読み違えることになる。

  • reply_broadcast: true を付けると、スレッド返信であると同時にチャンネル本体にも見える 形で投稿される。スレッド内の返信がチャンネル全体にとっても重要な知らせである場合のための ものだ。既定は false で、通常のスレッド返信は読み手がスレッドを開かないかぎりチャンネル 本体には現れない。

blocks + text: 実質的に必須のフォールバック

blocks があるとき text は厳密な必須項目ではないが、「強く推奨」されている。blocks を 指定した場合、text はプッシュ通知やメールダイジェスト、その他 Block Kit をレンダリング できない場所で表示されるフォールバック文字列として使われる(前掲の chat.postMessage)。 省略すれば、bot のメッセージは空白または汎用的な通知として届くことになる。表示される本文が すべてブロックであっても、text にはメッセージ内容を平易な言葉で短くまとめたものを必ず 設定しておくこと。

主要なブロック種別

ブロック用途主な制限
section主要コンテンツ — テキストに加えて任意の accessory(ボタン、画像、セレクトなど)、またはコンパクトなキー/値グリッド用に最大 10 個の fieldstext は最大 3,000 文字、fields 配列は最大 10 要素(リファレンス
context小さく控えめな補足行 — 短いテキストと画像要素の組み合わせで、「2 分前に更新」のようなメタデータ向き最大 10 要素
actionsインタラクティブ要素 — ボタン、セレクトメニュー、日付ピッカー最大 25 要素(リファレンス
divider視覚的な区切り線。コンテンツなし
headerメッセージ先頭の大きな太字テキスト。plain_text のみ(mrkdwn 不可、基本セットを超える絵文字ショートハンドのレンダリングもなし)最大 150 文字(リファレンス

1 つのメッセージが持てるブロックは最大 50 個。モーダルと App Home のビューではこれが 100 に 引き上げられる(Block Kit overview)。

メッセージサイズの現実

上のブロック単位の制限とは別に、chat.postMessagetext にはメッセージ全体としての 上限もある。Slack はおよそ 40,000 文字を超えるテキストを切り詰め(場合によっては複数の メッセージに分割)する (Truncating really long messages)。 この上限は、まともに組み立てられたメッセージが近づくような数字ではまったくない — 設計の目標値としてではなく、最後の砦として扱うこと。

リストをメッセージにレンダリングする bot(ダイジェストやバッチのサマリー)にとって、実務上の リスクは 40,000 文字の上限ではなく、上の表にある section の 3,000 文字制限、そしてもう 1 つ 微妙な落とし穴 —「…and N more」のようなフッターは、あと 1 行入れるかどうかを判断している まさにそのあいだにも N が変わるため長さ自体が変動する — にある。フッターも含めて実際に レンダリングされたテキスト全体を、プラットフォームの上限よりずっと低い保守的な予算 (メッセージ全体で 20,000 文字など)と突き合わせ、収まるまで末尾から行を削っていくこと。 その際、フッターは行を削る判断をしたあとに計算する。section あたり 3,000 文字の制限にも それ自体の余裕を持たせること — 3,000 ではなく 2,900 程度に切り詰めておけば、最後の行の フッターや 1 文字のずれでブロックが拒否される事態を防げる。

色付きの左アクセントバー

Block Kit 単体では、メッセージの左端に沿った色付きの縦帯をレンダリングする手段がない — section ブロックにはそのための border/color プロパティがないからだ。これをいまも実現できる 唯一の仕組みはレガシーな attachments フィールドで、モダンな Block Kit のブロックを 1 つの attachment の中に入れ子にする。

{
  channel: "C0123456789",
  text: "Deploy failed: payment-service",
  attachments: [
    {
      color: "#e01e5a",
      blocks: [
        {
          type: "section",
          text: { type: "mrkdwn", text: "*Deploy failed:* payment-service" },
        },
      ],
    },
  ],
}

attachments はレガシーだが非推奨ではない — 積極的な開発対象ではなく現状のまま凍結された 機能で、Slack 自身のガイダンスも、attachments にしかできないことを除けば Block Kit を 優先するよう述べている (Legacy secondary message attachments)。 アクセントバーはまさにその「attachments にしかできないこと」の 1 つで、内部にモダンな Block Kit を入れ子にしても問題なく組み合わさる — ここで attachments を使うことは、 メッセージの他の部分で Block Kit を手放すことを意味しない。トップレベルの text フィールドは 引き続き設定しておくこと。これは前述の通知フォールバックであり、attachments の中身とは 独立している。

細かいカテゴリごとに色を 1 つずつ割り当てたくなる衝動(ステータスコードごとに 1 色、 優先度ごとに 1 色、といったもの)は抑えること。小さな意味論的パレット — 例えば通常/要注意 の 2 状態程度で十分なことが多い — のほうが、似たような色の虹よりも速く読み取れるし、 読み手が一度に覚えるべき意味が 10 通りではなく 2、3 通りで済むぶん、頭の中の対応表も 組み立てやすい。

リンクボタン

url を設定した actions ブロックのボタンは、純粋なリンクボタンとして機能する — クリックすると ユーザーのブラウザで URL が開くだけで、他のリンクをクリックするのと変わらない。action_id を 省くのは近道ではなく仕様どおりの書き方だ。Slack のボタンのリファレンスでは、この要素の必須 フィールドは typetext だけで、action_idurlvaluestyle はいずれも任意と されている (Button element)。

ただし、ペイロードそのものが存在しないと結論する前に、同じリファレンスの続きを読んでおきたい。 そこには、url を使う場合でもインタラクションのペイロードは届き、それに応答(acknowledge)する 必要がある、と書かれている — 「アプリが扱いたければ扱える」よりも強い言い方だ。それでもリンク ボタンが成立するのはスキーマではなく設定の側の事情で、Interactivity の Request URL が設定されて いないアプリには Slack がそのペイロードを届ける先がなく、応答すべきリクエスト自体が発生しない。 どちらにしてもリンクは開くので、リンクボタンしか使わない bot であれば、そもそもアプリの Interactivity を有効にする必要はない。

とはいえ、安定した action_id は付けておくほうがよい。 追加はたった 1 行で、インタラクションの ペイロードが実際に届いたときに突き合わせるフィールドとしてドキュメントに定義されており、後から Interactivity を有効にする場面で「設定を変えるだけ」で済むか「これまでに出したボタンを全部 洗い直す」ことになるかの分かれ目になる。下のサンプルは最小構成を示すために省いているが、本番の コードでは付けておきたい。

{
  type: "actions",
  elements: [
    {
      type: "button",
      text: { type: "plain_text", text: "Open dashboard" },
      url: `${env.DASHBOARD_BASE_URL}/deploys/${deployId}`,
      style: "primary",
    },
  ],
}

style: "primary" を付けると、(1 セットにつき最大 1 個の)ボタンにメインアクションを表す 強調色が付く。

url が絶対 URL(http:// または https://)でないボタンは、そのボタン単体だけでなく メッセージ全体を invalid_blocks として拒否する。 これはまさに、ベース URL 用の環境変数が 未設定または空になっているときに実務で起きる失敗モードだ。env.DASHBOARD_BASE_URL が空文字列に 解決され、ボタンの url が裸の相対パスになり、メッセージの他の部分も含めて投稿全体が一切 送信されなくなる。ブロックを組み立てる前に候補となる URL をすべて検証し(スキームがあるか、 http/https かどうか)、不正な URL は壊れたボタンとして送るのではなく、メッセージ本文中の プレーンテキストの行に格下げすること — リンクが 1 つ欠けたメッセージのほうが、まったく投稿 されずに終わるメッセージよりもずっと小さな失敗で済む。

unfurl フラグ

bot による chat.postMessage の呼び出しにおいて、unfurl_linksunfurl_media は既定値を 共有しない。このページの以前の記述はそうではないと述べていたが、それは誤りだった。この メソッド自体の引数リファレンスは、この 2 つを対照的な言い回しで説明している。unfurl_media の説明は「false を渡すと無効化する」(オプトアウトの言い回し = 既定で有効)であるのに 対し、unfurl_links の説明は「true を渡すとテキストベースのコンテンツの展開を有効化 する」(オプトインの言い回し = 既定で無効)となっている (chat.postMessage) — このリファ レンスページが他の項目でも、あるフィールドが既定で有効か無効かを示すのに使っている同じ disable/enable の対応関係だ(mrkdwn も同じ言い回しで説明されている。「false を設定する ことで無効化… 既定で有効」)。実務でいえば、bot の投稿ではメディアプレビューはパラメータなしで 自動的に展開されるが、プレーンなテキストリンクはプレビューカードが表示される前に unfurl_links: true を明示的に渡す必要がある。

この非対称性はプログラムによる投稿に固有のものだ。人が Slack のメッセージ作成欄に直接 貼り付けたリンクは、アプリ側のパラメータをまったく介さず、クライアント自体の既定の挙動に 従って展開される — 「リンクは自動的に展開される」という直感は、人が入力したものについては 成り立つが、bot が Web API 経由で投稿したものについては成り立たない。

メッセージがすでに独自の Block Kit レイアウトを持っていて、その下に自動生成のメディアカードが 割り込んで場所を取り合う必要がない場合は unfurl_media: false を設定する。テキストリンクの プレビューが実際に欲しい場合は unfurl_links: true を意図的に渡すこと — そうしないと表示 されない。

同じ非対称性がパーマリンクのクロスポストにも当てはまる例は パーマリンクとチャンネル横断アラートを参照。そこでも unfurl_links: true を明示的に渡さなければプレビューカードは一切表示されない。

cron からの安全な投稿

ソースレコードごとに新規メッセージを投稿するスケジュールジョブ(1 つのダッシュボード メッセージをその場で更新し続けるのではなく、個々の Slack 投稿をバッチで作成するジョブ)には、 「投稿に成功した」の独自の定義が必要だ。「API 呼び出しが例外を投げなかった」だけでは 不十分で、HTTP レイヤーでは成功していても Slack 自身がメッセージを拒否することがあるからだ。

成功とは、HTTP が ok であり、かつ json.ok === true であり、かつ channelts の両方が 空でなく存在すること。 この 3 つがすべて揃って初めて、ソースレコードを投稿済みとしてマーク すべきだ。

async function postAndRecord(env: Env, record: SourceRecord): Promise<void> {
  const res = await fetch("https://slack.com/api/chat.postMessage", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${env.SLACK_BOT_TOKEN}`,
      "Content-Type": "application/json; charset=utf-8",
    },
    body: JSON.stringify({ channel: env.TARGET_CHANNEL_ID, text: renderText(record) }),
  });
  const json = await res.json<{ ok: boolean; channel?: string; ts?: string; error?: string }>();

  if (!res.ok || !json.ok || !json.channel || !json.ts) {
    throw new Error(`chat.postMessage failed for ${record.id}: ${json.error ?? res.status}`);
  }

  await saveDeliveryIdentity(env, record.id, { channel: json.channel, ts: json.ts });
}

成功が確認できた瞬間に、(channel, ts) のペアをそのレコードの永続的な識別子として保存する こと。このペアは、そのメッセージへ後から遡って辿れる唯一のキーだ — ジョブがそれを保存 し始める前に投稿されたメッセージは、二度と同期できなくなる。自分でその場で記録しておく以外に、 「あるレコードが生み出したメッセージ」を後から見つける API は存在しない。

chat.getPermalink は、同じ成功/失敗の単位には含まれない、別途リトライ可能な独立したステップ だ。投稿自体は成功したがパーミンクの取得に失敗した場合(ネットワークの瞬断や、その呼び出し 1 回だけの一過性のエラーなど)でも、そのレコードは投稿済みのまま正しくマークされている。 後続のパスでは、(channel, ts) は持っているがパーマリンクをまだ持たないレコードに対して パーマリンクだけを再取得すればよく、すでに識別子を持つメッセージを再投稿してはならない。

クラッシュの起こりうる窓を、なかったことにせず名前を付けて扱うこと。 「Slack がメッセージを 受理した」瞬間と「識別子の書き込みが完了する」瞬間のあいだでクラッシュが起きると(アイソレート の破棄、キャッチされない例外、KV/DB への書き込み失敗など)、次回実行時に重複投稿が 1 件 発生する — ソースレコードはまだ未投稿のままなので、ジョブが再度それを投稿してしまう。この窓は 狭めることはできる(識別子の書き込みをすぐ次のステップにし、2 つのステップの間に他の処理を 挟まない)が、Slack 自身が提供していない仕組み(chat.postMessage には冪等性キーの パラメータがない)なしに完全になくすことはできない。これをゼロに追い込むための凝った仕組みを 作り込むのではなく、既知の、範囲の定まった失敗モードとして受け入れること。

スレッド返信には独自の try/catch を用意する。 ジョブが親メッセージを投稿し、続けて 1 件以上のスレッド返信を投稿する場合(thread_ts に親の ts を指定する場合)、返信の失敗が 親のすでに確定した投稿済み状態をロールバックしてはならない。返信の投稿はそれぞれ個別に ラップし、失敗した返信についてはログを残すかリトライをキューに積み、親の識別子の書き込みは そのままにしておくこと — 親のレコードは投稿済みであり、返信が欠けているのはそれとは別の、 独立して直せる小さな問題だ。

text オブジェクト内で使う mrkdwn の構文は書式を、投稿済みメッセージを chat.update で更新するダッシュボードに仕立てる方法はその場での更新を 参照。

Revision History

作成更新