Compare commits

...

32 Commits

Author SHA1 Message Date
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
Kevin Dang
36a0cd309b Removed Channel Toggle Command (#115)
* Remove: channel-toggle as command and server config

* Remove: Thread interface

* Fix: Users Thread files will now delete

* Fix: Any user can chat in threads now

* Fix: Thread history files are now deleted with multiple users

* Update: version increment
2024-09-14 13:34:40 -07:00
Kevin Dang
b49b464afb Script Cleaning and Fixes (#114)
* organized existing scripts

* Delete nodemon.json

* exclude test/* from vitest

* remove nodemon from package

* Update: slight adjustments to package.json and readme

* Update: more small changes to readme

* Fix: cleanup scripts and start scripts

---------

Co-authored-by: JT2M0L3Y <jtsmoley@icloud.com>
2024-09-05 19:15:44 -07:00
Kevin Dang
2caf54346a Dependency Upgrade (#111)
* update: dependency upgrade

* update: version increment
2024-08-31 15:20:54 -07:00
Jonathan Smoley
6e6467c2a5 Removed GUILD_ID references (#109) 2024-08-03 12:53:24 -07:00
Kevin Dang
b463b0a8cb Deploy Shield (#107)
* Add: Deploy shield

* Update: job name to release
2024-08-03 10:18:40 -07:00
Kevin Dang
42ef38db14 Data Directory on Chat for Preferences (#105)
* Fix: data directory created on for openConfig

* Update: version increment
2024-08-03 09:50:03 -07:00
Kevin Dang
af23db20bb Removed GUILD_ID as an Environment Variable (#103) 2024-08-03 09:48:47 -07:00
Kevin Dang
117b195095 Fixed Incorrect Image Name (#98) 2024-08-01 17:55:42 -07:00
Kevin Dang
b361636a93 Push Docker Image Pipeline (#97)
* Update: run build after merge

* Add: deploy image pipeline to docker

* Update: release pipeline

* Add: release with latest tag

* Update: docker username as vars because its not really a secret

* Remove: workflow bot token and guild_id

* Add: supplimentary guild_id and token

* Update: version increment
2024-08-01 17:46:43 -07:00
Kevin Dang
4dbd45bccd Run Build Pipelines (#96) 2024-08-01 16:15:50 -07:00
Kevin Dang
02ffb6a196 Remove Unnecessary Docker Test Pipeline (#93)
* Remove: container test pipeline

* Update: build pipelines rely on test pipeline

* Fix: typo in build file

* Fix: naming conventions for workflows in yml
2024-07-31 06:20:00 -07:00
Kevin Dang
060494e883 Adjusted Slash Command Scope (#91)
* Update: Slash Command Scope

* Update: version increment
2024-07-31 06:19:23 -07:00
Kevin Dang
352d88ee9d Clear User Channel History Command (#88)
* Add: Clear user channel message history command

* Update: Checks if messages are empty and has clearer replies

* Fix: Issue where duplication happens on channel-toggle true in threads

* Update: version increment

* Fix: Missing test case for commands.test.ts

* Readability fix

---------

Co-authored-by: Jonathan Smoley <67881240+JT2M0L3Y@users.noreply.github.com>
2024-07-25 14:26:50 -07:00
Kevin Dang
e60c2f88b8 Handlers Directory and Universal Import Fix (#86)
* Update: split jsonHandler.ts to different files

* Add: handlers folder and moved some files there

* Update: interface file name
2024-07-23 16:59:54 -07:00
Kevin Dang
b498276978 Dependency Upgrade (#85)
* Update: dependencies upgrade

* Fix: Run tests at root scope
2024-07-23 15:41:16 -07:00
Kevin Dang
ae9cac65a9 PR Template Update (#84)
* Update: version increment and reminder on template

* Update: comment on incrementing as necessary
2024-07-11 17:08:34 -07:00
Kevin Dang
61d3dc4312 User Preferences Fix (#83)
* Fix: incorrect user preferences saving
2024-07-10 20:41:23 -07:00
Kevin Dang
35b9ad71cb User vs Server Preferences (#80)
* Update: Server vs User prefs

* Add: User vs Server Prefs

* Update: version increment

* Fix: src and tests added to validation range
2024-07-04 13:54:25 -07:00
Kevin Dang
7f1326f93e Guide and Documentation Overhaul (#79)
* Update: Local setup

* Update: docker setup changes

* Add: Discord App Creation Guide

* Update: readme changes

* Update: discord app guide link
2024-06-28 21:45:38 -07:00
Kevin Dang
359f46a450 Issue and Pull Request Templates (#78) 2024-06-23 20:37:51 -07:00
Kevin Dang
de15185cff Channel/Thread Chat Toggle (#75)
* Add: Some Commands work in GuildText

* Add: Channel Toggle Command

* Add: Channel History handler by user

* Update: version increment

* Update: Testing scope

* Update: env sample

* Update: Readme goal checks

* Update: builds run on PR to validate them
2024-06-22 20:57:38 -07:00
Kevin Dang
1041f4ca0b Dependencies and Readme Updates (#74) 2024-06-17 19:21:46 -07:00
Kevin Dang
06638fec1f Test Workflow PR Push Rules (#72)
* Add: file changes to ignore

* Fix: proper indentation in yml
2024-06-16 20:49:45 -07:00
Kevin Dang
32b12e93c0 Infinite Message Length for Streamed Messages (#70)
* Add: Infinite Stream messages

* Update: version increment
2024-06-16 18:20:23 -07:00
Kevin Dang
89213c2d39 Removed Ollama API Threads as an Option (#68)
* rm: threads as a chat option

* update: change test Actions name

* Fix: workflows running in correct instance
2024-06-16 15:47:49 -07:00
Kevin Dang
5efc7f00f2 CI Fixes and Testing within PRs (#64) 2024-06-10 20:57:42 -07:00
Kevin Dang
1973b1d3ae Public/Private Chat Threads (#62)
* add: validate thread creation in ollama channel

* rm: channel_id variable

* add: short notes for threads

* update: openFile to openConfig for clarity

* update: test ci runs on master

* add: notes for work

* add: basic chat storing via json

* update: stores entire msgHist according to capacity

* add: removes json file if thread is deleted

* add: chats with independent histories

* add: private vs public threads

* update: validate threads made by ollama for chats

* update: cleanup and version increment
2024-06-10 19:47:08 -07:00
Jonathan Smoley
9f77c5287f Initialize unit testing and code coverage
* add: vitest configs

* added vitest scripts to package

* test coverage of src code

* initial unit testing

* added new testing workflows

* comments added, overlapping tests removed

* decouple env, tests

---------

Co-authored-by: Kevin Dang <kevinthedang_1@outlook.com>
2024-06-05 08:50:56 -07:00
Kevin Dang
496ce43939 Infinite Message Length for Block Messages (#55)
* add: message loop for block messages

* add: infinite message length for block embeds

* update: error message on stream length

* rm: unnecessary import

* update: version increment

* update: embed max length

* update: check off features
2024-04-30 19:33:06 -07:00
Kevin Dang
b5194fa645 Discord Administrator Role Permissions (#54)
* add: admin check for disable

* update: shutoff uses memberPerms now

* rm: superUser env variable

* update: version increment

* rm: admin env in docker and workflow
2024-04-25 11:26:22 -07:00
Kevin Dang
d67106c03e Chat Stream Integration (#52)
* rm: axios dependency

* add: stream parsing for normal style

* fix: empty string problem

* add: stream for embedded prompts

* update: version increment
2024-04-21 14:40:30 -07:00
61 changed files with 3522 additions and 931 deletions

View File

@@ -1,27 +1,18 @@
# Discord token for the bot # Discord token for the bot
CLIENT_TOKEN = BOT_TOKEN CLIENT_TOKEN = BOT_TOKEN
# id token of a discord server
GUILD_ID = GUILD_ID
# Channel where the bot listens to messages
CHANNEL_ID = CHANNEL_ID
# model for the bot to query from (i.e. llama2 [llama2:13b], mistral, codellama, etc... ) # model for the bot to query from (i.e. llama2 [llama2:13b], mistral, codellama, etc... )
MODEL = MODEL_NAME MODEL = MODEL_NAME
# discord bot user id for mentions # discord bot user id for mentions
CLIENT_UID = BOT_USER_ID CLIENT_UID = BOT_USER_ID
# ip/port address of docker container, I use 172.18.X.X 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
OLLAMA_PORT = PORT OLLAMA_PORT = PORT
# ip address for discord bot container, I use 172.18.X.X, use different IP than ollama_ip # ip address for discord bot container, I use 172.18.0.2, use different IP than ollama_ip
DISCORD_IP = IP_ADDRESS DISCORD_IP = IP_ADDRESS
# subnet address, ex. 172.18.0.0 as we use /16. # subnet address, ex. 172.18.0.0 as we use /16.
SUBNET_ADDRESS = ADDRESS SUBNET_ADDRESS = ADDRESS
# list of admins to handle admin commands for the bot, use single quotes
ADMINS=['username1', 'username2', 'username3', ...]

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,38 @@
---
name: Bug report
about: Bug Report for Fixes/Improvements
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,17 @@
---
name: Feature request
about: Suggest Features
title: ''
labels: enhancement
assignees: ''
---
## Issue
A clear and concise description of what the problem/feature is.
## Solution
* Provide steps or ideals to how to implement or investigate this new feature.
## References
* Provide additional context and external references here

19
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,19 @@
## Steps to Creating a Pull Request
* Assign yourself as the **Assignee**
* Allow one of the Code Owners/Maintainers review the changes proposed by the Pull Request.
* Provide appropriate labels as necessary
> [!TIP]
> `enchancement` for new features, `documentation` for modifications to the docs, `performance` for performance related changes, and so on.
* Provide a description of the work that has been done.
* It is nice to know what was added, removed, modified, and with screenshots of those changes.
> [!TIP]
> You can have them under **Added**, **Removed**, **Updates**, and **Screenshots** if any (**Changes** could also be used).
## After the Pull Request is Opened
* One the Pull Request has been created, please add any Issue(s) that are being addressed by this change (if any).
* If the reviewer(s) mention any changes or open threads for questions, please resolve those as soon as you can.
# Ensure you version increment as necessary!!!

View File

@@ -1,84 +0,0 @@
name: Builds
run-name: Validate Node and Docker Builds
on:
push:
branches:
- master
jobs:
Discord-Node-Build: # test if the node install and run
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node Environment v18.18.2
uses: actions/setup-node@v4
with:
node-version: 18.18.2
cache: 'npm'
- name: Install Project Dependencies
run: |
npm install
- name: Build Application
run: |
npm run build
- name: Create Environment Variables
run: |
touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo GUILD_ID = ${{ secrets.GUILD_ID }} >> .env
echo CHANNEL_ID = ${{ secrets.CHANNEL_ID }} >> .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 ADMINS = ${{ secrets.ADMINS }} >> .env
# set -e ensures if nohup fails, this section fails
- name: Startup Discord Bot Client
run: |
set -e
nohup npm run prod &
Discord-Ollama-Container-Build: # test docker build and run
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node Environment v18.18.2
uses: actions/setup-node@v4
with:
node-version: 18.18.2
cache: 'npm'
- name: Create Environment Variables
run: |
touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo GUILD_ID = ${{ secrets.GUILD_ID }} >> .env
echo CHANNEL_ID = ${{ secrets.CHANNEL_ID }} >> .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 ADMINS = ${{ secrets.ADMINS }} >> .env
- name: Setup Docker Network and Images
run: |
npm run docker:start-cpu
- name: Check Images Exist
run: |
(docker images | grep -q 'discord/bot' && docker images | grep -qE 'ollama/ollama') || exit 1
- name: Check Containers Exist
run: |
(docker ps | grep -q 'ollama' && docker ps | grep -q 'discord') || exit 1

77
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,77 @@
name: Builds
run-name: Validate Node and Docker Builds
on:
push:
branches:
- master # runs after Pull Request is merged
jobs:
Discord-Node-Build: # test if the node install and run
runs-on: ubuntu-latest
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: Build Application
run: |
npm run build
- 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
# set -e ensures if nohup fails, this section fails
- name: Startup Discord Bot Client
run: |
set -e
nohup npm run prod &
Discord-Ollama-Container-Build: # test docker build and run
runs-on: ubuntu-latest
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: 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
- name: Setup Docker Network and Images
run: |
npm run docker:start-cpu
- name: Check Images Exist
run: |
(docker images | grep -q 'kevinthedang/discord-ollama' && docker images | grep -qE 'ollama/ollama') || exit 1
- name: Check Containers Exist
run: |
(docker ps | grep -q 'ollama' && docker ps | grep -q 'discord') || exit 1

50
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
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

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

@@ -0,0 +1,49 @@
name: Tests
run-name: Unit Tests
on:
pull_request:
branches:
- master
paths:
- '*'
- 'package*.json'
- 'src/**'
- 'tests/**'
- '!docs/**'
- '!imgs/**'
- '!.github/**'
- '.github/workflows/**'
- '!.gitignore'
- '!LICENSE'
- '!README'
jobs:
Discord-Node-Test:
runs-on: ubuntu-latest
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 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: Test Application
run: |
npm run test:run

View File

@@ -1,5 +1,5 @@
# use node LTS image for version 18 # use node LTS image for version 18
FROM node:18.18.2 FROM node:hydrogen-alpine
# set working directory inside container # set working directory inside container
WORKDIR /app WORKDIR /app

View File

@@ -4,21 +4,28 @@
<h3><a href="#"></a>Ollama as your Discord AI Assistant</h3> <h3><a href="#"></a>Ollama as your Discord AI Assistant</h3>
<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> <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/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-test.yml"><img alt="Build Status" src="https://github.com/kevinthedang/discord-ollama/actions/workflows/build-test.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/test.yml"><img alt="Testing Status" src="https://github.com/kevinthedang/discord-ollama/actions/workflows/test.yml/badge.svg" /></a>
</div> </div>
## About/Goals ## About/Goals
Ollama is an AI model management tool that allows users to install and use custom large language models locally. Ollama is an AI model management tool that allows users to install and use custom large language models locally.
The project aims to: The project aims to:
* [x] Create a Discord bot that will utilize Ollama and chat to chat with users! * [x] Create a Discord bot that will utilize Ollama and chat to chat with users!
* [ ] User Preferences on Chat * [x] User Preferences on Chat
* [ ] Message Persistance on Channels and Threads * [x] Message Persistance on Channels and Threads
* [x] Threads
* [x] Channels
* [x] Containerization with Docker * [x] Containerization with Docker
* [x] Slash Commands Compatible * [x] Slash Commands Compatible
* [x] Generated Token Length Handling for >2000 ~~or >6000 characters~~ * [x] Generated Token Length Handling for >2000
* [ ] Token Length Handling of any message size * [x] Token Length Handling of any message size
* [ ] External WebUI Integration * [x] User vs. Server Preferences
* [ ] Administrator Role Compatible * [ ] 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! * [ ] 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
@@ -27,21 +34,18 @@ The project aims to:
* 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.
* For example, `CLIENT_TOKEN = [Bot Token]` * For example, `CLIENT_TOKEN = [Bot Token]`
* Please refer to the docs for bot setup. **NOTE**: These guides assume you already know how to setup a bot account for discord. * Please refer to the docs for bot setup.
* [Creating a Discord App](./docs/setup-discord-app.md)
* [Local Machine Setup](./docs/setup-local.md) * [Local Machine Setup](./docs/setup-local.md)
* [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.
* Local use is not recommended. * Local use is not recommended.
## Resources ## Resources
* [NodeJS](https://nodejs.org/en) * [NodeJS](https://nodejs.org/en)
* This project uses `v20.10.0+` (npm `10.2.5`). Consider using [nvm](https://github.com/nvm-sh/nvm) for multiple NodeJS versions. * This project runs on `lts\hydrogen`.
* To run dev in `ts-node`, using `v18.18.2` is recommended. **CAUTION**: `v18.19.0` or `lts/hydrogen` will not run properly.
* To run dev with `tsx`, you can use `v20.10.0` or earlier.
* This project supports any NodeJS version above `16.x.x` to only allow ESModules. * This project supports any NodeJS version above `16.x.x` to only allow ESModules.
* [Ollama](https://ollama.ai/) * [Ollama](https://ollama.com/)
* [Ollama Docker Image](https://hub.docker.com/r/ollama/ollama) * [Ollama Docker Image](https://hub.docker.com/r/ollama/ollama)
* **IMPORTANT**: For Nvidia GPU setup, **install** `nvidia container toolkit/runtime` then **configure** it with Docker to utilize Nvidia driver.
* [Discord Developer Portal](https://discord.com/developers/docs/intro)
* [Discord.js Docs](https://discord.js.org/docs/packages/discord.js/main) * [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 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) * [Setting up Nvidia Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)

View File

@@ -1,5 +1,4 @@
# creates the docker compose # creates the docker compose
version: '3.7'
# build individual services # build individual services
services: services:
@@ -8,16 +7,13 @@ 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: discord/bot:0.4.0 image: kevinthedang/discord-ollama:0.6.0
environment: environment:
CLIENT_TOKEN: ${CLIENT_TOKEN} CLIENT_TOKEN: ${CLIENT_TOKEN}
GUILD_ID: ${GUILD_ID}
CHANNEL_ID: ${CHANNEL_ID}
MODEL: ${MODEL} MODEL: ${MODEL}
CLIENT_UID: ${CLIENT_UID} CLIENT_UID: ${CLIENT_UID}
OLLAMA_IP: ${OLLAMA_IP} OLLAMA_IP: ${OLLAMA_IP}
OLLAMA_PORT: ${OLLAMA_PORT} OLLAMA_PORT: ${OLLAMA_PORT}
ADMINS: ${ADMINS}
networks: networks:
ollama-net: ollama-net:
ipv4_address: ${DISCORD_IP} ipv4_address: ${DISCORD_IP}
@@ -41,6 +37,8 @@ services:
ports: ports:
- ${OLLAMA_PORT}:${OLLAMA_PORT} - ${OLLAMA_PORT}:${OLLAMA_PORT}
# Put Redis Container here?
# create a network that supports giving addresses withing a specific subnet # create a network that supports giving addresses withing a specific subnet
networks: networks:
ollama-net: ollama-net:

47
docs/setup-discord-app.md Normal file
View File

@@ -0,0 +1,47 @@
## Discord App/Bot Setup
* Refer to the [Discord Developers](https://discord.com/build/app-developers) tab on their site.
* Click on **Getting Started** and it may prompt you to log in. Do that.
* You should see this upon logging in.
![First App!](../imgs/tutorial/discord-dev.png)
* Click on **Create App**, you should not be prompted to create an App with a name. If you are apart of a team, you may choose to create it for your team or for yourself.
![App Create Modal](../imgs/tutorial/create-app.png)
* Great! Not you should have your App created. It should bring you to a page like this.
![Created App](../imgs/tutorial/created-app.png)
* From here, you will need you App's token, navigate to the **Bot** tab and click **Reset Token** to generate a new token to interact with you bot.
* The following app will not exist, usage of this token will be pointless as this is a guide.
![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.
![Scope](../imgs/tutorial/scope.png)
![Invite Link](../imgs/tutorial/invite.png)
* Notice that your App's **Client Id** is apart of the **Install Link**.
* Paste this link in a web browser and you should see something like this.
![Server Invite Initial](../imgs/tutorial/server-invite-1.png)
* Click **Add to Server** and you should see this.
![Server Invite Auth](../imgs/tutorial/server-invite-2-auth.png)
* Choose a server to add the App to, then click **Continue** then **Authorize**. You should see this after that.
![Invite Success](../imgs/tutorial/server-invite-3.png)
* Congratulations! You should now see you App on your server!
![Its Alive!](../imgs/tutorial/bot-in-server.png)

View File

@@ -47,6 +47,7 @@ sudo systemctl restart docker
* `DISCORD_IP = 172.18.0.3` * `DISCORD_IP = 172.18.0.3`
* `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 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. * 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. * 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.
@@ -54,7 +55,8 @@ sudo systemctl restart docker
* `docker compose stop` * `docker compose stop`
* `docker compose rm` * `docker compose rm`
* `docker ps` to check if containers have been removed. * `docker ps` to check if containers have been removed.
* You can also use `npm run clean` to clean up the containers and remove the network to address a possible `Address already in use` problem. * This may not work if the nvidia installation was done incorrectly. If this is the case, please utilize the [Manual "Clean-up"](#manual-run-with-docker) shown below.
* You can also use `npm run clean` to clean up the containers and remove the network to address a possible `Address already in use` problem. This script does not have to end without error to work.
## Manual Run (with Docker) ## Manual Run (with Docker)
* Run the following commands: * Run the following commands:

View File

@@ -1,19 +1,24 @@
## Ollama Setup ## Ollama Setup
* Go to Ollama's [Linux download page](https://ollama.ai/download/linux) and run the simple curl command they provide. The command should be `curl https://ollama.ai/install.sh | sh`. * Go to Ollama's [Linux download page](https://ollama.ai/download/linux) and run the simple curl command they provide. The command should be `curl https://ollama.ai/install.sh | sh`.
* Now the the following commands in separate terminals to test out how it works! * Since Ollama will run as a systemd service, there is no need to run `ollama serve` unless you disable it. If you do disable it or have an older `ollama` version, do the following:
* In terminal 1 -> `ollama serve` to setup ollama * In terminal 1 -> `ollama serve` to setup ollama
* In terminal 2 -> `ollama run [model name]`, for example `ollama run llama2` * In terminal 2 -> `ollama run [model name]`, for example `ollama run llama2`
* The models can vary as you can create your own model. You can also view ollama's [library](https://ollama.ai/library) of models. * The models can vary as you can create your own model. You can also view ollama's [library](https://ollama.ai/library) of models.
* If there are any issues running ollama because of missing LLMs, run `ollama pull [model name]` as it will pull the model if Ollama has it in their library. * Otherwise, if you have the latest `ollama`, you can just run `ollama run [model name]` rather than running this in 2 terminals.
* If there are any issues running ollama because of missing LLMs, run `ollama pull [model name]` as it will pull the model if Ollama has it in their library.
* This can also be done in [wsl](https://learn.microsoft.com/en-us/windows/wsl/install) for Windows machines. * This can also be done in [wsl](https://learn.microsoft.com/en-us/windows/wsl/install) for Windows machines.
* This should also not be a problem is a future feature that allows for pulling of models via discord client. For now, they must be pulled manually.
* 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!
## 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`.
* 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.
* Open up a separate terminal/shell (you will need wsl for this if on windows) and run `ollama serve` to startup ollama. * 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 will need to run `ollama pull [model name]` in a separate terminal to get it.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
imgs/tutorial/client-id.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
imgs/tutorial/invite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

BIN
imgs/tutorial/scope.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

BIN
imgs/tutorial/token.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

View File

@@ -1,12 +0,0 @@
{
"restartable": "rs",
"ignore": ["node_modules/"],
"watch": ["src/"],
"execMap": {
"ts": "ts-node --esm"
},
"env": {
"NODE_ENV": "development"
},
"ext": "js,json,ts"
}

2778
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +1,42 @@
{ {
"name": "discord-ollama", "name": "discord-ollama",
"version": "0.4.0", "version": "0.6.0",
"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",
"scripts": { "scripts": {
"dev-tsx": "tsx watch src/index.ts", "test:run": "vitest run",
"dev-mon": "nodemon --config nodemon.json src/index.ts", "test:coverage": "vitest run --coverage",
"watch": "tsx watch src",
"build": "tsc", "build": "tsc",
"prod": "node .", "prod": "node .",
"client": "npm run build && npm run prod", "client": "npm run build && npm run prod",
"clean": "docker compose down && docker rmi $(docker images | grep $(node -p \"require('./package.json').version\") | 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 kevinthedang | 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", "start": "docker compose build --no-cache && docker compose up -d",
"docker:start": "npm run docker:network && npm run docker:build && npm run docker:client && npm run docker:ollama", "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:start-cpu": "npm run docker:network && npm run docker:build && npm run docker:client && npm run docker:ollama-cpu",
"docker:clean": "docker rmi $(docker images --filter \"dangling=true\" -q --no-trunc)",
"docker:network": "docker network create --subnet=172.18.0.0/16 ollama-net", "docker:network": "docker network create --subnet=172.18.0.0/16 ollama-net",
"docker:build": "docker build --no-cache -t discord/bot:$(node -p \"require('./package.json').version\") .", "docker:build": "docker build --no-cache -t kevinthedang/discord-ollama:$(node -p \"require('./package.json').version\") .",
"docker:client": "docker run -d -v discord:/src/app --name discord --network ollama-net --ip 172.18.0.3 discord/bot:$(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: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": "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: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"
}, },
"author": "Kevin Dang", "author": "Kevin Dang",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"axios": "^1.6.2", "discord.js": "^14.15.3",
"discord.js": "^14.14.1", "dotenv": "^16.4.5",
"dotenv": "^16.3.1", "ollama": "^0.5.8"
"ollama": "^0.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.10.5", "@types/node": "^22.5.1",
"nodemon": "^3.0.2", "@vitest/coverage-v8": "^2.0.5",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.6.2", "tsx": "^4.19.0",
"typescript": "^5.3.3" "typescript": "^5.5.4",
"vitest": "^2.0.5"
}, },
"type": "module", "type": "module",
"engines": { "engines": {

View File

@@ -45,5 +45,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,6 +1,6 @@
import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js' import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { SlashCommand } from '../utils/commands.js' import { SlashCommand } from '../utils/commands.js'
import { openFile } from '../utils/jsonHandler.js' import { openConfig } from '../utils/index.js'
export const Capacity: SlashCommand = { export const Capacity: SlashCommand = {
name: 'modify-capacity', name: 'modify-capacity',
@@ -20,10 +20,10 @@ 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.GuildText) return if (!channel || channel.type !== (ChannelType.PrivateThread && ChannelType.PublicThread && ChannelType.GuildText)) return
// set state of bot chat features // set state of bot chat features
openFile('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: `Message History Capacity has been set to \`${interaction.options.get('context-capacity')?.value}\``,

View File

@@ -0,0 +1,34 @@
import { ChannelType, Client, CommandInteraction, TextChannel } from 'discord.js'
import { SlashCommand } from '../utils/commands.js'
import { clearChannelInfo } from '../utils/index.js'
export const ClearUserChannelHistory: SlashCommand = {
name: 'clear-user-channel-history',
description: 'clears history for user running this command in 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)
// if not an existing channel or a GuildText, fail command
if (!channel || channel.type !== ChannelType.GuildText) return
// clear channel info for user
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
})
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
})
}
}

View File

@@ -1,10 +1,10 @@
import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js' import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { SlashCommand } from '../utils/commands.js' import { SlashCommand } from '../utils/commands.js'
import { openFile } from '../utils/jsonHandler.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, slash commands will still work.', 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: [
@@ -22,8 +22,17 @@ export const Disable: SlashCommand = {
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 || channel.type !== ChannelType.GuildText) 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
})
return
}
// set state of bot chat features // set state of bot chat features
openFile('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: `Chat features has been \`${interaction.options.get('enabled')?.value ? "enabled" : "disabled" }\``,

View File

@@ -5,12 +5,16 @@ 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 { ClearUserChannelHistory } from './cleanUserChannelHistory.js'
export default [ export default [
ThreadCreate, ThreadCreate,
PrivateThreadCreate,
MessageStyle, MessageStyle,
MessageStream, MessageStream,
Disable, Disable,
Shutoff, Shutoff,
Capacity Capacity,
ClearUserChannelHistory
] as SlashCommand[] ] as SlashCommand[]

View File

@@ -1,6 +1,6 @@
import { ApplicationCommandOptionType, ChannelType, Client, CommandInteraction } from 'discord.js' import { ApplicationCommandOptionType, ChannelType, Client, CommandInteraction } from 'discord.js'
import { SlashCommand } from '../utils/commands.js' import { SlashCommand } from '../utils/commands.js'
import { openFile } from '../utils/jsonHandler.js' import { openConfig } from '../utils/index.js'
export const MessageStream: SlashCommand = { export const MessageStream: SlashCommand = {
name: 'message-stream', name: 'message-stream',
@@ -20,13 +20,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.GuildText) return if (!channel || channel.type !== (ChannelType.PrivateThread && ChannelType.PublicThread && ChannelType.GuildText)) return
// save value to json and write to it // save value to json and write to it
openFile('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 for embed set to: \`${interaction.options.get('stream')?.value}\``, content: `Message streaming preferences set to: \`${interaction.options.get('stream')?.value}\``,
ephemeral: true ephemeral: true
}) })
} }

View File

@@ -1,6 +1,6 @@
import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js' import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { SlashCommand } from '../utils/commands.js' import { SlashCommand } from '../utils/commands.js'
import { openFile } from '../utils/jsonHandler.js' import { openConfig } from '../utils/index.js'
export const MessageStyle: SlashCommand = { export const MessageStyle: SlashCommand = {
name: 'message-style', name: 'message-style',
@@ -20,10 +20,10 @@ export const MessageStyle: 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 || channel.type !== (ChannelType.PrivateThread && ChannelType.PublicThread && ChannelType.GuildText)) return
// set the message style // set the message style
openFile('config.json', interaction.commandName, interaction.options.get('embed')?.value) openConfig(`${interaction.user.username}-config.json`, interaction.commandName, interaction.options.get('embed')?.value)
interaction.reply({ interaction.reply({
content: `Message style preferences for embed set to: \`${interaction.options.get('embed')?.value}\``, content: `Message style preferences for embed set to: \`${interaction.options.get('embed')?.value}\``,

View File

@@ -1,10 +1,9 @@
import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js' import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { SlashCommand } from '../utils/commands.js' import { SlashCommand } from '../utils/commands.js'
import Keys from '../keys.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.', description: 'shutdown the bot. You will need to manually bring it online again. Administrator Only.',
// set available user options to pass to the command // set available user options to pass to the command
options: [ options: [
@@ -25,24 +24,22 @@ export const Shutoff: SlashCommand = {
// 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(`User -> ${interaction.user.tag} attempting to shutdown ${client.user!!.tag}`)
// create list of superUsers based on string parse
const superUsers: string[] = JSON.parse(Keys.superUser.replace(/'/g, '"'))
// check if admin or false on shutdown // check if admin or false on shutdown
if (interaction.user.tag !in superUsers) { if (!interaction.memberPermissions?.has('Administrator')) {
interaction.reply({ interaction.reply({
content: `Shutdown failed:\n\n${interaction.user.tag}, You do not have permission to shutoff **${client.user?.tag}**.`, content: `**Shutdown Aborted:**\n\n${interaction.user.tag}, You do not have permission to shutoff **${client.user?.tag}**.`,
ephemeral: true ephemeral: true
}) })
return // stop from shutting down return // stop from shutting down
} else if (!interaction.options.get('are-you-sure')?.value) { } else if (!interaction.options.get('are-you-sure')?.value) {
interaction.reply({ interaction.reply({
content: `Shutdown failed:\n\n${interaction.user.tag}, You didn't want to shutoff **${client.user?.tag}**.`, content: `**Shutdown Aborted:**\n\n${interaction.user.tag}, You didn't want to shutoff **${client.user?.tag}**.`,
ephemeral: true ephemeral: true
}) })
return return // chickened out
} }
// 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 ${interaction.options.get('are-you-sure')?.value ? "shutting down now." : "not shutting down." }`,
ephemeral: true ephemeral: true

View File

@@ -1,5 +1,6 @@
import { ChannelType, Client, CommandInteraction, TextChannel } from 'discord.js' import { ChannelType, Client, CommandInteraction, TextChannel, ThreadChannel } from 'discord.js'
import { SlashCommand } from '../utils/commands.js' import { SlashCommand } from '../utils/commands.js'
import { openChannelInfo } from '../utils/index.js'
export const ThreadCreate: SlashCommand = { export const ThreadCreate: SlashCommand = {
name: 'thread', name: 'thread',
@@ -12,16 +13,22 @@ export const ThreadCreate: SlashCommand = {
if (!channel || channel.type !== ChannelType.GuildText) return if (!channel || channel.type !== ChannelType.GuildText) return
const thread = await (channel as TextChannel).threads.create({ const thread = await (channel as TextChannel).threads.create({
name: `support-${Date.now()}`, name: `${client.user?.username}-support-${Date.now()}`,
reason: `Support ticket ${Date.now()}` reason: `Support ticket ${Date.now()}`,
type: ChannelType.PublicThread
}) })
// Send a message in the thread // Send a message in the thread
thread.send(`**User:** ${interaction.user} \n**People in Coversation:** ${thread.memberCount}`) 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.`)
// handle storing this chat channel
openChannelInfo(thread.id,
thread as ThreadChannel,
interaction.user.tag)
// user only reply // user only reply
return interaction.reply({ return interaction.reply({
content: `I can help you in the Thread below. \n**Thread ID:** ${thread.id}`, content: `I can help you in thread **${thread.id}** below.`,
ephemeral: true ephemeral: true
}) })
} }

View File

@@ -0,0 +1,37 @@
import { ChannelType, Client, CommandInteraction, TextChannel, ThreadChannel } from 'discord.js'
import { SlashCommand } from '../utils/commands.js'
import { openChannelInfo } from '../utils/index.js'
export const PrivateThreadCreate: SlashCommand = {
name: 'private-thread',
description: 'creates a private thread and mentions user',
// Query for server information
run: async (client: Client, interaction: CommandInteraction) => {
// fetch the channel
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || channel.type !== ChannelType.GuildText) return
const thread = await (channel as TextChannel).threads.create({
name: `${client.user?.username}-private-support-${Date.now()}`,
reason: `Support ticket ${Date.now()}`,
type: ChannelType.PrivateThread
})
// Send a message in the thread
thread.send(`Hello ${interaction.user}! \n\nIt's nice to meet you. Please talk to me by typing @${client.user?.username} with your prompt.`)
// handle storing this chat channel
// store: thread.id, thread.name
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
})
}
}

View File

@@ -2,10 +2,12 @@ import { Event } from '../utils/index.js'
import interactionCreate from './interactionCreate.js' import interactionCreate from './interactionCreate.js'
import messageCreate from './messageCreate.js' import messageCreate from './messageCreate.js'
import ready from './ready.js' import ready from './ready.js'
import threadDelete from './threadDelete.js'
// Centralized export for all events // Centralized export for all events
export default [ export default [
ready, ready,
messageCreate, messageCreate,
interactionCreate interactionCreate,
threadDelete
] as Event[] // staticly is better ts practice, dynamic exporting is possible ] as Event[] // staticly is better ts practice, dynamic exporting is possible

View File

@@ -1,31 +1,35 @@
import { ChatResponse } from 'ollama' import { embedMessage, event, Events, normalMessage, UserMessage } from '../utils/index.js'
import { embedMessage, event, Events, normalMessage } from '../utils/index.js' import { getChannelInfo, getServerConfig, getUserConfig, openChannelInfo, openConfig, UserConfig, getAttachmentData } from '../utils/index.js'
import { Configuration, getConfig, openFile } from '../utils/jsonHandler.js'
import { clean } from '../utils/mentionClean.js' import { clean } from '../utils/mentionClean.js'
import { TextChannel } from 'discord.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).
* Bot supports infinite lengths for normal messages.
*
* @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, tokens, ollama }, message) => {
log(`Message \"${clean(message.content)}\" from ${message.author.tag} in channel/thread ${message.channelId}.`) log(`Message \"${clean(message.content)}\" from ${message.author.tag} in channel/thread ${message.channelId}.`)
// Hard-coded channel to test output there only, in our case "ollama-endpoint"
if (message.channelId != tokens.channel) return
// Do not respond if bot talks in the chat // Do not respond if bot talks in the chat
if (message.author.tag === message.client.user.tag) 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(tokens.clientUid)) return
// Try to query and send embed // default stream to false
let shouldStream = false
try { try {
const config: Configuration = await new Promise((resolve, reject) => { // Retrieve Server/Guild Preferences
getConfig('config.json', (config) => { await new Promise((resolve, reject) => {
getServerConfig(`${message.guildId}-config.json`, (config) => {
// check if config.json exists // check if config.json exists
if (config === undefined) { if (config === undefined) {
reject(new Error('No Configuration is set up.\n\nCreating \`config.json\` with \`message-style\` set as \`false\` for regular messages.\nPlease try chatting again.')) // 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 return
} }
@@ -35,6 +39,19 @@ export default event(Events.MessageCreate, async ({ log, msgHist, tokens, ollama
return 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 // check if there is a set capacity in config
if (typeof config.options['modify-capacity'] !== 'number') if (typeof config.options['modify-capacity'] !== 'number')
log(`Capacity is undefined, using default capacity of ${msgHist.capacity}.`) log(`Capacity is undefined, using default capacity of ${msgHist.capacity}.`)
@@ -44,12 +61,52 @@ export default event(Events.MessageCreate, async ({ log, msgHist, tokens, ollama
log(`New Capacity found. Setting Context Capacity to ${config.options['modify-capacity']}.`) log(`New Capacity found. Setting Context Capacity to ${config.options['modify-capacity']}.`)
msgHist.capacity = config.options['modify-capacity'] msgHist.capacity = config.options['modify-capacity']
} }
// set stream state
shouldStream = config.options['message-stream'] as boolean || false
resolve(config) resolve(config)
}) })
}) })
let response: ChatResponse // 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.`))
}
})
})
}
// response string for ollama to put its response
let response: string
// get message attachment if exists
const messageAttachment: string[] = await getAttachmentData(message.attachments.first())
// set up new queue
msgHist.setQueue(chatMessages)
// check if we can push, if not, remove oldest // check if we can push, if not, remove oldest
while (msgHist.size() >= msgHist.capacity) msgHist.dequeue() while (msgHist.size() >= msgHist.capacity) msgHist.dequeue()
@@ -57,14 +114,15 @@ 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: clean(message.content),
images: messageAttachment || []
}) })
// undefined or false, use normal, otherwise use embed // undefined or false, use normal, otherwise use embed
if (config.options['message-style']) if (userConfig.options['message-style'])
response = await embedMessage(message, ollama, tokens, msgHist) response = await embedMessage(message, ollama, tokens, msgHist, shouldStream)
else else
response = await normalMessage(message, ollama, tokens, msgHist) 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 }
@@ -75,11 +133,18 @@ 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.message.content content: response,
images: messageAttachment || []
}) })
// only update the json on success
openChannelInfo(message.channelId,
message.channel as TextChannel,
message.author.tag,
msgHist.getItems()
)
} catch (error: any) { } catch (error: any) {
msgHist.pop() // remove message because of failure msgHist.pop() // remove message because of failure
openFile('config.json', 'message-style', false)
message.reply(`**Error Occurred:**\n\n**Reason:** *${error.message}*`) message.reply(`**Error Occurred:**\n\n**Reason:** *${error.message}*`)
} }
}) })

View File

@@ -0,0 +1,40 @@
import { ThreadChannel } from 'discord.js'
import { event, Events } from '../utils/index.js'
import fs from 'fs'
/**
* Event to remove the associated .json file for a thread once deleted
*/
export default event(Events.ThreadDelete, async ({ log }, thread: ThreadChannel) => {
// iterate through every guild member in the thread and delete their history, except the bot
try {
log(`Number of User Guild Members in Thread being deleted: ${thread.memberCount!! - 1}`)
const dirPath = 'data/'
// read all files in data/
fs.readdir(dirPath, (error, files) => {
if (error) {
log(`Error reading directory ${dirPath}`, error)
return
}
// filter files by thread id being deleted
const filesToDiscard = files.filter(
file => file.startsWith(`${thread.id}-`) &&
file.endsWith('.json'))
// remove files by unlinking
filesToDiscard.forEach(file => {
const filePath = dirPath + file
fs.unlink(filePath, error => {
if (error)
log(`Error deleting file ${filePath}`, error)
else
log(`Successfully deleted ${filePath} thread information`)
})
})
})
} catch (error) {
log(`Issue deleting user history files from ${thread.id}`)
}
})

View File

@@ -2,13 +2,10 @@ import { getEnvVar } from './utils/env.js'
export const Keys = { export const Keys = {
clientToken: getEnvVar('CLIENT_TOKEN'), clientToken: getEnvVar('CLIENT_TOKEN'),
channel: getEnvVar('CHANNEL_ID'),
model: getEnvVar('MODEL'), model: getEnvVar('MODEL'),
clientUid: getEnvVar('CLIENT_UID'), clientUid: getEnvVar('CLIENT_UID'),
guildId: getEnvVar('GUILD_ID'),
ipAddress: getEnvVar('OLLAMA_IP'), ipAddress: getEnvVar('OLLAMA_IP'),
portAddress: getEnvVar('OLLAMA_PORT'), portAddress: getEnvVar('OLLAMA_PORT'),
superUser: getEnvVar('ADMINS')
} as const // readonly keys } as const // readonly keys
export default Keys export default Keys

View File

@@ -53,10 +53,18 @@ export class Queue<T> implements IQueue<T> {
} }
/** /**
* Geet the queue as an array * Get the queue as an array
* @returns a array of T items * @returns a array of T items
*/ */
getItems(): T[] { getItems(): T[] {
return this.storage return this.storage
} }
/**
* Set a new queue to modify
* @param newQueue new queue of T[] to modify
*/
setQueue(newQueue: T[]): void {
this.storage = newQueue
}
} }

View File

@@ -0,0 +1,51 @@
import { UserMessage } from './events.js'
export interface UserConfiguration {
'message-stream'?: boolean,
'message-style'?: boolean,
'modify-capacity': number
}
export interface ServerConfiguration {
'toggle-chat'?: boolean,
}
/**
* Parent Configuration interface
*
* @see ServerConfiguration server settings per guild
* @see UserConfiguration user configurations (only for the user for any server)
*/
export interface Configuration {
readonly name: string
options: UserConfiguration | ServerConfiguration
}
/**
* User config to use outside of this file
*/
export interface UserConfig {
readonly name: string
options: UserConfiguration
}
export interface ServerConfig {
readonly name: string
options: ServerConfiguration
}
export interface Channel {
readonly id: string
readonly name: string
readonly user: string
messages: UserMessage[]
}
/**
* Check if the configuration we are editing/taking from is a Server Config
* @param key name of command we ran
* @returns true if command is from Server Config, false otherwise
*/
export function isServerConfigurationKey(key: string): key is keyof ServerConfiguration {
return ['toggle-chat'].includes(key);
}

View File

@@ -15,11 +15,22 @@ export type EventKeys = keyof ClientEvents // only wants keys of ClientEvents ob
* @param clientUid the discord id for the bot * @param clientUid the discord id for the bot
*/ */
export type Tokens = { export type Tokens = {
channel: string,
model: string, model: string,
clientUid: string clientUid: string
} }
/**
* Parameters to run the chat query
* @param model the model to run
* @param ollama ollama api client
* @param msgHist message history
*/
export type ChatParams = {
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 when the bot
* @param role either assistant, user, or system * @param role either assistant, user, or system
@@ -27,7 +38,8 @@ export type Tokens = {
*/ */
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

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

@@ -0,0 +1,115 @@
import { TextChannel, ThreadChannel } from 'discord.js'
import { Configuration, Channel, UserMessage } from '../index.js'
import fs from 'fs'
import path from 'path'
/**
* Method to check if a thread history file exists
*
* @param channel parent thread of the requested thread (can be GuildText)
* @returns true if channel does not exist, false otherwise
*/
async function checkChannelInfoExists(channel: TextChannel, user: string) {
const doesExists: boolean = await new Promise((resolve) => {
getChannelInfo(`${channel.id}-${user}.json`, (channelInfo) => {
if (channelInfo?.messages)
resolve(true)
else
resolve(false)
})
})
return doesExists
}
/**
* Method to clear channel history for requesting user
*
* @param filename guild id string
* @param channel the TextChannel in the Guild
* @param user username of user
* @returns nothing
*/
export async function clearChannelInfo(filename: string, channel: TextChannel, user: string): Promise<boolean> {
const channelInfoExists: boolean = await checkChannelInfoExists(channel, user)
// If thread does not exist, file can't be found
if (!channelInfoExists) return false
// Attempt to clear user channel history
const fullFileName = `data/${filename}-${user}.json`
const cleanedHistory: boolean = await new Promise((resolve) => {
fs.readFile(fullFileName, 'utf8', (error, data) => {
if (error)
console.log(`[Error: openChannelInfo] Incorrect file format`)
else {
const object = JSON.parse(data)
if (object['messages'].length === 0) // already empty, let user know
resolve(false)
else {
object['messages'] = [] // cleared history
fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2))
resolve(true)
}
}
})
})
console.log(cleanedHistory)
return cleanedHistory
}
/**
* Method to open the channel history
*
* @param filename name of the json file for the channel by user
* @param channel the text channel info
* @param user the user's name
* @param messages their messages
*/
export async function openChannelInfo(filename: string, channel: TextChannel | ThreadChannel, user: string, messages: UserMessage[] = []): Promise<void> {
const fullFileName = `data/${filename}-${user}.json`
if (fs.existsSync(fullFileName)) {
fs.readFile(fullFileName, 'utf8', (error, data) => {
if (error)
console.log(`[Error: openChannelInfo] Incorrect file format`)
else {
const object = JSON.parse(data)
if (object['messages'].length === 0)
object['messages'] = messages as []
else if (object['messages'].length !== 0 && messages.length !== 0)
object['messages'] = messages as []
fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2))
}
})
} else { // file doesn't exist, create it
const object: Configuration = JSON.parse(`{ \"id\": \"${channel?.id}\", \"name\": \"${channel?.name}\", \"user\": \"${user}\", \"messages\": []}`)
const directory = path.dirname(fullFileName)
if (!fs.existsSync(directory))
fs.mkdirSync(directory, { recursive: true })
// only creating it, no need to add anything
fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2))
console.log(`[Util: openChannelInfo] Created '${fullFileName}' in working directory`)
}
}
/**
* Method to get the channel information/history
*
* @param filename name of the json file for the channel by user
* @param callback function to handle resolving message history
*/
export async function getChannelInfo(filename: string, callback: (config: Channel | undefined) => void): Promise<void> {
const fullFileName = `data/${filename}`
if (fs.existsSync(fullFileName)) {
fs.readFile(fullFileName, 'utf8', (error, data) => {
if (error) {
callback(undefined)
return // something went wrong... stop
}
callback(JSON.parse(data))
})
} else {
callback(undefined) // file not found
}
}

View File

@@ -0,0 +1,92 @@
import { Configuration, ServerConfig, UserConfig, isServerConfigurationKey } from '../index.js'
import fs from 'fs'
import path from 'path'
/**
* Method to open a file in the working directory and modify/create it
*
* @param filename name of the file
* @param key key value to access
* @param value new value to assign
*/
// 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) => {
if (error)
console.log(`[Error: openConfig] Incorrect file format`)
else {
const object = JSON.parse(data)
object['options'][key] = value
fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2))
}
})
} else { // work on dynamic file creation
let object: Configuration
if (isServerConfigurationKey(key))
object = JSON.parse('{ \"name\": \"Server Confirgurations\" }')
else
object = JSON.parse('{ \"name\": \"User Confirgurations\" }')
// set standard information for config file and options
object['options'] = {
[key]: value
}
const directory = path.dirname(fullFileName)
if (!fs.existsSync(directory))
fs.mkdirSync(directory, { recursive: true })
fs.writeFileSync(`data/${filename}`, JSON.stringify(object, null, 2))
console.log(`[Util: openConfig] Created '${filename}' in working directory`)
}
}
/**
* Method to obtain the configurations of the message chat/thread
*
* @param filename name of the configuration file to get
* @param callback function to allow a promise from getting the config
*/
export async function getServerConfig(filename: string, callback: (config: ServerConfig | undefined) => void): Promise<void> {
const fullFileName = `data/${filename}`
// attempt to read the file and get the configuration
if (fs.existsSync(fullFileName)) {
fs.readFile(fullFileName, 'utf8', (error, data) => {
if (error) {
callback(undefined)
return // something went wrong... stop
}
callback(JSON.parse(data))
})
} else {
callback(undefined) // file not found
}
}
/**
* Method to obtain the configurations of the message chat/thread
*
* @param filename name of the configuration file to get
* @param callback function to allow a promise from getting the config
*/
export async function getUserConfig(filename: string, callback: (config: UserConfig | undefined) => void): Promise<void> {
const fullFileName = `data/${filename}`
// attempt to read the file and get the configuration
if (fs.existsSync(fullFileName)) {
fs.readFile(fullFileName, 'utf8', (error, data) => {
if (error) {
callback(undefined)
return // something went wrong... stop
}
callback(JSON.parse(data))
})
} else {
callback(undefined) // file not found
}
}

View File

@@ -0,0 +1,39 @@
import { ChatResponse } from "ollama"
import { ChatParams } from "../index.js"
import { AbortableAsyncIterator } from "ollama/src/utils.js"
/**
* Method to query the Ollama client for async generation
* @param params
* @returns Asyn
*/
export async function streamResponse(params: ChatParams): Promise<AbortableAsyncIterator<ChatResponse>> {
return await params.ollama.chat({
model: params.model,
messages: params.msgHist,
options: {
mirostat: 1,
mirostat_tau: 2.0,
top_k: 70
},
stream: true
}) as unknown as AbortableAsyncIterator<ChatResponse>
}
/**
* Method to query the Ollama client for a block response
* @param params parameters to query the client
* @returns ChatResponse generated by the Ollama client
*/
export async function blockResponse(params: ChatParams): Promise<ChatResponse> {
return await params.ollama.chat({
model: params.model,
messages: params.msgHist,
options: {
mirostat: 1,
mirostat_tau: 2.0,
top_k: 70
},
stream: false
})
}

View File

@@ -3,4 +3,11 @@ export * from './env.js'
export * from './events.js' export * from './events.js'
export * from './messageEmbed.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'
// handler imports
export * from './handlers/chatHistoryHandler.js'
export * from './handlers/configHandler.js'
export * from './handlers/streamHandler.js'
export * from './handlers/bufferHandler.js'

View File

@@ -1,58 +0,0 @@
import fs from 'fs'
export interface Configuration {
readonly name: string
options: {
'message-stream'?: boolean,
'message-style'?: boolean,
'toggle-chat'?: boolean,
'modify-capacity'?: number
}
}
/**
* Method to open a file in the working directory and modify/create it
*
* @param filename name of the file
* @param key key value to access
* @param value new value to assign
*/
export function openFile(filename: string, key: string, value: any) {
// check if the file exists, if not then make the config file
if (fs.existsSync(filename)) {
fs.readFile(filename, 'utf8', (error, data) => {
if (error)
console.log(`[Error: openFile] Incorrect file format`)
else {
const object = JSON.parse(data)
object['options'][key] = value
fs.writeFileSync(filename, JSON.stringify(object, null, 2))
}
})
} else {
const object: Configuration = JSON.parse('{ \"name\": \"Discord Ollama Confirgurations\" }')
// set standard information for config file and options
object['options'] = {
[key]: value
}
fs.writeFileSync(filename, JSON.stringify(object, null, 2))
console.log(`[Util: openFile] Created 'config.json' in working directory`)
}
}
export async function getConfig(filename: string, callback: (config: Configuration | undefined) => void): Promise<void> {
// attempt to read the file and get the configuration
if (fs.existsSync(filename)) {
fs.readFile(filename, 'utf8', (error, data) => {
if (error) {
callback(undefined)
return // something went wrong... stop
}
callback(JSON.parse(data))
})
} else {
callback(undefined) // file not found
}
}

View File

@@ -1,7 +1,8 @@
import { EmbedBuilder, Message } from 'discord.js' import { EmbedBuilder, Message } from 'discord.js'
import { ChatResponse, Ollama } from 'ollama' import { ChatResponse, Ollama } from 'ollama'
import { UserMessage } from './events.js' import { ChatParams, UserMessage, streamResponse, blockResponse } from './index.js'
import { Queue } from '../queues/queue.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 * Method to send replies as normal text on discord like any other user
@@ -13,13 +14,14 @@ export async function embedMessage(
message: Message, message: Message,
ollama: Ollama, ollama: Ollama,
tokens: { tokens: {
channel: string,
model: string model: string
}, },
msgHist: Queue<UserMessage> msgHist: Queue<UserMessage>,
) { stream: boolean
): Promise<string> {
// bot response // bot response
let response: ChatResponse let response: ChatResponse | AbortableAsyncIterator<ChatResponse>
let result: string = ''
// initial message to client // initial message to client
const botMessage = new EmbedBuilder() const botMessage = new EmbedBuilder()
@@ -30,28 +32,88 @@ export async function embedMessage(
// send the message // send the message
const sentMessage = await message.channel.send({ embeds: [botMessage] }) const sentMessage = await message.channel.send({ embeds: [botMessage] })
// create params
const params: ChatParams = {
model: tokens.model,
ollama: ollama,
msgHist: msgHist.getItems()
}
try { try {
// Attempt to query model for message // check if embed needs to stream
response = await ollama.chat({ if (stream) {
model: tokens.model, response = await streamResponse(params)
messages: msgHist.getItems(),
options: {
num_thread: 8, // remove if optimization needed further
mirostat: 1,
mirostat_tau: 2.0,
top_k: 70
},
stream: false
})
// dummy message to let user know that query is underway for await (const portion of response) {
const newEmbed = new EmbedBuilder() result += portion.message.content
.setTitle(`Responding to ${message.author.tag}`)
.setDescription(response.message.content || 'No Content to Provide...')
.setColor('#00FF00')
// edit the message // exceeds handled length
sentMessage.edit({ embeds: [newEmbed] }) 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) { } catch(error: any) {
console.log(`[Util: messageEmbed] Error creating message: ${error.message}`) console.log(`[Util: messageEmbed] Error creating message: ${error.message}`)
const errorEmbed = new EmbedBuilder() const errorEmbed = new EmbedBuilder()
@@ -64,5 +126,5 @@ export async function embedMessage(
} }
// Hope there is a response! undefined otherwie // Hope there is a response! undefined otherwie
return response!! return result
} }

View File

@@ -1,7 +1,8 @@
import { Message } from 'discord.js' import { Message } from 'discord.js'
import { ChatResponse, Ollama } from 'ollama' import { ChatResponse, Ollama } from 'ollama'
import { UserMessage } from './events.js' import { ChatParams, UserMessage, streamResponse, blockResponse } from './index.js'
import { Queue } from '../queues/queue.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 * Method to send replies as normal text on discord like any other user
@@ -13,41 +14,70 @@ export async function normalMessage(
message: Message, message: Message,
ollama: Ollama, ollama: Ollama,
tokens: { tokens: {
channel: string,
model: string model: string
}, },
msgHist: Queue<UserMessage> msgHist: Queue<UserMessage>,
) { stream: boolean
): Promise<string> {
// bot's respnse // bot's respnse
let response: ChatResponse let response: ChatResponse | AbortableAsyncIterator<ChatResponse>
let result: string = ''
await message.channel.send('Generating Response . . .').then(async sentMessage => { await message.channel.send('Generating Response . . .').then(async sentMessage => {
try { try {
// Attempt to query model for message const params: ChatParams = {
response = await ollama.chat({
model: tokens.model, model: tokens.model,
messages: msgHist.getItems(), ollama: ollama,
options: { msgHist: msgHist.getItems()
num_thread: 8, // remove if optimization needed further }
mirostat: 1,
mirostat_tau: 2.0,
top_k: 70
},
stream: false
})
// check if message length > discord max for normal messages // run query based on stream preference, true = stream, false = block
if (response.message.content.length > 2000) { if (stream) {
sentMessage.edit(response.message.content.slice(0, 2000)) let messageBlock: Message = sentMessage
message.channel.send(response.message.content.slice(2000)) response = await streamResponse(params) // THIS WILL BE SLOW due to discord limits!
} else // edit the 'generic' response to new message for await (const portion of response) {
sentMessage.edit(response.message.content) // check if over discord message limit
if (result.length + portion.message.content.length > 2000) {
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 })
} else {
result += portion.message.content
// ensure block is not empty
if (result.length > 5)
messageBlock.edit(result)
}
console.log(result)
}
}
else {
response = await blockResponse(params)
result = response.message.content
// check if message length > discord max for normal messages
if (result.length > 2000) {
sentMessage.edit(result.slice(0, 2000))
result = result.slice(2000)
// handle for rest of message that is >2000
while (result.length > 2000) {
message.channel.send(result.slice(0, 2000))
result = result.slice(2000)
}
// last part of message
message.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}`) console.log(`[Util: messageNormal] Error creating message: ${error.message}`)
sentMessage.edit(`**Response generation failed.**\n\nReason: ${error.message}`) sentMessage.edit(`**Response generation failed.**\n\nReason: ${error.message}`)
} }
}) })
// Hope there is a response, force client to believe // return the string representation of ollama query response
return response!! return result
} }

View File

@@ -1,27 +0,0 @@
import { AxiosResponse } from 'axios'
/**
* When running a /api/chat stream, the output needs to be parsed into an array of objects
* This method is used for development purposes and testing
*
* This will not work as intended with the inclusion of ollama-js, needs to be modified to work with it
*
* @param stream Axios response to from Ollama
*/
export function parseStream(stream: AxiosResponse<any, any>) {
// split string by newline
const keywordObjects: string[] = stream.data.trim().split('\n')
// parse string and load them into objects
const keywordsArray: {
model: string,
created_at: string,
message: {
role: string,
content: string
},
done: boolean
}[] = keywordObjects.map((keywordString) => JSON.parse(keywordString))
return keywordsArray
}

27
tests/commands.test.ts Normal file
View File

@@ -0,0 +1,27 @@
// 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'
/**
* 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', () => {
// 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')
})
})

23
tests/events.test.ts Normal file
View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import events from '../src/events'
/**
* 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
*/
describe('#events', () => {
// 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')
})
})

50
tests/getEnvVar.test.ts Normal file
View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { getEnvVar } from '../src/utils'
/**
* getEnvVar test suite, tests the getEnvVar function
*
* @param name name of the test suite
* @param fn function holding tests to run
*/
describe('#getEnvVar', () => {
// dummy set of keys
const keys = {
clientToken: 'CLIENT_TOKEN',
}
// set keys in environment
process.env['clientToken'] = keys.clientToken
// test for non-empty string
it('returns a non-empty string', () => {
expect(getEnvVar('CLIENT_TOKEN')).not.toBe('')
})
// test for string type
it('returns a string', () => {
expect(typeof getEnvVar('CLIENT_TOKEN')).toBe('string')
})
// test for distinct key
it('returns a distinct key', () => {
expect(getEnvVar('CLIENT_TOKEN')).toEqual(process.env[keys.clientToken])
})
// test for fallback case
it('returns a fallback', () => {
expect(getEnvVar('NON_EXISTENT_KEY', 'fallback')).toBe('fallback')
})
// test that all keys are consistently found
it('returns all keys found', () => {
for (const key in keys) {
expect(getEnvVar(key)).toEqual(keys[key])
}
})
// test that an error is thrown if key is not found
it('throws an error if key is not found', () => {
expect(() => getEnvVar('NON_EXISTENT_KEY')).toThrowError()
})
})

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { clean } from '../src/utils/mentionClean'
import { getEnvVar } from '../src/utils'
/**
* MentionClean test suite, tests the clean function
*
* @param name name of the test suite
* @param fn function holding tests to run
*/
describe('#clean', () => {
// 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!')
})
})

62
tests/queue.test.ts Normal file
View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { Queue } from '../src/queues/queue'
/**
* Queue test suite, tests the Queue class
*
* @param name name of the test suite
* @param fn function holding tests to run
*/
describe('#queue', () => {
let queue= new Queue<string>()
// test for queue creation
it('creates a new queue', () => {
expect(queue).not.toBeNull()
})
// test for queue capacity creation
it('adds specific capacity to the queue', () => {
queue = new Queue<string>(2)
expect(queue).not.toBeNull()
})
// test for enqueue success, size update
it('adds items to the queue', () => {
queue.enqueue('hello')
expect(queue.size()).toBe(1)
})
// test for multiple enqueue success, size update
it('adds multiple items to the queue', () => {
queue.enqueue('world')
expect(queue.size()).toBe(2)
})
// test for enqueue failure upon capacity overflow
it('throws an error when the queue is full', () => {
expect(() => queue.enqueue('!')).toThrowError()
})
// test for getItems success
it('returns all items in the queue', () => {
expect(queue.getItems()).toEqual(['hello', 'world'])
})
// test for pop success, size update
it('removes an item from the front of the queue', () => {
queue.pop()
expect(queue.size() && queue.getItems()[0]).toBe(1 && 'hello')
})
// test for dequeue success, size update
it('removes an item from the back of the queue', () => {
queue.dequeue()
expect(queue.size() && queue.getItems()[0]).toBe(0 && 'world')
})
// test for getItems success with nothing in the list
it('returns an empty array when the queue is empty', () => {
expect(queue.getItems()).toEqual([])
})
})

View File

@@ -2,8 +2,8 @@
"compilerOptions": { "compilerOptions": {
// Dependent on node version // Dependent on node version
"target": "ES2020", "target": "ES2020",
"module": "Node16", "module": "NodeNext",
"moduleResolution": "Node16", "moduleResolution": "NodeNext",
"strict": true, "strict": true,
// We must set the type // We must set the type
"noImplicitAny": true, "noImplicitAny": true,
@@ -13,11 +13,17 @@
"strictNullChecks": true, "strictNullChecks": true,
// We can import json files like JavaScript // We can import json files like JavaScript
"resolveJsonModule": true, "resolveJsonModule": true,
// Decompile .ts to .js into a folder named dist "skipLibCheck": true,
"esModuleInterop": true,
// Decompile .ts to .js into a folder named build
"outDir": "build", "outDir": "build",
"rootDir": "src" "rootDir": "src",
"baseUrl": ".",
"paths": {
"*": ["node_modules/"]
}
}, },
// environment for env vars // environment for env vars
"include": ["src/**/*"], "include": ["src/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

12
vitest.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig, configDefaults } from 'vitest/config'
// config for vitest
export default defineConfig({
test: {
globals: true, // <-- reduces test file imports
coverage: {
exclude: [...configDefaults.exclude, 'build/*', 'tests/*'], // <-- exclude JS build
reporter: ['text', 'html'] // <-- reports in text, html
}
}
})