74 Commits

Author SHA1 Message Date
JT2M0L3Y
be6c64be82 Update: fix imports based on last pkg fix 2025-07-11 13:26:47 -07:00
JT2M0L3Y
427c1ecd3d Added: defined objects directory 2025-07-11 13:22:44 -07:00
JT2M0L3Y
5eda32b185 Update: utility method logs use method name 2025-07-11 13:22:38 -07:00
Kevin Dang
b27cdfc162 Update Documentation with New Features (#185) 2025-06-22 20:43:50 -07:00
Jonathan Smoley
1074fe2270 Removed Redis Dependency (#184) 2025-06-20 17:04:56 -07:00
Jonathan Smoley
4236582cf4 Upgrade discord.js from 14.18.0 to 14.20.0 (#177)
* fix: upgrade discord.js from 14.18.0 to 14.19.3

Snyk has created this PR to upgrade discord.js from 14.18.0 to 14.19.3.

See this package in npm:
discord.js

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

* Update: discordjs to latest

* Fix: Broken commands

* Fix: Ollama offline failsafes trigger

---------

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: Kevin Dang <kevinthedang_1@outlook.com>
2025-06-20 08:34:10 -07:00
Kevin Dang
e07e8fbf89 Fix Missing Redis Connections and Error Messages (#182)
* Update: simplify npm command to run tests

* Fix: redis workaround for local non docker

* Update: error message and config creation

* Fix: Better Messages for Ollama service being offline

* Update: version increment

* Fix: verion typo

* Update: Use built-in catch method for logging

* Update: same catch method for redis
2025-06-18 07:54:53 -07:00
Jonathan Smoley
6d0a537540 Upgrade dotenv to 16.5.0 (#173)
Snyk has created this PR to upgrade dotenv from 16.4.7 to 16.5.0.

See this package in npm:
dotenv

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

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: Kevin Dang <77701718+kevinthedang@users.noreply.github.com>
2025-05-15 07:07:06 -07:00
Jonathan Smoley
0ddd59aea1 Upgrade ollama package to 0.5.15 (#174)
Snyk has created this PR to upgrade ollama from 0.5.14 to 0.5.15.

See this package in npm:
ollama

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

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

* Update: add in all packages

* Update: fix whitespace in events

---------

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

* Update: remove unused import

* Update: version increment

---------

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

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

* Updated Guides and Goals  (#134)

* Update README.md

* Update commands-guide.md

* Update events-guide.md

* Update commands-guide.md

* Added: redis client

* Fixed: redis mock in commands.test.ts

* Updated: npm package patches

* Fixed: redis ip name in keys.ts

* update Node LTS version, workflow env vars

* Updated: node package engine requirements

* Updated: documentation

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

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

See this package in npm:
dotenv

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

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

* Update: docs patches, connection ordering

---------

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

* Update: version increment

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

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

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

* Update: switch-model cannot pull models anymore

* Update: less technical responses

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

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

* Add: note of where problem can be

* Update: Check if model already exists for Pull Command

* Add: User/Admin Command Constants

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

* Update: Casting SendableChannels Once

* Remove: another semicolon

* Update: version increment

---------

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

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

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

* Update: version increment
2024-10-06 18:57:39 -07:00
Kevin Dang
947ff89958 Environment Variable Validation (#122)
* Update: env validation and discord token validation

* Add: IPv4 Address Validation

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

* test naming updated

* fix imports, remove old references

* added code coverage badge

* Add: coverage environment

* Fix: Readme hyperlink to coverage workflow

* grab coverage pct from env

* Update: gist hyperlink

* color range on coverage

* fix contributing, simplify coverage assessment

* lmiit coverage to master, add branch naming conventions

---------

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

* Fix: Non-Attachment Query

* Update: version increment

* Remove: Debugging logs from buffer file

* Add: comments for bufferHandler
2024-09-18 20:28:23 -07:00
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
Kevin Dang
bc989580a9 Fix Capacity Command (#49)
* fix: capacity while replace if

* fix: command name in config
2024-04-20 10:03:22 -07:00
Kevin Dang
477567b05d Remove Bot User ID from Prompt (#48) 2024-04-20 10:01:42 -07:00
Kevin Dang
6a1d66fd36 fix: updated ollama icon (#46) 2024-04-13 21:34:55 -07:00
Kevin Dang
ca865b322e Dependency Updates (#44)
* update: newer ollama icon

* update: ollama-js dependency

* update: other updated dependencies
2024-04-13 19:59:57 -07:00
Kevin Dang
615ee2029b Change of License (#41)
* update: license

* update: CC by 4.0
2024-04-13 19:07:49 -07:00
Kevin Dang
da1f08a070 Message Blocks for Normal Message Style (#37)
* add: if check for message length

* update: version increment

* update: readme
2024-04-07 16:09:27 -07:00
Kevin Dang
2bdc7b8583 Capacity Context Modify Command (#35)
* add: modify capacity command

* update: version increment
2024-04-03 15:22:34 -07:00
Kevin Dang
727731695e Chat Queue Persistence (#33)
* fix: workflow env

* update: center title on readme

* update: readme goals and format

* add: icons in readme

* fix: plus margin

* update: environment variables in contr.

* add: queue for chat history

* add: set -e for workflow failure

* update: version increment

* fix: client null info

* fix: shutoff issues
2024-04-02 22:04:09 -07:00
Kevin Dang
5f8b513269 Workflows Fix (#32)
* fix: workflows missing new env
2024-04-01 00:51:07 -07:00
Kevin Dang
fcb0267559 Shutoff Bot Command (#30)
* add: disable chat command

* update: workflow name

* add: shutoff using admin env list

* update: sample env for admins

* fix: shutdown booleans

* update: version increment
2024-04-01 00:43:19 -07:00
80 changed files with 4685 additions and 1149 deletions

View File

@@ -1,24 +1,15 @@
# Discord token for the bot
CLIENT_TOKEN = BOT_TOKEN
# id token of a discord server
GUILD_ID = GUILD_ID
# Default model for new users
MODEL = DEFAULT_MODEL
# 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 = MODEL_NAME
# discord bot user id for mentions
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_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
# subnet address, ex. 172.18.0.0 as we use /16.
SUBNET_ADDRESS = ADDRESS
SUBNET_ADDRESS = ADDRESS

View File

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

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,81 +0,0 @@
name: Test Discord-Ollama 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
- name: Startup Discord Bot Client
run: |
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
- 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

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

@@ -0,0 +1,75 @@
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/jod
uses: actions/setup-node@v4
with:
node-version: lts/jod
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 OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .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/jod
uses: actions/setup-node@v4
with:
node-version: lts/jod
cache: "npm"
- name: Create Environment Variables
run: |
touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .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/coverage.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Coverage
run-name: Code Coverage
on:
push:
branches:
- master
jobs:
Discord-Node-Coverage:
runs-on: ubuntu-latest
environment: coverage
timeout-minutes: 2
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node Environment lts/jod
uses: actions/setup-node@v4
with:
node-version: lts/jod
cache: "npm"
- name: Install Project Dependencies
run: |
npm install
- name: Create Environment Variables
run: |
touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
- name: Collect Code Coverage
run: |
LINE_PCT=$(npm run coverage | tail -2 | head -1 | awk '{print $3}')
echo "COVERAGE=$LINE_PCT" >> $GITHUB_ENV
- name: Upload Code Coverage
uses: schneegans/dynamic-badges-action@v1.7.0
with:
auth: ${{ secrets.GIST_SECRET }}
gistID: ${{ vars.GIST_ID }}
filename: coverage.json
label: Coverage
message: ${{ env.COVERAGE }}
valColorRange: ${{ env.COVERAGE }}
maxColorRange: 100
minColorRange: 0

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

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

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

@@ -0,0 +1,48 @@
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/jod
uses: actions/setup-node@v4
with:
node-version: lts/jod
cache: "npm"
- name: Install Project Dependencies
run: |
npm install
- name: Create Environment Variables
run: |
touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
- name: Test Application
run: |
npm run tests

24
CODEOWNERS Normal file
View File

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

View File

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

395
LICENSE Normal file
View File

@@ -0,0 +1,395 @@
Attribution 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution 4.0 International Public License ("Public License"). To the
extent this Public License may be interpreted as a contract, You are
granted the Licensed Rights in consideration of Your acceptance of
these terms and conditions, and the Licensor grants You such rights in
consideration of benefits the Licensor receives from making the
Licensed Material available under these terms and conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
d. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
e. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
f. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
g. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
h. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
i. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
j. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
k. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part; and
b. produce, reproduce, and Share Adapted Material.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
4. If You Share Adapted Material You produce, the Adapter's
License You apply must not prevent recipients of the Adapted
Material from complying with this Public License.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.

View File

@@ -1,25 +1,68 @@
# Discord Ollama Integration [![License: CC BY-NC 4.0](https://img.shields.io/badge/License-CC_BY--NC_4.0-darkgreen.svg)](https://creativecommons.org/licenses/by-nc/4.0/) [![Release Badge](https://img.shields.io/github/v/release/kevinthedang/discord-ollama?logo=github)](https://github.com/kevinthedang/discord-ollama/releases/latest)
Ollama is an AI model management tool that allows users to install and use custom large language models locally. The goal is to create a discord bot that will utilize Ollama and chat with it on a Discord server! Also, allow others to create their own models personalized for their own servers!
<div align="center">
<p><a href="#"><a href="https://ollama.ai/"><img alt="ollama" src="./imgs/ollama-icon.png" width="200px" /></a><img alt="+" src="./imgs/grey-plus.png" width="100px" /></a><a href="https://discord.com/"><img alt="discord" src="./imgs/discord-icon.png" width="195px" /></a></p>
<h1>Discord Ollama Integration</h1>
<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>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/releases/latest"><img alt="Release" src="https://img.shields.io/github/v/release/kevinthedang/discord-ollama?logo=github" /></a>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/actions/workflows/build.yml"><img alt="Build Status" src="https://github.com/kevinthedang/discord-ollama/actions/workflows/build.yml/badge.svg" /></a>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/actions/workflows/deploy.yml"><img alt="Deploy Status" src="https://github.com/kevinthedang/discord-ollama/actions/workflows/deploy.yml/badge.svg" /></a>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/actions/workflows/test.yml"><img alt="Testing Status" src="https://github.com/kevinthedang/discord-ollama/actions/workflows/test.yml/badge.svg" /></a>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/actions/workflows/coverage.yml"><img alt="Code Coverage" src="https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/kevinthedang/bc7b5dcfa16561ab02bb3df67a99b22d/raw/coverage.json"></a>
</div>
## About/Goals
Ollama is an AI model management tool that allows users to install and use custom large language models locally.
The project aims to:
* [x] Create a Discord bot that will utilize Ollama and chat to chat with users!
* [x] User and Server Preferences
* [x] Message Persistance
* [x] Containerization with Docker
* [x] Slash Commands Compatible
* [ ] Summary Command
* [ ] Model Info Command
* [ ] List Models Command
* [x] Pull Model Command
* [x] Switch Model Command
* [x] Delete Model Command
* [x] Create Thread Command
* [x] Create Private Thread Command
* [x] Message Stream Command
* [x] Change Message History Size Command
* [x] Clear Channel History Command (User Only)
* [x] Administrator Role Compatible
* [x] Generated Token Length Handling for >2000
* [x] Token Length Handling of any message size
* [x] Multi-User Chat Generation - This was built in from Ollama `v0.2.1+`
* [ ] Ollama Tool Support Implementation
* [ ] Enhanced Channel Context Awareness
* [ ] Improved User Replied Triggers
Further, Ollama provides the functionality to utilize custom models or provide context for the top-layer of any model available through the Ollama model library.
* [Customize a model](https://github.com/ollama/ollama#customize-a-model)
* [Modelfile Docs](https://github.com/ollama/ollama/blob/main/docs/modelfile.md)
## Documentation
These are guides to the features and capabilities of this app.
* [User Slash Commands](./docs/commands-guide.md)
* [Client Events](./docs/events-guide.md)
## Environment Setup
* Clone this repo using `git clone https://github.com/kevinthedang/discord-ollama.git` or just use [GitHub Desktop](https://desktop.github.com/) to clone the repo.
* 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]`
* 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)
* [Docker Setup for Servers and Local Machines](./docs/setup-docker.md)
* Nvidia is recommended for now, but support for other GPUs should be development.
* Local use is not recommended.
## Resources
* [NodeJS](https://nodejs.org/en)
* This project uses `v20.10.0+` (npm `10.2.5`). Consider using [nvm](https://github.com/nvm-sh/nvm) for multiple NodeJS versions.
* 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.
* [Ollama](https://ollama.ai/)
* This project runs on `lts\jod` and above.
* This project requires the use of npm version `10.9.0` or above.
* [Ollama](https://ollama.com/)
* [Ollama Docker Image](https://hub.docker.com/r/ollama/ollama)
* **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)
* [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)
@@ -28,4 +71,4 @@ Ollama is an AI model management tool that allows users to install and use custo
* [Kevin Dang](https://github.com/kevinthedang)
* [Jonathan Smoley](https://github.com/JT2M0L3Y)
[discord-ollama](https://github.com/kevinthedang/discord-ollama) © 2023 by [Kevin Dang](https://github.com/kevinthedang) is licensed under [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/?ref=chooser-v1)
[discord-ollama](https://github.com/kevinthedang/discord-ollama) © 2023 by [Kevin Dang](https://github.com/kevinthedang) is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)

View File

@@ -1,5 +1,4 @@
# creates the docker compose
version: '3.7'
# creates the docker compose
# build individual services
services:
@@ -8,15 +7,12 @@ services:
build: ./ # find docker file in designated path
container_name: discord
restart: always # rebuild container always
image: discord/bot:0.2.0
image: kevinthedang/discord-ollama:0.8.6
environment:
CLIENT_TOKEN: ${CLIENT_TOKEN}
GUILD_ID: ${GUILD_ID}
CHANNEL_ID: ${CHANNEL_ID}
MODEL: ${MODEL}
CLIENT_UID: ${CLIENT_UID}
OLLAMA_IP: ${OLLAMA_IP}
OLLAMA_PORT: ${OLLAMA_PORT}
MODEL: ${MODEL}
networks:
ollama-net:
ipv4_address: ${DISCORD_IP}
@@ -31,7 +27,6 @@ services:
networks:
ollama-net:
ipv4_address: ${OLLAMA_IP}
runtime: nvidia # use Nvidia Container Toolkit for GPU support
devices:
- /dev/nvidia0

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

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

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

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

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

@@ -0,0 +1,43 @@
## 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)
* 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

@@ -2,7 +2,9 @@
* Follow this guide to setup [Docker](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-docker-on-ubuntu-20-04)
* If on Windows, download [Docker Desktop](https://docs.docker.com/desktop/install/windows-install/) to get the docker engine.
* Please also install [Docker Compose](https://docs.docker.com/compose/install/linux/) for easy running. If not, there are [scripts](#manual-run-with-docker) to set everything up.
* **IMPORTANT NOTE**: Currently, it seems like wsl does not like Nvidia Container Toolkit. It will work initially then reset it for some odd reason. For now, it is advised to use an actually Linux machine to run using Docker. If you do not care about utilizing your GPU or don't even have a Nvidia GPU then disregard this.
> [!IMPORTANT]
> Currently, it seems like wsl does not like Nvidia Container Toolkit. It will work initially then reset it for some odd reason. For now, it is advised to use an actually Linux machine to run using Docker. If you do not care about utilizing your GPU or don't even have a Nvidia GPU then disregard this.
## Nvidia Container Toolkit Setup
### Installation with Apt
@@ -42,19 +44,19 @@ sudo systemctl restart docker
## To Run (with Docker and Docker Compose)
* With the inclusion of subnets in the `docker-compose.yml`, you will need to set the `SUBNET_ADDRESS`, `OLLAMA_IP`, `OLLAMA_PORT`, and `DISCORD_IP`. Here are some default values if you don't care:
* `SUBNET_ADDRESS = 172.18.0.0`
* `OLLAMA_IP = 172.18.0.2`
* `OLLAMA_PORT = 11434`
* `DISCORD_IP = 172.18.0.3`
* `SUBNET_ADDRESS = 172.18.0.0`
* Don't understand any of this? watch a Networking video to understand subnetting.
* 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.
* You also need all environment variables shown in [`.env.sample`](../.env.sample)
* Otherwise, there is no need to install any npm packages for this, you just need to run `npm run start` to pull the containers and spin them up.
* For cleaning up on Linux (or Windows), run the following commands:
* `docker compose stop`
* `docker compose rm`
* `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)
* Run the following commands:

View File

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

BIN
imgs/discord-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
imgs/grey-plus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
imgs/ollama-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

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"
}

2599
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +1,46 @@
{
"name": "discord-ollama",
"version": "0.3.3",
"version": "0.8.6",
"description": "Ollama Integration into discord",
"main": "build/index.js",
"exports": "./build/index.js",
"scripts": {
"dev-tsx": "tsx watch src/index.ts",
"dev-mon": "nodemon --config nodemon.json src/index.ts",
"tests": "vitest run",
"coverage": "vitest run --coverage",
"watch": "tsx watch src",
"build": "tsc",
"prod": "node .",
"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",
"docker:start": "npm run docker:network && npm run docker:build && npm run docker:client && npm run docker:ollama",
"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: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: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: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": "docker build --no-cache -t kevinthedang/discord-ollama:$(node -p \"require('./package.json').version\") .",
"docker:build-latest": "docker build --no-cache -t kevinthedang/discord-ollama:latest .",
"docker:client": "docker run -d -v discord:/src/app --name discord --network ollama-net --ip 172.18.0.3 kevinthedang/discord-ollama:$(node -p \"require('./package.json').version\")",
"docker: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",
"license": "ISC",
"dependencies": {
"axios": "^1.6.2",
"discord.js": "^14.14.1",
"dotenv": "^16.3.1",
"ollama": "^0.4.6"
"discord.js": "^14.20.0",
"dotenv": "^16.5.0",
"ollama": "^0.5.15"
},
"devDependencies": {
"@types/node": "^20.10.5",
"nodemon": "^3.0.2",
"@types/node": "^22.13.14",
"@vitest/coverage-v8": "^3.0.9",
"ts-node": "^10.9.2",
"tsx": "^4.6.2",
"typescript": "^5.3.3"
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vitest": "^3.0.4"
},
"type": "module",
"engines": {
"node": ">=16.0.0"
"npm": ">=10.9.0",
"node": ">=22.12.0"
}
}

View File

@@ -1,47 +1,41 @@
import { Client, GatewayIntentBits } from 'discord.js'
import { UserMessage, registerEvents } from './utils/events.js'
import Events from './events/index.js'
import { Ollama } from 'ollama'
// Import keys/tokens
import Keys from './keys.js'
// initialize the client with the following permissions when logging in
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent
]
});
// initialize connection to ollama container
const ollama = new Ollama({
host: `http://${Keys.ipAddress}:${Keys.portAddress}`,
})
// Create Queue managed by Events
const messageHistory: [UserMessage] = [
{
role: 'system',
content: 'Your name is Ollama GU'
}
]
/**
* register events for bot to listen to in discord
* @param messageHistory message history for the llm
* @param Events events to register
* @param client the bot reference
* @param Keys tokens from .env files
*/
registerEvents(client, Events, messageHistory, Keys, ollama)
// Try to log in the client
await client.login(Keys.clientToken)
.catch((error) => {
console.error('[Login Error]', error)
process.exit(1)
import { Client, GatewayIntentBits } from 'discord.js'
import { Ollama } from 'ollama'
import { Queue } from './components/index.js'
import { UserMessage, registerEvents } from './utils/index.js'
import Events from './events/index.js'
import Keys from './keys.js'
// initialize the client with the following permissions when logging in
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent
]
})
// initialize connection to ollama container
export const ollama = new Ollama({
host: `http://${Keys.ipAddress}:${Keys.portAddress}`,
})
// Create Queue managed by Events
const messageHistory: Queue<UserMessage> = new Queue<UserMessage>
// register all events
registerEvents(client, Events, messageHistory, ollama, Keys.defaultModel)
// Try to log in the client
await client.login(Keys.clientToken)
.catch((error) => {
console.error('[Login Error]', error)
process.exit(1)
})
// queue up bots name
messageHistory.enqueue({
role: 'assistant',
content: `My name is ${client.user?.username}`,
images: []
})

34
src/commands/capacity.ts Normal file
View File

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

View File

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

View File

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

43
src/commands/disable.ts Normal file
View File

@@ -0,0 +1,43 @@
import { Client, ChatInputCommandInteraction, ApplicationCommandOptionType, MessageFlags } from 'discord.js'
import { AdminCommand, openConfig, SlashCommand } from '../utils/index.js'
export const Disable: SlashCommand = {
name: 'toggle-chat',
description: 'toggle all chat features. Adminstrator Only.',
// set available user options to pass to the command
options: [
{
name: 'enabled',
description: 'true = enabled, false = disabled',
type: ApplicationCommandOptionType.Boolean,
required: true
}
],
// Query for message information and set the style
run: async (client: Client, interaction: ChatInputCommandInteraction) => {
// fetch channel and message
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !AdminCommand.includes(channel.type)) return
// check if runner is an admin
if (!interaction.memberPermissions?.has('Administrator')) {
interaction.reply({
content: `${interaction.commandName} is an admin command.\n\nPlease contact an admin to use this command for you.`,
flags: MessageFlags.Ephemeral
})
return
}
// set state of bot chat features
openConfig(`${interaction.guildId}-config.json`, interaction.commandName,
interaction.options.getBoolean('enabled')
)
interaction.reply({
content: `${client.user?.username} is now **${interaction.options.getBoolean('enabled') ? "enabled" : "disabled"}**.`,
flags: MessageFlags.Ephemeral
})
}
}

View File

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

View File

@@ -1,33 +1,34 @@
import { ApplicationCommandOptionType, ChannelType, Client, CommandInteraction } from 'discord.js'
import { SlashCommand } from '../utils/commands.js'
import { openFile } from '../utils/jsonHandler.js'
import { ApplicationCommandOptionType, Client, ChatInputCommandInteraction, MessageFlags } from 'discord.js'
import { openConfig, SlashCommand, UserCommand } from '../utils/index.js'
export const MessageStream: SlashCommand = {
name: 'message-stream',
description: 'change preference on message streaming from ollama. WARNING: can be very slow.',
description: 'change preference on message streaming from ollama. WARNING: can be very slow due to Discord limits.',
// user option(s) for setting stream
options: [
{
name: 'stream',
description: 'enable or disable stream preference',
description: 'enable or disable message streaming',
type: ApplicationCommandOptionType.Boolean,
required: true
}
],
// change preferences based on command
run: async (client: Client, interaction: CommandInteraction) => {
run: async (client: Client, interaction: ChatInputCommandInteraction) => {
// verify channel
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || channel.type !== ChannelType.GuildText) return
if (!channel || !UserCommand.includes(channel.type)) return
// 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.getBoolean('stream')
)
interaction.reply({
content: `Message streaming preferences for embed set to: \`${interaction.options.get('stream')?.value}\``,
ephemeral: true
content: `Message streaming is now set to: \`${interaction.options.getBoolean('stream')}\``,
flags: MessageFlags.Ephemeral
})
}
}

View File

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

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

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

37
src/commands/shutoff.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Client, CommandInteraction, MessageFlags } from 'discord.js'
import { AdminCommand, SlashCommand } from '../utils/index.js'
export const Shutoff: SlashCommand = {
name: 'shutoff',
description: 'shutdown the bot. You will need to manually bring it online again. Administrator Only.',
// Query for message information and set the style
run: async (client: Client, interaction: CommandInteraction) => {
// fetch channel and message
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !AdminCommand.includes(channel.type)) return
// log this, this will probably be improtant for logging who did this
console.log(`[Command: shutoff] User ${interaction.user.tag} attempting to shutdown ${client.user!!.tag}`)
// check if admin or false on shutdown
if (!interaction.memberPermissions?.has('Administrator')) {
interaction.reply({
content: `**Shutdown Aborted:**\n\n${interaction.user.tag}, You do not have permission to shutoff **${client.user?.tag}**.`,
flags: MessageFlags.Ephemeral
})
return // stop from shutting down
}
// Shutoff cleared, do it
interaction.reply({
content: `${client.user?.tag} is shutting down.`,
flags: MessageFlags.Ephemeral
})
console.log(`[Command: shutoff] ${client.user?.tag} is shutting down.`)
// clean up client instance and stop
client.destroy()
}
}

View File

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

View File

@@ -1,5 +1,5 @@
import { ChannelType, Client, CommandInteraction, TextChannel } from 'discord.js'
import { SlashCommand } from '../utils/commands.js'
import { ChannelType, Client, CommandInteraction, MessageFlags, TextChannel, ThreadChannel } from 'discord.js'
import { AdminCommand, openChannelInfo, SlashCommand } from '../utils/index.js'
export const ThreadCreate: SlashCommand = {
name: 'thread',
@@ -9,20 +9,24 @@ export const ThreadCreate: SlashCommand = {
run: async (client: Client, interaction: CommandInteraction) => {
// fetch the channel
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || channel.type !== ChannelType.GuildText) return
if (!channel || !AdminCommand.includes(channel.type)) return
const thread = await (channel as TextChannel).threads.create({
name: `support-${Date.now()}`,
reason: `Support ticket ${Date.now()}`
name: `${client.user?.username}-support-${Date.now()}`,
reason: `Support ticket ${Date.now()}`,
type: ChannelType.PublicThread
})
// 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 message.`)
// handle storing this chat channel
openChannelInfo(thread.id, thread as ThreadChannel, interaction.user.tag)
// user only reply
return interaction.reply({
content: `I can help you in the Thread below. \n**Thread ID:** ${thread.id}`,
ephemeral: true
content: `I can help you in <#${thread.id}> below.`,
flags: MessageFlags.Ephemeral
})
}
}

View File

@@ -0,0 +1,33 @@
import { ChannelType, Client, CommandInteraction, MessageFlags, TextChannel, ThreadChannel } from 'discord.js'
import { AdminCommand, openChannelInfo, SlashCommand } from '../utils/index.js'
export const PrivateThreadCreate: SlashCommand = {
name: 'private-thread',
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 || !AdminCommand.includes(channel.type)) 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.id}>.`,
flags: MessageFlags.Ephemeral
})
}
}

46
src/components/binder.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* @class Logger
* @description A class to handle logging messages
* @method log
*/
export class Logger {
private logPrefix: string = ''
private type: string = 'log'
private constructPrefix(component?: string, method?: string): string {
let prefix = this.type.toUpperCase()
if (component) {
prefix += ` [${component}`
if (method) prefix += `: ${method}`
prefix += ']'
}
return prefix
}
public bind(component?: string, method?: string): CallableFunction {
let tempPrefix = this.constructPrefix(component, method)
if (tempPrefix !== this.logPrefix) this.logPrefix = tempPrefix
switch (this.type) {
case 'warn':
return console.warn.bind(console, this.logPrefix)
case 'error':
return console.error.bind(console, this.logPrefix)
case 'log':
default:
return console.log.bind(console, this.logPrefix)
}
}
public log(type: string, message: unknown, component?: string, method?: string): void {
if (type && type !== this.type) this.type = type
let log = this.bind(component, method)
log(message)
}
}

2
src/components/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './queue.js'
export * from './binder.js'

70
src/components/queue.ts Normal file
View File

@@ -0,0 +1,70 @@
// Queue interfaces for any queue class to follow
interface IQueue<T> {
enqueue(item: T): void
dequeue(): T | undefined
size(): number
}
/**
* Queue for UserMessages
* When the limit for messages is met, we want to clear
* out the oldest message in the queue
*/
export class Queue<T> implements IQueue<T> {
private storage: T[] = []
/**
* Set up Queue
* @param capacity max length of queue
*/
constructor(public capacity: number = 5) { }
/**
* Put item in front of queue
* @param item object of type T to add into queue
*/
enqueue(item: T): void {
if (this.size() === this.capacity)
throw Error('Queue has reached max capacity, you cannot add more items.')
this.storage.push(item)
}
/**
* Remove item at end of queue
* @returns object of type T in queue
*/
dequeue(): T | undefined {
return this.storage.shift()
}
/**
* Size of the queue
* @returns length of queue as a int/number
*/
size(): number {
return this.storage.length
}
/**
* Remove the front of the queue, typically for errors
*/
pop(): void {
this.storage.pop()
}
/**
* Get the queue as an array
* @returns a array of T items
*/
getItems(): T[] {
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

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

View File

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

View File

@@ -1,60 +1,192 @@
import { ChatResponse } from 'ollama'
import { embedMessage, event, Events, normalMessage } from '../utils/index.js'
import { Configuration, getConfig, openFile } from '../utils/jsonHandler.js'
import { TextChannel } from 'discord.js'
import {
event, Events, normalMessage, UserMessage, clean,
getChannelInfo, getServerConfig, getUserConfig, openChannelInfo,
openConfig, UserConfig, getAttachmentData, getTextFileAttachmentData
} from '../utils/index.js'
/**
* Max Message length for free users is 2000 characters (bot or not).
* Bot supports infinite lengths for normal messages.
*
* @param message the message received from the channel
*/
export default event(Events.MessageCreate, async ({ log, msgHist, tokens, ollama }, message) => {
log(`Message \"${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
export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client, defaultModel }, message) => {
const clientId = client.user!!.id
let cleanedMessage = clean(message.content, clientId)
log(`Message \"${cleanedMessage}\" from ${message.author.tag} in channel/thread ${message.channelId}.`)
// Do not respond if bot talks in the chat
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
if (!message.mentions.has(tokens.clientUid)) return
if (!message.mentions.has(clientId)) return
// push user response
msgHist.push({
role: 'user',
content: message.content
})
// default stream to false
let shouldStream = false
// Params for Preferences Fetching
const maxRetries = 3
const delay = 1000 // in millisecons
// Try to query and send embed
try {
const config: Configuration = await new Promise((resolve, reject) => {
getConfig('config.json', (config) => {
if (config === undefined) {
reject(new Error('No Configuration is set up.'))
return
// Retrieve Server/Guild Preferences
let attempt = 0
while (attempt < maxRetries) {
try {
await new Promise((resolve, reject) => {
getServerConfig(`${message.guildId}-config.json`, (config) => {
// check if config.json exists
if (config === undefined) {
// Allowing chat options to be available
openConfig(`${message.guildId}-config.json`, 'toggle-chat', true)
reject(new Error('Failed to locate or create Server Preferences\n\nPlease try chatting again...'))
}
// check if chat is disabled
else if (!config.options['toggle-chat'])
reject(new Error('Admin(s) have disabled chat features.\n\n Please contact your server\'s admin(s).'))
else
resolve(config)
})
})
break // successful
} catch (error) {
++attempt
if (attempt < maxRetries) {
log(`Attempt ${attempt} failed for Server Preferences. Retrying in ${delay}ms...`)
await new Promise(ret => setTimeout(ret, delay))
} else
throw new Error(`Could not retrieve Server Preferences, please try chatting again...`)
}
}
// Reset attempts for User preferences
attempt = 0
let userConfig: UserConfig | undefined
while (attempt < maxRetries) {
try {
// Retrieve User Preferences
userConfig = await new Promise((resolve, reject) => {
getUserConfig(`${message.author.username}-config.json`, (config) => {
if (config === undefined) {
openConfig(`${message.author.username}-config.json`, 'switch-model', defaultModel)
reject(new Error(`No User Preferences is set up.\n\nCreating new preferences file for ${message.author.username}\nPlease try chatting again.`))
return
}
// check if there is a set capacity in config
else if (typeof config.options['modify-capacity'] !== 'number')
log(`Capacity is undefined, using default capacity of ${msgHist.capacity}.`)
else if (config.options['modify-capacity'] === msgHist.capacity)
log(`Capacity matches config as ${msgHist.capacity}, no changes made.`)
else {
log(`New Capacity found. Setting Context Capacity to ${config.options['modify-capacity']}.`)
msgHist.capacity = config.options['modify-capacity']
}
// set stream state
shouldStream = config.options['message-stream'] as boolean || false
if (typeof config.options['switch-model'] !== 'string')
reject(new Error(`No Model was set. Please set a model by running \`/switch-model <model of choice>\`.\n\nIf you do not have any models. Run \`/pull-model <model name>\`.`))
resolve(config)
})
})
break // successful
} catch (error) {
++attempt
if (attempt < maxRetries) {
log(`Attempt ${attempt} failed for User Preferences. Retrying in ${delay}ms...`)
await new Promise(ret => setTimeout(ret, delay))
} else
throw new Error(`Could not retrieve User Preferences, please try chatting again...`)
}
}
// 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([])
}
resolve(config)
})
})
let response: ChatResponse
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.`))
}
})
})
}
// undefined or false, use normal, otherwise use embed
if (config.options['message-style'])
response = await embedMessage(message, ollama, tokens, msgHist)
else
response = await normalMessage(message, ollama, tokens, msgHist)
if (!userConfig)
throw new Error(`Failed to initialize User Preference for **${message.author.username}**.\n\nIt's likely you do not have a model set. Please use the \`switch-model\` command to do that.`)
// get message attachment if exists
const attachment = message.attachments.first()
let messageAttachment: string[] = []
if (attachment && attachment.name?.endsWith(".txt"))
cleanedMessage += await getTextFileAttachmentData(attachment)
else if (attachment)
messageAttachment = await getAttachmentData(attachment)
const model: string = userConfig.options['switch-model']
// set up new queue
msgHist.setQueue(chatMessages)
// check if we can push, if not, remove oldest
while (msgHist.size() >= msgHist.capacity) msgHist.dequeue()
// push user response before ollama query
msgHist.enqueue({
role: 'user',
content: cleanedMessage,
images: messageAttachment || []
})
// response string for ollama to put its response
const response: string = await normalMessage(message, ollama, model, msgHist, shouldStream)
// If something bad happened, remove user query and stop
if (response == undefined) { msgHist.pop(); return }
// successful query, save it as history
msgHist.push({
role: 'assistant',
content: response.message.content
// if queue is full, remove the oldest message
while (msgHist.size() >= msgHist.capacity) msgHist.dequeue()
// successful query, save it in context history
msgHist.enqueue({
role: 'assistant',
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) {
msgHist.pop() // remove message because of failure
openFile('config.json', 'message-style', true)
message.reply(`**Response generation failed.**\n\n**Reason:** *${error.message}*\n\nCreating \`config.json\` with \`message-style\` set as \`true\` for embedded messages.\nPlease try chatting again.`)
message.reply(`**Error Occurred:**\n\n**Reason:** *${error.message}*`)
}
})

View File

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

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

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

View File

@@ -1,4 +1,4 @@
import { CommandInteraction, ChatInputApplicationCommandData, Client, ApplicationCommandOption } from 'discord.js'
import { ChatInputCommandInteraction, ChatInputApplicationCommandData, Client, ApplicationCommandOption } from 'discord.js'
/**
* interface for how slash commands should be run
@@ -6,7 +6,7 @@ import { CommandInteraction, ChatInputApplicationCommandData, Client, Applicatio
export interface SlashCommand extends ChatInputApplicationCommandData {
run: (
client: Client,
interaction: CommandInteraction,
interaction: ChatInputCommandInteraction,
options?: ApplicationCommandOption[]
) => void
}

View File

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

@@ -1,21 +1,34 @@
import { resolve } from 'path'
import { config } from 'dotenv'
// Find config - ONLY WORKS WITH NODEMON
const envFile = process.env.NODE_ENV === 'development' ? '.env.dev.local' : '.env'
// resolve config file
const envFilePath = resolve(process.cwd(), envFile)
const envFilePath = resolve(process.cwd(), '.env')
const ipValidate: RegExp = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/
// set current environment variable file
config({ path: envFilePath })
// Getter for environment variables
/**
* Method to validate if environment variables found in file utils/env.ts
*
* @param name Name of the environment variable in .env
* @param fallback fallback value to set if environment variable is not set (used manually in src/keys.ts)
* @returns environment variable value
*/
export function getEnvVar(name: string, fallback?: string): string {
const value = process.env[name] ?? fallback
if (value == undefined)
if (!value)
throw new Error(`Environment variable ${name} is not set.`)
// validate User-Generated Discord Application Tokens
if (name === "CLIENT_TOKEN" && value.length > 72)
throw new Error(`The "CLIENT_TOKEN" provided is not of at least length 72.
This is probably an invalid token unless Discord updated their token policy. Please provide a valid token.`)
// validate IPv4 address found in environment variables
if ((name.endsWith("_IP") || name.endsWith("_ADDRESS")) && !ipValidate.test(value))
throw new Error(`Environment variable ${name} does not follow IPv4 formatting.`)
// return env variable
return value
}

View File

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

View File

@@ -0,0 +1,66 @@
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]
}
/**
* Method to retrieve the string data from the text file
*
* @param attachment the text file to convert to a string
*/
export async function getTextFileAttachmentData(attachment: Attachment): Promise<string> {
return await (await fetch(attachment.url)).text()
}

View File

@@ -0,0 +1,121 @@
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)
}
}
})
})
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(this: any, 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: ${this.name}] 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(this: any, 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: ${this.name}] 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,38 @@
import { ChatResponse, AbortableAsyncIterator } from "ollama"
import { ChatParams } from "../index.js"
/**
* Method to query the Ollama client for async generation
* @param params
* @returns AsyncIterator<ChatResponse> generated by the Ollama client
*/
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

@@ -1,6 +1,13 @@
// Centralized import index
export * from './env.js'
export * from './events.js'
export * from './messageEmbed.js'
export * from './messageNormal.js'
export * from './commands.js'
export * from './commands.js'
export * from './configInterfaces.js'
export * from './mentionClean.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,56 +0,0 @@
import fs from 'fs'
export interface Configuration {
readonly name: string
options: {
'message-stream'?: boolean,
'message-style'?: boolean
}
}
/**
* 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
}
}

14
src/utils/mentionClean.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* Clean up the bot user_id so it only has the prompt
*
* Sample: <@CLIENT_ID> Hello
* - we want to remove <@CLIENT_ID>
* - replace function works well for this
*
* @param message
* @returns message without client id
*/
export function clean(message: string, clientId: string): string {
const cleanedMessage: string = message.replace(`<@${clientId}>`, '').trim()
return cleanedMessage
}

View File

@@ -1,67 +0,0 @@
import { EmbedBuilder, Message } from 'discord.js'
import { ChatResponse, Ollama } from 'ollama'
import { UserMessage } from './events.js'
/**
* Method to send replies as normal text on discord like any other user
* @param message message sent by the user
* @param tokens tokens to run query
* @param msgHist message history between user and model
*/
export async function embedMessage(
message: Message,
ollama: Ollama,
tokens: {
channel: string,
model: string
},
msgHist: UserMessage[]
) {
// bot response
let response: ChatResponse
// initial message to client
const botMessage = new EmbedBuilder()
.setTitle(`Responding to ${message.author.tag}`)
.setDescription('Generating Response . . .')
.setColor('#00FF00')
// send the message
const sentMessage = await message.channel.send({ embeds: [botMessage] })
try {
// Attempt to query model for message
response = await ollama.chat({
model: tokens.model,
messages: msgHist,
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
const newEmbed = new EmbedBuilder()
.setTitle(`Responding to ${message.author.tag}`)
.setDescription(response.message.content || 'No Content to Provide...')
.setColor('#00FF00')
// edit the message
sentMessage.edit({ embeds: [newEmbed] })
} catch(error: any) {
console.log(`[Util: messageEmbed] Error creating message: ${error.message}`)
const errorEmbed = new EmbedBuilder()
.setTitle(`Responding to ${message.author.tag}`)
.setDescription(`**Response generation failed.**\n\nReason: ${error.message}`)
.setColor('#00FF00')
// send back error
sentMessage.edit({ embeds: [errorEmbed] })
}
// Hope there is a response! undefined otherwie
return response!!
}

View File

@@ -1,48 +1,86 @@
import { Message } from 'discord.js'
import { ChatResponse, Ollama } from 'ollama'
import { UserMessage } from './events.js'
import { Message, SendableChannels } from 'discord.js'
import { ChatResponse, Ollama, AbortableAsyncIterator } from 'ollama'
import { ChatParams, UserMessage, streamResponse, blockResponse } from './index.js'
import { Queue } from '../components/index.js'
/**
* Method to send replies as normal text on discord like any other user
* @param message message sent by the user
* @param tokens tokens to run query
* @param model name of model to run query
* @param msgHist message history between user and model
*/
export async function normalMessage(
this: any,
message: Message,
ollama: Ollama,
tokens: {
channel: string,
model: string
},
msgHist: UserMessage[]
) {
model: string,
msgHist: Queue<UserMessage>,
stream: boolean
): Promise<string> {
// bot's respnse
let response: ChatResponse
let response: ChatResponse | AbortableAsyncIterator<ChatResponse>
let result: string = ''
const channel = message.channel as SendableChannels
await message.reply('Generating Response . . .').then(async sentMessage => {
await channel.send('Generating Response . . .').then(async sentMessage => {
try {
// Attempt to query model for message
response = await ollama.chat({
model: tokens.model,
messages: msgHist,
options: {
num_thread: 8, // remove if optimization needed further
mirostat: 1,
mirostat_tau: 2.0,
top_k: 70
},
stream: false
})
// edit the 'generic' response to new message
sentMessage.edit(response.message.content)
} catch(error: any) {
console.log(`[Util: messageNormal] Error creating message: ${error.message}`)
sentMessage.edit(`**Response generation failed.**\n\nReason: ${error.message}`)
const params: ChatParams = {
model: model,
ollama: ollama,
msgHist: msgHist.getItems()
}
// run query based on stream preference, true = stream, false = block
if (stream) {
let messageBlock: Message = sentMessage
response = await streamResponse(params) // THIS WILL BE SLOW due to discord limits!
for await (const portion of response) {
// 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 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) {
channel.send(result.slice(0, 2000))
result = result.slice(2000)
}
// last part of message
channel.send(result)
} else // edit the 'generic' response to new message since <2000
sentMessage.edit(result)
}
} catch (error: any) {
console.log(`[Util: ${this.name}] Error creating message: ${error.message}`)
if (error.message.includes('try pulling it first'))
sentMessage.edit(`**Response generation failed.**\n\nReason: You do not have the ${model} downloaded. Ask an admin to pull it using the \`pull-model\` command.`)
else
sentMessage.edit(`**Response generation failed.**\n\nReason: ${error.message}`)
}
})
// Hope there is a response, force client to believe
return response!!
}
// return the string representation of ollama query 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
}

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

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

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

@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from 'vitest'
import events from '../src/events/index.js'
/**
* Mocking ollama found in client.ts because pullModel.ts
* relies on the existence on ollama. To prevent the mock,
* we will have to pass through ollama to the commands somehow.
*/
vi.mock('../src/client.js', () => ({
ollama: {
pull: vi.fn() // Mock the pull method found with ollama
}
}))
/**
* Events test suite, tests the events object
* Each event is to be tested elsewhere, this file
* is to ensure that the events object is defined.
*/
describe('Events Existence', () => {
// 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/index.js'
/**
* getEnvVar test suite, tests the getEnvVar function
*
* @param name name of the test suite
* @param fn function holding tests to run
*/
describe('Environment Setup', () => {
// 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,19 @@
import { describe, expect, it } from 'vitest'
import { clean } from '../src/utils/index.js'
// Sample UID for testing
const sampleId = '123456789'
/**
* MentionClean test suite, tests the clean function
*
* @param name name of the test suite
* @param fn function holding tests to run
*/
describe('Mentions Cleaned', () => {
// test for id removal from message
it('removes the mention from a message', () => {
const message = `<@${sampleId}> Hello, World!`
expect(clean(message, sampleId)).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/components/index.js'
/**
* Queue test suite, tests the Queue class
*
* @param name name of the test suite
* @param fn function holding tests to run
*/
describe('Queue Structure', () => {
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": {
// Dependent on node version
"target": "ES2020",
"module": "Node16",
"moduleResolution": "Node16",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
// We must set the type
"noImplicitAny": true,
@@ -13,11 +13,17 @@
"strictNullChecks": true,
// We can import json files like JavaScript
"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",
"rootDir": "src"
"rootDir": "src",
"baseUrl": ".",
"paths": {
"*": ["node_modules/"]
}
},
// environment for env vars
"include": ["src/**/*"],
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}

13
vitest.config.ts Normal file
View File

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