Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/usr/bin/env node
2
3/**
4 * Share Latest Painting to Bluesky
5 *
6 * Fetch the latest painting from a user's aesthetic.computer gallery
7 * and post it to Bluesky with the image embedded.
8 *
9 * Usage:
10 * node share-latest-painting.mjs @handle
11 * node share-latest-painting.mjs @handle --preview
12 * node share-latest-painting.mjs @handle --message "Custom message"
13 */
14
15import { AtpAgent, RichText } from '@atproto/api'
16import { config } from 'dotenv'
17import { writeFileSync } from 'fs'
18
19config()
20
21const BSKY_SERVICE = process.env.BSKY_SERVICE || 'https://bsky.social'
22const BSKY_IDENTIFIER = process.env.BSKY_IDENTIFIER
23const BSKY_APP_PASSWORD = process.env.BSKY_APP_PASSWORD
24const AC_API = 'https://aesthetic.computer'
25
26// Parse command line arguments
27const args = process.argv.slice(2)
28const handle = args[0]
29const isPreview = args.includes('--preview')
30const messageIndex = args.indexOf('--message')
31const customMessage = messageIndex !== -1 ? args[messageIndex + 1] : null
32
33async function fetchLatestPainting(userHandle) {
34 console.log(`\n🔍 Fetching paintings for: ${userHandle}`)
35
36 // Remove @ if present
37 const cleanHandle = userHandle.startsWith('@') ? userHandle.slice(1) : userHandle
38
39 // Use TV API to get paintings, then filter by handle
40 const tvUrl = `${AC_API}/api/tv?limit=500`
41 console.log(`📡 Querying TV feed...\n`)
42
43 try {
44 const response = await fetch(tvUrl)
45 if (!response.ok) {
46 throw new Error(`HTTP ${response.status}: ${response.statusText}`)
47 }
48
49 const data = await response.json()
50
51 if (!data.media || !data.media.paintings) {
52 throw new Error('No paintings found in TV feed')
53 }
54
55 // Filter paintings by handle
56 const userPaintings = data.media.paintings.filter(painting => {
57 const paintingHandle = painting.owner.handle?.replace('@', '') || null
58 return paintingHandle === cleanHandle
59 })
60
61 if (userPaintings.length === 0) {
62 throw new Error(`No paintings found for @${cleanHandle}`)
63 }
64
65 const latest = userPaintings[0]
66
67 console.log(`✅ Found ${userPaintings.length} paintings for @${cleanHandle}`)
68 console.log(`🎨 Latest: ${latest.slug}`)
69 console.log(`📅 When: ${new Date(latest.when).toLocaleString()}`)
70 console.log(`🔗 URL: ${latest.media.url}\n`)
71
72 return {
73 url: latest.media.url,
74 handle: cleanHandle,
75 slug: latest.slug,
76 when: latest.when,
77 totalPaintings: userPaintings.length
78 }
79 } catch (error) {
80 console.error('❌ Failed to fetch paintings:', error.message)
81 throw error
82 }
83}
84
85async function downloadPainting(url) {
86 console.log(`📥 Downloading painting from: ${url}`)
87
88 try {
89 const response = await fetch(url)
90 if (!response.ok) {
91 throw new Error(`HTTP ${response.status}: ${response.statusText}`)
92 }
93
94 const arrayBuffer = await response.arrayBuffer()
95 const buffer = Buffer.from(arrayBuffer)
96
97 console.log(`✅ Downloaded ${buffer.length} bytes\n`)
98
99 return buffer
100 } catch (error) {
101 console.error('❌ Failed to download painting:', error.message)
102 throw error
103 }
104}
105
106async function postToBluesky(paintingData, imageBuffer, message) {
107 if (!BSKY_IDENTIFIER || !BSKY_APP_PASSWORD) {
108 console.error('❌ Error: Missing Bluesky credentials')
109 console.error('\nPlease set in .env:')
110 console.error(' BSKY_IDENTIFIER=aesthetic.computer')
111 console.error(' BSKY_APP_PASSWORD=your-app-password')
112 process.exit(1)
113 }
114
115 console.log(`📤 Posting to Bluesky as @${BSKY_IDENTIFIER}`)
116 console.log(`📡 Using service: ${BSKY_SERVICE}\n`)
117
118 const agent = new AtpAgent({ service: BSKY_SERVICE })
119
120 try {
121 // Login
122 console.log('🔐 Logging in...')
123 await agent.login({
124 identifier: BSKY_IDENTIFIER,
125 password: BSKY_APP_PASSWORD
126 })
127 console.log('✅ Logged in successfully\n')
128
129 // Upload image as blob
130 console.log('📤 Uploading painting to Bluesky...')
131 const uploadResponse = await agent.uploadBlob(imageBuffer, {
132 encoding: 'image/png'
133 })
134 console.log('✅ Image uploaded as blob\n')
135
136 // Create post with embedded image and AC URL
137 const acUrl = `https://aesthetic.computer/painting~@${paintingData.handle}/${paintingData.slug}`
138 const postText = message || `New painting by @${paintingData.handle} 🎨✨\n\n${acUrl}`
139
140 console.log('📝 Creating post...')
141 console.log('───────────────────────────')
142 console.log(postText)
143 console.log('───────────────────────────\n')
144
145 // Use RichText to automatically detect and format URLs as links
146 const rt = new RichText({ text: postText })
147 await rt.detectFacets(agent)
148
149 const postRecord = {
150 text: rt.text,
151 facets: rt.facets,
152 createdAt: new Date().toISOString(),
153 embed: {
154 $type: 'app.bsky.embed.images',
155 images: [{
156 image: uploadResponse.data.blob,
157 alt: `Painting by @${paintingData.handle} on aesthetic.computer`
158 }]
159 }
160 }
161
162 const response = await agent.post(postRecord)
163
164 console.log('✅ Post created successfully!')
165 console.log(`🔗 URI: ${response.uri}`)
166 console.log(`🆔 CID: ${response.cid}`)
167
168 // Extract post ID from URI
169 const postId = response.uri.split('/').pop()
170 console.log(`🌐 View: https://bsky.app/profile/${BSKY_IDENTIFIER}/post/${postId}\n`)
171
172 return response
173 } catch (error) {
174 console.error('❌ Failed to post to Bluesky:', error.message)
175 throw error
176 }
177}
178
179async function main() {
180 if (!handle || handle.startsWith('--')) {
181 console.error('\n❌ Usage: node share-latest-painting.mjs @handle [--preview] [--message "text"]')
182 console.error('\nExamples:')
183 console.error(' node share-latest-painting.mjs @jeffrey')
184 console.error(' node share-latest-painting.mjs @jeffrey --preview')
185 console.error(' node share-latest-painting.mjs @jeffrey --message "Check out this artwork! 🎨"')
186 process.exit(1)
187 }
188
189 try {
190 // Fetch latest painting metadata
191 const paintingData = await fetchLatestPainting(handle)
192
193 // Download the painting image
194 const imageBuffer = await downloadPainting(paintingData.url)
195
196 if (isPreview) {
197 // Save preview to disk
198 const previewPath = './preview-painting.png'
199 writeFileSync(previewPath, imageBuffer)
200 console.log(`👀 Preview saved to: ${previewPath}`)
201 console.log(`\n📊 Painting Info:`)
202 console.log(` Handle: @${paintingData.handle}`)
203 console.log(` URL: ${paintingData.url}`)
204 console.log(` Total paintings: ${paintingData.totalPaintings}`)
205 console.log(` Size: ${imageBuffer.length} bytes`)
206 console.log(`\n💡 Run without --preview to post to Bluesky`)
207 } else {
208 // Post to Bluesky
209 await postToBluesky(paintingData, imageBuffer, customMessage)
210 }
211
212 } catch (error) {
213 console.error('\n💥 Error:', error.message)
214 process.exit(1)
215 }
216}
217
218main()