Compare commits

..

6 Commits

7 changed files with 254 additions and 152 deletions

View File

@ -0,0 +1,19 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Response" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"pleromaNotificationId" TEXT NOT NULL DEFAULT 'null',
"to" TEXT NOT NULL DEFAULT 'null',
"request" TEXT NOT NULL DEFAULT 'null',
"response" TEXT NOT NULL DEFAULT 'null',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" DATETIME,
"isProcessing" BOOLEAN NOT NULL DEFAULT true,
"isComplete" BOOLEAN NOT NULL DEFAULT true
);
INSERT INTO "new_Response" ("createdAt", "id", "isProcessing", "pleromaNotificationId", "processedAt", "request", "response", "to") SELECT "createdAt", "id", "isProcessing", "pleromaNotificationId", "processedAt", "request", "response", "to" FROM "Response";
DROP TABLE "Response";
ALTER TABLE "new_Response" RENAME TO "Response";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -20,6 +20,7 @@ model Response {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
processedAt DateTime? processedAt DateTime?
isProcessing Boolean @default(true) isProcessing Boolean @default(true)
isComplete Boolean @default(true)
} }
model User { model User {

75
src/api.ts Normal file
View File

@ -0,0 +1,75 @@
import { envConfig, prisma } from "./main.js";
import { PleromaEmoji, Notification } from "../types.js";
const getNotifications = async () => {
const { bearerToken, pleromaInstanceUrl } = envConfig;
try {
const request = await fetch(
`${pleromaInstanceUrl}/api/v1/notifications?types[]=mention`,
{
method: "GET",
headers: {
Authorization: `Bearer ${bearerToken}`,
},
}
);
const notifications: Notification[] = await request.json();
return notifications;
} catch (error: any) {
throw new Error(error.message);
}
};
const getInstanceEmojis = async () => {
const { bearerToken, pleromaInstanceUrl } = envConfig;
try {
const request = await fetch(`${pleromaInstanceUrl}/api/v1/pleroma/emoji`, {
method: "GET",
headers: {
Authorization: `Bearer ${bearerToken}`,
},
});
if (!request.ok) {
console.error(`Emoji GET failed: ${request.status}`);
return;
}
const emojis: PleromaEmoji[] = await request.json();
return Object.keys(emojis);
} catch (error: any) {
console.error(`Could not fetch emojis: ${error.message}`);
}
};
const deleteNotification = async (notification: Notification) => {
const { pleromaInstanceUrl, bearerToken } = envConfig;
try {
if (!notification.id) {
return;
}
await prisma.response.updateMany({
// this is probably not the best way to do this, but since we may have duplicate notifications, we have to update all of them - probably won't scale (lmao)
where: { pleromaNotificationId: notification.id },
data: { isProcessing: false },
});
const response = await fetch(
`${pleromaInstanceUrl}/api/v1/notifications/${notification.id}/dismiss`,
{
method: "POST",
headers: {
Authorization: `Bearer ${bearerToken}`,
},
}
);
if (!response.ok) {
console.error(
`Could not delete notification ID: ${notification.id}\nReason: ${response.status} - ${response.statusText}`
);
}
} catch (error: any) {
throw new Error(error.message);
}
};
export { deleteNotification, getInstanceEmojis, getNotifications };

View File

@ -7,13 +7,26 @@ import {
} from "../types.js"; } from "../types.js";
import striptags from "striptags"; import striptags from "striptags";
import { PrismaClient } from "../generated/prisma/client.js"; import { PrismaClient } from "../generated/prisma/client.js";
import {
getInstanceEmojis,
deleteNotification,
getNotifications,
} from "./api.js";
import { storeUserData, storePromptData } from "./prisma.js";
import {
isFromWhitelistedDomain,
alreadyRespondedTo,
recordPendingResponse,
trimInputData,
selectRandomEmoji,
} from "./util.js";
const prisma = new PrismaClient(); export const prisma = new PrismaClient();
const envConfig = { export const envConfig = {
pleromaInstanceUrl: process.env.PLEROMA_INSTANCE_URL || "", pleromaInstanceUrl: process.env.PLEROMA_INSTANCE_URL || "",
pleromaInstanceDomain: process.env.PLEROMA_INSTANCE_DOMAIN || "", pleromaInstanceDomain: process.env.PLEROMA_INSTANCE_DOMAIN || "",
whitelistOnly: process.env.ONLY_WHITELIST === "true" ? true : false || "true", whitelistOnly: process.env.ONLY_WHITELIST === "true" ? true : false,
whitelistedDomains: process.env.WHITELISTED_DOMAINS whitelistedDomains: process.env.WHITELISTED_DOMAINS
? process.env.WHITELISTED_DOMAINS.split(",") ? process.env.WHITELISTED_DOMAINS.split(",")
: [process.env.PLEROMA_INSTANCE_DOMAIN], : [process.env.PLEROMA_INSTANCE_DOMAIN],
@ -29,121 +42,13 @@ const envConfig = {
}; };
const ollamaConfig: OllamaConfigOptions = { const ollamaConfig: OllamaConfigOptions = {
temperature: 1.2, temperature: 1.4,
top_k: 100,
top_p: 0.8,
}; };
const getNotifications = async () => { // this could be helpful
const { bearerToken, pleromaInstanceUrl } = envConfig; // https://replicate.com/blog/how-to-prompt-llama
try {
const request = await fetch(
`${pleromaInstanceUrl}/api/v1/notifications?types[]=mention`,
{
method: "GET",
headers: {
Authorization: `Bearer ${bearerToken}`,
},
}
);
const notifications: Notification[] = await request.json();
return notifications;
} catch (error: any) {
throw new Error(error.message);
}
};
const storeUserData = async (notification: Notification): Promise<void> => {
try {
await prisma.user.upsert({
where: { userFqn: notification.status.account.fqn },
update: {
lastRespondedTo: new Date(Date.now()),
},
create: {
userFqn: notification.status.account.fqn,
lastRespondedTo: new Date(Date.now()),
},
});
} catch (error: any) {
throw new Error(error.message);
}
};
const alreadyRespondedTo = async (
notification: Notification
): Promise<boolean> => {
try {
const duplicate = await prisma.response.findFirst({
where: { pleromaNotificationId: notification.id, isProcessing: true },
});
if (duplicate) {
return true;
}
return false;
} catch (error: any) {
throw new Error(error.message);
}
};
const storePromptData = async (
notification: Notification,
ollamaResponseBody: OllamaResponse
) => {
try {
await prisma.response.updateMany({
where: { pleromaNotificationId: notification.id },
data: {
response: ollamaResponseBody.response,
request: trimInputData(notification.status.content),
to: notification.account.fqn,
isProcessing: false,
},
});
} catch (error: any) {
throw new Error(error.message);
}
};
const trimInputData = (input: string): string => {
const strippedInput = striptags(input);
const split = strippedInput.split(" ");
const promptStringIndex = split.indexOf("!prompt");
split.splice(promptStringIndex, 1);
return split.join(" "); // returns everything after the !prompt
};
const recordPendingResponse = async (notification: Notification) => {
try {
await prisma.response.create({
data: {
pleromaNotificationId: notification.id,
},
});
} catch (error: any) {
throw new Error(error.message);
}
};
const isFromWhitelistedDomain = async (
notification: Notification
): Promise<boolean> => {
try {
const domain = notification.status.account.fqn.split("@")[1];
if (envConfig.whitelistedDomains.includes(domain)) {
return true;
}
console.log(
`Rejecting prompt request from non-whitelisted domain: ${domain}`
);
// delete the notification so we don't keep trying to fetch it
await deleteNotification(notification);
return false;
} catch (error: any) {
console.error(`Error with domain check: ${error.message}`);
return false;
}
};
const generateOllamaRequest = async ( const generateOllamaRequest = async (
notification: Notification notification: Notification
@ -155,9 +60,10 @@ const generateOllamaRequest = async (
striptags(notification.status.content).includes("!prompt") && striptags(notification.status.content).includes("!prompt") &&
!notification.status.account.bot && // sanity check, sort of !notification.status.account.bot && // sanity check, sort of
notification.type === "mention" && notification.type === "mention" &&
notification.status.visibility === "public" // for safety, let's only respond to public messages notification.status.visibility !== "private" // for safety, let's only respond to public messages
) { ) {
if (whitelistOnly && !isFromWhitelistedDomain(notification)) { if (whitelistOnly && !isFromWhitelistedDomain(notification)) {
await deleteNotification(notification);
return; return;
} }
if (await alreadyRespondedTo(notification)) { if (await alreadyRespondedTo(notification)) {
@ -168,9 +74,9 @@ const generateOllamaRequest = async (
const ollamaRequestBody: OllamaRequest = { const ollamaRequestBody: OllamaRequest = {
model: ollamaModel, model: ollamaModel,
system: ollamaSystemPrompt, system: ollamaSystemPrompt,
prompt: `@${notification.status.account.fqn} says: ${trimInputData( prompt: `[INST] @${
notification.status.content notification.status.account.fqn
)}`, } says: ${trimInputData(notification.status.content)} [/INST]`,
stream: false, stream: false,
options: ollamaConfig, options: ollamaConfig,
}; };
@ -192,11 +98,16 @@ const postReplyToStatus = async (
ollamaResponseBody: OllamaResponse ollamaResponseBody: OllamaResponse
) => { ) => {
const { pleromaInstanceUrl, bearerToken } = envConfig; const { pleromaInstanceUrl, bearerToken } = envConfig;
const emojiList = await getInstanceEmojis();
let randomEmoji;
if (emojiList) {
randomEmoji = selectRandomEmoji(emojiList);
}
try { try {
let mentions: string[]; let mentions: string[];
const statusBody: NewStatusBody = { const statusBody: NewStatusBody = {
content_type: "text/markdown", content_type: "text/markdown",
status: ollamaResponseBody.response, status: `${ollamaResponseBody.response} :${randomEmoji}:`,
in_reply_to_id: notification.status.id, in_reply_to_id: notification.status.id,
}; };
if ( if (
@ -228,36 +139,6 @@ const postReplyToStatus = async (
} }
}; };
const deleteNotification = async (notification: Notification) => {
const { pleromaInstanceUrl, bearerToken } = envConfig;
try {
if (!notification.id) {
return;
}
await prisma.response.updateMany({
// this is probably not the best way to do this, but since we may have duplicate notifications, we have to update all of them - probably won't scale (lmao)
where: { pleromaNotificationId: notification.id },
data: { isProcessing: false },
});
const response = await fetch(
`${pleromaInstanceUrl}/api/v1/notifications/${notification.id}/dismiss`,
{
method: "POST",
headers: {
Authorization: `Bearer ${bearerToken}`,
},
}
);
if (!response.ok) {
console.error(
`Could not delete notification ID: ${notification.id}\nReason: ${response.status} - ${response.statusText}`
);
}
} catch (error: any) {
throw new Error(error.message);
}
};
let notifications = []; let notifications = [];
const beginFetchCycle = async () => { const beginFetchCycle = async () => {
setInterval(async () => { setInterval(async () => {
@ -285,6 +166,11 @@ console.log(
} seconds.` } seconds.`
); );
console.log( console.log(
`Accepting prompts from domains: ${envConfig.whitelistedDomains.join(", ")}` `Accepting prompts from: ${envConfig.whitelistedDomains.join(", ")}`
);
console.log(
`Using model: ${envConfig.ollamaModel}\nConfig: ${JSON.stringify(
ollamaConfig
)}`
); );
await beginFetchCycle(); await beginFetchCycle();

42
src/prisma.ts Normal file
View File

@ -0,0 +1,42 @@
import { Notification, OllamaResponse } from "../types.js";
import { trimInputData } from "./util.js";
import { prisma } from "./main.js";
const storePromptData = async (
notification: Notification,
ollamaResponseBody: OllamaResponse
) => {
try {
await prisma.response.updateMany({
where: { pleromaNotificationId: notification.id },
data: {
response: ollamaResponseBody.response,
request: trimInputData(notification.status.content),
to: notification.account.fqn,
isProcessing: false,
isComplete: true,
},
});
} catch (error: any) {
throw new Error(error.message);
}
};
const storeUserData = async (notification: Notification): Promise<void> => {
try {
await prisma.user.upsert({
where: { userFqn: notification.status.account.fqn },
update: {
lastRespondedTo: new Date(Date.now()),
},
create: {
userFqn: notification.status.account.fqn,
lastRespondedTo: new Date(Date.now()),
},
});
} catch (error: any) {
throw new Error(error.message);
}
};
export { storeUserData, storePromptData };

70
src/util.ts Normal file
View File

@ -0,0 +1,70 @@
import striptags from "striptags";
import { prisma } from "./main.js";
import { envConfig } from "./main.js";
import { Notification } from "../types.js";
const trimInputData = (input: string): string => {
const strippedInput = striptags(input);
const split = strippedInput.split(" ");
const promptStringIndex = split.indexOf("!prompt");
split.splice(promptStringIndex, 1);
return split.join(" "); // returns everything after the !prompt
};
const recordPendingResponse = async (notification: Notification) => {
try {
await prisma.response.create({
data: {
pleromaNotificationId: notification.id,
isProcessing: true,
isComplete: false,
},
});
} catch (error: any) {
throw new Error(error.message);
}
};
const isFromWhitelistedDomain = (notification: Notification): boolean => {
try {
const domain = notification.status.account.fqn.split("@")[1];
if (envConfig.whitelistedDomains.includes(domain)) {
return true;
}
console.log(
`Rejecting prompt request from non-whitelisted domain: ${domain}`
);
return false;
} catch (error: any) {
console.error(`Error with domain check: ${error.message}`);
return false;
}
};
const alreadyRespondedTo = async (
notification: Notification
): Promise<boolean> => {
try {
const duplicate = await prisma.response.findFirst({
where: { pleromaNotificationId: notification.id },
});
if (duplicate?.isProcessing || duplicate?.isComplete) {
return true;
}
return false;
} catch (error: any) {
throw new Error(error.message);
}
};
const selectRandomEmoji = (emojiList: string[]) => {
return emojiList[Math.floor(Math.random() * emojiList.length)];
};
export {
alreadyRespondedTo,
selectRandomEmoji,
trimInputData,
recordPendingResponse,
isFromWhitelistedDomain,
};

9
types.d.ts vendored
View File

@ -73,6 +73,15 @@ export interface Mention {
username: string; username: string;
} }
export interface PleromaEmoji {
[emojiName: string]: PleromaEmojiMetadata;
}
interface PleromaEmojiMetadata {
image_url: string;
tags: string[];
}
/** /**
* Experimental settings, I wouldn't recommend messing with these if you don't know how they work (I don't either) * Experimental settings, I wouldn't recommend messing with these if you don't know how they work (I don't either)
*/ */