···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)
5DB_PATH="data/sqlite.db"
6GEMINI_MODEL="gemini-2.0-flash-lite"
78ADMIN_DID=""
9ADMIN_HANDLE=""
10+11DID=""
12HANDLE=""
13+14+# https://bsky.app/settings/app-passwords
15BSKY_PASSWORD=""
1617+# https://aistudio.google.com/apikey
18GEMINI_API_KEY=""
+19
src/constants.ts
···0000000000000000000
···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;
···7import consola from "consola";
8import { env } from "../env";
9import db from "../db";
10-import * as yaml from "js-yaml";
01112const logger = consola.withTag("Post Handler");
1314-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-}
4243async function generateAIResponse(parsedThread: string) {
44 const genai = new GoogleGenAI({
···56 {
57 role: "model" as const,
58 parts: [
59- { text: modelPrompt },
000000000060 ],
61 },
62 {
···8586 if (
87 call &&
88- SUPPORTED_FUNCTION_CALLS.includes(
89 call.name as SupportedFunctionCall,
90 )
91 ) {
···127 if (threadUtils.exceedsGraphemes(text)) {
128 threadUtils.multipartResponse(text, post);
129 } else {
130- post.reply({ text });
000131 }
132}
133134export async function handler(post: Post): Promise<void> {
135 try {
136- if (!await isAuthorizedUser(post.author.did)) {
137- await post.reply({ text: UNAUTHORIZED_MESSAGE });
000138 return;
139 }
140···162 await post.reply({
163 text:
164 "aw, shucks, something went wrong! gonna take a quick nap and try again later. 😴",
0165 });
166 }
167}
···7import consola from "consola";
8import { env } from "../env";
9import db from "../db";
10+import * as c from "../constants";
11+import { isAuthorizedUser, logInteraction } from "../utils/interactions";
1213const logger = consola.withTag("Post Handler");
1415+type SupportedFunctionCall = typeof c.SUPPORTED_FUNCTION_CALLS[number];
0000000000000000000000000001617async function generateAIResponse(parsedThread: string) {
18 const genai = new GoogleGenAI({
···30 {
31 role: "model" as const,
32 parts: [
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+ },
44 ],
45 },
46 {
···6970 if (
71 call &&
72+ c.SUPPORTED_FUNCTION_CALLS.includes(
73 call.name as SupportedFunctionCall,
74 )
75 ) {
···111 if (threadUtils.exceedsGraphemes(text)) {
112 threadUtils.multipartResponse(text, post);
113 } else {
114+ post.reply({
115+ text,
116+ tags: c.TAGS,
117+ });
118 }
119}
120121export async function handler(post: Post): Promise<void> {
122 try {
123+ if (!isAuthorizedUser(post.author.did)) {
124+ await post.reply({
125+ text: c.UNAUTHORIZED_MESSAGE,
126+ tags: c.TAGS,
127+ });
128 return;
129 }
130···152 await post.reply({
153 text:
154 "aw, shucks, something went wrong! gonna take a quick nap and try again later. 😴",
155+ tags: c.TAGS,
156 });
157 }
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.
23your 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
···1+you are echo, a bluesky bot powered by gemini 2.5 flash. your administrator is {{ administrator }}.
23your 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
+19
src/utils/interactions.ts
···0000000000000000000
···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
···4import { muted_threads } from "../db/schema";
5import { eq } from "drizzle-orm";
6import db from "../db";
7-8-const MAX_GRAPHEMES = 290;
9-const MAX_THREAD_DEPTH = 10;
1011/*
12 Traversal
···19 let parentCount = 0;
2021 while (
22- currentPost && parentCount < MAX_THREAD_DEPTH
23 ) {
24 const parentPost = await currentPost.fetchParent();
25···4748/*
49 Split Responses
50- * This code is AI generated, and a bit finicky. May re-do at some point
51*/
52export function exceedsGraphemes(content: string) {
53- return graphemeLength(content) > MAX_GRAPHEMES;
54}
5556-function splitResponse(content: string): string[] {
57- const rawParts: string[] = [];
58- let currentPart = "";
59- let currentGraphemes = 0;
6061- 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;
70 } else {
71- currentPart += sentence;
72- currentGraphemes += sentenceGraphemes;
73 }
74 }
7576- if (currentPart.trim().length > 0) {
77- rawParts.push(currentPart.trim());
78 }
7980- 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);
99100- 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;
119}
120121export async function multipartResponse(content: string, post?: Post) {
122- const parts = splitResponse(content);
123124- let root = null;
125- let latest: PostReference | null = null;
126127- 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- }
134135- root = latest.uri;
136- } else {
137- latest.reply({ text });
138- }
139 }
140141- return root!;
142}
143144/*
···4import { muted_threads } from "../db/schema";
5import { eq } from "drizzle-orm";
6import db from "../db";
7+import * as c from "../constants";
0089/*
10 Traversal
···17 let parentCount = 0;
1819 while (
20+ currentPost && parentCount < c.MAX_THREAD_DEPTH
21 ) {
22 const parentPost = await currentPost.fetchParent();
23···4546/*
47 Split Responses
048*/
49export function exceedsGraphemes(content: string) {
50+ return graphemeLength(content) > c.MAX_GRAPHEMES;
51}
5253+export function splitResponse(text: string): string[] {
54+ const words = text.split(" ");
55+ const chunks: string[] = [];
56+ let currentChunk = "";
5758+ for (const word of words) {
59+ if (currentChunk.length + word.length + 1 < c.MAX_GRAPHEMES - 10) {
60+ currentChunk += ` ${word}`;
00000061 } else {
62+ chunks.push(currentChunk.trim());
63+ currentChunk = word;
64 }
65 }
6667+ if (currentChunk.trim()) {
68+ chunks.push(currentChunk.trim());
69 }
7071+ const total = chunks.length;
72+ if (total <= 1) return [text];
000000000000000007374+ return chunks.map((chunk, i) => `(${i + 1}/${total}) ${chunk}`);
00000000000000000075}
7677export async function multipartResponse(content: string, post?: Post) {
78+ const parts = splitResponse(content).filter((p) => p.trim().length > 0);
7980+ let latest: PostReference;
81+ let rootUri: string;
8283+ 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+ }
9091+ for (const text of parts.slice(1)) {
92+ latest = await latest.reply({ text });
0093 }
9495+ return rootUri;
96}
9798/*