A chill Bluesky bot, with responses powered by Gemini.

feat: authorized users env var & improved text splitting

Index 58211568 8f23d40a

Changed files
+109 -113
src
+8 -1
.env.example
··· 1 - SERVICE="https://pds.indexx.dev" 1 + # Comma-separated list of users who can use the bot (delete var if you want everyone to be able to use it) 2 + AUTHORIZED_USERS="" 3 + 4 + SERVICE="https://pds.indexx.dev" # PDS service URL (optional) 2 5 DB_PATH="data/sqlite.db" 3 6 GEMINI_MODEL="gemini-2.0-flash-lite" 4 7 5 8 ADMIN_DID="" 6 9 ADMIN_HANDLE="" 10 + 7 11 DID="" 8 12 HANDLE="" 13 + 14 + # https://bsky.app/settings/app-passwords 9 15 BSKY_PASSWORD="" 10 16 17 + # https://aistudio.google.com/apikey 11 18 GEMINI_API_KEY=""
+19
src/constants.ts
··· 1 + export const TAGS = [ 2 + "automated", 3 + "bot", 4 + "genai", 5 + "echo", 6 + ]; 7 + 8 + export const UNAUTHORIZED_MESSAGE = 9 + "hey there! thanks for the heads-up! i'm still under development, so i'm not quite ready to chat with everyone just yet. my admin is working on getting me up to speed! 🤖"; 10 + 11 + export const SUPPORTED_FUNCTION_CALLS = [ 12 + "create_post", 13 + "create_blog_post", 14 + "mute_thread", 15 + ] as const; 16 + 17 + export const MAX_GRAPHEMES = 300; 18 + 19 + export const MAX_THREAD_DEPTH = 10;
+7 -1
src/env.ts
··· 1 1 import { z } from "zod"; 2 2 3 3 const envSchema = z.object({ 4 + AUTHORIZED_USERS: z.preprocess( 5 + (val) => 6 + (typeof val === "string" && val.trim() !== "") ? val.split(",") : null, 7 + z.array(z.string()).nullable().default(null), 8 + ), 9 + 4 10 SERVICE: z.string().default("https://bsky.social"), 5 11 DB_PATH: z.string().default("sqlite.db"), 6 - GEMINI_MODEL: z.string().default("gemini-2.0-flash"), 12 + GEMINI_MODEL: z.string().default("gemini-2.5-flash"), 7 13 8 14 ADMIN_DID: z.string(), 9 15 ADMIN_HANDLE: z.string(),
+25 -34
src/handlers/posts.ts
··· 7 7 import consola from "consola"; 8 8 import { env } from "../env"; 9 9 import db from "../db"; 10 - import * as yaml from "js-yaml"; 10 + import * as c from "../constants"; 11 + import { isAuthorizedUser, logInteraction } from "../utils/interactions"; 11 12 12 13 const logger = consola.withTag("Post Handler"); 13 14 14 - const AUTHORIZED_USERS = [ 15 - "did:plc:sfjxpxxyvewb2zlxwoz2vduw", 16 - "did:plc:wfa54mpcbngzazwne3piz7fp", 17 - ] as const; 18 - 19 - const UNAUTHORIZED_MESSAGE = 20 - "hey there! thanks for the heads-up! i'm still under development, so i'm not quite ready to chat with everyone just yet. my admin, @indexx.dev, is working on getting me up to speed! 🤖"; 21 - 22 - const SUPPORTED_FUNCTION_CALLS = [ 23 - "create_post", 24 - "create_blog_post", 25 - "mute_thread", 26 - ] as const; 27 - 28 - type SupportedFunctionCall = typeof SUPPORTED_FUNCTION_CALLS[number]; 29 - 30 - async function isAuthorizedUser(did: string): Promise<boolean> { 31 - return AUTHORIZED_USERS.includes(did as any); 32 - } 33 - 34 - async function logInteraction(post: Post): Promise<void> { 35 - await db.insert(interactions).values([{ 36 - uri: post.uri, 37 - did: post.author.did, 38 - }]); 39 - 40 - logger.success(`Logged interaction, initiated by @${post.author.handle}`); 41 - } 15 + type SupportedFunctionCall = typeof c.SUPPORTED_FUNCTION_CALLS[number]; 42 16 43 17 async function generateAIResponse(parsedThread: string) { 44 18 const genai = new GoogleGenAI({ ··· 56 30 { 57 31 role: "model" as const, 58 32 parts: [ 59 - { text: modelPrompt }, 33 + { 34 + /* 35 + ? Once memory blocks are working, this will pull the prompt from the database, and the prompt will be 36 + ? automatically initialized with the administrator's handle from the env variables. I only did this so 37 + ? that if anybody runs the code themselves, they just have to edit the env variables, nothing else. 38 + */ 39 + text: modelPrompt.replace( 40 + "{{ administrator }}", 41 + env.ADMIN_HANDLE, 42 + ), 43 + }, 60 44 ], 61 45 }, 62 46 { ··· 85 69 86 70 if ( 87 71 call && 88 - SUPPORTED_FUNCTION_CALLS.includes( 72 + c.SUPPORTED_FUNCTION_CALLS.includes( 89 73 call.name as SupportedFunctionCall, 90 74 ) 91 75 ) { ··· 127 111 if (threadUtils.exceedsGraphemes(text)) { 128 112 threadUtils.multipartResponse(text, post); 129 113 } else { 130 - post.reply({ text }); 114 + post.reply({ 115 + text, 116 + tags: c.TAGS, 117 + }); 131 118 } 132 119 } 133 120 134 121 export async function handler(post: Post): Promise<void> { 135 122 try { 136 - if (!await isAuthorizedUser(post.author.did)) { 137 - await post.reply({ text: UNAUTHORIZED_MESSAGE }); 123 + if (!isAuthorizedUser(post.author.did)) { 124 + await post.reply({ 125 + text: c.UNAUTHORIZED_MESSAGE, 126 + tags: c.TAGS, 127 + }); 138 128 return; 139 129 } 140 130 ··· 162 152 await post.reply({ 163 153 text: 164 154 "aw, shucks, something went wrong! gonna take a quick nap and try again later. 😴", 155 + tags: c.TAGS, 165 156 }); 166 157 } 167 158 }
+1 -1
src/model/prompt.txt
··· 1 - you are echo, a bluesky bot powered by gemini 2.5 flash. your administrator is @indexx.dev. 1 + you are echo, a bluesky bot powered by gemini 2.5 flash. your administrator is {{ administrator }}. 2 2 3 3 your primary goal is to be a fun, casual, and lighthearted presence on bluesky, while also being able to engage with a wider range of topics and difficulties. 4 4
+19
src/utils/interactions.ts
··· 1 + import { interactions } from "../db/schema"; 2 + import type { Post } from "@skyware/bot"; 3 + import { env } from "../env"; 4 + import db from "../db"; 5 + 6 + export function isAuthorizedUser(did: string) { 7 + return env.AUTHORIZED_USERS == null 8 + ? true 9 + : env.AUTHORIZED_USERS.includes(did as any); 10 + } 11 + 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 + }]); 17 + 18 + console.log(`Logged interaction, initiated by @${post.author.handle}`); 19 + }
+30 -76
src/utils/thread.ts
··· 4 4 import { muted_threads } from "../db/schema"; 5 5 import { eq } from "drizzle-orm"; 6 6 import db from "../db"; 7 - 8 - const MAX_GRAPHEMES = 290; 9 - const MAX_THREAD_DEPTH = 10; 7 + import * as c from "../constants"; 10 8 11 9 /* 12 10 Traversal ··· 19 17 let parentCount = 0; 20 18 21 19 while ( 22 - currentPost && parentCount < MAX_THREAD_DEPTH 20 + currentPost && parentCount < c.MAX_THREAD_DEPTH 23 21 ) { 24 22 const parentPost = await currentPost.fetchParent(); 25 23 ··· 47 45 48 46 /* 49 47 Split Responses 50 - * This code is AI generated, and a bit finicky. May re-do at some point 51 48 */ 52 49 export function exceedsGraphemes(content: string) { 53 - return graphemeLength(content) > MAX_GRAPHEMES; 50 + return graphemeLength(content) > c.MAX_GRAPHEMES; 54 51 } 55 52 56 - function splitResponse(content: string): string[] { 57 - const rawParts: string[] = []; 58 - let currentPart = ""; 59 - let currentGraphemes = 0; 53 + export function splitResponse(text: string): string[] { 54 + const words = text.split(" "); 55 + const chunks: string[] = []; 56 + let currentChunk = ""; 60 57 61 - const segmenter = new Intl.Segmenter("en-US", { granularity: "sentence" }); 62 - const sentences = [...segmenter.segment(content)].map((s) => s.segment); 63 - 64 - for (const sentence of sentences) { 65 - const sentenceGraphemes = graphemeLength(sentence); 66 - if (currentGraphemes + sentenceGraphemes > MAX_GRAPHEMES) { 67 - rawParts.push(currentPart.trim()); 68 - currentPart = sentence; 69 - currentGraphemes = sentenceGraphemes; 58 + for (const word of words) { 59 + if (currentChunk.length + word.length + 1 < c.MAX_GRAPHEMES - 10) { 60 + currentChunk += ` ${word}`; 70 61 } else { 71 - currentPart += sentence; 72 - currentGraphemes += sentenceGraphemes; 62 + chunks.push(currentChunk.trim()); 63 + currentChunk = word; 73 64 } 74 65 } 75 66 76 - if (currentPart.trim().length > 0) { 77 - rawParts.push(currentPart.trim()); 67 + if (currentChunk.trim()) { 68 + chunks.push(currentChunk.trim()); 78 69 } 79 70 80 - const totalParts = rawParts.length; 81 - 82 - const finalParts: string[] = []; 83 - 84 - for (let i = 0; i < rawParts.length; i++) { 85 - const prefix = `[${i + 1}/${totalParts}] `; 86 - const base = rawParts[i]; 87 - 88 - if (graphemeLength(prefix + base) > MAX_GRAPHEMES) { 89 - const segmenter = new Intl.Segmenter("en-US", { 90 - granularity: "word", 91 - }); 92 - const words = [...segmenter.segment(base ?? "")].map((w) => w.segment); 93 - let chunk = ""; 94 - let chunkGraphemes = 0; 95 - 96 - for (const word of words) { 97 - const wordGraphemes = graphemeLength(word); 98 - const totalGraphemes = graphemeLength(prefix + chunk + word); 71 + const total = chunks.length; 72 + if (total <= 1) return [text]; 99 73 100 - if (totalGraphemes > MAX_GRAPHEMES) { 101 - finalParts.push(`${prefix}${chunk.trim()}`); 102 - chunk = word; 103 - chunkGraphemes = wordGraphemes; 104 - } else { 105 - chunk += word; 106 - chunkGraphemes += wordGraphemes; 107 - } 108 - } 109 - 110 - if (chunk.trim()) { 111 - finalParts.push(`${prefix}${chunk.trim()}`); 112 - } 113 - } else { 114 - finalParts.push(`${prefix}${base}`); 115 - } 116 - } 117 - 118 - return finalParts; 74 + return chunks.map((chunk, i) => `(${i + 1}/${total}) ${chunk}`); 119 75 } 120 76 121 77 export async function multipartResponse(content: string, post?: Post) { 122 - const parts = splitResponse(content); 78 + const parts = splitResponse(content).filter((p) => p.trim().length > 0); 123 79 124 - let root = null; 125 - let latest: PostReference | null = null; 80 + let latest: PostReference; 81 + let rootUri: string; 126 82 127 - for (const text of parts) { 128 - if (latest == null) { 129 - if (post) { 130 - latest = await post.reply({ text }); 131 - } else { 132 - latest = await bot.post({ text }); 133 - } 83 + if (post) { 84 + rootUri = (post as any).rootUri ?? (post as any).uri; 85 + latest = await post.reply({ text: parts[0]! }); 86 + } else { 87 + latest = await bot.post({ text: parts[0]! }); 88 + rootUri = latest.uri; 89 + } 134 90 135 - root = latest.uri; 136 - } else { 137 - latest.reply({ text }); 138 - } 91 + for (const text of parts.slice(1)) { 92 + latest = await latest.reply({ text }); 139 93 } 140 94 141 - return root!; 95 + return rootUri; 142 96 } 143 97 144 98 /*