Compare commits

...

13 Commits

9 changed files with 273 additions and 148 deletions

View File

@ -1,7 +1,8 @@
DATABASE_URL="file:../dev.db" # SQLite database relative to the ./prisma path
PLEROMA_INSTANCE_URL="https://instance.tld" # Pleroma instance full URL including scheme
PLEROMA_INSTANCE_DOMAIN="instance.tld" # used if you want to only want to respond to people from a particular instance
ONLY_LOCAL_REPLIES="true" # reply to only users locally on your instance
ONLY_WHITELIST="true" # change to "false" if you want to accept prompts from any and all domains - *** USE WITH CAUTION ***
WHITELISTED_DOMAINS="" # comma separated list of domains you want to allow the bot to accept prompts from (i.e. poa.st,nicecrew.digital,detroitriotcity.com,decayable.ink)
OLLAMA_URL="http://localhost:11434" # OLLAMA connection URL
OLLAMA_SYSTEM_PROMPT="" # system prompt - used to help tune the responses from the AI
OLLAMA_MODEL="" # Ollama model for responses e.g dolphin-mistral:latest

View File

@ -1,6 +1,6 @@
{
"name": "pleroma-ollama-bot",
"version": "1.0.5",
"version": "1.0.7",
"main": "index.js",
"scripts": {
"start": "tsc && node -r dotenv/config dist/main.js",

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())
processedAt DateTime?
isProcessing Boolean @default(true)
isComplete Boolean @default(true)
}
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,29 @@ import {
} from "../types.js";
import striptags from "striptags";
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 || "",
pleromaInstanceDomain: process.env.PLEROMA_INSTANCE_DOMAIN || "",
onlyLocalReplies: process.env.ONLY_LOCAL_REPLIES === "true" ? true : false,
whitelistOnly: process.env.ONLY_WHITELIST === "true" ? true : false,
whitelistedDomains: process.env.WHITELISTED_DOMAINS
? process.env.WHITELISTED_DOMAINS.split(",")
: [process.env.PLEROMA_INSTANCE_DOMAIN],
ollamaUrl: process.env.OLLAMA_URL || "",
ollamaSystemPrompt:
process.env.OLLAMA_SYSTEM_PROMPT ||
@ -26,123 +42,28 @@ const envConfig = {
};
const ollamaConfig: OllamaConfigOptions = {
temperature: 0.3,
num_predict: 400,
temperature: 1.4,
top_k: 100,
top_p: 0.8,
};
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 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);
}
};
// this could be helpful
// https://replicate.com/blog/how-to-prompt-llama
const generateOllamaRequest = async (
notification: Notification
): Promise<OllamaResponse | undefined> => {
const {
onlyLocalReplies,
pleromaInstanceDomain,
ollamaModel,
ollamaSystemPrompt,
ollamaUrl,
} = envConfig;
const { whitelistOnly, ollamaModel, ollamaSystemPrompt, ollamaUrl } =
envConfig;
try {
if (
striptags(notification.status.content).includes("!prompt") &&
!notification.status.account.bot && // sanity check, sort of
notification.type === "mention"
notification.type === "mention" &&
notification.status.visibility !== "private" // for safety, let's only respond to public messages
) {
if (
onlyLocalReplies &&
!notification.status.account.fqn.includes(`@${pleromaInstanceDomain}`)
) {
if (whitelistOnly && !isFromWhitelistedDomain(notification)) {
await deleteNotification(notification);
return;
}
if (await alreadyRespondedTo(notification)) {
@ -153,9 +74,9 @@ const generateOllamaRequest = async (
const ollamaRequestBody: OllamaRequest = {
model: ollamaModel,
system: ollamaSystemPrompt,
prompt: `@${notification.status.account.fqn} says: ${trimInputData(
notification.status.content
)}`,
prompt: `[INST] @${
notification.status.account.fqn
} says: ${trimInputData(notification.status.content)} [/INST]`,
stream: false,
options: ollamaConfig,
};
@ -177,11 +98,16 @@ const postReplyToStatus = async (
ollamaResponseBody: OllamaResponse
) => {
const { pleromaInstanceUrl, bearerToken } = envConfig;
const emojiList = await getInstanceEmojis();
let randomEmoji;
if (emojiList) {
randomEmoji = selectRandomEmoji(emojiList);
}
try {
let mentions: string[];
const statusBody: NewStatusBody = {
content_type: "text/markdown",
status: ollamaResponseBody.response,
status: `${ollamaResponseBody.response} :${randomEmoji}:`,
in_reply_to_id: notification.status.id,
};
if (
@ -213,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 = [];
const beginFetchCycle = async () => {
setInterval(async () => {
@ -269,4 +165,12 @@ console.log(
envConfig.fetchInterval / 1000
} seconds.`
);
console.log(
`Accepting prompts from: ${envConfig.whitelistedDomains.join(", ")}`
);
console.log(
`Using model: ${envConfig.ollamaModel}\nConfig: ${JSON.stringify(
ollamaConfig
)}`
);
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,
};

19
types.d.ts vendored
View File

@ -40,7 +40,10 @@ export interface OllamaRequest {
/**
* Whether to stream responses from the API, or have it sent all as one payload.
*/
stream?: boolean = false; // stream response vs get response in one full message
stream?: boolean = false;
/**
* Ollama configuration options
*/
options?: OllamaConfigOptions;
}
@ -58,8 +61,9 @@ export interface Status {
created_at: string | Date; // when the post was created
id: string; // ID of the reply itself
in_reply_to_account_id: string; // account ID of the reply
in_reply_to_id?: string; // status that the user has replied to
mentions?: Mention[]; // array of mentions
in_reply_to_id: string; // status that the user has replied to
mentions: Mention[]; // array of mentions
visibility: "private" | "public" | "unlisted";
}
export interface Mention {
@ -69,6 +73,15 @@ export interface Mention {
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)
*/