Compare commits

...

59 Commits

Author SHA1 Message Date
e696343a73 a crumb of changes 2025-08-12 19:07:22 +00:00
88a0710c55 update system prompt to hopefully make it easier for Lexi to understand 2025-08-04 21:08:47 +00:00
75fa4cea8b jorkin my preanits 2025-08-04 11:56:06 +00:00
733a41a35c revert to more "chat" based api calls 2025-08-04 00:21:10 +00:00
ed3467b213 bump version 2025-08-03 23:32:48 +00:00
0f178fcfa9 beta release conversation context 2025-08-03 23:31:56 +00:00
0bfff52fd0 Merge branch 'main' into implement-conversation-context 2025-08-03 21:25:04 +00:00
8e90e8b71e add context response type 2025-08-03 21:24:40 +00:00
566d6ae518 update types 2025-08-03 20:59:53 +00:00
2ec367f203 fix typo 2025-08-03 20:05:21 +00:00
a04cb9a6ad some abstraction and I'm gonna kill myself 2025-08-03 19:43:05 +00:00
2111a47411 update readme 2025-08-03 18:57:07 +00:00
11c1332757 add systemd service example 2025-08-03 18:56:44 +00:00
aaf4adcf06 don't reply when not addressed 2025-08-03 18:56:05 +00:00
b6ad54f40a way better responsiveness, better system prompt 2025-08-03 14:37:23 +00:00
2f3d16dbc5 slight update to input processing 2025-08-03 14:24:50 +00:00
150e2d638e add configurable ad-hoc post interval 2025-08-02 23:24:35 +00:00
0c7c176bae I don't remember 2025-08-02 22:19:13 +00:00
c3d4f1b1ff delete notification on whitelist check fail 2025-07-07 18:32:03 +00:00
57ab59d342 separation of concerns 2025-07-07 18:26:09 +00:00
71ae54930c change response visibility logic 2025-07-07 01:15:17 +00:00
3466a984ac add isComplete column to response model for better sanity checking 2025-07-06 15:09:13 +00:00
cbf6b1d3eb its called we do a little abstraction 2025-07-06 14:53:48 +00:00
e2ce397118 add instance custom emojis to responses 2025-07-06 14:39:45 +00:00
9a7cd118b3 we do a little hackering on the 4channel 2025-07-06 03:38:07 +00:00
7a60a672d4 little more error checking/correction/QOL 2025-07-06 02:58:50 +00:00
419285487a typography 2025-07-06 02:50:35 +00:00
09722507c6 housekeeping 2025-07-06 02:47:35 +00:00
41317301bf bump version, other small change 2025-07-06 02:38:40 +00:00
00a2eb63bc add domain whitelist functionality 2025-07-06 02:36:16 +00:00
6c8f779294 update type with some more annotations 2025-07-06 02:11:30 +00:00
ff5c7506ff refactor 2025-07-05 13:34:24 +00:00
5c51acc8d1 update dependencies, description 2025-07-05 12:51:00 +00:00
d4ee457d74 bump version 2025-07-05 03:59:35 +00:00
b8f6023029 sanity checking, do not duplicate responses 2025-07-05 03:58:50 +00:00
ea5e783ee5 add ollama config options type 2025-07-05 02:40:56 +00:00
eb5282a50d slightly fix my bawlz 2025-07-05 02:17:02 +00:00
9ee3663890 maybe fix duplicate notification response issue 2025-07-05 01:15:40 +00:00
d85acd2179 more robust notification type safety, remove websocket, add fetch interval 2025-07-05 01:03:07 +00:00
856cc84208 add fetch interval configuration 2025-07-05 01:02:53 +00:00
ca4643092f removed WS protocol token, since WS connections are unreliable 2025-07-05 00:12:52 +00:00
b4b656f808 removed ws as we will no longer use it 2025-07-05 00:12:35 +00:00
92f1366574 I don't think the websocket thing is gonna work. 2025-07-04 23:55:51 +00:00
a64afa7e7b return if websocket open 2025-07-04 12:43:00 -04:00
d63aa365e7 log close event reason 2025-07-04 12:41:26 -04:00
3759c5aa23 vibe coding the reconnect logi 2025-07-03 17:46:52 -04:00
1a151b197b this is so nigger rigged dude I really should just use a class or
something
2025-07-03 11:39:57 -04:00
70180c5d5f muh dik 2025-07-03 10:42:39 -04:00
dac037809c loggign 2025-07-02 06:42:22 -04:00
6088a2cbd3 add keepalive ping, onclose event reason 2025-07-02 06:41:43 -04:00
ed8d148d0a update README 2025-07-01 17:18:53 -04:00
379099dc7a remove unused code, add try/catch block 2025-07-01 15:30:33 -04:00
c0ed38ac1a update README 2025-07-01 15:26:38 -04:00
b295777041 add websocket functionality 2025-07-01 15:25:00 -04:00
9145b07da7 trim input data for better results 2025-06-30 21:09:37 -04:00
593aa09a18 add dependencies 2025-06-30 20:39:16 -04:00
acddefe1e8 linting 2025-06-30 17:22:49 -04:00
c4a5de31f3 refactor to make a little more sense at a glance 2025-06-30 17:19:24 -04:00
396ef9d2be slight refactor 2025-06-30 17:11:54 -04:00
16 changed files with 752 additions and 148 deletions

View File

@ -1,8 +1,13 @@
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
PLEROMA_ACCOUNT_ID="" # obtained from /api/v1/accounts/{nickname} - used so we don't spam mentions when not directly addressed
REPLY_WITH_CONTEXT="" # set to true or false whether you want the bot to fetch context or not
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
FETCH_INTERVAL="" # interval for fetching new notifications from the instance, in milliseconds, recommend at least 15000
RANDOM_POST_INTERVAL="" # interval for ad-hoc posts in milliseconds
INSTANCE_BEARER_TOKEN="" # instance auth/bearer token (check the "verify_credentials" endpoint request headers in Chrome DevTools if on Soapbox)

3
.gitignore vendored
View File

@ -1,8 +1,9 @@
node_modules
# Keep environment variables out of version control
.env
.env*
*.log
*.db
/dist
screenlog*
/generated/prisma

View File

@ -1,15 +1,13 @@
## Pleroma -> Ollama Bot Setup
1. Clone project
2. Install npm 22.11.0 if you don't have it already
2. Install Node `v22.11.0` if you don't have it already
* If using `nvm`, just `nvm install 22.11.0` and then `nvm use 22.11.0` if necessary
3. `cd` into the project directory
4. Run `npm install`
5. Run `npx prisma init --datasource-provider sqlite --output ../generated/prisma`
6. Run `npx prisma migrate dev --name init`
7. To run the software on a cronjob, use `npm run once`
7. To start, run `npm run start`
### Database Migrations
If you add stuff to the schema, follow the [Prisma development workflow](https://www.prisma.io/docs/orm/prisma-migrate/workflows/development-and-production). This will apply the new schema to the database and generate a new Prisma client with type safety.
Setting as a system service will come at some point, or someone could contribute if they wanted.

24
package-lock.json generated
View File

@ -1,21 +1,22 @@
{
"name": "pleroma-ollama-bot",
"version": "1.0.0",
"version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pleroma-ollama-bot",
"version": "1.0.0",
"version": "1.0.5",
"dependencies": {
"@prisma/client": "^6.10.1",
"@types/node": "^24.0.5",
"dotenv": "^17.0.0",
"striptags": "^3.2.0",
"ts-node": "^10.9.2",
"typescript": "^5.8.3"
},
"devDependencies": {
"@types/node": "^24.0.10",
"@types/ws": "^8.18.1",
"prisma": "^6.10.1"
}
},
@ -163,14 +164,23 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.0.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.5.tgz",
"integrity": "sha512-CXEG9E7GCTOZIre0WdDznmnhvF7xi7AmnP/zF496trmLiqlfdtxp9nPRgLVqfmJ8jgtcKcs0EcvOu2yDZSuvTg==",
"license": "MIT",
"version": "24.0.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz",
"integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==",
"dependencies": {
"undici-types": "~7.8.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",

View File

@ -1,24 +1,25 @@
{
"name": "pleroma-ollama-bot",
"version": "1.0.0",
"version": "1.1.0",
"main": "index.js",
"scripts": {
"once": "tsc && node -r dotenv/config dist/main.js",
"start": "tsc && node -r dotenv/config dist/main.js",
"build": "tsc"
},
"type": "module",
"keywords": [],
"author": "NiceCrew",
"description": "A simple bot that responds to activities from Pleroma instances using Ollama's API.",
"description": "A simple bot that responds to activities from Pleroma instances using Ollama's API at a configurable interval.",
"dependencies": {
"@prisma/client": "^6.10.1",
"@types/node": "^24.0.5",
"dotenv": "^17.0.0",
"striptags": "^3.2.0",
"ts-node": "^10.9.2",
"typescript": "^5.8.3"
},
"devDependencies": {
"@types/node": "^24.0.10",
"@types/ws": "^8.18.1",
"prisma": "^6.10.1"
}
}

View File

@ -0,0 +1,18 @@
-- 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,
"request" TEXT,
"response" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" DATETIME,
"isProcessing" BOOLEAN NOT NULL DEFAULT true
);
INSERT INTO "new_Response" ("createdAt", "id", "pleromaNotificationId", "processedAt", "request", "response", "to") SELECT "createdAt", "id", "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

@ -0,0 +1,18 @@
-- 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
);
INSERT INTO "new_Response" ("createdAt", "id", "isProcessing", "pleromaNotificationId", "processedAt", "request", "response", "to") SELECT "createdAt", "id", "isProcessing", "pleromaNotificationId", "processedAt", coalesce("request", 'null') AS "request", coalesce("response", 'null') AS "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

@ -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

@ -14,11 +14,13 @@ datasource db {
model Response {
id Int @id @default(autoincrement())
pleromaNotificationId String @default("null")
to String
request String?
response String?
to String @default("null")
request String @default("null")
response String @default("null")
createdAt DateTime @default(now())
processedAt DateTime?
isProcessing Boolean @default(true)
isComplete Boolean @default(true)
}
model User {

106
src/api.ts Normal file
View File

@ -0,0 +1,106 @@
import { envConfig, prisma } from "./main.js";
import { PleromaEmoji, Notification, ContextResponse } 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 getStatusContext = async (statusId: string) => {
const { bearerToken, pleromaInstanceUrl } = envConfig;
try {
const response = await fetch(
`${pleromaInstanceUrl}/api/v1/statuses/${statusId}/context`,
{
method: "GET",
headers: {
Authorization: `Bearer ${bearerToken}`,
},
}
);
if (!response.ok) {
throw new Error(
`Could not get conversation context: ${response.status} - ${response.statusText}`
);
}
const data: ContextResponse = await response.json();
return data;
} catch (error: unknown) {
if (error instanceof Error) {
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,
getStatusContext,
};

View File

@ -1,128 +1,139 @@
import {
OllamaRequest,
OllamaResponse,
NewStatusBody,
Notification,
OllamaConfigOptions,
OllamaChatRequest,
OllamaChatResponse,
PostAncestorsForModel,
} from "../types.js";
import striptags from "striptags";
// import striptags from "striptags";
import { PrismaClient } from "../generated/prisma/client.js";
import {
// getInstanceEmojis,
deleteNotification,
getNotifications,
getStatusContext,
} from "./api.js";
import { storeUserData, storePromptData } from "./prisma.js";
import {
isFromWhitelistedDomain,
alreadyRespondedTo,
recordPendingResponse,
// trimInputData,
// selectRandomEmoji,
shouldContinue,
} from "./util.js";
const prisma = new PrismaClient();
export const prisma = new PrismaClient();
const getNotifications = async () => {
try {
const request = await fetch(
`${process.env.PLEROMA_INSTANCE_URL}/api/v1/notifications?types[]=mention`,
{
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INSTANCE_BEARER_TOKEN}`,
},
}
);
const notifications: Notification[] = await request.json();
return notifications;
} catch (error: any) {
throw new Error(error.message);
}
export const envConfig = {
pleromaInstanceUrl: process.env.PLEROMA_INSTANCE_URL || "",
pleromaInstanceDomain: process.env.PLEROMA_INSTANCE_DOMAIN || "",
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,
ollamaModel: process.env.OLLAMA_MODEL || "",
fetchInterval: process.env.FETCH_INTERVAL
? parseInt(process.env.FETCH_INTERVAL)
: 15000,
bearerToken: process.env.INSTANCE_BEARER_TOKEN || "",
adHocPostInterval: process.env.RANDOM_POST_INTERVAL
? parseInt(process.env.RANDOM_POST_INTERVAL)
: 3600000,
botAccountId: process.env.PLEROMA_ACCOUNT_ID,
replyWithContext: process.env.REPLY_WITH_CONTEXT === "true" ? true : false,
};
const notifications = await getNotifications();
const storeUserData = async (notification: Notification) => {
try {
const user = 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 ollamaConfig: OllamaConfigOptions = {
temperature: 0.9,
top_p: 0.85,
top_k: 60,
num_ctx: 16384, // maximum context window for Llama 3.1
repeat_penalty: 1.1,
};
const alreadyRespondedTo = async (
notification: Notification
): Promise<boolean> => {
try {
const duplicate = await prisma.response.findFirst({
where: { pleromaNotificationId: notification.status.id },
});
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.create({
data: {
response: ollamaResponseBody.response,
request: striptags(notification.status.content),
to: notification.account.fqn,
pleromaNotificationId: notification.status.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> => {
): Promise<OllamaChatResponse | undefined> => {
const {
whitelistOnly,
ollamaModel,
ollamaSystemPrompt,
ollamaUrl,
replyWithContext,
} = envConfig;
try {
if (
striptags(notification.status.content).includes("!prompt") &&
!notification.status.account.bot
) {
if (
process.env.ONLY_LOCAL_REPLIES === "true" &&
!notification.status.account.fqn.includes(
`@${process.env.PLEROMA_INSTANCE_DOMAIN}`
)
) {
if (shouldContinue(notification)) {
if (whitelistOnly && !isFromWhitelistedDomain(notification)) {
await deleteNotification(notification);
return;
}
if (await alreadyRespondedTo(notification)) {
// console.log(
// `Already responded to notification ID ${notification.status.id}. Canceling.`
// );
return;
}
await recordPendingResponse(notification);
await storeUserData(notification);
const ollamaRequestBody: OllamaRequest = {
model: process.env.OLLAMA_MODEL as string,
system: process.env.OLLAMA_SYSTEM_PROMPT as string,
prompt: `@${notification.status.account.fqn} asks: ${striptags(
notification.status.content
)}`,
let conversationHistory: PostAncestorsForModel[] = [];
if (replyWithContext) {
const contextPosts = await getStatusContext(notification.status.id);
if (!contextPosts?.ancestors || !contextPosts) {
throw new Error(`Unable to obtain post context ancestors.`);
}
conversationHistory = contextPosts.ancestors.map((ancestor) => {
const mentions = ancestor.mentions.map((mention) => mention.acct);
return {
account_fqn: ancestor.account.fqn,
mentions,
plaintext_content: ancestor.pleroma.content["text/plain"],
};
});
// console.log(conversationHistory);
}
// Simplified user message (remove [/INST] as it's not needed for Llama 3)
const userMessage = `${notification.status.account.fqn} says to you: \"${notification.status.pleroma.content["text/plain"]}\".`;
let systemContent = ollamaSystemPrompt;
if (replyWithContext) {
// Simplified context instructions (avoid heavy JSON; summarize for clarity)
systemContent = `${ollamaSystemPrompt}\n\nPrevious conversation context:\n${conversationHistory
.map(
(post) =>
`${post.account_fqn} (said to ${post.mentions.join(", ")}): ${
post.plaintext_content
}`
)
.join(
"\n"
)}\nReply to the user who addressed you (you are Lexi, also known as nice-ai or nice-ai@nicecrew.digital). Examine the context of the entire conversation and make references to topics or information where appropriate. Prefix usernames with '@' when addressing them. Assume if there is no domain in the username, the domain is @nicecrew.digital (for example @matty would be @matty@nicecrew.digital)`;
}
// Switch to chat request format (messages array auto-handles Llama 3 template)
const ollamaRequestBody: OllamaChatRequest = {
model: ollamaModel,
messages: [
{ role: "system", content: systemContent as string },
{ role: "user", content: userMessage },
],
stream: false,
options: ollamaConfig,
};
const response = await fetch(`${process.env.OLLAMA_URL}/api/generate`, {
// Change endpoint to /api/chat
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
body: JSON.stringify(ollamaRequestBody),
});
const ollamaResponse: OllamaResponse = await response.json();
const ollamaResponse: OllamaChatResponse = await response.json();
await storePromptData(notification, ollamaResponse);
// console.log(
// `${notification.status.account.fqn} asked:\n${notification.status.content}\nResponse:\n${ollamaResponse.response}`
// );
await postReplyToStatus(notification, ollamaResponse);
return ollamaResponse;
}
} catch (error: any) {
throw new Error(error.message);
@ -131,43 +142,153 @@ const generateOllamaRequest = async (
const postReplyToStatus = async (
notification: Notification,
ollamaResponseBody: OllamaResponse
ollamaResponseBody: OllamaChatResponse
) => {
const { pleromaInstanceUrl, bearerToken } = envConfig;
// const emojiList = await getInstanceEmojis();
// let randomEmoji;
// if (emojiList) {
// randomEmoji = selectRandomEmoji(emojiList);
// }
try {
const mentions = notification.status.mentions?.map((mention) => {
return mention.acct;
});
let statusBody: NewStatusBody = {
let mentions: string[];
const statusBody: NewStatusBody = {
content_type: "text/markdown",
status: ollamaResponseBody.response,
status: `${ollamaResponseBody.message.content}`,
in_reply_to_id: notification.status.id,
to: mentions,
};
if (
notification.status.mentions &&
notification.status.mentions.length > 0
) {
mentions = notification.status.mentions.map((mention) => {
return mention.acct;
});
statusBody.to = mentions;
}
const response = await fetch(
`${process.env.PLEROMA_INSTANCE_URL}/api/v1/statuses`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INSTANCE_BEARER_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify(statusBody),
}
);
const response = await fetch(`${pleromaInstanceUrl}/api/v1/statuses`, {
method: "POST",
headers: {
Authorization: `Bearer ${bearerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(statusBody),
});
if (!response.ok) {
throw new Error(`New status request failed: ${response.statusText}`);
}
await deleteNotification(notification);
} catch (error: any) {
throw new Error(error.message);
}
};
if (notifications) {
await Promise.all(
notifications.map((notification) => {
generateOllamaRequest(notification);
})
);
}
const createTimelinePost = async () => {
const {
bearerToken,
ollamaModel,
ollamaSystemPrompt,
ollamaUrl,
pleromaInstanceUrl,
} = envConfig;
const ollamaRequestBody: OllamaChatRequest = {
model: ollamaModel,
messages: [
{ role: "system", content: ollamaSystemPrompt as string },
{ role: "user", content: "Say something random." },
],
stream: false,
options: ollamaConfig,
};
try {
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
body: JSON.stringify(ollamaRequestBody),
});
if (!response.ok)
throw new Error("Error generating ad-hoc Ollama response");
const ollamaResponse: OllamaChatResponse = await response.json();
const newStatusBody: NewStatusBody = {
content_type: "text/markdown",
status: ollamaResponse.message.content,
};
const pleromaResponse = await fetch(
`${pleromaInstanceUrl}/api/v1/statuses`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${bearerToken}`,
},
body: JSON.stringify(newStatusBody),
}
);
if (!pleromaResponse.ok)
throw new Error("Error posting ad-hoc Ollama response to Pleroma");
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error(error.message);
}
}
};
let notifications = [];
const beginFetchCycle = async () => {
setInterval(async () => {
notifications = await getNotifications();
if (notifications.length > 0) {
await Promise.all(
notifications.map(async (notification) => {
try {
const ollamaResponse = await generateOllamaRequest(notification);
if (ollamaResponse) {
postReplyToStatus(notification, ollamaResponse);
}
} catch (error: any) {
throw new Error(error.message);
}
})
);
}
}, envConfig.fetchInterval); // lower intervals may cause the bot to respond multiple times to the same message, but we try to mitigate this with the deleteNotification function
};
const beginStatusPostInterval = async () => {
setInterval(async () => {
try {
createTimelinePost();
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error(error.message);
}
}
}, envConfig.adHocPostInterval);
};
console.log(
`Fetching notifications from ${envConfig.pleromaInstanceDomain}, every ${
envConfig.fetchInterval / 1000
} seconds.`
);
console.log(
`Accepting prompts from: ${envConfig.whitelistedDomains.join(", ")}`
);
console.log(
`Using model: ${envConfig.ollamaModel}\nConfig: ${JSON.stringify(
ollamaConfig
)}`
);
console.log(`System prompt: ${envConfig.ollamaSystemPrompt}`);
await beginFetchCycle();
// setInterval(async () => {
// createTimelinePost();
// }, 10000);
await beginStatusPostInterval();

42
src/prisma.ts Normal file
View File

@ -0,0 +1,42 @@
import { Notification, OllamaChatResponse } from "../types.js";
import { trimInputData } from "./util.js";
import { prisma } from "./main.js";
const storePromptData = async (
notification: Notification,
ollamaResponseBody: OllamaChatResponse
) => {
try {
await prisma.response.updateMany({
where: { pleromaNotificationId: notification.id },
data: {
response: ollamaResponseBody.message.content,
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 };

108
src/util.ts Normal file
View File

@ -0,0 +1,108 @@
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");
const botFqnIndex = split.indexOf("@nice-ai");
const botFqnIndexFull = split.indexOf("@nice-ai@nicecrew.digital");
if (botFqnIndex !== -1) {
split[botFqnIndex] = "Lexi";
}
if (botFqnIndexFull !== -1) {
split[botFqnIndexFull] = "Lexi";
}
// 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 shouldContinue = (notification: Notification) => {
// wow this is bad
try {
const { botAccountId } = envConfig;
const statusContent = trimInputData(notification.status.content);
if (
// notification.status.visibility !== "private" &&
!notification.account.bot &&
notification.type === "mention"
) {
if (notification.status.in_reply_to_account_id === botAccountId) {
return true;
} else if (
notification.status.in_reply_to_account_id !== botAccountId &&
statusContent.includes("Lexi")
) {
return true;
} else {
return false;
}
}
} catch (error: unknown) {
if (error instanceof Error) {
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,
shouldContinue,
};

14
systemd.service Normal file
View File

@ -0,0 +1,14 @@
[Unit]
Description=Pleroma Ollama Bot
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=bot
Restart=always
RestartSec=3
ExecStart=/usr/bin/screen -L -DmS pleroma-ollama-bot /home/bot/.nvm/versions/node/v22.11.0/bin/npm run start
WorkingDirectory=/path/to/directory
[Install]
WantedBy=multi-user.target

View File

@ -7,6 +7,8 @@
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true

147
types.d.ts vendored
View File

@ -1,6 +1,44 @@
export interface Notification {
account: Account;
status: Status;
id: string;
type: string;
created_at: string;
}
export interface ContextResponse {
ancestors: ContextObject[];
descendents: ContextObject[];
}
export interface PostAncestorsForModel {
account_fqn: string;
mentions: string[];
plaintext_content: string;
}
interface ContextAccountObject {
acct: string;
avatar: string;
bot: boolean;
display_name: string;
followers_count: number;
following_count: number;
fqn: string;
id: string;
}
export interface ContextObject {
content: string;
id: string;
in_reply_to_account_id: string | null;
in_reply_to_id: string | null;
media_attachments: string[];
mentions: Mention[];
pleroma: PleromaObjectInResponse;
visibility: "public" | "private" | "unlisted";
uri: string;
account: ContextAccountObject;
}
export interface NewStatusBody {
@ -33,11 +71,46 @@ export interface OllamaRequest {
/**
* Whatever system prompt you'd like to add to the model to make it more unique, or force it to respond a certain way.
*/
system: string;
system?: string;
/**
* 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;
}
export interface OllamaChatRequest {
model: string;
messages: OllamaMessages[];
stream?: boolean = false;
options?: OllamaConfigOptions;
}
export interface OllamaChatResponse {
model: string;
created_at: string;
message: OllamaChatResponseMessage;
done_reason: "string";
done: boolean;
total_duration: number;
load_duration: number;
prompt_eval_count: number;
prompt_eval_duration: number;
eval_count: number;
eval_duration: number;
}
interface OllamaChatResponseMessage {
role: "assistant";
content: string;
}
interface OllamaMessages {
role: "system" | "user";
content: string;
}
export interface OllamaResponse {
@ -54,8 +127,19 @@ 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
pleroma: PleromaObjectInResponse;
visibility: "private" | "public" | "unlisted";
}
interface PleromaObjectInResponse {
content: { "text/plain": string };
context: string;
conversation_id: number;
direct_conversation_id: number | null;
local: boolean;
in_reply_to_account_acct: string;
}
export interface Mention {
@ -64,3 +148,58 @@ export interface Mention {
url: 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)
*/
export interface OllamaConfigOptions {
/**
* Number of tokens guaranteed to be kept in memory during response generation. Higher values leave less
* possible room for num_ctx
*/
num_keep?: number;
seed?: number;
/**
* Sets maximum of tokens in the response
*/
num_predict?: number;
top_k?: number;
top_p?: number;
min_p?: number;
typical_p?: number;
repeat_last_n?: number;
/**
* How close of a response should the response be to the original prompt - lower = more focused response
*/
temperature?: number;
repeat_penalty?: number;
presence_penalty?: number;
frequency_penalty?: number;
mirostat?: number;
mirostat_tau?: number;
mirostat_eta?: number;
penalize_newline?: boolean;
stop?: string[];
numa?: boolean;
/**
* Number of tokens for the prompt to keep in memory for the response, minus the value of num_keep
*/
num_ctx?: number;
num_batch?: number;
num_gpu?: number;
main_gpu?: number;
low_vram?: boolean;
vocab_only?: boolean;
use_mmap?: boolean;
use_mlock?: boolean;
num_thread?: number;
}