A chill Bluesky bot, with responses powered by Gemini.

I've made some improvements to how I learn from our interactions.

I've updated the way I log our conversations so I can better remember your posts, my responses, and whether a particular conversation is muted.

To give you more relevant and personalized responses, I'll also review our last five interactions to get better context for your requests. I'll be sure to exclude messages from our current conversation to avoid repeating myself.

+4 -4
bun.lock
··· 5 5 "name": "bsky-echo", 6 6 "dependencies": { 7 7 "@atproto/syntax": "^0.4.0", 8 - "@google/genai": "^1.10.0", 8 + "@google/genai": "^1.11.0", 9 9 "@skyware/bot": "^0.3.12", 10 10 "@types/js-yaml": "^4.0.9", 11 11 "consola": "^3.4.2", 12 - "drizzle-orm": "^0.44.3", 12 + "drizzle-orm": "^0.44.4", 13 13 "js-yaml": "^4.1.0", 14 - "zod": "^4.0.5", 14 + "zod": "^4.0.14", 15 15 }, 16 16 "devDependencies": { 17 17 "@types/bun": "^1.2.19", 18 18 "drizzle-kit": "^0.31.4", 19 19 }, 20 20 "peerDependencies": { 21 - "typescript": "^5", 21 + "typescript": "^5.8.3", 22 22 }, 23 23 }, 24 24 },
+3
drizzle/0003_flowery_korvac.sql
··· 1 + ALTER TABLE `interactions` ADD `post` text;--> statement-breakpoint 2 + ALTER TABLE `interactions` ADD `response` text;--> statement-breakpoint 3 + ALTER TABLE `interactions` ADD `muted` integer;
+255
drizzle/meta/0003_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "30d38111-8e11-4d7d-99e8-cbafd962ca62", 5 + "prevId": "11e8b31f-8e38-4013-8d50-bec6177b015a", 6 + "tables": { 7 + "interactions": { 8 + "name": "interactions", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "integer", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": true 16 + }, 17 + "uri": { 18 + "name": "uri", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": false, 22 + "autoincrement": false 23 + }, 24 + "did": { 25 + "name": "did", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": false, 29 + "autoincrement": false 30 + }, 31 + "post": { 32 + "name": "post", 33 + "type": "text", 34 + "primaryKey": false, 35 + "notNull": false, 36 + "autoincrement": false 37 + }, 38 + "response": { 39 + "name": "response", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false 44 + }, 45 + "muted": { 46 + "name": "muted", 47 + "type": "integer", 48 + "primaryKey": false, 49 + "notNull": false, 50 + "autoincrement": false 51 + }, 52 + "created_at": { 53 + "name": "created_at", 54 + "type": "integer", 55 + "primaryKey": false, 56 + "notNull": false, 57 + "autoincrement": false, 58 + "default": "CURRENT_TIMESTAMP" 59 + } 60 + }, 61 + "indexes": { 62 + "interactions_uri_unique": { 63 + "name": "interactions_uri_unique", 64 + "columns": [ 65 + "uri" 66 + ], 67 + "isUnique": true 68 + } 69 + }, 70 + "foreignKeys": {}, 71 + "compositePrimaryKeys": {}, 72 + "uniqueConstraints": {}, 73 + "checkConstraints": {} 74 + }, 75 + "memory_block_entries": { 76 + "name": "memory_block_entries", 77 + "columns": { 78 + "id": { 79 + "name": "id", 80 + "type": "integer", 81 + "primaryKey": true, 82 + "notNull": true, 83 + "autoincrement": true 84 + }, 85 + "block_id": { 86 + "name": "block_id", 87 + "type": "integer", 88 + "primaryKey": false, 89 + "notNull": true, 90 + "autoincrement": false 91 + }, 92 + "label": { 93 + "name": "label", 94 + "type": "text", 95 + "primaryKey": false, 96 + "notNull": true, 97 + "autoincrement": false 98 + }, 99 + "value": { 100 + "name": "value", 101 + "type": "text", 102 + "primaryKey": false, 103 + "notNull": true, 104 + "autoincrement": false 105 + }, 106 + "added_by": { 107 + "name": "added_by", 108 + "type": "text", 109 + "primaryKey": false, 110 + "notNull": false, 111 + "autoincrement": false 112 + }, 113 + "created_at": { 114 + "name": "created_at", 115 + "type": "integer", 116 + "primaryKey": false, 117 + "notNull": false, 118 + "autoincrement": false, 119 + "default": "CURRENT_TIMESTAMP" 120 + } 121 + }, 122 + "indexes": {}, 123 + "foreignKeys": { 124 + "memory_block_entries_block_id_memory_blocks_id_fk": { 125 + "name": "memory_block_entries_block_id_memory_blocks_id_fk", 126 + "tableFrom": "memory_block_entries", 127 + "tableTo": "memory_blocks", 128 + "columnsFrom": [ 129 + "block_id" 130 + ], 131 + "columnsTo": [ 132 + "id" 133 + ], 134 + "onDelete": "no action", 135 + "onUpdate": "no action" 136 + } 137 + }, 138 + "compositePrimaryKeys": {}, 139 + "uniqueConstraints": {}, 140 + "checkConstraints": {} 141 + }, 142 + "memory_blocks": { 143 + "name": "memory_blocks", 144 + "columns": { 145 + "id": { 146 + "name": "id", 147 + "type": "integer", 148 + "primaryKey": true, 149 + "notNull": true, 150 + "autoincrement": true 151 + }, 152 + "did": { 153 + "name": "did", 154 + "type": "text", 155 + "primaryKey": false, 156 + "notNull": true, 157 + "autoincrement": false 158 + }, 159 + "name": { 160 + "name": "name", 161 + "type": "text", 162 + "primaryKey": false, 163 + "notNull": true, 164 + "autoincrement": false, 165 + "default": "'memory'" 166 + }, 167 + "description": { 168 + "name": "description", 169 + "type": "text", 170 + "primaryKey": false, 171 + "notNull": true, 172 + "autoincrement": false, 173 + "default": "'User memory'" 174 + }, 175 + "mutable": { 176 + "name": "mutable", 177 + "type": "integer", 178 + "primaryKey": false, 179 + "notNull": true, 180 + "autoincrement": false, 181 + "default": false 182 + } 183 + }, 184 + "indexes": {}, 185 + "foreignKeys": {}, 186 + "compositePrimaryKeys": {}, 187 + "uniqueConstraints": {}, 188 + "checkConstraints": {} 189 + }, 190 + "muted_threads": { 191 + "name": "muted_threads", 192 + "columns": { 193 + "id": { 194 + "name": "id", 195 + "type": "integer", 196 + "primaryKey": true, 197 + "notNull": true, 198 + "autoincrement": true 199 + }, 200 + "uri": { 201 + "name": "uri", 202 + "type": "text", 203 + "primaryKey": false, 204 + "notNull": false, 205 + "autoincrement": false 206 + }, 207 + "rkey": { 208 + "name": "rkey", 209 + "type": "text", 210 + "primaryKey": false, 211 + "notNull": false, 212 + "autoincrement": false 213 + }, 214 + "muted_at": { 215 + "name": "muted_at", 216 + "type": "integer", 217 + "primaryKey": false, 218 + "notNull": false, 219 + "autoincrement": false, 220 + "default": "CURRENT_TIMESTAMP" 221 + } 222 + }, 223 + "indexes": { 224 + "muted_threads_uri_unique": { 225 + "name": "muted_threads_uri_unique", 226 + "columns": [ 227 + "uri" 228 + ], 229 + "isUnique": true 230 + }, 231 + "muted_threads_rkey_unique": { 232 + "name": "muted_threads_rkey_unique", 233 + "columns": [ 234 + "rkey" 235 + ], 236 + "isUnique": true 237 + } 238 + }, 239 + "foreignKeys": {}, 240 + "compositePrimaryKeys": {}, 241 + "uniqueConstraints": {}, 242 + "checkConstraints": {} 243 + } 244 + }, 245 + "views": {}, 246 + "enums": {}, 247 + "_meta": { 248 + "schemas": {}, 249 + "tables": {}, 250 + "columns": {} 251 + }, 252 + "internal": { 253 + "indexes": {} 254 + } 255 + }
+7
drizzle/meta/_journal.json
··· 22 22 "when": 1753682242260, 23 23 "tag": "0002_green_millenium_guard", 24 24 "breakpoints": true 25 + }, 26 + { 27 + "idx": 3, 28 + "version": "6", 29 + "when": 1754166687687, 30 + "tag": "0003_flowery_korvac", 31 + "breakpoints": true 25 32 } 26 33 ] 27 34 }
sqlite.db

This is a binary file and will not be displayed.

+2 -1
src/db/index.ts
··· 1 1 import { drizzle } from "drizzle-orm/bun-sqlite"; 2 2 import { Database } from "bun:sqlite"; 3 + import * as schema from "./schema"; 3 4 import { env } from "../env"; 4 5 5 6 const sqlite = new Database(env.DB_PATH); 6 - export default drizzle(sqlite); 7 + export default drizzle(sqlite, { schema });
+3
src/db/schema.ts
··· 5 5 id: integer().primaryKey({ autoIncrement: true }), 6 6 uri: text().unique(), 7 7 did: text(), 8 + post: text(), 9 + response: text(), 10 + muted: integer({ mode: "boolean" }), 8 11 created_at: integer({ mode: "timestamp" }).default(sql`CURRENT_TIMESTAMP`), 9 12 }); 10 13
+22 -4
src/handlers/posts.ts
··· 1 - import { isAuthorizedUser, logInteraction } from "../utils/interactions"; 1 + import { 2 + isAuthorizedUser, 3 + logInteraction, 4 + getRecentInteractions, 5 + } from "../utils/interactions"; 2 6 import * as threadUtils from "../utils/thread"; 3 7 import modelPrompt from "../model/prompt.txt"; 4 8 import { GoogleGenAI } from "@google/genai"; ··· 131 135 return; 132 136 } 133 137 134 - await logInteraction(post); 135 - 136 - if (await threadUtils.isThreadMuted(post)) { 138 + const isMuted = await threadUtils.isThreadMuted(post); 139 + if (isMuted) { 137 140 logger.warn("Thread is muted."); 141 + await logInteraction(post, { 142 + responseText: null, 143 + wasMuted: true, 144 + }); 138 145 return; 139 146 } 140 147 ··· 151 158 await MemoryHandler.getBlocks(post.author.did), 152 159 ); 153 160 161 + const recentInteractions = await getRecentInteractions( 162 + post.author.did, 163 + thread, 164 + ); 165 + 154 166 const memory = yaml.dump({ 155 167 users_with_memory_blocks: { 156 168 [env.HANDLE]: botMemory.parseBlocks(), 157 169 [post.author.handle]: userMemory.parseBlocks(), 158 170 }, 171 + recent_interactions: recentInteractions, 159 172 }); 160 173 161 174 logger.log("Parsed memory blocks: ", memory); ··· 167 180 if (responseText) { 168 181 await sendResponse(post, responseText); 169 182 } 183 + 184 + await logInteraction(post, { 185 + responseText: responseText ?? null, 186 + wasMuted: false, 187 + }); 170 188 } catch (error) { 171 189 logger.error("Error in post handler:", error); 172 190
+37 -6
src/utils/interactions.ts
··· 1 1 import { interactions } from "../db/schema"; 2 2 import type { Post } from "@skyware/bot"; 3 + import { desc, notInArray } from "drizzle-orm"; 3 4 import { env } from "../env"; 4 5 import db from "../db"; 5 6 ··· 9 10 : env.AUTHORIZED_USERS.includes(did as any); 10 11 } 11 12 12 - export async function logInteraction(post: Post): Promise<void> { 13 - await db.insert(interactions).values([{ 14 - uri: post.uri, 15 - did: post.author.did, 16 - }]); 13 + export async function logInteraction( 14 + post: Post, 15 + options: { 16 + responseText: string | null; 17 + wasMuted: boolean; 18 + }, 19 + ): Promise<void> { 20 + await db.insert(interactions).values([ 21 + { 22 + uri: post.uri, 23 + did: post.author.did, 24 + post: post.text, 25 + response: options.responseText, 26 + muted: options.wasMuted, 27 + }, 28 + ]); 29 + 30 + console.log(`Logged interaction, initiated by @${post.author.handle}`); 31 + } 32 + 33 + export async function getRecentInteractions(did: string, thread: Post[]) { 34 + const threadUris = thread.map((p) => p.uri); 35 + 36 + const recentInteractions = await db.query.interactions.findMany({ 37 + where: (interactions, { eq, and, notInArray }) => 38 + and( 39 + eq(interactions.did, did), 40 + notInArray(interactions.uri, threadUris), 41 + ), 42 + orderBy: (interactions, { desc }) => [desc(interactions.created_at)], 43 + limit: 5, 44 + }); 17 45 18 - console.log(`Logged interaction, initiated by @${post.author.handle}`); 46 + return recentInteractions.map((i) => ({ 47 + post: i.post, 48 + response: i.response, 49 + })); 19 50 }