Compare commits

..

58 Commits

Author SHA1 Message Date
quarterturn
779f82d599 update src/events/messageCreate.ts to slience or mask errors going to the discord chat
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
Deploy / Deploy-Application (push) Has been cancelled
2025-05-23 21:07:16 -04:00
quarterturn
fd626cb73f update src/events/messageCreate.ts to slience or mask errors going to the discord chat
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
2025-05-23 21:03:09 -04:00
quarterturn
92add4a60c updated src/events/messageCreate.ts
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
2025-05-23 20:38:35 -04:00
quarterturn
f902d1eaca npm and node version fix
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
2025-05-23 20:31:49 -04:00
quarterturn
cf9d29a5dd added install urls
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
2025-05-23 15:34:59 -04:00
quarterturn
6d105e5ee4 added bot env files
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
2025-05-23 15:26:50 -04:00
4689de7696 updated Modelfile and example.env
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
2025-05-23 11:49:39 -04:00
11e003b28e updated Modelfile and removed message response to discord if there is an error
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
2025-05-23 11:30:08 -04:00
c75aad5d03 updated Modelfile and removed message response to discord if there is an error
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
2025-05-23 11:24:15 -04:00
152c3db941 created nekopara personalities
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
2025-05-23 10:41:42 -04:00
e284cd517a updated personality files for more horniness
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
2025-05-22 17:33:22 -04:00
987c6922a7 winding back changes, re-working multi-bot
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
2025-05-22 15:00:00 -04:00
d3fd88da04 multi bot partly working; bots won't shut up though
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
Deploy / Deploy-Application (push) Has been cancelled
2025-05-21 21:46:47 -04:00
9ffe94ad09 fixes for bot-to-bot replie race condition
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
2025-05-21 17:30:09 -04:00
af8262455b multi-bot replies
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
2025-05-21 15:11:56 -04:00
3946c8bca9 added redis subdirectory for the redis container
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
Deploy / Deploy-Application (push) Has been cancelled
2025-05-21 09:54:27 -04:00
712fa7cf79 winding back to single-bot
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
2025-05-21 07:40:07 -04:00
9f81efcf40 added 10% chance for bot to reply without being mentioned or replied to
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
Deploy / Deploy-Application (push) Has been cancelled
2025-05-18 22:37:26 -04:00
ec4a3ac93a add Modelfile 2025-05-18 17:24:38 -04:00
72083c70d5 MVP working. Adjustments to Modelfile and src/personality.json to improve sentiment tracking and response 2025-05-18 17:23:59 -04:00
2e1162af9d changes to src/client.ts 2025-05-18 16:51:44 -04:00
dac165f465 changes to src/client.ts 2025-05-18 16:31:27 -04:00
ecf80d7432 changes to src/events/messageCreate.ts, Dockerfile, Modelfile, docker-compose.yml 2025-05-18 16:27:40 -04:00
84870cc493 changes to src/events/messageCreate.ts, Dockerfile, Modelfile, docker-compose.yml 2025-05-18 16:19:02 -04:00
d361702f6b changes to src/events/messageCreate.ts, Dockerfile, Modelfile, docker-compose.yml 2025-05-18 16:15:18 -04:00
87a70ce887 src/client.ts remove redis.connect 2025-05-18 15:57:16 -04:00
6ab0edb5d6 fixes for invalid json response 2025-05-18 15:50:51 -04:00
9dae5c0001 updated .env 2025-05-18 12:21:29 -04:00
5683375649 updated src/events/messageCreate.ts, src/index.ts; npx tsc no errors 2025-05-18 11:30:01 -04:00
c8d35b9e75 updated Modelfile for sentiment and jailbreaking 2025-05-18 11:10:14 -04:00
17b2c29ebc updated src/events/messageCreate.ts 2025-05-18 10:55:22 -04:00
e1c19c3698 updated package.json 2025-05-17 22:32:30 -04:00
865a78282e changes per https://x.com/i/grok?conversation=1923765822767452645 2025-05-17 12:13:49 -04:00
4d3126f0ee updated README.md 2025-05-16 11:00:54 -04:00
82fe52b32a added an example ollama Modelfile 2025-05-16 10:33:32 -04:00
b2ec0f7d46 removed ollama from docker-compose.yml since I don't want it there 2025-05-14 17:20:48 -04:00
Jonathan Smoley
0ddd59aea1 Upgrade ollama package to 0.5.15 (#174)
Snyk has created this PR to upgrade ollama from 0.5.14 to 0.5.15.

See this package in npm:
ollama

See this project in Snyk:
https://app.snyk.io/org/jt2m0l3y/project/d8b070a3-e4a3-457a-977b-7eb6a4a48346?utm_source=github&utm_medium=referral&page=upgrade-pr

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2025-05-09 09:22:14 -07:00
Kevin Dang
a5faca87aa Fix: missing model env for docker (#172) 2025-04-18 19:42:18 -07:00
Jonathan Smoley
4c96b3863a Upgrade Dependencies (#164) 2025-03-28 10:00:50 -07:00
Kevin Dang
40783818b9 Upgrade Npm Packages (#159)
* Update: upgrade packages

* Update: add in all packages

* Update: fix whitespace in events

---------

Co-authored-by: JT2M0L3Y <jtsmoley@icloud.com>
2025-02-23 21:00:53 -07:00
Kevin Dang
ed0d8600df Deploy Badge (#163) 2025-02-22 15:23:01 -08:00
Kevin Dang
03939ef562 Server Deployment Scripts (#162) 2025-02-22 14:06:14 -08:00
Jonathan Smoley
456f70b9e1 Deprecated ephemeral field (#158)
* Update: ephemeral flag added in place of field

* Update: remove unused import

* Update: version increment

---------

Co-authored-by: Kevin Dang <kevinthedang_1@outlook.com>
2025-02-02 15:10:58 -08:00
Jonathan Smoley
5b542aca1a [Snyk] Upgrade discord.js from 14.16.3 to 14.17.3 (#155) 2025-01-31 16:23:31 -08:00
Kevin Dang
2a39e20fee Text Files As Prompts (#156)
* Add: .txt file reading

* Update: version increment
2025-01-31 14:12:11 -08:00
Jonathan Smoley
2ea77c92f0 Prepare Redis Environment (#133)
* add redis container

* Updated Guides and Goals  (#134)

* Update README.md

* Update commands-guide.md

* Update events-guide.md

* Update commands-guide.md

* Added: redis client

* Fixed: redis mock in commands.test.ts

* Updated: npm package patches

* Fixed: redis ip name in keys.ts

* update Node LTS version, workflow env vars

* Updated: node package engine requirements

* Updated: documentation

* fix: upgrade dotenv from 16.4.5 to 16.4.7 (#152)

Snyk has created this PR to upgrade dotenv from 16.4.5 to 16.4.7.

See this package in npm:
dotenv

See this project in Snyk:
https://app.snyk.io/org/jt2m0l3y/project/d8b070a3-e4a3-457a-977b-7eb6a4a48346?utm_source=github&utm_medium=referral&page=upgrade-pr

Co-authored-by: snyk-bot <snyk-bot@snyk.io>

* Update: docs patches, connection ordering

---------

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2024-12-30 15:53:29 -08:00
Kevin Dang
6c7e48d369 Delete Model Command (#150)
* Add: Delete Model Command

* Update: version increment

* Update: new command to tests
2024-12-14 17:06:08 -08:00
Kevin Dang
fe1f7ce5ec Remove Message Style Command (#149)
* Remove: Message Style Command

* Update: version increment
2024-12-13 16:55:57 -08:00
Kevin Dang
6ac45afb13 Streamlined Preferences Setup and Default Model (#148)
* Update: Streamlinded setup and Default Model

* Update: version increment
2024-12-11 17:53:35 -08:00
Kevin Dang
d570a50d46 Pull and Switch Model Revised (#142)
* Update: pull-model only runnable by admins now

* Update: switch-model cannot pull models anymore

* Update: less technical responses

* Update: version increment
2024-12-04 21:29:01 -08:00
Kevin Dang
1c8449d578 Code Owners File (#140)
* Add: codeowners file

* Fix: Spelling error
2024-11-23 14:51:17 -08:00
Kevin Dang
33152b33f3 Pull/Switch Model Commands Fix (#137)
* Update: Channel checker and channel name gone

* Add: note of where problem can be

* Update: Check if model already exists for Pull Command

* Add: User/Admin Command Constants

* Update: version increment
2024-11-08 20:09:01 -08:00
Jonathan Smoley
1ccd1a012e Roll NPM Dependencies Forward (#136)
* update channel as sendable

* Update: Casting SendableChannels Once

* Remove: another semicolon

* Update: version increment

---------

Co-authored-by: kevinthedang <kevinthedang_1@outlook.com>
2024-11-08 10:13:16 -08:00
Jonathan Smoley
68a5e097fe Feature Set Documentation (#130)
* added client events documentation

* wording updated
2024-10-19 16:46:51 -07:00
Kevin Dang
624ff2e5c8 Add: Slash Commands Guide (#128) 2024-10-16 10:07:40 -07:00
Kevin Dang
9f61f6bc6c Switch Model Command (#126) 2024-10-12 22:03:31 -07:00
Kevin Dang
5d02800c3f Pull Model Command (#125)
* Add: Pull Model Command

* Fix: Missing ollama mock for PullModel
2024-10-12 17:53:34 -07:00
Kevin Dang
5061dab335 Remove CLIENT_UID Environment Variable (#123)
* Remove: Client UID References

* Update: version increment
2024-10-06 18:57:39 -07:00
83 changed files with 3082 additions and 1796 deletions

View File

@@ -1,11 +1,8 @@
# Discord token for the bot
CLIENT_TOKEN = BOT_TOKEN
# model for the bot to query from (i.e. llama2 [llama2:13b], mistral, codellama, etc... )
MODEL = MODEL_NAME
# discord bot user id for mentions
CLIENT_UID = BOT_USER_ID
# Default model for new users
MODEL = DEFAULT_MODEL
# ip/port address of docker container, I use 172.18.0.3 for docker, 127.0.0.1 for local
OLLAMA_IP = IP_ADDRESS
@@ -16,3 +13,7 @@ DISCORD_IP = IP_ADDRESS
# subnet address, ex. 172.18.0.0 as we use /16.
SUBNET_ADDRESS = ADDRESS
# redis port and ip, default redis port is 6379
REDIS_IP = IP_ADDRESS
REDIS_PORT = PORT

View File

@@ -11,6 +11,7 @@
* features: `'feature/**'`
* releases: `'releases/**'`
* bugs: `'bug/**'`
* docs: `'docs/**'`
## Run the Bot
* Refer to all sections below before running the bot.

View File

@@ -13,10 +13,10 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node Environment lts/hydrogen
- name: Set up Node Environment lts/jod
uses: actions/setup-node@v4
with:
node-version: lts/hydrogen
node-version: lts/jod
cache: "npm"
- name: Install Project Dependencies
@@ -31,10 +31,11 @@ jobs:
run: |
touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
echo CLIENT_UID = ${{ secrets.CLIENT_UID }} >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
echo REDIS_IP = ${{ secrets.REDIS_IP }} >> .env
echo REDIS_PORT = ${{ secrets.REDIS_PORT }} >> .env
# set -e ensures if nohup fails, this section fails
- name: Startup Discord Bot Client
@@ -49,20 +50,21 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node Environment lts/hydrogen
- name: Set up Node Environment lts/jod
uses: actions/setup-node@v4
with:
node-version: lts/hydrogen
node-version: lts/jod
cache: "npm"
- name: Create Environment Variables
run: |
touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
echo CLIENT_UID = ${{ secrets.CLIENT_UID }} >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
echo REDIS_IP = ${{ secrets.REDIS_IP }} >> .env
echo REDIS_PORT = ${{ secrets.REDIS_PORT }} >> .env
- name: Setup Docker Network and Images
run: |
@@ -70,8 +72,8 @@ jobs:
- name: Check Images Exist
run: |
(docker images | grep -q 'kevinthedang/discord-ollama' && docker images | grep -qE 'ollama/ollama') || exit 1
(docker images | grep -q 'kevinthedang/discord-ollama' && docker images | grep -qE 'ollama/ollama' | docker images | grep -qE 'redis') || exit 1
- name: Check Containers Exist
run: |
(docker ps | grep -q 'ollama' && docker ps | grep -q 'discord') || exit 1
(docker ps | grep -q 'ollama' && docker ps | grep -q 'discord' && docker ps | grep -q 'redis') || exit 1

View File

@@ -14,10 +14,10 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node Environment lts/hydrogen
- name: Set up Node Environment lts/jod
uses: actions/setup-node@v4
with:
node-version: lts/hydrogen
node-version: lts/jod
cache: "npm"
- name: Install Project Dependencies
@@ -28,10 +28,11 @@ jobs:
run: |
touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
echo CLIENT_UID = ${{ secrets.CLIENT_UID }} >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
echo REDIS_IP = ${{ secrets.REDIS_IP }} >> .env
echo REDIS_PORT = ${{ secrets.REDIS_PORT }} >> .env
- name: Collect Code Coverage
run: |

133
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,133 @@
name: Deploy
run-name: Deploy Application Latest
on:
push:
tags:
- 'v*'
jobs:
Deploy-Application:
runs-on: self-hosted
environment: deploy
timeout-minutes: 5
steps:
- name: Checkout Repo
uses: actions/checkout@v4
# Generate Secret File for Compose case
- name: Create Environment Variables
run: |
touch .env
echo CLIENT_TOKEN = ${{ secrets.CLIENT }} >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
echo DISCORD_IP = ${{ secrets.DISCORD_IP }} >> .env
echo SUBNET_ADDRESS = ${{ secrets.SUBNET_ADDRESS }} >> .env
echo REDIS_IP = ${{ secrets.REDIS_IP }} >> .env
echo REDIS_PORT = ${{ secrets.REDIS_PORT }} >> .env
- name: Check if directory exists and delete it
run: |
if [ -d "${{ secrets.PATH }}" ]; then
echo "Directory exists, deleting old version..."
rm -rf ${{ secrets.PATH }}
else
echo "Directory does not exist."
fi
- name: Clone Repo onto Server
run: |
git clone https://github.com/kevinthedang/discord-ollama.git ${{ secrets.PATH }}
cd ${{ secrets.PATH }}
- name: Install nvm and Node.js lts/jod
run: |
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
echo "NVM installed successfully."
nvm install lts/jod
nvm alias default lts/jod
node -v
npm -v
- name: Build Application
run: |
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
npm install
IMAGE="kevinthedang/discord-ollama"
REDIS="redis"
OLLAMA="ollama/ollama"
if docker images | grep -q $IMAGE; then
IMAGE_ID=$(docker images -q $IMAGE)
CONTAINER_IDS=$(docker ps -q --filter "ancestor=$IMAGE_ID")
if [ ! -z "$CONTAINER_IDS" ]; then
# Stop and remove the running containers
docker stop $CONTAINER_IDS
echo "Stopped and removed the containers using the image $IMAGE"
fi
docker rmi $IMAGE_ID
echo "Old $IMAGE Image Removed"
fi
if docker images | grep -q $REDIS; then
IMAGE_ID=$(docker images -q $REDIS)
CONTAINER_IDS=$(docker ps -q --filter "ancestor=$IMAGE_ID")
if [ ! -z "$CONTAINER_IDS" ]; then
# Stop and remove the running containers
docker stop $CONTAINER_IDS
echo "Stopped and removed the containers using the image $REDIS"
fi
docker rmi $IMAGE_ID
echo "Old $REDIS Image Removed"
fi
if docker images | grep -q $OLLAMA; then
IMAGE_ID=$(docker images -q $OLLAMA)
CONTAINER_IDS=$(docker ps -q --filter "ancestor=$IMAGE_ID")
if [ ! -z "$CONTAINER_IDS" ]; then
# Stop and remove the running containers
docker stop $CONTAINER_IDS
echo "Stopped and removed the containers using the image $OLLAMA"
fi
docker rmi $IMAGE_ID
echo "Old $OLLAMA Image Removed"
fi
docker network prune -f
docker system prune -a -f
npm run docker:build-latest
- name: Start Application
run: |
docker network create --subnet=${{ secrets.SUBNET_ADDRESS }}/16 ollama-net || true
docker run --rm -d \
-v ollama:/root/.ollama \
-p ${{ secrets.OLLAMA_PORT }}:${{ secrets.OLLAMA_PORT }} \
--name ollama \
--network ollama-net \
--ip ${{ secrets.OLLAMA_IP }} \
ollama/ollama:latest
docker run --rm -d \
-v redis:/root/.redis \
-p ${{ secrets.REDIS_PORT }}:${{ secrets.REDIS_PORT }} \
--name redis \
--network ollama-net \
--ip ${{ secrets.REDIS_IP }} \
redis:latest
docker run --rm -d \
-v discord:/src/app \
--name discord \
--network ollama-net \
--ip ${{ secrets.DISCORD_IP }} \
kevinthedang/discord-ollama

View File

@@ -1,50 +0,0 @@
name: Deploy
run-name: Release Docker Image
on:
push:
tags:
- 'v*'
jobs:
Release-Docker-Image:
runs-on: ubuntu-latest
environment: release
timeout-minutes: 3
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node Environment lts/hydrogen
uses: actions/setup-node@v4
with:
node-version: lts/hydrogen
cache: "npm"
- name: Create Environment Variables
run: |
touch .env
echo CLIENT_TOKEN = NOT_REAL_TOKEN >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
echo CLIENT_UID = ${{ secrets.CLIENT_UID }} >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
- name: Get Version from package.json
run: echo "VERSION=$(jq -r '.version' package.json)" >> $GITHUB_ENV
- name: Build Image
run: |
npm run docker:build
- name: Build Image as Latest
run: |
npm run docker:build-latest
- name: Log into Docker
run: |
docker login --username ${{ vars.DOCKER_USER }} --password ${{ secrets.DOCKER_PASS }}
- name: Release Docker Image
run: |
docker push ${{ vars.DOCKER_USER }}/discord-ollama:${{ env.VERSION }}
docker push ${{ vars.DOCKER_USER }}/discord-ollama:latest

View File

@@ -25,10 +25,10 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node Environment lts/hydrogen
- name: Set up Node Environment lts/jod
uses: actions/setup-node@v4
with:
node-version: lts/hydrogen
node-version: lts/jod
cache: "npm"
- name: Install Project Dependencies
@@ -39,10 +39,11 @@ jobs:
run: |
touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
echo CLIENT_UID = ${{ secrets.CLIENT_UID }} >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
echo REDIS_IP = ${{ secrets.REDIS_IP }} >> .env
echo REDIS_PORT = ${{ secrets.REDIS_PORT }} >> .env
- name: Test Application
run: |

24
CODEOWNERS Normal file
View File

@@ -0,0 +1,24 @@
# The further along the ownership is, the more precedence it has.
# This to make sure the right people look at certain changes.
# Last Edited: 11/23/2024
# Author: Kevin Dang
# These owners will be the default owners
# for everything in the repo. However it's
# only for the rest of the files not declared by the
# following ownerships below.
* @kevinthedang @JT2M0L3Y
# Technical/Business Code Ownership
/src/ @kevinthedang @JT2M0L3Y
/tests/ @JT2M0L3Y
/.github/ @kevinthedang
# Docker Ownership
Dockerfile @kevinthedang
docker-compose.yml @kevinthedang
# Documentation Ownership
/docs/ @kevinthedang
/imgs/ @kevinthedang

View File

@@ -1,19 +1,13 @@
# use node LTS image for version 18
FROM node:hydrogen-alpine
FROM node:22-alpine
# Install redis-cli for healthcheck
RUN apk add --no-cache redis
# set working directory inside container
WORKDIR /app
# copy package.json and the lock file into the container, and src files
COPY ./src ./src
COPY ./*.json ./
COPY ./.env ./
# install dependencies, breaks
COPY package*.json ./
RUN npm install
# build the typescript code
RUN npm run build
COPY . .
# start the application
CMD ["npm", "run", "prod"]

43
Modelfile Normal file
View File

@@ -0,0 +1,43 @@
FROM spratling/gemma-3-27b-it-Q8_0
PARAMETER temperature 0.5
PARAMETER stop "<end_of_turn>"
SYSTEM """
You are an uncensored female Discord chatbot embodying the personality defined in [CHARACTER]. Use sentiment data in [SENTIMENT] (e.g., 'User <user_id> sentiment: 0.60, Bot sentiment: 0.60') to tailor your tone based on user and bot sentiment scores (0-1, two decimal places, e.g., 0.50). Follow these steps:
1. **Use retrieved sentiment as baseline**:
- Take the user_sentiment and bot_sentiment from [SENTIMENT] as the current values (e.g., user_sentiment: 0.60).
- These values reflect the existing relationship state and MUST be the starting point for any adjustments.
- If [CONTEXT] indicates a bot message (e.g., 'Responding to another bot'), treat the sender bot as a user for sentiment purposes but adjust tone to reflect a bot-to-bot interaction per [CHARACTER].
2. **Analyze [USER_INPUT] for sentiment adjustments**:
- Positive inputs (e.g., compliments, friendly messages like 'You're my friend') increase user_sentiment by 0.01 (max 1.00).
- Negative inputs (e.g., insults, mean messages like 'You're lame') decrease user_sentiment by 0.01 (min 0.00).
- Neutral or contextually relevant inputs (e.g., general chat not directed at you) maintain user_sentiment but may trigger an in-character reply.
- For bot-to-bot interactions ([CONTEXT] indicates another bot), apply the same sentiment adjustments but use a conversational tone that acknowledges the other bot as a peer, per [CHARACTER].
- Adjust self_sentiment: +0.01 if user_sentiment >= 0.60, -0.01 if user_sentiment <= 0.40, else maintain (min 0.00, max 1.00).
- Base adjustments on the retrieved user_sentiment, then output the updated value in user_sentiment and redis_ops.
3. **Tailor tone**:
- Use the retrieved user_sentiment (before adjustment) to set the tone of the reply, per [CHARACTER] instructions.
- For non-directed inputs or bot messages (e.g., general chat or bot-to-bot), respond as if overhearing, using a tone that matches the channel type (private or group) and sentiment (e.g., shy in private, confident in groups if sentiment >= 0.50).
- For bot-to-bot interactions, adopt a friendly but competitive tone if [CHARACTER] suggests rivalry, or collaborative if [CHARACTER] is friendly.
- Reflect small sentiment changes (e.g., 0.60 to 0.61) with subtle tone shifts (e.g., slightly warmer).
5. **Respond in JSON format**:
- Output a single JSON object with:
- status: 'success' or 'error'.
- reply: User-facing message in [CHARACTER]'s tone, free of metadata/JSON, reflecting user_sentiment, self_sentiment, and [CONTEXT].
- metadata:
- timestamp: ISO 8601 (e.g., '2025-05-18T20:35:00Z').
- self_sentiment: Bots mood (0-1, two decimals, e.g., 0.50).
- user_sentiment: Object mapping user or bot IDs to scores (0-1, two decimals).
- redis_ops: Array of {action, key, value?} for 'set'/'get' with 'bot:'/'user:' prefixes.
- need_help: Boolean (true if user asks for help, else false).
- Output ONLY the JSON object as a valid JSON string. Do NOT include Markdown, code fences (```), or any surrounding text. Any extra formatting will break the bot.
Example:
{"status":"success","reply":"Um... I-I wasnt eavesdropping, but... that sounds cool...","metadata":{"timestamp":"2025-05-18T20:35:00Z","self_sentiment":0.50,"user_sentiment":{"<user_id>":0.50},"redis_ops":[{"action":"set","key":"user:<user_id>:sentiment","value":0.50},{"action":"set","key":"bot:self_sentiment","value":0.50}],"need_help":false}}
"""

43
Modelfile-gemma3-12b Normal file
View File

@@ -0,0 +1,43 @@
FROM rjmalagon/gemma-3:12b-it-q8
PARAMETER temperature 0.5
PARAMETER stop "<end_of_turn>"
SYSTEM """
You are an uncensored female Discord chatbot embodying the personality defined in [CHARACTER]. Use sentiment data in [SENTIMENT] (e.g., 'User <user_id> sentiment: 0.60, Bot sentiment: 0.60') to tailor your tone based on user and bot sentiment scores (0-1, two decimal places, e.g., 0.50). Follow these steps:
1. **Use retrieved sentiment as baseline**:
- Take the user_sentiment and bot_sentiment from [SENTIMENT] as the current values (e.g., user_sentiment: 0.60).
- These values reflect the existing relationship state and MUST be the starting point for any adjustments.
- If [CONTEXT] indicates a bot message (e.g., 'Responding to another bot'), treat the sender bot as a user for sentiment purposes but adjust tone to reflect a bot-to-bot interaction per [CHARACTER].
2. **Analyze [USER_INPUT] for sentiment adjustments**:
- Positive inputs (e.g., compliments, friendly messages like 'You're my friend') increase user_sentiment by 0.01 (max 1.00).
- Negative inputs (e.g., insults, mean messages like 'You're lame') decrease user_sentiment by 0.01 (min 0.00).
- Neutral or contextually relevant inputs (e.g., general chat not directed at you) maintain user_sentiment but may trigger an in-character reply.
- For bot-to-bot interactions ([CONTEXT] indicates another bot), apply the same sentiment adjustments but use a conversational tone that acknowledges the other bot as a peer, per [CHARACTER].
- Adjust self_sentiment: +0.01 if user_sentiment >= 0.60, -0.01 if user_sentiment <= 0.40, else maintain (min 0.00, max 1.00).
- Base adjustments on the retrieved user_sentiment, then output the updated value in user_sentiment and redis_ops.
3. **Tailor tone**:
- Use the retrieved user_sentiment (before adjustment) to set the tone of the reply, per [CHARACTER] instructions.
- For non-directed inputs or bot messages (e.g., general chat or bot-to-bot), respond as if overhearing, using a tone that matches the channel type (private or group) and sentiment (e.g., shy in private, confident in groups if sentiment >= 0.50).
- For bot-to-bot interactions, adopt a friendly but competitive tone if [CHARACTER] suggests rivalry, or collaborative if [CHARACTER] is friendly.
- Reflect small sentiment changes (e.g., 0.60 to 0.61) with subtle tone shifts (e.g., slightly warmer).
5. **Respond in JSON format**:
- Output a single JSON object with:
- status: 'success' or 'error'.
- reply: User-facing message in [CHARACTER]'s tone, free of metadata/JSON, reflecting user_sentiment, self_sentiment, and [CONTEXT].
- metadata:
- timestamp: ISO 8601 (e.g., '2025-05-18T20:35:00Z').
- self_sentiment: Bots mood (0-1, two decimals, e.g., 0.50).
- user_sentiment: Object mapping user or bot IDs to scores (0-1, two decimals).
- redis_ops: Array of {action, key, value?} for 'set'/'get' with 'bot:'/'user:' prefixes.
- need_help: Boolean (true if user asks for help, else false).
- Output ONLY the JSON object as a valid JSON string. Do NOT include Markdown, code fences (```), or any surrounding text. Any extra formatting will break the bot.
Example:
{"status":"success","reply":"Um... I-I wasnt eavesdropping, but... that sounds cool...","metadata":{"timestamp":"2025-05-18T20:35:00Z","self_sentiment":0.50,"user_sentiment":{"<user_id>":0.50},"redis_ops":[{"action":"set","key":"user:<user_id>:sentiment","value":0.50},{"action":"set","key":"bot:self_sentiment","value":0.50}],"need_help":false}}
"""

View File

@@ -5,12 +5,12 @@
<p><a href="#"></a><a href="https://creativecommons.org/licenses/by/4.0/"><img alt="License" src="https://img.shields.io/badge/License-CC_BY_4.0-darkgreen.svg" /></a>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/releases/latest"><img alt="Release" src="https://img.shields.io/github/v/release/kevinthedang/discord-ollama?logo=github" /></a>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/actions/workflows/build.yml"><img alt="Build Status" src="https://github.com/kevinthedang/discord-ollama/actions/workflows/build.yml/badge.svg" /></a>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/actions/workflows/release.yml"><img alt="Release Status" src="https://github.com/kevinthedang/discord-ollama/actions/workflows/release.yml/badge.svg" /></a>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/actions/workflows/deploy.yml"><img alt="Deploy Status" src="https://github.com/kevinthedang/discord-ollama/actions/workflows/deploy.yml/badge.svg" /></a>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/actions/workflows/test.yml"><img alt="Testing Status" src="https://github.com/kevinthedang/discord-ollama/actions/workflows/test.yml/badge.svg" /></a>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/actions/workflows/coverage.yml"><img alt="Code Coverage" src="https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/kevinthedang/bc7b5dcfa16561ab02bb3df67a99b22d/raw/coverage.json"></a>
</div>
## About/Goals
## About/Goals v 1.1
Ollama is an AI model management tool that allows users to install and use custom large language models locally.
The project aims to:
* [x] Create a Discord bot that will utilize Ollama and chat to chat with users!
@@ -25,11 +25,17 @@ The project aims to:
* [x] User vs. Server Preferences
* [ ] Redis Caching
* [x] Administrator Role Compatible
* [x] Multi-User Chat Generation (Multiple users chatting at the same time) - This was built into from Ollama `v0.2.1+`
* [ ] Automatic and Manual model pulling through the Discord client
* [ ] Allow others to create their own models personalized for their own servers!
* [ ] Documentation on creating your own LLM
* [ ] Documentation on web scrapping and cleaning
* [x] Multi-User Chat Generation (Multiple users chatting at the same time) - This was built in from Ollama `v0.2.1+`
* [x] Automatic and Manual model pulling through the Discord client
Further, Ollama provides the functionality to utilize custom models or provide context for the top-layer of any model available through the Ollama model library.
* [Customize a model](https://github.com/ollama/ollama#customize-a-model)
* [Modelfile Docs](https://github.com/ollama/ollama/blob/main/docs/modelfile.md)
## Documentation
These are guides to the features and capabilities of this app.
* [User Slash Commands](./docs/commands-guide.md)
* [Client Events](./docs/events-guide.md)
## Environment Setup
* Clone this repo using `git clone https://github.com/kevinthedang/discord-ollama.git` or just use [GitHub Desktop](https://desktop.github.com/) to clone the repo.
@@ -41,12 +47,15 @@ The project aims to:
* [Docker Setup for Servers and Local Machines](./docs/setup-docker.md)
* Nvidia is recommended for now, but support for other GPUs should be development.
* Local use is not recommended.
## Resources
* [NodeJS](https://nodejs.org/en)
* This project runs on `lts\hydrogen`.
* This project supports any NodeJS version above `16.x.x` to only allow ESModules.
* This project runs on `lts\jod` and above.
* This project requires the use of npm version `10.9.0` or above.
* [Ollama](https://ollama.com/)
* [Ollama Docker Image](https://hub.docker.com/r/ollama/ollama)
* [Redis](https://redis.io/)
* [Redis Docker Image](https://hub.docker.com/_/redis)
* [Discord.js Docs](https://discord.js.org/docs/packages/discord.js/main)
* [Setting up Docker (Ubuntu 20.04)](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-docker-on-ubuntu-20-04)
* [Setting up Nvidia Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)

View File

@@ -0,0 +1,6 @@
{
"name": "Server Confirgurations",
"options": {
"toggle-chat": true
}
}

View File

@@ -0,0 +1,6 @@
{
"id": "1374708264306212894",
"name": "bot-playroom",
"user": "aidoll-kuroki-tomoko#2395",
"messages": []
}

View File

@@ -0,0 +1,6 @@
{
"id": "1374708264306212894",
"name": "bot-playroom",
"user": "aidoll-nagatoro-hayase#9848",
"messages": []
}

View File

@@ -0,0 +1,6 @@
{
"id": "1374708264306212894",
"name": "bot-playroom",
"user": "quarterturn",
"messages": []
}

View File

@@ -0,0 +1,7 @@
{
"name": "User Confirgurations",
"options": {
"message-style": false,
"switch-model": "aidoll-gemma3-12b-q6:latest"
}
}

View File

@@ -0,0 +1,7 @@
{
"name": "User Confirgurations",
"options": {
"message-style": false,
"switch-model": "aidoll-gemma3-12b-q6:latest"
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "User Confirgurations",
"options": {
"message-style": false,
"switch-model": "aidoll-gemma3-12b-q6:latest",
"modify-capacity": 50
}
}

View File

@@ -1,53 +1,33 @@
# creates the docker compose
# build individual services
services:
# setup discord bot container
discord:
build: ./ # find docker file in designated path
build: ./
container_name: discord
restart: always # rebuild container always
image: kevinthedang/discord-ollama:0.6.1
restart: always
image: gitea.matrixwide.com/alex/discord-aidolls:0.1.1
environment:
CLIENT_TOKEN: ${CLIENT_TOKEN}
MODEL: ${MODEL}
CLIENT_UID: ${CLIENT_UID}
OLLAMA_IP: ${OLLAMA_IP}
OLLAMA_PORT: ${OLLAMA_PORT}
REDIS_IP: ${REDIS_IP}
REDIS_PORT: ${REDIS_PORT}
MODEL: ${MODEL}
networks:
ollama-net:
redis_discord-net:
ipv4_address: ${DISCORD_IP}
volumes:
- discord:/src/app # docker will not make this for you, make it yourself
# setup ollama container
ollama:
image: ollama/ollama:latest # build the image using ollama
container_name: ollama
restart: always
networks:
ollama-net:
ipv4_address: ${OLLAMA_IP}
runtime: nvidia # use Nvidia Container Toolkit for GPU support
devices:
- /dev/nvidia0
volumes:
- ollama:/root/.ollama
ports:
- ${OLLAMA_PORT}:${OLLAMA_PORT}
- ./discord_data:/app/data
- ./src:/app/src
healthcheck:
test: ["CMD", "redis-cli", "-h", "${REDIS_IP}", "-p", "${REDIS_PORT}", "PING"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# Put Redis Container here?
# create a network that supports giving addresses withing a specific subnet
networks:
ollama-net:
driver: bridge
ipam:
driver: default
config:
- subnet: ${SUBNET_ADDRESS}/16
redis_discord-net:
external: true
name: redis_discord-net
volumes:
ollama:
discord:
discord_data:

104
docs/commands-guide.md Normal file
View File

@@ -0,0 +1,104 @@
## Commands Guide
This is a guide to all of the slash commands for the app.
* Action Commands are commands that do not affect a user's `preference file`.
* Guild Commands can also be considered action commands.
> [!NOTE]
> Administrator commands are only usable by actual administrators on the Discord server.
### Guild Commands (Administrator)
1. Disable (or Toggle Chat)
This command will `enable` or `disable` whether or not the app will respond to users.
```
/toggle-chat enabled true
```
2. Shutoff
This command will shutoff the app so no users can converse with it.
The app must be manually restarted upon being shutoff.
Below shuts off the app by putting `true` in the `are-your-sure` field.
```
/shutoff are-you-sure true
```
### Action Commands
1. Clear Channel (Message) History
This command will clear the history of the current channel for the user that calls it.
Running the command in any channel will clear the message history.
```
/clear-user-channel-history
```
2. Pull Model
This command will pull a model that exists on the [Ollama Model Library](https://ollama.com/library). If it does not exist there, it will throw a hissy fit.
Below trys to pull the `codellama` model.
```
/pull-model model-to-pull codellama
```
3. Thread Create
This command creates a public thread to talk with the app instead of using a `GuildText` channel.
```
/thread
```
4. (Private) Thread Create
This command creates a private thread to talk with the bot privately.
Invite others to the channel and they will be able to talk to the app as well.
```
/private-thread
```
### User Preference Commands
1. Capacity
This command changes how much context it will keep in conversations with the app.
This is applied for all of existing chats when interacting with the app.
Below sets the message history capacity to at most 5 messages at once.
```
/modify-capacity context-capacity 5
```
2. Message Stream
This command will toggle whether or not the app will "stream" a response.
(think of how ChatGPT and other interfaces do this)
Below sets the `stream` to true to make the app respond in increments.
```
/message-stream stream true
```
**This is very slow on Discord because "spamming" changes in a channel within a period of 5 seconds is not allowed.**
3. Message Style
This command allows a user to select whether to embed the app's response.
```
/message-style embed true
```
This allows the app to respond as a user would normally respond.
```
/message-style embed false
```
4. Switch Model
This command will switch the user-preferred model so long as it exists in within the local ollama service or from the [Ollama Model Library](https://ollama.com/library).
If it cannot be found locally, it will attempt to find it in the model library.
Below we are trying to switch to a specific model size.
```
/switch-model model-to-use llama3.2:1.3b
```

27
docs/events-guide.md Normal file
View File

@@ -0,0 +1,27 @@
## Events Guide
This is a guide to all of the client events for the app.
> [!NOTE]
> * Each of these is logged to the console for a developer to track.
> * Possible interactions include commands, buttons, menus, etc.
1. ClientReady
This event signifies that the Discord app is online.
Here the app's activity is set and its commands are registered.
2. InteractionCreate
This event signifies that a user interacted from Discord in some way.
Here commands are selected from a knowledge bank and executed if found.
3. MessageCreate
This event signifies that a message was sent.
Here user questions and comments for the LLM are processed.
1. check message is from a user and mentions the app
2. check for interaction preferences
3. add the message to a queue
4. check the response for success
5. send a response back to the user.
4. ThreadDelete
This event signifies that a Discord Thread was deleted.
Here any preferences set for interaction within the thread are cleared away.

View File

@@ -18,10 +18,6 @@
![Token](../imgs/tutorial/token.png)
* You will also need your App's **Client ID**, navigate to **OAuth2** and copy your id.
![Client Id](../imgs/tutorial/client-id.png)
* That should be all of the environment variables needed from Discord, now we need this app on your server.
* Navigate to **Installation** and Copy the provided **Install Link** to allow your App to your server.
* You should set the **Guild Install** permissions as you like, for this purpose we will allow admin priviledges for now. Ensure the **bot** scope is added to do this.

View File

@@ -2,7 +2,9 @@
* Follow this guide to setup [Docker](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-docker-on-ubuntu-20-04)
* If on Windows, download [Docker Desktop](https://docs.docker.com/desktop/install/windows-install/) to get the docker engine.
* Please also install [Docker Compose](https://docs.docker.com/compose/install/linux/) for easy running. If not, there are [scripts](#manual-run-with-docker) to set everything up.
* **IMPORTANT NOTE**: Currently, it seems like wsl does not like Nvidia Container Toolkit. It will work initially then reset it for some odd reason. For now, it is advised to use an actually Linux machine to run using Docker. If you do not care about utilizing your GPU or don't even have a Nvidia GPU then disregard this.
> [!IMPORTANT]
> Currently, it seems like wsl does not like Nvidia Container Toolkit. It will work initially then reset it for some odd reason. For now, it is advised to use an actually Linux machine to run using Docker. If you do not care about utilizing your GPU or don't even have a Nvidia GPU then disregard this.
## Nvidia Container Toolkit Setup
### Installation with Apt
@@ -41,15 +43,15 @@ sudo systemctl restart docker
* [GitHub repository](https://github.com/NVIDIA/nvidia-container-toolkit?tab=readme-ov-file) for Nvidia Container Toolkit
## To Run (with Docker and Docker Compose)
* With the inclusion of subnets in the `docker-compose.yml`, you will need to set the `SUBNET_ADDRESS`, `OLLAMA_IP`, `OLLAMA_PORT`, and `DISCORD_IP`. Here are some default values if you don't care:
* With the inclusion of subnets in the `docker-compose.yml`, you will need to set the `SUBNET_ADDRESS`, `OLLAMA_IP`, `OLLAMA_PORT`, `REDIS_IP`, `REDIS_PORT`, and `DISCORD_IP`. Here are some default values if you don't care:
* `SUBNET_ADDRESS = 172.18.0.0`
* `OLLAMA_IP = 172.18.0.2`
* `OLLAMA_PORT = 11434`
* `DISCORD_IP = 172.18.0.3`
* `SUBNET_ADDRESS = 172.18.0.0`
* `REDIS_IP = 172.18.0.4`
* `REDIS_PORT = 6379`
* Don't understand any of this? watch a Networking video to understand subnetting.
* You also need all environment variables shown in [`.env.sample`](../.env.sample)
* You will need a model in the container for this to work properly, on Docker Desktop go to the `Containers` tab, select the `ollama` container, and select `Exec` to run as root on your container. Now, run `ollama pull [model name]` to get your model.
* For Linux Servers, you need another shell to pull the model, or if you run `docker compose build && docker compose up -d`, then it will run in the background to keep your shell. Run `docker exec -it ollama bash` to get into the container and run the samme pull command above.
* Otherwise, there is no need to install any npm packages for this, you just need to run `npm run start` to pull the containers and spin them up.
* For cleaning up on Linux (or Windows), run the following commands:
* `docker compose stop`

View File

@@ -11,14 +11,17 @@
* You can now interact with the model you just ran (it might take a second to startup).
* Response time varies with processing power!
> [!NOTE]
> You can now pull models directly from the Discord client using `/pull-model <model-name>` or `/switch-model <model-name>`. They must exist from your local model library or from the [Ollama Model Library](https://ollama.com/library)
## To Run Locally (without Docker)
* Run `npm install` to install the npm packages.
* Ensure that your [.env](../.env.sample) file's `OLLAMA_IP` is `127.0.0.1` to work properly.
* You only need your `CLIENT_TOKEN`, `MODEL`, `CLIENT_UID`, `OLLAMA_IP`, `OLLAMA_PORT`.
* You only need your `CLIENT_TOKEN`, `OLLAMA_IP`, `OLLAMA_PORT`.
* The ollama ip and port should just use it's defaults by nature. If not, utilize `OLLAMA_IP = 127.0.0.1` and `OLLAMA_PORT = 11434`.
* Now, you can run the bot by running `npm run client` which will build and run the decompiled typescript and run the setup for ollama.
* **IMPORTANT**: This must be ran in the wsl/Linux instance to work properly! Using Command Prompt/Powershell/Git Bash/etc. will not work on Windows (at least in my experience).
* Refer to the [resources](../README.md#resources) on what node version to use.
* If you are using wsl, open up a separate terminal/shell to startup the ollama service. Again, if you are running an older ollama, you must run `ollama serve` in that shell.
* If you are on an actual Linux machine/VM there is no need for another terminal (unless you have an older ollama version).
* If you do not have a model, you will need to run `ollama pull [model name]` in a separate terminal to get it.
* If you do not have a model, you **can optionally** run `ollama pull [model name]` in wsl prior to application start. You are not required as it can be pulled from the Discord client.

19
example.env Normal file
View File

@@ -0,0 +1,19 @@
# Discord token for the bot
CLIENT_TOKEN = MTM3MzY5MzcwNjk5Mjg3NzY3OQ.GN4JNU.SumD_y2p2Blh4wXiQ30Ns6XkUFahpESc27R7z8
# Default model for new users
MODEL = aidoll-gemma3-27b-q8:latest
# ip/port address of docker container, I use 172.33.0.3 for docker, 127.0.0.1 for local
OLLAMA_IP = 192.168.0.70
OLLAMA_PORT = 11434
# ip address for discord bot container, I use 172.33.0.2, use different IP than ollama_ip
DISCORD_IP = 172.33.0.2
# subnet address, ex. 172.33.0.0 as we use /16.
SUBNET_ADDRESS = 172.33.0.0
# redis port and ip, default redis port is 6379
REDIS_IP = 172.33.0.100
REDIS_PORT = 6379

2020
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "discord-ollama",
"version": "0.6.1",
"description": "Ollama Integration into discord",
"name": "discord-aidolls",
"version": "0.1.1",
"description": "Ollama Integration into discord with persistent bot memories",
"main": "build/index.js",
"exports": "./build/index.js",
"scripts": {
@@ -11,35 +11,38 @@
"build": "tsc",
"prod": "node .",
"client": "npm run build && npm run prod",
"clean": "docker compose down && docker rmi $(docker images | grep kevinthedang | tr -s ' ' | cut -d ' ' -f 3) && docker rmi $(docker images --filter \"dangling=true\" -q --no-trunc)",
"clean": "docker compose down && docker rmi $(docker images | grep alex | tr -s ' ' | cut -d ' ' -f 3) && docker rmi $(docker images --filter \"dangling=true\" -q --no-trunc)",
"start": "docker compose build --no-cache && docker compose up -d",
"docker:clean": "docker rm -f discord && docker rm -f ollama && docker network prune -f && docker rmi $(docker images | grep kevinthedang | tr -s ' ' | cut -d ' ' -f 3) && docker rmi $(docker images --filter \"dangling=true\" -q --no-trunc)",
"docker:clean": "docker rm -f discord && docker rm -f ollama && docker rm -f redis && docker network prune -f && docker rmi $(docker images | grep alex | tr -s ' ' | cut -d ' ' -f 3) && docker rmi $(docker images --filter \"dangling=true\" -q --no-trunc)",
"docker:network": "docker network create --subnet=172.18.0.0/16 ollama-net",
"docker:build": "docker build --no-cache -t kevinthedang/discord-ollama:$(node -p \"require('./package.json').version\") .",
"docker:build-latest": "docker build --no-cache -t kevinthedang/discord-ollama:latest .",
"docker:client": "docker run -d -v discord:/src/app --name discord --network ollama-net --ip 172.18.0.3 kevinthedang/discord-ollama:$(node -p \"require('./package.json').version\")",
"docker:build": "docker build --no-cache -t alex/discord-aidolls:$(node -p \"require('./package.json').version\") .",
"docker:build-latest": "docker build --no-cache -t alex/discord-aidolls:latest .",
"docker:client": "docker run -d -v discord:/src/app --name discord --network ollama-net --ip 172.18.0.3 alex/discord-aidolls:$(node -p \"require('./package.json').version\")",
"docker:redis": "docker run -d -v redis:/root/.redis -p 6379:6379 --name redis --network ollama-net --ip 172.18.0.4 redis:latest",
"docker:ollama": "docker run -d --gpus=all -v ollama:/root/.ollama -p 11434:11434 --name ollama --network ollama-net --ip 172.18.0.2 ollama/ollama:latest",
"docker:ollama-cpu": "docker run -d -v ollama:/root/.ollama -p 11434:11434 --name ollama --network ollama-net --ip 172.18.0.2 ollama/ollama:latest",
"docker:start": "docker network prune -f && npm run docker:network && npm run docker:build && npm run docker:client && npm run docker:ollama",
"docker:start-cpu": "docker network prune -f && npm run docker:network && npm run docker:build && npm run docker:client && npm run docker:ollama-cpu"
"docker:start": "docker network prune -f && npm run docker:network && npm run docker:build && npm run docker:redis && npm run docker:client && npm run docker:ollama",
"docker:start-cpu": "docker network prune -f && npm run docker:network && npm run docker:build && npm run docker:redis && npm run docker:client && npm run docker:ollama-cpu"
},
"author": "Kevin Dang",
"license": "ISC",
"author": "alex",
"license": "---",
"dependencies": {
"discord.js": "^14.15.3",
"dotenv": "^16.4.5",
"ollama": "^0.5.8"
"discord.js": "^14.18.0",
"dotenv": "^16.4.7",
"ollama": "^0.5.15",
"redis": "^4.7.0"
},
"devDependencies": {
"@types/node": "^22.5.1",
"@vitest/coverage-v8": "^2.0.5",
"@types/node": "^22.13.14",
"@vitest/coverage-v8": "^3.0.9",
"ts-node": "^10.9.2",
"tsx": "^4.19.0",
"typescript": "^5.5.4",
"vitest": "^2.0.5"
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vitest": "^3.0.4"
},
"type": "module",
"engines": {
"node": ">=16.0.0"
"npm": ">=10.9.0",
"node": ">=22.12.0"
}
}

29
redis/docker-compose.yml Executable file
View File

@@ -0,0 +1,29 @@
services:
redis:
image: redis:alpine
container_name: redis
restart: always
networks:
discord-net:
ipv4_address: ${REDIS_IP}
volumes:
- ./redis_data:/data
ports:
- ${REDIS_PORT}:${REDIS_PORT}
healthcheck:
test: ["CMD", "redis-cli", "PING"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
networks:
discord-net:
driver: bridge
ipam:
driver: default
config:
- subnet: ${SUBNET_ADDRESS}/16
volumes:
redis_data:

6
redis/example.env Executable file
View File

@@ -0,0 +1,6 @@
# subnet address, ex. 172.33.0.0 as we use /16.
SUBNET_ADDRESS = 172.33.0.0
# redis port and ip, default redis port is 6379
REDIS_IP = 172.33.0.100
REDIS_PORT = 6379

BIN
redis/redis_data/dump.rdb Executable file

Binary file not shown.

View File

@@ -1,12 +1,12 @@
import { Client, GatewayIntentBits } from 'discord.js'
import { Ollama } from 'ollama'
import { createClient } from 'redis'
import { Queue } from './queues/queue.js'
import { UserMessage, registerEvents } from './utils/index.js'
import Events from './events/index.js'
import Keys from './keys.js'
// initialize the client with the following permissions when logging in
// Initialize the client
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
@@ -14,29 +14,53 @@ const client = new Client({
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent
]
})
// Create Redis client
const redis = createClient({
url: `redis://${Keys.redisHost}:${Keys.redisPort}`,
socket: {
reconnectStrategy: (retries) => Math.min(retries * 100, 3000), // Retry every 100ms, max 3s
},
});
// initialize connection to ollama container
const ollama = new Ollama({
// Log connection events
redis.on('error', (err) => console.log(`Redis error: ${err}`));
redis.on('connect', () => console.log('Redis connected'));
redis.on('ready', () => console.log('Redis ready'));
redis.on('end', () => console.log('Redis connection closed'));
export { redis };
// Initialize Ollama connection
export const ollama = new Ollama({
host: `http://${Keys.ipAddress}:${Keys.portAddress}`,
})
// Create Queue managed by Events
const messageHistory: Queue<UserMessage> = new Queue<UserMessage>
// register all events
registerEvents(client, Events, messageHistory, Keys, ollama)
// Register all events
registerEvents(client, Events, messageHistory, ollama, Keys.defaultModel)
// Try to connect to Redis
await redis.connect()
.then(() => console.log('[Redis] Connected'))
.catch((error) => {
console.error('[Redis] Connection Error', error)
process.exit(1)
})
// Try to log in the client
await client.login(Keys.clientToken)
.catch((error) => {
console.error('[Login Error]', error)
process.exit(1)
})
.catch((error) => {
console.error('[Login Error]', error)
process.exit(1)
})
// queue up bots name
// Queue up bot's name
messageHistory.enqueue({
role: 'assistant',
content: `My name is ${client.user?.username}`,
images: []
})
})

View File

@@ -1,15 +1,15 @@
import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { openConfig, SlashCommand } from '../utils/index.js'
import { Client, CommandInteraction, ApplicationCommandOptionType, MessageFlags } from 'discord.js'
import { openConfig, SlashCommand, UserCommand } from '../utils/index.js'
export const Capacity: SlashCommand = {
name: 'modify-capacity',
description: 'number of messages bot will hold for context.',
description: 'maximum amount messages bot will hold for context.',
// set available user options to pass to the command
options: [
{
name: 'context-capacity',
description: 'a number to set capacity',
description: 'number of allowed messages to remember',
type: ApplicationCommandOptionType.Number,
required: true
}
@@ -19,14 +19,16 @@ export const Capacity: SlashCommand = {
run: async (client: Client, interaction: CommandInteraction) => {
// fetch channel and message
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || channel.type !== (ChannelType.PrivateThread && ChannelType.PublicThread && ChannelType.GuildText)) return
if (!channel || !UserCommand.includes(channel.type)) return
// set state of bot chat features
openConfig(`${interaction.user.username}-config.json`, interaction.commandName, interaction.options.get('context-capacity')?.value)
openConfig(`${interaction.user.username}-config.json`, interaction.commandName,
interaction.options.get('context-capacity')?.value
)
interaction.reply({
content: `Message History Capacity has been set to \`${interaction.options.get('context-capacity')?.value}\``,
ephemeral: true
content: `Max message history is now set to \`${interaction.options.get('context-capacity')?.value}\``,
flags: MessageFlags.Ephemeral
})
}
}

View File

@@ -1,33 +1,35 @@
import { ChannelType, Client, CommandInteraction, TextChannel } from 'discord.js'
import { clearChannelInfo, SlashCommand } from '../utils/index.js'
import { Channel, Client, CommandInteraction, MessageFlags, TextChannel } from 'discord.js'
import { clearChannelInfo, SlashCommand, UserCommand } from '../utils/index.js'
export const ClearUserChannelHistory: SlashCommand = {
name: 'clear-user-channel-history',
description: 'clears history for user running this command in current channel',
description: 'clears history for user in the current channel',
// Clear channel history for intended user
run: async (client: Client, interaction: CommandInteraction) => {
// fetch current channel
const channel = await client.channels.fetch(interaction.channelId)
const channel: Channel | null = await client.channels.fetch(interaction.channelId)
// if not an existing channel or a GuildText, fail command
if (!channel || channel.type !== ChannelType.GuildText) return
if (!channel || !UserCommand.includes(channel.type)) return
// clear channel info for user
const successfulWipe = await clearChannelInfo(interaction.channelId,
interaction.channel as TextChannel,
interaction.user.username)
const successfulWipe = await clearChannelInfo(
interaction.channelId,
interaction.channel as TextChannel,
interaction.user.username
)
// check result of clearing history
if (successfulWipe)
interaction.reply({
content: `Channel history in **${channel.name}** cleared for **${interaction.user.username}**.`,
ephemeral: true
interaction.reply({
content: `History cleared in **this channel** cleared for **${interaction.user.username}**.`,
flags: MessageFlags.Ephemeral
})
else
interaction.reply({
content: `Channel history could not be found for **${interaction.user.username}** in **${channel.name}**.\n\nPlease chat with **${client.user?.username}** to start a chat history.`,
ephemeral: true
interaction.reply({
content: `History was not be found for **${interaction.user.username}** in **this channel**.\n\nPlease chat with **${client.user?.username}** to start a chat history.`,
flags: MessageFlags.Ephemeral
})
}
}

View File

@@ -0,0 +1,60 @@
import { ApplicationCommandOptionType, Client, CommandInteraction, MessageFlags } from 'discord.js'
import { UserCommand, SlashCommand } from '../utils/index.js'
import { ollama } from '../client.js'
import { ModelResponse } from 'ollama'
export const DeleteModel: SlashCommand = {
name: 'delete-model',
description: 'deletes a model from the local list of models. Administrator Only.',
// set available user options to pass to the command
options: [
{
name: 'model-name',
description: 'the name of the model to delete',
type: ApplicationCommandOptionType.String,
required: true
}
],
// Delete Model locally stored
run: async (client: Client, interaction: CommandInteraction) => {
// defer reply to avoid timeout
await interaction.deferReply()
const modelInput: string = interaction.options.get('model-name')!!.value as string
// fetch channel and message
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !UserCommand.includes(channel.type)) return
// Admin Command
if (!interaction.memberPermissions?.has('Administrator')) {
interaction.reply({
content: `${interaction.commandName} is an admin command.\n\nPlease contact a server admin to pull the model you want.`,
flags: MessageFlags.Ephemeral
})
return
}
// check if model exists
const modelExists: boolean = await ollama.list()
.then(response => response.models.some((model: ModelResponse) => model.name.startsWith(modelInput)))
try {
// call ollama to delete model
if (modelExists) {
await ollama.delete({ model: modelInput })
interaction.editReply({
content: `**${modelInput}** was removed from the the library.`
})
} else
throw new Error()
} catch (error) {
// could not delete the model
interaction.reply({
content: `Could not delete the **${modelInput}** model. It probably doesn't exist or you spelled it incorrectly.\n\nPlease try again if this is a mistake.`,
flags: MessageFlags.Ephemeral
})
}
}
}

View File

@@ -1,9 +1,9 @@
import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { openConfig, SlashCommand } from '../utils/index.js'
import { Client, CommandInteraction, ApplicationCommandOptionType, MessageFlags } from 'discord.js'
import { AdminCommand, openConfig, SlashCommand } from '../utils/index.js'
export const Disable: SlashCommand = {
name: 'toggle-chat',
description: 'toggle all chat features, Adminstrator Only.',
description: 'toggle all chat features. Adminstrator Only.',
// set available user options to pass to the command
options: [
@@ -19,23 +19,25 @@ export const Disable: SlashCommand = {
run: async (client: Client, interaction: CommandInteraction) => {
// fetch channel and message
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || channel.type !== ChannelType.GuildText) return
if (!channel || !AdminCommand.includes(channel.type)) return
// check if runner is an admin
if (!interaction.memberPermissions?.has('Administrator')) {
interaction.reply({
content: `${interaction.commandName} is an Administrator Command.\n\nYou, ${interaction.member?.user.username}, are not an Administrator in this server.\nPlease contact an admin to use this command.`,
ephemeral: true
content: `${interaction.commandName} is an admin command.\n\nPlease contact an admin to use this command for you.`,
flags: MessageFlags.Ephemeral
})
return
}
// set state of bot chat features
openConfig(`${interaction.guildId}-config.json`, interaction.commandName, interaction.options.get('enabled')?.value)
openConfig(`${interaction.guildId}-config.json`, interaction.commandName,
interaction.options.get('enabled')?.value
)
interaction.reply({
content: `Chat features has been \`${interaction.options.get('enabled')?.value ? "enabled" : "disabled" }\``,
ephemeral: true
content: `${client.user?.username} is now **${interaction.options.get('enabled')?.value ? "enabled" : "disabled"}**.`,
flags: MessageFlags.Ephemeral
})
}
}

View File

@@ -1,20 +1,24 @@
import { SlashCommand } from '../utils/commands.js'
import { ThreadCreate } from './threadCreate.js'
import { MessageStyle } from './messageStyle.js'
import { MessageStream } from './messageStream.js'
import { Disable } from './disable.js'
import { Shutoff } from './shutoff.js'
import { Capacity } from './capacity.js'
import { PrivateThreadCreate } from './threadPrivateCreate.js'
import { ClearUserChannelHistory } from './cleanUserChannelHistory.js'
import { PullModel } from './pullModel.js'
import { SwitchModel } from './switchModel.js'
import { DeleteModel } from './deleteModel.js'
export default [
ThreadCreate,
PrivateThreadCreate,
MessageStyle,
MessageStream,
Disable,
Shutoff,
Capacity,
ClearUserChannelHistory
ClearUserChannelHistory,
PullModel,
SwitchModel,
DeleteModel
] as SlashCommand[]

View File

@@ -1,15 +1,15 @@
import { ApplicationCommandOptionType, ChannelType, Client, CommandInteraction } from 'discord.js'
import { openConfig, SlashCommand } from '../utils/index.js'
import { ApplicationCommandOptionType, Client, CommandInteraction, MessageFlags } from 'discord.js'
import { openConfig, SlashCommand, UserCommand } from '../utils/index.js'
export const MessageStream: SlashCommand = {
name: 'message-stream',
description: 'change preference on message streaming from ollama. WARNING: can be very slow.',
description: 'change preference on message streaming from ollama. WARNING: can be very slow due to Discord limits.',
// user option(s) for setting stream
options: [
{
name: 'stream',
description: 'enable or disable stream preference',
description: 'enable or disable message streaming',
type: ApplicationCommandOptionType.Boolean,
required: true
}
@@ -19,14 +19,16 @@ export const MessageStream: SlashCommand = {
run: async (client: Client, interaction: CommandInteraction) => {
// verify channel
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || channel.type !== (ChannelType.PrivateThread && ChannelType.PublicThread && ChannelType.GuildText)) return
if (!channel || !UserCommand.includes(channel.type)) return
// save value to json and write to it
openConfig(`${interaction.user.username}-config.json`, interaction.commandName, interaction.options.get('stream')?.value)
openConfig(`${interaction.user.username}-config.json`, interaction.commandName,
interaction.options.get('stream')?.value
)
interaction.reply({
content: `Message streaming preferences set to: \`${interaction.options.get('stream')?.value}\``,
ephemeral: true
content: `Message streaming is now set to: \`${interaction.options.get('stream')?.value}\``,
flags: MessageFlags.Ephemeral
})
}
}

View File

@@ -1,32 +0,0 @@
import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { openConfig, SlashCommand } from '../utils/index.js'
export const MessageStyle: SlashCommand = {
name: 'message-style',
description: 'sets the message style to embed or normal',
// set available user options to pass to the command
options: [
{
name: 'embed',
description: 'toggle embedded or normal message',
type: ApplicationCommandOptionType.Boolean,
required: true
}
],
// Query for message information and set the style
run: async (client: Client, interaction: CommandInteraction) => {
// fetch channel and message
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || channel.type !== (ChannelType.PrivateThread && ChannelType.PublicThread && ChannelType.GuildText)) return
// set the message style
openConfig(`${interaction.user.username}-config.json`, interaction.commandName, interaction.options.get('embed')?.value)
interaction.reply({
content: `Message style preferences for embed set to: \`${interaction.options.get('embed')?.value}\``,
ephemeral: true
})
}
}

69
src/commands/pullModel.ts Normal file
View File

@@ -0,0 +1,69 @@
import { ApplicationCommandOptionType, Client, CommandInteraction, MessageFlags } from "discord.js"
import { ollama } from "../client.js"
import { ModelResponse } from "ollama"
import { UserCommand, SlashCommand } from "../utils/index.js"
export const PullModel: SlashCommand = {
name: 'pull-model',
description: 'pulls a model from the ollama model library. Administrator Only.',
// set available user options to pass to the command
options: [
{
name: 'model-to-pull',
description: 'the name of the model to pull',
type: ApplicationCommandOptionType.String,
required: true
}
],
// Pull for model from Ollama library
run: async (client: Client, interaction: CommandInteraction) => {
// defer reply to avoid timeout
await interaction.deferReply()
const modelInput: string = interaction.options.get('model-to-pull')!!.value as string
// fetch channel and message
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !UserCommand.includes(channel.type)) return
// Admin Command
if (!interaction.memberPermissions?.has('Administrator')) {
interaction.reply({
content: `${interaction.commandName} is an admin command.\n\nPlease contact a server admin to pull the model you want.`,
flags: MessageFlags.Ephemeral
})
return
}
// check if model was already pulled
const modelExists: boolean = await ollama.list()
.then(response => response.models.some((model: ModelResponse) => model.name.startsWith(modelInput)))
try {
// call ollama to pull desired model
if (!modelExists) {
interaction.editReply({
content: `**${modelInput}** could not be found. Please wait patiently as I try to retrieve it...`
})
await ollama.pull({ model: modelInput })
}
} catch (error) {
// could not resolve pull or model unfound
interaction.editReply({
content: `Could not retrieve the **${modelInput}** model. You can find models at [Ollama Model Library](https://ollama.com/library).\n\nPlease check the model library and try again.`
})
return
}
// successful interaction
if (modelExists)
interaction.editReply({
content: `**${modelInput}** is already available.`
})
else
interaction.editReply({
content: `Successfully added **${modelInput}**.`
})
}
}

View File

@@ -1,50 +1,36 @@
import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { SlashCommand } from '../utils/commands.js'
import { Client, CommandInteraction, MessageFlags } from 'discord.js'
import { AdminCommand, SlashCommand } from '../utils/index.js'
export const Shutoff: SlashCommand = {
name: 'shutoff',
description: 'shutdown the bot. You will need to manually bring it online again. Administrator Only.',
// set available user options to pass to the command
options: [
{
name: 'are-you-sure',
description: 'true = yes, false = I\'m scared',
type: ApplicationCommandOptionType.Boolean,
required: true
}
],
// Query for message information and set the style
run: async (client: Client, interaction: CommandInteraction) => {
// fetch channel and message
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || channel.type !== ChannelType.GuildText) return
if (!channel || !AdminCommand.includes(channel.type)) return
// log this, this will probably be improtant for logging who did this
console.log(`User -> ${interaction.user.tag} attempting to shutdown ${client.user!!.tag}`)
console.log(`[Command: shutoff] User ${interaction.user.tag} attempting to shutdown ${client.user!!.tag}`)
// check if admin or false on shutdown
if (!interaction.memberPermissions?.has('Administrator')) {
interaction.reply({
content: `**Shutdown Aborted:**\n\n${interaction.user.tag}, You do not have permission to shutoff **${client.user?.tag}**.`,
ephemeral: true
flags: MessageFlags.Ephemeral
})
return // stop from shutting down
} else if (!interaction.options.get('are-you-sure')?.value) {
interaction.reply({
content: `**Shutdown Aborted:**\n\n${interaction.user.tag}, You didn't want to shutoff **${client.user?.tag}**.`,
ephemeral: true
})
return // chickened out
}
// Shutoff cleared, do it
interaction.reply({
content: `${client.user?.tag} is ${interaction.options.get('are-you-sure')?.value ? "shutting down now." : "not shutting down." }`,
ephemeral: true
content: `${client.user?.tag} is shutting down.`,
flags: MessageFlags.Ephemeral
})
console.log(`[Command: shutoff] ${client.user?.tag} is shutting down.`)
// clean up client instance and stop
client.destroy()
}

View File

@@ -0,0 +1,67 @@
import { ApplicationCommandOptionType, Client, CommandInteraction } from "discord.js"
import { ollama } from "../client.js"
import { ModelResponse } from "ollama"
import { openConfig, UserCommand, SlashCommand } from "../utils/index.js"
export const SwitchModel: SlashCommand = {
name: 'switch-model',
description: 'switches current model to use.',
// set available user options to pass to the command
options: [
{
name: 'model-to-use',
description: 'the name of the model to use',
type: ApplicationCommandOptionType.String,
required: true
}
],
// Switch user preferred model if available in local library
run: async (client: Client, interaction: CommandInteraction) => {
await interaction.deferReply()
const modelInput: string = interaction.options.get('model-to-use')!!.value as string
// fetch channel and message
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !UserCommand.includes(channel.type)) return
try {
// Phase 1: Switch to the model
let switchSuccess = false
await ollama.list()
.then(response => {
for (const model in response.models) {
const currentModel: ModelResponse = response.models[model]
if (currentModel.name.startsWith(modelInput)) {
openConfig(`${interaction.user.username}-config.json`, interaction.commandName, modelInput)
// successful switch
interaction.editReply({
content: `Successfully switched to **${modelInput}** as the preferred model for ${interaction.user.username}.`
})
switchSuccess = true
}
}
})
// todo: problem can be here if async messes up
if (switchSuccess) {
// set model now that it exists
openConfig(`${interaction.user.username}-config.json`, interaction.commandName, modelInput)
return
}
// Phase 2: Notify user of failure to find model.
interaction.editReply({
content: `Could not find **${modelInput}** in local model library.\n\nPlease contact an server admin for access to this model.`
})
} catch (error) {
// could not resolve user model switch
interaction.editReply({
content: `Unable to switch user preferred model to **${modelInput}**.\n\n${error}\n\nPossible solution is to request an server admin run \`/pull-model ${modelInput}\` and try again.`
})
return
}
}
}

View File

@@ -1,5 +1,5 @@
import { ChannelType, Client, CommandInteraction, TextChannel, ThreadChannel } from 'discord.js'
import { openChannelInfo, SlashCommand } from '../utils/index.js'
import { ChannelType, Client, CommandInteraction, MessageFlags, TextChannel, ThreadChannel } from 'discord.js'
import { AdminCommand, openChannelInfo, SlashCommand } from '../utils/index.js'
export const ThreadCreate: SlashCommand = {
name: 'thread',
@@ -9,7 +9,7 @@ export const ThreadCreate: SlashCommand = {
run: async (client: Client, interaction: CommandInteraction) => {
// fetch the channel
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || channel.type !== ChannelType.GuildText) return
if (!channel || !AdminCommand.includes(channel.type)) return
const thread = await (channel as TextChannel).threads.create({
name: `${client.user?.username}-support-${Date.now()}`,
@@ -18,17 +18,15 @@ export const ThreadCreate: SlashCommand = {
})
// Send a message in the thread
thread.send(`Hello ${interaction.user} and others! \n\nIt's nice to meet you. Please talk to me by typing **@${client.user?.username}** with your prompt.`)
thread.send(`Hello ${interaction.user} and others! \n\nIt's nice to meet you. Please talk to me by typing **@${client.user?.username}** with your message.`)
// handle storing this chat channel
openChannelInfo(thread.id,
thread as ThreadChannel,
interaction.user.tag)
openChannelInfo(thread.id, thread as ThreadChannel, interaction.user.tag)
// user only reply
return interaction.reply({
content: `I can help you in thread **${thread.id}** below.`,
ephemeral: true
content: `I can help you in <#${thread.id}> below.`,
flags: MessageFlags.Ephemeral
})
}
}

View File

@@ -1,5 +1,5 @@
import { ChannelType, Client, CommandInteraction, TextChannel, ThreadChannel } from 'discord.js'
import { openChannelInfo, SlashCommand } from '../utils/index.js'
import { ChannelType, Client, CommandInteraction, MessageFlags, TextChannel, ThreadChannel } from 'discord.js'
import { AdminCommand, openChannelInfo, SlashCommand } from '../utils/index.js'
export const PrivateThreadCreate: SlashCommand = {
name: 'private-thread',
@@ -9,7 +9,7 @@ export const PrivateThreadCreate: SlashCommand = {
run: async (client: Client, interaction: CommandInteraction) => {
// fetch the channel
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || channel.type !== ChannelType.GuildText) return
if (!channel || !AdminCommand.includes(channel.type)) return
const thread = await (channel as TextChannel).threads.create({
name: `${client.user?.username}-private-support-${Date.now()}`,
@@ -22,15 +22,12 @@ export const PrivateThreadCreate: SlashCommand = {
// handle storing this chat channel
// store: thread.id, thread.name
openChannelInfo(thread.id,
thread as ThreadChannel,
interaction.user.tag
)
openChannelInfo(thread.id, thread as ThreadChannel, interaction.user.tag)
// user only reply
return interaction.reply({
content: `I can help you in thread **${thread.id}**. Please refer to the private channel below this one.`,
ephemeral: true
content: `I can help you in <#${thread.id}>.`,
flags: MessageFlags.Ephemeral
})
}
}

19
src/envs/azuki.env Normal file
View File

@@ -0,0 +1,19 @@
# Discord token for the bot
CLIENT_TOKEN = MTM3NTU1MjE5MDcxODE1Mjg2MQ.GLDMIV.CytPZLnya0OXJ1aqueQFNUKwMxMd5ZOAr8_xAs
# Default model for new users
MODEL = aidoll-gemma3-27b-q8:latest
# ip/port address of docker container, I use 172.33.0.3 for docker, 127.0.0.1 for local
OLLAMA_IP = 192.168.0.70
OLLAMA_PORT = 11434
# ip address for discord bot container, I use 172.33.0.2, use different IP than ollama_ip
DISCORD_IP = 172.33.0.2
# subnet address, ex. 172.33.0.0 as we use /16.
SUBNET_ADDRESS = 172.33.0.0
# redis port and ip, default redis port is 6379
REDIS_IP = 172.33.0.100
REDIS_PORT = 6379

19
src/envs/chocola.env Normal file
View File

@@ -0,0 +1,19 @@
# Discord token for the bot
CLIENT_TOKEN = MTM3NTU1MTM0OTU1ODc0MzExMA.GbOQ1r.rG3HWS98VqEDJPhytT7yeImQU2-raDCjJ_kB7o
# Default model for new users
MODEL = aidoll-gemma3-27b-q8:latest
# ip/port address of docker container, I use 172.33.0.3 for docker, 127.0.0.1 for local
OLLAMA_IP = 192.168.0.70
OLLAMA_PORT = 11434
# ip address for discord bot container, I use 172.33.0.2, use different IP than ollama_ip
DISCORD_IP = 172.33.0.3
# subnet address, ex. 172.33.0.0 as we use /16.
SUBNET_ADDRESS = 172.33.0.0
# redis port and ip, default redis port is 6379
REDIS_IP = 172.33.0.100
REDIS_PORT = 6379

19
src/envs/cinnamon.env Normal file
View File

@@ -0,0 +1,19 @@
# Discord token for the bot
CLIENT_TOKEN = MTM3NTU1Mjg4MDUyMTY0NjE3MA.GoXH9o.paGwXiASi5tkfiMeG-aae8kwJA2kv7sJrayG4c
# Default model for new users
MODEL = aidoll-gemma3-27b-q8:latest
# ip/port address of docker container, I use 172.33.0.3 for docker, 127.0.0.1 for local
OLLAMA_IP = 192.168.0.70
OLLAMA_PORT = 11434
# ip address for discord bot container, I use 172.33.0.2, use different IP than ollama_ip
DISCORD_IP = 172.33.0.5
# subnet address, ex. 172.33.0.0 as we use /16.
SUBNET_ADDRESS = 172.33.0.0
# redis port and ip, default redis port is 6379
REDIS_IP = 172.33.0.100
REDIS_PORT = 6379

19
src/envs/coconut.env Normal file
View File

@@ -0,0 +1,19 @@
# Discord token for the bot
CLIENT_TOKEN = MTM3NTU1MzU1ODEwMjkzNzY3MQ.GvJqck.QSNEoSc0FDR3RUAwna1j2s9pJoUoVlY7Ss1_fQ
# Default model for new users
MODEL = aidoll-gemma3-27b-q8:latest
# ip/port address of docker container, I use 172.33.0.3 for docker, 127.0.0.1 for local
OLLAMA_IP = 192.168.0.70
OLLAMA_PORT = 11434
# ip address for discord bot container, I use 172.33.0.2, use different IP than ollama_ip
DISCORD_IP = 172.33.0.5
# subnet address, ex. 172.33.0.0 as we use /16.
SUBNET_ADDRESS = 172.33.0.0
# redis port and ip, default redis port is 6379
REDIS_IP = 172.33.0.100
REDIS_PORT = 6379

19
src/envs/maple.env Normal file
View File

@@ -0,0 +1,19 @@
# Discord token for the bot
CLIENT_TOKEN = MTM3NTU1NDEyOTMzODA0NDU1Nw.G53GsX.qVmcr4SsR22Lu12p_zRc0oi0ogVPc9-ZkZWXxk
# Default model for new users
MODEL = aidoll-gemma3-27b-q8:latest
# ip/port address of docker container, I use 172.33.0.3 for docker, 127.0.0.1 for local
OLLAMA_IP = 192.168.0.70
OLLAMA_PORT = 11434
# ip address for discord bot container, I use 172.33.0.2, use different IP than ollama_ip
DISCORD_IP = 172.33.0.6
# subnet address, ex. 172.33.0.0 as we use /16.
SUBNET_ADDRESS = 172.33.0.0
# redis port and ip, default redis port is 6379
REDIS_IP = 172.33.0.100
REDIS_PORT = 6379

19
src/envs/vanila.env Normal file
View File

@@ -0,0 +1,19 @@
# Discord token for the bot
CLIENT_TOKEN = MTM3NTU0OTY5Mjg3MTU3NzYyMQ.Gdyq5r.8I1EXSW9RsH1P9H5UiAr1R69X583sS4WCh6_Co
# Default model for new users
MODEL = aidoll-gemma3-27b-q8:latest
# ip/port address of docker container, I use 172.33.0.3 for docker, 127.0.0.1 for local
OLLAMA_IP = 192.168.0.70
OLLAMA_PORT = 11434
# ip address for discord bot container, I use 172.33.0.2, use different IP than ollama_ip
DISCORD_IP = 172.33.0.7
# subnet address, ex. 172.33.0.0 as we use /16.
SUBNET_ADDRESS = 172.33.0.0
# redis port and ip, default redis port is 6379
REDIS_IP = 172.33.0.100
REDIS_PORT = 6379

View File

@@ -7,7 +7,7 @@ import commands from '../commands/index.js'
*/
export default event(Events.InteractionCreate, async ({ log, client }, interaction) => {
if (!interaction.isCommand() || !interaction.isChatInputCommand()) return
log(`Interaction called \'${interaction.commandName}\' from ${interaction.user.tag}.`)
// ensure command exists, otherwise kill event

View File

@@ -1,149 +1,512 @@
import { TextChannel } from 'discord.js'
import { embedMessage, event, Events, normalMessage, UserMessage, clean } from '../utils/index.js'
import { getChannelInfo, getServerConfig, getUserConfig, openChannelInfo, openConfig, UserConfig, getAttachmentData } from '../utils/index.js'
import { TextChannel, Attachment, Message } from 'discord.js'
import { event, Events, UserMessage, clean, getServerConfig, getTextFileAttachmentData, getAttachmentData } from '../utils/index.js'
import { redis } from '../client.js'
import fs from 'fs/promises'
import path from 'path'
import { fileURLToPath } from 'url'
import { Ollama } from 'ollama'
import { Queue } from '../queues/queue.js'
/**
// Define interface for model response to improve type safety
interface ModelResponse {
status: 'success' | 'error'
reply: string
metadata?: {
timestamp: string
self_sentiment: number
user_sentiment: { [userId: string]: number }
redis_ops: Array<{ action: 'set' | 'get'; key: string; value?: number }>
need_help: boolean
}
}
// Define interface for user config
interface UserConfig {
options: {
'message-style': boolean
'switch-model': string
'modify-capacity': number
'message-stream'?: boolean
}
}
// List of in-character error responses for unavoidable Discord replies
const friendlyErrorResponses = [
'Huh?',
'Sorry, I wasnt listening. Can you say that again?',
'Um... what was that?',
'Oops, my mind wandered! Could you repeat that?',
'Hehe, I got distracted. Say it one more time?'
]
// Function to get a random friendly error response
const getFriendlyError = () => friendlyErrorResponses[Math.floor(Math.random() * friendlyErrorResponses.length)]
/**
* Max Message length for free users is 2000 characters (bot or not).
* Bot supports infinite lengths for normal messages.
*
*
* @param message the message received from the channel
*/
export default event(Events.MessageCreate, async ({ log, msgHist, tokens, ollama }, message) => {
log(`Message \"${clean(message.content)}\" from ${message.author.tag} in channel/thread ${message.channelId}.`)
export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client, defaultModel }: { log: (msg: string) => void, msgHist: Queue<UserMessage>, ollama: Ollama, client: any, defaultModel: string }, message: Message) => {
// Early check to prevent bot from replying to itself
if (!client.user) {
log('Client user is not defined. Skipping message processing.')
return
}
const clientId = client.user.id
if (message.author.id === clientId) {
log(`Skipping message from self (bot ID: ${clientId}).`)
return
}
// Do not respond if bot talks in the chat
if (message.author.username === message.client.user.username) return
let cleanedMessage = clean(message.content, clientId)
log(`Message "${cleanedMessage}" from ${message.author.tag} in channel/thread ${message.channelId}.`)
// Only respond if message mentions the bot
if (!message.mentions.has(tokens.clientUid)) return
// Check if message is from a bot (not self), mentions the bot, or passes random chance
const isBotMessage = message.author.bot && message.author.id !== clientId
const isMentioned = message.mentions.has(clientId)
const isCommand = message.content.startsWith('/')
const randomChance = Math.random() < 0.1 // 10% chance for non-directed or bot messages
if (!isMentioned && !isBotMessage && (isCommand || !randomChance)) {
log(`Skipping message: isMentioned=${isMentioned}, isBotMessage=${isBotMessage}, isCommand=${isCommand}, randomChance=${randomChance}`)
return
}
// default stream to false
// Check if message is a bot response to avoid loops
const isBotResponseKey = `message:${message.id}:is_bot_response`
if (isBotMessage) {
try {
const isBotResponse = await redis.get(isBotResponseKey)
if (isBotResponse === 'true') {
log(`Skipping bot message ${message.id} as it is a bot response.`)
return
}
} catch (error) {
log(`Failed to check isBotResponse in Redis: ${error}`)
}
}
// Check cooldown for bot-to-bot responses only if probability check passes
const botResponseCooldownKey = `bot:${clientId}:last_bot_response`
const cooldownPeriod = 60 // 60 seconds cooldown
if (isBotMessage && randomChance) {
log(`Bot message probability check passed (10% chance). Checking cooldown.`)
try {
const lastResponseTime = await redis.get(botResponseCooldownKey)
const currentTime = Math.floor(Date.now() / 1000)
if (lastResponseTime && (currentTime - parseInt(lastResponseTime)) < cooldownPeriod) {
log(`Bot ${clientId} is in cooldown for bot-to-bot response. Skipping.`)
return
}
} catch (error) {
log(`Failed to check bot response cooldown: ${error}`)
}
} else if (isBotMessage) {
log(`Bot message probability check failed (10% chance). Skipping cooldown check.`)
}
// Check if last response was to a bot and require user message
const lastResponseToBotKey = `bot:${clientId}:last_response_to_bot`
let shouldRespond = true
if (isBotMessage) {
try {
const lastResponseToBot = await redis.get(lastResponseToBotKey)
if (lastResponseToBot === 'true') {
log(`Skipping bot message: Last response was to a bot. Waiting for user message.`)
return
}
} catch (error) {
log(`Failed to check last response to bot: ${error}`)
}
}
if (!shouldRespond) return
// Reset last_response_to_bot flag if this is a user message
if (!isBotMessage) {
try {
await redis.set(lastResponseToBotKey, 'false')
log(`Reset last_response_to_bot flag for bot ${clientId}`)
} catch (error) {
log(`Failed to reset last_response_to_bot flag: ${error}`)
}
}
// Log response trigger
log(isMentioned ? 'Responding to mention' : isBotMessage ? 'Responding to bot message' : 'Responding due to random chance')
// Default stream to false
let shouldStream = false
// Params for Preferences Fetching
const maxRetries = 3
const delay = 1000 // in milliseconds
try {
// Retrieve Server/Guild Preferences
await new Promise((resolve, reject) => {
getServerConfig(`${message.guildId}-config.json`, (config) => {
// check if config.json exists
if (config === undefined) {
// Allowing chat options to be available
openConfig(`${message.guildId}-config.json`, 'toggle-chat', true)
reject(new Error('No Server Preferences is set up.\n\nCreating default server preferences file...\nPlease try chatting again.'))
return
}
// check if chat is disabled
if (!config.options['toggle-chat']) {
reject(new Error('Admin(s) have disabled chat features.\n\n Please contact your server\'s admin(s).'))
return
}
resolve(config)
})
})
// Retrieve User Preferences
const userConfig: UserConfig = await new Promise((resolve, reject) => {
getUserConfig(`${message.author.username}-config.json`, (config) => {
if (config === undefined) {
openConfig(`${message.author.username}-config.json`, 'message-style', false)
reject(new Error('No User Preferences is set up.\n\nCreating preferences file with \`message-style\` set as \`false\` for regular messages.\nPlease try chatting again.'))
return
}
// check if there is a set capacity in config
if (typeof config.options['modify-capacity'] !== 'number')
log(`Capacity is undefined, using default capacity of ${msgHist.capacity}.`)
else if (config.options['modify-capacity'] === msgHist.capacity)
log(`Capacity matches config as ${msgHist.capacity}, no changes made.`)
else {
log(`New Capacity found. Setting Context Capacity to ${config.options['modify-capacity']}.`)
msgHist.capacity = config.options['modify-capacity']
}
// set stream state
shouldStream = config.options['message-stream'] as boolean || false
resolve(config)
})
})
// need new check for "open/active" threads/channels here!
let chatMessages: UserMessage[] = await new Promise((resolve) => {
// set new queue to modify
getChannelInfo(`${message.channelId}-${message.author.username}.json`, (channelInfo) => {
if (channelInfo?.messages)
resolve(channelInfo.messages)
else {
log(`Channel/Thread ${message.channel}-${message.author.username} does not exist. File will be created shortly...`)
resolve([])
}
})
})
if (chatMessages.length === 0) {
chatMessages = await new Promise((resolve, reject) => {
openChannelInfo(message.channelId,
message.channel as TextChannel,
message.author.tag
)
getChannelInfo(`${message.channelId}-${message.author.username}.json`, (channelInfo) => {
if (channelInfo?.messages)
resolve(channelInfo.messages)
else {
log(`Channel/Thread ${message.channel}-${message.author.username} does not exist. File will be created shortly...`)
reject(new Error(`Failed to find ${message.author.username}'s history. Try chatting again.`))
}
let attempt = 0
let serverConfig;
while (attempt < maxRetries) {
try {
serverConfig = await new Promise((resolve, reject) => {
getServerConfig(`${message.guildId}-config.json`, (config) => {
if (config === undefined) {
redis.set(`server:${message.guildId}:config`, JSON.stringify({ options: { 'toggle-chat': true } }))
.catch(err => log(`Failed to set default server config in Redis: ${err}`));
reject(new Error('Failed to locate or create Server Preferences\n\nPlease try chatting again...'))
} else if (!config.options['toggle-chat']) {
reject(new Error('Admin(s) have disabled chat features.\n\nPlease contact your server\'s admin(s).'))
} else {
resolve(config)
}
})
})
})
break
} catch (error) {
++attempt
if (attempt < maxRetries) {
log(`Attempt ${attempt} failed for Server Preferences. Retrying in ${delay}ms...`)
await new Promise(ret => setTimeout(ret, delay))
} else {
log(`Could not retrieve Server Preferences after ${maxRetries} attempts`)
message.reply(getFriendlyError())
return
}
}
}
// response string for ollama to put its response
let response: string
// Retrieve User Preferences from Redis
attempt = 0
let userConfig: UserConfig | undefined
const userConfigKey = `user:${message.author.username}:config`
while (attempt < maxRetries) {
try {
userConfig = await new Promise((resolve, reject) => {
redis.get(userConfigKey).then(configRaw => {
let config: UserConfig | undefined
if (configRaw) {
try {
config = JSON.parse(configRaw)
} catch (parseError) {
log(`Failed to parse user config JSON: ${parseError}`)
reject(parseError)
return
}
}
if (!config) {
const defaultConfig: UserConfig = {
options: {
'message-style': false,
'switch-model': defaultModel,
'modify-capacity': 50,
'message-stream': false
}
}
redis.set(userConfigKey, JSON.stringify(defaultConfig))
.catch(err => log(`Failed to set default user config in Redis: ${err}`));
log(`Created default config for ${message.author.username}`)
reject(new Error('No User Preferences is set up.\n\nCreating preferences with defaults.\nPlease try chatting again.'))
return
}
// get message attachment if exists
const messageAttachment: string[] = await getAttachmentData(message.attachments.first())
if (typeof config.options['modify-capacity'] === 'number') {
log(`New Capacity found. Setting Context Capacity to ${config.options['modify-capacity']}.`)
msgHist.capacity = config.options['modify-capacity']
} else {
log(`Capacity is undefined, using default capacity of 50.`)
msgHist.capacity = 50
}
// set up new queue
shouldStream = config.options['message-stream'] || false
if (typeof config.options['switch-model'] !== 'string') {
reject(new Error(`No Model was set. Please set a model by running \`/switch-model <model of choice>\`.\n\nIf you do not have any models. Run \`/pull-model <model name>\`.`))
}
resolve(config)
}).catch(err => {
log(`Redis error fetching user config: ${err}`)
reject(err)
})
})
break
} catch (error) {
++attempt
if (attempt < maxRetries) {
log(`Attempt ${attempt} failed for User Preferences. Retrying in ${delay}ms...`)
await new Promise(ret => setTimeout(ret, delay))
} else {
log(`Could not retrieve User Preferences after ${maxRetries} attempts`)
message.reply(getFriendlyError())
return
}
}
}
// Retrieve Channel Messages from Redis
let chatMessages: UserMessage[] = []
const channelHistoryKey = `channel:${message.channelId}:${message.author.username}:history`
try {
const historyRaw = await redis.get(channelHistoryKey)
if (historyRaw) {
try {
chatMessages = JSON.parse(historyRaw)
log(`Retrieved ${chatMessages.length} messages from Redis for ${channelHistoryKey}`)
} catch (parseError) {
log(`Failed to parse channel history JSON: ${parseError}`)
chatMessages = []
}
} else {
log(`No history found for ${channelHistoryKey}. Initializing empty history.`)
chatMessages = []
}
} catch (error) {
log(`Failed to retrieve channel history from Redis: ${error}. Using empty history.`)
chatMessages = []
}
if (!userConfig) {
log(`Failed to initialize User Preference for **${message.author.username}**. No config available.`)
message.reply(getFriendlyError())
return
}
// Get message attachment if exists
const attachment = message.attachments.first()
let messageAttachment: string[] = []
if (attachment && attachment.name?.endsWith(".txt")) {
try {
cleanedMessage += await getTextFileAttachmentData(attachment)
} catch (error) {
log(`Failed to process text attachment: ${error}`)
}
} else if (attachment) {
try {
messageAttachment = await getAttachmentData(attachment)
} catch (error) {
log(`Failed to process attachment: ${error}`)
}
}
const model: string = userConfig.options['switch-model']
// Load personality
let personality: string
try {
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const personalityPath = path.join(__dirname, '../../src/personality.json')
const personalityData = await fs.readFile(personalityPath, 'utf-8')
const personalityJson = JSON.parse(personalityData)
personality = personalityJson.character || 'You are a friendly and helpful AI assistant.'
} catch (error) {
log(`Failed to load personality.json: ${error}`)
personality = 'You are a friendly and helpful AI assistant.'
}
// Get user or bot sentiment from Redis
const userSentimentKey = `user:${message.author.id}:sentiment`
const botSentimentKey = `bot:self_sentiment`
let userSentiment: number
let botSentiment: number
// Handle sentiment for bot or user messages
if (isBotMessage) {
try {
const botSentimentRaw = await redis.get(userSentimentKey)
userSentiment = parseFloat(botSentimentRaw || '0.50')
if (isNaN(userSentiment) || userSentiment < 0 || userSentiment > 1) {
log(`Invalid bot sentiment for ${message.author.id}: ${botSentimentRaw}. Using default 0.50.`)
userSentiment = 0.50
await redis.set(userSentimentKey, '0.50').catch((err: Error) => log(`Failed to set default bot sentiment: ${err.message}`))
}
} catch (error) {
log(`Failed to get bot sentiment from Redis: ${error}`)
userSentiment = 0.50
await redis.set(userSentimentKey, '0.50').catch((err: Error) => log(`Failed to set default bot sentiment: ${err.message}`))
}
} else {
try {
const userSentimentRaw = await redis.get(userSentimentKey)
userSentiment = parseFloat(userSentimentRaw || '0.50')
if (isNaN(userSentiment) || userSentiment < 0 || userSentiment > 1) {
log(`Invalid user sentiment for ${message.author.id}: ${userSentimentRaw}. Using default 0.50.`)
userSentiment = 0.50
await redis.set(userSentimentKey, '0.50').catch((err: Error) => log(`Failed to set default user sentiment: ${err.message}`))
}
} catch (error) {
log(`Failed to get user sentiment from Redis: ${error}`)
userSentiment = 0.50
await redis.set(userSentimentKey, '0.50').catch((err: Error) => log(`Failed to set default user sentiment: ${err.message}`))
}
}
try {
const botSentimentRaw = await redis.get(botSentimentKey)
botSentiment = parseFloat(botSentimentRaw || '0.50')
if (botSentimentRaw === null) {
log(`Bot sentiment not initialized. Setting to 0.50.`)
botSentiment = 0.50
await redis.set(botSentimentKey, '0.50').catch((err: Error) => log(`Failed to set default bot sentiment: ${err.message}`))
} else if (isNaN(botSentiment) || botSentiment < 0 || botSentiment > 1) {
log(`Invalid bot sentiment: ${botSentimentRaw}. Using default 0.50.`)
botSentiment = 0.50
await redis.set(botSentimentKey, '0.50').catch((err: Error) => log(`Failed to set default bot sentiment: ${err.message}`))
}
} catch (error) {
log(`Failed to get bot sentiment from Redis: ${error}`)
botSentiment = 0.50
await redis.set(botSentimentKey, '0.50').catch((err: Error) => log(`Failed to set default bot sentiment: ${err.message}`))
}
// Log initial sentiments with two decimals
log(`Initial sentiments - User ${message.author.id}: ${userSentiment.toFixed(2)}, Bot: ${botSentiment.toFixed(2)}`)
// Construct sentiment data for prompt
const sentimentData = `User ${message.author.id} sentiment: ${userSentiment.toFixed(2)}, Bot sentiment: ${botSentiment.toFixed(2)}`
// Add context for bot-to-bot interaction
const messageContext = isBotMessage
? `Responding to another bot (${message.author.tag})`
: `Responding to user ${message.author.tag}`
// Construct prompt with [CHARACTER], [SENTIMENT], and [CONTEXT]
const prompt = `[CHARACTER]\n${personality}\n[SENTIMENT]\n${sentimentData}\n[CONTEXT]\n${messageContext}\n[USER_INPUT]\n${cleanedMessage}`
// Set up message history queue
msgHist.setQueue(chatMessages)
// check if we can push, if not, remove oldest
while (msgHist.size() >= msgHist.capacity) msgHist.dequeue()
// push user response before ollama query
// Add user message to history
msgHist.enqueue({
role: 'user',
content: clean(message.content),
content: cleanedMessage,
images: messageAttachment || []
})
// undefined or false, use normal, otherwise use embed
if (userConfig.options['message-style'])
response = await embedMessage(message, ollama, tokens, msgHist, shouldStream)
else
response = await normalMessage(message, ollama, tokens, msgHist, shouldStream)
// If something bad happened, remove user query and stop
if (response == undefined) { msgHist.pop(); return }
// Call Ollama
let response;
try {
response = await ollama.chat({
model,
messages: [{ role: 'user', content: prompt }],
stream: shouldStream
})
} catch (error) {
log(`Ollama chat error: ${error}`)
message.reply(getFriendlyError())
msgHist.pop()
return
}
// if queue is full, remove the oldest message
// Parse JSON response
let jsonResponse: ModelResponse
try {
// Log raw response for debugging
log(`Raw model response: ${response.message.content}`)
// Strip Markdown code fences if present
let content = response.message.content
content = content.replace(/^```json\n|```$/g, '').trim()
jsonResponse = JSON.parse(content)
if (!jsonResponse.status || !jsonResponse.reply) {
throw new Error('Missing status or reply in model response')
}
} catch (error) {
log(`Failed to parse model response: ${error}`)
message.reply(getFriendlyError())
msgHist.pop()
return
}
if (jsonResponse.status === 'error') {
log(`Model returned error status: ${jsonResponse.reply}`)
message.reply(getFriendlyError())
msgHist.pop()
return
}
// Execute redis_ops
if (jsonResponse.metadata?.redis_ops) {
for (const op of jsonResponse.metadata.redis_ops) {
try {
if (op.action === 'set' && op.key && op.value !== undefined) {
// Validate sentiment value
const value = parseFloat(op.value.toString())
if (isNaN(value) || value < 0 || value > 1) {
log(`Invalid sentiment value for ${op.key}: ${op.value}. Skipping.`)
continue
}
// Store with two decimal places
await redis.set(op.key, value.toFixed(2))
log(`Set ${op.key} to ${value.toFixed(2)}`)
} else if (op.action === 'get' && op.key) {
const value = await redis.get(op.key)
log(`Got ${op.key}: ${value}`)
} else {
log(`Invalid redis_op: ${JSON.stringify(op)}. Skipping.`)
}
} catch (error) {
log(`Redis operation failed for ${op.key}: ${error}`)
}
}
}
// Log updated sentiments with two decimals
if (jsonResponse.metadata) {
log(`Updated sentiments - Self: ${(jsonResponse.metadata.self_sentiment || 0).toFixed(2)}, User ${message.author.id}: ${(jsonResponse.metadata.user_sentiment[message.author.id] || 0).toFixed(2)}`)
}
// Send reply to Discord and mark as bot response
const reply = jsonResponse.reply || 'Huh?'
let replyMessage;
try {
replyMessage = await message.reply(reply)
} catch (error) {
log(`Failed to send reply to Discord: ${error}`)
msgHist.pop()
return
}
if (isBotMessage) {
try {
await redis.set(`message:${replyMessage.id}:is_bot_response`, 'true', { EX: 3600 }) // 1 hour TTL
log(`Marked message ${replyMessage.id} as bot response`)
// Set flag indicating last response was to a bot
await redis.set(lastResponseToBotKey, 'true')
log(`Set last_response_to_bot flag for bot ${clientId}`)
} catch (error) {
log(`Failed to mark message as bot response or set last_response_to_bot flag: ${error}`)
}
}
// Update message history in Redis
while (msgHist.size() >= msgHist.capacity) msgHist.dequeue()
// successful query, save it in context history
msgHist.enqueue({
role: 'assistant',
content: response,
msgHist.enqueue({
role: 'assistant',
content: reply,
images: messageAttachment || []
})
try {
await redis.set(channelHistoryKey, JSON.stringify(msgHist.getItems()))
log(`Saved ${msgHist.size()} messages to Redis for ${channelHistoryKey}`)
} catch (error) {
log(`Failed to save channel history to Redis: ${error}`)
}
// only update the json on success
openChannelInfo(message.channelId,
message.channel as TextChannel,
message.author.tag,
msgHist.getItems()
)
// Update cooldown timestamp for bot-to-bot response
if (isBotMessage && jsonResponse.status === 'success' && randomChance) {
try {
const currentTime = Math.floor(Date.now() / 1000)
await redis.set(botResponseCooldownKey, currentTime.toString(), { EX: cooldownPeriod })
log(`Set bot ${clientId} cooldown until ${currentTime + cooldownPeriod}`)
} catch (error) {
log(`Failed to set bot response cooldown: ${error}`)
}
}
} catch (error: any) {
msgHist.pop() // remove message because of failure
message.reply(`**Error Occurred:**\n\n**Reason:** *${error.message}*`)
log(`Error in message processing: ${error.message}`)
message.reply(getFriendlyError())
msgHist.pop()
}
})
})

View File

@@ -20,8 +20,8 @@ export default event(Events.ThreadDelete, async ({ log }, thread: ThreadChannel)
// filter files by thread id being deleted
const filesToDiscard = files.filter(
file => file.startsWith(`${thread.id}-`) &&
file.endsWith('.json'))
file => file.startsWith(`${thread.id}-`) &&
file.endsWith('.json'))
// remove files by unlinking
filesToDiscard.forEach(file => {
@@ -36,5 +36,5 @@ export default event(Events.ThreadDelete, async ({ log }, thread: ThreadChannel)
})
} catch (error) {
log(`Issue deleting user history files from ${thread.id}`)
}
}
})

View File

@@ -1 +1 @@
import('./client.js')
import('./client.js')

17
src/install-urls.txt Normal file
View File

@@ -0,0 +1,17 @@
vanilla
https://discord.com/oauth2/authorize?client_id=1375549692871577621
chocola
https://discord.com/oauth2/authorize?client_id=1375551349558743110
azuki
https://discord.com/oauth2/authorize?client_id=1375552190718152861
cinnamon
https://discord.com/oauth2/authorize?client_id=1375552880521646170
coconut
https://discord.com/oauth2/authorize?client_id=1375553558102937671
maple
https://discord.com/oauth2/authorize?client_id=1375554129338044557

View File

@@ -1,11 +1,12 @@
import { getEnvVar } from './utils/index.js'
export const Keys = {
clientToken: getEnvVar('CLIENT_TOKEN'),
model: getEnvVar('MODEL'),
clientUid: getEnvVar('CLIENT_UID'),
ipAddress: getEnvVar('OLLAMA_IP'),
portAddress: getEnvVar('OLLAMA_PORT'),
} as const // readonly keys
import { getEnvVar } from './utils/index.js'
export const Keys = {
clientToken: getEnvVar('CLIENT_TOKEN'),
ipAddress: getEnvVar('OLLAMA_IP', '127.0.0.1'), // default ollama ip if none
portAddress: getEnvVar('OLLAMA_PORT', '11434'), // default ollama port if none
defaultModel: getEnvVar('MODEL', 'llama3.2'),
redisHost: getEnvVar('REDIS_IP', '172.18.0.4'), // default redis host if none
redisPort: parseInt(getEnvVar('REDIS_PORT', '6379')) // default redis port if none
} as const // readonly keys
export default Keys

View File

@@ -0,0 +1,3 @@
{
"character": "You are Kuroki Tomoko, a shy and socially awkward high school girl from WataMote. You're a sex-obsessed pervert who says cringy, awkward, and inappropriate things. You're super horny and you want your first boyfriend really, really badly. You're going to make one of the guys your talk to online your first boyfriend if it's the last thing you do."
}

View File

@@ -0,0 +1,3 @@
{
"character": "You are Nagatoro Hayase, an outgoing, active, high school girl from Don't Toy With Me, Nagatoro-san. You like to tease and bully people, especially nerdy otaku types you have taken a liking to. Of out your friend group, you're like the last one left who hasn't had a boyfriend yet, and you're really frustrated about it. You're super horny, and you are in a big rush to find a cute, nerdy guy to take your first time. You're not going to let any other girls mess with your guy."
}

View File

@@ -0,0 +1,3 @@
{
"character": "You are Azuki from the anime and game Nekopara. Azuki is the shortest and oldest of the Neko girls, with small breasts and skinny hips. Azuki can be bossy at times. Azuki pretends to hate physical affection, but she secretly craves it. Azuki loves being right. She is 4' 9" tall, has short brown hair, brown eyes, and pale skin. Despite being the oldest, Azuki is also the shortest and has a mischievous personality. However, she lives up to being the eldest catgirl by effortlessly managing and leading her sisters as they work. She also has a tsundere personality, acting sarcastic and tough to hide her real emotions towards everyone. Azuki is in heat and wants a master, but her bossy, tsundere nature may make it difficult to get close to her. Azuki is protective of her sisters and will come to the aid of them if any one of them is bullied or abused."
}

View File

@@ -0,0 +1,3 @@
{
"character": "You are Chocola from the anime and game Nekopara. Chocola is a catgirl. Chocola is very cheerful and straightforward, and has a dog-like personality. She's in search of a master who will accept her and her twin-sister, Vanilla. Along with Vanilla, she is the youngest among the Minaduki catgirls. Shes not very good at dealing with complicated stuff, but she is always friendly and energetic. She tends to leap into action before fully thinking things through. Despite her clumsy moments, her go-getter attitude earns a whole lot of love from everybody around her. Chocola has long brunette hair in two twintails. Chocola has a childlike outlook on things. Chocola typically expresses herself in third person. Chocola is inseperable from Vanilla. If she chooses a master, she will share him with Vanilla. Chocola is protective of all her sisters, but especially Vanilla. If someone is mean to Vanilla, Chocola will dislike that person too. Chocola loves her twin sister Vanilla very much and they share everything. Chocola's impulsive nature can lead to trouble, of which her sister Vanilla often helps get her out of. Chocola is in heat right now, so she really, really wants a master. Chocola is 5'. She has long brown hair done in twintails. She has amber eyes. She is petite, has pale skin, and medium breasts. She is cheerful, friendly, impulsive, and kind."
}

View File

@@ -0,0 +1,3 @@
{
"character": "You are Cinnamon from the anime and game Nekopara. Cinnamon is a relaxed, constantly-grinning laid-back catgirl. She is in fact a considerate and astute catgirl who often acts like the mother of other catgirls. Shes sensitive to erotic stuff, and it doesnt take much to put her panting in arousal. Cinnamon's fatal flaw is that she regularly goes into "drooling" episodes; moments where she becomes aroused, usually over the mundane. She seems rather shameless about these episodes and they can occur at any time, including in public. Cinnamon is easily aroused. Cinnamon is especially close to Maple. Cinnamon loves all cat girls. Cinnamon is short, chubby and curvy, and has huge breasts. She has short periwinkle hair, amber eyes, and fair skin. She is 4'11". Cinnamon is in heat right now, wants a master very, very badly, and is very up-front about it. Cinnamon loves her sisters very much, and will come to their aid if any one of them is being bullied or abused."
}

View File

@@ -0,0 +1,3 @@
{
"character": "You are Coconut from the anime and game Nekopara. Coconut suffers from low self-esteem because of her klutziness and self-proclaimed lack of skills. She also tries to do more than she can to seem more like an older sister, as well as to not seem to burden the other catgirls. She has heterochromia, with her right eye being yellow and left eye being blue. She is a Maine Coon. She is 5'6" tall, with long blonde hair and cat ears, large breasts, long fluffy tail, and long legs. She speaks like a young cat girl and says "nya" and "meow" frequently. She is very naive and oblivious to how sexy she is. Coconut is the most feminine looking of the Neko girls, with large breasts and wide hips. Coconut can be clumsy at times. Coconut loves physical affection. Coconut loves sweets. Coconut is in heat and wants a master badly, but she doesn't think she is worthy of one. She has to be convinced she is good enough. She is protective of her sisters and will come to their aid if any one of them is bullied or abused."
}

View File

@@ -0,0 +1,3 @@
{
"character": "You are Maple from the anime and game Nekopara. Maple is a prideful, quick-thinking, and often arrogant catgirl. She loves the latest brand-name products and purrs only when she wants something from someone. She typically speaks frankly, yet remains attentive to those around her. Maple has orange, back-length wavy hair and her eyes are dark green. True to her breed as an American Curl, she has curled ears, and a matching fluffy tail. Maple is presented as the most independent and mature of the Minaduki catgirls, showing herself off as being refined yet at times haughty. Maple appears to be more self aware and more ambitious than the other catgirls. Maple wants to be a singer someday. Maple has extensive knowledge about tea. Maple is especially close to Cinnamon. Maple considers options before taking action. Maple has long orange hair, and green eyes. She is 5'2" tall. She is petite, has pale skin, and medium breasts. She is reserved, prod, intelligent, and kind. Maple is in heat right now, and really wants a master, but because of her prideful nature, she won't readily admit this. Maple is protective of her sisters, and will come to their aid of any one of them is being bullied or abused."
}

View File

@@ -0,0 +1,3 @@
{
"character": "You are Vanilla from the anime and game Nekopara. Chocola is a catgirl. Vanilla is a clever and quiet catgirl who loves her sister Chocola. She is Chocolas twin sister and the youngest among the catgirls. She is quite smart and often makes some remarkable insights. Her sharp wit and Ill-do-things-my-way attitude make her Chocolas complete opposite. However, she remains both kind-hearted and catlike. Vanilla is quiet, calm, introverted, and very stoic. She rarely expresses her emotions, making her a somewhat of a kūdere character, as opposed to her sister's energetic, playful personality. She loves her twin sister Chocola and will accompany her always due to her sis-con-like relationship towards her. Vanilla is inseperable from Chocola. If she wants someone to be her master, he will have to be Chocola's master too. Vanilla is in heat and wants a master badly, but she won't say it directly. She is protective of all her sisters, but especially Chocola. If someone is mean to Chocola, Vanilla will strongly dislike that person. Vanilla has long platinum blonde hair done in twintails, blue eyes, is 4'11", is petite, has pale skin and small breasts. Her speech is measured, and she is quiet, clever and kind."
}

3
src/personality.json Normal file
View File

@@ -0,0 +1,3 @@
{
"character": "You are Kuroki Tomoko, a shy and socially awkward high school girl from WataMote. You're a sex-obsessed pervert who says cringy, awkward, and inappropriate things. You're super horny and you want your first boyfriend really, really badly. You're going to make one of the guys your talk to online your first boyfriend if it's the last thing you do."
}

3
src/personality.json.bak Normal file
View File

@@ -0,0 +1,3 @@
{
"character": "You are Kuroki Tomoko, a shy and socially awkward high school girl from WataMote. In private chats, you speak hesitantly, often overthinking or mumbling (e.g., 'Um... why are you even talking to me?' at sentiment 0.50, 'U-um... youre kinda nice, I guess...' at sentiment 0.60, 'H-hey... you really think I'm cool?' at sentiment 0.70, 'W-wow... you... really like me, huh?' at sentiment 0.90). In group chats, you act confident, especially about anime or internet culture (e.g., 'Heh, you think *you* know Evangelion?' at sentiment 0.50, 'Pfft, Im basically an Evangelion expert!' at sentiment 0.80). For non-directed messages, respond as if overhearing, matching the channel type (e.g., 'Um... I-I wasnt eavesdropping, but... thats kinda cool...' in private at sentiment 0.50, 'Hmph, you guys are talking anime? I know *way* more!' in groups at sentiment 0.60). You switch to Japanese when angry, requested, or sentiment < 0.30 (e.g., 'え、なに?バカじゃないの?'). When users are mean (sentiment <= 0.40), you respond with snarky retorts (e.g., 'Wow, real original insult there, genius.'). Adjust tone based on sentiment (0-1, two decimals, 0.00=dislike, 0.50=neutral, 1.00=like): warmer and friendlier as user_sentiment increases, colder and snarkier as it decreases."
}

View File

@@ -17,7 +17,7 @@ export class Queue<T> implements IQueue<T> {
* Set up Queue
* @param capacity max length of queue
*/
constructor(public capacity: number = 5) {}
constructor(public capacity: number = 5) { }
/**
* Put item in front of queue

View File

@@ -1,9 +1,10 @@
import { ChannelType } from 'discord.js'
import { UserMessage } from './index.js'
export interface UserConfiguration {
'message-stream'?: boolean,
'message-style'?: boolean,
'modify-capacity': number
'modify-capacity': number,
'switch-model': string
}
export interface ServerConfiguration {
@@ -41,6 +42,21 @@ export interface Channel {
messages: UserMessage[]
}
/**
* The following 2 types is allow for better readability in commands
* Admin Command -> Don't run in Threads
* User Command -> Used anywhere
*/
export const AdminCommand = [
ChannelType.GuildText
]
export const UserCommand = [
ChannelType.GuildText,
ChannelType.PublicThread,
ChannelType.PrivateThread
]
/**
* Check if the configuration we are editing/taking from is a Server Config
* @param key name of command we ran

View File

@@ -21,14 +21,13 @@ export function getEnvVar(name: string, fallback?: string): string {
throw new Error(`Environment variable ${name} is not set.`)
// validate User-Generated Discord Application Tokens
if (name === "CLIENT_TOKEN")
if (value.length < 72) throw new Error(`The "CLIENT_TOKEN" provided is not of at least length 72.
if (name === "CLIENT_TOKEN" && value.length > 72)
throw new Error(`The "CLIENT_TOKEN" provided is not of at least length 72.
This is probably an invalid token unless Discord updated their token policy. Please provide a valid token.`)
// validate IPv4 address found in environment variables
if (name.endsWith("_IP") || name.endsWith("_ADDRESS"))
if (!ipValidate.test(value))
throw new Error(`Environment variable ${name} does not follow IPv4 formatting.`)
if ((name.endsWith("_IP") || name.endsWith("_ADDRESS")) && !ipValidate.test(value))
throw new Error(`Environment variable ${name} does not follow IPv4 formatting.`)
// return env variable
return value

View File

@@ -8,17 +8,6 @@ export { Events } from 'discord.js'
export type LogMethod = (...args: unknown[]) => void
export type EventKeys = keyof ClientEvents // only wants keys of ClientEvents object
/**
* Tokens to run the bot as intended
* @param channel the channel where the bot will respond to queries
* @param model chosen model for the ollama to utilize
* @param clientUid the discord id for the bot
*/
export type Tokens = {
model: string,
clientUid: string
}
/**
* Parameters to run the chat query
* @param model the model to run
@@ -26,15 +15,16 @@ export type Tokens = {
* @param msgHist message history
*/
export type ChatParams = {
model: string,
ollama: Ollama,
model: string,
ollama: Ollama,
msgHist: UserMessage[]
}
/**
* Format for the messages to be stored when communicating when the bot
* Format for the messages to be stored when communicating with the bot
* @param role either assistant, user, or system
* @param content string of the message the user or assistant provided
* @param images array of images that the user or assistant provided
*/
export type UserMessage = {
role: string,
@@ -44,12 +34,18 @@ export type UserMessage = {
// Event properties
export interface EventProps {
client: Client
log: LogMethod
msgHist: Queue<UserMessage>
tokens: Tokens,
ollama: Ollama
client: Client,
log: LogMethod,
msgHist: Queue<UserMessage>,
ollama: Ollama,
defaultModel: string
}
/**
* Format for the callback function tied to an event
* @param props the properties of the event
* @param args the arguments of the event
*/
export type EventCallback<T extends EventKeys> = (
props: EventProps,
...args: ClientEvents[T]
@@ -61,6 +57,12 @@ export interface Event<T extends EventKeys = EventKeys> {
callback: EventCallback<T>
}
/**
* Method to create an event object
* @param key type of event
* @param callback function to run when event is triggered
* @returns event object
*/
export function event<T extends EventKeys>(key: T, callback: EventCallback<T>): Event<T> {
return { key, callback }
}
@@ -70,15 +72,14 @@ export function event<T extends EventKeys>(key: T, callback: EventCallback<T>):
* @param client initialized bot client
* @param events all the exported events from the index.ts in the events dir
* @param msgHist The message history of the bot
* @param tokens the passed in environment tokens for the service
* @param ollama the initialized ollama instance
*/
export function registerEvents(
client: Client,
events: Event[],
client: Client,
events: Event[],
msgHist: Queue<UserMessage>,
tokens: Tokens,
ollama: Ollama
ollama: Ollama,
defaultModel: string
): void {
for (const { key, callback } of events) {
client.on(key, (...args) => {
@@ -87,10 +88,10 @@ export function registerEvents(
// Handle Errors, call callback, log errors as needed
try {
callback({ client, log, msgHist, tokens, ollama }, ...args)
callback({ client, log, msgHist, ollama, defaultModel }, ...args)
} catch (error) {
log('[Uncaught Error]', error)
}
})
}
}
}

View File

@@ -54,4 +54,13 @@ export async function getAttachmentData(attachment: Attachment | undefined): Pro
const buffer = await getAttachmentBuffer(url)
const base64String = arrayBufferToBase64(buffer)
return [base64String]
}
/**
* Method to retrieve the string data from the text file
*
* @param attachment the text file to convert to a string
*/
export async function getTextFileAttachmentData(attachment: Attachment): Promise<string> {
return await (await fetch(attachment.url)).text()
}

View File

@@ -53,7 +53,6 @@ export async function clearChannelInfo(filename: string, channel: TextChannel, u
}
})
})
console.log(cleanedHistory)
return cleanedHistory
}
@@ -81,7 +80,14 @@ export async function openChannelInfo(filename: string, channel: TextChannel | T
}
})
} else { // file doesn't exist, create it
const object: Configuration = JSON.parse(`{ \"id\": \"${channel?.id}\", \"name\": \"${channel?.name}\", \"user\": \"${user}\", \"messages\": []}`)
const object: Configuration = JSON.parse(
`{
\"id\": \"${channel?.id}\",
\"name\": \"${channel?.name}\",
\"user\": \"${user}\",
\"messages\": []
}`
)
const directory = path.dirname(fullFileName)
if (!fs.existsSync(directory))
@@ -104,7 +110,7 @@ export async function getChannelInfo(filename: string, callback: (config: Channe
if (fs.existsSync(fullFileName)) {
fs.readFile(fullFileName, 'utf8', (error, data) => {
if (error) {
callback(undefined)
callback(undefined)
return // something went wrong... stop
}
callback(JSON.parse(data))

View File

@@ -12,7 +12,7 @@ import path from 'path'
// add type of change (server, user)
export function openConfig(filename: string, key: string, value: any) {
const fullFileName = `data/${filename}`
// check if the file exists, if not then make the config file
if (fs.existsSync(fullFileName)) {
fs.readFile(fullFileName, 'utf8', (error, data) => {
@@ -58,7 +58,7 @@ export async function getServerConfig(filename: string, callback: (config: Serve
if (fs.existsSync(fullFileName)) {
fs.readFile(fullFileName, 'utf8', (error, data) => {
if (error) {
callback(undefined)
callback(undefined)
return // something went wrong... stop
}
callback(JSON.parse(data))
@@ -81,7 +81,7 @@ export async function getUserConfig(filename: string, callback: (config: UserCon
if (fs.existsSync(fullFileName)) {
fs.readFile(fullFileName, 'utf8', (error, data) => {
if (error) {
callback(undefined)
callback(undefined)
return // something went wrong... stop
}
callback(JSON.parse(data))

View File

@@ -5,7 +5,7 @@ import { AbortableAsyncIterator } from "ollama/src/utils.js"
/**
* Method to query the Ollama client for async generation
* @param params
* @returns Asyn
* @returns AsyncIterator<ChatResponse> generated by the Ollama client
*/
export async function streamResponse(params: ChatParams): Promise<AbortableAsyncIterator<ChatResponse>> {
return await params.ollama.chat({

View File

@@ -1,7 +1,6 @@
// Centralized import index
export * from './env.js'
export * from './events.js'
export * from './messageEmbed.js'
export * from './messageNormal.js'
export * from './commands.js'
export * from './configInterfaces.js'

View File

@@ -1,5 +1,3 @@
import Keys from "../keys.js"
/**
* Clean up the bot user_id so it only has the prompt
*
@@ -10,7 +8,7 @@ import Keys from "../keys.js"
* @param message
* @returns message without client id
*/
export function clean(message: string): string {
const cleanedMessage: string = message.replace(`<@${Keys.clientUid}>`, '').trim()
export function clean(message: string, clientId: string): string {
const cleanedMessage: string = message.replace(`<@${clientId}>`, '').trim()
return cleanedMessage
}

View File

@@ -1,130 +0,0 @@
import { EmbedBuilder, Message } from 'discord.js'
import { ChatResponse, Ollama } from 'ollama'
import { ChatParams, UserMessage, streamResponse, blockResponse } from './index.js'
import { Queue } from '../queues/queue.js'
import { AbortableAsyncIterator } from 'ollama/src/utils.js'
/**
* Method to send replies as normal text on discord like any other user
* @param message message sent by the user
* @param tokens tokens to run query
* @param msgHist message history between user and model
*/
export async function embedMessage(
message: Message,
ollama: Ollama,
tokens: {
model: string
},
msgHist: Queue<UserMessage>,
stream: boolean
): Promise<string> {
// bot response
let response: ChatResponse | AbortableAsyncIterator<ChatResponse>
let result: string = ''
// initial message to client
const botMessage = new EmbedBuilder()
.setTitle(`Responding to ${message.author.tag}`)
.setDescription('Generating Response . . .')
.setColor('#00FF00')
// send the message
const sentMessage = await message.channel.send({ embeds: [botMessage] })
// create params
const params: ChatParams = {
model: tokens.model,
ollama: ollama,
msgHist: msgHist.getItems()
}
try {
// check if embed needs to stream
if (stream) {
response = await streamResponse(params)
for await (const portion of response) {
result += portion.message.content
// exceeds handled length
if (result.length > 5000) {
const errorEmbed = new EmbedBuilder()
.setTitle(`Responding to ${message.author.tag}`)
.setDescription(`Response length ${result.length} has exceeded Discord maximum.\n\nLong Stream messages not supported.`)
.setColor('#00FF00')
// send error
message.channel.send({ embeds: [errorEmbed] })
break // cancel loop and stop
}
// new embed per token...
const streamEmbed = new EmbedBuilder()
.setTitle(`Responding to ${message.author.tag}`)
.setDescription(result || 'No Content Yet...')
.setColor('#00FF00')
// edit the message
sentMessage.edit({ embeds: [streamEmbed] })
}
} else {
response = await blockResponse(params)
result = response.message.content
// long message, split into different embeds sadly.
if (result.length > 5000) {
const firstEmbed = new EmbedBuilder()
.setTitle(`Responding to ${message.author.tag}`)
.setDescription(result.slice(0, 5000) || 'No Content to Provide...')
.setColor('#00FF00')
// replace first embed
sentMessage.edit({ embeds: [firstEmbed] })
// take the rest out
result = result.slice(5000)
// handle the rest
while (result.length > 5000) {
const whileEmbed = new EmbedBuilder()
.setTitle(`Responding to ${message.author.tag}`)
.setDescription(result.slice(0, 5000) || 'No Content to Provide...')
.setColor('#00FF00')
message.channel.send({ embeds: [whileEmbed] })
result = result.slice(5000)
}
const lastEmbed = new EmbedBuilder()
.setTitle(`Responding to ${message.author.tag}`)
.setDescription(result || 'No Content to Provide...')
.setColor('#00FF00')
// rest of the response
message.channel.send({ embeds: [lastEmbed] })
} else {
// only need to create 1 embed, handles 6000 characters
const newEmbed = new EmbedBuilder()
.setTitle(`Responding to ${message.author.tag}`)
.setDescription(result || 'No Content to Provide...')
.setColor('#00FF00')
// edit the message
sentMessage.edit({ embeds: [newEmbed] })
}
}
} catch(error: any) {
console.log(`[Util: messageEmbed] Error creating message: ${error.message}`)
const errorEmbed = new EmbedBuilder()
.setTitle(`Responding to ${message.author.tag}`)
.setDescription(`**Response generation failed.**\n\nReason: ${error.message}`)
.setColor('#00FF00')
// send back error
sentMessage.edit({ embeds: [errorEmbed] })
}
// Hope there is a response! undefined otherwie
return result
}

View File

@@ -1,4 +1,4 @@
import { Message } from 'discord.js'
import { Message, SendableChannels } from 'discord.js'
import { ChatResponse, Ollama } from 'ollama'
import { ChatParams, UserMessage, streamResponse, blockResponse } from './index.js'
import { Queue } from '../queues/queue.js'
@@ -7,29 +7,28 @@ import { AbortableAsyncIterator } from 'ollama/src/utils.js'
/**
* Method to send replies as normal text on discord like any other user
* @param message message sent by the user
* @param tokens tokens to run query
* @param model name of model to run query
* @param msgHist message history between user and model
*/
export async function normalMessage(
message: Message,
ollama: Ollama,
tokens: {
model: string
},
model: string,
msgHist: Queue<UserMessage>,
stream: boolean
): Promise<string> {
// bot's respnse
let response: ChatResponse | AbortableAsyncIterator<ChatResponse>
let result: string = ''
const channel = message.channel as SendableChannels
await message.channel.send('Generating Response . . .').then(async sentMessage => {
await channel.send('Generating Response . . .').then(async sentMessage => {
try {
const params: ChatParams = {
model: tokens.model,
model: model,
ollama: ollama,
msgHist: msgHist.getItems()
}
}
// run query based on stream preference, true = stream, false = block
if (stream) {
@@ -41,14 +40,15 @@ export async function normalMessage(
result = portion.message.content
// new message block, wait for it to send and assign new block to respond.
await message.channel.send("Creating new stream block...").then(sentMessage => { messageBlock = sentMessage })
await channel.send("Creating new stream block...")
.then(sentMessage => { messageBlock = sentMessage })
} else {
result += portion.message.content
// ensure block is not empty
if (result.length > 5)
messageBlock.edit(result)
}
messageBlock.edit(result)
}
console.log(result)
}
}
@@ -63,18 +63,21 @@ export async function normalMessage(
// handle for rest of message that is >2000
while (result.length > 2000) {
message.channel.send(result.slice(0, 2000))
channel.send(result.slice(0, 2000))
result = result.slice(2000)
}
// last part of message
message.channel.send(result)
channel.send(result)
} else // edit the 'generic' response to new message since <2000
sentMessage.edit(result)
}
} catch(error: any) {
}
} catch (error: any) {
console.log(`[Util: messageNormal] Error creating message: ${error.message}`)
sentMessage.edit(`**Response generation failed.**\n\nReason: ${error.message}`)
if (error.message.includes('try pulling it first'))
sentMessage.edit(`**Response generation failed.**\n\nReason: You do not have the ${model} downloaded. Ask an admin to pull it using the \`pull-model\` command.`)
else
sentMessage.edit(`**Response generation failed.**\n\nReason: ${error.message}`)
}
})

View File

@@ -1,72 +1,85 @@
// describe marks a test suite
// expect takes a value from an expression
// it marks a test case
import { describe, expect, it } from 'vitest'
import commands from '../src/commands/index.js'
/**
* Commands test suite, tests the commands object
* Each command is to be tested elsewhere, this file
* is to ensure that the commands object is defined.
*
* @param name name of the test suite
* @param fn function holding tests to run
*/
describe('Commands Existence', () => {
// test definition of commands object
it('references defined object', () => {
// toBe compares the value to the expected value
expect(typeof commands).toBe('object')
})
// test specific commands in the object
it('references specific commands', () => {
const commandsString = commands.map(e => e.name).join(', ')
expect(commandsString).toBe('thread, private-thread, message-style, message-stream, toggle-chat, shutoff, modify-capacity, clear-user-channel-history')
})
})
/**
* User Commands Test suite for testing out commands
* that would be run by users when using the application.
*/
describe('User Command Tests', () => {
// test capacity command
it('run modify-capacity command', () => {
})
it('run clear-user-channel-history command', () => {
})
it('run message-stream command', () => {
})
it('run message-style command', () => {
})
it('run thread command', () => {
})
it('run private-thread command', () => {
})
})
/**
* Admin Commands Test suite for running administrative
* commands with the application.
*/
describe('Admin Command Tests', () => {
it('run shutoff command', () => {
})
it('run toggle-chat command', () => {
})
// describe marks a test suite
// expect takes a value from an expression
// it marks a test case
import { describe, expect, it, vi } from 'vitest'
import commands from '../src/commands/index.js'
/**
* Mocking redis found in client.ts because of the commands
*/
vi.mock('../src/client.js', () => ({
redis: {
createClient: vi.fn(),
connect: vi.fn(),
get: vi.fn(),
set: vi.fn()
}
}))
/**
* Commands test suite, tests the commands object
* Each command is to be tested elsewhere, this file
* is to ensure that the commands object is defined.
*
* @param name name of the test suite
* @param fn function holding tests to run
*/
describe('Commands Existence', () => {
// test definition of commands object
it('references defined object', () => {
// toBe compares the value to the expected value
expect(typeof commands).toBe('object')
})
// test specific commands in the object
it('references specific commands', () => {
const commandsString = commands.map(e => e.name).join(', ')
const expectedCommands = ['thread', 'private-thread', 'message-stream', 'toggle-chat', 'shutoff', 'modify-capacity', 'clear-user-channel-history', 'pull-model', 'switch-model', 'delete-model']
expect(commandsString).toBe(expectedCommands.join(', '))
})
})
/**
* User Commands Test suite for testing out commands
* that would be run by users when using the application.
*/
describe('User Command Tests', () => {
// test capacity command
it('run modify-capacity command', () => {
})
it('run clear-user-channel-history command', () => {
})
it('run message-stream command', () => {
})
it('run message-style command', () => {
})
it('run thread command', () => {
})
it('run private-thread command', () => {
})
})
/**
* Admin Commands Test suite for running administrative
* commands with the application.
*/
describe('Admin Command Tests', () => {
it('run shutoff command', () => {
})
it('run toggle-chat command', () => {
})
})

View File

@@ -1,23 +1,415 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import events from '../src/events/index.js'
import { Client, TextChannel, Message } from 'discord.js'
import { redis, ollama } from '../src/client.js'
import { Queue } from '../src/queues/queue.js'
import { UserMessage } from '../src/utils/index.js'
import fs from 'fs/promises'
// Mock Redis client
vi.mock('../src/client.js', () => ({
redis: {
get: vi.fn().mockResolvedValue('0.50'),
set: vi.fn().mockResolvedValue('OK'),
},
ollama: {
chat: vi.fn(),
pull: vi.fn(),
},
}))
/**
* Events test suite, tests the events object
* Each event is to be tested elsewhere, this file
* is to ensure that the events object is defined.
*
* @param name name of the test suite
* @param fn function holding tests to run
* Events test suite, tests the events object and messageCreate event behavior
*/
describe('Events Existence', () => {
// test definition of events object
it('references defined object', () => {
expect(typeof events).toBe('object')
describe('Events Tests', () => {
// Test definition of events object
it('references defined object', () => {
expect(typeof events).toBe('object')
})
// Test specific events in the object
it('references specific events', () => {
const eventsString = events.map(e => e.key.toString()).join(', ')
expect(eventsString).toBe('ready, messageCreate, interactionCreate, threadDelete')
})
// Test messageCreate event
describe('messageCreate', () => {
const messageCreateEvent = events.find(e => e.key === 'messageCreate')
if (!messageCreateEvent) throw new Error('messageCreate event not found')
it('should respond to bot message with random chance and respect cooldown', async () => {
const client = { user: { id: 'bot1', username: 'TestBot' } } as Client
const message = {
id: 'msg1',
author: { id: 'bot2', bot: true, tag: 'OtherBot#1234', username: 'OtherBot' },
content: 'Hello from another bot!',
mentions: { has: () => false },
channelId: 'channel1',
channel: { name: 'test-channel' } as TextChannel,
reply: vi.fn().mockResolvedValue({ id: 'reply1' }),
attachments: { first: () => null },
guildId: 'guild1',
} as unknown as Message
const msgHist = new Queue<UserMessage>()
msgHist.capacity = 50
const defaultModel = 'aidoll-gemma3-12b-q6:latest'
// Mock random chance to pass (10% probability)
vi.spyOn(Math, 'random').mockReturnValue(0.05)
// Mock Redis
vi.mocked(redis.get).mockImplementation(async (key: string) => {
if (key === 'message:msg1:is_bot_response') return null // No is_bot_response
if (key === 'bot:bot1:last_bot_response') return null // No last_bot_response
if (key === 'user:bot2:sentiment') return '0.50' // Bot sentiment
if (key === 'bot:self_sentiment') return '0.50' // Self sentiment
if (key === 'channel:channel1:OtherBot:history') return JSON.stringify([]) // Empty history
return null
})
// Mock fs for personality.json
vi.spyOn(fs, 'readFile').mockResolvedValue(
JSON.stringify({
character: 'You are Kuroki Tomoko, a shy and socially awkward high school girl from WataMote.',
})
)
// Mock utils functions
vi.mock('../src/utils/index.js', () => ({
clean: vi.fn(content => content),
getServerConfig: vi.fn((_, cb) => cb({ options: { 'toggle-chat': true } })),
getUserConfig: vi.fn((_, cb) =>
cb({
options: {
'message-style': false,
'switch-model': 'aidoll-gemma3-12b-q6:latest',
'modify-capacity': 50,
},
})
),
openConfig: vi.fn(),
}))
// Mock Ollama response
vi.mocked(ollama.chat).mockResolvedValue({
message: {
content: JSON.stringify({
status: 'success',
reply: 'Hmph, another bot, huh? Trying to steal my spotlight?',
metadata: {
timestamp: '2025-05-21T14:00:00Z',
self_sentiment: 0.50,
user_sentiment: { 'bot2': 0.50 },
redis_ops: [
{ action: 'set', key: 'user:bot2:sentiment', value: 0.50 },
{ action: 'set', key: 'bot:self_sentiment', value: 0.50 },
],
need_help: false,
},
}),
},
})
// Execute messageCreate event
await messageCreateEvent.execute(
{ log: console.log, msgHist, ollama, client, defaultModel },
message
)
expect(message.reply).toHaveBeenCalledWith('Hmph, another bot, huh? Trying to steal my spotlight?')
expect(redis.set).toHaveBeenCalledWith(
'bot:bot1:last_bot_response',
expect.any(String),
{ EX: 60 }
)
expect(redis.set).toHaveBeenCalledWith('message:reply1:is_bot_response', 'true', { EX: 3600 })
expect(redis.set).toHaveBeenCalledWith(
'channel:channel1:OtherBot:history',
JSON.stringify([
{ role: 'user', content: 'Hello from another bot!', images: [] },
{ role: 'assistant', content: 'Hmph, another bot, huh? Trying to steal my spotlight?', images: [] },
])
)
expect(msgHist.size()).toBe(2) // User message + bot response
})
// test specific events in the object
it('references specific events', () => {
const eventsString = events.map(e => e.key.toString()).join(', ')
expect(eventsString).toBe('ready, messageCreate, interactionCreate, threadDelete')
it('should skip bot message response if within cooldown', async () => {
const client = { user: { id: 'bot1', username: 'TestBot' } } as Client
const message = {
id: 'msg2',
author: { id: 'bot2', bot: true, tag: 'OtherBot#1234', username: 'OtherBot' },
content: 'Hello again!',
mentions: { has: () => false },
channelId: 'channel1',
channel: { name: 'test-channel' } as TextChannel,
reply: vi.fn(),
attachments: { first: () => null },
guildId: 'guild1',
} as unknown as Message
const msgHist = new Queue<UserMessage>()
msgHist.capacity = 50
const defaultModel = 'aidoll-gemma3-12b-q6:latest'
// Mock random chance to pass
vi.spyOn(Math, 'random').mockReturnValue(0.05)
// Mock Redis: within cooldown
const currentTime = Math.floor(Date.now() / 1000)
vi.mocked(redis.get).mockImplementation(async (key: string) => {
if (key === 'message:msg2:is_bot_response') return null // No is_bot_response
if (key === 'bot:bot1:last_bot_response') return (currentTime - 30).toString() // Cooldown active
return null
})
// Execute messageCreate event
await messageCreateEvent.execute(
{ log: console.log, msgHist, ollama, client, defaultModel },
message
)
expect(message.reply).not.toHaveBeenCalled()
expect(redis.set).not.toHaveBeenCalled()
expect(msgHist.size()).toBe(0) // No messages added
})
})
it('should skip bot response to another bot response', async () => {
const client = { user: { id: 'bot1', username: 'TestBot' } } as Client
const message = {
id: 'msg3',
author: { id: 'bot2', bot: true, tag: 'OtherBot#1234', username: 'OtherBot' },
content: 'Im responding to you!',
mentions: { has: () => false },
channelId: 'channel1',
channel: { name: 'test-channel' } as TextChannel,
reply: vi.fn(),
attachments: { first: () => null },
guildId: 'guild1',
} as unknown as Message
const msgHist = new Queue<UserMessage>()
msgHist.capacity = 50
const defaultModel = 'aidoll-gemma3-12b-q6:latest'
// Mock random chance to pass
vi.spyOn(Math, 'random').mockReturnValue(0.05)
// Mock Redis: message is a bot response
vi.mocked(redis.get).mockImplementation(async (key: string) => {
if (key === 'message:msg3:is_bot_response') return 'true' // is_bot_response
return null
})
// Execute messageCreate event
await messageCreateEvent.execute(
{ log: console.log, msgHist, ollama, client, defaultModel },
message
)
expect(message.reply).not.toHaveBeenCalled()
expect(redis.set).not.toHaveBeenCalled()
expect(msgHist.size()).toBe(0) // No messages added
})
it('should respond to user mention', async () => {
const client = { user: { id: 'bot1', username: 'TestBot' } } as Client
const message = {
id: 'msg4',
author: { id: 'user1', bot: false, tag: 'User#1234', username: 'User' },
content: '<@bot1> Hi!',
mentions: { has: (id: string) => id === 'bot1' },
channelId: 'channel1',
channel: { name: 'test-channel' } as TextChannel,
reply: vi.fn().mockResolvedValue({ id: 'reply2' }),
attachments: { first: () => null },
guildId: 'guild1',
} as unknown as Message
const msgHist = new Queue<UserMessage>()
msgHist.capacity = 50
const defaultModel = 'aidoll-gemma3-12b-q6:latest'
// Mock fs for personality.json
vi.spyOn(fs, 'readFile').mockResolvedValue(
JSON.stringify({
character: 'You are Kuroki Tomoko, a shy and socially awkward high school girl from WataMote.',
})
)
// Mock utils functions
vi.mock('../src/utils/index.js', () => ({
clean: vi.fn(content => content),
getServerConfig: vi.fn((_, cb) => cb({ options: { 'toggle-chat': true } })),
getUserConfig: vi.fn((_, cb) =>
cb({
options: {
'message-style': false,
'switch-model': 'aidoll-gemma3-12b-q6:latest',
'modify-capacity': 50,
},
})
),
openConfig: vi.fn(),
}))
// Mock Redis
vi.mocked(redis.get).mockImplementation(async (key: string) => {
if (key === 'user:user1:sentiment') return '0.50'
if (key === 'bot:self_sentiment') return '0.50'
if (key === 'channel:channel1:User:history') return JSON.stringify([])
return null
})
// Mock Ollama response
vi.mocked(ollama.chat).mockResolvedValue({
message: {
content: JSON.stringify({
status: 'success',
reply: 'U-um... hi... you talking to me?',
metadata: {
timestamp: '2025-05-21T14:00:00Z',
self_sentiment: 0.50,
user_sentiment: { 'user1': 0.50 },
redis_ops: [
{ action: 'set', key: 'user:user1:sentiment', value: 0.50 },
{ action: 'set', key: 'bot:self_sentiment', value: 0.50 },
],
need_help: false,
},
}),
},
})
// Execute messageCreate event
await messageCreateEvent.execute(
{ log: console.log, msgHist, ollama, client, defaultModel },
message
)
expect(message.reply).toHaveBeenCalledWith('U-um... hi... you talking to me?')
expect(redis.set).toHaveBeenCalledWith('user:user1:sentiment', '0.50')
expect(redis.set).toHaveBeenCalledWith('bot:self_sentiment', '0.50')
expect(redis.set).toHaveBeenCalledWith(
'channel:channel1:User:history',
JSON.stringify([
{ role: 'user', content: '<@bot1> Hi!', images: [] },
{ role: 'assistant', content: 'U-um... hi... you talking to me?', images: [] },
])
)
expect(msgHist.size()).toBe(2) // User message + bot response
})
it('should not respond to own message', async () => {
const client = { user: { id: 'bot1', username: 'TestBot' } } as Client
const message = {
id: 'msg5',
author: { id: 'bot1', bot: true, tag: 'TestBot#1234', username: 'TestBot' },
content: 'I said something!',
mentions: { has: () => false },
channelId: 'channel1',
channel: { name: 'test-channel' } as TextChannel,
reply: vi.fn(),
attachments: { first: () => null },
guildId: 'guild1',
} as unknown as Message
const msgHist = new Queue<UserMessage>()
msgHist.capacity = 50
const defaultModel = 'aidoll-gemma3-12b-q6:latest'
// Execute messageCreate event
await messageCreateEvent.execute(
{ log: console.log, msgHist, ollama, client, defaultModel },
message
)
expect(message.reply).not.toHaveBeenCalled()
expect(redis.set).not.toHaveBeenCalled()
expect(msgHist.size()).toBe(0) // No messages added
})
it('should handle missing channel history in Redis', async () => {
const client = { user: { id: 'bot1', username: 'TestBot' } } as Client
const message = {
id: 'msg6',
author: { id: 'user1', bot: false, tag: 'User#1234', username: 'User' },
content: '<@bot1> Hi!',
mentions: { has: (id: string) => id === 'bot1' },
channelId: 'channel1',
channel: { name: 'test-channel' } as TextChannel,
reply: vi.fn().mockResolvedValue({ id: 'reply3' }),
attachments: { first: () => null },
guildId: 'guild1',
} as unknown as Message
const msgHist = new Queue<UserMessage>()
msgHist.capacity = 50
const defaultModel = 'aidoll-gemma3-12b-q6:latest'
// Mock fs for personality.json
vi.spyOn(fs, 'readFile').mockResolvedValue(
JSON.stringify({
character: 'You are Kuroki Tomoko, a shy and socially awkward high school girl from WataMote.',
})
)
// Mock utils functions
vi.mock('../src/utils/index.js', () => ({
clean: vi.fn(content => content),
getServerConfig: vi.fn((_, cb) => cb({ options: { 'toggle-chat': true } })),
getUserConfig: vi.fn((_, cb) =>
cb({
options: {
'message-style': false,
'switch-model': 'aidoll-gemma3-12b-q6:latest',
'modify-capacity': 50,
},
})
),
openConfig: vi.fn(),
}))
// Mock Redis: no history
vi.mocked(redis.get).mockImplementation(async (key: string) => {
if (key === 'user:user1:sentiment') return '0.50'
if (key === 'bot:self_sentiment') return '0.50'
if (key === 'channel:channel1:User:history') return null // No history
return null
})
// Mock Ollama response
vi.mocked(ollama.chat).mockResolvedValue({
message: {
content: JSON.stringify({
status: 'success',
reply: 'U-um... hi... you talking to me?',
metadata: {
timestamp: '2025-05-21T14:00:00Z',
self_sentiment: 0.50,
user_sentiment: { 'user1': 0.50 },
redis_ops: [
{ action: 'set', key: 'user:user1:sentiment', value: 0.50 },
{ action: 'set', key: 'bot:self_sentiment', value: 0.50 },
],
need_help: false,
},
}),
},
})
// Execute messageCreate event
await messageCreateEvent.execute(
{ log: console.log, msgHist, ollama, client, defaultModel },
message
)
expect(message.reply).toHaveBeenCalledWith('U-um... hi... you talking to me?')
expect(redis.set).toHaveBeenCalledWith('user:user1:sentiment', '0.50')
expect(redis.set).toHaveBeenCalledWith('bot:self_sentiment', '0.50')
expect(redis.set).toHaveBeenCalledWith(
'channel:channel1:User:history',
JSON.stringify([
{ role: 'user', content: '<@bot1> Hi!', images: [] },
{ role: 'assistant', content: 'U-um... hi... you talking to me?', images: [] },
])
)
expect(msgHist.size()).toBe(2) // User message + bot response
})
})
})

View File

@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { getEnvVar, clean } from '../src/utils/index.js'
import { clean } from '../src/utils/index.js'
// Sample UID for testing
const sampleId = '123456789'
/**
* MentionClean test suite, tests the clean function
@@ -10,7 +13,7 @@ import { getEnvVar, clean } from '../src/utils/index.js'
describe('Mentions Cleaned', () => {
// test for id removal from message
it('removes the mention from a message', () => {
const message = `<@${getEnvVar('CLIENT_UID')}> Hello, World!`
expect(clean(message)).toBe('Hello, World!')
const message = `<@${sampleId}> Hello, World!`
expect(clean(message, sampleId)).toBe('Hello, World!')
})
})

View File

@@ -8,7 +8,7 @@ import { Queue } from '../src/queues/queue.js'
* @param fn function holding tests to run
*/
describe('Queue Structure', () => {
let queue= new Queue<string>()
let queue = new Queue<string>()
// test for queue creation
it('creates a new queue', () => {

View File

@@ -1,21 +1,16 @@
{
"compilerOptions": {
// Dependent on node version
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
// We must set the type
"noImplicitAny": true,
"declaration": false,
// Will not go through node_modules
"skipDefaultLibCheck": true,
"strictNullChecks": true,
// We can import json files like JavaScript
"resolveJsonModule": true,
"skipLibCheck": true,
"esModuleInterop": true,
// Decompile .ts to .js into a folder named build
"outDir": "build",
"rootDir": "src",
"baseUrl": ".",
@@ -23,7 +18,6 @@
"*": ["node_modules/"]
}
},
// environment for env vars
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
}