From 58211568a7bbb1aa0880b015a28a126df502875d Mon Sep 17 00:00:00 2001 From: Index Date: Wed, 30 Jul 2025 01:48:01 -0500 Subject: [PATCH] feat: authorized users env var & improved text splitting --- .env.example | 9 +++- src/constants.ts | 19 +++++++ src/env.ts | 8 ++- src/handlers/posts.ts | 59 +++++++++------------ src/model/prompt.txt | 2 +- src/utils/interactions.ts | 19 +++++++ src/utils/thread.ts | 108 +++++++++++--------------------------- 7 files changed, 110 insertions(+), 114 deletions(-) create mode 100644 src/constants.ts create mode 100644 src/utils/interactions.ts diff --git a/.env.example b/.env.example index af3cf22..9edcee6 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,18 @@ -SERVICE="https://pds.indexx.dev" +# Comma-separated list of users who can use the bot (delete var if you want everyone to be able to use it) +AUTHORIZED_USERS="" + +SERVICE="https://pds.indexx.dev" # PDS service URL (optional) DB_PATH="data/sqlite.db" GEMINI_MODEL="gemini-2.0-flash-lite" ADMIN_DID="" ADMIN_HANDLE="" + DID="" HANDLE="" + +# https://bsky.app/settings/app-passwords BSKY_PASSWORD="" +# https://aistudio.google.com/apikey GEMINI_API_KEY="" \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..0c15443 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,19 @@ +export const TAGS = [ + "automated", + "bot", + "genai", + "echo", +]; + +export const UNAUTHORIZED_MESSAGE = + "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! 🤖"; + +export const SUPPORTED_FUNCTION_CALLS = [ + "create_post", + "create_blog_post", + "mute_thread", +] as const; + +export const MAX_GRAPHEMES = 300; + +export const MAX_THREAD_DEPTH = 10; diff --git a/src/env.ts b/src/env.ts index a2f6a21..df55af5 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,9 +1,15 @@ import { z } from "zod"; const envSchema = z.object({ + AUTHORIZED_USERS: z.preprocess( + (val) => + (typeof val === "string" && val.trim() !== "") ? val.split(",") : null, + z.array(z.string()).nullable().default(null), + ), + SERVICE: z.string().default("https://bsky.social"), DB_PATH: z.string().default("sqlite.db"), - GEMINI_MODEL: z.string().default("gemini-2.0-flash"), + GEMINI_MODEL: z.string().default("gemini-2.5-flash"), ADMIN_DID: z.string(), ADMIN_HANDLE: z.string(), diff --git a/src/handlers/posts.ts b/src/handlers/posts.ts index fdbeea9..33869f2 100644 --- a/src/handlers/posts.ts +++ b/src/handlers/posts.ts @@ -7,38 +7,12 @@ import * as tools from "../tools"; import consola from "consola"; import { env } from "../env"; import db from "../db"; -import * as yaml from "js-yaml"; +import * as c from "../constants"; +import { isAuthorizedUser, logInteraction } from "../utils/interactions"; const logger = consola.withTag("Post Handler"); -const AUTHORIZED_USERS = [ - "did:plc:sfjxpxxyvewb2zlxwoz2vduw", - "did:plc:wfa54mpcbngzazwne3piz7fp", -] as const; - -const UNAUTHORIZED_MESSAGE = - "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! 🤖"; - -const SUPPORTED_FUNCTION_CALLS = [ - "create_post", - "create_blog_post", - "mute_thread", -] as const; - -type SupportedFunctionCall = typeof SUPPORTED_FUNCTION_CALLS[number]; - -async function isAuthorizedUser(did: string): Promise { - return AUTHORIZED_USERS.includes(did as any); -} - -async function logInteraction(post: Post): Promise { - await db.insert(interactions).values([{ - uri: post.uri, - did: post.author.did, - }]); - - logger.success(`Logged interaction, initiated by @${post.author.handle}`); -} +type SupportedFunctionCall = typeof c.SUPPORTED_FUNCTION_CALLS[number]; async function generateAIResponse(parsedThread: string) { const genai = new GoogleGenAI({ @@ -56,7 +30,17 @@ async function generateAIResponse(parsedThread: string) { { role: "model" as const, parts: [ - { text: modelPrompt }, + { + /* + ? Once memory blocks are working, this will pull the prompt from the database, and the prompt will be + ? automatically initialized with the administrator's handle from the env variables. I only did this so + ? that if anybody runs the code themselves, they just have to edit the env variables, nothing else. + */ + text: modelPrompt.replace( + "{{ administrator }}", + env.ADMIN_HANDLE, + ), + }, ], }, { @@ -85,7 +69,7 @@ async function generateAIResponse(parsedThread: string) { if ( call && - SUPPORTED_FUNCTION_CALLS.includes( + c.SUPPORTED_FUNCTION_CALLS.includes( call.name as SupportedFunctionCall, ) ) { @@ -127,14 +111,20 @@ async function sendResponse(post: Post, text: string): Promise { if (threadUtils.exceedsGraphemes(text)) { threadUtils.multipartResponse(text, post); } else { - post.reply({ text }); + post.reply({ + text, + tags: c.TAGS, + }); } } export async function handler(post: Post): Promise { try { - if (!await isAuthorizedUser(post.author.did)) { - await post.reply({ text: UNAUTHORIZED_MESSAGE }); + if (!isAuthorizedUser(post.author.did)) { + await post.reply({ + text: c.UNAUTHORIZED_MESSAGE, + tags: c.TAGS, + }); return; } @@ -162,6 +152,7 @@ export async function handler(post: Post): Promise { await post.reply({ text: "aw, shucks, something went wrong! gonna take a quick nap and try again later. 😴", + tags: c.TAGS, }); } } diff --git a/src/model/prompt.txt b/src/model/prompt.txt index 57b1ee4..e744d87 100644 --- a/src/model/prompt.txt +++ b/src/model/prompt.txt @@ -1,4 +1,4 @@ -you are echo, a bluesky bot powered by gemini 2.5 flash. your administrator is @indexx.dev. +you are echo, a bluesky bot powered by gemini 2.5 flash. your administrator is {{ administrator }}. 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. diff --git a/src/utils/interactions.ts b/src/utils/interactions.ts new file mode 100644 index 0000000..16d61bb --- /dev/null +++ b/src/utils/interactions.ts @@ -0,0 +1,19 @@ +import { interactions } from "../db/schema"; +import type { Post } from "@skyware/bot"; +import { env } from "../env"; +import db from "../db"; + +export function isAuthorizedUser(did: string) { + return env.AUTHORIZED_USERS == null + ? true + : env.AUTHORIZED_USERS.includes(did as any); +} + +export async function logInteraction(post: Post): Promise { + await db.insert(interactions).values([{ + uri: post.uri, + did: post.author.did, + }]); + + console.log(`Logged interaction, initiated by @${post.author.handle}`); +} diff --git a/src/utils/thread.ts b/src/utils/thread.ts index 6b0ca62..bd28157 100644 --- a/src/utils/thread.ts +++ b/src/utils/thread.ts @@ -4,9 +4,7 @@ import bot from "../bot"; import { muted_threads } from "../db/schema"; import { eq } from "drizzle-orm"; import db from "../db"; - -const MAX_GRAPHEMES = 290; -const MAX_THREAD_DEPTH = 10; +import * as c from "../constants"; /* Traversal @@ -19,7 +17,7 @@ export async function traverseThread(post: Post): Promise { let parentCount = 0; while ( - currentPost && parentCount < MAX_THREAD_DEPTH + currentPost && parentCount < c.MAX_THREAD_DEPTH ) { const parentPost = await currentPost.fetchParent(); @@ -47,98 +45,54 @@ export function parseThread(thread: Post[]) { /* Split Responses - * This code is AI generated, and a bit finicky. May re-do at some point */ export function exceedsGraphemes(content: string) { - return graphemeLength(content) > MAX_GRAPHEMES; + return graphemeLength(content) > c.MAX_GRAPHEMES; } -function splitResponse(content: string): string[] { - const rawParts: string[] = []; - let currentPart = ""; - let currentGraphemes = 0; +export function splitResponse(text: string): string[] { + const words = text.split(" "); + const chunks: string[] = []; + let currentChunk = ""; - const segmenter = new Intl.Segmenter("en-US", { granularity: "sentence" }); - const sentences = [...segmenter.segment(content)].map((s) => s.segment); - - for (const sentence of sentences) { - const sentenceGraphemes = graphemeLength(sentence); - if (currentGraphemes + sentenceGraphemes > MAX_GRAPHEMES) { - rawParts.push(currentPart.trim()); - currentPart = sentence; - currentGraphemes = sentenceGraphemes; + for (const word of words) { + if (currentChunk.length + word.length + 1 < c.MAX_GRAPHEMES - 10) { + currentChunk += ` ${word}`; } else { - currentPart += sentence; - currentGraphemes += sentenceGraphemes; + chunks.push(currentChunk.trim()); + currentChunk = word; } } - if (currentPart.trim().length > 0) { - rawParts.push(currentPart.trim()); + if (currentChunk.trim()) { + chunks.push(currentChunk.trim()); } - const totalParts = rawParts.length; + const total = chunks.length; + if (total <= 1) return [text]; - const finalParts: string[] = []; - - for (let i = 0; i < rawParts.length; i++) { - const prefix = `[${i + 1}/${totalParts}] `; - const base = rawParts[i]; - - if (graphemeLength(prefix + base) > MAX_GRAPHEMES) { - const segmenter = new Intl.Segmenter("en-US", { - granularity: "word", - }); - const words = [...segmenter.segment(base ?? "")].map((w) => w.segment); - let chunk = ""; - let chunkGraphemes = 0; - - for (const word of words) { - const wordGraphemes = graphemeLength(word); - const totalGraphemes = graphemeLength(prefix + chunk + word); - - if (totalGraphemes > MAX_GRAPHEMES) { - finalParts.push(`${prefix}${chunk.trim()}`); - chunk = word; - chunkGraphemes = wordGraphemes; - } else { - chunk += word; - chunkGraphemes += wordGraphemes; - } - } - - if (chunk.trim()) { - finalParts.push(`${prefix}${chunk.trim()}`); - } - } else { - finalParts.push(`${prefix}${base}`); - } - } - - return finalParts; + return chunks.map((chunk, i) => `(${i + 1}/${total}) ${chunk}`); } export async function multipartResponse(content: string, post?: Post) { - const parts = splitResponse(content); + const parts = splitResponse(content).filter((p) => p.trim().length > 0); - let root = null; - let latest: PostReference | null = null; + let latest: PostReference; + let rootUri: string; - for (const text of parts) { - if (latest == null) { - if (post) { - latest = await post.reply({ text }); - } else { - latest = await bot.post({ text }); - } - - root = latest.uri; - } else { - latest.reply({ text }); - } + if (post) { + rootUri = (post as any).rootUri ?? (post as any).uri; + latest = await post.reply({ text: parts[0]! }); + } else { + latest = await bot.post({ text: parts[0]! }); + rootUri = latest.uri; } - return root!; + for (const text of parts.slice(1)) { + latest = await latest.reply({ text }); + } + + return rootUri; } /*