Compare commits
13 Commits
ff5c7506ff
...
main
Author | SHA1 | Date | |
---|---|---|---|
c3d4f1b1ff | |||
57ab59d342 | |||
71ae54930c | |||
3466a984ac | |||
cbf6b1d3eb | |||
e2ce397118 | |||
9a7cd118b3 | |||
7a60a672d4 | |||
419285487a | |||
09722507c6 | |||
41317301bf | |||
00a2eb63bc | |||
6c8f779294 |
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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;
|
@ -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
75
src/api.ts
Normal 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 };
|
190
src/main.ts
190
src/main.ts
@ -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
42
src/prisma.ts
Normal 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
70
src/util.ts
Normal 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
19
types.d.ts
vendored
@ -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)
|
||||
*/
|
||||
|
Reference in New Issue
Block a user