跳转到主要内容
Labeler 属于进阶功能。它需要作为服务器持续运行,因此本页面主要面向使用 Laravel Forge 或有能力自行搭建服务器的用户,不推荐 Laravel 初学者使用。这里不提供支持。不支持 Laravel Cloud。 Labeler 需要作为 WebSocket 服务器常驻,还需要修改 nginx 配置,而 Laravel Cloud 不允许修改 nginx 配置,因此无法运行 Labeler。

什么是 Labeler

Labeler 是在 AT Protocol(Bluesky)上给内容打 label 的服务。可用于内容审核、内容分类、自定义过滤等场景。 在动手之前,需要先理解 Labeler 的概念。 其他语言的 starter kit 也可以作为参考。 示例实现:

准备工作

运行 Labeler 需要具备:
  • Labeler 专用的新 Bluesky 账号(不要使用日常账号)
  • Labeler 专用的新 Laravel 项目(推荐把项目分开)
  • 一个(子)域名
  • VPS 或 AWS EC2 等生产用 Linux 服务器(不能使用 Laravel Cloud、Laravel Vapor、Vercel)
只能使用共享服务器的话,运营 Labeler 会非常困难。

安装附加依赖

composer require workerman/workerman revolt/event-loop

配置

首先生成私钥。
php artisan bluesky:labeler:new-private-key
将生成的私钥及相关配置项添加到 .env
BLUESKY_LABELER_DID=did:plc:***
BLUESKY_LABELER_IDENTIFIER=***.bsky.social
BLUESKY_LABELER_APP_PASSWORD=

BLUESKY_LABELER_PRIVATE_KEY=""

创建 Labeler 类

创建一个继承 AbstractLabeler 的自定义 Labeler 类。文件可以放在任意位置。
namespace App\Labeler;

use Revolution\Bluesky\Labeler\AbstractLabeler;

readonly class ArtisanLabeler extends AbstractLabeler
{
    // 实现各个方法
}
包会负责 Labeler 的大部分处理,你只需要实现需要自定义的部分。
示例实现: ArtisanLabeler.php

labels()

返回 label 的定义。skyware starter kit 中的常量定义可作为参考。
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
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
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
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 模型请参考包内的示例。

createReport()

当用户发送申诉等报告时被调用。
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 官方不使用,主要由第三方调用。不需要时返回空数组即可。
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()
use Revolution\Bluesky\Labeler\Labeler;
use App\Labeler\ArtisanLabeler;

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

账号初始化

将账号初始化为 Labeler 账号。
这个命令需要输入实际的账号密码,而不是 App Password。执行过程中你会收到一封主题为 “PLC Update Operation Requested” 的确认邮件,请按命令提示操作。
php artisan bluesky:labeler:setup
只要 endpoint URL 配置正确,也可以从本地环境执行。

声明 label 定义

将 label 定义注册到 Labeler 账号。
php artisan bluesky:labeler:declare-labels
这个命令同样可以从本地环境执行。

其他命令

删除 label 定义:
php artisan bluesky:labeler:delete-labels
将 Labeler 账号还原为普通账号:
php artisan bluesky:labeler:restore

在 Laravel Forge 上运行

在启用 SSL 之后,使用以下配置启动 Labeler 服务器。

nginx 配置

在 Forge 的 nginx 配置中添加 3 个 location 块。
# 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 会在停止后自动重启。
# 常规部署步骤
$FORGE_PHP artisan bluesky:labeler:server stop

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

后台进程(守护进程)配置

在 Forge 的后台进程配置界面中,选择 Custom 标签页,而不是 Queue Worker 标签页。 命令:
php artisan bluesky:labeler:server start
如果需要与 Jetstream 或 Firehose 同时启动,请附加相应选项。 不能与 bluesky:wsbluesky:firehose 命令同时执行。
# 与 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。
// 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。
最后修改于 2026年7月13日