feat: authorized users env var & improved text splitting

This commit is contained in:
Index 2025-07-30 01:48:01 -05:00
parent 8f23d40a99
commit 58211568a7
7 changed files with 110 additions and 114 deletions

View file

@ -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=""

19
src/constants.ts Normal file
View file

@ -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;

View file

@ -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(),

View file

@ -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<boolean> {
return AUTHORIZED_USERS.includes(did as any);
}
async function logInteraction(post: Post): Promise<void> {
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<void> {
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<void> {
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<void> {
await post.reply({
text:
"aw, shucks, something went wrong! gonna take a quick nap and try again later. 😴",
tags: c.TAGS,
});
}
}

View file

@ -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.

19
src/utils/interactions.ts Normal file
View file

@ -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<void> {
await db.insert(interactions).values([{
uri: post.uri,
did: post.author.did,
}]);
console.log(`Logged interaction, initiated by @${post.author.handle}`);
}

View file

@ -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<Post[]> {
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;
}
/*