Compare commits

..
Author SHA1 Message Date
JT2M0L3Y e808e3d5e0 increment ver 2026-10-21 20:40:24 -07:00
JT2M0L3Y 7df5d14e28 stateful logging 2026-10-21 20:22:36 -07:00
JT2M0L3Y 288c0eb08f pass root logger, don't init new ones 2026-10-21 19:31:02 -07:00
JT2M0L3Y 217291e531 implement pino poc 2026-10-21 19:09:28 -07:00
JT2M0L3Y 99db6d8163 Update: fix imports based on last pkg fix 2026-09-23 10:57:14 -07:00
JT2M0L3Y 3a770be5de Added: defined objects directory 2026-09-23 10:24:07 -07:00
JT2M0L3Y f8d8fe765f Update: utility method logs use method name 2026-09-23 10:24:05 -07:00
Kevin Dang 2c25f7f500 Upgrade Node to Krypton (#200)
* node 24 and vulnerabilities

* ready is clientReady

* better about section
2026-09-23 09:52:28 -07:00
Eric Curtin 0beaed85aa Document llmman and rename OLLAMA_IP/PORT to LLM_ENDPOINT/PORT (#198)
llmman (https://github.com/llmmanorg/llmman) serves the Ollama API on
port 17434 and works with the bot as-is, so document it in setup-local
and link it from the README. Since the backend is no longer Ollama-only,
rename the env vars to LLM_ENDPOINT / LLM_PORT as requested in review.
OLLAMA_IP / OLLAMA_PORT are kept as deprecated aliases in keys.ts and
docker-compose.yml so existing deployments keep working.
2026-09-10 17:42:42 -07:00
Kevin Dang 69637722db Separate Test File for Fork PRs (#199) 2026-09-05 07:31:59 -07:00
Jonathan Smoley 3d6d651e18 drop refs to coverage (#197) 2026-07-17 11:30:46 -07:00
Jonathan Smoley 7583d6d179 Fix: Code Coverage Badge Failed (#196)
* switch coverage to codecov

* reset node, increment ver

* remove auto-pass job var

* remove coverage 'env' note

* set badge with new action

* add fqvn for action

* fix step name

* make badge commit explicit
2026-07-16 17:44:11 -07:00
Kevin Dang 4aadea4611 Summary Command (#194) 2026-06-02 15:34:48 -07:00
Kevin Dang c0e29b3bbe Pipeline Trigger Change and Newline Format (#193) 2026-05-11 11:42:48 -07:00
Kevin Dang 32137dacb0 Add: Filter out think tags in bot message (#190) 2025-10-04 20:17:26 -07:00
Kevin Dang c00ea5de98 Additional Channel Awareness (#186) 2025-07-31 19:04:51 -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
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
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 Smoleyandsnyk-bot 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 DangandJT2M0L3Y 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 SmoleyandKevin Dang 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 Smoleyandsnyk-bot 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
58 changed files with 2644 additions and 2777 deletions
+6 -4
View File
@@ -4,11 +4,13 @@ CLIENT_TOKEN = BOT_TOKEN
# Default model for new users
MODEL = DEFAULT_MODEL
# ip/port address of docker container, I use 172.18.0.3 for docker, 127.0.0.1 for local
OLLAMA_IP = IP_ADDRESS
OLLAMA_PORT = PORT
# ip/port address of the LLM server (ollama, llmman, ...), I use 172.18.0.3 for docker, 127.0.0.1 for local
# ollama listens on 11434, llmman (https://github.com/llmmanorg/llmman) on 17434
# OLLAMA_IP / OLLAMA_PORT are still accepted as deprecated aliases
LLM_ENDPOINT = IP_ADDRESS
LLM_PORT = PORT
# ip address for discord bot container, I use 172.18.0.2, use different IP than ollama_ip
# ip address for discord bot container, I use 172.18.0.2, use different IP than llm_endpoint
DISCORD_IP = IP_ADDRESS
# subnet address, ex. 172.18.0.0 as we use /16.
+2 -2
View File
@@ -8,10 +8,10 @@ assignees: ''
---
## Issue
A clear and concise description of what the problem/feature is.
A clear and concise description of what the problem/feature is. PLease describe it as best as possible.
## Solution
* Provide steps or ideals to how to implement or investigate this new feature.
* Provide steps or ideas to how to implement or investigate this new feature.
## References
* Provide additional context and external references here
+15 -11
View File
@@ -1,5 +1,6 @@
name: Builds
run-name: Validate Node and Docker Builds
on:
push:
branches:
@@ -9,15 +10,16 @@ jobs:
Discord-Node-Build: # test if the node install and run
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node Environment lts/hydrogen
- name: Set up Node Environment
uses: actions/setup-node@v4
with:
node-version: lts/hydrogen
cache: "npm"
node-version: lts/krypton
cache: npm
- name: Install Project Dependencies
run: |
@@ -31,8 +33,9 @@ jobs:
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 LLM_ENDPOINT = ${{ secrets.OLLAMA_IP }} >> .env
echo LLM_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
# set -e ensures if nohup fails, this section fails
- name: Startup Discord Bot Client
@@ -42,23 +45,24 @@ jobs:
Discord-Ollama-Container-Build: # test docker build and run
runs-on: ubuntu-latest
timeout-minutes: 2
timeout-minutes: 3
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node Environment lts/hydrogen
- name: Set up Node Environment
uses: actions/setup-node@v4
with:
node-version: lts/hydrogen
cache: "npm"
node-version: lts/krypton
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 LLM_ENDPOINT = ${{ secrets.OLLAMA_IP }} >> .env
echo LLM_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
- name: Setup Docker Network and Images
run: |
-49
View File
@@ -1,49 +0,0 @@
name: Coverage
run-name: Code Coverage
on:
push:
branches:
- master
jobs:
Discord-Node-Coverage:
runs-on: ubuntu-latest
environment: coverage
timeout-minutes: 2
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node Environment lts/hydrogen
uses: actions/setup-node@v4
with:
node-version: lts/hydrogen
cache: "npm"
- name: Install Project Dependencies
run: |
npm install
- name: Create Environment Variables
run: |
touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
- name: Collect Code Coverage
run: |
LINE_PCT=$(npm run test:coverage | tail -2 | head -1 | awk '{print $3}')
echo "COVERAGE=$LINE_PCT" >> $GITHUB_ENV
- name: Upload Code Coverage
uses: schneegans/dynamic-badges-action@v1.7.0
with:
auth: ${{ secrets.GIST_SECRET }}
gistID: ${{ vars.GIST_ID }}
filename: coverage.json
label: Coverage
message: ${{ env.COVERAGE }}
valColorRange: ${{ env.COVERAGE }}
maxColorRange: 100
minColorRange: 0
+108
View File
@@ -0,0 +1,108 @@
name: Deploy
run-name: Deploy Application Latest
on: workflow_dispatch
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 LLM_ENDPOINT = ${{ secrets.OLLAMA_IP }} >> .env
echo LLM_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
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
View File
@@ -1,48 +0,0 @@
name: Deploy
run-name: Release Docker Image
on:
push:
tags:
- 'v*'
jobs:
Release-Docker-Image:
runs-on: ubuntu-latest
environment: release
timeout-minutes: 3
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node Environment lts/hydrogen
uses: actions/setup-node@v4
with:
node-version: lts/hydrogen
cache: "npm"
- name: Create Environment Variables
run: |
touch .env
echo CLIENT_TOKEN = NOT_REAL_TOKEN >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
- name: Get Version from package.json
run: echo "VERSION=$(jq -r '.version' package.json)" >> $GITHUB_ENV
- name: Build Image
run: |
npm run docker:build
- name: Build Image as Latest
run: |
npm run docker:build-latest
- name: Log into Docker
run: |
docker login --username ${{ vars.DOCKER_USER }} --password ${{ secrets.DOCKER_PASS }}
- name: Release Docker Image
run: |
docker push ${{ vars.DOCKER_USER }}/discord-ollama:${{ env.VERSION }}
docker push ${{ vars.DOCKER_USER }}/discord-ollama:latest
+47
View File
@@ -0,0 +1,47 @@
name: Tests (Fork PRs)
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-Fork:
if: ${{ github.event.pull_request.head.repo.fork == true }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 'lts/jod'
cache: npm
- name: Install Project Dependencies
run: |
npm install
- name: Create Dummy Env
run: |
echo CLIENT_TOKEN=test-token >> .env
echo LLM_ENDPOINT=127.0.0.1 >> .env
echo LLM_PORT=11434 >> .env
echo MODEL=dummy-model >> .env
- name: Test Application
run: |
npm run tests
+10 -6
View File
@@ -1,5 +1,6 @@
name: Tests
run-name: Unit Tests
on:
pull_request:
branches:
@@ -19,17 +20,19 @@ on:
jobs:
Discord-Node-Test:
if: ${{ github.event.pull_request.head.repo.fork == false }}
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node Environment lts/hydrogen
- name: Set up Node Environment
uses: actions/setup-node@v4
with:
node-version: lts/hydrogen
cache: "npm"
node-version: lts/jod
cache: npm
- name: Install Project Dependencies
run: |
@@ -39,9 +42,10 @@ jobs:
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 LLM_ENDPOINT = ${{ secrets.OLLAMA_IP }} >> .env
echo LLM_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
- name: Test Application
run: |
npm run test:run
npm run tests
-10
View File
@@ -37,16 +37,6 @@ pids
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
+2 -2
View File
@@ -11,8 +11,8 @@
# Technical/Business Code Ownership
/src/ @kevinthedang @JT2M0L3Y
/tests/ @kevinthedang @JT2M0L3Y
/.github/ @kevinthedang
/tests/ @JT2M0L3Y
/.github/ @kevinthedang @JT2M0L3Y
# Docker Ownership
Dockerfile @kevinthedang
+2 -2
View File
@@ -1,5 +1,5 @@
# use node LTS image for version 18
FROM node:hydrogen-alpine
# use node LTS image for version 24
FROM node:krypton-alpine
# set working directory inside container
WORKDIR /app
+34 -27
View File
@@ -4,42 +4,48 @@
<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/release.yml"><img alt="Release Status" src="https://github.com/kevinthedang/discord-ollama/actions/workflows/release.yml/badge.svg" /></a>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/actions/workflows/test.yml"><img alt="Testing Status" src="https://github.com/kevinthedang/discord-ollama/actions/workflows/test.yml/badge.svg" /></a>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/actions/workflows/coverage.yml"><img alt="Code Coverage" src="https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/kevinthedang/bc7b5dcfa16561ab02bb3df67a99b22d/raw/coverage.json"></a>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/actions/workflows/build.yml"><img alt="Builds" 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/test.yml"><img alt="Tests" src="https://github.com/kevinthedang/discord-ollama/actions/workflows/test.yml/badge.svg" /></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 Preferences on Chat
* [x] Message Persistance on Channels and Threads
* [x] Threads
* [x] Channels
* [x] Containerization with Docker
* [x] Slash Commands Compatible
* [x] Generated Token Length Handling for >2000
* [x] Token Length Handling of any message size
* [x] User vs. Server Preferences
* [ ] Redis Caching
* [x] Administrator Role Compatible
* [x] Multi-User Chat Generation (Multiple users chatting at the same time) - This was built in from Ollama `v0.2.1+`
* [x] Automatic and Manual model pulling through the Discord client
* [ ] Allow others to create their own models personalized for their own servers!
* [ ] Documentation on creating your own LLM
* [ ] Documentation on web scrapping and cleaning
## About
Discord Ollama is a Discord bot that lets users interact with locally hosted
Ollama language models directly from Discord.
The bot supports model management, streamed responses, conversation history,
private threads, and configurable server controls. Because Ollama runs locally,
you retain control over the models and data used by the bot.
See the [setup guides](./docs/setup-local.md) to get started.
## Documentation
These are guides to the feature set included and the events triggered in this app.
These are guides to the features and capabilities of this app.
* [User Slash Commands](./docs/commands-guide.md)
* [Client Events](./docs/events-guide.md)
## Logging
The application uses Pino child loggers with structured component metadata.
Outside production, `pino-caller` adds the source file and line to each log
entry. Set `LOG_LEVEL` to configure the minimum level (defaults to `info`).
```ts
import { createLogger } from './components/index.js'
const logger = createLogger({ component: 'request', requestId })
logger.info('Request started')
```
Log entries are JSON and include the structured fields provided to the log
method, along with `component` and (outside production) `caller`. The root Pino
instance is shared; create one child when entering a context, then pass that
same child logger through the context's work. Create another child only when
you need to add more scoped bindings; child creation does not mutate its parent.
## 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]`
* The LLM server is configured with `LLM_ENDPOINT` / `LLM_PORT` (`OLLAMA_IP` / `OLLAMA_PORT` still work as deprecated aliases).
* Please refer to the docs for bot setup.
* [Creating a Discord App](./docs/setup-discord-app.md)
* [Local Machine Setup](./docs/setup-local.md)
@@ -49,10 +55,11 @@ These are guides to the feature set included and the events triggered in this ap
## Resources
* [NodeJS](https://nodejs.org/en)
* This project runs on `lts\hydrogen`.
* This project supports any NodeJS version above `16.x.x` to only allow ESModules.
* This project runs on `lts\jod` and above.
* This project requires the use of npm version `10.9.0` or above.
* [Ollama](https://ollama.com/)
* [Ollama Docker Image](https://hub.docker.com/r/ollama/ollama)
* [llmman](https://github.com/llmmanorg/llmman) also serves the Ollama API (on port `17434`) and can be used in its place, see [Local Machine Setup](./docs/setup-local.md#using-with-llmman-alternative-to-ollama).
* [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)
+6 -8
View File
@@ -7,11 +7,12 @@ services:
build: ./ # find docker file in designated path
container_name: discord
restart: always # rebuild container always
image: kevinthedang/discord-ollama:0.7.5
image: kevinthedang/discord-ollama:0.9.3
environment:
CLIENT_TOKEN: ${CLIENT_TOKEN}
OLLAMA_IP: ${OLLAMA_IP}
OLLAMA_PORT: ${OLLAMA_PORT}
LLM_ENDPOINT: ${LLM_ENDPOINT:-${OLLAMA_IP}} # OLLAMA_IP is a deprecated alias
LLM_PORT: ${LLM_PORT:-${OLLAMA_PORT}} # OLLAMA_PORT is a deprecated alias
MODEL: ${MODEL}
networks:
ollama-net:
ipv4_address: ${DISCORD_IP}
@@ -25,17 +26,14 @@ services:
restart: always
networks:
ollama-net:
ipv4_address: ${OLLAMA_IP}
ipv4_address: ${LLM_ENDPOINT:-${OLLAMA_IP}}
runtime: nvidia # use Nvidia Container Toolkit for GPU support
devices:
- /dev/nvidia0
volumes:
- ollama:/root/.ollama
ports:
- ${OLLAMA_PORT}:${OLLAMA_PORT}
# Put Redis Container here?
- ${LLM_PORT:-${OLLAMA_PORT}}:${LLM_PORT:-${OLLAMA_PORT}}
# create a network that supports giving addresses withing a specific subnet
networks:
+1 -2
View File
@@ -78,8 +78,7 @@ This is a guide to all of the slash commands for the app.
```
/message-stream stream true
```
> [!NOTE]
> This is a very slow progress on Discord because "spamming" changes within 5 seconds is not allowed.
**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.
+3 -3
View File
@@ -1,7 +1,9 @@
## 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.
> [!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.
@@ -11,8 +13,6 @@ This is a guide to all of the client events for the app.
This event signifies that a user interacted from Discord in some way.
Here commands are selected from a knowledge bank and executed if found.
> [!NOTE] Possible interactions include commands, buttons, menus, etc.
3. MessageCreate
This event signifies that a message was sent.
Here user questions and comments for the LLM are processed.
+4 -4
View File
@@ -43,11 +43,11 @@ sudo systemctl restart docker
* [GitHub repository](https://github.com/NVIDIA/nvidia-container-toolkit?tab=readme-ov-file) for Nvidia Container Toolkit
## To Run (with Docker and Docker Compose)
* With the inclusion of subnets in the `docker-compose.yml`, you will need to set the `SUBNET_ADDRESS`, `OLLAMA_IP`, `OLLAMA_PORT`, and `DISCORD_IP`. Here are some default values if you don't care:
* `OLLAMA_IP = 172.18.0.2`
* `OLLAMA_PORT = 11434`
* `DISCORD_IP = 172.18.0.3`
* With the inclusion of subnets in the `docker-compose.yml`, you will need to set the `SUBNET_ADDRESS`, `LLM_ENDPOINT`, `LLM_PORT`, and `DISCORD_IP`. Here are some default values if you don't care:
* `SUBNET_ADDRESS = 172.18.0.0`
* `LLM_ENDPOINT = 172.18.0.2`
* `LLM_PORT = 11434`
* `DISCORD_IP = 172.18.0.3`
* Don't understand any of this? watch a Networking video to understand subnetting.
* You also need all environment variables shown in [`.env.sample`](../.env.sample)
* 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.
+10 -3
View File
@@ -14,11 +14,18 @@
> [!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)
## Using with llmman (alternative to Ollama)
* [llmman](https://github.com/llmmanorg/llmman) is a local model runner that serves the Ollama API on port `17434`. The bot talks to it exactly as it would to Ollama, only the port differs.
* Install it with `curl -fsSL https://raw.githubusercontent.com/llmmanorg/llmman/main/install.sh | sh` (Linux/macOS) or `irm https://raw.githubusercontent.com/llmmanorg/llmman/main/install.ps1 | iex` (Windows).
* Start the server with `llmman serve` and pull a model with `llmman pull [model name]`, for example `llmman pull gemma4` or `llmman pull hf.co/unsloth/Qwen3.5-0.8B-GGUF`.
* Point the bot at it by setting `LLM_ENDPOINT = 127.0.0.1` and `LLM_PORT = 17434` in your `.env`, then set `MODEL` to a model you pulled. `/pull-model`, `/switch-model` and `/delete-model` work the same way.
## 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`.
* Ensure that your [.env](../.env.sample) file's `LLM_ENDPOINT` is `127.0.0.1` to work properly.
* You only need your `CLIENT_TOKEN`, `LLM_ENDPOINT`, `LLM_PORT`.
* The LLM server ip and port should just use it's defaults by nature. If not, utilize `LLM_ENDPOINT = 127.0.0.1` and `LLM_PORT = 11434`.
* The older `OLLAMA_IP` / `OLLAMA_PORT` names are still accepted as deprecated aliases.
* 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.
+1463 -2252
View File
File diff suppressed because it is too large Load Diff
+13 -12
View File
@@ -1,12 +1,11 @@
{
"name": "discord-ollama",
"version": "0.7.5",
"version": "0.9.3",
"description": "Ollama Integration into discord",
"main": "build/index.js",
"exports": "./build/index.js",
"scripts": {
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"tests": "vitest run",
"watch": "tsx watch src",
"build": "tsc",
"prod": "node .",
@@ -26,20 +25,22 @@
"author": "Kevin Dang",
"license": "ISC",
"dependencies": {
"discord.js": "^14.16.3",
"dotenv": "^16.4.5",
"ollama": "^0.5.9"
"discord.js": "^14.20.0",
"dotenv": "^16.5.0",
"ollama": "^0.5.15",
"pino": "^10.4.0",
"pino-caller": "^4.0.0"
},
"devDependencies": {
"@types/node": "^22.9.0",
"@vitest/coverage-v8": "^2.1.4",
"@types/node": "^22.13.14",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",
"typescript": "^5.6.3",
"vitest": "^2.1.4"
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vitest": "^5.0.1"
},
"type": "module",
"engines": {
"node": ">=16.0.0"
"npm": ">=10.9.0",
"node": ">=24.0.0"
}
}
+8 -4
View File
@@ -1,10 +1,11 @@
import { Client, GatewayIntentBits } from 'discord.js'
import { Ollama } from 'ollama'
import { Queue } from './queues/queue.js'
import { Queue, createLogger } from './components/index.js'
import { UserMessage, registerEvents } from './utils/index.js'
import Events from './events/index.js'
import Keys from './keys.js'
const logger = createLogger({ component: 'client' })
// initialize the client with the following permissions when logging in
const client = new Client({
@@ -14,7 +15,7 @@ const client = new Client({
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent
]
});
})
// initialize connection to ollama container
export const ollama = new Ollama({
@@ -24,13 +25,16 @@ export const ollama = new Ollama({
// Create Queue managed by Events
const messageHistory: Queue<UserMessage> = new Queue<UserMessage>
// Create Channel History Queue managed by Events
const channelMessageHistory: Queue<UserMessage> = new Queue<UserMessage>
// register all events
registerEvents(client, Events, messageHistory, ollama, Keys.defaultModel)
registerEvents(client, Events, messageHistory, channelMessageHistory, ollama, Keys.defaultModel)
// Try to log in the client
await client.login(Keys.clientToken)
.catch((error) => {
console.error('[Login Error]', error)
logger.fatal({ err: error }, 'Client login failed')
process.exit(1)
})
+7 -4
View File
@@ -1,5 +1,6 @@
import { Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { Client, ChatInputCommandInteraction, ApplicationCommandOptionType, MessageFlags } from 'discord.js'
import { openConfig, SlashCommand, UserCommand } from '../utils/index.js'
import type { Logger as PinoLogger } from 'pino'
export const Capacity: SlashCommand = {
name: 'modify-capacity',
@@ -16,17 +17,19 @@ export const Capacity: SlashCommand = {
],
// Query for message information and set the style
run: async (client: Client, interaction: CommandInteraction) => {
run: async (client: Client, interaction: ChatInputCommandInteraction, logger: PinoLogger) => {
// 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.get('context-capacity')?.value)
openConfig(logger, `${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}\``,
ephemeral: true
flags: MessageFlags.Ephemeral
})
}
}
+10 -6
View File
@@ -1,12 +1,13 @@
import { Channel, Client, CommandInteraction, TextChannel } from 'discord.js'
import { Channel, Client, CommandInteraction, MessageFlags, TextChannel } from 'discord.js'
import { clearChannelInfo, SlashCommand, UserCommand } from '../utils/index.js'
import type { Logger as PinoLogger } from 'pino'
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) => {
run: async (client: Client, interaction: CommandInteraction, logger: PinoLogger) => {
// fetch current channel
const channel: Channel | null = await client.channels.fetch(interaction.channelId)
@@ -14,20 +15,23 @@ export const ClearUserChannelHistory: SlashCommand = {
if (!channel || !UserCommand.includes(channel.type)) return
// clear channel info for user
const successfulWipe = await clearChannelInfo(interaction.channelId,
const successfulWipe = await clearChannelInfo(
logger,
interaction.channelId,
interaction.channel as TextChannel,
interaction.user.username)
interaction.user.username
)
// check result of clearing history
if (successfulWipe)
interaction.reply({
content: `History cleared in **this channel** cleared for **${interaction.user.username}**.`,
ephemeral: true
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.`,
ephemeral: true
flags: MessageFlags.Ephemeral
})
}
}
+74
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'
import type { Logger as PinoLogger } from 'pino'
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, logger: PinoLogger) => {
// 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
logger.error({ err: error }, 'Failed to connect to Ollama service')
})
// 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
})
}
}
}
+9 -6
View File
@@ -1,5 +1,6 @@
import { Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { Client, ChatInputCommandInteraction, ApplicationCommandOptionType, MessageFlags } from 'discord.js'
import { AdminCommand, openConfig, SlashCommand } from '../utils/index.js'
import type { Logger as PinoLogger } from 'pino'
export const Disable: SlashCommand = {
name: 'toggle-chat',
@@ -16,7 +17,7 @@ export const Disable: SlashCommand = {
],
// Query for message information and set the style
run: async (client: Client, interaction: CommandInteraction) => {
run: async (client: Client, interaction: ChatInputCommandInteraction, logger: PinoLogger) => {
// fetch channel and message
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !AdminCommand.includes(channel.type)) return
@@ -25,17 +26,19 @@ export const Disable: SlashCommand = {
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.`,
ephemeral: true
flags: MessageFlags.Ephemeral
})
return
}
// set state of bot chat features
openConfig(`${interaction.guildId}-config.json`, interaction.commandName, interaction.options.get('enabled')?.value)
openConfig(logger, `${interaction.guildId}-config.json`, interaction.commandName,
interaction.options.getBoolean('enabled')
)
interaction.reply({
content: `${client.user?.username} is now **${interaction.options.get('enabled')?.value ? "enabled" : "disabled" }**.`,
ephemeral: true
content: `${client.user?.username} is now **${interaction.options.getBoolean('enabled') ? "enabled" : "disabled"}**.`,
flags: MessageFlags.Ephemeral
})
}
}
+5 -1
View File
@@ -8,6 +8,8 @@ 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'
import { Summary } from './summary.js'
export default [
ThreadCreate,
@@ -18,5 +20,7 @@ export default [
Capacity,
ClearUserChannelHistory,
PullModel,
SwitchModel
SwitchModel,
DeleteModel,
Summary
] as SlashCommand[]
+8 -5
View File
@@ -1,5 +1,6 @@
import { ApplicationCommandOptionType, Client, CommandInteraction } from 'discord.js'
import { ApplicationCommandOptionType, Client, ChatInputCommandInteraction, MessageFlags } from 'discord.js'
import { openConfig, SlashCommand, UserCommand } from '../utils/index.js'
import type { Logger as PinoLogger } from 'pino'
export const MessageStream: SlashCommand = {
name: 'message-stream',
@@ -16,17 +17,19 @@ export const MessageStream: SlashCommand = {
],
// change preferences based on command
run: async (client: Client, interaction: CommandInteraction) => {
run: async (client: Client, interaction: ChatInputCommandInteraction, logger: PinoLogger) => {
// verify channel
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !UserCommand.includes(channel.type)) return
// save value to json and write to it
openConfig(`${interaction.user.username}-config.json`, interaction.commandName, interaction.options.get('stream')?.value)
openConfig(logger, `${interaction.user.username}-config.json`, interaction.commandName,
interaction.options.getBoolean('stream')
)
interaction.reply({
content: `Message streaming is now set to: \`${interaction.options.get('stream')?.value}\``,
ephemeral: true
content: `Message streaming is now set to: \`${interaction.options.getBoolean('stream')}\``,
flags: MessageFlags.Ephemeral
})
}
}
+25 -11
View File
@@ -1,12 +1,12 @@
import { ApplicationCommandOptionType, Client, CommandInteraction } from "discord.js";
import { SlashCommand } from "../utils/commands.js";
import { ollama } from "../client.js";
import { ModelResponse } from "ollama";
import { UserCommand } from "../utils/index.js";
import { ApplicationCommandOptionType, Client, ChatInputCommandInteraction, MessageFlags } from "discord.js"
import { ollama } from "../client.js"
import { ModelResponse } from "ollama"
import { UserCommand, SlashCommand } from "../utils/index.js"
import type { Logger as PinoLogger } from 'pino'
export const PullModel: SlashCommand = {
name: 'pull-model',
description: 'pulls a model from the ollama model library. Administrator Only',
description: 'pulls a model from the ollama model library. Administrator Only.',
// set available user options to pass to the command
options: [
@@ -19,10 +19,11 @@ export const PullModel: SlashCommand = {
],
// Pull for model from Ollama library
run: async (client: Client, interaction: CommandInteraction) => {
run: async (client: Client, interaction: ChatInputCommandInteraction, logger: PinoLogger) => {
// defer reply to avoid timeout
await interaction.deferReply()
const modelInput: string = interaction.options.get('model-to-pull')!!.value as string
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)
@@ -32,14 +33,27 @@ export const PullModel: SlashCommand = {
if (!interaction.memberPermissions?.has('Administrator')) {
interaction.reply({
content: `${interaction.commandName} is an admin command.\n\nPlease contact a server admin to pull the model you want.`,
ephemeral: true
flags: MessageFlags.Ephemeral
})
return
}
// check if model was already pulled
const modelExists: boolean = await ollama.list()
// 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
logger.error({ err: error }, 'Failed to connect to Ollama service')
})
// 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
+9 -8
View File
@@ -1,25 +1,25 @@
import { Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { SlashCommand } from '../utils/commands.js'
import { AdminCommand } from '../utils/index.js'
import { Client, CommandInteraction, MessageFlags } from 'discord.js'
import { AdminCommand, SlashCommand } from '../utils/index.js'
import type { Logger as PinoLogger } from 'pino'
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) => {
run: async (client: Client, interaction: CommandInteraction, logger: PinoLogger) => {
// 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}`)
logger.info({ actor: interaction.user.tag, bot: client.user!!.tag }, 'Shutdown requested')
// check if admin or false on shutdown
if (!interaction.memberPermissions?.has('Administrator')) {
interaction.reply({
content: `**Shutdown Aborted:**\n\n${interaction.user.tag}, You do not have permission to shutoff **${client.user?.tag}**.`,
ephemeral: true
flags: MessageFlags.Ephemeral
})
return // stop from shutting down
}
@@ -27,9 +27,10 @@ export const Shutoff: SlashCommand = {
// Shutoff cleared, do it
interaction.reply({
content: `${client.user?.tag} is shutting down.`,
ephemeral: true
flags: MessageFlags.Ephemeral
})
console.log(`[Command: shutoff] ${client.user?.tag} is shutting down.`)
logger.info({ bot: client.user?.tag }, 'Bot is shutting down')
// clean up client instance and stop
client.destroy()
+51
View File
@@ -0,0 +1,51 @@
import { Client, CommandInteraction, MessageFlags } from "discord.js";
import { accessChannelContext, SlashCommand, summarizeContextHistory, UserCommand, UserMessage } from "../utils/index.js";
import type { Logger as PinoLogger } from 'pino'
import { ollama } from "../client.js"
export const Summary: SlashCommand = {
name: 'summary',
description: 'provides a summary of the chat history.',
// Generate Summary from additional context
run: async (client: Client, interaction: CommandInteraction, logger: PinoLogger) => {
// fetch channel
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !UserCommand.includes(channel.type)) return
// Defer
await interaction.deferReply({ flags: MessageFlags.Ephemeral })
// create summary using context
let channelContext: UserMessage[] = await new Promise((resolve) => {
accessChannelContext(interaction.channelId, (additionalContext) => {
if (additionalContext?.messages)
resolve(additionalContext.messages)
else
resolve([])
})
})
if (channelContext.length === 0) {
interaction.reply({
content: `There are no recent chat messages in this channel.`,
flags: MessageFlags.Ephemeral
})
return
}
// Push summarize prompt
channelContext.push({
role: "user",
content: "Please Summarize everything from the prior messages, provide in bullet point fashion on what people have said prior. For example: \"Someone mentioned/talked about ...\"",
images: []
})
// todo: instead of a default of codellama, we can user default model somehow. Look into later.
const response: string = await summarizeContextHistory(logger, ollama, "codellama", channelContext)
logger.info({ response }, 'Generated channel summary')
interaction.editReply({ content: response })
}
}
+17 -11
View File
@@ -1,8 +1,8 @@
import { ApplicationCommandOptionType, Client, CommandInteraction } from "discord.js";
import { SlashCommand } from "../utils/commands.js";
import { ollama } from "../client.js";
import { ModelResponse } from "ollama";
import { openConfig, UserCommand } from "../utils/index.js";
import { ApplicationCommandOptionType, Client, ChatInputCommandInteraction } from "discord.js"
import { ollama } from "../client.js"
import { ModelResponse } from "ollama"
import { openConfig, UserCommand, SlashCommand } from "../utils/index.js"
import type { Logger as PinoLogger } from 'pino'
export const SwitchModel: SlashCommand = {
name: 'switch-model',
@@ -19,10 +19,10 @@ export const SwitchModel: SlashCommand = {
],
// Switch user preferred model if available in local library
run: async (client: Client, interaction: CommandInteraction) => {
run: async (client: Client, interaction: ChatInputCommandInteraction, logger: PinoLogger) => {
await interaction.deferReply()
const modelInput: string = interaction.options.get('model-to-use')!!.value as string
const modelInput: string = interaction.options.getString('model-to-use') as string
// fetch channel and message
const channel = await client.channels.fetch(interaction.channelId)
@@ -36,7 +36,7 @@ export const SwitchModel: SlashCommand = {
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)
openConfig(logger, `${interaction.user.username}-config.json`, interaction.commandName, modelInput)
// successful switch
interaction.editReply({
@@ -46,10 +46,13 @@ export const SwitchModel: SlashCommand = {
}
}
})
.catch(error => {
logger.error({ err: error }, 'Failed to connect to Ollama service')
})
// 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)
openConfig(logger, `${interaction.user.username}-config.json`, interaction.commandName, modelInput)
return
}
@@ -57,10 +60,13 @@ export const SwitchModel: SlashCommand = {
interaction.editReply({
content: `Could not find **${modelInput}** in local model library.\n\nPlease contact an server admin for access to this model.`
})
} catch (error) {
} 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}\n\nPossible solution is to request an server admin run \`/pull-model ${modelInput}\` and try again.`
content: `Unable to switch user preferred model to **${modelInput}**.\n\n${error.message}`
})
return
}
+5 -6
View File
@@ -1,12 +1,13 @@
import { ChannelType, Client, CommandInteraction, TextChannel, ThreadChannel } from 'discord.js'
import { ChannelType, Client, CommandInteraction, MessageFlags, TextChannel, ThreadChannel } from 'discord.js'
import { AdminCommand, openChannelInfo, SlashCommand } from '../utils/index.js'
import type { Logger as PinoLogger } from 'pino'
export const ThreadCreate: SlashCommand = {
name: 'thread',
description: 'creates a thread and mentions user',
// Query for server information
run: async (client: Client, interaction: CommandInteraction) => {
run: async (client: Client, interaction: CommandInteraction, logger: PinoLogger) => {
// fetch the channel
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !AdminCommand.includes(channel.type)) return
@@ -21,14 +22,12 @@ export const ThreadCreate: SlashCommand = {
thread.send(`Hello ${interaction.user} and others! \n\nIt's nice to meet you. Please talk to me by typing **@${client.user?.username}** with your message.`)
// handle storing this chat channel
openChannelInfo(thread.id,
thread as ThreadChannel,
interaction.user.tag)
openChannelInfo(logger, thread.id, thread as ThreadChannel, interaction.user.tag)
// user only reply
return interaction.reply({
content: `I can help you in <#${thread.id}> below.`,
ephemeral: true
flags: MessageFlags.Ephemeral
})
}
}
+5 -7
View File
@@ -1,12 +1,13 @@
import { ChannelType, Client, CommandInteraction, TextChannel, ThreadChannel } from 'discord.js'
import { ChannelType, Client, CommandInteraction, MessageFlags, TextChannel, ThreadChannel } from 'discord.js'
import { AdminCommand, openChannelInfo, SlashCommand } from '../utils/index.js'
import type { Logger as PinoLogger } from 'pino'
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) => {
run: async (client: Client, interaction: CommandInteraction, logger: PinoLogger) => {
// fetch the channel
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !AdminCommand.includes(channel.type)) return
@@ -22,15 +23,12 @@ export const PrivateThreadCreate: SlashCommand = {
// handle storing this chat channel
// store: thread.id, thread.name
openChannelInfo(thread.id,
thread as ThreadChannel,
interaction.user.tag
)
openChannelInfo(logger, thread.id, thread as ThreadChannel, interaction.user.tag)
// user only reply
return interaction.reply({
content: `I can help you in <#${thread.id}>.`,
ephemeral: true
flags: MessageFlags.Ephemeral
})
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './queue.js'
export * from './logger.js'
+15
View File
@@ -0,0 +1,15 @@
import 'dotenv/config'
import pino, { type Logger as PinoLogger } from 'pino'
import { pinoCaller } from 'pino-caller'
export const rootLogger = pino({
level: process.env.LOG_LEVEL ?? 'info'
})
export function createLogger(bindings: pino.Bindings, parent: PinoLogger = rootLogger): PinoLogger {
const childLogger = parent.child(bindings)
return process.env.NODE_ENV === 'production'
? childLogger
: pinoCaller(childLogger, { relativeTo: process.cwd() })
}
+10 -3
View File
@@ -1,19 +1,26 @@
import { event, Events } from '../utils/index.js'
import commands from '../commands/index.js'
import { createLogger } from '../components/index.js'
/**
* Interaction creation listener for the client
* @param interaction the interaction received from the server
*/
export default event(Events.InteractionCreate, async ({ log, client }, interaction) => {
export default event(Events.InteractionCreate, async ({ logger, client }, interaction) => {
if (!interaction.isCommand() || !interaction.isChatInputCommand()) return
log(`Interaction called \'${interaction.commandName}\' from ${interaction.user.tag}.`)
logger.info({ command: interaction.commandName, user: interaction.user.tag }, 'Interaction received')
// ensure command exists, otherwise kill event
const command = commands.find(command => command.name === interaction.commandName)
if (!command) return
// the command exists, execute it
command.run(client, interaction)
const commandLogger = createLogger({
component: 'command',
command: command.name
}, logger)
void Promise.resolve(command.run(client, interaction, commandLogger))
.catch(error => commandLogger.error({ err: error }, 'Command execution failed'))
})
+75 -21
View File
@@ -1,6 +1,9 @@
import { TextChannel } from 'discord.js'
import { event, Events, normalMessage, UserMessage, clean } from '../utils/index.js'
import { getChannelInfo, getServerConfig, getUserConfig, openChannelInfo, openConfig, UserConfig, getAttachmentData } from '../utils/index.js'
import {
event, Events, normalMessage, UserMessage, clean, addToChannelContext,
getChannelInfo, getServerConfig, getUserConfig, openChannelInfo,
openConfig, UserConfig, getAttachmentData, getTextFileAttachmentData
} from '../utils/index.js'
/**
* Max Message length for free users is 2000 characters (bot or not).
@@ -8,14 +11,68 @@ import { getChannelInfo, getServerConfig, getUserConfig, openChannelInfo, openCo
*
* @param message the message received from the channel
*/
export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client, defaultModel }, message) => {
export default event(Events.MessageCreate, async ({ logger, msgHist, channelHistory, ollama, client, defaultModel }, message) => {
const clientId = client.user!!.id
const cleanedMessage = clean(message.content, clientId)
log(`Message \"${cleanedMessage}\" from ${message.author.tag} in channel/thread ${message.channelId}.`)
let cleanedMessage = clean(message.content, clientId)
logger.info({ author: message.author.tag, channelId: message.channelId, message: cleanedMessage }, 'Received message')
// Do not respond if bot talks in the chat
if (message.author.username === message.client.user.username) return
// Save User Chat even if not for the bot
let channelContextHistory: UserMessage[] = await new Promise((resolve) => {
getChannelInfo(`${message.channelId}-context.json`, (channelInfo) => {
if (channelInfo?.messages)
resolve(channelInfo.messages)
else {
logger.info({ channelId: message.channelId }, 'Channel context does not exist; creating it')
resolve([])
}
})
})
if (channelContextHistory.length === 0) {
channelContextHistory = await new Promise((resolve) => {
addToChannelContext(logger, message.channelId,
message.channel as TextChannel
)
getChannelInfo(`${message.channelId}-context.json`, (channelInfo) => {
if (channelInfo?.messages)
resolve(channelInfo.messages)
else {
logger.info({ channelId: message.channelId }, 'Channel context does not exist; creating it')
}
})
})
}
// Set Channel History Queue
channelHistory.setQueue(channelContextHistory)
// 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)
while (channelHistory.size() >= channelHistory.capacity) channelHistory.dequeue()
// push user response to channel history
channelHistory.enqueue({
role: 'user',
content: cleanedMessage,
images: messageAttachment || []
})
// Store in Channel Context
addToChannelContext(logger, message.channelId,
message.channel as TextChannel,
channelHistory.getItems()
)
// Only respond if message mentions the bot
if (!message.mentions.has(clientId)) return
@@ -36,7 +93,7 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client
// check if config.json exists
if (config === undefined) {
// Allowing chat options to be available
openConfig(`${message.guildId}-config.json`, 'toggle-chat', true)
openConfig(logger, `${message.guildId}-config.json`, 'toggle-chat', true)
reject(new Error('Failed to locate or create Server Preferences\n\nPlease try chatting again...'))
}
@@ -51,7 +108,7 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client
} catch (error) {
++attempt
if (attempt < maxRetries) {
log(`Attempt ${attempt} failed for Server Preferences. Retrying in ${delay}ms...`)
logger.warn({ attempt, delay }, 'Failed to retrieve server preferences; retrying')
await new Promise(ret => setTimeout(ret, delay))
} else
throw new Error(`Could not retrieve Server Preferences, please try chatting again...`)
@@ -68,19 +125,18 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client
userConfig = await new Promise((resolve, reject) => {
getUserConfig(`${message.author.username}-config.json`, (config) => {
if (config === undefined) {
openConfig(`${message.author.username}-config.json`, 'message-style', false)
openConfig(`${message.author.username}-config.json`, 'switch-model', defaultModel)
reject(new Error('No User Preferences is set up.\n\nCreating preferences file with \`message-style\` set as \`false\` for regular message style.\nPlease try chatting again.'))
openConfig(logger, `${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}.`)
logger.info({ capacity: msgHist.capacity }, 'Capacity is undefined; using default')
else if (config.options['modify-capacity'] === msgHist.capacity)
log(`Capacity matches config as ${msgHist.capacity}, no changes made.`)
logger.info({ capacity: msgHist.capacity }, 'Configured capacity matches current capacity')
else {
log(`New Capacity found. Setting Context Capacity to ${config.options['modify-capacity']}.`)
logger.info({ capacity: config.options['modify-capacity'] }, 'Updating context capacity')
msgHist.capacity = config.options['modify-capacity']
}
@@ -97,7 +153,7 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client
} catch (error) {
++attempt
if (attempt < maxRetries) {
log(`Attempt ${attempt} failed for User Preferences. Retrying in ${delay}ms...`)
logger.warn({ attempt, delay }, 'Failed to retrieve user preferences; retrying')
await new Promise(ret => setTimeout(ret, delay))
} else
throw new Error(`Could not retrieve User Preferences, please try chatting again...`)
@@ -111,7 +167,7 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client
if (channelInfo?.messages)
resolve(channelInfo.messages)
else {
log(`Channel/Thread ${message.channel}-${message.author.username} does not exist. File will be created shortly...`)
logger.info({ channelId: message.channelId, username: message.author.username }, 'User channel history does not exist; creating it')
resolve([])
}
})
@@ -119,7 +175,7 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client
if (chatMessages.length === 0) {
chatMessages = await new Promise((resolve, reject) => {
openChannelInfo(message.channelId,
openChannelInfo(logger, message.channelId,
message.channel as TextChannel,
message.author.tag
)
@@ -127,7 +183,7 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client
if (channelInfo?.messages)
resolve(channelInfo.messages)
else {
log(`Channel/Thread ${message.channel}-${message.author.username} does not exist. File will be created shortly...`)
logger.info({ channelId: message.channelId, username: message.author.username }, 'User channel history does not exist; creating it')
reject(new Error(`Failed to find ${message.author.username}'s history. Try chatting again.`))
}
})
@@ -137,8 +193,6 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client
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 messageAttachment: string[] = await getAttachmentData(message.attachments.first())
const model: string = userConfig.options['switch-model']
// set up new queue
@@ -155,7 +209,7 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client
})
// response string for ollama to put its response
const response: string = await normalMessage(message, ollama, model, msgHist, shouldStream)
const response: string = await normalMessage(logger, message, ollama, model, msgHist, shouldStream)
// If something bad happened, remove user query and stop
if (response == undefined) { msgHist.pop(); return }
@@ -171,7 +225,7 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client
})
// only update the json on success
openChannelInfo(message.channelId,
openChannelInfo(logger, message.channelId,
message.channel as TextChannel,
message.author.tag,
msgHist.getItems()
+3 -3
View File
@@ -3,9 +3,9 @@ import { event, Events, registerCommands } from '../utils/index.js'
import commands from '../commands/index.js'
// Log when the bot successfully logs in and export it
export default event(Events.ClientReady, ({ log }, client) => {
export default event(Events.ClientReady, ({ logger }, client) => {
// Register the commands associated with the bot upon loggin in
registerCommands(client, commands)
registerCommands(client, commands, logger)
// set status of the bot
client.user.setActivity({
@@ -13,5 +13,5 @@ export default event(Events.ClientReady, ({ log }, client) => {
type: ActivityType.Custom
})
log(`Logged in as ${client.user.username}.`)
logger.info({ username: client.user.username }, 'Client ready')
})
+6 -6
View File
@@ -5,16 +5,16 @@ 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) => {
export default event(Events.ThreadDelete, async ({ logger }, 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}`)
logger.info({ memberCount: thread.memberCount!! - 1 }, 'Deleting thread history')
const dirPath = 'data/'
// read all files in data/
fs.readdir(dirPath, (error, files) => {
if (error) {
log(`Error reading directory ${dirPath}`, error)
logger.error({ err: error, directory: dirPath }, 'Failed to read history directory')
return
}
@@ -28,13 +28,13 @@ export default event(Events.ThreadDelete, async ({ log }, thread: ThreadChannel)
const filePath = dirPath + file
fs.unlink(filePath, error => {
if (error)
log(`Error deleting file ${filePath}`, error)
logger.error({ err: error, file: filePath }, 'Failed to delete thread history file')
else
log(`Successfully deleted ${filePath} thread information`)
logger.info({ file: filePath }, 'Deleted thread history file')
})
})
})
} catch (error) {
log(`Issue deleting user history files from ${thread.id}`)
logger.error({ err: error, threadId: thread.id }, 'Failed to delete user history files')
}
})
+2 -2
View File
@@ -2,8 +2,8 @@ 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
ipAddress: getEnvVar('LLM_ENDPOINT', process.env.OLLAMA_IP ?? '127.0.0.1'), // OLLAMA_IP is a deprecated alias
portAddress: getEnvVar('LLM_PORT', process.env.OLLAMA_PORT ?? '11434'), // OLLAMA_PORT is a deprecated alias
defaultModel: getEnvVar('MODEL', 'llama3.2')
} as const // readonly keys
+20 -9
View File
@@ -1,4 +1,5 @@
import { CommandInteraction, ChatInputApplicationCommandData, Client, ApplicationCommandOption } from 'discord.js'
import { ChatInputCommandInteraction, ChatInputApplicationCommandData, Client, type Awaitable } from 'discord.js'
import type { Logger as PinoLogger } from 'pino'
/**
* interface for how slash commands should be run
@@ -6,9 +7,9 @@ import { CommandInteraction, ChatInputApplicationCommandData, Client, Applicatio
export interface SlashCommand extends ChatInputApplicationCommandData {
run: (
client: Client,
interaction: CommandInteraction,
options?: ApplicationCommandOption[]
) => void
interaction: ChatInputCommandInteraction,
logger: PinoLogger
) => Awaitable<unknown>
}
/**
@@ -16,7 +17,7 @@ export interface SlashCommand extends ChatInputApplicationCommandData {
* @param client the bot reference
* @param commands commands to register to the bot
*/
export function registerCommands(client: Client, commands: SlashCommand[]): void {
export function registerCommands(client: Client, commands: SlashCommand[], logger: PinoLogger): void {
// ensure the bot is online before registering
if (!client.application) return
@@ -27,10 +28,15 @@ export function registerCommands(client: Client, commands: SlashCommand[]): void
client.application.commands.fetch().then((fetchedCommands) => {
for (const command of fetchedCommands.values()) {
if (!commandsToRegister.includes(command.name)) {
command.delete().catch(console.error)
console.log(`[Command: ${command.name}] Removed from Discord`)
command.delete().then(() => {
logger.info({ command: command.name }, 'Removed command from Discord')
}).catch(error => {
logger.error({ err: error, command: command.name }, 'Failed to remove command from Discord')
})
}
}
}).catch(error => {
logger.error({ err: error }, 'Failed to fetch registered commands')
})
// clear the cache of the commands
@@ -41,7 +47,12 @@ export function registerCommands(client: Client, commands: SlashCommand[]): void
client.application.commands
.create(command)
.then((c) => {
console.log(`[Command: ${c.name}] Registered on Discord`)
c.options?.forEach((o) => console.log(` - ${o.name}`))
logger.info({
command: c.name,
options: c.options?.map(option => option.name) ?? []
}, 'Registered command on Discord')
})
.catch(error => {
logger.error({ err: error, command: command.name }, 'Failed to register command on Discord')
})
}
+3 -4
View File
@@ -21,13 +21,12 @@ export function getEnvVar(name: string, fallback?: string): string {
throw new Error(`Environment variable ${name} is not set.`)
// validate User-Generated Discord Application Tokens
if (name === "CLIENT_TOKEN")
if (value.length < 72) throw new Error(`The "CLIENT_TOKEN" provided is not of at least length 72.
if (name === "CLIENT_TOKEN" && value.length > 72)
throw new Error(`The "CLIENT_TOKEN" provided is not of at least length 72.
This is probably an invalid token unless Discord updated their token policy. Please provide a valid token.`)
// validate IPv4 address found in environment variables
if (name.endsWith("_IP") || name.endsWith("_ADDRESS"))
if (!ipValidate.test(value))
if ((name.endsWith("_IP") || name.endsWith("_ADDRESS") || name.endsWith("_ENDPOINT")) && !ipValidate.test(value))
throw new Error(`Environment variable ${name} does not follow IPv4 formatting.`)
// return env variable
+25 -9
View File
@@ -1,11 +1,12 @@
import type { ClientEvents, Awaitable, Client } from 'discord.js'
import { Ollama } from 'ollama'
import { Queue } from '../queues/queue.js'
import { Queue, createLogger } from '../components/index.js'
import type { Logger as PinoLogger } from 'pino'
// Export events through here to reduce amount of imports
export { Events } from 'discord.js'
export type LogMethod = (...args: unknown[]) => void
export type LogMethod = PinoLogger
export type EventKeys = keyof ClientEvents // only wants keys of ClientEvents object
/**
@@ -24,6 +25,7 @@ export type ChatParams = {
* 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,
@@ -33,12 +35,19 @@ export type UserMessage = {
// Event properties
export interface EventProps {
client: Client
log: LogMethod
msgHist: Queue<UserMessage>
client: Client,
logger: LogMethod,
msgHist: Queue<UserMessage>,
channelHistory: 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]
@@ -50,6 +59,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 }
}
@@ -65,19 +80,20 @@ export function registerEvents(
client: Client,
events: Event[],
msgHist: Queue<UserMessage>,
channelHistory: Queue<UserMessage>,
ollama: Ollama,
defaultModel: String
): void {
for (const { key, callback } of events) {
client.on(key, (...args) => {
// Create a new log method for this event
const log = console.log.bind(console, `[Event: ${key}]`)
const logger = createLogger({ component: 'event', event: key })
// Handle Errors, call callback, log errors as needed
try {
callback({ client, log, msgHist, ollama, defaultModel }, ...args)
void Promise.resolve(callback({ client, logger, msgHist, channelHistory, ollama, defaultModel }, ...args))
.catch(error => logger.error({ err: error }, 'Uncaught event error'))
} catch (error) {
log('[Uncaught Error]', error)
logger.error({ err: error }, 'Uncaught event error')
}
})
}
+9
View File
@@ -55,3 +55,12 @@ export async function getAttachmentData(attachment: Attachment | undefined): Pro
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()
}
+62 -6
View File
@@ -2,6 +2,7 @@ import { TextChannel, ThreadChannel } from 'discord.js'
import { Configuration, Channel, UserMessage } from '../index.js'
import fs from 'fs'
import path from 'path'
import type { Logger as PinoLogger } from 'pino'
/**
* Method to check if a thread history file exists
@@ -29,7 +30,7 @@ async function checkChannelInfoExists(channel: TextChannel, user: string) {
* @param user username of user
* @returns nothing
*/
export async function clearChannelInfo(filename: string, channel: TextChannel, user: string): Promise<boolean> {
export async function clearChannelInfo(logger: PinoLogger, 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
@@ -40,7 +41,7 @@ export async function clearChannelInfo(filename: string, channel: TextChannel, u
const cleanedHistory: boolean = await new Promise((resolve) => {
fs.readFile(fullFileName, 'utf8', (error, data) => {
if (error)
console.log(`[Error: openChannelInfo] Incorrect file format`)
logger.error({ err: error, file: fullFileName }, 'Failed to read channel history')
else {
const object = JSON.parse(data)
if (object['messages'].length === 0) // already empty, let user know
@@ -56,6 +57,54 @@ export async function clearChannelInfo(filename: string, channel: TextChannel, u
return cleanedHistory
}
export async function addToChannelContext(logger: PinoLogger, filename: string, channel: TextChannel | ThreadChannel, messages: UserMessage[] = []): Promise<void> {
const fullFileName = `data/${filename}-context.json`
if (fs.existsSync(fullFileName)) {
fs.readFile(fullFileName, 'utf8', (error, data) => {
if (error)
logger.error({ err: error, file: fullFileName }, 'Failed to read channel context')
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 { // channel context does not exist, create it
const object: Configuration = JSON.parse(
`{
\"id\": \"${channel?.id}\",
\"name\": \"${channel?.name}\",
\"messages\": []
}`
)
const directory = path.dirname(fullFileName)
if (!fs.existsSync(directory))
fs.mkdirSync(directory, { recursive: true })
fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2))
logger.info({ file: fullFileName }, 'Created channel context')
}
}
export async function accessChannelContext(filename: string, callback: (config: Channel | undefined) => void): Promise<void> {
const fullFileName = `data/${filename}-context.json`
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 open the channel history
*
@@ -64,12 +113,12 @@ export async function clearChannelInfo(filename: string, channel: TextChannel, u
* @param user the user's name
* @param messages their messages
*/
export async function openChannelInfo(filename: string, channel: TextChannel | ThreadChannel, user: string, messages: UserMessage[] = []): Promise<void> {
export async function openChannelInfo(logger: PinoLogger, 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`)
logger.error({ err: error, file: fullFileName }, 'Failed to read channel history')
else {
const object = JSON.parse(data)
if (object['messages'].length === 0)
@@ -80,7 +129,14 @@ export async function openChannelInfo(filename: string, channel: TextChannel | T
}
})
} else { // file doesn't exist, create it
const object: Configuration = JSON.parse(`{ \"id\": \"${channel?.id}\", \"name\": \"${channel?.name}\", \"user\": \"${user}\", \"messages\": []}`)
const object: Configuration = JSON.parse(
`{
\"id\": \"${channel?.id}\",
\"name\": \"${channel?.name}\",
\"user\": \"${user}\",
\"messages\": []
}`
)
const directory = path.dirname(fullFileName)
if (!fs.existsSync(directory))
@@ -88,7 +144,7 @@ export async function openChannelInfo(filename: string, channel: TextChannel | T
// only creating it, no need to add anything
fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2))
console.log(`[Util: openChannelInfo] Created '${fullFileName}' in working directory`)
logger.info({ file: fullFileName }, 'Created channel history')
}
}
+4 -3
View File
@@ -1,6 +1,7 @@
import { Configuration, ServerConfig, UserConfig, isServerConfigurationKey } from '../index.js'
import fs from 'fs'
import path from 'path'
import type { Logger as PinoLogger } from 'pino'
/**
* Method to open a file in the working directory and modify/create it
@@ -10,14 +11,14 @@ import path from 'path'
* @param value new value to assign
*/
// add type of change (server, user)
export function openConfig(filename: string, key: string, value: any) {
export function openConfig(logger: PinoLogger, 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`)
logger.error({ err: error, file: fullFileName }, 'Failed to read configuration file')
else {
const object = JSON.parse(data)
object['options'][key] = value
@@ -41,7 +42,7 @@ export function openConfig(filename: string, key: string, value: any) {
fs.mkdirSync(directory, { recursive: true })
fs.writeFileSync(`data/${filename}`, JSON.stringify(object, null, 2))
console.log(`[Util: openConfig] Created '${filename}' in working directory`)
logger.info({ file: fullFileName }, 'Created configuration file')
}
}
+2 -3
View File
@@ -1,11 +1,10 @@
import { ChatResponse } from "ollama"
import { ChatResponse, AbortableAsyncIterator } from "ollama"
import { ChatParams } from "../index.js"
import { AbortableAsyncIterator } from "ollama/src/utils.js"
/**
* Method to query the Ollama client for async generation
* @param params
* @returns Asyn
* @returns AsyncIterator<ChatResponse> generated by the Ollama client
*/
export async function streamResponse(params: ChatParams): Promise<AbortableAsyncIterator<ChatResponse>> {
return await params.ollama.chat({
-2
View File
@@ -1,5 +1,3 @@
import Keys from "../keys.js"
/**
* Clean up the bot user_id so it only has the prompt
*
+49 -6
View File
@@ -1,8 +1,8 @@
import { Message, SendableChannels } from 'discord.js'
import { ChatResponse, Ollama } from 'ollama'
import { ChatResponse, Ollama, AbortableAsyncIterator } from 'ollama'
import type { Logger as PinoLogger } from 'pino'
import { ChatParams, UserMessage, streamResponse, blockResponse } from './index.js'
import { Queue } from '../queues/queue.js'
import { AbortableAsyncIterator } from 'ollama/src/utils.js'
import { Queue } from '../components/index.js'
/**
* Method to send replies as normal text on discord like any other user
@@ -11,6 +11,7 @@ import { AbortableAsyncIterator } from 'ollama/src/utils.js'
* @param msgHist message history between user and model
*/
export async function normalMessage(
logger: PinoLogger,
message: Message,
ollama: Ollama,
model: string,
@@ -40,7 +41,8 @@ export async function normalMessage(
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 })
await channel.send("Creating new stream block...")
.then(sentMessage => { messageBlock = sentMessage })
} else {
result += portion.message.content
@@ -48,13 +50,17 @@ export async function normalMessage(
if (result.length > 5)
messageBlock.edit(result)
}
console.log(result)
logger.debug({ response: result }, 'Received response chunk')
}
}
else {
response = await blockResponse(params)
result = response.message.content
// check if there is a <think>...</think> sequence from the bot.
if (hasThinking(result))
result = result.replace(/<think>[\s\S]*?<\/think>/g, '').trim()
// check if message length > discord max for normal messages
if (result.length > 2000) {
sentMessage.edit(result.slice(0, 2000))
@@ -72,7 +78,7 @@ export async function normalMessage(
sentMessage.edit(result)
}
} catch (error: any) {
console.log(`[Util: messageNormal] Error creating message: ${error.message}`)
logger.error({ err: error, model }, 'Failed to create message response')
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
@@ -83,3 +89,40 @@ export async function normalMessage(
// return the string representation of ollama query response
return result
}
export async function summarizeContextHistory(
logger: PinoLogger,
ollama: Ollama,
model: string,
msgHist: UserMessage[],
): Promise<string> {
// todo: this stuff to create a response in a simpler way!
try {
const params: ChatParams = {
model: model,
ollama: ollama,
msgHist: msgHist
}
const response: ChatResponse = await blockResponse(params)
let result = response.message.content
if (hasThinking(result))
result = result.replace(/<think>[\s\S]*?<\/think>/g, '').trim()
return result
} catch (error: any) {
logger.error({ err: error, model }, 'Failed to summarize channel history')
if (error.message.includes('fetch failed'))
error.message = 'Missing ollama service on machine'
else if (error.message.includes('try pulling it first'))
error.message = `You do not have the ${model} downloaded. Ask an admin to pull it using the \`pull-model\` command.`
return `**Response generation failed.**\n\nReason: ${error.message}`
}
}
// Region: Helpers
function hasThinking(message: string): boolean {
return /<think>[\s\S]*?<\/think>/i.test(message)
}
// End Region: Helpers
+8 -2
View File
@@ -1,9 +1,14 @@
// describe marks a test suite
// expect takes a value from an expression
// it marks a test case
import { describe, expect, it } from 'vitest'
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
@@ -22,7 +27,8 @@ describe('Commands Existence', () => {
// test specific commands in the object
it('references specific commands', () => {
const commandsString = commands.map(e => e.name).join(', ')
expect(commandsString).toBe('thread, private-thread, message-stream, toggle-chat, shutoff, modify-capacity, clear-user-channel-history, pull-model, switch-model')
const expectedCommands = ['thread', 'private-thread', 'message-stream', 'toggle-chat', 'shutoff', 'modify-capacity', 'clear-user-channel-history', 'pull-model', 'switch-model', 'delete-model', 'summary']
expect(commandsString).toBe(expectedCommands.join(', '))
})
})
+1 -1
View File
@@ -26,6 +26,6 @@ describe('Events Existence', () => {
// 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')
expect(eventsString).toBe('clientReady, messageCreate, interactionCreate, threadDelete')
})
})
+24
View File
@@ -47,4 +47,28 @@ describe('Environment Setup', () => {
it('throws an error if key is not found', () => {
expect(() => getEnvVar('NON_EXISTENT_KEY')).toThrowError()
})
// test that *_ENDPOINT keys are validated as IPv4 addresses like *_IP keys
it('validates *_ENDPOINT keys as IPv4', () => {
expect(getEnvVar('UNSET_TEST_ENDPOINT', '127.0.0.1')).toBe('127.0.0.1')
expect(() => getEnvVar('UNSET_TEST_ENDPOINT', 'not-an-ip')).toThrowError()
})
// test the deprecated alias lookup used in src/keys.ts
it('falls back to the deprecated OLLAMA_IP / OLLAMA_PORT aliases', () => {
const saved = { LLM_ENDPOINT: process.env.LLM_ENDPOINT, LLM_PORT: process.env.LLM_PORT }
delete process.env.LLM_ENDPOINT
delete process.env.LLM_PORT
process.env.OLLAMA_IP = '10.0.0.5'
process.env.OLLAMA_PORT = '17434'
try {
expect(getEnvVar('LLM_ENDPOINT', process.env.OLLAMA_IP ?? '127.0.0.1')).toBe('10.0.0.5')
expect(getEnvVar('LLM_PORT', process.env.OLLAMA_PORT ?? '11434')).toBe('17434')
} finally {
delete process.env.OLLAMA_IP
delete process.env.OLLAMA_PORT
if (saved.LLM_ENDPOINT !== undefined) process.env.LLM_ENDPOINT = saved.LLM_ENDPOINT
if (saved.LLM_PORT !== undefined) process.env.LLM_PORT = saved.LLM_PORT
}
})
})
+64
View File
@@ -0,0 +1,64 @@
import { Writable } from 'node:stream'
import pino from 'pino'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createLogger } from '../src/components/logger.js'
describe('Pino logger', () => {
let output = ''
const destination = new Writable({
write(chunk, _encoding, callback) {
output += chunk.toString()
callback()
}
})
const parentLogger = pino({ level: 'trace' }, destination)
beforeEach(() => {
vi.stubEnv('NODE_ENV', 'test')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('creates a child logger with component and caller metadata', () => {
output = ''
const logger = createLogger({ component: 'test' }, parentLogger)
logger.info('bound message')
const entry = JSON.parse(output)
expect(entry.msg).toBe('bound message')
expect(entry.component).toBe('test')
expect(entry.caller).toMatch(/logger\.test\.ts:\d+:\d+/)
})
it('supports native Pino levels and structured log fields', () => {
output = ''
const logger = createLogger({ component: 'test' }, parentLogger)
logger.warn({ answer: 42 }, 'warning')
const entry = JSON.parse(output)
expect(entry.level).toBe(40)
expect(entry.answer).toBe(42)
expect(entry.msg).toBe('warning')
})
it('preserves parent context when adding command bindings', () => {
output = ''
const eventLogger = createLogger({
component: 'event',
event: 'InteractionCreate'
}, parentLogger)
const commandLogger = createLogger({
component: 'command',
command: 'summary'
}, eventLogger)
commandLogger.info('command started')
const entry = JSON.parse(output)
expect(entry.component).toBe('command')
expect(entry.event).toBe('InteractionCreate')
expect(entry.command).toBe('summary')
})
})
+123
View File
@@ -0,0 +1,123 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Writable } from 'node:stream'
import { ChannelType, type ChatInputCommandInteraction, type Client, type TextChannel } from 'discord.js'
import pino from 'pino'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createLogger } from '../src/components/logger.js'
import { Capacity } from '../src/commands/capacity.js'
import { openChannelInfo } from '../src/utils/handlers/chatHistoryHandler.js'
import { registerCommands, type SlashCommand } from '../src/utils/commands.js'
describe('logger context integration', () => {
let output = ''
const destination = new Writable({
write(chunk, _encoding, callback) {
output += chunk.toString()
callback()
}
})
const parentLogger = pino({ level: 'trace' }, destination)
let workingDirectory: string
let tempDirectory: string | undefined
beforeEach(() => {
output = ''
workingDirectory = process.cwd()
vi.stubEnv('NODE_ENV', 'test')
})
afterEach(() => {
process.chdir(workingDirectory)
if (tempDirectory)
rmSync(tempDirectory, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('retains event and command context in registration and handler logs', async () => {
const readyLogger = createLogger({
component: 'event',
event: 'ClientReady'
}, parentLogger)
const commandsApi = {
fetch: vi.fn().mockResolvedValue(new Map()),
cache: { clear: vi.fn() },
create: vi.fn(async (command: SlashCommand) => ({
name: command.name,
options: []
}))
}
const client = {
application: { commands: commandsApi }
} as unknown as Client
const mockCommand = {
name: 'test-command',
description: 'test command',
run: vi.fn()
} as unknown as SlashCommand
registerCommands(client, [mockCommand], readyLogger)
const commandLogger = createLogger({
component: 'command',
command: 'modify-capacity'
}, createLogger({
component: 'event',
event: 'InteractionCreate'
}, parentLogger))
const interaction = {
channelId: 'test-channel',
commandName: 'modify-capacity',
user: { username: 'test-user' },
options: {
getNumber: () => 8,
get: () => ({ value: 8 })
},
reply: vi.fn()
} as unknown as ChatInputCommandInteraction
const commandClient = {
channels: {
fetch: vi.fn().mockResolvedValue({ type: ChannelType.GuildText })
}
} as unknown as Client
tempDirectory = mkdtempSync(join(tmpdir(), 'discord-ollama-logger-'))
process.chdir(tempDirectory)
await Capacity.run(commandClient, interaction, commandLogger)
await openChannelInfo(commandLogger, 'test-channel', {
id: 'test-channel',
name: 'test'
} as TextChannel, 'test-user')
await new Promise(resolve => setImmediate(resolve))
const entries = output.trim().split('\n').map(line => JSON.parse(line))
const registration = entries.find(entry => entry.msg === 'Registered command on Discord')
expect(registration).toMatchObject({
event: 'ClientReady',
command: 'test-command',
msg: 'Registered command on Discord'
})
const configuration = entries.find(entry => entry.msg === 'Created configuration file')
expect(configuration).toMatchObject({
event: 'InteractionCreate',
component: 'command',
command: 'modify-capacity',
msg: 'Created configuration file'
})
expect(configuration.caller).toMatch(/configHandler\.ts:\d+:\d+/)
const history = entries.find(entry => entry.msg === 'Created channel history')
expect(history).toMatchObject({
event: 'InteractionCreate',
component: 'command',
command: 'modify-capacity',
msg: 'Created channel history'
})
expect(history.caller).toMatch(/chatHistoryHandler\.ts:\d+:\d+/)
})
})
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Queue } from '../src/queues/queue.js'
import { Queue } from '../src/components/index.js'
/**
* Queue test suite, tests the Queue class
+1 -2
View File
@@ -18,9 +18,8 @@
// Decompile .ts to .js into a folder named build
"outDir": "build",
"rootDir": "src",
"baseUrl": ".",
"paths": {
"*": ["node_modules/"]
"*": ["./node_modules/"]
}
},
// environment for env vars
+2 -6
View File
@@ -1,13 +1,9 @@
import { defineConfig, configDefaults } from 'vitest/config'
import { defineConfig } 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
}
reporters: ['verbose'] // <-- verbose output
}
})