7 Commits
Author SHA1 Message Date
Codex a9e2db352c ci: migrate workflows and dependencies to Gitea 2026-11-05 23:02:12 +03:00
deytenit 039dfdfa10 Revise README for better organization and information
Updated README to improve structure and clarity.
2026-01-28 21:43:40 +03:00
deytenit 7a3220a3aa Merge pull request #11 from deytenit/copilot/rewrite-ci-workflow-for-image-push
Migrate Docker image publishing to GitHub Container Registry
2026-01-28 21:33:29 +03:00
copilot-swe-agent[bot]anddeytenit de7048a213 Add required permissions for GHCR package write
Co-authored-by: deytenit <60275123+deytenit@users.noreply.github.com>
2026-01-28 18:28:54 +00:00
copilot-swe-agent[bot]anddeytenit 40013b5919 Update CI workflow to push Docker images to ghcr.io/deytenit/quetza
Co-authored-by: deytenit <60275123+deytenit@users.noreply.github.com>
2026-01-28 18:27:33 +00:00
copilot-swe-agent[bot] e77a471f81 Initial plan 2026-01-28 18:26:07 +00:00
deytenit 534cd72444 feat: Add AI module
ollama based ai module for more bot entertaiment
2025-06-13 14:42:08 +03:00
16 changed files with 649 additions and 213 deletions

No files matched your search

+4
View File
@@ -17,3 +17,7 @@
!build.sh
!config.tsconfig.json
!tsconfig.json
!.ci/
!.ci/vendor/
!.ci/vendor/yt-dlp
+64
View File
@@ -0,0 +1,64 @@
import json
import os
import re
import subprocess
import sys
from pathlib import Path
def lines(value):
return [line.strip() for line in value.splitlines() if line.strip()]
def output(name, value):
with open(os.environ['GITHUB_OUTPUT'], 'a') as stream:
stream.write(f'{name}<<GITEA_VALUE\n{value}\nGITEA_VALUE\n')
def metadata(images, rules, sha):
result = []
for rule in lines(rules):
fields = dict(part.split('=', 1) for part in rule.split(',') if '=' in part)
if fields.get('enable', 'true').lower() == 'false':
continue
kind = fields.get('type')
if kind == 'sha':
tag = 'sha-' + sha[:7]
elif kind == 'raw':
tag = fields['value']
else:
raise ValueError('Unsupported metadata rule')
if not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}', tag):
raise ValueError('Invalid image tag')
result.extend(image.lower() + ':' + tag for image in lines(images))
return result
def main():
mode = sys.argv[1]
if mode == 'login':
subprocess.run(['docker', 'login', os.environ['REGISTRY'], '--username', os.environ['REGISTRY_USER'], '--password-stdin'], input=os.environ['REGISTRY_TOKEN'], text=True, check=True)
elif mode == 'metadata':
output('tags', '\n'.join(metadata(os.environ['IMAGES'], os.environ['TAG_RULES'], os.environ['SOURCE_SHA'])))
output('labels', 'org.opencontainers.image.revision=' + os.environ['SOURCE_SHA'])
elif mode == 'build':
command = ['docker', 'buildx', 'build', '--metadata-file', '/tmp/image-metadata.json']
for env, flag in [('TAGS', '--tag'), ('LABELS', '--label'), ('BUILD_ARGS', '--build-arg')]:
for value in lines(os.environ.get(env, '')):
command.extend([flag, value])
command += ['--label', 'org.opencontainers.image.revision=' + os.environ['SOURCE_SHA'], '--label', 'com.ermnvldmr.ci.run=' + os.environ['RUN_ID']]
if os.environ.get('PLATFORMS'):
command += ['--platform', os.environ['PLATFORMS']]
if os.environ.get('DOCKERFILE'):
command += ['--file', os.environ['DOCKERFILE']]
if os.environ.get('PUSH', 'false').lower() == 'true':
command.append('--push')
command.append(os.environ.get('CONTEXT', '.'))
subprocess.run(command, check=True)
output('digest', json.loads(Path('/tmp/image-metadata.json').read_text())['containerimage.digest'])
else:
raise ValueError('Unknown docker operation')
if __name__ == '__main__':
main()
+145
View File
@@ -0,0 +1,145 @@
name: Continuous Integration
'on':
push:
tags:
- '*'
branches:
- master
jobs:
mocha:
runs-on: apex-debian13
strategy:
matrix:
node-version:
- 22.x
pnpm-version:
- 9
steps:
- name: Check out the repo
run: "set -euo pipefail\ncase \"$CHECKOUT_REF\" in -*) exit 1;; esac\nGITHUB_SERVER_URL=${GITHUB_SERVER_URL%/}\n\
case \"$GITHUB_SERVER_URL\" in https://git.ermnvldmr.com) ;; *) exit 1;; esac\n\
AUTH=$(printf 'deytenit:%s' \"$CHECKOUT_TOKEN\" | base64 -w0)\nHEADER=\"http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION:\
\ basic $AUTH\"\ngit init \"$GITHUB_WORKSPACE\"\ncd \"$GITHUB_WORKSPACE\"\n\
git remote add origin \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git\"\ngit -c\
\ \"$HEADER\" fetch --tags origin '+refs/heads/*:refs/remotes/origin/*'\n\
git -c \"$HEADER\" fetch origin \"$CHECKOUT_REF\"\ngit checkout --detach FETCH_HEAD\n\
test \"$(git rev-parse HEAD)\" = \"$GITHUB_SHA\"\nif [ \"$CHECKOUT_SUBMODULES\"\
\ = recursive ]; then\n key_file=$(mktemp \"${RUNNER_TEMP:-/tmp}/hextra-key.XXXXXX\"\
)\n trap 'rm -f \"$key_file\"' EXIT\n umask 077\n printf '%s\\n'\
\ \"$HEXTRA_READ_KEY\" > \"$key_file\"\n chmod 600 \"$key_file\"\n export\
\ GIT_SSH_COMMAND=\"ssh -T -i $key_file -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes\
\ -o HostKeyAlgorithms=ssh-ed25519 -o UserKnownHostsFile=$GITHUB_WORKSPACE/.gitea/known_hosts\"\
\n git submodule update --init --recursive\nfi\nif [ \"$PERSIST_CREDENTIALS\"\
\ = true ]; then\n git config \"http.${GITHUB_SERVER_URL}/.extraheader\"\
\ \"AUTHORIZATION: basic $AUTH\"\nfi\n"
env:
CHECKOUT_REF: ${{ github.sha }}
CHECKOUT_SUBMODULES: 'false'
CHECKOUT_TOKEN: ${{ github.token }}
PERSIST_CREDENTIALS: 'false'
HEXTRA_READ_KEY: ''
- run: 'case "$PNPM_VERSION" in 8|9|9.12.3) ;; *) exit 1;; esac
test -x "/opt/ci/pnpm/$PNPM_VERSION/bin/pnpm"
echo "/opt/ci/pnpm/$PNPM_VERSION/bin" >> "$GITHUB_PATH"'
env:
PNPM_VERSION: ${{ matrix.pnpm-version }}
- name: Use Node.js ${{ matrix.node-version }}
run: "case \"$NODE_VERSION\" in\n 18*) major=18;; 20*) major=20;; 22*) major=22;;\
\ *) exit 1;;\nesac\ntest -x \"/opt/ci/node/$major/bin/node\"\necho \"/opt/ci/node/$major/bin\"\
\ >> \"$GITHUB_PATH\""
env:
NODE_VERSION: ${{ matrix.node-version }}
- name: Install dependencies
run: pnpm install
- name: ESLint & Prettier
run: pnpm run lint
- name: Build
run: pnpm run build
- name: Test
run: pnpm run --if-present test
env:
npm_config_build_from_source: 'true'
docker:
runs-on: apex-debian13
needs: mocha
permissions:
contents: read
packages: write
steps:
- name: Check out the repo
run: "set -euo pipefail\ncase \"$CHECKOUT_REF\" in -*) exit 1;; esac\nGITHUB_SERVER_URL=${GITHUB_SERVER_URL%/}\n\
case \"$GITHUB_SERVER_URL\" in https://git.ermnvldmr.com) ;; *) exit 1;; esac\n\
AUTH=$(printf 'deytenit:%s' \"$CHECKOUT_TOKEN\" | base64 -w0)\nHEADER=\"http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION:\
\ basic $AUTH\"\ngit init \"$GITHUB_WORKSPACE\"\ncd \"$GITHUB_WORKSPACE\"\n\
git remote add origin \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git\"\ngit -c\
\ \"$HEADER\" fetch --tags origin '+refs/heads/*:refs/remotes/origin/*'\n\
git -c \"$HEADER\" fetch origin \"$CHECKOUT_REF\"\ngit checkout --detach FETCH_HEAD\n\
test \"$(git rev-parse HEAD)\" = \"$GITHUB_SHA\"\nif [ \"$CHECKOUT_SUBMODULES\"\
\ = recursive ]; then\n key_file=$(mktemp \"${RUNNER_TEMP:-/tmp}/hextra-key.XXXXXX\"\
)\n trap 'rm -f \"$key_file\"' EXIT\n umask 077\n printf '%s\\n'\
\ \"$HEXTRA_READ_KEY\" > \"$key_file\"\n chmod 600 \"$key_file\"\n export\
\ GIT_SSH_COMMAND=\"ssh -T -i $key_file -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes\
\ -o HostKeyAlgorithms=ssh-ed25519 -o UserKnownHostsFile=$GITHUB_WORKSPACE/.gitea/known_hosts\"\
\n git submodule update --init --recursive\nfi\nif [ \"$PERSIST_CREDENTIALS\"\
\ = true ]; then\n git config \"http.${GITHUB_SERVER_URL}/.extraheader\"\
\ \"AUTHORIZATION: basic $AUTH\"\nfi\n"
env:
CHECKOUT_REF: ${{ github.sha }}
CHECKOUT_SUBMODULES: 'false'
CHECKOUT_TOKEN: ${{ github.token }}
PERSIST_CREDENTIALS: 'false'
HEXTRA_READ_KEY: ''
- name: Stage verified yt-dlp asset
run: 'mkdir -p .ci/vendor
cp /opt/ci/assets/yt-dlp .ci/vendor/yt-dlp'
- name: Set up QEMU
run: test -r /proc/sys/fs/binfmt_misc/qemu-aarch64
- name: Set up Docker Buildx
run: 'docker buildx version
docker buildx create --name apex-ci --driver docker-container --use
docker buildx inspect --bootstrap'
- name: Log in to GitHub Container Registry
run: python3 .gitea/scripts/docker.py login
env:
REGISTRY: git.ermnvldmr.com
REGISTRY_USER: ${{ github.repository_owner }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
- name: Docker Generate Meta
id: meta
run: python3 .gitea/scripts/docker.py metadata
env:
IMAGES: 'git.ermnvldmr.com/deytenit/quetza
'
TAG_RULES: 'type=raw,value=latest,enable=${{ github.ref_type == ''tag'' &&
!contains(github.ref_name, ''-'') }}
type=raw,value=latest-dev,enable=${{ github.ref_type == ''branch'' && github.ref_name
== ''master'' }}
type=raw,value=${{ github.ref_name }}
type=raw,value=${{ github.sha }},enable=${{ github.ref_type == ''branch''
}}
'
SOURCE_SHA: ${{ github.sha }}
- name: Build and Push Docker Image
run: python3 .gitea/scripts/docker.py build
env:
TAGS: ${{ steps.meta.outputs.tags }}
LABELS: ${{ steps.meta.outputs.labels }}
BUILD_ARGS: ''
DOCKERFILE: ''
PLATFORMS: linux/amd64,linux/arm64
PUSH: 'true'
CONTEXT: .
SOURCE_SHA: ${{ github.sha }}
RUN_ID: ${{ github.run_id }}
env:
npm_config_build_from_source: 'true'
-88
View File
@@ -1,88 +0,0 @@
name: Continuous Integration
on:
push:
tags:
- "*"
branches:
- master
jobs:
mocha:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22.x]
pnpm-version: [9]
steps:
- name: Check out the repo
uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: ${{ matrix.pnpm-version }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: pnpm install
- name: ESLint & Prettier
run: pnpm run lint
- name: Build
run: pnpm run build
- name: Test
run: pnpm run --if-present test
docker:
runs-on: ubuntu-latest
needs: mocha
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Docker Generate Meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
deytenit/quetza
# generate Docker tags based on the following events/attributes
# on tag event: tag using git tag, and as latest if the tag doesn't contain hyphens (pre-releases)
# on push event: tag using git sha, branch name and as latest-dev
tags: |
type=raw,value=latest,enable=${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }}
type=raw,value=latest-dev,enable=${{ github.ref_type == 'branch' && github.ref_name == 'master' }}
type=raw,value=${{ github.ref_name }}
type=raw,value=${{ github.sha }},enable=${{ github.ref_type == 'branch' }}
flavor: |
latest=false
- name: Build and Push Docker Image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+3 -2
View File
@@ -6,7 +6,8 @@ RUN apk add --no-cache make
RUN apk add --no-cache gcc
RUN apk add --no-cache build-base
RUN npm install -g pnpm
RUN npm install -g pnpm@9.12.3
ENV npm_config_build_from_source=true
WORKDIR /usr/src/app
@@ -19,7 +20,7 @@ COPY . .
RUN pnpm run build
RUN mkdir ./dist/bin/
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/src/app/dist/bin/yt-dlp
COPY .ci/vendor/yt-dlp /usr/src/app/dist/bin/yt-dlp
RUN chmod a+rx /usr/src/app/dist/bin/yt-dlp
RUN pnpm install --prod
+39 -121
View File
@@ -4,145 +4,63 @@
Awesome Discord bot that performs various duties on my server.
## Architecture
## Quick Start
At its core **Quetza** is simply a **[user-client](./src/lib/client.ts)** with a **[logger](./src/lib/logger.ts)**.
- [Documentation](<https://docs.ermnvldmr.com/en/quetza/>)
- [Docker Image](<https://ghcr.io/deytenit/quetza>)
- [Report an issue](<https://github.com/deyteit/Quetza/issues>)
### Modules
## Who Are You?
**Modules** define the features of **Quetza**.
### Visitor
> 🗂 **The structure** of a **module**
Just interested in what Quetza can do.
```sh
modules/example/
├ module.ts
├ commands/
│ ├ [command-name].ts
├ events/
│ ├ [event-name].ts
├ lib/
│ ├ [...libraries] // Including controller if exists.
```
- **See Quetza in Action by running an image**: [ghcr.io: quetza image](https://ghcr.io/deytenit/quetza)
#### **module.ts**
### Developer
**The module** must have a definition file that **adheres** to the following **interface** as _export_.
Want to run or contribute to the bot.
> 📐 **Module** export **interface** [defined in _[src/lib/types.ts](./src/lib/types.ts)_]
- **Documentation**: [Docs: Quetza](https://docs.ermnvldmr.com/en/quetza/)
- **Run with Docker** (recommended):
```bash
docker run \
-e DISCORD_TOKEN=<your-token> \
--name quetza -d \
ghcr.io/deyteit/quetza:latest
```
- **Build from Source**:
```bash
pnpm install
pnpm run build
pnpm start
```
- **Environment Variables**:
- `DISCORD_TOKEN`: Generated at [Discord Developer Portal](https://discord.com/developers/applications)
```ts
interface Module {
name: string; // Name of the module.
description?: string; // Description of the module.
controller?: unknown;
// The service orchestrating the module. (passed as third argument to command or event executor)
}
```
### Designer
#### commands/[command-name].ts
Interested in the visual design and messaging.
Each **command** has to be defined in its own file, preferably named after **the command** itself.
**The client** indexes **the commands** of each **module** and sends them to **the Discord API**.
- **Icons & Branding**: See [assets/](./assets/) directory for logos and visual elements
- **Message Typography**: Explore Discord embeds and message formatting in the `lib/replies.ts` files of the respectful modules.
- **Design License**: Branding assets are separately licensed (see [LICENSE](./assets/LICENSE))
> 📐 **Command** **interface** to export [defined in _[src/lib/types.ts](./src/lib/types.ts)_]
### Contributor
```ts
interface Command {
data: ApplicationCommandData; // Definition of command specifically for the Discord API.
execute: (client: Client, interaction: CommandInteraction, controller?: unknown) => Promise<void>;
// Function that will be called upon interaction invocation.
}
```
Want to submit improvements?
> ⚠️ Each **command** must comply with the _command interaction_ requirements of **the Discord API**.
Just create a Pull-Request, and I would be greatful. (or just fork repo for yourself, then I won't even be needed)
#### events/[event-name].ts
## Communication & Support
**Events** are pre-defined by **the Discord API** and can only have new functions
bound to **them** by **modules**, despite being declared similarly to **commands**.
- **Issues**: [Github: issues](https://github.com/deyteit/Quetza/issues)
> 📐 **Event** **interface** to export [defined in _[src/lib/types.ts](./src/lib/types.ts)_]
> Not a fact that I would care about that
```ts
export interface Event {
name: string;
execute: (client: Client, eventee: unknown[], controller?: unknown) => Promise<void>;
}
```
## License
> ✅ **These** can be re-defined so that they can operate independently in their own **module**.
#### lib/[...libraries] & controller
If you expand the provided **module** **structure**, it will still be a valid **module**.
**_lib/_** is simply a suggestion to add consistency to the codebase.
**The controller** is responsible for orchestrating the behaviour of your **module**.
_Imagine it like a web application service._
> 📘 **For example**, creating players for listening to music on voice channels
## Build System
**Quetza** relies on various dependencies to function correctly. The **[package.json](./package.json)** file lists the _engines_, _toolchain_, _dependencies_, and other _metadata_.
> ⚠️ **pnpm** is being used as package manager.
### External Dependencies
**Modules** (or maybe **Quetza** itself) can leverage external _binaries_
that shall be located at _bin/_ directory or be accessible from _the environment_.
> 🔗 **Listing** of extenal _dependencies_
| **Base/Module** | **Dependency** |
| --------------- | ------------------------------------------ |
| Music | [yt-dlp](https://github.com/yt-dlp/yt-dlp) |
### Shipping
#### Docker
[![Build Status](https://github.com/unknowableshade/quetza/actions/workflows/ci.yml/badge.svg?branch=master&event=push)](https://github.com/unknowableshade/quetza/actions/workflows/ci.yml)
The primary method of deploying **Quetza** is through
a **[Docker Image](https://hub.docker.com/repository/docker/unknowableshade/quetza)**
provided by the respective
**[action](./.github/workflows/ci.yml)**.
```bash
docker run \
-e DISCORD_TOKEN=*aquired via Discord Developer Portal* \
--name quetza -d \
unknowableshade/quetza-bot:latest
```
#### From Source
Although it is possible to run it directly from the source, some additional steps are required.
- Compile _TypeScript_
```bash
pnpm run build
```
- Run **Quetza**
```bash
pnpm start
```
#### Environment
In any case, you have to provide **environmental variables** for **bot** to run.
- **DISCORD_TOKEN**: generated token issued by **Discord** itself at
**[Developer Portal](https://discord.com/developers/applications)**.
## Licence
**Quetza's** source code is licensed under **[the MIT Licence](,/LICENSE)**.
**Quetza's** source code is licensed under **[the MIT Licence](./LICENSE)**.
**Notice!** This does not cover '**_Quetza_**' branding such as **[logo](./assets/quetza-logo.png)** or other **[assets listed](./assets/)**, as these are protected by **[the separate licence](./assets/LICENSE)**.
+8 -1
View File
@@ -47,12 +47,19 @@ const dev = {
guild: testGuild
};
/** AI module secrets. */
const llama = {
apiUrl: process.env.LLAMA_API_URL,
model: process.env.LLAMA_MODEL
};
/** Exported bundled configuration object. */
const config = {
application,
colors,
path,
dev
dev,
llama
};
export default config;
+79
View File
@@ -0,0 +1,79 @@
/**
* /ask
*
* Sends a prompt to the locally deployed Llama LLM and returns the response.
* Maintains conversation history per user and guild.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import AI from "../lib/ai.js";
import replies from "../lib/replies.js";
async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: AI
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild()) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const prompt = interaction.options.getString("prompt", true);
await interaction.deferReply();
try {
// Test Llama connection
const isConnected = await controller.llama.testConnection();
if (!isConnected) {
await interaction.editReply(replies.connectionError());
return;
}
// Get or create conversation
const conversation = controller.getConversation(interaction.guildId, interaction.user.id);
// Clear stale conversations
if (conversation.isStale()) {
conversation.clear();
}
// Add user message to conversation
conversation.addUserMessage(prompt);
// Send to Llama
const response = await controller.llama.chat(conversation.getHistory());
// Add assistant response to conversation
conversation.addAssistantMessage(response);
// Reply with the response
await interaction.editReply(replies.response(response));
} catch (error) {
logger.error("Error in ask command", {
error,
userId: interaction.user.id,
guildId: interaction.guildId
});
await interaction.editReply(replies.error());
}
}
const data = new SlashCommandBuilder()
.setName("ask")
.setDescription("Ask a question to the AI assistant")
.addStringOption((option) =>
option
.setName("prompt")
.setDescription("Your question or prompt")
.setRequired(true)
.setMaxLength(2000)
)
.setDMPermission(false);
export { data, execute };
+35
View File
@@ -0,0 +1,35 @@
/**
* /askclear
*
* Clears the conversation history for the current user in the current guild.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import AI from "../lib/ai.js";
import replies from "../lib/replies.js";
async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: AI
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild()) {
logger.warn("Interaction rejected.", { interaction });
return;
}
controller.clearConversation(interaction.guildId, interaction.user.id);
await interaction.reply(replies.historyCleared());
}
const data = new SlashCommandBuilder()
.setName("askclear")
.setDescription("Clear your conversation history with the AI assistant")
.setDMPermission(false);
export { data, execute };
+49
View File
@@ -0,0 +1,49 @@
import { Collection } from "discord.js";
import { Conversation } from "./conversation.js";
import { LlamaClient } from "./llama.js";
/**
* AI module controller.
*
* Manages conversations and Llama LLM interactions.
*/
export default class AI {
/**
* Llama API client.
*/
public readonly llama = new LlamaClient();
/**
* Guild + User ID to Conversation mapping.
*/
private conversations_ = new Collection<string, Conversation>();
/**
* Gets or creates a conversation for a user in a guild.
*
* @param guildId - Discord guild ID
* @param userId - Discord user ID
* @returns Conversation instance
*/
public getConversation(guildId: string, userId: string): Conversation {
const key = `${guildId}:${userId}`;
if (!this.conversations_.has(key)) {
this.conversations_.set(key, new Conversation(guildId, userId));
}
return this.conversations_.get(key)!;
}
/**
* Clears conversation history for a user in a guild.
*
* @param guildId - Discord guild ID
* @param userId - Discord user ID
*/
public clearConversation(guildId: string, userId: string): void {
const key = `${guildId}:${userId}`;
this.conversations_.delete(key);
}
}
+77
View File
@@ -0,0 +1,77 @@
import { LlamaMessage } from "./llama.js";
/**
* Manages conversation history for a specific user in a guild.
*/
export class Conversation {
public readonly guildId: string;
public readonly userId: string;
public readonly messages: LlamaMessage[] = [];
public readonly createdAt: Date = new Date();
public lastActivity: Date = new Date();
private readonly maxMessages = 50; // Limit conversation history
public constructor(guildId: string, userId: string) {
this.guildId = guildId;
this.userId = userId;
}
/**
* Adds a user message to the conversation.
*
* @param content - User's message content
*/
public addUserMessage(content: string): void {
this.messages.push({ role: "user", content });
this.lastActivity = new Date();
this.trimHistory();
}
/**
* Adds an assistant response to the conversation.
*
* @param content - Assistant's response content
*/
public addAssistantMessage(content: string): void {
this.messages.push({ role: "assistant", content });
this.lastActivity = new Date();
this.trimHistory();
}
/**
* Gets the conversation history.
*
* @returns Array of messages
*/
public getHistory(): LlamaMessage[] {
return [...this.messages];
}
/**
* Clears the conversation history.
*/
public clear(): void {
this.messages.length = 0;
this.lastActivity = new Date();
}
/**
* Trims conversation history to stay within limits.
*/
private trimHistory(): void {
if (this.messages.length > this.maxMessages) {
this.messages.splice(0, this.messages.length - this.maxMessages);
}
}
/**
* Checks if the conversation is stale (inactive for over 1 hour).
*
* @returns True if conversation should be considered stale
*/
public isStale(): boolean {
const oneHour = 60 * 60 * 1000;
return Date.now() - this.lastActivity.getTime() > oneHour;
}
}
+66
View File
@@ -0,0 +1,66 @@
import logger from "$lib/logger.js";
import config from "$config.js";
export interface LlamaResponse {
content: string;
done: boolean;
}
export interface LlamaMessage {
role: "system" | "user" | "assistant";
content: string;
}
/**
* Client for communicating with locally deployed Llama LLM.
*/
export class LlamaClient {
private readonly baseUrl = config.llama.apiUrl || "http://localhost:11434";
private readonly model = config.llama.model || "llama3.2";
/**
* Sends a chat completion request to Llama.
*
* @param messages - Array of conversation messages
* @returns Promise resolving to Llama response
*/
public async chat(messages: LlamaMessage[]): Promise<string> {
try {
const response = await fetch(`${this.baseUrl}/api/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
model: this.model,
messages,
stream: false
})
});
if (!response.ok) {
throw new Error(`Llama API error: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as { message: { content: string } };
return data.message.content;
} catch (error) {
logger.error("Failed to communicate with Llama API", { error });
throw new Error("Unable to process your request. Please try again later.");
}
}
/**
* Tests connection to Llama API.
*
* @returns Promise resolving to true if connection is successful
*/
public async testConnection(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/api/tags`);
return response.ok;
} catch {
return false;
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import { BaseMessageOptions, EmbedBuilder, italic } from "discord.js";
import config from "$config.js";
const replies = {
response: (content: string): BaseMessageOptions => {
return { content };
},
error: (
message: string = "Sorry, I encountered an error processing your request."
): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.error || "#ff0000")
.setTitle("⚠️ Error")
.setDescription(italic(message));
return { embeds: [embed] };
},
connectionError: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.warning || "#ffaa00")
.setTitle("🔌 Connection Issue")
.setDescription(
italic("Unable to connect to the LLAMA service. Please try again later.")
);
return { embeds: [embed] };
},
historyCleared: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success || "#00ff00")
.setTitle("🧹 History Cleared")
.setDescription(italic("Your conversation history has been cleared."));
return { embeds: [embed] };
}
};
export default replies;
+17
View File
@@ -0,0 +1,17 @@
/**
* AI module definition.
*
* Module to provide AI conversational capabilities using locally deployed Llama LLM.
* Supports slash commands and reply-based interactions with conversation persistence.
*
* @packageDocumentation
*/
import AI from "./lib/ai.js";
const name = "ai";
const description = "AI conversational module with Llama LLM integration";
const controller = new AI();
export { controller, description, name };
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"$config.js": ["../../config.js"],
"$lib/*": ["../../src/lib/*"],
"$mlib/*": ["./lib/*"]
},
"rootDir": ".",
"outDir": "../../dist/modules/ai",
"composite": true
},
"references": [{ "path": "../../src/" }]
}
+5 -1
View File
@@ -17,5 +17,9 @@
"skipLibCheck": true
},
"references": [{ "path": "./modules/core" }, { "path": "./modules/music" }]
"references": [
{ "path": "./modules/core" },
{ "path": "./modules/music" },
{ "path": "./modules/ai" }
]
}