bluesky

bluesky is a comprehensive wrapper library for the app.bsky.*, chat.bsky.*, and tools.ozone.* Lexicons.
This package includes complete Bluesky API support, including the latest chat features, plus all core AT Protocol functionality from the atproto package. It's your one-stop solution for Bluesky application development.
Use bluesky for:
- Bluesky application development
- Social features (feeds, posts, likes, follows, chat)
app.bsky.*andchat.bsky.*endpoints- Complete AT Protocol + Bluesky functionality
Use atproto for:
- Multi-service AT Protocol applications
- Core protocol functionality only (
com.atproto.*endpoints) - Infrastructure tools and minimal dependencies
π‘ Note: This package includes all atproto functionalityβyou don't need both.
If you are having trouble implementing RichText in the Bluesky API, check out bluesky_text!
Features ββ
- β
Supports Powerful Built-In Retry using Exponential BackOff And Jitter, with a pluggable
RetryStrategy - β
Supports the endpoints listed in the matrix for
app.bsky.*,chat.bsky.*, andtools.ozone.* - β Chat API Support with conversation management and messaging
- β Advanced Feed Operations including posts, likes, reposts, and timeline management
- β Comprehensive Social Graph with follows, blocks, mutes, and lists
- β Rich Notification System, including client-side notification grouping that matches the official app
- β Video Upload Support with status tracking and limits management
- β Well Documented and Well Tested
- β Supports Powerful Firehose API for real-time data streaming
- β Supports Comprehensive Moderation API with labeling and reporting
- β Supports OAuth DPoP for secure authentication
- β 100% Null Safety
- β Built In atproto Features
See API Supported Matrix for a list of endpoints supported by bluesky.
If you're building applications that need to work with multiple AT Protocol services (not just Bluesky), consider using atproto for core functionality:
- Service Agnostic: Works with any AT Protocol service, not just Bluesky
- Minimal Footprint: Smaller dependency with only core AT Protocol features
- Infrastructure Tools: Perfect for building bridges, crawlers, or multi-service apps
- Core Protocol: Direct access to
com.atproto.*endpoints without Bluesky-specific abstractions
However, if you're building specifically for Bluesky, this package provides everything you need in one convenient library.
Getting Started πͺβ
Installβ
See the Install Package section for more details on how to install a package in your Dart and Flutter app.
With Dart:
dart pub add bluesky
dart pub get
With Flutter:
flutter pub add bluesky
flutter pub get
Importβ
Just by writing following one-line import, you can use all endpoints provided by bluesky.
import 'package:bluesky/bluesky.dart';
For chat functionality, you can also import the chat-specific library:
import 'package:bluesky/bluesky_chat.dart';
For Ozone moderation tools:
import 'package:bluesky/ozone.dart';
Or import all for full functionality:
import 'package:bluesky/bluesky.dart' as bsky;
import 'package:bluesky/bluesky_chat.dart' as chat;
import 'package:bluesky/ozone.dart' as ozone;
Several focused libraries round out the package:
| Library | Contents |
|---|---|
package:bluesky/moderation.dart | Client-side moderation engine. Import unprefixed β much of it is extensions. |
package:bluesky/firehose.dart | SyncSubscribeReposAdaptor, RepoCommitHandler |
package:bluesky/cardyb.dart | getLinkPreview(url) and LinkPreview for link cards |
package:bluesky/atproto_oauth.dart | OAuthClient, OAuthSessionManager, OAuthSession |
package:bluesky/ids.dart | Every Lexicon ID as a constant |
package:bluesky/core.dart | AtUri, Session, RetryConfig, the XRPC exceptions |
Instantiate Blueskyβ
You need to use Bluesky object to access most of the features supported by bluesky. And there are two ways to instantiate an Bluesky object.
As shown in the following example, the key point in instantiating Bluesky object is whether the endpoint you wish to use requires authentication.
See API Supported Matrix for whether or not authentication is required for each endpoint.
If authentication is required, first create a session with the ATP server using your credentials with the .createSession function.
The credentials passed to the .createSession function should be your handle or email address as identifier and your password or app password as password.
Your credentials will be sent safely and securely to the ATP server when you execute the .createSession function. And it will return a Session object with an access token once authentication is complete.
You then do not need to be particularly aware of the contents of the retrieved Session object, just pass it to the .fromSession constructor of Bluesky to safely and securely create an instance of the Bluesky object.
import 'package:bluesky/atproto.dart';
import 'package:bluesky/bluesky.dart';
Future<void> main() async {
// Let's authenticate here.
final session = await createSession(
identifier: 'YOUR_HANDLE_OR_EMAIL', // Like "shinyakato.dev"
password: 'YOUR_PASSWORD',
);
print(session);
// Just pass created session data.
final bsky = Bluesky.fromSession(session.data);
}
Or, it's very easy if authentication is not required, simply use the .anonymous() constructor.
import 'package:bluesky/bluesky.dart';
Future<void> main() async {
// Just call anonymous constructor.
final bsky = Bluesky.anonymous();
}
See Authentication for more details about authentication.
Later examples abbreviate the block above as await _session, which simply stands for (await createSession(...)).data.
OAuthβ
App passwords are convenient, but OAuth with DPoP is the authentication method the AT Protocol is moving to. Use Bluesky.fromOAuth with an OAuthSessionManager, which owns DPoP proof building and transparent token refresh, or Bluesky.fromOAuthSession when you already hold an OAuthSession.
import 'dart:convert';
import 'package:bluesky/atproto_oauth.dart';
import 'package:bluesky/bluesky.dart';
/// Runs the full authorization code flow, then builds a [Bluesky] from it.
Future<Bluesky> signIn() async {
// Your own client metadata, served over HTTPS.
final metadata = await getClientMetadata(
'https://atprotodart.com/oauth/bluesky/atprotodart/client-metadata.json',
);
final oauth = OAuthClient(metadata);
// Resolves the identity, discovers its authorization server, performs the
// pushed authorization request, and persists the per-authorization state.
final authUrl = await oauth.authorize('shinyakato.dev');
// Send the user to `authUrl` (e.g. with `flutter_web_auth_2`) and feed the
// callback URL back in.
const callbackUrl =
'https://atprotodart.com/oauth/bluesky/auth.html'
'?iss=xxxx&state=xxxxxxx&code=xxxxxxx';
final session = await oauth.callback(callbackUrl);
// The manager owns DPoP header building and transparent token refresh.
final manager = OAuthSessionManager(oauth, sub: session.sub);
return Bluesky.fromOAuth(manager);
}
/// Restores a session you persisted earlier.
Bluesky restore(final String storedJson) {
final restored = OAuthSession.fromJson(jsonDecode(storedJson));
// Equivalent to `Bluesky.fromOAuth(OAuthSessionManager.fromSession(...))`.
// Pass `oauthClient` to keep transparent token refresh working; without it
// the session is used as-is and cannot be refreshed.
return Bluesky.fromOAuthSession(restored);
}
BlueskyChat and OzoneTool expose the same fromOAuth / fromOAuthSession constructors, and the resulting client reports its manager through the oAuthSessionManager getter (session stays null).
Supported Servicesβ
bluesky supports following services.
Chat Servicesβ
For chat functionality, bluesky provides a separate BlueskyChat class with the following services:
| Property | Class | Lexicon |
|---|---|---|
| actor | ActorService | chat.bsky.actor.* |
| convo | ConvoService | chat.bsky.convo.* |
| moderation | ModerationService | chat.bsky.moderation.* |
chat.bsky.group.* and chat.bsky.notification.* are generated and exported from package:bluesky/chat_bsky_services.dart as GroupService and NotificationService, but BlueskyChat does not yet surface them as properties. Construct them directly if you need those endpoints.
To use chat services, create a BlueskyChat instance:
import 'package:bluesky/atproto.dart';
import 'package:bluesky/bluesky_chat.dart';
Future<void> main() async {
final session = await createSession(
identifier: 'YOUR_HANDLE_OR_EMAIL',
password: 'YOUR_PASSWORD',
);
// Create BlueskyChat instance for chat functionality.
final chat = BlueskyChat.fromSession(session.data);
// Access chat services.
final conversations = await chat.convo.listConvos();
print(conversations);
}
Ozone Servicesβ
For Ozone moderation tools, bluesky provides a separate OzoneTool class with comprehensive moderation services:
| Service | Description |
|---|---|
| Communication | Handle moderation communications and templates |
| Hosting | Manage hosting configurations and settings |
| Moderation | Query and manage moderation events, subjects, and actions |
| Server | Server configuration and administrative tools |
| Team | Manage moderation team members and roles |
| Additional Services | Safelink, Set, Setting, Signature, and Verification services |
tools.ozone.queue.* and tools.ozone.report.* are generated as QueueService and ReportService, but OzoneTool does not yet surface them as properties.
To use Ozone services, create an OzoneTool instance:
import 'package:bluesky/atproto.dart';
import 'package:bluesky/ozone.dart';
Future<void> main() async {
final session = await createSession(
identifier: 'YOUR_HANDLE_OR_EMAIL',
password: 'YOUR_PASSWORD',
);
// Create OzoneTool instance for moderation tools.
final ozone = OzoneTool.fromSession(session.data);
// Query moderation events.
final events = await ozone.moderation.queryEvents(limit: 50);
// Get details about specific subjects. `subjects` is required.
final subjects = await ozone.moderation.getSubjects(
subjects: ['did:plc:iijrtk7ocored6zuziwmqq3c'],
);
// Access team management.
final teamMembers = await ozone.team.listMembers();
}
Once an instance of the Bluesky object has been created, service endpoints can be used by accessing the property corresponding to each service as follows.
import 'package:bluesky/atproto.dart';
import 'package:bluesky/bluesky.dart';
Future<void> main() async {
final session = await createSession(
identifier: 'YOUR_HANDLE_OR_EMAIL',
password: 'YOUR_PASSWORD',
);
// Just pass created session data.
final bsky = Bluesky.fromSession(session.data);
final timeline = await bsky.feed.getTimeline();
print(timeline);
}
See API Supported Matrix for a list of endpoints supported by bluesky.
Let's Implementβ
Okay then, let's try some endpoints!
The following example first authenticates the user against bsky.social, sends the post to Bluesky, and then deletes it using a reference to the created record.
import 'package:bluesky/atproto.dart';
import 'package:bluesky/bluesky.dart';
Future<void> main() async {
final session = await createSession(
identifier: 'YOUR_HANDLE_OR_EMAIL',
password: 'YOUR_PASSWORD',
);
// Just pass created session data.
final bsky = Bluesky.fromSession(session.data);
// Create a record to specific service like Bluesky.
final strongRef = await bsky.feed.post.create(text: 'Hello, Bluesky!');
// And delete it. `uri` is already an `AtUri`.
await bsky.feed.post.delete(rkey: strongRef.data.uri.rkey);
}
See API Support Matrix for all supported endpoints.
Feed Operationsβ
bluesky provides comprehensive support for all feed-related operations. Here are some common examples:
Creating Postsβ
import 'dart:io';
import 'package:bluesky/app_bsky_embed_external.dart';
import 'package:bluesky/app_bsky_embed_images.dart';
import 'package:bluesky/app_bsky_feed_post.dart';
import 'package:bluesky/bluesky.dart';
import 'package:bluesky/cardyb.dart';
Future<void> main() async {
final bsky = Bluesky.fromSession(await _session);
// Simple text post.
final post = await bsky.feed.post.create(text: 'Hello, Bluesky! π¦');
print('Posted: ${post.data.uri}');
// Upload an image and embed it.
final image = File('./cool_image.jpg').readAsBytesSync();
final blob = await bsky.atproto.repo.uploadBlob(bytes: image);
final imagePost = await bsky.feed.post.create(
text: 'Check out this image!',
embed: UFeedPostEmbed.embedImages(
data: EmbedImages(
images: [EmbedImagesImage(image: blob.data.blob, alt: 'Cool image!')],
),
),
);
// Post with an external link. `EmbedExternalExternal.uri` is a plain
// `String`, not an `AtUri`.
final linkPost = await bsky.feed.post.create(
text: 'Interesting article',
embed: UFeedPostEmbed.embedExternal(
data: EmbedExternal(
external: EmbedExternalExternal(
uri: 'https://example.com',
title: 'Article Title',
description: 'Article description',
),
),
),
);
// Or let `package:bluesky/cardyb.dart` build the card metadata for you.
final preview = await getLinkPreview(Uri.https('example.com'));
final cardPost = await bsky.feed.post.create(
text: 'Interesting article',
embed: UFeedPostEmbed.embedExternal(
data: EmbedExternal(
external: EmbedExternalExternal(
uri: preview.data.url ?? 'https://example.com',
title: preview.data.title ?? '',
description: preview.data.description ?? '',
),
),
),
);
}
package:bluesky/cardyb.dart exposes getLinkPreview(url) and the LinkPreview model, so you do not have to scrape the page title and description yourself. Because cardyb answers 200 even on failure, check preview.data.error before trusting the result.
Timeline and Feed Managementβ
import 'package:bluesky/bluesky.dart';
import 'package:bluesky/core.dart';
Future<void> main() async {
final bsky = Bluesky.fromSession(await _session);
// Get user timeline.
final timeline = await bsky.feed.getTimeline(limit: 50);
for (final feed in timeline.data.feed) {
print('${feed.post.author.displayName}: ${feed.post.record}');
}
// Get author feed.
final authorFeed = await bsky.feed.getAuthorFeed(
actor: 'shinyakato.dev',
limit: 20,
);
// Get post thread. `uri` is an `AtUri`.
final thread = await bsky.feed.getPostThread(
uri: AtUri('at://did:plc:example/app.bsky.feed.post/example'),
);
}
Anything that identifies a record takes an AtUri, not a String β getPostThread, getLikes, getRepostedBy, getQuotes, getPosts, and RepoStrongRef.uri included. Values coming back from the API (post.data.uri, data.uri in Firehose handlers) are already AtUri, so pass them straight through.
Interactions (Likes, Reposts, Replies)β
import 'package:bluesky/app_bsky_feed_post.dart';
import 'package:bluesky/bluesky.dart';
import 'package:bluesky/com_atproto_repo_strongref.dart';
import 'package:bluesky/core.dart';
Future<void> main() async {
final bsky = Bluesky.fromSession(await _session);
final postUri = AtUri('at://did:plc:example/app.bsky.feed.post/example');
const postCid = 'bafyreiexamplecid';
// `RepoStrongRef.uri` is an `AtUri`.
final subject = RepoStrongRef(uri: postUri, cid: postCid);
// Like a post.
final like = await bsky.feed.like.create(subject: subject);
// Repost.
final repost = await bsky.feed.repost.create(subject: subject);
// Reply to a post.
final reply = await bsky.feed.post.create(
text: 'Great post!',
reply: ReplyRef(root: subject, parent: subject),
);
// Get post likes.
final likes = await bsky.feed.getLikes(uri: postUri);
for (final like in likes.data.likes) {
print('Liked by: ${like.actor.displayName}');
}
// Get reposts.
final reposts = await bsky.feed.getRepostedBy(uri: postUri);
}
Chat Featuresβ
bluesky provides full support for Bluesky's chat functionality through the BlueskyChat class.
Conversation Managementβ
import 'package:bluesky/bluesky_chat.dart';
Future<void> main() async {
final chat = BlueskyChat.fromSession(await _session);
// List all conversations.
final conversations = await chat.convo.listConvos(limit: 50);
for (final convo in conversations.data.convos) {
print(
'Conversation with: ${convo.members.map((m) => m.displayName).join(', ')}',
);
print('Last message: ${convo.lastMessage ?? 'No messages'}');
}
// Get a specific conversation.
final convo = await chat.convo.getConvo(convoId: 'convo-id');
// Get conversation for specific members.
final memberConvo = await chat.convo.getConvoForMembers(
members: ['did:plc:example1', 'did:plc:example2'],
);
// Accept a conversation request.
await chat.convo.acceptConvo(convoId: 'convo-id');
// Leave a conversation.
await chat.convo.leaveConvo(convoId: 'convo-id');
// Mute/unmute conversations.
await chat.convo.muteConvo(convoId: 'convo-id');
await chat.convo.unmuteConvo(convoId: 'convo-id');
}
Messages and Reactionsβ
MessageInput comes from package:bluesky/chat_bsky_convo_defs.dart and BatchItem from package:bluesky/chat_bsky_convo_sendmessagebatch.dart.
import 'package:bluesky/bluesky_chat.dart';
import 'package:bluesky/chat_bsky_convo_defs.dart';
import 'package:bluesky/chat_bsky_convo_sendmessagebatch.dart';
Future<void> main() async {
final chat = BlueskyChat.fromSession(await _session);
// Send a message.
final message = await chat.convo.sendMessage(
convoId: 'convo-id',
message: MessageInput(text: 'Hello! How are you doing?'),
);
// Send multiple messages in batch.
await chat.convo.sendMessageBatch(
items: [
BatchItem(
convoId: 'convo-id-1',
message: MessageInput(text: 'Message 1'),
),
BatchItem(
convoId: 'convo-id-2',
message: MessageInput(text: 'Message 2'),
),
],
);
// Get messages from a conversation.
final messages = await chat.convo.getMessages(convoId: 'convo-id', limit: 50);
for (final message in messages.data.messages) {
print('$message');
}
// Mark messages as read.
await chat.convo.updateRead(convoId: 'convo-id', messageId: 'message-id');
// Mark all conversations as read.
await chat.convo.updateAllRead();
// Delete a message for yourself only.
await chat.convo.deleteMessageForSelf(
convoId: 'convo-id',
messageId: 'message-id',
);
// Add and remove a reaction.
await chat.convo.addReaction(
convoId: 'convo-id',
messageId: 'message-id',
value: 'π',
);
await chat.convo.removeReaction(
convoId: 'convo-id',
messageId: 'message-id',
value: 'π',
);
}
Chat Moderationβ
import 'package:bluesky/bluesky_chat.dart';
Future<void> main() async {
final chat = BlueskyChat.fromSession(await _session);
// Get actor metadata for moderation.
final actorMeta = await chat.moderation.getActorMetadata(
actor: 'did:plc:example',
);
// Update actor access (for moderators).
await chat.moderation.updateActorAccess(
actor: 'did:plc:example',
allowAccess: true,
);
// Get message context for moderation.
final messageContext = await chat.moderation.getMessageContext(
convoId: 'convo-id',
messageId: 'message-id',
);
}
Notification Groupingβ
A raw notification list is unusable in a UI: fifty separate "X liked your post" rows should read as one. bluesky groups notifications client-side the same way the official Bluesky app does, through the group() extension on the listNotifications output.
Since v2.1.0 the default is official social-app parity: it groups like, repost, follow, like-via-repost, repost-via-repost, and subscribed-post; applies a 48-hour sliding window anchored on each group's newest item; separates follow-backs into their own groups; and marks a group unread if any of its notifications is unread.
import 'package:bluesky/app_bsky_notification_listnotifications.dart';
import 'package:bluesky/bluesky.dart';
Future<void> main() async {
final bsky = Bluesky.fromSession(await _session);
final notifications = await bsky.notification.listNotifications();
// Default: official Bluesky social-app parity β groups like / repost /
// follow / like-via-repost / repost-via-repost / subscribed-post within a
// 48h sliding window, separates follow-backs, and marks a group unread if
// any of its notifications is unread.
final grouped = notifications.data.group();
for (final group in grouped.notifications) {
print('${group.reason}: ${group.authors.length} author(s)');
print('Newest subject: ${group.uris.first}');
print('Unread: ${group.isRead == false}');
}
// Keep the legacy behavior from bluesky <= 2.x.
final legacy = notifications.data.group(
config: const NotificationsGrouperConfig.lenient(),
);
// Or fully customize the grouping.
final custom = notifications.data.group(
config: const NotificationsGrouperConfig(
groupableReasons: {
KnownNotificationReason.like,
KnownNotificationReason.repost,
},
window: Duration(hours: 24),
separateFollowBacks: true,
unreadIfAny: false,
),
);
// Additionally bucket by wall-clock time before grouping.
final hourly = notifications.data.groupByHour(3);
final byMinute = notifications.data.groupByMinute(30);
// `GroupBy` can be combined with a config.
final legacyHourly = notifications.data.group(
by: GroupBy.hour(3),
config: const NotificationsGrouperConfig.lenient(),
);
// The grouper is also usable directly.
const grouper = NotificationsGrouper(
config: NotificationsGrouperConfig.official(),
);
final GroupedNotifications result = grouper.group(notifications.data);
}
| Config | Grouped reasons | Window | Follow-backs | Unread |
|---|---|---|---|---|
NotificationsGrouperConfig.official() (default) | 6 reasons | 48h sliding | Separated | If any unread |
NotificationsGrouperConfig.lenient() | like / repost / follow | none | Merged | Newest item's isRead |
NotificationsGrouperConfig(...) | yours | yours | yours | yours |
v2.1.0 changed the default output. If you depended on the pre-2.1 behavior, pass NotificationsGrouperConfig.lenient() explicitly. This applies to groupByHour / groupByMinute too, which now also default to the official config.
More Tips πβ
These topics behave identically whether you drive them through atproto or
bluesky, so they are documented once in the Guides
section rather than twice here:
| Guide | What it covers |
|---|---|
| Authentication | Creating, persisting and refreshing sessions; app passwords; two-factor sign-in |
| Custom Services | Pointing the client at your own PDS, a relay, or the public AppView |
| Error Handling | The exception hierarchy, and what rate limiting actually does |
| Retries and Timeouts | Retry strategies, backoff, and the idempotency guard that protects procedures |
| Pagination | Cursor loops, and the condition that actually ends one |
| Working with Data | Serialization, sealed union types, and unknown fields |
| Lexicon IDs | The generated constants for every Lexicon and object ID |
| Firehose | Streaming repository events, and staying connected |
| HTTP Client | Injecting a shared, proxied, instrumented or fake transport |
Moderation APIβ
bluesky ships the same client-side moderation engine the official Bluesky app uses, plus the endpoints for reporting and labelling.
moderation.dart unprefixedgetModerationPrefs, getLabelDefinitions, and the ViewerState helpers are Dart extensions, and Dart does not apply extensions imported behind a prefix. import 'package:bluesky/moderation.dart' as mod; will compile the import but leave those members invisible.
Content Moderationβ
Moderation is a three-step pipeline: read the user's preferences, resolve the label definitions of the labelers they subscribe to, then ask for a decision per subject.
import 'package:bluesky/bluesky.dart';
// `moderation.dart` ships extensions (`getModerationPrefs`,
// `getLabelDefinitions`). Dart never applies extensions behind an import
// prefix, so this import must stay unprefixed.
import 'package:bluesky/moderation.dart';
Future<void> main() async {
final bsky = Bluesky.fromSession(await _session);
// First get the user's moderation prefs and their label definitions.
final preferences = await bsky.actor.getPreferences();
final moderationPrefs = preferences.data.getModerationPrefs();
final labelDefs = await bsky.labeler.getLabelDefinitions(moderationPrefs);
final moderationOpts = ModerationOpts(
userDid: bsky.session!.did,
prefs: moderationPrefs,
labelDefs: labelDefs,
);
// `getLabelerHeaders` tells the AppView which labelers to apply. Without it
// the feed comes back unlabelled and moderation has nothing to act on.
final feeds = await bsky.feed.getTimeline(
$headers: getLabelerHeaders(moderationPrefs),
);
for (final feed in feeds.data.feed) {
final decision = moderatePost(
ModerationSubjectPost.postView(data: feed.post),
moderationOpts,
);
// Ask for the UI decision in the context you are rendering.
final contentList = decision.getUI(ModerationBehaviorContext.contentList);
final contentView = decision.getUI(ModerationBehaviorContext.contentView);
final contentMedia = decision.getUI(ModerationBehaviorContext.contentMedia);
if (contentList.filter) {
continue; // Don't include in feeds.
}
if (contentList.blur) {
// Render behind a content warning; `noOverride` means the user may not
// click through.
print('Blur (override allowed: ${!contentList.noOverride})');
}
if (contentList.alert || contentList.inform) {
print('Show a moderation notice');
}
}
}
getLabelerHeaders(prefs) is not optional decoration. It sends the atproto-accept-labelers header that tells the AppView which labelers to apply. Omit it and the feed comes back unlabelled, so every moderation decision you compute from it is empty.
getUI answers a different question per context, because the same label warrants different treatment in a scrolling feed than on a dedicated post page.
| Context | Meaning |
|---|---|
ModerationBehaviorContext.contentList | The subject as a row in a feed or list |
ModerationBehaviorContext.contentView | The subject on its own screen |
ModerationBehaviorContext.contentMedia | The subject's images or video |
ModerationBehaviorContext.avatar / banner / displayName | Profile chrome |
The returned ModerationUI exposes filter (drop it entirely), blur (hide behind a warning), alert and inform (annotate it), and noOverride (the user may not click through).
Moderating other subjectsβ
Posts are not the only thing worth moderating. Each subject has its own entry point.
import 'package:bluesky/moderation.dart';
void main() {
final opts = _moderationOpts;
// Posts.
final post = moderatePost(
ModerationSubjectPost.postView(data: _postView),
opts,
);
// Profiles β merges the account and profile decisions.
final profile = moderateProfile(
ModerationSubjectProfile.profileViewDetailed(data: _profile),
opts,
);
// Live status (`app.bsky.actor.defs#statusView`).
final status = moderateStatus(
ModerationSubjectProfile.profileViewDetailed(data: _profile),
opts,
);
// Notifications.
final notification = moderateNotification(
ModerationSubjectNotification.notification(data: _notification),
opts,
);
// Feed generators.
final generator = moderateFeedGenerator(
ModerationSubjectFeedGenerator.generatorView(data: _generator),
opts,
);
// User lists.
final list = moderateUserList(
ModerationSubjectUserList.listView(data: _list),
opts,
);
}
Muted wordsβ
The engine applies the user's muted words as part of moderatePost, but the matcher is also public if you want to run it over your own text.
import 'package:bluesky/moderation.dart';
void main() {
final mutedWords = _moderationPrefs.mutedWords;
// A boolean check.
final muted = hasMutedWord(
mutedWords: mutedWords,
text: 'this post mentions spoilers',
languages: ['en'],
);
// Or the individual matches, so you can explain *why* it was muted.
final matches = matchMuteWords(
mutedWords: mutedWords,
text: 'this post mentions spoilers',
languages: ['en'],
);
for (final MuteWordMatch match in matches) {
print('Matched "${match.predicate}" for ${match.word.value}');
}
}
matchMuteWords returns the individual MuteWordMatches, so you can tell the user which muted word hid a post. Both functions accept facets, outlineTags, languages, and an actor β the last one is what makes actorTarget: exclude-following work.
Reporting Contentβ
import 'package:bluesky/bluesky.dart';
import 'package:bluesky/com_atproto_admin_defs.dart';
import 'package:bluesky/com_atproto_moderation_createreport.dart';
import 'package:bluesky/com_atproto_moderation_defs.dart';
import 'package:bluesky/com_atproto_repo_strongref.dart';
import 'package:bluesky/core.dart';
Future<void> main() async {
final bsky = Bluesky.fromSession(await _session);
// Report an account.
final accountReport = await bsky.atproto.moderation.createReport(
reasonType: ReasonType.knownValue(
data: KnownReasonType.comAtprotoModerationDefsReasonSpam,
),
subject: UModerationCreateReportSubject.repoRef(
data: RepoRef(did: 'did:plc:example'),
),
reason: 'This account is engaging in harassment',
);
// Report a single record, such as a post.
final postReport = await bsky.atproto.moderation.createReport(
reasonType: ReasonType.knownValue(
data: KnownReasonType.comAtprotoModerationDefsReasonViolation,
),
subject: UModerationCreateReportSubject.repoStrongRef(
data: RepoStrongRef(
uri: AtUri('at://did:plc:example/app.bsky.feed.post/example'),
cid: 'bafyreiexamplecid',
),
),
reason: 'This post contains spam content',
);
}
KnownReasonType constants are named after their fully-qualified Lexicon ID, e.g. KnownReasonType.comAtprotoModerationDefsReasonSpam.
Working with Labelsβ
import 'package:bluesky/bluesky.dart';
Future<void> main() async {
final bsky = Bluesky.anonymous();
// Get labeler services.
final labelerServices = await bsky.labeler.getServices(
dids: ['did:plc:labeler-example'],
detailed: true,
);
// Query labels for content.
final labels = await bsky.atproto.label.queryLabels(
uriPatterns: ['at://did:plc:example/app.bsky.feed.post/*'],
sources: ['did:plc:labeler-example'],
);
for (final label in labels.data.labels) {
print('Label: ${label.val} on ${label.uri}');
print('Created by: ${label.src}');
print('Created at: ${label.cts}');
}
}
Custom Moderation Workflowsβ
The built-in engine covers the official rules; anything beyond that is ordinary application code layered on top.
// `ViewerStateExtension` (`hasBlocking` / `isBlockedBy`) lives here, and Dart
// never applies extensions behind an import prefix.
import 'package:bluesky/app_bsky_actor_defs.dart';
import 'package:bluesky/app_bsky_feed_defs.dart';
import 'package:bluesky/app_bsky_feed_post.dart';
import 'package:bluesky/app_bsky_graph_getrelationships.dart';
import 'package:bluesky/bluesky.dart';
import 'package:bluesky/com_atproto_admin_defs.dart';
import 'package:bluesky/com_atproto_moderation_createreport.dart';
import 'package:bluesky/com_atproto_moderation_defs.dart';
/// Layer your own rules on top of the built-in moderation engine.
class CustomModerationService {
const CustomModerationService(this.bsky, this.blockedKeywords);
final Bluesky bsky;
final Set<String> blockedKeywords;
Future<bool> shouldFilterPost(final PostView post) async {
// `PostView.record` is raw JSON; marshal it into the typed record.
final record = FeedPostRecord.fromJson(post.record);
final text = record.text.toLowerCase();
if (blockedKeywords.any(text.contains)) return true;
// Check the viewer's block status against the author.
final relationships = await bsky.graph.getRelationships(
actor: bsky.session!.did,
others: [post.author.did],
);
final relationship = relationships.data.relationships.first;
switch (relationship) {
case UGraphGetRelationshipsRelationshipsRelationship():
final actor = await bsky.actor.getProfile(actor: relationship.data.did);
return (actor.data.viewer?.hasBlocking ?? false) ||
(actor.data.viewer?.isBlockedBy ?? false);
default:
return false;
}
}
Future<void> reportAndBlock(final String did, final String reason) async {
await bsky.atproto.moderation.createReport(
reasonType: ReasonType.knownValue(
data: KnownReasonType.comAtprotoModerationDefsReasonViolation,
),
subject: UModerationCreateReportSubject.repoRef(data: RepoRef(did: did)),
reason: reason,
);
await bsky.graph.block.create(subject: did);
}
}
Related Packagesβ
Core AT Protocolβ
If you need only core AT Protocol functionality without Bluesky-specific features:
- atproto - Minimal AT Protocol client for infrastructure tools and multi-service applications
Text Processingβ
For advanced text processing and rich text features:
- bluesky_text - Rich text analysis, facet generation, and entity extraction
Package Overviewβ
See the Package Overview for a complete list of all available packages and their relationships.