first commit
This commit is contained in:
408
packages/plugins/atproto/src/atproto.ts
Normal file
408
packages/plugins/atproto/src/atproto.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* AT Protocol client helpers
|
||||
*
|
||||
* Handles session management, record CRUD, and handle resolution.
|
||||
* All HTTP goes through ctx.http.fetch() for sandbox compatibility.
|
||||
*/
|
||||
|
||||
import type { PluginContext } from "emdash";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────
|
||||
|
||||
export interface AtSession {
|
||||
accessJwt: string;
|
||||
refreshJwt: string;
|
||||
did: string;
|
||||
handle: string;
|
||||
}
|
||||
|
||||
export interface AtRecord {
|
||||
uri: string;
|
||||
cid: string;
|
||||
}
|
||||
|
||||
export interface BlobRef {
|
||||
$type: "blob";
|
||||
ref: { $link: string };
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/** Get the HTTP client from plugin context, or throw a helpful error. */
|
||||
export function requireHttp(ctx: PluginContext) {
|
||||
if (!ctx.http) {
|
||||
throw new Error("AT Protocol plugin requires the network:fetch capability");
|
||||
}
|
||||
return ctx.http;
|
||||
}
|
||||
|
||||
/** Validate that a PDS response contains expected string fields. */
|
||||
function requireString(data: Record<string, unknown>, field: string, context: string): string {
|
||||
const value = data[field];
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`${context}: missing or invalid '${field}' in response`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Session management ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new session with the PDS using an app password.
|
||||
*/
|
||||
export async function createSession(
|
||||
ctx: PluginContext,
|
||||
pdsHost: string,
|
||||
identifier: string,
|
||||
password: string,
|
||||
): Promise<AtSession> {
|
||||
const http = requireHttp(ctx);
|
||||
const res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.server.createSession`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`createSession failed (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
return {
|
||||
accessJwt: requireString(data, "accessJwt", "createSession"),
|
||||
refreshJwt: requireString(data, "refreshJwt", "createSession"),
|
||||
did: requireString(data, "did", "createSession"),
|
||||
handle: requireString(data, "handle", "createSession"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an existing session using the refresh token.
|
||||
*/
|
||||
export async function refreshSession(
|
||||
ctx: PluginContext,
|
||||
pdsHost: string,
|
||||
refreshJwt: string,
|
||||
): Promise<AtSession> {
|
||||
const http = requireHttp(ctx);
|
||||
const res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.server.refreshSession`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${refreshJwt}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`refreshSession failed (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
return {
|
||||
accessJwt: requireString(data, "accessJwt", "refreshSession"),
|
||||
refreshJwt: requireString(data, "refreshJwt", "refreshSession"),
|
||||
did: requireString(data, "did", "refreshSession"),
|
||||
handle: requireString(data, "handle", "refreshSession"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-flight refresh promise for deduplication.
|
||||
* Prevents concurrent publishes from racing on token refresh,
|
||||
* which would corrupt tokens since PDS invalidates refresh tokens after use.
|
||||
*/
|
||||
let refreshInFlight: Promise<AtSession> | null = null;
|
||||
|
||||
/**
|
||||
* Get a valid access token, refreshing if needed.
|
||||
* Uses promise deduplication to prevent concurrent refresh races.
|
||||
*/
|
||||
export async function ensureSession(ctx: PluginContext): Promise<{
|
||||
accessJwt: string;
|
||||
did: string;
|
||||
pdsHost: string;
|
||||
}> {
|
||||
const pdsHost = (await ctx.kv.get<string>("settings:pdsHost")) || "bsky.social";
|
||||
const handle = await ctx.kv.get<string>("settings:handle");
|
||||
const appPassword = await ctx.kv.get<string>("settings:appPassword");
|
||||
|
||||
if (!handle || !appPassword) {
|
||||
throw new Error("AT Protocol credentials not configured");
|
||||
}
|
||||
|
||||
// Try existing tokens first
|
||||
const existingAccess = await ctx.kv.get<string>("state:accessJwt");
|
||||
const existingRefresh = await ctx.kv.get<string>("state:refreshJwt");
|
||||
const existingDid = await ctx.kv.get<string>("state:did");
|
||||
|
||||
if (existingAccess && existingDid) {
|
||||
return { accessJwt: existingAccess, did: existingDid, pdsHost };
|
||||
}
|
||||
|
||||
// Try refresh if we have a refresh token (deduplicated)
|
||||
if (existingRefresh) {
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = refreshSession(ctx, pdsHost, existingRefresh)
|
||||
.then(async (session) => {
|
||||
await persistSession(ctx, session);
|
||||
return session;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
const session = await refreshInFlight;
|
||||
return { accessJwt: session.accessJwt, did: session.did, pdsHost };
|
||||
} catch {
|
||||
// Refresh failed, fall through to full login
|
||||
}
|
||||
}
|
||||
|
||||
// Full login
|
||||
const session = await createSession(ctx, pdsHost, handle, appPassword);
|
||||
await persistSession(ctx, session);
|
||||
return { accessJwt: session.accessJwt, did: session.did, pdsHost };
|
||||
}
|
||||
|
||||
async function persistSession(ctx: PluginContext, session: AtSession): Promise<void> {
|
||||
await ctx.kv.set("state:accessJwt", session.accessJwt);
|
||||
await ctx.kv.set("state:refreshJwt", session.refreshJwt);
|
||||
await ctx.kv.set("state:did", session.did);
|
||||
}
|
||||
|
||||
// ── Record CRUD ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a record on the PDS. Returns the AT-URI and CID.
|
||||
* Retries once on 401 (expired token) by refreshing the session.
|
||||
*/
|
||||
export async function createRecord(
|
||||
ctx: PluginContext,
|
||||
pdsHost: string,
|
||||
accessJwt: string,
|
||||
did: string,
|
||||
collection: string,
|
||||
record: unknown,
|
||||
): Promise<AtRecord> {
|
||||
const http = requireHttp(ctx);
|
||||
let res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.createRecord`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessJwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ repo: did, collection, record }),
|
||||
});
|
||||
|
||||
// Retry once on 401 with refreshed token
|
||||
if (res.status === 401) {
|
||||
const refreshed = await ensureSessionFresh(ctx, pdsHost);
|
||||
if (refreshed) {
|
||||
res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.createRecord`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${refreshed.accessJwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ repo: refreshed.did, collection, record }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`createRecord failed (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
return {
|
||||
uri: requireString(data, "uri", "createRecord"),
|
||||
cid: requireString(data, "cid", "createRecord"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update (upsert) a record on the PDS.
|
||||
* Retries once on 401 (expired token).
|
||||
*/
|
||||
export async function putRecord(
|
||||
ctx: PluginContext,
|
||||
pdsHost: string,
|
||||
accessJwt: string,
|
||||
did: string,
|
||||
collection: string,
|
||||
rkey: string,
|
||||
record: unknown,
|
||||
): Promise<AtRecord> {
|
||||
const http = requireHttp(ctx);
|
||||
let res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.putRecord`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessJwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ repo: did, collection, rkey, record }),
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
const refreshed = await ensureSessionFresh(ctx, pdsHost);
|
||||
if (refreshed) {
|
||||
res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.putRecord`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${refreshed.accessJwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ repo: refreshed.did, collection, rkey, record }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`putRecord failed (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
return {
|
||||
uri: requireString(data, "uri", "putRecord"),
|
||||
cid: requireString(data, "cid", "putRecord"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a record from the PDS.
|
||||
* Retries once on 401 (expired token).
|
||||
*/
|
||||
export async function deleteRecord(
|
||||
ctx: PluginContext,
|
||||
pdsHost: string,
|
||||
accessJwt: string,
|
||||
did: string,
|
||||
collection: string,
|
||||
rkey: string,
|
||||
): Promise<void> {
|
||||
const http = requireHttp(ctx);
|
||||
let res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.deleteRecord`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessJwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ repo: did, collection, rkey }),
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
const refreshed = await ensureSessionFresh(ctx, pdsHost);
|
||||
if (refreshed) {
|
||||
res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.deleteRecord`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${refreshed.accessJwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ repo: refreshed.did, collection, rkey }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`deleteRecord failed (${res.status}): ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a session refresh (for 401 retry). Clears the stale access token
|
||||
* and delegates to ensureSession, which handles refresh deduplication.
|
||||
* Returns null if refresh fails.
|
||||
*/
|
||||
async function ensureSessionFresh(
|
||||
ctx: PluginContext,
|
||||
_pdsHost: string,
|
||||
): Promise<{ accessJwt: string; did: string } | null> {
|
||||
// Clear stale access token so ensureSession will attempt a refresh
|
||||
await ctx.kv.set("state:accessJwt", "");
|
||||
|
||||
try {
|
||||
const result = await ensureSession(ctx);
|
||||
return { accessJwt: result.accessJwt, did: result.did };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handle resolution ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve an AT Protocol handle to a DID.
|
||||
* Uses the public API -- no auth required.
|
||||
*/
|
||||
export async function resolveHandle(ctx: PluginContext, handle: string): Promise<string> {
|
||||
const http = requireHttp(ctx);
|
||||
const res = await http.fetch(
|
||||
`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`resolveHandle failed for ${handle} (${res.status})`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
return requireString(data, "did", "resolveHandle");
|
||||
}
|
||||
|
||||
// ── Blob upload ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upload a blob (image) to the PDS. Returns a blob reference for embedding.
|
||||
*/
|
||||
export async function uploadBlob(
|
||||
ctx: PluginContext,
|
||||
pdsHost: string,
|
||||
accessJwt: string,
|
||||
imageBytes: ArrayBuffer,
|
||||
mimeType: string,
|
||||
): Promise<BlobRef> {
|
||||
const http = requireHttp(ctx);
|
||||
const res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.uploadBlob`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessJwt}`,
|
||||
"Content-Type": mimeType,
|
||||
},
|
||||
body: imageBytes,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`uploadBlob failed (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
if (!data.blob || typeof data.blob !== "object") {
|
||||
throw new Error("uploadBlob: missing 'blob' in response");
|
||||
}
|
||||
const blob = data.blob as Record<string, unknown>;
|
||||
if (!blob.ref || typeof blob.ref !== "object") {
|
||||
throw new Error("uploadBlob: malformed blob reference in response");
|
||||
}
|
||||
return data.blob as BlobRef;
|
||||
}
|
||||
|
||||
// ── Utilities ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the rkey from an AT-URI.
|
||||
* at://did:plc:xxx/collection/rkey -> rkey
|
||||
*/
|
||||
export function rkeyFromUri(uri: string): string {
|
||||
const parts = uri.split("/");
|
||||
const rkey = parts.at(-1);
|
||||
if (!rkey) {
|
||||
throw new Error(`Invalid AT-URI: ${uri}`);
|
||||
}
|
||||
return rkey;
|
||||
}
|
||||
185
packages/plugins/atproto/src/bluesky.ts
Normal file
185
packages/plugins/atproto/src/bluesky.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Bluesky cross-posting helpers
|
||||
*
|
||||
* Builds app.bsky.feed.post records with link cards and rich text facets.
|
||||
*/
|
||||
|
||||
import type { BlobRef } from "./atproto.js";
|
||||
|
||||
// ── Pre-compiled regexes ────────────────────────────────────────
|
||||
|
||||
const TEMPLATE_TITLE_RE = /\{title\}/g;
|
||||
const TEMPLATE_URL_RE = /\{url\}/g;
|
||||
const TEMPLATE_EXCERPT_RE = /\{excerpt\}/g;
|
||||
const TRAILING_PUNCTUATION_RE = /[.,;:!?'"]+$/;
|
||||
// Global regexes for facet detection -- reset lastIndex before each use
|
||||
const URL_REGEX = /https?:\/\/[^\s)>\]]+/g;
|
||||
const HASHTAG_REGEX = /(?<=\s|^)#([a-zA-Z0-9_]+)/g;
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────
|
||||
|
||||
export interface BskyPost {
|
||||
$type: "app.bsky.feed.post";
|
||||
text: string;
|
||||
createdAt: string;
|
||||
langs?: string[];
|
||||
facets?: BskyFacet[];
|
||||
embed?: BskyEmbed;
|
||||
}
|
||||
|
||||
export interface BskyFacet {
|
||||
index: { byteStart: number; byteEnd: number };
|
||||
features: Array<
|
||||
| { $type: "app.bsky.richtext.facet#link"; uri: string }
|
||||
| { $type: "app.bsky.richtext.facet#tag"; tag: string }
|
||||
>;
|
||||
}
|
||||
|
||||
export type BskyEmbed = {
|
||||
$type: "app.bsky.embed.external";
|
||||
external: {
|
||||
uri: string;
|
||||
title: string;
|
||||
description: string;
|
||||
thumb?: BlobRef;
|
||||
};
|
||||
};
|
||||
|
||||
// ── Post builder ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a Bluesky post record for cross-posting published content.
|
||||
*/
|
||||
export function buildBskyPost(opts: {
|
||||
template: string;
|
||||
content: Record<string, unknown>;
|
||||
siteUrl: string;
|
||||
thumbBlob?: BlobRef;
|
||||
langs?: string[];
|
||||
}): BskyPost {
|
||||
const { template, content, siteUrl, thumbBlob, langs } = opts;
|
||||
|
||||
const title = (content.title as string) || "Untitled";
|
||||
const slug = content.slug as string;
|
||||
const excerpt = (content.excerpt || content.description || "") as string;
|
||||
const url = slug ? `${stripTrailingSlash(siteUrl)}/${slug}` : siteUrl;
|
||||
|
||||
// Apply template -- substitute before truncation so we can detect
|
||||
// if the URL survives intact after truncation
|
||||
const fullText = template
|
||||
.replace(TEMPLATE_TITLE_RE, title)
|
||||
.replace(TEMPLATE_URL_RE, url)
|
||||
.replace(TEMPLATE_EXCERPT_RE, excerpt);
|
||||
|
||||
// Truncate to 300 graphemes (Bluesky limit)
|
||||
const text = truncateGraphemes(fullText, 300);
|
||||
const wasTruncated = text !== fullText;
|
||||
|
||||
const post: BskyPost = {
|
||||
$type: "app.bsky.feed.post",
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (langs && langs.length > 0) {
|
||||
post.langs = langs.slice(0, 3); // Max 3 per spec
|
||||
}
|
||||
|
||||
// Auto-detect URLs in text and build facets.
|
||||
// If text was truncated, skip facets -- truncation may have cut
|
||||
// a URL mid-string, producing a broken link facet.
|
||||
if (!wasTruncated) {
|
||||
const facets = buildFacets(text);
|
||||
if (facets.length > 0) {
|
||||
post.facets = facets;
|
||||
}
|
||||
}
|
||||
|
||||
// Link card embed
|
||||
post.embed = {
|
||||
$type: "app.bsky.embed.external",
|
||||
external: {
|
||||
uri: url,
|
||||
title,
|
||||
description: truncateGraphemes(excerpt, 300),
|
||||
...(thumbBlob ? { thumb: thumbBlob } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
// ── Rich text facets ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build rich text facets for URLs and hashtags in text.
|
||||
*
|
||||
* CRITICAL: Facet byte offsets use UTF-8 bytes, not JavaScript string indices.
|
||||
*/
|
||||
export function buildFacets(text: string): BskyFacet[] {
|
||||
const encoder = new TextEncoder();
|
||||
const facets: BskyFacet[] = [];
|
||||
|
||||
// Detect URLs
|
||||
let match: RegExpExecArray | null;
|
||||
URL_REGEX.lastIndex = 0;
|
||||
while ((match = URL_REGEX.exec(text)) !== null) {
|
||||
// Strip trailing punctuation that was captured by the greedy regex
|
||||
const cleanUrl = match[0].replace(TRAILING_PUNCTUATION_RE, "");
|
||||
const beforeBytes = encoder.encode(text.slice(0, match.index));
|
||||
const matchBytes = encoder.encode(cleanUrl);
|
||||
facets.push({
|
||||
index: {
|
||||
byteStart: beforeBytes.length,
|
||||
byteEnd: beforeBytes.length + matchBytes.length,
|
||||
},
|
||||
features: [{ $type: "app.bsky.richtext.facet#link", uri: cleanUrl }],
|
||||
});
|
||||
}
|
||||
|
||||
// Detect hashtags
|
||||
HASHTAG_REGEX.lastIndex = 0;
|
||||
while ((match = HASHTAG_REGEX.exec(text)) !== null) {
|
||||
const tag = match[1];
|
||||
if (!tag) continue;
|
||||
|
||||
// Include the # in the byte range
|
||||
const beforeBytes = encoder.encode(text.slice(0, match.index));
|
||||
const matchBytes = encoder.encode(match[0]);
|
||||
facets.push({
|
||||
index: {
|
||||
byteStart: beforeBytes.length,
|
||||
byteEnd: beforeBytes.length + matchBytes.length,
|
||||
},
|
||||
features: [{ $type: "app.bsky.richtext.facet#tag", tag }],
|
||||
});
|
||||
}
|
||||
|
||||
return facets;
|
||||
}
|
||||
|
||||
// ── Utilities ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Truncate a string to a maximum number of graphemes.
|
||||
* Uses Intl.Segmenter for correct Unicode handling.
|
||||
*/
|
||||
function truncateGraphemes(text: string, maxGraphemes: number): string {
|
||||
// Intl.Segmenter handles multi-codepoint graphemes (emoji, combining chars)
|
||||
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
|
||||
const segments = [...segmenter.segment(text)];
|
||||
|
||||
if (segments.length <= maxGraphemes) return text;
|
||||
|
||||
// Truncate and add ellipsis
|
||||
return (
|
||||
segments
|
||||
.slice(0, maxGraphemes - 1)
|
||||
.map((s) => s.segment)
|
||||
.join("") + "\u2026"
|
||||
);
|
||||
}
|
||||
|
||||
function stripTrailingSlash(url: string): string {
|
||||
return url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
}
|
||||
42
packages/plugins/atproto/src/index.ts
Normal file
42
packages/plugins/atproto/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* AT Protocol / standard.site Plugin for EmDash CMS
|
||||
*
|
||||
* Syndicates published content to the AT Protocol network using the
|
||||
* standard.site lexicons, with optional cross-posting to Bluesky.
|
||||
*
|
||||
* Features:
|
||||
* - Creates site.standard.publication record (one per site)
|
||||
* - Creates site.standard.document records on publish
|
||||
* - Optional Bluesky cross-post with link card
|
||||
* - Automatic <link rel="site.standard.document"> injection via page:metadata
|
||||
* - Sync status tracking in plugin storage
|
||||
*
|
||||
* Designed for sandboxed execution:
|
||||
* - All HTTP via ctx.http.fetch()
|
||||
* - Block Kit admin UI (no React components)
|
||||
* - Capabilities: read:content, network:fetch:any
|
||||
*/
|
||||
|
||||
import type { PluginDescriptor } from "emdash";
|
||||
|
||||
// ── Descriptor ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create the AT Protocol plugin descriptor.
|
||||
* Import this in your astro.config.mjs / live.config.ts.
|
||||
*/
|
||||
export function atprotoPlugin(): PluginDescriptor {
|
||||
return {
|
||||
id: "atproto",
|
||||
version: "0.1.0",
|
||||
format: "standard",
|
||||
entrypoint: "@emdashcms/plugin-atproto/sandbox",
|
||||
capabilities: ["read:content", "network:fetch:any"],
|
||||
storage: {
|
||||
publications: { indexes: ["contentId", "platform", "publishedAt"] },
|
||||
},
|
||||
// Block Kit admin pages (no adminEntry needed -- sandboxed)
|
||||
adminPages: [{ path: "/status", label: "AT Protocol", icon: "globe" }],
|
||||
adminWidgets: [{ id: "sync-status", title: "AT Protocol", size: "third" }],
|
||||
};
|
||||
}
|
||||
671
packages/plugins/atproto/src/sandbox-entry.ts
Normal file
671
packages/plugins/atproto/src/sandbox-entry.ts
Normal file
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* Sandbox Entry Point -- AT Protocol
|
||||
*
|
||||
* Canonical plugin implementation using the standard format.
|
||||
* The bundler (tsdown) inlines all local imports from atproto.ts,
|
||||
* bluesky.ts, and standard-site.ts into a single self-contained file.
|
||||
*/
|
||||
|
||||
import { definePlugin } from "emdash";
|
||||
import type { PluginContext } from "emdash";
|
||||
|
||||
import {
|
||||
ensureSession,
|
||||
createRecord,
|
||||
putRecord,
|
||||
deleteRecord,
|
||||
rkeyFromUri,
|
||||
uploadBlob,
|
||||
requireHttp,
|
||||
} from "./atproto.js";
|
||||
import { buildBskyPost } from "./bluesky.js";
|
||||
import { buildPublication, buildDocument } from "./standard-site.js";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────
|
||||
|
||||
interface SyndicationRecord {
|
||||
collection: string;
|
||||
contentId: string;
|
||||
atUri: string;
|
||||
atCid: string;
|
||||
bskyPostUri?: string;
|
||||
bskyPostCid?: string;
|
||||
publishedAt: string;
|
||||
lastSyncedAt: string;
|
||||
status: "synced" | "error" | "pending";
|
||||
errorMessage?: string;
|
||||
retryCount?: number;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
async function isCollectionAllowed(ctx: PluginContext, collection: string): Promise<boolean> {
|
||||
const setting = await ctx.kv.get<string>("settings:collections");
|
||||
if (!setting || setting.trim() === "") return true;
|
||||
const allowed = setting.split(",").map((s) => s.trim().toLowerCase());
|
||||
return allowed.includes(collection.toLowerCase());
|
||||
}
|
||||
|
||||
async function syndicateContent(
|
||||
ctx: PluginContext,
|
||||
collection: string,
|
||||
contentId: string,
|
||||
content: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const storageKey = `${collection}:${contentId}`;
|
||||
const existing = (await ctx.storage.records!.get(storageKey)) as SyndicationRecord | null;
|
||||
|
||||
if (existing && existing.status === "synced") {
|
||||
const syncOnUpdate = (await ctx.kv.get<boolean>("settings:syncOnUpdate")) ?? true;
|
||||
if (!syncOnUpdate) return;
|
||||
}
|
||||
|
||||
const siteUrl = await ctx.kv.get<string>("settings:siteUrl");
|
||||
if (!siteUrl) throw new Error("Site URL not configured");
|
||||
|
||||
const publicationUri = await ctx.kv.get<string>("state:publicationUri");
|
||||
if (!publicationUri)
|
||||
throw new Error("Publication record not created yet. Use Sync Publication first.");
|
||||
|
||||
const { accessJwt, did, pdsHost } = await ensureSession(ctx);
|
||||
|
||||
// Upload cover image if present
|
||||
let coverImageBlob;
|
||||
const rawCoverImage = content.cover_image as string | undefined;
|
||||
if (rawCoverImage) {
|
||||
let imageUrl = rawCoverImage;
|
||||
if (imageUrl.startsWith("/")) imageUrl = `${siteUrl}${imageUrl}`;
|
||||
|
||||
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
|
||||
try {
|
||||
const http = requireHttp(ctx);
|
||||
const imageRes = await http.fetch(imageUrl);
|
||||
if (imageRes.ok) {
|
||||
const bytes = await imageRes.arrayBuffer();
|
||||
if (bytes.byteLength <= 1_000_000) {
|
||||
const mimeType = imageRes.headers.get("content-type") || "image/jpeg";
|
||||
coverImageBlob = await uploadBlob(ctx, pdsHost, accessJwt, bytes, mimeType);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.log.warn("Failed to upload cover image, skipping", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bskyPostRef: { uri: string; cid: string } | undefined;
|
||||
|
||||
if (existing && existing.atUri) {
|
||||
const rkey = rkeyFromUri(existing.atUri);
|
||||
const doc = buildDocument({
|
||||
publicationUri,
|
||||
content,
|
||||
coverImageBlob,
|
||||
bskyPostRef:
|
||||
existing.bskyPostUri && existing.bskyPostCid
|
||||
? { uri: existing.bskyPostUri, cid: existing.bskyPostCid }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const result = await putRecord(
|
||||
ctx,
|
||||
pdsHost,
|
||||
accessJwt,
|
||||
did,
|
||||
"site.standard.document",
|
||||
rkey,
|
||||
doc,
|
||||
);
|
||||
|
||||
await ctx.storage.records!.put(storageKey, {
|
||||
collection: existing.collection,
|
||||
contentId: existing.contentId,
|
||||
atUri: result.uri,
|
||||
atCid: result.cid,
|
||||
bskyPostUri: existing.bskyPostUri,
|
||||
bskyPostCid: existing.bskyPostCid,
|
||||
publishedAt: existing.publishedAt,
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
status: "synced",
|
||||
retryCount: 0,
|
||||
} satisfies SyndicationRecord);
|
||||
|
||||
ctx.log.info(`Updated AT Protocol document for ${collection}/${contentId}`);
|
||||
} else {
|
||||
const doc = buildDocument({ publicationUri, content, coverImageBlob });
|
||||
const result = await createRecord(ctx, pdsHost, accessJwt, did, "site.standard.document", doc);
|
||||
|
||||
const enableCrosspost = (await ctx.kv.get<boolean>("settings:enableBskyCrosspost")) ?? true;
|
||||
if (enableCrosspost) {
|
||||
try {
|
||||
const template =
|
||||
(await ctx.kv.get<string>("settings:crosspostTemplate")) || "{title}\n\n{url}";
|
||||
const langsStr = (await ctx.kv.get<string>("settings:langs")) || "en";
|
||||
const langs = langsStr
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
const post = buildBskyPost({
|
||||
template,
|
||||
content,
|
||||
siteUrl,
|
||||
thumbBlob: coverImageBlob,
|
||||
langs,
|
||||
});
|
||||
|
||||
const postResult = await createRecord(
|
||||
ctx,
|
||||
pdsHost,
|
||||
accessJwt,
|
||||
did,
|
||||
"app.bsky.feed.post",
|
||||
post,
|
||||
);
|
||||
bskyPostRef = { uri: postResult.uri, cid: postResult.cid };
|
||||
|
||||
const rkey = rkeyFromUri(result.uri);
|
||||
const updatedDoc = buildDocument({ publicationUri, content, coverImageBlob, bskyPostRef });
|
||||
await putRecord(ctx, pdsHost, accessJwt, did, "site.standard.document", rkey, updatedDoc);
|
||||
|
||||
ctx.log.info(`Cross-posted ${collection}/${contentId} to Bluesky`);
|
||||
} catch (error) {
|
||||
ctx.log.warn("Failed to cross-post to Bluesky, document still synced", error);
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.storage.records!.put(storageKey, {
|
||||
collection,
|
||||
contentId,
|
||||
atUri: result.uri,
|
||||
atCid: result.cid,
|
||||
bskyPostUri: bskyPostRef?.uri,
|
||||
bskyPostCid: bskyPostRef?.cid,
|
||||
publishedAt: (content.published_at as string) || new Date().toISOString(),
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
status: "synced",
|
||||
} satisfies SyndicationRecord);
|
||||
|
||||
ctx.log.info(`Created AT Protocol document for ${collection}/${contentId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plugin definition ───────────────────────────────────────────
|
||||
|
||||
export default definePlugin({
|
||||
hooks: {
|
||||
"plugin:install": async (_event: unknown, ctx: PluginContext) => {
|
||||
ctx.log.info("AT Protocol plugin installed");
|
||||
},
|
||||
|
||||
"content:afterSave": {
|
||||
handler: async (
|
||||
event: { content: Record<string, unknown>; collection: string; isNew: boolean },
|
||||
ctx: PluginContext,
|
||||
) => {
|
||||
const { content, collection } = event;
|
||||
const contentId = typeof content.id === "string" ? content.id : String(content.id);
|
||||
const status = content.status as string | undefined;
|
||||
|
||||
if (status !== "published") return;
|
||||
if (!(await isCollectionAllowed(ctx, collection))) return;
|
||||
|
||||
try {
|
||||
await syndicateContent(ctx, collection, contentId, content);
|
||||
} catch (error) {
|
||||
ctx.log.error(`Failed to syndicate ${collection}/${contentId}`, error);
|
||||
|
||||
const storageKey = `${collection}:${contentId}`;
|
||||
const existing = await ctx.storage.records!.get(storageKey);
|
||||
const record = (existing as SyndicationRecord | null) || {
|
||||
collection,
|
||||
contentId,
|
||||
atUri: "",
|
||||
atCid: "",
|
||||
publishedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await ctx.storage.records!.put(storageKey, {
|
||||
...record,
|
||||
status: "error",
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
retryCount: ((record as SyndicationRecord).retryCount || 0) + 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
"content:afterDelete": {
|
||||
handler: async (event: { id: string; collection: string }, ctx: PluginContext) => {
|
||||
const { id, collection } = event;
|
||||
const deleteOnUnpublish = (await ctx.kv.get<boolean>("settings:deleteOnUnpublish")) ?? true;
|
||||
if (!deleteOnUnpublish) return;
|
||||
|
||||
const storageKey = `${collection}:${id}`;
|
||||
const existing = (await ctx.storage.records!.get(storageKey)) as SyndicationRecord | null;
|
||||
if (!existing || !existing.atUri) return;
|
||||
|
||||
try {
|
||||
const { accessJwt, did, pdsHost } = await ensureSession(ctx);
|
||||
const rkey = rkeyFromUri(existing.atUri);
|
||||
await deleteRecord(ctx, pdsHost, accessJwt, did, "site.standard.document", rkey);
|
||||
|
||||
if (existing.bskyPostUri) {
|
||||
const postRkey = rkeyFromUri(existing.bskyPostUri);
|
||||
await deleteRecord(ctx, pdsHost, accessJwt, did, "app.bsky.feed.post", postRkey);
|
||||
}
|
||||
|
||||
await ctx.storage.records!.delete(storageKey);
|
||||
ctx.log.info(`Deleted AT Protocol records for ${collection}/${id}`);
|
||||
} catch (error) {
|
||||
ctx.log.error(`Failed to delete AT Protocol records for ${collection}/${id}`, error);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
"page:metadata": async (
|
||||
event: { page: { content?: { collection: string; id: string } } },
|
||||
ctx: PluginContext,
|
||||
) => {
|
||||
const pageContent = event.page.content;
|
||||
if (!pageContent) return null;
|
||||
|
||||
const storageKey = `${pageContent.collection}:${pageContent.id}`;
|
||||
const record = (await ctx.storage.records!.get(storageKey)) as SyndicationRecord | null;
|
||||
|
||||
if (!record || !record.atUri || record.status !== "synced") return null;
|
||||
|
||||
return {
|
||||
kind: "link" as const,
|
||||
rel: "site.standard.document",
|
||||
href: record.atUri,
|
||||
key: "atproto-document",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
routes: {
|
||||
status: {
|
||||
handler: async (_routeCtx: unknown, ctx: PluginContext) => {
|
||||
try {
|
||||
const handle = await ctx.kv.get<string>("settings:handle");
|
||||
const did = await ctx.kv.get<string>("state:did");
|
||||
const pubUri = await ctx.kv.get<string>("state:publicationUri");
|
||||
const synced = await ctx.storage.records!.count({
|
||||
status: "synced",
|
||||
});
|
||||
const errors = await ctx.storage.records!.count({
|
||||
status: "error",
|
||||
});
|
||||
const pending = await ctx.storage.records!.count({
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
return {
|
||||
configured: !!handle,
|
||||
connected: !!did,
|
||||
handle: handle || null,
|
||||
did: did || null,
|
||||
publicationUri: pubUri || null,
|
||||
stats: { synced, errors, pending },
|
||||
};
|
||||
} catch (error) {
|
||||
ctx.log.error("Failed to get status", error);
|
||||
return {
|
||||
configured: false,
|
||||
connected: false,
|
||||
handle: null,
|
||||
did: null,
|
||||
publicationUri: null,
|
||||
stats: { synced: 0, errors: 0, pending: 0 },
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
"test-connection": {
|
||||
handler: async (_routeCtx: unknown, ctx: PluginContext) => {
|
||||
try {
|
||||
const session = await ensureSession(ctx);
|
||||
return {
|
||||
success: true,
|
||||
did: session.did,
|
||||
pdsHost: session.pdsHost,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
"sync-publication": {
|
||||
handler: async (_routeCtx: unknown, ctx: PluginContext) => {
|
||||
try {
|
||||
const siteUrl = await ctx.kv.get<string>("settings:siteUrl");
|
||||
const siteName = await ctx.kv.get<string>("settings:siteName");
|
||||
if (!siteUrl || !siteName)
|
||||
return {
|
||||
success: false,
|
||||
error: "Site URL and name are required",
|
||||
};
|
||||
|
||||
const { accessJwt, did, pdsHost } = await ensureSession(ctx);
|
||||
const publication = buildPublication(siteUrl, siteName);
|
||||
const existingUri = await ctx.kv.get<string>("state:publicationUri");
|
||||
|
||||
let result;
|
||||
if (existingUri) {
|
||||
const rkey = rkeyFromUri(existingUri);
|
||||
result = await putRecord(
|
||||
ctx,
|
||||
pdsHost,
|
||||
accessJwt,
|
||||
did,
|
||||
"site.standard.publication",
|
||||
rkey,
|
||||
publication,
|
||||
);
|
||||
} else {
|
||||
result = await createRecord(
|
||||
ctx,
|
||||
pdsHost,
|
||||
accessJwt,
|
||||
did,
|
||||
"site.standard.publication",
|
||||
publication,
|
||||
);
|
||||
}
|
||||
|
||||
await ctx.kv.set("state:publicationUri", result.uri);
|
||||
await ctx.kv.set("state:publicationCid", result.cid);
|
||||
return {
|
||||
success: true,
|
||||
uri: result.uri,
|
||||
cid: result.cid,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
"recent-syncs": {
|
||||
handler: async (_routeCtx: unknown, ctx: PluginContext) => {
|
||||
try {
|
||||
const result = await ctx.storage.records!.query({
|
||||
orderBy: { lastSyncedAt: "desc" },
|
||||
limit: 20,
|
||||
});
|
||||
return {
|
||||
items: result.items.map((item: { id: string; data: unknown }) => ({
|
||||
id: item.id,
|
||||
...(item.data as SyndicationRecord),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
ctx.log.error("Failed to get recent syncs", error);
|
||||
return { items: [] };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
verification: {
|
||||
handler: async (_routeCtx: unknown, ctx: PluginContext) => {
|
||||
const pubUri = await ctx.kv.get<string>("state:publicationUri");
|
||||
const siteUrl = await ctx.kv.get<string>("settings:siteUrl");
|
||||
return {
|
||||
publicationUri: pubUri || null,
|
||||
siteUrl: siteUrl || null,
|
||||
wellKnownPath: "/.well-known/site.standard.publication",
|
||||
wellKnownContent: pubUri || "(not configured yet)",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
admin: {
|
||||
handler: async (routeCtx: any, ctx: PluginContext) => {
|
||||
const interaction = routeCtx.input as {
|
||||
type: string;
|
||||
page?: string;
|
||||
action_id?: string;
|
||||
values?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (interaction.type === "page_load" && interaction.page === "widget:sync-status") {
|
||||
return buildSyncWidget(ctx);
|
||||
}
|
||||
if (interaction.type === "page_load" && interaction.page === "/status") {
|
||||
return buildStatusPage(ctx);
|
||||
}
|
||||
if (interaction.type === "form_submit" && interaction.action_id === "save_settings") {
|
||||
return saveSettings(ctx, interaction.values ?? {});
|
||||
}
|
||||
if (interaction.type === "block_action" && interaction.action_id === "test_connection") {
|
||||
return testConnection(ctx);
|
||||
}
|
||||
return { blocks: [] };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ── Block Kit admin helpers ─────────────────────────────────────
|
||||
|
||||
async function buildSyncWidget(ctx: PluginContext) {
|
||||
try {
|
||||
const handle = await ctx.kv.get<string>("settings:handle");
|
||||
const did = await ctx.kv.get<string>("state:did");
|
||||
const synced = await ctx.storage.records!.count({ status: "synced" });
|
||||
const errors = await ctx.storage.records!.count({ status: "error" });
|
||||
|
||||
if (!handle) {
|
||||
return {
|
||||
blocks: [
|
||||
{ type: "context", text: "Not configured -- set your handle in AT Protocol settings." },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
blocks: [
|
||||
{
|
||||
type: "fields",
|
||||
fields: [
|
||||
{ label: "Handle", value: `@${handle}` },
|
||||
{ label: "Status", value: did ? "Connected" : "Not connected" },
|
||||
{ label: "Synced", value: String(synced) },
|
||||
{ label: "Errors", value: String(errors) },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
ctx.log.error("Failed to build sync widget", error);
|
||||
return { blocks: [{ type: "context", text: "Failed to load status" }] };
|
||||
}
|
||||
}
|
||||
|
||||
async function buildStatusPage(ctx: PluginContext) {
|
||||
try {
|
||||
const handle = await ctx.kv.get<string>("settings:handle");
|
||||
const appPassword = await ctx.kv.get<string>("settings:appPassword");
|
||||
const pdsHost = await ctx.kv.get<string>("settings:pdsHost");
|
||||
const siteUrl = await ctx.kv.get<string>("settings:siteUrl");
|
||||
const enableCrosspost = await ctx.kv.get<boolean>("settings:enableCrosspost");
|
||||
const did = await ctx.kv.get<string>("state:did");
|
||||
const pubUri = await ctx.kv.get<string>("state:publicationUri");
|
||||
|
||||
const blocks: unknown[] = [
|
||||
{ type: "header", text: "AT Protocol" },
|
||||
{
|
||||
type: "section",
|
||||
text: "Syndicate content to the AT Protocol network (Bluesky, standard.site).",
|
||||
},
|
||||
{ type: "divider" },
|
||||
];
|
||||
|
||||
if (did) {
|
||||
blocks.push({
|
||||
type: "banner",
|
||||
style: "success",
|
||||
text: `Connected as ${handle} (${did})`,
|
||||
});
|
||||
} else if (handle) {
|
||||
blocks.push({
|
||||
type: "banner",
|
||||
style: "warning",
|
||||
text: "Handle configured but not yet connected. Save settings and test the connection.",
|
||||
});
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: "form",
|
||||
block_id: "atproto-settings",
|
||||
fields: [
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "handle",
|
||||
label: "AT Protocol Handle",
|
||||
initial_value: handle ?? "",
|
||||
},
|
||||
{ type: "secret_input", action_id: "appPassword", label: "App Password" },
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "pdsHost",
|
||||
label: "PDS Host",
|
||||
initial_value: pdsHost ?? "https://bsky.social",
|
||||
},
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "siteUrl",
|
||||
label: "Site URL",
|
||||
initial_value: siteUrl ?? "",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
action_id: "enableCrosspost",
|
||||
label: "Cross-post to Bluesky",
|
||||
initial_value: enableCrosspost ?? false,
|
||||
},
|
||||
],
|
||||
submit: { label: "Save Settings", action_id: "save_settings" },
|
||||
});
|
||||
|
||||
blocks.push({
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: "Test Connection",
|
||||
action_id: "test_connection",
|
||||
style: handle && appPassword ? "primary" : undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (did) {
|
||||
const result = await ctx.storage.records!.query({
|
||||
orderBy: { lastSyncedAt: "desc" },
|
||||
limit: 10,
|
||||
});
|
||||
const items = result.items.map((item: { id: string; data: unknown }) => ({
|
||||
id: item.id,
|
||||
...(item.data as SyndicationRecord),
|
||||
}));
|
||||
|
||||
if (items.length > 0) {
|
||||
blocks.push(
|
||||
{ type: "divider" },
|
||||
{ type: "header", text: "Recent Syncs" },
|
||||
{
|
||||
type: "table",
|
||||
columns: [
|
||||
{ key: "collection", label: "Collection", format: "text" },
|
||||
{ key: "contentId", label: "Content", format: "code" },
|
||||
{ key: "status", label: "Status", format: "badge" },
|
||||
{ key: "lastSyncedAt", label: "Synced", format: "relative_time" },
|
||||
],
|
||||
rows: items.map((r) => ({
|
||||
collection: r.collection,
|
||||
contentId: r.contentId,
|
||||
status: r.status,
|
||||
lastSyncedAt: r.lastSyncedAt,
|
||||
})),
|
||||
emptyText: "No syncs yet",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (pubUri) {
|
||||
blocks.push(
|
||||
{ type: "divider" },
|
||||
{ type: "header", text: "Verification" },
|
||||
{
|
||||
type: "fields",
|
||||
fields: [
|
||||
{ label: "Publication URI", value: pubUri },
|
||||
{ label: "Well-known path", value: "/.well-known/site.standard.publication" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
text: "Add this path to your site to verify ownership on the AT Protocol network.",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { blocks };
|
||||
} catch (error) {
|
||||
ctx.log.error("Failed to build status page", error);
|
||||
return { blocks: [{ type: "banner", style: "error", text: "Failed to load settings" }] };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(ctx: PluginContext, values: Record<string, unknown>) {
|
||||
try {
|
||||
if (typeof values.handle === "string") await ctx.kv.set("settings:handle", values.handle);
|
||||
if (typeof values.appPassword === "string" && values.appPassword)
|
||||
await ctx.kv.set("settings:appPassword", values.appPassword);
|
||||
if (typeof values.pdsHost === "string") await ctx.kv.set("settings:pdsHost", values.pdsHost);
|
||||
if (typeof values.siteUrl === "string") await ctx.kv.set("settings:siteUrl", values.siteUrl);
|
||||
if (typeof values.enableCrosspost === "boolean")
|
||||
await ctx.kv.set("settings:enableCrosspost", values.enableCrosspost);
|
||||
|
||||
const page = await buildStatusPage(ctx);
|
||||
return { ...page, toast: { message: "Settings saved", type: "success" } };
|
||||
} catch (error) {
|
||||
ctx.log.error("Failed to save settings", error);
|
||||
return {
|
||||
blocks: [{ type: "banner", style: "error", text: "Failed to save settings" }],
|
||||
toast: { message: "Failed to save settings", type: "error" },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(ctx: PluginContext) {
|
||||
try {
|
||||
const session = await ensureSession(ctx);
|
||||
const page = await buildStatusPage(ctx);
|
||||
return {
|
||||
...page,
|
||||
toast: { message: `Connected to ${session.pdsHost} as ${session.did}`, type: "success" },
|
||||
};
|
||||
} catch (error) {
|
||||
const page = await buildStatusPage(ctx);
|
||||
return {
|
||||
...page,
|
||||
toast: {
|
||||
message: `Connection failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
type: "error",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
195
packages/plugins/atproto/src/standard-site.ts
Normal file
195
packages/plugins/atproto/src/standard-site.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* standard.site record builders
|
||||
*
|
||||
* Builds site.standard.publication and site.standard.document records
|
||||
* from EmDash content.
|
||||
*/
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────
|
||||
|
||||
export interface StandardPublication {
|
||||
$type: "site.standard.publication";
|
||||
url: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface StandardDocument {
|
||||
$type: "site.standard.document";
|
||||
/** AT-URI of the publication record, or HTTPS URL for loose documents */
|
||||
site: string;
|
||||
title: string;
|
||||
publishedAt: string;
|
||||
/** Path component -- combined with publication URL to form canonical URL */
|
||||
path?: string;
|
||||
description?: string;
|
||||
textContent?: string;
|
||||
tags?: string[];
|
||||
updatedAt?: string;
|
||||
coverImage?: BlobRefLike;
|
||||
/** Strong reference to a Bluesky post for off-platform comments */
|
||||
bskyPostRef?: { uri: string; cid: string };
|
||||
}
|
||||
|
||||
interface BlobRefLike {
|
||||
$type: "blob";
|
||||
ref: { $link: string };
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// ── Builders ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a site.standard.publication record.
|
||||
*/
|
||||
export function buildPublication(
|
||||
siteUrl: string,
|
||||
siteName: string,
|
||||
description?: string,
|
||||
): StandardPublication {
|
||||
return {
|
||||
$type: "site.standard.publication",
|
||||
url: stripTrailingSlash(siteUrl),
|
||||
name: siteName,
|
||||
...(description ? { description } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a site.standard.document record from EmDash content.
|
||||
*/
|
||||
export function buildDocument(opts: {
|
||||
publicationUri: string;
|
||||
content: Record<string, unknown>;
|
||||
coverImageBlob?: BlobRefLike;
|
||||
bskyPostRef?: { uri: string; cid: string };
|
||||
}): StandardDocument {
|
||||
const { publicationUri, content, coverImageBlob, bskyPostRef } = opts;
|
||||
|
||||
const slug = getString(content, "slug");
|
||||
const title = getString(content, "title") || "Untitled";
|
||||
const description = getString(content, "excerpt") || getString(content, "description");
|
||||
const publishedAt = getString(content, "published_at") || new Date().toISOString();
|
||||
const updatedAt = getString(content, "updated_at");
|
||||
const tags = extractTags(content);
|
||||
|
||||
const doc: StandardDocument = {
|
||||
$type: "site.standard.document",
|
||||
site: publicationUri,
|
||||
title,
|
||||
publishedAt,
|
||||
};
|
||||
|
||||
if (slug) {
|
||||
doc.path = `/${slug}`;
|
||||
}
|
||||
|
||||
if (description) {
|
||||
doc.description = description;
|
||||
}
|
||||
|
||||
const plainText = extractPlainText(content);
|
||||
if (plainText) {
|
||||
doc.textContent = plainText;
|
||||
}
|
||||
|
||||
if (tags.length > 0) {
|
||||
doc.tags = tags;
|
||||
}
|
||||
|
||||
if (updatedAt) {
|
||||
doc.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
if (coverImageBlob) {
|
||||
doc.coverImage = coverImageBlob;
|
||||
}
|
||||
|
||||
if (bskyPostRef) {
|
||||
doc.bskyPostRef = bskyPostRef;
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function stripTrailingSlash(url: string): string {
|
||||
return url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
}
|
||||
|
||||
// Pre-compiled regexes
|
||||
const HTML_TAG_RE = /<[^>]+>/g;
|
||||
const NBSP_RE = / /g;
|
||||
const AMP_RE = /&/g;
|
||||
const LT_RE = /</g;
|
||||
const GT_RE = />/g;
|
||||
const QUOT_RE = /"/g;
|
||||
const APOS_RE = /'/g;
|
||||
const WHITESPACE_RE = /\s+/g;
|
||||
const HASH_PREFIX_RE = /^#/;
|
||||
const MAX_TEXT_CONTENT_LENGTH = 10_000;
|
||||
|
||||
function getString(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const v = obj[key];
|
||||
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tags from content. Handles both string arrays and
|
||||
* tag objects with a name property.
|
||||
*/
|
||||
function extractTags(content: Record<string, unknown>): string[] {
|
||||
const raw = content.tags;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
const tags: string[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item === "string") {
|
||||
tags.push(item.replace(HASH_PREFIX_RE, ""));
|
||||
} else if (
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
"name" in item &&
|
||||
typeof (item as Record<string, unknown>).name === "string"
|
||||
) {
|
||||
tags.push(((item as Record<string, unknown>).name as string).replace(HASH_PREFIX_RE, ""));
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain text from content for the textContent field.
|
||||
* Strips HTML tags and collapses whitespace.
|
||||
*/
|
||||
export function extractPlainText(content: Record<string, unknown>): string | undefined {
|
||||
// Try common content field names
|
||||
const body =
|
||||
getString(content, "body") || getString(content, "content") || getString(content, "text");
|
||||
|
||||
if (!body) return undefined;
|
||||
|
||||
// Strip HTML tags (simple -- not a full parser, but sufficient for plain text extraction).
|
||||
// Decode & last to avoid double-decoding (e.g. &lt; -> < -> <).
|
||||
let text = body
|
||||
.replace(HTML_TAG_RE, " ")
|
||||
.replace(NBSP_RE, " ")
|
||||
.replace(LT_RE, "<")
|
||||
.replace(GT_RE, ">")
|
||||
.replace(QUOT_RE, '"')
|
||||
.replace(APOS_RE, "'")
|
||||
.replace(AMP_RE, "&")
|
||||
.replace(WHITESPACE_RE, " ")
|
||||
.trim();
|
||||
|
||||
if (!text) return undefined;
|
||||
|
||||
// Truncate to 10,000 chars to avoid exceeding PDS record size limits (~100KB)
|
||||
if (text.length > MAX_TEXT_CONTENT_LENGTH) {
|
||||
text = text.slice(0, MAX_TEXT_CONTENT_LENGTH - 1) + "\u2026";
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
Reference in New Issue
Block a user