Something went wrong. Try again.
A fully featured CLI used as an alternative to AppView applications (Bsky.app/mu.social)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241import type { Client } from './api.ts';import { parseAtUri } from './api.ts';import { feedUri, listUri } from './refs.ts';import { getPreferences, SAVED_FEEDS, type SavedFeed } from './prefs.ts';import { UsageError, NotFoundError } from './errors.ts';import { info } from './output.ts';
// Feeds and lists by name.//// Wherever a feed or a list is wanted, it can be named the way a person// thinks of it — "mutuals", "JS/TS Wien", "discover" — as well as by at:// URI// or bsky.app link. A name is looked for in widening circles, and the first// circle with a good match wins://// 1. the feeds and lists you have saved or pinned ("following" is your// home timeline);// 2. the feeds and lists you have made;// 3. Bluesky's feed search, for feeds anyone has made.//// A name matches a feed's display name, or the last segment of its address// (its record key: the "mutuals" of …/feed/mutuals), however it is// capitalised, accented or punctuated, and also when it is only the start// of the name, some of its words, or its letters in order. Two of your own// feeds that match equally well are an error that lists them, rather than a// guess; of feeds found by searching, the most liked is taken, and the// others are mentioned.
export type FeedKind = 'feed' | 'list' | 'timeline';
export interface FeedTarget { kind: FeedKind; /** The at:// URI; `following` for the home timeline. */ uri: string; /** What it is called, when known. */ name?: string | undefined; /** Who made it, when known. */ creator?: string | undefined; /** How it was found: by address, or among your feeds, your own, or a search. */ via: 'address' | 'saved' | 'yours' | 'search';}
interface Candidate { kind: FeedKind; uri: string; name: string; creator?: string | undefined; likes: number; via: FeedTarget['via'];}
type Json = Record<string, any>;
// ---------------------------------------------------------------- matching ---
export function normalise(text: string): string { return text.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();}
function isSubsequence(needle: string, haystack: string): boolean { let at = 0; for (const char of haystack) if (char === needle[at]) at++; return at === needle.length;}
/** How well `query` names something called `name` at record key `rkey`: 0 is not at all, 100 exactly. */export function matchScore(query: string, name: string, rkey = ''): number { const q = normalise(query); if (!q) return 0; const qCompact = q.replace(/ /g, ''); const n = normalise(name); const nCompact = n.replace(/ /g, ''); const r = normalise(rkey).replace(/ /g, ''); if (n === q || (nCompact && nCompact === qCompact)) return 100; if (r && r === qCompact) return 95; if (n.startsWith(q) || (nCompact && nCompact.startsWith(qCompact))) return 80; if (r && r.startsWith(qCompact)) return 75; const queryWords = q.split(' '); const nameWords = n.split(' '); if (queryWords.every((word) => nameWords.some((each) => each.startsWith(word)))) return 70; if (queryWords.every((word) => n.includes(word))) return 60; if (nCompact.includes(qCompact) || (r && r.includes(qCompact))) return 50; if (qCompact.length >= 3 && (isSubsequence(qCompact, nCompact) || (r && isSubsequence(qCompact, r)))) return 30; return 0;}
const STRONG = 75;const ENOUGH = 30;
// -------------------------------------------------------------- candidates ---
function rkeyOf(uri: string): string { try { return parseAtUri(uri).rkey ?? ''; } catch { return ''; }}
async function hydrate(client: Client, items: Array<{ kind: 'feed' | 'list'; uri: string }>): Promise<Map<string, Json>> { const views = new Map<string, Json>(); const feeds = items.filter((item) => item.kind === 'feed').map((item) => item.uri); for (let at = 0; at < feeds.length; at += 25) { const { feeds: found } = await client.appview<{ feeds: Json[] }>('app.bsky.feed.getFeedGenerators', { feeds: feeds.slice(at, at + 25) }); for (const view of found) views.set(String(view['uri']), view); } await Promise.all(items.filter((item) => item.kind === 'list').map(async (item) => { const response = await client.appview<{ list: Json }>('app.bsky.graph.getList', { list: item.uri, limit: 1 }).catch(() => undefined); if (response) views.set(item.uri, response.list); })); return views;}
function fromView(view: Json, kind: 'feed' | 'list', via: FeedTarget['via']): Candidate { return { kind, uri: String(view['uri']), name: String(view['displayName'] ?? view['name'] ?? ''), creator: view['creator']?.handle, likes: Number(view['likeCount'] ?? 0), via, };}
async function savedCandidates(client: Client): Promise<Candidate[]> { const preferences = await getPreferences(client); const items = (preferences.find((preference) => preference.$type === SAVED_FEEDS)?.['items'] as SavedFeed[] | undefined) ?? []; const candidates: Candidate[] = []; const hydrating: Array<{ kind: 'feed' | 'list'; uri: string }> = []; for (const item of items) { if (item.type === 'timeline') candidates.push({ kind: 'timeline', uri: 'following', name: 'Following', likes: 0, via: 'saved' }); else hydrating.push({ kind: item.type, uri: item.value }); } const views = await hydrate(client, hydrating); for (const item of hydrating) { const view = views.get(item.uri); candidates.push(view ? fromView(view, item.kind, 'saved') : { kind: item.kind, uri: item.uri, name: '', likes: 0, via: 'saved' }); } return candidates;}
async function ownCandidates(client: Client, did: string): Promise<Candidate[]> { const [feeds, lists] = await Promise.all([ client.appview<{ feeds: Json[] }>('app.bsky.feed.getActorFeeds', { actor: did, limit: 100 }), client.appview<{ lists: Json[] }>('app.bsky.graph.getLists', { actor: did, limit: 100 }), ]); return [...feeds.feeds.map((view) => fromView(view, 'feed', 'yours')), ...lists.lists.map((view) => fromView(view, 'list', 'yours'))];}
async function searchCandidates(client: Client, query: string): Promise<Candidate[]> { const { feeds } = await client.appview<{ feeds: Json[] }>('app.bsky.unspecced.getPopularFeedGenerators', { query, limit: 50 }); return feeds.map((view) => fromView(view, 'feed', 'search'));}
function scored(query: string, candidates: Candidate[], kinds: readonly FeedKind[]): Array<Candidate & { score: number }> { return candidates .filter((candidate) => kinds.includes(candidate.kind)) .map((candidate) => ({ ...candidate, score: matchScore(query, candidate.name, candidate.kind === 'timeline' ? 'following' : rkeyOf(candidate.uri)) })) .filter((candidate) => candidate.score >= ENOUGH) .sort((a, b) => b.score - a.score || b.likes - a.likes);}
/** `best`, unless another of your own fits exactly as well: then which is meant is yours to say. */function unambiguous(text: string, best: Candidate & { score: number }, mine: Array<Candidate & { score: number }>): Candidate { const rivals = mine.filter((candidate) => candidate.score === best.score && candidate.uri !== best.uri); if (rivals.length) { throw new UsageError(`'${text}' fits ${rivals.length + 1} of your feeds equally well:\n${[best, ...rivals].map((each) => ` ${describe(each)}`).join('\n')}\nGive more of the name, or its at:// URI.`); } return best;}
/** How to name a feed exactly, and briefly: <handle>/<rkey>. */function short(candidate: Candidate): string { return candidate.creator ? `${candidate.creator}/${rkeyOf(candidate.uri)}` : candidate.uri;}
function describe(candidate: Candidate): string { return `${candidate.name || candidate.uri}${candidate.creator ? ` by @${candidate.creator}` : ''} (${candidate.uri})`;}
// --------------------------------------------------------------- resolving ---
/** Whether `ref` is an address (at://, a link, handle/rkey) rather than a name. */function isAddress(ref: string): boolean { return /^at:\/\//.test(ref) || /^https?:\/\//.test(ref) || /^@?(did:[^/\s]+|[a-z0-9-]+(\.[a-z0-9-]+)+)\/[A-Za-z0-9._:~-]+$/i.test(ref);}
/** * The feed or list `ref` names. `kinds` says what may be meant: a command * that reads a feed takes any, one that changes a list only lists. With * `ownOnly`, only lists and feeds you made (or saved) are looked among. */export async function resolveFeed(client: Client, ref: string, { kinds = ['feed', 'list', 'timeline'] as readonly FeedKind[], ownOnly = false, quiet = false }: { kinds?: readonly FeedKind[]; ownOnly?: boolean; quiet?: boolean } = {}): Promise<FeedTarget> { const text = ref.trim(); if (!text) throw new UsageError('give a feed: its name, an at:// URI or a bsky.app link'); const wants = kinds.includes('feed') && kinds.includes('list') ? 'feed or list' : kinds.includes('list') ? 'list' : 'feed';
if (isAddress(text)) { const list = /app\.bsky\.graph\.list|\/lists\//.test(text); if (list && !kinds.includes('list')) throw new UsageError(`${text} is a list, not a ${wants}`); if (!list && !kinds.includes('feed')) throw new UsageError(`${text} is a feed, not a ${wants}`); const kind = list ? 'list' : 'feed'; const uri = list ? await listUri(text) : await feedUri(text); // What it is called, for saying what was read; not knowing is no reason to fail. const view = (await hydrate(client, [{ kind, uri }]).catch(() => new Map<string, Json>())).get(uri); return { kind, uri, name: view ? fromView(view, kind, 'address').name || undefined : undefined, creator: view?.['creator']?.handle, via: 'address' }; }
// "following" (or "timeline") is your home timeline, saved among your feeds or not. if (kinds.includes('timeline') && /^(following|timeline|home)$/.test(normalise(text))) return { kind: 'timeline', uri: 'following', name: 'Following', via: 'saved' };
const session = await client.maybeSession(); const pick = (best: Candidate): FeedTarget => ({ kind: best.kind, uri: best.uri, name: best.name || undefined, creator: best.creator, via: best.via });
// Your own circles first: what you saved, then what you made. let mine: Array<Candidate & { score: number }> = []; if (session) { // A failure to look is said, not taken for having nothing: that would // send the name off to a search, and answer 'not found' for a feed // that is right there. const [saved, own] = await Promise.all([savedCandidates(client), ownCandidates(client, session.did)]); const seen = new Set<string>(); mine = scored(text, [...saved, ...own].filter((candidate) => !seen.has(candidate.uri) && seen.add(candidate.uri)), kinds); const best = mine[0]; if (best && best.score >= STRONG) return pick(unambiguous(text, best, mine)); }
// Then anyone's feeds, by search; a good match of your own still wins a tie. const found = ownOnly || !kinds.includes('feed') ? [] : scored(text, await searchCandidates(client, text), kinds); const best = [...mine, ...found].sort((a, b) => b.score - a.score || (a.via === 'search' ? 1 : 0) - (b.via === 'search' ? 1 : 0) || b.likes - a.likes)[0]; if (!best) { const where = session ? (ownOnly ? 'among your lists and feeds' : 'among your feeds, the ones you made, or by searching') : 'by searching (sign in to look among your own feeds too)'; throw new NotFoundError(`no ${wants} called '${text}' found ${where}`, 'FeedNotFound'); } if (best.via !== 'search') return pick(unambiguous(text, best, mine)); if (!quiet) { const others = found.filter((candidate) => candidate.uri !== best.uri && candidate.score >= best.score - 10).slice(0, 3); info(`using ${best.name}${best.creator ? ` by @${best.creator}` : ''}, found by searching${others.length ? `; others called that: ${others.map(short).join(', ')} (name one like that to read it)` : ''}`); } return pick(best);}