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

Configure Feed

Select the types of activity you want to include in your feed.

at main 269 lines 7.2 kB view raw
1import { 2 type ChatMessage, 3 type Conversation, 4 graphemeLength, 5 RichText, 6} from "@skyware/bot"; 7import * as yaml from "js-yaml"; 8import db from "../db"; 9import { conversations, messages } from "../db/schema"; 10import { and, eq } from "drizzle-orm"; 11import { env } from "../env"; 12import { bot, ERROR_MESSAGE, MAX_GRAPHEMES } from "../core"; 13import { parsePost, parsePostImages, traverseThread } from "./post"; 14import { postCache } from "../utils/cache"; 15 16/* 17 Utilities 18*/ 19const getUserDid = (convo: Conversation) => 20 convo.members.find((actor) => actor.did != env.DID)!; 21 22function generateRevision(bytes = 8) { 23 const array = new Uint8Array(bytes); 24 crypto.getRandomValues(array); 25 return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join(""); 26} 27 28/* 29 Conversations 30*/ 31async function initConvo(convo: Conversation, initialMessage: ChatMessage) { 32 const user = getUserDid(convo); 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 44 return await db.transaction(async (tx) => { 45 const [_convo] = await tx 46 .insert(conversations) 47 .values({ 48 id: convo.id, 49 did: user.did, 50 postUri, 51 revision: generateRevision(), 52 }) 53 .returning(); 54 55 if (!_convo) { 56 throw new Error("Error during database transaction"); 57 } 58 59 await tx 60 .insert(messages) 61 .values({ 62 conversationId: _convo.id, 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!; 74 }); 75} 76 77async function getConvo(convoId: string) { 78 const [convo] = await db 79 .select() 80 .from(conversations) 81 .where(eq(conversations.id, convoId)) 82 .limit(1); 83 84 return convo; 85} 86 87export async function parseConversation( 88 convo: Conversation, 89 latestMessage: ChatMessage, 90) { 91 let row = await getConvo(convo.id); 92 if (!row) { 93 row = await initConvo(convo, latestMessage); 94 } else { 95 const postUri = await parseMessagePostUri(latestMessage); 96 if (postUri) { 97 const [updatedRow] = await db 98 .update(conversations) 99 .set({ 100 postUri, 101 revision: generateRevision(), 102 }) 103 .returning(); 104 105 if (!updatedRow) { 106 throw new Error("Failed to update conversation in database"); 107 } 108 109 row = updatedRow; 110 } 111 112 await db 113 .insert(messages) 114 .values({ 115 conversationId: convo.id, 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"; 143 144 return { 145 role, 146 parts: [ 147 { 148 text: message.text, 149 }, 150 ], 151 }; 152 }), 153 }; 154 } catch (e) { 155 await convo.sendMessage({ 156 text: ERROR_MESSAGE, 157 }); 158 159 throw new Error("Failed to parse conversation"); 160 } 161 162 return parseResult; 163} 164 165/* 166 Messages 167*/ 168async function parseMessagePostUri(message: ChatMessage) { 169 if (!message.embed) return null; 170 const post = message.embed; 171 return post.uri; 172} 173 174async function getRelevantMessages(convo: typeof conversations.$inferSelect) { 175 const convoMessages = await db 176 .select() 177 .from(messages) 178 .where( 179 and( 180 eq(messages.conversationId, convo.id), 181 eq(messages.postUri, convo.postUri), 182 eq(messages.revision, convo.revision), 183 ), 184 ) 185 .limit(15); 186 187 return convoMessages; 188} 189 190export async function saveMessage( 191 convo: Conversation, 192 did: string, 193 text: string, 194) { 195 const _convo = await getConvo(convo.id); 196 if (!_convo) { 197 throw new Error("Failed to find conversation with ID: " + convo.id); 198 } 199 200 await db 201 .insert(messages) 202 .values({ 203 conversationId: _convo.id, 204 postUri: _convo.postUri, 205 revision: _convo.revision, 206 did, 207 text, 208 }); 209} 210 211/* 212 Reponse Utilities 213*/ 214export 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 221export function splitResponse(text: string): string[] { 222 const words = text.split(" "); 223 const chunks: string[] = []; 224 let currentChunk = ""; 225 226 for (const word of words) { 227 if (currentChunk.length + word.length + 1 < MAX_GRAPHEMES - 10) { 228 currentChunk += ` ${word}`; 229 } else { 230 chunks.push(currentChunk.trim()); 231 currentChunk = word; 232 } 233 } 234 235 if (currentChunk.trim()) { 236 chunks.push(currentChunk.trim()); 237 } 238 239 const total = chunks.length; 240 if (total <= 1) return [text]; 241 242 return chunks.map((chunk, i) => `(${i + 1}/${total}) ${chunk}`); 243} 244 245export 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({ 266 text: segment, 267 }); 268 } 269}