Compare commits

...

14 Commits

Author SHA1 Message Date
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
Kevin Dang
947ff89958 Environment Variable Validation (#122)
* Update: env validation and discord token validation

* Add: IPv4 Address Validation

* Update: version increment
2024-10-05 19:29:01 -07:00
Jonathan Smoley
6a9ee2d6d0 Code Coverage and Clean References (#120)
* Add: skeleton suite for command tests (#119)

* test naming updated

* fix imports, remove old references

* added code coverage badge

* Add: coverage environment

* Fix: Readme hyperlink to coverage workflow

* grab coverage pct from env

* Update: gist hyperlink

* color range on coverage

* fix contributing, simplify coverage assessment

* lmiit coverage to master, add branch naming conventions

---------

Co-authored-by: Kevin Dang <77701718+kevinthedang@users.noreply.github.com>
2024-10-01 10:11:23 -07:00
Kevin Dang
e3b0c9abe4 Message Attachment Image Recognition (#118)
* Add: Image recognition

* Fix: Non-Attachment Query

* Update: version increment

* Remove: Debugging logs from buffer file

* Add: comments for bufferHandler
2024-09-18 20:28:23 -07:00
46 changed files with 1411 additions and 954 deletions

View File

@@ -1,11 +1,8 @@
# Discord token for the bot # Discord token for the bot
CLIENT_TOKEN = BOT_TOKEN CLIENT_TOKEN = BOT_TOKEN
# model for the bot to query from (i.e. llama2 [llama2:13b], mistral, codellama, etc... ) # Default model for new users
MODEL = MODEL_NAME MODEL = DEFAULT_MODEL
# discord bot user id for mentions
CLIENT_UID = BOT_USER_ID
# ip/port address of docker container, I use 172.18.0.3 for docker, 127.0.0.1 for local # ip/port address of docker container, I use 172.18.0.3 for docker, 127.0.0.1 for local
OLLAMA_IP = IP_ADDRESS OLLAMA_IP = IP_ADDRESS

View File

@@ -1,31 +1,39 @@
<!-- <!--
Author: Kevin Dang Author: Kevin Dang
Date: 1-30-2024 Date: 1-30-2024
Changes:
10-01-2024 - Jonathan Smoley
--> -->
## Naming Conventions
* Branches
* prefix your branch name with the type of contribution:
* features: `'feature/**'`
* releases: `'releases/**'`
* bugs: `'bug/**'`
* docs: `'docs/**'`
## Run the Bot ## Run the Bot
* Refer to all sections below before running the bot. * Refer to all sections below before running the bot.
* You should now have `Ollama`, `NodeJS`, ran `npm install`. * You should now have `Ollama`, `NodeJS`, ran `npm install`.
* You will also need a discord bot to run. Refer to the [developer portal](https://discord.com/developers/) to learn how to set one up and invite it to your server. If that does not help then look up a YouTube video like this [one](https://www.youtube.com/watch?v=KZ3tIGHU314&ab_channel=UnderCtrl). * You will also need a discord bot to run. Refer to the [developer portal](https://discord.com/developers/) to learn how to set one up and invite it to your server. If that does not help then look up a YouTube video like this [one](https://www.youtube.com/watch?v=KZ3tIGHU314&ab_channel=UnderCtrl).
* Now run `npm run start` to run the client and ollama at the same time (this must be one in wsl or a Linux distro) * Now run `npm run client` to run the client (this must be done in wsl or a Linux distro)
## Set up (Development-side) ## Set up (Development-side)
* Pull the repository using `https://github.com/kevinthedang/discord-ollama.git`. * Pull the repository using `https://github.com/kevinthedang/discord-ollama.git`.
* Refer to `Ollama Setup` in the readme to set up Ollama. * Refer to `Environment Setup` in the readme to set up Ollama.
* This must be set up in a Linux environment or wsl2. * This must be set up in a Linux environment or wsl2.
* Install NodeJS `v18.18.2` * Install NodeJS `v18.18.2`
* You can check out `Resources` and `To Run` in the readme for a bit of help. * You can check out `Resources` in the readme for a bit of help.
* You can also reference [NodeJS Setup](#nodejs-setup) * You can also reference [NodeJS Setup](#nodejs-setup)
* When you have the project pulled from github, open up a terminal and run `npm i` or `npm install` to get all of the packages for the project. * When you have the project pulled from github, open up a terminal and run `npm i` or `npm install` to get all of the packages for the project.
* In some kind of terminal (`git bash` is good) to run the client. You can run Ollama but opening up wsl2 and typing `ollama serve`. * In some kind of terminal (`git bash` is good) to run the client. You can run Ollama but opening up wsl2 and typing `ollama serve`.
* Refer to `Ollama Setup` if there are any issues. * Refer to `Ollama Setup` if there are any issues.
## Environment ## Environment
* You will need two environment files: * You will need an environment file:
* `.env`: for running the bot * `.env`: for running the bot
* Please refer to `.env.sample` for all environment variables to include * Please refer to `.env.sample` for all environment variables to include
* `.env.dev.local`: also runs the bot, but with development variables
* Currently there are no differences between the two, but when needed, you may add environment variables as needed.
## NodeJS Setup ## NodeJS Setup
* Install [nvm](https://github.com/nvm-sh/nvm?tab=readme-ov-file#installing-and-updating) using `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash` * Install [nvm](https://github.com/nvm-sh/nvm?tab=readme-ov-file#installing-and-updating) using `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash`

View File

@@ -31,8 +31,6 @@ jobs:
run: | run: |
touch .env touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .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_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
@@ -59,8 +57,6 @@ jobs:
run: | run: |
touch .env touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .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_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env

49
.github/workflows/coverage.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Coverage
run-name: Code Coverage
on:
push:
branches:
- master
jobs:
Discord-Node-Coverage:
runs-on: ubuntu-latest
environment: coverage
timeout-minutes: 2
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: Install Project Dependencies
run: |
npm install
- name: Create Environment Variables
run: |
touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
- name: Collect Code Coverage
run: |
LINE_PCT=$(npm run test:coverage | tail -2 | head -1 | awk '{print $3}')
echo "COVERAGE=$LINE_PCT" >> $GITHUB_ENV
- name: Upload Code Coverage
uses: schneegans/dynamic-badges-action@v1.7.0
with:
auth: ${{ secrets.GIST_SECRET }}
gistID: ${{ vars.GIST_ID }}
filename: coverage.json
label: Coverage
message: ${{ env.COVERAGE }}
valColorRange: ${{ env.COVERAGE }}
maxColorRange: 100
minColorRange: 0

View File

@@ -24,8 +24,6 @@ jobs:
run: | run: |
touch .env touch .env
echo CLIENT_TOKEN = NOT_REAL_TOKEN >> .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_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env

View File

@@ -39,8 +39,6 @@ jobs:
run: | run: |
touch .env touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .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_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env

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/ @kevinthedang @JT2M0L3Y
/.github/ @kevinthedang
# Docker Ownership
Dockerfile @kevinthedang
docker-compose.yml @kevinthedang
# Documentation Ownership
/docs/ @kevinthedang
/imgs/ @kevinthedang

View File

@@ -7,6 +7,7 @@
<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/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/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/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/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> </div>
## About/Goals ## About/Goals
@@ -24,12 +25,17 @@ The project aims to:
* [x] User vs. Server Preferences * [x] User vs. Server Preferences
* [ ] Redis Caching * [ ] Redis Caching
* [x] Administrator Role Compatible * [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+` * [x] Multi-User Chat Generation (Multiple users chatting at the same time) - This was built in from Ollama `v0.2.1+`
* [ ] Automatic and Manual model pulling through the Discord client * [x] Automatic and Manual model pulling through the Discord client
* [ ] Allow others to create their own models personalized for their own servers! * [ ] Allow others to create their own models personalized for their own servers!
* [ ] Documentation on creating your own LLM * [ ] Documentation on creating your own LLM
* [ ] Documentation on web scrapping and cleaning * [ ] Documentation on web scrapping and cleaning
## Documentation
These are guides to the feature set included and the events triggered in this app.
* [User Slash Commands](./docs/commands-guide.md)
* [Client Events](./docs/events-guide.md)
## Environment Setup ## 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. * 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.
* You will need a `.env` file in the root of the project directory with the bot's token. There is a `.env.sample` is provided for you as a reference for what environment variables. * You will need a `.env` file in the root of the project directory with the bot's token. There is a `.env.sample` is provided for you as a reference for what environment variables.
@@ -40,6 +46,7 @@ The project aims to:
* [Docker Setup for Servers and Local Machines](./docs/setup-docker.md) * [Docker Setup for Servers and Local Machines](./docs/setup-docker.md)
* Nvidia is recommended for now, but support for other GPUs should be development. * Nvidia is recommended for now, but support for other GPUs should be development.
* Local use is not recommended. * Local use is not recommended.
## Resources ## Resources
* [NodeJS](https://nodejs.org/en) * [NodeJS](https://nodejs.org/en)
* This project runs on `lts\hydrogen`. * This project runs on `lts\hydrogen`.
@@ -54,4 +61,4 @@ The project aims to:
* [Kevin Dang](https://github.com/kevinthedang) * [Kevin Dang](https://github.com/kevinthedang)
* [Jonathan Smoley](https://github.com/JT2M0L3Y) * [Jonathan Smoley](https://github.com/JT2M0L3Y)
[discord-ollama](https://github.com/kevinthedang/discord-ollama) © 2023 by [Kevin Dang](https://github.com/kevinthedang) is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) [discord-ollama](https://github.com/kevinthedang/discord-ollama) © 2023 by [Kevin Dang](https://github.com/kevinthedang) is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)

View File

@@ -7,11 +7,9 @@ services:
build: ./ # find docker file in designated path build: ./ # find docker file in designated path
container_name: discord container_name: discord
restart: always # rebuild container always restart: always # rebuild container always
image: kevinthedang/discord-ollama:0.5.11 image: kevinthedang/discord-ollama:0.7.5
environment: environment:
CLIENT_TOKEN: ${CLIENT_TOKEN} CLIENT_TOKEN: ${CLIENT_TOKEN}
MODEL: ${MODEL}
CLIENT_UID: ${CLIENT_UID}
OLLAMA_IP: ${OLLAMA_IP} OLLAMA_IP: ${OLLAMA_IP}
OLLAMA_PORT: ${OLLAMA_PORT} OLLAMA_PORT: ${OLLAMA_PORT}
networks: networks:

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

@@ -0,0 +1,105 @@
## 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
```
> [!NOTE]
> This is a very slow progress on Discord because "spamming" changes within 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.
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.
> [!NOTE] Possible interactions include commands, buttons, menus, etc.
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) ![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. * 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. * 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. * 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) * 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. * 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. * 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 ## Nvidia Container Toolkit Setup
### Installation with Apt ### Installation with Apt
@@ -48,8 +50,6 @@ sudo systemctl restart docker
* `SUBNET_ADDRESS = 172.18.0.0` * `SUBNET_ADDRESS = 172.18.0.0`
* Don't understand any of this? watch a Networking video to understand subnetting. * 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 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. * 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: * For cleaning up on Linux (or Windows), run the following commands:
* `docker compose stop` * `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). * You can now interact with the model you just ran (it might take a second to startup).
* Response time varies with processing power! * 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) ## To Run Locally (without Docker)
* Run `npm install` to install the npm packages. * 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. * 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`. * 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. * 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). * **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. * 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 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 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.

1220
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "discord-ollama", "name": "discord-ollama",
"version": "0.5.11", "version": "0.7.5",
"description": "Ollama Integration into discord", "description": "Ollama Integration into discord",
"main": "build/index.js", "main": "build/index.js",
"exports": "./build/index.js", "exports": "./build/index.js",
@@ -26,17 +26,17 @@
"author": "Kevin Dang", "author": "Kevin Dang",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"discord.js": "^14.15.3", "discord.js": "^14.16.3",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"ollama": "^0.5.8" "ollama": "^0.5.9"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.5.1", "@types/node": "^22.9.0",
"@vitest/coverage-v8": "^2.0.5", "@vitest/coverage-v8": "^2.1.4",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.19.0", "tsx": "^4.19.2",
"typescript": "^5.5.4", "typescript": "^5.6.3",
"vitest": "^2.0.5" "vitest": "^2.1.4"
}, },
"type": "module", "type": "module",
"engines": { "engines": {

View File

@@ -1,10 +1,8 @@
import { Client, GatewayIntentBits } from 'discord.js' import { Client, GatewayIntentBits } from 'discord.js'
import { UserMessage, registerEvents } from './utils/events.js'
import Events from './events/index.js'
import { Ollama } from 'ollama' import { Ollama } from 'ollama'
import { Queue } from './queues/queue.js' import { Queue } from './queues/queue.js'
import { UserMessage, registerEvents } from './utils/index.js'
// Import keys/tokens import Events from './events/index.js'
import Keys from './keys.js' import Keys from './keys.js'
@@ -19,21 +17,15 @@ const client = new Client({
}); });
// initialize connection to ollama container // initialize connection to ollama container
const ollama = new Ollama({ export const ollama = new Ollama({
host: `http://${Keys.ipAddress}:${Keys.portAddress}`, host: `http://${Keys.ipAddress}:${Keys.portAddress}`,
}) })
// Create Queue managed by Events // Create Queue managed by Events
const messageHistory: Queue<UserMessage> = new Queue<UserMessage> const messageHistory: Queue<UserMessage> = new Queue<UserMessage>
/** // register all events
* register events for bot to listen to in discord registerEvents(client, Events, messageHistory, ollama, Keys.defaultModel)
* @param messageHistory message history for the llm
* @param Events events to register
* @param client the bot reference
* @param Keys tokens from .env files
*/
registerEvents(client, Events, messageHistory, Keys, ollama)
// Try to log in the client // Try to log in the client
await client.login(Keys.clientToken) await client.login(Keys.clientToken)
@@ -45,5 +37,6 @@ await client.login(Keys.clientToken)
// queue up bots name // queue up bots name
messageHistory.enqueue({ messageHistory.enqueue({
role: 'assistant', role: 'assistant',
content: `My name is ${client.user?.username}` content: `My name is ${client.user?.username}`,
images: []
}) })

View File

@@ -1,16 +1,15 @@
import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js' import { Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { SlashCommand } from '../utils/commands.js' import { openConfig, SlashCommand, UserCommand } from '../utils/index.js'
import { openConfig } from '../utils/index.js'
export const Capacity: SlashCommand = { export const Capacity: SlashCommand = {
name: 'modify-capacity', 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 // set available user options to pass to the command
options: [ options: [
{ {
name: 'context-capacity', name: 'context-capacity',
description: 'a number to set capacity', description: 'number of allowed messages to remember',
type: ApplicationCommandOptionType.Number, type: ApplicationCommandOptionType.Number,
required: true required: true
} }
@@ -20,13 +19,13 @@ export const Capacity: SlashCommand = {
run: async (client: Client, interaction: CommandInteraction) => { run: async (client: Client, interaction: CommandInteraction) => {
// fetch channel and message // fetch channel and message
const channel = await client.channels.fetch(interaction.channelId) 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 // 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({ interaction.reply({
content: `Message History Capacity has been set to \`${interaction.options.get('context-capacity')?.value}\``, content: `Max message history is now set to \`${interaction.options.get('context-capacity')?.value}\``,
ephemeral: true ephemeral: true
}) })
} }

View File

@@ -1,18 +1,17 @@
import { ChannelType, Client, CommandInteraction, TextChannel } from 'discord.js' import { Channel, Client, CommandInteraction, TextChannel } from 'discord.js'
import { SlashCommand } from '../utils/commands.js' import { clearChannelInfo, SlashCommand, UserCommand } from '../utils/index.js'
import { clearChannelInfo } from '../utils/index.js'
export const ClearUserChannelHistory: SlashCommand = { export const ClearUserChannelHistory: SlashCommand = {
name: 'clear-user-channel-history', 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 // Clear channel history for intended user
run: async (client: Client, interaction: CommandInteraction) => { run: async (client: Client, interaction: CommandInteraction) => {
// fetch current channel // 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 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 // clear channel info for user
const successfulWipe = await clearChannelInfo(interaction.channelId, const successfulWipe = await clearChannelInfo(interaction.channelId,
@@ -22,12 +21,12 @@ export const ClearUserChannelHistory: SlashCommand = {
// check result of clearing history // check result of clearing history
if (successfulWipe) if (successfulWipe)
interaction.reply({ interaction.reply({
content: `Channel history in **${channel.name}** cleared for **${interaction.user.username}**.`, content: `History cleared in **this channel** cleared for **${interaction.user.username}**.`,
ephemeral: true ephemeral: true
}) })
else else
interaction.reply({ 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.`, 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.`,
ephemeral: true ephemeral: true
}) })
} }

View File

@@ -1,10 +1,9 @@
import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js' import { Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { SlashCommand } from '../utils/commands.js' import { AdminCommand, openConfig, SlashCommand } from '../utils/index.js'
import { openConfig } from '../utils/index.js'
export const Disable: SlashCommand = { export const Disable: SlashCommand = {
name: 'toggle-chat', 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 // set available user options to pass to the command
options: [ options: [
@@ -20,12 +19,12 @@ export const Disable: SlashCommand = {
run: async (client: Client, interaction: CommandInteraction) => { run: async (client: Client, interaction: CommandInteraction) => {
// fetch channel and message // fetch channel and message
const channel = await client.channels.fetch(interaction.channelId) 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 // check if runner is an admin
if (!interaction.memberPermissions?.has('Administrator')) { if (!interaction.memberPermissions?.has('Administrator')) {
interaction.reply({ 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.`, content: `${interaction.commandName} is an admin command.\n\nPlease contact an admin to use this command for you.`,
ephemeral: true ephemeral: true
}) })
return return
@@ -35,7 +34,7 @@ export const Disable: SlashCommand = {
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({ interaction.reply({
content: `Chat features has been \`${interaction.options.get('enabled')?.value ? "enabled" : "disabled" }\``, content: `${client.user?.username} is now **${interaction.options.get('enabled')?.value ? "enabled" : "disabled" }**.`,
ephemeral: true ephemeral: true
}) })
} }

View File

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

View File

@@ -1,16 +1,15 @@
import { ApplicationCommandOptionType, ChannelType, Client, CommandInteraction } from 'discord.js' import { ApplicationCommandOptionType, Client, CommandInteraction } from 'discord.js'
import { SlashCommand } from '../utils/commands.js' import { openConfig, SlashCommand, UserCommand } from '../utils/index.js'
import { openConfig } from '../utils/index.js'
export const MessageStream: SlashCommand = { export const MessageStream: SlashCommand = {
name: 'message-stream', 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 // user option(s) for setting stream
options: [ options: [
{ {
name: 'stream', name: 'stream',
description: 'enable or disable stream preference', description: 'enable or disable message streaming',
type: ApplicationCommandOptionType.Boolean, type: ApplicationCommandOptionType.Boolean,
required: true required: true
} }
@@ -20,13 +19,13 @@ export const MessageStream: SlashCommand = {
run: async (client: Client, interaction: CommandInteraction) => { run: async (client: Client, interaction: CommandInteraction) => {
// verify channel // verify channel
const channel = await client.channels.fetch(interaction.channelId) 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 // 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({ interaction.reply({
content: `Message streaming preferences set to: \`${interaction.options.get('stream')?.value}\``, content: `Message streaming is now set to: \`${interaction.options.get('stream')?.value}\``,
ephemeral: true ephemeral: true
}) })
} }

View File

@@ -1,33 +0,0 @@
import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { SlashCommand } from '../utils/commands.js'
import { openConfig } 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
})
}
}

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

@@ -0,0 +1,70 @@
import { ApplicationCommandOptionType, Client, CommandInteraction } from "discord.js";
import { SlashCommand } from "../utils/commands.js";
import { ollama } from "../client.js";
import { ModelResponse } from "ollama";
import { UserCommand } 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.`,
ephemeral: true
})
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,28 +1,19 @@
import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js' import { Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { SlashCommand } from '../utils/commands.js' import { SlashCommand } from '../utils/commands.js'
import { AdminCommand } from '../utils/index.js'
export const Shutoff: SlashCommand = { export const Shutoff: SlashCommand = {
name: 'shutoff', name: 'shutoff',
description: 'shutdown the bot. You will need to manually bring it online again. Administrator Only.', 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 // Query for message information and set the style
run: async (client: Client, interaction: CommandInteraction) => { run: async (client: Client, interaction: CommandInteraction) => {
// fetch channel and message // fetch channel and message
const channel = await client.channels.fetch(interaction.channelId) 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 // 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 // check if admin or false on shutdown
if (!interaction.memberPermissions?.has('Administrator')) { if (!interaction.memberPermissions?.has('Administrator')) {
@@ -31,19 +22,14 @@ export const Shutoff: SlashCommand = {
ephemeral: true ephemeral: true
}) })
return // stop from shutting down 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 // Shutoff cleared, do it
interaction.reply({ interaction.reply({
content: `${client.user?.tag} is ${interaction.options.get('are-you-sure')?.value ? "shutting down now." : "not shutting down." }`, content: `${client.user?.tag} is shutting down.`,
ephemeral: true ephemeral: true
}) })
console.log(`[Command: shutoff] ${client.user?.tag} is shutting down.`)
// clean up client instance and stop // clean up client instance and stop
client.destroy() client.destroy()

View File

@@ -0,0 +1,68 @@
import { ApplicationCommandOptionType, Client, CommandInteraction } from "discord.js";
import { SlashCommand } from "../utils/commands.js";
import { ollama } from "../client.js";
import { ModelResponse } from "ollama";
import { openConfig, UserCommand } 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,6 +1,5 @@
import { ChannelType, Client, CommandInteraction, TextChannel, ThreadChannel } from 'discord.js' import { ChannelType, Client, CommandInteraction, TextChannel, ThreadChannel } from 'discord.js'
import { SlashCommand } from '../utils/commands.js' import { AdminCommand, openChannelInfo, SlashCommand } from '../utils/index.js'
import { openChannelInfo } from '../utils/index.js'
export const ThreadCreate: SlashCommand = { export const ThreadCreate: SlashCommand = {
name: 'thread', name: 'thread',
@@ -10,7 +9,7 @@ export const ThreadCreate: SlashCommand = {
run: async (client: Client, interaction: CommandInteraction) => { run: async (client: Client, interaction: CommandInteraction) => {
// fetch the channel // fetch the channel
const channel = await client.channels.fetch(interaction.channelId) 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({ const thread = await (channel as TextChannel).threads.create({
name: `${client.user?.username}-support-${Date.now()}`, name: `${client.user?.username}-support-${Date.now()}`,
@@ -19,7 +18,7 @@ export const ThreadCreate: SlashCommand = {
}) })
// Send a message in the thread // 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 // handle storing this chat channel
openChannelInfo(thread.id, openChannelInfo(thread.id,
@@ -28,7 +27,7 @@ export const ThreadCreate: SlashCommand = {
// user only reply // user only reply
return interaction.reply({ return interaction.reply({
content: `I can help you in thread **${thread.id}** below.`, content: `I can help you in <#${thread.id}> below.`,
ephemeral: true ephemeral: true
}) })
} }

View File

@@ -1,6 +1,5 @@
import { ChannelType, Client, CommandInteraction, TextChannel, ThreadChannel } from 'discord.js' import { ChannelType, Client, CommandInteraction, TextChannel, ThreadChannel } from 'discord.js'
import { SlashCommand } from '../utils/commands.js' import { AdminCommand, openChannelInfo, SlashCommand } from '../utils/index.js'
import { openChannelInfo } from '../utils/index.js'
export const PrivateThreadCreate: SlashCommand = { export const PrivateThreadCreate: SlashCommand = {
name: 'private-thread', name: 'private-thread',
@@ -10,7 +9,7 @@ export const PrivateThreadCreate: SlashCommand = {
run: async (client: Client, interaction: CommandInteraction) => { run: async (client: Client, interaction: CommandInteraction) => {
// fetch the channel // fetch the channel
const channel = await client.channels.fetch(interaction.channelId) 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({ const thread = await (channel as TextChannel).threads.create({
name: `${client.user?.username}-private-support-${Date.now()}`, name: `${client.user?.username}-private-support-${Date.now()}`,
@@ -30,7 +29,7 @@ export const PrivateThreadCreate: SlashCommand = {
// user only reply // user only reply
return interaction.reply({ return interaction.reply({
content: `I can help you in thread **${thread.id}**. Please refer to the private channel below this one.`, content: `I can help you in <#${thread.id}>.`,
ephemeral: true ephemeral: true
}) })
} }

View File

@@ -1,7 +1,6 @@
import { embedMessage, event, Events, normalMessage, UserMessage } from '../utils/index.js'
import { getChannelInfo, getServerConfig, getUserConfig, openChannelInfo, openConfig, ServerConfig, UserConfig } from '../utils/index.js'
import { clean } from '../utils/mentionClean.js'
import { TextChannel } from 'discord.js' import { TextChannel } from 'discord.js'
import { event, Events, normalMessage, UserMessage, clean } from '../utils/index.js'
import { getChannelInfo, getServerConfig, getUserConfig, openChannelInfo, openConfig, UserConfig, getAttachmentData } from '../utils/index.js'
/** /**
* Max Message length for free users is 2000 characters (bot or not). * Max Message length for free users is 2000 characters (bot or not).
@@ -9,65 +8,101 @@ import { TextChannel } from 'discord.js'
* *
* @param message the message received from the channel * @param message the message received from the channel
*/ */
export default event(Events.MessageCreate, async ({ log, msgHist, tokens, ollama }, message) => { export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client, defaultModel }, message) => {
log(`Message \"${clean(message.content)}\" from ${message.author.tag} in channel/thread ${message.channelId}.`) const clientId = client.user!!.id
const cleanedMessage = clean(message.content, clientId)
log(`Message \"${cleanedMessage}\" from ${message.author.tag} in channel/thread ${message.channelId}.`)
// Do not respond if bot talks in the chat // Do not respond if bot talks in the chat
if (message.author.username === message.client.user.username) return if (message.author.username === message.client.user.username) return
// Only respond if message mentions the bot // Only respond if message mentions the bot
if (!message.mentions.has(tokens.clientUid)) return if (!message.mentions.has(clientId)) return
// default stream to false // default stream to false
let shouldStream = false let shouldStream = false
// Params for Preferences Fetching
const maxRetries = 3
const delay = 1000 // in millisecons
try { try {
// Retrieve Server/Guild Preferences // Retrieve Server/Guild Preferences
await new Promise((resolve, reject) => { let attempt = 0
getServerConfig(`${message.guildId}-config.json`, (config) => { while (attempt < maxRetries) {
// check if config.json exists try {
if (config === undefined) { await new Promise((resolve, reject) => {
// Allowing chat options to be available getServerConfig(`${message.guildId}-config.json`, (config) => {
openConfig(`${message.guildId}-config.json`, 'toggle-chat', true) // check if config.json exists
reject(new Error('No Server Preferences is set up.\n\nCreating default server preferences file...\nPlease try chatting again.')) if (config === undefined) {
return // Allowing chat options to be available
} openConfig(`${message.guildId}-config.json`, 'toggle-chat', true)
reject(new Error('Failed to locate or create Server Preferences\n\nPlease try chatting again...'))
}
// check if chat is disabled
else if (!config.options['toggle-chat'])
reject(new Error('Admin(s) have disabled chat features.\n\n Please contact your server\'s admin(s).'))
else
resolve(config)
})
})
break // successful
} 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
throw new Error(`Could not retrieve Server Preferences, please try chatting again...`)
}
}
// check if chat is disabled // Reset attempts for User preferences
if (!config.options['toggle-chat']) { attempt = 0
reject(new Error('Admin(s) have disabled chat features.\n\n Please contact your server\'s admin(s).')) let userConfig: UserConfig | undefined
return
}
resolve(config) while (attempt < maxRetries) {
}) try {
}) // Retrieve User Preferences
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)
openConfig(`${message.author.username}-config.json`, 'switch-model', defaultModel)
reject(new Error('No User Preferences is set up.\n\nCreating preferences file with \`message-style\` set as \`false\` for regular message style.\nPlease try chatting again.'))
return
}
// check if there is a set capacity in config
else 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']
}
// Retrieve User Preferences // set stream state
const userConfig: UserConfig = await new Promise((resolve, reject) => { shouldStream = config.options['message-stream'] as boolean || false
getUserConfig(`${message.author.username}-config.json`, (config) => {
if (config === undefined) { if (typeof config.options['switch-model'] !== 'string')
openConfig(`${message.author.username}-config.json`, 'message-style', false) 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>\`.`))
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 resolve(config)
} })
})
// check if there is a set capacity in config break // successful
if (typeof config.options['modify-capacity'] !== 'number') } catch (error) {
log(`Capacity is undefined, using default capacity of ${msgHist.capacity}.`) ++attempt
else if (config.options['modify-capacity'] === msgHist.capacity) if (attempt < maxRetries) {
log(`Capacity matches config as ${msgHist.capacity}, no changes made.`) log(`Attempt ${attempt} failed for User Preferences. Retrying in ${delay}ms...`)
else { await new Promise(ret => setTimeout(ret, delay))
log(`New Capacity found. Setting Context Capacity to ${config.options['modify-capacity']}.`) } else
msgHist.capacity = config.options['modify-capacity'] throw new Error(`Could not retrieve User Preferences, please try chatting again...`)
} }
}
// set stream state
shouldStream = config.options['message-stream'] as boolean || false
resolve(config)
})
})
// need new check for "open/active" threads/channels here! // need new check for "open/active" threads/channels here!
let chatMessages: UserMessage[] = await new Promise((resolve) => { let chatMessages: UserMessage[] = await new Promise((resolve) => {
@@ -99,8 +134,12 @@ export default event(Events.MessageCreate, async ({ log, msgHist, tokens, ollama
}) })
} }
// response string for ollama to put its response if (!userConfig)
let response: string throw new Error(`Failed to initialize User Preference for **${message.author.username}**.\n\nIt's likely you do not have a model set. Please use the \`switch-model\` command to do that.`)
// get message attachment if exists
const messageAttachment: string[] = await getAttachmentData(message.attachments.first())
const model: string = userConfig.options['switch-model']
// set up new queue // set up new queue
msgHist.setQueue(chatMessages) msgHist.setQueue(chatMessages)
@@ -111,14 +150,12 @@ export default event(Events.MessageCreate, async ({ log, msgHist, tokens, ollama
// push user response before ollama query // push user response before ollama query
msgHist.enqueue({ msgHist.enqueue({
role: 'user', role: 'user',
content: clean(message.content) content: cleanedMessage,
images: messageAttachment || []
}) })
// undefined or false, use normal, otherwise use embed // response string for ollama to put its response
if (userConfig.options['message-style']) const response: string = await normalMessage(message, ollama, model, msgHist, shouldStream)
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 something bad happened, remove user query and stop
if (response == undefined) { msgHist.pop(); return } if (response == undefined) { msgHist.pop(); return }
@@ -129,7 +166,8 @@ export default event(Events.MessageCreate, async ({ log, msgHist, tokens, ollama
// successful query, save it in context history // successful query, save it in context history
msgHist.enqueue({ msgHist.enqueue({
role: 'assistant', role: 'assistant',
content: response content: response,
images: messageAttachment || []
}) })
// only update the json on success // only update the json on success

View File

@@ -1,5 +1,5 @@
import { event, Events, registerCommands } from '../utils/index.js'
import { ActivityType } from 'discord.js' import { ActivityType } from 'discord.js'
import { event, Events, registerCommands } from '../utils/index.js'
import commands from '../commands/index.js' import commands from '../commands/index.js'
// Log when the bot successfully logs in and export it // Log when the bot successfully logs in and export it

View File

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

View File

@@ -1,9 +1,10 @@
import { UserMessage } from './events.js' import { ChannelType } from 'discord.js'
import { UserMessage } from './index.js'
export interface UserConfiguration { export interface UserConfiguration {
'message-stream'?: boolean, 'message-stream'?: boolean,
'message-style'?: boolean, 'modify-capacity': number,
'modify-capacity': number 'switch-model': string
} }
export interface ServerConfiguration { export interface ServerConfiguration {
@@ -41,6 +42,21 @@ export interface Channel {
messages: UserMessage[] 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 * Check if the configuration we are editing/taking from is a Server Config
* @param key name of command we ran * @param key name of command we ran

View File

@@ -1,21 +1,35 @@
import { resolve } from 'path' import { resolve } from 'path'
import { config } from 'dotenv' import { config } from 'dotenv'
// Find config - ONLY WORKS WITH NODEMON
const envFile = process.env.NODE_ENV === 'development' ? '.env.dev.local' : '.env'
// resolve config file // resolve config file
const envFilePath = resolve(process.cwd(), envFile) const envFilePath = resolve(process.cwd(), '.env')
const ipValidate: RegExp = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/
// set current environment variable file // set current environment variable file
config({ path: envFilePath }) config({ path: envFilePath })
// Getter for environment variables /**
* Method to validate if environment variables found in file utils/env.ts
*
* @param name Name of the environment variable in .env
* @param fallback fallback value to set if environment variable is not set (used manually in src/keys.ts)
* @returns environment variable value
*/
export function getEnvVar(name: string, fallback?: string): string { export function getEnvVar(name: string, fallback?: string): string {
const value = process.env[name] ?? fallback const value = process.env[name] ?? fallback
if (value == undefined) if (!value)
throw new Error(`Environment variable ${name} is not set.`) 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.
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.`)
// return env variable // return env variable
return value return value
} }

View File

@@ -1,4 +1,4 @@
import type { ClientEvents, Awaitable, Client, User } from 'discord.js' import type { ClientEvents, Awaitable, Client } from 'discord.js'
import { Ollama } from 'ollama' import { Ollama } from 'ollama'
import { Queue } from '../queues/queue.js' import { Queue } from '../queues/queue.js'
@@ -8,17 +8,6 @@ export { Events } from 'discord.js'
export type LogMethod = (...args: unknown[]) => void export type LogMethod = (...args: unknown[]) => void
export type EventKeys = keyof ClientEvents // only wants keys of ClientEvents object 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 * Parameters to run the chat query
* @param model the model to run * @param model the model to run
@@ -38,7 +27,8 @@ export type ChatParams = {
*/ */
export type UserMessage = { export type UserMessage = {
role: string, role: string,
content: string content: string,
images: string[] // May or may not have images in message
} }
// Event properties // Event properties
@@ -46,8 +36,8 @@ export interface EventProps {
client: Client client: Client
log: LogMethod log: LogMethod
msgHist: Queue<UserMessage> msgHist: Queue<UserMessage>
tokens: Tokens, ollama: Ollama,
ollama: Ollama defaultModel: String
} }
export type EventCallback<T extends EventKeys> = ( export type EventCallback<T extends EventKeys> = (
props: EventProps, props: EventProps,
@@ -69,15 +59,14 @@ export function event<T extends EventKeys>(key: T, callback: EventCallback<T>):
* @param client initialized bot client * @param client initialized bot client
* @param events all the exported events from the index.ts in the events dir * @param events all the exported events from the index.ts in the events dir
* @param msgHist The message history of the bot * @param msgHist The message history of the bot
* @param tokens the passed in environment tokens for the service
* @param ollama the initialized ollama instance * @param ollama the initialized ollama instance
*/ */
export function registerEvents( export function registerEvents(
client: Client, client: Client,
events: Event[], events: Event[],
msgHist: Queue<UserMessage>, msgHist: Queue<UserMessage>,
tokens: Tokens, ollama: Ollama,
ollama: Ollama defaultModel: String
): void { ): void {
for (const { key, callback } of events) { for (const { key, callback } of events) {
client.on(key, (...args) => { client.on(key, (...args) => {
@@ -86,7 +75,7 @@ export function registerEvents(
// Handle Errors, call callback, log errors as needed // Handle Errors, call callback, log errors as needed
try { try {
callback({ client, log, msgHist, tokens, ollama }, ...args) callback({ client, log, msgHist, ollama, defaultModel }, ...args)
} catch (error) { } catch (error) {
log('[Uncaught Error]', error) log('[Uncaught Error]', error)
} }

View File

@@ -0,0 +1,57 @@
import { Attachment } from "discord.js"
/**
* Method to convert a Discord attachment url to an array buffer
*
* @param url Discord Attachment Url
* @returns array buffer from Attachment Url
*/
async function getAttachmentBuffer(url: string): Promise<ArrayBuffer> {
// Get the data from the image
const response = await fetch(url)
// Validate the image came in fine
if (!response.ok)
throw new Error('Failed to fetch the attachment.')
// Return image as Buffer
return await response.arrayBuffer()
}
/**
* Method to convert an array buffer to a Base64 String
*
* @param buffer Array Buffer from attachment
* @returns converted Base64 string
*/
function arrayBufferToBase64(buffer: ArrayBuffer): string {
// Converting to Uint8Array
const uint8Array = new Uint8Array(buffer)
let binary = ''
const len = uint8Array.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(uint8Array[i])
}
// Return as Base64
return Buffer.from(binary, 'binary').toString('base64')
}
/**
* Method to retrieve the Base64 Array of provided Message Attachment
*
* @param attachment Message Attachment from Discord
* @returns Base64 string array
*/
export async function getAttachmentData(attachment: Attachment | undefined): Promise<string[]> {
const url: string = attachment !== undefined ? attachment.url : "Missing Url"
// case of no attachment
if (url === "Missing Url")
return []
// Convert data to base64
const buffer = await getAttachmentBuffer(url)
const base64String = arrayBufferToBase64(buffer)
return [base64String]
}

View File

@@ -53,7 +53,6 @@ export async function clearChannelInfo(filename: string, channel: TextChannel, u
} }
}) })
}) })
console.log(cleanedHistory)
return cleanedHistory return cleanedHistory
} }

View File

@@ -1,12 +1,13 @@
// Centralized import index // Centralized import index
export * from './env.js' export * from './env.js'
export * from './events.js' export * from './events.js'
export * from './messageEmbed.js'
export * from './messageNormal.js' export * from './messageNormal.js'
export * from './commands.js' export * from './commands.js'
export * from './configInterfaces.js' export * from './configInterfaces.js'
export * from './mentionClean.js'
// handler imports // handler imports
export * from './handlers/chatHistoryHandler.js' export * from './handlers/chatHistoryHandler.js'
export * from './handlers/configHandler.js' export * from './handlers/configHandler.js'
export * from './handlers/streamHandler.js' export * from './handlers/streamHandler.js'
export * from './handlers/bufferHandler.js'

View File

@@ -8,9 +8,9 @@ import Keys from "../keys.js"
* - replace function works well for this * - replace function works well for this
* *
* @param message * @param message
* @returns * @returns message without client id
*/ */
export function clean(message: string): string { export function clean(message: string, clientId: string): string {
const cleanedMessage: string = message.replace(`<@${Keys.clientUid}>`, '').trim() const cleanedMessage: string = message.replace(`<@${clientId}>`, '').trim()
return cleanedMessage 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 { ChatResponse, Ollama } from 'ollama'
import { ChatParams, UserMessage, streamResponse, blockResponse } from './index.js' import { ChatParams, UserMessage, streamResponse, blockResponse } from './index.js'
import { Queue } from '../queues/queue.js' import { Queue } from '../queues/queue.js'
@@ -7,26 +7,25 @@ import { AbortableAsyncIterator } from 'ollama/src/utils.js'
/** /**
* Method to send replies as normal text on discord like any other user * Method to send replies as normal text on discord like any other user
* @param message message sent by the 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 * @param msgHist message history between user and model
*/ */
export async function normalMessage( export async function normalMessage(
message: Message, message: Message,
ollama: Ollama, ollama: Ollama,
tokens: { model: string,
model: string
},
msgHist: Queue<UserMessage>, msgHist: Queue<UserMessage>,
stream: boolean stream: boolean
): Promise<string> { ): Promise<string> {
// bot's respnse // bot's respnse
let response: ChatResponse | AbortableAsyncIterator<ChatResponse> let response: ChatResponse | AbortableAsyncIterator<ChatResponse>
let result: string = '' 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 { try {
const params: ChatParams = { const params: ChatParams = {
model: tokens.model, model: model,
ollama: ollama, ollama: ollama,
msgHist: msgHist.getItems() msgHist: msgHist.getItems()
} }
@@ -41,7 +40,7 @@ export async function normalMessage(
result = portion.message.content result = portion.message.content
// new message block, wait for it to send and assign new block to respond. // 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 { } else {
result += portion.message.content result += portion.message.content
@@ -63,18 +62,21 @@ export async function normalMessage(
// handle for rest of message that is >2000 // handle for rest of message that is >2000
while (result.length > 2000) { while (result.length > 2000) {
message.channel.send(result.slice(0, 2000)) channel.send(result.slice(0, 2000))
result = result.slice(2000) result = result.slice(2000)
} }
// last part of message // last part of message
message.channel.send(result) channel.send(result)
} else // edit the 'generic' response to new message since <2000 } else // edit the 'generic' response to new message since <2000
sentMessage.edit(result) sentMessage.edit(result)
} }
} catch(error: any) { } catch(error: any) {
console.log(`[Util: messageNormal] Error creating message: ${error.message}`) 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

@@ -2,7 +2,7 @@
// expect takes a value from an expression // expect takes a value from an expression
// it marks a test case // it marks a test case
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import commands from '../src/commands' import commands from '../src/commands/index.js'
/** /**
* Commands test suite, tests the commands object * Commands test suite, tests the commands object
@@ -12,7 +12,7 @@ import commands from '../src/commands'
* @param name name of the test suite * @param name name of the test suite
* @param fn function holding tests to run * @param fn function holding tests to run
*/ */
describe('#commands', () => { describe('Commands Existence', () => {
// test definition of commands object // test definition of commands object
it('references defined object', () => { it('references defined object', () => {
// toBe compares the value to the expected value // toBe compares the value to the expected value
@@ -22,6 +22,51 @@ describe('#commands', () => {
// test specific commands in the object // test specific commands in the object
it('references specific commands', () => { it('references specific commands', () => {
const commandsString = commands.map(e => e.name).join(', ') 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') expect(commandsString).toBe('thread, private-thread, message-stream, toggle-chat, shutoff, modify-capacity, clear-user-channel-history, pull-model, switch-model')
})
})
/**
* 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,15 +1,23 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import events from '../src/events' import events from '../src/events/index.js'
/**
* Mocking ollama found in client.ts because pullModel.ts
* relies on the existence on ollama. To prevent the mock,
* we will have to pass through ollama to the commands somehow.
*/
vi.mock('../src/client.js', () => ({
ollama: {
pull: vi.fn() // Mock the pull method found with ollama
}
}))
/** /**
* Events test suite, tests the events object * Events test suite, tests the events object
* Each event is to be tested elsewhere, this file * Each event is to be tested elsewhere, this file
* is to ensure that the events object is defined. * is to ensure that the events object is defined.
*
* @param name name of the test suite
* @param fn function holding tests to run
*/ */
describe('#events', () => { describe('Events Existence', () => {
// test definition of events object // test definition of events object
it('references defined object', () => { it('references defined object', () => {
expect(typeof events).toBe('object') expect(typeof events).toBe('object')

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { getEnvVar } from '../src/utils' import { getEnvVar } from '../src/utils/index.js'
/** /**
* getEnvVar test suite, tests the getEnvVar function * getEnvVar test suite, tests the getEnvVar function
@@ -7,7 +7,7 @@ import { getEnvVar } from '../src/utils'
* @param name name of the test suite * @param name name of the test suite
* @param fn function holding tests to run * @param fn function holding tests to run
*/ */
describe('#getEnvVar', () => { describe('Environment Setup', () => {
// dummy set of keys // dummy set of keys
const keys = { const keys = {
clientToken: 'CLIENT_TOKEN', clientToken: 'CLIENT_TOKEN',

View File

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

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { Queue } from '../src/queues/queue' import { Queue } from '../src/queues/queue.js'
/** /**
* Queue test suite, tests the Queue class * Queue test suite, tests the Queue class
@@ -7,7 +7,7 @@ import { Queue } from '../src/queues/queue'
* @param name name of the test suite * @param name name of the test suite
* @param fn function holding tests to run * @param fn function holding tests to run
*/ */
describe('#queue', () => { describe('Queue Structure', () => {
let queue= new Queue<string>() let queue= new Queue<string>()
// test for queue creation // test for queue creation

View File

@@ -4,9 +4,10 @@ import { defineConfig, configDefaults } from 'vitest/config'
export default defineConfig({ export default defineConfig({
test: { test: {
globals: true, // <-- reduces test file imports globals: true, // <-- reduces test file imports
reporters: ['verbose'], // <-- verbose output
coverage: { coverage: {
exclude: [...configDefaults.exclude, 'build/*', 'tests/*'], // <-- exclude JS build exclude: [...configDefaults.exclude, 'build/*', 'tests/*'], // <-- exclude JS build
reporter: ['text', 'html'] // <-- reports in text, html reporter: ['text-summary'] // <-- report in text-summary
} }
} }
}) })