A simple Bluesky bot to make sense of the noise, with responses powered by Gemini, similar to Grok.

feat: include image alt text with post context

-11
Dockerfile
··· 8 8 9 9 COPY . . 10 10 11 - ENV AUTHORIZED_USERS="" 12 - ENV SERVICE="https://bsky.social" 13 - ENV DB_PATH="data/sqlite.db" 14 - ENV GEMINI_MODEL="gemini-2.5-flash" 15 - ENV ADMIN_DID="" 16 - ENV ADMIN_HANDLE="" 17 - ENV DID="" 18 - ENV HANDLE="" 19 - ENV BSKY_PASSWORD="" 20 - ENV GEMINI_API_KEY="" 21 - 22 11 CMD ["bun", "start"]
+17
docker-compose.yml
··· 1 + services: 2 + aero: 3 + build: 4 + context: . 5 + dockerfile: Dockerfile 6 + environment: 7 + - "AUTHORIZED_USERS=" 8 + - "SERVICE=${SERVICE:?https://bsky.social}" 9 + - "DB_PATH=data/sqlite.db" 10 + - "GEMINI_MODEL=${GEMINI_MODEL:?gemini-2.5-flash}" 11 + - "DID=${DID:?}" 12 + - "HANDLE=${HANDLE:?}" 13 + - "BSKY_PASSWORD=${BSKY_PASSWORD:?}" 14 + - "GEMINI_API_KEY=${GEMINI_API_KEY:?}" 15 + volumes: 16 + - .:/app 17 + - aero_db:/app/data
-2
src/env.ts
··· 11 11 DB_PATH: z.string().default("sqlite.db"), 12 12 GEMINI_MODEL: z.string().default("gemini-2.5-flash"), 13 13 14 - ADMIN_DID: z.string(), 15 - ADMIN_HANDLE: z.string(), 16 14 DID: z.string(), 17 15 HANDLE: z.string(), 18 16 BSKY_PASSWORD: z.string(),
+14 -1
src/utils/conversation.ts
··· 9 9 import { and, eq } from "drizzle-orm"; 10 10 import { env } from "../env"; 11 11 import { bot, MAX_GRAPHEMES } from "../core"; 12 - import { traverseThread } from "./thread"; 12 + import { parsePostImages, traverseThread } from "./post"; 13 13 14 + /* 15 + Utilities 16 + */ 14 17 const resolveDid = (convo: Conversation, did: string) => 15 18 convo.members.find((actor) => actor.did == did)!; 16 19 ··· 23 26 return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join(""); 24 27 } 25 28 29 + /* 30 + Conversations 31 + */ 26 32 async function initConvo(convo: Conversation) { 27 33 const user = getUserDid(convo); 28 34 ··· 136 142 ? `${post.author.displayName} (${post.author.handle})` 137 143 : `Handle: ${post.author.handle}`, 138 144 text: post.text, 145 + images: parsePostImages(post), 139 146 likes: post.likeCount || 0, 140 147 replies: post.replyCount || 0, 141 148 }, ··· 152 159 }); 153 160 } 154 161 162 + /* 163 + Messages 164 + */ 155 165 async function parseMessagePostUri(message: ChatMessage) { 156 166 if (!message.embed) return null; 157 167 const post = message.embed; ··· 194 204 }); 195 205 } 196 206 207 + /* 208 + Reponse Utilities 209 + */ 197 210 export function exceedsGraphemes(content: string) { 198 211 return graphemeLength(content) > MAX_GRAPHEMES; 199 212 }
+64
src/utils/post.ts
··· 1 + import { EmbedImage, Post } from "@skyware/bot"; 2 + import * as c from "../core"; 3 + import * as yaml from "js-yaml"; 4 + 5 + export function parsePostImages(post: Post) { 6 + if (!post.embed) return []; 7 + 8 + let images: EmbedImage[] = []; 9 + 10 + if (post.embed.isImages()) { 11 + images = post.embed.images; 12 + } else if (post.embed.isRecordWithMedia()) { 13 + const media = post.embed.media; 14 + if (media && media.isImages()) { 15 + images = media.images; 16 + } 17 + } 18 + 19 + return images.map((image, idx) => parseImage(image, idx + 1)); 20 + } 21 + 22 + function parseImage(image: EmbedImage, index: number) { 23 + return { 24 + index: index, 25 + alt: image.alt, 26 + }; 27 + } 28 + 29 + /* 30 + Traversal 31 + */ 32 + export async function traverseThread(post: Post): Promise<Post[]> { 33 + const thread: Post[] = [ 34 + post, 35 + ]; 36 + let currentPost: Post | undefined = post; 37 + let parentCount = 0; 38 + 39 + while ( 40 + currentPost && parentCount < c.MAX_THREAD_DEPTH 41 + ) { 42 + const parentPost = await currentPost.fetchParent(); 43 + 44 + if (parentPost) { 45 + thread.push(parentPost); 46 + currentPost = parentPost; 47 + } else { 48 + break; 49 + } 50 + parentCount++; 51 + } 52 + 53 + return thread.reverse(); 54 + } 55 + 56 + export function parseThread(thread: Post[]) { 57 + return yaml.dump({ 58 + uri: thread[0]!.uri, 59 + posts: thread.map((post) => ({ 60 + author: `${post.author.displayName} (${post.author.handle})`, 61 + text: post.text, 62 + })), 63 + }); 64 + }
-40
src/utils/thread.ts
··· 1 - import { Post } from "@skyware/bot"; 2 - import * as c from "../core"; 3 - import * as yaml from "js-yaml"; 4 - 5 - /* 6 - Traversal 7 - */ 8 - export async function traverseThread(post: Post): Promise<Post[]> { 9 - const thread: Post[] = [ 10 - post, 11 - ]; 12 - let currentPost: Post | undefined = post; 13 - let parentCount = 0; 14 - 15 - while ( 16 - currentPost && parentCount < c.MAX_THREAD_DEPTH 17 - ) { 18 - const parentPost = await currentPost.fetchParent(); 19 - 20 - if (parentPost) { 21 - thread.push(parentPost); 22 - currentPost = parentPost; 23 - } else { 24 - break; 25 - } 26 - parentCount++; 27 - } 28 - 29 - return thread.reverse(); 30 - } 31 - 32 - export function parseThread(thread: Post[]) { 33 - return yaml.dump({ 34 - uri: thread[0]!.uri, 35 - posts: thread.map((post) => ({ 36 - author: `${post.author.displayName} (${post.author.handle})`, 37 - text: post.text, 38 - })), 39 - }); 40 - }