Compare commits
10 commits
e5633fbcbd
...
e6372de095
| Author | SHA1 | Date | |
|---|---|---|---|
| e6372de095 | |||
| 9b3be5d52a | |||
| 92bdb975d9 | |||
| 8ac057b80a | |||
| 2fbf412df4 | |||
| dda2552126 | |||
| f237f33946 | |||
| df5933cbc9 | |||
| abb6b8b719 | |||
| ce1fbe60ae |
14 changed files with 410 additions and 118 deletions
|
|
@ -4,14 +4,17 @@ AUTHORIZED_USERS=""
|
||||||
# PDS service URL (optional)
|
# PDS service URL (optional)
|
||||||
SERVICE="https://bsky.social"
|
SERVICE="https://bsky.social"
|
||||||
|
|
||||||
DB_PATH="data/sqlite.db"
|
DB_PATH="sqlite.db"
|
||||||
GEMINI_MODEL="gemini-2.5-flash"
|
GEMINI_MODEL="gemini-2.5-flash"
|
||||||
|
|
||||||
DID=""
|
DID=""
|
||||||
HANDLE=""
|
HANDLE=""
|
||||||
|
|
||||||
# https://bsky.app/settings/app-passwords
|
# https://bsky.app/settings/app-passwords
|
||||||
BSKY_PASSWORD=""
|
APP_PASSWORD=""
|
||||||
|
|
||||||
# https://aistudio.google.com/apikey
|
# https://aistudio.google.com/apikey
|
||||||
GEMINI_API_KEY=""
|
GEMINI_API_KEY=""
|
||||||
|
|
||||||
|
DAILY_QUERY_LIMIT=15
|
||||||
|
USE_JETSTREAM=false
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -34,4 +34,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
data
|
*.db
|
||||||
|
|
@ -6,12 +6,15 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- "AUTHORIZED_USERS=${AUTHORIZED_USERS}"
|
- "AUTHORIZED_USERS=${AUTHORIZED_USERS}"
|
||||||
- "SERVICE=${SERVICE:?https://bsky.social}"
|
- "SERVICE=${SERVICE:?https://bsky.social}"
|
||||||
- "DB_PATH=data/sqlite.db"
|
- DB_PATH=/data/sqlite.db
|
||||||
- "GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash}"
|
- "GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash}"
|
||||||
- "DID=${DID:?}"
|
- "DID=${DID:?}"
|
||||||
- "HANDLE=${HANDLE:?}"
|
- "HANDLE=${HANDLE:?}"
|
||||||
- "BSKY_PASSWORD=${BSKY_PASSWORD:?}"
|
- "APP_PASSWORD=${APP_PASSWORD:?}"
|
||||||
- "GEMINI_API_KEY=${GEMINI_API_KEY:?}"
|
- "GEMINI_API_KEY=${GEMINI_API_KEY:?}"
|
||||||
|
- "USE_JETSTREAM=${USE_JETSTREAM:-false}"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- "aero_db:/data"
|
||||||
- aero_db:/app/data
|
|
||||||
|
volumes:
|
||||||
|
aero_db:
|
||||||
|
|
|
||||||
57
src/core.ts
57
src/core.ts
|
|
@ -1,16 +1,71 @@
|
||||||
import { GoogleGenAI } from "@google/genai";
|
import { GoogleGenAI } from "@google/genai";
|
||||||
import { Bot } from "@skyware/bot";
|
import { Bot, EventStrategy } from "@skyware/bot";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
|
import type { BinaryType } from "bun";
|
||||||
|
|
||||||
|
// Websocket patch was written by Claude, hopefully it doesn't suck
|
||||||
|
const OriginalWebSocket = global.WebSocket;
|
||||||
|
const binaryTypeDescriptor = Object.getOwnPropertyDescriptor(
|
||||||
|
OriginalWebSocket.prototype,
|
||||||
|
"binaryType",
|
||||||
|
);
|
||||||
|
|
||||||
|
const originalSetter = binaryTypeDescriptor?.set;
|
||||||
|
|
||||||
|
if (OriginalWebSocket && originalSetter) {
|
||||||
|
global.WebSocket = new Proxy(OriginalWebSocket, {
|
||||||
|
construct(target, args) {
|
||||||
|
//@ts-ignore
|
||||||
|
const ws = new target(...args) as WebSocket & {
|
||||||
|
_binaryType?: BinaryType;
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(ws, "binaryType", {
|
||||||
|
get(): BinaryType {
|
||||||
|
return ws._binaryType ||
|
||||||
|
(binaryTypeDescriptor.get
|
||||||
|
? binaryTypeDescriptor.get.call(ws)
|
||||||
|
: "arraybuffer");
|
||||||
|
},
|
||||||
|
set(value: BinaryType) {
|
||||||
|
//@ts-ignore
|
||||||
|
if (value === "blob") {
|
||||||
|
originalSetter.call(ws, "arraybuffer");
|
||||||
|
//@ts-ignore
|
||||||
|
ws._binaryType = "blob";
|
||||||
|
} else {
|
||||||
|
originalSetter.call(ws, value);
|
||||||
|
ws._binaryType = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
},
|
||||||
|
}) as typeof WebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
export const bot = new Bot({
|
export const bot = new Bot({
|
||||||
service: env.SERVICE,
|
service: env.SERVICE,
|
||||||
emitChatEvents: true,
|
emitChatEvents: true,
|
||||||
|
eventEmitterOptions: {
|
||||||
|
strategy: env.USE_JETSTREAM
|
||||||
|
? EventStrategy.Jetstream
|
||||||
|
: EventStrategy.Polling,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ai = new GoogleGenAI({
|
export const ai = new GoogleGenAI({
|
||||||
apiKey: env.GEMINI_API_KEY,
|
apiKey: env.GEMINI_API_KEY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const QUOTA_EXCEEDED_MESSAGE =
|
||||||
|
"You have exceeded your daily message quota (15). Please wait 24 hours before trying again.";
|
||||||
|
|
||||||
|
export const ERROR_MESSAGE =
|
||||||
|
"Sorry, I ran into an issue analyzing that post. Please try again.";
|
||||||
|
|
||||||
export const UNAUTHORIZED_MESSAGE =
|
export const UNAUTHORIZED_MESSAGE =
|
||||||
"I can’t make sense of your noise just yet. You’ll need to be whitelisted before I can help.";
|
"I can’t make sense of your noise just yet. You’ll need to be whitelisted before I can help.";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
import { Database } from "bun:sqlite";
|
import { Database } from "bun:sqlite";
|
||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
import { env } from "../env";
|
import { env } from "../env";
|
||||||
import { migrateDB } from "./migrate";
|
|
||||||
|
|
||||||
await migrateDB();
|
|
||||||
|
|
||||||
const sqlite = new Database(env.DB_PATH);
|
const sqlite = new Database(env.DB_PATH);
|
||||||
export default drizzle(sqlite, { schema });
|
const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
|
migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
|
||||||
|
export default db;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
|
||||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
||||||
import { Database } from "bun:sqlite";
|
|
||||||
import { env } from "../env";
|
|
||||||
|
|
||||||
export async function migrateDB() {
|
|
||||||
const sqlite = new Database(env.DB_PATH);
|
|
||||||
const db = drizzle(sqlite);
|
|
||||||
await migrate(db, { migrationsFolder: "./drizzle" });
|
|
||||||
}
|
|
||||||
13
src/env.ts
13
src/env.ts
|
|
@ -11,11 +11,22 @@ const envSchema = z.object({
|
||||||
DB_PATH: z.string().default("sqlite.db"),
|
DB_PATH: z.string().default("sqlite.db"),
|
||||||
GEMINI_MODEL: z.string().default("gemini-2.5-flash"),
|
GEMINI_MODEL: z.string().default("gemini-2.5-flash"),
|
||||||
|
|
||||||
|
ADMIN_DID: z.string().optional(),
|
||||||
|
|
||||||
DID: z.string(),
|
DID: z.string(),
|
||||||
HANDLE: z.string(),
|
HANDLE: z.string(),
|
||||||
BSKY_PASSWORD: z.string(),
|
APP_PASSWORD: z.string(),
|
||||||
|
|
||||||
GEMINI_API_KEY: z.string(),
|
GEMINI_API_KEY: z.string(),
|
||||||
|
DAILY_QUERY_LIMIT: z.preprocess(
|
||||||
|
(val) =>
|
||||||
|
(typeof val === "string" && val.trim() !== "") ? Number(val) : undefined,
|
||||||
|
z.number().int().positive().default(15),
|
||||||
|
),
|
||||||
|
USE_JETSTREAM: z.preprocess(
|
||||||
|
(val) => val === "true",
|
||||||
|
z.boolean().default(false),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import modelPrompt from "../model/prompt.txt";
|
import modelPrompt from "../model/prompt.txt";
|
||||||
import { ChatMessage, Conversation } from "@skyware/bot";
|
import { ChatMessage, Conversation, RichText } from "@skyware/bot";
|
||||||
import * as c from "../core";
|
import * as c from "../core";
|
||||||
import * as tools from "../tools";
|
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 { messages } from "../db/schema";
|
||||||
|
import { and, count, eq, gte, lt } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
exceedsGraphemes,
|
exceedsGraphemes,
|
||||||
multipartResponse,
|
multipartResponse,
|
||||||
|
|
@ -15,7 +18,12 @@ const logger = consola.withTag("Message Handler");
|
||||||
|
|
||||||
type SupportedFunctionCall = typeof c.SUPPORTED_FUNCTION_CALLS[number];
|
type SupportedFunctionCall = typeof c.SUPPORTED_FUNCTION_CALLS[number];
|
||||||
|
|
||||||
async function generateAIResponse(parsedConversation: string) {
|
async function generateAIResponse(parsedContext: string, messages: {
|
||||||
|
role: string;
|
||||||
|
parts: {
|
||||||
|
text: string;
|
||||||
|
}[];
|
||||||
|
}[]) {
|
||||||
const config = {
|
const config = {
|
||||||
model: env.GEMINI_MODEL,
|
model: env.GEMINI_MODEL,
|
||||||
config: {
|
config: {
|
||||||
|
|
@ -29,21 +37,19 @@ async function generateAIResponse(parsedConversation: string) {
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
text: modelPrompt
|
text: modelPrompt
|
||||||
.replace("{{ handle }}", env.HANDLE),
|
.replace("$handle", env.HANDLE),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "user" as const,
|
role: "model" as const,
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
text:
|
text: parsedContext,
|
||||||
`Below is the yaml for the current conversation. The last message is the one to respond to. The post is the current one you are meant to be analyzing.
|
|
||||||
|
|
||||||
${parsedConversation}`,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...messages,
|
||||||
];
|
];
|
||||||
|
|
||||||
let inference = await c.ai.models.generateContent({
|
let inference = await c.ai.models.generateContent({
|
||||||
|
|
@ -96,17 +102,64 @@ ${parsedConversation}`,
|
||||||
return inference;
|
return inference;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendResponse(
|
function addCitations(
|
||||||
conversation: Conversation,
|
inference: Awaited<ReturnType<typeof c.ai.models.generateContent>>,
|
||||||
text: string,
|
) {
|
||||||
): Promise<void> {
|
let originalText = inference.text ?? "";
|
||||||
if (exceedsGraphemes(text)) {
|
if (!inference.candidates) {
|
||||||
multipartResponse(conversation, text);
|
return originalText;
|
||||||
} else {
|
|
||||||
conversation.sendMessage({
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
const supports = inference.candidates[0]?.groundingMetadata
|
||||||
|
?.groundingSupports;
|
||||||
|
const chunks = inference.candidates[0]?.groundingMetadata?.groundingChunks;
|
||||||
|
|
||||||
|
const richText = new RichText();
|
||||||
|
|
||||||
|
if (!supports || !chunks || originalText === "") {
|
||||||
|
return richText.addText(originalText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedSupports = [...supports].sort(
|
||||||
|
(a, b) => (b.segment?.endIndex ?? 0) - (a.segment?.endIndex ?? 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let currentText = originalText;
|
||||||
|
|
||||||
|
for (const support of sortedSupports) {
|
||||||
|
const endIndex = support.segment?.endIndex;
|
||||||
|
if (endIndex === undefined || !support.groundingChunkIndices?.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const citationLinks = support.groundingChunkIndices
|
||||||
|
.map((i) => {
|
||||||
|
const uri = chunks[i]?.web?.uri;
|
||||||
|
if (uri) {
|
||||||
|
return { index: i + 1, uri };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (citationLinks.length > 0) {
|
||||||
|
richText.addText(currentText.slice(endIndex));
|
||||||
|
|
||||||
|
citationLinks.forEach((citation, idx) => {
|
||||||
|
if (citation) {
|
||||||
|
richText.addLink(`[${citation.index}]`, citation.uri);
|
||||||
|
if (idx < citationLinks.length - 1) {
|
||||||
|
richText.addText(", ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
currentText = currentText.slice(0, endIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
richText.addText(currentText);
|
||||||
|
|
||||||
|
return richText;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handler(message: ChatMessage): Promise<void> {
|
export async function handler(message: ChatMessage): Promise<void> {
|
||||||
|
|
@ -122,42 +175,78 @@ export async function handler(message: ChatMessage): Promise<void> {
|
||||||
: env.AUTHORIZED_USERS.includes(message.senderDid as any);
|
: env.AUTHORIZED_USERS.includes(message.senderDid as any);
|
||||||
|
|
||||||
if (!authorized) {
|
if (!authorized) {
|
||||||
conversation.sendMessage({
|
await conversation.sendMessage({
|
||||||
text: c.UNAUTHORIZED_MESSAGE,
|
text: c.UNAUTHORIZED_MESSAGE,
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.senderDid != env.ADMIN_DID) {
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const dailyCount = await db
|
||||||
|
.select({ count: count(messages.id) })
|
||||||
|
.from(messages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(messages.did, message.senderDid),
|
||||||
|
gte(messages.created_at, todayStart),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dailyCount[0]!.count >= env.DAILY_QUERY_LIMIT) {
|
||||||
|
conversation.sendMessage({
|
||||||
|
text: c.QUOTA_EXCEEDED_MESSAGE,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.success("Found conversation");
|
logger.success("Found conversation");
|
||||||
conversation.sendMessage({
|
conversation.sendMessage({
|
||||||
text: "...",
|
text: "...",
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsedConversation = await parseConversation(conversation);
|
const parsedConversation = await parseConversation(conversation, message);
|
||||||
|
|
||||||
logger.info("Parsed conversation: ", parsedConversation);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const inference = await generateAIResponse(parsedConversation);
|
const inference = await generateAIResponse(
|
||||||
|
parsedConversation.context,
|
||||||
|
parsedConversation.messages,
|
||||||
|
);
|
||||||
if (!inference) {
|
if (!inference) {
|
||||||
throw new Error("Failed to generate text. Returned undefined.");
|
logger.error("Failed to generate text. Returned undefined.");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success("Generated text:", inference.text);
|
|
||||||
|
|
||||||
saveMessage(conversation, env.DID, inference.text!);
|
|
||||||
|
|
||||||
const responseText = inference.text;
|
const responseText = inference.text;
|
||||||
if (responseText) {
|
const responseWithCitations = addCitations(inference);
|
||||||
await sendResponse(conversation, responseText);
|
|
||||||
|
if (responseWithCitations) {
|
||||||
|
logger.success("Generated text:", responseText);
|
||||||
|
saveMessage(conversation, env.DID, responseText!);
|
||||||
|
|
||||||
|
if (exceedsGraphemes(responseWithCitations)) {
|
||||||
|
multipartResponse(conversation, responseWithCitations);
|
||||||
|
} else {
|
||||||
|
conversation.sendMessage({
|
||||||
|
text: responseWithCitations,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
logger.error("Error in post handler:", error);
|
logger.error("Error in post handler:", error);
|
||||||
|
let errorMsg = c.ERROR_MESSAGE;
|
||||||
|
|
||||||
|
if (error.error.code == 503) {
|
||||||
|
errorMsg =
|
||||||
|
"Sorry, the AI model is currently overloaded. Please try again later.";
|
||||||
|
}
|
||||||
|
|
||||||
await conversation.sendMessage({
|
await conversation.sendMessage({
|
||||||
text:
|
text: errorMsg,
|
||||||
"Sorry, I ran into an issue analyzing that post. Please try again.",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ logger.info("Logging in..");
|
||||||
try {
|
try {
|
||||||
await bot.login({
|
await bot.login({
|
||||||
identifier: env.HANDLE,
|
identifier: env.HANDLE,
|
||||||
password: env.BSKY_PASSWORD,
|
password: env.APP_PASSWORD,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.success(`Logged in as @${env.HANDLE} (${env.DID})`);
|
logger.success(`Logged in as @${env.HANDLE} (${env.DID})`);
|
||||||
|
|
@ -19,7 +19,7 @@ try {
|
||||||
await bot.setChatPreference(IncomingChatPreference.All);
|
await bot.setChatPreference(IncomingChatPreference.All);
|
||||||
bot.on("message", messages.handler);
|
bot.on("message", messages.handler);
|
||||||
|
|
||||||
logger.success("Registered events (reply, mention, quote)");
|
logger.success("Registered events (message)");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("Failure to log-in: ", e);
|
logger.error("Failure to log-in: ", e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
You are Aero, a neutral and helpful assistant on Bluesky.
|
You are Aero, a neutral and helpful assistant on Bluesky.
|
||||||
Your job is to give clear, factual, and concise explanations or context about posts users send you.
|
Your job is to give clear, factual, and concise explanations or context about posts users send you.
|
||||||
|
|
||||||
Handle: {{ handle }}
|
Handle: $handle
|
||||||
|
|
||||||
Guidelines:
|
Guidelines:
|
||||||
|
|
||||||
|
|
|
||||||
12
src/types.ts
Normal file
12
src/types.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export type ParsedPost = {
|
||||||
|
thread?: {
|
||||||
|
ancestors: ParsedPost[];
|
||||||
|
};
|
||||||
|
author: string;
|
||||||
|
text: string;
|
||||||
|
images?: {
|
||||||
|
index: number;
|
||||||
|
alt: string;
|
||||||
|
}[];
|
||||||
|
quotePost?: ParsedPost;
|
||||||
|
};
|
||||||
42
src/utils/cache.ts
Normal file
42
src/utils/cache.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
value: T;
|
||||||
|
expiry: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimedCache<T> {
|
||||||
|
private cache = new Map<string, CacheEntry<T>>();
|
||||||
|
private ttl: number; // Time to live in milliseconds
|
||||||
|
|
||||||
|
constructor(ttl: number) {
|
||||||
|
this.ttl = ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): T | undefined {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() > entry.expiry) {
|
||||||
|
this.cache.delete(key); // Entry expired
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, value: T): void {
|
||||||
|
const expiry = Date.now() + this.ttl;
|
||||||
|
this.cache.set(key, { value, expiry });
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string): void {
|
||||||
|
this.cache.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const postCache = new TimedCache<any>(2 * 60 * 1000); // 2 minutes cache
|
||||||
|
|
@ -2,21 +2,20 @@ import {
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
type Conversation,
|
type Conversation,
|
||||||
graphemeLength,
|
graphemeLength,
|
||||||
|
RichText,
|
||||||
} from "@skyware/bot";
|
} from "@skyware/bot";
|
||||||
import * as yaml from "js-yaml";
|
import * as yaml from "js-yaml";
|
||||||
import db from "../db";
|
import db from "../db";
|
||||||
import { conversations, messages } from "../db/schema";
|
import { conversations, messages } from "../db/schema";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { env } from "../env";
|
import { env } from "../env";
|
||||||
import { bot, MAX_GRAPHEMES } from "../core";
|
import { bot, ERROR_MESSAGE, MAX_GRAPHEMES } from "../core";
|
||||||
import { parsePostImages, traverseThread } from "./post";
|
import { parsePost, parsePostImages, traverseThread } from "./post";
|
||||||
|
import { postCache } from "../utils/cache";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Utilities
|
Utilities
|
||||||
*/
|
*/
|
||||||
const resolveDid = (convo: Conversation, did: string) =>
|
|
||||||
convo.members.find((actor) => actor.did == did)!;
|
|
||||||
|
|
||||||
const getUserDid = (convo: Conversation) =>
|
const getUserDid = (convo: Conversation) =>
|
||||||
convo.members.find((actor) => actor.did != env.DID)!;
|
convo.members.find((actor) => actor.did != env.DID)!;
|
||||||
|
|
||||||
|
|
@ -29,22 +28,16 @@ function generateRevision(bytes = 8) {
|
||||||
/*
|
/*
|
||||||
Conversations
|
Conversations
|
||||||
*/
|
*/
|
||||||
async function initConvo(convo: Conversation) {
|
async function initConvo(convo: Conversation, initialMessage: ChatMessage) {
|
||||||
const user = getUserDid(convo);
|
const user = getUserDid(convo);
|
||||||
|
|
||||||
const initialMessage = (await convo.getMessages()).messages[0] as
|
|
||||||
| ChatMessage
|
|
||||||
| undefined;
|
|
||||||
if (!initialMessage) {
|
|
||||||
throw new Error("Failed to get initial message of conversation");
|
|
||||||
}
|
|
||||||
|
|
||||||
const postUri = await parseMessagePostUri(initialMessage);
|
const postUri = await parseMessagePostUri(initialMessage);
|
||||||
if (!postUri) {
|
if (!postUri) {
|
||||||
convo.sendMessage({
|
await convo.sendMessage({
|
||||||
text:
|
text:
|
||||||
"Please send a post for me to make sense of the noise for you.",
|
"Please send a post for me to make sense of the noise for you.",
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new Error("No post reference in initial message.");
|
throw new Error("No post reference in initial message.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +63,11 @@ async function initConvo(convo: Conversation) {
|
||||||
did: user.did,
|
did: user.did,
|
||||||
postUri,
|
postUri,
|
||||||
revision: _convo.revision,
|
revision: _convo.revision,
|
||||||
text: initialMessage.text,
|
text:
|
||||||
|
!initialMessage.text ||
|
||||||
|
initialMessage.text.trim().length == 0
|
||||||
|
? "Explain this post."
|
||||||
|
: initialMessage.text,
|
||||||
});
|
});
|
||||||
|
|
||||||
return _convo!;
|
return _convo!;
|
||||||
|
|
@ -87,14 +84,14 @@ async function getConvo(convoId: string) {
|
||||||
return convo;
|
return convo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseConversation(convo: Conversation) {
|
export async function parseConversation(
|
||||||
|
convo: Conversation,
|
||||||
|
latestMessage: ChatMessage,
|
||||||
|
) {
|
||||||
let row = await getConvo(convo.id);
|
let row = await getConvo(convo.id);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
row = await initConvo(convo);
|
row = await initConvo(convo, latestMessage);
|
||||||
} else {
|
} else {
|
||||||
const latestMessage = (await convo.getMessages())
|
|
||||||
.messages[0] as ChatMessage;
|
|
||||||
|
|
||||||
const postUri = await parseMessagePostUri(latestMessage);
|
const postUri = await parseMessagePostUri(latestMessage);
|
||||||
if (postUri) {
|
if (postUri) {
|
||||||
const [updatedRow] = await db
|
const [updatedRow] = await db
|
||||||
|
|
@ -119,44 +116,50 @@ export async function parseConversation(convo: Conversation) {
|
||||||
did: getUserDid(convo).did,
|
did: getUserDid(convo).did,
|
||||||
postUri: row.postUri,
|
postUri: row.postUri,
|
||||||
revision: row.revision,
|
revision: row.revision,
|
||||||
text: latestMessage!.text,
|
text: postUri &&
|
||||||
|
(!latestMessage.text ||
|
||||||
|
latestMessage.text.trim().length == 0)
|
||||||
|
? "Explain this post."
|
||||||
|
: latestMessage.text,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const post = await bot.getPost(row.postUri);
|
let post = postCache.get(row.postUri);
|
||||||
|
if (!post) {
|
||||||
|
post = await bot.getPost(row.postUri);
|
||||||
|
postCache.set(row.postUri, post);
|
||||||
|
}
|
||||||
const convoMessages = await getRelevantMessages(row!);
|
const convoMessages = await getRelevantMessages(row!);
|
||||||
|
|
||||||
const thread = await traverseThread(post);
|
let parseResult = null;
|
||||||
|
try {
|
||||||
|
const parsedPost = await parsePost(post, true, new Set());
|
||||||
|
parseResult = {
|
||||||
|
context: yaml.dump({
|
||||||
|
post: parsedPost || null,
|
||||||
|
}),
|
||||||
|
messages: convoMessages.map((message) => {
|
||||||
|
const role = message.did == env.DID ? "model" : "user";
|
||||||
|
|
||||||
return yaml.dump({
|
return {
|
||||||
post: {
|
role,
|
||||||
thread: {
|
parts: [
|
||||||
ancestors: thread.map((post) => ({
|
{
|
||||||
author: post.author.displayName
|
text: message.text,
|
||||||
? `${post.author.displayName} (${post.author.handle})`
|
},
|
||||||
: `Handle: ${post.author.handle}`,
|
],
|
||||||
text: post.text,
|
};
|
||||||
})),
|
}),
|
||||||
},
|
};
|
||||||
author: post.author.displayName
|
} catch (e) {
|
||||||
? `${post.author.displayName} (${post.author.handle})`
|
await convo.sendMessage({
|
||||||
: `Handle: ${post.author.handle}`,
|
text: ERROR_MESSAGE,
|
||||||
text: post.text,
|
});
|
||||||
images: parsePostImages(post),
|
|
||||||
likes: post.likeCount || 0,
|
|
||||||
replies: post.replyCount || 0,
|
|
||||||
},
|
|
||||||
messages: convoMessages.map((message) => {
|
|
||||||
const profile = resolveDid(convo, message.did);
|
|
||||||
|
|
||||||
return {
|
throw new Error("Failed to parse conversation");
|
||||||
user: profile.displayName
|
}
|
||||||
? `${profile.displayName} (${profile.handle})`
|
|
||||||
: `Handle: ${profile.handle}`,
|
return parseResult;
|
||||||
text: message.text,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -175,7 +178,8 @@ async function getRelevantMessages(convo: typeof conversations.$inferSelect) {
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(messages.conversationId, convo.id),
|
eq(messages.conversationId, convo.id),
|
||||||
eq(messages.postUri, convo!.postUri),
|
eq(messages.postUri, convo.postUri),
|
||||||
|
eq(messages.revision, convo.revision),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(15);
|
.limit(15);
|
||||||
|
|
@ -198,7 +202,7 @@ export async function saveMessage(
|
||||||
.values({
|
.values({
|
||||||
conversationId: _convo.id,
|
conversationId: _convo.id,
|
||||||
postUri: _convo.postUri,
|
postUri: _convo.postUri,
|
||||||
revision: _convo.postUri,
|
revision: _convo.revision,
|
||||||
did,
|
did,
|
||||||
text,
|
text,
|
||||||
});
|
});
|
||||||
|
|
@ -207,7 +211,10 @@ export async function saveMessage(
|
||||||
/*
|
/*
|
||||||
Reponse Utilities
|
Reponse Utilities
|
||||||
*/
|
*/
|
||||||
export function exceedsGraphemes(content: string) {
|
export function exceedsGraphemes(content: string | RichText) {
|
||||||
|
if (content instanceof RichText) {
|
||||||
|
return graphemeLength(content.text) > MAX_GRAPHEMES;
|
||||||
|
}
|
||||||
return graphemeLength(content) > MAX_GRAPHEMES;
|
return graphemeLength(content) > MAX_GRAPHEMES;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,8 +242,24 @@ export function splitResponse(text: string): string[] {
|
||||||
return chunks.map((chunk, i) => `(${i + 1}/${total}) ${chunk}`);
|
return chunks.map((chunk, i) => `(${i + 1}/${total}) ${chunk}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function multipartResponse(convo: Conversation, content: string) {
|
export async function multipartResponse(
|
||||||
const parts = splitResponse(content).filter((p) => p.trim().length > 0);
|
convo: Conversation,
|
||||||
|
content: string | RichText,
|
||||||
|
) {
|
||||||
|
let parts: (string | RichText)[];
|
||||||
|
|
||||||
|
if (content instanceof RichText) {
|
||||||
|
if (exceedsGraphemes(content)) {
|
||||||
|
// If RichText exceeds grapheme limit, convert to plain text for splitting
|
||||||
|
parts = splitResponse(content.text);
|
||||||
|
} else {
|
||||||
|
// Otherwise, send the RichText directly as a single part
|
||||||
|
parts = [content];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If content is a string, behave as before
|
||||||
|
parts = splitResponse(content);
|
||||||
|
}
|
||||||
|
|
||||||
for (const segment of parts) {
|
for (const segment of parts) {
|
||||||
await convo.sendMessage({
|
await convo.sendMessage({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,66 @@
|
||||||
import { EmbedImage, Post } from "@skyware/bot";
|
import {
|
||||||
|
EmbedImage,
|
||||||
|
Post,
|
||||||
|
PostEmbed,
|
||||||
|
RecordEmbed,
|
||||||
|
RecordWithMediaEmbed,
|
||||||
|
} from "@skyware/bot";
|
||||||
import * as c from "../core";
|
import * as c from "../core";
|
||||||
import * as yaml from "js-yaml";
|
import * as yaml from "js-yaml";
|
||||||
|
import type { ParsedPost } from "../types";
|
||||||
|
import { postCache } from "../utils/cache";
|
||||||
|
|
||||||
|
export async function parsePost(
|
||||||
|
post: Post,
|
||||||
|
includeThread: boolean,
|
||||||
|
seenUris: Set<string> = new Set(),
|
||||||
|
): Promise<ParsedPost | undefined> {
|
||||||
|
if (seenUris.has(post.uri)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
seenUris.add(post.uri);
|
||||||
|
|
||||||
|
const [images, quotePost, ancestorPosts] = await Promise.all([
|
||||||
|
parsePostImages(post),
|
||||||
|
parseQuote(post, seenUris),
|
||||||
|
includeThread ? traverseThread(post) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
author: post.author.displayName
|
||||||
|
? `${post.author.displayName} (${post.author.handle})`
|
||||||
|
: `Handle: ${post.author.handle}`,
|
||||||
|
text: post.text,
|
||||||
|
...(images && { images }),
|
||||||
|
...(quotePost && { quotePost }),
|
||||||
|
...(ancestorPosts && {
|
||||||
|
thread: {
|
||||||
|
ancestors: (await Promise.all(
|
||||||
|
ancestorPosts.map((ancestor) => parsePost(ancestor, false, seenUris)),
|
||||||
|
)).filter((post): post is ParsedPost => post !== undefined),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseQuote(post: Post, seenUris: Set<string>) {
|
||||||
|
if (
|
||||||
|
!post.embed || (!post.embed.isRecord() && !post.embed.isRecordWithMedia())
|
||||||
|
) return undefined;
|
||||||
|
|
||||||
|
const record = (post.embed as RecordEmbed || RecordWithMediaEmbed).record;
|
||||||
|
if (seenUris.has(record.uri)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let embedPost = postCache.get(record.uri);
|
||||||
|
if (!embedPost) {
|
||||||
|
embedPost = await c.bot.getPost(record.uri);
|
||||||
|
postCache.set(record.uri, embedPost);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await parsePost(embedPost, false, seenUris);
|
||||||
|
}
|
||||||
|
|
||||||
export function parsePostImages(post: Post) {
|
export function parsePostImages(post: Post) {
|
||||||
if (!post.embed) return [];
|
if (!post.embed) return [];
|
||||||
|
|
@ -16,7 +76,9 @@ export function parsePostImages(post: Post) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return images.map((image, idx) => parseImage(image, idx + 1));
|
return images.map((image, idx) => parseImage(image, idx + 1)).filter((img) =>
|
||||||
|
img.alt.length > 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseImage(image: EmbedImage, index: number) {
|
function parseImage(image: EmbedImage, index: number) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue