> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# Labeler

> 使用 Laravel Bluesky 建置與運作 Labeler 伺服器的方式。說明繼承 AbstractLabeler、標籤定義、WebSocket 連線，直到在 Laravel Forge 部署的完整流程。

<Warning>
  Labeler 是進階功能。因需持續作為伺服器運行，本文件對象為使用 Laravel Forge 或能自行架設伺服器的使用者。不建議 Laravel 初學者使用，且不提供支援。

  **Laravel Cloud 不支援。** Labeler 需以 WebSocket 伺服器常駐並修改 nginx 設定，而 Laravel Cloud 無法設定 nginx，因此無法執行 Labeler。
</Warning>

## 什麼是 Labeler

Labeler 是在 AT Protocol（Bluesky）上為內容附加標籤的服務。可用於審核、內容分類、自訂過濾等場景。

事先了解 Labeler 的概念相當必要。

* [AT Protocol: Label spec](https://atproto.com/specs/label)
* [Bluesky's Moderation Architecture](https://docs.bsky.app/blog/blueskys-moderation-architecture)

其他語言的 starter kit 也可作為參考。

* [skyware.js.org — Labeler guide](https://skyware.js.org/guides/labeler/introduction/getting-started/)
* [aliceisjustplaying/labeler-starter-kit-bsky](https://github.com/aliceisjustplaying/labeler-starter-kit-bsky)

範例實作：

* [laralabeler.bsky.social](https://bsky.app/profile/laralabeler.bsky.social)
* [invokable/laralabeler](https://github.com/invokable/laralabeler)

```mermaid theme={null}
sequenceDiagram
    participant Client as Bluesky<br>用戶端
    participant Labeler as Laravel<br>Labeler 伺服器
    participant DB as 資料庫

    Client->>Labeler: WebSocket: subscribeLabels
    Labeler-->>Client: label 串流

    Client->>Labeler: POST /xrpc/tools.ozone.moderation.emitEvent
    Labeler->>DB: 儲存 label
    Labeler-->>Client: 回應
```

## 準備

執行 Labeler 需要以下項目。

* **Labeler 專用的新 Bluesky 帳號**（不要使用平時的帳號）
* **Labeler 專用的新 Laravel 專案**（建議獨立專案）
* **（子）網域**
* **VPS 或 AWS EC2 等正式環境的 Linux 伺服器**（Laravel Cloud、Laravel Vapor、Vercel 均不可）

<Info>
  若只能使用共享伺服器，運作 Labeler 有相當困難。
</Info>

## 安裝額外套件

```bash theme={null}
composer require workerman/workerman revolt/event-loop
```

## 設定

首先產生私鑰。

```bash theme={null}
php artisan bluesky:labeler:new-private-key
```

將產生的私鑰與相關設定加入 `.env`。

```dotenv theme={null}
BLUESKY_LABELER_DID=did:plc:***
BLUESKY_LABELER_IDENTIFIER=***.bsky.social
BLUESKY_LABELER_APP_PASSWORD=

BLUESKY_LABELER_PRIVATE_KEY=""
```

## 建立 Labeler 類別

建立繼承 `AbstractLabeler` 的自訂 Labeler 類別。檔案可放於任意位置。

```php theme={null}
namespace App\Labeler;

use Revolution\Bluesky\Labeler\AbstractLabeler;

readonly class ArtisanLabeler extends AbstractLabeler
{
    // 實作各方法
}
```

由於套件承擔 Labeler 大部分處理，因此只需實作需要客製化的部分。

<Info>
  範例實作：[ArtisanLabeler.php](https://github.com/invokable/laralabeler/blob/main/app/Labeler/ArtisanLabeler.php)
</Info>

### labels()

回傳標籤定義。skyware starter kit 的常數定義可供參考。

```php theme={null}
use Revolution\Bluesky\Labeler\LabelDefinition;
use Revolution\Bluesky\Labeler\LabelLocale;

public function labels(): array
{
    return [
        new LabelDefinition(
            identifier: 'artisan',
            locales: [
                new LabelLocale(
                    lang: 'en',
                    name: 'artisan',
                    description: 'Web artisan',
                ),
            ],
            severity: 'inform',
            blurs: 'none',
            defaultSetting: 'warn',
            adultOnly: false,
        ),
    ];
}
```

### subscribeLabels()

於 WebSocket 連線成功後立即被呼叫。以 iterator 回傳 `SubscribeLabelResponse`。

```php theme={null}
use Revolution\Bluesky\Labeler\Labeler;
use Revolution\Bluesky\Labeler\LabelerException;
use Revolution\Bluesky\Labeler\Response\SubscribeLabelResponse;

/**
 * @return iterable<SubscribeLabelResponse>
 */
public function subscribeLabels(?int $cursor): iterable
{
    if (is_null($cursor)) {
        return null;
    }

    // 若要回傳錯誤回應，務必擲出 LabelerException
    if ($cursor > Label::max('id')) {
        throw new LabelerException('FutureCursor', 'Cursor is in the future');
    }

    foreach (Label::oldest()->where('id', '>', $cursor)->lazy() as $label) {
        $arr = $label->toArray();
        $arr = Labeler::formatLabel($arr);

        yield new SubscribeLabelResponse(
            seq: $label->id,
            labels: [$arr],
        );
    }
}
```

### emitEvent()

當有要求新增或刪除標籤時被呼叫。以 iterator 回傳 `UnsignedLabel`。

```php theme={null}
use Illuminate\Http\Request;
use Revolution\Bluesky\Labeler\LabelerException;
use Revolution\Bluesky\Labeler\UnsignedLabel;

/**
 * @return iterable<UnsignedLabel>
 *
 * @link https://docs.bsky.app/docs/api/tools-ozone-moderation-emit-event
 */
public function emitEvent(Request $request, ?string $did, ?string $token): iterable
{
    $type = data_get($request->input('event'), '$type');
    if ($type !== 'tools.ozone.moderation.defs#modEventLabel') {
        throw new LabelerException('InvalidRequest', 'Unsupported event type');
    }

    $subject = $request->input('subject');
    $uri = data_get($subject, 'uri', data_get($subject, 'did'));
    $cid = data_get($subject, 'cid');

    $createLabelVals = (array) data_get($request->input('event'), 'createLabelVals');
    $negateLabelVals = (array) data_get($request->input('event'), 'negateLabelVals');

    foreach ($createLabelVals as $val) {
        yield new UnsignedLabel(
            uri: $uri,
            cid: $cid,
            val: $val,
            src: config('bluesky.labeler.did'),
            cts: now()->micro(0)->toISOString(),
        );
    }

    foreach ($negateLabelVals as $val) {
        yield new UnsignedLabel(
            uri: $uri,
            cid: $cid,
            val: $val,
            src: config('bluesky.labeler.did'),
            cts: now()->micro(0)->toISOString(),
            neg: true,
        );
    }
}
```

### saveLabel()

將已簽章的標籤儲存至資料庫。回傳 `SavedLabel`。

```php theme={null}
use Revolution\Bluesky\Labeler\SavedLabel;
use Revolution\Bluesky\Labeler\SignedLabel;

public function saveLabel(SignedLabel $signed, string $sign): ?SavedLabel
{
    // App\Models\Label 需自行建立
    $saved = Label::create($signed->toArray());

    return new SavedLabel(
        $saved->id,
        $signed,
    );
}
```

Migration 與 Eloquent 模型請參考套件內的範例。

* [Migration](https://github.com/invokable/laravel-bluesky/blob/main/workbench/database/migrations/2024_12_31_000000_create_labels_table.php)
* [Eloquent 模型](https://github.com/invokable/laravel-bluesky/blob/main/workbench/app/Models/Label.php)

### createReport()

當使用者提出申訴等時被呼叫。

```php theme={null}
use Illuminate\Http\Request;

/**
 * @link https://docs.bsky.app/docs/api/com-atproto-moderation-create-report
 */
public function createReport(Request $request): array
{
    // 處理 report 並回傳陣列
    // 必要欄位：id, reasonType, reason, subject, reportedBy, createdAt

    return [
        'id' => 1,
        'reasonType' => $request->input('reasonType'),
        'reason' => $request->input('reason', ''),
        'subject' => $request->input('subject'),
        'reportedBy' => '',
        'createdAt' => now()->toISOString(),
    ];
}
```

### queryLabels()

透過 HTTP API 取代 WebSocket 查詢標籤。Bluesky 官方並未使用，主要由第三方使用。若不需要可回傳空陣列。

```php theme={null}
use Illuminate\Http\Request;

/**
 * @link https://docs.bsky.app/docs/api/com-atproto-label-query-labels
 */
public function queryLabels(Request $request): array
{
    return [];
}
```

## 註冊至 AppServiceProvider

將所建立的 Labeler 類別註冊於 `AppServiceProvider::boot()`。

```php theme={null}
use Revolution\Bluesky\Labeler\Labeler;
use App\Labeler\ArtisanLabeler;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Labeler::register(ArtisanLabeler::class);
    }
}
```

## 帳號初始化

將帳號初始化為 Labeler 帳號。

<Warning>
  此指令輸入的不是 App Password，而是實際的帳號密碼。過程中會收到一封「PLC Update Operation Requested」的郵件確認信，請依指令的指示輸入。
</Warning>

```bash theme={null}
php artisan bluesky:labeler:setup
```

只要端點 URL 已正確設定，也可以從本機環境執行。

## 宣告標籤定義

於 Labeler 帳號註冊標籤定義。

```bash theme={null}
php artisan bluesky:labeler:declare-labels
```

此指令亦可從本機環境執行。

## 其他指令

刪除標籤定義：

```bash theme={null}
php artisan bluesky:labeler:delete-labels
```

將 Labeler 帳號還原為一般帳號：

```bash theme={null}
php artisan bluesky:labeler:restore
```

## 在 Laravel Forge 上執行

啟用 SSL 後，以下列設定啟動 Labeler 伺服器。

### nginx 設定

於 Forge 的 nginx 設定新增 3 個 `location` 區塊。

```nginx theme={null}
# WebSocket：訂閱 label
location /xrpc/com.atproto.label.subscribeLabels
{
    proxy_pass http://127.0.0.1:7000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_set_header X-Real-IP $remote_addr;
}

# HTTP：發送事件
location /xrpc/tools.ozone.moderation.emitEvent
{
    proxy_pass http://127.0.0.1:7001;
    proxy_http_version 1.1;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header Connection "";
}

# 健康檢查
location /xrpc/_health
{
    proxy_pass http://127.0.0.1:7001;
    proxy_http_version 1.1;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header Connection "";
}
```

### 部署腳本

於部署時停止 Labeler 伺服器。停止後 Supervisor 會自動重啟。

```bash theme={null}
# 一般部署流程
$FORGE_PHP artisan bluesky:labeler:server stop

# 若上述無法運作，直接重啟 daemon
sudo -S supervisorctl restart daemon-{id}:*
```

### 背景程序（daemon）設定

在 Forge 的背景程序設定畫面，選擇 **Custom** 頁籤而非 Queue Worker 頁籤。

**指令：**

```bash theme={null}
php artisan bluesky:labeler:server start
```

若要與 Jetstream 或 Firehose 同時啟動，可指定選項。
無法與 `bluesky:ws` 或 `bluesky:firehose` 指令同時執行。

```bash theme={null}
# 與 Jetstream 同時啟動
php artisan bluesky:labeler:server start --jetstream

# 過濾特定 collection
php artisan bluesky:labeler:server start --jetstream -C app.bsky.graph.follow -C app.bsky.feed.like

# 與 Firehose 同時啟動
php artisan bluesky:labeler:server start --firehose
```

## 附加標籤

如何附加標籤取決於應用程式的實作。範例中利用 Laravel 的事件功能，在「被追蹤時」附加標籤。

```php theme={null}
// app/Listeners/FollowListener.php 的示意

use Revolution\Bluesky\Facades\Bluesky;
use Revolution\Bluesky\Events\Jetstream\JetstreamCommitMessage;
use Revolution\Bluesky\Types\RepoRef;

public function handle(JetstreamCommitMessage $event): void
{
    $message = $event->message;
    $followerDid = data_get($message, 'did');

    // 透過 Bluesky 的 Ozone API 附加標籤
    Bluesky::login(
        identifier: config('bluesky.labeler.identifier'),
        password: config('bluesky.labeler.password'),
    )->createLabels(
        subject: RepoRef::to($followerDid),
        labels: ['artisan'],
    );
}
```

為避免事件遺漏，建議同時透過任務排程進行標籤附加。

<Info>
  Source：[docs/labeler.md](https://github.com/invokable/laravel-bluesky/blob/main/docs/labeler.md)
  Sample：[invokable/laralabeler](https://github.com/invokable/laralabeler)
</Info>


## Related topics

- [WebSocket (Jetstream / Firehose)](/zh-TW/packages/laravel-bluesky/websocket.md)
- [BlueskyManager 與 HasShortHand](/zh-TW/packages/laravel-bluesky/bluesky-manager.md)
- [Crypto — AT Protocol 加密](/zh-TW/packages/laravel-bluesky/crypto.md)
- [路由](/zh-TW/packages/laravel-bluesky/route.md)
- [我的包](/zh-CN/packages/index.md)
