> ## 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、label 定义、WebSocket 连接以及使用 Laravel Forge 部署。

<Warning>
  Labeler 属于进阶功能。它需要作为服务器持续运行，因此本页面主要面向使用 Laravel Forge 或有能力自行搭建服务器的用户，不推荐 Laravel 初学者使用。这里不提供支持。

  **不支持 Laravel Cloud。** Labeler 需要作为 WebSocket 服务器常驻，还需要修改 nginx 配置，而 Laravel Cloud 不允许修改 nginx 配置，因此无法运行 Labeler。
</Warning>

## 什么是 Labeler

Labeler 是在 AT Protocol（Bluesky）上给内容打 label 的服务。可用于内容审核、内容分类、自定义过滤等场景。

在动手之前，需要先理解 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()

返回 label 的定义。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 建立连接后立即被调用。以迭代器的形式返回 `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()

当收到添加或删除 label 的请求时被调用。以迭代器的形式返回 `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()

将已签名的 label 保存到数据库。返回 `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,
    );
}
```

迁移文件和 Eloquent 模型请参考包内的示例。

* [迁移](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
{
    // 处理报告并返回数组
    // 必填字段：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()

作为 WebSocket 的替代，通过 HTTP API 查询 label。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
```

只要 endpoint URL 配置正确，也可以从本地环境执行。

## 声明 label 定义

将 label 定义注册到 Labeler 账号。

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

这个命令同样可以从本地环境执行。

## 其他命令

删除 label 定义：

```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

# 如果上面无法生效，可以直接重启守护进程
sudo -S supervisorctl restart daemon-{id}:*
```

### 后台进程（守护进程）配置

在 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
```

## 添加 label

具体如何打 label 完全取决于你的应用实现。示例中使用 Laravel 的事件机制，在 “被关注时” 添加 label。

```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 添加 label
    Bluesky::login(
        identifier: config('bluesky.labeler.identifier'),
        password: config('bluesky.labeler.password'),
    )->createLabels(
        subject: RepoRef::to($followerDid),
        labels: ['artisan'],
    );
}
```

为防止事件丢失，建议同时用定时任务补充做一次打 label。

<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/packages/laravel-bluesky/websocket.md)
- [BlueskyManager 与 HasShortHand](/zh/packages/laravel-bluesky/bluesky-manager.md)
- [Crypto —— AT Protocol 加密](/zh/packages/laravel-bluesky/crypto.md)
- [路由](/zh/packages/laravel-bluesky/route.md)
