ここがつらい！ Slack API

- 半分ネタ記事です。あんまり真面目に書きません。
- 項目数が多いので，気力でなんとか書きます。分類は諦めます。
- 他にもある！っていうのがあったらコメント欄で教えて下さい。<del>気が向いたら</del>追記します。

## 公式の TypeScript 型定義がもはや型定義を諦めている

`辛い度: ★★★★★`

辛い中でもこれはかなり上位に来るやつ。

https://github.com/slackapi/slack-api-specs

こちらに OpenAPI 形式で仕様が定義されていて，

- https://github.com/slackapi/node-slack-sdk/tree/main/packages/web-api/types

ここに仕様に基づいて TypeScript の型定義ファイルが吐かれるようになっています。 Git 管理されていないので，実際のリリースを見てみましょう。

- https://unpkg.com/@slack/web-api@6.7.2/dist/response/ReactionsGetResponse.d.ts
- https://unpkg.com/@slack/web-api@6.7.2/dist/response/ConversationsHistoryResponse.d.ts

```ts
export declare type ReactionsGetResponse = WebAPICallResult & {
    channel?: string;
    error?: string;
    message?: Message;
    needed?: string;
    ok?: boolean;
    provided?: string;
    type?: string;
};
export interface Message {
    app_id?: string;
    blocks?: Block[];
    bot_id?: string;
    bot_profile?: BotProfile;
    permalink?: string;
    reactions?: Reaction[];
    team?: string;
    text?: string;
    ts?: string;
    type?: string;
    user?: string;
}
export interface Block {
    accessory?: Accessory;
    alt_text?: string;
    api_decoration_available?: boolean;
    block_id?: string;
    call?: Call;
    call_id?: string;
    dispatch_action?: boolean;
    element?: Accessory;
    elements?: Accessory[];
    external_id?: string;
    fallback?: string;
    fields?: Hint[];
    file?: File;
    file_id?: string;
    hint?: Hint;
    image_bytes?: number;
    image_height?: number;
    image_url?: string;
    image_width?: number;
    label?: Hint;
    optional?: boolean;
    source?: string;
    text?: Hint;
    title?: Hint;
    type?: string;
}
```

**見てください！この `undefined` になりうる値の<font color=blue>大海原</font>！！！**
最高すぎますよね！！！！！！！

全部のエンドポイントがこの状態です。どの値がどういう場合に省略されるのかは，実際にリクエストを送ってみて，返ってきたそれが答えです。

## 腐敗防止層を用意せずしてまともに起用する気にならない

`辛い度: ★★★★☆`

そのまま使ったら，アプリケーション側でしょっちゅう `undefined` でないかの確認が必要です。汚染がやばすぎる…

## 公式のドキュメントが間違っていることがある

`辛い度: ★★★★★`

<del>辛かった場所が多すぎて何が間違っていたかあんまり覚えてないんですが</del>，とにかくドキュメントは 100% は信じないほうがいいです。仕様よりも本物の動作が全て。

## PHP 用の SDK が無い

`辛い度: ★★★★★`

これはポジショントークですが， PHP ユーザにとっては悲報です。公式が提供している SDK が

- Java
- Python
- Node

以上 3 言語向けしかなく，<del>マイナー言語である</del> PHP は非公式のものを使わなければなりません。

https://github.com/jolicode/slack-php-api

ただ，これも[コメント](https://github.com/jolicode/slack-php-api/issues/137#issuecomment-1015170230)で触れられている通り，**<font color=red>本家の仕様定義が間違っている</font>ために修正パッチを適用して**生成したりしていることがあるので，マジで茨の道です。

## 開発やデバッグの手間がかかる

`辛い度: ★★☆☆☆`

実際に自分で動かさないとわからない！な Slack API ですが，シンプルな Web API （REST API と言いたいけど REST ではない）はローカルで動かすこともできるものの，スラッシュコマンドなど，**ユーザ操作の介入を伴うものは Slack からアクセスできるように公開する必要がある**ため，ローカルでのデバッグが面倒臭いです。

https://qiita.com/mininobu/items/b45dbc70faedf30f484e

今はこういうサービスもあるので，多少ラクにはなったかもしれません。

## トークンの権限などの設定ページが難解・トークンの種類が多い

`辛い度: ★★★☆☆`

以下がトークンなどの設定ページです。

https://api.slack.com/apps

以下が最初に現れるページの抜粋スクショです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/5e15183e-00b4-d736-db7f-1fdcce71124e.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/928571d6-ed5b-9f76-c076-4f977cbe9eca.png)

さて，あなたは以下のような状況にいることを想定してください。

*あなたは Slack を利用したアプリケーションの開発プロジェクトとして，後から参入してきました。既にいるメンバーが作ってくれたトークンが，新しい機能を開発するためには権限が足りませんでした。あなたはトークンに権限を追加する必要があります。*

あなたはどこに行けばいいでしょうか？正解は…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/cd1c0fe9-a60b-db6f-a98c-14292cec211b.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/4999ec18-00f6-97f8-12ad-8624ff0edf20.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/a2c47ab3-1b29-96d8-c29b-09d62a885ee5.png)

状況（というか作る物）にもよりますが，殆どの場合の正解はこれです。途中で出てきましたが， Slack には **3種類** のトークンがあります。

https://slack.dev/bolt-js/ja-jp/tutorial/getting-started

> Slack アプリで使用できるトークンには、ユーザートークン（xoxp）とボットトークン（xoxb）、アプリレベルトークン（xapp）の 3 種類があります。
> - **ユーザートークン** を使用すると、アプリをインストールまたは認証したユーザーに成り代わって API メソッドを呼び出すことができます。1 つのワークスペースに複数のユーザートークンが存在する可能性があります。
> - **ボットトークン** はボットユーザーに関連づけられ、1 つのワークスペースでは最初に誰かがそのアプリをインストールした際に一度だけ発行されます。どのユーザーがインストールを実行しても、アプリが使用するボットトークンは同じになります。 ほとんど のアプリで使用されるのは、ボットトークンです。
> - **アプリレベルトークン** は、全ての組織（とその配下のワークスペースでの個々のユーザーによるインストール）を横断して、あなたのアプリを代理するものです。アプリレベルトークンは、アプリの WebSocket コネクションを確立するためによく使われます。

更に，歴史的な部分を見ると， Bot トークンもトータルで3種類あったらしいです。

https://qiita.com/tkit/items/2536ea6971754f9a75d1#classic-bot

なんじゃそりゃ。どうしてこうなった。

## API の種類が多すぎる

`辛い度: ★★★★★`

https://api.slack.com/apis

を見ると，以下の API が列挙されています。

- **Web API**
- **Events API**
- Other APIs
  - Admin API
  - SCIM API
  - Audit Logs API
  - Status API
  - RTM API

（数えなくていいようなものもありますが）なんと合計 7 種類！
多くの場合は先頭 2 種類で済むよ〜と書いてくれているのが救いですね…

と言いたいのですが，なんと既にもうここでドキュメント漏れが存在します。以下の **Interactivity API** は完全にこのページからは漏れています。

https://api.slack.com/interactivity

図で整理してみると，こういう関係性。

（Events API だけどっちにも書いてあるので解釈がちょっと割れそう。性質的に，私はこれは Interactivity API とは別物だと考えています）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/d7837fae-a24b-2b8a-6630-cb4f64729ebe.png)


ややこしすぎるやろ！

## Interactivity API の中で，Slash Commands だけ WebHook エンドポイントの管理方法とかペイロードフォーマットが違う

`辛い度: ★★★☆☆`

既に書いている通り， Slash Commands だけはコマンド1個毎に個別に登録する仕様になっています。歴史的に見ると，最初に Slash Commands が出来て，後から Shortcuts と Interactive Components が出来た影響で， Slash Commands だけ仕様がナンセンスになっていたりするのかなぁと予想。

そしてなんと， ペイロードフォーマットが…

| API | ペイロードフォーマット |
|:---|:---|
| Slash Commands | `application/x-www-form-urlencoded` |
| Shortcuts | **`application/x-www-form-urlencoded` の <font color=red>"payload"</font> キー配下に URL エンコードされた  `application/json` が存在** |
| Interactive Components | **`application/x-www-form-urlencoded` の <font color=red>"payload"</font> キー配下に URL エンコードされた  `application/json` が存在** |

なんでそうなったん？

## Message Shortcut と Global Shortcut という分類なのにそれぞれ `message_action` `shortcut` という命名

`辛い度: ★☆☆☆☆`

https://api.slack.com/reference/interaction-payloads/shortcuts

> Helps identify which type of interactive component sent the payload. Global shortcuts will return `shortcut`, message shortcuts will return `message_action`.

なんでそうなったん？

## Slash Commands の引数パースを Slack 側がやってくれない

`辛い度: ★★★★★`

`/weather 94070` を実行したときのペイロードがこちら。

> ```
> token=gIkuvaNzQIHg97ATvDxqgjtO
> &team_id=T0001
> &team_domain=example
> &enterprise_id=E0001
> &enterprise_name=Globular%20Construct%20Inc
> &channel_id=C2147483705
> &channel_name=test
> &user_id=U2147483697
> &user_name=Steve
> &command=/weather
> &text=94070
> &response_url=https://hooks.slack.com/commands/1234/5678
> &trigger_id=13345224609.738474920.8088930838d88f008e0
> &api_app_id=A123456
> ```

コマンドだけ `command` に分離されますが，残りの部分は全部 `text` に結合された状態で入ってきます。これを頑張ってパースしなければなりません，が…

https://api.slack.com/reference/surfaces/formatting

ここに書かれている通り，数多くのエスケープ表現が存在します。メンションが典型的な例。ライブラリ無しでは正直やってられません…

（PHP 製のまともなライブラリが1つも無かったので，業務用にガッツリ書いて作ってます。まだ OSS 化は出来ていませんが）

## Block Kit システムでのレイアウトが3種類ある

`辛い度: ★★★★★`

Interactive Components の構成要素として， 「モーダル」「メッセージ」「ホームタブ」の 3 種類があることを既に示しましたが，これらで実現可能なことが若干違っています。モーダルが持つ機能が多いです。

https://api.slack.com/surfaces

| アクションタイプ | 対応範囲 | 説明 |
|:---|:---|:---|
| Block Action | モーダル<br>メッセージ<br>ホームタブ | あらゆるコントロールを触った瞬間に発生するアクション |
| View Submission | モーダル | モーダルのサブミットボタン押下で送信するアクション |

そのほか，モーダルだけ `private_metadata` というフィールドに状態を保持したり，モーダルの上にモーダルをスタックとして積んで開いたり，<del>開発者の対応が死ぬほど面倒くさい</del>器用なことが出来たりします。

## Block Kit Builder に状態管理まわりの不具合があって仕様を知らないと誤解しやすい

`辛い度: ★★☆☆☆`

https://app.slack.com/block-kit-builder

↑が Block Kit Builder というツールで，視覚的に Interactive Components を組み立てられる優れものです。これ自体はめちゃくちゃ便利なのですが…

「Actions Preview」タブでペイロードの中身を確認するとき， **直近の1アクションで発生したものしか `state.values` の中に現れなくなっています**。言葉よりも実際の状態を見てもらったほうがいいのでスクリーンショットを。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/1bcf5557-4afd-a40c-1b1b-87e84c95a19c.png)

これは

1. テキストボックスに内容を入力
1. チェックボックスをチェック
1. チェックボックスをチェックした瞬間の送信アクションを確認

という手順で調べたものですが，テキストボックスの中身を入れたはずなのになぜか `null` になっています。これは Block Kit Builder の仕様で，実際はちゃんと値は入ってきます。この仕様を知らないと無駄に悩むことになると思うので，ここで覚えておいてください。

## Block Kit で見た目が同じなのに分類や JSON ペイロードの持ち方が全く違うコントロールがある

`辛い度: ★★★☆☆`

Block Kit Builder の左側のペインを見てもらうと分かりますが，同じような名前のものが違うグルーピングのところに存在します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/6ee2cbbc-42bd-3f3f-4604-311e0e1af293.png)

これはどちらもラジオボタンですが，それぞれ

- Input with Radio Buttons
- Section with Radio Buttons

で微妙なデザイン差異や JSON ペイロードの構造に違いがあります。どっちかだけで良くないかこれ…？

## Interactive Components で応答するときに「WebHook レスポンスに載せていいもの」「別途リクエストを送る必要があるもの」の区別がつきにくい

`辛い度: ★★★★☆`

https://api.slack.com/interactivity/handling#responses

以下に「WebHook のレスポンスとして 200 OK で返していいもの」を示します。

| | Slash Commands | Block Actions | View Submissions | Message Shortcuts | Global Shortcuts |
|:---|:---|:---|:---|:---|:---|
| 空レスポンスによる ACK | 他を選択しない場合必須 | 必須 | 他を選択しない場合必須 | 必須 | 必須 |
| メッセージ応答 | 任意 | :x: | :x: | :x: | :x: |
| モーダル状態コントロール | :x: | :x: | 任意 | :x: | :x: |

https://api.slack.com/interactivity/slash-commands#responding_immediate_response

ここの「Sending an immediate response」の部分を見て「どうせモーダルでも一緒だろ」と思い込んで，モーダルからメッセージ応答しようとしたら全く動かなかったのが苦い思い出。

## `response_url` という分かりづらい命名の機能があるが，  Qiita に Slack の中の人が解説記事をわざわざ投稿するぐらい難解

`辛い度: ★★★★★`

実は各インタラクションには `response_url` というフィールドが含まれているのですが，これに対して**更にこちらからリクエストを送信すると**， **ACK を送った後に別途遅延処理としてメッセージ応答を伝えることができます**。↑で同期的なレスポンスで返せなかった Slash Commands 以外のインタラクションも，これを使えば対応できたりします。

…が，これの仕様がとにかく難解。 なんと Slack の中の人が Qiita に解説記事を投稿しています。

https://qiita.com/seratch/items/ed29acd565af36e65072

記事はとてもありがたいけど，もうちょっとこう…簡単にならないんですかねぇ…ｗ

## Web API の エンドポイントの命名が REST じゃない

`辛い度: ★★☆☆☆`

命名で Web API という形でごまかされています。本来は REST API と書きたい部分だと思われますが…

https://api.slack.com/methods

例↓

| 用途 | メソッド | パス |
|:---|:---|:---|
| メッセージの**作成** | POST | **`chat.postMessage`**
| メッセージの**削除** | POST | **`chat.delete`** |
| 投稿へのリアクションの追加 | POST | `reactions.add` |
| 投稿からのリアクションの削除 | POST | `reactions.remove` |
| ある**投稿**に対するリアクション群の取得 | GET | **`reactions.get`** |
| ある**ユーザ**が行ったリアクション群の取得 | GET | **`reactions.list`** |

これだけで「ん？？？」と感じられる方はいらっしゃると思いますが，実際そうなってます。動いているものが正義なんです。

（REST は万能ではないので至上主義ではないにしろ，それにしても命名をもうちょっとしっかりやってほしいですね…）

## ステータスコードが殆どすべて 200 で，存在しないエンドポイントにリクエストしても 200 が返ってくる

`辛い度: ★☆☆☆☆`

失敗したときは `{"ok":false}` で返してくるタイプの API です。まあ REST じゃないって言ってるからこれは許してあげよう。

```shell-session
user@host:~$ curl -i -X POST https://slack.com/api/chat.postMessage
HTTP/2 200
content-type: application/json; charset=utf-8
{"ok":false,"error":"not_authed"}

user@host:~$ curl -i -X GET https://slack.com/api/foo.bar
HTTP/2 200
content-type: application/json; charset=utf-8
{"ok":false,"error":"unknown_method","req_method":"foo.bar"}

user@host:~$ curl -i -X GET https://slack.com/api/foo-bar
HTTP/2 404
content-type: text/html; charset=utf-8
```

だが存在しないエンドポイントも 200 で返してくるのどうなん！？
しかし，ルーティングに引っ掛からないような文字種を使うと一応 404 になるらしい…なんじゃこりゃ

## メッセージを特定するための ID 相当のものが「チャンネル」「タイムスタンプ」の複合ナチュラルキーになっている

`辛い度: ★★★★☆`

殆どの方は， Web アプリケーションの開発には

- `AUTO_INCREMENT` `SERIAL` `IDENTITY` と呼ばれる自動採番値
- UUID と呼ばれる，確率的にほぼ被らないことが保証されている仕様の値

のいずれかを**サロゲートキー**（値自体に意味は無い，整理のために発行されるキー）を使われると思いますが， Slack は一部が**複合ナチュラルキー**（意味のある値の組み合わせのキー）になっています。メッセージに割り振られるものは複合ナチュラルキーです。ナチュラルキーそのものは否定しませんが，複合になってくると管理がそれなりに面倒です。

| キー | 説明 | 具体例 |
|:---|:---|:---|
| `channel_id` |チャンネルの ID<br>（これ自体はサロゲートキー）| `C0NF841BK` |
| `timestamp` |メッセージが投稿されたときのマイクロ秒精度のタイムスタンプ | `1524523204.000192` |

これらの値を2つとも渡さないとメッセージを特定できないケースが殆どです。

:::note warn
`channel_id` は，そのトークンでアクセス可能なワークスペース単位でしかユニークではありません。別の会社の Slack に，全く同じ `channel_id` が存在している可能性はあります。
:::

もし腐敗防止層をアプリケーション側で用意する場合は，これらの値をセットにしてシリアライズすると設計がラクになるでしょう。

## 「投稿の詳細情報を取得」に使うエンドポイントが事実上 `reactions.get` になる

`辛い度: ★★★★☆`

ID を指定して投稿の詳細情報を取得！と思い立ったら `messages.get` `messages.show` あたりを考えると思いますが，正解は **`reactions.get`** です。

https://api.slack.com/methods/reactions.get

仕様をよく読んでみると…

- 渡す値
  - **メッセージのリアクションを取得する場合， `channel` `timestamp` を渡す**
  - ファイルのリアクションを取得する場合， `file` を渡す
  - ファイルコメントのリアクションを取得する場合， `file` `file_comment` を渡す
- 返り値
  - **取りに行った<font color=red>リアクション対象そのもの</font>の中に `reactions` というキーでリアクション群が内包されるようになっている**

という形になっていることが分かります。つまり `reactions.get` の実態は **`messages.get?include=reactions` に近い**んです。

:::note warn
`full` パラメータを付けないと，リアクションの取得上限は **50** となります。完全な情報が欲しい場合は付けましょう。
:::

メッセージの場合は，以下のようなペイロードとして取得されます。

```json:普通のメッセージ
{
  "ok": true,
  "type": "message",
  "message": {
    "type": "message",
    "text": "test",
    "user": "U02ASEM3ESV",
    "ts": "1640154436.002000",
    "team": "T02FKA4M391",
    "permalink": "https://.../archives/C02S0JR88AD/p1640154436002000"
  },
  "channel": "C02S0JR88AD"
}
```

```json:リアクションが1件着いたメッセージ
{
  "ok": true,
  "type": "message",
  "message": {
    "type": "message",
    "text": "test",
    "user": "U02ASEM3ESV",
    "ts": "1640154436.002000",
    "team": "T02FKA4M391",
    "reactions": [
      {
        "name": ":ok:",
        "users": [
          "U01V7FUJWC8"
        ],
        "count": 1
      }
    ],
    "permalink": "https://.../archives/C02S0JR88AD/p1640154436002000"
  },
  "channel": "C02S0JR88AD"
}
```

そして，**リアクションがあるときだけ**フィールドが出現します！さすが何でもあり `undefined` な Slack API。

また，リアクションに加えて **パーマリンク** も含まれている点も重要だったりします。その理由は後述します。

## スレッドの表現形式がメッセージの親子関係になっている

`辛い度: ★★★★☆`

`reactions.get` を使って，スレッド内に作られたメッセージを取得すると以下のようになります。

```json:スレッド内に作られたメッセージ
{
  "ok": true,
  "type": "message",
  "message": {
    "type": "message",
    "text": "スレッド内メッセージです",
    "user": "U02ASEM3ESV",
    "ts": "1642505773.002100",
    "team": "T02FKA4M391",
    "thread_ts": "1642504946.001400",
    "parent_user_id": "U02ASEM3ESV",
    "permalink": "https://.../archives/C02S0JR88AD/p1642505773002100?thread_ts=1642504946.001400&cid=C02S0JR88AD"
  },
  "channel": "C02S0JR88AD"
}
```

なにやら `thread_ts` `parent_user_id` というものが生えてきました！

| キー | スレッドに属すとき<br>必ず存在するか？ | 説明 |
|:---|:---|:---|
| `thread_ts` | YES | 所属対象のスレッド化されたメッセージのタイムスタンプ |
| `parent_user_id` | **<font color=red>NO</font>** | 所属対象のスレッド化されたメッセージの作成者 ID。**<font color=red>所属対象のメッセージ作成者が「自分自身」「Bot」のいずれかに該当する場合は存在しない</font>** |

スレッドとして全く別の第 3 のオブジェクトが作られるわけではなく，他のメッセージを parent として親に持つ仕様になっていることに注意してください。

:::note success
スレッドに属するメッセージであるかを判定するためには，以下のようにタイムスタンプを比較検証します。
```js
const isReplyMessage = typeof message.thread_ts === 'string'
                    && message.ts !== message.thread_ts
```
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/754fdbba-614b-d75c-1a79-46ee3f9192ec.png)
↑デバッガーの苦しみ

## 返信された瞬間にメッセージはスレッド扱いになり，未定義だったフィールドが後から生える

`辛い度: ★★★★★`

```json:他のメッセージが返信として付いて，スレッド化されたメッセージ
{
  "ok": true,
  "type": "message",
  "message": {
    "type": "message",
    "text": "親メッセージ",
    "user": "U02ASEM3ESV",
    "ts": "1642504946.001400",
    "team": "T02FKA4M391",
    "thread_ts": "1642504946.001400",
    "reply_count": 1,
    "reply_users_count": 1,
    "latest_reply": "1642506002.004200",
    "reply_users": [
      "U01V7FUJWC8"
    ],
    "is_locked": false,
    "subscribed": false,
    "permalink": "https://.../archives/C02S0JR88AD/p1642504946001400?thread_ts=1642504946.001400&cid=C02S0JR88AD"
  },
  "channel": "C02S0JR88AD"
}
```

もうどんなペイロードを見てもあなたは驚かないでしょう。これが Slack API です。

| キー | スレッド化されたとき<br>必ず存在するか？ | 説明 |
|:---|:---|:---|
| `thread_ts` | YES | メッセージのタイムスタンプ |
| `reply_count` | YES | 返信数 |
| `reply_users_count` | YES | 返信者数 |
| `latest_reply` | YES | 最も新しい返信のタイムスタンプ |

:::note success
スレッド化されたメッセージであるかどうかを判定するためには，以下のようにタイムスタンプを比較検証します。<br>（こちらは他の方法もありますが，返信と対象的な書き方をしたほうが良いでしょう）
```js
const isThreadRootMessage = typeof message.thread_ts === 'string'
                         && message.ts === message.thread_ts
```
:::

:::note warn
全ての返信が削除されると，自分自身は再びスレッドでは無くなります。ステートフルなので気をつけてください！
:::

## 返信が残った状態で所属先のスレッドだけを削除すると，削除したメッセージが Slack Bot の投稿に変身する

`辛い度: ★★★★☆`

お前は何を言っているんだ感がありますが，これです，これ。見たことありますよね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/c88dd307-46bf-9fb1-ea81-a66c42159697.png)

これを API で取得するとこうなっています。

```json
{
  "ok": true,
  "type": "message",
  "message": {
    "type": "message",
    "subtype": "tombstone",
    "text": "This message was deleted.",
    "user": "USLACKBOT",
    "hidden": true,
    "ts": "1655549063.053909",
    "thread_ts": "1655549063.053909",
    "parent_user_id": "USLACKBOT",
    "reply_count": 1,
    "reply_users_count": 1,
    "latest_reply": "1655549413.855839",
    "reply_users": [
      "U02ASEM3ESV"
    ],
    "is_locked": false,
    "subscribed": false,
    "permalink": "https://.../archives/C02S0JR88AD/p1655549063053909?thread_ts=1655549063.053909&cid=C02S0JR88AD"
  },
  "channel": "C02S0JR88AD"
}
```

`USLACKBOT` は **公式 Slack Bot** 的なものです。この子です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/93ba7649-e3e2-5872-56c2-460f42dcb86f.png)
アイコン違うやん！ゴミ箱やん！
（てかサブタイプ `tombstone` なのに墓石じゃないんや…）

## パーマリンクを取得するためのエンドポイントがわざわざ存在する

`辛い度: ★★★☆☆`

> また，リアクションに加えて **パーマリンク** も含まれている点も重要だったりします。その理由は後述します。

と書きましたが，その理由は **パーマリンクが含まれないエンドポイントがある** からです。

```bash:通常メッセージまたはスレッドルートメッセージのパーマリンク
# チャンネルID/タイムスタンプ
https://.../archives/C02S0JR88AD/p1642505773002100
```

```bash:スレッドに所属する返信メッセージのパーマリンク
# チャンネルID/タイムスタンプ?thread_ts=スレッドルートのタイムスタンプ&cid=スレッドルートのチャンネルID
https://.../archives/C02S0JR88AD/p1642505773002100?thread_ts=1642504946.001400&cid=C02S0JR88AD
```

パーマリンクは上記のようなフォーマットとなりますが，ワークスペースの名前やスレッドの親子関係も含ませる必要があるため，自前生成はかなり骨が折れます。これは無いと困りますね。

パーマリンクを持たないレスポンスを返してくるエンドポイントの一例を挙げます。

- [conversations.history method | Slack](https://api.slack.com/methods/conversations.history)
  - チャンネルを指定して新しいほうからメッセージを取得するエンドポイント。**返信は含まれない。**
- [conversations.replies method | Slack](https://api.slack.com/methods/conversations.replies)
  - スレッドを指定して新しいほうから返信を取得するエンドポイント。
- [reaction_added event | Slack](https://api.slack.com/events/reaction_added)
  - リアクションが付いたことをリアルタイムで通知する Event API イベントのペイロード。

|  | `conversations.history` | `conversations.replies` | `reaction_added event` |
|:---|:---:|:---:|:---:|
| `permalink` を含む？ | :x: | :x: | :x: |
| `thread_ts` を含む？ | ✅ | ✅ | :x: |

これで最も困るのは， `reaction_added event` です。なんと， Event API で入ってくる値は**返信メッセージであっても `thread_ts` を含まないため， <font color=red>親を特定することができません</font>**。せめてパーマリンクさえあれば特定できるのに…

という流れで（知らんけど？），パーマリンクだけを取得するエンドポイントがこの悲劇を救済するかのように生えています。

- [chat.getPermalink method | Slack](https://api.slack.com/methods/chat.getPermalink)

> ```json
> {
>     "ok": true,
>     "channel": "C1H9RESGA",
>     "permalink": "https://.../archives/C1H9RESGA/p135854651500008"
> }
> ```

本当に最小の情報しか返さないんだな…

…しかし…お気づきでしょうか？**`reactions.get` を使うとパーマリンクやその他もろもろの情報を根こそぎ全部取れることに！** 但し，設定されている Tier が異なるため，可能な限り負荷の軽いほうを使う方が良いでしょう。

| エンドポイント | Tier | 1分あたりの上限アクセス数（概数） |
|:---|:---|:---|
| [reactions.get](https://api.slack.com/methods/reactions.get) | 3 | 50 |
| **[chat.getPermalink](https://api.slack.com/methods/chat.getPermalink)** | 特殊 | **ほぼ無制限** |

`reactions.get` を投稿の詳細情報取得に使用， `chat.getPermalink` を投稿の親子関係取得に使用… 命名からは想像もつかない濫用だ…

## 「Bot」「組織外ユーザ」「ゲスト」「オーナー」などの権限判定方法が非常に難解

`辛い度: ★★★★★`

権限を管理するのはどんなアプリケーションでも骨が折れるトピックですが， Slack の内部はかなり大変なことになっているようです。以前整理してみたときの樹形図があるので掲載します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/9b15482b-ae52-f479-d9fe-e87ed633adaf.png)

（一部私が考えた造語を使っています）

- Bot は SlackBot（公式の`USLACKBOT`） と CustomBot（ユーザが作るもの）の2種類がある
- 通常ユーザは組織内しか見えないが， **Slack Connect** で繋がっている場合は外部でも見える
- 組織内ユーザは，通常ユーザとは別に招待できるゲストがあり， **シングルチャンネルゲスト** と **マルチチャンネルゲスト** の2種類がある
- 組織内ユーザのうち一部は強い権限を持ち，強い順に **代表オーナー**, **オーナー**, **管理者** の3種類がある

https://slack.com/intl/ja-jp/help/articles/115004151203-Slack-%E3%82%B3%E3%83%8D%E3%82%AF%E3%83%88%E3%82%AC%E3%82%A4%E3%83%89---%E7%A4%BE%E5%A4%96%E3%83%91%E3%83%BC%E3%83%88%E3%83%8A%E3%83%BC%E3%81%A8%E3%81%AE%E9%80%A3%E6%90%BA

https://slack.com/intl/ja-jp/help/articles/202518103-Slack-%E3%81%A7%E3%81%AE%E3%82%B2%E3%82%B9%E3%83%88%E3%81%AE%E3%83%A1%E3%83%B3%E3%83%90%E3%83%BC%E7%A8%AE%E5%88%A5%E3%82%92%E7%90%86%E8%A7%A3%E3%81%99%E3%82%8B

https://slack.com/intl/ja-jp/help/articles/201314026-Slack-%E3%81%A7%E3%81%AE%E5%90%84%E3%83%A1%E3%83%B3%E3%83%90%E3%83%BC%E7%A8%AE%E5%88%A5%E3%81%AE%E6%A8%A9%E9%99%90

いやーめちゃくちゃややこしいですね。そしてこれらを完全に判定・分離するロジックがこちら。

```php:PHP&#32;コード
class Member
{
    private const SLACK_BOT_ID = 'USLACKBOT';

    // ObjsUser は SlackAPI のレスポンスほぼそのまま
    // jolicode/slack-php-api が提供するクラス
    public static function fromApiUser(ObjsUser $user): self
    {
        $id = $user->getId();

        assert($id !== null);

        return new self(
            id: $id,
            email: $user->getProfile()?->getEmail(),
            isBot: $user->getIsBot() ?? false,
            isAdmin: $user->getIsAdmin() ?? false,
            isOwner: $user->getIsOwner() ?? false,
            isPrimaryOwner: $user->getIsPrimaryOwner() ?? false,
            isRestricted: $user->getIsRestricted() ?? false,
            isUltraRestricted: $user->getIsUltraRestricted() ?? false,
        );
    }

    public function __construct(
        private readonly string $id,
        private readonly ?string $email,
        private readonly bool $isBot,
        private readonly bool $isAdmin,
        private readonly bool $isOwner,
        private readonly bool $isPrimaryOwner,
        private readonly bool $isRestricted,
        private readonly bool $isUltraRestricted,
    ) {
    }

    public function isBot(): bool
    {
        return $this->isSlackBot() || $this->isCustomBot();
    }

    public function isSlackBot(): bool
    {
        return $this->id === self::SLACK_BOT_ID;
    }

    public function isCustomBot(): bool
    {
        return $this->isBot;
    }

    public function isHuman(): bool
    {
        return !$this->isBot();
    }

    public function isHumanInExternalOrganization(): bool
    {
        // NOTE:
        //   外部ユーザは「users:read.email スコープを付与してもメールを取得できない」で判断
        //   https://engineer.dena.com/posts/2020.11/slack-app-for-enterprise-grid/
        return $this->isHuman()
            && $this->email === null;
    }

    public function isHumanInInternalOrganization(): bool
    {
        return $this->isHuman()
            && $this->email !== null;
    }

    public function isGuest(): bool
    {
        return $this->isRestricted;
    }

    public function isSingleChannelGuest(): bool
    {
        return $this->isUltraRestricted;
    }

    public function isMultiChannelGuest(): bool
    {
        return $this->isGuest() && !$this->isSingleChannelGuest();
    }

    public function isRegularMember(): bool
    {
        // NOTE:
        //   トークンに users:read.email スコープが無いと完全にバグるので注意
        return $this->isHumanInInternalOrganization()
            && !$this->isGuest();
    }

    public function isPrivilegedRegularMember(): bool
    {
        return $this->isRegularMember() && ($this->isAdmin() || $this->isOwner());
    }

    public function isNonPrivilegedRegularMember(): bool
    {
        return $this->isRegularMember() && (!$this->isAdmin() && !$this->isOwner());
    }

    public function isAdmin(): bool
    {
        return $this->isAdmin;
    }

    public function isOwner(): bool
    {
        return $this->isOwner;
    }

    public function isPrimaryOwner(): bool
    {
        return $this->isPrimaryOwner;
    }

    public function isSecondaryOwner(): bool
    {
        return $this->isOwner() && !$this->isPrimaryOwner();
    }
}
```

```
　　　　　　　　　　　　　 ,, -―-、
　　　　　　　　　　　　　／　　　　 ヽ
　　　　　　　／￣￣／　　／i⌒ヽ､|　　　　オエーー！！！！
　　　　　　/　　（゜）/　　 ／　/
　　　　　/　　　　 ト､.,../　,ー-､
　　　　=彳　　　　　 ＼＼‘ﾟ。､｀ ヽ。、ｏ
　　　　/ 　 　　　　　　　＼＼ﾟ。､。、ｏ
　　　/　　　　　　　　　/⌒ ヽ ヽU　　ｏ
　　 /　　　　　　　　　│　　　`ヽU　∴ｌ
　　│　　　　　　　　　│　　　　　U　：l
　　　　　　　　　　　　　　　　　　　　|：!
　　　　　　　　　　　　　　　　　　　　Ｕ
```

`is_restricted` `is_ultra_restricted` とかいう謎の命名と，外部組織ユーザの判定ロジックが香ばしいポイント。以下の記事は大変参考になりました。

https://engineer.dena.com/posts/2020.11/slack-app-for-enterprise-grid/

## 歴史的理由で `/me` コマンド専用の謎のメッセージ形式がある

`辛い度: ★★☆☆☆`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/43af4470-5219-5d8a-24b1-ed2ad4c08fc5.png)

ん？なんやこれ？

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/9f64de12-f2ac-5e8f-7d48-5e01ac102fdd.png)

API で取ってみると，中身は全く違います。

```json
[
  {
    "type": "message",
    "text": "_これは書式変更で斜体にしただけ_",
    "user": "U02ASEM3ESV",
    "ts": "1655561762.619899",
    "team": "T02FKA4M391",
    "blocks": [
      {
        "type": "rich_text",
        "block_id": "Wn6",
        "elements": [
          {
            "type": "rich_text_section",
            "elements": [
              {
                "type": "text",
                "text": "これは書式変更で斜体にしただけ",
                "style": {
                  "italic": true
                }
              }
            ]
          }
        ]
      }
    ]
  },
  {
    "type": "message",
    "subtype": "me_message",
    "text": "これは /me コマンド",
    "user": "U02ASEM3ESV",
    "ts": "1655561735.831039"
  }
]
```

斜体に見えるだけの謎のメッセージタイプ。なぜこんなものがあるんでしょうか？以下，調べてみた結果

https://twitter.com/mpyw/status/1471426520459939841

教えていただいたツイートが発掘できなかったのですが，マイクラのお遊びコマンドが元ネタのようで…

https://minecraft.fandom.com/ja/wiki/%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89/me

https://twitter.com/mpyw/status/1471435593507024899

例えば `/me left the channel` のように書くと，自分が退室したことに関するシステムアナウンスのように偽装できたようです。

> Slack 「ビジネス展開ガチで進めてきたし，そろそろ黎明期のお遊びコマンドは消すか。真面目な場で悪用されたらヤバいし」
> ユーザ 「えええええええええええええええ！！！！突然の破壊的変更はやめてくれ！！！！復活させろや！！！！」
> Slack 「ごめんて。完全に消すのはアレやからとりあえず**斜体で出すだけのコマンド**として復活させとくわ。ほなおおきに」

（きっとこういう流れ）

## JSON ペイロードの中の似ている同士の情報のどちらを使ったらいいのか分かりにくいものがある

`辛い度: ★★★☆☆`

https://qiita.com/hoto17296/items/587f299e4bf9fa3fd3fa

名前に関する項目を絞り込むと，似たようなフィールドがこれだけあります。上の記事を読んでいてもなおまだ迷いますね…

```json
{
  "name": "spengler",
  "real_name": "Egon Spengler",
  "profile": {
    "real_name": "Egon Spengler",
    "display_name": "spengler",
    "real_name_normalized": "Egon Spengler",
    "display_name_normalized": "spengler",
  },
}
```

- `*_normalized` はラテン文字を変換したやつかな…？
- `real_name` はダブってるけどどっちを見ればいいんですかねぇ…
  - 多分 `profile` が後から追加されたので `profile.real_name` を使うべきと予想

なお，名前以外にもまだ何個かダブり項目はあったような気がします…