+8
-1
.env.example
+8
-1
.env.example
···
1
-
SERVICE="https://pds.indexx.dev"
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)
2
5
DB_PATH="data/sqlite.db"
3
6
GEMINI_MODEL="gemini-2.0-flash-lite"
4
7
5
8
ADMIN_DID=""
6
9
ADMIN_HANDLE=""
10
+
7
11
DID=""
8
12
HANDLE=""
13
+
14
+
# https://bsky.app/settings/app-passwords
9
15
BSKY_PASSWORD=""
10
16
17
+
# https://aistudio.google.com/apikey
11
18
GEMINI_API_KEY=""
+19
src/constants.ts
+19
src/constants.ts
···
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;
+7
-1
src/env.ts
+7
-1
src/env.ts
···
1
1
import { z } from "zod";
2
2
3
3
const envSchema = z.object({
4
+
AUTHORIZED_USERS: z.preprocess(
5
+
(val) =>
6
+
(typeof val === "string" && val.trim() !== "") ? val.split(",") : null,
7
+
z.array(z.string()).nullable().default(null),
8
+
),
9
+
4
10
SERVICE: z.string().default("https://bsky.social"),
5
11
DB_PATH: z.string().default("sqlite.db"),
6
-
GEMINI_MODEL: z.string().default("gemini-2.0-flash"),
12
+
GEMINI_MODEL: z.string().default("gemini-2.5-flash"),
7
13
8
14
ADMIN_DID: z.string(),
9
15
ADMIN_HANDLE: z.string(),
+25
-34
src/handlers/posts.ts
+25
-34
src/handlers/posts.ts
···
7
7
import consola from "consola";
8
8
import { env } from "../env";
9
9
import db from "../db";
10
-
import * as yaml from "js-yaml";
10
+
import * as c from "../constants";
11
+
import { isAuthorizedUser, logInteraction } from "../utils/interactions";
11
12
12
13
const logger = consola.withTag("Post Handler");
13
14
14
-
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
-
}
15
+
type SupportedFunctionCall = typeof c.SUPPORTED_FUNCTION_CALLS[number];
42
16
43
17
async function generateAIResponse(parsedThread: string) {
44
18
const genai = new GoogleGenAI({
···
56
30
{
57
31
role: "model" as const,
58
32
parts: [
59
-
{ text: modelPrompt },
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
+
},
60
44
],
61
45
},
62
46
{
···
85
69
86
70
if (
87
71
call &&
88
-
SUPPORTED_FUNCTION_CALLS.includes(
72
+
c.SUPPORTED_FUNCTION_CALLS.includes(
89
73
call.name as SupportedFunctionCall,
90
74
)
91
75
) {
···
127
111
if (threadUtils.exceedsGraphemes(text)) {
128
112
threadUtils.multipartResponse(text, post);
129
113
} else {
130
-
post.reply({ text });
114
+
post.reply({
115
+
text,
116
+
tags: c.TAGS,
117
+
});
131
118
}
132
119
}
133
120
134
121
export async function handler(post: Post): Promise<void> {
135
122
try {
136
-
if (!await isAuthorizedUser(post.author.did)) {
137
-
await post.reply({ text: UNAUTHORIZED_MESSAGE });
123
+
if (!isAuthorizedUser(post.author.did)) {
124
+
await post.reply({
125
+
text: c.UNAUTHORIZED_MESSAGE,
126
+
tags: c.TAGS,
127
+
});
138
128
return;
139
129
}
140
130
···
162
152
await post.reply({
163
153
text:
164
154
"aw, shucks, something went wrong! gonna take a quick nap and try again later. 😴",
155
+
tags: c.TAGS,
165
156
});
166
157
}
167
158
}
+1
-1
src/model/prompt.txt
+1
-1
src/model/prompt.txt
···
1
-
you are echo, a bluesky bot powered by gemini 2.5 flash. your administrator is @indexx.dev.
1
+
you are echo, a bluesky bot powered by gemini 2.5 flash. your administrator is {{ administrator }}.
2
2
3
3
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.
4
4
+19
src/utils/interactions.ts
+19
src/utils/interactions.ts
···
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
+30
-76
src/utils/thread.ts
···
4
4
import { muted_threads } from "../db/schema";
5
5
import { eq } from "drizzle-orm";
6
6
import db from "../db";
7
-
8
-
const MAX_GRAPHEMES = 290;
9
-
const MAX_THREAD_DEPTH = 10;
7
+
import * as c from "../constants";
10
8
11
9
/*
12
10
Traversal
···
19
17
let parentCount = 0;
20
18
21
19
while (
22
-
currentPost && parentCount < MAX_THREAD_DEPTH
20
+
currentPost && parentCount < c.MAX_THREAD_DEPTH
23
21
) {
24
22
const parentPost = await currentPost.fetchParent();
25
23
···
47
45
48
46
/*
49
47
Split Responses
50
-
* This code is AI generated, and a bit finicky. May re-do at some point
51
48
*/
52
49
export function exceedsGraphemes(content: string) {
53
-
return graphemeLength(content) > MAX_GRAPHEMES;
50
+
return graphemeLength(content) > c.MAX_GRAPHEMES;
54
51
}
55
52
56
-
function splitResponse(content: string): string[] {
57
-
const rawParts: string[] = [];
58
-
let currentPart = "";
59
-
let currentGraphemes = 0;
53
+
export function splitResponse(text: string): string[] {
54
+
const words = text.split(" ");
55
+
const chunks: string[] = [];
56
+
let currentChunk = "";
60
57
61
-
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;
58
+
for (const word of words) {
59
+
if (currentChunk.length + word.length + 1 < c.MAX_GRAPHEMES - 10) {
60
+
currentChunk += ` ${word}`;
70
61
} else {
71
-
currentPart += sentence;
72
-
currentGraphemes += sentenceGraphemes;
62
+
chunks.push(currentChunk.trim());
63
+
currentChunk = word;
73
64
}
74
65
}
75
66
76
-
if (currentPart.trim().length > 0) {
77
-
rawParts.push(currentPart.trim());
67
+
if (currentChunk.trim()) {
68
+
chunks.push(currentChunk.trim());
78
69
}
79
70
80
-
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);
71
+
const total = chunks.length;
72
+
if (total <= 1) return [text];
99
73
100
-
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;
74
+
return chunks.map((chunk, i) => `(${i + 1}/${total}) ${chunk}`);
119
75
}
120
76
121
77
export async function multipartResponse(content: string, post?: Post) {
122
-
const parts = splitResponse(content);
78
+
const parts = splitResponse(content).filter((p) => p.trim().length > 0);
123
79
124
-
let root = null;
125
-
let latest: PostReference | null = null;
80
+
let latest: PostReference;
81
+
let rootUri: string;
126
82
127
-
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
-
}
83
+
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
+
}
134
90
135
-
root = latest.uri;
136
-
} else {
137
-
latest.reply({ text });
138
-
}
91
+
for (const text of parts.slice(1)) {
92
+
latest = await latest.reply({ text });
139
93
}
140
94
141
-
return root!;
95
+
return rootUri;
142
96
}
143
97
144
98
/*