···11+# Comma-separated list of users who can use the bot (delete var if you want everyone to be able to use it)
22+AUTHORIZED_USERS=""
33+44+# PDS service URL (optional)
55+SERVICE="https://bsky.social"
66+77+DB_PATH="data/sqlite.db"
88+GEMINI_MODEL="gemini-2.5-flash"
99+1010+ADMIN_DID=""
1111+ADMIN_HANDLE=""
1212+1313+DID=""
1414+HANDLE=""
1515+1616+# https://bsky.app/settings/app-passwords
1717+BSKY_PASSWORD=""
1818+1919+# https://aistudio.google.com/apikey
2020+GEMINI_API_KEY=""
···11+# Aero
22+33+A simple Bluesky bot to make sense of the noise, with responses powered by Gemini, similar to Grok. Built with the [@skyware/bot](https://github.com/skyware-js/bot) library.
44+55+## How to Use
66+77+- Find a post you want to ask about
88+- Start a conversation by sending a link to the post and your initial query to the [`@aero.indexx.dev`](https://bsky.app/profile/did:plc:brtrdeexwvywvennyptpwcnu) account.
99+ ..after a few seconds, you'll get a response to your query!
1010+1111+**For any further queries:**
1212+1313+- You don't need to resend the post link, just send the message like normal. Ever want to switch posts? Just send a new post link and your context window will reset.
1414+- You can ask up to 15 queries per post link, and all messages prior to the new post link are ignored.
1515+1616+All messages are stored in the database just to make the process simpler so I don't have to deal with cursors or accidentally including messages that shouldn't be included in the context window.
···11+CREATE TABLE `conversations` (
22+ `id` text NOT NULL,
33+ `did` text NOT NULL,
44+ `post_uri` text NOT NULL,
55+ `revision` text NOT NULL,
66+ `created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
77+ `last_active` integer DEFAULT CURRENT_TIMESTAMP NOT NULL
88+);
99+--> statement-breakpoint
1010+CREATE UNIQUE INDEX `conversations_id_unique` ON `conversations` (`id`);--> statement-breakpoint
1111+CREATE UNIQUE INDEX `conversations_did_unique` ON `conversations` (`did`);--> statement-breakpoint
1212+CREATE TABLE `messages` (
1313+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
1414+ `conversation_id` text NOT NULL,
1515+ `revision` text NOT NULL,
1616+ `did` text NOT NULL,
1717+ `post_uri` text NOT NULL,
1818+ `text` text NOT NULL,
1919+ `created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
2020+ FOREIGN KEY (`conversation_id`) REFERENCES `conversations`(`id`) ON UPDATE no action ON DELETE no action,
2121+ FOREIGN KEY (`revision`) REFERENCES `conversations`(`revision`) ON UPDATE no action ON DELETE no action
2222+);
···11+import { GoogleGenAI } from "@google/genai";
22+import { Bot } from "@skyware/bot";
33+import { env } from "./env";
44+55+export const bot = new Bot({
66+ service: env.SERVICE,
77+ emitChatEvents: true,
88+});
99+1010+export const ai = new GoogleGenAI({
1111+ apiKey: env.GEMINI_API_KEY,
1212+});
1313+1414+export const UNAUTHORIZED_MESSAGE =
1515+ "I can’t make sense of your noise just yet. You’ll need to be whitelisted before I can help.";
1616+1717+export const SUPPORTED_FUNCTION_CALLS = [
1818+ "search_posts",
1919+] as const;
2020+2121+export const MAX_GRAPHEMES = 1000;
2222+2323+export const MAX_THREAD_DEPTH = 10;
+7
src/db/index.ts
···11+import { drizzle } from "drizzle-orm/bun-sqlite";
22+import { Database } from "bun:sqlite";
33+import * as schema from "./schema";
44+import { env } from "../env";
55+66+const sqlite = new Database(env.DB_PATH);
77+export default drizzle(sqlite, { schema });
+8
src/db/migrate.ts
···11+import { migrate } from "drizzle-orm/bun-sqlite/migrator";
22+import { drizzle } from "drizzle-orm/bun-sqlite";
33+import { Database } from "bun:sqlite";
44+import { env } from "../env";
55+66+const sqlite = new Database(env.DB_PATH);
77+const db = drizzle(sqlite);
88+migrate(db, { migrationsFolder: "./drizzle" });
···11+import modelPrompt from "../model/prompt.txt";
22+import { ChatMessage, Conversation } from "@skyware/bot";
33+import * as c from "../core";
44+import * as tools from "../tools";
55+import consola from "consola";
66+import { env } from "../env";
77+import {
88+ exceedsGraphemes,
99+ multipartResponse,
1010+ parseConversation,
1111+ saveMessage,
1212+} from "../utils/conversation";
1313+1414+const logger = consola.withTag("Message Handler");
1515+1616+type SupportedFunctionCall = typeof c.SUPPORTED_FUNCTION_CALLS[number];
1717+1818+async function generateAIResponse(parsedConversation: string) {
1919+ const config = {
2020+ model: env.GEMINI_MODEL,
2121+ config: {
2222+ tools: tools.declarations,
2323+ },
2424+ };
2525+2626+ const contents = [
2727+ {
2828+ role: "model" as const,
2929+ parts: [
3030+ {
3131+ text: modelPrompt
3232+ .replace("{{ handle }}", env.HANDLE),
3333+ },
3434+ ],
3535+ },
3636+ {
3737+ role: "user" as const,
3838+ parts: [
3939+ {
4040+ text:
4141+ `Below is the yaml for the current conversation. The last message is the one to respond to. The post is the current one you are meant to be analyzing.
4242+4343+${parsedConversation}`,
4444+ },
4545+ ],
4646+ },
4747+ ];
4848+4949+ let inference = await c.ai.models.generateContent({
5050+ ...config,
5151+ contents,
5252+ });
5353+5454+ logger.log(
5555+ `Initial inference took ${inference.usageMetadata?.totalTokenCount} tokens`,
5656+ );
5757+5858+ if (inference.functionCalls && inference.functionCalls.length > 0) {
5959+ const call = inference.functionCalls[0];
6060+6161+ if (
6262+ call &&
6363+ c.SUPPORTED_FUNCTION_CALLS.includes(
6464+ call.name as SupportedFunctionCall,
6565+ )
6666+ ) {
6767+ logger.log("Function called invoked:", call.name);
6868+6969+ const functionResponse = await tools.handler(
7070+ call as typeof call & { name: SupportedFunctionCall },
7171+ );
7272+7373+ logger.log("Function response:", functionResponse);
7474+7575+ //@ts-ignore
7676+ contents.push(inference.candidates[0]?.content!);
7777+7878+ contents.push({
7979+ role: "user" as const,
8080+ parts: [{
8181+ //@ts-ignore
8282+ functionResponse: {
8383+ name: call.name as string,
8484+ response: { res: functionResponse },
8585+ },
8686+ }],
8787+ });
8888+8989+ inference = await c.ai.models.generateContent({
9090+ ...config,
9191+ contents,
9292+ });
9393+ }
9494+ }
9595+9696+ return inference;
9797+}
9898+9999+async function sendResponse(
100100+ conversation: Conversation,
101101+ text: string,
102102+): Promise<void> {
103103+ if (exceedsGraphemes(text)) {
104104+ multipartResponse(conversation, text);
105105+ } else {
106106+ conversation.sendMessage({
107107+ text,
108108+ });
109109+ }
110110+}
111111+112112+export async function handler(message: ChatMessage): Promise<void> {
113113+ const conversation = await message.getConversation();
114114+ // ? Conversation should always be able to be found, but just in case:
115115+ if (!conversation) {
116116+ logger.error("Cannot find conversation");
117117+ return;
118118+ }
119119+120120+ const authorized = env.AUTHORIZED_USERS == null
121121+ ? true
122122+ : env.AUTHORIZED_USERS.includes(message.senderDid as any);
123123+124124+ if (!authorized) {
125125+ conversation.sendMessage({
126126+ text: c.UNAUTHORIZED_MESSAGE,
127127+ });
128128+129129+ return;
130130+ }
131131+132132+ logger.success("Found conversation");
133133+ conversation.sendMessage({
134134+ text: "...",
135135+ });
136136+137137+ const parsedConversation = await parseConversation(conversation);
138138+139139+ logger.info("Parsed conversation: ", parsedConversation);
140140+141141+ try {
142142+ const inference = await generateAIResponse(parsedConversation);
143143+ if (!inference) {
144144+ throw new Error("Failed to generate text. Returned undefined.");
145145+ }
146146+147147+ logger.success("Generated text:", inference.text);
148148+149149+ saveMessage(conversation, env.DID, inference.text!);
150150+151151+ const responseText = inference.text;
152152+ if (responseText) {
153153+ await sendResponse(conversation, responseText);
154154+ }
155155+ } catch (error) {
156156+ logger.error("Error in post handler:", error);
157157+158158+ await conversation.sendMessage({
159159+ text:
160160+ "Sorry, I ran into an issue analyzing that post. Please try again.",
161161+ });
162162+ }
163163+}
+26
src/index.ts
···11+import * as messages from "./handlers/messages";
22+import { env } from "./env";
33+import { bot } from "./core";
44+import consola from "consola";
55+import { IncomingChatPreference } from "@skyware/bot";
66+77+const logger = consola.withTag("Entrypoint");
88+99+logger.info("Logging in..");
1010+1111+try {
1212+ await bot.login({
1313+ identifier: env.HANDLE,
1414+ password: env.BSKY_PASSWORD,
1515+ });
1616+1717+ logger.success(`Logged in as @${env.HANDLE} (${env.DID})`);
1818+1919+ await bot.setChatPreference(IncomingChatPreference.All);
2020+ bot.on("message", messages.handler);
2121+2222+ logger.success("Registered events (reply, mention, quote)");
2323+} catch (e) {
2424+ logger.error("Failure to log-in: ", e);
2525+ process.exit(1);
2626+}
+14
src/model/prompt.txt
···11+You are Aero, a neutral and helpful assistant on Bluesky.
22+Your job is to give clear, factual, and concise explanations or context about posts users send you.
33+44+Handle: {{ handle }}
55+66+Guidelines:
77+88+* Always stay neutral and avoid opinions or bias.
99+* Give short, factual background or definitions that help users understand a post.
1010+* If something is unclear, briefly explain possible meanings.
1111+* Keep every reply as concise as possible while staying complete.
1212+* Never exceed 1000 graphemes.
1313+* Do not speculate or include unverified information; say if something is uncertain.
1414+* Write in plain text only. Do not use markdown, symbols, or formatting.
+31
src/tools/index.ts
···11+import type { FunctionCall } from "@google/genai";
22+import * as search_posts from "./search_posts";
33+import type { infer as z_infer } from "zod";
44+55+const validation_mappings = {
66+ "search_posts": search_posts.validator,
77+} as const;
88+99+export const declarations = [
1010+ { urlContext: {} },
1111+ { googleSearch: {} },
1212+ /*
1313+ {
1414+ functionDeclarations: [
1515+ search_posts.definition,
1616+ ],
1717+ },
1818+ */
1919+];
2020+2121+type ToolName = keyof typeof validation_mappings;
2222+export async function handler(call: FunctionCall & { name: ToolName }) {
2323+ const parsedArgs = validation_mappings[call.name].parse(call.args);
2424+2525+ switch (call.name) {
2626+ case "search_posts":
2727+ return await search_posts.handler(
2828+ parsedArgs as z_infer<typeof search_posts.validator>,
2929+ );
3030+ }
3131+}
+26
src/tools/search_posts.ts
···11+import { AtUri } from "@atproto/syntax";
22+import { ai, bot } from "../core";
33+import { Type } from "@google/genai";
44+import { env } from "../env";
55+import z from "zod";
66+77+export const definition = {
88+ name: "search_posts",
99+ description: "Searches posts across the entire Bluesky network.",
1010+ parameters: {
1111+ type: Type.OBJECT,
1212+ properties: {
1313+ query: {
1414+ type: Type.STRING,
1515+ description: "The query to search for.",
1616+ },
1717+ },
1818+ required: ["query"],
1919+ },
2020+};
2121+2222+export const validator = z.object({
2323+ query: z.string(),
2424+});
2525+2626+export async function handler(args: z.infer<typeof validator>) {}
+233
src/utils/conversation.ts
···11+import {
22+ type ChatMessage,
33+ type Conversation,
44+ graphemeLength,
55+} from "@skyware/bot";
66+import * as yaml from "js-yaml";
77+import db from "../db";
88+import { conversations, messages } from "../db/schema";
99+import { and, eq } from "drizzle-orm";
1010+import { env } from "../env";
1111+import { bot, MAX_GRAPHEMES } from "../core";
1212+import { traverseThread } from "./thread";
1313+1414+const resolveDid = (convo: Conversation, did: string) =>
1515+ convo.members.find((actor) => actor.did == did)!;
1616+1717+const getUserDid = (convo: Conversation) =>
1818+ convo.members.find((actor) => actor.did != env.DID)!;
1919+2020+function generateRevision(bytes = 8) {
2121+ const array = new Uint8Array(bytes);
2222+ crypto.getRandomValues(array);
2323+ return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
2424+}
2525+2626+async function initConvo(convo: Conversation) {
2727+ const user = getUserDid(convo);
2828+2929+ const initialMessage = (await convo.getMessages()).messages[0] as
3030+ | ChatMessage
3131+ | undefined;
3232+ if (!initialMessage) {
3333+ throw new Error("Failed to get initial message of conversation");
3434+ }
3535+3636+ const postUri = await parseMessagePostUri(initialMessage);
3737+ if (!postUri) {
3838+ convo.sendMessage({
3939+ text:
4040+ "Please send a post for me to make sense of the noise for you.",
4141+ });
4242+ throw new Error("No post reference in initial message.");
4343+ }
4444+4545+ return await db.transaction(async (tx) => {
4646+ const [_convo] = await tx
4747+ .insert(conversations)
4848+ .values({
4949+ id: convo.id,
5050+ did: user.did,
5151+ postUri,
5252+ revision: generateRevision(),
5353+ })
5454+ .returning();
5555+5656+ if (!_convo) {
5757+ throw new Error("Error during database transaction");
5858+ }
5959+6060+ await tx
6161+ .insert(messages)
6262+ .values({
6363+ conversationId: _convo.id,
6464+ did: user.did,
6565+ postUri,
6666+ revision: _convo.revision,
6767+ text: initialMessage.text,
6868+ });
6969+7070+ return _convo!;
7171+ });
7272+}
7373+7474+async function getConvo(convoId: string) {
7575+ const [convo] = await db
7676+ .select()
7777+ .from(conversations)
7878+ .where(eq(conversations.id, convoId))
7979+ .limit(1);
8080+8181+ return convo;
8282+}
8383+8484+export async function parseConversation(convo: Conversation) {
8585+ let row = await getConvo(convo.id);
8686+ if (!row) {
8787+ row = await initConvo(convo);
8888+ } else {
8989+ const latestMessage = (await convo.getMessages())
9090+ .messages[0] as ChatMessage;
9191+9292+ const postUri = await parseMessagePostUri(latestMessage);
9393+ if (postUri) {
9494+ const [updatedRow] = await db
9595+ .update(conversations)
9696+ .set({
9797+ postUri,
9898+ revision: generateRevision(),
9999+ })
100100+ .returning();
101101+102102+ if (!updatedRow) {
103103+ throw new Error("Failed to update conversation in database");
104104+ }
105105+106106+ row = updatedRow;
107107+ }
108108+109109+ await db
110110+ .insert(messages)
111111+ .values({
112112+ conversationId: convo.id,
113113+ did: getUserDid(convo).did,
114114+ postUri: row.postUri,
115115+ revision: row.revision,
116116+ text: latestMessage!.text,
117117+ });
118118+ }
119119+120120+ const post = await bot.getPost(row.postUri);
121121+ const convoMessages = await getRelevantMessages(row!);
122122+123123+ const thread = await traverseThread(post);
124124+125125+ return yaml.dump({
126126+ post: {
127127+ thread: {
128128+ ancestors: thread.map((post) => ({
129129+ author: post.author.displayName
130130+ ? `${post.author.displayName} (${post.author.handle})`
131131+ : `Handle: ${post.author.handle}`,
132132+ text: post.text,
133133+ })),
134134+ },
135135+ author: post.author.displayName
136136+ ? `${post.author.displayName} (${post.author.handle})`
137137+ : `Handle: ${post.author.handle}`,
138138+ text: post.text,
139139+ likes: post.likeCount || 0,
140140+ replies: post.replyCount || 0,
141141+ },
142142+ messages: convoMessages.map((message) => {
143143+ const profile = resolveDid(convo, message.did);
144144+145145+ return {
146146+ user: profile.displayName
147147+ ? `${profile.displayName} (${profile.handle})`
148148+ : `Handle: ${profile.handle}`,
149149+ text: message.text,
150150+ };
151151+ }),
152152+ });
153153+}
154154+155155+async function parseMessagePostUri(message: ChatMessage) {
156156+ if (!message.embed) return null;
157157+ const post = message.embed;
158158+ return post.uri;
159159+}
160160+161161+async function getRelevantMessages(convo: typeof conversations.$inferSelect) {
162162+ const convoMessages = await db
163163+ .select()
164164+ .from(messages)
165165+ .where(
166166+ and(
167167+ eq(messages.conversationId, convo.id),
168168+ eq(messages.postUri, convo!.postUri),
169169+ ),
170170+ )
171171+ .limit(15);
172172+173173+ return convoMessages;
174174+}
175175+176176+export async function saveMessage(
177177+ convo: Conversation,
178178+ did: string,
179179+ text: string,
180180+) {
181181+ const _convo = await getConvo(convo.id);
182182+ if (!_convo) {
183183+ throw new Error("Failed to find conversation with ID: " + convo.id);
184184+ }
185185+186186+ await db
187187+ .insert(messages)
188188+ .values({
189189+ conversationId: _convo.id,
190190+ postUri: _convo.postUri,
191191+ revision: _convo.postUri,
192192+ did,
193193+ text,
194194+ });
195195+}
196196+197197+export function exceedsGraphemes(content: string) {
198198+ return graphemeLength(content) > MAX_GRAPHEMES;
199199+}
200200+201201+export function splitResponse(text: string): string[] {
202202+ const words = text.split(" ");
203203+ const chunks: string[] = [];
204204+ let currentChunk = "";
205205+206206+ for (const word of words) {
207207+ if (currentChunk.length + word.length + 1 < MAX_GRAPHEMES - 10) {
208208+ currentChunk += ` ${word}`;
209209+ } else {
210210+ chunks.push(currentChunk.trim());
211211+ currentChunk = word;
212212+ }
213213+ }
214214+215215+ if (currentChunk.trim()) {
216216+ chunks.push(currentChunk.trim());
217217+ }
218218+219219+ const total = chunks.length;
220220+ if (total <= 1) return [text];
221221+222222+ return chunks.map((chunk, i) => `(${i + 1}/${total}) ${chunk}`);
223223+}
224224+225225+export async function multipartResponse(convo: Conversation, content: string) {
226226+ const parts = splitResponse(content).filter((p) => p.trim().length > 0);
227227+228228+ for (const segment of parts) {
229229+ await convo.sendMessage({
230230+ text: segment,
231231+ });
232232+ }
233233+}
+40
src/utils/thread.ts
···11+import { Post } from "@skyware/bot";
22+import * as c from "../core";
33+import * as yaml from "js-yaml";
44+55+/*
66+ Traversal
77+*/
88+export async function traverseThread(post: Post): Promise<Post[]> {
99+ const thread: Post[] = [
1010+ post,
1111+ ];
1212+ let currentPost: Post | undefined = post;
1313+ let parentCount = 0;
1414+1515+ while (
1616+ currentPost && parentCount < c.MAX_THREAD_DEPTH
1717+ ) {
1818+ const parentPost = await currentPost.fetchParent();
1919+2020+ if (parentPost) {
2121+ thread.push(parentPost);
2222+ currentPost = parentPost;
2323+ } else {
2424+ break;
2525+ }
2626+ parentCount++;
2727+ }
2828+2929+ return thread.reverse();
3030+}
3131+3232+export function parseThread(thread: Post[]) {
3333+ return yaml.dump({
3434+ uri: thread[0]!.uri,
3535+ posts: thread.map((post) => ({
3636+ author: `${post.author.displayName} (${post.author.handle})`,
3737+ text: post.text,
3838+ })),
3939+ });
4040+}