Public/Private Chat Threads (#62)

* add: validate thread creation in ollama channel

* rm: channel_id variable

* add: short notes for threads

* update: openFile to openConfig for clarity

* update: test ci runs on master

* add: notes for work

* add: basic chat storing via json

* update: stores entire msgHist according to capacity

* add: removes json file if thread is deleted

* add: chats with independent histories

* add: private vs public threads

* update: validate threads made by ollama for chats

* update: cleanup and version increment
This commit is contained in:
Kevin Dang
2024-06-10 19:47:08 -07:00
committed by GitHub
parent 9f77c5287f
commit 1973b1d3ae
22 changed files with 199 additions and 49 deletions

View File

@@ -4,9 +4,6 @@ CLIENT_TOKEN = BOT_TOKEN
# id token of a discord server
GUILD_ID = GUILD_ID
# Channel where the bot listens to messages
CHANNEL_ID = CHANNEL_ID
# model for the bot to query from (i.e. llama2 [llama2:13b], mistral, codellama, etc... )
MODEL = MODEL_NAME

View File

@@ -32,7 +32,6 @@ jobs:
touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo GUILD_ID = ${{ secrets.GUILD_ID }} >> .env
echo CHANNEL_ID = ${{ secrets.CHANNEL_ID }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
echo CLIENT_UID = ${{ secrets.CLIENT_UID }} >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
@@ -62,7 +61,6 @@ jobs:
touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo GUILD_ID = ${{ secrets.GUILD_ID }} >> .env
echo CHANNEL_ID = ${{ secrets.CHANNEL_ID }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
echo CLIENT_UID = ${{ secrets.CLIENT_UID }} >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env

View File

@@ -3,7 +3,7 @@ run-name: Test source code for errors
on:
push:
branches:
- unit-testing
- master
jobs:
Discord-Node-Test:
@@ -28,7 +28,6 @@ jobs:
touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo GUILD_ID = ${{ secrets.GUILD_ID }} >> .env
echo CHANNEL_ID = ${{ secrets.CHANNEL_ID }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
echo CLIENT_UID = ${{ secrets.CLIENT_UID }} >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env
@@ -56,7 +55,6 @@ jobs:
touch .env
echo CLIENT_TOKEN = ${{ secrets.BOT_TOKEN }} >> .env
echo GUILD_ID = ${{ secrets.GUILD_ID }} >> .env
echo CHANNEL_ID = ${{ secrets.CHANNEL_ID }} >> .env
echo MODEL = ${{ secrets.MODEL }} >> .env
echo CLIENT_UID = ${{ secrets.CLIENT_UID }} >> .env
echo OLLAMA_IP = ${{ secrets.OLLAMA_IP }} >> .env

View File

@@ -8,11 +8,10 @@ services:
build: ./ # find docker file in designated path
container_name: discord
restart: always # rebuild container always
image: discord/bot:0.4.4
image: discord/bot:0.5.0
environment:
CLIENT_TOKEN: ${CLIENT_TOKEN}
GUILD_ID: ${GUILD_ID}
CHANNEL_ID: ${CHANNEL_ID}
MODEL: ${MODEL}
CLIENT_UID: ${CLIENT_UID}
OLLAMA_IP: ${OLLAMA_IP}
@@ -40,6 +39,8 @@ services:
ports:
- ${OLLAMA_PORT}:${OLLAMA_PORT}
# Put Redis Container here?
# create a network that supports giving addresses withing a specific subnet
networks:
ollama-net:

10
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "discord-ollama",
"version": "0.4.4",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "discord-ollama",
"version": "0.4.4",
"version": "0.5.0",
"license": "ISC",
"dependencies": {
"discord.js": "^14.14.1",
@@ -1980,9 +1980,9 @@
}
},
"node_modules/ollama": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/ollama/-/ollama-0.5.0.tgz",
"integrity": "sha512-CRtRzsho210EGdK52GrUMohA2pU+7NbgEaBG3DcYeRmvQthDO7E2LHOkLlUUeaYUlNmEd8icbjC02ug9meSYnw==",
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/ollama/-/ollama-0.5.1.tgz",
"integrity": "sha512-mAiCHxdvu63E8EFopz0y82QG7rGfYmKAWgmjG2C7soiRuz/Sj3r/ebvCOp+jasiCubqUPE0ZThKT5LR6wrrPtA==",
"dependencies": {
"whatwg-fetch": "^3.6.20"
}

View File

@@ -1,6 +1,6 @@
{
"name": "discord-ollama",
"version": "0.4.4",
"version": "0.5.0",
"description": "Ollama Integration into discord",
"main": "build/index.js",
"exports": "./build/index.js",

View File

@@ -1,6 +1,6 @@
import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { SlashCommand } from '../utils/commands.js'
import { openFile } from '../utils/jsonHandler.js'
import { openConfig } from '../utils/jsonHandler.js'
export const Capacity: SlashCommand = {
name: 'modify-capacity',
@@ -20,10 +20,10 @@ export const Capacity: SlashCommand = {
run: async (client: Client, interaction: CommandInteraction) => {
// fetch channel and message
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || channel.type !== ChannelType.GuildText) return
if (!channel || channel.type !== ChannelType.PublicThread) return
// set state of bot chat features
openFile('config.json', interaction.commandName, interaction.options.get('context-capacity')?.value)
openConfig('config.json', interaction.commandName, interaction.options.get('context-capacity')?.value)
interaction.reply({
content: `Message History Capacity has been set to \`${interaction.options.get('context-capacity')?.value}\``,

View File

@@ -1,6 +1,6 @@
import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { SlashCommand } from '../utils/commands.js'
import { openFile } from '../utils/jsonHandler.js'
import { openConfig } from '../utils/jsonHandler.js'
export const Disable: SlashCommand = {
name: 'toggle-chat',
@@ -32,7 +32,7 @@ export const Disable: SlashCommand = {
}
// set state of bot chat features
openFile('config.json', interaction.commandName, interaction.options.get('enabled')?.value)
openConfig('config.json', interaction.commandName, interaction.options.get('enabled')?.value)
interaction.reply({
content: `Chat features has been \`${interaction.options.get('enabled')?.value ? "enabled" : "disabled" }\``,

View File

@@ -5,9 +5,11 @@ import { MessageStream } from './messageStream.js'
import { Disable } from './disable.js'
import { Shutoff } from './shutoff.js'
import { Capacity } from './capacity.js'
import { PrivateThreadCreate } from './threadPrivateCreate.js'
export default [
ThreadCreate,
PrivateThreadCreate,
MessageStyle,
MessageStream,
Disable,

View File

@@ -1,6 +1,6 @@
import { ApplicationCommandOptionType, ChannelType, Client, CommandInteraction } from 'discord.js'
import { SlashCommand } from '../utils/commands.js'
import { openFile } from '../utils/jsonHandler.js'
import { openConfig } from '../utils/jsonHandler.js'
export const MessageStream: SlashCommand = {
name: 'message-stream',
@@ -20,10 +20,10 @@ export const MessageStream: SlashCommand = {
run: async (client: Client, interaction: CommandInteraction) => {
// verify channel
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || channel.type !== ChannelType.GuildText) return
if (!channel || channel.type !== ChannelType.PublicThread) return
// save value to json and write to it
openFile('config.json', interaction.commandName, interaction.options.get('stream')?.value)
openConfig('config.json', interaction.commandName, interaction.options.get('stream')?.value)
interaction.reply({
content: `Message streaming preferences for embed set to: \`${interaction.options.get('stream')?.value}\``,

View File

@@ -1,6 +1,6 @@
import { ChannelType, Client, CommandInteraction, ApplicationCommandOptionType } from 'discord.js'
import { SlashCommand } from '../utils/commands.js'
import { openFile } from '../utils/jsonHandler.js'
import { openConfig } from '../utils/jsonHandler.js'
export const MessageStyle: SlashCommand = {
name: 'message-style',
@@ -20,10 +20,10 @@ export const MessageStyle: SlashCommand = {
run: async (client: Client, interaction: CommandInteraction) => {
// fetch channel and message
const channel = await client.channels.fetch(interaction.channelId)
if (!channel || channel.type !== ChannelType.GuildText) return
if (!channel || channel.type !== ChannelType.PublicThread) return
// set the message style
openFile('config.json', interaction.commandName, interaction.options.get('embed')?.value)
openConfig('config.json', interaction.commandName, interaction.options.get('embed')?.value)
interaction.reply({
content: `Message style preferences for embed set to: \`${interaction.options.get('embed')?.value}\``,

View File

@@ -1,5 +1,6 @@
import { ChannelType, Client, CommandInteraction, TextChannel } from 'discord.js'
import { SlashCommand } from '../utils/commands.js'
import { openThreadInfo } from '../utils/jsonHandler.js'
export const ThreadCreate: SlashCommand = {
name: 'thread',
@@ -12,16 +13,21 @@ export const ThreadCreate: SlashCommand = {
if (!channel || channel.type !== ChannelType.GuildText) return
const thread = await (channel as TextChannel).threads.create({
name: `support-${Date.now()}`,
reason: `Support ticket ${Date.now()}`
name: `${client.user?.username}-support-${Date.now()}`,
reason: `Support ticket ${Date.now()}`,
type: ChannelType.PublicThread
})
// Send a message in the thread
thread.send(`**User:** ${interaction.user} \n**People in Coversation:** ${thread.memberCount}`)
thread.send(`Hello ${interaction.user} and others! \n\nIt's nice to meet you. Please talk to me by typing **@${client.user?.username}** with your prompt.`)
// handle storing this chat channel
// store: thread.id, thread.name
openThreadInfo(`${thread.id}.json`, thread)
// user only reply
return interaction.reply({
content: `I can help you in the Thread below. \n**Thread ID:** ${thread.id}`,
content: `I can help you in thread **${thread.id}** below.`,
ephemeral: true
})
}

View File

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

View File

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

View File

@@ -1,17 +1,25 @@
import { ChatResponse } from 'ollama'
import { embedMessage, event, Events, normalMessage } from '../utils/index.js'
import { Configuration, getConfig, openFile } from '../utils/jsonHandler.js'
import { embedMessage, event, Events, normalMessage, UserMessage } from '../utils/index.js'
import { Configuration, getConfig, getThread, openConfig, openThreadInfo } from '../utils/jsonHandler.js'
import { clean } from '../utils/mentionClean.js'
import { ThreadChannel } from 'discord.js'
/**
* Max Message length for free users is 2000 characters (bot or not).
* @param message the message received from the channel
*/
export default event(Events.MessageCreate, async ({ log, msgHist, tokens, ollama }, message) => {
export default event(Events.MessageCreate, async ({ log, msgHist, tokens, ollama, client }, message) => {
log(`Message \"${clean(message.content)}\" from ${message.author.tag} in channel/thread ${message.channelId}.`)
// Hard-coded channel to test output there only, in our case "ollama-endpoint"
if (message.channelId != tokens.channel) return
// need new check for "open/active" threads here!
const threadMessages: UserMessage[] = await new Promise((resolve) => {
// set new queue to modify
getThread(`${message.channelId}.json`, (threadInfo) => {
if (threadInfo?.messages)
resolve(threadInfo.messages)
else
log(`Channel/Thread ${message.channelId} does not exist.`)
})
})
// Do not respond if bot talks in the chat
if (message.author.tag === message.client.user.tag) return
@@ -54,8 +62,12 @@ export default event(Events.MessageCreate, async ({ log, msgHist, tokens, ollama
})
})
// response string for ollama to put its response
let response: string
// set up new queue
msgHist.setQueue(threadMessages)
// check if we can push, if not, remove oldest
while (msgHist.size() >= msgHist.capacity) msgHist.dequeue()
@@ -82,9 +94,15 @@ export default event(Events.MessageCreate, async ({ log, msgHist, tokens, ollama
role: 'assistant',
content: response
})
// only update the json on success
openThreadInfo(`${message.channelId}.json`,
client.channels.fetch(message.channelId) as unknown as ThreadChannel,
msgHist.getItems()
)
} catch (error: any) {
msgHist.pop() // remove message because of failure
openFile('config.json', 'message-style', false)
openConfig('config.json', 'message-style', false)
message.reply(`**Error Occurred:**\n\n**Reason:** *${error.message}*`)
}
})

View File

@@ -0,0 +1,20 @@
import { ThreadChannel } from 'discord.js'
import { event, Events } from '../utils/index.js'
import fs from 'fs'
/**
* Event to remove the associated .json file for a thread once deleted
*/
export default event(Events.ThreadDelete, ({ log }, thread: ThreadChannel) => {
const filePath = `data/${thread.id}.json`
if (fs.existsSync(filePath)) {
fs.unlink(filePath, (error) => {
if (error)
log(`Error deleting file ${filePath}`, error)
else
log(`Successfully deleted ${filePath} thread info`)
})
} else {
log(`File ${filePath} does not exist.`)
}
})

View File

@@ -2,7 +2,6 @@ import { getEnvVar } from './utils/env.js'
export const Keys = {
clientToken: getEnvVar('CLIENT_TOKEN'),
channel: getEnvVar('CHANNEL_ID'),
model: getEnvVar('MODEL'),
clientUid: getEnvVar('CLIENT_UID'),
guildId: getEnvVar('GUILD_ID'),

View File

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

View File

@@ -15,7 +15,6 @@ export type EventKeys = keyof ClientEvents // only wants keys of ClientEvents ob
* @param clientUid the discord id for the bot
*/
export type Tokens = {
channel: string,
model: string,
clientUid: string
}

View File

@@ -1,4 +1,7 @@
import { ThreadChannel } from 'discord.js'
import { UserMessage } from './events.js'
import fs from 'fs'
import path from 'path'
export interface Configuration {
readonly name: string
@@ -10,6 +13,12 @@ export interface Configuration {
}
}
export interface Thread {
readonly id: string
readonly name: string
messages: UserMessage[]
}
/**
* Method to open a file in the working directory and modify/create it
*
@@ -17,19 +26,19 @@ export interface Configuration {
* @param key key value to access
* @param value new value to assign
*/
export function openFile(filename: string, key: string, value: any) {
export function openConfig(filename: string, key: string, value: any) {
// check if the file exists, if not then make the config file
if (fs.existsSync(filename)) {
fs.readFile(filename, 'utf8', (error, data) => {
if (error)
console.log(`[Error: openFile] Incorrect file format`)
console.log(`[Error: openConfig] Incorrect file format`)
else {
const object = JSON.parse(data)
object['options'][key] = value
fs.writeFileSync(filename, JSON.stringify(object, null, 2))
}
})
} else {
} else { // work on dynamic file creation
const object: Configuration = JSON.parse('{ \"name\": \"Discord Ollama Confirgurations\" }')
// set standard information for config file and options
@@ -38,10 +47,16 @@ export function openFile(filename: string, key: string, value: any) {
}
fs.writeFileSync(filename, JSON.stringify(object, null, 2))
console.log(`[Util: openFile] Created 'config.json' in working directory`)
console.log(`[Util: openConfig] Created '${filename}' in working directory`)
}
}
/**
* Method to obtain the configurations of the message chat/thread
*
* @param filename name of the configuration file to get
* @param callback function to allow a promise from getting the config
*/
export async function getConfig(filename: string, callback: (config: Configuration | undefined) => void): Promise<void> {
// attempt to read the file and get the configuration
if (fs.existsSync(filename)) {
@@ -56,3 +71,58 @@ export async function getConfig(filename: string, callback: (config: Configurati
callback(undefined) // file not found
}
}
/**
* Method to open/create and modify a json file containing thread information
*
* @param filename name of the thread file
* @param thread the thread with all of the interactions
* @param message message contents and from who
*/
export function openThreadInfo(filename: string, thread: ThreadChannel, messages: UserMessage[] = []) {
// check if the file exists, if not then make the config file
const fullFileName = `data/${filename}`
if (fs.existsSync(fullFileName)) {
fs.readFile(fullFileName, 'utf8', (error, data) => {
if (error)
console.log(`[Error: openConfig] Incorrect file format`)
else {
const object = JSON.parse(data)
object['messages'] = messages as []
fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2))
}
})
} else { // file doesn't exist, create it
const object: Configuration = JSON.parse(`{ \"id\": \"${thread?.id}\", \"name\": \"${thread?.name}\", \"messages\": []}`)
const directory = path.dirname(fullFileName)
if (!fs.existsSync(directory))
fs.mkdirSync(directory, { recursive: true })
// only creating it, no need to add anything
fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2))
console.log(`[Util: openThreadInfo] Created '${fullFileName}' in working directory`)
}
}
/**
* Method to obtain the configurations of the message chat/thread
*
* @param filename name of the configuration file to get
* @param callback function to allow a promise from getting the config
*/
export async function getThread(filename: string, callback: (config: Thread | undefined) => void): Promise<void> {
// attempt to read the file and get the configuration
const fullFileName = `data/${filename}`
if (fs.existsSync(fullFileName)) {
fs.readFile(fullFileName, 'utf8', (error, data) => {
if (error) {
callback(undefined)
return // something went wrong... stop
}
callback(JSON.parse(data))
})
} else {
callback(undefined) // file not found
}
}

View File

@@ -13,7 +13,6 @@ export async function embedMessage(
message: Message,
ollama: Ollama,
tokens: {
channel: string,
model: string
},
msgHist: Queue<UserMessage>,

View File

@@ -13,7 +13,6 @@ export async function normalMessage(
message: Message,
ollama: Ollama,
tokens: {
channel: string,
model: string
},
msgHist: Queue<UserMessage>,