Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9e2db352c | ||
|
|
039dfdfa10 | ||
|
|
7a3220a3aa | ||
|
|
de7048a213 | ||
|
|
40013b5919 | ||
|
|
e77a471f81 | ||
|
|
534cd72444
|
||
|
|
63e4d85cbb
|
||
|
|
d79713eae9
|
||
|
|
cfb6930ccc
|
No files matched your search
@@ -17,3 +17,7 @@
|
||||
!build.sh
|
||||
!config.tsconfig.json
|
||||
!tsconfig.json
|
||||
|
||||
!.ci/
|
||||
!.ci/vendor/
|
||||
!.ci/vendor/yt-dlp
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
[](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)**.
|
||||
@@ -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;
|
||||
@@ -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).
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+2500
-1986
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -17,5 +17,9 @@
|
||||
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"references": [{ "path": "./modules/core" }, { "path": "./modules/music" }]
|
||||
"references": [
|
||||
{ "path": "./modules/core" },
|
||||
{ "path": "./modules/music" },
|
||||
{ "path": "./modules/ai" }
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user