@atproto/api
Version:
Client library for atproto and Bluesky
1,244 lines • 48.7 kB
JavaScript
import AwaitLock from 'await-lock';
import { TID, retry } from '@atproto/common-web';
import { AtUri, ensureValidDidRegex } from '@atproto/syntax';
import { XrpcClient, buildFetchHandler, } from '@atproto/xrpc';
import { AppBskyActorDefs, AppBskyActorProfile, AppNS, ChatNS, ComAtprotoRepoPutRecord, ComNS, ToolsNS, } from './client/index.js';
import { schemas } from './client/lexicons.js';
import { BSKY_LABELER_DID } from './const.js';
import { DEFAULT_LABEL_SETTINGS } from './moderation/const/labels.js';
import { interpretLabelValueDefinitions } from './moderation/index.js';
import * as predicate from './predicate.js';
import { asAtprotoProxy, asDid, isDid, } from './types.js';
import { getSavedFeedType, sanitizeMutedWordValue, savedFeedsToUriArrays, validateNux, validateSavedFeed, } from './util.js';
const FEED_VIEW_PREF_DEFAULTS = {
hideReplies: false,
hideRepliesByUnfollowed: true,
hideRepliesByLikeCount: 0,
hideReposts: false,
hideQuotePosts: false,
};
const THREAD_VIEW_PREF_DEFAULTS = {
sort: 'hotness',
};
/**
* An {@link Agent} is an {@link AtpBaseClient} with the following
* additional features:
* - AT Protocol labelers configuration utilities
* - AT Protocol proxy configuration utilities
* - Cloning utilities
* - `app.bsky` syntactic sugar
* - `com.atproto` syntactic sugar
*/
export class Agent extends XrpcClient {
//#region Static configuration
/**
* The labelers to be used across all requests with the takedown capability
*/
static { this.appLabelers = [BSKY_LABELER_DID]; }
/**
* Configures the Agent (or its sub classes) globally.
*/
static configure(opts) {
if (opts.appLabelers) {
this.appLabelers = opts.appLabelers.map(asDid); // Validate & copy
}
}
/** @deprecated use `this` instead */
get xrpc() {
return this;
}
constructor(options) {
const sessionManager = typeof options === 'object' && 'fetchHandler' in options
? options
: {
did: undefined,
fetchHandler: buildFetchHandler(options),
};
super((url, init) => {
const headers = new Headers(init?.headers);
if (this.proxy && !headers.has('atproto-proxy')) {
headers.set('atproto-proxy', this.proxy);
}
// Merge the labelers header of this particular request with the app &
// instance labelers.
headers.set('atproto-accept-labelers', [
...this.appLabelers.map((l) => `${l};redact`),
...this.labelers,
headers.get('atproto-accept-labelers')?.trim(),
]
.filter(Boolean)
.join(', '));
return this.sessionManager.fetchHandler(url, { ...init, headers });
}, schemas);
//#endregion
this.com = new ComNS(this);
this.app = new AppNS(this);
this.chat = new ChatNS(this);
this.tools = new ToolsNS(this);
this.labelers = [];
//#region "com.atproto" lexicon short hand methods
/**
* Upload a binary blob to the server
*/
this.uploadBlob = (data, opts) => this.com.atproto.repo.uploadBlob(data, opts);
/**
* Resolve a handle to a DID
*/
this.resolveHandle = (params, opts) => this.com.atproto.identity.resolveHandle(params, opts);
/**
* Change the user's handle
*/
this.updateHandle = (data, opts) => this.com.atproto.identity.updateHandle(data, opts);
/**
* Create a moderation report
*/
this.createModerationReport = (data, opts) => this.com.atproto.moderation.createReport(data, opts);
//#endregion
//#region "app.bsky" lexicon short hand methods
this.getTimeline = (params, opts) => this.app.bsky.feed.getTimeline(params, opts);
this.getAuthorFeed = (params, opts) => this.app.bsky.feed.getAuthorFeed(params, opts);
this.getActorLikes = (params, opts) => this.app.bsky.feed.getActorLikes(params, opts);
this.getPostThread = (params, opts) => this.app.bsky.feed.getPostThread(params, opts);
this.getPost = (params) => this.app.bsky.feed.post.get(params);
this.getPosts = (params, opts) => this.app.bsky.feed.getPosts(params, opts);
this.getLikes = (params, opts) => this.app.bsky.feed.getLikes(params, opts);
this.getRepostedBy = (params, opts) => this.app.bsky.feed.getRepostedBy(params, opts);
this.getFollows = (params, opts) => this.app.bsky.graph.getFollows(params, opts);
this.getFollowers = (params, opts) => this.app.bsky.graph.getFollowers(params, opts);
this.getProfile = (params, opts) => this.app.bsky.actor.getProfile(params, opts);
this.getProfiles = (params, opts) => this.app.bsky.actor.getProfiles(params, opts);
this.getSuggestions = (params, opts) => this.app.bsky.actor.getSuggestions(params, opts);
this.searchActors = (params, opts) => this.app.bsky.actor.searchActors(params, opts);
this.searchActorsTypeahead = (params, opts) => this.app.bsky.actor.searchActorsTypeahead(params, opts);
this.listNotifications = (params, opts) => this.app.bsky.notification.listNotifications(params, opts);
this.countUnreadNotifications = (params, opts) => this.app.bsky.notification.getUnreadCount(params, opts);
this.getLabelers = (params, opts) => this.app.bsky.labeler.getServices(params, opts);
//- Private methods
this.#prefsLock = new AwaitLock();
this.sessionManager = sessionManager;
}
//#region Cloning utilities
clone() {
return this.copyInto(new Agent(this.sessionManager));
}
copyInto(inst) {
inst.configureLabelers(this.labelers);
inst.configureProxy(this.proxy ?? null);
inst.clearHeaders();
for (const [key, value] of this.headers)
inst.setHeader(key, value);
return inst;
}
withProxy(serviceType, did) {
const inst = this.clone();
inst.configureProxy(`${asDid(did)}#${serviceType}`);
return inst;
}
//#endregion
//#region ATPROTO labelers configuration utilities
/**
* The labelers statically configured on the class of the current instance.
*/
get appLabelers() {
return this.constructor.appLabelers;
}
configureLabelers(labelerDids) {
this.labelers = labelerDids.map(asDid); // Validate & copy
}
/** @deprecated use {@link configureLabelers} instead */
configureLabelersHeader(labelerDids) {
// Filtering non-did values for backwards compatibility
this.configureLabelers(labelerDids.filter(isDid));
}
configureProxy(value) {
if (value === null)
this.proxy = undefined;
else
this.proxy = asAtprotoProxy(value);
}
/** @deprecated use {@link configureProxy} instead */
configureProxyHeader(serviceType, did) {
// Ignoring non-did values for backwards compatibility
if (isDid(did))
this.configureProxy(`${did}#${serviceType}`);
}
//#endregion
//#region Session management
/**
* Get the authenticated user's DID, if any.
*/
get did() {
const { did } = this.sessionManager;
if (!did)
return undefined;
ensureValidDidRegex(did);
return did;
}
/** @deprecated Use {@link Agent.assertDid} instead */
get accountDid() {
return this.assertDid;
}
/**
* Get the authenticated user's DID, or throw an error if not authenticated.
*/
get assertDid() {
this.assertAuthenticated();
return this.did;
}
/**
* Assert that the user is authenticated.
*/
assertAuthenticated() {
if (!this.did)
throw new Error('Not logged in');
}
//#endregion
/** @deprecated use "this" instead */
get api() {
return this;
}
async getLabelDefinitions(prefs) {
// collect the labeler dids
const dids = [...this.appLabelers];
if (isBskyPrefs(prefs)) {
dids.push(...prefs.moderationPrefs.labelers.map((l) => l.did));
}
else if (isModPrefs(prefs)) {
dids.push(...prefs.labelers.map((l) => l.did));
}
else {
dids.push(...prefs);
}
// fetch their definitions
const labelers = await this.getLabelers({
dids,
detailed: true,
});
// assemble a map of labeler dids to the interpreted label value definitions
const labelDefs = {};
if (labelers.data) {
for (const labeler of labelers.data
.views) {
labelDefs[labeler.creator.did] = interpretLabelValueDefinitions(labeler);
}
}
return labelDefs;
}
async post(record) {
record.createdAt ||= new Date().toISOString();
return this.app.bsky.feed.post.create({ repo: this.accountDid }, record);
}
async deletePost(postUri) {
this.assertAuthenticated();
const postUrip = new AtUri(postUri);
return this.app.bsky.feed.post.delete({
repo: postUrip.hostname,
rkey: postUrip.rkey,
});
}
async like(uri, cid, via) {
return this.app.bsky.feed.like.create({ repo: this.accountDid }, {
subject: { uri, cid },
createdAt: new Date().toISOString(),
via,
});
}
async deleteLike(likeUri) {
this.assertAuthenticated();
const likeUrip = new AtUri(likeUri);
return this.app.bsky.feed.like.delete({
repo: likeUrip.hostname,
rkey: likeUrip.rkey,
});
}
async repost(uri, cid, via) {
return this.app.bsky.feed.repost.create({ repo: this.accountDid }, {
subject: { uri, cid },
createdAt: new Date().toISOString(),
via,
});
}
async deleteRepost(repostUri) {
this.assertAuthenticated();
const repostUrip = new AtUri(repostUri);
return this.app.bsky.feed.repost.delete({
repo: repostUrip.hostname,
rkey: repostUrip.rkey,
});
}
async follow(subjectDid, via) {
return this.app.bsky.graph.follow.create({ repo: this.accountDid }, {
subject: subjectDid,
createdAt: new Date().toISOString(),
via,
});
}
async deleteFollow(followUri) {
this.assertAuthenticated();
const followUrip = new AtUri(followUri);
return this.app.bsky.graph.follow.delete({
repo: followUrip.hostname,
rkey: followUrip.rkey,
});
}
/**
* @note: Using this method will reset the whole profile record if it
* previously contained invalid values (wrt to the profile lexicon).
*/
async upsertProfile(updateFn) {
const upsert = async () => {
const repo = this.assertDid;
const collection = 'app.bsky.actor.profile';
const existing = await this.com.atproto.repo
.getRecord({ repo, collection, rkey: 'self' })
.catch((_) => undefined);
const existingRecord = existing && predicate.isValidProfile(existing.data.value)
? existing.data.value
: undefined;
// run the update
const updated = await updateFn(existingRecord);
// validate the value returned by the update function
const validation = AppBskyActorProfile.validateRecord({
$type: collection,
...updated,
});
if (!validation.success) {
throw validation.error;
}
await this.com.atproto.repo.putRecord({
repo,
collection,
rkey: 'self',
record: validation.value,
swapRecord: existing?.data.cid || null,
});
};
return retry(upsert, {
maxRetries: 5,
retryable: (e) => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError,
});
}
async mute(actor) {
return this.app.bsky.graph.muteActor({ actor });
}
async unmute(actor) {
return this.app.bsky.graph.unmuteActor({ actor });
}
async muteModList(uri) {
return this.app.bsky.graph.muteActorList({ list: uri });
}
async unmuteModList(uri) {
return this.app.bsky.graph.unmuteActorList({ list: uri });
}
async blockModList(uri) {
return this.app.bsky.graph.listblock.create({ repo: this.accountDid }, {
subject: uri,
createdAt: new Date().toISOString(),
});
}
async unblockModList(uri) {
const repo = this.accountDid;
const listInfo = await this.app.bsky.graph.getList({
list: uri,
limit: 1,
});
const blocked = listInfo.data.list.viewer?.blocked;
if (blocked) {
const { rkey } = new AtUri(blocked);
return this.app.bsky.graph.listblock.delete({
repo,
rkey,
});
}
}
async updateSeenNotifications(seenAt = new Date().toISOString()) {
return this.app.bsky.notification.updateSeen({ seenAt });
}
async getPreferences() {
const prefs = {
feeds: {
saved: undefined,
pinned: undefined,
},
// @ts-ignore populating below
savedFeeds: undefined,
feedViewPrefs: {
home: {
...FEED_VIEW_PREF_DEFAULTS,
},
},
threadViewPrefs: { ...THREAD_VIEW_PREF_DEFAULTS },
moderationPrefs: {
adultContentEnabled: false,
labels: { ...DEFAULT_LABEL_SETTINGS },
labelers: this.appLabelers.map((did) => ({
did,
labels: {},
})),
mutedWords: [],
hiddenPosts: [],
},
birthDate: undefined,
interests: {
tags: [],
},
bskyAppState: {
queuedNudges: [],
activeProgressGuide: undefined,
nuxs: [],
},
postInteractionSettings: {
threadgateAllowRules: undefined,
postgateEmbeddingRules: undefined,
},
verificationPrefs: {
hideBadges: false,
},
liveEventPreferences: {
hiddenFeedIds: [],
hideAllFeeds: false,
},
};
const res = await this.app.bsky.actor.getPreferences({});
const labelPrefs = [];
for (const pref of res.data.preferences) {
if (predicate.isValidAdultContentPref(pref)) {
// adult content preferences
prefs.moderationPrefs.adultContentEnabled = pref.enabled;
}
else if (predicate.isValidContentLabelPref(pref)) {
// content label preference
const adjustedPref = adjustLegacyContentLabelPref(pref);
labelPrefs.push(adjustedPref);
}
else if (predicate.isValidLabelersPref(pref)) {
// labelers preferences
prefs.moderationPrefs.labelers = this.appLabelers
.map((did) => ({ did, labels: {} }))
.concat(pref.labelers.map((labeler) => ({
...labeler,
labels: {},
})));
}
else if (predicate.isValidSavedFeedsPrefV2(pref)) {
prefs.savedFeeds = pref.items;
}
else if (predicate.isValidSavedFeedsPref(pref)) {
// saved and pinned feeds
prefs.feeds.saved = pref.saved;
prefs.feeds.pinned = pref.pinned;
}
else if (predicate.isValidPersonalDetailsPref(pref)) {
// birth date (irl)
if (pref.birthDate) {
prefs.birthDate = new Date(pref.birthDate);
}
}
else if (predicate.isValidDeclaredAgePref(pref)) {
const { $type: _, ...declaredAgePref } = pref;
prefs.declaredAge = declaredAgePref;
}
else if (predicate.isValidFeedViewPref(pref)) {
// feed view preferences
const { $type: _, feed, ...v } = pref;
prefs.feedViewPrefs[feed] = { ...FEED_VIEW_PREF_DEFAULTS, ...v };
}
else if (predicate.isValidThreadViewPref(pref)) {
// thread view preferences
const { $type: _, ...v } = pref;
prefs.threadViewPrefs = { ...prefs.threadViewPrefs, ...v };
}
else if (predicate.isValidInterestsPref(pref)) {
const { $type: _, ...v } = pref;
prefs.interests = { ...prefs.interests, ...v };
}
else if (predicate.isValidMutedWordsPref(pref)) {
prefs.moderationPrefs.mutedWords = pref.items;
if (prefs.moderationPrefs.mutedWords.length) {
prefs.moderationPrefs.mutedWords =
prefs.moderationPrefs.mutedWords.map((word) => {
word.actorTarget = word.actorTarget || 'all';
return word;
});
}
}
else if (predicate.isValidHiddenPostsPref(pref)) {
prefs.moderationPrefs.hiddenPosts = pref.items;
}
else if (predicate.isValidBskyAppStatePref(pref)) {
prefs.bskyAppState.queuedNudges = pref.queuedNudges || [];
prefs.bskyAppState.activeProgressGuide = pref.activeProgressGuide;
prefs.bskyAppState.nuxs = pref.nuxs || [];
}
else if (predicate.isValidPostInteractionSettingsPref(pref)) {
prefs.postInteractionSettings.threadgateAllowRules =
pref.threadgateAllowRules;
prefs.postInteractionSettings.postgateEmbeddingRules =
pref.postgateEmbeddingRules;
}
else if (predicate.isValidVerificationPrefs(pref)) {
prefs.verificationPrefs = {
hideBadges: pref.hideBadges,
};
}
else if (predicate.isValidLiveEventPreferences(pref)) {
prefs.liveEventPreferences = {
hiddenFeedIds: pref.hiddenFeedIds || [],
hideAllFeeds: pref.hideAllFeeds ?? false,
};
}
}
/*
* If `prefs.savedFeeds` is undefined, no `savedFeedsPrefV2` exists, which
* means we want to try to migrate if needed.
*
* If v1 prefs exist, they will be migrated to v2.
*
* If no v1 prefs exist, the user is either new, or could be old and has
* never edited their feeds.
*/
if (prefs.savedFeeds == null) {
const { saved, pinned } = prefs.feeds;
if (saved && pinned) {
const uniqueMigratedSavedFeeds = new Map();
// insert Following feed first
uniqueMigratedSavedFeeds.set('timeline', {
id: TID.nextStr(),
type: 'timeline',
value: 'following',
pinned: true,
});
// use pinned as source of truth for feed order
for (const uri of pinned) {
const type = getSavedFeedType(uri);
// only want supported types
if (type === 'unknown')
continue;
uniqueMigratedSavedFeeds.set(uri, {
id: TID.nextStr(),
type,
value: uri,
pinned: true,
});
}
for (const uri of saved) {
if (!uniqueMigratedSavedFeeds.has(uri)) {
const type = getSavedFeedType(uri);
// only want supported types
if (type === 'unknown')
continue;
uniqueMigratedSavedFeeds.set(uri, {
id: TID.nextStr(),
type,
value: uri,
pinned: false,
});
}
}
prefs.savedFeeds = Array.from(uniqueMigratedSavedFeeds.values());
}
else {
prefs.savedFeeds = [
{
id: TID.nextStr(),
type: 'timeline',
value: 'following',
pinned: true,
},
];
}
// save to user preferences so this migration doesn't re-occur
await this.overwriteSavedFeeds(prefs.savedFeeds);
}
// apply the label prefs
for (const pref of labelPrefs) {
if (pref.labelerDid) {
const labeler = prefs.moderationPrefs.labelers.find((labeler) => labeler.did === pref.labelerDid);
if (!labeler)
continue;
labeler.labels[pref.label] = pref.visibility;
}
else {
prefs.moderationPrefs.labels[pref.label] =
pref.visibility;
}
}
prefs.moderationPrefs.labels = remapLegacyLabels(prefs.moderationPrefs.labels);
// automatically configure the client
this.configureLabelers(prefsArrayToLabelerDids(res.data.preferences));
return prefs;
}
async overwriteSavedFeeds(savedFeeds) {
savedFeeds.forEach(validateSavedFeed);
const uniqueSavedFeeds = new Map();
savedFeeds.forEach((feed) => {
// remove and re-insert to preserve order
if (uniqueSavedFeeds.has(feed.id)) {
uniqueSavedFeeds.delete(feed.id);
}
uniqueSavedFeeds.set(feed.id, feed);
});
return this.updateSavedFeedsV2Preferences(() => Array.from(uniqueSavedFeeds.values()));
}
async updateSavedFeeds(savedFeedsToUpdate) {
savedFeedsToUpdate.map(validateSavedFeed);
return this.updateSavedFeedsV2Preferences((savedFeeds) => {
return savedFeeds.map((savedFeed) => {
const updatedVersion = savedFeedsToUpdate.find((updated) => savedFeed.id === updated.id);
if (updatedVersion) {
return {
...savedFeed,
// only update pinned
pinned: updatedVersion.pinned,
};
}
return savedFeed;
});
});
}
async addSavedFeeds(savedFeeds) {
const toSave = savedFeeds.map((f) => ({
...f,
id: TID.nextStr(),
}));
toSave.forEach(validateSavedFeed);
return this.updateSavedFeedsV2Preferences((savedFeeds) => [
...savedFeeds,
...toSave,
]);
}
async removeSavedFeeds(ids) {
return this.updateSavedFeedsV2Preferences((savedFeeds) => [
...savedFeeds.filter((feed) => !ids.find((id) => feed.id === id)),
]);
}
/**
* @deprecated use `overwriteSavedFeeds`
*/
async setSavedFeeds(saved, pinned) {
return this.updateFeedPreferences(() => ({
saved,
pinned,
}));
}
/**
* @deprecated use `addSavedFeeds`
*/
async addSavedFeed(v) {
return this.updateFeedPreferences((saved, pinned) => ({
saved: [...saved.filter((uri) => uri !== v), v],
pinned,
}));
}
/**
* @deprecated use `removeSavedFeeds`
*/
async removeSavedFeed(v) {
return this.updateFeedPreferences((saved, pinned) => ({
saved: saved.filter((uri) => uri !== v),
pinned: pinned.filter((uri) => uri !== v),
}));
}
/**
* @deprecated use `addSavedFeeds` or `updateSavedFeeds`
*/
async addPinnedFeed(v) {
return this.updateFeedPreferences((saved, pinned) => ({
saved: [...saved.filter((uri) => uri !== v), v],
pinned: [...pinned.filter((uri) => uri !== v), v],
}));
}
/**
* @deprecated use `updateSavedFeeds` or `removeSavedFeeds`
*/
async removePinnedFeed(v) {
return this.updateFeedPreferences((saved, pinned) => ({
saved,
pinned: pinned.filter((uri) => uri !== v),
}));
}
async setAdultContentEnabled(v) {
await this.updatePreferences((prefs) => {
const adultContentPref = prefs.findLast(predicate.isValidAdultContentPref) || {
$type: 'app.bsky.actor.defs#adultContentPref',
enabled: v,
};
adultContentPref.enabled = v;
return prefs
.filter((pref) => !AppBskyActorDefs.isAdultContentPref(pref))
.concat(adultContentPref);
});
}
async setContentLabelPref(key, value, labelerDid) {
if (labelerDid) {
ensureValidDidRegex(labelerDid);
}
await this.updatePreferences((prefs) => {
const labelPref = prefs
.filter(predicate.isValidContentLabelPref)
.findLast((pref) => pref.label === key && pref.labelerDid === labelerDid) || {
$type: 'app.bsky.actor.defs#contentLabelPref',
label: key,
labelerDid,
visibility: value,
};
labelPref.visibility = value;
let legacyLabelPref;
if (AppBskyActorDefs.isContentLabelPref(labelPref)) {
// is global
if (!labelPref.labelerDid) {
const legacyLabelValue = {
'graphic-media': 'gore',
porn: 'nsfw',
sexual: 'suggestive',
// Protect against using toString, hasOwnProperty, etc. as a label:
__proto__: null,
}[labelPref.label];
// if it's a legacy label, double-write the legacy label
if (legacyLabelValue) {
legacyLabelPref = prefs
.filter(predicate.isValidContentLabelPref)
.findLast((pref) => pref.label === legacyLabelValue &&
pref.labelerDid === undefined) || {
$type: 'app.bsky.actor.defs#contentLabelPref',
label: legacyLabelValue,
labelerDid: undefined,
visibility: value,
};
legacyLabelPref.visibility = value;
}
}
}
return prefs
.filter((pref) => !AppBskyActorDefs.isContentLabelPref(pref) ||
!(pref.label === key && pref.labelerDid === labelerDid))
.concat(labelPref)
.filter((pref) => {
if (!legacyLabelPref)
return true;
return (!AppBskyActorDefs.isContentLabelPref(pref) ||
!(pref.label === legacyLabelPref.label &&
pref.labelerDid === undefined));
})
.concat(legacyLabelPref ? [legacyLabelPref] : []);
});
}
async addLabeler(did) {
const prefs = await this.updatePreferences((prefs) => {
const labelersPref = prefs.findLast(predicate.isValidLabelersPref) || {
$type: 'app.bsky.actor.defs#labelersPref',
labelers: [],
};
if (!labelersPref.labelers.some((labeler) => labeler.did === did)) {
labelersPref.labelers.push({ did });
}
return prefs
.filter((pref) => !AppBskyActorDefs.isLabelersPref(pref))
.concat(labelersPref);
});
// automatically configure the client
this.configureLabelers(prefsArrayToLabelerDids(prefs));
}
async removeLabeler(did) {
const prefs = await this.updatePreferences((prefs) => {
const labelersPref = prefs.findLast(predicate.isValidLabelersPref) || {
$type: 'app.bsky.actor.defs#labelersPref',
labelers: [],
};
labelersPref.labelers = labelersPref.labelers.filter((l) => l.did !== did);
return prefs
.filter((pref) => !AppBskyActorDefs.isLabelersPref(pref))
.concat(labelersPref);
});
// automatically configure the client
this.configureLabelers(prefsArrayToLabelerDids(prefs));
}
async setPersonalDetails({ birthDate, }) {
await this.updatePreferences((prefs) => {
const personalDetailsPref = prefs.findLast(predicate.isValidPersonalDetailsPref) || {
$type: 'app.bsky.actor.defs#personalDetailsPref',
};
personalDetailsPref.birthDate =
birthDate instanceof Date ? birthDate.toISOString() : birthDate;
return prefs
.filter((pref) => !AppBskyActorDefs.isPersonalDetailsPref(pref))
.concat(personalDetailsPref);
});
}
async setFeedViewPrefs(feed, pref) {
await this.updatePreferences((prefs) => {
const existing = prefs
.filter(predicate.isValidFeedViewPref)
.findLast((pref) => pref.feed === feed);
return prefs
.filter((p) => !AppBskyActorDefs.isFeedViewPref(p) || p.feed !== feed)
.concat({
...existing,
...pref,
$type: 'app.bsky.actor.defs#feedViewPref',
feed,
});
});
}
async setThreadViewPrefs(pref) {
await this.updatePreferences((prefs) => {
const existing = prefs.findLast(predicate.isValidThreadViewPref);
return prefs
.filter((p) => !AppBskyActorDefs.isThreadViewPref(p))
.concat({
...existing,
...pref,
$type: 'app.bsky.actor.defs#threadViewPref',
});
});
}
async setInterestsPref(pref) {
await this.updatePreferences((prefs) => {
const existing = prefs.findLast(predicate.isValidInterestsPref);
return prefs
.filter((p) => !AppBskyActorDefs.isInterestsPref(p))
.concat({
...existing,
...pref,
$type: 'app.bsky.actor.defs#interestsPref',
});
});
}
/**
* Add a muted word to user preferences.
*/
async addMutedWord(mutedWord) {
const sanitizedValue = sanitizeMutedWordValue(mutedWord.value);
if (!sanitizedValue)
return;
await this.updatePreferences((prefs) => {
let mutedWordsPref = prefs.findLast(predicate.isValidMutedWordsPref);
const newMutedWord = {
id: TID.nextStr(),
value: sanitizedValue,
targets: mutedWord.targets || [],
actorTarget: mutedWord.actorTarget || 'all',
expiresAt: mutedWord.expiresAt || undefined,
};
if (mutedWordsPref) {
mutedWordsPref.items.push(newMutedWord);
/**
* Migrate any old muted words that don't have an id
*/
mutedWordsPref.items = migrateLegacyMutedWordsItems(mutedWordsPref.items);
}
else {
// if the pref doesn't exist, create it
mutedWordsPref = {
$type: 'app.bsky.actor.defs#mutedWordsPref',
items: [newMutedWord],
};
}
return prefs
.filter((p) => !AppBskyActorDefs.isMutedWordsPref(p))
.concat(mutedWordsPref);
});
}
/**
* Convenience method to add muted words to user preferences
*/
async addMutedWords(newMutedWords) {
await Promise.all(newMutedWords.map((word) => this.addMutedWord(word)));
}
/**
* @deprecated use `addMutedWords` or `addMutedWord` instead
*/
async upsertMutedWords(mutedWords) {
await this.addMutedWords(mutedWords);
}
/**
* Update a muted word in user preferences.
*/
async updateMutedWord(mutedWord) {
await this.updatePreferences((prefs) => {
const mutedWordsPref = prefs.findLast(predicate.isValidMutedWordsPref);
if (mutedWordsPref) {
mutedWordsPref.items = mutedWordsPref.items.map((existingItem) => {
const match = matchMutedWord(existingItem, mutedWord);
if (match) {
const updated = {
...existingItem,
...mutedWord,
};
return {
id: existingItem.id || TID.nextStr(),
value: sanitizeMutedWordValue(updated.value) || existingItem.value,
targets: updated.targets || [],
actorTarget: updated.actorTarget || 'all',
expiresAt: updated.expiresAt || undefined,
};
}
else {
return existingItem;
}
});
/**
* Migrate any old muted words that don't have an id
*/
mutedWordsPref.items = migrateLegacyMutedWordsItems(mutedWordsPref.items);
return prefs
.filter((p) => !AppBskyActorDefs.isMutedWordsPref(p))
.concat(mutedWordsPref);
}
return prefs;
});
}
/**
* Remove a muted word from user preferences.
*/
async removeMutedWord(mutedWord) {
await this.updatePreferences((prefs) => {
const mutedWordsPref = prefs.findLast(predicate.isValidMutedWordsPref);
if (mutedWordsPref) {
for (let i = 0; i < mutedWordsPref.items.length; i++) {
const match = matchMutedWord(mutedWordsPref.items[i], mutedWord);
if (match) {
mutedWordsPref.items.splice(i, 1);
break;
}
}
/**
* Migrate any old muted words that don't have an id
*/
mutedWordsPref.items = migrateLegacyMutedWordsItems(mutedWordsPref.items);
return prefs
.filter((p) => !AppBskyActorDefs.isMutedWordsPref(p))
.concat(mutedWordsPref);
}
return prefs;
});
}
/**
* Convenience method to remove muted words from user preferences
*/
async removeMutedWords(mutedWords) {
await Promise.all(mutedWords.map((word) => this.removeMutedWord(word)));
}
async hidePost(postUri) {
await this.updateHiddenPost(postUri, 'hide');
}
async unhidePost(postUri) {
await this.updateHiddenPost(postUri, 'unhide');
}
async bskyAppQueueNudges(nudges) {
await this.updatePreferences((prefs) => {
const pref = prefs.findLast(predicate.isValidBskyAppStatePref) || {
$type: 'app.bsky.actor.defs#bskyAppStatePref',
};
pref.queuedNudges = (pref.queuedNudges || []).concat(nudges);
return prefs
.filter((p) => !AppBskyActorDefs.isBskyAppStatePref(p))
.concat(pref);
});
}
async bskyAppDismissNudges(nudges) {
await this.updatePreferences((prefs) => {
const pref = prefs.findLast(predicate.isValidBskyAppStatePref) || {
$type: 'app.bsky.actor.defs#bskyAppStatePref',
};
nudges = Array.isArray(nudges) ? nudges : [nudges];
pref.queuedNudges = (pref.queuedNudges || []).filter((nudge) => !nudges.includes(nudge));
return prefs
.filter((p) => !AppBskyActorDefs.isBskyAppStatePref(p))
.concat(pref);
});
}
async bskyAppSetActiveProgressGuide(guide) {
if (guide) {
const result = AppBskyActorDefs.validateBskyAppProgressGuide(guide);
if (!result.success)
throw result.error;
}
await this.updatePreferences((prefs) => {
const pref = prefs.findLast(predicate.isValidBskyAppStatePref) || {
$type: 'app.bsky.actor.defs#bskyAppStatePref',
};
pref.activeProgressGuide = guide;
return prefs
.filter((p) => !AppBskyActorDefs.isBskyAppStatePref(p))
.concat(pref);
});
}
/**
* Insert or update a NUX in user prefs
*/
async bskyAppUpsertNux(nux) {
validateNux(nux);
await this.updatePreferences((prefs) => {
const pref = prefs.findLast(predicate.isValidBskyAppStatePref) || {
$type: 'app.bsky.actor.defs#bskyAppStatePref',
};
pref.nuxs = pref.nuxs || [];
const existing = pref.nuxs?.find((n) => {
return n.id === nux.id;
});
let next;
if (existing) {
next = {
id: existing.id,
completed: nux.completed,
data: nux.data,
expiresAt: nux.expiresAt,
};
}
else {
next = nux;
}
// remove duplicates and append
pref.nuxs = pref.nuxs.filter((n) => n.id !== nux.id).concat(next);
return prefs
.filter((p) => !AppBskyActorDefs.isBskyAppStatePref(p))
.concat(pref);
});
}
/**
* Removes NUXs from user preferences.
*/
async bskyAppRemoveNuxs(ids) {
await this.updatePreferences((prefs) => {
const pref = prefs.findLast(predicate.isValidBskyAppStatePref) || {
$type: 'app.bsky.actor.defs#bskyAppStatePref',
};
pref.nuxs = (pref.nuxs || []).filter((nux) => !ids.includes(nux.id));
return prefs
.filter((p) => !AppBskyActorDefs.isBskyAppStatePref(p))
.concat(pref);
});
}
async setPostInteractionSettings(settings) {
const result = AppBskyActorDefs.validatePostInteractionSettingsPref(settings);
// Fool-proofing (should not be needed because of type safety)
if (!result.success)
throw result.error;
await this.updatePreferences((prefs) => {
const pref = prefs.findLast(predicate.isValidPostInteractionSettingsPref) || {
$type: 'app.bsky.actor.defs#postInteractionSettingsPref',
};
/**
* Matches handling of `threadgate.allow` where `undefined` means "everyone"
*/
pref.threadgateAllowRules = settings.threadgateAllowRules;
pref.postgateEmbeddingRules = settings.postgateEmbeddingRules;
return prefs
.filter((p) => !AppBskyActorDefs.isPostInteractionSettingsPref(p))
.concat(pref);
});
}
async setVerificationPrefs(settings) {
const result = AppBskyActorDefs.validateVerificationPrefs(settings);
// Fool-proofing (should not be needed because of type safety)
if (!result.success)
throw result.error;
await this.updatePreferences((prefs) => {
const pref = prefs.findLast(predicate.isValidVerificationPrefs) || {
$type: 'app.bsky.actor.defs#verificationPrefs',
hideBadges: false,
};
pref.hideBadges = settings.hideBadges;
return prefs
.filter((p) => !AppBskyActorDefs.isVerificationPrefs(p))
.concat(pref);
});
}
async updateLiveEventPreferences(action) {
return this.updatePreferences((prefs) => {
const pref = prefs.findLast(predicate.isValidLiveEventPreferences) || {
$type: 'app.bsky.actor.defs#liveEventPreferences',
hiddenFeedIds: [],
hideAllFeeds: false,
};
const hiddenFeedIds = new Set(pref.hiddenFeedIds || []);
switch (action.type) {
case 'hideFeed':
hiddenFeedIds.add(action.id);
break;
case 'unhideFeed':
hiddenFeedIds.delete(action.id);
break;
case 'toggleHideAllFeeds':
pref.hideAllFeeds = !pref.hideAllFeeds;
break;
}
pref.hiddenFeedIds = [...hiddenFeedIds];
return prefs
.filter((p) => !AppBskyActorDefs.isLiveEventPreferences(p))
.concat(pref);
});
}
//- Private methods
#prefsLock;
/**
* This function updates the preferences of a user and allows for a callback function to be executed
* before the update.
* @param cb - cb is a callback function that takes in a single parameter of type
* AppBskyActorDefs.Preferences and returns either a boolean or void. This callback function is used to
* update the preferences of the user. The function is called with the current preferences as an
* argument and if the callback returns false, the preferences are not updated.
*/
async updatePreferences(cb) {
try {
await this.#prefsLock.acquireAsync();
const res = await this.app.bsky.actor.getPreferences({});
const newPrefs = cb(res.data.preferences);
if (newPrefs === false) {
return res.data.preferences;
}
await this.app.bsky.actor.putPreferences({
preferences: newPrefs,
});
return newPrefs;
}
finally {
this.#prefsLock.release();
}
}
async updateHiddenPost(postUri, action) {
await this.updatePreferences((prefs) => {
const pref = prefs.findLast(predicate.isValidHiddenPostsPref) || {
$type: 'app.bsky.actor.defs#hiddenPostsPref',
items: [],
};
const hiddenItems = new Set(pref.items);
if (action === 'hide')
hiddenItems.add(postUri);
else
hiddenItems.delete(postUri);
pref.items = [...hiddenItems];
return prefs
.filter((p) => !AppBskyActorDefs.isHiddenPostsPref(p))
.concat(pref);
});
}
/**
* A helper specifically for updating feed preferences
*/
async updateFeedPreferences(cb) {
let res;
await this.updatePreferences((prefs) => {
const feedsPref = prefs.findLast(predicate.isValidSavedFeedsPref) || {
$type: 'app.bsky.actor.defs#savedFeedsPref',
saved: [],
pinned: [],
};
res = cb(feedsPref.saved, feedsPref.pinned);
feedsPref.saved = res.saved;
feedsPref.pinned = res.pinned;
return prefs
.filter((pref) => !AppBskyActorDefs.isSavedFeedsPref(pref))
.concat(feedsPref);
});
return res;
}
async updateSavedFeedsV2Preferences(cb) {
let maybeMutatedSavedFeeds = [];
await this.updatePreferences((prefs) => {
const existingV2Pref = prefs.findLast(predicate.isValidSavedFeedsPrefV2) || {
$type: 'app.bsky.actor.defs#savedFeedsPrefV2',
items: [],
};
const newSavedFeeds = cb(existingV2Pref.items);
// enforce ordering: pinned first, then saved
existingV2Pref.items = [...newSavedFeeds].sort((a, b) =>
// @NOTE: preserve order of items with the same pinned status
a.pinned === b.pinned ? 0 : a.pinned ? -1 : 1);
// Store the return value
maybeMutatedSavedFeeds = newSavedFeeds;
let updatedPrefs = prefs
.filter((pref) => !AppBskyActorDefs.isSavedFeedsPrefV2(pref))
.concat(existingV2Pref);
/*
* If there's a v2 pref present, it means this account was migrated from v1
* to v2. During the transition period, we double write v2 prefs back to
* v1, but NOT the other way around.
*/
let existingV1Pref = prefs.findLast(predicate.isValidSavedFeedsPref);
if (existingV1Pref) {
const { saved, pinned } = existingV1Pref;
const v2Compat = savedFeedsToUriArrays(
// v1 only supports feeds and lists
existingV2Pref.items.filter((i) => ['feed', 'list'].includes(i.type)));
existingV1Pref = {
...existingV1Pref,
saved: Array.from(new Set([...saved, ...v2Compat.saved])),
pinned: Array.from(new Set([...pinned, ...v2Compat.pinned])),
};
updatedPrefs = updatedPrefs
.filter((pref) => !AppBskyActorDefs.isSavedFeedsPref(pref))
.concat(existingV1Pref);
}
return updatedPrefs;
});
return maybeMutatedSavedFeeds;
}
}
/**
* Helper to transform the legacy content preferences.
*/
function adjustLegacyContentLabelPref(pref) {
let visibility = pref.visibility;
// adjust legacy values
if (visibility === 'show') {
visibility = 'ignore';
}
return { ...pref, visibility };
}
/**
* Re-maps legacy labels to new labels on READ. Does not save these changes to
* the user's preferences.
*/
function remapLegacyLabels(labels) {
const _labels = { ...labels };
const legacyToNewMap = {
gore: 'graphic-media',
nsfw: 'porn',
suggestive: 'sexual',
};
for (const labelName in _labels) {
const newLabelName = legacyToNewMap[labelName];
if (newLabelName) {
_labels[newLabelName] = _labels[labelName];
}
}
return _labels;
}
/**
* A helper to get the currently enabled labelers from the full preferences array
*/
function prefsArrayToLabelerDids(prefs) {
const labelersPref = prefs.findLast(predicate.isValidLabelersPref);
let dids = [];
if (labelersPref) {
dids = labelersPref.labelers.map((labeler) => labeler.did);
}
return dids;
}
function isBskyPrefs(v) {
return (v &&
typeof v === 'object' &&
'moderationPrefs' in v &&
isModPrefs(v.moderationPrefs));
}
function isModPrefs(v) {
return v && typeof v === 'object' && 'labelers' in v;
}
function migrateLegacyMutedWordsItems(items) {
return items.map((item) => ({
...item,
id: item.id || TID.nextStr(),
}));
}
function matchMutedWord(existingWord, newWord) {
// id is undefined in legacy implementation
const existingId = existingWord.id;
// prefer matching based on id
const matchById = existingId && existingId === newWord.id;
// handle legacy case where id is not set
const legacyMatchByValue = !existingId && existingWord.value === newWord.value;
return matchById || legacyMatchByValue;
}
//# sourceMappingURL=agent.js.map