Slackでもホラクラシーロールで呼び合えるようにした話

突然ですが、LAPRAS社の Slack では次のような**奇妙なメンション**が飛び交っています。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/309088/d9c141c3-9dc2-e2ea-3ef8-bcd1e03d2a27.png" width=640">
こうなった経緯をお話します。

# これはなにか

LAPRAS では、[ホラクラシー](https://medium.com/@hirokishimada_80077/%E3%83%9B%E3%83%A9%E3%82%AF%E3%83%A9%E3%82%B7%E3%83%BC%E7%B5%84%E7%B9%94%E3%81%B8%E3%81%AE%E8%AA%A4%E8%A7%A3%E3%81%A8%E6%9C%AC%E5%BD%93%E3%81%AE%E6%84%8F%E5%91%B3-a304d75251ae) という組織形態を導入しています。
筆者は[新しいホラクラシー憲法の全文を翻訳する](https://note.com/nunukim/n/nb3c1ee638ee8)ほどのホラクラシーオタクであり、話し始めるときりがないのでここではあまり説明しませんが、一言でいうと、ホラクラシーとは

> 役職ではなく、明文化された役割(ロール)が主役となる組織形態

です。と言われてもピンと来ないと思うので、図で説明します。下図は、LAPRAS社の2019年12月20日現在の[ホラクラシー組織図](https://app.holaspirit.com/public/lapras)の一部です。
<img width="784" alt="883034DC-FA51-4BA0-BFFC-62D46C06F230.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/309088/a82df57b-78fa-d5f6-62d9-3e31b562632c.jpeg">

1つ1つのマルが「ロール」を表しており、それぞれに明文化された目的と、責任と権限の範囲が設定されています。ロールを囲うマルは「サークル」で、いくつかのロールを目的によって集めたものです(サークルもまたロールの一種です)。あるロールには、1人または複数の人がアサインされ、1人の人は複数のロールにアサインされることができる多対多の関係になっています。

ホラクラシーでは、このような**ロールとサークルの構造を動的な見直しプロセスによって進化させていく**組織であり、メンバーは、**アサインされたロールを演じる**ことで、その**目的と権限、責任を明瞭化する**ことができます。
このように、ホラクラシー組織においては、ロールを演じること、つまり、**自分が何のロールとして行動しているのかを意識すること**が非常に重要なのです。

冒頭の画像は、**Slack 上においてホラクラシーのロール名を使って呼び合うことができるようになった** 様子です。例えば、`@role-product-product_management`は、「[プロダクトマネジメント](https://app.holaspirit.com/public/lapras/circle/lu995S-product/role/7ABsxD-)」ロール宛のメンションです。
これは、ホラクラシー組織の管理ツールである [holaSpirit](https://www.holaspirit.com/) のロール・サークル構造をSlack の UserGroup に同期することで実現しています。

日本語で入力補完も使えていい感じです。
![demon.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/309088/dfb78e70-83c6-3992-1136-190810757b76.gif)


# きっかけ

ホラクラシーでは、サークルのメンバーが集まるミーティングが[憲法で決められています](https://github.com/lapras-inc/holacracy_constitution/blob/master/holcracy_constitution_lapras.md#%E3%82%AC%E3%83%90%E3%83%8A%E3%83%B3%E3%82%B9%E3%83%9F%E3%83%BC%E3%83%86%E3%82%A3%E3%83%B3%E3%82%B0)。その参加者にSlackでメンションを送ろうとした場合、次のいずれかの方法を取ることになりますが、

* メンバーを確認して、1人ずつ個人名で送る → 非常に面倒
* `@here` でチャンネル全体に通知する → 関係ない人も巻き込むことになる

というように、いずれも問題を抱えていました。

これを解決するために、Slack の User Group を作って手動で運用することをしばらくやってみたのですが、なにぶんサークルのメンバーは動的に変わるし、サークル構造自体もどんどん変わってしまうので、手動で追従するのは無理がありました。

また、同じ頃、社内で「メンションを飛ばされても、どのロールの自分に対するメンションなのかわからない」という意見もありました。
名前ではなくロール名で呼び合えるようにしたほうが、よりロールになりきることができ、ホラクラシーを浸透できると考えました。

そこで、ホラクラシーのサークル構造やアサインを管理している holaSpirit の API を使って、 holaSpirit のサークルメンバーを Slack の UserGroup に同期する仕組みを作ることにしました。
ちょうど、 holaSpirit API を叩いてミーティング時間を集計するスクリプトを Google App Script で書いていたので、今回も軽い気持ちで GAS で実装することにしました。(これが地獄の始まりでした。)

# 作戦

![作戦.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/309088/2f75c036-894b-1d80-a978-bc1f20093f04.png)

holaSpirit と Slack の構造は上図のようになっています。
holaSpirit の情報を Slack に同期させるためには、次の手順で行いました。

1. holaSpirit と Slack の ユーザー ID を紐付ける。
2. holaSpirit のロール・サークル情報 を Slack の UserGroup に同期する。
3. holaSpirit のアサイン情報を Slack の UserGroup への参加状況に同期する。

Slack API との通信には、 [GASlacker](https://github.com/yhay81/GASlacker) を用い、holaSpirit は GAS 標準の `UrlFetchApp` を使うこととしました。

### 1. holaSpirit と Slack の ユーザー ID を紐付ける

紐付けには、メールアドレスを使います。
これは、全社的に Slack, holaSpirit ともに GSuite のメールアドレスを使うこととしていたため、わりとすんなり紐付けができることがわかりました。

Salck API でユーザーのメールアドレスを取得するには、 `users:read` 権限だけではなく、[`users:read.email`](https://api.slack.com/scopes/users:read.email) 権限も必要なので注意が必要です。

### 2. holaSpirit のロール・サークル情報 を Slack の UserGroup に同期する

次に、holaSpirit の ロール・サークル情報を Slack の UserGroup 情報に同期します。

holaSpirit 側から必要となる情報は、全て [Roles API](https://app.holaspirit.com/api/doc/role) (`GET /roles`) を1回叩くだけでまるっと取ってくることが出来ます。
Slack 側は、毎回すべての UserGroup に対して `usergroups.update` を叩いてまわるとすぐに Rate Limit に引っかかってしまうので、前回の値を保持しておき、変更があったものだけを更新するようにします。

また、Slack の UserGroup には、メンションに使う `@` から始まる ID 名が必要です。
これは普段メンションするときに使うものですから、連番やランダムな文字列ではなく、なるべく理解可能なものでなければなりません。
今回は、次の命名規則で ID 名を生成する事にしました。

* XXXサークルの場合: `@circ-xxx`
* XXXサークルのYYYロールの場合: `@role-xxx-yyy`

問題は、この ID 名には英数字といくつかの記号しか使えないことです。
サークル名やロール名には日本語も使われているので、英語の ID 名を生成するために、Google Translate (GAS の `LanguageApp.translate()` メソッド) を使いました。

例えば、次のように変換されます。

* [WebApp/データベースの鬼](https://app.holaspirit.com/public/lapras/circle/5JzsxD-webapp/role/hjBsxD-) → `@role-webapp-demon_of_database`
* [法務/法務の伝道師](https://app.holaspirit.com/public/lapras/circle/PMAsxD-/role/np_EpX-) → `@role-legal_affairs-legal_evangelist`
* [Prototyping Squad/成果刈り取りマン](https://app.holaspirit.com/public/lapras/circle/9ZzsxD-prototyping-squad/role/3K1HrK-) → `@role-prototyping_squad-achievement_mowing_man`

いい感じです。

### 3. holaSpirit のアサイン情報を Slack の UserGroup への参加状況に同期する

1 で紐付けたユーザー ID の対応関係を使って、holaSpirit でのロール・サークルのアサイン状況を、Slack ユーザーグループのメンバー情報に同期します。
Slack API 側は、 [usergroups.users.update](https://api.slack.com/methods/usergroups.users.update) を使います。
こちらも、Rate Limit を回避するため、変更があった場合にだけ更新するようにします。

# つまづきポイント


何をとち狂ったか、GASで開発を始めてしまったため、様々な部分で問題が発生しました。

## つまづきその1: データストア

先程述べたとおり、holaSpirit のロール・サークルの情報と UserGroup を同期する際、変更があったものだけを更新するため、データストアが必要でした。また、holaSpirit 上でのリソースの ID と Slack 上での ID を対応付けて保存しておく必要もあります。(でないと、新規作成と更新の判断がつかないです。)
これを実現するには、次のような情報を保持するシンプルな KVS があれば事足ります。

| key | value |
| --- | --- |
| `roles/{holaspirit_role_id}/usergroup_id` | holaSpirit 上のロール・サークルに対応する UserGroup の ID |
| `roles/{holaspirit_role_id}/attrs` | ロール・サークルオブジェクトのJSON |


#### Property Service を KVS として使ってみる

これを実装するにあたり、「GAS で KVS といえば [Property Service](https://developers.google.com/apps-script/reference/properties) やろ！」 ということで、まずは Property Serivce を使って実装することにしました。

実装して動かしてみたところ、最初はうまく動いていたのですが、途中から非常に時間がかかり実行時間制限に引っかかって強制終了されてしまいました。計測してみたところ、プロパティの1回の書き込み・読み出しに数秒も掛かっていました。どうやら、Property Service はこのような使い方は想定していないらしく、Rate Limit を掛けているようでした。

#### Property Service にデカいJSON を置く

次に考えたのは、 Property Service を KVS として使うのではなく、すべてのkey value を含む1つの大きな JSON を1つのプロパティに保存してしまって、スクリプト実行の最初にすべてを読み込んで使い、最後に更新されたものを上書きするというものです。

しかしながら、今度はプロパティ1つあたりのサイズの上限に引っかかってしまいました。

#### Google Drive に JSON を置く

Property Service がダメならということで、JSON を Google Drive に置くことにしました。

気をつけるべきは、スクリプトが途中で落ちたときに、データ不整合が起こらないようにすることでした。
ここでは、次のようなコンテクストマネージャー的な `JsonPropertyManager` クラスを作って、途中で例外が発生してもデータ不整合が起こらないようにしました。

```javascript
var prop = JsonPropertyManager("Google Drive file ID") ;

prop.begin(); // ファイルからオブジェクトを読み込む
try {

    // ... ここで実際の処理を行う。
    prop.getProperty("key");          // オブジェクトから値を取得
    prop.setProperty("key", "value"); // オブジェクトに値をセット

} finally {
    prop.commit(); // オブジェクトをファイルに書き出す
}

```

それでも、こんな実装では事故は起こるさ！ ということで、一度、 **データを全ロストしました**。
理由は不明ですが、おそらく、実行時間制限などによって、スクリプトが例外を吐かずに強制終了された結果、データ不整合が生じたのではないかと思います。
また、 **Google Docs は自動でファイルの履歴を保持してくれる** から事故が起きても復元できるだろうとタカをくくっていましたが、デフォルトでは最新100個までしか履歴が残らないため、1時間に1回スクリプトを走らせると4日ばかりで復元が不可能になってしまうのでした。
その時は、ちょうど連休入りのタイミングでこのエラーが発生したため、**履歴もろともデータをロストしてしまいました**。

良い子の皆さんは、 **GAS で始めたから** という理由で横着せず、ちゃんとした KVS を使いましょう。

## つまづきその2: GASlacker

GAS で Slack API を叩くということで、良いライブラリが無いか探したところ、最初に見つかった [GASlacker](https://github.com/yhay81/GASlacker) を使うことにしました。
Slack の UserGroup API などは使う人も殆どいないためか、あまりメンテされておらず、そのままでは動きませんでした。

以下の2点がバグっており、修正したら動きました。

* POST 時の `Authorization` ヘッダに `Bearer` がない。
* `usergroups.update` を呼ぶべきところで `usergroups.create` を呼んでいた。

特に、後者のバグは 「更新しようとしたら Duplicalted Key 的なエラーが発生する」 という状況で、Slack の API の挙動がおかしいのではないかと思い、発見までに時間が掛かりました。
修正点は [PR](https://github.com/yhay81/GASlacker/pull/8) にしたので、じきにマージされると思います。

## つまづきその3: Slack の UserGroup API

Slack の API 自体にもつまづきポイントがありました。

まず、1度作った UserGroup は、アーカイブすることは出来ますが、 **削除することが出来ません**。これの何が困るかというと、 `@role-xxx-yyy` などのID名はユニーク制約があるわけですが、アーカイブしても名前空間を占有し続けます。
なので、古くなった UserGroup を処分するためには、**名前をランダムな文字列に変更しつつ、アーカイブする**という方法を取るしかありません。
また、アーカイブしても検索に引っかかり続けるので、**なるべくUserGroupは再利用する**ようにしましょう。


また、APIのドキュメントには書いていませんが、UserGroup のdescription の長さは、 `140 byte` の制限があります。 `140文字` ではなく、 `140 byte` なのでご注意ください。UTF-8でエンコードされているらしく、全て全角文字の場合は、46文字しか入りません。
これでは相当短いため、少しでも文字を詰め込めるように、全角半角入り混じっている場合は、文字数ではなく、実際のバイト数が 140 byte を越えないギリギリになるように削る処理を入れました。


# まとめ

こうして、**データの全ロスト**という憂き目に遭いながらも、5ヶ月ほどはそれなりに安定して運用できています。
社内では、かなりの人が `@circ-xxx` や `@role-xxx-yyy` というメンションを使いこなしており、以前よりもロールを意識して普段の業務を遂行するようになってくれたのではないかと思います。

今回は**何をとち狂ったか**GASで実装しましたが、いずれ、もっと安定したプラットフォームに移植できればなと思います(とはいえ、なかなか時間が取れないのが現状ですが・・・)。また、そのときにでもコードを公開できたらなと思います。