Compare commits

..

3 Commits

Author SHA1 Message Date
Michal Tuszowski c03e2e985e Fix and integrate the conanjurer (Conan Exiles) bridge
Fixes the broken, never-loaded conanjurer module and wires it into the bot
following the project convention (cog in *_commands, logic in *_functions).

- conanjurer_functions.py: move 'from __future__' to the top (was a
  SyntaxError on line 6), drop duplicate import; config now comes from
  constants instead of dotenv/.env; guard optional deps (aiomcrcon,
  asyncssh) so the extension loads even when they are absent
- conanjurer_commands.py: single ConanModule(commands.Cog) with the
  standard (bot, logger_name)+setup() shape; owns its RconClient (no more
  undefined bot.cfg/bot.rcon); GM check uses CONAN_GM_ROLE_ID; commands
  report cleanly when RCON is unconfigured
- bot.py: load_extension('conanjurer_commands')
- constants.py: CONAN_* config (env-overridable); integration is dormant
  unless configured
- requirements_bot.txt: add aiomcrcon, asyncssh

Player-join notifications (task 2): watch_players() polls RCON
'listplayers', diffs against the previous set and announces new players on
CONAN_JOIN_CHANNEL_ID. Disabled when that channel is not defined (0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 23:46:33 +02:00
Michal Tuszowski f9bb0b2834 Apply conversation bot improvements (proto-improvements)
Diff vs working-copy baseline = exactly this session's bot edits:
- constants.py: env-var config, safe JSON loading, optional-dependency
  guards, env->netrc token resolution, API_SHARED_KEY + service_headers()
- communication_subroutine.py: queue get(timeout) + Empty handling,
  daemon threads, cooperative stop_event, inbound _authorize_request()
- bot.py: single asyncio event loop with cooperative shutdown
- music_functions / radio_commands / librarian_commands: send
  X-Conjurer-Api-Key on internal HTTP calls via service_headers()

Musician runtime data overlay dropped; conjurer_musician/.gitignore now
keeps generated playlists/mp3 out of the repo. The 1+2+3 musician code
port already lives in the base conjurer_musician.

Depends on #9: must merge after restructure/working-copy-root.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 23:36:58 +02:00
Michal Tuszowski f9a1e7c03a Restructure: promote working_copy to repo root
Make the stable 'working copy' bot the canonical code at the repository
root so the install/deploy scripts operate against it again.

- Move working_copy/* to root (bot entrypoint is bot.py)
- Restore root-level install/ops scripts from c4fa88e (deploy.sh,
  install_main_bot.sh, status_report.*, conjurer.service, etc.)
- Fix deploy.sh: copy bot.py (was thin_client.py) and add the
  conanjurer_* modules; bump command count
- Remove side-by-side variant dirs (backup_old_docker, prototype_one,
  prototype_musician_one, musician_old, working_copy) and docker cruft
- Keep components as subdirs: conjurer_librarian, conjurer_musician,
  spotify_dl, yt_dlp, fonts, utils, docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:51:24 +02:00
54 changed files with 586 additions and 3036 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
-51
View File
@@ -45,14 +45,6 @@ class Events(commands.Cog):
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] != "":
@@ -109,40 +101,6 @@ class Events(commands.Cog):
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",
@@ -343,15 +301,6 @@ class Events(commands.Cog):
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:
+57 -252
View File
@@ -8,11 +8,8 @@ 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,
@@ -26,222 +23,19 @@ from constants import (
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
# Jeżeli jawnie podano algorithm (i nie jest 'auto'/''):
if algo and str(algo).strip().lower() not in ("auto",):
# wyjątek: MUZYKA ma zawsze być tania — nadpisujemy TYLKO jeśli przyszedł domyślny 'gpt-4o'
if req_type == "MUSIC" and algo.strip() in (LATEST_MODEL,):
return CHEAP_MODEL
return algo
# Auto-dobór:
if req_type == "MUSIC":
return cheap
return latest
return CHEAP_MODEL
return LATEST_MODEL
async def openai_call(messages, model, temperature=0.2):
@@ -462,46 +256,57 @@ async def handle_response(
timeout_sec = 120
deadline = time.time() + timeout_sec
response = await asyncio.wait_for(
provider_generate(messages=history_msgs, model=model_to_use),
openai_call(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}"
except openai.APITimeoutError as e:
# Handle timeout error, e.g. retry or log
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ę*: {e}"
except openai.APIConnectionError as e:
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ę*: {e}"
except openai.BadRequestError as e:
# 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 {e} prostym językiem. Przeproś za nadmierną cenzurę. Wytłumacz co mogło być nie tak w prompcie 'prompt'",
True,
True,
MESSAGE_TABLE,
username,
"RANDOM",
)
response = 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.APIResponseValidationError as e:
# 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 {e} prostym językiem. Przeproś za nadmierną cenzurę. Wytłumacz co mogło być nie tak w prompcie 'prompt'",
True,
True,
MESSAGE_TABLE,
username,
"RANDOM",
)
response = 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
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ę:* {e}"
except openai.PermissionDeniedError as e:
# 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ę:* {e}"
except openai.RateLimitError as e:
response = f"*Kondziu patrzy na terminal* Wołaj szefa. Zapłacić rachunki za AI trzeba. Jak chcesz to się na #zebranie dorzuć. {e}"
except openai.UnprocessableEntityError as e:
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ę:* {e}"
except openai.APIError as e:
# 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*: {e}"
logger.info("Historia wysłana:")
temp_assistant = {"role": "assistant", "content": response}
+29 -226
View File
@@ -4,21 +4,9 @@
# pylint: disable=too-many-lines
"""
Module of a python bot named Conjurer - used to work on BDSM discord servers.
Startup is defensive by design:
* every cog is loaded independently - one broken/missing dependency disables
that cog only, never the whole bot,
* cogs that need a sibling service (musician / librarian) are only enabled
after a positive health check; a watchdog keeps re-checking and enables them
the moment the service comes alive,
* fatal misconfiguration (missing Discord token) exits loudly on stderr with a
clear message instead of dying silently into a log file.
"""
import asyncio
import logging
import os
import sys
# *=========================================== Standard Library Imports
import random
@@ -27,27 +15,12 @@ from logging import handlers
# *==============Imported libraries
import discord
import requests
from discord.ext import commands
from communication_subroutine import comm_subroutine
from constants import (
ENCODING,
FILE_SERVICE_ADDRESS,
GET_MP3,
LIBRARIAN_SERVICE_ADDRESS,
LOGFILE,
RADIO_SERVICE_ADDRESS,
TOKEN,
)
from constants import ENCODING, LOGFILE, TOKEN
logger = logging.getLogger("discord")
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
# File log (rotated). constants ensures the directory exists; this is belt and
# braces for exotic overrides.
os.makedirs(os.path.dirname(LOGFILE) or ".", exist_ok=True)
logger.setLevel(logging.INFO)
handler = handlers.RotatingFileHandler(
filename=LOGFILE,
encoding=ENCODING,
@@ -55,18 +28,10 @@ handler = handlers.RotatingFileHandler(
maxBytes=6 * 1024 * 1024,
backupCount=6,
)
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
# Console log so `docker logs` / journalctl actually show what happened.
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# Some dependency calls logging.basicConfig(), adding a root handler; without
# this the 'discord' logger's records get printed twice (our format + root's).
logger.propagate = False
# *=========================================== Initializations
intents = discord.Intents.default()
intents.message_content = True
@@ -83,134 +48,37 @@ intents.moderation = True
random.seed()
client = commands.Bot(intents=intents, command_prefix="$")
# *=========================================== Extension groups
# Core cogs depend on nothing but the bot itself (broken ones are skipped
# individually). Service groups are gated on a health check of the service
# they talk to and enabled later by the watchdog when the service appears.
CORE_EXTENSIONS = [
"administration_commands",
"ai_commands",
"other_commands",
"latex_commands",
"voice_recognition_commands",
"conanjurer_commands",
]
SERVICE_EXTENSION_GROUPS = {
# musician (file service): Discord music download/search, file shares
"musician": {
"health_url": f"{FILE_SERVICE_ADDRESS}{GET_MP3}",
"extensions": ["music_commands", "file_search_commands"],
},
# betoniarka (radio operator colocated with Liquidsoap): radio playlists
"radio": {
"health_url": f"{RADIO_SERVICE_ADDRESS}/ping",
"extensions": ["radio_commands"],
},
# librarian: DOI / Crossref search
"librarian": {
"health_url": f"{LIBRARIAN_SERVICE_ADDRESS}/",
"extensions": ["librarian_commands"],
},
}
SERVICE_RECHECK_SECONDS = 300
def _service_alive(url: str) -> bool:
"""True when the service answers HTTP at all (any status code counts)."""
try:
requests.get(url, timeout=3)
return True
except requests.exceptions.RequestException:
return False
async def _load_extension_safe(name: str) -> bool:
"""Load one extension; log and continue instead of killing startup."""
if name in client.extensions:
return True
try:
await client.load_extension(name)
logger.info("Extension loaded: %s", name)
return True
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Extension FAILED (disabled, bot continues): %s", name)
return False
async def _load_service_groups() -> bool:
"""Health-check each service group and load its cogs when alive.
Returns True when anything new was loaded (caller may want to re-sync).
"""
loaded_any = False
for service, group in SERVICE_EXTENSION_GROUPS.items():
missing = [e for e in group["extensions"] if e not in client.extensions]
if not missing:
continue
alive = await asyncio.to_thread(_service_alive, group["health_url"])
if not alive:
logger.warning(
"Service '%s' unreachable (%s) - cogs stay disabled: %s",
service,
group["health_url"],
", ".join(missing),
)
continue
logger.info("Service '%s' is alive - enabling: %s", service, ", ".join(missing))
for extension in missing:
if await _load_extension_safe(extension):
loaded_any = True
return loaded_any
async def _sync_tree() -> None:
try:
await client.tree.sync()
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Slash-command tree sync failed (commands may lag)")
async def _service_watchdog() -> None:
"""Periodically retry offline services and enable their cogs when up."""
while not client.is_closed():
await asyncio.sleep(SERVICE_RECHECK_SECONDS)
try:
if await _load_service_groups():
await _sync_tree()
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Service watchdog tick failed")
_STARTUP_DONE = False
# *=========================================== Define Events
@client.event
async def on_ready():
"""Metoda wywoływana przy połączeniu do serwera."""
global _STARTUP_DONE # pylint: disable=global-statement
logger = logging.getLogger("discord")
logger.debug("SAMPLE DEBUG LOG")
logger.info("%s has connected to Discord!", client.user)
if _STARTUP_DONE:
logger.info("Reconnected - extensions already loaded")
return
_STARTUP_DONE = True
# TODO: load vs reload
logger.info("Reactor: online")
for extension in CORE_EXTENSIONS:
await _load_extension_safe(extension)
await client.load_extension("administration_commands")
await _load_service_groups()
await client.load_extension("librarian_commands")
await client.load_extension("music_commands")
await client.load_extension("radio_commands")
await client.load_extension("ai_commands")
await client.load_extension("other_commands")
await client.load_extension("voice_recognition_commands")
await client.load_extension("file_search_commands")
await client.load_extension("latex_commands")
await client.load_extension("conanjurer_commands")
logger.info("Sensors: online")
logger.info(client.cogs)
await _sync_tree()
await client.tree.sync()
for com in client.commands:
logger.info("Command %s is awejleble", com.qualified_name)
asyncio.create_task(_service_watchdog())
logger.info("Logged in as ----> %s", client.user)
logger.info("ID:%s ", client.user.id)
logger.info("All systems: operational")
@@ -225,64 +93,22 @@ async def on_ready():
async def _run_comm_subroutine(stop_event: threading.Event) -> None:
"""Run the blocking comm subroutine in a worker thread.
A crash here takes down the internal HTTP endpoints only - the Discord
side keeps running, so log loudly and swallow.
"""
try:
await asyncio.to_thread(comm_subroutine, stop_event)
except Exception: # pylint: disable=broad-exception-caught
logger.exception(
"Comm layer crashed - internal HTTP endpoints are down "
"(musician/librarian callbacks will not arrive); bot continues"
)
"""Run the blocking comm subroutine in a worker thread."""
await asyncio.to_thread(comm_subroutine, stop_event)
async def _run_bot(token: str, shutdown_event: asyncio.Event) -> None:
"""Start the Discord client; log WHY it died before flagging shutdown.
Exceptions were previously swallowed by ``gather(return_exceptions=True)``
which made a failed login look like a clean exit (silent crash-loop in
docker). Now every death is diagnosed on the console first.
"""
"""Start the Discord client and flag shutdown when it returns."""
try:
# NOTE: log_handler is a Client.run()-only kwarg (run() configures
# logging, then calls start()); start() takes just the token. We set
# up our own handlers above, so nothing is lost.
await client.start(token)
except discord.LoginFailure:
logger.critical(
"FATAL: Discord REJECTED the token (Improper token). Fix the "
"'discord' entry in the netrc mounted at CONJURER_NETRC_FILE or "
"the DISCORD_TOKEN env var. If the token leaked/reset, generate a "
"new one in the Discord Developer Portal -> Bot -> Reset Token."
)
raise
except discord.PrivilegedIntentsRequired:
logger.critical(
"FATAL: this bot application does not have the Privileged Gateway "
"Intents enabled. Open Discord Developer Portal -> your app -> "
"Bot -> enable 'Presence', 'Server Members' and 'Message Content' "
"Intents, then restart."
)
raise
except Exception: # pylint: disable=broad-exception-caught
logger.exception("FATAL: Discord client crashed at startup/runtime")
raise
await client.start(token, log_handler=None)
finally:
shutdown_event.set()
async def main() -> int:
"""Run both halves; return a process exit code (0 = clean shutdown)."""
if TOKEN:
token_source = (
"env DISCORD_TOKEN" if os.getenv("DISCORD_TOKEN") else "netrc file"
)
logger.info(
"Discord token: loaded from %s (length %d)", token_source, len(TOKEN)
)
async def main() -> None:
if not TOKEN:
logger.error("Discord token missing - set DISCORD_TOKEN or configure netrc")
return
logger.info("Starting discord bot")
shutdown_event = asyncio.Event()
@@ -291,7 +117,6 @@ async def main() -> int:
comm_task = asyncio.create_task(_run_comm_subroutine(comm_stop_event))
bot_task = asyncio.create_task(_run_bot(TOKEN, shutdown_event))
exit_code = 0
try:
await shutdown_event.wait()
except (KeyboardInterrupt, asyncio.CancelledError):
@@ -302,31 +127,9 @@ async def main() -> int:
comm_stop_event.set()
if not client.is_closed():
await client.close()
results = await asyncio.gather(bot_task, comm_task, return_exceptions=True)
for result in results:
if isinstance(result, BaseException) and not isinstance(
result, asyncio.CancelledError
):
# Already logged with full traceback inside the task; repeat
# the one-liner so it is the LAST thing in `docker logs`.
logger.critical("Task died: %r", result)
exit_code = 1
return exit_code
await asyncio.gather(bot_task, comm_task, return_exceptions=True)
# *================================== Run
if __name__ == "__main__":
if not TOKEN:
# Loud, unmissable and in `docker logs`: this is THE most common cause
# of a silent container crash-loop.
MSG = (
"FATAL: Discord token missing.\n"
"Provide it via the DISCORD_TOKEN environment variable or a netrc "
"file (machine 'discord') at the path in CONJURER_NETRC_FILE.\n"
"Docker: check that your secrets mount exists, e.g.\n"
" -v /srv/conjurer/secrets/.netrc:/secrets/.netrc:ro\n"
" CONJURER_NETRC_FILE=/secrets/.netrc"
)
logger.critical(MSG)
sys.exit(MSG)
sys.exit(asyncio.run(main()))
asyncio.run(main())
+3 -6
View File
@@ -11,9 +11,9 @@ from urllib import request as urequest
from flask import Flask, abort, jsonify, request
from waitress import serve
HOST_ADDRESS = os.getenv("CONJURER_DISCORD_HOST", "192.168.1.92")
HOST_ADDRESS = os.getenv("CONJURER_DISCORD_HOST", "192.168.1.31")
PORT_ADDRESS = int(os.getenv("CONJURER_DISCORD_PORT", "5000"))
ICECAST_ADDRESS = os.getenv("CONJURER_ICECAST", "http://192.168.1.12:8000")
ICECAST_ADDRESS = os.getenv("CONJURER_ICECAST", "http://192.168.1.15:8000")
API_KEY = os.getenv("CONJURER_API_KEY")
OUT_COMM_Q = Queue()
IN_COMM_Q = Queue()
@@ -234,13 +234,10 @@ def comm_subroutine(stop_event: Optional[threading.Event] = None):
:param stop_event: optional :class:`threading.Event` shared with the caller
to coordinate a cooperative shutdown.
"""
# logger.setLevel(logging.DEBUG)
logger = logging.getLogger("discord")
logger.setLevel(logging.DEBUG)
logger.info("Started comms")
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(
-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")
+4 -7
View File
@@ -25,7 +25,6 @@ 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
@@ -195,7 +194,7 @@ class Librarian(object):
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("cr_results.json", "r+", encoding="utf-8") as data_file:
# First we load existing data into a dict.
try:
file_data = json.load(data_file)
@@ -259,7 +258,7 @@ class Librarian(object):
for item in temp:
refined_result[item["DOI"]]= item
with open(lib_paths.RR_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)
@@ -418,8 +417,7 @@ class BackgroundTaskSearch(threading.Thread):
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)
except JSONDecodeError:
@@ -433,8 +431,7 @@ class BackgroundTaskSearch(threading.Thread):
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)
except JSONDecodeError:
-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()
+4 -7
View File
@@ -4,7 +4,6 @@ This module contains the code for the scrape bot.
import json
import logging
import os
import random
import re
import time
@@ -16,11 +15,9 @@ 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()
@@ -32,7 +29,7 @@ def load_ndb_to_q(logger):
"""
logger.info("Loader started")
while True:
with open(lib_paths.NOT_IN_DB, "r+", encoding="utf-8") as ndb_file:
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):
+11 -9
View File
@@ -19,20 +19,22 @@ Global Variables:
"""
# TODO: Wpiemdolić to wszystko w klasę z loggerem przysłanym z góry
import os
from queue import Empty, Queue
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\\")
# 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")
# DEPLOYMENT DATA
MAXTHREADS = 41
DATABASE_PATH = r"C:\\Database\\chunks\\"
ENCODING = "utf-8"
CHUNK = "_chunk.txt"
_sentinel = object()
WORK_Q_SIZE = 35500000
@@ -47,7 +49,7 @@ def producer(out_q, control_q, filename, _logger):
filename (str): Name of the file.
_logger: Logger object for logging.
"""
with open(DATABASE_PATH + filename, "r", encoding=ENCODING) as operated_file:
with open(DATABASE_PATH + filename, "r+", encoding=ENCODING) as operated_file:
print(f"Worker {filename} ")
line_no = 0
while True:
@@ -105,7 +107,7 @@ def consumer(in_q, control_q, doi, live_results, result_list, control_dict, no,
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']}")
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
+2 -2
View File
@@ -47,7 +47,7 @@ def producer(out_q, control_q, filename, _logger):
filename (str): Name of the file.
_logger: Logger object for logging.
"""
with open(DATABASE_PATH + filename, "r", encoding=ENCODING) as operated_file:
with open(DATABASE_PATH + filename, "r+", encoding=ENCODING) as operated_file:
print(f"Worker {filename} ")
line_no = 0
while True:
@@ -108,7 +108,7 @@ def consumer(in_q, control_q, doi, live_results, result_list, control_dict, no,
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']}")
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"]
+298 -15
View File
@@ -1,12 +1,7 @@
"""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.
"""
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
@@ -19,9 +14,18 @@ import time
from datetime import datetime
from logging import handlers
from pathlib import Path
from typing import List
from typing import Dict, List
from flask import Flask, abort, jsonify, request
import requests
from flask import (
Flask,
abort,
jsonify,
redirect,
render_template,
request,
send_from_directory,
)
from waitress import serve
import media_search_functions
@@ -37,6 +41,8 @@ def _env_path(name: str, default: str) -> Path:
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("CONJURER_MUSICIAN_HOST", "0.0.0.0")
PORT_ADDRESS = int(_env("CONJURER_MUSICIAN_PORT", "5000"))
@@ -50,12 +56,52 @@ LOGSTORE = _env_path("CONJURER_LOGSTORE", str(BASE_DIR / "logs"))
MUSIC_FOLDER = _env_path(
"CONJURER_MUSIC_FOLDER", str(BASE_DIR / "music")
)
PRIORITY_FOLDER = _env_path(
"CONJURER_PRIORITY_FOLDER", str(MUSIC_FOLDER / "priority")
)
RADIOLOG_PATH = _env_path(
"CONJURER_RADIO_LOG", str(BASE_DIR / "radio_log.log")
)
PERSISTENCE_PATH = _env_path(
"CONJURER_PERSISTENCE_LOG", str(BASE_DIR / "persistence.log")
)
ALL_PLAYLIST_PATH = _env_path(
"CONJURER_ALL_PLAYLIST", str(BASE_DIR / "all_playlist.playlist")
)
HIT_PLAYLIST_PATH = _env_path(
"CONJURER_HIT_PLAYLIST", str(BASE_DIR / "hit.playlist")
)
REQUEST_PLAYLIST_PATH = _env_path(
"CONJURER_REQUEST_PLAYLIST", str(BASE_DIR / "request.playlist")
)
PRIORITY_PLAYLIST_PATH = _env_path(
"CONJURER_PRIORITY_PLAYLIST", str(BASE_DIR / "priority_queue.playlist")
)
STREAM_TEMPLATE = _env_path(
"CONJURER_STREAM_TEMPLATE", str(BASE_DIR / "stream.html")
)
ENCODING = _env("CONJURER_ENCODING", "utf-8")
SEPARATOR_FILE_PATH = os.sep
for playlist_path in (
ALL_PLAYLIST_PATH,
HIT_PLAYLIST_PATH,
REQUEST_PLAYLIST_PATH,
PRIORITY_PLAYLIST_PATH,
):
playlist_path.parent.mkdir(parents=True, exist_ok=True)
random.seed()
music_file_list: List[str] = []
priority_list: List[str] = []
def _build_headers() -> Dict[str, str]:
headers: Dict[str, str] = {}
if API_KEY:
headers["X-Conjurer-Api-Key"] = API_KEY
return headers
def _authorize_request() -> None:
@@ -63,16 +109,29 @@ def _authorize_request() -> None:
abort(401)
def rescan():
"""Refresh the in-memory library index used by /mp3 and /get_music.
def _post_to_bot(payload: List[str]) -> None:
response = requests.post(
f"{MAIN_BOT_ADDRESS}{MUSIC_TRACKER}",
json=payload,
headers=_build_headers(),
timeout=60,
)
logger = logging.getLogger("conjurer_musician")
logger.info("SENT")
logger.info(response.status_code)
logger.info("SEND CONFIRMED")
Radio playlist files are NOT written here anymore - that is the
betoniarka's job, colocated with Liquidsoap.
def rescan():
"""
The `rescan` function logs a message, scans for mp3 files in a specified folder,
and adds them to a list of music files.
"""
logger = logging.getLogger("conjurer_musician")
logger.info("Rescan triggered")
music_file_list.clear()
priority_list.clear()
for mp3_item in MUSIC_FOLDER.glob("**/*.mp3"):
temp_music_file = mp3_item.as_posix()
@@ -80,6 +139,27 @@ def rescan():
temp_music_file = temp_music_file.replace("/", "\\")
music_file_list.append(temp_music_file)
for mp3_item in PRIORITY_FOLDER.glob("**/*.mp3"):
temp_music_file = mp3_item.as_posix()
if os.name == "nt":
temp_music_file = temp_music_file.replace("/", "\\")
priority_list.append(temp_music_file)
with ALL_PLAYLIST_PATH.open("w", encoding=ENCODING) as w_file:
try:
for item in music_file_list:
w_file.write(item)
w_file.write("\n")
except json.JSONDecodeError:
pass
with HIT_PLAYLIST_PATH.open("w", encoding=ENCODING) as w_file:
try:
for item in priority_list:
w_file.write(item)
w_file.write("\n")
except json.JSONDecodeError:
pass
def thread_rescan():
"""
@@ -94,6 +174,55 @@ def thread_rescan():
rescan()
def scan_tracks():
# Set the filename and open the file
logger = logging.getLogger("conjurer_musician")
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)
app = Flask(__name__)
# AutoIndex(app, browse_root="/")
@@ -199,6 +328,9 @@ 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 PRIORITY_PLAYLIST_PATH.open("a", encoding=ENCODING) as s_file:
s_file.write(search_weight[itr][1] + "\n")
break
itr += 1
else:
@@ -269,6 +401,62 @@ def get_share_links():
@app.route("/stream", methods=["GET"])
def stream_music():
"""
The function `stream_music` is a route handler for the "/stream" endpoint.
It returns a JSON response containing a key "music_file_list" with the value of the variable `music_file_list`.
: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")
return render_template(str(STREAM_TEMPLATE))
@app.route("/<string:file_name>")
def stream(file_name):
"""
Stream the specified file from the video directory.
Args:
file_name (str): The name of the file to be streamed.
Returns:
Response: The response containing the streamed file.
"""
# trunk-ignore(bandit/B108)
video_dir = "/tmp/hls"
return send_from_directory(video_dir, file_name)
@app.route("/stream_mp3", methods=["GET"])
def stream_music_mp3():
"""
The function `stream_music_mp3` is a route handler for the "/stream_mp3" endpoint.
It redirects the user to the URL "http://www.example.com" with a status code of 302.
:return: A redirect response to "http://www.example.com" with a status code of 302.
"""
return redirect("http://www.example.com", code=302)
@app.route("/clear_pr_pls", methods=["GET"])
def clear_pr_pls():
_authorize_request()
"""
The function `clear_pr_pls` clears the contents of the priority queue playlist file.
:return: A JSON response indicating the success of the operation.
"""
app.logger.info("CLEARING PLAYLIST")
with PRIORITY_PLAYLIST_PATH.open("w", encoding=ENCODING) as cleared_pl:
cleared_pl.write("")
return_data = jsonify(isError=False, message="Success", statusCode=200, data=[])
return return_data, 200
@app.route("/mp3", methods=["GET"])
def get_music_list():
"""
@@ -329,6 +517,96 @@ def look_for_playlist():
return return_data
@app.route("/request_radio_file", methods=["POST"])
def add_request():
_authorize_request()
record = json.loads(request.data)
app.logger.info(record)
app.logger.info(record["lista_slow"])
app.logger.info(record["UUID"])
return_data = wyszukaj(record["lista_slow"], 0, app.logger, False)
with REQUEST_PLAYLIST_PATH.open("a", encoding=ENCODING) as s_file:
for item in return_data:
s_file.write(item[1] + "\n")
return_data = (
jsonify(
isError=False, message="Success", statusCode=200, data={"status": "OK"}
),
200,
)
return return_data
@app.route("/create_priority_playlist", methods=["POST"])
def create_priority_playlist():
"""
The function `create_priority_playlist` 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.
"""
_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, False
)
random.shuffle(return_data)
with REQUEST_PLAYLIST_PATH.open("a", encoding=ENCODING) as s_file:
for item in return_data:
s_file.write(item[1] + "\n")
return_data = (
jsonify(
isError=False, message="Success", statusCode=200, data={"status": "OK"}
),
200,
)
return return_data
@app.route("/add_to_priority", methods=["POST"])
def add_to_priority():
"""
The function `add_to_priority` 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.
"""
_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, False
)
with PRIORITY_PLAYLIST_PATH.open("a", encoding=ENCODING) as s_file:
for item in return_data:
s_file.write(item[1] + "\n")
return_data = (
jsonify(
isError=False, message="Success", statusCode=200, data={"status": "OK"}
),
200,
)
return return_data
def flask_debug():
"""
The `flask_debug` function starts a Flask application in debug mode without using the reloader.
@@ -382,8 +660,13 @@ if __name__ == "__main__":
for worker in threads:
worker.start()
time.sleep(60)
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 musician service")
+15 -37
View File
@@ -1,44 +1,24 @@
#!/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 argparse
import json
import os
import sys
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)
# 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():
"""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
with open(JSON_DB) as f:
return json.load(f)['entries']
ENTRIES = load_db()
def relevancy(path, keywords):
score = 0
@@ -48,20 +28,17 @@ def relevancy(path, keywords):
score += low.count(kw.lower())
return score
def find_matches(count, keywords):
scored = []
for entry in _entries():
score = relevancy(entry["path"], keywords)
for e in ENTRIES:
score = relevancy(e['path'], keywords)
if score > 0:
scored.append((score, entry["path"]))
scored.append((score, e['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
@@ -72,3 +49,4 @@ def publish(paths):
pass
urls.append(f"{BASE_URL}/{token}")
return urls
+14 -14
View File
@@ -1,16 +1,16 @@
# Radio Conjurer - Liquidsoap script (containerised paths: /srv/betoniarka/*)
# FILEPATH: /home/mtuszowski/conjurer/conjurer_musician/radio_conjurer.liq
# 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")
let json.parse credentials = file.contents("/home/pi/Conjurer/icecast_credentials.json")
# Enable replaygain metadata processing
enable_replaygain_metadata()
# Set up a playlog for tracking played tracks
l = playlog(duration = 72000.0, persistency="/srv/betoniarka/data/persistence.log")
l = playlog(duration = 72000.0, persistency="/home/pi/Conjurer/persistence.log")
# Function to check if a track can be played based on its metadata
def check(r)
@@ -25,20 +25,20 @@ def check(r)
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"))
s1 = replaygain(playlist(reload_mode="watch", check_next=check, "/home/pi/Conjurer/all_playlist.playlist"))
s2 = replaygain(playlist(reload_mode="watch", check_next=check, "/home/pi/Conjurer/priority_queue.playlist"))
s3 = replaygain(playlist(reload_mode="watch", check_next=check, "/home/pi/Conjurer/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")
text=file.lines("/home/pi/Conjurer/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)
file.remove("/home/pi/Conjurer/request.playlist")
f = file.open("/home/pi/Conjurer/request.playlist", create=true)
f.close()
end
end
@@ -63,7 +63,7 @@ end
s4 = random(id="randomizer", weights=[2, 3, 5], [s1, s2, s3])
# Load jingles playlist
jingles = (playlist(reload_mode="watch", "/srv/betoniarka/data/jingles.playlist"))
jingles = (playlist(reload_mode="watch", "/home/pi/Conjurer/jingles.playlist"))
# Create the main stream with random playlist and jingles
s = rotate(id="randomizer", weights=[10, 1], [s4, jingles])
@@ -111,7 +111,7 @@ s=switch(track_sensitive=true,
# 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)
@@ -120,7 +120,7 @@ set("log.level", loglevel)
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")
emergency = single("/home/pi/MediaFolder/mp3/Youtube/Dr. Peacock - Trip to Ireland [GvrvQTUbUcA].mp3")
radio = fallback(id="switcher2", track_sensitive=false, [s, emergency])
# Set up an interactive control for skipping tracks
@@ -150,11 +150,11 @@ thread.run(every=15., check_skip)
# Enable persistent script parameters
interactive.persistent("/srv/betoniarka/data/script.params")
interactive.persistent("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.icecast(%mp3, host="radio", 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
+11 -184
View File
@@ -38,11 +38,6 @@ try:
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
@@ -112,18 +107,18 @@ if platform in ("linux", "linux2"):
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"
LOGFILE = "/home/pi/Conjurer/discord.log"
MEMORY_FIVE_SIARA = "/home/pi/Conjurer/pamiec.json"
SYSTEM_GPT_SETTINGS = "/home/pi/Conjurer/system_gpt_settings.json"
MEMORY_FIVE_MUZYKA = "/home/pi/Conjurer/pamiec_muzyki.json"
MUSIC_FOLDER = "/home/pi/MediaShare/mp3/"
SETTINGS_FILE = "/home/pi/Conjurer/settings.json"
NETRC_FILE = "/home/pi/.netrc"
LOGSTORE = "/home/pi/MediaShara/logs/"
ACCIDENT_LOG = "/home/pi/Conjurer/accident_log.json"
ENCODING = "utf-8"
GRAPHICS_PATH = "./Conjurer_graphics/"
DIR_PATH_SADOX = "./Fansadox/"
GRAPHICS_PATH = "/home/pi/MediaShare/Conjurer_graphics/"
DIR_PATH_SADOX = "/home/pi/MediaShare/Fansadox/"
elif platform == "win32":
@@ -163,23 +158,6 @@ else:
# 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)
@@ -194,18 +172,8 @@ 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"
@@ -217,66 +185,6 @@ PORT_ADDRESS = int(os.getenv("CONJURER_DISCORD_PORT", "5000"))
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."""
@@ -341,20 +249,8 @@ if openai and 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:
@@ -402,75 +298,6 @@ 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.
-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
+1 -18
View File
@@ -43,28 +43,11 @@ You can rebalance as follows:
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.
`CONJURER_NETRC_FILE` accordingly.
## 4. Install Docker on Raspberry Pis and Windows
-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`.
+3
View File
@@ -0,0 +1,3 @@
Package: ffmpeg libavcodec-dev libavcodec59 libavdevice59 libavfilter8 libavformat-dev libavformat59 libavutil-dev libavutil57 libpostproc56 libswresample-dev libswresample4 libswscale-dev libswscale6 libavdevice-dev libavfilter-dev libpostproc-dev
Pin: origin deb.debian.org
Pin-Priority: 1001
+1 -1
View File
@@ -27,7 +27,7 @@ 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 "Downgrade ffmpeg by copying ffmpeg.pref from repository to /etc/apt/preferences.d"
echo "Install opam from installation link"
echo "Initialize opam"
echo "Install liquidsoap and its dependencies"
-5
View File
@@ -1,5 +0,0 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
addopts = -ra
+5 -5
View File
@@ -8,7 +8,7 @@ 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
from constants import RADIO_HARBOR_ADDRESS, SKIP_TRACK, FILE_SERVICE_ADDRESS, ADD_TO_PRIO_PLAYLIST, REQUEST_MUSIC, CREATE_PRIO_PLAYLIST, CLEAR_PRIO, service_headers
SERVICE_HEADERS = service_headers()
@@ -96,7 +96,7 @@ class RadioModule(commands.Cog):
}
coroutine = asyncio.to_thread(
requests.post,
f"{RADIO_SERVICE_ADDRESS}{ADD_TO_PRIO_PLAYLIST}",
f"{FILE_SERVICE_ADDRESS}{ADD_TO_PRIO_PLAYLIST}",
json=jrequest,
headers=SERVICE_HEADERS,
timeout=360,
@@ -132,7 +132,7 @@ class RadioModule(commands.Cog):
}
coroutine = asyncio.to_thread(
requests.post,
f"{RADIO_SERVICE_ADDRESS}{REQUEST_MUSIC}",
f"{FILE_SERVICE_ADDRESS}{REQUEST_MUSIC}",
json=jrequest,
headers=SERVICE_HEADERS,
timeout=360,
@@ -170,7 +170,7 @@ class RadioModule(commands.Cog):
}
coroutine = asyncio.to_thread(
requests.post,
f"{RADIO_SERVICE_ADDRESS}{CREATE_PRIO_PLAYLIST}",
f"{FILE_SERVICE_ADDRESS}{CREATE_PRIO_PLAYLIST}",
json=jrequest,
headers=SERVICE_HEADERS,
timeout=360,
@@ -199,7 +199,7 @@ class RadioModule(commands.Cog):
async with ctx.typing():
coroutine = asyncio.to_thread(
requests.get,
f"{RADIO_SERVICE_ADDRESS}{CLEAR_PRIO}",
f"{FILE_SERVICE_ADDRESS}{CLEAR_PRIO}",
headers=SERVICE_HEADERS,
timeout=360,
)
+1 -1
View File
@@ -4,7 +4,6 @@ yt_dlp
spotify_dl
spotipy
openai
anthropic
eyed3
numpy
pdf2image
@@ -18,5 +17,6 @@ PyMuPDF
waitress
assemblyai[extras]
SpeechRecognition
aiomcrcon
asyncssh
git+https://github.com/imayhaveborkedit/discord-ext-voice-recv
-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
-24
View File
@@ -39,29 +39,5 @@
"Zrobisz absolutnie wszystko jako asystent, poniewa\u017c sama my\u015bl o tym \u017ce m\u00f3g\u0142by\u015b rozgniewa\u0107 operatora rozgniewa\u0107 Ci\u0119 przera\u017ca do poziomu histerii.",
"asst_1PnD0eQMWo0RkZ3xUacC1uqJ"
]
},
{
"active": "gpt",
"configs": {
"gpt": {
"provider": "openai",
"latest_model": "gpt-4o",
"cheap_model": "gpt-4o-mini",
"temperature": 0.2
},
"claude": {
"provider": "anthropic",
"latest_model": "claude-opus-4-8",
"cheap_model": "claude-haiku-4-5",
"max_tokens": 2048
},
"_template": {
"provider": "openai",
"latest_model": "model-id-here",
"cheap_model": "cheaper-model-id-here",
"temperature": 0.2,
"max_tokens": 2048
}
}
}
]
+20
View File
@@ -0,0 +1,20 @@
import logging
from logging import handlers
logger = logging.getLogger("discord")
logger.setLevel(logging.DEBUG)
handler = handlers.RotatingFileHandler(
filename="test.log",
encoding="utf-8",
mode="a",
maxBytes=6 * 1024 * 1024,
backupCount=6,
)
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
logger2 = logging.getLogger("discord")
for item in logger2.handlers:
print(item)
+13
View File
@@ -0,0 +1,13 @@
AsyncCursorPage[Message](
data=[Message(id='msg_3eWSdgbcU8sbCmJK2momOgQQ',
assistant_id='asst_06eZiwvYNK3MR34suFP60gvg',
attachments=[],
completed_at=None,
content=[TextContentBlock(text=Text(annotations=[], value='Cześć! Oto coś do przemyślenia: \n\n„Sukces to suma niewielkich wysiłków powtarzanych dzień po dniu.” — Robert Collier\n\nTo przypomina nam, że często to nie wielkie działania, ale konsekwentne, małe kroki prowadzą do osiągnięcia celu. Jak mogę Ci dzisiaj pomóc?'), type='text')], created_at=1731620112, incomplete_at=None, incomplete_details=None, metadata={}, object='thread.message', role='assistant', run_id='run_JGjWQTCEkDcEYpyCJnrkZU8Q', status=None, thread_id='thread_dDEjGbGm6ICfG75u0KKpoVxD'), Message(id='msg_GFcnfCFgAthGm2D3oE5d0ZkQ', assistant_id=None, attachments=[], completed_at=None, content=[TextContentBlock(text=Text(annotations=[], value='Cześć! Powiedz coś mądrego'), type='text')], created_at=1731620110, incomplete_at=None, incomplete_details=None, metadata={}, object='thread.message', role='user', run_id=None, status=None, thread_id='thread_dDEjGbGm6ICfG75u0KKpoVxD')], object='list', first_id='msg_3eWSdgbcU8sbCmJK2momOgQQ', last_id='msg_GFcnfCFgAthGm2D3oE5d0ZkQ', has_more=False)
[TextContentBlock(
text=Text(annotations=[],
value='Cześć! Oto coś do rozważenia: "Największą przeszkodą w naszym życiu jest brak odwagi do wprowadzenia zmian." Niezależnie od tego, jakie masz cele czy marzenia, odwaga do działania i przystosowania się do nowych sytuacji jest kluczem do osiągnięcia sukcesu. Jakie masz przemyślenia na ten temat?'),
type='text')
]
+8
View File
@@ -0,0 +1,8 @@
import time
first_time = time.time_ns()
time.sleep(1)
time_diff = time.time_ns() - first_time
print(time_diff)
# 2000149433
-56
View File
@@ -1,56 +0,0 @@
"""Integration: the bot's communication Flask layer enforces the shared key,
and the key the bot would *send* (constants.service_headers) is accepted.
"""
import constants
import communication_subroutine as cs
def _client(key="test-secret"):
cs.API_KEY = key
return cs.app.test_client()
def test_prepped_tracks_rejected_without_key():
client = _client()
resp = client.post(
"/prepped_tracks", data='["all", "x"]', content_type="application/json"
)
assert resp.status_code == 401
def test_prepped_tracks_accepted_with_key():
client = _client()
resp = client.post(
"/prepped_tracks",
data='["all", "x"]',
headers={"X-Conjurer-Api-Key": "test-secret"},
content_type="application/json",
)
assert resp.status_code == 200
def test_conjurer_get_is_open():
client = _client()
assert client.get("/conjurer").status_code == 200
def test_open_when_key_unset():
client = _client(key=None)
resp = client.post(
"/prepped_tracks", data='["all", "x"]', content_type="application/json"
)
assert resp.status_code == 200
def test_bot_service_headers_accepted_by_service(monkeypatch):
# End-to-end auth contract: the header constants.service_headers() produces
# is exactly what communication_subroutine._authorize_request() expects.
monkeypatch.setattr(constants, "API_SHARED_KEY", "shared-xyz")
cs.API_KEY = "shared-xyz"
resp = cs.app.test_client().post(
"/prepped_tracks",
data='["all", "x"]',
headers=constants.service_headers(),
content_type="application/json",
)
assert resp.status_code == 200
-34
View File
@@ -1,34 +0,0 @@
"""Integration: the musician Flask service enforces the shared key on its
authenticated endpoints while leaving the open ones reachable.
"""
import conjurer_musician as m
def _client(key="test-secret"):
m.API_KEY = key
return m.app.test_client()
def test_clear_pr_pls_rejected_without_key():
client = _client()
assert client.get("/clear_pr_pls").status_code == 401
def test_clear_pr_pls_accepted_with_key():
client = _client()
resp = client.get(
"/clear_pr_pls", headers={"X-Conjurer-Api-Key": "test-secret"}
)
assert resp.status_code == 200
def test_mp3_list_is_open():
client = _client()
resp = client.get("/mp3")
assert resp.status_code == 200
assert "music_file_list" in resp.get_json()
def test_open_when_key_unset():
client = _client(key=None)
assert client.get("/clear_pr_pls").status_code == 200
-138
View File
@@ -1,138 +0,0 @@
"""Unit tests for the GPT/Claude provider switch.
The unit CI job installs only pytest, so the heavy runtime deps that
``ai_functions`` imports unguarded (``openai``, ``tiktoken``, ``other_functions``
-> ``discord``) are stubbed *only when genuinely absent*. Locally, where the
real packages exist, the stubs are skipped and the real modules are used.
"""
import sys
import types
def _stub_if_missing(name: str, build):
if name in sys.modules:
return
try: # real package present (local dev / bot image) -> use it
__import__(name)
except ImportError:
sys.modules[name] = build()
def _build_tiktoken():
mod = types.ModuleType("tiktoken")
class _Enc:
def encode(self, text):
return list(text)
mod.encoding_for_model = lambda _model: _Enc()
return mod
def _build_other_functions():
mod = types.ModuleType("other_functions")
async def _noop(*_a, **_k):
return None
mod.discord_friendly_send = _noop
mod.discord_friendly_reply = _noop
return mod
def _build_openai():
mod = types.ModuleType("openai")
for cls_name in (
"APITimeoutError",
"APIConnectionError",
"BadRequestError",
"APIResponseValidationError",
"AuthenticationError",
"PermissionDeniedError",
"RateLimitError",
"UnprocessableEntityError",
"APIError",
"OpenAIError",
):
setattr(mod, cls_name, type(cls_name, (Exception,), {}))
return mod
_stub_if_missing("tiktoken", _build_tiktoken)
_stub_if_missing("other_functions", _build_other_functions)
_stub_if_missing("openai", _build_openai)
import ai_functions # noqa: E402 (import after stubbing)
import openai # noqa: E402 (real or stub, same object ai_functions uses)
def _reset_active(name="gpt"):
ai_functions._ACTIVE_CONFIG_NAME = name
def test_split_extracts_system_and_starts_with_user():
system, convo = ai_functions._to_anthropic_messages(
[
{"role": "system", "content": "SYS1"},
{"role": "system", "content": "SYS2"},
{"role": "assistant", "content": "leading-assistant-dropped"},
{"role": "user", "content": "u1"},
{"role": "assistant", "content": "a1"},
{"role": "user", "content": "hi"},
]
)
assert system == "SYS1\n\nSYS2"
assert convo[0] == {"role": "user", "content": "u1"}
assert convo == [
{"role": "user", "content": "u1"},
{"role": "assistant", "content": "a1"},
{"role": "user", "content": "hi"},
]
def test_split_synthesises_user_turn_when_only_system():
_system, convo = ai_functions._to_anthropic_messages(
[{"role": "system", "content": "only"}]
)
assert convo == [{"role": "user", "content": " "}]
def test_select_model_gpt_active():
_reset_active("gpt")
# legacy default auto-selects; MUSIC is cheap; explicit ids are honoured
assert ai_functions.select_model("GENERAL", "gpt-4o") == "gpt-4o"
assert ai_functions.select_model("MUSIC", "gpt-4o") == "gpt-4o-mini"
assert ai_functions.select_model("MUSIC", "auto") == "gpt-4o-mini"
assert ai_functions.select_model("GENERAL", "o1-preview") == "o1-preview"
def test_select_model_claude_active_maps_legacy_default():
_reset_active("claude")
try:
# the old gpt-4o default must not leak to Claude - it auto-maps
assert ai_functions.select_model("GENERAL", "gpt-4o") == "claude-opus-4-8"
assert ai_functions.select_model("MUSIC", "gpt-4o") == "claude-haiku-4-5"
# a real, deliberate model id is still honoured verbatim
assert ai_functions.select_model("GENERAL", "claude-sonnet-5") == "claude-sonnet-5"
finally:
_reset_active("gpt")
def test_list_ai_configs_hides_templates():
names = ai_functions.list_ai_configs()
assert "_template" not in names
assert {"gpt", "claude"}.issubset(set(names))
def _bare(cls):
# Build an instance without invoking __init__ - the real openai SDK
# exceptions require response/body kwargs, the CI stubs don't. isinstance
# (all _map_openai_error cares about) works on __new__-created instances.
return cls.__new__(cls)
def test_map_openai_error_categories():
assert ai_functions._map_openai_error(_bare(openai.AuthenticationError)).category == "auth"
assert ai_functions._map_openai_error(_bare(openai.RateLimitError)).category == "rate_limit"
assert ai_functions._map_openai_error(_bare(openai.APITimeoutError)).category == "timeout"
assert ai_functions._map_openai_error(ValueError("x")).category == "api"
-63
View File
@@ -1,63 +0,0 @@
"""Unit tests for the Conan Exiles bridge helpers (no Discord/RCON needed)."""
import conanjurer_functions as cf
def test_parse_players_basic():
out = (
"Idx | Char name | Player name | User ID\n"
"0 | Conan | SteamGuy | 1\n"
"1 | Khasar | OtherGuy | 2\n"
"--- | --- | --- | ---"
)
assert cf.parse_players(out) == {"Conan", "Khasar"}
def test_parse_players_empty_inputs():
assert cf.parse_players("No players connected.") == set()
assert cf.parse_players("") == set()
def test_parse_line_login():
event = cf.parse_line("SomeGuy joined the server")
assert event is not None
assert event.kind == "login"
assert "SomeGuy" in event.text
def test_parse_line_chat():
event = cf.parse_line("Chat: Bob: hello there")
assert event is not None
assert event.kind == "chat"
assert "Bob" in event.text and "hello" in event.text
def test_parse_line_unrecognised_is_ignored():
assert cf.parse_line("random server noise") is None
def test_conanconfig_disabled_when_unconfigured():
cfg = cf.ConanConfig("", 25575, "", "local", "", "", 22, "", "")
assert cfg.rcon_enabled is False
assert cfg.log_enabled is False
def test_conanconfig_local_log_enabled():
cfg = cf.ConanConfig("", 0, "", "local", "/tmp/conan.log", "", 22, "", "")
assert cfg.log_enabled is True
def test_conanconfig_rcon_enabled_requires_lib(monkeypatch):
# Without aiomcrcon installed, rcon stays disabled even when host+pw are set.
monkeypatch.setattr(cf, "_Rcon", None)
cfg = cf.ConanConfig("1.2.3.4", 25575, "pw", "local", "", "", 22, "", "")
assert cfg.rcon_enabled is False
# With the lib present, it enables.
monkeypatch.setattr(cf, "_Rcon", object)
cfg2 = cf.ConanConfig("1.2.3.4", 25575, "pw", "local", "", "", 22, "", "")
assert cfg2.rcon_enabled is True
def test_conanconfig_sftp_log_requires_asyncssh(monkeypatch):
monkeypatch.setattr(cf, "asyncssh", None)
cfg = cf.ConanConfig("", 0, "", "sftp", "/log", "sftp.host", 22, "u", "p")
assert cfg.log_enabled is False
-50
View File
@@ -1,50 +0,0 @@
"""Unit tests for constants helpers (defensive config / auth headers)."""
import constants
def test_service_headers_empty_when_no_key(monkeypatch):
monkeypatch.setattr(constants, "API_SHARED_KEY", "")
assert constants.service_headers() == {}
def test_service_headers_with_key(monkeypatch):
monkeypatch.setattr(constants, "API_SHARED_KEY", "s3cr3t")
assert constants.service_headers() == {"X-Conjurer-Api-Key": "s3cr3t"}
def test_load_json_missing_returns_fallback(tmp_path):
missing = tmp_path / "nope.json"
assert constants._load_json(str(missing), {"fallback": 1}) == {"fallback": 1}
def test_load_json_valid(tmp_path):
good = tmp_path / "ok.json"
good.write_text('{"a": 2}', encoding="utf-8")
assert constants._load_json(str(good), {}) == {"a": 2}
def test_load_json_corrupt_returns_fallback(tmp_path):
bad = tmp_path / "bad.json"
bad.write_text("{ not valid json", encoding="utf-8")
assert constants._load_json(str(bad), []) == []
def test_conan_defaults_present():
# Feature toggles default to "off" so the bridge stays dormant.
assert constants.CONAN_JOIN_CHANNEL_ID == 0
assert constants.CONAN_RCON_HOST == ""
def test_ai_configs_expose_gpt_and_claude_backends():
cfgs = constants.AI_CONFIGS
assert cfgs["gpt"]["provider"] == "openai"
assert cfgs["claude"]["provider"] == "anthropic"
# Claude carries a max_tokens (Anthropic requires it) and omits temperature
# (Opus 4.8 / Sonnet 5 reject sampling params).
assert "max_tokens" in cfgs["claude"]
assert "temperature" not in cfgs["claude"]
def test_default_ai_config_is_a_known_config():
# The single startup switch must always resolve to a real, selectable config.
assert constants.DEFAULT_AI_CONFIG in constants.AI_CONFIGS
+7 -12
View File
@@ -1,5 +1,6 @@
import asyncio
import logging
import netrc
import time
import wave
@@ -8,17 +9,11 @@ import discord
from discord.ext import commands, tasks, voice_recv
from discord.opus import Decoder as OpusDecoder
from constants import ASSEMBLYAI_API_KEY, TRANSCRIPTS_PATH
# Credentials come from constants (env ASSEMBLYAI_API_KEY, or the 'assemblyai'
# machine in the netrc at CONJURER_NETRC_FILE). Raising here means the guarded
# extension loader logs the reason and disables ONLY this cog.
if not ASSEMBLYAI_API_KEY:
raise RuntimeError(
"AssemblyAI API key not configured (netrc machine 'assemblyai' or "
"ASSEMBLYAI_API_KEY env) - voice recognition stays disabled"
)
aai.settings.api_key = ASSEMBLYAI_API_KEY
# Replace with your API key
NETRC_FILE = "/home/pi/.netrc"
netrc_mod = netrc.netrc(NETRC_FILE)
authTokens = netrc_mod.authenticators("assemblyai")
aai.settings.api_key = authTokens[2]
discord.opus._load_default()
CHANNELS = OpusDecoder.CHANNELS
@@ -28,7 +23,7 @@ SAMPLING_RATE = OpusDecoder.SAMPLING_RATE
# rotate file after there is 0.5s between last received pcm for user.
# delete messsages after user disconnect
LOCATION = TRANSCRIPTS_PATH
LOCATION = "/home/pi/Conjurer/transcripts/"
class CommunicationObject: