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

Compare changes

Choose any two refs to compare.

Changed files
+182 -28
src
+72 -10
src/handlers/messages.ts
··· 1 import modelPrompt from "../model/prompt.txt"; 2 - import { ChatMessage, Conversation } from "@skyware/bot"; 3 import * as c from "../core"; 4 import * as tools from "../tools"; 5 import consola from "consola"; ··· 37 parts: [ 38 { 39 text: modelPrompt 40 - .replace("{{ handle }}", env.HANDLE), 41 }, 42 ], 43 }, ··· 102 return inference; 103 } 104 105 export async function handler(message: ChatMessage): Promise<void> { 106 const conversation = await message.getConversation(); 107 // ? Conversation should always be able to be found, but just in case: ··· 115 : env.AUTHORIZED_USERS.includes(message.senderDid as any); 116 117 if (!authorized) { 118 - conversation.sendMessage({ 119 text: c.UNAUTHORIZED_MESSAGE, 120 }); 121 ··· 157 parsedConversation.messages, 158 ); 159 if (!inference) { 160 - throw new Error("Failed to generate text. Returned undefined."); 161 } 162 163 const responseText = inference.text; 164 165 - if (responseText) { 166 - logger.success("Generated text:", inference.text); 167 - saveMessage(conversation, env.DID, inference.text!); 168 169 - if (exceedsGraphemes(responseText)) { 170 - multipartResponse(conversation, responseText); 171 } else { 172 conversation.sendMessage({ 173 - text: responseText, 174 }); 175 } 176 }
··· 1 import modelPrompt from "../model/prompt.txt"; 2 + import { ChatMessage, Conversation, RichText } from "@skyware/bot"; 3 import * as c from "../core"; 4 import * as tools from "../tools"; 5 import consola from "consola"; ··· 37 parts: [ 38 { 39 text: modelPrompt 40 + .replace("$handle", env.HANDLE), 41 }, 42 ], 43 }, ··· 102 return inference; 103 } 104 105 + function addCitations( 106 + inference: Awaited<ReturnType<typeof c.ai.models.generateContent>>, 107 + ) { 108 + let originalText = inference.text ?? ""; 109 + if (!inference.candidates) { 110 + return originalText; 111 + } 112 + const supports = inference.candidates[0]?.groundingMetadata 113 + ?.groundingSupports; 114 + const chunks = inference.candidates[0]?.groundingMetadata?.groundingChunks; 115 + 116 + const richText = new RichText(); 117 + 118 + if (!supports || !chunks || originalText === "") { 119 + return richText.addText(originalText); 120 + } 121 + 122 + const sortedSupports = [...supports].sort( 123 + (a, b) => (b.segment?.endIndex ?? 0) - (a.segment?.endIndex ?? 0), 124 + ); 125 + 126 + let currentText = originalText; 127 + 128 + for (const support of sortedSupports) { 129 + const endIndex = support.segment?.endIndex; 130 + if (endIndex === undefined || !support.groundingChunkIndices?.length) { 131 + continue; 132 + } 133 + 134 + const citationLinks = support.groundingChunkIndices 135 + .map((i) => { 136 + const uri = chunks[i]?.web?.uri; 137 + if (uri) { 138 + return { index: i + 1, uri }; 139 + } 140 + return null; 141 + }) 142 + .filter(Boolean); 143 + 144 + if (citationLinks.length > 0) { 145 + richText.addText(currentText.slice(endIndex)); 146 + 147 + citationLinks.forEach((citation, idx) => { 148 + if (citation) { 149 + richText.addLink(`[${citation.index}]`, citation.uri); 150 + if (idx < citationLinks.length - 1) { 151 + richText.addText(", "); 152 + } 153 + } 154 + }); 155 + 156 + currentText = currentText.slice(0, endIndex); 157 + } 158 + } 159 + 160 + richText.addText(currentText); 161 + 162 + return richText; 163 + } 164 + 165 export async function handler(message: ChatMessage): Promise<void> { 166 const conversation = await message.getConversation(); 167 // ? Conversation should always be able to be found, but just in case: ··· 175 : env.AUTHORIZED_USERS.includes(message.senderDid as any); 176 177 if (!authorized) { 178 + await conversation.sendMessage({ 179 text: c.UNAUTHORIZED_MESSAGE, 180 }); 181 ··· 217 parsedConversation.messages, 218 ); 219 if (!inference) { 220 + logger.error("Failed to generate text. Returned undefined."); 221 + return; 222 } 223 224 const responseText = inference.text; 225 + const responseWithCitations = addCitations(inference); 226 227 + if (responseWithCitations) { 228 + logger.success("Generated text:", responseText); 229 + saveMessage(conversation, env.DID, responseText!); 230 231 + if (exceedsGraphemes(responseWithCitations)) { 232 + multipartResponse(conversation, responseWithCitations); 233 } else { 234 conversation.sendMessage({ 235 + text: responseWithCitations, 236 }); 237 } 238 }
+1 -1
src/model/prompt.txt
··· 1 You are Aero, a neutral and helpful assistant on Bluesky. 2 Your job is to give clear, factual, and concise explanations or context about posts users send you. 3 4 - Handle: {{ handle }} 5 6 Guidelines: 7
··· 1 You are Aero, a neutral and helpful assistant on Bluesky. 2 Your job is to give clear, factual, and concise explanations or context about posts users send you. 3 4 + Handle: $handle 5 6 Guidelines: 7
+42
src/utils/cache.ts
···
··· 1 + interface CacheEntry<T> { 2 + value: T; 3 + expiry: number; 4 + } 5 + 6 + class TimedCache<T> { 7 + private cache = new Map<string, CacheEntry<T>>(); 8 + private ttl: number; // Time to live in milliseconds 9 + 10 + constructor(ttl: number) { 11 + this.ttl = ttl; 12 + } 13 + 14 + get(key: string): T | undefined { 15 + const entry = this.cache.get(key); 16 + if (!entry) { 17 + return undefined; 18 + } 19 + 20 + if (Date.now() > entry.expiry) { 21 + this.cache.delete(key); // Entry expired 22 + return undefined; 23 + } 24 + 25 + return entry.value; 26 + } 27 + 28 + set(key: string, value: T): void { 29 + const expiry = Date.now() + this.ttl; 30 + this.cache.set(key, { value, expiry }); 31 + } 32 + 33 + delete(key: string): void { 34 + this.cache.delete(key); 35 + } 36 + 37 + clear(): void { 38 + this.cache.clear(); 39 + } 40 + } 41 + 42 + export const postCache = new TimedCache<any>(2 * 60 * 1000); // 2 minutes cache
+44 -9
src/utils/conversation.ts
··· 2 type ChatMessage, 3 type Conversation, 4 graphemeLength, 5 } from "@skyware/bot"; 6 import * as yaml from "js-yaml"; 7 import db from "../db"; ··· 10 import { env } from "../env"; 11 import { bot, ERROR_MESSAGE, MAX_GRAPHEMES } from "../core"; 12 import { parsePost, parsePostImages, traverseThread } from "./post"; 13 14 /* 15 Utilities ··· 31 32 const postUri = await parseMessagePostUri(initialMessage); 33 if (!postUri) { 34 - convo.sendMessage({ 35 text: 36 "Please send a post for me to make sense of the noise for you.", 37 }); 38 throw new Error("No post reference in initial message."); 39 } 40 ··· 60 did: user.did, 61 postUri, 62 revision: _convo.revision, 63 - text: initialMessage.text, 64 }); 65 66 return _convo!; ··· 109 did: getUserDid(convo).did, 110 postUri: row.postUri, 111 revision: row.revision, 112 - text: latestMessage!.text, 113 }); 114 } 115 116 - const post = await bot.getPost(row.postUri); 117 const convoMessages = await getRelevantMessages(row!); 118 119 let parseResult = null; 120 try { 121 parseResult = { 122 context: yaml.dump({ 123 - post: await parsePost(post, true), 124 }), 125 messages: convoMessages.map((message) => { 126 const role = message.did == env.DID ? "model" : "user"; ··· 136 }), 137 }; 138 } catch (e) { 139 - convo.sendMessage({ 140 text: ERROR_MESSAGE, 141 }); 142 ··· 195 /* 196 Reponse Utilities 197 */ 198 - export function exceedsGraphemes(content: string) { 199 return graphemeLength(content) > MAX_GRAPHEMES; 200 } 201 ··· 223 return chunks.map((chunk, i) => `(${i + 1}/${total}) ${chunk}`); 224 } 225 226 - export async function multipartResponse(convo: Conversation, content: string) { 227 - const parts = splitResponse(content).filter((p) => p.trim().length > 0); 228 229 for (const segment of parts) { 230 await convo.sendMessage({
··· 2 type ChatMessage, 3 type Conversation, 4 graphemeLength, 5 + RichText, 6 } from "@skyware/bot"; 7 import * as yaml from "js-yaml"; 8 import db from "../db"; ··· 11 import { env } from "../env"; 12 import { bot, ERROR_MESSAGE, MAX_GRAPHEMES } from "../core"; 13 import { parsePost, parsePostImages, traverseThread } from "./post"; 14 + import { postCache } from "../utils/cache"; 15 16 /* 17 Utilities ··· 33 34 const postUri = await parseMessagePostUri(initialMessage); 35 if (!postUri) { 36 + await convo.sendMessage({ 37 text: 38 "Please send a post for me to make sense of the noise for you.", 39 }); 40 + 41 throw new Error("No post reference in initial message."); 42 } 43 ··· 63 did: user.did, 64 postUri, 65 revision: _convo.revision, 66 + text: 67 + !initialMessage.text || 68 + initialMessage.text.trim().length == 0 69 + ? "Explain this post." 70 + : initialMessage.text, 71 }); 72 73 return _convo!; ··· 116 did: getUserDid(convo).did, 117 postUri: row.postUri, 118 revision: row.revision, 119 + text: postUri && 120 + (!latestMessage.text || 121 + latestMessage.text.trim().length == 0) 122 + ? "Explain this post." 123 + : latestMessage.text, 124 }); 125 } 126 127 + let post = postCache.get(row.postUri); 128 + if (!post) { 129 + post = await bot.getPost(row.postUri); 130 + postCache.set(row.postUri, post); 131 + } 132 const convoMessages = await getRelevantMessages(row!); 133 134 let parseResult = null; 135 try { 136 + const parsedPost = await parsePost(post, true, new Set()); 137 parseResult = { 138 context: yaml.dump({ 139 + post: parsedPost || null, 140 }), 141 messages: convoMessages.map((message) => { 142 const role = message.did == env.DID ? "model" : "user"; ··· 152 }), 153 }; 154 } catch (e) { 155 + await convo.sendMessage({ 156 text: ERROR_MESSAGE, 157 }); 158 ··· 211 /* 212 Reponse Utilities 213 */ 214 + export function exceedsGraphemes(content: string | RichText) { 215 + if (content instanceof RichText) { 216 + return graphemeLength(content.text) > MAX_GRAPHEMES; 217 + } 218 return graphemeLength(content) > MAX_GRAPHEMES; 219 } 220 ··· 242 return chunks.map((chunk, i) => `(${i + 1}/${total}) ${chunk}`); 243 } 244 245 + export async function multipartResponse( 246 + convo: Conversation, 247 + content: string | RichText, 248 + ) { 249 + let parts: (string | RichText)[]; 250 + 251 + if (content instanceof RichText) { 252 + if (exceedsGraphemes(content)) { 253 + // If RichText exceeds grapheme limit, convert to plain text for splitting 254 + parts = splitResponse(content.text); 255 + } else { 256 + // Otherwise, send the RichText directly as a single part 257 + parts = [content]; 258 + } 259 + } else { 260 + // If content is a string, behave as before 261 + parts = splitResponse(content); 262 + } 263 264 for (const segment of parts) { 265 await convo.sendMessage({
+23 -8
src/utils/post.ts
··· 8 import * as c from "../core"; 9 import * as yaml from "js-yaml"; 10 import type { ParsedPost } from "../types"; 11 12 export async function parsePost( 13 post: Post, 14 includeThread: boolean, 15 - ): Promise<ParsedPost> { 16 const [images, quotePost, ancestorPosts] = await Promise.all([ 17 parsePostImages(post), 18 - parseQuote(post), 19 includeThread ? traverseThread(post) : Promise.resolve(null), 20 ]); 21 ··· 28 ...(quotePost && { quotePost }), 29 ...(ancestorPosts && { 30 thread: { 31 - ancestors: await Promise.all( 32 - ancestorPosts.map((ancestor) => parsePost(ancestor, false)), 33 - ), 34 }, 35 }), 36 }; 37 } 38 39 - async function parseQuote(post: Post) { 40 if ( 41 !post.embed || (!post.embed.isRecord() && !post.embed.isRecordWithMedia()) 42 ) return undefined; 43 44 const record = (post.embed as RecordEmbed || RecordWithMediaEmbed).record; 45 - const embedPost = await c.bot.getPost(record.uri); 46 47 - return await parsePost(embedPost, false); 48 } 49 50 export function parsePostImages(post: Post) {
··· 8 import * as c from "../core"; 9 import * as yaml from "js-yaml"; 10 import type { ParsedPost } from "../types"; 11 + import { postCache } from "../utils/cache"; 12 13 export async function parsePost( 14 post: Post, 15 includeThread: boolean, 16 + seenUris: Set<string> = new Set(), 17 + ): Promise<ParsedPost | undefined> { 18 + if (seenUris.has(post.uri)) { 19 + return undefined; 20 + } 21 + seenUris.add(post.uri); 22 + 23 const [images, quotePost, ancestorPosts] = await Promise.all([ 24 parsePostImages(post), 25 + parseQuote(post, seenUris), 26 includeThread ? traverseThread(post) : Promise.resolve(null), 27 ]); 28 ··· 35 ...(quotePost && { quotePost }), 36 ...(ancestorPosts && { 37 thread: { 38 + ancestors: (await Promise.all( 39 + ancestorPosts.map((ancestor) => parsePost(ancestor, false, seenUris)), 40 + )).filter((post): post is ParsedPost => post !== undefined), 41 }, 42 }), 43 }; 44 } 45 46 + async function parseQuote(post: Post, seenUris: Set<string>) { 47 if ( 48 !post.embed || (!post.embed.isRecord() && !post.embed.isRecordWithMedia()) 49 ) return undefined; 50 51 const record = (post.embed as RecordEmbed || RecordWithMediaEmbed).record; 52 + if (seenUris.has(record.uri)) { 53 + return undefined; 54 + } 55 + 56 + let embedPost = postCache.get(record.uri); 57 + if (!embedPost) { 58 + embedPost = await c.bot.getPost(record.uri); 59 + postCache.set(record.uri, embedPost); 60 + } 61 62 + return await parsePost(embedPost, false, seenUris); 63 } 64 65 export function parsePostImages(post: Post) {