feat: authorized users env var & improved text splitting
This commit is contained in:
parent
8f23d40a99
commit
58211568a7
7 changed files with 110 additions and 114 deletions
|
|
@ -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
19
src/constants.ts
Normal 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;
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
19
src/utils/interactions.ts
Normal 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}`);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
|||
Loading…
Reference in a new issue