Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/danielroe/roe.dev. This is the code and content for my personal website, built in Nuxt. roe.dev
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311/** * Per-platform publishers for AMA threads. Each entry point takes * already-resolved post text (mentions swapped, footer appended) plus an * optional image and the original question as alt text. */import { createError, useRuntimeConfig } from 'nuxt/server'import { $fetch } from 'ofetch'import { Client, asStringFormat, isBlobRef, jsonToLex } from '@atproto/lex'import type { $Typed, JsonValue } from '@atproto/lex'import { PasswordSession } from '@atproto/lex-password-session'import { post as createPost } from '@bsky/sdk'import type { app } from '@bsky/sdk/lexicons'import { com } from '@bsky/sdk/lexicons'import { createRestAPIClient } from 'masto'import { parseURL, withProtocol } from 'ufo'import { BLUESKY_IMAGE_MAX_BYTES } from '#shared/cms/blob'
export interface PublishImage { /** Publicly-fetchable URL (typically a PDS blob URL). */ url: string width: number height: number /** Used by Mastodon, which rejects un-typed uploads. */ mimeType?: string}
export interface BlueskyImage { blob: unknown width: number height: number size?: number | null}
export interface ResolvedPost { text: string /** Bluesky-only: facets covering mentions, hashtags, URLs. */ facets?: app.bsky.richtext.facet.Main[]}
// ---------- Bluesky ----------
async function resolveBlueskyPds (handle: string): Promise<string> { const resolved = await fetch(`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`) if (!resolved.ok) throw new Error(`Failed to resolve Bluesky handle ${handle}: ${resolved.status}`) const { did } = await resolved.json() as { did: string }
const didDoc = await fetch(`https://plc.directory/${encodeURIComponent(did)}`) if (!didDoc.ok) throw new Error(`Failed to resolve DID document for ${did}: ${didDoc.status}`) const doc = await didDoc.json() as { service?: Array<{ id: string, type: string, serviceEndpoint: string }> } const pds = doc.service?.find(s => s.id === '#atproto_pds')?.serviceEndpoint if (!pds) throw new Error(`No PDS endpoint found in DID document for ${did}`) return pds}
export async function publishBlueskyThread ( posts: ResolvedPost[], image: BlueskyImage | undefined, altText: string,): Promise<{ url: string }> { const config = useRuntimeConfig() const { identifier } = config.social.networks.bluesky const password = config.bluesky.accessToken
const pdsUrl = await resolveBlueskyPds(identifier) const session = await PasswordSession.login({ service: pdsUrl, identifier, password }) const client = new Client(session)
let replyTo: app.bsky.feed.post.ReplyRef | undefined const postUrls: string[] = []
for (let i = 0; i < posts.length; i++) { const { text, facets = [] } = posts[i]! let embed: $Typed<app.bsky.embed.images.Main> | undefined
if (i === 0 && image) { if (image.size != null && image.size > BLUESKY_IMAGE_MAX_BYTES) { throw createError({ status: 413, statusText: `AMA image is ${image.size} bytes; Bluesky embeds must be under ${BLUESKY_IMAGE_MAX_BYTES}. Regenerate the image to compress it.`, }) } const blob = jsonToLex(image.blob as JsonValue) if (!isBlobRef(blob)) { throw new Error('AMA image is not a valid blob ref; cannot embed.') }
embed = { $type: 'app.bsky.embed.images' as const, images: [{ alt: altText, image: blob, aspectRatio: { $type: 'app.bsky.embed.defs#aspectRatio', width: image.width, height: image.height, }, }], } }
// Resolve handle→DID just before posting (handles can change between // author time and publish, and Bluesky stores the DID in the facet). const resolvedFacets: app.bsky.richtext.facet.Main[] = await Promise.all( facets.map(async facet => { const features = await Promise.all(facet.features.map(async f => { if (f.$type !== 'app.bsky.richtext.facet#mention') return f const mention = f as { $type: string, did: string } try { const handle = mention.did.replace(/^@/, '') const { did } = await client.call(com.atproto.identity.resolveHandle, { handle: asStringFormat(handle, 'handle') }) return { ...f, did } } catch (err) { console.warn(`Failed to resolve handle ${mention.did}:`, err) return f } })) return { ...facet, features: features as app.bsky.richtext.facet.Main['features'] } }), )
const post = await client.call(createPost, { text, langs: [asStringFormat('en', 'language')], embed, reply: replyTo, facets: resolvedFacets.length ? resolvedFacets : undefined, })
const [_did, , rkey] = post.uri.replace('at://', '').split('/') const handle = identifier.includes('.') ? identifier : `${identifier}.bsky.social` postUrls.push(`https://bsky.app/profile/${handle}/post/${rkey}`)
if (i === 0) { replyTo = { $type: 'app.bsky.feed.post#replyRef', root: { uri: post.uri, cid: post.cid }, parent: { uri: post.uri, cid: post.cid }, } } else if (replyTo) { replyTo.parent = { uri: post.uri, cid: post.cid } } }
return { url: postUrls[0]! }}
// ---------- Mastodon ----------
function splitIntoChunks (text: string, maxLength: number): string[] { if (text.length <= maxLength) return [text]
const out: string[] = [] const lines = text.split('\n') let current = ''
for (const line of lines) { if ((current + '\n' + line).length > maxLength) { if (current.trim()) out.push(current.trim()) current = '' if (line.length > maxLength) { // Hard wrap on whitespace as a last resort. for (const word of line.split(' ')) { if ((current + ' ' + word).length > maxLength) { if (current.trim()) out.push(current.trim()) current = word } else { current = current ? current + ' ' + word : word } } } else { current = line } } else { current = current ? current + '\n' + line : line } } if (current.trim()) out.push(current.trim()) return out}
export async function publishMastodon ( text: string, image: PublishImage | undefined, altText: string,): Promise<{ url: string }> { const config = useRuntimeConfig() const { identifier } = config.social.networks.mastodon
const accessToken = config.mastodon.accessToken if (!accessToken) throw new Error('Mastodon access token not configured (NUXT_MASTODON_ACCESS_TOKEN).')
const [username, domain] = identifier.split('@') if (!domain) throw new Error('Invalid Mastodon identifier; expected user@instance.')
const webfinger = await $fetch<{ aliases?: string[] }>('.well-known/webfinger', { baseURL: withProtocol(domain, 'https://'), query: { resource: `acct:${identifier}` }, }) const { host, protocol } = parseURL(webfinger.aliases?.[0]) if (!host || !protocol) throw new Error('Failed to discover Mastodon instance via webfinger.')
const client = createRestAPIClient({ url: withProtocol(host, protocol), accessToken })
const chunks = splitIntoChunks(text, 2000) let inReplyToId: string | undefined const urls: string[] = []
for (let i = 0; i < chunks.length; i++) { let mediaIds: string[] = [] if (i === 0 && image) { const mime = image.mimeType || 'image/png' const ext = mime.split('/')[1]?.split('+')[0] || 'png' const imageBuffer = await fetch(image.url).then(r => r.arrayBuffer()) const file = new File([imageBuffer], `ama.${ext}`, { type: mime }) const media = await client.v2.media.create({ file, description: altText }) mediaIds = [media.id] } const status = await client.v1.statuses.create({ status: chunks[i]!, language: 'en', mediaIds, inReplyToId, }) urls.push(status.url || `https://${domain}/@${username}/${status.id}`) inReplyToId = status.id } return { url: urls[0]! }}
// ---------- LinkedIn ----------
async function getLinkedInPersonURN (accessToken: string): Promise<string> { const profile = await $fetch<{ sub?: string }>('https://api.linkedin.com/v2/userinfo', { headers: { Authorization: `Bearer ${accessToken}` }, }) if (!profile.sub) throw new Error('LinkedIn userinfo missing `sub` field.') return `urn:li:person:${profile.sub}`}
async function uploadImageToLinkedIn (imageUrl: string, accessToken: string, personURN: string): Promise<string> { const register = await $fetch<{ value: { asset: string, uploadMechanism: Record<string, { uploadUrl: string }> } }>( 'https://api.linkedin.com/v2/assets?action=registerUpload', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}` }, body: { registerUploadRequest: { recipes: ['urn:li:digitalmediaRecipe:feedshare-image'], owner: personURN, serviceRelationships: [{ relationshipType: 'OWNER', identifier: 'urn:li:userGeneratedContent' }], }, }, }, ) const uploadUrl = register.value.uploadMechanism['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest']!.uploadUrl
const imageBuffer = await fetch(imageUrl).then(r => r.arrayBuffer()) const uploadRes = await fetch(uploadUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/octet-stream' }, body: imageBuffer, }) if (!uploadRes.ok) throw new Error(`LinkedIn image upload failed: ${uploadRes.status} ${await uploadRes.text()}`)
return register.value.asset}
export async function publishLinkedIn ( text: string, image: PublishImage | undefined, altText: string,): Promise<{ url: string }> { const config = useRuntimeConfig() const accessToken = config.linkedin.accessToken if (!accessToken) throw new Error('LinkedIn access token not configured (NUXT_LINKEDIN_ACCESS_TOKEN).')
const personURN = await getLinkedInPersonURN(accessToken)
let shareMediaCategory: 'NONE' | 'IMAGE' = 'NONE' let media: unknown[] = [] if (image) { const asset = await uploadImageToLinkedIn(image.url, accessToken, personURN) shareMediaCategory = 'IMAGE' media = [{ status: 'READY', description: { text: altText }, media: asset, title: { text: altText } }] }
const post = await $fetch<{ id: string }>('https://api.linkedin.com/v2/ugcPosts', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'X-Restli-Protocol-Version': '2.0.0' }, body: { author: personURN, lifecycleState: 'PUBLISHED', specificContent: { 'com.linkedin.ugc.ShareContent': { shareCommentary: { text }, shareMediaCategory, media, }, }, visibility: { 'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC' }, }, })
return { url: `https://www.linkedin.com/feed/update/${post.id}/` }}