# 我讓工作流在 Box 檔案上留言 @提及上傳者，並回報處理進度 — 開始、完成與失敗

> 我讓工作流監看 Box 資料夾。每個檔案上傳後，工作流會在處理開始、完成和失敗時於該檔案上留言，並依 modified_by 欄位提及上傳者。

- Source: https://oharu121.com/zh-tw/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 就能成功，預覽的側邊欄也顯示為處理中。但這張 `status` 卡片不會通知任何人，所以我沒有採用。

## 在檔案上留言，並提及上傳者

這個工作流由兩個元件組成：

1. Box Webhook：每次有檔案上傳，就把一則訊息放進佇列。
2. worker：從佇列取出訊息、處理檔案，並把結果檔案寫回 Box。

處理進度則由 worker 在檔案上發出的三則留言回報。worker 透過 Box 的 `POST /comments` 端點留言。**@提及要放在 `tagged_message` 裡**。Box 會在這個欄位中尋找 `@[user_id:name]`，並通知對應的使用者。

*Figure — CommentPoints: worker 會在三個時間點於原始檔案上留言。等到佇列不會再重新傳遞，最後一次傳遞才會留下失敗留言。*

1. **開始**：worker 先取得檔案的鎖，再確認是否支援該檔案的格式，並確認該檔案的當前版本尚未處理完成後，留言開始處理。
2. **完成**：處理完成後，worker 會在原本的檔案上留言，並附上結果檔案的連結。
3. **失敗**：worker 只在最後一次傳遞也失敗時才會留言。佇列最多會傳遞同一則訊息三次，較早的失敗仍可能在下一次傳遞時成功，所以只有最後一次傳遞也失敗時，worker 才會在該檔案上留言告知處理失敗。

*Image: Box 日文介面中，一個檔案的「活動」面板裡有三則來自應用程式的留言。每則都以 @提及上傳者開頭，內容依序是「Processing has started. I will comment here again when it finishes.」、「Processing finished. Output:」加上一個 Box 檔案連結，以及「Processing failed. The operators have been notified automatically.」。面板底部是留言輸入框*

*上傳者在檔案的「活動」面板中看到的三則留言。留言者和被提及者的名稱已經模糊處理。*

負責發出留言的是包裝 Box API 的 connector 裡的 `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))
```

顯示名稱會去掉 `[`、`]` 和 `:`，因為這三個字元就是提及語法本身的分隔符號，而 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`），留言不會觸發其中任何一個。

## 要 @提及誰：用 `modified_by`，而不是 `created_by`

等待處理結果的是**檔案的 `modified_by`，也就是上傳目前這個版本的使用者**。worker 本來就會呼叫 `GET /files/{id}` 取得檔名和上層資料夾，所以要取得上傳者，只需在 `fields` 裡多加一項，不必多送一次請求：

```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"]
))
```

為了確認哪個欄位會跟著上傳者變動，我先透過自己的帳戶上傳了第一個版本。第一個版本的 `modified_by` 和 `created_by` 都指向了我。透過應用程式的服務帳戶上傳第二個版本之後，`modified_by` 變成了服務帳戶，**`created_by` 仍然是我**。

我再用自己的帳戶上傳第三個版本後，`modified_by` 又變回了我。由此可知，`created_by` 記錄的是最早上傳這個檔案的人。如果用 `created_by` 當作通知對象，一旦最早上傳的人和更新版本的人不同，就會提及錯誤的人。

由應用程式自己的帳戶上傳的版本不需要留言。因此當 `modified_by` 與 `GET /users/me` 回傳的帳戶（也就是應用程式自己的帳戶）相同時，worker 會略過留言。

> **小心喔**
>
> **用 ID 查使用者會得到 404**
>
> 應用程式對我的帳戶呼叫 `GET /users/{id}` 時，得到的是 `404 Not Found`，所以不能靠 ID 另外查詢上傳者。提及需要的 ID 和顯示名稱，`modified_by` 裡本來就有。

## 即使關閉留言電子郵件通知，被 @提及時仍然收到郵件通知

上傳者即使關掉了資料夾的留言電子郵件通知，仍然會收到提及的通知。我在資料夾的通知設定中取消勾選「Adds a comment」，關閉留言時的寄信通知。之後應用程式發出一則提及時，**電子郵件和 Box 的收件匣兩邊的通知都還是送達了**。

*Image: Box 日文介面的通知電子郵件。Box 標誌下方寫著留言者在 office-features.docx 的留言中提及了你，框內引用了留言「Processing has started. I will comment here again when it finishes.」，下方是一個藍色的回覆按鈕*

*Box 針對提及寄出的電子郵件，內容引用了留言並附上檔案連結。*

*Image: Box 日文介面中，收件匣圖示下方展開了收件匣面板，裡面有一張未讀卡片，寫著留言者在 office-features.docx 提及了你，並引用了「Processing failed. The operators have been notified automatically.」*

*提及也會以未讀狀態出現在 Box 收件匣裡，這裡顯示的是失敗留言的提及。*

因此，這些留言不會讓共用資料夾的成員被信件淹沒。不想收到別人檔案進度的協作者，只要關掉那個資料夾的留言電子郵件通知即可。對於自己上傳的檔案，仍然會透過 @提及收到通知。

## 陷阱 1：同時送出 `message` 和 `tagged_message` 時，Box 會回傳 400

Box Python SDK（`box_sdk_gen`）的 `create_comment()` 會同時送出 `message` 和 `tagged_message`，而 **Box 會拒絕這樣的組合**。這個 SDK 把 `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)也寫明，有 @提及時要改用 `tagged_message` 而非平常使用的 `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：如果只在第一次工作傳遞時通知處理開始，搶不到鎖時通知會被漏掉

如果只在第一次傳遞時發出開始留言，那麼當那次傳遞拿不到檔案的鎖時，開始留言就會漏掉。worker 在 DynamoDB 的工作紀錄裡為每個檔案保存一把鎖，避免兩個工作同時處理同一個檔案。如果工作發現鎖已經被其他工作取走，就會回到佇列，等待之後的傳遞。

Box 可能在同一時間針對同一個檔案送出 `FILE.MOVED` 和 `FILE.UPLOADED`，所以拿不到鎖是很常見的情況。佇列會在 `ApproximateReceiveCount` 中計算傳遞次數。如果只在次數為 1 時才通知，遇到這種情況通知就會被跳過。

第 1 次傳遞拿不到鎖，什麼都沒通知。第 2 次傳遞拿到鎖並處理了檔案，但次數是 2，所以同樣什麼都沒通知。**最後上傳者只收到了完成留言，前面的開始留言卻被跳過了**。

*Figure — DeliveryTimeline: 第 1 次傳遞拿不到鎖，由第 2 次傳遞完成處理。以次數判定時兩次都不會通知，以版本紀錄判定時會在第 2 次傳遞通知開始。*

重新執行也會讓同樣的判定失效。worker 忙碌時如果有新版本進來，worker 會在同一次傳遞裡再處理一次。這時次數如果已經是 2 以上，這個新版本的開始就永遠不會被通知。

解決方法是**把已經通知過開始的檔案版本，記在 DynamoDB 裡那筆保存鎖的紀錄上**，欄位名稱是 `start_notified`。worker 用條件式更新寫入這個欄位：只有在 `start_notified` 還不是目前這個版本時，寫入才會成功。所以同一個版本只會有一次寫入成功，開始留言也只會發一次：

```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 本身的機制，現在 worker 會針對處理狀態留下三種留言，並在每一則裡 @提及上傳者。

這次做下來，我學到的大多是關於 Box 的事。提及的通知和留言的電子郵件通知是分開的，所以協作者即使關掉某個資料夾的留言郵件通知，也不會漏掉自己檔案的進度。`modified_by` 指的是目前版本的上傳者，而 `created_by` 則是指向第一個上傳的人。

SDK 的 `create_comment()` 要把 `message` 傳成 `None`，請求才只會帶 `tagged_message`，Box 也才會接受。開始通知則依檔案版本記在 `start_notified`，這樣不論哪一次傳遞拿到鎖，每個版本都只會通知一次開始。

## 參考連結

- [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)
