Box のファイルコメントでアップロードした人をメンションし、処理の進み具合を知らせる — 開始・完了・失敗
Box フォルダを監視する処理基盤で、アップロードされたファイルに開始・完了・失敗のコメントを付け、ファイルの modified_by からアップロードした人をメンションしました。

目次
はじめに
Box のフォルダにファイルがアップロードされるたびに起動し、そのファイルを処理するパイプラインを作りました。ところが、ファイルを置くたびに、処理が進んでいるのかどうか私にはわかりませんでした。成功しても失敗しても、アップロードした人には通知が届かず、しばらくしてからファイルの処理が終わったかを確かめに戻るしかありませんでした。
アップロードした人が自分のファイルの処理状況をすぐに把握できるよう、パイプラインから進み具合をリアルタイムで知らせたいと考えました。ただ、専用の UI は作りたくありませんでしたし、Slack で通知するのも避けたいと思いました。利用者が増えると、通知の数もそれに合わせて膨らむからです。
解決策は、アップロードされたファイルに、アップロードした人を @メンションするコメントを付けることでした。コメントを付けるのは、処理を始めたとき、結果へのリンクを添えて終わったとき、そして最終的に失敗したときです。Box はメンションされた相手にメールと受信トレイで通知するので、待っている本人は Box を開かなくても進み具合を受け取れます。
アップロードした人に届く Box の手段
別の UI を作らずに、アップロードした人が処理の進み具合を確認できる方法をいくつか挙げました。
| 手段 | アップロードした人が知る方法 | 副作用 |
|---|---|---|
| ファイルをロックする | ファイル名の横に鍵アイコンが出る。通知なし | 「編集できない」という意味に見え、ジョブが途中で落ちるとロックが残る |
| 説明欄に書き込む | 詳細パネルに表示される。通知なし | 人が自分で書き込む欄を上書きしてしまう |
[processing] … に名前を変える | ファイル一覧の名前が変わる。通知なし | FILE.RENAMED が発火し、パイプラインを起動する Box の Webhook がもう一度動く |
| Box Skills のステータスカード | プレビューのサイドバーに表示される。通知なし | ファイルのプレビューを開いているときしか見えない |
| @メンション付きのコメント | コメント欄に表示され、メールと Box の受信トレイに通知が届く | コメントのメール通知をオンにしているコラボレータにもメールが届く |
アップロードした人をメンションして、ファイルにコメントする
パイプラインは 2 つの部品でできています。
- Box の Webhook:ファイルがアップロードされるたびに、キューへメッセージを 1 件積みます。
- ワーカー:キューからメッセージを取り出してファイルを処理し、結果のファイルを Box に書き戻します。
処理の進み具合は、ワーカーがファイルに付ける 3 つのコメントで伝えます。ワーカーは Box の POST /comments エンドポイントでコメントを付けます。メンションは tagged_message に入れます。Box はこのフィールドから @[user_id:name] を探し、その相手に通知を送ります。
- 開始。ワーカーがファイルのロックを取り、対応している形式で、処理を終えた版ではないと分かった時点で付けます。
- 完了。結果のファイルをアップロードした後に、そのリンクを添えて付けます。
- 失敗。最後の配信も失敗したときにだけ付けます。キューは同じメッセージを最大 3 回配信し、それより前の失敗は次の配信で成功するかもしれません。そのため、最後の配信も失敗して初めて、ワーカーがファイルに失敗のコメントを付けます。

コメントを付けるのは、Box API をラップするコネクタの post_comment() で、数行しかありません。mention は必須の引数です。通知する相手のいないコメントは進捗の報告にならないからです。
_MENTION_NAME_DELIMITERS = str.maketrans("", "", "[]:")
def post_comment(self, file_id: str, text: str, *, mention: Person) -> None: item = CreateCommentItem(id=file_id, type=CreateCommentItemTypeField.FILE) name = mention.name.translate(_MENTION_NAME_DELIMITERS).strip() or mention.id tagged = f"@[{mention.id}:{name}] {text}" self._call(lambda: self._client.comments.create_comment(None, item, tagged_message=tagged))表示名から [、]、: を取り除いているのは、この 3 文字がメンション構文そのものの区切り文字で、Box がエスケープ方法を公開していないからです。表示名が Sato [QA]: Tokyo の場合で比べます。
@[12345:Sato [QA]: Tokyo]@[12345:Sato QA Tokyo]除去前の文字列では、構文上 QA の後ろの ] でメンションが終わってしまいます。名前はメンションのラベルにすぎず、Box は通知の宛先を user_id で決めるので、誰に届くかは変わりません。最後の行の None については、後述の落とし穴 1 で説明します。
コメントの投稿に失敗しても、ジョブは失敗させません。エラーを外に投げるとメッセージがキューに戻り、完了コメントの投稿に失敗した場合は、結果をすでにアップロードしたファイルをもう一度処理してしまいます。そのため、失敗は警告としてログに残すだけにしています。ログに含めるのは例外の型だけで、アップロードした人の名前やコメントの本文は残しません。
メンションする相手は created_by ではなく modified_by
待っているのは、ファイルの modified_by、つまり現在の版をアップロードしたユーザーです。ワーカーはファイル名と親フォルダを得るためにもともと GET /files/{id} を呼んでいるので、アップロードした人の取得は fields に 1 項目足すだけで済み、リクエストは増えません。
file = self._call(lambda: self._client.files.get_file_by_id( file_id, fields=["name", "parent", "size", "version_number", "modified_by"]))どちらのフィールドがアップロードした人を指すのかは、2 つのアカウントから版をアップロードして確かめました。最初の版を私がアップロードした時点では、どちらのフィールドも私を指していました。アプリのサービスアカウントが新しい版をアップロードすると、modified_by はサービスアカウントに変わりましたが、created_by は私のままでした。
私がもう一度アップロードすると、modified_by は私に戻りました。created_by が記録するのは最初にファイルを置いた人なので、同僚がファイルを差し替えた後は、間違った人をメンションしてしまいます。
アプリ自身のアカウントがアップロードした版には、コメントは不要です。そこでワーカーは、modified_by が GET /users/me の返すアカウント(つまりアプリ自身のアカウント)と一致するときはコメントを付けません。
コメントのメール通知をオフにしてもメンションは届く
アップロードした人がフォルダのコメントのメール通知をオフにしても、メンションは届きます。フォルダの通知設定で「Adds a comment」のチェックを外し、コメントのたびに届くメールをオフにしました。その後アプリがメンション付きのコメントを付けると、メールと Box の受信トレイの通知はどちらも届きました。


そのため、共有フォルダをコメントのメールであふれさせずに済みます。他人のファイルの進み具合を受け取りたくないコラボレータは、そのフォルダのコメントのメール通知をオフにすれば済みます。それでも、自分がアップロードしたファイルについてはメンションで知らせが届きます。
落とし穴 1: message と tagged_message を両方送ると Box が 400 を返す
Box の Python SDK の create_comment() は message と tagged_message を一緒に送りますが、Box はこの組み合わせを受け付けません。SDK の box_sdk_gen は message を必須の位置引数にしていて、リクエストボディを両方のキーで組み立てます。
request_body: Dict = { 'message': message, 'tagged_message': tagged_message, 'item': item,}コメントの本文を message に、メンションを加えた同じ本文を tagged_message に渡すのは、シグネチャが求めているとおりに見えます。ところが実際の Box に対しては、どの呼び出しも次のエラーを返しました。
Message: 400 Bad Request; Request ID: … Status code: 400 Code: bad_requestBox 自身のガイドにも、メンションを含む文字列は「message ではなく tagged_message として渡す」と書かれています(拙訳)。ただ SDK は message を必須にしているので、None を渡しました。SDK の serialize() は値が None のキーを落とすので、実際に送られるリクエストボディには tagged_message だけが残ります。
# SDK の引数create_comment(text, item, tagged_message=tagged)
# 送られるリクエストボディ{ "message": "Processing has started.", "tagged_message": "@[12345:Sato] Processing has started.", "item": { "id": "…", "type": "file" }}# SDK の引数create_comment(None, item, tagged_message=tagged)
# 送られるリクエストボディ{ "tagged_message": "@[12345:Sato] Processing has started.", "item": { "id": "…", "type": "file" }}落とし穴 2: 開始の通知を最初の配信でだけ出すと、ロックの競合で抜け落ちる
開始のコメントを最初の配信のときだけ付けるようにすると、その配信がファイルのロックを取れなかったときに開始のコメントが抜け落ちます。ワーカーは DynamoDB のジョブレコードでファイルごとのロックを持ち、2 つのジョブが同じファイルを同時に処理しないようにしています。ロックがすでに取られていると、そのジョブはキューに戻り、後の配信を待ちます。
Box は同じファイルについて FILE.MOVED と FILE.UPLOADED を同時に送ることがあるので、ロックを取れないのは珍しいことではありません。キューは配信の回数を ApproximateReceiveCount で数えていますが、この回数が 1 のときだけ通知する判定はここで破綻します。
1 回目の配信はロックを取れず、何も通知しません。2 回目の配信はロックを取ってファイルを処理しますが、回数が 2 なので、やはり何も通知しません。アップロードした人には、開始のコメントがないまま完了のコメントだけが届きます。
再実行でも同じ判定が破綻します。ワーカーの処理中に新しい版が届くと、ワーカーは同じ配信の中でもう一度処理しますが、回数がすでに 2 以上なら、その新しい版の開始は通知されません。
解決策は、開始を通知したファイルの版を、DynamoDB でロックを持っているのと同じレコードに記録することです。フィールド名は start_notified です。ワーカーはこのフィールドを条件付き更新で書き込み、start_notified がまだ現在の版でないときだけ書き込みが成功します。そのため、同じ版について成功する書き込みは 1 回だけで、開始のコメントも 1 回だけ付きます。
def claim_start_notice(self, key: str, *, source_version: str | None) -> bool: try: self._table.update_item( Key={"pk": key}, UpdateExpression="SET start_notified = :ver", ConditionExpression=( "attribute_not_exists(start_notified) OR start_notified <> :ver" ), ExpressionAttributeValues={":ver": source_version or ""}, ) except ClientError as exc: if exc.response["Error"]["Code"] != "ConditionalCheckFailedException": raise return False return Trueジョブが完了すると start_notified を消し、失敗したときは残します。そのため、次のようになります。
- 1 回目の配信でロックを取れなかった場合。
start_notifiedはまだ書き込まれていないので、2 回目の配信が開始を通知します。 - 失敗の後の再試行。
start_notifiedはすでにこの版なので、再試行では通知しません。 - 新しい版のための再実行。完了時に
start_notifiedを消しているので、再実行で通知します。 - 最終的な失敗の後に届いた新しい版。
start_notifiedはまだ古い版なので、新しい版の開始を通知します。
まとめ
今では Box が、アップロードした人に、処理が始まったこと、結果へのリンク付きで終わったこと、あるいは処理に失敗したことを知らせます。ファイルのコメント欄にはその履歴も残ります。仕組みはすべて Box 自体の機能を利用したもので、ワーカーは処理状態に応じて 3 種類のコメントを付け、それぞれでアップロードした人をメンションします。
この作業で学んだことの多くは Box についてでした。メンションはコメントのメール通知とは別に届くので、コラボレータは騒がしいフォルダの通知を切っても、自分のファイルの知らせは受け取れます。modified_by は現在の版をアップロードした人を指しますが、created_by は最初にアップロードした人を指したままです。
SDK の create_comment() では message に None を渡すと、tagged_message だけが送られ、Box が受け付けます。開始の通知はファイルの版ごとに start_notified へ記録するので、どの配信がロックを取っても、各版の開始は 1 回だけ通知されます。
