Compare commits

..
Author SHA1 Message Date
snyk-bot caccb47976 fix: upgrade redis from 4.7.0 to 4.7.1
Snyk has created this PR to upgrade redis from 4.7.0 to 4.7.1.

See this package in npm:
redis

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
2025-05-29 09:09:36 +00:00
53 changed files with 2524 additions and 2411 deletions
+8 -6
View File
@@ -4,14 +4,16 @@ CLIENT_TOKEN = BOT_TOKEN
# Default model for new users # Default model for new users
MODEL = DEFAULT_MODEL MODEL = DEFAULT_MODEL
# ip/port address of the LLM server (ollama, llmman, ...), I use 172.18.0.3 for docker, 127.0.0.1 for local # ip/port address of docker container, I use 172.18.0.3 for docker, 127.0.0.1 for local
# ollama listens on 11434, llmman (https://github.com/llmmanorg/llmman) on 17434 OLLAMA_IP = IP_ADDRESS
# OLLAMA_IP / OLLAMA_PORT are still accepted as deprecated aliases OLLAMA_PORT = PORT
LLM_ENDPOINT = IP_ADDRESS
LLM_PORT = PORT
# ip address for discord bot container, I use 172.18.0.2, use different IP than llm_endpoint # ip address for discord bot container, I use 172.18.0.2, use different IP than ollama_ip
DISCORD_IP = IP_ADDRESS DISCORD_IP = IP_ADDRESS
# subnet address, ex. 172.18.0.0 as we use /16. # subnet address, ex. 172.18.0.0 as we use /16.
SUBNET_ADDRESS = ADDRESS SUBNET_ADDRESS = ADDRESS
# redis port and ip, default redis port is 6379
REDIS_IP = IP_ADDRESS
REDIS_PORT = PORT
+2 -2
View File
@@ -8,10 +8,10 @@ assignees: ''
--- ---
## Issue ## Issue
A clear and concise description of what the problem/feature is. PLease describe it as best as possible. A clear and concise description of what the problem/feature is.
## Solution ## Solution
* Provide steps or ideas to how to implement or investigate this new feature. * Provide steps or ideals to how to implement or investigate this new feature.
## References ## References
* Provide additional context and external references here * Provide additional context and external references here
+79 -77
View File
@@ -1,77 +1,79 @@
name: Builds name: Builds
run-name: Validate Node and Docker Builds run-name: Validate Node and Docker Builds
on:
on: push:
push: branches:
branches: - master # runs after Pull Request is merged
- master # runs after Pull Request is merged
jobs:
jobs: Discord-Node-Build: # test if the node install and run
Discord-Node-Build: # test if the node install and run runs-on: ubuntu-latest
runs-on: ubuntu-latest timeout-minutes: 2
timeout-minutes: 2 steps:
- name: Checkout Repository
steps: uses: actions/checkout@v4
- name: Checkout Repository
uses: actions/checkout@v4 - name: Set up Node Environment lts/jod
uses: actions/setup-node@v4
- name: Set up Node Environment with:
uses: actions/setup-node@v4 node-version: lts/jod
with: cache: "npm"
node-version: lts/krypton
cache: npm - name: Install Project Dependencies
run: |
- name: Install Project Dependencies npm install
run: |
npm install - name: Build Application
run: |
- name: Build Application npm run build
run: |
npm run build - name: Create Environment Variables
run: |
- name: Create Environment Variables touch .env
run: | echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
touch .env echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo LLM_ENDPOINT = ${{ secrets.OLLAMA_IP }} >> .env echo MODEL = ${{ secrets.MODEL }} >> .env
echo LLM_PORT = ${{ secrets.OLLAMA_PORT }} >> .env echo REDIS_IP = ${{ secrets.REDIS_IP }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env echo REDIS_PORT = ${{ secrets.REDIS_PORT }} >> .env
# set -e ensures if nohup fails, this section fails # set -e ensures if nohup fails, this section fails
- name: Startup Discord Bot Client - name: Startup Discord Bot Client
run: | run: |
set -e set -e
nohup npm run prod & nohup npm run prod &
Discord-Ollama-Container-Build: # test docker build and run Discord-Ollama-Container-Build: # test docker build and run
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 3 timeout-minutes: 2
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Node Environment - name: Set up Node Environment lts/jod
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: lts/krypton node-version: lts/jod
cache: npm cache: "npm"
- name: Create Environment Variables - name: Create Environment Variables
run: | run: |
touch .env touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo LLM_ENDPOINT = ${{ secrets.OLLAMA_IP }} >> .env echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo LLM_PORT = ${{ secrets.OLLAMA_PORT }} >> .env echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env echo MODEL = ${{ secrets.MODEL }} >> .env
echo REDIS_IP = ${{ secrets.REDIS_IP }} >> .env
- name: Setup Docker Network and Images echo REDIS_PORT = ${{ secrets.REDIS_PORT }} >> .env
run: |
npm run docker:start-cpu - name: Setup Docker Network and Images
run: |
- name: Check Images Exist npm run docker:start-cpu
run: |
(docker images | grep -q 'kevinthedang/discord-ollama' && docker images | grep -qE 'ollama/ollama') || exit 1 - name: Check Images Exist
run: |
- name: Check Containers Exist (docker images | grep -q 'kevinthedang/discord-ollama' && docker images | grep -qE 'ollama/ollama' | docker images | grep -qE 'redis') || exit 1
run: |
(docker ps | grep -q 'ollama' && docker ps | grep -q 'discord') || exit 1 - name: Check Containers Exist
run: |
(docker ps | grep -q 'ollama' && docker ps | grep -q 'discord' && docker ps | grep -q 'redis') || exit 1
+52
View File
@@ -0,0 +1,52 @@
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
echo REDIS_IP = ${{ secrets.REDIS_IP }} >> .env
echo REDIS_PORT = ${{ secrets.REDIS_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
+31 -6
View File
@@ -1,14 +1,15 @@
name: Deploy name: Deploy
run-name: Deploy Application Latest run-name: Deploy Application Latest
on:
on: workflow_dispatch push:
tags:
- 'v*'
jobs: jobs:
Deploy-Application: Deploy-Application:
runs-on: self-hosted runs-on: self-hosted
environment: deploy environment: deploy
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- name: Checkout Repo - name: Checkout Repo
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -18,11 +19,13 @@ jobs:
run: | run: |
touch .env touch .env
echo CLIENT_TOKEN = ${{ secrets.CLIENT }} >> .env echo CLIENT_TOKEN = ${{ secrets.CLIENT }} >> .env
echo LLM_ENDPOINT = ${{ secrets.OLLAMA_IP }} >> .env echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
echo LLM_PORT = ${{ secrets.OLLAMA_PORT }} >> .env echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env echo MODEL = ${{ secrets.MODEL }} >> .env
echo DISCORD_IP = ${{ secrets.DISCORD_IP }} >> .env echo DISCORD_IP = ${{ secrets.DISCORD_IP }} >> .env
echo SUBNET_ADDRESS = ${{ secrets.SUBNET_ADDRESS }} >> .env echo SUBNET_ADDRESS = ${{ secrets.SUBNET_ADDRESS }} >> .env
echo REDIS_IP = ${{ secrets.REDIS_IP }} >> .env
echo REDIS_PORT = ${{ secrets.REDIS_PORT }} >> .env
- name: Check if directory exists and delete it - name: Check if directory exists and delete it
run: | run: |
@@ -38,7 +41,7 @@ jobs:
git clone https://github.com/kevinthedang/discord-ollama.git ${{ secrets.PATH }} git clone https://github.com/kevinthedang/discord-ollama.git ${{ secrets.PATH }}
cd ${{ secrets.PATH }} cd ${{ secrets.PATH }}
- name: Install nvm and Node.js - name: Install nvm and Node.js lts/jod
run: | run: |
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
export NVM_DIR="$HOME/.nvm" export NVM_DIR="$HOME/.nvm"
@@ -56,6 +59,7 @@ jobs:
npm install npm install
IMAGE="kevinthedang/discord-ollama" IMAGE="kevinthedang/discord-ollama"
REDIS="redis"
OLLAMA="ollama/ollama" OLLAMA="ollama/ollama"
if docker images | grep -q $IMAGE; then if docker images | grep -q $IMAGE; then
@@ -71,6 +75,19 @@ jobs:
echo "Old $IMAGE Image Removed" echo "Old $IMAGE Image Removed"
fi fi
if docker images | grep -q $REDIS; then
IMAGE_ID=$(docker images -q $REDIS)
CONTAINER_IDS=$(docker ps -q --filter "ancestor=$IMAGE_ID")
if [ ! -z "$CONTAINER_IDS" ]; then
# Stop and remove the running containers
docker stop $CONTAINER_IDS
echo "Stopped and removed the containers using the image $REDIS"
fi
docker rmi $IMAGE_ID
echo "Old $REDIS Image Removed"
fi
if docker images | grep -q $OLLAMA; then if docker images | grep -q $OLLAMA; then
IMAGE_ID=$(docker images -q $OLLAMA) IMAGE_ID=$(docker images -q $OLLAMA)
CONTAINER_IDS=$(docker ps -q --filter "ancestor=$IMAGE_ID") CONTAINER_IDS=$(docker ps -q --filter "ancestor=$IMAGE_ID")
@@ -100,6 +117,14 @@ jobs:
--ip ${{ secrets.OLLAMA_IP }} \ --ip ${{ secrets.OLLAMA_IP }} \
ollama/ollama:latest ollama/ollama:latest
docker run --rm -d \
-v redis:/root/.redis \
-p ${{ secrets.REDIS_PORT }}:${{ secrets.REDIS_PORT }} \
--name redis \
--network ollama-net \
--ip ${{ secrets.REDIS_IP }} \
redis:latest
docker run --rm -d \ docker run --rm -d \
-v discord:/src/app \ -v discord:/src/app \
--name discord \ --name discord \
-47
View File
@@ -1,47 +0,0 @@
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
+50 -51
View File
@@ -1,51 +1,50 @@
name: Tests name: Tests
run-name: Unit Tests run-name: Unit Tests
on:
on: pull_request:
pull_request: branches:
branches: - master
- master paths:
paths: - '*'
- '*' - 'package*.json'
- 'package*.json' - 'src/**'
- 'src/**' - 'tests/**'
- 'tests/**' - '!docs/**'
- '!docs/**' - '!imgs/**'
- '!imgs/**' - '!.github/**'
- '!.github/**' - '.github/workflows/**'
- '.github/workflows/**' - '!.gitignore'
- '!.gitignore' - '!LICENSE'
- '!LICENSE' - '!README'
- '!README'
jobs:
jobs: Discord-Node-Test:
Discord-Node-Test: runs-on: ubuntu-latest
if: ${{ github.event.pull_request.head.repo.fork == false }} timeout-minutes: 2
runs-on: ubuntu-latest steps:
timeout-minutes: 2 - name: Checkout Repository
uses: actions/checkout@v4
steps:
- name: Checkout Repository - name: Set up Node Environment lts/jod
uses: actions/checkout@v4 uses: actions/setup-node@v4
with:
- name: Set up Node Environment node-version: lts/jod
uses: actions/setup-node@v4 cache: "npm"
with:
node-version: lts/jod - name: Install Project Dependencies
cache: npm run: |
npm install
- name: Install Project Dependencies
run: | - name: Create Environment Variables
npm install run: |
touch .env
- name: Create Environment Variables echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
run: | echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
touch .env echo OLLAMA_PORT = ${{ secrets.OLLAMA_PORT }} >> .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env echo MODEL = ${{ secrets.MODEL }} >> .env
echo LLM_ENDPOINT = ${{ secrets.OLLAMA_IP }} >> .env echo REDIS_IP = ${{ secrets.REDIS_IP }} >> .env
echo LLM_PORT = ${{ secrets.OLLAMA_PORT }} >> .env echo REDIS_PORT = ${{ secrets.REDIS_PORT }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
- name: Test Application
- name: Test Application run: |
run: | npm run test:run
npm run tests
+10
View File
@@ -37,6 +37,16 @@ pids
*.seed *.seed
*.pid.lock *.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 intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt .grunt
+1 -1
View File
@@ -12,7 +12,7 @@
# Technical/Business Code Ownership # Technical/Business Code Ownership
/src/ @kevinthedang @JT2M0L3Y /src/ @kevinthedang @JT2M0L3Y
/tests/ @JT2M0L3Y /tests/ @JT2M0L3Y
/.github/ @kevinthedang @JT2M0L3Y /.github/ @kevinthedang
# Docker Ownership # Docker Ownership
Dockerfile @kevinthedang Dockerfile @kevinthedang
+2 -2
View File
@@ -1,5 +1,5 @@
# use node LTS image for version 24 # use node LTS image for version 22
FROM node:krypton-alpine FROM node:jod-alpine
# set working directory inside container # set working directory inside container
WORKDIR /app WORKDIR /app
+26 -30
View File
@@ -4,48 +4,43 @@
<h3><a href="#"></a>Ollama as your Discord AI Assistant</h3> <h3><a href="#"></a>Ollama as your Discord AI Assistant</h3>
<p><a href="#"></a><a href="https://creativecommons.org/licenses/by/4.0/"><img alt="License" src="https://img.shields.io/badge/License-CC_BY_4.0-darkgreen.svg" /></a> <p><a href="#"></a><a href="https://creativecommons.org/licenses/by/4.0/"><img alt="License" src="https://img.shields.io/badge/License-CC_BY_4.0-darkgreen.svg" /></a>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/releases/latest"><img alt="Release" src="https://img.shields.io/github/v/release/kevinthedang/discord-ollama?logo=github" /></a> <a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/releases/latest"><img alt="Release" src="https://img.shields.io/github/v/release/kevinthedang/discord-ollama?logo=github" /></a>
<a href="#"></a><a href="https://github.com/kevinthedang/discord-ollama/actions/workflows/build.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/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/test.yml"><img alt="Tests" 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/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> </div>
## About ## About/Goals
Discord Ollama is a Discord bot that lets users interact with locally hosted Ollama is an AI model management tool that allows users to install and use custom large language models locally.
Ollama language models directly from Discord. 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
The bot supports model management, streamed responses, conversation history, 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.
private threads, and configurable server controls. Because Ollama runs locally, * [Customize a model](https://github.com/ollama/ollama#customize-a-model)
you retain control over the models and data used by the bot. * [Modelfile Docs](https://github.com/ollama/ollama/blob/main/docs/modelfile.md)
See the [setup guides](./docs/setup-local.md) to get started.
## Documentation ## Documentation
These are guides to the features and capabilities of this app. These are guides to the features and capabilities of this app.
* [User Slash Commands](./docs/commands-guide.md) * [User Slash Commands](./docs/commands-guide.md)
* [Client Events](./docs/events-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 ## Environment Setup
* Clone this repo using `git clone https://github.com/kevinthedang/discord-ollama.git` or just use [GitHub Desktop](https://desktop.github.com/) to clone the repo. * Clone this repo using `git clone https://github.com/kevinthedang/discord-ollama.git` or just use [GitHub Desktop](https://desktop.github.com/) to clone the repo.
* You will need a `.env` file in the root of the project directory with the bot's token. There is a `.env.sample` is provided for you as a reference for what environment variables. * You will need a `.env` file in the root of the project directory with the bot's token. There is a `.env.sample` is provided for you as a reference for what environment variables.
* For example, `CLIENT_TOKEN = [Bot Token]` * 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. * Please refer to the docs for bot setup.
* [Creating a Discord App](./docs/setup-discord-app.md) * [Creating a Discord App](./docs/setup-discord-app.md)
* [Local Machine Setup](./docs/setup-local.md) * [Local Machine Setup](./docs/setup-local.md)
@@ -59,7 +54,8 @@ you need to add more scoped bindings; child creation does not mutate its parent.
* This project requires the use of npm version `10.9.0` or above. * This project requires the use of npm version `10.9.0` or above.
* [Ollama](https://ollama.com/) * [Ollama](https://ollama.com/)
* [Ollama Docker Image](https://hub.docker.com/r/ollama/ollama) * [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). * [Redis](https://redis.io/)
* [Redis Docker Image](https://hub.docker.com/_/redis)
* [Discord.js Docs](https://discord.js.org/docs/packages/discord.js/main) * [Discord.js Docs](https://discord.js.org/docs/packages/discord.js/main)
* [Setting up Docker (Ubuntu 20.04)](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-docker-on-ubuntu-20-04) * [Setting up Docker (Ubuntu 20.04)](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-docker-on-ubuntu-20-04)
* [Setting up Nvidia Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) * [Setting up Nvidia Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
+21 -5
View File
@@ -7,12 +7,14 @@ services:
build: ./ # find docker file in designated path build: ./ # find docker file in designated path
container_name: discord container_name: discord
restart: always # rebuild container always restart: always # rebuild container always
image: kevinthedang/discord-ollama:0.9.3 image: kevinthedang/discord-ollama:0.8.4
environment: environment:
CLIENT_TOKEN: ${CLIENT_TOKEN} CLIENT_TOKEN: ${CLIENT_TOKEN}
LLM_ENDPOINT: ${LLM_ENDPOINT:-${OLLAMA_IP}} # OLLAMA_IP is a deprecated alias OLLAMA_IP: ${OLLAMA_IP}
LLM_PORT: ${LLM_PORT:-${OLLAMA_PORT}} # OLLAMA_PORT is a deprecated alias OLLAMA_PORT: ${OLLAMA_PORT}
MODEL: ${MODEL} MODEL: ${MODEL}
REDIS_IP: ${REDIS_IP}
REDIS_PORT: ${REDIS_PORT}
networks: networks:
ollama-net: ollama-net:
ipv4_address: ${DISCORD_IP} ipv4_address: ${DISCORD_IP}
@@ -26,14 +28,27 @@ services:
restart: always restart: always
networks: networks:
ollama-net: ollama-net:
ipv4_address: ${LLM_ENDPOINT:-${OLLAMA_IP}} ipv4_address: ${OLLAMA_IP}
runtime: nvidia # use Nvidia Container Toolkit for GPU support runtime: nvidia # use Nvidia Container Toolkit for GPU support
devices: devices:
- /dev/nvidia0 - /dev/nvidia0
volumes: volumes:
- ollama:/root/.ollama - ollama:/root/.ollama
ports: ports:
- ${LLM_PORT:-${OLLAMA_PORT}}:${LLM_PORT:-${OLLAMA_PORT}} - ${OLLAMA_PORT}:${OLLAMA_PORT}
# setup redis container
redis:
image: redis:latest
container_name: redis
restart: always
networks:
ollama-net:
ipv4_address: ${REDIS_IP}
volumes:
- redis:/root/.redis
ports:
- ${REDIS_PORT}:${REDIS_PORT}
# create a network that supports giving addresses withing a specific subnet # create a network that supports giving addresses withing a specific subnet
networks: networks:
@@ -47,3 +62,4 @@ networks:
volumes: volumes:
ollama: ollama:
discord: discord:
redis:
+5 -3
View File
@@ -43,11 +43,13 @@ sudo systemctl restart docker
* [GitHub repository](https://github.com/NVIDIA/nvidia-container-toolkit?tab=readme-ov-file) for Nvidia Container Toolkit * [GitHub repository](https://github.com/NVIDIA/nvidia-container-toolkit?tab=readme-ov-file) for Nvidia Container Toolkit
## To Run (with Docker and Docker Compose) ## 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`, `LLM_ENDPOINT`, `LLM_PORT`, and `DISCORD_IP`. Here are some default values if you don't care: * With the inclusion of subnets in the `docker-compose.yml`, you will need to set the `SUBNET_ADDRESS`, `OLLAMA_IP`, `OLLAMA_PORT`, `REDIS_IP`, `REDIS_PORT`, and `DISCORD_IP`. Here are some default values if you don't care:
* `SUBNET_ADDRESS = 172.18.0.0` * `SUBNET_ADDRESS = 172.18.0.0`
* `LLM_ENDPOINT = 172.18.0.2` * `OLLAMA_IP = 172.18.0.2`
* `LLM_PORT = 11434` * `OLLAMA_PORT = 11434`
* `DISCORD_IP = 172.18.0.3` * `DISCORD_IP = 172.18.0.3`
* `REDIS_IP = 172.18.0.4`
* `REDIS_PORT = 6379`
* Don't understand any of this? watch a Networking video to understand subnetting. * Don't understand any of this? watch a Networking video to understand subnetting.
* You also need all environment variables shown in [`.env.sample`](../.env.sample) * You also need all environment variables shown in [`.env.sample`](../.env.sample)
* Otherwise, there is no need to install any npm packages for this, you just need to run `npm run start` to pull the containers and spin them up. * Otherwise, there is no need to install any npm packages for this, you just need to run `npm run start` to pull the containers and spin them up.
+3 -10
View File
@@ -14,18 +14,11 @@
> [!NOTE] > [!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) > 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) ## To Run Locally (without Docker)
* Run `npm install` to install the npm packages. * Run `npm install` to install the npm packages.
* Ensure that your [.env](../.env.sample) file's `LLM_ENDPOINT` is `127.0.0.1` to work properly. * Ensure that your [.env](../.env.sample) file's `OLLAMA_IP` is `127.0.0.1` to work properly.
* You only need your `CLIENT_TOKEN`, `LLM_ENDPOINT`, `LLM_PORT`. * You only need your `CLIENT_TOKEN`, `OLLAMA_IP`, `OLLAMA_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 ollama ip and port should just use it's defaults by nature. If not, utilize `OLLAMA_IP = 127.0.0.1` and `OLLAMA_PORT = 11434`.
* The 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. * Now, you can run the bot by running `npm run client` which will build and run the decompiled typescript and run the setup for ollama.
* **IMPORTANT**: This must be ran in the wsl/Linux instance to work properly! Using Command Prompt/Powershell/Git Bash/etc. will not work on Windows (at least in my experience). * **IMPORTANT**: This must be ran in the wsl/Linux instance to work properly! Using Command Prompt/Powershell/Git Bash/etc. will not work on Windows (at least in my experience).
* Refer to the [resources](../README.md#resources) on what node version to use. * Refer to the [resources](../README.md#resources) on what node version to use.
+1953 -1434
View File
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -1,46 +1,48 @@
{ {
"name": "discord-ollama", "name": "discord-ollama",
"version": "0.9.3", "version": "0.8.4",
"description": "Ollama Integration into discord", "description": "Ollama Integration into discord",
"main": "build/index.js", "main": "build/index.js",
"exports": "./build/index.js", "exports": "./build/index.js",
"scripts": { "scripts": {
"tests": "vitest run", "test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"watch": "tsx watch src", "watch": "tsx watch src",
"build": "tsc", "build": "tsc",
"prod": "node .", "prod": "node .",
"client": "npm run build && npm run prod", "client": "npm run build && npm run prod",
"clean": "docker compose down && docker rmi $(docker images | grep kevinthedang | tr -s ' ' | cut -d ' ' -f 3) && docker rmi $(docker images --filter \"dangling=true\" -q --no-trunc)", "clean": "docker compose down && docker rmi $(docker images | grep kevinthedang | tr -s ' ' | cut -d ' ' -f 3) && docker rmi $(docker images --filter \"dangling=true\" -q --no-trunc)",
"start": "docker compose build --no-cache && docker compose up -d", "start": "docker compose build --no-cache && docker compose up -d",
"docker:clean": "docker rm -f discord && docker rm -f ollama && docker network prune -f && docker rmi $(docker images | grep kevinthedang | tr -s ' ' | cut -d ' ' -f 3) && docker rmi $(docker images --filter \"dangling=true\" -q --no-trunc)", "docker:clean": "docker rm -f discord && docker rm -f ollama && docker rm -f redis && docker network prune -f && docker rmi $(docker images | grep 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:network": "docker network create --subnet=172.18.0.0/16 ollama-net",
"docker:build": "docker build --no-cache -t kevinthedang/discord-ollama:$(node -p \"require('./package.json').version\") .", "docker:build": "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: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: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:redis": "docker run -d -v redis:/root/.redis -p 6379:6379 --name redis --network ollama-net --ip 172.18.0.4 redis:latest",
"docker:ollama": "docker run -d --gpus=all -v ollama:/root/.ollama -p 11434:11434 --name ollama --network ollama-net --ip 172.18.0.2 ollama/ollama:latest", "docker:ollama": "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": "docker network prune -f && npm run docker:network && npm run docker:build && npm run docker:redis && npm run docker:client && npm run docker:ollama",
"docker:start-cpu": "docker network prune -f && npm run docker:network && npm run docker:build && npm run docker:client && npm run docker:ollama-cpu" "docker:start-cpu": "docker network prune -f && npm run docker:network && npm run docker:build && npm run docker:redis && npm run docker:client && npm run docker:ollama-cpu"
}, },
"author": "Kevin Dang", "author": "Kevin Dang",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"discord.js": "^14.20.0", "discord.js": "^14.18.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"ollama": "^0.5.15", "ollama": "^0.5.15",
"pino": "^10.4.0", "redis": "^4.7.1"
"pino-caller": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.14", "@types/node": "^22.13.14",
"@vitest/coverage-v8": "^3.0.9",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.19.3", "tsx": "^4.19.3",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"vitest": "^5.0.1" "vitest": "^3.0.4"
}, },
"type": "module", "type": "module",
"engines": { "engines": {
"npm": ">=10.9.0", "npm": ">=10.9.0",
"node": ">=24.0.0" "node": ">=22.12.0"
} }
} }
+54 -45
View File
@@ -1,46 +1,55 @@
import { Client, GatewayIntentBits } from 'discord.js' import { Client, GatewayIntentBits } from 'discord.js'
import { Ollama } from 'ollama' import { Ollama } from 'ollama'
import { Queue, createLogger } from './components/index.js' import { createClient } from 'redis'
import { UserMessage, registerEvents } from './utils/index.js' import { Queue } from './queues/queue.js'
import Events from './events/index.js' import { UserMessage, registerEvents } from './utils/index.js'
import Keys from './keys.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
// initialize the client with the following permissions when logging in const client = new Client({
const client = new Client({ intents: [
intents: [ GatewayIntentBits.Guilds,
GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent
GatewayIntentBits.MessageContent ]
] })
})
// initialize connection to redis
// initialize connection to ollama container const redis = createClient({
export const ollama = new Ollama({ url: `redis://${Keys.redisHost}:${Keys.redisPort}`,
host: `http://${Keys.ipAddress}:${Keys.portAddress}`, })
})
// initialize connection to ollama container
// Create Queue managed by Events export const ollama = new Ollama({
const messageHistory: Queue<UserMessage> = new Queue<UserMessage> host: `http://${Keys.ipAddress}:${Keys.portAddress}`,
})
// Create Channel History Queue managed by Events
const channelMessageHistory: Queue<UserMessage> = new Queue<UserMessage> // Create Queue managed by Events
const messageHistory: Queue<UserMessage> = new Queue<UserMessage>
// register all events
registerEvents(client, Events, messageHistory, channelMessageHistory, ollama, Keys.defaultModel) // register all events
registerEvents(client, Events, messageHistory, ollama, Keys.defaultModel)
// Try to log in the client
await client.login(Keys.clientToken) // Try to connect to redis
.catch((error) => { await redis.connect()
logger.fatal({ err: error }, 'Client login failed') .then(() => console.log('[Redis] Connected'))
process.exit(1) .catch((error) => {
}) console.error('[Redis] Connection Error', error)
process.exit(1)
// queue up bots name })
messageHistory.enqueue({
role: 'assistant', // Try to log in the client
content: `My name is ${client.user?.username}`, await client.login(Keys.clientToken)
images: [] .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: []
}) })
+4 -5
View File
@@ -1,6 +1,5 @@
import { Client, ChatInputCommandInteraction, ApplicationCommandOptionType, MessageFlags } from 'discord.js' import { Client, CommandInteraction, ApplicationCommandOptionType, MessageFlags } from 'discord.js'
import { openConfig, SlashCommand, UserCommand } from '../utils/index.js' import { openConfig, SlashCommand, UserCommand } from '../utils/index.js'
import type { Logger as PinoLogger } from 'pino'
export const Capacity: SlashCommand = { export const Capacity: SlashCommand = {
name: 'modify-capacity', name: 'modify-capacity',
@@ -17,14 +16,14 @@ export const Capacity: SlashCommand = {
], ],
// Query for message information and set the style // Query for message information and set the style
run: async (client: Client, interaction: ChatInputCommandInteraction, logger: PinoLogger) => { run: async (client: Client, interaction: CommandInteraction) => {
// fetch channel and message // fetch channel and message
const channel = await client.channels.fetch(interaction.channelId) const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !UserCommand.includes(channel.type)) return if (!channel || !UserCommand.includes(channel.type)) return
// set state of bot chat features // set state of bot chat features
openConfig(logger, `${interaction.user.username}-config.json`, interaction.commandName, openConfig(`${interaction.user.username}-config.json`, interaction.commandName,
interaction.options.getNumber('context-capacity') interaction.options.get('context-capacity')?.value
) )
interaction.reply({ interaction.reply({
+1 -3
View File
@@ -1,13 +1,12 @@
import { Channel, Client, CommandInteraction, MessageFlags, TextChannel } from 'discord.js' import { Channel, Client, CommandInteraction, MessageFlags, TextChannel } from 'discord.js'
import { clearChannelInfo, SlashCommand, UserCommand } from '../utils/index.js' import { clearChannelInfo, SlashCommand, UserCommand } from '../utils/index.js'
import type { Logger as PinoLogger } from 'pino'
export const ClearUserChannelHistory: SlashCommand = { export const ClearUserChannelHistory: SlashCommand = {
name: 'clear-user-channel-history', name: 'clear-user-channel-history',
description: 'clears history for user in the current channel', description: 'clears history for user in the current channel',
// Clear channel history for intended user // Clear channel history for intended user
run: async (client: Client, interaction: CommandInteraction, logger: PinoLogger) => { run: async (client: Client, interaction: CommandInteraction) => {
// fetch current channel // fetch current channel
const channel: Channel | null = await client.channels.fetch(interaction.channelId) const channel: Channel | null = await client.channels.fetch(interaction.channelId)
@@ -16,7 +15,6 @@ export const ClearUserChannelHistory: SlashCommand = {
// clear channel info for user // clear channel info for user
const successfulWipe = await clearChannelInfo( const successfulWipe = await clearChannelInfo(
logger,
interaction.channelId, interaction.channelId,
interaction.channel as TextChannel, interaction.channel as TextChannel,
interaction.user.username interaction.user.username
+4 -18
View File
@@ -1,8 +1,7 @@
import { ApplicationCommandOptionType, ChatInputCommandInteraction, Client, CommandInteraction, MessageFlags } from 'discord.js' import { ApplicationCommandOptionType, Client, CommandInteraction, MessageFlags } from 'discord.js'
import { UserCommand, SlashCommand } from '../utils/index.js' import { UserCommand, SlashCommand } from '../utils/index.js'
import { ollama } from '../client.js' import { ollama } from '../client.js'
import { ModelResponse } from 'ollama' import { ModelResponse } from 'ollama'
import type { Logger as PinoLogger } from 'pino'
export const DeleteModel: SlashCommand = { export const DeleteModel: SlashCommand = {
name: 'delete-model', name: 'delete-model',
@@ -19,11 +18,10 @@ export const DeleteModel: SlashCommand = {
], ],
// Delete Model locally stored // Delete Model locally stored
run: async (client: Client, interaction: ChatInputCommandInteraction, logger: PinoLogger) => { run: async (client: Client, interaction: CommandInteraction) => {
// defer reply to avoid timeout // defer reply to avoid timeout
await interaction.deferReply() await interaction.deferReply()
const modelInput: string = interaction.options.getString('model-name') as string const modelInput: string = interaction.options.get('model-name')!!.value as string
let ollamaOffline: boolean = false
// fetch channel and message // fetch channel and message
const channel = await client.channels.fetch(interaction.channelId) const channel = await client.channels.fetch(interaction.channelId)
@@ -39,20 +37,8 @@ export const DeleteModel: SlashCommand = {
} }
// check if model exists // check if model exists
const modelExists = await ollama.list() const modelExists: boolean = await ollama.list()
.then(response => response.models.some((model: ModelResponse) => model.name.startsWith(modelInput))) .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 { try {
// call ollama to delete model // call ollama to delete model
+5 -6
View File
@@ -1,6 +1,5 @@
import { Client, ChatInputCommandInteraction, ApplicationCommandOptionType, MessageFlags } from 'discord.js' import { Client, CommandInteraction, ApplicationCommandOptionType, MessageFlags } from 'discord.js'
import { AdminCommand, openConfig, SlashCommand } from '../utils/index.js' import { AdminCommand, openConfig, SlashCommand } from '../utils/index.js'
import type { Logger as PinoLogger } from 'pino'
export const Disable: SlashCommand = { export const Disable: SlashCommand = {
name: 'toggle-chat', name: 'toggle-chat',
@@ -17,7 +16,7 @@ export const Disable: SlashCommand = {
], ],
// Query for message information and set the style // Query for message information and set the style
run: async (client: Client, interaction: ChatInputCommandInteraction, logger: PinoLogger) => { run: async (client: Client, interaction: CommandInteraction) => {
// fetch channel and message // fetch channel and message
const channel = await client.channels.fetch(interaction.channelId) const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !AdminCommand.includes(channel.type)) return if (!channel || !AdminCommand.includes(channel.type)) return
@@ -32,12 +31,12 @@ export const Disable: SlashCommand = {
} }
// set state of bot chat features // set state of bot chat features
openConfig(logger, `${interaction.guildId}-config.json`, interaction.commandName, openConfig(`${interaction.guildId}-config.json`, interaction.commandName,
interaction.options.getBoolean('enabled') interaction.options.get('enabled')?.value
) )
interaction.reply({ interaction.reply({
content: `${client.user?.username} is now **${interaction.options.getBoolean('enabled') ? "enabled" : "disabled"}**.`, content: `${client.user?.username} is now **${interaction.options.get('enabled')?.value ? "enabled" : "disabled"}**.`,
flags: MessageFlags.Ephemeral flags: MessageFlags.Ephemeral
}) })
} }
+1 -3
View File
@@ -9,7 +9,6 @@ import { ClearUserChannelHistory } from './cleanUserChannelHistory.js'
import { PullModel } from './pullModel.js' import { PullModel } from './pullModel.js'
import { SwitchModel } from './switchModel.js' import { SwitchModel } from './switchModel.js'
import { DeleteModel } from './deleteModel.js' import { DeleteModel } from './deleteModel.js'
import { Summary } from './summary.js'
export default [ export default [
ThreadCreate, ThreadCreate,
@@ -21,6 +20,5 @@ export default [
ClearUserChannelHistory, ClearUserChannelHistory,
PullModel, PullModel,
SwitchModel, SwitchModel,
DeleteModel, DeleteModel
Summary
] as SlashCommand[] ] as SlashCommand[]
+5 -6
View File
@@ -1,6 +1,5 @@
import { ApplicationCommandOptionType, Client, ChatInputCommandInteraction, MessageFlags } from 'discord.js' import { ApplicationCommandOptionType, Client, CommandInteraction, MessageFlags } from 'discord.js'
import { openConfig, SlashCommand, UserCommand } from '../utils/index.js' import { openConfig, SlashCommand, UserCommand } from '../utils/index.js'
import type { Logger as PinoLogger } from 'pino'
export const MessageStream: SlashCommand = { export const MessageStream: SlashCommand = {
name: 'message-stream', name: 'message-stream',
@@ -17,18 +16,18 @@ export const MessageStream: SlashCommand = {
], ],
// change preferences based on command // change preferences based on command
run: async (client: Client, interaction: ChatInputCommandInteraction, logger: PinoLogger) => { run: async (client: Client, interaction: CommandInteraction) => {
// verify channel // verify channel
const channel = await client.channels.fetch(interaction.channelId) const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !UserCommand.includes(channel.type)) return if (!channel || !UserCommand.includes(channel.type)) return
// save value to json and write to it // save value to json and write to it
openConfig(logger, `${interaction.user.username}-config.json`, interaction.commandName, openConfig(`${interaction.user.username}-config.json`, interaction.commandName,
interaction.options.getBoolean('stream') interaction.options.get('stream')?.value
) )
interaction.reply({ interaction.reply({
content: `Message streaming is now set to: \`${interaction.options.getBoolean('stream')}\``, content: `Message streaming is now set to: \`${interaction.options.get('stream')?.value}\``,
flags: MessageFlags.Ephemeral flags: MessageFlags.Ephemeral
}) })
} }
+5 -20
View File
@@ -1,8 +1,7 @@
import { ApplicationCommandOptionType, Client, ChatInputCommandInteraction, MessageFlags } from "discord.js" import { ApplicationCommandOptionType, Client, CommandInteraction, MessageFlags } from "discord.js"
import { ollama } from "../client.js" import { ollama } from "../client.js"
import { ModelResponse } from "ollama" import { ModelResponse } from "ollama"
import { UserCommand, SlashCommand } from "../utils/index.js" import { UserCommand, SlashCommand } from "../utils/index.js"
import type { Logger as PinoLogger } from 'pino'
export const PullModel: SlashCommand = { export const PullModel: SlashCommand = {
name: 'pull-model', name: 'pull-model',
@@ -19,11 +18,10 @@ export const PullModel: SlashCommand = {
], ],
// Pull for model from Ollama library // Pull for model from Ollama library
run: async (client: Client, interaction: ChatInputCommandInteraction, logger: PinoLogger) => { run: async (client: Client, interaction: CommandInteraction) => {
// defer reply to avoid timeout // defer reply to avoid timeout
await interaction.deferReply() await interaction.deferReply()
const modelInput: string = interaction.options.getString('model-to-pull') as string const modelInput: string = interaction.options.get('model-to-pull')!!.value as string
let ollamaOffline: boolean = false
// fetch channel and message // fetch channel and message
const channel = await client.channels.fetch(interaction.channelId) const channel = await client.channels.fetch(interaction.channelId)
@@ -38,22 +36,9 @@ export const PullModel: SlashCommand = {
return return
} }
// check if model was already pulled, if the ollama service isn't running throw error // check if model was already pulled
const modelExists = await ollama.list() const modelExists: boolean = await ollama.list()
.then(response => response.models.some((model: ModelResponse) => model.name.startsWith(modelInput))) .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 { try {
// call ollama to pull desired model // call ollama to pull desired model
+3 -4
View File
@@ -1,19 +1,18 @@
import { Client, CommandInteraction, MessageFlags } from 'discord.js' import { Client, CommandInteraction, MessageFlags } from 'discord.js'
import { AdminCommand, SlashCommand } from '../utils/index.js' import { AdminCommand, SlashCommand } from '../utils/index.js'
import type { Logger as PinoLogger } from 'pino'
export const Shutoff: SlashCommand = { export const Shutoff: SlashCommand = {
name: 'shutoff', name: 'shutoff',
description: 'shutdown the bot. You will need to manually bring it online again. Administrator Only.', description: 'shutdown the bot. You will need to manually bring it online again. Administrator Only.',
// Query for message information and set the style // Query for message information and set the style
run: async (client: Client, interaction: CommandInteraction, logger: PinoLogger) => { run: async (client: Client, interaction: CommandInteraction) => {
// fetch channel and message // fetch channel and message
const channel = await client.channels.fetch(interaction.channelId) const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !AdminCommand.includes(channel.type)) return if (!channel || !AdminCommand.includes(channel.type)) return
// log this, this will probably be improtant for logging who did this // log this, this will probably be improtant for logging who did this
logger.info({ actor: interaction.user.tag, bot: client.user!!.tag }, 'Shutdown requested') console.log(`[Command: shutoff] User ${interaction.user.tag} attempting to shutdown ${client.user!!.tag}`)
// check if admin or false on shutdown // check if admin or false on shutdown
if (!interaction.memberPermissions?.has('Administrator')) { if (!interaction.memberPermissions?.has('Administrator')) {
@@ -30,7 +29,7 @@ export const Shutoff: SlashCommand = {
flags: MessageFlags.Ephemeral flags: MessageFlags.Ephemeral
}) })
logger.info({ bot: client.user?.tag }, 'Bot is shutting down') console.log(`[Command: shutoff] ${client.user?.tag} is shutting down.`)
// clean up client instance and stop // clean up client instance and stop
client.destroy() client.destroy()
-51
View File
@@ -1,51 +0,0 @@
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 })
}
}
+7 -14
View File
@@ -1,8 +1,7 @@
import { ApplicationCommandOptionType, Client, ChatInputCommandInteraction } from "discord.js" import { ApplicationCommandOptionType, Client, CommandInteraction } from "discord.js"
import { ollama } from "../client.js" import { ollama } from "../client.js"
import { ModelResponse } from "ollama" import { ModelResponse } from "ollama"
import { openConfig, UserCommand, SlashCommand } from "../utils/index.js" import { openConfig, UserCommand, SlashCommand } from "../utils/index.js"
import type { Logger as PinoLogger } from 'pino'
export const SwitchModel: SlashCommand = { export const SwitchModel: SlashCommand = {
name: 'switch-model', name: 'switch-model',
@@ -19,10 +18,10 @@ export const SwitchModel: SlashCommand = {
], ],
// Switch user preferred model if available in local library // Switch user preferred model if available in local library
run: async (client: Client, interaction: ChatInputCommandInteraction, logger: PinoLogger) => { run: async (client: Client, interaction: CommandInteraction) => {
await interaction.deferReply() await interaction.deferReply()
const modelInput: string = interaction.options.getString('model-to-use') as string const modelInput: string = interaction.options.get('model-to-use')!!.value as string
// fetch channel and message // fetch channel and message
const channel = await client.channels.fetch(interaction.channelId) const channel = await client.channels.fetch(interaction.channelId)
@@ -36,7 +35,7 @@ export const SwitchModel: SlashCommand = {
for (const model in response.models) { for (const model in response.models) {
const currentModel: ModelResponse = response.models[model] const currentModel: ModelResponse = response.models[model]
if (currentModel.name.startsWith(modelInput)) { if (currentModel.name.startsWith(modelInput)) {
openConfig(logger, `${interaction.user.username}-config.json`, interaction.commandName, modelInput) openConfig(`${interaction.user.username}-config.json`, interaction.commandName, modelInput)
// successful switch // successful switch
interaction.editReply({ interaction.editReply({
@@ -46,13 +45,10 @@ 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 // todo: problem can be here if async messes up
if (switchSuccess) { if (switchSuccess) {
// set model now that it exists // set model now that it exists
openConfig(logger, `${interaction.user.username}-config.json`, interaction.commandName, modelInput) openConfig(`${interaction.user.username}-config.json`, interaction.commandName, modelInput)
return return
} }
@@ -60,13 +56,10 @@ export const SwitchModel: SlashCommand = {
interaction.editReply({ interaction.editReply({
content: `Could not find **${modelInput}** in local model library.\n\nPlease contact an server admin for access to this model.` content: `Could not find **${modelInput}** in local model library.\n\nPlease contact an server admin for access to this model.`
}) })
} catch (error: any) { } catch (error) {
// could not resolve user model switch // 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({ interaction.editReply({
content: `Unable to switch user preferred model to **${modelInput}**.\n\n${error.message}` content: `Unable to switch user preferred model to **${modelInput}**.\n\n${error}\n\nPossible solution is to request an server admin run \`/pull-model ${modelInput}\` and try again.`
}) })
return return
} }
+2 -3
View File
@@ -1,13 +1,12 @@
import { ChannelType, Client, CommandInteraction, MessageFlags, TextChannel, ThreadChannel } from 'discord.js' import { ChannelType, Client, CommandInteraction, MessageFlags, TextChannel, ThreadChannel } from 'discord.js'
import { AdminCommand, openChannelInfo, SlashCommand } from '../utils/index.js' import { AdminCommand, openChannelInfo, SlashCommand } from '../utils/index.js'
import type { Logger as PinoLogger } from 'pino'
export const ThreadCreate: SlashCommand = { export const ThreadCreate: SlashCommand = {
name: 'thread', name: 'thread',
description: 'creates a thread and mentions user', description: 'creates a thread and mentions user',
// Query for server information // Query for server information
run: async (client: Client, interaction: CommandInteraction, logger: PinoLogger) => { run: async (client: Client, interaction: CommandInteraction) => {
// fetch the channel // fetch the channel
const channel = await client.channels.fetch(interaction.channelId) const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !AdminCommand.includes(channel.type)) return if (!channel || !AdminCommand.includes(channel.type)) return
@@ -22,7 +21,7 @@ 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.`) thread.send(`Hello ${interaction.user} and others! \n\nIt's nice to meet you. Please talk to me by typing **@${client.user?.username}** with your message.`)
// handle storing this chat channel // handle storing this chat channel
openChannelInfo(logger, thread.id, thread as ThreadChannel, interaction.user.tag) openChannelInfo(thread.id, thread as ThreadChannel, interaction.user.tag)
// user only reply // user only reply
return interaction.reply({ return interaction.reply({
+2 -3
View File
@@ -1,13 +1,12 @@
import { ChannelType, Client, CommandInteraction, MessageFlags, TextChannel, ThreadChannel } from 'discord.js' import { ChannelType, Client, CommandInteraction, MessageFlags, TextChannel, ThreadChannel } from 'discord.js'
import { AdminCommand, openChannelInfo, SlashCommand } from '../utils/index.js' import { AdminCommand, openChannelInfo, SlashCommand } from '../utils/index.js'
import type { Logger as PinoLogger } from 'pino'
export const PrivateThreadCreate: SlashCommand = { export const PrivateThreadCreate: SlashCommand = {
name: 'private-thread', name: 'private-thread',
description: 'creates a private thread and mentions user', description: 'creates a private thread and mentions user',
// Query for server information // Query for server information
run: async (client: Client, interaction: CommandInteraction, logger: PinoLogger) => { run: async (client: Client, interaction: CommandInteraction) => {
// fetch the channel // fetch the channel
const channel = await client.channels.fetch(interaction.channelId) const channel = await client.channels.fetch(interaction.channelId)
if (!channel || !AdminCommand.includes(channel.type)) return if (!channel || !AdminCommand.includes(channel.type)) return
@@ -23,7 +22,7 @@ export const PrivateThreadCreate: SlashCommand = {
// handle storing this chat channel // handle storing this chat channel
// store: thread.id, thread.name // store: thread.id, thread.name
openChannelInfo(logger, thread.id, thread as ThreadChannel, interaction.user.tag) openChannelInfo(thread.id, thread as ThreadChannel, interaction.user.tag)
// user only reply // user only reply
return interaction.reply({ return interaction.reply({
-2
View File
@@ -1,2 +0,0 @@
export * from './queue.js'
export * from './logger.js'
-15
View File
@@ -1,15 +0,0 @@
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() })
}
+3 -10
View File
@@ -1,26 +1,19 @@
import { event, Events } from '../utils/index.js' import { event, Events } from '../utils/index.js'
import commands from '../commands/index.js' import commands from '../commands/index.js'
import { createLogger } from '../components/index.js'
/** /**
* Interaction creation listener for the client * Interaction creation listener for the client
* @param interaction the interaction received from the server * @param interaction the interaction received from the server
*/ */
export default event(Events.InteractionCreate, async ({ logger, client }, interaction) => { export default event(Events.InteractionCreate, async ({ log, client }, interaction) => {
if (!interaction.isCommand() || !interaction.isChatInputCommand()) return if (!interaction.isCommand() || !interaction.isChatInputCommand()) return
logger.info({ command: interaction.commandName, user: interaction.user.tag }, 'Interaction received') log(`Interaction called \'${interaction.commandName}\' from ${interaction.user.tag}.`)
// ensure command exists, otherwise kill event // ensure command exists, otherwise kill event
const command = commands.find(command => command.name === interaction.commandName) const command = commands.find(command => command.name === interaction.commandName)
if (!command) return if (!command) return
// the command exists, execute it // the command exists, execute it
const commandLogger = createLogger({ command.run(client, interaction)
component: 'command',
command: command.name
}, logger)
void Promise.resolve(command.run(client, interaction, commandLogger))
.catch(error => commandLogger.error({ err: error }, 'Command execution failed'))
}) })
+26 -70
View File
@@ -1,6 +1,6 @@
import { TextChannel } from 'discord.js' import { TextChannel } from 'discord.js'
import { event, Events, normalMessage, UserMessage, clean } from '../utils/index.js'
import { import {
event, Events, normalMessage, UserMessage, clean, addToChannelContext,
getChannelInfo, getServerConfig, getUserConfig, openChannelInfo, getChannelInfo, getServerConfig, getUserConfig, openChannelInfo,
openConfig, UserConfig, getAttachmentData, getTextFileAttachmentData openConfig, UserConfig, getAttachmentData, getTextFileAttachmentData
} from '../utils/index.js' } from '../utils/index.js'
@@ -11,68 +11,14 @@ import {
* *
* @param message the message received from the channel * @param message the message received from the channel
*/ */
export default event(Events.MessageCreate, async ({ logger, msgHist, channelHistory, ollama, client, defaultModel }, message) => { export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client, defaultModel }, message) => {
const clientId = client.user!!.id const clientId = client.user!!.id
let cleanedMessage = clean(message.content, clientId) let cleanedMessage = clean(message.content, clientId)
logger.info({ author: message.author.tag, channelId: message.channelId, message: cleanedMessage }, 'Received message') log(`Message \"${cleanedMessage}\" from ${message.author.tag} in channel/thread ${message.channelId}.`)
// Do not respond if bot talks in the chat // Do not respond if bot talks in the chat
if (message.author.username === message.client.user.username) return if (message.author.username === message.client.user.username) return
// 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 // Only respond if message mentions the bot
if (!message.mentions.has(clientId)) return if (!message.mentions.has(clientId)) return
@@ -93,7 +39,7 @@ export default event(Events.MessageCreate, async ({ logger, msgHist, channelHist
// check if config.json exists // check if config.json exists
if (config === undefined) { if (config === undefined) {
// Allowing chat options to be available // Allowing chat options to be available
openConfig(logger, `${message.guildId}-config.json`, 'toggle-chat', true) openConfig(`${message.guildId}-config.json`, 'toggle-chat', true)
reject(new Error('Failed to locate or create Server Preferences\n\nPlease try chatting again...')) reject(new Error('Failed to locate or create Server Preferences\n\nPlease try chatting again...'))
} }
@@ -108,7 +54,7 @@ export default event(Events.MessageCreate, async ({ logger, msgHist, channelHist
} catch (error) { } catch (error) {
++attempt ++attempt
if (attempt < maxRetries) { if (attempt < maxRetries) {
logger.warn({ attempt, delay }, 'Failed to retrieve server preferences; retrying') log(`Attempt ${attempt} failed for Server Preferences. Retrying in ${delay}ms...`)
await new Promise(ret => setTimeout(ret, delay)) await new Promise(ret => setTimeout(ret, delay))
} else } else
throw new Error(`Could not retrieve Server Preferences, please try chatting again...`) throw new Error(`Could not retrieve Server Preferences, please try chatting again...`)
@@ -125,18 +71,19 @@ export default event(Events.MessageCreate, async ({ logger, msgHist, channelHist
userConfig = await new Promise((resolve, reject) => { userConfig = await new Promise((resolve, reject) => {
getUserConfig(`${message.author.username}-config.json`, (config) => { getUserConfig(`${message.author.username}-config.json`, (config) => {
if (config === undefined) { if (config === undefined) {
openConfig(logger, `${message.author.username}-config.json`, 'switch-model', defaultModel) openConfig(`${message.author.username}-config.json`, 'message-style', false)
reject(new Error(`No User Preferences is set up.\n\nCreating new preferences file for ${message.author.username}\nPlease try chatting again.`)) openConfig(`${message.author.username}-config.json`, 'switch-model', defaultModel)
reject(new Error('No User Preferences is set up.\n\nCreating preferences file with \`message-style\` set as \`false\` for regular message style.\nPlease try chatting again.'))
return return
} }
// check if there is a set capacity in config // check if there is a set capacity in config
else if (typeof config.options['modify-capacity'] !== 'number') else if (typeof config.options['modify-capacity'] !== 'number')
logger.info({ capacity: msgHist.capacity }, 'Capacity is undefined; using default') log(`Capacity is undefined, using default capacity of ${msgHist.capacity}.`)
else if (config.options['modify-capacity'] === msgHist.capacity) else if (config.options['modify-capacity'] === msgHist.capacity)
logger.info({ capacity: msgHist.capacity }, 'Configured capacity matches current capacity') log(`Capacity matches config as ${msgHist.capacity}, no changes made.`)
else { else {
logger.info({ capacity: config.options['modify-capacity'] }, 'Updating context capacity') log(`New Capacity found. Setting Context Capacity to ${config.options['modify-capacity']}.`)
msgHist.capacity = config.options['modify-capacity'] msgHist.capacity = config.options['modify-capacity']
} }
@@ -153,7 +100,7 @@ export default event(Events.MessageCreate, async ({ logger, msgHist, channelHist
} catch (error) { } catch (error) {
++attempt ++attempt
if (attempt < maxRetries) { if (attempt < maxRetries) {
logger.warn({ attempt, delay }, 'Failed to retrieve user preferences; retrying') log(`Attempt ${attempt} failed for User Preferences. Retrying in ${delay}ms...`)
await new Promise(ret => setTimeout(ret, delay)) await new Promise(ret => setTimeout(ret, delay))
} else } else
throw new Error(`Could not retrieve User Preferences, please try chatting again...`) throw new Error(`Could not retrieve User Preferences, please try chatting again...`)
@@ -167,7 +114,7 @@ export default event(Events.MessageCreate, async ({ logger, msgHist, channelHist
if (channelInfo?.messages) if (channelInfo?.messages)
resolve(channelInfo.messages) resolve(channelInfo.messages)
else { else {
logger.info({ channelId: message.channelId, username: message.author.username }, 'User channel history does not exist; creating it') log(`Channel/Thread ${message.channel}-${message.author.username} does not exist. File will be created shortly...`)
resolve([]) resolve([])
} }
}) })
@@ -175,7 +122,7 @@ export default event(Events.MessageCreate, async ({ logger, msgHist, channelHist
if (chatMessages.length === 0) { if (chatMessages.length === 0) {
chatMessages = await new Promise((resolve, reject) => { chatMessages = await new Promise((resolve, reject) => {
openChannelInfo(logger, message.channelId, openChannelInfo(message.channelId,
message.channel as TextChannel, message.channel as TextChannel,
message.author.tag message.author.tag
) )
@@ -183,7 +130,7 @@ export default event(Events.MessageCreate, async ({ logger, msgHist, channelHist
if (channelInfo?.messages) if (channelInfo?.messages)
resolve(channelInfo.messages) resolve(channelInfo.messages)
else { else {
logger.info({ channelId: message.channelId, username: message.author.username }, 'User channel history does not exist; creating it') 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.`)) reject(new Error(`Failed to find ${message.author.username}'s history. Try chatting again.`))
} }
}) })
@@ -193,6 +140,15 @@ export default event(Events.MessageCreate, async ({ logger, msgHist, channelHist
if (!userConfig) 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.`) 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'] const model: string = userConfig.options['switch-model']
// set up new queue // set up new queue
@@ -209,7 +165,7 @@ export default event(Events.MessageCreate, async ({ logger, msgHist, channelHist
}) })
// response string for ollama to put its response // response string for ollama to put its response
const response: string = await normalMessage(logger, message, ollama, model, msgHist, shouldStream) const response: string = await normalMessage(message, ollama, model, msgHist, shouldStream)
// If something bad happened, remove user query and stop // If something bad happened, remove user query and stop
if (response == undefined) { msgHist.pop(); return } if (response == undefined) { msgHist.pop(); return }
@@ -225,7 +181,7 @@ export default event(Events.MessageCreate, async ({ logger, msgHist, channelHist
}) })
// only update the json on success // only update the json on success
openChannelInfo(logger, message.channelId, openChannelInfo(message.channelId,
message.channel as TextChannel, message.channel as TextChannel,
message.author.tag, message.author.tag,
msgHist.getItems() msgHist.getItems()
+3 -3
View File
@@ -3,9 +3,9 @@ import { event, Events, registerCommands } from '../utils/index.js'
import commands from '../commands/index.js' import commands from '../commands/index.js'
// Log when the bot successfully logs in and export it // Log when the bot successfully logs in and export it
export default event(Events.ClientReady, ({ logger }, client) => { export default event(Events.ClientReady, ({ log }, client) => {
// Register the commands associated with the bot upon loggin in // Register the commands associated with the bot upon loggin in
registerCommands(client, commands, logger) registerCommands(client, commands)
// set status of the bot // set status of the bot
client.user.setActivity({ client.user.setActivity({
@@ -13,5 +13,5 @@ export default event(Events.ClientReady, ({ logger }, client) => {
type: ActivityType.Custom type: ActivityType.Custom
}) })
logger.info({ username: client.user.username }, 'Client ready') log(`Logged in as ${client.user.username}.`)
}) })
+6 -6
View File
@@ -5,16 +5,16 @@ import fs from 'fs'
/** /**
* Event to remove the associated .json file for a thread once deleted * Event to remove the associated .json file for a thread once deleted
*/ */
export default event(Events.ThreadDelete, async ({ logger }, thread: ThreadChannel) => { export default event(Events.ThreadDelete, async ({ log }, thread: ThreadChannel) => {
// iterate through every guild member in the thread and delete their history, except the bot // iterate through every guild member in the thread and delete their history, except the bot
try { try {
logger.info({ memberCount: thread.memberCount!! - 1 }, 'Deleting thread history') log(`Number of User Guild Members in Thread being deleted: ${thread.memberCount!! - 1}`)
const dirPath = 'data/' const dirPath = 'data/'
// read all files in data/ // read all files in data/
fs.readdir(dirPath, (error, files) => { fs.readdir(dirPath, (error, files) => {
if (error) { if (error) {
logger.error({ err: error, directory: dirPath }, 'Failed to read history directory') log(`Error reading directory ${dirPath}`, error)
return return
} }
@@ -28,13 +28,13 @@ export default event(Events.ThreadDelete, async ({ logger }, thread: ThreadChann
const filePath = dirPath + file const filePath = dirPath + file
fs.unlink(filePath, error => { fs.unlink(filePath, error => {
if (error) if (error)
logger.error({ err: error, file: filePath }, 'Failed to delete thread history file') log(`Error deleting file ${filePath}`, error)
else else
logger.info({ file: filePath }, 'Deleted thread history file') log(`Successfully deleted ${filePath} thread information`)
}) })
}) })
}) })
} catch (error) { } catch (error) {
logger.error({ err: error, threadId: thread.id }, 'Failed to delete user history files') log(`Issue deleting user history files from ${thread.id}`)
} }
}) })
+1 -1
View File
@@ -1 +1 @@
import('./client.js') import('./client.js')
+11 -9
View File
@@ -1,10 +1,12 @@
import { getEnvVar } from './utils/index.js' import { getEnvVar } from './utils/index.js'
export const Keys = { export const Keys = {
clientToken: getEnvVar('CLIENT_TOKEN'), clientToken: getEnvVar('CLIENT_TOKEN'),
ipAddress: getEnvVar('LLM_ENDPOINT', process.env.OLLAMA_IP ?? '127.0.0.1'), // OLLAMA_IP is a deprecated alias ipAddress: getEnvVar('OLLAMA_IP', '127.0.0.1'), // default ollama ip if none
portAddress: getEnvVar('LLM_PORT', process.env.OLLAMA_PORT ?? '11434'), // OLLAMA_PORT is a deprecated alias portAddress: getEnvVar('OLLAMA_PORT', '11434'), // default ollama port if none
defaultModel: getEnvVar('MODEL', 'llama3.2') defaultModel: getEnvVar('MODEL', 'llama3.2'),
} as const // readonly keys redisHost: getEnvVar('REDIS_IP', '172.18.0.4'), // default redis host if none
redisPort: parseInt(getEnvVar('REDIS_PORT', '6379')) // default redis port if none
} as const // readonly keys
export default Keys export default Keys
+9 -20
View File
@@ -1,5 +1,4 @@
import { ChatInputCommandInteraction, ChatInputApplicationCommandData, Client, type Awaitable } from 'discord.js' import { CommandInteraction, ChatInputApplicationCommandData, Client, ApplicationCommandOption } from 'discord.js'
import type { Logger as PinoLogger } from 'pino'
/** /**
* interface for how slash commands should be run * interface for how slash commands should be run
@@ -7,9 +6,9 @@ import type { Logger as PinoLogger } from 'pino'
export interface SlashCommand extends ChatInputApplicationCommandData { export interface SlashCommand extends ChatInputApplicationCommandData {
run: ( run: (
client: Client, client: Client,
interaction: ChatInputCommandInteraction, interaction: CommandInteraction,
logger: PinoLogger options?: ApplicationCommandOption[]
) => Awaitable<unknown> ) => void
} }
/** /**
@@ -17,7 +16,7 @@ export interface SlashCommand extends ChatInputApplicationCommandData {
* @param client the bot reference * @param client the bot reference
* @param commands commands to register to the bot * @param commands commands to register to the bot
*/ */
export function registerCommands(client: Client, commands: SlashCommand[], logger: PinoLogger): void { export function registerCommands(client: Client, commands: SlashCommand[]): void {
// ensure the bot is online before registering // ensure the bot is online before registering
if (!client.application) return if (!client.application) return
@@ -28,15 +27,10 @@ export function registerCommands(client: Client, commands: SlashCommand[], logge
client.application.commands.fetch().then((fetchedCommands) => { client.application.commands.fetch().then((fetchedCommands) => {
for (const command of fetchedCommands.values()) { for (const command of fetchedCommands.values()) {
if (!commandsToRegister.includes(command.name)) { if (!commandsToRegister.includes(command.name)) {
command.delete().then(() => { command.delete().catch(console.error)
logger.info({ command: command.name }, 'Removed command from Discord') console.log(`[Command: ${command.name}] Removed 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 // clear the cache of the commands
@@ -47,12 +41,7 @@ export function registerCommands(client: Client, commands: SlashCommand[], logge
client.application.commands client.application.commands
.create(command) .create(command)
.then((c) => { .then((c) => {
logger.info({ console.log(`[Command: ${c.name}] Registered on Discord`)
command: c.name, c.options?.forEach((o) => console.log(` - ${o.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')
}) })
} }
+1 -1
View File
@@ -26,7 +26,7 @@ export function getEnvVar(name: string, fallback?: string): string {
This is probably an invalid token unless Discord updated their token policy. Please provide a valid token.`) This is probably an invalid token unless Discord updated their token policy. Please provide a valid token.`)
// validate IPv4 address found in environment variables // validate IPv4 address found in environment variables
if ((name.endsWith("_IP") || name.endsWith("_ADDRESS") || name.endsWith("_ENDPOINT")) && !ipValidate.test(value)) if ((name.endsWith("_IP") || name.endsWith("_ADDRESS")) && !ipValidate.test(value))
throw new Error(`Environment variable ${name} does not follow IPv4 formatting.`) throw new Error(`Environment variable ${name} does not follow IPv4 formatting.`)
// return env variable // return env variable
+7 -10
View File
@@ -1,12 +1,11 @@
import type { ClientEvents, Awaitable, Client } from 'discord.js' import type { ClientEvents, Awaitable, Client } from 'discord.js'
import { Ollama } from 'ollama' import { Ollama } from 'ollama'
import { Queue, createLogger } from '../components/index.js' import { Queue } from '../queues/queue.js'
import type { Logger as PinoLogger } from 'pino'
// Export events through here to reduce amount of imports // Export events through here to reduce amount of imports
export { Events } from 'discord.js' export { Events } from 'discord.js'
export type LogMethod = PinoLogger export type LogMethod = (...args: unknown[]) => void
export type EventKeys = keyof ClientEvents // only wants keys of ClientEvents object export type EventKeys = keyof ClientEvents // only wants keys of ClientEvents object
/** /**
@@ -36,9 +35,8 @@ export type UserMessage = {
// Event properties // Event properties
export interface EventProps { export interface EventProps {
client: Client, client: Client,
logger: LogMethod, log: LogMethod,
msgHist: Queue<UserMessage>, msgHist: Queue<UserMessage>,
channelHistory: Queue<UserMessage>,
ollama: Ollama, ollama: Ollama,
defaultModel: String defaultModel: String
} }
@@ -80,20 +78,19 @@ export function registerEvents(
client: Client, client: Client,
events: Event[], events: Event[],
msgHist: Queue<UserMessage>, msgHist: Queue<UserMessage>,
channelHistory: Queue<UserMessage>,
ollama: Ollama, ollama: Ollama,
defaultModel: String defaultModel: String
): void { ): void {
for (const { key, callback } of events) { for (const { key, callback } of events) {
client.on(key, (...args) => { client.on(key, (...args) => {
const logger = createLogger({ component: 'event', event: key }) // Create a new log method for this event
const log = console.log.bind(console, `[Event: ${key}]`)
// Handle Errors, call callback, log errors as needed // Handle Errors, call callback, log errors as needed
try { try {
void Promise.resolve(callback({ client, logger, msgHist, channelHistory, ollama, defaultModel }, ...args)) callback({ client, log, msgHist, ollama, defaultModel }, ...args)
.catch(error => logger.error({ err: error }, 'Uncaught event error'))
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Uncaught event error') log('[Uncaught Error]', error)
} }
}) })
} }
+5 -54
View File
@@ -2,7 +2,6 @@ import { TextChannel, ThreadChannel } from 'discord.js'
import { Configuration, Channel, UserMessage } from '../index.js' import { Configuration, Channel, UserMessage } from '../index.js'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import type { Logger as PinoLogger } from 'pino'
/** /**
* Method to check if a thread history file exists * Method to check if a thread history file exists
@@ -30,7 +29,7 @@ async function checkChannelInfoExists(channel: TextChannel, user: string) {
* @param user username of user * @param user username of user
* @returns nothing * @returns nothing
*/ */
export async function clearChannelInfo(logger: PinoLogger, filename: string, channel: TextChannel, user: string): Promise<boolean> { export async function clearChannelInfo(filename: string, channel: TextChannel, user: string): Promise<boolean> {
const channelInfoExists: boolean = await checkChannelInfoExists(channel, user) const channelInfoExists: boolean = await checkChannelInfoExists(channel, user)
// If thread does not exist, file can't be found // If thread does not exist, file can't be found
@@ -41,7 +40,7 @@ export async function clearChannelInfo(logger: PinoLogger, filename: string, cha
const cleanedHistory: boolean = await new Promise((resolve) => { const cleanedHistory: boolean = await new Promise((resolve) => {
fs.readFile(fullFileName, 'utf8', (error, data) => { fs.readFile(fullFileName, 'utf8', (error, data) => {
if (error) if (error)
logger.error({ err: error, file: fullFileName }, 'Failed to read channel history') console.log(`[Error: openChannelInfo] Incorrect file format`)
else { else {
const object = JSON.parse(data) const object = JSON.parse(data)
if (object['messages'].length === 0) // already empty, let user know if (object['messages'].length === 0) // already empty, let user know
@@ -57,54 +56,6 @@ export async function clearChannelInfo(logger: PinoLogger, filename: string, cha
return cleanedHistory 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 * Method to open the channel history
* *
@@ -113,12 +64,12 @@ export async function accessChannelContext(filename: string, callback: (config:
* @param user the user's name * @param user the user's name
* @param messages their messages * @param messages their messages
*/ */
export async function openChannelInfo(logger: PinoLogger, filename: string, channel: TextChannel | ThreadChannel, user: string, messages: UserMessage[] = []): Promise<void> { export async function openChannelInfo(filename: string, channel: TextChannel | ThreadChannel, user: string, messages: UserMessage[] = []): Promise<void> {
const fullFileName = `data/${filename}-${user}.json` const fullFileName = `data/${filename}-${user}.json`
if (fs.existsSync(fullFileName)) { if (fs.existsSync(fullFileName)) {
fs.readFile(fullFileName, 'utf8', (error, data) => { fs.readFile(fullFileName, 'utf8', (error, data) => {
if (error) if (error)
logger.error({ err: error, file: fullFileName }, 'Failed to read channel history') console.log(`[Error: openChannelInfo] Incorrect file format`)
else { else {
const object = JSON.parse(data) const object = JSON.parse(data)
if (object['messages'].length === 0) if (object['messages'].length === 0)
@@ -144,7 +95,7 @@ export async function openChannelInfo(logger: PinoLogger, filename: string, chan
// only creating it, no need to add anything // only creating it, no need to add anything
fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2)) fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2))
logger.info({ file: fullFileName }, 'Created channel history') console.log(`[Util: openChannelInfo] Created '${fullFileName}' in working directory`)
} }
} }
+3 -4
View File
@@ -1,7 +1,6 @@
import { Configuration, ServerConfig, UserConfig, isServerConfigurationKey } from '../index.js' import { Configuration, ServerConfig, UserConfig, isServerConfigurationKey } from '../index.js'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import type { Logger as PinoLogger } from 'pino'
/** /**
* Method to open a file in the working directory and modify/create it * Method to open a file in the working directory and modify/create it
@@ -11,14 +10,14 @@ import type { Logger as PinoLogger } from 'pino'
* @param value new value to assign * @param value new value to assign
*/ */
// add type of change (server, user) // add type of change (server, user)
export function openConfig(logger: PinoLogger, filename: string, key: string, value: any) { export function openConfig(filename: string, key: string, value: any) {
const fullFileName = `data/${filename}` const fullFileName = `data/${filename}`
// check if the file exists, if not then make the config file // check if the file exists, if not then make the config file
if (fs.existsSync(fullFileName)) { if (fs.existsSync(fullFileName)) {
fs.readFile(fullFileName, 'utf8', (error, data) => { fs.readFile(fullFileName, 'utf8', (error, data) => {
if (error) if (error)
logger.error({ err: error, file: fullFileName }, 'Failed to read configuration file') console.log(`[Error: openConfig] Incorrect file format`)
else { else {
const object = JSON.parse(data) const object = JSON.parse(data)
object['options'][key] = value object['options'][key] = value
@@ -42,7 +41,7 @@ export function openConfig(logger: PinoLogger, filename: string, key: string, va
fs.mkdirSync(directory, { recursive: true }) fs.mkdirSync(directory, { recursive: true })
fs.writeFileSync(`data/${filename}`, JSON.stringify(object, null, 2)) fs.writeFileSync(`data/${filename}`, JSON.stringify(object, null, 2))
logger.info({ file: fullFileName }, 'Created configuration file') console.log(`[Util: openConfig] Created '${filename}' in working directory`)
} }
} }
+2 -1
View File
@@ -1,5 +1,6 @@
import { ChatResponse, AbortableAsyncIterator } from "ollama" import { ChatResponse } from "ollama"
import { ChatParams } from "../index.js" import { ChatParams } from "../index.js"
import { AbortableAsyncIterator } from "ollama/src/utils.js"
/** /**
* Method to query the Ollama client for async generation * Method to query the Ollama client for async generation
+5 -47
View File
@@ -1,8 +1,8 @@
import { Message, SendableChannels } from 'discord.js' import { Message, SendableChannels } from 'discord.js'
import { ChatResponse, Ollama, AbortableAsyncIterator } from 'ollama' import { ChatResponse, Ollama } from 'ollama'
import type { Logger as PinoLogger } from 'pino'
import { ChatParams, UserMessage, streamResponse, blockResponse } from './index.js' import { ChatParams, UserMessage, streamResponse, blockResponse } from './index.js'
import { Queue } from '../components/index.js' import { Queue } from '../queues/queue.js'
import { AbortableAsyncIterator } from 'ollama/src/utils.js'
/** /**
* Method to send replies as normal text on discord like any other user * Method to send replies as normal text on discord like any other user
@@ -11,7 +11,6 @@ import { Queue } from '../components/index.js'
* @param msgHist message history between user and model * @param msgHist message history between user and model
*/ */
export async function normalMessage( export async function normalMessage(
logger: PinoLogger,
message: Message, message: Message,
ollama: Ollama, ollama: Ollama,
model: string, model: string,
@@ -50,17 +49,13 @@ export async function normalMessage(
if (result.length > 5) if (result.length > 5)
messageBlock.edit(result) messageBlock.edit(result)
} }
logger.debug({ response: result }, 'Received response chunk') console.log(result)
} }
} }
else { else {
response = await blockResponse(params) response = await blockResponse(params)
result = response.message.content 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 // check if message length > discord max for normal messages
if (result.length > 2000) { if (result.length > 2000) {
sentMessage.edit(result.slice(0, 2000)) sentMessage.edit(result.slice(0, 2000))
@@ -78,7 +73,7 @@ export async function normalMessage(
sentMessage.edit(result) sentMessage.edit(result)
} }
} catch (error: any) { } catch (error: any) {
logger.error({ err: error, model }, 'Failed to create message response') console.log(`[Util: messageNormal] Error creating message: ${error.message}`)
if (error.message.includes('try pulling it first')) 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.`) 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 else
@@ -89,40 +84,3 @@ export async function normalMessage(
// return the string representation of ollama query response // return the string representation of ollama query response
return result 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
+84 -77
View File
@@ -1,78 +1,85 @@
// describe marks a test suite // describe marks a test suite
// expect takes a value from an expression // expect takes a value from an expression
// it marks a test case // it marks a test case
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import commands from '../src/commands/index.js' import commands from '../src/commands/index.js'
/** /**
* Mocking client.ts because of the commands * Mocking redis found in client.ts because of the commands
*/ */
vi.mock('../src/client.js', () => ({})) vi.mock('../src/client.js', () => ({
redis: {
/** createClient: vi.fn(),
* Commands test suite, tests the commands object connect: vi.fn(),
* Each command is to be tested elsewhere, this file get: vi.fn(),
* is to ensure that the commands object is defined. set: vi.fn()
* }
* @param name name of the test suite }))
* @param fn function holding tests to run
*/ /**
describe('Commands Existence', () => { * Commands test suite, tests the commands object
// test definition of commands object * Each command is to be tested elsewhere, this file
it('references defined object', () => { * is to ensure that the commands object is defined.
// toBe compares the value to the expected value *
expect(typeof commands).toBe('object') * @param name name of the test suite
}) * @param fn function holding tests to run
*/
// test specific commands in the object describe('Commands Existence', () => {
it('references specific commands', () => { // test definition of commands object
const commandsString = commands.map(e => e.name).join(', ') it('references defined object', () => {
const expectedCommands = ['thread', 'private-thread', 'message-stream', 'toggle-chat', 'shutoff', 'modify-capacity', 'clear-user-channel-history', 'pull-model', 'switch-model', 'delete-model', 'summary'] // toBe compares the value to the expected value
expect(commandsString).toBe(expectedCommands.join(', ')) expect(typeof commands).toBe('object')
}) })
})
// test specific commands in the object
/** it('references specific commands', () => {
* User Commands Test suite for testing out commands const commandsString = commands.map(e => e.name).join(', ')
* that would be run by users when using the application. 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(', '))
describe('User Command Tests', () => { })
// test capacity command })
it('run modify-capacity command', () => {
/**
}) * User Commands Test suite for testing out commands
* that would be run by users when using the application.
it('run clear-user-channel-history command', () => { */
describe('User Command Tests', () => {
}) // test capacity command
it('run modify-capacity command', () => {
it('run message-stream command', () => {
})
})
it('run clear-user-channel-history command', () => {
it('run message-style command', () => {
})
})
it('run message-stream command', () => {
it('run thread command', () => {
})
})
it('run message-style command', () => {
it('run private-thread command', () => {
})
})
}) it('run thread command', () => {
/** })
* Admin Commands Test suite for running administrative
* commands with the application. it('run private-thread command', () => {
*/
describe('Admin Command Tests', () => { })
it('run shutoff command', () => { })
}) /**
* Admin Commands Test suite for running administrative
it('run toggle-chat command', () => { * commands with the application.
*/
}) describe('Admin Command Tests', () => {
it('run shutoff command', () => {
})
it('run toggle-chat command', () => {
})
}) })
+1 -1
View File
@@ -26,6 +26,6 @@ describe('Events Existence', () => {
// test specific events in the object // test specific events in the object
it('references specific events', () => { it('references specific events', () => {
const eventsString = events.map(e => e.key.toString()).join(', ') const eventsString = events.map(e => e.key.toString()).join(', ')
expect(eventsString).toBe('clientReady, messageCreate, interactionCreate, threadDelete') expect(eventsString).toBe('ready, messageCreate, interactionCreate, threadDelete')
}) })
}) })
-24
View File
@@ -47,28 +47,4 @@ describe('Environment Setup', () => {
it('throws an error if key is not found', () => { it('throws an error if key is not found', () => {
expect(() => getEnvVar('NON_EXISTENT_KEY')).toThrowError() 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
@@ -1,64 +0,0 @@
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
@@ -1,123 +0,0 @@
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 { describe, expect, it } from 'vitest'
import { Queue } from '../src/components/index.js' import { Queue } from '../src/queues/queue.js'
/** /**
* Queue test suite, tests the Queue class * Queue test suite, tests the Queue class
+2 -1
View File
@@ -18,8 +18,9 @@
// Decompile .ts to .js into a folder named build // Decompile .ts to .js into a folder named build
"outDir": "build", "outDir": "build",
"rootDir": "src", "rootDir": "src",
"baseUrl": ".",
"paths": { "paths": {
"*": ["./node_modules/"] "*": ["node_modules/"]
} }
}, },
// environment for env vars // environment for env vars
+6 -2
View File
@@ -1,9 +1,13 @@
import { defineConfig } from 'vitest/config' import { defineConfig, configDefaults } from 'vitest/config'
// config for vitest // config for vitest
export default defineConfig({ export default defineConfig({
test: { test: {
globals: true, // <-- reduces test file imports globals: true, // <-- reduces test file imports
reporters: ['verbose'] // <-- verbose output reporters: ['verbose'], // <-- verbose output
coverage: {
exclude: [...configDefaults.exclude, 'build/*', 'tests/*'], // <-- exclude JS build
reporter: ['text-summary'] // <-- report in text-summary
}
} }
}) })