A simple Bluesky bot to make sense of the noise, with responses powered by Gemini, similar to Grok.
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}