Compare commits

..

No commits in common. "main" and "v0.0.9" have entirely different histories.
main ... v0.0.9

7 changed files with 100 additions and 189 deletions

View File

@ -15,12 +15,12 @@ jobs:
GITHUB: ${{ secrets.GITHUB }} GITHUB: ${{ secrets.GITHUB }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: '3.14' python-version: '3.10'
- 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@v5 uses: actions/upload-artifact@v3
with: with:
name: quality-reports name: quality-reports
path: /tmp/results path: /tmp/results

View File

@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v6 uses: actions/checkout@v4
- 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-bot:${{ gitea.ref_name }} src/ docker build -f src/Dockerfile -t git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-discord-bot:${{ gitea.ref_name }} src/
# latest-Tag setzen # latest-Tag setzen
docker tag git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-bot:${{ gitea.ref_name }} \ docker tag git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-discord-bot:${{ gitea.ref_name }} \
git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-bot:latest git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-discord-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-bot:${{ gitea.ref_name }} docker push git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-discord-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-bot:latest docker push git.0day.agency/${{ secrets.REGISTRY_USERNAME }}/novelai-discord-bot:latest

20
.github/renovate.json vendored
View File

@ -1,20 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"baseBranches": [
"dev"
],
"packageRules": [
{
"matchUpdateTypes": [
"minor",
"patch",
"pin",
"digest"
],
"automerge": false
}
]
}

View File

@ -1,52 +1,2 @@
# NovelAI Discord Bot # novelai-bot
![Code Quality](https://git.0day.agency/wirehack7/novelai-bot/actions/workflows/check-code_quality.yaml/badge.svg)
![Safety Check](https://git.0day.agency/wirehack7/novelai-bot/actions/workflows/check-safety.yaml/badge.svg)
![Release](https://git.0day.agency/wirehack7/novelai-bot/actions/workflows/release.yaml/badge.svg)
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.

View File

@ -1,4 +1,4 @@
FROM python:3.14.2-slim FROM python:3.13.3-slim
WORKDIR /app WORKDIR /app

View File

@ -1,115 +1,97 @@
"""Discord Bot zur Bildgenerierung mit NovelAI API."""
# flake8: noqa E501
import io import io
import logging
import os import os
import random
import sys import sys
import time import random
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")) ALLOWED_CHANNEL_ID = int(os.getenv("DISCORD_CHANNEL_ID", "0")) # falls nicht gesetzt → 0
# Logging Setup
logging.basicConfig( logging.basicConfig(
stream=sys.stdout, stream=sys.stdout,
level=logging.INFO, level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s", format="%(asctime)s - %(levelname)s - %(message)s"
) )
logger = logging.getLogger(__name__) 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( raise ValueError("Environment-Variablen NOVELAI_API_TOKEN, DISCORD_BOT_TOKEN und DISCORD_CHANNEL_ID müssen gesetzt sein.")
"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)
@bot.event @bot.event
async def on_ready(): async def on_ready():
"""Bot Startup Ereignis.""" print(f"Bot started as {bot.user}")
print(f"Bot gestartet als {bot.user}")
try: try:
synced = await bot.tree.sync() synced = await bot.tree.sync()
logger.info("%d Slash-Commands synchronized.", len(synced)) logging.info(f"{len(synced)} Slash-Commands synchronized.")
except Exception as err: # pylint: disable=W0718 except Exception as e:
logger.error("error while syncing the command: %s", err) logging.error(e)
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, " "blurry, lowres, error, film grain, scan artifacts, worst quality, bad quality, jpeg artifacts, "
"bad quality, jpeg artifacts, very displeasing, chromatic aberration, " "very displeasing, chromatic aberration, multiple views, logo, too many watermarks, white blank page, "
"multiple views, logo, too many watermarks, white blank page, blank page, " "blank page, watermarks, watermark, text, "
"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.", ephemeral=True "This command isn't allowed here.",
ephemeral=True
) )
return return
MAX_PROMPT_LENGTH = 500
max_prompt_length = 500 if len(prompt) > MAX_PROMPT_LENGTH:
negative_prompt = default_negative + undesired_prompt.strip() await interaction.followup.send(f"Prompt too long! Maximum {MAX_PROMPT_LENGTH} chars allowed.")
if len(prompt) > max_prompt_length:
await interaction.response.send_message(
f"Prompt too long! Max {max_prompt_length} characters.", ephemeral=True
)
return return
if len(negative_prompt) > max_prompt_length: if len(default_negative + undesired_prompt.strip()) > MAX_PROMPT_LENGTH:
await interaction.response.send_message( await interaction.followup.send(f"Negative Prompt too long! Maximum {MAX_PROMPT_LENGTH} chars allowed.")
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)
logger.info( logger.info(
"User %s (%s, %s) requested image", "User %s (%s, %s) requested image",
interaction.user.display_name, interaction.user.display_name,
interaction.user.name, interaction.user.name,
interaction.user.id, interaction.user.id
) )
width, height = (1216, 832) if orientation.lower() == "landscape" else (832, 1216) # Auflösung wählen
if orientation.lower() == "landscape":
width, height = 1216, 832
else:
width, height = 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",
@ -139,90 +121,89 @@ async def generate(
"seed": seed, "seed": seed,
"characterPrompts": [], "characterPrompts": [],
"v4_prompt": { "v4_prompt": {
"caption": {"base_caption": prompt, "char_captions": []}, "caption": {
"base_caption": prompt,
"char_captions": []
},
"use_coords": False, "use_coords": False,
"use_order": True, "use_order": True
}, },
"v4_negative_prompt": { "v4_negative_prompt": {
"caption": {"base_caption": negative_prompt, "char_captions": []}, "caption": {
"legacy_uc": False, "base_caption": default_negative + undesired_prompt.strip(),
"char_captions": []
},
"legacy_uc": False
}, },
"negative_prompt": negative_prompt, "negative_prompt": default_negative + undesired_prompt.strip(),
"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"
} }
start_time = time.monotonic() response = requests.post(
try: "https://image.novelai.net/ai/generate-image",
async with aiohttp.ClientSession() as session: json=payload,
async with session.post( headers=headers,
"https://image.novelai.net/ai/generate-image", timeout=120
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}")
duration = time.monotonic() - start_time if response.status_code == 200:
logger.info("Image creation took %.2f seconds", duration) response_bytes = response.content
if status_code == 200: param_text = f"""```Prompt: {prompt}
param_text = ( Undesired prompt: {default_negative + undesired_prompt.strip()}
f"```Prompt: {prompt}\n" Seed: {seed}
f"Undesired prompt: {negative_prompt}\n" Resolution: {width}x{height}
f"Seed: {seed}\n" Sampler: k_euler_ancestral
f"Resolution: {width}x{height}\n" Steps: 28
f"Sampler: k_euler_ancestral\n" Scale: 3.7
f"Steps: 28\n" Model: nai-diffusion-4-full```"""
f"Scale: 3.7\n"
f"Model: nai-diffusion-4-full```"
)
if response_bytes[:2] == b"PK": # Prüfen ob ZIP (PK am Anfang)
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()
image_name = next( # Erstes PNG suchen
(name for name in namelist if name.endswith(".png")), None image_name = next((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)
return else:
await interaction.followup.send("Found no PNG inside archive") await interaction.followup.send(
return "Found no PNG inside archive"
)
if response_bytes[:4] == b"\x89PNG": elif 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
await interaction.followup.send("API didn't send any file") else:
return await interaction.followup.send(
"API didn't send any file"
)
try: else:
error_data = response_bytes.decode("utf-8") try:
except Exception: # pylint: disable=W0718 error_data = response.json()
error_data = "error message unreadable" except Exception:
error_data = {"error": response.text}
error_message = ( error_message = f"Error {response.status_code} at API-request.\n"
f"Error {status_code} at API-request.\n" f"Answer: {error_data}\n" for key, value in error_data.items():
) error_message += f"**{key}**: {value}\n"
await interaction.followup.send(error_message) await interaction.followup.send(error_message)
bot.run(BOT_TOKEN) bot.run(BOT_TOKEN)

View File

@ -1,2 +1,2 @@
discord discord
aiohttp requests