메인 콘텐츠로 건너뛰기
Labeler는 상급자용 기능입니다. 서버로 계속 가동시켜야 하므로, Laravel Forge를 사용하고 있거나 스스로 서버를 구축할 수 있는 분을 대상으로 합니다. Laravel 초보자에게는 권장하지 않습니다. 지원은 없습니다.Laravel Cloud는 미지원입니다. Labeler는 WebSocket 서버로서 상시 기동시키고 nginx 설정을 변경해야 하지만, Laravel Cloud는 nginx 설정이 불가능하므로 Labeler를 동작시킬 수 없습니다.

Labeler란

Labeler는 AT Protocol(Bluesky)에서 콘텐츠에 라벨을 붙이는 서비스입니다. 모더레이션, 콘텐츠 분류, 커스텀 필터링 등에 활용할 수 있습니다. Labeler의 개념을 사전에 이해해 두는 것이 필요합니다. 다른 언어용 스타터 키트도 참고가 됩니다. 샘플 구현:

준비

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

라벨 정의를 반환합니다. skyware 스타터 키트의 상수 정의가 참고가 됩니다.
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을 throw
    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()

라벨을 추가하거나 삭제하는 요청이 왔을 때 호출됩니다. 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()

서명된 라벨을 데이터베이스에 저장합니다. 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를 통해 라벨을 쿼리합니다. 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 계정으로 초기화합니다.
이 명령어에서는 앱 패스워드가 아니라 실제 계정 패스워드를 입력합니다. 처리 중에 “PLC Update Operation Requested”라는 메일 확인이 오므로, 명령어의 지시에 따라 입력하세요.
php artisan bluesky:labeler:setup
엔드포인트 URL이 올바르게 설정되어 있다면 로컬 환경에서도 실행할 수 있습니다.

라벨 정의 선언

Labeler 계정에 라벨 정의를 등록합니다.
php artisan bluesky:labeler:declare-labels
이 명령어도 로컬 환경에서 실행할 수 있습니다.

그 외 명령어

라벨 정의 삭제:
php artisan bluesky:labeler:delete-labels
Labeler 계정을 일반 계정으로 되돌리기:
php artisan bluesky:labeler:restore

Laravel Forge에서 실행

SSL을 활성화한 뒤, 다음 설정으로 Labeler 서버를 기동합니다.

nginx 설정

Forge의 nginx 설정에 3개의 location 블록을 추가합니다.
# WebSocket: 라벨 구독
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의 백그라운드 프로세스 설정 화면에서 Queue Worker 탭이 아닌 Custom 탭을 선택합니다. 명령어:
php artisan bluesky:labeler:server start
Jetstream이나 Firehose와 동시에 기동하는 경우에는 옵션을 지정합니다. bluesky:wsbluesky:firehose 명령어와의 동시 실행은 할 수 없습니다.
# Jetstream과 함께 기동하는 경우
php artisan bluesky:labeler:server start --jetstream

# 특정 컬렉션을 필터링하는 경우
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 이벤트 기능을 사용해 “팔로우되었을 때” 라벨을 부여하고 있습니다.
// 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'],
    );
}
이벤트 누락에 대비해, 태스크 스케줄에서도 라벨 부여를 하는 것을 권장합니다.
마지막 수정일 2026년 7월 13일