10 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
deytenit 63e4d85cbb Update dependencies 2024-11-08 22:29:50 +03:00
deytenit d79713eae9 Add privacy policy 2024-11-08 21:27:12 +03:00
deytenit cfb6930ccc Multiplatform docker images from actions 2024-11-08 21:26:06 +03:00
22 changed files with 3269 additions and 2231 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
-21
View File
@@ -1,21 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"warnOnUnsupportedTypeScriptVersion": false,
"sourceType": "module"
},
"env": {
"browser": false,
"node": true,
"es6": true
},
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"plugins": ["@typescript-eslint", "import", "simple-import-sort"],
"ignorePatterns": ["**/node_modules/**", "/dist/**", "/tests/**"],
"rules": {
"curly": ["error", "all"],
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error"
}
}
+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'
-81
View File
@@ -1,81 +0,0 @@
name: Continuous Integration
on:
push:
tags:
- "*"
branches:
- master
jobs:
mocha:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
pnpm-version: [8]
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: 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: |
unknowableshade/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
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;
+36
View File
@@ -0,0 +1,36 @@
# Privacy Policy of Quetza
This privacy policy applies to the Discord bot Quetza (hereby referred to as "Quetza")
that was created by Vladimir Eremin (hereby referred to as "Service Provider")
as an Open Source service.
Quetza is intended for use "AS IS",
in compliance with [Discord Privacy Policy](https://discord.com/privacy).
## What information does Quetza store and how is it used?
Quetza does not store any information.
## Do third parties see and/or have access to information obtained by the Quetza?
Since Quetza does not store any information, no data is shared with third parties.
## Changes
This Privacy Policy may be updated from time to time for any reason.
The Service Provider will notify you of any changes to their Privacy Policy
by updating this page with the new Privacy Policy.
You are advised to consult this Privacy Policy regularly for any changes,
as continued use is deemed approval of all changes.
_This privacy policy is effective as of 2024-11-08_
## Your Consent
By using Quetza, you are consenting to the processing of your information
as set forth in this Privacy Policy now and as amended by the Service Provider.
## Contact Us
If you have any questions regarding privacy while using Quetza, or have questions
about the practices, please contact the Service Provider via email at [personal@ermnvldmr.com](mailto:personal@ermnvldmr.com).
+44
View File
@@ -0,0 +1,44 @@
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
export default [
{ ignores: ["**/node_modules/**/*", "dist/**/*", "tests/**/*"] },
...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"),
{
plugins: {
"@typescript-eslint": typescriptEslint
},
languageOptions: {
globals: {
...Object.fromEntries(Object.entries(globals.browser).map(([key]) => [key, "off"])),
...globals.node
},
parser: tsParser,
ecmaVersion: "latest",
sourceType: "module",
parserOptions: {
warnOnUnsupportedTypeScriptVersion: false
}
},
rules: {
curly: ["error", "all"]
}
}
];
+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/" }]
}
+21 -18
View File
@@ -1,6 +1,6 @@
{
"name": "quetza",
"version": "2024.3.0",
"version": "2024.11.0",
"description": "Awesome Discord bot that performs various duties",
"type": "module",
"main": "./dist/src/index.js",
@@ -15,7 +15,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/unknowableshade/Quetza.git"
"url": "git+https://github.com/deytenit/Quetza.git"
},
"author": {
"name": "Vladimir Eremin",
@@ -23,32 +23,35 @@
"url": "https://ermnvldmr.com"
},
"bugs": {
"url": "https://github.com/unknowableshade/Quetza/issues"
"url": "https://github.com/deytenit/Quetza/issues"
},
"readme": "https://github.com/unknowableshade/Quetza#readme",
"readme": "https://github.com/deytenit/Quetza#readme",
"engines": {
"node": "20.x.x",
"node": "22.x.x",
"npm": "*"
},
"dependencies": {
"@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.16.1",
"discord.js": "^14.14.1",
"@discordjs/voice": "^0.17.0",
"discord.js": "^14.16.3",
"prism-media": "^1.3.5",
"tweetnacl": "^1.0.3",
"winston": "^3.11.0"
"winston": "^3.16.0"
},
"devDependencies": {
"@types/node": "^20.11.24",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"eslint": "^8.57.0",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.14.0",
"@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.13.0",
"@typescript-eslint/parser": "^8.13.0",
"eslint": "^9.14.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-simple-import-sort": "^12.0.0",
"mocha": "^10.3.0",
"prettier": "^3.2.5",
"tsc-alias": "^1.8.8",
"typescript": "^5.3.3"
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^15.12.0",
"mocha": "^10.8.2",
"prettier": "^3.3.3",
"tsc-alias": "^1.8.10",
"typescript": "^5.6.3"
}
}
+2500 -1986
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
set -e
mkdir -p .git/hooks
HOOK_CONTENT="#!/bin/sh
pnpm run lint
if [ \$? -ne 0 ]; then
echo 'Pre-commit hook failed. Please fix the issues and commit again.'
exit 1
fi
"
echo "$HOOK_CONTENT" > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "Pre-commit hook installed successfully!"
+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" }
]
}