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 처리의 대부분을 담당하므로, 커스터마이즈가 필요한 부분만 구현합니다.
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:ws나 bluesky: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'],
);
}
이벤트 누락에 대비해, 태스크 스케줄에서도 라벨 부여를 하는 것을 권장합니다.