Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8bb6681c7 | |||
| c0d5bff873 | |||
| ff7b33b952 | |||
| 702e62973a | |||
| c3dd3fa7b0 | |||
| b4954b082e | |||
| 54713ed346 | |||
| 673fc0deaf | |||
| f80cc18294 | |||
| e03b26da0f | |||
| 9eef597c8d | |||
| 57acaef1b2 | |||
| aa2382f3b4 | |||
| a8d83c8768 | |||
| e7be5553cc | |||
| 895b17390d | |||
| 9145ac1bb4 | |||
| 44d0315059 | |||
| b3045bf724 | |||
| 90c97d3f54 | |||
| 6bf61fdbf0 | |||
| b2c2d4bcc2 | |||
| 1ef60193a0 | |||
| 3fbc996de1 | |||
| 8819489a7d | |||
| 3c979d855e | |||
| 5d42ab65e6 | |||
| fa70c064cf | |||
| dfaf4ee8f3 | |||
| ed3cd1df57 | |||
| 059761fb29 | |||
| 05f4454ace | |||
| afb1ac797f | |||
| 8e5d4dd250 | |||
| c2f7acb30d | |||
| 7b8021de41 | |||
| 2312e00cdc | |||
| 8f1c68aa04 | |||
| 1a76246f53 | |||
| 656d8e21e3 | |||
| 7461571cec | |||
| 198cdd1af3 | |||
| 422a77e07f | |||
| 3564a1db62 | |||
| a92549221e | |||
| 413bc52594 | |||
| f530071bb2 | |||
| a288d06345 |
@ -15,12 +15,12 @@ jobs:
|
|||||||
GITHUB: ${{ secrets.GITHUB }}
|
GITHUB: ${{ secrets.GITHUB }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.14'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
@ -85,7 +85,7 @@ jobs:
|
|||||||
|
|
||||||
# Ergebnisse als Artefakte speichern
|
# Ergebnisse als Artefakte speichern
|
||||||
- name: Upload results as artifacts
|
- name: Upload results as artifacts
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: quality-reports
|
name: quality-reports
|
||||||
path: /tmp/results
|
path: /tmp/results
|
||||||
|
|||||||
@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Verify Docker installation
|
- name: Verify Docker installation
|
||||||
run: docker --version
|
run: docker --version
|
||||||
@ -20,15 +20,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
run: |
|
run: |
|
||||||
docker build -f src/Dockerfile -t git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-discord-bot:${{ gitea.ref_name }} src/
|
docker build -f src/Dockerfile -t git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-bot:${{ gitea.ref_name }} src/
|
||||||
# latest-Tag setzen
|
# latest-Tag setzen
|
||||||
docker tag git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-discord-bot:${{ gitea.ref_name }} \
|
docker tag git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-bot:${{ gitea.ref_name }} \
|
||||||
git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-discord-bot:latest
|
git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-bot:latest
|
||||||
|
|
||||||
- name: Push Docker Image mit Versionstag
|
- name: Push Docker Image mit Versionstag
|
||||||
run: |
|
run: |
|
||||||
docker push git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-discord-bot:${{ gitea.ref_name }}
|
docker push git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-bot:${{ gitea.ref_name }}
|
||||||
|
|
||||||
- name: Push Docker Image als latest
|
- name: Push Docker Image als latest
|
||||||
run: |
|
run: |
|
||||||
docker push git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-discord-bot:latest
|
docker push git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-bot:latest
|
||||||
20
.github/renovate.json
vendored
Normal file
20
.github/renovate.json
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended"
|
||||||
|
],
|
||||||
|
"baseBranches": [
|
||||||
|
"dev"
|
||||||
|
],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchUpdateTypes": [
|
||||||
|
"minor",
|
||||||
|
"patch",
|
||||||
|
"pin",
|
||||||
|
"digest"
|
||||||
|
],
|
||||||
|
"automerge": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
52
README.md
52
README.md
@ -1,2 +1,52 @@
|
|||||||
# novelai-bot
|
# NovelAI Discord Bot
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
Simple bot which registers a command to generate images via the NovelAI API.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Just enter `/generate` in the desired channel.
|
||||||
|
|
||||||
|
```text
|
||||||
|
/generate
|
||||||
|
*prompt: string
|
||||||
|
undesired_prompt: string
|
||||||
|
orientation: landscape or portrait
|
||||||
|
seed: integer
|
||||||
|
```
|
||||||
|
|
||||||
|
`*: required`
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
You might create environvent vars:
|
||||||
|
|
||||||
|
- **NOVELAI_API_TOKEN**: your NovelAI user API key
|
||||||
|
- **DISCORD_BOT_TOKEN**: token of your Discord bot
|
||||||
|
- **DISCORD_CHANNEL_ID**: ID of the channel to enable this bot
|
||||||
|
|
||||||
|
Don't forget to install the requirements in [requirements.txt](src/requirements.txt)
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
Or simply run it via **docker-compose**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
bot:
|
||||||
|
restart: unless-stopped
|
||||||
|
image: git.0day.agency/wirehack7/novelai-bot:latest
|
||||||
|
environment:
|
||||||
|
- NOVELAI_API_TOKEN=
|
||||||
|
- DISCORD_BOT_TOKEN=
|
||||||
|
- DISCORD_CHANNEL_ID=
|
||||||
|
networks: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
To create a Discord bot go to [Discord Developers](https://discord.com/developers/).
|
||||||
|
You should make the bot private.
|
||||||
@ -1,4 +1,4 @@
|
|||||||
FROM python:3.13.3-slim
|
FROM python:3.14.2-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
207
src/main.py
207
src/main.py
@ -1,94 +1,115 @@
|
|||||||
|
"""Discord Bot zur Bildgenerierung mit NovelAI API."""
|
||||||
|
|
||||||
|
# flake8: noqa E501
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
import logging
|
|
||||||
|
import aiohttp
|
||||||
import discord
|
import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
import requests
|
|
||||||
|
|
||||||
|
# ENV Variablen
|
||||||
NOVELAI_API_TOKEN = os.getenv("NOVELAI_API_TOKEN")
|
NOVELAI_API_TOKEN = os.getenv("NOVELAI_API_TOKEN")
|
||||||
BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
|
BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
|
||||||
ALLOWED_CHANNEL_ID = int(os.getenv("DISCORD_CHANNEL_ID", "0")) # falls nicht gesetzt → 0
|
ALLOWED_CHANNEL_ID = int(os.getenv("DISCORD_CHANNEL_ID", "0"))
|
||||||
|
|
||||||
|
# Logging Setup
|
||||||
|
logging.basicConfig(
|
||||||
|
stream=sys.stdout,
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
if not all([NOVELAI_API_TOKEN, BOT_TOKEN, ALLOWED_CHANNEL_ID]):
|
if not all([NOVELAI_API_TOKEN, BOT_TOKEN, ALLOWED_CHANNEL_ID]):
|
||||||
raise ValueError("Environment-Variablen NOVELAI_API_TOKEN, DISCORD_BOT_TOKEN und DISCORD_CHANNEL_ID müssen gesetzt sein.")
|
raise ValueError(
|
||||||
|
"NOVELAI_API_TOKEN, DISCORD_BOT_TOKEN and DISCORD_CHANNEL_ID have to be set."
|
||||||
|
)
|
||||||
|
|
||||||
intents = discord.Intents.default()
|
intents = discord.Intents.default()
|
||||||
bot = commands.Bot(command_prefix=commands.when_mentioned, intents=intents)
|
bot = commands.Bot(command_prefix=commands.when_mentioned, intents=intents)
|
||||||
|
|
||||||
async def get_available_antlas(api_token):
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {api_token}"
|
|
||||||
}
|
|
||||||
response = requests.get("https://api.novelai.net/user/info", headers=headers)
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
return data.get("imageAnlas", 0)
|
|
||||||
else:
|
|
||||||
logging.error(f"Fehler beim Abrufen der Antlas: {response.status_code}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@bot.event
|
@bot.event
|
||||||
async def on_ready():
|
async def on_ready():
|
||||||
print(f"Bot started as {bot.user}")
|
"""Bot Startup Ereignis."""
|
||||||
|
print(f"Bot gestartet als {bot.user}")
|
||||||
try:
|
try:
|
||||||
synced = await bot.tree.sync()
|
synced = await bot.tree.sync()
|
||||||
logging.info(f"{len(synced)} Slash-Commands synchronized.")
|
logger.info("%d Slash-Commands synchronized.", len(synced))
|
||||||
except Exception as e:
|
except Exception as err: # pylint: disable=W0718
|
||||||
logging.error(e)
|
logger.error("error while syncing the command: %s", err)
|
||||||
|
|
||||||
|
activity = discord.Game(name="generating juicy NovelAI images 🥵")
|
||||||
|
await bot.change_presence(status=discord.Status.online, activity=activity)
|
||||||
|
|
||||||
|
|
||||||
@bot.tree.command(name="generate", description="Generate image with NovelAI v4 Full")
|
@bot.tree.command(name="generate", description="Generate image with NovelAI v4 Full")
|
||||||
@app_commands.describe(
|
@app_commands.describe(
|
||||||
prompt="What should be generated?",
|
prompt="What should be generated?",
|
||||||
undesired_prompt="What should be avoided? (optional)",
|
undesired_prompt="What should be avoided? (optional)",
|
||||||
orientation="portrait or landscape (Standard: portrait)",
|
orientation="portrait or landscape (Standard: portrait)",
|
||||||
seed="Optional seed (integer). If empty, a random one will be generated"
|
seed="Optional seed (integer). If empty, a random one will be generated",
|
||||||
)
|
)
|
||||||
|
# pylint: disable=too-many-locals,too-many-return-statements,too-many-statements
|
||||||
async def generate(
|
async def generate(
|
||||||
interaction: discord.Interaction,
|
interaction: discord.Interaction,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
undesired_prompt: str = "",
|
undesired_prompt: str = "",
|
||||||
orientation: str = "portrait",
|
orientation: str = "portrait",
|
||||||
seed: int = None
|
seed: int = None,
|
||||||
):
|
):
|
||||||
|
"""Slash-Command zur Bildgenerierung."""
|
||||||
default_negative = (
|
default_negative = (
|
||||||
"blurry, lowres, error, film grain, scan artifacts, worst quality, bad quality, jpeg artifacts, "
|
"blurry, lowres, error, film grain, scan artifacts, worst quality, "
|
||||||
"very displeasing, chromatic aberration, multiple views, logo, too many watermarks, white blank page, "
|
"bad quality, jpeg artifacts, very displeasing, chromatic aberration, "
|
||||||
"blank page, watermarks, watermark, text, "
|
"multiple views, logo, too many watermarks, white blank page, blank page, "
|
||||||
|
"watermarks, watermark, text, "
|
||||||
)
|
)
|
||||||
|
|
||||||
if interaction.channel.id != ALLOWED_CHANNEL_ID:
|
if interaction.channel.id != ALLOWED_CHANNEL_ID:
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
"This command isn't allowed here.",
|
"This command isn't allowed here.", ephemeral=True
|
||||||
ephemeral=True
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
MAX_PROMPT_LENGTH = 500
|
max_prompt_length = 500
|
||||||
|
negative_prompt = default_negative + undesired_prompt.strip()
|
||||||
|
|
||||||
if len(prompt) > MAX_PROMPT_LENGTH:
|
if len(prompt) > max_prompt_length:
|
||||||
await interaction.followup.send(f"Prompt too long! Maximum {MAX_PROMPT_LENGTH} chars allowed.")
|
await interaction.response.send_message(
|
||||||
|
f"Prompt too long! Max {max_prompt_length} characters.", ephemeral=True
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if len(default_negative + undesired_prompt.strip()) > MAX_PROMPT_LENGTH:
|
if len(negative_prompt) > max_prompt_length:
|
||||||
await interaction.followup.send(f"Negative Prompt too long! Maximum {MAX_PROMPT_LENGTH} chars allowed.")
|
await interaction.response.send_message(
|
||||||
|
f"Negative prompt too long! Max {max_prompt_length} characters.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await interaction.response.defer(thinking=True)
|
await interaction.response.defer(thinking=True)
|
||||||
|
|
||||||
# Auflösung wählen
|
logger.info(
|
||||||
if orientation.lower() == "landscape":
|
"User %s (%s, %s) requested image",
|
||||||
width, height = 1216, 832
|
interaction.user.display_name,
|
||||||
else:
|
interaction.user.name,
|
||||||
width, height = 832, 1216
|
interaction.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
width, height = (1216, 832) if orientation.lower() == "landscape" else (832, 1216)
|
||||||
|
|
||||||
if seed is None:
|
if seed is None:
|
||||||
seed = random.randint(0, 2**32 - 1)
|
seed = random.randint(0, 2**32 - 1)
|
||||||
|
|
||||||
# API-Payload zusammenbauen
|
|
||||||
payload = {
|
payload = {
|
||||||
"input": prompt,
|
"input": prompt,
|
||||||
"model": "nai-diffusion-4-full",
|
"model": "nai-diffusion-4-full",
|
||||||
@ -118,90 +139,88 @@ async def generate(
|
|||||||
"seed": seed,
|
"seed": seed,
|
||||||
"characterPrompts": [],
|
"characterPrompts": [],
|
||||||
"v4_prompt": {
|
"v4_prompt": {
|
||||||
"caption": {
|
"caption": {"base_caption": prompt, "char_captions": []},
|
||||||
"base_caption": prompt,
|
|
||||||
"char_captions": []
|
|
||||||
},
|
|
||||||
"use_coords": False,
|
"use_coords": False,
|
||||||
"use_order": True
|
"use_order": True,
|
||||||
},
|
},
|
||||||
"v4_negative_prompt": {
|
"v4_negative_prompt": {
|
||||||
"caption": {
|
"caption": {"base_caption": negative_prompt, "char_captions": []},
|
||||||
"base_caption": default_negative + undesired_prompt.strip(),
|
"legacy_uc": False,
|
||||||
"char_captions": []
|
|
||||||
},
|
|
||||||
"legacy_uc": False
|
|
||||||
},
|
},
|
||||||
"negative_prompt": default_negative + undesired_prompt.strip(),
|
"negative_prompt": negative_prompt,
|
||||||
"deliberate_euler_ancestral_bug": False,
|
"deliberate_euler_ancestral_bug": False,
|
||||||
"prefer_brownian": True
|
"prefer_brownian": True,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {NOVELAI_API_TOKEN}",
|
"Authorization": f"Bearer {NOVELAI_API_TOKEN}",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(
|
start_time = time.monotonic()
|
||||||
"https://image.novelai.net/ai/generate-image",
|
try:
|
||||||
json=payload,
|
async with aiohttp.ClientSession() as session:
|
||||||
headers=headers,
|
async with session.post(
|
||||||
timeout=120
|
"https://image.novelai.net/ai/generate-image",
|
||||||
)
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=aiohttp.ClientTimeout(total=120),
|
||||||
|
) as response:
|
||||||
|
response_bytes = await response.read()
|
||||||
|
status_code = response.status
|
||||||
|
except aiohttp.TimeoutError:
|
||||||
|
await interaction.followup.send("Error: the request timed out.")
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
await interaction.followup.send(f"Error: {e}")
|
||||||
|
|
||||||
if response.status_code == 200:
|
duration = time.monotonic() - start_time
|
||||||
antlas = get_available_antlas(NOVELAI_API_TOKEN)
|
logger.info("Image creation took %.2f seconds", duration)
|
||||||
logging.info("Generated image, current antlas: {antlas}")
|
|
||||||
response_bytes = response.content
|
|
||||||
|
|
||||||
param_text = f"""```Prompt: {prompt}
|
if status_code == 200:
|
||||||
Undesired prompt: {default_negative + undesired_prompt.strip()}
|
param_text = (
|
||||||
Seed: {seed}
|
f"```Prompt: {prompt}\n"
|
||||||
Resolution: {width}x{height}
|
f"Undesired prompt: {negative_prompt}\n"
|
||||||
Sampler: k_euler_ancestral
|
f"Seed: {seed}\n"
|
||||||
Steps: 28
|
f"Resolution: {width}x{height}\n"
|
||||||
Scale: 3.7
|
f"Sampler: k_euler_ancestral\n"
|
||||||
Model: nai-diffusion-4-full```"""
|
f"Steps: 28\n"
|
||||||
|
f"Scale: 3.7\n"
|
||||||
|
f"Model: nai-diffusion-4-full```"
|
||||||
|
)
|
||||||
|
|
||||||
# Prüfen ob ZIP (PK am Anfang)
|
if response_bytes[:2] == b"PK":
|
||||||
if response_bytes[:2] == b'PK':
|
|
||||||
# ZIP-Archiv entpacken
|
|
||||||
with zipfile.ZipFile(io.BytesIO(response_bytes)) as zip_file:
|
with zipfile.ZipFile(io.BytesIO(response_bytes)) as zip_file:
|
||||||
namelist = zip_file.namelist()
|
namelist = zip_file.namelist()
|
||||||
# Erstes PNG suchen
|
image_name = next(
|
||||||
image_name = next((name for name in namelist if name.endswith(".png")), None)
|
(name for name in namelist if name.endswith(".png")), None
|
||||||
|
)
|
||||||
if image_name:
|
if image_name:
|
||||||
image_bytes = zip_file.read(image_name)
|
image_bytes = zip_file.read(image_name)
|
||||||
filename = "novelai.png"
|
filename = "novelai.png"
|
||||||
file = discord.File(io.BytesIO(image_bytes), filename=filename)
|
file = discord.File(io.BytesIO(image_bytes), filename=filename)
|
||||||
await interaction.followup.send(content=param_text, file=file)
|
await interaction.followup.send(content=param_text, file=file)
|
||||||
else:
|
return
|
||||||
await interaction.followup.send(
|
await interaction.followup.send("Found no PNG inside archive")
|
||||||
"Found no PNG inside archive"
|
return
|
||||||
)
|
|
||||||
|
|
||||||
elif response_bytes[:4] == b'\x89PNG':
|
if response_bytes[:4] == b"\x89PNG":
|
||||||
# Direkte PNG-Rückgabe
|
|
||||||
filename = "novelai.png"
|
filename = "novelai.png"
|
||||||
file = discord.File(io.BytesIO(response_bytes), filename=filename)
|
file = discord.File(io.BytesIO(response_bytes), filename=filename)
|
||||||
await interaction.followup.send(content=param_text, file=file)
|
await interaction.followup.send(content=param_text, file=file)
|
||||||
|
return
|
||||||
|
|
||||||
else:
|
await interaction.followup.send("API didn't send any file")
|
||||||
await interaction.followup.send(
|
return
|
||||||
"API didn't send any file"
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
try:
|
||||||
try:
|
error_data = response_bytes.decode("utf-8")
|
||||||
error_data = response.json()
|
except Exception: # pylint: disable=W0718
|
||||||
except Exception:
|
error_data = "error message unreadable"
|
||||||
error_data = {"error": response.text}
|
|
||||||
|
|
||||||
error_message = f"Error {response.status_code} at API-request.\n"
|
error_message = (
|
||||||
for key, value in error_data.items():
|
f"Error {status_code} at API-request.\n" f"Answer: {error_data}\n"
|
||||||
error_message += f"**{key}**: {value}\n"
|
)
|
||||||
|
|
||||||
await interaction.followup.send(error_message)
|
await interaction.followup.send(error_message)
|
||||||
|
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
discord
|
discord
|
||||||
requests
|
aiohttp
|
||||||
Loading…
x
Reference in New Issue
Block a user