# Box のファイルコメントでアップロードした人をメンションし、処理の進み具合を知らせる — 開始・完了・失敗

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

- Source: https://oharu121.com/ja/blog/box-mention-comment-file-progress-start-done-failure/
- Published: 2026-11-04T16:05:22+09:00
- Tags: Box, 自動化

---
## はじめに

Box のフォルダにファイルがアップロードされるたびに起動し、そのファイルを処理するパイプラインを作りました。ところが、ファイルを置くたびに、処理が進んでいるのかどうか私にはわかりませんでした。成功しても失敗しても、アップロードした人には通知が届かず、しばらくしてからファイルの処理が終わったかを確かめに戻るしかありませんでした。

アップロードした人が自分のファイルの処理状況をすぐに把握できるよう、パイプラインから進み具合をリアルタイムで知らせたいと考えました。ただ、専用の UI は作りたくありませんでしたし、Slack で通知するのも避けたいと思いました。利用者が増えると、通知の数もそれに合わせて膨らむからです。

解決策は、**アップロードされたファイルに、アップロードした人を @メンションするコメントを付けること**でした。コメントを付けるのは、処理を始めたとき、結果へのリンクを添えて終わったとき、そして最終的に失敗したときです。Box はメンションされた相手にメールと受信トレイで通知するので、待っている本人は Box を開かなくても進み具合を受け取れます。

## アップロードした人に届く Box の手段

別の UI を作らずに、アップロードした人が処理の進み具合を確認できる方法をいくつか挙げました。

| 手段 | アップロードした人が知る方法 | 副作用 |
| --- | --- | --- |
| ファイルをロックする | ファイル名の横に鍵アイコンが出る。通知なし | 「編集できない」という意味に見え、ジョブが途中で落ちるとロックが残る |
| 説明欄に書き込む | 詳細パネルに表示される。通知なし | 人が自分で書き込む欄を上書きしてしまう |
| `[processing] …` に名前を変える | ファイル一覧の名前が変わる。通知なし | `FILE.RENAMED` が発火し、パイプラインを起動する Box の Webhook がもう一度動く |
| Box Skills のステータスカード | プレビューのサイドバーに表示される。通知なし | ファイルのプレビューを開いているときしか見えない |
| @メンション付きのコメント | コメント欄に表示され、メールと Box の受信トレイに通知が届く | コメントのメール通知をオンにしているコラボレータにもメールが届く |

> **ちなみに**
>
> **Skills のカードは処理状況を表示できるが、誰にも通知しない**
>
> Box Skills は、外部の処理サービスをフォルダに結び付ける Box の仕組みです。処理結果は、グローバルの `boxSkillsCards` メタデータテンプレートにカードとして書き戻されます。`POST /files/{id}/metadata/global/boxSkillsCards` で `status` カードを直接書き込んでみると、Skill を登録していなくても成功し、プレビューのサイドバーに処理中と表示されました。ただ誰にも通知が届かないので、採用しませんでした。

## アップロードした人をメンションして、ファイルにコメントする

パイプラインは 2 つの部品でできています。

1. Box の Webhook：ファイルがアップロードされるたびに、キューへメッセージを 1 件積みます。
2. ワーカー：キューからメッセージを取り出してファイルを処理し、結果のファイルを Box に書き戻します。

処理の進み具合は、ワーカーがファイルに付ける 3 つのコメントで伝えます。ワーカーは Box の `POST /comments` エンドポイントでコメントを付けます。**メンションは `tagged_message` に入れます**。Box はこのフィールドから `@[user_id:name]` を探し、その相手に通知を送ります。

*Figure — CommentPoints: ワーカーは元のファイルに 3 つの時点でコメントします。失敗のコメントは、キューの再試行が残っていない最後の配信でだけ付けます。*

1. **開始**。ワーカーがファイルのロックを取り、対応している形式で、処理を終えた版ではないと分かった時点で付けます。
2. **完了**。結果のファイルをアップロードした後に、そのリンクを添えて付けます。
3. **失敗**。最後の配信も失敗したときにだけ付けます。キューは同じメッセージを最大 3 回配信し、それより前の失敗は次の配信で成功するかもしれません。そのため、最後の配信も失敗して初めて、ワーカーがファイルに失敗のコメントを付けます。

*Image: Box の日本語 UI で、1 つのファイルのアクティビティパネルにアプリからのコメントが 3 件並んでいる。どれもアップロードした人への @メンションで始まり、続く本文は「Processing has started. I will comment here again when it finishes.」「Processing finished. Output:」と Box のファイルリンク、「Processing failed. The operators have been notified automatically.」。下部にコメント入力欄がある*

*アップロードした人がファイルのアクティビティパネルで見る 3 件のコメント。コメントした人とメンション先の名前はぼかしています。*

コメントを付けるのは、Box API をラップするコネクタの `post_comment()` で、数行しかありません。`mention` は必須の引数です。通知する相手のいないコメントは進捗の報告にならないからです。

```python title="box/client.py"
_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` の場合で比べます。

```text title="除去前"
@[12345:Sato [QA]: Tokyo]
```

```text title="除去後"
@[12345:Sato QA Tokyo]
```

除去前の文字列では、構文上 `QA` の後ろの `]` でメンションが終わってしまいます。名前はメンションのラベルにすぎず、**Box は通知の宛先を `user_id` で決める**ので、誰に届くかは変わりません。最後の行の `None` については、後述の落とし穴 1 で説明します。

**コメントの投稿に失敗しても、ジョブは失敗させません**。エラーを外に投げるとメッセージがキューに戻り、完了コメントの投稿に失敗した場合は、結果をすでにアップロードしたファイルをもう一度処理してしまいます。そのため、失敗は警告としてログに残すだけにしています。ログに含めるのは例外の型だけで、アップロードした人の名前やコメントの本文は残しません。

> **ちなみに**
>
> **コメントで Webhook は発火しない**
>
> Webhook が購読しているのはファイルのトリガー（`FILE.UPLOADED`、`FILE.MOVED`、`FILE.RENAMED`、`FILE.TRASHED`、`FILE.DELETED`、`FILE.RESTORED`）だけで、コメントを付けてもどれも発火しません。

## メンションする相手は `created_by` ではなく `modified_by`

待っているのは、**ファイルの `modified_by`、つまり現在の版をアップロードしたユーザー**です。ワーカーはファイル名と親フォルダを得るためにもともと `GET /files/{id}` を呼んでいるので、アップロードした人の取得は `fields` に 1 項目足すだけで済み、リクエストは増えません。

```python title="box/client.py" ins={2}
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` の返すアカウント（つまりアプリ自身のアカウント）と一致するときはコメントを付けません。

> **気を付けて**
>
> **ID でユーザーを引くと 404 が返る**
>
> アプリが私のアカウントについて `GET /users/{id}` を呼ぶと `404 Not Found` が返ったので、アップロードした人を ID で別途引くことはできません。メンションに必要な ID と表示名は、`modified_by` にすでに入っています。

## コメントのメール通知をオフにしてもメンションは届く

アップロードした人がフォルダのコメントのメール通知をオフにしても、メンションは届きます。フォルダの通知設定で「Adds a comment」のチェックを外し、コメントのたびに届くメールをオフにしました。その後アプリがメンション付きのコメントを付けると、**メールと Box の受信トレイの通知はどちらも届きました**。

*Image: Box の日本語 UI の通知メール。Box のロゴの下に、コメントした人が office-features.docx のコメントであなたをメンションしました、とある。枠の中にコメント「Processing has started. I will comment here again when it finishes.」が引用され、その下に青い返信ボタンがある*

*メンションに対して Box が送るメール。コメントを引用し、ファイルへのリンクを含みます。*

*Image: Box の日本語 UI で、受信トレイのアイコンの下に受信トレイのパネルが開いている。未読のカードに、コメントした人が office-features.docx であなたにメンションしました、とあり、「Processing failed. The operators have been notified automatically.」が引用されている*

*メンションは Box の受信トレイにも未読として届きます。この画像は失敗のコメントのメンションです。*

そのため、共有フォルダをコメントのメールであふれさせずに済みます。他人のファイルの進み具合を受け取りたくないコラボレータは、そのフォルダのコメントのメール通知をオフにすれば済みます。それでも、自分がアップロードしたファイルについてはメンションで知らせが届きます。

## 落とし穴 1: `message` と `tagged_message` を両方送ると Box が 400 を返す

Box の Python SDK の `create_comment()` は `message` と `tagged_message` を一緒に送りますが、**Box はこの組み合わせを受け付けません**。SDK の `box_sdk_gen` は `message` を必須の位置引数にしていて、リクエストボディを両方のキーで組み立てます。

```python title="box_sdk_gen/managers/comments.py"
request_body: Dict = {
    'message': message,
    'tagged_message': tagged_message,
    'item': item,
}
```

コメントの本文を `message` に、メンションを加えた同じ本文を `tagged_message` に渡すのは、シグネチャが求めているとおりに見えます。ところが実際の Box に対しては、どの呼び出しも次のエラーを返しました。

```text
Message: 400 Bad Request; Request ID: …
	Status code: 400
	Code: bad_request
```

[Box 自身のガイド](https://developer.box.com/guides/comments/create-comment)にも、メンションを含む文字列は「`message` ではなく `tagged_message` として渡す」と書かれています（拙訳）。ただ SDK は `message` を必須にしているので、`None` を渡しました。SDK の `serialize()` は値が `None` のキーを落とすので、**実際に送られるリクエストボディには `tagged_message` だけが残ります**。

```python title="message に文字列を渡す（Box が 400 を返す）"
# SDK の引数
create_comment(text, item, tagged_message=tagged)

# 送られるリクエストボディ
{
  "message": "Processing has started.",
  "tagged_message": "@[12345:Sato] Processing has started.",
  "item": { "id": "…", "type": "file" }
}
```

```python title="message に None を渡す（コメントが作成される）"
# 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 なので、やはり何も通知しません。**アップロードした人には、開始のコメントがないまま完了のコメントだけが届きます**。

*Figure — DeliveryTimeline: 1 回目の配信はロックを取れず、2 回目の配信が処理を行います。回数で判定するとどちらの配信でも何も通知せず、版の記録で判定すると 2 回目の配信で開始を通知します。*

再実行でも同じ判定が破綻します。ワーカーの処理中に新しい版が届くと、ワーカーは同じ配信の中でもう一度処理しますが、回数がすでに 2 以上なら、その新しい版の開始は通知されません。

解決策は、**開始を通知したファイルの版を、DynamoDB でロックを持っているのと同じレコードに記録すること**です。フィールド名は `start_notified` です。ワーカーはこのフィールドを条件付き更新で書き込み、`start_notified` がまだ現在の版でないときだけ書き込みが成功します。そのため、同じ版について成功する書き込みは 1 回だけで、開始のコメントも 1 回だけ付きます。

```python title="state.py"
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
```

*Figure — StartNoticeClaim: 条件付き更新で、その配信が開始のコメントを付けるかが決まります。完了すると start_notified を消し、失敗したときは残します。*

ジョブが完了すると `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 回だけ通知されます。

## 参考リンク

- [Box API リファレンス: Create comment](https://developer.box.com/reference/post-comments)
- [Box 開発者ガイド: Create comment（`@[user_id:name]` のメンション構文を含む）](https://developer.box.com/guides/comments/create-comment)
- [Box サポート: Box Folder Settings（フォルダのメール通知の設定項目を含む）](https://support.box.com/hc/en-us/articles/360043696014-Box-Folder-Settings)
- [Box API リファレンス: Status Skill card](https://developer.box.com/reference/resources/status-skill-card/)
- [Box 開発者ガイド: V2 Webhook とそのトリガー](https://developer.box.com/guides/webhooks/v2)
