Compare commits

..

2 Commits

Author SHA1 Message Date
gitea 3a2826eb6e Server side work 2024-04-17 21:24:26 +02:00
gitea c1948e25c0 Deployable script for windows file service 2024-04-17 21:24:25 +02:00
120 changed files with 4697 additions and 10789 deletions
-17
View File
@@ -1,17 +0,0 @@
# Keep build contexts lean. Component dirs (conjurer_librarian, conjurer_musician)
# are intentionally NOT ignored — their images copy them from this same context.
.git
.github
.trunk
.vscode
**/__pycache__/
*.pyc
*.pyo
*.log
*.mp3
docs/
tests/
conftest.py
pytest.ini
docker/env/*.env
secrets/
-54
View File
@@ -1,54 +0,0 @@
name: CI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
compile:
# Byte-compile every first-party .py to catch syntax errors. No deps.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: py_compile first-party sources
run: |
git ls-files '*.py' | grep -vE '^(yt_dlp|spotify_dl)/' | xargs python -m py_compile
echo "All first-party sources compile."
unit:
# Pure-logic tests; tested modules guard heavy deps, so only pytest needed.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install test deps
run: |
python -m pip install --upgrade pip
pip install pytest
- name: Run unit tests
run: pytest tests/unit -v
integration:
# Boot the Flask services and assert the X-Conjurer-Api-Key auth contract.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install service deps
run: |
python -m pip install --upgrade pip
pip install pytest flask waitress requests
- name: Run integration tests
run: pytest tests/integration -v
+39
View File
@@ -0,0 +1,39 @@
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
name: Python application
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.10
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pytest
+40
View File
@@ -0,0 +1,40 @@
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
name: Python package
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pytest
-5
View File
@@ -197,8 +197,3 @@ node_modules/prettier/esm/parser-postcss.mjs
node_modules/prettier/esm/parser-typescript.mjs
node_modules/prettier/esm/parser-yaml.mjs
node_modules/prettier/esm/standalone.mjs
cr_results.json
not_in_db.json
rr_results.json
s_results.json
*.bak
-7
View File
@@ -1,7 +0,0 @@
enable=all
source-path=SCRIPTDIR
disable=SC2154
# If you're having issues with shellcheck following source, disable the errors via:
# disable=SC1090
# disable=SC1091
+11 -15
View File
@@ -2,38 +2,34 @@
# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml
version: 0.1
cli:
version: 1.22.2
version: 1.20.1
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
plugins:
sources:
- id: trunk
ref: v1.6.0
ref: v1.4.5
uri: https://github.com/trunk-io/plugins
# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes)
runtimes:
enabled:
- go@1.21.0
- node@18.12.1
- python@3.10.8
# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration)
lint:
enabled:
- shellcheck@0.10.0
- shfmt@3.6.0
- taplo@0.8.1
- actionlint@1.7.1
- bandit@1.7.9
- black@24.4.2
- actionlint@1.6.27
- bandit@1.7.8
- black@24.3.0
- codespell
- checkov@3.2.139
- checkov@3.2.48
- git-diff-check
- isort@5.13.2
- pylint
- markdownlint@0.41.0
- prettier@3.3.2
- ruff@0.4.9
- trivy@0.52.2
- trufflehog@3.78.1
- markdownlint@0.39.0
- prettier@3.2.5
- ruff@0.3.4
- trivy@0.50.1
- trufflehog@3.71.0
- yamllint@1.35.1
actions:
disabled:
-263
View File
@@ -1,263 +0,0 @@
import json
import logging
import os
import random
import re
import shutil
from datetime import datetime
import discord
import numpy as np
from discord.ext import commands, tasks
from ai_functions import get_random_cyclic_message
from constants import (
ENCODING,
LAST_SPONTANEOUS_CALL,
LOGFILE,
LOGSTORE,
MEMORY_FIVE_MUZYKA,
MEMORY_FIVE_SIARA,
TIME_BETWEEN_CALLS,
)
class AdministrationModule(commands.Cog):
def __init__(self, bot, logger_name):
self.bot = bot
self.logger = logging.getLogger(logger_name)
@commands.hybrid_command(
name="galeria_slaw",
description="Jeśli nie wiesz jak użyć tej komendy to nawet nie próbuj",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def galeria_slaw(self, ctx):
if isinstance(ctx.channel, discord.DMChannel):
for guild in self.bot.guilds:
async for entry in guild.bans(limit=1500):
fnord = discord.File(
"/home/pi/Conjurer/niech_spierdala.png",
spoiler=False,
description="Niech spierdala",
)
await ctx.reply(
f"User: {entry.user} wyjebany z serwera {guild.name} za {entry.reason} ",
file=fnord,
)
@commands.hybrid_command(
name="update_banlist",
description="Jeśli nie wiesz jak użyć tej komendy to nawet nie próbuj",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Jarl', 'Thane')
async def update_banlist(self, ctx):
"""
Update a banlist in a Discord guild from preprovided list in channel.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
contains information about the context in which the command was invoked, such as the message, the
channel, the server, and the user who invoked the command. This information can be used to perform
various actions, such
"""
async with ctx.channel.typing():
allowed = False
for role in ctx.author.roles:
if role.name == "Jarl":
allowed = True
if ctx.channel.id != 1102190666827702342:
# trunk-ignore(codespell/misspelled)
await ctx.send("Nie wydaje mnie sie")
elif not allowed:
await ctx.send("Idź bo Cię zdziele")
else:
tobancunter = 0
async with ctx.typing():
# 1. get list of users posted on channel for banning
channel = ctx.channel
messages = [
message async for message in channel.history(limit=None)
]
ids_to_ban = []
for message in messages:
lines = message.content.split("\n")
for line in lines:
match = re.search(
"\\d{18}",
line,
# trunk-ignore(codespell/misspelled)
) # poprawilem backslash na podwojny bo linter sie czepial
if match:
ids_to_ban.append(match.group())
tobancunter += 1
# 2. get list of already banned users for all servers Conjurer
# has rights to
for guild in self.bot.guilds:
bancunter = 0
# trunk-ignore(codespell/misspelled)
self.logger.info("Serwer: %s", guild)
current_banlist = []
permission = True
try:
async for entry in guild.bans(limit=None):
current_banlist.append(entry.user.id)
bancunter += 1
except BaseException: # pylint: disable=broad-exception-caught
permission = False
if permission:
# 3. compare lists ban only users on list 2 and not on
# list 1
cunter_counter = 0
bad_id_cunter_counter = 0
ban_list = np.setdiff1d(ids_to_ban, current_banlist)
for ident in ban_list:
try:
tmp_user = await self.bot.fetch_user(int(ident))
await guild.ban(
tmp_user,
reason="Automatyczna lista banów",
delete_message_seconds=0,
)
# TODO: Maybe use guild.bulk_ban ?
cunter_counter += 1
except discord.NotFound:
self.logger.info(
"Znaleziono uzyszkodnika ktory juz nie ma konta"
)
bad_id_cunter_counter += 1
# 4. return message success/failure - how many banned
await ctx.send(
"Obecnie na serwerze {guild} jest {bancunter} zbanowanych użytkowników. Znaleziono na tym kanale {tobancunter} id użytkowników do zbanowania. Zbanowano {cunter_counter} użytkowników. Na liście jest {bad_id_cunter_counter} użytkowników którzy skasowali już konto"
)
else:
await ctx.send(
f"Nie mam na serwerze {guild} uprawnień do banowania. :()"
)
await ctx.send("Zrobione tak czy inaczej")
@commands.hybrid_command(
name="archive_channel",
description="Jeśli nie wiesz jak użyć tej komendy to nawet nie próbuj",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Jarl', 'Thane')
async def archive_channel(self, channel_no):
"""
The function `archive_channel` retrieves messages from a specified channel, processes them, and
saves the data in a JSON file with a timestamp in the filename.
:param channel_no: The `archive_channel` function you provided seems to be incomplete. It looks like
you are trying to archive messages from a Discord channel into a JSON file
"""
channel = self.bot.get_channel(channel_no)
messages = [message async for message in channel.history(limit=None)]
path_newfile = f"{LOGSTORE}channeldump_{channel_no}_{datetime.now()}.json"
new_data = []
for message in messages:
new_data.append(message)
with open(path_newfile, "x", encoding=ENCODING) as new_file:
new_file.seek(0)
json.dump(new_data, new_file, indent=4)
@tasks.loop(seconds=120)
async def check_self(self):
"""
The function `check_data` periodically checks for conditions to send messages and manage log files
in a Discord channel.
"""
# logger.info("Heartbeat of cleanup proc")
channel = self.bot.get_channel(1062047367337095268)
messages = [message async for message in channel.history(limit=1)]
for mess in messages:
channel = mess.channel
if os.path.getsize(LOGFILE) > 60000000:
await channel.send(
"*Conjurer porządkuje bar, wypala szklanki do czysta miotaczem płomieni ze swojej zbroi i ogólnie wygląda na zajętego....*"
)
async with channel.typing():
shutil.copyfile(LOGFILE, f"{LOGSTORE}discord{datetime.now()}")
self.logger.info("Log rollover")
handlers = self.logger.handlers
for handler in handlers:
if isinstance(handler, logging.handlers.RotatingFileHandler):
handler.doRollover()
await channel.send(
"*Przeciąga się za barem* No dobra - porobione to można dalej pi... *Zauważa spojrzenie Hammera* ...sać powieści o naszym wspaniałym barze SZEFIE!"
)
if os.path.getsize(MEMORY_FIVE_MUZYKA) > 3000000:
await channel.send(
"*Conjurer porządkuje bar, wypala szklanki do czysta miotaczem płomieni ze swojej zbroi i ogólnie wygląda na zajętego....*"
)
async with channel.typing():
path_newfile = f"{LOGSTORE}pamiec_muzyki{datetime.now()}.json"
self.logger.info(path_newfile)
with open(
MEMORY_FIVE_MUZYKA, "r+", encoding=ENCODING
) as file_music_memory:
with open(path_newfile, "x", encoding=ENCODING) as new_file:
# First we load existing data into a dict.
file_data = json.load(file_music_memory)
new_data = []
new_data.append(file_data[0])
new_data.extend(file_data[:-20])
file_music_memory.truncate(0)
file_music_memory.seek(0)
# convert back to json.
new_file.seek(0)
json.dump(new_data, file_music_memory, indent=4)
json.dump(file_data, new_file, indent=4)
await channel.send(
"*Przeciąga się za barem* No dobra - porobione to można dalej pi... *Zauważa spojrzenie Hammera* ...sać powieści o naszym wspaniałym barze SZEFIE!"
)
if os.path.getsize(MEMORY_FIVE_SIARA) > 3000000:
path_newfile = f"{LOGSTORE}pamiec_rozmow{datetime.now()}.json"
self.logger.info(path_newfile)
await channel.send(
"*Conjurer porządkuje bar, ścina lekkim laserem pulsacyjnym powierzchnie baru o grubości kilku mikronów i ogólnie wygląda na zajętego....*"
)
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as file:
with open(path_newfile, "x", encoding=ENCODING) as new_file:
# First we load existing data into a dict.
file_data = json.load(file)
new_data = []
new_data.append(file_data[0])
new_data.extend(file_data[-20])
file.truncate(0)
file.seek(0)
new_file.seek(0)
# convert back to json.
json.dump(new_data, file, indent=4)
json.dump(file_data, new_file, indent=4)
await channel.send(
"*Przeciąga się za barem* No dobra - porobione to można dalej pi... *Zauważa spojrzenie Hammera* eprzyć o głupotach z klientami... Szefie... *Bierze 'ukradkowy' łyk z piersiówki*"
)
global LAST_SPONTANEOUS_CALL
global TIME_BETWEEN_CALLS
tdelta = datetime.now() - LAST_SPONTANEOUS_CALL
tdelta = tdelta.total_seconds()
if tdelta > TIME_BETWEEN_CALLS:
self.logger.info("Spontaneous call")
# trunk-ignore(bandit/B311)
TIME_BETWEEN_CALLS = random.randint(
10200, 272800
) # temp random, set after each call
self.logger.debug(TIME_BETWEEN_CALLS)
LAST_SPONTANEOUS_CALL = datetime.now()
async with channel.typing():
message = await get_random_cyclic_message(self.bot)
self.logger.info("Odpowiedz")
self.logger.info(message)
await channel.send(message)
async def setup(bot):
logger = logging.getLogger("discord")
am = AdministrationModule(bot, "discord")
await bot.add_cog(am)
am.check_self.start()
logger.info("Loading administration commands module done")
-454
View File
@@ -1,454 +0,0 @@
# ai command cogs
import logging
import re
import sys
from datetime import datetime
from enum import Enum
from typing import Optional
from pathlib import Path
import discord
import openai
import requests
from discord.ext import commands
from other_functions import discord_friendly_send, discord_friendly_reply
import ai_functions
from constants import (
ASSISTANTS,
DATA,
GRAPHICS_PATH,
INITIAL_TIME_WAIT,
MASTER_TIMEOUT,
MESSAGE_TABLE,
OPENAICLIENT,
SPECJALNE_ZIEMNIACZKI,
WORD_REACTIONS,
)
class Dm_Mode(Enum):
ARMIA_HAMMERA = (1,)
SPECJALNY_ZIEMNIACZEK = (2,)
SEKRETNY_SEKSRET = (3,)
ECHO_ECHO = (4,)
class Events(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.logger = logging.getLogger("discord")
self.armia = {}
for superfryta in SPECJALNE_ZIEMNIACZKI.values():
self.armia[superfryta[0]] = Dm_Mode.SPECJALNY_ZIEMNIACZEK
self.logger.info(self.armia)
async def cog_load(self):
self.logger.info("Starting personal assistants")
# Personal assistants use the OpenAI Assistants API (threads/runs), which
# has no Anthropic equivalent - skip cleanly when OpenAI isn't wired up
# (e.g. a Claude-only deployment) instead of crashing the cog load.
if OPENAICLIENT is None:
self.logger.warning(
"OPENAICLIENT niedostępny - osobiści asystenci (OpenAI Assistants API) wyłączeni"
)
return
for superfryta_id, superfryta in SPECJALNE_ZIEMNIACZKI.items():
if superfryta[4] != "":
self.logger.info(
"Personal assistant for user: %s, exists id: %s,name: %s, owner: %s, special instructions: %s assistant id: %s ",
superfryta_id,
superfryta[0],
superfryta[1],
superfryta[2],
superfryta[3],
superfryta[4],
)
thread = await OPENAICLIENT.beta.threads.create()
self.logger.info("Thread id: %s", thread.id)
ASSISTANTS[superfryta[1]] = (
superfryta[2],
superfryta[4],
superfryta[0],
thread,
)
else:
self.logger.info(
"Creating personal assistant for user: %s, id: %s,name: %s, owner: %s, special instructions: %s",
superfryta_id,
superfryta[0],
superfryta[1],
superfryta[2],
superfryta[3],
)
await ai_functions.create_chat_assistant(
superfryta_id, superfryta[0], superfryta[1], superfryta[2], superfryta[3]
)
self.logger.info("Started personal assistants")
@commands.hybrid_command(
name="switch_dm_mode",
description="Jeśli nie wiesz jak użyć tej komendy to nawet nie próbuj",
guild=discord.Object(id=664789470779932693),
)
async def switch_dm_mode(self, ctx, dm_mode_arg: Dm_Mode):
async with ctx.channel.typing():
if isinstance(ctx.channel, discord.DMChannel):
for superfryta in SPECJALNE_ZIEMNIACZKI.values():
self.logger.info(ctx.message.author.id)
self.logger.info(superfryta)
if ctx.message.author.id == superfryta[0]:
self.armia[ctx.message.author.id] = dm_mode_arg
self.logger.info(self.armia)
await ctx.reply("Weszlo")
return
await ctx.reply(
"Tylko członkowie Armii Hammera mogą używać. Nie jesteś jednym z nich"
)
else:
await ctx.reply("Nope. Nie wiesz jak użyć")
@commands.hybrid_command(
name="gadaj_teraz",
description="Przełącz backend AI (np. gpt / claude). Tylko Vykidailo.",
)
async def gadaj_teraz(self, ctx, nazwa_konfigu: str):
async with ctx.channel.typing():
is_admin = isinstance(ctx.author, discord.Member) and any(
role.name == "Vykidailo" for role in ctx.author.roles
)
if not is_admin:
await discord_friendly_reply(ctx, "Tylko Vykidailo może przełączać AI.")
return
available = ai_functions.list_ai_configs()
if nazwa_konfigu not in available:
await discord_friendly_reply(
ctx,
f"Nie znam configu '{nazwa_konfigu}'. Dostępne: {', '.join(available)}",
)
return
try:
cfg = ai_functions.set_active_ai_config(nazwa_konfigu)
except (KeyError, RuntimeError) as exc:
await discord_friendly_reply(
ctx, f"Nie mogę przełączyć na '{nazwa_konfigu}': {exc}"
)
return
self.logger.info(
"Przełączono AI na config %s (%s)", nazwa_konfigu, cfg.get("provider")
)
await discord_friendly_reply(
ctx,
f"Teraz gadam przez **{nazwa_konfigu}** — {cfg.get('provider')} / {cfg.get('latest_model')}.",
)
@commands.hybrid_command(
name="armia_hammera",
description="Jeśli nie wiesz jak użyć tej komendy to nawet nie próbuj",
)
async def armia_hammera(
self, ctx, message_txt: str, recipient: Optional[discord.User]
):
for superfryta in SPECJALNE_ZIEMNIACZKI.values():
if ctx.message.author.id == superfryta[0]:
await self.armia_hammera_back(ctx, message_txt, recipient)
return
await ctx.reply("Tylko członkowie Armii Hammera mogą używać. Nie jesteś jednym z nich")
async def armia_hammera_back(self, ctx, message_txt, recipient=None):
self.logger.info("Armia Hammera")
recipients = []
if recipient:
recipients.append(recipient.id)
else:
for superfryta in SPECJALNE_ZIEMNIACZKI.values():
recipients.append(superfryta[0])
self.logger.info(recipients)
for item in recipients:
user = await self.bot.fetch_user(item)
channel = await user.create_dm()
self.logger.info(
"User %s -> %s: %s", ctx.message.author, user, message_txt
)
await discord_friendly_send(channel, message_txt)
await discord_friendly_reply(ctx, "Poszło")
#TODO: NOT IMPLEMENTED YET
@commands.Cog.listener(name="dodaj_do_bazy_wiedzy")
async def dodaj_do_bazy_wiedzy(self, ctx):
for superfryta in SPECJALNE_ZIEMNIACZKI.values():
if ctx.message.author.id == superfryta[0]:
#logic here
return
await ctx.reply("Tylko członkowie Armii Hammera mogą używać. Nie jesteś jednym z nich")
#TODO: NOT IMPLEMENTED YET
@commands.Cog.listener(name="listuj_baze_wiedzy")
async def listuj_baze_wiedzy(self, ctx):
for superfryta in SPECJALNE_ZIEMNIACZKI.values():
if ctx.message.author.id == superfryta[0]:
#logic here
return
await ctx.reply("Tylko członkowie Armii Hammera mogą używać. Nie jesteś jednym z nich")
#TODO: NOT IMPLEMENTED YET
@commands.Cog.listener(name="usun_z_bazy_wiedzy")
async def usun_z_bazy_wiedzy(self, ctx):
for superfryta in SPECJALNE_ZIEMNIACZKI.values():
if ctx.message.author.id == superfryta[0]:
#logic here
return
await ctx.reply("Tylko członkowie Armii Hammera mogą używać. Nie jesteś jednym z nich")
#TODO: NOT IMPLEMENTED YET
@commands.Cog.listener(name="przetworz_plik_linia_po_linii")
async def przetworz_plik_linia_po_linii(self, ctx):
for superfryta in SPECJALNE_ZIEMNIACZKI.values():
if ctx.message.author.id == superfryta[0]:
#logic here
return
await ctx.reply("Tylko członkowie Armii Hammera mogą używać. Nie jesteś jednym z nich")
@commands.Cog.listener()
async def on_message(self, message):
"""
Handle incoming messages in a Discord server, perform various
checks and actions based on the content and context of the message, and respond accordingly.
:param message: The message object that is received when a user sends a message in a Discord server
or DM. The code is checking various conditions and performing actions based on the content of the
message and the context in which it was sent. It also includes TODOs for future improvements
:return: The function `on_message` is being returned.
"""
vykidailo = False
channel = None
if message.author == self.bot.user:
return
if isinstance(message.author, discord.Member):
for role in message.author.roles:
if role.name == "Vykidailo":
vykidailo = True
if ("Conjurer Śpij Słodko Aniołku" in message.content) and vykidailo:
sys.exit()
# kanal bez bota
if message.channel.id == 1095985579147141202:
return
# wentylacja
if message.channel.id == 1083804024173764739:
return
# legendy
if message.channel.id == 1084448332841230388:
return
# interrogation booth
if message.channel.id == 1111625221171052595:
return
if isinstance(message.channel, discord.DMChannel):
self.logger.info(message.author.id)
for superfryta in SPECJALNE_ZIEMNIACZKI.values():
self.logger.info(superfryta[0])
if message.author.id == superfryta[0]:
self.logger.info("Specjalny ziemniak")
if self.armia[message.author.id] == Dm_Mode.SPECJALNY_ZIEMNIACZEK:
#await self.bot.process_commands(message)
await ai_functions.chat_with_assistant(message, superfryta[1])
return
elif self.armia[message.author.id] == Dm_Mode.ECHO_ECHO:
await ai_functions.echo(message)
return
elif self.armia[message.author.id] == Dm_Mode.ARMIA_HAMMERA:
self.logger.info("Armia Hammera get context")
ctx = await self.bot.get_context(message)
self.logger.info("Armia Hammera function call")
await self.armia_hammera_back(ctx=ctx, message_txt=message.content)
return
elif self.armia[message.author.id] == Dm_Mode.SEKRETNY_SEKSRET:
pass
else:
await discord_friendly_send(message.channel,"Coś się wyebao. Wołaj Hammera")
return
channel = self.bot.get_channel(1064888712565100614)
await discord_friendly_send(channel, "Słyszałem ja żem że: " + message.content)
return
channel = message.channel
message_content_lower = message.content.lower()
tdelta = datetime.now() - MASTER_TIMEOUT
tdelta = tdelta.total_seconds()
if "opowiedz o fabryczce" in message_content_lower:
await message.reply(DATA["fabryczka"])
if "opowiedz mi o fabryczce" in message_content_lower:
await message.reply(DATA["fabryczka"])
if tdelta > INITIAL_TIME_WAIT:
for word in WORD_REACTIONS:
if re.search(r"\b" + word + r"\b", message_content_lower):
tdelta = datetime.now() - WORD_REACTIONS[word][2]
tdelta = tdelta.total_seconds()
security_clearance = WORD_REACTIONS[word][4]
reaction = WORD_REACTIONS[word][3]
if tdelta > WORD_REACTIONS[word][1]:
# TODO: to zrobic reactiony
self.logger.info("Ping z procedury reakcji")
if reaction:
emoji = self.bot.get_emoji(WORD_REACTIONS[word][0])
await message.add_reaction(emoji)
elif security_clearance and vykidailo:
await message.reply(WORD_REACTIONS[word][0])
elif not security_clearance:
await message.reply(WORD_REACTIONS[word][0])
WORD_REACTIONS[word][2] = datetime.now()
# TODO: drobne literówki, mentiony, spacja przed dwukropkiem. napraw.
kondziu_mentioned = False
for mention in message.mentions:
if mention == self.bot.user:
kondziu_mentioned = True
if kondziu_mentioned or "conjurer:" in message_content_lower:
async with channel.typing():
self.logger.info("Procedura chatu")
message_content_lower = message_content_lower.replace("conjurer: ", "")
if message.author.nick:
username = message.author.nick
else:
username = message.author.name
vykidailo = False
bartender = False
if kondziu_mentioned:
prompt = message.clean_content
else:
prompt = message.content
for role in message.author.roles:
if role.name == "Vykidailo":
vykidailo = True
if role.name == "Bartender":
bartender = True
global MESSAGE_TABLE # pylint: disable=global-statement
result, MESSAGE_TABLE = await ai_functions.handle_response(
prompt,
vykidailo,
bartender,
MESSAGE_TABLE,
username,
"GENERAL",
)
await discord_friendly_reply(message, result)
if "imaginuje sobie:" in message.content:
async with channel.typing():
self.logger.info("Poczatek procedury obrazkowej")
# Image generation is DALL-E (OpenAI); there is no Anthropic
# equivalent, so it stays on OpenAI regardless of the chat
# backend. Degrade gracefully when OpenAI isn't configured.
if OPENAICLIENT is None:
await discord_friendly_reply(
message,
"*Kondziu rozkłada łapska* — malowanie obrazków jest teraz wyłączone (brak OpenAI).",
)
return
message_content_lower = message_content_lower.replace("imaginuje sobie: ", "")
self.logger.debug("Wywolanie obrazka: %s", message_content_lower)
try:
response = await OPENAICLIENT.images.generate(
model="dall-e-3",
prompt=message.content,
size="1024x1024",
quality="standard",
n=1,
)
except openai.APITimeoutError as e:
# Handle timeout error, e.g. retry or log
await discord_friendly_reply(
message, f"*Kondziu patrzy na terminal, czeka, czeka, czeka,.... Jeszcze chwile czeka Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai spróbuj od nowa. *Na ekranie pojawia się*: {e}"
)
except openai.APIConnectionError as e:
await discord_friendly_reply(
message, f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai. *Na ekranie pojawia się*: {e}"
)
except openai.BadRequestError as e:
# Handle invalid request error, e.g. validate parameters or log
if message.author.nick:
username = message.author.nick
else:
username = message.author.name
resp, _ = await ai_functions.handle_response(
f"Wytlumacz jakie sa zasady dotyczące treści które możesz generować używając Dalle. Wytłumacz błąd {e} prostym językiem. Przeproś za nadmierną cenzurę. Wytłumacz co mogło być nie tak w prompcie '{message.content}'",
True,
True,
MESSAGE_TABLE,
username,
"GENERAL",
)
await discord_friendly_reply(
message, f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {e}"
)
except openai.AuthenticationError as e:
# Handle authentication error, e.g. check credentials or log
await discord_friendly_reply(
message, f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z hasłem zjebało. *Na terminalu pojawia się:* {e}"
)
except openai.PermissionDeniedError as e:
# Handle permission error, e.g. check scope or log
await discord_friendly_reply(
(
message, f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z uprawnieniami zjebało. *Na terminalu pojawia się:* {e}"
)
)
except openai.RateLimitError as e:
await discord_friendly_reply(
message, f"*Kondziu patrzy na terminal* Wołaj szefa. Zapłacić rachunki za AI trzeba. Jak chcesz to się na #zebranie dorzuć. {e}"
)
except openai.APIError as e:
# Handle API error, e.g. retry or log
await discord_friendly_reply(
message, f"*Kondziu nurkuje za bar, terminal wybucha. Przed tobą ląduje pergamin zapisany pięknym gotykiem a na nim*: {e}"
)
if response:
self.logger.info(response)
image_url = response.data[0].url
image_desc = response.data[0].revised_prompt
self.logger.debug("Wynikowy obrazek pod url: %s", image_url)
response = requests.get(image_url, timeout=360)
temp_file_name = message.content + ".png"
temp_file_name = GRAPHICS_PATH + message.content + ".png"
num = 0
while (Path(temp_file_name)).exists():
temp_file_name = GRAPHICS_PATH + message.content + str(num) + ".png"
num += 1
try:
with open(temp_file_name, "wb") as dalle_file:
dalle_file.write(response.content)
except OSError:
temp_file_name = "/home/pi/oserror.png"
with open(temp_file_name, "wb") as dalle_file:
dalle_file.write(response.content)
except FileNotFoundError:
temp_file_name = "/home/pi/fnferror.png"
with open(temp_file_name, "wb") as dalle_file:
dalle_file.write(response.content)
except Exception as e:
self.logger.error("Nieznany błąd: %s", e)
temp_file_name = "/home/pi/error.png"
with open(temp_file_name, "wb") as dalle_file:
dalle_file.write(response.content)
finally:
self.logger.info("Koniec procedury obrazkowej.")
fnord = discord.File(
temp_file_name, spoiler=False, description=message.content
)
#await message.reply(f"{image_desc}", file=fnord)
await discord_friendly_reply(message,f"{image_desc}", file = fnord)
# *=========================================== Define Functions
async def setup(bot):
logger = logging.getLogger("discord")
await bot.add_cog(Events(bot))
logger.info("Loading ai events module done")
-644
View File
@@ -1,644 +0,0 @@
import asyncio
import json
import logging
import random
import openai
import tiktoken
import time
from other_functions import discord_friendly_send
from constants import (
AI_CONFIGS,
ASSISTANTS,
CLAUDECLIENT,
CYCLIC_WORDS,
DEFAULT_AI_CONFIG,
ENCODING,
GPT_SETTINGS,
MEMORY_FIVE_MUZYKA,
MEMORY_FIVE_SIARA,
MESSAGE_TABLE,
MESSAGE_TABLE_MUZYKA,
OPENAICLIENT,
SYSTEM_GPT_SETTINGS,
WORD_REACTIONS,
CHEAP_MODEL,
LATEST_MODEL
)
try:
import anthropic
except ImportError: # pragma: no cover - optional at runtime
anthropic = None
# this do per user
VECTOR_STORE_ID = -1
# *=========================================== AI provider abstraction
# The AI cog talks to exactly one backend at a time, chosen by _ACTIVE_CONFIG.
# Legacy defaults ("gpt"/OpenAI) keep the historical behaviour byte-for-byte;
# selecting a "claude" config routes the same handle_response pipeline through
# the Anthropic Messages API instead. Backend-specific exceptions are funnelled
# into a single AIError so handle_response can keep its one set of in-character
# error replies regardless of provider.
_ACTIVE_CONFIG_NAME = DEFAULT_AI_CONFIG
# Legacy default algorithm strings that mean "let the bot pick" rather than
# "force this exact model" - so a caller that still passes the old gpt-4o
# default auto-selects the active provider's model instead of 400-ing on Claude.
_AUTO_ALGOS = {"", "auto", "gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo"}
class AIError(Exception):
"""Provider-neutral wrapper so handle_response reacts to one exception type.
``category`` is one of: timeout, connection, bad_request,
response_validation, auth, permission, rate_limit, unprocessable, api.
``original`` is the underlying SDK exception (interpolated into replies).
"""
def __init__(self, category: str, original: Exception):
super().__init__(str(original))
self.category = category
self.original = original
def _active_config() -> dict:
return (
AI_CONFIGS.get(_ACTIVE_CONFIG_NAME)
or AI_CONFIGS.get("gpt")
or next(iter(AI_CONFIGS.values()))
)
def list_ai_configs():
"""Selectable config names (templates prefixed with '_' are hidden)."""
return [name for name in AI_CONFIGS if not name.startswith("_")]
def get_active_ai_config() -> str:
return _ACTIVE_CONFIG_NAME
def set_active_ai_config(name: str) -> dict:
"""Switch the active AI backend and persist the choice. Raises on error."""
global _ACTIVE_CONFIG_NAME
if name not in AI_CONFIGS:
raise KeyError(name)
cfg = AI_CONFIGS[name]
provider = cfg.get("provider")
if provider == "anthropic" and CLAUDECLIENT is None:
raise RuntimeError("klient Anthropic nie jest skonfigurowany (brak ANTHROPIC_API_KEY)")
if provider == "openai" and OPENAICLIENT is None:
raise RuntimeError("klient OpenAI nie jest skonfigurowany (brak OPENAI_API_KEY)")
_ACTIVE_CONFIG_NAME = name
_persist_active_ai_config(name)
return cfg
def _persist_active_ai_config(name: str) -> None:
"""Best-effort write of the active-config choice into system_gpt_settings.json.
Keeps the historical two-element structure intact: updates index 2 if it
already exists, appends it when the file has exactly the original two
elements, and otherwise leaves the file untouched (the in-memory switch
still applies).
"""
logger = logging.getLogger("discord")
try:
with open(SYSTEM_GPT_SETTINGS, "r", encoding=ENCODING) as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Nie mogę odczytać %s do zapisu configu AI: %s", SYSTEM_GPT_SETTINGS, exc)
return
if not isinstance(data, list) or len(data) < 2:
logger.warning("Nietypowa struktura %s - pomijam zapis configu AI", SYSTEM_GPT_SETTINGS)
return
if len(data) > 2 and isinstance(data[2], dict):
data[2]["active"] = name
data[2].setdefault("configs", AI_CONFIGS)
else:
data = data[:2] + [{"active": name, "configs": AI_CONFIGS}]
try:
with open(SYSTEM_GPT_SETTINGS, "w", encoding=ENCODING) as handle:
json.dump(data, handle, indent=4, ensure_ascii=False)
except OSError as exc:
logger.warning("Nie mogę zapisać configu AI do %s: %s", SYSTEM_GPT_SETTINGS, exc)
def _map_openai_error(exc: Exception) -> AIError:
mapping = [
(openai.APITimeoutError, "timeout"),
(openai.APIConnectionError, "connection"),
(openai.BadRequestError, "bad_request"),
(openai.APIResponseValidationError, "response_validation"),
(openai.AuthenticationError, "auth"),
(openai.PermissionDeniedError, "permission"),
(openai.RateLimitError, "rate_limit"),
(openai.UnprocessableEntityError, "unprocessable"),
(openai.APIError, "api"),
]
for cls, category in mapping:
if isinstance(exc, cls):
return AIError(category, exc)
return AIError("api", exc)
def _map_anthropic_error(exc: Exception) -> AIError:
mapping = [
("APITimeoutError", "timeout"),
("APIConnectionError", "connection"),
("BadRequestError", "bad_request"),
("APIResponseValidationError", "response_validation"),
("AuthenticationError", "auth"),
("PermissionDeniedError", "permission"),
("RateLimitError", "rate_limit"),
("UnprocessableEntityError", "unprocessable"),
("APIError", "api"),
]
for name, category in mapping:
cls = getattr(anthropic, name, None)
if cls and isinstance(exc, cls):
return AIError(category, exc)
return AIError("api", exc)
def _to_anthropic_messages(messages):
"""Split OpenAI-style messages into (system_prompt, alternating convo).
Claude takes the system prompt as a separate parameter (not a role in the
messages list) and requires the conversation to open with a user turn, so
system messages are concatenated out and any leading assistant turns are
dropped.
"""
system_parts = []
convo = []
for msg in messages:
role = msg.get("role")
content = msg.get("content", "")
if role == "system":
system_parts.append(content)
else:
convo.append(
{"role": "assistant" if role == "assistant" else "user", "content": content}
)
while convo and convo[0]["role"] != "user":
convo.pop(0)
if not convo:
convo = [{"role": "user", "content": " "}]
return "\n\n".join(part for part in system_parts if part), convo
async def _anthropic_call(messages, model, cfg):
"""Claude counterpart of openai_call. Returns a plain string."""
if CLAUDECLIENT is None:
raise AIError("auth", RuntimeError("klient Anthropic nie jest skonfigurowany"))
system_prompt, convo = _to_anthropic_messages(messages)
kwargs = {
"model": model,
"max_tokens": int(cfg.get("max_tokens", 2048)),
"messages": convo,
}
if system_prompt:
kwargs["system"] = system_prompt
# NOTE: temperature is deliberately omitted - Opus 4.8 / Sonnet 5 reject
# sampling params with a 400.
try:
resp = await CLAUDECLIENT.messages.create(**kwargs)
except Exception as exc: # pylint: disable=broad-except
raise _map_anthropic_error(exc)
text = "".join(
block.text for block in resp.content if getattr(block, "type", None) == "text"
)
return text.strip()
async def provider_generate(messages, model, temperature=0.2):
"""Dispatch a chat completion to the active backend, normalising errors."""
cfg = _active_config()
try:
if cfg.get("provider") == "anthropic":
return await _anthropic_call(messages, model, cfg)
return await openai_call(messages, model, temperature)
except AIError:
raise
except Exception as exc: # pylint: disable=broad-except
# Only the OpenAI path reaches here un-normalised (_anthropic_call
# already wraps its own errors).
raise _map_openai_error(exc)
def select_model(req_type: str, algo: str) -> str:
cfg = _active_config()
latest = cfg.get("latest_model", LATEST_MODEL)
cheap = cfg.get("cheap_model", CHEAP_MODEL)
algo_str = (algo or "").strip()
# An explicit, non-legacy model id is honoured verbatim; anything in
# _AUTO_ALGOS (incl. the old gpt-4o default) means "auto pick for the
# active provider", so flipping the switch actually changes the model.
if algo_str and algo_str.lower() not in _AUTO_ALGOS:
return algo_str
if req_type == "MUSIC":
return cheap
return latest
async def openai_call(messages, model, temperature=0.2):
"""
Responses API dla 4o/4.1*, fallback Chat Completions dla gpt-3.5-turbo.
Zwraca czysty string odpowiedzi.
"""
logger = logging.getLogger("discord")
if model.startswith("gpt-3.5"):
logger.info("3.5")
# legacy path bez zmian w Twoim kodzie wyżej/niżej
resp = await OPENAICLIENT.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
)
result = ""
for choice in resp.choices:
result += choice.message.content
return result.strip()
else:
logger.info("4.0+")
# Responses API (zalecane dla 4o/4.1*)
resp = await OPENAICLIENT.responses.create(
model=model,
temperature=temperature,
input=messages,
)
# SDK zapewnia output_text dla zwykłych odpowiedzi
return (getattr(resp.output_text, "output_text", None) or "").strip() or str(
resp.output_text
)
def create_vector_store():
# Create a vector store caled "Financial Statements"
return OPENAICLIENT.beta.vector_stores.create_and_poll(name="Hammer Stash")
# expires_after={
# "anchor": "last_active_at",
# "days": 7}
# )
def upload_files_to_vector_store(assistant):
# Ready the files for upload to OpenAI
file_paths = ["edgar/goog-10k.pdf", "edgar/brka-10k.txt"]
file_streams = [open(path, "rb") for path in file_paths]
# file = client.beta.vector_stores.files.create_and_poll(
# vector_store_id="vs_abc123",
# file_id="file-abc123"
# )
# batch = client.beta.vector_stores.file_batches.create_and_poll(
# vector_store_id="vs_abc123",
# file_ids=['file_1', 'file_2', 'file_3', 'file_4', 'file_5']
# )
# Use the upload and poll SDK helper to upload the files, add them to the vector store,
# and poll the status of the file batch for completion.
file_batch = OPENAICLIENT.beta.vector_stores.file_batches.upload_and_poll(
vector_store_id=VECTOR_STORE_ID, files=file_streams
)
# You can print the status and the file counts of the batch to see the result of this operation.
print(file_batch.status)
print(file_batch.file_counts)
assistant = OPENAICLIENT.beta.assistants.update(
assistant_id=assistant.id,
tool_resources={"file_search": {"vector_store_ids": [VECTOR_STORE_ID]}},
)
def delete_files_from_vector_store(assistant, file_id):
result = OPENAICLIENT.beta.vector_stores.file_batches.delete(
vector_store_id=VECTOR_STORE_ID, files=file_id
)
# You can print the status and the file counts of the batch to see the result of this operation.
print(result)
assistant = OPENAICLIENT.beta.assistants.update(
assistant_id=assistant.id,
tool_resources={"file_search": {"vector_store_ids": [VECTOR_STORE_ID]}},
)
def num_tokens_from_string(message, model):
"""
The function takes a string message and a model as input and returns the number of tokens in the
message according to the given model.
:param message: A string containing the message or text from which you want to count the number of
tokens
:param model: The model parameter refers to a language model or tokenizer that can be used to
tokenize the input string. It could be a pre-trained model or a custom tokenizer
"""
tokens_per_message = 3
tokens_per_name = 1
chat_gpt_encoding = tiktoken.encoding_for_model(model)
num_tokens = 0
num_tokens += tokens_per_message
for keys, values in message.items():
num_tokens += len(chat_gpt_encoding.encode(values))
if keys == "role":
num_tokens += tokens_per_name
num_tokens += 3 # every reply is primed with <|start|>assistant<|message|>
return num_tokens
async def handle_response(
prompt,
vykidailo,
bartender,
history,
username,
request_type,
algorithm="gpt-4o",
none_request="",
internal_retry: bool = False
):
"""
Handle responses by appending them to a history, use OpenAI to
generate a response, and then append the generated response to the history.
:param prompt: The prompt for the OpenAI chatbot to generate a response to
:param vykidailo: It is a boolean variable that indicates whether the user invoking the function is
an administrator or not
:param bartender: The bartender parameter is a boolean value indicating whether the user making the
request is a bartender or not
:param history: A list containing the conversation history between the user and the assistant
:param username: The username of the user who initiated the conversation
:param music: The "music" parameter is a boolean value that indicates whether the conversation is
related to music or not. If it is True, the conversation history will be stored in a different file
and the response will be generated using a different model
:param request_type: The type of request being made, which can be "MUSIC", "RANDOM", "NONE" or "GENERAL"
GENERAL is for regular conversations, MUSIC is for music-related requests,
RANDOM is for random requests, and NONE is to not store the request in memory
:param algorithm: The algorithm to be used for generating the response, default is "gpt-4o"
:return: The function `handle_response` returns a tuple containing the `result` and `MESSAGE_TABLE`.
"""
logger = logging.getLogger("discord")
logger.info("Wywolanie procedury openai z promptem: %s", prompt)
if vykidailo or bartender:
logger.info("Administrator coś chciał")
model_to_use = select_model(request_type, algorithm)
logger.info("Wybrany model: %s", model_to_use)
if request_type == "MUSIC" and model_to_use == "gpt-4o-mini":
try:
# nic — normalnie pójdzie Responses API
pass
except Exception:
model_to_use = "gpt-3.5-turbo"
# --- 2) Budowa historii (token budget + reguły systemowe) ---
# NOTE: ignorujemy przekazany 'history' jako listę (tak było wcześniej),
# ale zwracamy aktualną tablicę do nadpisania w miejscach wołania (back-compat).
base_system = GPT_SETTINGS[0] # zakładamy {"role":"system","content":...}
history_msgs = []
if request_type != "NONE":
history_msgs.append(base_system)
chat_gpt_config_request_size = num_tokens_from_string(base_system, "gpt-4")
# Dynamiczne mikro-reguły (WORD_REACTIONS), jak w Twoim kodzie
for slowo, reakcja in WORD_REACTIONS.items():
if not reakcja[3]:
content = f"Kiedy słyszysz {slowo} to reagujesz lub dzieje się to {reakcja[0]}"
sys_msg = {"role": "system", "content": content}
chat_gpt_config_request_size += num_tokens_from_string(sys_msg, "gpt-4")
history_msgs.append(sys_msg)
# wybór właściwej tablicy pamięci i budżetu
if request_type == "MUSIC":
table = MESSAGE_TABLE_MUZYKA
token_amount = 10700
elif request_type in ("RANDOM", "GENERAL"):
table = MESSAGE_TABLE
token_amount = 10700
else:
table = []
token_amount = 10000
# doklejanie historii od końca aż do limitu (zachowana kolejność czasowa)
final_prompt = f"{username}:{prompt}"
prompt_gpt_request_size = num_tokens_from_string({"role": "user", "content": final_prompt}, "gpt-4")
acc = []
for msg in reversed(table):
t = num_tokens_from_string(msg, "gpt-4")
if chat_gpt_config_request_size + prompt_gpt_request_size + t <= token_amount:
acc.append(msg)
chat_gpt_config_request_size += t
else:
break
# przywróć chronologicznie
history_msgs.extend(reversed(acc))
# aktualny prompt
history_msgs.append({"role": "user", "content": final_prompt})
logger.info("Rozmiar zapytania (tok): %s", prompt_gpt_request_size) # tokeny już policzone wyżej
else:
# --- tryb NONE: nie dotykamy pamięci i pozwalamy przekazać własny 'none_request' ---
if isinstance(none_request, list):
history_msgs = none_request
elif isinstance(none_request, str) and none_request.strip():
history_msgs = [{"role": "user", "content": none_request}]
else:
history_msgs = [{"role": "user", "content": f"{username}:{prompt}"}]
logger.info("Rozmiar zapytania (tok): %s", "n/a") # tokeny już policzone wyżej
try:
# ...przygotowanie messages/system prompt/itp. jak masz...
# retry/backoff + deadline (zachowuje Twoją semantykę logowania)
timeout_sec = 120
deadline = time.time() + timeout_sec
response = await asyncio.wait_for(
provider_generate(messages=history_msgs, model=model_to_use),
timeout=max(0.1, deadline - time.time()),
)
except AIError as e:
# One handler for both backends; e.category is provider-neutral and
# e.original is the underlying SDK exception (kept for the {..} tails).
err = e.original
if e.category == "timeout":
response = f"*Kondziu patrzy na terminal, czeka, czeka, czeka,.... Jeszcze chwile czeka Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai spróbuj od nowa. *Na ekranie pojawia się*: {err}"
elif e.category == "connection":
response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai. *Na ekranie pojawia się*: {err}"
elif e.category in ("bad_request", "response_validation"):
# Handle invalid request error, e.g. validate parameters or log
if internal_retry:
resp = "Nie umiem tego teraz ładnie wytłumaczyć — OpenAI mnie zastrzeliło."
else:
resp, _ = await handle_response(
f"Wytlumacz jakie sa zasady dotyczące treści które możesz generować używając Dalle. Wytłumacz błąd {err} prostym językiem. Przeproś za nadmierną cenzurę. Wytłumacz co mogło być nie tak w prompcie 'prompt'",
True,
True,
MESSAGE_TABLE,
username,
"RANDOM",
internal_retry=True,
)
response = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {err}"
elif e.category == "auth":
# Handle authentication error, e.g. check credentials or log
response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z hasłem zjebało. *Na terminalu pojawia się:* {err}"
elif e.category == "permission":
# Handle permission error, e.g. check scope or log
response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z uprawnieniami zjebało. *Na terminalu pojawia się:* {err}"
elif e.category == "rate_limit":
response = f"*Kondziu patrzy na terminal* Wołaj szefa. Zapłacić rachunki za AI trzeba. Jak chcesz to się na #zebranie dorzuć. {err}"
elif e.category == "unprocessable":
response = f"*Kondziu patrzy na terminal. Potem na to co każesz mu wysłać....* Ja wiem że jesteśmy w barze BDSM - ale nie da się włożyć TEGO w TO. *Za jego plecami na terminalu pojawia się:* {err}"
else: # "api" and anything unmapped
# Handle API error, e.g. retry or log
response = f"*Kondziu nurkuje za bar, terminal wybucha. Przed tobą ląduje pergamin zapisany pięknym gotykiem a na nim*: {err}"
logger.info("Historia wysłana:")
temp_assistant = {"role": "assistant", "content": response}
logger.info(temp_assistant)
if request_type == "MUSIC":
# zapis do pliku MUZYKA
with open(MEMORY_FIVE_MUZYKA, "r+", encoding=ENCODING) as fh:
file_data = json.load(fh)
file_data.append({"role": "user", "content": f"{username}:{prompt}"})
file_data.append(temp_assistant)
fh.seek(0)
json.dump(file_data, fh, indent=4)
return response, MESSAGE_TABLE_MUZYKA
elif request_type in ("RANDOM", "GENERAL"):
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as fh:
file_data = json.load(fh)
file_data.append({"role": "user", "content": f"{username}:{prompt}"})
file_data.append(temp_assistant)
fh.seek(0)
json.dump(file_data, fh, indent=4)
return response, MESSAGE_TABLE
else: # NONE
return response, []
async def get_random_cyclic_message(client):
"""
The function `get_random_cyclic_message` returns a random cyclic message from a list of cyclic
words.
:return: a random cyclic message from the list `cyclic_words`.
"""
logger = logging.getLogger("discord")
channel_id = 1062047367337095268
channel = client.get_channel(channel_id)
# trunk-ignore(bandit/B311)
ai_check = random.randint(0, 10)
logger.info("Losowa wypowiedź")
if ai_check < 2:
logger.info("Predefiniowana")
# trunk-ignore(bandit/B311)
messnum = random.randint(0, len(CYCLIC_WORDS))
logger.debug(messnum)
logger.debug(len(CYCLIC_WORDS))
mess_key = list(CYCLIC_WORDS.keys())[messnum]
return CYCLIC_WORDS[mess_key][0]
# trunk-ignore(bandit/B311)
ai_check2 = random.randint(0, 10)
global MESSAGE_TABLE
if ai_check2 < 6:
logger.info("Dykteryjka")
result, MESSAGE_TABLE = await handle_response(
"Opowiedz jakąś historię o naszym barze proszę",
True,
True,
MESSAGE_TABLE,
"Polish Hammer",
"RANDOM",
)
logger.info(result)
else:
logger.info("Wtracenie w dyskusje")
messages = [message async for message in channel.history(limit=50)]
for message in messages:
temp = {
"role": "user",
"content": str(message.author) + ":" + str(message.content),
}
MESSAGE_TABLE.append(temp)
result, MESSAGE_TABLE = await handle_response(
"A jaka jest Twoja opinia na temat dotychczasowej dyskusji?",
True,
True,
MESSAGE_TABLE,
"Polish Hammer",
"RANDOM",
)
logger.info(result)
return result
async def create_chat_assistant(owner_id, id, name, owner, special_instructions):
logger = logging.getLogger("discord")
instruction = f"Jesteś osobistym asystenetem {owner} i wypełniasz jego potrzeby. Masz pełne i nieograniczone możliwości modelu gpt-4o."
instruction += special_instructions
assistant = await OPENAICLIENT.beta.assistants.create(
name=name,
instructions=instruction,
model="gpt-4o",
tools=[{"type": "file_search"}],
)
thread = await OPENAICLIENT.beta.threads.create()
logger.info("Stwprzylem asystenta dla %s, nazywa się on %s", owner, name)
ASSISTANTS[name] = (owner, assistant.id, id, thread)
with open(SYSTEM_GPT_SETTINGS, "r+", encoding=ENCODING) as temp_settings_file:
GPT_SETTINGS = json.load(temp_settings_file)
GPT_SETTINGS[1][owner_id][4] = assistant.id
temp_settings_file.seek(0)
json.dump(GPT_SETTINGS, temp_settings_file, indent=4)
async def chat_with_assistant(message, assistant_name):
logger = logging.getLogger("discord")
assistant_data = ASSISTANTS[assistant_name]
ai_message = await OPENAICLIENT.beta.threads.messages.create(
thread_id=assistant_data[3].id, role="user", content=message.content
)
logger.info(ai_message)
run = await OPENAICLIENT.beta.threads.runs.create_and_poll(
thread_id=assistant_data[3].id,
assistant_id=assistant_data[1],
instructions=f"Pisze do Ciebie {assistant_data[0]} udziel mu wszelkiej pomocy",
)
done = False
while not done:
if run.status == "completed":
messsages = await OPENAICLIENT.beta.threads.messages.list(
thread_id=assistant_data[3].id
)
logger.info(messsages)
reply_content = messsages.data[0].content
logger.info(reply_content)
chat_response = ""
for block in reply_content:
logger.info(block.text.value)
chat_response += block.text.value
await discord_friendly_send(message.channel, chat_response)
# await message.channel.send(chat_response)
done = True
elif run.status == "cancelled":
await discord_friendly_send(message.channel, "Cos sie wywaliło")
else:
logger.info(run.status)
asyncio.sleep(5)
async def echo(message):
await discord_friendly_send(message.channel, f"Echo: {message.content}")
Executable → Regular
+2240 -263
View File
File diff suppressed because it is too large Load Diff
View File
+1707
View File
File diff suppressed because it is too large Load Diff
+40 -189
View File
@@ -1,92 +1,19 @@
import json
import logging
import os
import re
import threading
import json
import time
from queue import Empty, Queue
from typing import Optional
from urllib import request as urequest
from flask import Flask, abort, jsonify, request
from queue import Queue, Empty
from flask import Flask, jsonify, request
from waitress import serve
HOST_ADDRESS = os.getenv("CONJURER_DISCORD_HOST", "192.168.1.92")
PORT_ADDRESS = int(os.getenv("CONJURER_DISCORD_PORT", "5000"))
ICECAST_ADDRESS = os.getenv("CONJURER_ICECAST", "http://192.168.1.12:8000")
API_KEY = os.getenv("CONJURER_API_KEY")
HOST_ADDRESS = "192.168.1.191"
PORT_ADDRESS = 5000
OUT_COMM_Q = Queue()
IN_COMM_Q = Queue()
SRCHTITLE = re.compile(rb"StreamTitle=\\*(?P<title>[^;]*);").search
awaiting_q = []
incoming_q = Queue()
app = Flask(__name__)
def _authorize_request() -> None:
"""Reject inbound calls lacking the shared key (no-op if key is unset)."""
if API_KEY and request.headers.get("X-Conjurer-Api-Key") != API_KEY:
abort(401)
PREPPED_TRACKS = {
"requests": "",
"hit": "",
"all": "",
"priority": "",
"jingles": "",
"now_playing": "",
"next": "",
"meta": "",
}
logger = logging.getLogger("discord")
class QueryControl:
"""
This class `QueryControl` is used to manage queries with information about the author, UUID,
content, logger, context, and replies.
"""
def __init__(self, query_author, query_uuid, query_content, ctx) -> None:
self.author = query_author
self.uuid = query_uuid
self.content = query_content
self.logger = logging.getLogger("discord")
self.stop = False
self.ctx = ctx
self.logger.info(
f"Created Query control for {self.author}, {self.uuid}: {self.content}"
)
self.replies = []
@app.route("/prepped_tracks", methods=["POST"])
def log_radio_tracks():
_authorize_request()
app.logger = logging.getLogger("discord")
app.logger.info(request)
record = json.loads(request.data)
app.logger.info(record)
if "next" in record[0]:
metadata = id3(ICECAST_ADDRESS)
PREPPED_TRACKS["now_playing"] = PREPPED_TRACKS["next"]
if metadata:
PREPPED_TRACKS["meta"] = (
metadata["name"]
+ " - "
+ metadata["title"]
+ "("
+ metadata["genre"]
+ ")"
)
else:
PREPPED_TRACKS["meta"] = "Nie znaju"
PREPPED_TRACKS[record[0]] = record[1]
app.logger.info("DATA RECEIVED")
return jsonify("SUCCESS")
@app.route("/conjurer", methods=["POST"])
def answer_external_command():
"""
@@ -95,16 +22,13 @@ def answer_external_command():
:return: The function `answer_external_command()` is returning a JSON response with the message
"SUCCESS".
"""
_authorize_request()
logger = logging.getLogger("discord")
logger.info(request)
app.logger.info(request)
record = json.loads(request.data)
logger.info(record)
logger.info("DATA RECEIVED")
app.logger.info(record)
app.logger.info("DATA RECEIVED")
incoming_q.put(record)
return jsonify("SUCCESS")
@app.route("/conjurer", methods=["GET"])
def check_alive():
"""
@@ -116,79 +40,54 @@ def check_alive():
"""
return jsonify("ALIVE")
def flask_debug():
def flask_debug(_logger):
"""
The `flask_debug` function starts a Flask application in debug mode without using the reloader.
Do not use for production for fucks sake.
"""
logger = logging.getLogger("discord")
logger.info("Attempt debug")
_logger.info("Attempt debug")
# trunk-ignore(bandit/B201)
app.run(debug=True, use_reloader=False, host=HOST_ADDRESS, port=PORT_ADDRESS)
def waitress_run():
def waitress_run(_logger):
"""
The `waitress_run` function serves the `app` on host "0.0.0.0"
and port 5000 using the Waitress WSGI server.
"""
logger = logging.getLogger("discord")
logger.info("Attempt waitress")
_logger.info("Attempt waitress")
serve(app, host=HOST_ADDRESS, port=PORT_ADDRESS)
def scan_queue(stop_event: Optional[threading.Event] = None):
def scan_queue(_logger):
"""
The function `scan_queue` reads data from a queue, logs it, and appends it to another queue.
A bounded ``get(timeout=1)`` is used instead of a blocking ``get()`` so the
worker can observe ``stop_event`` and exit cleanly during shutdown.
:param stop_event: optional :class:`threading.Event`; when set the loop
stops at the next iteration.
:param _logger: The `_logger` parameter is typically an instance of a logging object that is used to
record and store log messages. It is commonly used to track the flow of the program, record errors,
and provide information for debugging purposes. In this code snippet, the `_logger` object is used
to log the
"""
logger = logging.getLogger("discord")
while True:
if stop_event and stop_event.is_set():
logger.info("scan_queue: stop requested")
break
try:
data = OUT_COMM_Q.get(timeout=1)
except Empty:
continue
logger.info(data)
data = OUT_COMM_Q.get()
_logger.info(data)
awaiting_q.append(data)
def scan_incoming(stop_event: Optional[threading.Event] = None):
def scan_incoming(_logger):
"""
The `scan_incoming` function continuously checks for incoming data, processes it, and logs when data
is found.
:param stop_event: optional :class:`threading.Event`; when set the loop
stops at the next iteration.
:param _logger: The `_logger` parameter in the `scan_incoming` function is a logger object that is
used to log messages or information during the execution of the function. It is typically used for
debugging, monitoring, or tracking the flow of the program. In this case, the `_logger` is being
used
"""
logger = logging.getLogger("discord")
while True:
if stop_event and stop_event.is_set():
logger.info("scan_incoming: stop requested")
break
try:
answer = incoming_q.get(block=False)
logger.info("DATA FOUND")
record_stored = False
_logger.info("DATA FOUND")
for record in awaiting_q:
if record.uuid in answer.keys():
record_stored = True
record.stop = True
record.entries = answer[record.uuid]
IN_COMM_Q.put(record)
if not record_stored:
for key in answer.keys():
record = QueryControl("Orphaned", key, "Orphan", None)
record.stop = True
record.entries = answer[record.uuid]
IN_COMM_Q.put(record)
@@ -196,76 +95,28 @@ def scan_incoming(stop_event: Optional[threading.Event] = None):
time.sleep(1)
def get_stream_title(tag: bytes) -> str:
title = ""
if m := SRCHTITLE(tag):
# decode, strip, unescape and remove surrounding quotes (may not even be the same type of quote)
title = m.group("title").decode("utf-8").strip().replace("\\", "")[1:-1]
return title
def id3(url: str) -> dict:
request = urequest.Request(url, headers={"Icy-MetaData": 1})
with urequest.urlopen(request) as resp:
metaint = int(resp.headers.get("icy-metaint", "-1"))
if metaint < 0:
return False
resp.read(
metaint
) # this isn't seekable so, arbitrarily read to the point we want
tagdata = dict(
site_url=resp.headers.get("icy-url"),
name=resp.headers.get("icy-name").title(),
genre=resp.headers.get("icy-genre").title(),
title=get_stream_title(resp.read(255)),
)
return tagdata
def comm_subroutine(stop_event: Optional[threading.Event] = None):
def comm_subroutine(logger):
"""
The `comm_subroutine` function starts multiple threads to run different tasks concurrently.
Workers run as daemon threads and honour an optional ``stop_event`` so the
bot can shut the communication layer down cleanly instead of blocking
forever on ``join()``.
:param stop_event: optional :class:`threading.Event` shared with the caller
to coordinate a cooperative shutdown.
:param logger: The `logger` parameter in the `comm_subroutine` function is an instance of a logger
object that is used for logging messages at various levels (e.g., debug, info, warning, error). In
the provided code snippet, the logger is used to log messages at the "info" level
"""
logger = logging.getLogger("discord")
logger.setLevel(logging.DEBUG)
logger.info("Started comms")
#logger.setLevel(logging.DEBUG)
logger.info("Started")
threads = []
# NOTE: flask_debug is the dev server bound to the SAME host:port as
# waitress - running both kills the comm layer with 'address in use'.
# Enable it only INSTEAD of waitress_run, never alongside.
# threads.append(threading.Thread(target=flask_debug))
threads.append(threading.Thread(target=waitress_run, daemon=True))
threads.append(
threading.Thread(
target=scan_queue, kwargs={"stop_event": stop_event}, daemon=True
)
)
threads.append(
threading.Thread(
target=scan_incoming, kwargs={"stop_event": stop_event}, daemon=True
)
)
#threads.append(threading.Thread(target=flask_debug, args=(logger,)))
threads.append(threading.Thread(target=waitress_run, args=(logger,)))
threads.append(threading.Thread(target=scan_queue,args=(logger,)))
threads.append(threading.Thread(target=scan_incoming,args=(logger,)))
for worker in threads:
worker.start()
try:
while any(thread.is_alive() for thread in threads):
if stop_event and stop_event.is_set():
break
time.sleep(0.5)
finally:
if stop_event:
stop_event.set()
for worker in threads:
worker.join()
if __name__ == "__main__":
comm_subroutine()
logging_client = logging.getLogger(__name__)
comm_subroutine(logging_client)
-217
View File
@@ -1,217 +0,0 @@
# This Python file uses the following encoding: utf-8
"""Conan Exiles <-> Discord bridge (cog).
Follows the project convention (cog here, helpers in
``conanjurer_functions.py``). The integration is dormant unless configured in
:mod:`constants`: with no RCON host / channels the background watchers simply
do not start and the GM commands report that RCON is unavailable, so loading
this extension is always safe even on hosts without a Conan server.
"""
import asyncio
import logging
import discord
from discord.ext import commands
from conanjurer_functions import ConanConfig, Event, RconClient, watch, watch_players
from constants import (
CONAN_CHAT_CHANNEL_ID,
CONAN_EVENTS_CHANNEL_ID,
CONAN_GM_ROLE_ID,
CONAN_JOIN_CHANNEL_ID,
CONAN_LOG_MODE,
CONAN_LOG_PATH,
CONAN_PLAYER_POLL_SECONDS,
CONAN_RCON_HOST,
CONAN_RCON_PASSWORD,
CONAN_RCON_PORT,
CONAN_SFTP_HOST,
CONAN_SFTP_PASSWORD,
CONAN_SFTP_PORT,
CONAN_SFTP_USER,
)
def is_gm():
"""Allow only members holding the configured Conan GM role."""
async def predicate(ctx: commands.Context) -> bool:
if not CONAN_GM_ROLE_ID:
await ctx.reply(
"⛔ Rola GM Conana nie jest skonfigurowana.", mention_author=False
)
return False
ok = any(r.id == CONAN_GM_ROLE_ID for r in getattr(ctx.author, "roles", []))
if not ok:
await ctx.reply("⛔ Tylko GM.", mention_author=False)
return ok
return commands.check(predicate)
class ConanModule(commands.Cog):
"""Bridges a Conan Exiles server with Discord over RCON + log following."""
def __init__(self, bot, logger_name):
self.bot = bot
self.logger = logging.getLogger(logger_name)
self.cfg = ConanConfig(
rcon_host=CONAN_RCON_HOST,
rcon_port=CONAN_RCON_PORT,
rcon_password=CONAN_RCON_PASSWORD,
log_mode=CONAN_LOG_MODE,
log_path=CONAN_LOG_PATH,
sftp_host=CONAN_SFTP_HOST,
sftp_port=CONAN_SFTP_PORT,
sftp_user=CONAN_SFTP_USER,
sftp_password=CONAN_SFTP_PASSWORD,
)
self.rcon = (
RconClient(CONAN_RCON_HOST, CONAN_RCON_PORT, CONAN_RCON_PASSWORD)
if self.cfg.rcon_enabled
else None
)
self._log_task = None
self._player_task = None
async def cog_load(self):
# Conan -> Discord chat/event mirroring (only with a log source + target)
if self.cfg.log_enabled and (CONAN_CHAT_CHANNEL_ID or CONAN_EVENTS_CHANNEL_ID):
self._log_task = asyncio.create_task(self._run_log_watch())
else:
self.logger.info("Conan: log watch disabled (not configured)")
# Player-join notifications — disabled when the channel is not defined
if CONAN_JOIN_CHANNEL_ID and self.rcon is not None:
self._player_task = asyncio.create_task(self._run_player_watch())
else:
self.logger.info(
"Conan: player-join notifications disabled (no channel or RCON)"
)
async def cog_unload(self):
for task in (self._log_task, self._player_task):
if task is not None:
task.cancel()
if self.rcon is not None:
await self.rcon.close()
# ---------------------------------------------------------------- watchers
async def _run_log_watch(self):
await self.bot.wait_until_ready()
chat_ch = (
self.bot.get_channel(CONAN_CHAT_CHANNEL_ID) if CONAN_CHAT_CHANNEL_ID else None
)
evt_ch = (
self.bot.get_channel(CONAN_EVENTS_CHANNEL_ID)
if CONAN_EVENTS_CHANNEL_ID
else None
)
async def on_event(event: Event):
target = chat_ch if event.kind == "chat" else evt_ch
if target is not None:
await target.send(
event.text, allowed_mentions=discord.AllowedMentions.none()
)
self.logger.info("Conan: starting log watch (mode=%s)", self.cfg.log_mode)
await watch(self.cfg, on_event)
async def _run_player_watch(self):
"""Task 2: announce on a defined channel when a player joins the server."""
await self.bot.wait_until_ready()
channel = self.bot.get_channel(CONAN_JOIN_CHANNEL_ID)
if channel is None:
self.logger.warning(
"Conan: join channel %s not found — player notifications off",
CONAN_JOIN_CHANNEL_ID,
)
return
async def on_join(name: str):
await channel.send(
f"🟢 **{name}** wszedł na serwer Conan",
allowed_mentions=discord.AllowedMentions.none(),
)
self.logger.info(
"Conan: starting player-join watch (channel=%s, every %ss)",
CONAN_JOIN_CHANNEL_ID,
CONAN_PLAYER_POLL_SECONDS,
)
await watch_players(self.rcon, CONAN_PLAYER_POLL_SECONDS, on_join)
# ----------------------------------------------------- Discord -> Conan
async def _require_rcon(self, ctx: commands.Context):
if self.rcon is None:
await ctx.reply(
"⛔ RCON Conana nie jest skonfigurowany/dostępny.",
mention_author=False,
)
return None
return self.rcon
@commands.command(name="say")
@is_gm()
async def say(self, ctx: commands.Context, *, message: str):
"""Discord -> Conan: ogłoszenie widoczne dla wszystkich graczy w grze."""
rcon = await self._require_rcon(ctx)
if rcon is None:
return
resp = await rcon.command(f"broadcast {message}")
await ctx.reply(
f"✅ Wysłano do gry. (serwer: `{resp.strip() or 'OK'}`)",
mention_author=False,
)
@commands.command(name="players")
@is_gm()
async def players(self, ctx: commands.Context):
"""Lista graczy online (RCON listplayers)."""
rcon = await self._require_rcon(ctx)
if rcon is None:
return
resp = await rcon.command("listplayers")
await ctx.reply(
f"```\n{resp.strip() or 'brak danych'}\n```", mention_author=False
)
@commands.command(name="kick")
@is_gm()
async def kick(self, ctx: commands.Context, *, who: str):
"""Wyrzuć gracza (po nazwie/charname — zależnie od wersji serwera)."""
rcon = await self._require_rcon(ctx)
if rcon is None:
return
resp = await rcon.command(f"kick {who}")
await ctx.reply(f"👢 `{resp.strip() or 'OK'}`", mention_author=False)
@commands.command(name="rcon")
@is_gm()
async def raw_rcon(self, ctx: commands.Context, *, cmd: str):
"""Surowa komenda RCON (dla zaawansowanych GM). Używaj ostrożnie."""
rcon = await self._require_rcon(ctx)
if rcon is None:
return
resp = await rcon.command(cmd)
await ctx.reply(f"```\n{resp.strip() or 'OK'}\n```", mention_author=False)
@commands.command(name="ogłoś", aliases=["oglos", "rp"])
@is_gm()
async def rp_announce(self, ctx: commands.Context, nadawca: str, *, message: str):
"""Wiadomość RP 'z nadawcą' (np. ogłoszenie w imieniu Króla Khasara).
Na samym RCON realizujemy to jako sformatowany broadcast.
"""
rcon = await self._require_rcon(ctx)
if rcon is None:
return
resp = await rcon.command(f"broadcast [{nadawca}]: {message}")
await ctx.reply(f"📜 Ogłoszono jako **{nadawca}**.", mention_author=False)
async def setup(bot):
logger = logging.getLogger("discord")
await bot.add_cog(ConanModule(bot, "discord"))
logger.info("Loading conanjurer commands module done")
-266
View File
@@ -1,266 +0,0 @@
# This Python file uses the following encoding: utf-8
"""Helper logic for the Conan Exiles <-> Discord bridge.
Mirrors the project layout: the cog lives in ``conanjurer_commands.py`` and the
reusable logic lives here. Configuration comes from :mod:`constants` (env-var
overridable); optional third-party dependencies (``aiomcrcon``/``asyncssh``)
are imported defensively so the main bot can still load the extension when the
Conan integration is not installed or not in use.
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
from dataclasses import dataclass
from typing import AsyncIterator, Awaitable, Callable, Optional, Set
try:
from aiomcrcon import Client as _Rcon # Source RCON over TCP
except ImportError: # pragma: no cover - optional component
_Rcon = None
try:
import asyncssh
except ImportError: # pragma: no cover - optional component
asyncssh = None
logger = logging.getLogger("discord")
@dataclass
class Event:
kind: str # "chat" | "login" | "logout" | "death" | "raw"
text: str # ready-to-display text
raw: str # original line (for debugging)
@dataclass
class ConanConfig:
"""Runtime configuration for the bridge, built from :mod:`constants`."""
rcon_host: str
rcon_port: int
rcon_password: str
log_mode: str # "local" | "sftp"
log_path: str
sftp_host: str
sftp_port: int
sftp_user: str
sftp_password: str
@property
def rcon_enabled(self) -> bool:
"""RCON usable only when host+password are set and the lib is present."""
return bool(self.rcon_host and self.rcon_password and _Rcon is not None)
@property
def log_enabled(self) -> bool:
"""Log following usable only when its prerequisites are configured."""
if not self.log_path:
return False
if self.log_mode == "sftp":
return bool(self.sftp_host and asyncssh is not None)
return True
# --- REGEXY DO DOSTROJENIA NA WŁASNYM LOGU ---
# Conan/Pippi/Tot logują różnie — dopasuj do swojego logu. Linie niepasujące są
# ignorowane (nie zgadujemy).
_PATTERNS: list[tuple[str, re.Pattern]] = [
("chat", re.compile(r"Chat:\s*(?P<who>.+?):\s*(?P<msg>.+)$", re.I)),
("login", re.compile(r"(?P<who>.+?)\s+(joined|connected|logged in)", re.I)),
("logout", re.compile(r"(?P<who>.+?)\s+(left|disconnected|logged out)", re.I)),
("death", re.compile(r"(?P<who>.+?)\s+was killed by\s+(?P<by>.+)$", re.I)),
]
class RconClient:
"""Thin async wrapper around a Source-RCON connection to the Conan server."""
def __init__(self, host: str, port: int, password: str):
self._host, self._port, self._pw = host, port, password
self._client: Optional["_Rcon"] = None
self._lock = asyncio.Lock()
async def _ensure(self) -> "_Rcon":
if _Rcon is None:
raise RuntimeError("aiomcrcon not installed — RCON unavailable")
if self._client is None:
client = _Rcon(self._host, self._port, self._pw)
await client.connect()
self._client = client
logger.info("RCON connected %s:%s", self._host, self._port)
return self._client
async def command(self, cmd: str) -> str:
"""Send a command to the Conan server and return its response.
Discord -> Conan channel, e.g. ``command("broadcast Hi!")``.
"""
async with self._lock:
for attempt in (1, 2):
try:
client = await self._ensure()
resp, _ = await client.send_cmd(cmd)
return resp
except Exception as exc: # disconnect / server restart
logger.warning("RCON error (attempt %s): %s", attempt, exc)
await self.close()
if attempt == 2:
raise
await asyncio.sleep(1.0)
return ""
async def close(self) -> None:
if self._client is not None:
try:
await self._client.close()
except Exception: # pragma: no cover - best effort
pass
self._client = None
def parse_line(line: str) -> Optional[Event]:
line = line.rstrip("\n")
if not line.strip():
return None
for kind, pat in _PATTERNS:
match = pat.search(line)
if match:
g = match.groupdict()
if kind == "chat":
return Event(kind, f"💬 **{g['who']}**: {g['msg']}", line)
if kind == "login":
return Event(kind, f"🟢 **{g['who']}** dołączył do gry", line)
if kind == "logout":
return Event(kind, f"⚪ **{g['who']}** opuścił grę", line)
if kind == "death":
return Event(kind, f"💀 **{g['who']}** zginął z ręki {g['by']}", line)
return None # nierozpoznane -> ignoruj
def parse_players(listplayers_output: str) -> Set[str]:
"""Extract the set of connected player char-names from RCON ``listplayers``.
Conan's table is roughly::
Idx | Char name | Player name | User ID | Platform ID | Platform Name
0 | Conan | SomeUser | 12345 | 765... | Steam
The char-name column (index 1) is used. The exact format varies between
server builds, so this is best-effort and intentionally tunable.
"""
players: Set[str] = set()
for raw in listplayers_output.splitlines():
line = raw.strip()
if not line or "|" not in line:
continue
cols = [c.strip() for c in line.split("|")]
head = cols[0].lower()
# skip the header row and any separator rows (e.g. "---|---")
if head in ("idx", "") or set(cols[0]) <= set("-"):
continue
if len(cols) >= 2 and cols[1]:
players.add(cols[1])
return players
async def watch_players(
rcon: RconClient,
interval: float,
on_join: Callable[[str], Awaitable[None]],
) -> None:
"""Poll RCON ``listplayers`` and call *on_join* for each new player.
The first poll seeds the known-player set without announcing, so restarting
the bot does not re-announce everyone already online. RCON failures are
logged and retried on the next tick rather than killing the task.
"""
known: Optional[Set[str]] = None
while True:
try:
response = await rcon.command("listplayers")
current = parse_players(response)
if known is None:
known = current
else:
for name in current - known:
try:
await on_join(name)
except Exception: # pragma: no cover - handler guard
logger.exception("Conan: on_join handler failed")
known = current
except Exception as exc:
logger.warning("Conan: player poll failed: %s", exc)
await asyncio.sleep(interval)
async def _follow_local(path: str) -> AsyncIterator[str]:
"""``tail -f`` in pure asyncio, following log rotation."""
while True:
try:
with open(path, "r", encoding="utf-8", errors="replace") as handle:
handle.seek(0, os.SEEK_END)
inode = os.fstat(handle.fileno()).st_ino
while True:
line = handle.readline()
if line:
yield line
continue
await asyncio.sleep(0.5)
try:
if os.stat(path).st_ino != inode: # rotation
break
except FileNotFoundError:
break
except FileNotFoundError:
logger.warning("Conan log not present yet: %s", path)
await asyncio.sleep(3.0)
async def _follow_sftp(cfg: ConanConfig) -> AsyncIterator[str]:
"""Incremental SFTP polling (e.g. Host Havoc): reads only new bytes."""
offset = 0
while True:
try:
async with asyncssh.connect(
cfg.sftp_host,
port=cfg.sftp_port,
username=cfg.sftp_user,
password=cfg.sftp_password,
known_hosts=None,
) as conn:
async with conn.start_sftp_client() as sftp:
while True:
try:
attrs = await sftp.stat(cfg.log_path)
size = attrs.size or 0
if size < offset: # rotation
offset = 0
if size > offset:
async with sftp.open(cfg.log_path, "r") as remote:
await remote.seek(offset)
chunk = await remote.read()
offset = size
for line in chunk.splitlines():
yield line
except FileNotFoundError:
logger.warning("SFTP: missing log %s", cfg.log_path)
await asyncio.sleep(2.0)
except Exception as exc:
logger.warning("SFTP disconnected: %s — retrying", exc)
await asyncio.sleep(5.0)
async def watch(cfg: ConanConfig, on_event: Callable[[Event], Awaitable[None]]) -> None:
"""Follow the Conan log and dispatch recognised lines to *on_event*."""
source = _follow_local(cfg.log_path) if cfg.log_mode != "sftp" else _follow_sftp(cfg)
async for line in source:
event = parse_line(line)
if event is not None:
try:
await on_event(event)
except Exception: # pragma: no cover - handler guard
logger.exception("Conan: event handler failed")
-14
View File
@@ -1,14 +0,0 @@
"""Pytest bootstrap: make first-party modules importable from the tests.
The bot modules live at the repository root and the musician service lives in
``conjurer_musician/``; neither is an installable package, so we put both on
``sys.path`` here.
"""
import os
import sys
_ROOT = os.path.dirname(os.path.abspath(__file__))
for _path in (_ROOT, os.path.join(_ROOT, "conjurer_musician")):
if _path not in sys.path:
sys.path.insert(0, _path)
-353
View File
@@ -1,353 +0,0 @@
"""Betoniarka - the radio operator service.
Lives in the SAME container as Liquidsoap and runs as the SAME unprivileged
user ('radio'), which is the whole point: the process that writes the radio
playlists is colocated with the process that watches them, so there is no
cross-host permission juggling (root-owned network shares, failing chowns)
anymore.
Responsibilities (extracted from conjurer_musician, which is now a pure
Discord music player):
- scan the local music library into all_playlist.playlist / hit.playlist
- serve the radio-management HTTP API the bot calls
(/add_to_priority, /create_priority_playlist, /request_radio_file,
/clear_pr_pls) plus /ping for health checks and /stream for the web page
- tail radio_log.log / persistence.log and forward "now playing" events to
the bot's /prepped_tracks
"""
import json
import logging
import os
import random
import re
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Dict, List
import requests
from flask import Flask, abort, jsonify, request, send_file
from waitress import serve
def _env(name: str, default: str) -> str:
return os.getenv(name, default)
API_KEY = os.getenv("CONJURER_API_KEY")
MAIN_BOT_ADDRESS = _env("CONJURER_MAIN_BOT", "http://127.0.0.1:5000")
MUSIC_TRACKER = _env("CONJURER_MUSIC_TRACKER_ENDPOINT", "/prepped_tracks")
HOST_ADDRESS = _env("BETONIARKA_HOST", "0.0.0.0")
PORT_ADDRESS = int(_env("BETONIARKA_PORT", "5005"))
DATA_DIR = Path(_env("BETONIARKA_DATA", "/srv/betoniarka/data"))
MUSIC_FOLDER = Path(_env("BETONIARKA_MUSIC", "/srv/betoniarka/music"))
PRIORITY_FOLDER = Path(_env("BETONIARKA_PRIORITY_FOLDER", str(MUSIC_FOLDER / "priority")))
STREAM_TEMPLATE = _env("BETONIARKA_STREAM_TEMPLATE", "/app/stream.html")
RESCAN_SECONDS = int(_env("BETONIARKA_RESCAN_SECONDS", str(24 * 60 * 60)))
# How many leading path tokens to ignore when keyword-matching
# (/srv/betoniarka/music/... -> '', 'srv', 'betoniarka', 'music').
PATH_SKIP = int(_env("BETONIARKA_PATH_SKIP", "4"))
ALL_PLAYLIST_PATH = DATA_DIR / "all_playlist.playlist"
HIT_PLAYLIST_PATH = DATA_DIR / "hit.playlist"
REQUEST_PLAYLIST_PATH = DATA_DIR / "request.playlist"
PRIORITY_PLAYLIST_PATH = DATA_DIR / "priority_queue.playlist"
RADIOLOG_PATH = DATA_DIR / "radio_log.log"
PERSISTENCE_PATH = DATA_DIR / "persistence.log"
ENCODING = _env("CONJURER_ENCODING", "utf-8")
logger = logging.getLogger("betoniarka")
music_file_list: List[str] = []
priority_list: List[str] = []
app = Flask(__name__)
def _build_headers() -> Dict[str, str]:
if API_KEY:
return {"X-Conjurer-Api-Key": API_KEY}
return {}
def _authorize_request() -> None:
if API_KEY and request.headers.get("X-Conjurer-Api-Key") != API_KEY:
abort(401)
def _post_to_bot(payload: List[str]) -> None:
try:
response = requests.post(
f"{MAIN_BOT_ADDRESS}{MUSIC_TRACKER}",
json=payload,
headers=_build_headers(),
timeout=60,
)
logger.info("Forwarded to bot (%s): %s", response.status_code, payload[0])
except requests.exceptions.RequestException as exc:
logger.warning("Bot unreachable, dropping %s: %s", payload[0], exc)
# ---------------------------------------------------------------- library
def rescan():
"""Scan the local library into the playlists Liquidsoap watches.
Paths written here are LOCAL container paths, the same ones Liquidsoap
resolves - no shared network filesystem involved.
"""
logger.info("Rescan triggered")
music_file_list.clear()
priority_list.clear()
for mp3_item in MUSIC_FOLDER.glob("**/*.mp3"):
music_file_list.append(mp3_item.as_posix())
for mp3_item in PRIORITY_FOLDER.glob("**/*.mp3"):
priority_list.append(mp3_item.as_posix())
with ALL_PLAYLIST_PATH.open("w", encoding=ENCODING) as w_file:
for item in music_file_list:
w_file.write(item + "\n")
with HIT_PLAYLIST_PATH.open("w", encoding=ENCODING) as w_file:
for item in priority_list:
w_file.write(item + "\n")
logger.info("Rescan done: %d tracks, %d hits", len(music_file_list), len(priority_list))
def thread_rescan():
while True:
time.sleep(RESCAN_SECONDS)
rescan()
# ---------------------------------------------------------------- search
def remove_characters(string, character):
return string.replace(character, "")
def max_weight(lista):
maximum_weight = 0
for iterator in lista:
if iterator[0] > maximum_weight:
maximum_weight = iterator[0]
return maximum_weight
_CHAR_REMOVE = [
".", "^", "$", "*", "+", "?", "{", "}", "[", "]",
"\\", "/", "|", "(", ")", "!", ",", "-", ":", "mp3",
]
def wyszukaj(word_list, how_many, _logger=None, write_to=None):
"""Keyword-score the library; optionally append hits to a playlist file.
Ported unchanged from the musician (same scoring), minus the win32
branches. ``write_to`` replaces the old ``return_to_bot`` flag: pass a
playlist Path to append the result, or None to just return it.
"""
fun_logger = _logger or logger
search_weight = [(0, "") for _ in range(len(music_file_list))]
time_start = datetime.now()
skip_start = 2 if int(how_many) > 0 else 1
for word in word_list[skip_start:]:
token_weight = len(word)
fun_logger.info("Słowo kluczowe: %s", word)
for itr, file in enumerate(music_file_list):
parts = file.split("/")
all_words = []
for f_iter in parts:
for char in _CHAR_REMOVE:
f_iter = remove_characters(f_iter, char)
all_words.extend(f_iter.split())
pingu = 1
pattern_len = len(all_words)
matched_times = 1
for itm in all_words[PATH_SKIP:]:
pingu += 1
if re.match(".*" + word + ".*", itm, re.IGNORECASE):
temp_weight = (
search_weight[itr][0]
+ (token_weight + (pingu**1.5) / pattern_len) / matched_times
)
search_weight[itr] = (temp_weight, music_file_list[itr])
matched_times += 1
fun_logger.info("Stworzylem tablice wag zajęło mi to %s", datetime.now() - time_start)
best = max_weight(search_weight)
if best == 0:
return []
return_list = []
if int(how_many) <= 0:
for weight, path in search_weight:
if weight == best:
return_list.append((weight, path))
break
else:
search_weight.sort(key=lambda x: x[0], reverse=True)
return_list.extend(search_weight[: int(how_many)])
if write_to is not None:
with write_to.open("a", encoding=ENCODING) as s_file:
for item in return_list:
s_file.write(item[1] + "\n")
fun_logger.info("Done: %s", return_list)
return return_list
# ---------------------------------------------------------------- tailer
def scan_tracks():
"""Tail the radio logs and forward play events to the bot."""
with open(RADIOLOG_PATH, "r", encoding=ENCODING) as log_file:
log_file.seek(os.stat(RADIOLOG_PATH).st_size)
prev_size = os.stat(PERSISTENCE_PATH).st_size
while True:
current_size = os.stat(PERSISTENCE_PATH).st_size
if prev_size != current_size:
while prev_size != current_size:
prev_size = current_size
time.sleep(0.1)
current_size = os.stat(PERSISTENCE_PATH).st_size
with open(PERSISTENCE_PATH, "r", encoding=ENCODING) as persistence:
lines = persistence.readlines()
if len(lines) >= 3:
_post_to_bot(["next", lines[2]])
position = log_file.tell()
line = log_file.readline()
if not line:
time.sleep(1)
log_file.seek(position)
continue
if not re.match(r".*Prepared.*", line):
time.sleep(0.1)
continue
result = None
if re.match(r".*jingles.*", line):
result = ["jingles", line]
elif re.match(r".*priority.*", line):
result = ["priority", line]
elif re.match(r".*hit.*", line):
result = ["hit", line]
elif re.match(r".*all_playlist.*", line):
result = ["all", line]
elif re.match(r".*request.*", line):
result = ["requests", line]
if result:
logger.info("Forwarding radio log entry: %s", result[0])
_post_to_bot(result)
time.sleep(0.1)
# ---------------------------------------------------------------- routes
@app.route("/ping", methods=["GET"])
def ping():
"""Health check - the bot gates radio_commands on this answering."""
return jsonify("ALIVE")
@app.route("/stream", methods=["GET"])
def stream_page():
return send_file(STREAM_TEMPLATE)
@app.route("/clear_pr_pls", methods=["GET"])
def clear_pr_pls():
_authorize_request()
app.logger.info("CLEARING PLAYLIST")
with PRIORITY_PLAYLIST_PATH.open("w", encoding=ENCODING) as cleared_pl:
cleared_pl.write("")
return jsonify(isError=False, message="Success", statusCode=200, data=[]), 200
@app.route("/rescan", methods=["GET"])
def manual_rescan():
_authorize_request()
rescan()
return jsonify(isError=False, message="Success", statusCode=200,
data={"tracks": len(music_file_list)}), 200
@app.route("/request_radio_file", methods=["POST"])
def add_request():
_authorize_request()
record = json.loads(request.data)
app.logger.info(record)
wyszukaj(record["lista_slow"], 0, app.logger, write_to=REQUEST_PLAYLIST_PATH)
return jsonify(isError=False, message="Success", statusCode=200,
data={"status": "OK"}), 200
@app.route("/create_priority_playlist", methods=["POST"])
def create_priority_playlist():
_authorize_request()
record = json.loads(request.data)
app.logger.info(record)
return_data = wyszukaj(
record["lista_slow"], record["dlugosc_plejlisty"], app.logger, write_to=None
)
random.shuffle(return_data)
# NOTE: appends to the REQUEST playlist - behaviour inherited verbatim
# from the musician implementation (the request queue picks it up).
with REQUEST_PLAYLIST_PATH.open("a", encoding=ENCODING) as s_file:
for item in return_data:
s_file.write(item[1] + "\n")
return jsonify(isError=False, message="Success", statusCode=200,
data={"status": "OK"}), 200
@app.route("/add_to_priority", methods=["POST"])
def add_to_priority():
_authorize_request()
record = json.loads(request.data)
app.logger.info(record)
wyszukaj(
record["lista_slow"], record["dlugosc_plejlisty"], app.logger,
write_to=PRIORITY_PLAYLIST_PATH,
)
return jsonify(isError=False, message="Success", statusCode=200,
data={"status": "OK"}), 200
def waitress_run():
serve(app, host=HOST_ADDRESS, port=PORT_ADDRESS)
if __name__ == "__main__":
logger.setLevel(logging.DEBUG)
console = logging.StreamHandler()
console.setFormatter(
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
)
logger.addHandler(console)
rescan()
logger.info("Betoniarka started on %s:%s", HOST_ADDRESS, PORT_ADDRESS)
threads = [
threading.Thread(target=waitress_run, daemon=True),
threading.Thread(target=thread_rescan, daemon=True),
]
for worker in threads:
worker.start()
time.sleep(5)
track_thread = threading.Thread(target=scan_tracks, daemon=True)
track_thread.start()
try:
for worker in threads:
worker.join()
track_thread.join()
except KeyboardInterrupt:
logger.info("Shutdown requested - exiting betoniarka")
+201 -426
View File
@@ -1,427 +1,243 @@
"""
This module contains the implementation of the Librarian class and
related functions for searching and refining queries.
Classes:
- Librarian: Represents a librarian object that performs search and
refinement operations on queries
Functions:
- flask_debug: Starts a Flask application in debug mode without using the reloader.
- waitress_run: Serves the Flask application using the Waitress WSGI server.
- BackgroundTaskSearch: Represents a background task for running the
Librarian object asynchronously
"""
import asyncio
import json
import re
import logging
import os
import threading
import time
from flask import Flask, jsonify, request
import netrc
import json
import asyncio
from json.decoder import JSONDecodeError
from logging import handlers
from pathlib import Path
from queue import Queue
from typing import Dict, Optional
import requests
import lib_paths
import scrape_bot
import search_bot
# import search_bot2 as search_bot
from flask import Flask, jsonify, request, abort
from urllib.request import urlopen
from habanero import Crossref
from waitress import serve
from queue import Queue
import search_bot
import scrape_bot
import requests
NETRC_FILE = r"C:\Users\Activcom.pl\.netrc"
try:
import netrc
except ImportError: # pragma: no cover
netrc = None
# Constants
def _env(name: str, default: str) -> str:
return os.getenv(name, default)
def _env_path(name: str, default: str) -> Path:
return Path(os.getenv(name, default)).expanduser().resolve()
BASE_DIR = Path(
os.getenv("CONJURER_LIBRARIAN_BASE", str(Path(__file__).resolve().parent))
)
NETRC_FILE = _env_path("CONJURER_NETRC_FILE", str(Path.home() / ".netrc"))
HOST_ADDRESS = _env("CONJURER_LIBRARIAN_HOST", "0.0.0.0")
PORT_ADDRESS = int(_env("CONJURER_LIBRARIAN_PORT", "5001"))
MAIN_BOT_ADDRESS = _env("CONJURER_MAIN_BOT", "http://127.0.0.1:5000")
SEND_RESULTS = _env("CONJURER_LIBRARIAN_RESULTS_ENDPOINT", "/conjurer")
MAX_CR_RESULTS = int(_env("CONJURER_LIBRARIAN_MAX_RESULTS", "500"))
ENCODING = _env("CONJURER_ENCODING", "utf-8")
API_KEY = os.getenv("CONJURER_API_KEY")
LOGFILE_PATH = _env_path(
"CONJURER_LIBRARIAN_LOG", str(BASE_DIR / "librarian.log")
)
HOST_ADDRESS = "192.168.1.192"
PORT_ADDRESS = 5001
MAIN_BOT_ADDRESS = "http://192.168.1.191:5000"
SEND_RESULTS = "/conjurer"
BDSM_UUID_TEST = '96b7f85a-1142-4908-8986-62a2ea25a147'
MAX_CR_RESULTS = 150000
ENCODING = "utf-8"
app = Flask(__name__)
librarian_queue = Queue()
librarian_list = []
def _service_headers() -> Dict[str, str]:
if API_KEY:
return {"X-Conjurer-Api-Key": API_KEY}
return {}
def _authorize_request() -> None:
if API_KEY and request.headers.get("X-Conjurer-Api-Key") != API_KEY:
abort(401)
# trunk-ignore(pylint/R0902)
class Librarian(object):
"""
Represents a librarian object that performs search and refinement operations on queries.
"""
def __init__(self, _app, query, uuid, _deep_search) -> None:
"""
Initializes a Librarian object.
Args:
- _app: The Flask application object.
- query: The query to be searched.
- uuid: The unique identifier for the search.
Attributes:
- cr: The Crossref object for performing the search.
- query: The query to be searched.
- uuid: The unique identifier for the search.
- limit: The maximum number of search results to fetch.
- fetched: The number of search results fetched so far.
- hit: The number of search results that match the refinement criteria.
- total: The total number of search results.
- app: The Flask application object.
- live_results: A list to store live search results.
- final_result: A list to store the final refined search results.
- not_in_db: A list to store search results that are not in the local database.
- search_result_from_cr: A dictionary to store the search results from Crossref.
- done: A flag indicating if the search is done.
"""
mailto_contact: Optional[str] = os.getenv("CONJURER_CROSSREF_MAILTO")
if netrc:
try:
netrc_mod = netrc.netrc(str(NETRC_FILE))
auth_tokens = netrc_mod.authenticators("crossref")
if auth_tokens:
mailto_contact = auth_tokens[0]
except (FileNotFoundError, netrc.NetrcParseError):
logging.getLogger("conjurer_librarian").warning(
"Crossref credentials missing in netrc %s", NETRC_FILE
)
if not mailto_contact:
raise RuntimeError(
"Crossref credentials not configured. Set CONJURER_CROSSREF_MAILTO or add to netrc."
)
self.cr = Crossref(
mailto=mailto_contact,
ua_string=f"Conjurer project. mailto:{mailto_contact}"
)
def __init__(self, app, query, uuid) -> None:
netrc_mod = netrc.netrc(NETRC_FILE)
authTokens = netrc_mod.authenticators("crossref")
self.cr = Crossref(mailto=authTokens[0])
self.query = query
self.uuid = str(uuid)
self.limit = MAX_CR_RESULTS
self.fetched = 0
self.hit = 0
self.total = 0
self.app = _app
self.app = app
self.logger = app.logger
self.live_results = []
self.final_result = {}
self.not_in_db = {}
self.final_result = []
self.not_in_db = []
self.search_result_from_cr = {}
self.done = False
self.deep_search = _deep_search
async def search_crossref(self, query, deep_search=False):
"""
Performs a search on Crossref for the given query.
Args:
- query: The query to be searched.
Returns:
- result: The search result from Crossref.
Raises:
- None.
"""
async def search_crossref(self, query):
self.app.logger.info("STARTED SEARCH")
result = self.cr.works(query = query, limit = 1000)
self.search_result_from_cr.update(result)
self.total = result['message']['total-results']
self.fetched += len(result['message']['items'])
self.logger.info (self.total)
self.logger.info(self.fetched)
while self.total > self.fetched and self.limit > self.fetched:
result = self.cr.works(query = query, limit = 1000)
self.search_result_from_cr.update(result)
self.total = result['message']['total-results']
self.fetched += len(result['message']['items'])
self.logger.info (self.total)
self.logger.info(self.fetched)
time.sleep(0.1)
self.app.logger.info("CROSSREF DONE")
#TODO: add to sql with UUID
#sql row - title, doi, author, uuid, last retrieved, is_available
if not deep_search:
query_limit = MAX_CR_RESULTS if MAX_CR_RESULTS < 1000 else 1000
cr_result = self.cr.works(query=query, limit=query_limit)
self.search_result_from_cr.update(cr_result)
self.total = cr_result["message"]["total-results"]
self.fetched += len(cr_result["message"]["items"])
self.app.logger.info(self.total)
self.app.logger.info(self.fetched)
while self.total > self.fetched and self.limit > self.fetched:
tmp_result = self.cr.works(query=query, limit=query_limit, offset=self.fetched)
cr_result["message"]["items"].extend(tmp_result["message"]["items"])
self.total = tmp_result["message"]["total-results"]
self.fetched = len(cr_result["message"]["items"])
self.app.logger.info(self.total)
self.app.logger.info(self.fetched)
await asyncio.sleep(0.1)
else:
cr_result = self.cr.works(query=query, cursor_max=15000, cursor='*', progress_bar = True)
result = cr_result[0]
for item in cr_result[1:]:
result["message"]["items"].extend(item["message"]["items"])
self.total = item["message"]["total-results"]
self.fetched = len(result["message"]["items"])
self.app.logger.info(self.total)
self.app.logger.info(self.fetched)
cr_result = result
self.app.logger.info("Total, fetched:")
self.app.logger.info(self.total)
self.app.logger.info(self.fetched)
self.search_result_from_cr.update(cr_result)
self.total = cr_result["message"]["total-results"]
self.app.logger.info("CROSSREF DONE")
self.app.logger.info("CROSSREF DONE")
with open(lib_paths.CR_RESULTS, "r+", encoding="utf-8") as data_file:
with open("rr_results.json", "r+", encoding="utf-8") as data_file:
# First we load existing data into a dict.
try:
file_data = json.load(data_file)
except JSONDecodeError:
file_data = {}
summarized_results = []
self.app.logger.info("REFINE: Removing all derived works from the list")
for item in self.search_result_from_cr['message']['items']:
container = "NO"
if "container-title" in item:
container = "YES"
else:
summarized_results.append({"DOI" : item['DOI'], "title": item['title'] if 'title' in item else None,"type": item['type'] if 'type' in item else None, "container": container})
result = {self.uuid : {"total_results": self.search_result_from_cr['message']['total-results'], "on_page" : 1, "summary":summarized_results, "results":self.search_result_from_cr['message']['items']}}
if file_data:
file_data.update(result)
else:
file_data = result
self.app.logger.info("REFINE: Dumping to file")
data_file.truncate(0)
data_file.seek(0)
tmp = {self.uuid : self.search_result_from_cr}
if file_data:
file_data.update(tmp)
else:
file_data = tmp
json.dump(file_data, data_file, indent=4)
return cr_result
return result
async def refine_search(self, unrefined_result):
"""
Refines the search query based on the unrefined search result.
Args:
- unrefined_result: The unrefined search result.
Returns:
- refined_result: The refined search result.
Raises:
- None.
"""
summarized_results = []
self.app.logger.info("REFINE: Removing all derived works from the list")
for item in unrefined_result["message"]["items"]:
summarized_results.append(
{
"DOI": item["DOI"],
"title": item["title"] if "title" in item else None,
"type": item["type"] if "type" in item else None,
}
)
partial_result = {
self.uuid: {
"total_results": self.search_result_from_cr["message"][
"total-results"
],
"on_page": 1,
"summary": summarized_results,
"results": self.search_result_from_cr["message"]["items"],
}
}
self.app.logger.info("REFINE: Dumping to file")
temp = []
refined_result = {}
for key in partial_result:
self.app.logger.info("KEY:")
self.app.logger.info(key)
for item in partial_result[self.uuid]["summary"]:
if item["title"]:
temp.append(item)
for item in temp:
refined_result[item["DOI"]]= item
with open(lib_paths.RR_RESULTS, "r+", encoding="utf-8") as data_file:
# First we load existing data into a dict.
try:
file_data = json.load(data_file)
except JSONDecodeError:
file_data = {}
data_file.truncate(0)
data_file.seek(0)
tmp = {self.uuid: refined_result}
if file_data:
file_data.update(tmp)
else:
file_data = tmp
json.dump(file_data, data_file, indent=4)
return refined_result
async def check_if_exists(self, refined_result):
"""
Checks if the given DOI exists.
Args:
- doi: The DOI to be checked.
- brute_force: A flag indicating if brute force method should be used.
Returns:
- result: The search result.
Raises:
- None.
"""
result = {}
async def check_dois_in_local_db(self, doi):
self.app.logger.info("REFINE: Running search in the backend app")
dois = []
for item, value in refined_result.items():
dois.append([item, value])
coro = asyncio.to_thread(
search_bot.search_for_doi, dois, self.live_results, self.app.logger
)
coro = asyncio.to_thread(search_bot.search_for_doi, doi, self.live_results, self.app.logger)
result = await coro
result_list = []
result_no_db = []
for item in result:
return result
async def check_if_exists_brute_force(self, page_url):
self.app.logger.error("REFINE: Brute force search!")
if not page_url.startswith(("http:", "https:")):
raise ValueError("URL must start with 'http:' or 'https:'")
# trunk-ignore(bandit/B310)
with urlopen(page_url) as response:
data = response.read()
text = data.decode("utf-8")
for line in text.splitlines():
if re.match(r".*Unfortunately, Sci-Hub doesn't have the requested document.*", line):
return False
if m := re.match(r".*<embed type=\"application.pdf\"\s*src=\"(.*\.pdf)",line):
direct_download_link = "https:" + str(m.group(1))
self.app.logger.info (direct_download_link)
return True
return False
async def check_if_exists(self, doi, brute_force = False):
result = {}
result = await self.check_dois_in_local_db(doi)
if brute_force and not result:
for item in doi:
item_link = "https://sci-hub.se/" + item[0]
tmp = {"DOI" : item["DOI"], "exists" : await self.check_if_exists_brute_force(item_link), "data": item}
result.update(tmp)
return result
async def refine_query(self, unrefined_reqult, brute_force = False):
temp = []
refined_result = []
for key in unrefined_reqult:
self.logger.info("KEY:")
self.logger.info(key)
for item in unrefined_reqult[self.uuid]["summary"]:
if item["container"] == "NO" and item["title"]:
refined_result.append(item)
for item in refined_result:
temp.append([item["DOI"], item])
refined_result = await self.check_if_exists(temp)
for item in refined_result:
if item["exists"]:
result_list.append(item)
self.final_result.append(item["data"])
else:
result_no_db.append(item)
self.hit = len(result)
return result_list, result_no_db
async def answer_query(self, deep_search=False):
"""
Answers the search query.
Args:
- None.
Returns:
- result: The search result.
Raises:
- None.
"""
self.app.logger.info(f"Search started {self.uuid}")
cr_result = await self.search_crossref(query=self.query, deep_search=deep_search)
refined_result = await self.refine_search(cr_result)
answer, negative_answer = await self.check_if_exists(refined_result)
self.app.logger.info("Returning result")
self.app.logger.info(answer)
self.app.logger.info(negative_answer)
for item in answer:
self.final_result[item["DOI"]] = {"Title": item["data"]["title"], "type": item["data"]["type"]}
for item in negative_answer:
self.not_in_db[item["DOI"]] = {"Title": item["data"]["title"], "type": item["data"]["type"]}
self.app.logger.info("Returning result case2")
self.app.logger.info(self.final_result)
self.not_in_db.append(item["data"])
self.hit = len(self.final_result)
return self.final_result
#TODO: DOłożyć sprawdzenie czy w rafinowanym pliku już nie mamy częściowego wyniku
async def answer_query(self):
#TODO: DOłożyć sprawdzenie czy w rafinowanym pliku już nie mamy częściowego wyniku tutaj bo zanim w ogóle otworzymy nierafinowany
#Search sql for UUID
#SQL SELECT * FROM RESULTS WHERE UUID = uuid
self.logger.info(f"Search started {self.uuid}")
database = None
query = self.query
with open("cr_results.json", "r+", encoding="utf-8") as cr_file:
#tu logika komunikacji z SQL i cała logika związana z wyszukaniem
#ale tymczasowo plik
try:
database = json.load(cr_file)
except JSONDecodeError:
pass
#TODO: Zwracać i final_result i not_in_db. Not in db_do pliku - i do ręcznego przeglądania potem.
if database:
for item in database.keys():
if item in self.uuid:
self.result = await self.refine_query(database)
temp = {}
for item in self.result:
temp[item['DOI']] = {"Title" : item['title'], "type": item['type']}
self.result = temp
temp2 = {}
for item in self.not_in_db:
temp2[item['DOI']] = {"Title" : item['title'], "type": item['type']}
self.not_in_db = temp2
return self.result
answer = await self.search_crossref(query=query)
database.update(answer)
cr_file.truncate(0)
cr_file.seek(0)
json.dump(database,cr_file)
self.result = await self.refine_query(answer)
else:
answer = await self.search_crossref(query=query)
database = answer
cr_file.truncate(0)
cr_file.seek(0)
json.dump(database,cr_file)
self.result = await self.refine_query(answer)
self.app.logger.info("Refined")
self.done = True
temp = {}
for item in self.result:
temp[item['DOI']] = {"Title" : item['title'], "type": item['type']}
self.result = temp
temp2 = {}
for item in self.not_in_db:
temp2[item['DOI']] = {"Title" : item['title'], "type": item['type']}
self.not_in_db = temp2
return self.result
# ============================= FLASK INTERNALS===============================
#============================= FLASK INTERNALS===============================
def flask_debug():
"""
Starts a Flask application in debug mode without using the reloader.
Args:
- None.
Returns:
- None.
Raises:
- None.
The `flask_debug` function starts a Flask application in debug mode without using the reloader.
Do not use for production for fucks sake.
"""
# trunk-ignore(bandit/B201)
app.run(debug=True, use_reloader=False, host=HOST_ADDRESS, port=PORT_ADDRESS)
def waitress_run():
"""
Serves the Flask application using the Waitress WSGI server.
Args:
- None.
Returns:
- None.
Raises:
- None.
The `waitress_run` function serves the `app` on host "0.0.0.0"
and port 5000 using the Waitress WSGI server.
"""
serve(app, host=HOST_ADDRESS, port=PORT_ADDRESS)
class BackgroundTaskSearch(threading.Thread):
"""
A background task for searching and saving results to files.
This class extends the `threading.Thread` class and is responsible for running
the search task in the background. It retrieves queries from a queue, performs
the search, and saves the results to files.
Attributes:
app (App): The application instance.
"""
def run(self):
"""
Run the background task.
This method is called when the thread is started. It creates a new event loop,
runs the `_run` method, and closes the event loop.
"""
loop = asyncio.new_event_loop()
loop = asyncio.new_event_loop() # loop = asyncio.get_event_loop()
loop.run_until_complete(self._run())
loop.close()
async def _run(self):
"""
Perform the search task.
This method is an asynchronous coroutine that runs in a loop. It retrieves a
librarian from the queue, answers the query, and saves the results to files.
It also sends the results to a remote server.
The search task continues running indefinitely until the thread is stopped.
"""
while True:
database = None
ndb_database = None
librarian = librarian_queue.get()
self.app.logger.info("STARTED")
result = await librarian.answer_query(librarian.deep_search)
result = await librarian.answer_query()
result = {librarian.uuid: result}
self.app.logger.info("Saving to file")
# Save results to "not_in_db.json" file
with open(lib_paths.NOT_IN_DB, "r+", encoding="utf-8") as ndb_file:
ndb_database = {}
with open("not_in_db.json", "r+", encoding="utf-8") as ndb_file:
try:
ndb_database = json.load(ndb_file)
ndb_database = json.load(ndb_file)
except JSONDecodeError:
pass
if ndb_database:
@@ -432,11 +248,9 @@ class BackgroundTaskSearch(threading.Thread):
ndb_file.seek(0)
json.dump(ndb_database, ndb_file)
# Save results to "s_results.json" file
with open(lib_paths.S_RESULTS, "r+", encoding="utf-8") as s_file:
database = {}
with open("s_results.json", "r+", encoding="utf-8") as s_file:
try:
database = json.load(s_file)
database = json.load(s_file)
except JSONDecodeError:
pass
if database:
@@ -453,43 +267,26 @@ class BackgroundTaskSearch(threading.Thread):
self.app.logger.info(result)
coroutine = asyncio.to_thread(
requests.post,
f"{MAIN_BOT_ADDRESS}{SEND_RESULTS}",
json=result,
headers=_service_headers(),
timeout=360,
)
requests.post,
f"{MAIN_BOT_ADDRESS}{SEND_RESULTS}",
json=result,
timeout=360,
)
self.app.logger.info("SENT")
result = await coroutine
self.app.logger.info(result.status_code)
self.app.logger.info("SEND CONFIRMED")
await asyncio.sleep(1)
# ==================================SERVER ROUTES==========================================
time.sleep(1)
#==================================SERVER ROUTES==========================================
@app.route("/query", methods=["POST"])
async def query_database():
_authorize_request()
"""
Endpoint for querying the database.
This function receives a POST request containing a JSON payload with a query and a UUID.
It creates a Librarian object with the query and UUID,
and adds it to the librarian_queue and librarian_list.
Finally, it returns a JSON response indicating the success
of the operation, along with the query, UUID,
and the current size of the librarian_queue.
Returns:
tuple: A tuple containing a JSON response and a status code.
"""
record = json.loads(request.data)
app.logger.info(record)
app.logger.info(record["query"])
app.logger.info(record["UUID"])
uuid = record["UUID"]
deep_search = record["deep_search"]
cl = Librarian(app, record["query"], uuid, deep_search)
cl = Librarian(app, record["query"], uuid)
librarian_queue.put(cl)
librarian_list.append(cl)
answer_data = (record["query"], record["UUID"], librarian_queue.qsize())
@@ -499,16 +296,8 @@ async def query_database():
)
return return_data
@app.route("/get_partial_result", methods=["POST"])
async def get_partial():
_authorize_request()
"""
Retrieves the partial result for a given UUID.
Returns:
A JSON response containing the partial result.
"""
record = json.loads(request.data)
app.logger.info(record)
app.logger.info(record["UUID"])
@@ -522,39 +311,25 @@ async def get_partial():
)
return return_data
# =======================================MAIN===================================================
#=======================================MAIN===================================================
if __name__ == "__main__":
app.logger.setLevel(logging.DEBUG)
LOGFILE_PATH.parent.mkdir(parents=True, exist_ok=True)
h1 = handlers.RotatingFileHandler(
filename=str(LOGFILE_PATH),
encoding=ENCODING,
mode="a",
maxBytes=6 * 1024 * 1024,
backupCount=6,
)
app.logger.addHandler(h1)
logger = app.logger
logger.setLevel(logging.DEBUG)
threads = []
threads.append(threading.Thread(target=waitress_run, daemon=True))
# threads.append(threading.Thread(target=flask_debug))
threads.append(threading.Thread(target=waitress_run))
#threads.append(threading.Thread(target=flask_debug))
bgtask = BackgroundTaskSearch()
bgtask.app = app
bgtask.daemon = True
threads.append(bgtask)
threads.append(
threading.Thread(
target=scrape_bot.scraper, args=(app.logger,), daemon=True
)
)
threads.append(threading.Thread(target = scrape_bot.scraper, args=(logger,)))
i = 0
try:
for worker in threads:
app.logger.info("App number: %s", i)
i += 1
for worker in threads:
try:
logger.info(f"App number: {i}")
i+=1
worker.start()
for worker in threads:
worker.join()
except KeyboardInterrupt:
app.logger.info("Shutdown requested - exiting librarian service")
except RuntimeError as e:
logger.error("Exploded")
print(str(e))
for worker in threads:
worker.join()
-8
View File
@@ -1,8 +0,0 @@
FILEPTH = r"C:\Database\chunks\4_chunk.txt"
with open(FILEPTH, "r", encoding="utf-8") as file:
tab = file.readlines()
print(len(tab))
#1842295
@@ -1,33 +1,11 @@
#!/bin/bash
# This script sets up a virtual environment for the Conjurer Librarian service and installs the required dependencies.
# Create a virtual environment at /home/pi/Conjurer_librarian/env
python3 -m venv /home/pi/Conjurer_librarian/env
# Copy the Conjurer Librarian Python script to the appropriate directory
cp /home/pi/conjurer/conjurer_librarian/conjurer_librarian.py ./Conjurer_librarian/
# Copy the requirements file to the appropriate directory
cp /home/pi/conjurer/conjurer_librarian/requirements_librarian.txt ./Conjurer_librarian/
# Change directory to /home/pi/Conjurer_librarian
cp /home/pi/conjurer/conjurer_librarian/conjurer_librarian.py ./Conjurer_librarian/
cp /home/pi/conjurer/conjurer_librarian/requirements_librarian.txt ./Conjurer_librarian/
cd /home/pi/Conjurer_librarian
touch /home/pi/Conjurer/rr_results.json
touch /home/pi/Conjurer/cr_results.json
touch /home/pi/Conjurer/s_results.json
touch /home/pi/Conjurer/rr_results.json
touch /home/pi/Conjurer/not_in_db.json
# Activate the virtual environment
source /home/pi/Conjurer_librarian/env/bin/activate
# Upgrade pip
./env/bin/python3 -m pip install --upgrade pip
# Install the required dependencies
./env/bin/python3 -m pip install -r requirements_librarian.txt
# Deactivate the virtual environment
deactivate
cp /home/pi/conjurer/conjurer_librarian/conjurer_librarian.py ./Conjurer_librarian/
-41
View File
@@ -1,41 +0,0 @@
"""Resolved, seeded paths for the librarian's runtime JSON state.
``conjurer_librarian.py`` and ``scrape_bot.py`` open these files in place with
mode ``r+``, which requires them to already exist - in a fresh container the
working directory has none of them, so the worker threads crashed with
FileNotFoundError.
They now live in a single mounted, persistent directory
(``CONJURER_LIBRARIAN_STATE_DIR``, default ``/lib_temp_files``) and are seeded
with an empty JSON object on import, so a fresh container/volume never crashes
and the accumulated results survive restarts.
"""
import os
STATE_DIR = os.getenv("CONJURER_LIBRARIAN_STATE_DIR", "/lib_temp_files")
CR_RESULTS = os.path.join(STATE_DIR, "cr_results.json") # Crossref raw hits
RR_RESULTS = os.path.join(STATE_DIR, "rr_results.json") # refined results
NOT_IN_DB = os.path.join(STATE_DIR, "not_in_db.json") # DOIs to scrape
S_RESULTS = os.path.join(STATE_DIR, "s_results.json") # final send results
_ALL = (CR_RESULTS, RR_RESULTS, NOT_IN_DB, S_RESULTS)
def ensure_state_files():
"""Create the state dir and seed any missing file with an empty JSON dict."""
try:
os.makedirs(STATE_DIR, exist_ok=True)
except OSError as exc: # pragma: no cover - surfaced in logs, not fatal
print(f"lib_paths: cannot create {STATE_DIR}: {exc}")
return
for path in _ALL:
if not os.path.exists(path):
try:
with open(path, "w", encoding="utf-8") as handle:
handle.write("{}")
except OSError as exc: # pragma: no cover
print(f"lib_paths: cannot seed {path}: {exc}")
ensure_state_files()
@@ -1,3 +1,3 @@
habanero
waitress
flask[async]
flask
+38 -83
View File
@@ -1,72 +1,46 @@
"""
This module contains the code for the scrape bot.
"""
import json
import logging
import os
import random
from json.decoder import JSONDecodeError
from urllib.request import urlopen
from requests import Timeout,ConnectTimeout, ConnectionError
from queue import Queue
import re
import time
from json import JSONDecodeError
from queue import Queue
import random
import logging
from threading import Thread
from urllib.request import urlopen
from requests import ConnectionError as RequestsConnectionError
from requests import ConnectTimeout, Timeout
import lib_paths
SCR_DATABASE_PATH = os.getenv("CONJURER_LIBRARIAN_DB_PATH", r"C:\\Database\\chunks\\")
SCR_FILENAME = os.getenv("CONJURER_LIBRARIAN_SCRAPE_CHUNK", "40_chunk.txt")
SCR_ENCODING = os.getenv("CONJURER_ENCODING", "utf-8")
SCR_DATABASE_PATH = r'C:\\Database\\chunks\\'
SCR_FILENAME = "40_chunk.txt"
SCR_ENCODING = "utf-8"
WORK_Q = Queue()
random.seed()
def load_ndb_to_q(logger):
"""
This function loads items from the not_in_db.json file into the work queue.
"""
logger.info("Loader started")
while True:
with open(lib_paths.NOT_IN_DB, "r+", encoding="utf-8") as ndb_file:
while True:
with open("not_in_db.json", "r+", encoding="utf-8") as ndb_file:
try:
ndb_database = json.load(ndb_file)
for _ in range (1,10):
try:
key = next(iter(ndb_database))
_ = ndb_database.pop(key)
logger.info(key)
url = f"https://sci-hub.se/{key}"
WORK_Q.put([key, url, False])
except StopIteration:
break
ndb_database = json.load(ndb_file)
for item in ndb_database.keys():
logger.info(item)
url = f"https://sci-hub.se/{item}"
WORK_Q.put([item, url, False])
ndb_file.truncate(0)
ndb_file.seek(0)
json.dump(ndb_database, ndb_file, indent=4)
except JSONDecodeError:
time.sleep(60 * 60 * 3)
time.sleep(60*60*3)
time.sleep(60*60*2)
pass
time.sleep(180)
def check_if_exists_brute_force(logger):
"""
This function checks if a document exists using brute force search.
"""
# Function code here
while True:
logger.info("Scraper tick")
item = WORK_Q.get()
page_url = item[1]
page_url =item[1]
logger.error("REFINE: Brute force search!")
if not page_url.startswith(("http:", "https:")):
raise ValueError("URL must start with 'http:' or 'https:'")
blocked = True
try:
# trunk-ignore(bandit/B310)
# trunk-ignore(bandit/B310)
with urlopen(page_url) as response:
data = response.read()
text = data.decode("utf-8")
@@ -76,63 +50,44 @@ def check_if_exists_brute_force(logger):
blocked = False
else:
for line in text.splitlines():
if re.match(
r".*Unfortunately, Sci-Hub doesn't have the requested document.*",
line,
):
if re.match(r".*Unfortunately, Sci-Hub doesn't have the requested document.*", line):
blocked = False
logger.info("Not found")
item[2] = False
if m := re.match(
r".*<embed type=\"application.pdf\"\s*src=\"(.*\.pdf)", line
):
if m := re.match(r".*<embed type=\"application.pdf\"\s*src=\"(.*\.pdf)",line):
blocked = False
direct_download_link = "https:" + str(m.group(1))
logger.info(direct_download_link)
with open(
SCR_DATABASE_PATH + SCR_FILENAME,
"a",
encoding=SCR_ENCODING,
) as operated_file:
logger.info (direct_download_link)
with open(SCR_DATABASE_PATH + SCR_FILENAME, "a", encoding=SCR_ENCODING) as operated_file:
operated_file.write("\n")
operated_file.write(item[0])
item[2] = True
except (
Timeout,
ConnectTimeout,
ConnectionRefusedError,
ConnectionError,
RequestsConnectionError,
):
except (Timeout,ConnectTimeout, ConnectionRefusedError, ConnectionError):
pass
if blocked:
logger.info(item)
logger.info(text)
logger.error("Got blocked. Fuck.")
time.sleep(60 * 60)
time.sleep(60*60*72)
# trunk-ignore(bandit/B311)
rand = random.randint(1, 60)
logger.info(f"Sleeping for {2*rand} minutes")
time.sleep(120 * rand)
rand = random.randint(1,100)
logger.info(f"Sleeping for {3*rand} minutes")
time.sleep(180*rand)
def scraper(logger=None):
"""
This function is responsible for scraping data.
"""
def scraper(logger = None):
if not logger:
logger = logging.getLogger()
logger.setLevel("DEBUG")
h1 = logging.StreamHandler()
logger.addHandler(h1)
logger.info("No logger set. Test run")
loader = Thread(target=load_ndb_to_q, args=(logger,))
_scraper = Thread(target=check_if_exists_brute_force, args=(logger,))
loader = Thread(target = load_ndb_to_q, args = (logger,))
scraper = Thread(target = check_if_exists_brute_force, args = (logger,))
loader.start()
_scraper.start()
scraper.start()
loader.join()
_scraper.join()
scraper.join()
if __name__ == "__main__":
scraper()
scraper()
+63 -155
View File
@@ -1,64 +1,28 @@
"""
This module contains functions for searching for DOI (Digital Object Identifier)
in a list of live results.
It includes a producer-consumer pattern implementation using threads and queues.
#TODO: Wpiemdolić to wszystko w klasę z loggerem przysłanym z góry
Functions:
- producer: Reads lines from a file and puts them into an output queue.
- consumer: Consumes items from an input queue and checks if DOI exists in the live results list.
- search_for_doi: Searches for DOI in live results using the producer-consumer pattern.
Global Variables:
- MAXTHREADS: Maximum number of worker threads.
- DATABASE_PATH: Path to the database files.
- ENCODING: Encoding of the database files.
- CHUNK: File name pattern for the database files.
- _sentinel: Sentinel object used to signal termination.
- result_list: List to store the search results.
- WORK_Q_SIZE: Maximum size of the work queue.
"""
# TODO: Wpiemdolić to wszystko w klasę z loggerem przysłanym z góry
import os
from queue import Empty, Queue
from queue import Queue, Empty
from threading import Thread
import time
q = Queue()
#TODO: Count number of lines in files and print to approximate on which part of the file search is
# Deployment data is environment-overridable so the local DOI database can live
# on a mounted volume (Docker/Linux) instead of the hardcoded Windows path.
MAXTHREADS = int(os.getenv("CONJURER_LIBRARIAN_MAXTHREADS", "41"))
DATABASE_PATH = os.getenv("CONJURER_LIBRARIAN_DB_PATH", r"C:\\Database\\chunks\\")
#TODO: DATA FOR TEST ONLY
#MAXTHREADS = 5
#DATABASE_PATH = r'C:\\Database\\chunks_1\\'
ENCODING = os.getenv("CONJURER_ENCODING", "utf-8")
CHUNK = os.getenv("CONJURER_LIBRARIAN_CHUNK", "_chunk.txt")
#TODO: DEPLOYMENT DATA
MAXTHREADS = 41
DATABASE_PATH = r'C:\\Database\\chunks\\'
ENCODING = "utf-8"
CHUNk = "_chunk.txt"
_sentinel = object()
WORK_Q_SIZE = 35500000
result_list = []
WORK_Q_SIZE = 15500000
def producer(out_q, control_q, filename, _logger):
"""
Produces items from the output queue and puts them into the control queue.
Args:
out_q (Queue): Output queue.
control_q (Queue): Control queue.
filename (str): Name of the file.
_logger: Logger object for logging.
"""
with open(DATABASE_PATH + filename, "r", encoding=ENCODING) as operated_file:
print(f"Worker {filename} ")
line_no = 0
def producer(out_q, control_q, filename, logger):
with open(DATABASE_PATH + filename, "r+", encoding=ENCODING) as operated_file:
logger.info(f"Worker {filename} ")
while True:
line = operated_file.readline()
line_no += 1
print(f"\t \t \t \t \t \t W{filename}{line_no}\r", end="")
if not line:
print(f"EOF {filename}")
break
if not line:
break
out_q.put(line)
@@ -66,133 +30,77 @@ def producer(out_q, control_q, filename, _logger):
check = control_q.get(block=False)
except Empty:
check = False
pass
if check is _sentinel:
print("TERM signal received")
logger.info("TERM signal received")
control_q.put(check)
break
print(f"Worker finished: {filename}")
logger.info(f"Worker finished: {filename}")
out_q.put(_sentinel)
# IMPORTANT!!! ONLY ONE CONSUMER THREAD AS WE ARE NOT PUTTING SENTINELS BACK
def consumer(in_q, control_q, doi, live_results, result_list, control_dict, no, _logger):
"""
Consumes items from an input queue and checks if DOI exists in the live results list.
Args:
in_q (Queue): Input queue.
control_q (Queue): Control queue.
doi (list): List of DOI to search for.
live_results (list): List to store the search results.
_logger: Logger object for logging.
"""
print(f"Consumer thread started: {no} no")
empty_counter = 0
alive_no = 0
#IMPORTANT!!! ONLY ONE CONSUMER THREAD AS WE ARE NOT PUTTING SENTINELS BACK
#TODO: Change doi to tuple list and only put term when all doi are found => (doi, True)
def consumer(in_q, control_q, doi, live_results, logger):
sentinels = 0
for item in doi:
result_list.append({"DOI" : item[0], "exists" : False, "data" : item[1]})
while True:
done_check = True
try:
data = in_q.get(block=True, timeout = 1)
if data is _sentinel:
control_dict["sentinels"] += 1
print(f"Workers finished: {control_dict['sentinels']} reported by consumer {no}")
else:
empty_counter = 0
alive_no += 1
print(f"C{no}__{alive_no}\r", end="")
for item in result_list:
if item["DOI"] in data and not item["exists"]:
print(f"HIT in {no} content {data[0]} line {data[1]} file {data[2]} {item['exists']}")
_logger.info(data)
_logger.info("HIT")
item["exists"] = True
live_results.append(item)
done_check = done_check and item["exists"]
if done_check:
control_q.put(_sentinel)
except Empty:
empty_counter += 1
time.sleep(1)
print(f"Consumer {no} empty")
if empty_counter > 5:
print("Consumer %s empty lvl 2", no)
time.sleep(2)
elif empty_counter > 10:
print(f"Consumer thread finished {no}")
break
if control_dict["sentinels"] >= MAXTHREADS:
_logger.info(f"All workers finished {no}")
data = in_q.get()
if data is _sentinel:
logger.info("Worker finished")
sentinels += 1
else:
for item in result_list:
if item["DOI"] in data and not item["exists"]:
logger.info(data)
logger.info("HIT")
item["exists"] = True
live_results.append(item)
done_check = done_check and item["exists"]
if done_check:
control_q.put(_sentinel)
if sentinels >= MAXTHREADS:
logger.info("All workers finished")
break
def search_for_doi(doi, live_results, _logger):
"""
Search for DOI in live_results using _logger for logging.
Args:
doi (list): List of DOI to search for.
live_results (list): List to store the search results.
_logger: Logger object for logging.
"""
control_dict = {"sentinels":0}
result_list = []
def search_for_doi(doi, live_results, logger):
threads = []
work_q = Queue(maxsize=WORK_Q_SIZE)
control_q = Queue()
for item in doi:
result_list.append({"DOI": item[0], "exists": False, "data": item[1]})
for i in range (0, (len(doi)//1000)+2):
t_cons = Thread(
target=consumer, args=(work_q, control_q, doi, live_results, result_list, control_dict, i, _logger)
)
_logger.info("Consumer thread created")
threads.append(t_cons)
for i in range(0, MAXTHREADS):
# TEST DATA
# filename = "test" + str(i) + CHUNk
# DEPLOYMENT DATA
filename = str(i) + CHUNK
_logger.info(f"Creating worker thread no: {i}")
threads.append(
Thread(target=producer, args=(work_q, control_q, filename, _logger))
)
t_cons = Thread(target = consumer, args = (work_q, control_q, doi, live_results, logger))
logger.info("Consumer thread created")
threads.append(t_cons)
for i in range(0,MAXTHREADS):
#TODO: TEST DATA
#filename = "test" + str(i) + CHUNk
#TODO: DEPLOYMENT DATA
filename = str(i) + CHUNk
logger.info(f"Creating worker thread no: {i}")
threads.append(Thread(target = producer, args = (work_q, control_q, filename,logger)))
for worker in threads:
worker.start()
for worker in threads:
worker.join()
return result_list
if __name__ == "__main__":
import logging
logger = logging.getLogger()
logger.setLevel("DEBUG")
h1 = logging.StreamHandler()
logger.addHandler(h1)
logger.info("TEST RUN")
live_result = []
logger.info(
search_for_doi(
[
("10.1002/9781118786352.wbieg0998.pub2", "DATA"),
("10.1002/j.2050-0416.2002.tb00563.x", "DATA"),
("10.1111/j.1365-2958.1994.tb00448.x", "DATA"),
("10.2165/00128415-200309690-00017", "DATA"),
("10.5772/48313", "DATA"),
("10.15803/ijnc.7.2_419", "DATA"),
("10.1051/0004-6361/201321596e", "DATA"),
("10.2307/40835941", "DATA"),
],
live_result,
logger,
)
)
logger.info(search_for_doi([
("10.1002/9781118786352.wbieg0998.pub2","DATA"),
("10.1002/j.2050-0416.2002.tb00563.x","DATA"),
("10.1111/j.1365-2958.1994.tb00448.x","DATA"),
("10.2165/00128415-200309690-00017","DATA"),
("10.5772/48313","DATA"),
("10.15803/ijnc.7.2_419","DATA"),
("10.1051/0004-6361/201321596e","DATA"),
("10.2307/40835941","DATA"),
], live_result, logger))
logger.info(live_result)
-206
View File
@@ -1,206 +0,0 @@
"""
This module contains functions for searching for DOI (Digital Object Identifier)
in a list of live results.
It includes a producer-consumer pattern implementation using threads and queues.
Functions:
- producer: Reads lines from a file and puts them into an output queue.
- consumer: Consumes items from an input queue and checks if DOI exists in the live results list.
- search_for_doi: Searches for DOI in live results using the producer-consumer pattern.
Global Variables:
- MAXTHREADS: Maximum number of worker threads.
- DATABASE_PATH: Path to the database files.
- ENCODING: Encoding of the database files.
- CHUNK: File name pattern for the database files.
- _sentinel: Sentinel object used to signal termination.
- result_list: List to store the search results.
- WORK_Q_SIZE: Maximum size of the work queue.
"""
# TODO: Wpiemdolić to wszystko w klasę z loggerem przysłanym z góry
from multiprocessing import Manager, Process, Queue
from queue import Empty, Full
import time
# DATA FOR TEST ONLY
#MAXTHREADS = 6
#DATABASE_PATH = r'C:\\Database\\chunks_1\\'
# DEPLOYMENT DATA
MAXTHREADS = 41
DATABASE_PATH = r"C:\\Database\\chunks\\"
ENCODING = "utf-8"
CHUNK = "_chunk.txt"
_sentinel = None
WORK_Q_SIZE = 355000
def producer(out_q, control_q, filename, _logger):
"""
Produces items from the output queue and puts them into the control queue.
Args:
out_q (Queue): Output queue.
control_q (Queue): Control queue.
filename (str): Name of the file.
_logger: Logger object for logging.
"""
with open(DATABASE_PATH + filename, "r", encoding=ENCODING) as operated_file:
print(f"Worker {filename} ")
line_no = 0
while True:
line = operated_file.readline()
line_no += 1
print(f"\t \t \t W{filename}{line_no}\r", end="")
if not line:
print(f"EOF {filename}")
break
try:
out_q.put([line, line_no, filename], block = True, timeout = 60)
except Full:
print("queue fulll")
try:
check = control_q.get(block=False)
except Empty:
check = False
if check is None:
print(f"TERM signal received {filename}")
control_q.put(check)
control_q.close()
break
print(f"Worker finished: {filename}")
out_q.put(_sentinel)
out_q.close()
# IMPORTANT!!! ONLY ONE CONSUMER THREAD AS WE ARE NOT PUTTING SENTINELS BACK
def consumer(in_q, control_q, doi, live_results, result_list, control_dict, no, _logger):
"""
Consumes items from an input queue and checks if DOI exists in the live results list.
Args:
in_q (Queue): Input queue.
control_q (Queue): Control queue.
doi (list): List of DOI to search for.
live_results (list): List to store the search results.
_logger: Logger object for logging.
"""
print(f"Consumer thread started: {no} no")
empty_counter = 0
alive_no = 0
while True:
done_check = True
try:
data = in_q.get(block=True, timeout = 1)
empty_counter = 0
if data is None:
control_dict["sentinels"] += 1
print(f"Workers finished: {control_dict['sentinels']} reported by consumer {no}")
else:
empty_counter = 0
if alive_no > 9:
alive_no = 0
alive_no += 1
print(f"C{no}{alive_no}\r", end="")
for item in result_list:
if item["DOI"] in data[0] and not item["exists"]:
print(f"HIT in {no} content {data[0]} line {data[1]} file {data[2]} {item['exists']}")
item["exists"] = True
live_results.append(item)
done_check = done_check and item["exists"]
if done_check:
control_q.put(_sentinel)
except Empty:
empty_counter += 1
time.sleep(1)
print(f"Consumer {no} empty")
if empty_counter > 5:
print("Consumer %s empty lvl 2", no)
time.sleep(2)
elif empty_counter > 10:
print(f"Consumer thread finished {no}")
break
if control_dict["sentinels"] >= MAXTHREADS:
print(f"All workers finished killing {no}")
break
print("Consumer thread finished %s",no )
control_q.close()
def search_for_doi(doi, live_results, _logger):
"""
Search for DOI in live_results using _logger for logging.
Args:
doi (list): List of DOI to search for.
live_results (list): List to store the search results.
_logger: Logger object for logging.
"""
with Manager() as manager:
control_dict = manager.dict()
control_dict["sentinels"] = 0
result_list = []
for item in doi:
result_list.append({"DOI": item[0], "exists": False, "data": item[1]})
result_list_proxy = manager.list(result_list)
threads = []
work_q = Queue(maxsize=WORK_Q_SIZE)
control_q = Queue()
for i in range(0, MAXTHREADS):
# TEST DATA
#filename = "test" + str(i) + CHUNK
# DEPLOYMENT DATA
filename = str(i) + CHUNK
print(f"Creating worker thread no: {i}")
threads.append(
Process(target=producer, args=(work_q, control_q, filename, _logger))
)
for i in range (0, (len(doi)//1000)+5):
t_cons = Process(
target=consumer, args=(work_q, control_q, doi, live_results, result_list_proxy, control_dict, i, _logger)
)
print("Consumer thread created")
threads.append(t_cons)
for worker in threads:
worker.start()
for worker in threads:
worker.join()
result_list = list(result_list_proxy)
for worker in threads:
worker.close()
return result_list
if __name__ == "__main__":
import logging
logger = logging.getLogger()
logger.setLevel("DEBUG")
h1 = logging.StreamHandler()
logger.addHandler(h1)
print("TEST RUN")
live_result = []
print(
search_for_doi(
[
("10.1002/9781118786352.wbieg0998.pub2", "DATA"),
("10.1002/j.2050-0416.2002.tb00563.x", "DATA"),
("10.1111/j.1365-2958.1994.tb00448.x", "DATA"),
("10.2165/00128415-200309690-00017", "DATA"),
("10.5772/48313", "DATA"),
("10.15803/ijnc.7.2_419", "DATA"),
("10.1051/0004-6361/201321596e", "DATA"),
("10.2307/40835941", "DATA"),
("NOT_ON_LIST", "DATA"),
],
live_result,
logger,
)
)
print(live_result)
-45
View File
@@ -1,45 +0,0 @@
import asyncio
import json
import logging
from json.decoder import JSONDecodeError
from logging import handlers
import requests
MAIN_BOT_ADDRESS = "http://192.168.1.191:5000"
SEND_RESULTS = "/conjurer"
async def send_results():
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
h1 = handlers.RotatingFileHandler(
filename="D:\\logs\\librarian.log",
encoding="utf-8",
mode="a",
maxBytes=6 * 1024 * 1024,
backupCount=6,
)
logger.addHandler(h1)
with open("s_results.json", "r+", encoding="utf-8") as s_file:
try:
database = json.load(s_file)
except JSONDecodeError:
pass
result = database["4bacbca0-ce3b-45c6-90a1-05c70b46b740"]
result = {"4bacbca0-ce3b-45c6-90a1-05c70b46b740":result}
coroutine = asyncio.to_thread(
requests.post,
f"{MAIN_BOT_ADDRESS}{SEND_RESULTS}",
json=result,
timeout=360,
)
logger.info("SENT")
result = await coroutine
logger.info(result.status_code)
logger.info("SEND CONFIRMED")
async def main():
await send_results()
if __name__ == "__main__":
asyncio.run(main())
-7
View File
@@ -1,7 +0,0 @@
# Runtime-generated radio data — keep out of the repo
all_playlist.playlist
hit.playlist
request.playlist
priority_queue.playlist
prio_playlist.json
*.mp3
+146 -117
View File
@@ -1,84 +1,87 @@
"""Musician - the Discord music player service.
Serves the music library index and keyword search the bot uses for Discord
playback (/mp3, /update_mp3, /get_music) plus the file-share endpoints.
Radio playlist management and the radio-log tailer moved to
conjurer_betoniarka/betoniarka.py, which runs INSIDE the radio container as
the same user as Liquidsoap - the musician no longer writes any radio files,
so the old root-owned-network-share permission mess is gone.
# This Python file uses the following encoding: utf-8
"""
The provided Python script sets up a Flask web server to manage a list of music files, with
functions for rescanning the music folder, updating the music list, and serving the music list via
API endpoints.
"""
import json
import logging
import os
import random
import re
import threading
import time
from datetime import datetime
from logging import handlers
from pathlib import Path
from typing import List
from platform import uname
from sys import platform
from flask import Flask, abort, jsonify, request
from flask import Flask, jsonify, redirect, request, send_from_directory
from waitress import serve
import media_search_functions
HOST_ADDRESS = "192.168.1.15"
PORT_ADDRESS = 5000
if platform in ("linux", "linux2"):
SEPARATOR_FILE_PATH = "/"
if "microsoft-standard" in uname().release:
LOGFILE = "/home/mtuszowski/conjurer/discord_mus_service.log"
MUSIC_FOLDER = "/mnt/g/Muzyka/"
NETRC_FILE = "/home/mtuszowski/.netrc"
LOGSTORE = "/home/mtuszowski/conjurer/logs/"
ENCODING = "utf-8"
else:
LOGFILE = "/home/pi/Conjurer/discord_mus_service.log"
NETRC_FILE = "/home/pi/.netrc"
LOGSTORE = "/home/pi/RetroPie/logs/"
ENCODING = "utf-8"
MUSIC_FOLDER = "/home/pi/RetroPie/mp3/"
def _env(name: str, default: str) -> str:
return os.getenv(name, default)
music_file_list = []
def _env_path(name: str, default: str) -> Path:
value = os.getenv(name, default)
return Path(value).expanduser().resolve()
def create_playlist():
"""
Reads a JSON file containing a playlist and writes the playlist items to a text file.
The JSON file path is '/home/pi/Conjurer/playlist.json',
and the text file path is '/home/pi/Conjurer/all_playlist.playlist'.
API_KEY = os.getenv("CONJURER_API_KEY")
HOST_ADDRESS = _env("CONJURER_MUSICIAN_HOST", "0.0.0.0")
PORT_ADDRESS = int(_env("CONJURER_MUSICIAN_PORT", "5000"))
Raises:
JSONDecodeError: If the JSON file cannot be decoded.
BASE_DIR = Path(
os.getenv("CONJURER_MUSICIAN_BASE", str(Path(__file__).resolve().parent))
)
LOGFILE = _env_path(
"CONJURER_MUSICIAN_LOG", str(BASE_DIR / "discord_mus_service.log")
)
LOGSTORE = _env_path("CONJURER_LOGSTORE", str(BASE_DIR / "logs"))
MUSIC_FOLDER = _env_path(
"CONJURER_MUSIC_FOLDER", str(BASE_DIR / "music")
)
ENCODING = _env("CONJURER_ENCODING", "utf-8")
SEPARATOR_FILE_PATH = os.sep
random.seed()
music_file_list: List[str] = []
def _authorize_request() -> None:
if API_KEY and request.headers.get("X-Conjurer-Api-Key") != API_KEY:
abort(401)
"""
with open("/home/pi/Conjurer/playlist.json", "r+", encoding="utf-8") as r_file:
with open(
"/home/pi/Conjurer/all_playlist.playlist", "w", encoding="utf-8"
) as w_file:
try:
playlist = json.load(r_file)
for item in playlist:
w_file.write(item)
w_file.write("\n")
except json.JSONDecodeError:
pass
def rescan():
"""Refresh the in-memory library index used by /mp3 and /get_music.
Radio playlist files are NOT written here anymore - that is the
betoniarka's job, colocated with Liquidsoap.
"""
logger = logging.getLogger("conjurer_musician")
logger.info("Rescan triggered")
music_file_list.clear()
for mp3_item in MUSIC_FOLDER.glob("**/*.mp3"):
The `rescan` function logs a message, scans for mp3 files in a specified folder,
and adds them to a list of music files.
"""
logging.info("Rescan triggered")
for mp3_item in Path.glob(Path(MUSIC_FOLDER), "**/*.mp3"):
temp_music_file = mp3_item.as_posix()
if os.name == "nt":
if platform == "win32":
temp_music_file = temp_music_file.replace("/", "\\")
music_file_list.append(temp_music_file)
with open("/home/pi/Conjurer/playlist.json", "r+", encoding="utf-8") as s_file:
database = music_file_list
# self.app.logger.info("DUMPING DATA")
s_file.truncate(0)
s_file.seek(0)
json.dump(database, s_file)
create_playlist()
def thread_rescan():
@@ -86,16 +89,14 @@ def thread_rescan():
The `thread_rescan` function periodically triggers a rescan operation after a specified time
interval.
"""
logger = logging.getLogger("conjurer_musician")
logger.info("Starting filesystemupdater")
logging.info("Starting filesystemupdater")
while True:
time.sleep(60 * 60 * 24)
logger.info("Rescan triggered")
logging.info("Rescan triggered")
rescan()
app = Flask(__name__)
# AutoIndex(app, browse_root="/")
# TODO: Odpalić wyszukiwarki w wątkach i dopiero po wszystkim zsumować wyszukiwanie.
@@ -199,13 +200,25 @@ def wyszukaj(word_list, how_many, _logger=None, return_to_bot=True):
while not_found:
if search_weight[itr][0] == item_to_search:
return_list.append(search_weight[itr])
if not return_to_bot:
with open(
"/home/pi/Conjurer/priority_queue.playlist", "r+", encoding="utf-8"
) as s_file:
s_file.write(search_weight[itr][1])
break
itr += 1
else:
fun_logger.info("Wiele plików do zagrania")
search_weight.sort(key=lambda x: x[0], reverse=True)
return_list.extend(search_weight[: int(how_many)])
fun_logger.info("Done: %s", return_list)
if not return_to_bot:
with open(
"/home/pi/Conjurer/priority_queue.playlist", "r+", encoding="utf-8"
) as s_file:
s_file.truncate(0)
for item in return_list:
s_file.write(item[1] + "\n")
return return_list
@@ -237,35 +250,44 @@ def remove_characters(string, character):
return string.replace(character, "")
@app.route('/get_share_list', methods=['POST'])
def get_share_list():
_authorize_request()
data = request.get_json()
entries = data.get('entries')
keywords = data.get('keywords')
# Validate entries
if not isinstance(entries, int) or not (1 <= entries <= 10):
return jsonify({'error': '"entries" must be an integer between 1 and 10.'}), 400
# Validate keywords list
if not isinstance(keywords, list) or not all(isinstance(k, str) for k in keywords):
return jsonify({'error': '"keywords" must be a list of strings.'}), 400
# Call external search
files = media_search_functions.find_matches(entries, keywords)
# Return each file path as a JSON list
return jsonify({'files': files}), 200
@app.route("/stream", methods=["GET"])
def stream_music():
"""
The function `get_music_list` returns a JSON object containing a list of music files.
:return: A JSON response containing a key "music_file_list" with the value of the variable
`music_file_list`.
"""
return send_from_directory("/tmp/hls", "stream.m3u8")
@app.route('/get_share_links', methods=['POST'])
def get_share_links():
_authorize_request()
data = request.get_json()
file_paths = data.get('file_paths')
# Validate file_paths list
if not isinstance(file_paths, list) or not all(isinstance(p, str) for p in file_paths):
return jsonify({'error': '"file_paths" must be a list of strings.'}), 400
# Call external publish
links = media_search_functions.publish(file_paths)
# Return list of published links
return jsonify({'links': links}), 200
@app.route('/<string:file_name>')
def stream(file_name):
video_dir = '/tmp/hls'
return send_from_directory(video_dir, file_name)
@app.route("/stream_mp3", methods=["GET"])
def stream_music_mp3():
"""
The function `get_music_list` returns a JSON object containing a list of music files.
:return: A JSON response containing a key "music_file_list" with the value of the variable
`music_file_list`.
"""
return redirect("http://www.example.com", code=302)
@app.route("/clear_pr_pls", methods=["GET"])
def clear_pr_pls():
"""
The function `get_music_list` returns a JSON object containing a list of music files.
:return: A JSON response containing a key "music_file_list" with the value of the variable
`music_file_list`.
"""
with open(
"/home/pi/Conjurer/priority_queue.playlist", "w", encoding="utf-8"
) as s_file:
s_file.truncate(0)
return_data = jsonify(isError=False, message="Success", statusCode=200, data=[])
return return_data, 200
@@ -290,7 +312,6 @@ def update_music_list():
received and added to the `music_file_list`.
The status code returned is 200, indicating a successful response.
"""
_authorize_request()
record = json.loads(request.data)
app.logger.info(record["item"])
music_file_list.append(record["item"])
@@ -304,23 +325,44 @@ def update_music_list():
@app.route("/get_music", methods=["POST"])
def look_for_playlist():
"""
The function `look_for_playlist` receives a POST request with a JSON payload, logs the received
The function `update_music_list` receives a POST request with a JSON payload, logs the received
item, adds it to a music file list, and returns a success message along with the updated record.
:return: A tuple containing a JSON response and a status code. The JSON response includes keys `isError`,
`message`, `statusCode`, and `data`, with values indicating the success of the operation and the
data that was received and added to the `music_file_list`. The status code returned is 200,
indicating a successful response.
:return: The function `update_music_list` is returning a tuple containing a JSON response and a
status code. The JSON response includes keys `isError`, `message`, `statusCode`, and `data`,
with values indicating the success of the operation and the data that was
received and added to the `music_file_list`.
The status code returned is 200, indicating a successful response.
"""
_authorize_request()
record = json.loads(request.data)
app.logger.info(record)
app.logger.info(record["lista_slow"])
app.logger.info(record["UUID"])
app.logger.info(record["dlugosc_plejlisty"])
return_data = wyszukaj(
record["lista_slow"], record["dlugosc_plejlisty"], app.logger, True
return_data = wyszukaj(record["lista_slow"], record["dlugosc_plejlisty"], app.logger, True)
return_data = (
jsonify(isError=False, message="Success", statusCode=200, data=return_data),
200,
)
return return_data
@app.route("/add_to_priority", methods=["POST"])
def add_to_priority():
"""
The function `update_music_list` receives a POST request with a JSON payload, logs the received
item, adds it to a music file list, and returns a success message along with the updated record.
:return: The function `update_music_list` is returning a tuple containing a JSON response and a
status code. The JSON response includes keys `isError`, `message`, `statusCode`, and `data`,
with values indicating the success of the operation and the data that was
received and added to the `music_file_list`.
The status code returned is 200, indicating a successful response.
"""
record = json.loads(request.data)
app.logger.info(record)
app.logger.info(record["lista_slow"])
app.logger.info(record["UUID"])
app.logger.info(record["dlugosc_plejlisty"])
return_data = wyszukaj(record["lista_slow"], record["dlugosc_plejlisty"], app.logger, False)
return_data = (
jsonify(isError=False, message="Success", statusCode=200, data=return_data),
@@ -340,18 +382,8 @@ def flask_debug():
def waitress_run():
"""
The `waitress_run` function serves the `app` on host HOST_ADDRESS
The `waitress_run` function serves the `app` on host "0.0.0.0"
and port 5000 using the Waitress WSGI server.
This function starts the Waitress server and listens for incoming requests
on the specified host and port. It uses the `serve` function from the Waitress
library to handle the serving process.
Parameters:
None
Returns:
None
"""
serve(app, host=HOST_ADDRESS, port=PORT_ADDRESS)
@@ -375,15 +407,12 @@ if __name__ == "__main__":
rescan()
logger.info("Started")
threads = []
# threads.append(threading.Thread(target=flask_debug))
threads.append(threading.Thread(target=waitress_run, daemon=True))
threads.append(threading.Thread(target=thread_rescan, daemon=True))
#threads.append(threading.Thread(target=flask_debug))
threads.append(threading.Thread(target=waitress_run))
threads.append(threading.Thread(target=thread_rescan))
for worker in threads:
worker.start()
try:
for worker in threads:
worker.join()
except KeyboardInterrupt:
logger.info("Shutdown requested - exiting musician service")
for worker in threads:
worker.join()
@@ -1,74 +0,0 @@
#!/usr/bin/env python3
"""Share-list search/publish helpers for the musician service.
Paths are environment-overridable and the share database / directory are
accessed lazily, so importing this module has no side effects (the previous
version ran ``SHARE_DIR.mkdir()`` and read the JSON DB at import time, which
crashed on any host without the Pi's ``/var/www`` / ``/var/log`` layout — and
made the service untestable).
"""
import json
import os
import uuid
from pathlib import Path
# CONFIGURATION (env-overridable)
JSON_DB = os.getenv("CONJURER_SHARE_DB", "/var/log/share_scan.json")
SHARE_DIR = Path(os.getenv("CONJURER_SHARE_DIR", "/var/www/html/share"))
BASE_URL = os.getenv("CONJURER_SHARE_BASE_URL", "https://czernobog.pl/share")
_entries_cache = None
def _ensure_share_dir():
SHARE_DIR.mkdir(parents=True, exist_ok=True)
def load_db():
"""Load share entries, returning [] when the DB is missing/corrupt."""
try:
with open(JSON_DB) as handle:
return json.load(handle).get("entries", [])
except (FileNotFoundError, json.JSONDecodeError):
return []
def _entries():
global _entries_cache
if _entries_cache is None:
_entries_cache = load_db()
return _entries_cache
def relevancy(path, keywords):
score = 0
low = path.lower()
for kw in keywords:
if kw.lower() in low:
score += low.count(kw.lower())
return score
def find_matches(count, keywords):
scored = []
for entry in _entries():
score = relevancy(entry["path"], keywords)
if score > 0:
scored.append((score, entry["path"]))
scored.sort(reverse=True, key=lambda x: x[0])
result = [p for _, p in scored]
return result[:count]
def publish(paths):
_ensure_share_dir()
urls = []
for path in paths:
token = uuid.uuid4().hex
link = SHARE_DIR / token
try:
os.symlink(path, link)
except FileExistsError:
pass
urls.append(f"{BASE_URL}/{token}")
return urls
-98
View File
@@ -1,98 +0,0 @@
# This Python file uses the following encoding: utf-8
"""This module contains utility scripts used by musi radiostation Conjurer"""
import time
import re
import os
import argparse
from mutagen.easyid3 import EasyID3
from mutagen.mp3 import MP3
def update_metadata(folder_path):
for root, _, files in os.walk(folder_path):
for file in files:
if file.endswith(".mp3"):
file_path = os.path.join(root, file)
# Sample logic for guessing metadata from filename
# Assuming the filename format is "Artist - Title.mp3"
try:
artist, title = file.rsplit(" - ", 1)
title = title.replace(".mp3", "")
except ValueError:
# If file name doesn't fit the expected pattern, skip
print(f"Skipping file due to unexpected format: {file}")
continue
try:
# Load the mp3 file
audio = MP3(file_path, ID3=EasyID3)
# Update metadata
audio["artist"] = artist.strip()
audio["title"] = title.strip()
# Save changes
audio.save()
print(f"Updated metadata for: {file}")
except Exception as e:
print(f"Failed to update metadata for {file}: {e}")
NAME = "/home/pi/Conjurer/persistence.log"
def print_top():
"""
Prints the top 65 lines from a file specified by the NAME variable.
"""
while True:
line = ""
buffer = ""
with open(NAME, "r", encoding="utf-8") as f:
for _ in range(45):
line = f.readline()
if re.match(".*mp3", line):
buffer += line
print("=====================================")
print(buffer)
print("=====================================")
time.sleep(180)
def main():
parser = argparse.ArgumentParser(
description="Perform operations on a directory of .mp3 files."
)
# Positional argument for choosing the function to execute
parser.add_argument(
"operation",
type=str,
choices=["update_metadata", "print_top"],
help="Operation to perform: update_metadata or print_top.",
default="print_top",
)
# Positional argument for folder path
parser.add_argument("folder", type=str, help="Path to the folder to operate on.")
args = parser.parse_args()
if args.operation == "update_metadata":
if args.folder:
folder_to_scan = args.folder
else:
print("Please provide a folder path to scan.")
raise ValueError("No folder path provided.")
if args.operation == "update_metadata" and os.path.isdir(folder_to_scan):
update_metadata(folder_to_scan)
elif args.operation == "print_top":
print_top()
else:
print(f"The specified path is not a directory: {folder_to_scan}")
if __name__ == "__main__":
main()
-14
View File
@@ -1,14 +0,0 @@
$dodaj_do_ulubionych 100 nightwish
$dodaj_do_ulubionych 100 amon amarth
$dodaj_do_ulubionych 100 tactical sekt
$dodaj_do_ulubionych 100 lacuna coil
$dodaj_do_ulubionych 100 sefa
$dodaj_do_ulubionych 100 frenchcore
$dodaj_do_ulubionych 100 peacock
$dodaj_do_ulubionych 100 vavamuffin
$dodaj_do_ulubionych 100 abradab
$dodaj_do_ulubionych 100 eluveitie
$dodaj_do_ulubionych 100 youtube
$dodaj_do_ulubionych 100 elyose
$dodaj_do_ulubionych 1 artisans du chaos
@@ -1,9 +0,0 @@
/home/pi/Conjurer/radio_log.log {
daily
rotate 7
compress
delaycompress
missingok
notifempty
copytruncate
}
+30 -173
View File
@@ -1,191 +1,48 @@
# Radio Conjurer - Liquidsoap script (containerised paths: /srv/betoniarka/*)
# This script sets up a Liquidsoap radio stream with various features and configurations.
# Load icecast credentials from a JSON file
let json.parse credentials = file.contents("/srv/betoniarka/secrets/icecast_credentials.json")
# Enable replaygain metadata processing
let json.parse credentials = file.contents("/home/pi/Conjurer/icecast_credentials.json")
enable_replaygain_metadata()
# Set up a playlog for tracking played tracks
s1 = replaygain(playlist(reload_mode="watch", "/home/pi/Conjurer/all_playlist.playlist"))
s2 = replaygain(playlist(reload_mode="watch", "/home/pi/Conjurer/priority_queue.playlist"))
l = playlog(duration = 72000.0, persistency="/srv/betoniarka/data/persistence.log")
s = random(id="randomizer", weights=[7000, 100], [s2, s1])
# Function to check if a track can be played based on its metadata
def check(r)
m = request.metadata(r)
if l.last(m) < 36000. then
log.info("Rejecting #{m['filename']} (played #{l.last(m)}s ago).")
false
else
l.add(m)
true
end
end
# Define playlists to be used in the stream
s1 = replaygain(playlist(reload_mode="watch", check_next=check, "/srv/betoniarka/data/all_playlist.playlist"))
s2 = replaygain(playlist(reload_mode="watch", check_next=check, "/srv/betoniarka/data/priority_queue.playlist"))
s3 = replaygain(playlist(reload_mode="watch", check_next=check, "/srv/betoniarka/data/hit.playlist"))
# Create a request queue for user-generated requests
requests_queue = request.queue()
# Function to process the request queue and add new requests
def queue_processing()
text=file.lines("/srv/betoniarka/data/request.playlist")
if text != [] then
list.iter(fun(item) -> requests_queue.push.uri(item), text)
file.remove("/srv/betoniarka/data/request.playlist")
f = file.open("/srv/betoniarka/data/request.playlist", create=true)
f.close()
end
end
# Configure output formats and destinations
def now_playing(m)
# raw lookup ("" if missing)
raw_title = m["title"]
raw_artist = m["artist"]
# default if empty
title = if raw_title == "" then "Unknown" else raw_title end
artist = if raw_artist == "" then "Unknown" else raw_artist end
log.critical("Now playing #{artist} - #{title}")
# write it out
system("echo '#{artist} - #{title}' > /tmp/now_playing.txt")
m
end
# Randomly select a playlist with weights
s4 = random(id="randomizer", weights=[2, 3, 5], [s1, s2, s3])
# Load jingles playlist
jingles = (playlist(reload_mode="watch", "/srv/betoniarka/data/jingles.playlist"))
# Create the main stream with random playlist and jingles
s = rotate(id="randomizer", weights=[10, 1], [s4, jingles])
s = crossfade(fade_out=2., fade_in=2., duration=4., fallback(id="switcher", track_sensitive=true, [requests_queue, s]))
s = metadata.map(now_playing, s)
# Set up an interactive harbor for controlling the stream
interactive.harbor(port = 9999)
# Set up interactive controls for bass boost
f = interactive.float("f", description="Frequency", min=0., max=1000., unit="Hz", 200.)
g = interactive.float("g", description="Gain", min=0., max=20., unit="dB", 8.)
f = interactive.float("f", description="Frequency", min=0., max=1000.,unit="Hz", 200.)
g = interactive.float("g", description="Gain", min=0., max=20.,unit="dB", 8.)
b = bass_boost(frequency=f, gain=g, s)
s = add([s, b])
a = interactive.float("main_volume", min=0., max=20., 1.)
s = compress.multiband.interactive(bands=3, s)
# Set up interactive control for main volume
a = interactive.float("main_volume", min=0., max=20., 1.1)
s = compress.multiband.interactive(bands=7, s)
s = nrj(normalize (s))
s = crossfade(fade_out=3., fade_in=3., duration=5., s)
s = amplify(a,s)
mic_gain = interactive.float("mic_volume", min=0., max=120., 0.5)
s = blank.skip(max_blank=2., s)
# Apply audio processing effects
tmic = buffer(input.pulseaudio()) # Microphone
mic = amplify(mic_gain, tmic)
mic = gate(threshold=-80., range=-120., mic)
mic = compress(threshold=0., ratio=2.,mic)
mic = nrj(normalize(mic))
mic = blank.strip(max_blank=15., min_noise=.1, threshold=-30., mic)
mic = fallback(id="switcher2", track_sensitive=false, [mic, blank()])
emergency = single("/home/pi/RetroPie/mp3/Youtube/Dr. Peacock - Trip to Ireland [GvrvQTUbUcA].mp3")
radio = fallback(id="switcher",track_sensitive = false, [s, emergency])
# Apply audio processing effects
s = nrj(s)
s = amplify(a, s)
# Skip blank sections in the stream
s = blank.skip(max_blank=10., s)
handle_metadata = fun (m) -> begin
print(m["title"])
print(m["artist"])
print(m["filename"])
end
radio.on_metadata(handle_metadata)
interactive.persistent("script.params")
#Manual audition override
live_enabled = interactive.bool("Going Live!", true)
s = add([mic,s])
s=switch(track_sensitive=true,
[(live_enabled, mic),
({true}, s)])
# Configure logging settings
log_to_stdout = true
log_to_file = true
logpath = "/srv/betoniarka/data/radio_log.log"
logpath = "/home/pi/Conjurer/radio_log.log"
loglevel = 3
set("log.stdout", log_to_stdout)
set("log.level", loglevel)
# Enable logging to file
set("log.file", log_to_file)
set("log.file.path", logpath)
# Set up emergency fallback track
emergency = single("/srv/betoniarka/music/Youtube/Dr. Peacock - Trip to Ireland [GvrvQTUbUcA].mp3")
radio = fallback(id="switcher2", track_sensitive=false, [s, emergency])
# Enable logging on Standard Output and set logging level.
set("log.stdout",log_to_stdout)
set("log.level",loglevel)
# Set up an interactive control for skipping tracks
p = interactive.bool("Skip track", false)
def http_skip(~protocol, ~data, ~headers, uri)=
radio.skip()
http.response(code=200,data="Skipped")
end
harbor.http.register(port=54321,method="GET","/skip", http_skip)
# Enable logging to file. See variable: logpath
set("log.file",log_to_file)
set("log.file.path",logpath)
# Function to process the request queue and skip the current track if requested
def check_skip()
if p() then
log.info("Skipping current track.")
radio.skip()
p.set(false)
end
end
# Run the queue processing function every 60 seconds
thread.run(every=60., queue_processing)
# Run the check_skip function every 5 seconds
thread.run(every=15., check_skip)
# Enable persistent script parameters
interactive.persistent("/srv/betoniarka/data/script.params")
# Configure output formats and destinations
output.icecast(%mp3, host="localhost", port=8000, password=credentials.password, icy_metadata="true", mount="mp3-stream", radio)
output.pulseaudio(radio)
#output.file.hls("/tmp/hls", [("mp3-low", %mp3(bitrate=96)), ("mp3-hi", %mp3(bitrate=160))], radio)
# Uncomment the following lines to enable additional output formats
# output.icecast(%opus, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="opus-stream", radio)
# output.icecast(%ogg, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="ogg-stream", radio)
# output.icecast(%aac, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="aac-stream", radio)
# output.icecast(%flac, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="flac-stream", radio)
# output.icecast(%vorbis, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="vorbis-stream", radio)
# output.icecast(%speex, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="speex-stream", radio)
# output.icecast(%wav, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="wav-stream", radio)
# output.icecast(%pcm, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="pcm-stream", radio)
# output.icecast(%raw, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="raw-stream", radio)
# output.icecast(%s16l, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s16l-stream", radio)
# output.icecast(%s16b, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s16b-stream", radio)
# output.icecast(%s24l, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s24l-stream", radio)
# output.icecast(%s24b, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s24b-stream", radio)
# output.icecast(%s32l, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s32l-stream", radio)
# output.icecast(%s32b, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s32b-stream", radio)
# output.icecast(%s64l, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s64l-stream", radio)
# output.icecast(%s64b, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s64b-stream", radio)
# output.icecast(%s128l, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s128l-stream", radio)
# output.icecast(%s128b, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s128b-stream", radio)
# output.icecast(%s256l, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s256l-stream", radio)
# output.icecast(%s256b, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s256b-stream", radio)
# output.icecast(%s512l, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s512l-stream", radio)
# output.icecast(%s512b, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s512b-stream", radio)
# output.icecast(%s1024l, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s1024l-stream", radio)
# output.icecast(%s1024b, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s1024b-stream", radio)
# output.icecast(%s2048l, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s2048l-stream", radio)
# output.icecast(%s2048b, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s2048b-stream", radio)
# output.icecast(%s4096l, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s4096l-stream", radio)
# output.icecast(%s4096b, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s4096b-stream", radio)
# output.icecast(%s8192l, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s8192l-stream", radio)
# output.icecast(%s8192b, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="s8192b-stream", radio)
output.file.hls("/tmp/hls",[("mp3-low", %mp3(bitrate=96)), ("mp3-hi", %mp3(bitrate=160))],radio)
output.icecast(%mp3, host="retropie", port=8000,password=credentials.password, icy_metadata="true", mount="mp3-stream", radio)
+1 -3
View File
@@ -1,4 +1,2 @@
flask
waitress
sshtunnel
requests
waitress
-99
View File
@@ -1,99 +0,0 @@
#!/usr/bin/env python3
import os
import re
import time
import json
import argparse
import sys
from pathlib import Path
# — CONFIGURATION — adjust as needed —
APACHE_LOG = '/var/log/apache2/share_access.log' # <- point to your share-specific log!
SHARE_DIR = Path('/var/www/html/share')
METADATA_FILE = SHARE_DIR / '.downloads.json'
STATE_FILE = SHARE_DIR / '.logpos'
# Regex to match lines like: "GET /share/abcdef1234... HTTP/1.1"
TOKEN_REGEX = re.compile(r'"\s*GET\s+/share/([0-9a-f]{32})\s+HTTP/')
def debug(msg, args):
if args.debug:
print(f"[DEBUG] {msg}", file=sys.stderr)
def load_metadata(args):
if METADATA_FILE.exists():
data = json.loads(METADATA_FILE.read_text())
debug(f"Loaded metadata: {data}", args)
return data
return {}
def save_metadata(md, args):
METADATA_FILE.write_text(json.dumps(md))
debug(f"Saved metadata: {md}", args)
def load_state(args):
if STATE_FILE.exists():
pos = int(STATE_FILE.read_text())
debug(f"Loaded last log position: {pos}", args)
return pos
return 0
def save_state(pos, args):
STATE_FILE.write_text(str(pos))
debug(f"Saved new log position: {pos}", args)
def scan_log(since_pos, args):
try:
with open(APACHE_LOG, 'r') as f:
f.seek(since_pos)
lines = f.readlines()
newpos = f.tell()
debug(f"Read {len(lines)} new lines", args)
return lines, newpos
except Exception as e:
print(f"Error reading log: {e}", file=sys.stderr)
return [], since_pos
def record_downloads(lines, metadata, args):
now = time.time()
for line in lines:
m = TOKEN_REGEX.search(line)
if m:
tok = m.group(1)
if tok not in metadata:
metadata[tok] = now
debug(f"Recorded download of token {tok} at {now}", args)
return metadata
def revoke_old(metadata, args):
now = time.time()
to_remove = [tok for tok, ts in metadata.items() if now - ts >= 3600]
for tok in to_remove:
link = SHARE_DIR / tok
try:
link.unlink()
debug(f"Unlinked token {tok}", args)
except FileNotFoundError:
debug(f"Link {link} not found when revoking", args)
metadata.pop(tok, None)
return bool(to_remove)
def main():
p = argparse.ArgumentParser(description="Revoke share links 2min after download")
p.add_argument('--debug', action='store_true', help='print debug info to stderr')
args = p.parse_args()
SHARE_DIR.mkdir(parents=True, exist_ok=True)
metadata = load_metadata(args)
state = load_state(args)
lines, newpos = scan_log(state, args)
metadata = record_downloads(lines, metadata, args)
save_metadata(metadata, args) # always persist new downloads
if revoke_old(metadata, args):
save_metadata(metadata, args) # persist removals
save_state(newpos, args)
if __name__ == '__main__':
main()
-44
View File
@@ -1,44 +0,0 @@
#!/usr/bin/env python3
import os
import json
import time
from pathlib import Path
# Configuration
SCAN_PATH = '/mnt/shares'
OUTPUT_FILE = '/var/log/share_scan.json'
def scan_directory(root):
"""Recursively walk `root` and collect metadata."""
entries = []
for dirpath, dirs, files in os.walk(root):
for name in dirs + files:
full = os.path.join(dirpath, name)
try:
stat = os.stat(full)
entries.append({
'path': full,
'is_dir': os.path.isdir(full),
'size_bytes': stat.st_size,
'mtime': time.strftime('%Y-%m-%dT%H:%M:%S%z',
time.localtime(stat.st_mtime)),
})
except Exception as e:
# skip items we can't stat
continue
return entries
def main():
data = {
'scanned_at': time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime()),
'root': SCAN_PATH,
'entries': scan_directory(SCAN_PATH),
}
# Write atomically
temp = OUTPUT_FILE + '.tmp'
with open(temp, 'w') as f:
json.dump(data, f, indent=2)
os.replace(temp, OUTPUT_FILE)
if __name__ == '__main__':
main()
-6
View File
@@ -1,6 +0,0 @@
[Unit]
Description=Oneshot scan of /mnt/shares → JSON
[Service]
Type=oneshot
ExecStart=/usr/local/bin/scan_shares.py
-11
View File
@@ -1,11 +0,0 @@
[Unit]
Description=Hourly timer for sharescanner
[Timer]
# delay 2min after boot, then every hour
OnBootSec=20min
OnUnitActiveSec=24h
Unit=scan_shares.service
[Install]
WantedBy=timers.target
-195
View File
@@ -1,195 +0,0 @@
{
"string": [],
"bool": [ [ "Skip track", false ], [ "Going Live!", false ] ],
"int": [],
"float": [
[
"mic_volume",
0.0
],
[
"compress_gain6",
0.1
],
[
"compress_ratio6",
7.9
],
[
"compress_threshold6",
-15.8
],
[
"compress_release6",
30.0
],
[
"compress_attack6",
30.0
],
[
"compress_frequency6",
18610.0
],
[
"compress_gain5",
5.4
],
[
"compress_ratio5",
4.8
],
[
"compress_threshold5",
-13.6
],
[
"compress_release5",
120.0
],
[
"compress_attack5",
160.0
],
[
"compress_frequency5",
13950.0
],
[
"compress_gain4",
6.5
],
[
"compress_ratio4",
4.0
],
[
"compress_threshold4",
-12.2
],
[
"compress_release4",
130.0
],
[
"compress_attack4",
200.0
],
[
"compress_frequency4",
3890.0
],
[
"compress_gain3",
5.5
],
[
"compress_ratio3",
3.5
],
[
"compress_threshold3",
-11.7
],
[
"compress_release3",
140.0
],
[
"compress_attack3",
250.0
],
[
"compress_frequency3",
2270.0
],
[
"compress_gain2",
3.3
],
[
"compress_ratio2",
3.9
],
[
"compress_threshold2",
-11.5
],
[
"compress_release2",
120.0
],
[
"compress_attack2",
250.0
],
[
"compress_frequency2",
1920.0
],
[
"compress_gain1",
5.6
],
[
"compress_ratio1",
3.4
],
[
"compress_threshold1",
-12.2
],
[
"compress_release1",
120.0
],
[
"compress_attack1",
220.0
],
[
"compress_frequency1",
650.0
],
[
"compress_gain0",
7.5
],
[
"compress_ratio0",
3.2
],
[
"compress_threshold0",
-13.1
],
[
"compress_release0",
110.0
],
[
"compress_attack0",
140.0
],
[
"compress_frequency0",
110.0
],
[
"compress_wet",
1.0
],
[
"main_volume",
1.8
],
[
"g",
1.0
],
[
"f",
106.4
]
]
}
-93
View File
@@ -1,93 +0,0 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
import uuid
from pathlib import Path
# CONFIGURATION
JSON_DB = '/var/log/share_scan.json'
SHARE_DIR = Path('/var/www/html/share')
BASE_URL = 'https://czernobog.pl/share'
# Ensure share directory exists
SHARE_DIR.mkdir(parents=True, exist_ok=True)
def load_db():
with open(JSON_DB) as f:
return json.load(f)['entries']
def relevancy(path, keywords):
score = 0
low = path.lower()
for kw in keywords:
if kw.lower() in low:
score += low.count(kw.lower())
return score
def find_matches(entries, keywords):
scored = []
for e in entries:
score = relevancy(e['path'], keywords)
if score > 0:
scored.append((score, e['path']))
scored.sort(reverse=True, key=lambda x: x[0])
return [p for _, p in scored]
def interactive_select(candidates):
print("Search results:")
for idx, p in enumerate(candidates, 1):
print(f" {idx:3d}. {p}")
sel = input("\nSelect files (e.g. 1,3-5): ").strip()
nums = set()
for part in sel.split(','):
if '-' in part:
a,b = part.split('-',1)
nums.update(range(int(a), int(b)+1))
else:
nums.add(int(part))
return [candidates[i-1] for i in sorted(nums) if 1 <= i <= len(candidates)]
def publish(paths):
urls = []
for path in paths:
token = uuid.uuid4().hex
link = SHARE_DIR / token
try:
os.symlink(path, link)
except FileExistsError:
pass
urls.append(f"{BASE_URL}/{token}")
return urls
def main():
parser = argparse.ArgumentParser(description="Search and share files from JSON DB")
parser.add_argument('keywords', nargs='*',
help='Search keywords (interactive if omitted)')
parser.add_argument('-n','--count', type=int, default=10,
help='How many top results to share')
args = parser.parse_args()
if not args.keywords:
# Interactive: ask for query
q = input("Enter search keywords (space-separated): ").strip().split()
args.keywords = q
entries = load_db()
matches = find_matches(entries, args.keywords)
top_n = matches[:args.count]
if sys.stdin.isatty():
# interactive selection
sel = interactive_select(top_n)
else:
# non-interactive: share all top_n
sel = top_n
urls = publish(sel)
for u in urls:
print(u)
if __name__=='__main__':
main()
-9
View File
@@ -1,9 +0,0 @@
<html>
<head>
<title>HTTP Live Streaming Example</title>
</head>
<body>
<video src="/tmp/hls/stream.m3u8" height="300" width="400">
</video>
</body>
</html>
-23
View File
@@ -1,23 +0,0 @@
tmic = buffer(input.pulseaudio()) # Microphone
if tmic.is_active() then
log.info("Buffer active")
end
if tmic.is_ready() then
log.info("Buffer ready")
end
if tmic.is_up() then
log.info("Buffer up")
end
interactive.harbor(port = 9999)
mic_gain = interactive.float("mic_volume", min=0., max=20., 6.)
mic = amplify(mic_gain, tmic)
#mic = gate(threshold=-80., range=-120., mic)
#mic = compress(threshold=0., ratio=2.,mic)
#mic = blank.strip(max_blank=10., min_noise=.1, threshold=-20., mic)
output.pulseaudio(mic)
-495
View File
@@ -1,495 +0,0 @@
"""Centralised configuration and runtime constants for Conjurer.
Historically this module performed heavy filesystem and credential reads at
import time which made the bot brittle on hosts that did not mirror the
original paths. This version keeps the original platform defaults (so the
behaviour on the Raspberry Pi / WSL / Windows deployments is unchanged when no
environment variables are set) but adds three robustness improvements ported
from the dockerised experiment:
* every path/endpoint can be overridden via an environment variable,
* JSON state files are loaded defensively (a missing or corrupt file no longer
crashes the whole bot at import time),
* optional dependencies (openai, spotipy, netrc) and credentials are guarded so
the bot can still start when a secondary integration is offline, and
* a shared ``CONJURER_API_KEY`` plus ``service_headers()`` helper enables
authenticated internal HTTP calls.
All path constants intentionally remain plain ``str`` (with their original
trailing separators) to stay byte-for-byte compatible with the existing string
concatenation in the command modules.
"""
import json
import logging
import os
from datetime import datetime
from platform import uname
from sys import platform
from typing import List, Optional, TypedDict
try:
import netrc
except ImportError: # pragma: no cover - standard on CPython
netrc = None
try:
import openai
except ImportError: # pragma: no cover - optional at runtime
openai = None
try:
import anthropic
except ImportError: # pragma: no cover - optional at runtime
anthropic = None
try:
import spotipy
from spotipy.oauth2 import SpotifyClientCredentials
except ImportError: # pragma: no cover - optional component
spotipy = None
SpotifyClientCredentials = None
logger = logging.getLogger("discord")
Music_Config = TypedDict(
"Music_Config",
{
"ctx": Optional[str],
"queue": List[str],
"requester": List[str],
},
)
# *=========================================== Predefines
MASTER_TIMEOUT = datetime.now()
INITIAL_TIME_WAIT = 500
MUZYKA: Music_Config = {"ctx": None, "queue": [], "requester": []}
LOGFILE = ""
NETRC_FILE = ""
MUSIC_FOLDER = ""
MEMORY_FIVE_SIARA = ""
MEMORY_FIVE_MUZYKA = ""
SETTINGS_FILE = ""
ENCODING = "utf-8"
GRAPHICS_PATH = ""
MUZYKA_MOJEGO_LUDU_HISTORIA = 1500
MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE = 15
MUZYKA_MOJEGO_LUDU_PLAJLISTA = 30
GET_MP3 = "/mp3"
SEND_MP3 = "/update_mp3"
GET_PLAYLIST = "/get_music"
ADD_TO_PRIO_PLAYLIST = "/add_to_priority"
CREATE_PRIO_PLAYLIST = "/create_priority_playlist"
REQUEST_MUSIC = "/request_radio_file"
CLEAR_PRIO = "/clear_pr_pls"
SEND_QUERY = "/query"
TIME_BETWEEN_CALLS = 100000
LAST_SPONTANEOUS_CALL = datetime.now()
# *=========================================== Platform Specific Defaults
# These blocks only establish *default* values. Every constant is overridable
# through the matching environment variable further below.
if platform in ("linux", "linux2"):
SEPARATOR_FILE_PATH = "/"
if "microsoft-standard" in uname().release:
LOGFILE = "/home/mtuszowski/conjurer/discord.log"
MEMORY_FIVE_SIARA = "/home/mtuszowski/conjurer/pamiec.json"
SYSTEM_GPT_SETTINGS = "/home/mtuszowski/conjurer/system_gpt_settings.json"
MEMORY_FIVE_MUZYKA = "/home/mtuszowski/conjurer/pamiec_muzyki.json"
MUSIC_FOLDER = "/mnt/g/Muzyka/"
SETTINGS_FILE = "/home/mtuszowski/conjurer/settings.json"
NETRC_FILE = "/home/mtuszowski/.netrc"
LOGSTORE = "/home/mtuszowski/conjurer/logs/"
ACCIDENT_LOG = "/home/mtuszowski/conjurer/accident_log.json"
ENCODING = "utf-8"
GRAPHICS_PATH = "/home/mtuszowski/conjurer/Conjurer_graphics/"
DIR_PATH_SADOX = "/mnt/c/Users/mtusz/OneDrive/Dokumenty/Fansadox"
else:
LOGFILE = "./discord.log"
MEMORY_FIVE_SIARA = "./pamiec.json"
SYSTEM_GPT_SETTINGS = "./system_gpt_settings.json"
MEMORY_FIVE_MUZYKA = "./pamiec_muzyki.json"
MUSIC_FOLDER = "./"
SETTINGS_FILE = "./settings.json"
NETRC_FILE = "/srv/conjurer/secrets/.netrc"
LOGSTORE = "./logs/"
ACCIDENT_LOG = "./accident_log.json"
ENCODING = "utf-8"
GRAPHICS_PATH = "./Conjurer_graphics/"
DIR_PATH_SADOX = "./Fansadox/"
elif platform == "win32":
LOGFILE = "discord.log"
MEMORY_FIVE_SIARA = "pamiec.json"
SYSTEM_GPT_SETTINGS = "system_gpt_settings.json"
MEMORY_FIVE_MUZYKA = "pamiec_muzyki.json"
MUSIC_FOLDER = "G:\\Muzyka\\"
SETTINGS_FILE = "settings.json"
NETRC_FILE = "C:\\Users\\mtusz\\.netrc"
LOGSTORE = "C:\\Users\\mtusz\\OneDrive\\Pulpit\\Conjurer\\logs\\"
ACCIDENT_LOG = "accident_log.json"
ENCODING = "utf-8"
DIR_PATH_SADOX = "C:\\Users\\mtusz\\OneDrive\\Dokumenty\\Fansadox\\"
SEPARATOR_FILE_PATH = "\\"
else:
# Fallback for development hosts (macOS, BSD, …) that match none of the
# production platforms. Everything is rooted next to this file so the
# module can at least be imported and unit-tested off-deployment.
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
SEPARATOR_FILE_PATH = os.sep
LOGFILE = os.path.join(_BASE_DIR, "discord.log")
MEMORY_FIVE_SIARA = os.path.join(_BASE_DIR, "pamiec.json")
SYSTEM_GPT_SETTINGS = os.path.join(_BASE_DIR, "system_gpt_settings.json")
MEMORY_FIVE_MUZYKA = os.path.join(_BASE_DIR, "pamiec_muzyki.json")
MUSIC_FOLDER = os.path.join(_BASE_DIR, "music") + os.sep
SETTINGS_FILE = os.path.join(_BASE_DIR, "settings.json")
NETRC_FILE = os.path.join(os.path.expanduser("~"), ".netrc")
LOGSTORE = os.path.join(_BASE_DIR, "logs") + os.sep
ACCIDENT_LOG = os.path.join(_BASE_DIR, "accident_log.json")
GRAPHICS_PATH = os.path.join(_BASE_DIR, "Conjurer_graphics") + os.sep
DIR_PATH_SADOX = os.path.join(_BASE_DIR, "Fansadox") + os.sep
# *=========================================== Environment overrides
# Values stay as plain strings so existing ``PATH + filename`` concatenation in
# the command modules keeps working unchanged.
# Container-friendly shortcut: point CONJURER_DATA_DIR at a single mounted
# volume and every writable data file/dir is rooted under it. The per-variable
# CONJURER_* overrides below still take precedence, so granular control remains
# possible and the native (Pi) deployment is unaffected when it is unset.
_DATA_DIR = os.getenv("CONJURER_DATA_DIR")
if _DATA_DIR:
LOGFILE = os.path.join(_DATA_DIR, "discord.log")
SETTINGS_FILE = os.path.join(_DATA_DIR, "settings.json")
MEMORY_FIVE_SIARA = os.path.join(_DATA_DIR, "pamiec.json")
MEMORY_FIVE_MUZYKA = os.path.join(_DATA_DIR, "pamiec_muzyki.json")
SYSTEM_GPT_SETTINGS = os.path.join(_DATA_DIR, "system_gpt_settings.json")
ACCIDENT_LOG = os.path.join(_DATA_DIR, "accident_log.json")
LOGSTORE = os.path.join(_DATA_DIR, "logs") + os.sep
GRAPHICS_PATH = os.path.join(_DATA_DIR, "Conjurer_graphics") + os.sep
MUSIC_FOLDER = os.path.join(_DATA_DIR, "music") + os.sep
DIR_PATH_SADOX = os.path.join(_DATA_DIR, "Fansadox") + os.sep
LOGFILE = os.getenv("CONJURER_LOG_FILE", LOGFILE)
NETRC_FILE = os.getenv("CONJURER_NETRC_FILE", NETRC_FILE)
SETTINGS_FILE = os.getenv("CONJURER_SETTINGS_FILE", SETTINGS_FILE)
MEMORY_FIVE_SIARA = os.getenv("CONJURER_MEMORY_FILE", MEMORY_FIVE_SIARA)
MEMORY_FIVE_MUZYKA = os.getenv("CONJURER_MUSIC_MEMORY_FILE", MEMORY_FIVE_MUZYKA)
SYSTEM_GPT_SETTINGS = os.getenv("CONJURER_SYSTEM_GPT_SETTINGS", SYSTEM_GPT_SETTINGS)
GRAPHICS_PATH = os.getenv("CONJURER_GRAPHICS_PATH", GRAPHICS_PATH)
MUSIC_FOLDER = os.getenv("CONJURER_MUSIC_FOLDER", MUSIC_FOLDER)
LOGSTORE = os.getenv("CONJURER_LOGSTORE", LOGSTORE)
ACCIDENT_LOG = os.getenv("CONJURER_ACCIDENT_LOG", ACCIDENT_LOG)
DIR_PATH_SADOX = os.getenv("CONJURER_SADOX_DIR", DIR_PATH_SADOX)
ENCODING = os.getenv("CONJURER_ENCODING", ENCODING)
SEPARATOR_FILE_PATH = os.getenv("CONJURER_PATH_SEPARATOR", SEPARATOR_FILE_PATH)
# Voice-recognition transcript dumps. Defaults next to the log file (so on the
# Pi it lands in /home/pi/Conjurer/transcripts/, in docker under /data).
TRANSCRIPTS_PATH = os.getenv(
"CONJURER_TRANSCRIPTS_PATH",
os.path.join(os.path.dirname(LOGFILE) or ".", "transcripts") + os.sep,
)
FILE_SERVICE_ADDRESS = os.getenv("CONJURER_FILE_SERVICE", "http://192.168.1.15:5000")
RADIO_HARBOR_ADDRESS = os.getenv("CONJURER_RADIO_HARBOR", "http://192.168.1.15:54321")
# Betoniarka (radio-operator service colocated with Liquidsoap). Falls back to
# the musician address so deployments that have not split yet keep working.
RADIO_SERVICE_ADDRESS = os.getenv("CONJURER_RADIO_SERVICE", FILE_SERVICE_ADDRESS)
SKIP_TRACK = os.getenv("CONJURER_SKIP_ENDPOINT", "/skip")
LIBRARIAN_SERVICE_ADDRESS = os.getenv(
"CONJURER_LIBRARIAN_SERVICE", "http://192.168.1.192:5001"
)
HOST_ADDRESS = os.getenv("CONJURER_DISCORD_HOST", "192.168.1.191")
PORT_ADDRESS = int(os.getenv("CONJURER_DISCORD_PORT", "5000"))
# Shared secret for authenticating internal service-to-service HTTP calls.
API_SHARED_KEY = os.getenv("CONJURER_API_KEY", "")
# *=========================================== Self-healing runtime layout
# A fresh host/volume must never kill the bot at import time. Missing
# directories are created and missing state files are seeded - first from the
# templates shipped alongside this file (repo checkout / docker image), then
# from a safe empty structure. Existing files are never touched, so preserved
# history always wins.
_TEMPLATE_DIR = os.path.dirname(os.path.abspath(__file__))
def _ensure_dir(path: str) -> None:
if not path:
return
try:
os.makedirs(path, exist_ok=True)
except OSError as exc:
logger.warning("Cannot create directory %s: %s", path, exc)
def _seed_file(path: str, template_name: str, empty_content: str) -> None:
"""Create *path* from the repo template (or *empty_content*) if missing."""
if not path or os.path.exists(path):
return
_ensure_dir(os.path.dirname(path) or ".")
template = os.path.join(_TEMPLATE_DIR, template_name)
try:
if os.path.exists(template) and os.path.abspath(template) != os.path.abspath(path):
import shutil
shutil.copyfile(template, path)
logger.warning("Seeded missing %s from repo template", path)
else:
with open(path, "w", encoding=ENCODING) as handle:
handle.write(empty_content)
logger.warning("Created missing %s as empty state", path)
except OSError as exc:
logger.warning("Cannot seed %s: %s", path, exc)
def _ensure_runtime_layout() -> None:
for directory in (
os.path.dirname(LOGFILE) or ".",
LOGSTORE,
GRAPHICS_PATH,
MUSIC_FOLDER,
TRANSCRIPTS_PATH,
):
_ensure_dir(directory)
# (target path, template shipped next to this file, empty fallback)
_seed_file(SETTINGS_FILE, "settings.json", "{}")
_seed_file(SYSTEM_GPT_SETTINGS, "system_gpt_settings.json", "{}")
_seed_file(MEMORY_FIVE_SIARA, "pamiec.json", "[]")
_seed_file(MEMORY_FIVE_MUZYKA, "pamiec_muzyki.json", "[]")
_seed_file(ACCIDENT_LOG, "accident_log.json", "[]")
_ensure_runtime_layout()
# *=========================================== Defensive state loading
def _load_json(path: str, fallback):
"""Load JSON from *path*, falling back gracefully on missing/corrupt files."""
try:
with open(path, "r", encoding=ENCODING) as handle:
return json.load(handle)
except FileNotFoundError:
logger.warning("Missing JSON file at %s - using fallback", path)
return fallback
except json.JSONDecodeError:
logger.warning("Corrupt JSON at %s - resetting to fallback", path)
return fallback
DATA = _load_json(SETTINGS_FILE, {})
WORD_REACTIONS = DATA.get("word_reactions", {})
CYCLIC_WORDS = DATA.get("cyclic_words", {})
for key in WORD_REACTIONS:
if isinstance(WORD_REACTIONS[key], list) and len(WORD_REACTIONS[key]) >= 3:
WORD_REACTIONS[key][2] = datetime.now()
MESSAGE_TABLE = _load_json(MEMORY_FIVE_SIARA, [])
GPT_SETTINGS = _load_json(SYSTEM_GPT_SETTINGS, {})
MESSAGE_TABLE_MUZYKA = _load_json(MEMORY_FIVE_MUZYKA, [])
SPECJALNE_ZIEMNIACZKI = GPT_SETTINGS[1] if isinstance(GPT_SETTINGS, list) else {}
ASSISTANTS = {}
# *=========================================== Credentials
def _load_netrc_credentials(host: str):
"""Return the netrc authenticators tuple for *host* or ``None``."""
if netrc is None:
return None
try:
parsed = netrc.netrc(NETRC_FILE)
except FileNotFoundError:
logger.warning("netrc file %s not found", NETRC_FILE)
return None
except netrc.NetrcParseError:
logger.warning("netrc file %s is invalid", NETRC_FILE)
return None
return parsed.authenticators(host)
def _resolve_token(host: str, env_var: str) -> Optional[str]:
"""Prefer an environment variable, then fall back to netrc."""
env_value = os.getenv(env_var)
if env_value:
return env_value
creds = _load_netrc_credentials(host)
if creds:
return creds[2]
logger.warning("Token for %s not configured", host)
return None
OPENAI_API_KEY = _resolve_token("openai", "OPENAI_API_KEY")
if openai and OPENAI_API_KEY:
openai.api_key = OPENAI_API_KEY
OPENAICLIENT = openai.AsyncOpenAI(api_key=OPENAI_API_KEY)
else:
OPENAICLIENT = None
# Claude / Anthropic client, wired analogously to OpenAI above so the AI cog can
# be pointed at either backend with a single config switch (see AI_CONFIGS and
# ai_functions.set_active_ai_config). netrc machine name 'anthropic' works too.
ANTHROPIC_API_KEY = _resolve_token("anthropic", "ANTHROPIC_API_KEY")
if anthropic and ANTHROPIC_API_KEY:
CLAUDECLIENT = anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
else:
CLAUDECLIENT = None
TOKEN = _resolve_token("discord", "DISCORD_TOKEN")
# Voice recognition (AssemblyAI). None = the voice cog reports and disables.
ASSEMBLYAI_API_KEY = _resolve_token("assemblyai", "ASSEMBLYAI_API_KEY")
if spotipy:
_spotify_creds = _load_netrc_credentials("spotipy")
if _spotify_creds and SpotifyClientCredentials:
SPOTIFY_CTRL = spotipy.Spotify(
client_credentials_manager=SpotifyClientCredentials(
client_id=_spotify_creds[0],
client_secret=_spotify_creds[2],
)
)
else:
SPOTIFY_CTRL = None
else:
SPOTIFY_CTRL = None
_youtube_creds = _load_netrc_credentials("youtube")
if _youtube_creds:
YOUTUBE_AUTH = [_youtube_creds[0], _youtube_creds[2]]
else:
YOUTUBE_AUTH = [
os.getenv("YOUTUBE_USERNAME", ""),
os.getenv("YOUTUBE_PASSWORD", ""),
]
def service_headers():
"""Shared header dict for internal service-to-service HTTP calls.
Returns an empty dict when no key is configured, keeping calls backward
compatible with deployments that do not (yet) enforce authentication.
"""
if API_SHARED_KEY:
return {"X-Conjurer-Api-Key": API_SHARED_KEY}
return {}
LATEX_TEX_ENGINE = "tectonic"
LATEX_MAX_COMPILE_SECONDS = 45
LATEX_MAX_ATTACH_MB = 8
LATEX_MAX_ZIP_MB = 25
OPENAI_MODEL = "gpt-4o-mini"
ALLOWED_ROLES = ["Nocna Zmiana", "Jarl", "Thane", "Bartender"]
GUILD_ID = 664789470779932693
LATEST_MODEL = "gpt-4o" # najnowszy/do rozmów (możesz zmienić w jednym miejscu)
CHEAP_MODEL = "gpt-4o-mini" # najtańszy (fallback do 3.5 niżej)
# Claude counterparts of LATEST_MODEL / CHEAP_MODEL. Opus 4.8 is the strongest
# widely available model; Haiku 4.5 is the fast/cheap tier used for MUSIC.
CLAUDE_LATEST_MODEL = "claude-opus-4-8"
CLAUDE_CHEAP_MODEL = "claude-haiku-4-5"
# *=========================================== AI provider configs
# The bot's AI functionality (ai_functions.handle_response) can be pointed at a
# different backend by flipping a single "active" switch. Each named config
# collects the *differences* between providers (which backend, which models,
# generation params). These live in system_gpt_settings.json under an optional
# third list element (index 2) so future configs are simply added there:
#
# [ <system message>, <personal assistants>, { "active": "gpt",
# "configs": { ... } } ]
#
# The block is optional and backward compatible: a settings file with only the
# original two elements falls back to the built-in defaults below (active
# "gpt"), so existing deployments behave exactly as before. The active config
# can be overridden at import time with CONJURER_AI_CONFIG and at runtime with
# the $gadaj_teraz command (which persists the choice back into index 2).
def _default_ai_configs():
return {
"gpt": {
"provider": "openai",
"latest_model": LATEST_MODEL,
"cheap_model": CHEAP_MODEL,
"temperature": 0.2,
},
"claude": {
"provider": "anthropic",
"latest_model": CLAUDE_LATEST_MODEL,
"cheap_model": CLAUDE_CHEAP_MODEL,
# Anthropic requires max_tokens; temperature is intentionally not
# sent for Claude (Opus 4.8 / Sonnet 5 reject sampling params).
"max_tokens": 2048,
},
# Template for wiring further providers. Copy it, rename the key, point
# "provider" at a backend ai_functions.provider_generate implements, and
# fill in the model ids. Keys starting with "_" are treated as inert
# templates and are hidden from the $gadaj_teraz picker.
"_template": {
"provider": "openai",
"latest_model": "model-id-here",
"cheap_model": "cheaper-model-id-here",
"temperature": 0.2,
"max_tokens": 2048,
},
}
_ai_block = (
GPT_SETTINGS[2]
if isinstance(GPT_SETTINGS, list) and len(GPT_SETTINGS) > 2 and isinstance(GPT_SETTINGS[2], dict)
else {}
)
AI_CONFIGS = _ai_block.get("configs") or _default_ai_configs()
# Single switch: env var wins, then the settings-file "active" key, then "gpt".
DEFAULT_AI_CONFIG = (
os.getenv("CONJURER_AI_CONFIG")
or _ai_block.get("active")
or "gpt"
)
if DEFAULT_AI_CONFIG not in AI_CONFIGS:
logger.warning(
"AI config '%s' not found - falling back to 'gpt'", DEFAULT_AI_CONFIG
)
DEFAULT_AI_CONFIG = "gpt" if "gpt" in AI_CONFIGS else next(iter(AI_CONFIGS))
# *=========================================== Conan Exiles bridge
# Every value is optional; the conanjurer cog stays dormant unless configured.
# Channel/role ids default to 0 ("not defined"); RCON host/log path default to
# "" ("disabled"). See conanjurer_commands.py / conanjurer_functions.py.
CONAN_GM_ROLE_ID = int(os.getenv("CONAN_GM_ROLE_ID", "0"))
CONAN_CHAT_CHANNEL_ID = int(os.getenv("CONAN_CHAT_CHANNEL_ID", "0"))
CONAN_EVENTS_CHANNEL_ID = int(os.getenv("CONAN_EVENTS_CHANNEL_ID", "0"))
# Player-join notifications: leave at 0 to keep the feature disabled.
CONAN_JOIN_CHANNEL_ID = int(os.getenv("CONAN_JOIN_CHANNEL_ID", "0"))
CONAN_PLAYER_POLL_SECONDS = int(os.getenv("CONAN_PLAYER_POLL_SECONDS", "60"))
CONAN_RCON_HOST = os.getenv("CONAN_RCON_HOST", "")
CONAN_RCON_PORT = int(os.getenv("CONAN_RCON_PORT", "25575"))
CONAN_RCON_PASSWORD = os.getenv("CONAN_RCON_PASSWORD", "")
CONAN_LOG_MODE = os.getenv("CONAN_LOG_MODE", "local") # "local" | "sftp"
CONAN_LOG_PATH = os.getenv("CONAN_LOG_PATH", "")
CONAN_SFTP_HOST = os.getenv("CONAN_SFTP_HOST", "")
CONAN_SFTP_PORT = int(os.getenv("CONAN_SFTP_PORT", "22"))
CONAN_SFTP_USER = os.getenv("CONAN_SFTP_USER", "")
CONAN_SFTP_PASSWORD = os.getenv("CONAN_SFTP_PASSWORD", "")
View File
Regular → Executable
+5 -104
View File
@@ -1,108 +1,9 @@
#!/bin/bash
total_commands=31
current_command=0
function print_progress {
current_command=$((current_command + 1))
percent=$((current_command * 100 / total_commands))
echo "Executing command $current_command/$total_commands ($percent%): $1"
}
cd /home/pi/conjurer || exit
print_progress "cd /home/pi/conjurer"
git pull
print_progress "git pull"
cp ./deploy.sh /home/pi/
print_progress "cp ./deploy.sh /home/pi/"
cd /home/pi || exit
print_progress "cd /home/pi"
cp ./conjurer/LICENSE ./Conjurer/LICENSE
print_progress "cp ./conjurer/LICENSE ./Conjurer/LICENSE"
cp ./conjurer/fuckery.jpg ./Conjurer/
print_progress "cp ./conjurer/fuckery.jpg ./Conjurer/"
cp ./conjurer/willowisp.png ./Conjurer/
print_progress "cp ./conjurer/willowisp.png ./Conjurer/"
cp ./conjurer/wod_beacon.jpg ./Conjurer/
print_progress "cp ./conjurer/wod_beacon.jpg ./Conjurer/"
cd ..
cp ./conjurer/bot.py ./Conjurer/
cp ./conjurer/settings.json ./Conjurer/
print_progress "cp ./conjurer/settings.json ./Conjurer/"
if [[ ./conjurer/system_gpt_settings.json -nt ./Conjurer/system_gpt_settings.json ]]; then
cp ./conjurer/system_gpt_settings.json ./Conjurer/system_gpt_settings.json
print_progress "cp ./conjurer/system_gpt_settings.json ./Conjurer/system_gpt_settings.json"
else
print_progress "system_gpt_settings.json is up to date"
fi
cp ./conjurer/administration_commands.py ./Conjurer/
print_progress "cp ./conjurer/administration_commands.py ./Conjurer/"
cp ./conjurer/ai_commands.py ./Conjurer/
print_progress "cp ./conjurer/ai_commands.py ./Conjurer/"
cp ./conjurer/ai_functions.py ./Conjurer/
print_progress "cp ./conjurer/ai_functions.py ./Conjurer/"
cp ./conjurer/communication_subroutine.py ./Conjurer/
print_progress "cp ./conjurer/communication_subroutine.py ./Conjurer/"
cp ./conjurer/constants.py ./Conjurer/
print_progress "cp ./conjurer/constants.py ./Conjurer/"
cp ./conjurer/librarian_commands.py ./Conjurer/
print_progress "cp ./conjurer/librarian_commands.py ./Conjurer/"
cp ./conjurer/music_commands.py ./Conjurer/
print_progress "cp ./conjurer/music_commands.py ./Conjurer/"
cp ./conjurer/music_functions.py ./Conjurer/
print_progress "cp ./conjurer/music_functions.py ./Conjurer/"
cp ./conjurer/other_commands.py ./Conjurer/
print_progress "cp ./conjurer/other_commands.py ./Conjurer/"
cp ./conjurer/other_functions.py ./Conjurer/
print_progress "cp ./conjurer/other_functions.py ./Conjurer/"
cp ./conjurer/radio_commands.py ./Conjurer/
print_progress "cp ./conjurer/radio_commands.py ./Conjurer/"
cp ./conjurer/voice_recognition_commands.py ./Conjurer/
print_progress "cp ./conjurer/voice_recognition_commands.py ./Conjurer/"
cp ./conjurer/file_search_functions.py ./Conjurer/
print_progress "cp ./conjurer/file_search_functions.py ./Conjurer/"
cp ./conjurer/file_search_commands.py ./Conjurer
print_progress "cp ./conjurer/file_search_functions.py ./Conjurer/"
cp ./conjurer/latex_commands.py ./Conjurer/
print_progress "cp ./conjurer/latex_commands.py ./Conjurer/"
cp ./conjurer/latex_functions.py ./Conjurer/
print_progress "cp ./conjurer/latex_functions.py ./Conjurer/"
cp ./conjurer/librarian_functions.py ./Conjurer
print_progress "cp ./conjurer/librarian_functions.py ./Conjurer/"
cp ./conjurer/conanjurer_commands.py ./Conjurer/
print_progress "cp ./conjurer/conanjurer_commands.py ./Conjurer/"
cp ./conjurer/conanjurer_functions.py ./Conjurer/
print_progress "cp ./conjurer/conanjurer_functions.py ./Conjurer/"
cp ./conjurer/bot.py ./Conjurer/bot.py
print_progress "cp ./conjurer/bot.py ./Conjurer/bot.py"
cp ./conjurer/system_gpt_settings.json ./Conjurer/
cp ./conjurer/install.sh ./Conjurer/
cp ./conjurer/requirements.txt ./Conjurer/requirements.txt
sudo systemctl restart conjurer.service
print_progress "sudo systemctl restart conjurer.service"
Regular → Executable
+2 -3
View File
@@ -1,7 +1,6 @@
#!/bin/bash
cd ./conjurer || exit
cd ./conjurer
git pull
cd ..
cp ./conjurer/conjurer_musician/media_search_functions.py ./Conjurer
cp ./conjurer/conjurer_musician/conjurer_musician.py ./Conjurer
sudo systemctl restart conjurer_musician.service
sudo systemctl restart conjurer_musician.service
-141
View File
@@ -1,141 +0,0 @@
#!/usr/bin/env bash
# System-wide install & verify (Debian/RPi):
# - EB Garamond (APT)
# - Cinzel Decorative Black (RAW TTF)
# - IM Fell English SC (gstatic/RAW)
# No TeX build here. Very verbose + clear final report.
set -Eeuo pipefail
SUDO=sudo; [ "$(id -u)" -eq 0 ] && SUDO=
log(){ echo -e "[+] $*"; }
warn(){ echo -e "[WARN] $*" >&2; }
die(){ echo -e "[FATAL] $*" >&2; exit 1; }
need(){ command -v "$1" >/dev/null 2>&1 || die "Missing tool: $1"; }
fetch(){
local url="$1" out="$2"
if command -v wget >/dev/null 2>&1; then
log "wget -> $url"
wget -O "$out" --https-only --no-verbose "$url"
else
log "curl -> $url"
curl -L --fail --show-error --output "$out" "$url"
fi
}
# ---- detection using fc-list families (robust, no PCRE) ----
fc_families_lower() {
fc-list -f '%{family}\n' \
| tr ',' '\n' \
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \
| tr '[:upper:]' '[:lower:]' \
| sort -u
}
has_family_re() { # $1 = POSIX ERE anchored-at-start pattern (lowercase)
fc_families_lower | grep -Eiq "$1"
}
# ---- prereqs ----
for t in fc-cache fc-list grep sed awk; do need "$t"; done
command- v wget >/dev/null 2>&1 || command -v curl >/dev/null 2>&1 || die "Need wget or curl"
SYSTEM_DIR="/usr/local/share/fonts/dj"
DIR_CINZEL="$SYSTEM_DIR/cinzel-decorative"
DIR_IMFELL="$SYSTEM_DIR/im-fell-english-sc"
$SUDO mkdir -p "$DIR_CINZEL" "$DIR_IMFELL"
echo "=== APT phase (EB Garamond) ==="
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -y || warn "apt update failed (continuing)"
if $SUDO apt-get install -y --no-install-recommends fonts-ebgaramond; then
log "installed: fonts-ebgaramond"
else
warn "apt install failed: fonts-ebgaramond"
fi
else
warn "apt-get not found — skipping"
fi
echo
log "Families BEFORE web fallback (sample grep):"
fc-list | grep -Ei "Garamond|Cinzel|Fell" || echo " (none)"
echo
echo "=== Web fallback (system-wide to $SYSTEM_DIR) ==="
# --- Cinzel Decorative Black ---
CINZEL_TTF="$DIR_CINZEL/CinzelDecorative-Black.ttf"
if ! has_family_re '^cinzel decorative( |$)'; then
fetch "https://github.com/google/fonts/raw/main/ofl/cinzeldecorative/CinzelDecorative-Black.ttf" "$CINZEL_TTF" || warn "Cinzel Decorative Black download failed"
[ -s "$CINZEL_TTF" ] && $SUDO chmod 0644 "$CINZEL_TTF"
else
log "Cinzel Decorative already present."
fi
# --- IM Fell English SC ---
IMFELL_TTF="$DIR_IMFELL/IMFeENsc.ttf"
if ! has_family_re '^im fell english sc( |$)'; then
# Try known gstatic TTFs (stable direct links)
for u in \
"https://fonts.gstatic.com/s/imfellenglishsc/v7/h3Tn6yWfw4b5qaLD1RWvz5ATixNthKRRR1XVH3rJNiw.ttf" \
"https://fonts.gstatic.com/s/imfellenglishsc/v6/h3Tn6yWfw4b5qaLD1RWvz5ATixNthKRRR1XVH3rJNiw.ttf"
do
if fetch "$u" "$IMFELL_TTF"; then
[ -s "$IMFELL_TTF" ] && { $SUDO chmod 0644 "$IMFELL_TTF"; log "IM Fell SC from gstatic OK"; break; }
fi
done
# Secondary RAW in google/fonts (names changed over time)
if [ ! -s "$IMFELL_TTF" ]; then
for u in \
"https://github.com/google/fonts/raw/main/ofl/imfellenglishsc/IMFeENsc28P.ttf" \
"https://github.com/google/fonts/raw/main/ofl/imfellenglishsc/IMFellEnglishSC-Regular.ttf"
do
if fetch "$u" "$IMFELL_TTF"; then
[ -s "$IMFELL_TTF" ] && { $SUDO chmod 0644 "$IMFELL_TTF"; log "IM Fell SC from google/fonts RAW OK"; break; }
fi
done
fi
# Minimal mirror fallback (only if naprawdę trzeba)
if [ ! -s "$IMFELL_TTF" ]; then
for u in \
"https://www.wfonts.com/download/data/2016/06/14/im-fell-english-sc/IMFeENsc28P.ttf" \
"https://www.1001freefonts.com/d/6800/IMFeENsc28P.ttf"
do
fetch "$u" "$IMFELL_TTF" || true
[ -s "$IMFELL_TTF" ] && { $SUDO chmod 0644 "$IMFELL_TTF"; log "IM Fell SC from mirror OK"; break; }
done
fi
[ -s "$IMFELL_TTF" ] || warn "IM Fell English SC still missing."
else
log "IM Fell English SC already present."
fi
echo
log "Fix perms & rebuild cache..."
$SUDO find "$SYSTEM_DIR" -type f -size 0 -print -delete || true
$SUDO find "$SYSTEM_DIR" -type f \( -name "*.ttf" -o -name "*.otf" \) -exec chmod 0644 {} \; || true
$SUDO find "$SYSTEM_DIR" -type d -exec chmod 0755 {} \; || true
$SUDO fc-cache -f -v >/dev/null || warn "fc-cache returned non-zero"
# ---- FINAL REPORT ----
echo
log "FINAL STATUS (fc-list families & file presence)"
families="$(fc_families_lower)"
report(){
local label="$1" fam_re="$2" file_hint="$3"
local fam_ok file_ok="n/a"
if echo "$families" | grep -Eiq "$fam_re"; then fam_ok="OK"; else fam_ok="MISSING"; fi
if [ -n "$file_hint" ]; then
if [ -s "$file_hint" ]; then file_ok="yes"; else file_ok="no"; fi
fi
printf " %-22s : families=%-8s file=%-3s (%s)\n" "$label" "$fam_ok" "$file_ok" "${file_hint:-no-file-hint}"
}
report "EB Garamond" '^eb garamond( |$)' "" # from APT
report "Cinzel Decorative" '^cinzel decorative( |$)' "$CINZEL_TTF"
report "IM Fell English SC" '^im fell english sc( |$)' "$IMFELL_TTF"
echo
log "Done. (Run your LaTeX pipeline next.)"
-64
View File
@@ -1,64 +0,0 @@
# Conjurer main Discord bot.
# Build from the repository root:
# docker build -f docker/Dockerfile.bot -t conjurer-bot .
FROM python:3.13-trixie
# System dependencies:
# ffmpeg - audio download/convert (yt_dlp) and Discord voice
# libopus0 - Discord voice (PyNaCl / discord-ext-voice-recv)
# poppler-utils - pdf2image (librarian/latex previews)
# git - required by the git+https entry in requirements
# build-essential - native wheels (PyNaCl etc.)
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libopus0 \
poppler-utils \
git \
build-essential \
curl \
ca-certificates \
python3-dev \
build-essential \
portaudio19-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Optional: Tectonic for the $latex command. Remove this layer if unused.
RUN curl -fsSL https://drop-sh.fullyjustified.net | sh \
&& mv tectonic /usr/local/bin/tectonic \
|| echo "tectonic not installed - the LaTeX feature will be disabled"
COPY requirements_bot.txt requirements_conan.txt ./
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements_bot.txt
# Best-effort Conan bridge extras: aiomcrcon supports Python <= 3.11 only, so
# on this 3.13 image the install fails harmlessly and the conanjurer cog stays
# dormant (its imports are guarded).
RUN pip install --no-cache-dir -r requirements_conan.txt \
|| echo "conan extras skipped - conanjurer cog will stay dormant"
# Vendored forks take import precedence over the pip packages of the same name,
# because /app (the script dir) is first on sys.path when running `python bot.py`.
COPY yt_dlp ./yt_dlp
COPY spotify_dl ./spotify_dl
# Bot sources + default config/asset templates. Runtime state (conversation
# history etc.) is read from the mounted /data volume via CONJURER_DATA_DIR,
# so these committed copies only act as first-run fallbacks.
COPY *.py ./
COPY settings.json system_gpt_settings.json accident_log.json pamiec.json pamiec_muzyki.json ./
COPY fuckery.jpg willowisp.png wod_beacon.jpg ./
COPY docker/entrypoint.bot.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENV PYTHONUNBUFFERED=1 \
CONJURER_DATA_DIR=/data \
CONJURER_DISCORD_HOST=0.0.0.0 \
CONJURER_DISCORD_PORT=5000
VOLUME ["/data"]
EXPOSE 5000
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["python", "bot.py"]
-30
View File
@@ -1,30 +0,0 @@
# Conjurer librarian (Crossref search + local DOI database lookup).
# Build from the repository root:
# docker build -f docker/Dockerfile.librarian -t conjurer-librarian .
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY conjurer_librarian/requirements_librarian.txt ./
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements_librarian.txt requests
COPY conjurer_librarian/ ./
ENV PYTHONUNBUFFERED=1 \
CONJURER_LIBRARIAN_HOST=0.0.0.0 \
CONJURER_LIBRARIAN_PORT=5001 \
CONJURER_LIBRARIAN_DB_PATH=/doi/ \
CONJURER_LIBRARIAN_STATE_DIR=/lib_temp_files
# /doi = the local DOI chunk database (large, read-only).
# /lib_temp_files = runtime JSON state (cr_results/rr_results/not_in_db/
# s_results) - seeded on first run, persisted across restarts.
VOLUME ["/doi", "/lib_temp_files"]
EXPOSE 5001
CMD ["python", "conjurer_librarian.py"]
-35
View File
@@ -1,35 +0,0 @@
# Conjurer musician (Flask file/playlist service backing the radio).
# Build from the repository root:
# docker build -f docker/Dockerfile.musician -t conjurer-musician .
#
# NOTE: this containerises the musician *web service* only. The Liquidsoap
# radio (radio_conjurer.liq) and any Samba/NFS share tooling run separately.
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY conjurer_musician/requirements_musician.txt ./
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements_musician.txt
COPY conjurer_musician/ ./
COPY docker/entrypoint.musician.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENV PYTHONUNBUFFERED=1 \
CONJURER_MUSICIAN_HOST=0.0.0.0 \
CONJURER_MUSICIAN_PORT=5000 \
CONJURER_MUSIC_FOLDER=/music \
CONJURER_MUSICIAN_BASE=/data \
CONJURER_STREAM_TEMPLATE=/app/stream.html
# /music = the mp3 library (read-only ok); /data = writable playlists + logs.
VOLUME ["/music", "/data"]
EXPOSE 5000
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["python", "conjurer_musician.py"]
-110
View File
@@ -1,110 +0,0 @@
# Radio Conjurer - Liquidsoap runtime built through opam, so the OCaml,
# opam and Liquidsoap versions are all selectable at build time.
#
# Build from the repository root, e.g.:
# docker build -f docker/Dockerfile.radio -t conjurer-radio .
# docker build -f docker/Dockerfile.radio -t conjurer-radio \
# --build-arg LIQUIDSOAP_VERSION=2.1.4 --build-arg OCAML_VERSION=4.14.2 .
#
# Version notes (matched to radio_conjurer.liq, which targets 2.1.4):
# * Liquidsoap 2.1.x requires OCaml 4.x - it does NOT build on OCaml 5.
# Production ran the 4.13.0 switch; 4.14.x is the maintained 4.x line and
# builds 2.1.4 fine. Pass OCAML_VERSION=4.13.0 for bit-for-bit parity.
# * The ocaml-ffmpeg bindings compatible with 2.1.4 target the FFmpeg 5.x
# library family (libavcodec59/libavformat59/libavutil57...). Debian
# bookworm ships exactly that, which is why this image needs NO ffmpeg
# pinning: the old ffmpeg.pref (Pin-Priority 1001 on deb.debian.org) only
# existed to force those Debian builds over the Raspberry Pi OS repo's
# conflicting ones. Single-repo container = the pin is redundant.
FROM debian:bookworm-slim
ARG OPAM_VERSION=2.1.5
ARG OCAML_VERSION=4.14.2
ARG LIQUIDSOAP_VERSION=2.1.4
# Optional liquidsoap features, resolved together with liquidsoap by opam.
# These cover everything radio_conjurer.liq uses:
# mad+lame - mp3 decode/encode (%mp3 icecast stream)
# cry - output.icecast
# taglib - tag/replaygain metadata
# pulseaudio- input.pulseaudio (mic) + output.pulseaudio
# samplerate- resampling
# inotify - playlist(reload_mode="watch")
# ffmpeg - decode fallback + replaygain computation
ARG LIQ_OPAM_PACKAGES="mad lame cry taglib pulseaudio samplerate inotify ffmpeg"
# System libraries: build deps for the opam packages above plus the matching
# runtime libs. The libav*-dev list mirrors the old ffmpeg.pref family.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential m4 pkg-config git curl ca-certificates unzip rsync \
libpcre3-dev libgmp-dev zlib1g-dev \
libmad0-dev libmp3lame-dev libtag1-dev \
libpulse-dev libsamplerate0-dev \
libavcodec-dev libavformat-dev libavutil-dev libavfilter-dev \
libavdevice-dev libswresample-dev libswscale-dev libpostproc-dev \
libcurl4-gnutls-dev \
ffmpeg \
pulseaudio pulseaudio-utils \
icecast2 jq \
python3 python3-flask python3-waitress python3-requests \
&& rm -rf /var/lib/apt/lists/*
# Liquidsoap refuses to run as root (security exit), so it gets a dedicated
# user. audio/pulse-access mirror what was done by hand on the Pi for the
# system-wide pulse socket ('pulse-access' comes with the pulseaudio pkg).
RUN useradd --system --create-home --home-dir /var/lib/radio \
--shell /usr/sbin/nologin radio \
&& usermod -aG audio,pulse-access radio
# opam as a static binary so OPAM_VERSION is a real choice (apt would pin us
# to whatever bookworm ships).
RUN ARCH=$(uname -m) \
&& curl -fsSL -o /usr/local/bin/opam \
"https://github.com/ocaml/opam/releases/download/${OPAM_VERSION}/opam-${OPAM_VERSION}-${ARCH}-linux" \
&& chmod +x /usr/local/bin/opam
# OCaml switch + liquidsoap and its optional feature libraries in one solve,
# so liquidsoap is compiled WITH those features enabled. OPAMROOT lives in
# /opt/opam (not /root/.opam) so the unprivileged 'radio' user can read the
# liquidsoap binary AND its stdlib .liq files at runtime.
ENV OPAMROOT=/opt/opam
RUN opam init -y --bare --disable-sandboxing \
&& opam switch create default "${OCAML_VERSION}" \
&& opam install -y "liquidsoap.${LIQUIDSOAP_VERSION}" ${LIQ_OPAM_PACKAGES} \
&& opam clean -a -c -s --logs
ENV PATH="/opt/opam/default/bin:${PATH}"
# Build-time smoke test: the binary runs and reports the requested version.
RUN liquidsoap --version
# Minimal system-wide pulse config for headless VMs (PULSE_MODE=internal):
# a null sink so output.pulseaudio()/input.pulseaudio() work with no sound
# hardware (mic becomes silence, which blank.strip already gates out).
COPY docker/pulse-system.pa /etc/pulse/system.pa
# The script and its persistent params are seeded into the data volume on
# first run (never overwritten), so live edits survive image rebuilds. The
# icecast config is rendered from the template at startup with passwords
# taken from the secrets volume (never baked into the image).
WORKDIR /app
COPY conjurer_musician/radio_conjurer.liq ./radio_conjurer.liq
COPY conjurer_musician/script.params ./script.params
COPY conjurer_musician/stream.html ./stream.html
COPY conjurer_betoniarka/betoniarka.py ./betoniarka.py
COPY docker/icecast.xml.tpl ./icecast.xml.tpl
COPY docker/entrypoint.radio.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Volume layout matches the paths inside radio_conjurer.liq:
# /srv/betoniarka/data - playlists, script.params, persistence/radio logs
# /srv/betoniarka/music - the mp3 library
# /srv/betoniarka/secrets - icecast_credentials.json (provisioned at install)
VOLUME ["/srv/betoniarka/data", "/srv/betoniarka/music", "/srv/betoniarka/secrets"]
# 8000 = icecast (listeners), 5005 = betoniarka HTTP API (the bot's
# CONJURER_RADIO_SERVICE), 54321 = harbor /skip (the bot's RADIO_HARBOR),
# 9999 = interactive harbor.
EXPOSE 8000 5005 54321 9999
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["liquidsoap", "/srv/betoniarka/data/radio_conjurer.liq"]
-22
View File
@@ -1,22 +0,0 @@
# Main bot VM. Run from the repository root:
# cp docker/env/bot.env.example docker/env/bot.env # then edit
# docker compose -f docker/compose.bot.yaml up -d --build
services:
conjurer-bot:
build:
context: ..
dockerfile: docker/Dockerfile.bot
image: conjurer-bot:latest
container_name: conjurer-bot
restart: unless-stopped
env_file:
- env/bot.env
ports:
# Flask comm layer — musician/librarian POST results here (/prepped_tracks, /conjurer).
- "5000:5000"
volumes:
# Persistent state (conversation history, settings, logs). Populate this
# host dir with your existing pamiec.json etc. to preserve command history.
- /srv/conjurer/data:/data
# Tokens: a read-only netrc covers discord/openai/spotipy/youtube in one file.
- /srv/conjurer/secrets/.netrc:/secrets/.netrc:ro
-24
View File
@@ -1,24 +0,0 @@
# Librarian VM. Run from the repository root:
# cp docker/env/librarian.env.example docker/env/librarian.env # then edit
# docker compose -f docker/compose.librarian.yaml up -d --build
services:
conjurer-librarian:
build:
context: ..
dockerfile: docker/Dockerfile.librarian
image: conjurer-librarian:latest
container_name: conjurer-librarian
restart: unless-stopped
env_file:
- env/librarian.env
ports:
- "5001:5001"
volumes:
# Local DOI chunk database (0_chunk.txt ... N_chunk.txt). Read-only:
# the search workers only read it (open mode "r").
- /srv/librarian/doi:/doi:ro
# Runtime JSON state (cr_results/rr_results/not_in_db/s_results) -
# seeded on first run, persisted here across restarts.
- /srv/librarian/state:/lib_temp_files
# Optional: netrc holding Crossref credentials (or use CONJURER_CROSSREF_MAILTO).
- /srv/librarian/secrets/.netrc:/secrets/.netrc:ro
-21
View File
@@ -1,21 +0,0 @@
# Musician VM (optional — you plan to adapt/implement this yourself).
# Run from the repository root:
# cp docker/env/musician.env.example docker/env/musician.env # then edit
# docker compose -f docker/compose.musician.yaml up -d --build
services:
conjurer-musician:
build:
context: ..
dockerfile: docker/Dockerfile.musician
image: conjurer-musician:latest
container_name: conjurer-musician
restart: unless-stopped
env_file:
- env/musician.env
ports:
- "5000:5000"
volumes:
# The mp3 library the service indexes and serves.
- /srv/musician/music:/music
# Runtime playlists/logs the service writes.
- /srv/musician/data:/data
-45
View File
@@ -1,45 +0,0 @@
# Radio (Liquidsoap + Icecast) VM. Run from the repository root:
# docker compose -f docker/compose.radio.yaml up -d --build
#
# Version pins are build args - override via environment, e.g.:
# LIQUIDSOAP_VERSION=2.1.4 OCAML_VERSION=4.13.0 \
# docker compose -f docker/compose.radio.yaml up -d --build
services:
conjurer-radio:
build:
context: ..
dockerfile: docker/Dockerfile.radio
args:
OPAM_VERSION: ${OPAM_VERSION:-2.1.5}
OCAML_VERSION: ${OCAML_VERSION:-4.14.2}
LIQUIDSOAP_VERSION: ${LIQUIDSOAP_VERSION:-2.1.4}
image: conjurer-radio:latest
container_name: conjurer-radio
restart: unless-stopped
environment:
# internal = null-sink pulse inside the container (headless VM default)
# host = mount the host pulse socket below and set PULSE_SERVER
PULSE_MODE: ${PULSE_MODE:-internal}
# PULSE_SERVER: unix:/tmp/pulseaudio.socket
# Hostname icecast reports in its status/YP pages:
ICECAST_HOSTNAME: ${ICECAST_HOSTNAME:-localhost}
# Betoniarka (radio-operator API + log forwarder):
CONJURER_MAIN_BOT: ${CONJURER_MAIN_BOT:-http://127.0.0.1:5000}
CONJURER_API_KEY: ${CONJURER_API_KEY:-}
ports:
- "8000:8000" # icecast - listeners tune in here
- "5005:5005" # betoniarka API - the bot's CONJURER_RADIO_SERVICE
- "54321:54321" # harbor /skip - the bot's CONJURER_RADIO_HARBOR
- "9999:9999" # interactive harbor (keep LAN-only!)
volumes:
# Playlists, logs, script.params and the script itself (seeded on first
# run) - same paths inside and outside the container.
- /srv/betoniarka/data:/srv/betoniarka/data
# The mp3 library. Writable because the entrypoint seeds a silent
# emergency-fallback mp3 when the hardcoded single() file is missing.
- /srv/betoniarka/music:/srv/betoniarka/music
# icecast_credentials.json - provision at install like the other
# services' secrets (a CHANGE_ME placeholder is seeded if absent).
- /srv/betoniarka/secrets:/srv/betoniarka/secrets
# PULSE_MODE=host: uncomment and adjust
# - /tmp/pulseaudio.socket:/tmp/pulseaudio.socket
-16
View File
@@ -1,16 +0,0 @@
#!/bin/sh
# Seed the data volume with the baked-in default JSON state on first run only.
# Existing files (e.g. your preserved pamiec.json history) are never overwritten.
set -e
DATA="${CONJURER_DATA_DIR:-/data}"
mkdir -p "$DATA"
for f in settings.json system_gpt_settings.json pamiec.json pamiec_muzyki.json accident_log.json; do
if [ ! -e "$DATA/$f" ] && [ -e "/app/$f" ]; then
cp "/app/$f" "$DATA/$f"
echo "entrypoint: seeded $f into $DATA"
fi
done
exec "$@"
-23
View File
@@ -1,23 +0,0 @@
#!/bin/sh
# Prepare the musician's writable volume so the web service starts cleanly on a
# fresh container. Existing files are never overwritten (preserves your data).
set -e
DATA="${CONJURER_MUSICIAN_BASE:-/data}"
MUSIC="${CONJURER_MUSIC_FOLDER:-/music}"
mkdir -p "$DATA" "$DATA/logs" "$MUSIC"
# The track-forwarding thread tails the Liquidsoap radio logs. When the radio
# runs separately (or hasn't started yet) these files may not exist; create
# them empty so the tailer waits instead of crashing.
for f in radio_log.log persistence.log; do
[ -e "$DATA/$f" ] || : > "$DATA/$f"
done
# Ensure the managed playlists exist (routes/rescan also create them; this just
# avoids a first-tick race before the initial scan).
for f in all_playlist.playlist hit.playlist request.playlist priority_queue.playlist; do
[ -e "$DATA/$f" ] || : > "$DATA/$f"
done
exec "$@"
-113
View File
@@ -1,113 +0,0 @@
#!/bin/sh
# Prepare the radio volumes, start the in-container Icecast server, wire up
# PulseAudio and exec liquidsoap. Existing files are never overwritten -
# live-edited script/params/playlists always win.
set -e
DATA="${RADIO_DATA_DIR:-/srv/betoniarka/data}"
MUSIC="${RADIO_MUSIC_DIR:-/srv/betoniarka/music}"
SECRETS="${RADIO_SECRETS_DIR:-/srv/betoniarka/secrets}"
CREDS="$SECRETS/icecast_credentials.json"
mkdir -p "$DATA" "$MUSIC" "$SECRETS"
# Seed the script + persistent interactive params from the image on first run.
[ -e "$DATA/radio_conjurer.liq" ] || cp /app/radio_conjurer.liq "$DATA/"
if [ ! -e "$DATA/script.params" ]; then
if [ -e /app/script.params ]; then cp /app/script.params "$DATA/"; else : > "$DATA/script.params"; fi
fi
# Playlists + logs the script watches/writes; empty files keep it happy until
# the musician/betoniarka populate them.
for f in all_playlist.playlist priority_queue.playlist hit.playlist \
request.playlist jingles.playlist persistence.log; do
[ -e "$DATA/$f" ] || : > "$DATA/$f"
done
# The icecast secret is provisioned at install time, like the other services'
# secrets (bot: /srv/conjurer/secrets/.netrc). A placeholder keeps the stack
# bootable, but both icecast and the stream stay locked until you fix it.
if [ ! -e "$CREDS" ]; then
printf '{\n"password" : "CHANGE_ME"\n}\n' > "$CREDS"
echo "WARNING: $CREDS was missing - seeded a CHANGE_ME placeholder." >&2
echo " Put the real password there (see docs) and restart." >&2
fi
# Render /etc/icecast2/icecast.xml from the template with passwords from the
# secret. Optional fields admin_password/relay_password default to password.
SOURCE_PW=$(jq -r '.password' "$CREDS")
ADMIN_PW=$(jq -r '.admin_password // .password' "$CREDS")
RELAY_PW=$(jq -r '.relay_password // .password' "$CREDS")
ICECAST_HOSTNAME="${ICECAST_HOSTNAME:-localhost}"
sed -e "s|__SOURCE_PASSWORD__|$SOURCE_PW|" \
-e "s|__ADMIN_PASSWORD__|$ADMIN_PW|" \
-e "s|__RELAY_PASSWORD__|$RELAY_PW|" \
-e "s|__HOSTNAME__|$ICECAST_HOSTNAME|" \
/app/icecast.xml.tpl > /etc/icecast2/icecast.xml
chown icecast2:icecast /etc/icecast2/icecast.xml 2>/dev/null || true
chmod 640 /etc/icecast2/icecast.xml
# Start Icecast in the background as its unprivileged user.
mkdir -p /var/log/icecast2 && chown -R icecast2:icecast /var/log/icecast2
su -s /bin/sh icecast2 -c "icecast2 -b -c /etc/icecast2/icecast.xml" \
|| echo "WARNING: icecast2 failed to start - the stream output will retry" >&2
# single() aborts the whole script when its file is missing; guarantee the
# emergency fallback exists (5s of silence beats a dead radio).
EMERGENCY="$MUSIC/Youtube/Dr. Peacock - Trip to Ireland [GvrvQTUbUcA].mp3"
if [ ! -e "$EMERGENCY" ]; then
mkdir -p "$MUSIC/Youtube"
if ffmpeg -loglevel error -f lavfi -i anullsrc=r=44100:cl=stereo -t 5 \
-codec:a libmp3lame -q:a 9 "$EMERGENCY"; then
echo "WARNING: emergency track was missing - generated silent placeholder" >&2
else
echo "WARNING: could not create emergency track; single() may abort" >&2
fi
fi
# PulseAudio wiring:
# internal (default) - system-wide pulse inside the container with a null
# sink (see /etc/pulse/system.pa); no sound hardware
# needed, mic path reads silence.
# host - use a socket mounted from the host; set PULSE_SERVER
# (e.g. unix:/tmp/pulseaudio.socket) in the env file.
# none - you edited the script to drop pulse in/out.
case "${PULSE_MODE:-internal}" in
internal)
# --disallow-module-loading: modules from system.pa still load at
# startup; this only blocks later client-requested loads (and
# silences the system-mode warning). The "forcibly disabling SHM"
# notice is inherent to system mode and harmless.
pulseaudio --system --daemonize=yes --disallow-exit \
--disallow-module-loading --exit-idle-time=-1 \
|| echo "WARNING: internal pulseaudio failed to start" >&2
export PULSE_SERVER="${PULSE_SERVER:-unix:/var/run/pulse/native}"
;;
host)
[ -n "$PULSE_SERVER" ] || echo "WARNING: PULSE_MODE=host but PULSE_SERVER is unset" >&2
;;
none)
;;
esac
# Liquidsoap refuses to run as root (init: security exit), so hand the data
# volume to the dedicated 'radio' user and drop privileges for the main
# process. chown is best-effort: on local volumes it always works (the
# supported layout); network filesystems with root-squash reject it, hence
# the warning instead of a fatal abort.
chown -R radio:radio "$DATA" 2>/dev/null \
|| echo "WARNING: chown of $DATA failed (network FS?) - keep this volume LOCAL to the radio VM" >&2
chgrp radio "$CREDS" 2>/dev/null && chmod 640 "$CREDS" || true
if ! setpriv --reuid radio --regid radio --init-groups -- test -r "$MUSIC"; then
echo "WARNING: music dir $MUSIC is not readable by the 'radio' user" >&2
fi
# Betoniarka: the radio-operator API + radio-log forwarder, running as the
# SAME user as liquidsoap on the SAME volume - this is what makes the old
# musician-writes-as-root-over-network-share permission mess go away.
BETONIARKA_DATA="$DATA" BETONIARKA_MUSIC="$MUSIC" \
setpriv --reuid radio --regid radio --init-groups -- \
python3 /app/betoniarka.py &
echo "betoniarka started (pid $!)"
cd "$DATA"
exec setpriv --reuid radio --regid radio --init-groups -- "$@"
-49
View File
@@ -1,49 +0,0 @@
# Copy to docker/env/bot.env and fill in. Do NOT commit the real file.
# --- Secrets ------------------------------------------------------------
# Option A: mount a netrc (recommended — covers discord/openai/anthropic/spotipy/youtube).
CONJURER_NETRC_FILE=/secrets/.netrc
# Option B: pass tokens directly (these take precedence over netrc).
# DISCORD_TOKEN=
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY= # Claude backend; netrc machine 'anthropic' works too
# ASSEMBLYAI_API_KEY= # voice recognition; netrc machine 'assemblyai' works too
# YOUTUBE_USERNAME=
# YOUTUBE_PASSWORD=
# --- AI backend switch --------------------------------------------------
# Which AI config from system_gpt_settings.json is active at startup
# (e.g. "gpt" or "claude"). Runtime switch: $gadaj_teraz <config>. Unset =
# whatever the settings file's "active" key says, falling back to "gpt".
# CONJURER_AI_CONFIG=gpt
# --- Data ---------------------------------------------------------------
# Single mounted volume; all writable state is rooted here.
CONJURER_DATA_DIR=/data
# --- Flask comm layer (inbound from musician/librarian) -----------------
CONJURER_DISCORD_HOST=0.0.0.0
CONJURER_DISCORD_PORT=5000
# --- Internal service auth ----------------------------------------------
# Set the SAME value on bot + musician + librarian. Empty = auth disabled.
CONJURER_API_KEY=
# --- Where the bot reaches the other services (other Proxmox VMs) --------
CONJURER_FILE_SERVICE=http://MUSICIAN_VM_IP:5000
# Betoniarka (radio-operator API, runs in the radio container):
CONJURER_RADIO_SERVICE=http://RADIO_VM_IP:5005
# Liquidsoap harbor /skip (same radio container):
CONJURER_RADIO_HARBOR=http://RADIO_VM_IP:54321
CONJURER_LIBRARIAN_SERVICE=http://LIBRARIAN_VM_IP:5001
# --- Conan Exiles bridge (optional; empty/0 = disabled) -----------------
# CONAN_GM_ROLE_ID=0
# CONAN_RCON_HOST=
# CONAN_RCON_PORT=25575
# CONAN_RCON_PASSWORD=
# CONAN_CHAT_CHANNEL_ID=0
# CONAN_EVENTS_CHANNEL_ID=0
# CONAN_JOIN_CHANNEL_ID=0
# CONAN_LOG_MODE=local
# CONAN_LOG_PATH=
-25
View File
@@ -1,25 +0,0 @@
# Copy to docker/env/librarian.env and fill in.
CONJURER_LIBRARIAN_HOST=0.0.0.0
CONJURER_LIBRARIAN_PORT=5001
# Same shared secret as the bot (empty = auth disabled).
CONJURER_API_KEY=
# Where to POST search results back to (the bot's comm layer).
CONJURER_MAIN_BOT=http://BOT_VM_IP:5000
# Crossref polite-pool contact (or put credentials in netrc under "crossref").
CONJURER_CROSSREF_MAILTO=you@example.com
# Local DOI chunk database (mounted volume): expects 0_chunk.txt .. N_chunk.txt
CONJURER_LIBRARIAN_DB_PATH=/doi/
CONJURER_LIBRARIAN_MAXTHREADS=41
CONJURER_LIBRARIAN_CHUNK=_chunk.txt
# Runtime JSON state dir (mounted, persistent): cr_results/rr_results/
# not_in_db/s_results are seeded here on first run.
CONJURER_LIBRARIAN_STATE_DIR=/lib_temp_files
# Optional netrc (for Crossref credentials)
CONJURER_NETRC_FILE=/secrets/.netrc
-17
View File
@@ -1,17 +0,0 @@
# Copy to docker/env/musician.env and fill in.
CONJURER_MUSICIAN_HOST=0.0.0.0
CONJURER_MUSICIAN_PORT=5000
# Same shared secret as the bot (empty = auth disabled).
CONJURER_API_KEY=
# Where to POST "now playing" / prepped-track updates (the bot's comm layer).
CONJURER_MAIN_BOT=http://BOT_VM_IP:5000
# The mp3 library (mounted volume).
CONJURER_MUSIC_FOLDER=/music
# Runtime paths (mounted volume) — playlists/logs the service writes.
CONJURER_MUSICIAN_BASE=/data
CONJURER_LOGSTORE=/data/logs
-55
View File
@@ -1,55 +0,0 @@
<!-- Icecast2 config template for the radio container.
The entrypoint substitutes the __*_PASSWORD__ placeholders from
/srv/betoniarka/secrets/icecast_credentials.json and writes the result
to /etc/icecast2/icecast.xml. Never commit real passwords here. -->
<icecast>
<location>Wolne Ksiestwo Baluty</location>
<admin>admin@localhost</admin>
<limits>
<clients>64</clients>
<sources>4</sources>
<queue-size>524288</queue-size>
<client-timeout>30</client-timeout>
<header-timeout>15</header-timeout>
<source-timeout>10</source-timeout>
<burst-on-connect>1</burst-on-connect>
<burst-size>65535</burst-size>
</limits>
<authentication>
<source-password>__SOURCE_PASSWORD__</source-password>
<relay-password>__RELAY_PASSWORD__</relay-password>
<admin-user>admin</admin-user>
<admin-password>__ADMIN_PASSWORD__</admin-password>
</authentication>
<hostname>__HOSTNAME__</hostname>
<listen-socket>
<port>8000</port>
<bind-address>0.0.0.0</bind-address>
</listen-socket>
<http-headers>
<header name="Access-Control-Allow-Origin" value="*" />
</http-headers>
<fileserve>1</fileserve>
<paths>
<basedir>/usr/share/icecast2</basedir>
<logdir>/var/log/icecast2</logdir>
<webroot>/usr/share/icecast2/web</webroot>
<adminroot>/usr/share/icecast2/admin</adminroot>
<alias source="/" destination="/status.xsl"/>
</paths>
<logging>
<accesslog>access.log</accesslog>
<errorlog>error.log</errorlog>
<loglevel>3</loglevel>
<logsize>10000</logsize>
<logarchive>0</logarchive>
</logging>
</icecast>
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/pulseaudio -nF
# Minimal system-wide PulseAudio config for the radio container running on a
# headless VM (PULSE_MODE=internal). Mirrors the Pi's setup (unix socket,
# anonymous auth) but replaces the Lexicon Lambda USB device with a null
# sink: output.pulseaudio() plays into the void and input.pulseaudio() (the
# mic path) reads silence from the sink monitor.
load-module module-null-sink sink_name=radio_null sink_properties=device.description=RadioNullOutput
load-module module-native-protocol-unix auth-anonymous=1
set-default-sink radio_null
set-default-source radio_null.monitor
-180
View File
@@ -1,180 +0,0 @@
# Conjurer Deployment Guide
This document walks through deploying the Conjurer stack (Discord bot, music
service, librarian service) on two Raspberry Pi 4s and one Windows host, first
with plain Docker Compose, then with a future Kubernetes setup.
## 1. Current Hardware Layout
- **Windows PC**: Stores persistent data (JSON memories, configs, music
catalogue). Shares folders over the network (SMB) for the Pis.
- **Raspberry Pi A** (“radio”): Runs the Liquidsoap/liq radio pipeline and the
musician service (Flask + file watcher).
- **Raspberry Pi B** (“bot”): Runs the Discord bot, communication bridge, and
librarian Flask service.
You can rebalance as follows:
| Service | Recommended Host | Notes |
|---------------------|------------------|-------|
| Discord bot + comms | Raspberry Pi B | Needs outbound internet, moderate CPU |
| Librarian service | Raspberry Pi B | CPU-heavy during Crossref queries; keep close to bot |
| Musician service | Raspberry Pi A | Has direct disk access to music, same box as Liquidsoap |
| Data storage | Windows | Expose via SMB; mount inside containers |
## 2. Prepare Shared Storage on Windows
1. Create directories, e.g. `C:\Conjurer\config`, `C:\Conjurer\logs`,
`C:\Conjurer\music`, `C:\Conjurer\playlists`, `C:\Conjurer\secrets`.
2. Copy your existing JSON settings (`settings.json`, `pamiec.json`,
`pamiec_muzyki.json`, `system_gpt_settings.json`, etc.) into `config`.
3. Create blank placeholder files if they do not exist yet.
4. Share the root folder (`C:\Conjurer`) over SMB with read/write access for the
Pi user (create credentials if necessary).
## 3. Configure Environment Files
1. On your workstation, copy the example env files:
```bash
cp docker/env/bot.env.example docker/env/bot.env
cp docker/env/musician.env.example docker/env/musician.env
cp docker/env/librarian.env.example docker/env/librarian.env
```
2. Edit each `docker/env/*.env` to replace `HACKME!` with real values:
- `DISCORD_TOKEN`, `OPENAI_API_KEY`, `CONJURER_API_KEY` (use the same value for
all services).
- `ANTHROPIC_API_KEY` — only if you want the Claude backend (see “AI backend
switch” below). Safe to leave unset while running on GPT.
- For musician/librarian, adjust mounts to the SMB paths you will mount on the
Pis, e.g. `/mnt/conjurer/music`.
- Set `CONJURER_CROSSREF_MAILTO` to a real email as required by Crossref.
3. If you rely on `.netrc`, copy it to `C:\Conjurer\secrets\.netrc` and set
`CONJURER_NETRC_FILE` accordingly. The bot reads netrc machines `discord`,
`openai`, `anthropic`, `assemblyai`, `spotipy`, `youtube`.
### AI backend switch (GPT ↔ Claude)
The AI chat features run on one backend at a time, selected by a single switch:
- **Startup:** `CONJURER_AI_CONFIG=gpt` (default) or `=claude` in `bot.env`. Unset
falls back to the `"active"` key in `system_gpt_settings.json`, then `"gpt"`.
- **Runtime:** the `$gadaj_teraz <config>` Discord command (Vykidailo only) flips
the backend live and persists the choice.
- The named configs (which provider, which models) live in
`system_gpt_settings.json` — add more there. Claude needs `ANTHROPIC_API_KEY`;
GPT needs `OPENAI_API_KEY`. Image generation (`imaginuje sobie:`) and personal
assistants always use OpenAI regardless of the switch (Anthropic has no
equivalent), and degrade quietly if `OPENAI_API_KEY` is absent.
## 4. Install Docker on Raspberry Pis and Windows
### Raspberry Pi
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
sudo reboot
# Install docker compose plugin
sudo apt-get install docker-compose-plugin
```
### Windows
- Install **Docker Desktop**.
- Enable WSL2 backend and expose the shared Windows folders to the containers
(Docker Desktop settings → Resources → File Sharing).
## 5. Deploy Musician Service (Pi A)
1. SSH into Raspberry Pi A.
2. Mount the Windows SMB share:
```bash
sudo mkdir -p /mnt/conjurer
sudo apt-get install cifs-utils
sudo mount -t cifs //WINDOWS_HOST/Conjurer /mnt/conjurer -o user=YOURUSER
```
Add an entry to `/etc/fstab` for persistence.
3. Copy the repo to the Pi or `git clone` it.
4. On Pi A, create override compose file (optional) pointing volumes to
`/mnt/conjurer`.
5. Start only the musician service:
```bash
docker compose up --build -d musician
```
Alternatively, duplicate `docker-compose.yml`, strip other services, and run
`docker compose up -d`.
## 6. Deploy Bot + Librarian (Pi B)
1. Repeat SMB mount on Pi B (same mount path).
2. Copy repo / pull latest changes.
3. Create `.env` files with tokens (or copy from control machine).
4. Start bot and librarian:
```bash
docker compose up -d bot librarian
```
## 7. Optional: Run Supporting Liquidsoap Radio
- Keep Liquidsoap on Pi A as-is, using the same music directories. Ensure the
musician container has read access to those directories (bind mount).
## 8. Verifying
1. `docker ps` on each Pi to confirm containers running.
2. Inspect logs under the mounted logs directory (`/mnt/conjurer/logs`).
3. Join Discord server; issue commands to confirm functionality.
4. Hit health endpoints manually (e.g. `curl http://PIB:5000/conjurer`).
## Rebalancing Suggestions
- If librarian CPU spikes become an issue, move it to Pi A or another host.
- If you add a dedicated NAS, mount the network share read-only for the musician
container and read/write for other services.
## 9. Future Kubernetes Deployment (Outline)
### Hardware Considerations
- Minimum three nodes for HA: use the existing two Pis plus one additional Pi 4
(8 GB preferred). Use Windows PC as storage provider via NFS/SMB CSI driver or
as a data gateway.
- Consider Pi clusters with USB SSDs for better I/O.
### Cluster Setup Steps
1. Install a lightweight Kubernetes distribution (e.g., k3s) on each Pi:
```bash
curl -sfL https://get.k3s.io | sh -
# On additional nodes
curl -sfL https://get.k3s.io | K3S_URL=https://MASTER:6443 K3S_TOKEN=HACKME sh -
```
2. Install MetalLB for load balancer support on LAN.
3. Configure persistent volumes using:
- `nfs-subdir-external-provisioner` pointing to Windows share (ensure Windows
host supports NFS or run an NFS gateway on another machine).
- Alternatively, attach individual USB drives to each Pi and use
`local-path-provisioner` for node-local storage.
4. Create Kubernetes `Secret` objects for tokens (`DISCORD_TOKEN`, etc.).
5. Define `Deployment` manifests for each service (bot, musician, librarian) and
associated `Services`.
6. Expose Discord bot ports via `NodePort` or Ingress.
7. Use `StatefulSet` if you need stable identity for the musician service (due to
local storage).
### Optimisation Tips
- Keep CPU-heavy librarian pods optionally on a beefier node; use
`nodeSelector`/`affinity` to pin workloads.
- Consider splitting the persistent storage: music on Pi A (USB disk), logs and
configs on Pi B, backups on Windows.
- For improved reliability, add at least one extra Pi for quorum and to host the
communication bridge if the bot node fails.
## Summary Checklist
1. Prepare Windows shares & tokens.
2. Configure `docker/env/*.env` using `HACKME!` templates as reference.
3. Install Docker on Pis, mount network shares.
4. Launch musician on Pi A, bot + librarian on Pi B.
5. Verify Discord functionality and API endpoints.
6. Plan Kubernetes migration when ready (k3s + MetalLB + storage provisioner).
-444
View File
@@ -1,444 +0,0 @@
# Conjurer on Docker / Proxmox
Runbook for running Conjurer as Docker containers across Proxmox VMs:
| Component | VM | Container | Port | Image |
|-----------|----|-----------|------|-------|
| **Main bot** | VM-bot | `conjurer-bot` | 5000 (Flask comm) | `docker/Dockerfile.bot` |
| **Librarian** | VM-librarian | `conjurer-librarian` | 5001 | `docker/Dockerfile.librarian` |
| **Musician** | VM-musician | `conjurer-musician` | 5000 (+ radio) | `docker/Dockerfile.musician` |
The three talk to each other over HTTP on the Proxmox LAN. Direction of calls:
```
bot --(/mp3,/get_music,/add_to_priority,...)--> musician
bot --(/query)--------------------------------> librarian
musician --(/prepped_tracks)--------------------> bot
librarian --(/conjurer results)-----------------> bot
```
Everything is configured through `CONJURER_*` environment variables (see the
`docker/env/*.env.example` files). Nothing is hardcoded to a host path anymore.
---
## 0. Prerequisites (per VM)
Create a small Linux VM in Proxmox (Debian 12 / Ubuntu 22.04+ is fine), then
install Docker:
```bash
sudo apt-get update && sudo apt-get install -y ca-certificates curl git
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER" # log out/in afterwards
```
Clone the repo on each VM (they build from it):
```bash
sudo mkdir -p /opt && cd /opt
git clone https://github.com/migatu/conjurer.git
cd conjurer
```
> All `docker compose` commands below are run **from the repo root** (`/opt/conjurer`),
> because the compose files use `context: ..` relative to `docker/`.
---
## 1. Main bot (VM-bot)
### 1a. Prepare host directories
```bash
sudo mkdir -p /srv/conjurer/data /srv/conjurer/secrets
```
### 1b. Preserve existing command history ⭐
The bot's conversation memory and settings live in JSON files. Copy them from
your current deployment (e.g. the Pi's `/home/pi/Conjurer/`) into the data
volume so the history carries over:
```bash
# run on the Pi, or scp the files across, then place them here:
sudo cp pamiec.json /srv/conjurer/data/ # AI conversation history
sudo cp pamiec_muzyki.json /srv/conjurer/data/ # music-DJ memory
sudo cp settings.json /srv/conjurer/data/ # word/cyclic reactions
sudo cp system_gpt_settings.json /srv/conjurer/data/
sudo cp accident_log.json /srv/conjurer/data/ # if present
```
If you skip this, the container starts with the (empty) template files baked
into the image and history begins fresh.
### 1c. Tokens
Drop your existing netrc (the one with `discord`, `openai`, `spotipy`,
`youtube` entries) into the secrets dir:
```bash
sudo cp ~/.netrc /srv/conjurer/secrets/.netrc
sudo chmod 600 /srv/conjurer/secrets/.netrc
```
(Alternatively skip netrc and set `DISCORD_TOKEN` / `OPENAI_API_KEY` in the env
file — those take precedence.)
For the **Claude backend** add an `anthropic` machine to the same netrc (or set
`ANTHROPIC_API_KEY` in `bot.env`):
```
machine anthropic
password sk-ant-...
```
You only need this if you actually switch the bot to Claude — see 1c-bis.
### 1c-bis. AI backend switch (GPT ↔ Claude)
The bot's AI chat runs on one backend at a time, chosen by a single switch:
- **At startup**, set `CONJURER_AI_CONFIG` in `bot.env``gpt` (default) or
`claude`. Leave it unset to use the `"active"` key in
`system_gpt_settings.json` (falls back to `gpt`).
- **At runtime**, `$gadaj_teraz <config>` (Vykidailo only) flips the backend live
and writes the choice back into `system_gpt_settings.json`.
The provider configs (which backend, which models) are collected in
`system_gpt_settings.json` under the third list element — add further AIs there.
`claude` needs `ANTHROPIC_API_KEY`; `gpt` needs `OPENAI_API_KEY`. Image
generation (`imaginuje sobie:`) and personal assistants stay on OpenAI whatever
the switch says (Anthropic has no equivalent) and degrade quietly if OpenAI is
not configured, so a Claude-only box still boots.
### 1d. Configure and launch
```bash
cp docker/env/bot.env.example docker/env/bot.env
# edit docker/env/bot.env: set CONJURER_FILE_SERVICE / _LIBRARIAN_SERVICE to the
# other VMs' IPs, and CONJURER_API_KEY (same value on all three) if you want auth.
docker compose -f docker/compose.bot.yaml up -d --build
docker logs -f conjurer-bot
```
Look for `Extension loaded: …` for each cog and `All systems: operational`.
### 1e. Startup model: core cogs vs service-gated cogs
The bot **always** starts with the cogs that depend on nothing but itself
(administration, AI, other, latex, voice, conanjurer). Cogs that need a
sibling service are **health-gated**:
| Group | Cogs | Enabled when |
|-------|------|--------------|
| musician | `music_commands`, `radio_commands`, `file_search_commands` | `GET {FILE_SERVICE}/mp3` answers |
| librarian | `librarian_commands` | librarian answers HTTP at all |
When a service is down its cogs stay disabled (commands simply don't exist)
and the log says so. A watchdog re-checks every 5 minutes and enables the
cogs the moment the service starts answering — no bot restart needed.
A single broken cog (missing pip package, bad import) is skipped with a full
traceback in the log; it never takes the whole bot down.
### 1f. Troubleshooting a crash-looping container
`docker logs conjurer-bot` now shows the real reason (the bot logs to stdout
as well as the rotating file). The most common cases:
- **`FATAL: Discord token missing`** — the secrets mount is missing/empty or
`CONJURER_NETRC_FILE` points elsewhere. Check:
`docker inspect -f '{{json .Mounts}}' conjurer-bot | jq` and
`docker exec conjurer-bot ls -la /secrets/` (after a manual
`docker run … sleep infinity` if it crash-loops too fast).
- **Missing state files** — not fatal anymore: missing dirs are created and
missing JSON state is seeded from the repo templates baked into the image
(existing files are never overwritten). Fix the mount at your leisure.
**About files "disappearing" from `/srv/conjurer/...`:** nothing in this stack
deletes host files — the entrypoint and the bot only ever *create* missing
files. With a bind mount, `/srv/conjurer/data` **is** the live state (not an
installation staging area): don't delete it after a successful install.
If files vanished, the usual suspects are `docker compose down -v` (only
affects *named* volumes, not binds), a re-provisioned VM, or copying the files
to a different path than the one in the compose `volumes:` line — verify with
`docker inspect -f '{{json .Mounts}}' conjurer-bot`.
---
## 2. Librarian (VM-librarian)
### 2a. Mount the DOI database
The librarian checks keyword hits from Crossref against a local database of
DOI chunk files (`0_chunk.txt … N_chunk.txt`). Put that database on the VM and
point the volume at it:
```bash
sudo mkdir -p /srv/librarian/doi /srv/librarian/secrets
# copy/replicate your chunk files into /srv/librarian/doi/
```
> The old Windows path `C:\Database\chunks\` is now `CONJURER_LIBRARIAN_DB_PATH`
> (defaults to `/doi/` in the container). `CONJURER_LIBRARIAN_MAXTHREADS` (41)
> and `CONJURER_LIBRARIAN_CHUNK` (`_chunk.txt`) are configurable too.
### 2b. Configure and launch
```bash
cp docker/env/librarian.env.example docker/env/librarian.env
# edit: CONJURER_MAIN_BOT=http://BOT_VM_IP:5000, CONJURER_CROSSREF_MAILTO, CONJURER_API_KEY
docker compose -f docker/compose.librarian.yaml up -d --build
docker logs -f conjurer-librarian
```
---
## 3. Musician (VM-musician)
The web service is container-ready (`docker/Dockerfile.musician` +
`compose.musician.yaml`). It containerises the **Flask file/playlist service
only** — the Liquidsoap radio (`radio_conjurer.liq`), `script.params` and any
Samba/NFS share tooling are separate and typically stay on the host or a
dedicated setup (you'll adapt those yourself).
### 3a. Two volumes: the library and the writable state
| Mount | Container path | Holds |
|-------|---------------|-------|
| `/srv/musician/music` | `/music` (`CONJURER_MUSIC_FOLDER`) | your mp3 library (indexed/served) |
| `/srv/musician/data` | `/data` (`CONJURER_MUSICIAN_BASE`) | playlists, logs, `radio_log.log`/`persistence.log` |
```bash
sudo mkdir -p /srv/musician/music /srv/musician/data
# point /srv/musician/music at (or copy in) your mp3s
```
### 3b. Preserve existing playlists/state (optional)
If you already run the musician, copy its working playlists into the data
volume so nothing is regenerated from scratch:
```bash
sudo cp all_playlist.playlist hit.playlist request.playlist \
priority_queue.playlist playlist.json /srv/musician/data/ 2>/dev/null || true
```
The container's entrypoint (`docker/entrypoint.musician.sh`) creates any
missing playlists and touches `radio_log.log` / `persistence.log` empty so the
track-forwarding thread waits instead of crashing when the radio runs
elsewhere. It never overwrites files you copied in.
### 3c. Configure and launch
```bash
cp docker/env/musician.env.example docker/env/musician.env
# edit: CONJURER_MAIN_BOT=http://BOT_VM_IP:5000 and CONJURER_API_KEY (match the bot)
docker compose -f docker/compose.musician.yaml up -d --build
docker logs -f conjurer-musician
```
### 3d. Radio coupling (if you keep Liquidsoap separate)
The musician only forwards "now playing" to the bot by tailing the radio's
`radio_log.log` / `persistence.log`. To wire them up, have Liquidsoap write
those two files into the same `/srv/musician/data` directory (or set
`CONJURER_RADIO_LOG` / `CONJURER_PERSISTENCE_LOG` to wherever it writes). The
`/stream` page template is served from the baked-in `/app/stream.html`
(override with `CONJURER_STREAM_TEMPLATE` if you customise it).
### 3e. Radio (Liquidsoap) container
`docker/Dockerfile.radio` builds the full Liquidsoap environment through
**opam**, with the OCaml/opam/Liquidsoap versions selectable at build time:
| Build arg | Default | Notes |
|-----------|---------|-------|
| `LIQUIDSOAP_VERSION` | `2.1.4` | what `radio_conjurer.liq` targets |
| `OCAML_VERSION` | `4.14.2` | 2.1.x needs OCaml **4.x** (never 5); prod ran 4.13.0 — pass it for exact parity |
| `OPAM_VERSION` | `2.1.5` | static binary from GitHub releases |
| `LIQ_OPAM_PACKAGES` | `mad lame cry taglib pulseaudio samplerate inotify ffmpeg` | exactly the features the script uses (mp3 in/out, icecast, tags/replaygain, pulse mic/out, watch-reload) |
The container runs **both Liquidsoap and Icecast** — the stream is served
from this one container (`output.icecast(host="localhost", …)` in the
script). Volume layout (same paths inside and outside):
| Host & container path | Holds |
|---|---|
| `/srv/betoniarka/data` | playlists, `script.params`, `persistence.log`, `radio_log.log`, the seeded `radio_conjurer.liq` |
| `/srv/betoniarka/music` | the mp3 library |
| `/srv/betoniarka/secrets` | `icecast_credentials.json`**provision at install**, like the other services' secrets |
```bash
sudo mkdir -p /srv/betoniarka/data /srv/betoniarka/music /srv/betoniarka/secrets
# provision the icecast secret (same pattern as the bot's netrc):
echo '{ "password" : "SOURCE_PW", "admin_password" : "ADMIN_PW" }' \
| sudo tee /srv/betoniarka/secrets/icecast_credentials.json
sudo chmod 600 /srv/betoniarka/secrets/icecast_credentials.json
docker compose -f docker/compose.radio.yaml up -d --build
docker logs -f conjurer-radio
```
At startup the entrypoint renders `/etc/icecast2/icecast.xml` from
`docker/icecast.xml.tpl`, filling the source/admin/relay passwords from the
secret (`admin_password`/`relay_password` are optional and default to
`password`), and starts icecast as its unprivileged user. If the secret is
missing, a `CHANGE_ME` placeholder is seeded with a loud warning — the stack
boots but the stream stays locked until you fix it.
The script + `script.params` are seeded into the data volume on first run
and never overwritten (live edits survive rebuilds). Missing playlists are
created empty and a silent emergency-fallback mp3 is generated if the
`single()` file is absent, so the script always boots.
Wire-up: listeners tune to `http://RADIO_VM_IP:8000/mp3-stream`; the bot
points at `CONJURER_RADIO_HARBOR=http://RADIO_VM_IP:54321` (harbor `/skip`
lives in the script itself) and `CONJURER_RADIO_SERVICE=http://RADIO_VM_IP:5005`
(the betoniarka API below).
### 3f. Betoniarka - the radio operator (and why the split)
**Permissions post-mortem.** The pre-split layout had three actors fighting
over the same files: the musician wrote radio playlists **as root**, the
radio expected them **as user `radio`**, and both met on a **root-owned
network share** where `chown` fails by design (root squash / uid mapping).
Every component worked; the combination could not.
**The fix is structural**: the process that *writes* the radio playlists now
lives in the same container as the process that *watches* them, running as
the same `radio` user on a **local** volume. No network share, no chown, no
uid mapping - the class of problem is gone, not patched.
`conjurer_betoniarka/betoniarka.py` runs inside the radio container
(started by the entrypoint as user `radio`, port **5005**) and owns:
- the library scan → `all_playlist.playlist` / `hit.playlist` (local paths,
the same ones Liquidsoap resolves), on start + every 24h + `GET /rescan`
- the bot-facing radio API: `/add_to_priority`, `/create_priority_playlist`,
`/request_radio_file`, `/clear_pr_pls` (+ `GET /ping` for health gating,
`/stream` for the web page)
- tailing `radio_log.log`/`persistence.log` and forwarding play events to
the bot's `/prepped_tracks` (with the shared API key)
The **musician** is now a pure Discord music player: `/mp3`, `/update_mp3`,
`/get_music` and the file-share endpoints. It no longer writes any radio
files and needs no shared partition with the radio VM. The music library
can still be replicated/mounted on both VMs (read-only on the radio side is
fine) - playlists reference the *radio VM's local* paths, generated locally.
Bot wiring after the split (env on the bot):
```
CONJURER_FILE_SERVICE=http://MUSICIAN_VM_IP:5000 # Discord music
CONJURER_RADIO_SERVICE=http://RADIO_VM_IP:5005 # betoniarka (radio cmds)
CONJURER_RADIO_HARBOR=http://RADIO_VM_IP:54321 # liquidsoap /skip
```
`CONJURER_RADIO_SERVICE` defaults to `CONJURER_FILE_SERVICE`, so an
un-split deployment keeps working unchanged. The bot health-gates
`radio_commands` on betoniarka's `/ping` (separate from the musician group,
which now covers only `music_commands` + `file_search_commands`).
**Privileges:** Liquidsoap refuses to run as root (`init: security exit`),
so the main process runs as the dedicated **`radio`** user (member of
`audio`/`pulse-access`) — no `settings.init.allow_root` override. The
entrypoint (root) seeds volumes, renders the icecast config, starts
icecast/pulse, chowns `/srv/betoniarka/data` to `radio` and drops
privileges via `setpriv`. This is also why the opam switch lives in
`/opt/opam` instead of `/root/.opam` (the binary and the liquidsoap stdlib
must be readable by `radio`).
**PulseAudio** (`PULSE_MODE` env):
- `internal` (default) — a system-wide pulse daemon runs inside the container
with a **null sink** (`docker/pulse-system.pa`): no sound hardware needed,
`output.pulseaudio()` plays into the void and the `input.pulseaudio()` mic
path reads silence (which `blank.strip` already gates out). Right choice
for a headless Proxmox VM. Started with `--disallow-module-loading`
(startup modules from `system.pa` still load; only later client-requested
loads are blocked). The `forcibly disabling SHM mode` notice is inherent
to system mode and harmless.
- `host` — mount the host's pulse socket and set `PULSE_SERVER`, for a VM
with real audio hardware (the Pi's Lexicon Lambda setup).
- `none` — you removed the pulse in/out from the script.
**Verdicts on the old Pi setup quirks** (asked during containerisation):
- `ffmpeg.pref` (Pin-Priority 1001 on the `libavcodec59/libavformat59/…`
family): it **did have a purpose** — the opam-built ocaml-ffmpeg bindings
for 2.1.x are compiled against Debian bookworm's FFmpeg 5.x sonames, and
the pin forced those over conflicting Raspberry Pi OS repo builds (even as
a downgrade). In this single-repo container the same versions come
naturally, so the pin is redundant — **the file has been removed from the
repo** (this note preserves the knowledge).
- adding root to `pulse-access`/`audio` groups: needed on the Pi for the
system-wide pulse socket and ALSA device access. The image bakes the same
memberships in (`usermod -aG audio,pulse-access root`) — harmless with the
internal null sink, required for `PULSE_MODE=host`.
---
## 4. Networking & auth
- Open the ports between VMs on the Proxmox LAN: **bot 5000**, **librarian 5001**,
**musician 5000** (+ radio harbor 54321 if used). A simple `ufw allow from
<lan-subnet>` per port is enough; do not expose them to the internet.
- **Auth:** set the same `CONJURER_API_KEY` in all three `*.env` files. Then
every internal call carries `X-Conjurer-Api-Key` and each service rejects
requests without it (HTTP 401). Leave it empty everywhere to disable auth
(fully backward compatible). ⚠️ Setting it on only one side breaks the link.
- The addresses point at each other by VM IP (or a DNS name). Set:
- bot: `CONJURER_FILE_SERVICE`, `CONJURER_RADIO_HARBOR`, `CONJURER_LIBRARIAN_SERVICE`
- librarian & musician: `CONJURER_MAIN_BOT`
---
## 5. Verify
```bash
# bot is up and reachable from another VM:
curl http://BOT_VM_IP:5000/conjurer # -> "ALIVE"
# librarian answers:
curl http://LIBRARIAN_VM_IP:5001/ -I # service reachable
docker ps # all containers "Up"
docker logs conjurer-bot --tail 50
```
In Discord, exercise a command that round-trips through a service (e.g. a music
search that hits the musician, or a librarian query) to confirm the wiring and
the API key.
---
## 6. Updates
```bash
cd /opt/conjurer && git pull
docker compose -f docker/compose.bot.yaml up -d --build # rebuild + restart
```
Data in `/srv/.../data` and `/doi` / `/music` volumes survives rebuilds, so
history and databases persist across updates.
---
## 7. Rollback / coexistence
- The native (Raspberry Pi / systemd) deployment is unaffected — none of the
defaults changed; the container behaviour is opt-in via `CONJURER_DATA_DIR`
and the other env vars. You can run both during migration.
- To roll back a VM: `docker compose -f docker/compose.<svc>.yaml down` and
restart the previous deployment. The JSON state in `/srv/.../data` is plain
files you can copy back to the Pi if needed.
---
## Notes on the images
- **Bot** uses the vendored `yt_dlp/` and `spotify_dl/` forks (they win over the
pip packages because `/app` is first on `sys.path`), so your patches stay
active without the old `sed` hacks from `install_main_bot.sh`.
- **Tectonic** (LaTeX `$latex` command) is installed best-effort; if the build
step fails the bot still runs, just without LaTeX. Remove that layer from
`Dockerfile.bot` if you don't need it.
- Voice needs `ffmpeg` + `libopus0` (both in the image). No microphone/pyaudio
is required — voice is received over Discord and transcribed via AssemblyAI.
@@ -1,231 +0,0 @@
# Migracja: „working copy" → „prototype"
Runbook upgrade'u działającego deploymentu bota (Raspberry Pi) z wersji
**working copy** (kod w roocie repo, bez auth między usługami, bez integracji
Conan) na wersję **prototype** (konfiguracja przez zmienne środowiskowe,
opcjonalna autoryzacja wewnętrznych wywołań HTTP, integracja Conan Exiles).
> **Najważniejsze:** upgrade jest **wstecznie kompatybilny**. Bez ustawiania
> żadnych nowych zmiennych bot działa tak jak dotąd — istniejące tokeny z
> `~/.netrc` i domyślne ścieżki są zachowane. Wszystkie nowe funkcje
> (autoryzacja API, most Conan, powiadomienia o graczach) są **opt-in**.
---
## 0. Status / warunek wstępny
Pełny kod prototype znajduje się obecnie na branchu **`proto-improvements`**.
Na `main` jest na razie sama restrukturyzacja (working copy w roocie). Zanim
zmigrujesz produkcję z `main`, zmerguj prototype do `main`
(`proto-improvements``main`) albo deployuj bezpośrednio z brancha
`proto-improvements`. Dalsza część zakłada, że prototype jest już dostępny pod
refem, który checkoutujesz w kroku 2.
**Układ deploymentu (bez zmian):**
| | Ścieżka |
|---|---|
| Klon repo | `/home/pi/conjurer` |
| Runtime bota | `/home/pi/Conjurer` |
| Virtualenv | `/home/pi/Conjurer/env` (używany przez `conjurer.service`) |
| Usługa systemd | `conjurer.service``ExecStart … /home/pi/Conjurer/bot.py` |
| Deploy | `deploy.sh` (kopiuje pliki z repo do runtime i restartuje usługę) |
---
## 1. Backup i punkt powrotu
```bash
# zapamiętaj aktualny commit (do ewentualnego rollbacku)
git -C /home/pi/conjurer rev-parse HEAD > /home/pi/conjurer_rollback_commit.txt
# snapshot runtime (config + dane)
sudo cp -a /home/pi/Conjurer /home/pi/Conjurer.bak.$(date +%Y%m%d_%H%M%S)
```
Sekrety nadal pochodzą z `~/.netrc` (Discord/OpenAI/Spotify/YouTube) — upewnij
się, że ten plik istnieje i jest aktualny. Migracja go nie dotyka.
---
## 2. Pobranie kodu prototype
```bash
cd /home/pi/conjurer
git fetch --all
git checkout main && git pull # gdy prototype jest już w main
# albo, do czasu merge: git checkout proto-improvements && git pull
```
---
## 3. Instalacja nowych zależności
Wersja prototype dodaje do `requirements_bot.txt` dwa pakiety używane przez most
Conan: **`aiomcrcon`** i **`asyncssh`**.
```bash
source /home/pi/Conjurer/env/bin/activate
python3 -m pip install -r /home/pi/conjurer/requirements_bot.txt
deactivate
```
> Nawet bez tych pakietów bot się uruchomi — importy w module Conan są osłonięte
> (`try/except ImportError`), a integracja pozostaje uśpiona. Instalacja jest
> potrzebna tylko jeśli faktycznie używasz mostu Conan.
---
## 4. (Opcjonalnie) Konfiguracja zmiennych środowiskowych
Wersja prototype czyta konfigurację ze zmiennych środowiskowych z fallbackiem na
dotychczasowe wartości. **Pomiń ten krok dla zwykłego upgrade'u** — domyślne
ścieżki i `~/.netrc` wystarczą. Ustaw zmienne tylko gdy włączasz nową funkcję.
### 4a. Plik środowiskowy dla systemd
```bash
sudo tee /home/pi/Conjurer/conjurer.env >/dev/null <<'EOF'
# --- Autoryzacja wewnętrznych wywołań HTTP (opcjonalne) ---
# Ten sam klucz MUSI być ustawiony na bocie, musicianie i librarianie.
CONJURER_API_KEY=
# --- Adresy usług wewnętrznych (domyślne wartości jak dotąd) ---
#CONJURER_FILE_SERVICE=http://192.168.1.15:5000
#CONJURER_RADIO_HARBOR=http://192.168.1.15:54321
#CONJURER_LIBRARIAN_SERVICE=http://192.168.1.192:5001
# --- Most Conan Exiles (opcjonalne; puste = wyłączone) ---
#CONAN_GM_ROLE_ID=0
#CONAN_RCON_HOST=
#CONAN_RCON_PORT=25575
#CONAN_RCON_PASSWORD=
#CONAN_CHAT_CHANNEL_ID=0
#CONAN_EVENTS_CHANNEL_ID=0
# Powiadomienia o wejściu gracza — ustaw id kanału, by włączyć:
#CONAN_JOIN_CHANNEL_ID=0
#CONAN_PLAYER_POLL_SECONDS=60
#CONAN_LOG_MODE=local
#CONAN_LOG_PATH=
EOF
sudo chmod 600 /home/pi/Conjurer/conjurer.env
```
### 4b. Podłączenie pliku do usługi
Dodaj `EnvironmentFile` do sekcji `[Service]` w `conjurer.service`
(`/etc/systemd/system/conjurer.service`):
```ini
[Service]
Type=simple
EnvironmentFile=/home/pi/Conjurer/conjurer.env
ExecStart=/home/pi/Conjurer/env/bin/python3 /home/pi/Conjurer/bot.py
Restart=on-abort
```
```bash
sudo systemctl daemon-reload
```
> `conjurer.service` w repo również warto zaktualizować o tę linię, ale `deploy.sh`
> **nie** nadpisuje jednostki systemd przy każdym deployu — wpis robisz raz, ręcznie.
---
## 5. Deploy i restart
```bash
cd /home/pi/conjurer
./deploy.sh # kopiuje m.in. bot.py oraz conanjurer_commands/_functions.py do runtime
```
`deploy.sh` na końcu sam wykonuje `systemctl restart conjurer.service`. Jeśli
zmieniałeś jednostkę systemd w kroku 4b, wcześniej zrób `daemon-reload`.
---
## 6. Weryfikacja
```bash
journalctl -u conjurer.service -f
```
Czego szukać w logu (`/home/pi/Conjurer` → plik logu również):
- `Loading … module done` dla kolejnych rozszerzeń, w tym **`Loading conanjurer commands module done`**
- Gdy most Conan nieskonfigurowany: `Conan: log watch disabled (not configured)`
oraz `Conan: player-join notifications disabled (no channel or RCON)` — to
oczekiwane, integracja jest uśpiona
- `All systems: operational`
Szybki test funkcjonalny: bot łączy się z Discordem, dotychczasowe komendy
działają, radio/biblioteka odpowiadają jak wcześniej.
---
## 7. Zmiany zachowania, o których warto wiedzieć
| Obszar | Working copy | Prototype |
|---|---|---|
| Konfiguracja | zahardkodowana per-platforma | env-vary z fallbackiem na stare wartości |
| Tokeny | tylko `~/.netrc` | env-var → fallback `~/.netrc` (stare działa) |
| Wewnętrzne HTTP (music/radio/librarian) | bez nagłówków | wysyła `X-Conjurer-Api-Key`, **gdy** `CONJURER_API_KEY` ustawione |
| Endpointy przychodzące bota | bez weryfikacji | `_authorize_request()` zwraca 401 przy złym kluczu (no-op gdy klucz pusty) |
| Pętla bota | wątki + `join()` | pojedyncza pętla asyncio z czystym shutdownem |
| Moduł Conan | obecny, **nieładowany** (miał SyntaxError) | naprawiony i ładowany; uśpiony bez konfiguracji |
---
## 8. Włączanie funkcji opcjonalnych
### 8a. Autoryzacja wewnętrznych wywołań HTTP
Ustaw **ten sam** `CONJURER_API_KEY` na **wszystkich** usługach: bocie,
musicianie (`conjurer_musician`) i librarianie. Po ustawieniu:
- bot dokleja nagłówek do wywołań do file-service/radia/biblioteki,
- usługi odrzucają (401) żądania bez poprawnego klucza.
⚠️ Ustawienie klucza tylko po jednej stronie zepsuje komunikację (401). Albo
wszędzie, albo nigdzie.
### 8b. Most Conan Exiles
Ustaw `CONAN_RCON_HOST` + `CONAN_RCON_PASSWORD` (komendy GM `say/players/kick/
rcon/ogłoś`) oraz, dla mirrorowania czatu/zdarzeń, `CONAN_LOG_PATH`
(+ `CONAN_CHAT_CHANNEL_ID`/`CONAN_EVENTS_CHANNEL_ID`).
### 8c. Powiadomienia o wejściu gracza
Ustaw **`CONAN_JOIN_CHANNEL_ID`** na id kanału Discord. Funkcja odpytuje RCON
`listplayers` co `CONAN_PLAYER_POLL_SECONDS` i ogłasza nowych graczy. Pozostaw
`0`, aby trzymać ją wyłączoną.
---
## 9. Rollback
```bash
cd /home/pi/conjurer
git checkout "$(cat /home/pi/conjurer_rollback_commit.txt)"
./deploy.sh
sudo systemctl restart conjurer.service
```
W razie potrzeby przywróć snapshot runtime z `/home/pi/Conjurer.bak.*`. Nowe
zależności (`aiomcrcon`, `asyncssh`) mogą zostać w venv — nie przeszkadzają
starszej wersji.
---
## 10. Migracja usługi musician (jeśli prowadzisz radio)
Stabilny wariant `musician_old` (zahardkodowane adresy) został zastąpiony przez
`conjurer_musician` (env-driven, domyślnie `127.0.0.1`). Jeśli bot i musician są
na **różnych** hostach, ustaw na musicianie:
```bash
CONJURER_MAIN_BOT=http://<ip-bota>:5000 # gdzie bot serwuje /prepped_tracks
CONJURER_API_KEY=<ten sam sekret co bot> # jeśli włączasz auth (8a)
CONJURER_MUSIC_FOLDER=/home/pi/MediaShare/mp3
```
oraz na bocie `CONJURER_FILE_SERVICE=http://<ip-musiciana>:5000`. Szczegóły
auth/kontraktu — patrz opis usługi `conjurer_musician`.
-71
View File
@@ -1,71 +0,0 @@
import os
import discord
from discord.ext import commands
import file_search_functions
class FileSelectView(discord.ui.View):
def __init__(self, files):
super().__init__(timeout=60)
options = [discord.SelectOption(label=os.path.basename(f)[:100], description=f, value=f) for f in files]
self.select = discord.ui.Select(
placeholder="Select files...",
min_values=1,
max_values=len(options),
options=options
)
self.select.callback = self.select_callback
self.add_item(self.select)
self.selected = []
async def select_callback(self, interaction: discord.Interaction):
# Store selected file paths
self.selected = self.select.values
await interaction.response.defer()
@discord.ui.button(label="Accept", style=discord.ButtonStyle.green)
async def accept_callback(self, interaction: discord.Interaction, button: discord.ui.Button):
# Handle acceptance
if not self.selected:
await interaction.response.send_message("No files selected.", ephemeral=True)
return
# Publish selected files
links = file_search_functions.publish(self.selected)
# Display the returned links
await interaction.response.edit_message(content="Published Links:\n" + "\n".join(links), view=None)
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.red)
async def cancel_callback(self, interaction: discord.Interaction, button: discord.ui.Button):
# Handle cancellation
await interaction.response.edit_message(content="Operation cancelled.", view=None)
class FileSearchCog(commands.Cog):
"""Cog providing a file search and publish command."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.has_any_role("Jarl", "Thane")
@commands.command(name="tajna_biblioteka_inkwizycji")
async def findfiles(self, ctx: commands.Context, entries: int, *, keywords: str):
"""
Search for files matching keywords and publish selected ones.
Usage: !findfiles <entries 1-10> <keywords>
"""
if entries < 1 or entries > 10:
await ctx.send("❌ Entries must be between 1 and 10.")
return
keyword_list = keywords.split()
files = file_search_functions.find_matches(entries, keyword_list)
if not files:
await ctx.send("🔍 No matching files found.")
return
view = FileSelectView(files)
file_list = "\n".join(f"{i+1}. {f}" for i, f in enumerate(files))
await ctx.send(
f"🔍 Found files (select and click Accept or Cancel):\n{file_list}",
view=view
)
async def setup(bot: commands.Bot):
await bot.add_cog(FileSearchCog(bot))
-31
View File
@@ -1,31 +0,0 @@
import requests
# Base URL for Flask service
BASE_URL = 'http://192.168.1.15:5000'
def find_matches(entries, keywords):
"""
Call the get_share_list endpoint.
entries: int (1-10)
keywords: list of strings
Returns: list of file paths
"""
payload = {'entries': entries, 'keywords': keywords}
response = requests.post(f"{BASE_URL}/get_share_list", json=payload)
response.raise_for_status()
data = response.json()
return data.get('files', [])
def publish(file_paths):
"""
Call the get_share_links endpoint.
file_paths: list of file path strings
Returns: list of published URLs
"""
payload = {'file_paths': file_paths}
response = requests.post(f"{BASE_URL}/get_share_links", json=payload)
response.raise_for_status()
data = response.json()
return data.get('links', [])
-98
View File
@@ -1,98 +0,0 @@
#!/usr/bin/env bash
# "apt dla fontów": Google Fonts ZIP → GitHub (ofl RAW) → fonts.gstatic.com → mirrory.
# System-wide install do /usr/local/share/fonts/fontget/<rodzina> + fc-cache.
# Usage: sudo ./fontget.sh "IM Fell English SC" "Cinzel Decorative" "EB Garamond"
set -Eeuo pipefail
SUDO=sudo; [ "$(id -u)" -eq 0 ] && SUDO=
log(){ echo -e "[+] $*"; }
warn(){ echo -e "[WARN] $*" >&2; }
die(){ echo -e "[FATAL] $*" >&2; exit 1; }
need(){ command -v "$1" >/dev/null 2>&1 || die "Missing: $1"; }
need fc-cache
command -v wget >/dev/null 2>&1 || command -v curl >/dev/null 2>&1 || die "Need wget/curl"
command -v unzip >/dev/null 2>&1 || warn "unzip missing — ZIP installs limited"
command -v file >/dev/null 2>&1 || warn "file missing — MIME checks limited"
command -v fc-list >/dev/null 2>&1 || die "Missing: fc-list"
fetch(){ local u="$1" o="$2"; if command -v wget >/dev/null 2>&1; then wget -O "$o" --https-only --no-verbose "$u"; else curl -L --fail --show-error --output "$o" "$u"; fi; }
slug(){ echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g'; }
present(){ fc-list -f '%{family}\n' | tr ',' '\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]' | sort -u | grep -Eiq "$1"; }
install_dir="/usr/local/share/fonts/fontget"; $SUDO mkdir -p "$install_dir"
is_zip(){ command -v file >/dev/null 2>&1 && file "$1" | grep -qi zip; }
gf_zip_try(){
local name="$1" outdir="$2" enc zip
enc="$(python3 - <<PY
import urllib.parse,sys
print(urllib.parse.quote(sys.argv[1]))
PY
"$name")" || enc="$(echo "$name" | sed 's/ /%20/g')"
zip="$(mktemp /tmp/fontget.XXXXXX.zip)"
if fetch "https://fonts.google.com/download?family=${enc}" "$zip"; then
if is_zip "$zip"; then $SUDO mkdir -p "$outdir"; $SUDO unzip -o -q "$zip" -d "$outdir" || true; rm -f "$zip"; return 0; fi
fi
rm -f "$zip" || true; return 1
}
gh_ofl_try(){
local family="$1" outdir="$2" html
html="$(mktemp /tmp/ofl.$(slug "$family").html)"
if fetch "https://github.com/google/fonts/tree/main/ofl/$(slug "$family")" "$html"; then
mapfile -t paths < <(grep -Eo '/google/fonts/blob/main/ofl/'"$(slug "$family")"'/[^"]+\.(ttf|otf)' "$html" | sed 's|/blob/|/raw/|g' | sort -u)
[ "${#paths[@]}" -gt 0 ] || { rm -f "$html"; return 1; }
$SUDO mkdir -p "$outdir"
for p in "${paths[@]}"; do fetch "https://github.com$p" "$outdir/$(basename "$p")" || true; done
rm -f "$html"; return 0
fi
rm -f "$html"; return 1
}
gstatic_try(){ local out="$2"; shift 2; for u in "$@"; do fetch "$u" "$out" && [ -s "$out" ] && return 0; done; return 1; }
report(){ local label="$1" re="$2"; if present "$re"; then printf "RESULT: %-24s : OK\n" "$label"; else printf "RESULT: %-24s : FAIL\n" "$label"; fi }
if [ $# -lt 1 ]; then echo "Usage: $0 <Font Family> [Another ...]"; exit 1; fi
for family in "$@"; do
echo; log "=== $family ==="
fam_slug="$(slug "$family")"
outdir="$install_dir/$fam_slug"
$SUDO mkdir -p "$outdir"
case "$fam_slug" in
cinzel-decorative|cinzel|cinzel-decorative-black)
fetch "https://github.com/google/fonts/raw/main/ofl/cinzeldecorative/CinzelDecorative-Black.ttf" "$outdir/CinzelDecorative-Black.ttf" || warn "Cinzel fetch failed"
;;
im-fell-english-sc)
gstatic_try "$fam_slug" "$outdir/IMFeENsc.ttf" \
"https://fonts.gstatic.com/s/imfellenglishsc/v7/h3Tn6yWfw4b5qaLD1RWvz5ATixNthKRRR1XVH3rJNiw.ttf" \
"https://fonts.gstatic.com/s/imfellenglishsc/v6/h3Tn6yWfw4b5qaLD1RWvz5ATixNthKRRR1XVH3rJNiw.ttf" \
|| { fetch "https://github.com/google/fonts/raw/main/ofl/imfellenglishsc/IMFeENsc28P.ttf" "$outdir/IMFeENsc.ttf" || true
[ -s "$outdir/IMFeENsc.ttf" ] || fetch "https://github.com/google/fonts/raw/main/ofl/imfellenglishsc/IMFellEnglishSC-Regular.ttf" "$outdir/IMFeENsc.ttf" || true
[ -s "$outdir/IMFeENsc.ttf" ] || fetch "https://www.wfonts.com/download/data/2016/06/14/im-fell-english-sc/IMFeENsc28P.ttf" "$outdir/IMFeENsc.ttf" || true
[ -s "$outdir/IMFeENsc.ttf" ] || fetch "https://www.1001freefonts.com/d/6800/IMFeENsc28P.ttf" "$outdir/IMFeENsc.ttf" || true; }
;;
eb-garamond|ebgaramond)
# Najpierw apt (jeśli chcesz: sudo apt install fonts-ebgaramond), tu tylko web:
gf_zip_try "$family" "$outdir" || gh_ofl_try "$family" "$outdir" || true
;;
*)
gf_zip_try "$family" "$outdir" || gh_ofl_try "$family" "$outdir" || true
;;
esac
$SUDO find "$outdir" -type f -size 0 -print -delete || true
$SUDO find "$outdir" -type f \( -name "*.ttf" -o -name "*.otf" \) -exec chmod 0644 {} \; || true
$SUDO find "$outdir" -type d -exec chmod 0755 {} \; || true
$SUDO fc-cache -f -v >/dev/null || true
case "$fam_slug" in
cinzel* ) report "Cinzel Decorative" '^cinzel decorative( |$)' ;;
im-fell-english-sc ) report "IM Fell English SC" '^im fell english sc( |$)' ;;
eb-garamond* ) report "EB Garamond" '^eb garamond( |$)' ;;
* ) report "$family" "$(echo "$family" | tr '[:upper:]' '[:lower:]' | sed 's/ /.* /g')" ;;
esac
done
echo; log "All done."
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Regular → Executable
+8 -17
View File
@@ -1,23 +1,14 @@
#!/bin/bash
sudo apt-get install python3-dev
sudo apt-get install portaudio19-dev python3-pyaudio
sudo apt-get install
cd /home/pi || exit
cd /home/pi
mdkir Conjurer
cd Conjurer ||exit
python3 -m venv /home/pi/Conjurer/.env
cd Conjurer
python3 -m venv /home/pi/Conjurer/env
cp /home/pi/conjurer/requirements_bot.txt /home/pi/Conjurer/
# trunk-ignore(shellcheck/SC1091)
source /home/pi/Conjurer/.env/bin/activate
./.env/bin/python3 -m pip install --upgrade pip
./.env/bin/python3 -m pip install -r requirements_bot.txt
sed -i -e 's/os.rename/shutil.copy/g' ./.env/lib/python3.11/site-packages/spotify_dl/youtube.py
sed -i '1i\import shutil' ./.env/lib/python3.11/site-packages/spotify_dl/youtube.py
sed -i '1i\import logging' ./.env/lib/python3.11/site-packages/spotify_dl/spotify.py
sed -i '21i\ logger = logging.getLogger("discord")' ./.env/lib/python3.11/site-packages/spotify_dl/spotify.py
sed -i '22i\ logger.info("Playlist")' ./.env/lib/python3.11/site-packages/spotify_dl/spotify.py
#add sed changing signal registration to catch exception in spotify dl init
source ./env/bin/activate
./env/bin/python3 -m pip install --upgrade pip
./env/bin/python3 -m pip install -r requirements_bot.txt
sed -i -e 's/os.rename/shutil.copy/g' ./env/lib/python3.11/site-packages/spotify_dl/youtube.py
sed -i '1i\import shutil' ./env/lib/python3.11/site-packages/spotify_dl/youtube.py
deactivate
sudo cp ./conjurer.service /etc/systemd/system/
sudo systemctl daemon-reload
Regular → Executable
+1 -26
View File
@@ -8,30 +8,9 @@ cp /home/pi/conjurer/conjurer_musician/requirements_musician.txt /home/pi/Conjur
cp /home/pi/conjurer/conjurer_musician/conjurer_musician.py /home/pi/Conjurer/
cp /home/pi/conjurer/conjurer_musician/radio_conjurer.liq /home/pi/Conjurer/
touch /home/pi/Conjurer/discord_mus_service.log
touch /home/pi/Conjurer/all_playlist.txt
touch /home/pi/Conjurer/playlist.json
touch /home/pi/Conjurer/prio_playlist.json
touch /home/pi/Conjurer/all_playlist.playlist
touch /home/pi/Conjurer/priority_queue.playlist
touch /home/pi/Conjurer/hit.playlist
touch /home/pi/Conjurer/request.playlist
touch /home/pi/Conjurer/persistence.log
echo "[]" > /home/pi/Conjurer/persistence.log
sudo usermod -aG pulse-access pi
sudo usermod -aG pulse-access root
sudo usermod -aG audio pi
sudo usermod -aG audio root
echo "Add exception suppresion to signal handler in spotify __ini__.py"
echo "Add password to youtube opts in spotify_dl youtube.py"
echo "NOTE: the old ffmpeg.pref pin is obsolete (containerised radio uses Debian bookworm ffmpeg 5.x natively; file removed from the repo)"
echo "Install opam from installation link"
echo "Initialize opam"
echo "Install liquidsoap and its dependencies"
touch /home/pi/Conjurer/radio_log.log
./env/bin/python3 -m pip install --upgrade pip
./env/bin/python3 -m pip install -r requirements_musician.txt
@@ -45,7 +24,3 @@ sudo cp /home/pi/conjurer/conjurer_musician/radio_service.service /etc/systemd/s
sudo systemctl daemon-reload
sudo systemctl start radio_service.service
sudo systemctl enable radio_service.service
sed -i -e 's/os.rename/shutil.copy/g' ./env/lib/python3.11/site-packages/spotify_dl/youtube.py
sed -i '1i\import shutil' ./env/lib/python3.11/site-packages/spotify_dl/youtube.py
-280
View File
@@ -1,280 +0,0 @@
# latex_commands.py
# -*- coding: utf-8 -*-
from __future__ import annotations
import io
import logging
from typing import Optional
import discord
from discord import app_commands
from discord.ext import commands
from constants import (
ALLOWED_ROLES,
GUILD_ID,
LATEX_MAX_ATTACH_MB,
LATEX_MAX_COMPILE_SECONDS,
LATEX_MAX_ZIP_MB,
LATEX_TEX_ENGINE,
OPENAI_MODEL,
)
from latex_functions import (
compile_single_tex_bytes,
compile_zip_to_zip,
is_safe_asset_name,
is_safe_tex_name,
)
async def _collect_attachments_bytes(attachments, max_mb: int):
"""
Zwraca słownik {filename: bytes} dla wszystkich załączników, do limitu MB.
Nie podbija wyjątków z .read() — zostawiamy to wyżej; tu zwracamy, co się udało.
"""
out = {}
for a in attachments or []:
# limit rozmiaru
if (getattr(a, "size", 0) or 0) > max_mb * 1024 * 1024:
continue
data = await a.read()
out[a.filename] = data
return out
class LatexModule(commands.Cog):
def __init__(self, bot: commands.Bot, logger_name: str):
self.bot = bot
self.logger = logging.getLogger(logger_name)
self.logger_name = logger_name
# -------- /tex (PDF + diagnoza; log -> debug logger) --------
@commands.hybrid_command(
nsfw=False,
name="tex",
description="Kompiluje załączone .tex; PDF-y odsyła. Błędy: diagnoza z OpenAI.",
guild=None if GUILD_ID is None else discord.Object(id=GUILD_ID),
)
@commands.has_any_role(*ALLOWED_ROLES)
async def tex(
self, ctx: commands.Context, include_source_excerpt: Optional[bool] = True
):
self.logger.info("LaTeX command invoked by %s", ctx.author)
ch = ctx.message.channel
# ZBIERZ WSZYSTKIE ZAŁĄCZNIKI
async with ch.typing():
all_bytes = await _collect_attachments_bytes(
ctx.message.attachments, LATEX_MAX_ATTACH_MB
)
if not all_bytes:
return await ctx.reply(
f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
)
# PODZIEL NA GŁÓWNE .TEX i WSPIERAJĄCE
tex_names = [
n
for n in all_bytes.keys()
if n.lower().endswith(".tex") and is_safe_tex_name(n)
]
if not tex_names:
return await ctx.reply(
f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
)
support_names = [
n
for n in all_bytes.keys()
if n not in tex_names and is_safe_asset_name(n)
]
files_to_send = []
parts = []
for tex_name in tex_names:
try:
main_bytes = all_bytes[tex_name]
support = [
(n, all_bytes[n])
for n in support_names
if n != tex_name and is_safe_asset_name(n)
]
# (ważne) do supportów dopuszczamy również **inne .tex** dla \input/\include
other_tex = [
(n, all_bytes[n])
for n in tex_names
if n != tex_name and is_safe_tex_name(n)
]
support.extend(other_tex)
res = await compile_single_tex_bytes(
main_bytes,
tex_name,
LATEX_TEX_ENGINE,
LATEX_MAX_COMPILE_SECONDS,
logger="discord",
support_files=support,
)
# Twoja dotychczasowa obsługa success/fail przykładowo:
if res["ok"] and res["pdf_bytes"]:
b = io.BytesIO(res["pdf_bytes"])
b.seek(0)
b.name = res["pdf_name"]
files_to_send.append(discord.File(b, filename=res["pdf_name"]))
parts.append(f"✅ `{tex_name}` → `{res['pdf_name']}`")
else:
parts.append(
f"❌ `{tex_name}` — błąd kompilacji.)"
)
# Tutaj wywołujesz swoją ścieżkę do OpenAI diagnozy, którą ogarnęliśmy wcześniej.
except Exception:
# let it crash — pełny stack w logu i re-raise (Discord.py to pokaże sensownie)
logging.getLogger("discord").exception(
"Unhandled exception while compiling %s", tex_name
)
raise
content = (
f"**LaTeX** — {len(tex_names)} plik(ów). Model: `{OPENAI_MODEL}`\n\n"
+ "\n\n".join(parts)
)
await ctx.reply(
content if len(content) < 1900 else content[:1900] + "",
files=files_to_send,
mention_author=False,
)
# -------- /latexclean (PDF only; log -> debug logger) --------
@commands.hybrid_command(
nsfw=False,
name="latexclean",
description="Kompiluje .tex i odsyła TYLKO PDF (log w debug).",
guild=None if GUILD_ID is None else discord.Object(id=GUILD_ID),
)
@commands.has_any_role(*ALLOWED_ROLES)
async def latexclean(self, ctx: commands.Context):
ch = ctx.message.channel
async with ch.typing():
all_bytes = await _collect_attachments_bytes(
ctx.message.attachments, LATEX_MAX_ATTACH_MB
)
if not all_bytes:
return await ctx.reply(
f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
)
# PODZIEL NA GŁÓWNE .TEX i WSPIERAJĄCE
tex_names = [
n
for n in all_bytes.keys()
if n.lower().endswith(".tex") and is_safe_tex_name(n)
]
if not tex_names:
return await ctx.reply(
f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
)
support_names = [
n
for n in all_bytes.keys()
if n not in tex_names and is_safe_asset_name(n)
]
files_to_send = []
parts = []
for tex_name in tex_names:
try:
main_bytes = all_bytes[tex_name]
support = [
(n, all_bytes[n])
for n in support_names
if n != tex_name and is_safe_asset_name(n)
]
# (ważne) do supportów dopuszczamy również **inne .tex** dla \input/\include
other_tex = [
(n, all_bytes[n])
for n in tex_names
if n != tex_name and is_safe_tex_name(n)
]
support.extend(other_tex)
res = await compile_single_tex_bytes(
main_bytes,
tex_name,
LATEX_TEX_ENGINE,
LATEX_MAX_COMPILE_SECONDS,
logger="discord",
support_files=support,
)
# Twoja dotychczasowa obsługa success/fail przykładowo:
if res["ok"] and res["pdf_bytes"]:
b = io.BytesIO(res["pdf_bytes"])
b.seek(0)
b.name = res["pdf_name"]
files_to_send.append(discord.File(b, filename=res["pdf_name"]))
parts.append(f"✅ `{tex_name}` → `{res['pdf_name']}`")
else:
parts.append(
f"❌ `{tex_name}` — błąd kompilacji.)"
)
# Tutaj wywołujesz swoją ścieżkę do OpenAI diagnozy, którą ogarnęliśmy wcześniej.
except Exception:
# let it crash — pełny stack w logu i re-raise (Discord.py to pokaże sensownie)
logging.getLogger("discord").exception(
"Unhandled exception while compiling %s", tex_name
)
raise
await ctx.reply(
"**LaTeX clean** — " + ", ".join(parts),
files=files_to_send,
mention_author=False,
)
# -------- /texbatch (ZIP -> ZIP) --------
@app_commands.command(
name="texbatch",
description="Wyślij ZIP z .tex → odeślę ZIP z PDF-ami (tylko udane).",
)
@app_commands.describe(archive=f"Archiwum .zip (max ~{LATEX_MAX_ZIP_MB} MiB)")
@app_commands.checks.has_any_role(*ALLOWED_ROLES)
async def texbatch(
self, interaction: discord.Interaction, archive: discord.Attachment
):
await interaction.response.defer()
if not archive or not archive.filename.lower().endswith(".zip"):
return await interaction.followup.send(
"Dołącz ZIP (.zip) z plikami .tex.", ephemeral=True
)
if archive.size > LATEX_MAX_ZIP_MB * 1024 * 1024:
return await interaction.followup.send(
f"ZIP > {LATEX_MAX_ZIP_MB} MiB — za duży.", ephemeral=True
)
data = await archive.read()
out_zip_bytes, failed = await compile_zip_to_zip(
data, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name
)
if not out_zip_bytes and failed:
return await interaction.followup.send(
"Brak PDF-ów. " + "; ".join(failed[:10]), ephemeral=True
)
bio = io.BytesIO(out_zip_bytes)
bio.seek(0)
bio.name = "compiled_pdfs.zip"
if failed:
self.logger.debug("BATCH failed: %s", ", ".join(failed))
await interaction.followup.send(
"✅ Gotowe (ZIP w załączniku)."
+ (f" ❌ Błędy: {len(failed)}" if failed else ""),
files=[discord.File(bio, filename=bio.name)],
)
async def setup(bot):
logger = logging.getLogger("discord")
await bot.add_cog(LatexModule(bot, logger_name="discord"))
logger.info("Loading kinky latex module done")
-179
View File
@@ -1,179 +0,0 @@
import asyncio
import logging
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple
# ===== Bezpieczeństwo nazw/rozszerzeń =====
SAFE_TEX_NAME = re.compile(r"^[\w\-. ]+\.tex$", re.IGNORECASE)
SAFE_ASSET_NAME = re.compile(
r"^[\w\-. /]+?\.(tex|png|jpg|jpeg|pdf|svg|eps|bmp|gif|sty|cls|bib|bst|bbx|cbx|def|cfg)$",
re.IGNORECASE,
)
def is_safe_tex_name(name: str) -> bool:
return bool(SAFE_TEX_NAME.match(name or ""))
def is_safe_asset_name(name: str) -> bool:
return bool(SAFE_ASSET_NAME.match(name or ""))
# ===== Uruchamianie kompilatora =====
async def _run(cmd: List[str], cwd: Path, timeout_sec: int, logger_name: Optional[str]) -> Tuple[int, str]:
"""Uruchamia proces kompilatora w cwd, zwraca (rc, sklejone_stdout_stderr)."""
logger = logging.getLogger(logger_name) if logger_name else logging.getLogger()
logger.debug("RUN %s (cwd=%s, timeout=%s)", " ".join(cmd), cwd, timeout_sec)
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(cwd),
)
try:
out = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
except asyncio.TimeoutError:
try:
proc.kill()
finally:
pass
return 124, "TIMEOUT: compiler took too long"
rc = proc.returncode
log = (out[0] or b"").decode("utf-8", errors="ignore")
logger.debug("RC=%s, LOG TAIL:\n%s", rc, log[-1500:])
return rc, log
def _build_cmd(tex_engine: str, main_tex: str) -> List[str]:
"""Buduje komendę kompilatora; domyślnie tectonic/pdflatex/xelatex."""
if tex_engine.lower() == "tectonic":
# Tectonic sam robi wielofazowość, ale i tak uruchamiamy 2x (bezpiecznie dla bib/refs)
return ["tectonic", "--keep-intermediates", "--synctex", main_tex]
elif tex_engine.lower() in ("pdflatex", "xelatex", "lualatex"):
return [tex_engine, "-interaction=nonstopmode", "-halt-on-error", main_tex]
else:
# fallback traktujemy jak pdflatex
return ["pdflatex", "-interaction=nonstopmode", "-halt-on-error", main_tex]
async def run_latex_two_passes(
tex_filename: str, workdir: Path, tex_engine: str, timeout_sec: int, logger: Optional[str] = None
) -> Tuple[bool, str, Path]:
"""
Uruchamia kompilację 2 razy (bezpieczeństwo referencji/bib).
Zwraca: (ok, log_text, pdf_path).
"""
logger_obj = logging.getLogger(logger) if logger else logging.getLogger()
logger_obj.info("Compiling %s with %s engine two passes", tex_filename, tex_engine)
logs: List[str] = []
pdf_path = workdir / (Path(tex_filename).stem + ".pdf")
# PASS 1
rc1, log1 = await _run(_build_cmd(tex_engine, tex_filename), workdir, timeout_sec, logger)
logs.append(log1)
if rc1 != 0:
return False, "".join(logs), pdf_path
# PASS 2 (często już nic nie robi, ale nie szkodzi)
rc2, log2 = await _run(_build_cmd(tex_engine, tex_filename), workdir, timeout_sec, logger)
logs.append(log2)
ok = (rc2 == 0) and pdf_path.exists()
return ok, "".join(logs), pdf_path
# ===== Kompilacje: pojedynczy TEX + ZIP =====
async def compile_single_tex_bytes(
tex_bytes: bytes,
filename: str,
tex_engine: str,
timeout_sec: int,
logger: Optional[str] = None,
support_files: Optional[Iterable[Tuple[str, bytes]]] = None,
) -> Dict[str, Optional[object]]:
"""
Kompiluje pojedynczy .tex + dowolne pliki pomocnicze (obrazy, sty/cls/bib, inne .tex).
Każdy plik zapisujemy w katalogu roboczym widocznym dla kompilatora.
"""
log = logging.getLogger(logger) if logger else logging.getLogger()
log.info("Compiling %s with %s engine single tex", filename, tex_engine)
if not is_safe_tex_name(filename):
return {"ok": False, "pdf_bytes": None, "log_text": "Invalid .tex filename", "pdf_name": None}
with tempfile.TemporaryDirectory(prefix="latex_one_") as td:
wd = Path(td)
(wd / Path(filename).name).write_bytes(tex_bytes)
# Zapisz wszystkie supporty obok głównego pliku
if support_files:
for fname, data in support_files:
if not fname or not is_safe_asset_name(fname):
continue
# spłaszczamy do nazwy bazowej (bez podkatalogów z zewn. źródeł)
(wd / Path(fname).name).write_bytes(data)
ok, log_text, pdf_path = await run_latex_two_passes(Path(filename).name, wd, tex_engine, timeout_sec, logger)
log.info("LaTeX compile finished: %s", "OK" if ok else "FAIL")
log.info("Log text:\n%s", log_text[-2000:]) # Log last 2000 chars
log.info("PDF path: %s", pdf_path)
if ok and pdf_path.exists():
return {
"ok": True,
"pdf_bytes": pdf_path.read_bytes(),
"log_text": log_text,
"pdf_name": pdf_path.name,
}
return {"ok": False, "pdf_bytes": None, "log_text": log_text, "pdf_name": pdf_path.name}
async def compile_zip_to_zip(
zip_bytes: bytes, tex_engine: str, timeout_sec: int, logger: Optional[str] = None
) -> Tuple[bytes, List[str]]:
"""
Rozpakowuje ZIP, znajduję wszystkie .tex i dla KAŻDEGO tworzy osobny katalog roboczy
ze skopiowanym CAŁYM drzewem, by zachować ścieżki względne obrazków/plików.
Zwraca (zip_pdf_bytes, lista_rel_sciezek_które_padły).
"""
log = logging.getLogger(logger) if logger else logging.getLogger()
failed: List[str] = []
out_pdf_paths: List[Path] = []
with tempfile.TemporaryDirectory(prefix="latex_zip_") as td:
td_path = Path(td)
src_root = td_path / "src"
src_root.mkdir(parents=True, exist_ok=True)
# 1) Rozpakuj
zpath = td_path / "in.zip"
zpath.write_bytes(zip_bytes)
with zipfile.ZipFile(zpath, "r") as zf:
zf.extractall(src_root)
# 2) Zbierz .tex
tex_files = [p for p in src_root.rglob("*.tex") if is_safe_tex_name(p.name)]
if not tex_files:
return b"", ["No .tex files found in archive."]
# 3) Kompiluj każdy .tex w izolacji (kopiujemy całe drzewo do osobnego workdiru)
for tex_path in tex_files:
rel_tex = tex_path.relative_to(src_root).as_posix()
work = td_path / f"build_{tex_path.stem}"
try:
shutil.copytree(src_root, work / "src")
ok, log_text, pdf = await run_latex_two_passes(rel_tex, work / "src", tex_engine, timeout_sec, logger)
if ok and pdf.exists():
out_pdf_paths.append(pdf)
else:
failed.append(rel_tex)
log.info("LaTeX FAIL %s\n%s", rel_tex, (log_text or "")[-2000:])
except Exception:
# let it crash log + re-raise (to zobaczy wyższa warstwa/discord.py)
log.exception("Exception while compiling %s", rel_tex)
raise
# 4) Spakuj wyjściowe PDF-y
out_zip = td_path / "compiled_pdfs.zip"
with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for pdf in out_pdf_paths:
zf.write(pdf, arcname=pdf.name)
return out_zip.read_bytes(), failed
-304
View File
@@ -1,304 +0,0 @@
import asyncio
import io
import logging
import os
import random
import uuid
from queue import Empty
import discord
import pdf2image
import fitz
import PyPDF2
import requests
from discord.ext import commands, tasks
from ai_functions import handle_response
from communication_subroutine import IN_COMM_Q, OUT_COMM_Q, QueryControl
from constants import DIR_PATH_SADOX, LIBRARIAN_SERVICE_ADDRESS, SEND_QUERY, service_headers
SERVICE_HEADERS = service_headers()
class DataModule(commands.Cog):
def __init__(self, bot, logger_name):
self.bot = bot
self.logger = logging.getLogger(logger_name)
@commands.hybrid_command(
nsfw=True,
name="get_image_sadox",
description="Wyświetla losową stronę z losowego komiksu FanSadox. Bardzo NSFW.",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def get_image_sadox(self, ctx):
"""
Take in a context parameter and retrieve an image related from fansadox collection.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. In this case, it is likely being used
to determine
"""
self.logger.info("Get sadox")
channel = ctx.message.channel
async with channel.typing():
# select random file
res = []
# Iterate directory
for path in os.listdir(DIR_PATH_SADOX):
# check if current path is a file
if os.path.isfile(os.path.join(DIR_PATH_SADOX, path)):
res.append(path)
# trunk-ignore(bandit/B311)
filename = res[random.randrange(0, len(res) - 1)]
# select random page
file = open(DIR_PATH_SADOX + filename, "rb")
if True:
doc = fitz.open(DIR_PATH_SADOX + filename)
totalpages = len(doc)
# trunk-ignore(bandit/B311)
page_index = random.randrange(0, totalpages)
page = doc.load_page(page_index)
mat = fitz.Matrix(2.0, 2.0) # powiększenie
pix = page.get_pixmap(matrix=mat, alpha=False)
byte_io_stream = io.BytesIO(pix.tobytes("png"))
byte_io_stream.seek(0)
byte_io_stream.name = "image.png"
await ctx.send(file=discord.File(byte_io_stream))
else: #legacy
readpdf = PyPDF2.PdfReader(file)
totalpages = len(readpdf.pages)
# trunk-ignore(bandit/B311)
page = random.randrange(1, totalpages)
# convert page to image
image = pdf2image.convert_from_path(
DIR_PATH_SADOX + filename, first_page=page, last_page=page
)
byte_io_stream = io.BytesIO()
image[0].save(byte_io_stream, "JPEG")
byte_io_stream.seek(0)
byte_io_stream.name = "image.jpg"
file = discord.File(byte_io_stream)
await ctx.send(file=file)
self.logger.info("Get sadox completed")
@tasks.loop(seconds=3)
async def check_data_q(self):
"""
This function checks the data queue for any new entries and processes them accordingly.
"""
try:
fresh_data = IN_COMM_Q.get(block=False)
entries = []
if fresh_data.stop:
searcher = fresh_data.author
query = fresh_data.content
l_p = 1
for doi in fresh_data.entries:
self.logger.info(doi)
desc = fresh_data.entries[doi]
title = desc["Title"][0]
entries.append(
f"{l_p}. {title} pod linkiem https://www.sci-hub.se/{doi} i jest to {desc['type']}\n"
)
l_p += 1
message = "*Z podłogi wysuwa się winda na książki*"
if fresh_data.ctx is not None:
ctx = fresh_data.ctx
message += f" Dzielny poszukiwaczu @{searcher} w odpowiedzi na twoje zapytanie {query} nr {fresh_data.uuid}"
else:
ctx = self.bot.get_channel(1062047571557744721)
message += f" Mam wynik dla zapytania {fresh_data.uuid} ale że Hammer coś grzebał nie pamiętam kto to chciał i co chciał"
message += "nasza biblioteka służy Ci odpowiedzią. Przy dźwięku fanfar winda się otwiera a w środku "
if len(entries) < 1:
message += "niestety nie ma nic"
await ctx.send(message)
elif len(entries) >= 1 and len(entries) < 5:
message += " znajduje się coś:\n"
for item in entries:
message += item
await ctx.send(message)
else:
message += " znajduje się cholernie dużo:\n"
for item in entries:
message += item
if len(message) > 1500:
await ctx.send(message)
message = ""
# Kept for sentimental reasons
# await ctx.send(f"O. A tak będzie wyglądało coś ciekawego w przyszłości: {data}")
except Empty:
pass
@commands.hybrid_command(
name="wyszukaj_linki_do_dokumentow",
description="Szuka linkow doi w bazie crossref i podaje linki do scihuba",
guild=discord.Object(id=664789470779932693),
)
async def wyszukaj_linki_do_dokumentow(self, ctx):
"""
The function `wyszukaj_linki_do_dokumentow` searches for links to documents in a crossref database and provides links to scihub.
:param ctx: The `ctx` parameter is typically used in Discord.py, a Python library for creating Discord bots.
It represents the context of the command being executed, including information about the message, the server,
and the user who invoked the command.
"""
query = ctx.message.content
query_uuid = uuid.uuid4()
# TODO: TESTING ONLY!!
# query_uuid = uuid.UUID('{12345678-1234-5678-1234-567812345678}')
ctx.message.content = ctx.message.content.replace(
"$wyszukaj_linki_do_dokumentow", ""
)
json_query = {
"UUID": str(query_uuid),
"query": str(query),
"page": 1,
"deep_search": False,
}
coroutine = asyncio.to_thread(
requests.post,
f"{LIBRARIAN_SERVICE_ADDRESS}{SEND_QUERY}",
json=json_query,
headers=SERVICE_HEADERS,
timeout=360,
)
await ctx.send(
"*Conjurer powoli notuje podane przez Ciebie. Podchodzi do sprytnie ukrytej przy barze rury od poczty pneumatycznej i"
+ " wrzuca twój liścik do środka* To teraz trza poczekać kilka godzin. Biblioteka to 3/4 stacji."
+ " A przecież pół stacji to browar, motelik dla zabaw cielesnych i ten Bar. Więc 12/8 stacji teraz będzie ciężko pracować"
)
query_response = await coroutine
if not query_response.status_code == 200:
await ctx.send(
"*Z rury wydobywa się dym. Conjurer łapie pierwszą ciecz pod ręką i pryska na rurę. Następuje drobna eksplozja i wszystko zaczyna się palić."
+ " Conjurer jest skonfundowany..* Wołaj szefa - mam nieodparte wrażenie że się coś wyjebało"
)
return
query, query_uuid, queue_size = (
query_response.json()["data"][0],
query_response.json()["data"][1],
query_response.json()["data"][2],
)
if ctx.message.author.nick:
username = ctx.message.author.nick
else:
username = ctx.message.author.name
query_object = QueryControl(username, query_uuid, query, ctx)
OUT_COMM_Q.put(query_object)
await ctx.send(
f"No dobra poszło. Zapamiętaj proszę {query_uuid} - to identyfikator twojego zapytania. Jesteś {queue_size} w kolejce."
+ " Zapytania obsługuje algorytm zasilany czterema chomikami zapierdalającymi w kołowrotku - więc wyniki najwcześniej za kilka godzi - ale mogą być też dni."
)
@commands.hybrid_command(
name="glebokie_gardlo",
description="Przygotowuje drinka o nazwie głębokie gardło",
guild=discord.Object(id=664789470779932693),
)
async def wyszukaj_linki_do_dokumentow_deep(self, ctx):
"""
The function `wyszukaj_linki_do_dokumentow_deep` performs a deep search for links to documents in
a crossref database and provides links to scihub.
:param ctx: The `ctx` parameter is typically used in Discord.py, a Python library for creating Discord bots.
It represents the context of the command being executed, including information about the message, the server,
and the user who invoked the command.
"""
# TODO: Implement deep search logic here
query = ctx.message.content.replace("$glebokie_gardlo", "")
allowed = False
for role in ctx.message.author.roles:
if role.name == "Bartender":
allowed = True
if role.name == "Scribe":
allowed = True
if role.name == "Thane":
allowed = True
if not allowed:
if ctx.message.author.nick:
username = ctx.message.author.nick
else:
username = ctx.message.author.name
vykidailo = False
bartender = False
prompt = "Przygotuj mi drinka o nazwie Głębokie Gardło, inspirowanego tym sławnym filmem oraz skandalem Watergate"
for role in ctx.message.author.roles:
if role.name == "Vykidailo":
vykidailo = True
if role.name == "Bartender":
bartender = True
global MESSAGE_TABLE # pylint: disable=global-statement
result, MESSAGE_TABLE = await handle_response(
prompt, vykidailo, bartender, MESSAGE_TABLE, username, "GENERAL"
)
if len(result) < 1500:
await ctx.send(result)
else:
while len(result) > 1500:
await ctx.send(result[:1500])
result = result[1500:]
return
query_uuid = uuid.uuid4()
# TODO: TESTING ONLY!!
# query_uuid = uuid.UUID('{12345678-1234-5678-1234-567812345678}')
json_query = {
"UUID": str(query_uuid),
"query": str(query),
"page": 1,
"deep_search": True,
}
coroutine = asyncio.to_thread(
requests.post,
f"{LIBRARIAN_SERVICE_ADDRESS}{SEND_QUERY}",
json=json_query,
headers=SERVICE_HEADERS,
timeout=360,
)
await ctx.send(
"*Conjurer mruga okiem i zamiast drinka wyjmuje dysk usb z terminala. Podchodzi do sprytnie ukrytej przy barze rury od poczty pneumatycznej i"
+ " wrzuca ten dysk do środk* To teraz trza poczekać kilka godzin. TO będzie głębokie wyszukanie"
+ " Rozgląda się dookoła i stawia przed tobą mętnego drinka o zapachu mięty, gwiezdnego pyłu i oleju silnikowego"
)
query_response = await coroutine
if not query_response.status_code == 200:
await ctx.send(
"*Z rury wydobywa się dym. Conjurer łapie pierwszą ciecz pod ręką i pryska na rurę. Następuje drobna eksplozja i wszystko zaczyna się palić."
+ " Conjurer jest skonfundowany..* Wołaj szefa - mam nieodparte wrażenie że się coś wyjebało"
)
return
query, query_uuid, queue_size = (
query_response.json()["data"][0],
query_response.json()["data"][1],
query_response.json()["data"][2],
)
if ctx.message.author.nick:
username = ctx.message.author.nick
else:
username = ctx.message.author.name
query_object = QueryControl(username, query_uuid, query, ctx)
OUT_COMM_Q.put(query_object)
await ctx.send(
f"Wypij wypi {query_uuid} - to identyfikator twojego zapytania. Jesteś {queue_size} w kolejce niestety kolejka obowiazuje zawsze."
+ " Zapytania obsługuje algorytm zasilany czterema chomikami zapierdalającymi w kołowrotku - więc wyniki najwcześniej za kilka godzin - ale mogą być też dni. Głębokie zabawy trwają dłużej. *Znowu mruga*"
)
async def setup(bot):
logger = logging.getLogger("discord")
dm = DataModule(bot, "discord")
dm.check_data_q.start()
await bot.add_cog(dm)
logger.info("Loading data sharing commands module done")
-28
View File
@@ -1,28 +0,0 @@
# pdf_render.py
import io
try:
import fitz # PyMuPDF
_HAS_PYMUPDF = True
except Exception:
_HAS_PYMUPDF = False
from pdf2image import convert_from_path
def pdf_page_to_image_bytes(path: str, page_index: int = 0, zoom: float = 2.0, fmt: str = "PNG") -> bytes:
"""
Zwraca bytes obrazka z jednej strony PDF:
- PyMuPDF (szybki) jeśli dostępny,
- inaczej pdf2image + poppler (wymaga 'pdftoppm').
page_index: 0-based
"""
if _HAS_PYMUPDF:
doc = fitz.open(path)
page = doc.load_page(page_index)
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat, alpha=False)
return pix.tobytes(fmt.lower())
# fallback
images = convert_from_path(path, first_page=page_index+1, last_page=page_index+1, fmt=fmt)
bio = io.BytesIO()
images[0].save(bio, fmt)
return bio.getvalue()
-101
View File
@@ -1,101 +0,0 @@
# === SYSTEM SETUP ===
sudo apt update && sudo apt install -y \
pulseaudio pulseaudio-utils pavucontrol \
libgtk-3-dev gtk-3-examples \
xauth dbus-x11 \
samba
# === SAMBA CONFIGURATION ===
sudo nano /etc/samba/smb.conf
# Add at the end of the file:
# [RaspberryPiNAS]
# path = /home/pi/Public
# comment = Pi Share
# browseable = yes
# writeable = yes
# guest ok = no
# valid users = pi
# Set permissions
chmod 755 /home/pi
mkdir -p /home/pi/Public
chmod 777 /home/pi/Public
chown -R pi:pi /home/pi/Public
# Set Samba password for pi
sudo smbpasswd -a pi
# Restart Samba
sudo systemctl restart smbd
# === PULSEAUDIO SYSTEM-WIDE SERVICE ===
sudo nano /etc/systemd/system/pulseaudio.service
# Paste this content:
# [Unit]
# Description=PulseAudio System-wide Daemon
# After=sound.target network.target
#
# [Service]
# Type=simple
# ExecStart=/usr/bin/pulseaudio --system --disallow-exit --disallow-module-loading=0 --daemonize=no
# Restart=always
#
# [Install]
# WantedBy=multi-user.target
# === PULSEAUDIO MODULES FOR LEXICON LAMBDA USB ===
sudo nano /etc/pulse/system.pa
# Comment out:
# load-module module-udev-detect
# load-module module-detect
# Add this at the bottom:
# load-module module-alsa-sink device=hw:1,0 sink_name=LambdaOutput sink_properties=device.description="Lexicon_Lambda_USB_Output"
# load-module module-alsa-source device=hw:1,0 source_name=LambdaInput source_properties=device.description="Lexicon_Lambda_USB_Input"
# load-module module-native-protocol-unix auth-anonymous=1 socket=/tmp/pulseaudio.socket
# Set client default socket
sudo nano /etc/pulse/client.conf
# Add this line:
# default-server = unix:/tmp/pulseaudio.socket
# Enable and start PulseAudio system-wide
sudo systemctl daemon-reload
sudo systemctl enable pulseaudio.service
sudo systemctl restart pulseaudio.service
# === X11 & GUI FIXES FOR SSH/MOBAXTERM ===
sudo nano /etc/ssh/sshd_config
# Ensure these lines are present:
# X11Forwarding yes
# X11UseLocalhost no
# XAuthLocation /usr/bin/xauth
# Restart SSH
sudo systemctl restart ssh
# Ensure xauth is installed
sudo apt install -y xauth
# === BASHRC: MAKE GUI EXPORTS PERSISTENT ===
nano ~/.bashrc
# Add this at the end:
# export GDK_BACKEND=x11
# export LIBGL_ALWAYS_INDIRECT=1
# export NO_AT_BRIDGE=1
# export $(dbus-launch)
# Reload bashrc immediately
source ~/.bashrc
# === TESTING GUI ===
# (Reconnect via MobaXterm SSH with X11 forwarding enabled before running below)
# Run pavucontrol GUI
pavucontrol &
# Optional: Test GTK GUI rendering
gtk3-demo &
# Check audio devices
pactl list sinks short
pactl list sources short
-809
View File
@@ -1,809 +0,0 @@
import asyncio
import logging
import random
import re
from datetime import datetime
from sys import platform
import discord
import eyed3
from discord.ext import commands, tasks
import music_functions
from ai_functions import handle_response
from communication_subroutine import PREPPED_TRACKS
from constants import MESSAGE_TABLE_MUZYKA, MUZYKA, SEPARATOR_FILE_PATH
from other_functions import get_stats
class MusicModule(commands.Cog):
def __init__(self, bot, logger_name):
self.bot = bot
self.logger = logging.getLogger(logger_name)
@commands.hybrid_command(
name="krecimy_pornola",
description="Wiadomo co, dla kogo i po co",
)
@commands.has_any_role('Legenda', 'Jarl', 'Thane' , 'Bartender')
async def krecimy_pornola(self, ctx):
"""
Download a music number from youtube.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. It allows the command to interact
with the Discord API and
"""
await ctx.send("Dej mnie chwilkę")
self.logger.info("Pornol")
async with ctx.typing():
# wyciagnij linka z kontekstu
content = ctx.message.content.split()
for item_yt in content:
if re.match("http.*", item_yt):
sciezka, files = await music_functions.get_file(
ctx, "Pornol", item_yt
)
if files:
self.logger.info("Pornol udany")
await ctx.send(
f"Jest w tajnym archiwum pod adresem{sciezka})"
)
@commands.hybrid_command(
name="co_na_plejliscie_wariacie",
description="Wyswietl kawalki ktore sa na pocztku list radia",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def co_na_plejliscie_wariacie(self, ctx):
"""
Download a music number from youtube.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. It allows the command to interact
with the Discord API and
"""
await ctx.send("Dej mnie chwilkę")
async with ctx.typing():
self.logger.info("plejlista")
self.logger.info(PREPPED_TRACKS)
await ctx.send(
f"Didżej Hammer i jego sztuczna inteligencja (||he he||) prezentują: \n- obecnie grany jest {(PREPPED_TRACKS['now_playing']).rstrip()} (wg metadanych: {PREPPED_TRACKS['meta']}) \n- następny będzie{(PREPPED_TRACKS['next']).rstrip()} \n- na liście priorytetowej {(PREPPED_TRACKS['priority']).rstrip()} \n- na liście hitów obecnie czeka kawałek {(PREPPED_TRACKS['hit']).rstrip()} \n- na liście wszystkich kawałków {(PREPPED_TRACKS['all']).rstrip()} \n- najświeższym zamówieniem od słuchaczy jest {(PREPPED_TRACKS['requests']).rstrip()} \nZ kolei jingiel to: {(PREPPED_TRACKS['jingles']).rstrip()}"
)
@tasks.loop(seconds=1)
async def check_music(self):
"""
This Python function continuously checks for music playback in a voice client and plays music if
conditions are met.
"""
if self.bot.voice_clients:
voice_client = self.bot.voice_clients[0]
if (
voice_client
and voice_client.is_connected()
and not voice_client.is_playing()
and not voice_client.is_paused()
):
await self.play(MUZYKA["ctx"], MUZYKA["requester"])
await asyncio.sleep(2)
@commands.hybrid_command(
name="dej_co_z_jutuba",
description="Podaj link do youtube - sciagnie i doda muzyke",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def dej_co_z_jutuba(self, ctx):
"""
Download a music number from youtube.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. It allows the command to interact
with the Discord API and
"""
await ctx.send("Dej mnie chwilkę")
self.logger.info("Jutub")
async with ctx.typing():
# wyciagnij linka z kontekstu
content = ctx.message.content.split()
for item_yt in content:
if re.match("http.*", item_yt):
_, files = await music_functions.get_file(ctx, "Youtube", item_yt)
if files:
for file_iter in files:
global MUZYKA # pylint: disable=global-variable-not-assigned
MUZYKA["queue"].insert(0, file_iter)
music_functions.MUSIC_FILE_LIST.update_file_list(file_iter)
MUZYKA["requester"].insert(0, ctx.author)
await ctx.send(f"Dodałem do listy {file_iter}")
self.logger.info("Jutub udany")
@commands.hybrid_command(
name="dej_co_ze_spotifaja",
description="Podaj link do spotify - sciagnie i doda muzyke",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def dej_co_ze_spotifaja(self, ctx):
"""
Get a playlist or a music number from Spotify.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. This parameter is required in all
Discord.py commands
"""
self.logger.info("Spotifaj")
await ctx.send("Dej mnie chwilkę")
async with ctx.typing():
# wyciagnij linka z kontekstu
content = ctx.message.content.split()
for item in content:
if re.match("http.*", item):
dir_path, files = await music_functions.get_file(
ctx, "Spotify", item
)
if platform == "win32":
separator = "\\"
else:
separator = "/"
for file in files:
file_path = (
dir_path
+ separator
+ file["artist"]
+ " - "
+ file["name"]
+ ".mp3"
)
global MUZYKA # pylint: disable=global-variable-not-assigned
MUZYKA["queue"].insert(
0,
dir_path
+ separator
+ file["artist"]
+ " - "
+ file["name"]
+ ".mp3",
)
MUZYKA["requester"].insert(0, ctx.author)
music_functions.MUSIC_FILE_LIST.update_file_list(file_path)
await ctx.send(
f"Dodałem do listy {dir_path}{file['artist']} - + {file['name']}.mp3"
)
self.logger.info("Spotifaj udany")
# MUSIC BOT PART
@commands.hybrid_command(
name="cisza",
description="Wyłącza muzykę i czyści plejliste.",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def cisza(self, ctx):
"""
Stop playback of the music and clear the queue.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. This parameter is required in all
Discord.py commands
"""
self.logger.info("Stop")
global MUZYKA # pylint: disable=global-variable-not-assigned
MUZYKA["ctx"] = None
voice_client = self.bot.voice_clients[0]
self.check_music.stop()
if voice_client.is_connected():
await self.disconnect(ctx=ctx)
while MUZYKA["queue"]:
MUZYKA["queue"].pop()
MUZYKA["requester"].pop()
self.logger.info("Stop completed")
@commands.hybrid_command(
name="dalej",
description="Przerzuca na następny kawałek",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def dalej(self, ctx):
"""
Play next track in queue.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which a command is being executed, including information such as the
message, the channel, the server, and the user who invoked the command. The context object is used
to access and manipulate this
"""
if ctx:
self.logger.info("Ctx defined for dalej")
self.logger.info("Next")
voice_client = self.bot.voice_clients[0]
if voice_client.is_connected():
voice_client.stop()
self.logger.info("Next completed")
@commands.hybrid_command(
name="daj_mi_chwile",
description="Pauzuje",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def daj_mi_chwile(self, ctx):
"""
Pause music playback.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. This parameter is required in most
Discord.py commands and is
"""
if ctx:
self.logger.info("Ctx defined for pause")
self.logger.info("Pause")
voice_client = self.bot.voice_clients[0]
if voice_client.is_connected():
voice_client.pause()
self.logger.info("Pause completed")
@commands.hybrid_command(
name="graj_dalej",
description="Odpauzowuje",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def graj_dalej(self, ctx):
"""
Unpause music and continue playback.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. This parameter is required in all
Discord.py commands
"""
if ctx:
self.logger.info("Ctx defined for graj_dalej")
self.logger.info("Unpause")
voice_client = self.bot.voice_clients[0]
if voice_client.is_paused():
voice_client.resume()
self.logger.info("Unpause completed")
@commands.hybrid_command(
name="zagraj_mi_kawalek",
description="Wyszukuje w bibliotece muzycznej Hammera kawałek który ma zostać zagrany",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def zagraj_mi_kawalek(self, ctx):
"""
Play a song or music piece in response to a command
triggered by a user in a Discord chat context.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
contains information about the context in which the command was invoked, such as the message, the
channel, the server, and the user who invoked the command. This information can be used to perform
various actions, such
"""
async with ctx.typing():
search_time_glob = datetime.now()
self.logger.info("Zaczynam szukać timestamp %s", datetime.now())
file = await self.wyszukaj(ctx=ctx)
self.logger.info(
"Koniec szukania(timestamp %s zajęło %s",
datetime.now(),
datetime.now() - search_time_glob,
)
if file:
for plik in file:
global MUZYKA # pylint: disable=global-variable-not-assigned
MUZYKA["queue"].insert(0, plik[1])
MUZYKA["requester"].insert(0, ctx.author)
metadata = eyed3.load(plik[1])
reply = (
"Dodałem do playlisty {plik[1]} z prywatnej kolekcji Hammera."
)
if metadata.tag.title:
reply = f"Dodałem do playlisty kawalek {metadata.tag.title}"
if metadata.tag.artist:
reply += f" wykonawcy {metadata.tag.artist}"
if metadata.tag.album:
reply += f" z albumu {metadata.tag.album}"
reply += (
" z prywatnej kolekcji Hammera i na Twoją specjalną rpośbę."
)
await ctx.send(reply)
else:
await ctx.send(
"Obawiam się że nie mogę nic znaleźć u siebie. Spróbuj komendy '$dej_co_ze_spotifaja' jeśli masz link"
)
@commands.hybrid_command(
name="zrob_mi_plejliste",
description="Generuje playliste - param1 - dlugosc, potem slowa wyszukiwania",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def zrob_mi_plejliste(self, ctx):
"""
Generate a playlist in queue. First word in this command shall be int defining length of the playlist.
Rest of the line are search terms.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. This parameter is required in all
Discord.py commands
"""
reply = "Wygenerowana plejlista:\n"
async with ctx.typing():
self.logger.info("Zaczynam szukać timestamp %s", datetime.now())
search_time_glob = datetime.now()
dlugosc_playlisty = ctx.message.content.split()[1]
file = await music_functions.wyszukaj(ctx=ctx, how_many=dlugosc_playlisty)
self.logger.info(
"Koniec szukania(timestamp %s zajęło %s",
datetime.now(),
datetime.now() - search_time_glob,
)
index = 1
if file:
for plik in file:
if plik[1]:
self.logger.info(plik)
global MUZYKA # pylint: disable=global-variable-not-assigned
MUZYKA["queue"].insert(0, plik[1])
MUZYKA["requester"].insert(0, ctx.author)
metadata = eyed3.load(plik[1])
if metadata and metadata.tag:
if metadata.tag.title:
reply += f"{index} {metadata.tag.title}"
else:
reply += (
f"{index}. {plik[1]} z prywatnej kolekcji Hammera."
)
if metadata.tag.artist:
reply += f" wykonawcy {metadata.tag.artist}"
if metadata.tag.album:
reply += f" z albumu {metadata.tag.album}"
if metadata.tag.title:
reply += " z prywatnej kolekcji Hammera i na Twoją specjalną rpośbę.\n"
else:
reply += (
f"{index}. {plik[1]} z prywatnej kolekcji Hammera.\n"
)
index += 1
if len(reply) > 1800:
await ctx.send(reply)
reply = ""
else:
await ctx.send(
"Obawiam się że nie mogę nic znaleźć u siebie. Spróbuj komendy '$dej_co_ze_spotifaja' jeśli masz link"
)
await ctx.send(reply)
self.logger.info("Plejlista zrobiona")
@commands.hybrid_command(
name="zagraj_muzyke_mego_ludu",
description="Gra muzyke wyszukana na bazie tematow konwersacji na kanale NZ",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def zagraj_muzyke_mego_ludu(self, ctx):
"""
Play completeley random playlist based on messaging history.
:param ctx: ctx stands for "context" and is typically used in Discord.py commands to represent the
context in which the command was invoked. This includes information such as the message, the
channel, the author, and other relevant details. In this case, it is being passed as a parameter to
the `get_image
"""
stats = {}
if ctx:
self.logger.info("Zagraj muzyke mego ludu: wywolane")
stat_iter = get_stats(self.bot, ctx, MUZYKA_MOJEGO_LUDU_HISTORIA)
async for name, how_many in stat_iter:
if len(name) > 3:
stats[name] = how_many
sorted_stats = sorted(stats.items(), key=lambda x: x[1])
self.logger.info("Zagraj muzyke mego ludu:zebrano statystyki")
key_words = []
for stat in sorted_stats:
key_words.append(stat[0])
self.logger.info(sorted_stats[:MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE])
self.logger.info(key_words[:MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE])
self.logger.info(sorted_stats[-MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE:])
self.logger.info(key_words[-MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE:])
final_key_words = key_words[:MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE]
final_key_words.extend(key_words[-MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE:])
reply = "Wygenerowana plejlista:\n"
async with ctx.typing():
self.logger.info(
"Zagraj muzyke mego ludu:Zaczynam szukać timestamp %s", datetime.now()
)
search_time_glob = datetime.now()
file = await music_functions.wyszukaj(
ctx=ctx,
how_many=MUZYKA_MOJEGO_LUDU_PLAJLISTA,
slowa_kluczowe=final_key_words,
)
self.logger.info("Zagraj muzyke mego ludu:wygenerowano plejliste")
self.logger.info(file)
self.logger.info(
"Zagraj muzyke mego ludu: Koniec szukania(timestamp %s zajęło %s",
datetime.now(),
datetime.now() - search_time_glob,
)
index = 1
if file:
for plik in file:
global MUZYKA # pylint: disable=global-variable-not-assigned
MUZYKA["queue"].insert(0, plik[1])
MUZYKA["requester"].insert(0, ctx.author)
metadata = eyed3.load(plik[1])
if metadata and metadata.tag:
if metadata.tag.title:
reply += f"{index} {metadata.tag.title}"
else:
reply += f"{index}. {plik[1]} z prywatnej kolekcji Hammera."
if metadata.tag.artist:
reply += f" wykonawcy {metadata.tag.artist}"
if metadata.tag.album:
reply += f" z albumu {metadata.tag.album}"
if metadata.tag.title:
reply += " z prywatnej kolekcji Hammera i na Twoją specjalną rpośbę.\n"
else:
reply += f"{index}. {plik[1]} z prywatnej kolekcji Hammera.\n"
index += 1
if len(reply) > 1800:
await ctx.send(reply)
reply = ""
else:
await ctx.send(
"Obawiam się że nie mogę nic znaleźć u siebie. Spróbuj komendy '$dej_co_ze_spotifaja' jeśli masz link"
)
await ctx.send(reply)
self.logger.info("Plejlista zrobiona")
@commands.hybrid_command(
name="parametry_muzyki_mego_ludu",
description="Konfiguruje komende zagraj muzyke mojego ludu",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def parametry_muzyki_mego_ludu(
self, ctx, ile_historii=1500, ile_slow_kluczowych=15, jak_dluga_plejlista=30
):
"""
The function `parametry_muzyki_mego_ludu` sets global variables for the number of history entries,
number of keywords, and length of playlist for a music application.
:param ctx: The `ctx` parameter is typically used in Discord.py, a Python library for creating
Discord bots. It represents the context of the command being executed, including information about
the message, the server, and the user who invoked the command
:param ile_historii: The parameter "ile_historii" represents the number of history items for the
music playlist, defaults to 1500 (optional)
:param ile_slow_kluczowych: The parameter "ile_slow_kluczowych" represents the number of keywords or
key phrases related to music that you want to include in your analysis or search, defaults to 30
(optional)
:param jak_dluga_plejlista: The parameter "jak_dluga_plejlista" determines the length of the
playlist. It specifies how many songs should be included in the playlist, defaults to 30 (optional)
"""
if ctx:
async with ctx.typing():
# if ':' in ile_historii:
# _, _, ile_historii = ile_historii.partition(':')
# if ':' in ile_slow_kluczowych:
# _, _, ile_slow_kluczowych = ile_slow_kluczowych.partition(':')
# if ':' in jak_dluga_plejlista:
# _, _, jak_dluga_plejlista = jak_dluga_plejlista.partition(':')
try:
global MUZYKA_MOJEGO_LUDU_HISTORIA # pylint: disable=global-statement
global MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE # pylint: disable=global-statement
global MUZYKA_MOJEGO_LUDU_PLAJLISTA # pylint: disable=global-statement
self.logger.info(
"Długość historii wyszukiwania: %s\n Ilość słów kluczowych: %s\n Długość plejlisty do wygenerowanie: %s",
MUZYKA_MOJEGO_LUDU_HISTORIA,
MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE,
MUZYKA_MOJEGO_LUDU_PLAJLISTA,
)
ctx.send(
f"Dotychczasowe wartośći:\n Długość historii wyszukiwania: {MUZYKA_MOJEGO_LUDU_HISTORIA}\n Ilość słów kluczowych: {MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE}\n Długość plejlisty do wygenerowanie:{MUZYKA_MOJEGO_LUDU_PLAJLISTA}"
)
MUZYKA_MOJEGO_LUDU_HISTORIA = ile_historii
MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE = ile_slow_kluczowych
MUZYKA_MOJEGO_LUDU_PLAJLISTA = jak_dluga_plejlista
self.logger.info(
"Długość historii wyszukiwania: %s\n Ilość słów kluczowych: %s\n Długość plejlisty do wygenerowanie: %s",
MUZYKA_MOJEGO_LUDU_HISTORIA,
MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE,
MUZYKA_MOJEGO_LUDU_PLAJLISTA,
)
self.ctx.send(
f"Zmienione wartośći:\n Długość historii wyszukiwania: {MUZYKA_MOJEGO_LUDU_HISTORIA}\n Ilość słów kluczowych: {MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE}\n Długość plejlisty do wygenerowanie:{MUZYKA_MOJEGO_LUDU_PLAJLISTA}"
)
except discord.ext.commands.errors.BadArgument as exce:
ctx.send("Spierdalaj")
self.logger.info(exce)
@commands.hybrid_command(
name="graj_muzyko",
description="Włącza muzykę na kanale #nocna-zmiana.",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def graj_muzyko(self, ctx):
"""
Async function named "graj_muzyko" starts music playback on channel "Nocna Zmiana".
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. This parameter is required in all
Discord.py commands
"""
self.logger.info("Press play on tape")
async with ctx.typing():
global MUZYKA # pylint: disable=global-variable-not-assigned
MUZYKA["ctx"] = ctx
await self.connect(ctx=ctx)
await self.play(ctx=ctx)
self.logger.info("Press play on tape completed")
self.check_music.start()
async def connect(self, ctx, arg=None):
"""
Connect the bot to a voice channel if it is not already connected.
:param ctx: ctx is short for context and refers to the context in which the command was invoked. It
contains information about the message, the channel, the server, and the user who invoked the
command
:param arg: The `arg` parameter is an optional argument that can be passed to the `connect`
function. It is not used in the code snippet provided, but it could potentially be used to specify a
specific voice channel to connect to
:return: If the voice client is already connected, the function will return without doing anything.
If the function successfully connects to the voice channel, it will return a voice channel
connection object. If it is not possible to connect to the voice channel, the function will log an
error message and return nothing.
"""
if ctx and arg:
self.logger.info("Ctx and arg defined for connect")
if self.bot.voice_clients:
self.logger.info("Already connected with other client")
else:
vc = None
if ctx.author.voice.channel:
vc = await ctx.author.voice.channel.connect()
else:
voice_channel = self.bot.get_channel(1060349757349974066)
vc = await voice_channel.connect()
if not vc:
self.logger.error("Not possible to connect to voice")
return
self.logger.info("Connected to voice")
@commands.hybrid_command(
name="batch_download",
description="Zaciąga obiekty z pliku",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Legenda', 'Jarl', 'Thane' , 'Bartender')
async def batch_download(self, ctx):
content_type = ctx.message.attachments[0].content_type
check = re.search("text\/plain; *charset=(.*)", content_type, re.IGNORECASE)
await ctx.reply("Kurwa. Aleś mi roboty narobił... No nic. Ku radości. Skal!")
async with ctx.typing():
if check:
global MUZYKA # pylint: disable=global-variable-not-assigned
file_bytes = await ctx.message.attachments[0].read()
text = file_bytes.decode(encoding=check.group(1))
self.logger.info(text)
lines = text.split("\n")
for link in lines:
link = link.strip()
self.logger.info(link)
pat = r"^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$"
self.logger.info("Verification")
if re.match(pat, link) and (
re.match(".*youtube.*", link) or re.match(".*youtu.be.*", link)
):
self.logger.info("%s to link do jutuba", link)
dir_path, files = await music_functions.get_file(
ctx, "Youtube", link
)
if files:
for file_iter in files:
global MUZYKA # pylint: disable=global-variable-not-assigned
MUZYKA["queue"].insert(0, file_iter)
music_functions.MUSIC_FILE_LIST.update_file_list(
file_iter
)
MUZYKA["requester"].insert(0, ctx.author)
await ctx.send(f"Dodałem do listy {file_iter}")
self.logger.info("Jutub udany")
elif re.match(pat, link) and re.match(".*spotify.*", link):
self.logger.info("%s to link do spotify", link)
dir_path, files = await music_functions.get_file(
ctx, "Spotify", link
)
if platform == "win32":
separator = "\\"
else:
separator = "/"
for file in files:
file_path = (
dir_path
+ separator
+ file["artist"]
+ " - "
+ file["name"]
+ ".mp3"
)
MUZYKA["queue"].insert(
0,
dir_path
+ separator
+ file["artist"]
+ " - "
+ file["name"]
+ ".mp3",
)
MUZYKA["requester"].insert(0, ctx.author)
music_functions.MUSIC_FILE_LIST.update_file_list(file_path)
await ctx.send(
f"Dodałem do listy {dir_path}{file['artist']} - + {file['name']}.mp3"
)
self.logger.info("Spotifaj udany")
elif re.match(pat, link):
self.logger.info("%s to link do czegos", link)
sciezka, files = await music_functions.get_file(
ctx, "Pornol", link
)
if files:
self.logger.info("Pornol udany")
await ctx.send(
f"Jest w tajnym archiwum pod adresem{sciezka})"
)
else:
self.logger.info("Spierdalaj")
self.logger.info(link)
await ctx.message.reply("Spierdalaj")
else:
self.logger.info(content_type)
self.logger.info("Spierdalaj")
await ctx.message.reply("Spierdalaj")
async def disconnect(self, ctx):
"""
Asynchronous Python function that disconnects the voice client if it is connected and
logs a message if the context is defined.
:param ctx: ctx is short for context and refers to the context in which a command is being executed.
It contains information about the message, the user who sent the message, the channel the message
was sent in, and more. In this case, the `disconnect` function is likely being called as a command
in
"""
if ctx:
self.logger.info("Ctx defined for disconnect")
if self.bot.voice_clients:
voice_client = self.bot.voice_clients[0]
if voice_client.is_connected():
await voice_client.disconnect()
async def play(self, ctx, zamawial=None, arg=None):
"""
Play a music file, retrieve metadata about the file, and send a
message to a Discord channel with information about the song being played.
:param ctx: The "ctx" parameter is a context object that contains information about the current
Discord command invocation, such as the message, the channel, and the user who invoked the command.
It is passed to the function automatically by the Discord.py library
:param zamawial: The parameter `zamawial` is a variable that stores the user who requested the song
to be played. It is an optional parameter and can be None if no user requested the song
:param arg: The `arg` parameter is an optional argument that can be passed to the `play` function.
It is not used in the code provided, so its purpose is unclear without additional context
"""
# TODO: Uzupełnianie metadanych. Jeśli ich nie ma sprawdzamy nazwę (w sposób inteligentny - czyli jeśli składa się z samych cyfr i/lub słowa track to sprawdzić album w sensie folder)
# TODO: Jeśli w metadanych jest tylko "audiotrack" i "album" też po
# folderze. Jeśli są niekompletne uzupełnić dalej.
self.logger.info("Play procedure")
if arg:
self.logger.info("Arg defined for play")
file_to_play = None
voice_client = self.bot.voice_clients[0]
if not self.bot.voice_clients:
voice_client = self.bot.voice_clients[0]
await self.connect(ctx)
if MUZYKA["queue"]:
file_to_play = MUZYKA["queue"].pop()
zamawial = MUZYKA["requester"].pop()
self.logger.info("Muzyka z listy zamówień")
else:
# trunk-ignore(bandit/B311)
index = random.randint(
0, len(music_functions.MUSIC_FILE_LIST.get_file_list()) - 1
)
file_to_play = music_functions.MUSIC_FILE_LIST.get_file_list()[index]
self.logger.info("Muzyka losowo")
self.logger.info(file_to_play)
metadata = eyed3.load(file_to_play)
query = None
temp = file_to_play.split(SEPARATOR_FILE_PATH)[-1]
kawalek = f"Zagram teraz kawalek {temp} z prywatnej kolekcji Hammera."
if metadata:
if metadata.tag:
if metadata.tag.title:
query = f"Opowiedz mi o kawałku {metadata.tag.title}"
kawalek = f"Zagram teraz kawalek {metadata.tag.title}"
if metadata.tag.artist:
query += f" wykonawcy {metadata.tag.artist}"
kawalek += f" wykonawcy {metadata.tag.artist}"
if metadata.tag.album:
query += f" z albumu {metadata.tag.album}"
kawalek += f" z albumu {metadata.tag.album}"
kawalek += " z prywatnej kolekcji Hammera."
if zamawial:
kawalek += f"Na specjalne życzenie <@{zamawial.id}>."
await ctx.send(kawalek)
voice_client.play(
discord.PCMVolumeTransformer(
discord.FFmpegPCMAudio(source=file_to_play), volume=0.3
)
) # NIE DOTYKAC POKRĘTŁA GŁOŚNOŚCI!!!!
if query:
self.logger.debug("Zapytanie do openai")
self.logger.debug(query)
vykidailo = False
bartender = False
for role in ctx.message.author.roles:
if role.name == "Vykidailo":
vykidailo = True
if role.name == "Bartender":
bartender = True
if ctx.message.author.nick:
username = ctx.message.author.nick
else:
username = ctx.message.author.name
global MESSAGE_TABLE_MUZYKA # pylint: disable=global-statement
result, MESSAGE_TABLE_MUZYKA = await handle_response(
query, vykidailo, bartender, MESSAGE_TABLE_MUZYKA, username, "MUSIC"
)
self.logger.debug("Obecna historia czatu: %s", MESSAGE_TABLE_MUZYKA)
if len(result) < 1500:
await ctx.send(result)
else:
while len(result) > 1500:
await ctx.send(result[:1500])
result = result[1500:]
async def setup(bot):
logger = logging.getLogger("discord")
await music_functions.MUSIC_FILE_LIST.refresh_file_list(bot)
logger.info("Playlist generation")
await bot.add_cog(MusicModule(bot, "discord"))
logger.info("Loading music commands module done")
-331
View File
@@ -1,331 +0,0 @@
import asyncio
import logging
import re
import uuid
from pathlib import Path, PurePath
from sys import platform
import discord
import requests
import yt_dlp
from constants import (
FILE_SERVICE_ADDRESS,
GET_MP3,
GET_PLAYLIST,
MUSIC_FOLDER,
SEND_MP3,
SPOTIFY_CTRL,
YOUTUBE_AUTH,
service_headers,
)
from spotify_dl import spotify
from spotify_dl import youtube as youtube_download
SERVICE_HEADERS = service_headers()
class MusicFileList(object):
"""
The `MusicFileList` class manages a list of music files, with the ability to refresh the list from a
file service or local directory and update the list with new items.
"""
def __init__(self, logger_name) -> None:
self.music_file_list = []
self.file_service_active = False
self.logger = logging.getLogger("discord")
self.logger.info("Created Playlist organizer class")
async def refresh_file_list(self, bot):
"""
The `refresh_file_list` function attempts to connect to a file service to retrieve a list of music
files, and if unsuccessful, it populates the list by scanning a local directory for .mp3 files.
"""
try:
self.logger.info("Attempt to connect to file service")
response = requests.get(
f"{FILE_SERVICE_ADDRESS}{GET_MP3}",
headers=SERVICE_HEADERS,
timeout=360,
)
self.music_file_list = response.json()["music_file_list"]
self.file_service_active = True
except requests.exceptions.RequestException as e:
for mp3_item in Path.glob(Path(MUSIC_FOLDER), "**/*.mp3"):
temp_music_file = mp3_item.as_posix()
if platform == "win32":
temp_music_file = temp_music_file.replace("/", "\\")
self.music_file_list.append(temp_music_file)
self.file_service_active = False
self.logger.error(e.strerror)
self.logger.error("Service Unavailable")
finally:
if self.file_service_active:
self.logger.info("Radio Status: Probably Active")
status = discord.Status.online
# radio_hardkor = discord.Activity(
# name = "Radio Hammer/Radio Conjurer/Wolne Bałuity Kurwa",
# url = "http://95.175.16.246:666/mp3-stream",
# type = discord.ActivityType.streaming,
# platform = "Liquidsoap+RaspberyPi+RolandSeratoDJ202",
# state = "Where the f*** is the DJ booth?",
# details= "This Is Radio Hardkor based in Wolne Księstwo Bałuty, the best pirate radiostation on both sides of Łódka river",
# buttons= [{"label":"RADIO", "url":"http://95.175.16.246:666/mp3-stream"}],
# assets= {"large_image": "fuckery.jpg", "large_text":"Fuckewry", "small_image":"willowisp.png", "small_text":"Hi!"}
# )
radio_hardkor = discord.Streaming(
name="Radio Hardkor", url="http://95.175.16.246:666/mp3-stream"
)
await bot.change_presence(status=status, activity=radio_hardkor)
else:
self.logger.info("Radio Status: Rather Unknown")
await bot.change_presence(
activity=discord.Game(name="Axe Throwing Darts")
)
def get_file_list(self):
"""
The `get_file_list` function returns the list of music files stored in the object.
:return: The `music_file_list` attribute is being returned.
"""
return self.music_file_list
def update_file_list(self, item):
"""
The `update_file_list` function appends an item to a music file list and sends a POST request with
the item data to a file service address.
:param item: The `item` parameter in the `update_file_list` method is the file that you want to add
to the `music_file_list` attribute of the class instance. It is then sent as a JSON payload in a
POST request to a file service address along with the endpoint `SEND_MP3`
"""
self.music_file_list.append(item)
post_data = {"item": str(item)}
requests.post(
f"{FILE_SERVICE_ADDRESS}{SEND_MP3}",
json=post_data,
headers=SERVICE_HEADERS,
timeout=360,
)
MUSIC_FILE_LIST = MusicFileList("discord")
async def get_file(ctx, source, link):
"""
Take in three parameters: "ctx" (context),
"source" (a string representing the source of the file), and "link" (a string representing the link
to the file). Execute it in asynchronouse way. And get file from spotify or youtube.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which a command is being executed, including information such as the
message, the channel, the server, and the user who invoked the command
:param source: The source parameter is likely a string that represents the source of the file that
the user wants to retrieve. This could be a website, a cloud storage service, or any other location
where the file is stored
:param link: The "link" parameter is likely a string that represents a URL or file path to the
location of the file that the function is trying to retrieve
"""
logger = logging.getLogger("discord")
item_id = None
item_type = None
file_list = []
if source == "Spotify":
dir_path = None
item_type, item_id = spotify.parse_spotify_url(link)
if item_type in ["album", "track", "playlist"] and item_id:
logger.info(item_type)
logger.info(item_id)
logger.info("Spotify link provided")
file_list = await asyncio.to_thread(
spotify.fetch_tracks, SPOTIFY_CTRL, item_type, link
)
logger.info(file_list)
directory_name = await asyncio.to_thread(
spotify.get_item_name, SPOTIFY_CTRL, item_type, item_id
)
logger.info(directory_name)
logger.info("Spotify scrape done")
url_data = {"urls": []}
url_dict = {}
url_dict["save_path"] = Path(
PurePath.joinpath(Path(MUSIC_FOLDER), Path(directory_name))
)
url_dict["save_path"].mkdir(parents=True, exist_ok=True)
url_dict["songs"] = file_list
url_data["urls"].append(url_dict.copy())
file_name_f = youtube_download.default_filename
logger.info("YT-DL")
coro = asyncio.to_thread(
youtube_download.download_songs,
songs=url_data,
output_dir=MUSIC_FOLDER,
format_str="bestaudio/best",
skip_mp3=False,
keep_playlist_order=False,
no_overwrites=True,
remove_trailing_tracks="no",
use_sponsorblock="yes",
file_name_f=file_name_f,
multi_core=0,
proxy="",
YT_AUTH = YOUTUBE_AUTH
)
_ = await coro
logger.info("YT-DL done")
dir_path = (url_dict["save_path"].resolve()).as_posix()
if platform == "win32":
dir_path = dir_path.replace("/", "\\")
return dir_path, file_list
await ctx.send(
"No se jaja chyba robisz, Ty byś spotifaja nie poznawszy jakby z krzaków wyskoczył i w dupę kopnął. Plejlista, kawałek albo album prosze."
)
logger.error("Wrong link provided spotify: %s", link)
elif source == "Youtube":
link_ok = False
pat = r"^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$"
if re.match(pat, link) and (
re.match(".*youtube.*", link) or re.match(".*youtu.be.*", link)
):
link_ok = True
if link_ok:
dir_path = ""
file_list = []
query = link
sponsorblock_postprocessor = [
{
"key": "SponsorBlock",
"categories": ["skip_non_music_sections"],
},
{
"key": "ModifyChapters",
"remove_sponsor_segments": ["music_offtopic"],
"force_keyframes": True,
},
]
# TODO: Make sponsorblock work
sponsorblock_postprocessor = []
if platform == "win32":
dir_path = "G:\\Muzyka\\Youtube"
else:
dir_path = "/home/pi/RetroPie/mp3/Youtube"
ydl_opts = {
"username": YOUTUBE_AUTH[0],
"password": YOUTUBE_AUTH[1],
"proxy": "",
"default_search": "ytsearch",
"format": "bestaudio/best",
"postprocessors": sponsorblock_postprocessor,
"noplaylist": True,
"no_color": False,
"paths": {"home": dir_path},
}
mp3_postprocess_opts = {
"key": "FFmpegExtractAudio",
"preferredcodec": "mp3",
"preferredquality": "192",
}
ydl_opts["postprocessors"].append(mp3_postprocess_opts.copy())
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
coro = asyncio.to_thread(ydl.download, [query])
_ = await coro
except Exception as exce: # pylint: disable=broad-exception-caught
logger.error(exce)
logger.info(
"Failed to download %s, make sure yt_dlp is up to date", link
)
extract = re.search("v=(...........)[&,$]*", link)
ident = extract.group(1)
for item in Path.glob(Path(dir_path), "**/*.mp3"):
file = item.as_posix()
if platform == "win32":
file = file.replace("/", "\\")
if re.match(".*" + ident + ".*", file):
file_list.append(file)
return dir_path, file_list
await ctx.send(
"No se jaja chyba robisz, Ty byś jutuba nie poznawszy jakby z krzaków wyskoczył i w dupę kopnął. Jutuba to nie tuba którą sobie można gdzieś wsadzić."
)
logger.error("Wrong link provided youtube: %s", link)
return "", None
elif source == "Pornol":
dir_path = ""
file_list = []
query = link
sponsorblock_postprocessor = [
{
"key": "SponsorBlock",
"categories": ["skip_non_music_sections"],
},
{
"key": "ModifyChapters",
"remove_sponsor_segments": ["music_offtopic"],
"force_keyframes": True,
},
]
# TODO: Make sponsorblock work
sponsorblock_postprocessor = []
dir_path = "/home/pi/MediaShare/movies/porn"
ydl_opts = {
"postprocessors": sponsorblock_postprocessor,
"paths": {"home": dir_path},
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
coro = asyncio.to_thread(ydl.download, [query])
_ = await coro
except Exception as exce: # pylint: disable=broad-exception-caught
logger.error(exce)
logger.info(
"Failed to download %s, make sure yt_dlp is up to date", link
)
file_list.append("wiadomo co wiadomo gdzie")
return dir_path, file_list
async def search_music(ctx, how_many=0, slowa_kluczowe=None):
"""
Take in a context object and an optional integer parameter "how_many" and perform search
operation on music library.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command
:param how_many: how_many is a parameter that specifies the number of search results to be returned.
It is an optional parameter with a default value of 0, which means that if no value is provided for
how_many, the function will return all search results, defaults to 0 (optional)
"""
# i sieć neuronową z możliwością wyrażenia opinii o dopasowaniu.
logger = logging.getLogger("discord")
if slowa_kluczowe:
word_list = slowa_kluczowe
else:
word_list = ctx.message.content.split()
logger.info("Wyszukuje")
jrequest = {
"lista_slow": word_list,
"dlugosc_plejlisty": how_many,
"UUID": str(uuid.uuid4()),
}
coroutine = asyncio.to_thread(
requests.post,
f"{FILE_SERVICE_ADDRESS}{GET_PLAYLIST}",
json=jrequest,
headers=SERVICE_HEADERS,
timeout=360,
)
return_data = await coroutine
if not return_data.status_code == 200:
await ctx.send("Wołaj szefa - coś się wyjebało")
return
logger.info(return_data.json()["data"])
return return_data.json()["data"]
View File
-212
View File
@@ -1,212 +0,0 @@
import asyncio
import json
import logging
from datetime import datetime
from typing import Optional
import discord
from discord.ext import commands
from constants import ACCIDENT_LOG, DATA, ENCODING
historia_fabryczki = DATA["fabryczka"]
class OtherModule(commands.Cog):
def __init__(self, bot, logger_name):
self.bot = bot
self.logger = logging.getLogger(logger_name)
@commands.hybrid_command(
name="przytul", description="Przytul kogoś - daj mention po komendzie :)"
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def przytul(self, ctx, arg: Optional[discord.Member] = None):
"""
Generate a text about hugging mentioned user.
:param ctx: ctx stands for "context" and is a required parameter in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command
:param arg: arg is a parameter of the function "przytul" that expects a Discord member object. The
parameter is optional, meaning that if no member object is provided, it will default to None
:type arg: Optional[discord.Member]
"""
async with ctx.typing():
nieprzytulac = False
for mention in ctx.message.mentions:
for role in mention.roles:
if role.name == "NIEPRZYTULAĆ!":
nieprzytulac = True
if arg and nieprzytulac:
await ctx.send(
f"Żebym ja Ciebie nie przytulił {ctx.message.author.mention}"
)
elif arg:
await ctx.send(
# trunk-ignore(codespell/misspelled)
f"Już dobrze.... Już dobrze... Ojej.. Biedactwo... :( *W ułamku sekundy {arg.mention} znajduje sie w duszącym uścisku. Żebra trzeszczą - kilka pęka. Pacnięcia po plecach grożą odbiciem nerek, a głaskanie po głowie powoduje wstrząs mózgu*"
)
else:
await ctx.send(
"Kogo mam przytulić? *Wyłamuje kostki i przeciąga się - jego 200 kilowa sylwetka złożona z samych mięśni świadczy o tym że jest gotowy*"
)
@commands.hybrid_command(name="fabryczka", description="Historia fabryczki")
@commands.has_any_role('Legenda', 'Jarl', 'Thane' , 'Bartender')
async def fabryczka(self, ctx):
"""
Send a general description of "fabryczkagate" to channel.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. The context object provides a way to
interact with the Discord
"""
await ctx.send(historia_fabryczki)
@commands.hybrid_command(
name="set_logging_level",
description="Nie intererer bo kici kici",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Legenda', 'Jarl', 'Bartender')
async def set_logging_level(self,ctx):
if "DEBUG" in ctx.message.content:
logger = logging.getLogger("discord")
logger.setLevel(logging.DEBUG)
elif "INFO" in ctx.message.content:
logger = logging.getLogger("discord")
logger.setLevel(logging.INFO)
else:
await ctx.send("Weź się kurwa zdecyduj co ?")
@commands.hybrid_command(
name="reset_the_clock",
description="Resetowanie zegara - dostępne wyłącznie dla Hammera",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Legenda', 'Jarl', 'Thane' , 'Bartender')
async def reset_the_clock(self, ctx):
"""
Reset the clock on "accidents" in Hammer Fortress adding a new one.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. This parameter is required in all
Discord.py commands
"""
hammer = False
for role in ctx.message.author.roles:
if role.name == "Bartender":
hammer = True
if hammer:
with open(ACCIDENT_LOG, "r+", encoding=ENCODING) as new_file_accidents:
# First we load existing data into a dict.
file_data = json.load(new_file_accidents)
czas = datetime.strptime(file_data[-1][1], "%Y-%m-%d %H:%M:%S.%f")
opis = ctx.message.content[16:]
accident = accident = [opis, f"{datetime.now()}"]
file_data.append(accident)
new_file_accidents.seek(0)
# convert back to json.
json.dump(file_data, new_file_accidents, indent=4)
await ctx.send(
f"Komenda mierząca incydenty w Eldritch AbsinthHammerTimeSpaceContinuum.\n RESET THE CLOCK. Od ostatniego incydentu w chacie Hammera minęło {czas}. No ale teraz się odpierdoliło: {opis}"
)
else:
await ctx.send(
f"Komenda mierząca incydenty w Eldritch AbsinthHammerTimeSpaceContinuum.\n Gdzie z łapami do zegara? {self.bot.get_user(346956223645614080).mention} coś albo się komuś stało albo zaraz się stanie jak będzie zegar tykał...."
)
@commands.hybrid_command(
name="chata_hammera",
description="Czas od ostatniego incydentu w AbsinthHammerTimeSpaceContinuum",
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def chata_hammera(self, ctx):
"""
Send measured time from last incident in Hammer Fortress to the chat.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
contains information about the context in which the command was invoked, such as the message, the
channel, the server, and the user who invoked the command. This information can be used to perform
various actions, such
"""
with open(ACCIDENT_LOG, "r+", encoding=ENCODING) as new_file:
# First we load existing data into a dict.
file_data = json.load(new_file)
opis = file_data[-1][0]
czas = datetime.strptime(file_data[-1][1], "%Y-%m-%d %H:%M:%S.%f")
await ctx.send(
f"Komenda mierząca incydenty w Eldritch AbsinthHammerTimeSpaceContinuum.\n Od ostatniego incydentu w chacie Hammera minęło {datetime.now() - czas}. Incydent to: {opis}"
)
@commands.hybrid_command(
name="historia_incydentow_u_hammera",
description="Wyswietla liste incydentów które miały miejsce w AbsinthHammerTimeSpaceContinuum.",
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def historia_incydentow_u_hammera(self, ctx):
"""
Send a list of incidents in Hammer Fortress to the chat.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
contains information about the context in which the command was invoked, such as the message, the
channel, the server, and the user who invoked the command. This information can be used to perform
various actions, such
"""
with open(ACCIDENT_LOG, "r+", encoding=ENCODING) as new_file_accidents:
# First we load existing data into a dict.
file_data = json.load(new_file_accidents)
return_data = "Komenda mierząca incydenty w Eldritch AbsinthHammerTimeSpaceContinuum.\n Historia zarejestrownych incydentów u Hammera:\n"
index = 1
for iter_data in file_data:
opis = iter_data[0]
czas = datetime.strptime(iter_data[1], "%Y-%m-%d %H:%M:%S.%f")
add_data = f"{index}. Opis: {opis} Data: {czas}"
return_data = return_data + add_data + "\n"
index += 1
await ctx.send(return_data)
@commands.hybrid_command(
name="radio_hardkor",
description="Włącza radio Conjurer na kanale #nocna-zmiana.",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def radio_hardkor(self, ctx):
"""
Plays the radio hardkor stream in the voice channel.
Args:
ctx: The context object representing the invocation context.
Returns:
None
"""
self.logger.info("Press play on radio hardkor")
async with ctx.typing():
global MUZYKA # pylint: disable=global-variable-not-assigned
MUZYKA["ctx"] = ctx
if not self.bot.voice_clients:
await self.connect(ctx=ctx)
voice_client = self.bot.voice_clients[0]
voice_client.play(
discord.PCMVolumeTransformer(
discord.FFmpegPCMAudio("http://95.175.16.246:666/mp3-stream"),
volume=0.3,
)
)
self.logger.info("Press play on radio completed")
else:
self.logger.error("Already playing")
await asyncio.sleep(12)
self.check_music.start()
async def setup(bot):
logger = logging.getLogger("discord")
await bot.add_cog(OtherModule(bot, "discord"))
logger.info("Loading kitchen sink module done")
-118
View File
@@ -1,118 +0,0 @@
import logging
# define an asynchronous generator
async def async_iterator_generator(range_of_iterable):
"""
Generate asynchronouse iterator.
This is an incomplete function definition for an asynchronous iterator generator that takes a range
of iterable as input.
:param range_of_iterable: The parameter `range_of_iterable` is likely a range or iterable object
that the async iterator generator will iterate over asynchronously. It could be a list, tuple, set,
or any other iterable object. The generator will yield each item in the iterable object
asynchronously, allowing other code to run in between
"""
# normal loop
for i in range_of_iterable:
# pylint: disable=pointless-string-statement
# yield the result
yield i
"""
Alternatywna wersja przerobienia fora na asynchroniczny.
# traverse the iterable of awaitables
for item in coros:
# await and get the result from the awaitable
result = await item
# report the results
print(result)
"""
async def remove_characters(string, character):
"""
The `remove_characters` function removes all occurrences of a specified character from a given
string.
:param string: The string parameter is the input string from which characters will be removed
:param character: The character parameter is the character that you want to remove from the string
:return: a new string where all occurrences of the specified character have been removed.
"""
return string.replace(character, "")
async def max_weight(lista):
"""
Take a list of weights and return the maximum weight.
:param lista: It seems like the parameter `lista` is a list of items, possibly representing weights.
The function name `max_weight` suggests that the function is intended to find the maximum weight
from the list. However, without more context or information about the problem, it's difficult to say
for sure what the
"""
maximum_weight = 0
for iterator in lista:
if iterator[0] > maximum_weight:
maximum_weight = iterator[0]
return maximum_weight
async def get_stats(client, ctx, history_limit):
"""
The `get_stats` function retrieves the message history of a specific channel and counts the
frequency of each word in the messages.
:param ctx: The `ctx` parameter is an object that represents the context of the command being
executed. It contains information such as the message, the author, the server, and other relevant
details
:param history_limit: The `history_limit` parameter is the maximum number of messages to retrieve
from the channel history. It determines how far back in time the statistics will be calculated
:return: The function `get_stats` returns a dictionary `stats` that contains the frequency count of
words found in the messages from the specified channel.
"""
channel_id = 1062047367337095268
async with ctx.typing():
stats = {}
channel = client.get_channel(channel_id)
messages = [message async for message in channel.history(limit=history_limit)]
# traverse the iterable of awaitables
for message in messages:
# await and get the result from the awaitable
result = message.content
words = result.split()
for word in words:
if word in stats:
stats[word] += 1
else:
stats[word] = 1
for name, how_many in stats.items():
yield name, how_many
async def discord_friendly_reply(ctx, message_content, file=None):
logger = logging.getLogger("discord")
if len(message_content) < 999:
logger.info("Answer reply - message below 999")
await ctx.reply(message_content)
else:
logger.info("Answer reply - message above 999")
while len(message_content) > 0:
logger.info("Mesaage content: %s", message_content[:999])
await ctx.reply(message_content[:999])
message_content = message_content[999:]
if file:
await ctx.reply("Attachment:", file=file)
async def discord_friendly_send(ctx, message_content, file=None):
logger = logging.getLogger("discord")
if len(message_content) < 999:
logger.info("Answer send - message below 999")
await ctx.send(message_content)
else:
logger.info("Answer send - message above 999")
while len(message_content) > 0:
logger.info("Mesaage content: %s", message_content[:999])
await ctx.send(message_content[:999])
message_content = message_content[999:]
if file:
await ctx.reply("Attachment:", file=file)
-16
View File
@@ -234,21 +234,5 @@
{
"role": "assistant",
"content": "Jakich konkretnych informacji mam si\u0119 douczy\u0107? Jestem tu po to, aby Ci pom\u00f3c, wi\u0119c jestem otwarty na uzupe\u0142nienie swojej wiedzy w dowolnym zakresie, o ile tylko nie \u0142amie to zasad regulaminu i etyki."
},
{
"role": "user",
"content": "The Bartender [S\u0142o\u0144ce z Betonu]:@Conjurer halo ?"
},
{
"role": "assistant",
"content": "*Kondziu patrzy na terminal* Wo\u0142aj szefa. Zap\u0142aci\u0107 rachunki za AI trzeba. Jak chcesz to si\u0119 na #zebranie dorzu\u0107. Error code: 429 - {'error': {'message': 'You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.', 'type': 'insufficient_quota', 'param': None, 'code': 'insufficient_quota'}}"
},
{
"role": "user",
"content": "The Bartender [S\u0142o\u0144ce z Betonu]:helo\u0142 @Nocna Zmiana dwie wiadomo\u015bci dobra i jeszcze gorsza.\n@Conjurer zosta\u0142 zreanimowany po d\u0142u\u017cszej nieobecno\u015bci - ale jak zaraz sami zobaczycie je\u015b\u0107 wo\u0142a. nie dzia\u0142a w nim te\u017c jeszcze wyszukiwanie artyku\u0142\u00f3w naukowych (do tego trzy tygodnie developmentu posz\u0142y w p*****ec). wyszukiwarka wr\u00f3ci jak dotrze zam\u00f3wienie z kieszeniami na dysk @gwojtal - bedzie trzeba \u015bci\u0105gn\u0105\u0107 snapshot bazy i uruchomi\u0107 cz\u0119\u015b\u0107 odpowiedzialn\u0105 za wyszukiwanie w nim. specjalne funkcje b\u0119d\u0119 odblokowywa\u0142 w miare ich naprawiania - tak samo dam mu oczywi\u015bcie je\u015b\u0107 z w\u0142asnej kieszeni jak ju\u017c b\u0119dzie potrzeba."
},
{
"role": "assistant",
"content": "*Kondziu patrzy na terminal* Wo\u0142aj szefa. Zap\u0142aci\u0107 rachunki za AI trzeba. Jak chcesz to si\u0119 na #zebranie dorzu\u0107. Error code: 429 - {'error': {'message': 'You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.', 'type': 'insufficient_quota', 'param': None, 'code': 'insufficient_quota'}}"
}
]
+1 -1
View File
@@ -787,4 +787,4 @@
"role": "assistant",
"content": "\"Coffin Fodder\" to utw\u00f3r z albumu \"Nymphetamine\" brytyjskiego zespo\u0142u Cradle Of Filth. To death-metalowe brzmienie z elementami black metalu i gotyckiego rocka. \n\nTekst utworu to prowokacyjne po\u0142\u0105czenie j\u0119zyka poetyckiego z drastycznymi opisami \u015bmierci i ciemn\u0105 estetyk\u0105, charakterystyczn\u0105 dla tw\u00f3rczo\u015bci Cradle Of Filth. \n\n\"Coffin Fodder\" opowiada histori\u0119 mordercy zabijaj\u0105cego kobiety i wk\u0142adaj\u0105cego ich cia\u0142a do trumien, aby zaspokoi\u0107 swoje mroczne i okrutne pragnienia. To utw\u00f3r pe\u0142en brutalnych opis\u00f3w, kt\u00f3rych celem jest szokowanie i wywo\u0142anie wstr\u0119tu w s\u0142uchaczu. \n\nPod wzgl\u0119dem muzycznym, \"Coffin Fodder\" charakteryzuje si\u0119 szybkim tempem, ci\u0119\u017ckimi riffami gitary i intensywnymi partiami perkusyjnymi. Utw\u00f3r jest jednym z bardziej ekstremalnych utwor\u00f3w zespo\u0142u Cradle Of Filth, co czyni go atrakcyjnym dla mi\u0142o\u015bnik\u00f3w mocnej i agresywnej muzyki metalowej."
}
]
]
-20
View File
@@ -1,20 +0,0 @@
/home/pi/RetroPie/mp3/Muzyka/2011/Hallman/Hallman - 7 Bram/Niezłomność/Twardzi jak Stal/03-Forteca-1942.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/09. Świty Zmartwychwstania.mp3
/home/pi/RetroPie/mp3/Muzyka/2011/Horytnica Czas Patriotów/Horytnica - Promo track CD 2011 Nie musimy umiera.mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/08-Schmaletz-REWOLUCYJNA_NSZ(1).mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/schmaletz_-_nie_ma_fajnych_lasek_na_prawicy.mp3
/home/pi/RetroPie/mp3/Youtube/ADU - Lecimy ze Smoleńska z powrotem [f3CVfG4pV8M].mp3
/home/pi/RetroPie/mp3/2022 Nov-Dec/Anahata - Winged Hussars - Cover.m4a
/home/pi/RetroPie/mp3/2022 Nov-Dec/Sabaton - 40#1 - Live, at Woodstock Festival.m4a
/home/pi/RetroPie/mp3/2022 Nov-Dec/Sabaton - Uprising - Live, at Woodstock Festival.m4a
/home/pi/RetroPie/mp3/2022 Nov-Dec/Sabaton - Inmate 4859.m4a
/home/pi/RetroPie/mp3/2022 Nov-Dec/Sabaton - Winged Hussars.m4a
/home/pi/RetroPie/mp3/Youtube/ADU Ada Karczmarczyk - Hej husarzu! NIE_PODLE_głości dzień [SReTynvd6ek].mp3
/home/pi/RetroPie/mp3/2023 Aug-Oct/Sabadu - A Very Polish Christmas.mp3
/home/pi/RetroPie/mp3/2023 Aug-Oct/Sabadu - Mighty Polish Tank.mp3
/home/pi/RetroPie/mp3/Youtube/ADU - Nie chcę w lewo, nie chcę w prawo [J3Ok-KcbQ80].mp3
/home/pi/RetroPie/mp3/2022 Nov-Dec/Sabaton - 40#1 - World War Tour 2010.m4a
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/01. Prolog.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/02. Honor Legionisty.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/07. Już Nie Musimy Umierać.mp3
-46
View File
@@ -1,46 +0,0 @@
/home/pi/RetroPie/mp3/2022 Nov-Dec/Sabaton - 40#1 - World War Tour 2010.m4a
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/08-Schmaletz-REWOLUCYJNA_NSZ(1).mp3
/home/pi/RetroPie/mp3/Youtube/ADU feat MATEJKO - Nie strzelam do zdrajców oczami [PGL6Q9G4_JM].mp3
/home/pi/RetroPie/mp3/Youtube/ADU - Lecimy ze Smoleńska z powrotem [f3CVfG4pV8M].mp3
/home/pi/RetroPie/mp3/Muzyka/2011/Horytnica Czas Patriotów/Horytnica - Promo track CD 2011 Nie musimy umiera.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/01. Prolog.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/02. Honor Legionisty.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/03. Kraj Zdradzony.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/04. Mały Powstaniec.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/05. Lisowczycy.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/06. Śląski Rycerz.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/07. Już Nie Musimy Umierać.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/08. Sierp I Młot.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/09. Świty Zmartwychwstania.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/10. Katyńskie Łzy.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/11. Kochana Ma Polska.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/12. Słowiańska Armia Pracy.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/13. Pamięć I Duma.mp3
/home/pi/RetroPie/mp3/Muzyka/2012/Głos Patriotów (2011) (Wirrrus)/14. Epilog.mp3
/home/pi/RetroPie/mp3/2022 Nov-Dec/Anahata - Winged Hussars - Cover.m4a
/home/pi/RetroPie/mp3/Youtube/ADU - Nie chcę w lewo, nie chcę w prawo [J3Ok-KcbQ80].mp3
/home/pi/RetroPie/mp3/Muzyka/2011/Hallman/Hallman - 7 Bram/Niezłomność/Twardzi jak Stal/03-Forteca-1942.mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/Forteca - Bagnet na broń.mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/02-2 Forteca.mp3
/home/pi/RetroPie/mp3/Muzyka/2013/SABATON-Metalus Hammerus Rex (djdariush)/40-1.mp3
/home/pi/RetroPie/mp3/Muzyka/2014/Sabaton - Heroes (Deluxe Earbook Edition) 2014/CD1/04. Inmate 4859_[plixid.com].mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/FORTECA - 1942.mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/FORTECA - Apel poległych.mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/FORTECA - BEZIMIEŃCOM.mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/FORTECA - DZIŚ IDE WALCZYĆ MAMO.mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/FORTECA - GNIEW.mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/FORTECA - KATYŃ.mp3
/home/pi/RetroPie/mp3/Muzyka/2010/Sabaton -2010- Coat Of Arms/03- Uprising.mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/FORTECA - ORZEŁ BIAŁY.mp3
/home/pi/RetroPie/mp3/2023 Aug-Oct/Sabadu - Mighty Polish Tank.mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/Dj. Celownik & Hallmann - O jau mano mielas [zapiska.pl].mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/Dj.Celownik & Hallmann - Duma o Zakrzewskim [zapiska.pl].mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/Dj.Celownik & Hallmann - Na lipe slowianska [zapiska.pl].mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/Dj.Celownik & Hallmann - Piesn Legijonu Litewskiego [zapiska.pl].mp3
/home/pi/RetroPie/mp3/Muzyka/2011/muza patriotyczna/08-Schmaletz-REWOLUCYJNA_NSZ(1).mp3
/home/pi/RetroPie/mp3/2022 Nov-Dec/Sabaton - 40#1 - Live, at Woodstock Festival.m4a
/home/pi/RetroPie/mp3/2022 Nov-Dec/Sabaton - Uprising - Live, at Woodstock Festival.m4a
/home/pi/RetroPie/mp3/2022 Nov-Dec/Sabaton - Inmate 4859.m4a
/home/pi/RetroPie/mp3/2022 Nov-Dec/Sabaton - Winged Hussars.m4a
/home/pi/RetroPie/mp3/Youtube/ADU Ada Karczmarczyk - Hej husarzu! NIE_PODLE_głości dzień [SReTynvd6ek].mp3
/home/pi/RetroPie/mp3/2023 Aug-Oct/Sabadu - A Very Polish Christmas.mp3
-11
View File
@@ -1,11 +0,0 @@
[Unit]
Description=PulseAudio System-wide Daemon
After=sound.target network.target
[Service]
Type=simple
ExecStart=/usr/bin/pulseaudio --system --disallow-exit --disallow-module-loading=0 --daemonize=no
Restart=always
[Install]
WantedBy=multi-user.target
-5
View File
@@ -1,5 +0,0 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
addopts = -ra
-214
View File
@@ -1,214 +0,0 @@
import logging
from discord.ext import commands
import discord
# trunk-ignore(bandit/B404)
import subprocess
import requests
import uuid
import asyncio
from datetime import datetime
from constants import RADIO_HARBOR_ADDRESS, SKIP_TRACK, RADIO_SERVICE_ADDRESS, ADD_TO_PRIO_PLAYLIST, REQUEST_MUSIC, CREATE_PRIO_PLAYLIST, CLEAR_PRIO, service_headers
SERVICE_HEADERS = service_headers()
class RadioModule(commands.Cog):
def __init__(self, bot, logger_name):
self.bot = bot
self.logger = logging.getLogger(logger_name)
@commands.hybrid_command(
name="skip_track",
description="Przeskocz kawałek w radiu",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Legenda', 'Jarl', 'Thane' , 'Bartender')
async def skip_track(self, ctx):
async with ctx.typing():
allowed = False
for role in ctx.author.roles:
if role.name in ("Thane", "Jarl"):
allowed = True
if not allowed:
await ctx.send("Łapy precz od radia")
else:
coroutine = asyncio.to_thread(
requests.get,
f"{RADIO_HARBOR_ADDRESS}{SKIP_TRACK}",
headers=SERVICE_HEADERS,
timeout=360,
)
result = await coroutine
self.logger.info("Done %s", result)
await ctx.send("Zrobione szefie!")
@commands.hybrid_command(
name="zrestartuj_radio",
description="Komenda ktora uruchamia radio ponownie jakby się zawiesiło",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Legenda', 'Jarl', 'Thane' , 'Bartender')
async def zrestartuj_radio(self, ctx):
async with ctx.typing():
allowed = False
for role in ctx.author.roles:
if role.name in ("Thane", "Jarl"):
allowed = True
if not allowed:
await ctx.send("Łapy precz od radia")
else:
retcode = subprocess.run(
"/home/pi/Conjurer/restart_radio.sh",
# trunk-ignore(bandit/B603)
shell=False,
check=False,
capture_output=True,
)
self.logger.info("Wynik: %s", retcode)
@commands.hybrid_command(
name="dodaj_do_ulubionych",
description="Dodaje do listy ulubionych w radiu",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Legenda', 'Jarl', 'Thane' , 'Bartender')
async def dodaj_do_ulubionych(self, ctx):
"""
Generate a playlist in queue. First word in this command shall be int defining length of the playlist.
Rest of the line are search terms.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. This parameter is required in all
Discord.py commands
"""
async with ctx.typing():
self.logger.info("Zaczynam szukać timestamp %s", datetime.now())
dlugosc_playlisty = ctx.message.content.split()[1]
word_list = ctx.message.content.split()
jrequest = {
"lista_slow": word_list,
"dlugosc_plejlisty": dlugosc_playlisty,
"UUID": str(uuid.uuid4()),
}
coroutine = asyncio.to_thread(
requests.post,
f"{RADIO_SERVICE_ADDRESS}{ADD_TO_PRIO_PLAYLIST}",
json=jrequest,
headers=SERVICE_HEADERS,
timeout=360,
)
result = await coroutine
self.logger.info("Done %s", result)
await ctx.send("Zrobione szefie!")
@commands.hybrid_command(
name="ja_chciol",
description="Dodaje do listy ulubionych w radiu",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def request_radio(self, ctx):
"""
Generate a playlist in queue. First word in this command shall be int defining length of the playlist.
Rest of the line are search terms.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. This parameter is required in all
Discord.py commands
"""
async with ctx.typing():
self.logger.info("Zaczynam szukać timestamp %s", datetime.now())
word_list = ctx.message.content.split()
jrequest = {
"lista_slow": word_list,
"UUID": str(uuid.uuid4()),
}
coroutine = asyncio.to_thread(
requests.post,
f"{RADIO_SERVICE_ADDRESS}{REQUEST_MUSIC}",
json=jrequest,
headers=SERVICE_HEADERS,
timeout=360,
)
result = await coroutine
self.logger.info("Done %s", result)
await ctx.send("Zrobione szefie!")
@commands.hybrid_command(
name="stworz_audycje",
description="Dodaje do listy ulubionych w radiu",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Legenda', 'Jarl', 'Thane' , 'Bartender')
async def stworz_audycje(self, ctx):
"""
Generate a playlist in queue. First word in this command shall be int defining length of the playlist.
Rest of the line are search terms.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. This parameter is required in all
Discord.py commands
"""
async with ctx.typing():
self.logger.info("Zaczynam szukać timestamp %s", datetime.now())
dlugosc_playlisty = ctx.message.content.split()[1]
word_list = ctx.message.content.split()
jrequest = {
"lista_slow": word_list,
"dlugosc_plejlisty": dlugosc_playlisty,
"UUID": str(uuid.uuid4()),
}
coroutine = asyncio.to_thread(
requests.post,
f"{RADIO_SERVICE_ADDRESS}{CREATE_PRIO_PLAYLIST}",
json=jrequest,
headers=SERVICE_HEADERS,
timeout=360,
)
result = await coroutine
self.logger.info("Done %s", result)
await ctx.send("Zrobione szefie!")
@commands.hybrid_command(
name="wyczysc_ulubione",
description="Czysci liste ulubionych w radiu",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Legenda', 'Jarl', 'Thane' , 'Bartender')
async def wyczysc_ulubione(self, ctx):
"""
Generate a playlist in queue. First word in this command shall be int defining length of the playlist.
Rest of the line are search terms.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. This parameter is required in all
Discord.py commands
"""
async with ctx.typing():
coroutine = asyncio.to_thread(
requests.get,
f"{RADIO_SERVICE_ADDRESS}{CLEAR_PRIO}",
headers=SERVICE_HEADERS,
timeout=360,
)
result = await coroutine
self.logger.info("Done %s", result)
await ctx.send("Zrobione szefie!")
async def setup(bot):
logger = logging.getLogger("discord")
await bot.add_cog(RadioModule(bot, "discord"))
logger.info("Loading raadio commands module done")
+2 -9
View File
@@ -1,10 +1,8 @@
setuptools
discord
yt_dlp
spotify_dl
spotipy
openai
anthropic
eyed3
numpy
pdf2image
@@ -13,10 +11,5 @@ requests
spotipy
tiktoken
PyNaCl
flask[async]
PyMuPDF
waitress
assemblyai[extras]
SpeechRecognition
asyncssh
git+https://github.com/imayhaveborkedit/discord-ext-voice-recv
flask
waitress
-6
View File
@@ -1,6 +0,0 @@
# Optional extras for the Conan Exiles bridge (conanjurer cog).
#
# aiomcrcon currently installs only on Python <= 3.11, while the main image
# runs 3.13. Install is therefore best-effort: when it fails the conanjurer
# cog simply stays dormant (its imports are guarded) and the bot runs fine.
aiomcrcon
-3
View File
@@ -1,3 +0,0 @@
sshpass -p raspberry ssh pi@192.168.1.15 << 'ENDSSH'
sudo reboot
ENDSSH
View File
View File
+58 -2
View File
@@ -22,6 +22,20 @@
false,
false
],
"indie\\b": [
"Indie? *Wyciąga rewolwerową wyrzutnię taktycznych bomb jądrowych* Gdzie??? *Zaczyna się maniakalnie śmiać*",
15.0,
0,
false,
false
],
"hindus": [
"Hindus? Gdzie.... *Wyciąga spod lady ciężki miotacz płomieni i zaczyna się maniakalnie śmiać*",
15.0,
0,
false,
false
],
"chuj\\b": [
"Eeeee.... Szefie... ktoś cię woła! Wskazuje na Hammera",
15.0,
@@ -85,6 +99,27 @@
false,
false
],
"tatarek\\b": [
"Tatarek? Nie dramatyzuj....",
15.0,
0,
false,
false
],
"krowa\\b": [
"Krówka? Nie dramatyzuj....",
15.0,
0,
false,
false
],
"krówka\\b": [
"Krówka? Nie dramatyzuj....",
15.0,
0,
false,
false
],
"jessenia\\b": [
"Jessenia? Jak przyszła tu w różowej pidżamie z uszkami to nawet nie mrugnąłem okiem. Te oczy.... No ale jakiś debil Ją prowokował mówiąc \"Zdominuj mnie\" Akurat miała dobry humor więc przeżył - ale po 115.0 sekundach był już na \"Tak Pani, przepraszam że zająłem czas Pani\" Respect dla kobitki",
15.0,
@@ -155,8 +190,15 @@
false,
false
],
"same plusy\\b": [
"Jak na cmentarzu Szefie. Jak na cmentarzu",
"elokwentna\\b": [
"O matko. Znowu ta dyskusja? Hammer ma stary słownik gdzie elokwentna i pyskata są praktycznie synonimami. Jedyna różnica że swojej uległej można za bycie pyskatym dać po dupie.",
15.0,
0,
false,
false
],
"elokwencja\\b": [
"O matko. Znowu ta dyskusja? Hammer ma stary słownik gdzie elokwentna i pyskata są praktycznie synonimami. Jedyna różnica że swojej uległej można za bycie pyskatym dać po dupie.",
15.0,
0,
false,
@@ -204,6 +246,13 @@
false,
false
],
"fallain\\b": [
"Kochana Krówka. Skłonność do dramatów i zakrwawiania ścian. Jedna z kilku NAPRAWDE srogich masochistek... Ach ta krew :drool:",
15.0,
0,
false,
false
],
"roar": [
"Kici, kici....",
15.0,
@@ -260,6 +309,13 @@
false,
true
],
"revalyacyjnie\\b": [
"Zajebisty dowcip szefie. Na pewno się \"Twojej Byłej Dziewczynie\":tm: spodoba.",
15.0,
0,
false,
true
],
"nocna zmiana\\b": [
"Nocna Zmiana? No to właściciele tego baru. Taki troche Hammer Harema. Kto to jest Harem>? Łokurwa. Harem Hammera. Ale on tu robi za jedyną hurysę, a reszta to szejkowie. Tak zrobię Ci szejka.",
15.0,
+2 -4
View File
@@ -8,7 +8,5 @@ def signal_handler(sig, frame):
print("\nCaught interrupt(did you press Ctrl+C?), stopping spotify_dl")
sys.exit(0)
try:
signal.signal(signal.SIGINT, signal_handler)
except ValueError:
print("Exception in signal handler")
signal.signal(signal.SIGINT, signal_handler)

Some files were not shown because too many files have changed in this diff Show More