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"
|
DB_PATH="data/sqlite.db"
|
||||||
GEMINI_MODEL="gemini-2.0-flash-lite"
|
GEMINI_MODEL="gemini-2.0-flash-lite"
|
||||||
|
|
||||||
ADMIN_DID=""
|
ADMIN_DID=""
|
||||||
ADMIN_HANDLE=""
|
ADMIN_HANDLE=""
|
||||||
|
|
||||||
DID=""
|
DID=""
|
||||||
HANDLE=""
|
HANDLE=""
|
||||||
|
|
||||||
|
# https://bsky.app/settings/app-passwords
|
||||||
BSKY_PASSWORD=""
|
BSKY_PASSWORD=""
|
||||||
|
|
||||||
|
# https://aistudio.google.com/apikey
|
||||||
GEMINI_API_KEY=""
|
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";
|
import { z } from "zod";
|
||||||
|
|
||||||
const envSchema = z.object({
|
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"),
|
SERVICE: z.string().default("https://bsky.social"),
|
||||||
DB_PATH: z.string().default("sqlite.db"),
|
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_DID: z.string(),
|
||||||
ADMIN_HANDLE: z.string(),
|
ADMIN_HANDLE: z.string(),
|
||||||
|
|
|
||||||
|
|
@ -7,38 +7,12 @@ import * as tools from "../tools";
|
||||||
import consola from "consola";
|
import consola from "consola";
|
||||||
import { env } from "../env";
|
import { env } from "../env";
|
||||||
import db from "../db";
|
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 logger = consola.withTag("Post Handler");
|
||||||
|
|
||||||
const AUTHORIZED_USERS = [
|
type SupportedFunctionCall = typeof c.SUPPORTED_FUNCTION_CALLS[number];
|
||||||
"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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateAIResponse(parsedThread: string) {
|
async function generateAIResponse(parsedThread: string) {
|
||||||
const genai = new GoogleGenAI({
|
const genai = new GoogleGenAI({
|
||||||
|
|
@ -56,7 +30,17 @@ async function generateAIResponse(parsedThread: string) {
|
||||||
{
|
{
|
||||||
role: "model" as const,
|
role: "model" as const,
|
||||||
parts: [
|
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 (
|
if (
|
||||||
call &&
|
call &&
|
||||||
SUPPORTED_FUNCTION_CALLS.includes(
|
c.SUPPORTED_FUNCTION_CALLS.includes(
|
||||||
call.name as SupportedFunctionCall,
|
call.name as SupportedFunctionCall,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
|
@ -127,14 +111,20 @@ async function sendResponse(post: Post, text: string): Promise<void> {
|
||||||
if (threadUtils.exceedsGraphemes(text)) {
|
if (threadUtils.exceedsGraphemes(text)) {
|
||||||
threadUtils.multipartResponse(text, post);
|
threadUtils.multipartResponse(text, post);
|
||||||
} else {
|
} else {
|
||||||
post.reply({ text });
|
post.reply({
|
||||||
|
text,
|
||||||
|
tags: c.TAGS,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handler(post: Post): Promise<void> {
|
export async function handler(post: Post): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (!await isAuthorizedUser(post.author.did)) {
|
if (!isAuthorizedUser(post.author.did)) {
|
||||||
await post.reply({ text: UNAUTHORIZED_MESSAGE });
|
await post.reply({
|
||||||
|
text: c.UNAUTHORIZED_MESSAGE,
|
||||||
|
tags: c.TAGS,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,6 +152,7 @@ export async function handler(post: Post): Promise<void> {
|
||||||
await post.reply({
|
await post.reply({
|
||||||
text:
|
text:
|
||||||
"aw, shucks, something went wrong! gonna take a quick nap and try again later. 😴",
|
"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.
|
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 { muted_threads } from "../db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import db from "../db";
|
import db from "../db";
|
||||||
|
import * as c from "../constants";
|
||||||
const MAX_GRAPHEMES = 290;
|
|
||||||
const MAX_THREAD_DEPTH = 10;
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Traversal
|
Traversal
|
||||||
|
|
@ -19,7 +17,7 @@ export async function traverseThread(post: Post): Promise<Post[]> {
|
||||||
let parentCount = 0;
|
let parentCount = 0;
|
||||||
|
|
||||||
while (
|
while (
|
||||||
currentPost && parentCount < MAX_THREAD_DEPTH
|
currentPost && parentCount < c.MAX_THREAD_DEPTH
|
||||||
) {
|
) {
|
||||||
const parentPost = await currentPost.fetchParent();
|
const parentPost = await currentPost.fetchParent();
|
||||||
|
|
||||||
|
|
@ -47,98 +45,54 @@ export function parseThread(thread: Post[]) {
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Split Responses
|
Split Responses
|
||||||
* This code is AI generated, and a bit finicky. May re-do at some point
|
|
||||||
*/
|
*/
|
||||||
export function exceedsGraphemes(content: string) {
|
export function exceedsGraphemes(content: string) {
|
||||||
return graphemeLength(content) > MAX_GRAPHEMES;
|
return graphemeLength(content) > c.MAX_GRAPHEMES;
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitResponse(content: string): string[] {
|
export function splitResponse(text: string): string[] {
|
||||||
const rawParts: string[] = [];
|
const words = text.split(" ");
|
||||||
let currentPart = "";
|
const chunks: string[] = [];
|
||||||
let currentGraphemes = 0;
|
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;
|
|
||||||
} else {
|
|
||||||
currentPart += sentence;
|
|
||||||
currentGraphemes += sentenceGraphemes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPart.trim().length > 0) {
|
|
||||||
rawParts.push(currentPart.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalParts = rawParts.length;
|
|
||||||
|
|
||||||
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) {
|
for (const word of words) {
|
||||||
const wordGraphemes = graphemeLength(word);
|
if (currentChunk.length + word.length + 1 < c.MAX_GRAPHEMES - 10) {
|
||||||
const totalGraphemes = graphemeLength(prefix + chunk + word);
|
currentChunk += ` ${word}`;
|
||||||
|
|
||||||
if (totalGraphemes > MAX_GRAPHEMES) {
|
|
||||||
finalParts.push(`${prefix}${chunk.trim()}`);
|
|
||||||
chunk = word;
|
|
||||||
chunkGraphemes = wordGraphemes;
|
|
||||||
} else {
|
} else {
|
||||||
chunk += word;
|
chunks.push(currentChunk.trim());
|
||||||
chunkGraphemes += wordGraphemes;
|
currentChunk = word;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chunk.trim()) {
|
if (currentChunk.trim()) {
|
||||||
finalParts.push(`${prefix}${chunk.trim()}`);
|
chunks.push(currentChunk.trim());
|
||||||
}
|
|
||||||
} else {
|
|
||||||
finalParts.push(`${prefix}${base}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return finalParts;
|
const total = chunks.length;
|
||||||
|
if (total <= 1) return [text];
|
||||||
|
|
||||||
|
return chunks.map((chunk, i) => `(${i + 1}/${total}) ${chunk}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function multipartResponse(content: string, post?: Post) {
|
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;
|
||||||
let latest: PostReference | null = null;
|
let rootUri: string;
|
||||||
|
|
||||||
for (const text of parts) {
|
|
||||||
if (latest == null) {
|
|
||||||
if (post) {
|
if (post) {
|
||||||
latest = await post.reply({ text });
|
rootUri = (post as any).rootUri ?? (post as any).uri;
|
||||||
|
latest = await post.reply({ text: parts[0]! });
|
||||||
} else {
|
} else {
|
||||||
latest = await bot.post({ text });
|
latest = await bot.post({ text: parts[0]! });
|
||||||
|
rootUri = latest.uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
root = latest.uri;
|
for (const text of parts.slice(1)) {
|
||||||
} else {
|
latest = await latest.reply({ text });
|
||||||
latest.reply({ text });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return root!;
|
return rootUri;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue