Compare commits

...

17 Commits

Author SHA1 Message Date
gitea f9ad679833 Dockerization + ai review recomendations. 2025-10-29 14:57:43 +01:00
gitea c4fa88e8ee Legacy remove 2025-10-29 13:54:58 +01:00
gitea d43980c976 sa 2025-08-23 17:53:08 +02:00
gitea f6607440ad aaa 2025-08-23 17:49:47 +02:00
gitea 8750430cde FX 2025-08-23 17:44:08 +02:00
gitea 640f8bd824 aaa 2025-08-23 16:20:16 +02:00
gitea bf2835cfc7 fix 2025-08-23 12:36:07 +02:00
gitea a0663d32e0 ttt 2025-08-23 12:30:50 +02:00
gitea 534b1feb58 testing 2025-08-23 12:25:10 +02:00
gitea bb9cdc483d ffx 2025-08-20 22:34:15 +02:00
gitea 04799a8ac1 fx 2025-08-20 22:16:33 +02:00
gitea 75e2205b3d fx 2025-08-20 22:07:10 +02:00
gitea 75014d2ec9 Merge branch 'main' of https://github.com/migatu/conjurer 2025-08-20 21:59:26 +02:00
gitea 28685780f3 Vibe coding :P 2025-08-20 21:59:11 +02:00
gitea 609dbae864 refix 2025-08-20 21:21:15 +02:00
gitea 7ef1502249 upgrade to latest OpenAI API usage and logging improvements 2025-08-20 21:20:00 +02:00
gitea 2f13853084 Partial + reformatting 2025-08-20 18:25:23 +02:00
34 changed files with 1880 additions and 3349 deletions
+32
View File
@@ -0,0 +1,32 @@
# Python
__pycache__
*.py[cod]
*.pyo
*.pyd
*.so
# VCS/CI
.git
.gitignore
.gitattributes
.github
# Editor
.vscode
.idea
# Python tooling
.pytest_cache
.mypy_cache
.ruff_cache
.tox
dist
build
*.egg-info
# Local assets
logs
music
data
*.env
*.env.*
+21
View File
@@ -1,2 +1,23 @@
# conjurer # conjurer
Discord.py bot for fun, sex and BDSM Discord.py bot for fun, sex and BDSM
## Docker quick start
Docker definitions live at the repository root and let you run the Discord bot
(`thin_client.py`), the musician file service, and the librarian search worker as
separate containers.
1. **Prepare configuration**
- Edit the environment files under `docker/env/*.env` and replace the
placeholders (`replace-me`, `shared-secret`, etc.) with your real tokens.
Keep `CONJURER_API_KEY` identical across all services.
2. **Create host directories** listed in `docker-compose.yml` (for example
`docker/volumes/bot/config`, `docker/volumes/musician/data`, …) and populate
them with the required JSON settings or media assets.
3. **Launch the stack**
```bash
docker compose up --build
```
Logs for each container are mounted under `docker/volumes/*` so you can diff the
runtime JSON and log files in git if needed.
+146 -148
View File
@@ -19,13 +19,26 @@ from constants import (
OPENAICLIENT, OPENAICLIENT,
SYSTEM_GPT_SETTINGS, SYSTEM_GPT_SETTINGS,
WORD_REACTIONS, WORD_REACTIONS,
CHEAP_MODEL,
LATEST_MODEL
) )
#this do per user # this do per user
VECTOR_STORE_ID = -1 VECTOR_STORE_ID = -1
def select_model(req_type: str, algo: str) -> 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_MODEL
return LATEST_MODEL
async def _openai_call(messages, model, temperature=0.2): async def openai_call(messages, model, temperature=0.2):
""" """
Responses API dla 4o/4.1*, fallback Chat Completions dla gpt-3.5-turbo. Responses API dla 4o/4.1*, fallback Chat Completions dla gpt-3.5-turbo.
Zwraca czysty string odpowiedzi. Zwraca czysty string odpowiedzi.
@@ -47,23 +60,26 @@ async def _openai_call(messages, model, temperature=0.2):
else: else:
logger.info("4.0+") logger.info("4.0+")
# Responses API (zalecane dla 4o/4.1*) # Responses API (zalecane dla 4o/4.1*)
resp = await OPENAICLIENT.responses.create( resp = await OPENAICLIENT.responses.create(
model=model, model=model,
temperature=temperature, temperature=temperature,
input=messages, input=messages,
) )
# SDK zapewnia output_text dla zwykłych odpowiedzi # SDK zapewnia output_text dla zwykłych odpowiedzi
return (getattr(resp.output_text, "output_text", None) or "").strip() or str(resp.output_text) return (getattr(resp.output_text, "output_text", None) or "").strip() or str(
resp.output_text
)
def create_vector_store(): def create_vector_store():
# Create a vector store caled "Financial Statements" # Create a vector store caled "Financial Statements"
return OPENAICLIENT.beta.vector_stores.create_and_poll(name="Hammer Stash") return OPENAICLIENT.beta.vector_stores.create_and_poll(name="Hammer Stash")
#expires_after={ # expires_after={
#"anchor": "last_active_at", # "anchor": "last_active_at",
#"days": 7} # "days": 7}
#) # )
def upload_files_to_vector_store(assistant): def upload_files_to_vector_store(assistant):
@@ -71,15 +87,14 @@ def upload_files_to_vector_store(assistant):
file_paths = ["edgar/goog-10k.pdf", "edgar/brka-10k.txt"] file_paths = ["edgar/goog-10k.pdf", "edgar/brka-10k.txt"]
file_streams = [open(path, "rb") for path in file_paths] file_streams = [open(path, "rb") for path in file_paths]
#file = client.beta.vector_stores.files.create_and_poll( # file = client.beta.vector_stores.files.create_and_poll(
#vector_store_id="vs_abc123", # vector_store_id="vs_abc123",
#file_id="file-abc123" # file_id="file-abc123"
#) # )
#batch = client.beta.vector_stores.file_batches.create_and_poll( # batch = client.beta.vector_stores.file_batches.create_and_poll(
#vector_store_id="vs_abc123", # vector_store_id="vs_abc123",
#file_ids=['file_1', 'file_2', 'file_3', 'file_4', 'file_5'] # file_ids=['file_1', 'file_2', 'file_3', 'file_4', 'file_5']
#) # )
# Use the upload and poll SDK helper to upload the files, add them to the vector store, # Use the upload and poll SDK helper to upload the files, add them to the vector store,
# and poll the status of the file batch for completion. # and poll the status of the file batch for completion.
@@ -91,10 +106,11 @@ def upload_files_to_vector_store(assistant):
print(file_batch.status) print(file_batch.status)
print(file_batch.file_counts) print(file_batch.file_counts)
assistant = OPENAICLIENT.beta.assistants.update( assistant = OPENAICLIENT.beta.assistants.update(
assistant_id=assistant.id, assistant_id=assistant.id,
tool_resources={"file_search": {"vector_store_ids": [VECTOR_STORE_ID]}}, tool_resources={"file_search": {"vector_store_ids": [VECTOR_STORE_ID]}},
) )
def delete_files_from_vector_store(assistant, file_id): def delete_files_from_vector_store(assistant, file_id):
result = OPENAICLIENT.beta.vector_stores.file_batches.delete( result = OPENAICLIENT.beta.vector_stores.file_batches.delete(
vector_store_id=VECTOR_STORE_ID, files=file_id vector_store_id=VECTOR_STORE_ID, files=file_id
@@ -103,8 +119,8 @@ def delete_files_from_vector_store(assistant, file_id):
# You can print the status and the file counts of the batch to see the result of this operation. # You can print the status and the file counts of the batch to see the result of this operation.
print(result) print(result)
assistant = OPENAICLIENT.beta.assistants.update( assistant = OPENAICLIENT.beta.assistants.update(
assistant_id=assistant.id, assistant_id=assistant.id,
tool_resources={"file_search": {"vector_store_ids": [VECTOR_STORE_ID]}}, tool_resources={"file_search": {"vector_store_ids": [VECTOR_STORE_ID]}},
) )
@@ -133,7 +149,15 @@ def num_tokens_from_string(message, model):
async def handle_response( async def handle_response(
prompt, vykidailo, bartender, history, username, request_type, algorithm="gpt-4o", none_request="" prompt,
vykidailo,
bartender,
history,
username,
request_type,
algorithm="gpt-4o",
none_request="",
internal_retry: bool = False
): ):
""" """
Handle responses by appending them to a history, use OpenAI to Handle responses by appending them to a history, use OpenAI to
@@ -157,104 +181,83 @@ async def handle_response(
""" """
logger = logging.getLogger("discord") logger = logging.getLogger("discord")
logger.info("Wywolanie procedury openai z promptem: %s", prompt) logger.info("Wywolanie procedury openai z promptem: %s", prompt)
temp = {"role": "user", "content": username + ":" + prompt}
if vykidailo or bartender: if vykidailo or bartender:
logger.info("Administrator coś chciał") logger.info("Administrator coś chciał")
history.append(temp) model_to_use = select_model(request_type, algorithm)
if request_type == "MUSIC": logger.info("Wybrany model: %s", model_to_use)
with open(MEMORY_FIVE_MUZYKA, "r+", encoding=ENCODING) as file_music_memory: if request_type == "MUSIC" and model_to_use == "gpt-4o-mini":
# First we load existing data into a dict. try:
file_data = json.load(file_music_memory) # nic — normalnie pójdzie Responses API
# Join new_data with file_data inside emp_details pass
file_data.append(temp) except Exception:
file_music_memory.seek(0) model_to_use = "gpt-3.5-turbo"
# convert back to json. # --- 2) Budowa historii (token budget + reguły systemowe) ---
json.dump(file_data, file_music_memory, indent=4) # NOTE: ignorujemy przekazany 'history' jako listę (tak było wcześniej),
elif request_type == "RANDOM": # ale zwracamy aktualną tablicę do nadpisania w miejscach wołania (back-compat).
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as file_memory: base_system = GPT_SETTINGS[0] # zakładamy {"role":"system","content":...}
# First we load existing data into a dict. history_msgs = []
file_data = json.load(file_memory)
# Join new_data with file_data inside emp_details
file_data.append(temp)
file_memory.seek(0)
# convert back to json.
json.dump(file_data, file_memory, indent=4)
elif request_type == "GENERAL":
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as file_memory:
# First we load existing data into a dict.
file_data = json.load(file_memory)
# Join new_data with file_data inside emp_details
file_data.append(temp)
file_memory.seek(0)
# convert back to json.
json.dump(file_data, file_memory, indent=4)
history = []
if request_type != "NONE":
history.append(GPT_SETTINGS[0])
chat_gpt_config_request_size = num_tokens_from_string(GPT_SETTINGS[0], "gpt-4")
if request_type != "NONE":
history_msgs.append(base_system)
chat_gpt_config_request_size = num_tokens_from_string(base_system, "gpt-4")
# Dynamiczne mikro-reguły (WORD_REACTIONS), jak w Twoim kodzie
for slowo, reakcja in WORD_REACTIONS.items(): for slowo, reakcja in WORD_REACTIONS.items():
if not reakcja[3]: if not reakcja[3]:
content = ( content = f"Kiedy słyszysz {slowo} to reagujesz lub dzieje się to {reakcja[0]}"
"Kiedy słyszysz " sys_msg = {"role": "system", "content": content}
+ slowo chat_gpt_config_request_size += num_tokens_from_string(sys_msg, "gpt-4")
+ " to reagujesz lub dzieje się to " history_msgs.append(sys_msg)
+ reakcja[0]
)
temp = {"role": "system", "content": content}
chat_gpt_config_request_size += num_tokens_from_string(temp, "gpt-4")
history.append(temp)
final_prompt = username + ":" + prompt # wybór właściwej tablicy pamięci i budżetu
logger.debug(
"Rozmiar zapytania przed dodaniem historii %s", chat_gpt_config_request_size
)
if request_type == "MUSIC": if request_type == "MUSIC":
table = MESSAGE_TABLE_MUZYKA table = MESSAGE_TABLE_MUZYKA
token_amount = 10700 token_amount = 10700
elif request_type == "RANDOM": elif request_type in ("RANDOM", "GENERAL"):
table = MESSAGE_TABLE
token_amount = 10700
elif request_type == "GENERAL":
table = MESSAGE_TABLE table = MESSAGE_TABLE
token_amount = 10700 token_amount = 10700
else: else:
table = [] table = []
token_amount = 10000 token_amount = 10000
# doklejanie historii od końca aż do limitu (zachowana kolejność czasowa)
final_prompt = f"{username}:{prompt}"
prompt_gpt_request_size = num_tokens_from_string({"role": "user", "content": final_prompt}, "gpt-4")
acc = []
for msg in reversed(table):
t = num_tokens_from_string(msg, "gpt-4")
if chat_gpt_config_request_size + prompt_gpt_request_size + t <= token_amount:
acc.append(msg)
chat_gpt_config_request_size += t
else:
break
# przywróć chronologicznie
history_msgs.extend(reversed(acc))
# aktualny prompt
history_msgs.append({"role": "user", "content": final_prompt})
logger.info("Rozmiar zapytania (tok): %s", prompt_gpt_request_size) # tokeny już policzone wyżej
prompt_gpt_request_size = num_tokens_from_string(
{"role": "user", "content": final_prompt}, "gpt-4"
)
temptable = []
for i in reversed(table):
temp_token = num_tokens_from_string(i, "gpt-4")
logger.debug(
"Rozmiar zapytania %s prompt %s temp %s",
chat_gpt_config_request_size,
prompt_gpt_request_size,
temp_token,
)
if (
chat_gpt_config_request_size
< token_amount + prompt_gpt_request_size + temp_token
):
temptable.insert(1, i)
chat_gpt_config_request_size += temp_token
history.extend(temptable)
temp = {"role": "user", "content": final_prompt}
history.append(temp)
else: else:
history = none_request # --- tryb NONE: nie dotykamy pamięci i pozwalamy przekazać własny 'none_request' ---
logger.info("Rozmiar zapytania po wyslaniu %s", chat_gpt_config_request_size) if isinstance(none_request, list):
history_msgs = none_request
elif isinstance(none_request, str) and none_request.strip():
history_msgs = [{"role": "user", "content": none_request}]
else:
history_msgs = [{"role": "user", "content": f"{username}:{prompt}"}]
logger.info("Rozmiar zapytania (tok): %s", "n/a") # tokeny już policzone wyżej
try: try:
# ...przygotowanie messages/system prompt/itp. jak masz... # ...przygotowanie messages/system prompt/itp. jak masz...
# retry/backoff + deadline (zachowuje Twoją semantykę logowania) # retry/backoff + deadline (zachowuje Twoją semantykę logowania)
timeout_sec = 120 timeout_sec = 120
deadline = time.time() + timeout_sec deadline = time.time() + timeout_sec
response = await asyncio.wait_for( response = await asyncio.wait_for(
_openai_call(messages=history, model=algorithm), openai_call(messages=history_msgs, model=model_to_use),
timeout=max(0.1, deadline - time.time()) timeout=max(0.1, deadline - time.time()),
) )
except openai.APITimeoutError as e: except openai.APITimeoutError as e:
@@ -264,26 +267,33 @@ async def handle_response(
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}" 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: except openai.BadRequestError as e:
# Handle invalid request error, e.g. validate parameters or log # Handle invalid request error, e.g. validate parameters or log
resp, _ = await handle_response( if internal_retry:
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'", resp = "Nie umiem tego teraz ładnie wytłumaczyć — OpenAI mnie zastrzeliło."
True, else:
True, resp, _ = await handle_response(
MESSAGE_TABLE,
username, 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'",
"RANDOM", True,
) True,
response = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {e}" 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: except openai.APIResponseValidationError as e:
# Handle invalid request error, e.g. validate parameters or log # Handle invalid request error, e.g. validate parameters or log
resp, _ = await handle_response( if internal_retry:
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'", resp = "Nie umiem tego teraz ładnie wytłumaczyć — OpenAI mnie zastrzeliło."
True, else:
True, resp, _ = await handle_response(
MESSAGE_TABLE, 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'",
username, True,
"RANDOM", True,
) MESSAGE_TABLE,
response = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {e}" 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: except openai.AuthenticationError as e:
# Handle authentication error, e.g. check credentials or log # 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}" 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}"
@@ -299,43 +309,31 @@ async def handle_response(
response = f"*Kondziu nurkuje za bar, terminal wybucha. Przed tobą ląduje pergamin zapisany pięknym gotykiem a na nim*: {e}" 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:") logger.info("Historia wysłana:")
logger.info(history) temp_assistant = {"role": "assistant", "content": response}
await asyncio.sleep(15) logger.info(temp_assistant)
temp = {"role": "assistant", "content": response}
history.append(temp)
if request_type == "MUSIC": if request_type == "MUSIC":
with open(MEMORY_FIVE_MUZYKA, "r+", encoding=ENCODING) as file_music_memory: # zapis do pliku MUZYKA
# First we load existing data into a dict. with open(MEMORY_FIVE_MUZYKA, "r+", encoding=ENCODING) as fh:
file_data = json.load(file_music_memory) file_data = json.load(fh)
# Join new_data with file_data inside emp_details file_data.append({"role": "user", "content": f"{username}:{prompt}"})
file_data.append(temp) file_data.append(temp_assistant)
file_music_memory.seek(0) fh.seek(0)
# convert back to json. json.dump(file_data, fh, indent=4)
json.dump(file_data, file_music_memory, indent=4)
return response, MESSAGE_TABLE_MUZYKA return response, MESSAGE_TABLE_MUZYKA
elif request_type == "RANDOM":
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as file_memory: elif request_type in ("RANDOM", "GENERAL"):
# First we load existing data into a dict. with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as fh:
file_data = json.load(file_memory) file_data = json.load(fh)
# Join new_data with file_data inside emp_details file_data.append({"role": "user", "content": f"{username}:{prompt}"})
file_data.append(temp) file_data.append(temp_assistant)
file_memory.seek(0) fh.seek(0)
# convert back to json. json.dump(file_data, fh, indent=4)
json.dump(file_data, file_memory, indent=4)
return response, MESSAGE_TABLE return response, MESSAGE_TABLE
elif request_type == "GENERAL":
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as file_memory: else: # NONE
# First we load existing data into a dict.
file_data = json.load(file_memory)
# Join new_data with file_data inside emp_details
file_data.append(temp)
file_memory.seek(0)
# convert back to json.
json.dump(file_data, file_memory, indent=4)
return response, MESSAGE_TABLE
else:
return response, [] return response, []
async def get_random_cyclic_message(client): async def get_random_cyclic_message(client):
""" """
The function `get_random_cyclic_message` returns a random cyclic message from a list of cyclic The function `get_random_cyclic_message` returns a random cyclic message from a list of cyclic
@@ -438,7 +436,7 @@ async def chat_with_assistant(message, assistant_name):
logger.info(block.text.value) logger.info(block.text.value)
chat_response += block.text.value chat_response += block.text.value
await discord_friendly_send(message.channel, chat_response) await discord_friendly_send(message.channel, chat_response)
#await message.channel.send(chat_response) # await message.channel.send(chat_response)
done = True done = True
elif run.status == "cancelled": elif run.status == "cancelled":
await discord_friendly_send(message.channel, "Cos sie wywaliło") await discord_friendly_send(message.channel, "Cos sie wywaliło")
-2579
View File
File diff suppressed because it is too large Load Diff
+43 -10
View File
@@ -1,12 +1,14 @@
import json import json
import logging import logging
import os
import re import re
import threading import threading
import time import time
from queue import Empty, Queue from queue import Empty, Queue
from typing import Optional
from urllib import request as urequest from urllib import request as urequest
from flask import Flask, jsonify, request from flask import Flask, abort, jsonify, request
from waitress import serve from waitress import serve
HOST_ADDRESS = "192.168.1.191" HOST_ADDRESS = "192.168.1.191"
@@ -31,6 +33,13 @@ PREPPED_TRACKS = {
} }
logger = logging.getLogger("discord") logger = logging.getLogger("discord")
API_KEY = os.getenv("CONJURER_API_KEY")
def _authorize_request() -> None:
if API_KEY and request.headers.get("X-Conjurer-Api-Key") != API_KEY:
abort(401)
class QueryControl: class QueryControl:
""" """
@@ -53,6 +62,7 @@ class QueryControl:
@app.route("/prepped_tracks", methods=["POST"]) @app.route("/prepped_tracks", methods=["POST"])
def log_radio_tracks(): def log_radio_tracks():
_authorize_request()
app.logger = logging.getLogger("discord") app.logger = logging.getLogger("discord")
app.logger.info(request) app.logger.info(request)
@@ -79,6 +89,7 @@ def log_radio_tracks():
@app.route("/conjurer", methods=["POST"]) @app.route("/conjurer", methods=["POST"])
def answer_external_command(): def answer_external_command():
_authorize_request()
""" """
The function `answer_external_command` logs the request data, loads the data as JSON, logs the The function `answer_external_command` logs the request data, loads the data as JSON, logs the
record, and then puts the record into an incoming queue before returning a success message. record, and then puts the record into an incoming queue before returning a success message.
@@ -129,7 +140,7 @@ def waitress_run():
serve(app, host=HOST_ADDRESS, port=PORT_ADDRESS) serve(app, host=HOST_ADDRESS, port=PORT_ADDRESS)
def scan_queue(): def scan_queue(stop_event: Optional[threading.Event] = None):
""" """
The function `scan_queue` reads data from a queue, logs it, and appends it to another queue. The function `scan_queue` reads data from a queue, logs it, and appends it to another queue.
@@ -140,12 +151,18 @@ def scan_queue():
""" """
logger = logging.getLogger("discord") logger = logging.getLogger("discord")
while True: while True:
data = OUT_COMM_Q.get() if stop_event and stop_event.is_set():
logger.info("scan_queue: stop requested")
break
try:
data = OUT_COMM_Q.get(timeout=1)
except Empty:
continue
logger.info(data) logger.info(data)
awaiting_q.append(data) awaiting_q.append(data)
def scan_incoming(): def scan_incoming(stop_event: Optional[threading.Event] = None):
""" """
The `scan_incoming` function continuously checks for incoming data, processes it, and logs when data The `scan_incoming` function continuously checks for incoming data, processes it, and logs when data
is found. is found.
@@ -157,6 +174,9 @@ def scan_incoming():
""" """
logger = logging.getLogger("discord") logger = logging.getLogger("discord")
while True: while True:
if stop_event and stop_event.is_set():
logger.info("scan_incoming: stop requested")
break
try: try:
answer = incoming_q.get(block=False) answer = incoming_q.get(block=False)
logger.info("DATA FOUND") logger.info("DATA FOUND")
@@ -204,7 +224,7 @@ def id3(url: str) -> dict:
return tagdata return tagdata
def comm_subroutine(): def comm_subroutine(stop_event: Optional[threading.Event] = None):
""" """
The `comm_subroutine` function starts multiple threads to run different tasks concurrently. The `comm_subroutine` function starts multiple threads to run different tasks concurrently.
@@ -217,14 +237,27 @@ def comm_subroutine():
logger.info("Started comms") logger.info("Started comms")
threads = [] threads = []
# threads.append(threading.Thread(target=flask_debug)) # threads.append(threading.Thread(target=flask_debug))
threads.append(threading.Thread(target=waitress_run)) threads.append(
threads.append(threading.Thread(target=scan_queue)) threading.Thread(target=waitress_run, daemon=True)
threads.append(threading.Thread(target=scan_incoming)) )
threads.append(
threading.Thread(target=scan_queue, kwargs={"stop_event": stop_event}, daemon=True)
)
threads.append(
threading.Thread(target=scan_incoming, kwargs={"stop_event": stop_event}, daemon=True)
)
for worker in threads: for worker in threads:
worker.start() worker.start()
for worker in threads:
worker.join() try:
while any(thread.is_alive() for thread in threads):
if stop_event and stop_event.is_set():
break
time.sleep(0.5)
finally:
if stop_event:
stop_event.set()
if __name__ == "__main__": if __name__ == "__main__":
+81 -31
View File
@@ -16,34 +16,52 @@ Functions:
import asyncio import asyncio
import json import json
import logging import logging
import netrc import os
import threading import threading
import time
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
from logging import handlers from logging import handlers
from pathlib import Path
from queue import Queue from queue import Queue
from typing import Dict, Optional
import requests import requests
import scrape_bot import scrape_bot
import search_bot import search_bot
#import search_bot2 as search_bot # import search_bot2 as search_bot
from flask import Flask, jsonify, request from flask import Flask, jsonify, request, abort
from habanero import Crossref from habanero import Crossref
from waitress import serve from waitress import serve
try:
import netrc
except ImportError: # pragma: no cover
netrc = None
# Constants # Constants
NETRC_FILE = r"C:\Users\Activcom.pl\.netrc"
HOST_ADDRESS = "192.168.1.192"
PORT_ADDRESS = 5001
MAIN_BOT_ADDRESS = "http://192.168.1.191:5000"
SEND_RESULTS = "/conjurer"
BDSM_UUID_TEST = "96b7f85a-1142-4908-8986-62a2ea25a147"
MAX_CR_RESULTS = 500
#TEST PURPOSES ONLY!
#MAX_CR_RESULTS = 5
ENCODING = "utf-8" def _env(name: str, default: str) -> str:
return os.getenv(name, default)
def _env_path(name: str, default: str) -> Path:
return Path(os.getenv(name, default)).expanduser().resolve()
BASE_DIR = Path(
os.getenv("CONJURER_LIBRARIAN_BASE", str(Path(__file__).resolve().parent))
)
NETRC_FILE = _env_path("CONJURER_NETRC_FILE", str(Path.home() / ".netrc"))
HOST_ADDRESS = _env("CONJURER_LIBRARIAN_HOST", "0.0.0.0")
PORT_ADDRESS = int(_env("CONJURER_LIBRARIAN_PORT", "5001"))
MAIN_BOT_ADDRESS = _env("CONJURER_MAIN_BOT", "http://127.0.0.1:5000")
SEND_RESULTS = _env("CONJURER_LIBRARIAN_RESULTS_ENDPOINT", "/conjurer")
MAX_CR_RESULTS = int(_env("CONJURER_LIBRARIAN_MAX_RESULTS", "500"))
ENCODING = _env("CONJURER_ENCODING", "utf-8")
API_KEY = os.getenv("CONJURER_API_KEY")
LOGFILE_PATH = _env_path(
"CONJURER_LIBRARIAN_LOG", str(BASE_DIR / "librarian.log")
)
app = Flask(__name__) app = Flask(__name__)
@@ -51,6 +69,17 @@ librarian_queue = Queue()
librarian_list = [] librarian_list = []
def _service_headers() -> Dict[str, str]:
if API_KEY:
return {"X-Conjurer-Api-Key": API_KEY}
return {}
def _authorize_request() -> None:
if API_KEY and request.headers.get("X-Conjurer-Api-Key") != API_KEY:
abort(401)
# trunk-ignore(pylint/R0902) # trunk-ignore(pylint/R0902)
class Librarian(object): class Librarian(object):
""" """
@@ -81,11 +110,24 @@ class Librarian(object):
- search_result_from_cr: A dictionary to store the search results from Crossref. - search_result_from_cr: A dictionary to store the search results from Crossref.
- done: A flag indicating if the search is done. - done: A flag indicating if the search is done.
""" """
netrc_mod = netrc.netrc(NETRC_FILE) mailto_contact: Optional[str] = os.getenv("CONJURER_CROSSREF_MAILTO")
auth_tokens = netrc_mod.authenticators("crossref") if netrc:
try:
netrc_mod = netrc.netrc(str(NETRC_FILE))
auth_tokens = netrc_mod.authenticators("crossref")
if auth_tokens:
mailto_contact = auth_tokens[0]
except (FileNotFoundError, netrc.NetrcParseError):
logging.getLogger("conjurer_librarian").warning(
"Crossref credentials missing in netrc %s", NETRC_FILE
)
if not mailto_contact:
raise RuntimeError(
"Crossref credentials not configured. Set CONJURER_CROSSREF_MAILTO or add to netrc."
)
self.cr = Crossref( self.cr = Crossref(
mailto=auth_tokens[0], mailto=mailto_contact,
ua_string=f"Conjurer project. mailto:{auth_tokens[0]}" ua_string=f"Conjurer project. mailto:{mailto_contact}"
) )
self.query = query self.query = query
self.uuid = str(uuid) self.uuid = str(uuid)
@@ -132,7 +174,7 @@ class Librarian(object):
self.fetched = len(cr_result["message"]["items"]) self.fetched = len(cr_result["message"]["items"])
self.app.logger.info(self.total) self.app.logger.info(self.total)
self.app.logger.info(self.fetched) self.app.logger.info(self.fetched)
time.sleep(0.1) await asyncio.sleep(0.1)
else: else:
cr_result = self.cr.works(query=query, cursor_max=15000, cursor='*', progress_bar = True) cr_result = self.cr.works(query=query, cursor_max=15000, cursor='*', progress_bar = True)
@@ -411,18 +453,20 @@ class BackgroundTaskSearch(threading.Thread):
requests.post, requests.post,
f"{MAIN_BOT_ADDRESS}{SEND_RESULTS}", f"{MAIN_BOT_ADDRESS}{SEND_RESULTS}",
json=result, json=result,
headers=_service_headers(),
timeout=360, timeout=360,
) )
self.app.logger.info("SENT") self.app.logger.info("SENT")
result = await coroutine result = await coroutine
self.app.logger.info(result.status_code) self.app.logger.info(result.status_code)
self.app.logger.info("SEND CONFIRMED") self.app.logger.info("SEND CONFIRMED")
time.sleep(1) await asyncio.sleep(1)
# ==================================SERVER ROUTES========================================== # ==================================SERVER ROUTES==========================================
@app.route("/query", methods=["POST"]) @app.route("/query", methods=["POST"])
async def query_database(): async def query_database():
_authorize_request()
""" """
Endpoint for querying the database. Endpoint for querying the database.
@@ -455,6 +499,7 @@ async def query_database():
@app.route("/get_partial_result", methods=["POST"]) @app.route("/get_partial_result", methods=["POST"])
async def get_partial(): async def get_partial():
_authorize_request()
""" """
Retrieves the partial result for a given UUID. Retrieves the partial result for a given UUID.
@@ -478,9 +523,10 @@ async def get_partial():
# =======================================MAIN=================================================== # =======================================MAIN===================================================
if __name__ == "__main__": if __name__ == "__main__":
app.logger.setLevel(logging.DEBUG) app.logger.setLevel(logging.DEBUG)
LOGFILE_PATH.parent.mkdir(parents=True, exist_ok=True)
h1 = handlers.RotatingFileHandler( h1 = handlers.RotatingFileHandler(
filename="D:\\logs\\librarian.log", filename=str(LOGFILE_PATH),
encoding="utf-8", encoding=ENCODING,
mode="a", mode="a",
maxBytes=6 * 1024 * 1024, maxBytes=6 * 1024 * 1024,
backupCount=6, backupCount=6,
@@ -488,20 +534,24 @@ if __name__ == "__main__":
app.logger.addHandler(h1) app.logger.addHandler(h1)
threads = [] threads = []
threads.append(threading.Thread(target=waitress_run)) threads.append(threading.Thread(target=waitress_run, daemon=True))
# threads.append(threading.Thread(target=flask_debug)) # threads.append(threading.Thread(target=flask_debug))
bgtask = BackgroundTaskSearch() bgtask = BackgroundTaskSearch()
bgtask.app = app bgtask.app = app
bgtask.daemon = True
threads.append(bgtask) threads.append(bgtask)
threads.append(threading.Thread(target=scrape_bot.scraper, args=(app.logger,))) threads.append(
threading.Thread(
target=scrape_bot.scraper, args=(app.logger,), daemon=True
)
)
i = 0 i = 0
for worker in threads: try:
try: for worker in threads:
app.logger.info("App number: %s", i) app.logger.info("App number: %s", i)
i += 1 i += 1
worker.start() worker.start()
except RuntimeError as e: for worker in threads:
app.logger.error("Exploded") worker.join()
print(str(e)) except KeyboardInterrupt:
for worker in threads: app.logger.info("Shutdown requested - exiting librarian service")
worker.join()
+162 -119
View File
@@ -11,17 +11,15 @@ import random
import re import re
import threading import threading
import time import time
# from flask_autoindex import AutoIndex
from datetime import datetime from datetime import datetime
from logging import handlers from logging import handlers
from pathlib import Path from pathlib import Path
from platform import uname from typing import Dict, List
from sys import platform
import requests import requests
from flask import ( from flask import (
Flask, Flask,
abort,
jsonify, jsonify,
redirect, redirect,
render_template, render_template,
@@ -29,37 +27,99 @@ from flask import (
send_from_directory, send_from_directory,
) )
from waitress import serve from waitress import serve
import media_search_functions import media_search_functions
MAIN_BOT_ADDRESS = "http://192.168.1.191:5000" def _env(name: str, default: str) -> str:
MUSIC_TRACKER = "/prepped_tracks" return os.getenv(name, default)
HOST_ADDRESS = "192.168.1.15"
PORT_ADDRESS = 5000
if platform in ("linux", "linux2"):
SEPARATOR_FILE_PATH = "/"
if "microsoft-standard" in uname().release:
LOGFILE = "/home/mtuszowski/conjurer/discord_mus_service.log"
MUSIC_FOLDER = "/mnt/g/Muzyka/"
NETRC_FILE = "/home/mtuszowski/.netrc"
LOGSTORE = "/home/mtuszowski/conjurer/logs/"
ENCODING = "utf-8"
RADIOLOG_PATH = "/home/pi/Conjurer/radio_log.log"
PERSISTENCE_PATH = "/home/pi/Conjurer/persistence.log"
else:
LOGFILE = "/home/pi/Conjurer/discord_mus_service.log" def _env_path(name: str, default: str) -> Path:
NETRC_FILE = "/home/pi/.netrc" value = os.getenv(name, default)
LOGSTORE = "/home/pi/MediaFolder/logs/" return Path(value).expanduser().resolve()
ENCODING = "utf-8"
MUSIC_FOLDER = "/home/pi/MediaFolder/mp3/"
PRIORITY_FOLDER = "/home/pi/MediaFoldermp3/Magiczne i chuj/" API_KEY = os.getenv("CONJURER_API_KEY")
RADIOLOG_PATH = "/home/pi/Conjurer/radio_log.log" MAIN_BOT_ADDRESS = _env("CONJURER_MAIN_BOT", "http://127.0.0.1:5000")
PERSISTENCE_PATH = "/home/pi/Conjurer/persistence.log" 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"))
BASE_DIR = Path(
os.getenv("CONJURER_MUSICIAN_BASE", str(Path(__file__).resolve().parent))
)
LOGFILE = _env_path(
"CONJURER_MUSICIAN_LOG", str(BASE_DIR / "discord_mus_service.log")
)
LOGSTORE = _env_path("CONJURER_LOGSTORE", str(BASE_DIR / "logs"))
MUSIC_FOLDER = _env_path(
"CONJURER_MUSIC_FOLDER", str(BASE_DIR / "music")
)
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() random.seed()
music_file_list = [] music_file_list: List[str] = []
priority_list = [] 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:
if API_KEY and request.headers.get("X-Conjurer-Api-Key") != API_KEY:
abort(401)
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")
def rescan(): def rescan():
@@ -70,28 +130,29 @@ def rescan():
logger = logging.getLogger("conjurer_musician") logger = logging.getLogger("conjurer_musician")
logger.info("Rescan triggered") logger.info("Rescan triggered")
for mp3_item in Path.glob(Path(MUSIC_FOLDER), "**/*.mp3"): music_file_list.clear()
priority_list.clear()
for mp3_item in MUSIC_FOLDER.glob("**/*.mp3"):
temp_music_file = mp3_item.as_posix() temp_music_file = mp3_item.as_posix()
if platform == "win32": if os.name == "nt":
temp_music_file = temp_music_file.replace("/", "\\") temp_music_file = temp_music_file.replace("/", "\\")
music_file_list.append(temp_music_file) music_file_list.append(temp_music_file)
for mp3_item in Path.glob(Path(PRIORITY_FOLDER), "**/*.mp3"): for mp3_item in PRIORITY_FOLDER.glob("**/*.mp3"):
temp_music_file = mp3_item.as_posix() temp_music_file = mp3_item.as_posix()
if platform == "win32": if os.name == "nt":
temp_music_file = temp_music_file.replace("/", "\\") temp_music_file = temp_music_file.replace("/", "\\")
priority_list.append(temp_music_file) priority_list.append(temp_music_file)
with open( with ALL_PLAYLIST_PATH.open("w", encoding=ENCODING) as w_file:
"/home/pi/Conjurer/all_playlist.playlist", "w", encoding="utf-8"
) as w_file:
try: try:
for item in music_file_list: for item in music_file_list:
w_file.write(item) w_file.write(item)
w_file.write("\n") w_file.write("\n")
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
with open("/home/pi/Conjurer/hit.playlist", "w", encoding="utf-8") as w_file: with HIT_PLAYLIST_PATH.open("w", encoding=ENCODING) as w_file:
try: try:
for item in priority_list: for item in priority_list:
w_file.write(item) w_file.write(item)
@@ -116,72 +177,50 @@ def thread_rescan():
def scan_tracks(): def scan_tracks():
# Set the filename and open the file # Set the filename and open the file
logger = logging.getLogger("conjurer_musician") 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
file = open(RADIOLOG_PATH, "r") while True:
# Find the size of the file and move to the end current_size = os.stat(PERSISTENCE_PATH).st_size
st_results = os.stat(RADIOLOG_PATH) if prev_size != current_size:
st_size = st_results[6] while prev_size != current_size:
file.seek(st_size) prev_size = current_size
st_results1 = os.stat(PERSISTENCE_PATH) time.sleep(0.1)
prev_st_size1 = st_results[6] 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]])
while 1: position = log_file.tell()
line = log_file.readline()
if not line:
time.sleep(1)
log_file.seek(position)
continue
st_results1 = os.stat(PERSISTENCE_PATH) if not re.match(r".*Prepared.*", line):
st_size1 = st_results1[6]
if prev_st_size1 != st_size1:
while prev_st_size1 != st_size1:
prev_st_size1 = st_size1
st_results1 = os.stat(PERSISTENCE_PATH)
st_size1 = st_results1[6]
time.sleep(0.1) time.sleep(0.1)
file1 = open(PERSISTENCE_PATH, "r") continue
lines = file1.readlines()
result = ["next", lines[2]]
file1.close()
returned = requests.post(
f"{MAIN_BOT_ADDRESS}{MUSIC_TRACKER}", json=result, timeout=360
)
logger.info("SENT")
logger.info(returned.status_code)
logger.info("SEND CONFIRMED")
where = file.tell() result = None
line = file.readline() if re.match(r".*jingles.*", line):
if not line: result = ["jingles", line]
time.sleep(1) elif re.match(r".*priority.*", line):
file.seek(where) result = ["priority", line]
else: elif re.match(r".*hit.*", line):
if re.match(".*Prepared.*", line): result = ["hit", line]
result = None elif re.match(r".*all_playlist.*", line):
if re.match(".*jingles.*", line): result = ["all", line]
logger.info("jingles") elif re.match(r".*request.*", line):
logger.info(line) # already has newline result = ["requests", line]
result = ["jingles", line]
elif re.match(".*priority.*", line): if result:
logger.info("priority") logger.info("Forwarding radio log entry: %s", result[0])
logger.info(line) # already has newline _post_to_bot(result)
result = ["priority", line]
elif re.match(".*hit.*", line): time.sleep(0.1)
logger.info("hit")
logger.info(line) # already has newline
result = ["hit", line]
elif re.match(".*all_playlist.*", line):
logger.info("all")
logger.info(line) # already has newline
result = ["all", line]
elif re.match(".*request.*", line):
logger.info("requests")
logger.info(line) # already has newline
result = ["requests", line]
if result:
returned = requests.post(
f"{MAIN_BOT_ADDRESS}{MUSIC_TRACKER}", json=result, timeout=360
)
logger.info("SENT")
logger.info(returned.status_code)
logger.info("SEND CONFIRMED")
time.sleep(0.1)
app = Flask(__name__) app = Flask(__name__)
@@ -290,12 +329,8 @@ def wyszukaj(word_list, how_many, _logger=None, return_to_bot=True):
if search_weight[itr][0] == item_to_search: if search_weight[itr][0] == item_to_search:
return_list.append(search_weight[itr]) return_list.append(search_weight[itr])
if not return_to_bot: if not return_to_bot:
with open( with PRIORITY_PLAYLIST_PATH.open("a", encoding=ENCODING) as s_file:
"/home/pi/Conjurer/priority_queue.playlist", s_file.write(search_weight[itr][1] + "\n")
"r+",
encoding="utf-8",
) as s_file:
s_file.write(search_weight[itr][1])
break break
itr += 1 itr += 1
else: else:
@@ -336,6 +371,7 @@ def remove_characters(string, character):
@app.route('/get_share_list', methods=['POST']) @app.route('/get_share_list', methods=['POST'])
def get_share_list(): def get_share_list():
_authorize_request()
data = request.get_json() data = request.get_json()
entries = data.get('entries') entries = data.get('entries')
keywords = data.get('keywords') keywords = data.get('keywords')
@@ -352,6 +388,7 @@ def get_share_list():
@app.route('/get_share_links', methods=['POST']) @app.route('/get_share_links', methods=['POST'])
def get_share_links(): def get_share_links():
_authorize_request()
data = request.get_json() data = request.get_json()
file_paths = data.get('file_paths') file_paths = data.get('file_paths')
# Validate file_paths list # Validate file_paths list
@@ -374,7 +411,7 @@ def stream_music():
""" """
# return send_from_directory("/tmp/hls", "stream.m3u8") # return send_from_directory("/tmp/hls", "stream.m3u8")
return render_template("/home/pi/Conjurer/stream.html") return render_template(str(STREAM_TEMPLATE))
@app.route("/<string:file_name>") @app.route("/<string:file_name>")
@@ -406,15 +443,14 @@ def stream_music_mp3():
@app.route("/clear_pr_pls", methods=["GET"]) @app.route("/clear_pr_pls", methods=["GET"])
def clear_pr_pls(): def clear_pr_pls():
_authorize_request()
""" """
The function `clear_pr_pls` clears the contents of the priority queue playlist file. The function `clear_pr_pls` clears the contents of the priority queue playlist file.
:return: A JSON response indicating the success of the operation. :return: A JSON response indicating the success of the operation.
""" """
app.logger.info("CLEARING PLAYLIST") app.logger.info("CLEARING PLAYLIST")
with open( with PRIORITY_PLAYLIST_PATH.open("w", encoding=ENCODING) as cleared_pl:
"/home/pi/Conjurer/priority_queue.playlist", "w", encoding="utf-8"
) as cleared_pl:
cleared_pl.write("") cleared_pl.write("")
return_data = jsonify(isError=False, message="Success", statusCode=200, data=[]) return_data = jsonify(isError=False, message="Success", statusCode=200, data=[])
@@ -442,6 +478,7 @@ def update_music_list():
received and added to the `music_file_list`. received and added to the `music_file_list`.
The status code returned is 200, indicating a successful response. The status code returned is 200, indicating a successful response.
""" """
_authorize_request()
record = json.loads(request.data) record = json.loads(request.data)
app.logger.info(record["item"]) app.logger.info(record["item"])
music_file_list.append(record["item"]) music_file_list.append(record["item"])
@@ -463,6 +500,7 @@ def look_for_playlist():
data that was received and added to the `music_file_list`. The status code returned is 200, data that was received and added to the `music_file_list`. The status code returned is 200,
indicating a successful response. indicating a successful response.
""" """
_authorize_request()
record = json.loads(request.data) record = json.loads(request.data)
app.logger.info(record) app.logger.info(record)
app.logger.info(record["lista_slow"]) app.logger.info(record["lista_slow"])
@@ -481,13 +519,14 @@ def look_for_playlist():
@app.route("/request_radio_file", methods=["POST"]) @app.route("/request_radio_file", methods=["POST"])
def add_request(): def add_request():
_authorize_request()
record = json.loads(request.data) record = json.loads(request.data)
app.logger.info(record) app.logger.info(record)
app.logger.info(record["lista_slow"]) app.logger.info(record["lista_slow"])
app.logger.info(record["UUID"]) app.logger.info(record["UUID"])
return_data = wyszukaj(record["lista_slow"], 0, app.logger, False) return_data = wyszukaj(record["lista_slow"], 0, app.logger, False)
with open("/home/pi/Conjurer/request.playlist", "a", encoding="utf-8") as s_file: with REQUEST_PLAYLIST_PATH.open("a", encoding=ENCODING) as s_file:
for item in return_data: for item in return_data:
s_file.write(item[1] + "\n") s_file.write(item[1] + "\n")
return_data = ( return_data = (
@@ -512,6 +551,7 @@ def create_priority_playlist():
data that was received and added to the `music_file_list`. data that was received and added to the `music_file_list`.
The status code returned is 200,indicating a successful response. The status code returned is 200,indicating a successful response.
""" """
_authorize_request()
record = json.loads(request.data) record = json.loads(request.data)
app.logger.info(record) app.logger.info(record)
app.logger.info(record["lista_slow"]) app.logger.info(record["lista_slow"])
@@ -521,7 +561,7 @@ def create_priority_playlist():
record["lista_slow"], record["dlugosc_plejlisty"], app.logger, False record["lista_slow"], record["dlugosc_plejlisty"], app.logger, False
) )
random.shuffle(return_data) random.shuffle(return_data)
with open("/home/pi/Conjurer/request.playlist", "a", encoding="utf-8") as s_file: with REQUEST_PLAYLIST_PATH.open("a", encoding=ENCODING) as s_file:
for item in return_data: for item in return_data:
s_file.write(item[1] + "\n") s_file.write(item[1] + "\n")
return_data = ( return_data = (
@@ -546,6 +586,7 @@ def add_to_priority():
data that was received and added to the `music_file_list`. data that was received and added to the `music_file_list`.
The status code returned is 200,indicating a successful response. The status code returned is 200,indicating a successful response.
""" """
_authorize_request()
record = json.loads(request.data) record = json.loads(request.data)
app.logger.info(record) app.logger.info(record)
app.logger.info(record["lista_slow"]) app.logger.info(record["lista_slow"])
@@ -554,9 +595,7 @@ def add_to_priority():
return_data = wyszukaj( return_data = wyszukaj(
record["lista_slow"], record["dlugosc_plejlisty"], app.logger, False record["lista_slow"], record["dlugosc_plejlisty"], app.logger, False
) )
with open( with PRIORITY_PLAYLIST_PATH.open("a", encoding=ENCODING) as s_file:
"/home/pi/Conjurer/priority_queue.playlist", "a", encoding="utf-8"
) as s_file:
for item in return_data: for item in return_data:
s_file.write(item[1] + "\n") s_file.write(item[1] + "\n")
return_data = ( return_data = (
@@ -615,15 +654,19 @@ if __name__ == "__main__":
logger.info("Started") logger.info("Started")
threads = [] threads = []
# threads.append(threading.Thread(target=flask_debug)) # threads.append(threading.Thread(target=flask_debug))
threads.append(threading.Thread(target=waitress_run)) threads.append(threading.Thread(target=waitress_run, daemon=True))
threads.append(threading.Thread(target=thread_rescan)) threads.append(threading.Thread(target=thread_rescan, daemon=True))
for worker in threads: for worker in threads:
worker.start() worker.start()
time.sleep(60) time.sleep(60)
threads.append(threading.Thread(target=scan_tracks)) track_thread = threading.Thread(target=scan_tracks, daemon=True)
threads[2].start() track_thread.start()
for worker in threads: try:
worker.join() for worker in threads:
worker.join()
track_thread.join()
except KeyboardInterrupt:
logger.info("Shutdown requested - exiting musician service")
+15 -15
View File
@@ -45,7 +45,7 @@
], ],
[ [
"compress_release5", "compress_release5",
170.0 120.0
], ],
[ [
"compress_attack5", "compress_attack5",
@@ -69,7 +69,7 @@
], ],
[ [
"compress_release4", "compress_release4",
180.0 130.0
], ],
[ [
"compress_attack4", "compress_attack4",
@@ -81,7 +81,7 @@
], ],
[ [
"compress_gain3", "compress_gain3",
8.2 5.5
], ],
[ [
"compress_ratio3", "compress_ratio3",
@@ -93,7 +93,7 @@
], ],
[ [
"compress_release3", "compress_release3",
180.0 140.0
], ],
[ [
"compress_attack3", "compress_attack3",
@@ -105,7 +105,7 @@
], ],
[ [
"compress_gain2", "compress_gain2",
7.4 3.3
], ],
[ [
"compress_ratio2", "compress_ratio2",
@@ -117,7 +117,7 @@
], ],
[ [
"compress_release2", "compress_release2",
190.0 120.0
], ],
[ [
"compress_attack2", "compress_attack2",
@@ -129,7 +129,7 @@
], ],
[ [
"compress_gain1", "compress_gain1",
11.1 5.6
], ],
[ [
"compress_ratio1", "compress_ratio1",
@@ -137,11 +137,11 @@
], ],
[ [
"compress_threshold1", "compress_threshold1",
-12.7 -12.2
], ],
[ [
"compress_release1", "compress_release1",
170.0 120.0
], ],
[ [
"compress_attack1", "compress_attack1",
@@ -153,7 +153,7 @@
], ],
[ [
"compress_gain0", "compress_gain0",
16.0 7.5
], ],
[ [
"compress_ratio0", "compress_ratio0",
@@ -161,15 +161,15 @@
], ],
[ [
"compress_threshold0", "compress_threshold0",
-15.3 -13.1
], ],
[ [
"compress_release0", "compress_release0",
200.0 110.0
], ],
[ [
"compress_attack0", "compress_attack0",
90.0 140.0
], ],
[ [
"compress_frequency0", "compress_frequency0",
@@ -185,11 +185,11 @@
], ],
[ [
"g", "g",
9.5 1.0
], ],
[ [
"f", "f",
64.2 106.4
] ]
] ]
} }
+174 -102
View File
@@ -1,13 +1,36 @@
import json """Centralised configuration and runtime constants for Conjurer services.
import netrc
from datetime import datetime
from platform import uname
from sys import platform
from typing import List, Optional, TypedDict
import openai This module used to perform heavy filesystem and credential reads at import
import spotipy time which made the project brittle on hosts that did not mirror the original
from spotipy.oauth2 import SpotifyClientCredentials paths. The current implementation defers that work, reads configuration from
environment variables (with sensible fallbacks), and protects optional
dependencies so the main bot can start even when a secondary service is
offline.
"""
import json
import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypedDict
try:
import netrc
except ImportError: # pragma: no cover - standard on CPython
netrc = None
try:
import openai
except ImportError: # pragma: no cover - optional at runtime
openai = None
try:
import spotipy
from spotipy.oauth2 import SpotifyClientCredentials
except ImportError: # pragma: no cover - optional component
spotipy = None
SpotifyClientCredentials = None
Music_Config = TypedDict( Music_Config = TypedDict(
"Music_Config", "Music_Config",
@@ -24,21 +47,43 @@ MASTER_TIMEOUT = datetime.now()
INITIAL_TIME_WAIT = 500 INITIAL_TIME_WAIT = 500
MUZYKA: Music_Config = {"ctx": None, "queue": [], "requester": []} MUZYKA: Music_Config = {"ctx": None, "queue": [], "requester": []}
LOGFILE = "" logger = logging.getLogger("discord")
NETRC_FILE = ""
MUSIC_FOLDER = ""
MEMORY_FIVE_SIARA = "" def _env_path(var_name: str, fallback: Path) -> Path:
MEMORY_FIVE_MUZYKA = "" value = os.getenv(var_name)
SETTINGS_FILE = "" if value:
ENCODING = "" return Path(value).expanduser().resolve()
GRAPHICS_PATH = "" return fallback
def _env(var_name: str, fallback: str) -> str:
return os.getenv(var_name, fallback)
BASE_DIR = Path(os.getenv("CONJURER_BASE_DIR", Path(__file__).resolve().parent))
LOGFILE = _env_path("CONJURER_LOG_FILE", BASE_DIR / "discord.log")
NETRC_FILE = _env_path("CONJURER_NETRC_FILE", Path.home() / ".netrc")
SETTINGS_FILE = _env_path("CONJURER_SETTINGS_FILE", BASE_DIR / "settings.json")
MEMORY_FIVE_SIARA = _env_path("CONJURER_MEMORY_FILE", BASE_DIR / "pamiec.json")
MEMORY_FIVE_MUZYKA = _env_path(
"CONJURER_MUSIC_MEMORY_FILE", BASE_DIR / "pamiec_muzyki.json"
)
GRAPHICS_PATH = _env_path(
"CONJURER_GRAPHICS_PATH", BASE_DIR / "Conjurer_graphics"
)
MUSIC_FOLDER = _env_path("CONJURER_MUSIC_FOLDER", BASE_DIR / "music")
ENCODING = _env("CONJURER_ENCODING", "utf-8")
API_SHARED_KEY = os.getenv("CONJURER_API_KEY", "")
MUZYKA_MOJEGO_LUDU_HISTORIA = 1500 MUZYKA_MOJEGO_LUDU_HISTORIA = 1500
MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE = 15 MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE = 15
MUZYKA_MOJEGO_LUDU_PLAJLISTA = 30 MUZYKA_MOJEGO_LUDU_PLAJLISTA = 30
FILE_SERVICE_ADDRESS = "http://192.168.1.15:5000" FILE_SERVICE_ADDRESS = _env("CONJURER_FILE_SERVICE", "http://127.0.0.1:5000")
RADIO_HARBOR_ADDRESS = "http://192.168.1.15:54321" RADIO_HARBOR_ADDRESS = _env("CONJURER_RADIO_HARBOR", "http://127.0.0.1:54321")
SKIP_TRACK = "/skip" SKIP_TRACK = _env("CONJURER_SKIP_ENDPOINT", "/skip")
GET_MP3 = "/mp3" GET_MP3 = "/mp3"
SEND_MP3 = "/update_mp3" SEND_MP3 = "/update_mp3"
@@ -48,98 +93,123 @@ CREATE_PRIO_PLAYLIST = "/create_priority_playlist"
REQUEST_MUSIC = "/request_radio_file" REQUEST_MUSIC = "/request_radio_file"
CLEAR_PRIO = "/clear_pr_pls" CLEAR_PRIO = "/clear_pr_pls"
LIBRARIAN_SERVICE_ADDRESS = "http://192.168.1.192:5001" LIBRARIAN_SERVICE_ADDRESS = _env(
SEND_QUERY = "/query" "CONJURER_LIBRARIAN_SERVICE", "http://127.0.0.1:5001"
)
SEND_QUERY = _env("CONJURER_LIBRARIAN_QUERY_ENDPOINT", "/query")
TIME_BETWEEN_CALLS = 100000 TIME_BETWEEN_CALLS = 100000
LAST_SPONTANEOUS_CALL = datetime.now() LAST_SPONTANEOUS_CALL = datetime.now()
HOST_ADDRESS = "192.168.1.191" HOST_ADDRESS = _env("CONJURER_DISCORD_HOST", "0.0.0.0")
PORT_ADDRESS = 5000 PORT_ADDRESS = int(_env("CONJURER_DISCORD_PORT", "5000"))
# *=========================================== Platform Specific Predefines # *=========================================== Platform Specific Predefines
if platform in ("linux", "linux2"): SEPARATOR_FILE_PATH = _env("CONJURER_PATH_SEPARATOR", os.sep)
SEPARATOR_FILE_PATH = "/"
if "microsoft-standard" in uname().release:
LOGFILE = "/home/mtuszowski/conjurer/discord.log"
MEMORY_FIVE_SIARA = "/home/mtuszowski/conjurer/pamiec.json"
SYSTEM_GPT_SETTINGS = "/home/mtuszowski/conjurer/system_gpt_settings.json"
MEMORY_FIVE_MUZYKA = "/home/mtuszowski/conjurer/pamiec_muzyki.json"
MUSIC_FOLDER = "/mnt/g/Muzyka/"
SETTINGS_FILE = "/home/mtuszowski/conjurer/settings.json"
NETRC_FILE = "/home/mtuszowski/.netrc"
LOGSTORE = "/home/mtuszowski/conjurer/logs/"
ACCIDENT_LOG = "/home/mtuszowski/conjurer/accident_log.json"
ENCODING = "utf-8"
GRAPHICS_PATH = "/home/mtuszowski/conjurer/Conjurer_graphics/"
DIR_PATH_SADOX = "/mnt/c/Users/mtusz/OneDrive/Dokumenty/Fansadox"
else: DIR_PATH_SADOX = _env_path(
LOGFILE = "/home/pi/Conjurer/discord.log" "CONJURER_SADOX_DIR", BASE_DIR / "Fansadox"
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 = "/home/pi/MediaShare/Conjurer_graphics/"
DIR_PATH_SADOX = "/home/pi/MediaShare/Fansadox/"
elif platform == "win32":
LOGFILE = "discord.log"
MEMORY_FIVE_SIARA = "pamiec.json"
SYSTEM_GPT_SETTINGS = "system_gpt_settings.json"
MEMORY_FIVE_MUZYKA = "pamiec_muzyki.json"
MUSIC_FOLDER = "G:\\Muzyka\\"
SETTINGS_FILE = "settings.json"
NETRC_FILE = "C:\\Users\\mtusz\\.netrc"
LOGSTORE = "C:\\Users\\mtusz\\OneDrive\\Pulpit\\Conjurer\\logs\\"
ACCIDENT_LOG = "accident_log.json"
ENCODING = "utf-8"
DIR_PATH_SADOX = "C:\\Users\\mtusz\\OneDrive\\Dokumenty\\Fansadox\\"
SEPARATOR_FILE_PATH = "\\"
with open(SETTINGS_FILE, "r", encoding=ENCODING) as f_settings_file:
DATA = json.load(f_settings_file)
REMOTE_HOST_NAME = "openai"
netrc_mod = netrc.netrc(NETRC_FILE)
authTokens = netrc_mod.authenticators(REMOTE_HOST_NAME)
openai.api_key = authTokens[2]
OPENAICLIENT = openai.AsyncOpenAI(api_key=openai.api_key)
REMOTE_HOST_NAME = "discord"
authTokens = netrc_mod.authenticators(REMOTE_HOST_NAME)
TOKEN = authTokens[2]
REMOTE_HOST_NAME = "spotipy"
authTokens = netrc_mod.authenticators(REMOTE_HOST_NAME)
SPOTIFY_CTRL = spotipy.Spotify(
client_credentials_manager=SpotifyClientCredentials(
client_id=authTokens[0],
client_secret=authTokens[2],
)
) )
REMOTE_HOST_NAME = "youtube"
authTokens = netrc_mod.authenticators(REMOTE_HOST_NAME)
YOUTUBE_AUTH = [authTokens[0],authTokens[2]]
WORD_REACTIONS = DATA["word_reactions"] SYSTEM_GPT_SETTINGS = _env_path(
CYCLIC_WORDS = DATA["cyclic_words"] "CONJURER_SYSTEM_GPT_SETTINGS", BASE_DIR / "system_gpt_settings.json"
)
LOGSTORE = _env_path("CONJURER_LOGSTORE", BASE_DIR / "logs")
ACCIDENT_LOG = _env_path(
"CONJURER_ACCIDENT_LOG", BASE_DIR / "accident_log.json"
)
def _load_json(path: Path, fallback) -> object:
try:
with path.open("r", encoding=ENCODING) as handle:
return json.load(handle)
except FileNotFoundError:
logger.warning("Missing JSON file at %s using fallback", path)
return fallback
except json.JSONDecodeError:
logger.warning("Corrupt JSON at %s resetting to fallback", path)
return fallback
DATA = _load_json(SETTINGS_FILE, {})
WORD_REACTIONS = DATA.get("word_reactions", {})
CYCLIC_WORDS = DATA.get("cyclic_words", {})
for key in WORD_REACTIONS: for key in WORD_REACTIONS:
WORD_REACTIONS[key][2] = datetime.now() if isinstance(WORD_REACTIONS[key], list) and len(WORD_REACTIONS[key]) >= 3:
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as temp_memory_file: WORD_REACTIONS[key][2] = datetime.now()
# First we load existing data into a dict.
MESSAGE_TABLE = json.load(temp_memory_file)
with open(SYSTEM_GPT_SETTINGS, "r+", encoding=ENCODING) as temp_settings_file: MESSAGE_TABLE = _load_json(MEMORY_FIVE_SIARA, {})
# First we load existing data into a dict. GPT_SETTINGS = _load_json(SYSTEM_GPT_SETTINGS, {})
GPT_SETTINGS = json.load(temp_settings_file) MESSAGE_TABLE_MUZYKA = _load_json(MEMORY_FIVE_MUZYKA, {})
with open(MEMORY_FIVE_MUZYKA, "r+", encoding=ENCODING) as temp_music_memory_file:
# First we load existing data into a dict. SPECJALNE_ZIEMNIACZKI = GPT_SETTINGS[1] if isinstance(GPT_SETTINGS, list) else {}
MESSAGE_TABLE_MUZYKA = json.load(temp_music_memory_file) ASSISTANTS: Dict[str, Tuple[str, str, int, object]] = {}
SPECJALNE_ZIEMNIACZKI = GPT_SETTINGS[1]
ASSISTANTS = {}
def _load_netrc_credentials(host: str) -> Optional[Tuple[str, str, str]]:
if netrc is None:
return None
try:
parsed = netrc.netrc(str(NETRC_FILE))
except FileNotFoundError:
logger.warning("netrc file %s not found", NETRC_FILE)
return None
except netrc.NetrcParseError:
logger.warning("netrc file %s is invalid", NETRC_FILE)
return None
return parsed.authenticators(host)
def _resolve_token(host: str, env_var: str) -> Optional[str]:
env_value = os.getenv(env_var)
if env_value:
return env_value
creds = _load_netrc_credentials(host)
if creds:
return creds[2]
logger.warning("Token for %s not configured", host)
return None
OPENAI_API_KEY = _resolve_token("openai", "OPENAI_API_KEY")
if openai and OPENAI_API_KEY:
openai.api_key = OPENAI_API_KEY
OPENAICLIENT = openai.AsyncOpenAI(api_key=OPENAI_API_KEY)
else:
OPENAICLIENT = None
TOKEN = _resolve_token("discord", "DISCORD_TOKEN")
if spotipy:
spotify_creds = _load_netrc_credentials("spotipy")
if spotify_creds and SpotifyClientCredentials:
SPOTIFY_CTRL = spotipy.Spotify(
client_credentials_manager=SpotifyClientCredentials(
client_id=spotify_creds[0],
client_secret=spotify_creds[2],
)
)
else:
SPOTIFY_CTRL = None
else:
SPOTIFY_CTRL = None
youtube_creds = _load_netrc_credentials("youtube")
if youtube_creds:
YOUTUBE_AUTH = [youtube_creds[0], youtube_creds[2]]
else:
YOUTUBE_AUTH = [
os.getenv("YOUTUBE_USERNAME", ""),
os.getenv("YOUTUBE_PASSWORD", ""),
]
def service_headers() -> Dict[str, str]:
"""Shared header dict for internal HTTP calls."""
if API_SHARED_KEY:
return {"X-Conjurer-Api-Key": API_SHARED_KEY}
return {}
LATEX_TEX_ENGINE = "tectonic" LATEX_TEX_ENGINE = "tectonic"
@@ -151,3 +221,5 @@ OPENAI_MODEL = "gpt-4o-mini"
ALLOWED_ROLES = ["Nocna Zmiana", "Jarl", "Thane", "Bartender"] ALLOWED_ROLES = ["Nocna Zmiana", "Jarl", "Thane", "Bartender"]
GUILD_ID = 664789470779932693 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)
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
exists() { command -v "$1" >/dev/null 2>&1; }
echo "[i] Checking environment..."
for c in fc-list pdffonts tectonic; do
if ! exists "$c"; then
echo " - $c: NOT FOUND (optional but recommended: sudo apt install -y fontconfig poppler-utils tectonic)"
else
echo " - $c: OK"
fi
done
echo "[i] Listing local ./fonts content:"
ls -l ./fonts 2>/dev/null || echo " (no ./fonts directory)"
if exists fc-list; then
echo "[i] System fonts (grep Garamond|Cinzel|FELL):"
fc-list | grep -Ei "Garamond|Cinzel|Fell" || echo " (none found in system)"
fi
pdfs=(dj_cheat_sheet_transitions.pdf dj_tracklist.pdf dj_mini_sheet.pdf dj_notes.pdf dj_setup_mapping.pdf dj_one_laptop_fallback.pdf dj_rider.pdf dj_podrecznik.pdf)
if exists pdffonts; then
for p in "${pdfs[@]}"; do
[ -f "$p" ] || continue
echo "[i] Fonts embedded in $p:"
pdffonts "$p" || true
done
else
echo "[!] pdffonts not installed; cannot list embedded fonts."
fi
echo "[i] Grepping last Tectonic log (if any .log files exist)..."
logs=$(ls -1 *.log 2>/dev/null || true)
if [ -n "$logs" ]; then
grep -Ei "fontspec|warning|not found" *.log || echo " (no relevant warnings)"
else
echo " (no .log files)"
fi
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
# System-wide install & verify (Debian/RPi):
# - EB Garamond (APT)
# - Cinzel Decorative Black (RAW TTF)
# - IM Fell English SC (gstatic/RAW)
# No TeX build here. Very verbose + clear final report.
set -Eeuo pipefail
SUDO=sudo; [ "$(id -u)" -eq 0 ] && SUDO=
log(){ echo -e "[+] $*"; }
warn(){ echo -e "[WARN] $*" >&2; }
die(){ echo -e "[FATAL] $*" >&2; exit 1; }
need(){ command -v "$1" >/dev/null 2>&1 || die "Missing tool: $1"; }
fetch(){
local url="$1" out="$2"
if command -v wget >/dev/null 2>&1; then
log "wget -> $url"
wget -O "$out" --https-only --no-verbose "$url"
else
log "curl -> $url"
curl -L --fail --show-error --output "$out" "$url"
fi
}
# ---- detection using fc-list families (robust, no PCRE) ----
fc_families_lower() {
fc-list -f '%{family}\n' \
| tr ',' '\n' \
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \
| tr '[:upper:]' '[:lower:]' \
| sort -u
}
has_family_re() { # $1 = POSIX ERE anchored-at-start pattern (lowercase)
fc_families_lower | grep -Eiq "$1"
}
# ---- prereqs ----
for t in fc-cache fc-list grep sed awk; do need "$t"; done
command- v wget >/dev/null 2>&1 || command -v curl >/dev/null 2>&1 || die "Need wget or curl"
SYSTEM_DIR="/usr/local/share/fonts/dj"
DIR_CINZEL="$SYSTEM_DIR/cinzel-decorative"
DIR_IMFELL="$SYSTEM_DIR/im-fell-english-sc"
$SUDO mkdir -p "$DIR_CINZEL" "$DIR_IMFELL"
echo "=== APT phase (EB Garamond) ==="
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -y || warn "apt update failed (continuing)"
if $SUDO apt-get install -y --no-install-recommends fonts-ebgaramond; then
log "installed: fonts-ebgaramond"
else
warn "apt install failed: fonts-ebgaramond"
fi
else
warn "apt-get not found — skipping"
fi
echo
log "Families BEFORE web fallback (sample grep):"
fc-list | grep -Ei "Garamond|Cinzel|Fell" || echo " (none)"
echo
echo "=== Web fallback (system-wide to $SYSTEM_DIR) ==="
# --- Cinzel Decorative Black ---
CINZEL_TTF="$DIR_CINZEL/CinzelDecorative-Black.ttf"
if ! has_family_re '^cinzel decorative( |$)'; then
fetch "https://github.com/google/fonts/raw/main/ofl/cinzeldecorative/CinzelDecorative-Black.ttf" "$CINZEL_TTF" || warn "Cinzel Decorative Black download failed"
[ -s "$CINZEL_TTF" ] && $SUDO chmod 0644 "$CINZEL_TTF"
else
log "Cinzel Decorative already present."
fi
# --- IM Fell English SC ---
IMFELL_TTF="$DIR_IMFELL/IMFeENsc.ttf"
if ! has_family_re '^im fell english sc( |$)'; then
# Try known gstatic TTFs (stable direct links)
for u in \
"https://fonts.gstatic.com/s/imfellenglishsc/v7/h3Tn6yWfw4b5qaLD1RWvz5ATixNthKRRR1XVH3rJNiw.ttf" \
"https://fonts.gstatic.com/s/imfellenglishsc/v6/h3Tn6yWfw4b5qaLD1RWvz5ATixNthKRRR1XVH3rJNiw.ttf"
do
if fetch "$u" "$IMFELL_TTF"; then
[ -s "$IMFELL_TTF" ] && { $SUDO chmod 0644 "$IMFELL_TTF"; log "IM Fell SC from gstatic OK"; break; }
fi
done
# Secondary RAW in google/fonts (names changed over time)
if [ ! -s "$IMFELL_TTF" ]; then
for u in \
"https://github.com/google/fonts/raw/main/ofl/imfellenglishsc/IMFeENsc28P.ttf" \
"https://github.com/google/fonts/raw/main/ofl/imfellenglishsc/IMFellEnglishSC-Regular.ttf"
do
if fetch "$u" "$IMFELL_TTF"; then
[ -s "$IMFELL_TTF" ] && { $SUDO chmod 0644 "$IMFELL_TTF"; log "IM Fell SC from google/fonts RAW OK"; break; }
fi
done
fi
# Minimal mirror fallback (only if naprawdę trzeba)
if [ ! -s "$IMFELL_TTF" ]; then
for u in \
"https://www.wfonts.com/download/data/2016/06/14/im-fell-english-sc/IMFeENsc28P.ttf" \
"https://www.1001freefonts.com/d/6800/IMFeENsc28P.ttf"
do
fetch "$u" "$IMFELL_TTF" || true
[ -s "$IMFELL_TTF" ] && { $SUDO chmod 0644 "$IMFELL_TTF"; log "IM Fell SC from mirror OK"; break; }
done
fi
[ -s "$IMFELL_TTF" ] || warn "IM Fell English SC still missing."
else
log "IM Fell English SC already present."
fi
echo
log "Fix perms & rebuild cache..."
$SUDO find "$SYSTEM_DIR" -type f -size 0 -print -delete || true
$SUDO find "$SYSTEM_DIR" -type f \( -name "*.ttf" -o -name "*.otf" \) -exec chmod 0644 {} \; || true
$SUDO find "$SYSTEM_DIR" -type d -exec chmod 0755 {} \; || true
$SUDO fc-cache -f -v >/dev/null || warn "fc-cache returned non-zero"
# ---- FINAL REPORT ----
echo
log "FINAL STATUS (fc-list families & file presence)"
families="$(fc_families_lower)"
report(){
local label="$1" fam_re="$2" file_hint="$3"
local fam_ok file_ok="n/a"
if echo "$families" | grep -Eiq "$fam_re"; then fam_ok="OK"; else fam_ok="MISSING"; fi
if [ -n "$file_hint" ]; then
if [ -s "$file_hint" ]; then file_ok="yes"; else file_ok="no"; fi
fi
printf " %-22s : families=%-8s file=%-3s (%s)\n" "$label" "$fam_ok" "$file_ok" "${file_hint:-no-file-hint}"
}
report "EB Garamond" '^eb garamond( |$)' "" # from APT
report "Cinzel Decorative" '^cinzel decorative( |$)' "$CINZEL_TTF"
report "IM Fell English SC" '^im fell english sc( |$)' "$IMFELL_TTF"
echo
log "Done. (Run your LaTeX pipeline next.)"
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
need() { command -v "$1" >/dev/null 2>&1 || { echo "Missing: $1" >&2; exit 1; }; }
echo "[+] Checking tools..."
need wget
need unzip
need tectonic || { echo "Install tectonic first (e.g. sudo apt install tectonic)"; exit 1; }
mkdir -p fonts
echo "[+] Downloading EB Garamond (Initials) to ./fonts ..."
tmpzip="/tmp/ebgaramond.zip"
wget -q -O "$tmpzip" https://github.com/octaviopardo/EBGaramond/releases/download/v0.016/EBGaramond-ttf.zip
unzip -jq "$tmpzip" "*Initials*.ttf" -d fonts
ls -1 fonts | grep -i initials || echo "WARN: EBGaramond Initials not found in zip?"
echo "[+] Downloading Cinzel Decorative (Black) to ./fonts ..."
tmpzip="/tmp/cinzel.zip"
wget -q -O "$tmpzip" "https://fonts.google.com/download?family=Cinzel%20Decorative" || true
unzip -jq "$tmpzip" "*Decorative-Black*.ttf" -d fonts || echo "WARN: Cinzel Decorative Black not found; continuing."
echo "[+] (Optional) Downloading IM FELL English SC to ./fonts ..."
tmpzip="/tmp/imfell.zip"
wget -q -O "$tmpzip" "https://fonts.google.com/download?family=IM%20Fell%20English%20SC" || true
unzip -jq "$tmpzip" "*.ttf" -d fonts || echo "INFO: IM FELL SC optional; skip if not needed."
echo "[+] Building PDFs with Tectonic..."
for f in dj_cheat_sheet_transitions.tex dj_tracklist.tex dj_mini_sheet.tex dj_notes.tex dj_setup_mapping.tex dj_one_laptop_fallback.tex dj_rider.tex dj_podrecznik.tex; do
if [ -f "$f" ]; then
echo " -> $f"
tectonic "$f" >/dev/null
else
echo "SKIP: $f not found"
fi
done
echo "[+] Done. Generated PDFs:"
ls -1 *.pdf 2>/dev/null || true
+45
View File
@@ -0,0 +1,45 @@
version: "3.9"
services:
bot:
build:
context: .
dockerfile: docker/Dockerfile.bot
container_name: conjurer-bot
env_file:
- docker/env/bot.env
volumes:
- ./docker/volumes/bot/config:/data/config
- ./docker/volumes/bot/logs:/data/logs
depends_on:
- musician
- librarian
restart: unless-stopped
ports:
- "5000:5000"
musician:
build:
context: .
dockerfile: docker/Dockerfile.musician
container_name: conjurer-musician
env_file:
- docker/env/musician.env
volumes:
- ./docker/volumes/musician/data:/data
restart: unless-stopped
ports:
- "5001:5000"
librarian:
build:
context: .
dockerfile: docker/Dockerfile.librarian
container_name: conjurer-librarian
env_file:
- docker/env/librarian.env
volumes:
- ./docker/volumes/librarian/data:/data
restart: unless-stopped
ports:
- "5002:5001"
+23
View File
@@ -0,0 +1,23 @@
FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements_bot.txt /tmp/requirements.txt
RUN pip install -r /tmp/requirements.txt
COPY . /app
RUN useradd --create-home appuser \
&& chown -R appuser:appuser /app
USER appuser
CMD ["python", "thin_client.py"]
+24
View File
@@ -0,0 +1,24 @@
FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
poppler-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY conjurer_librarian/requirements_librarian.txt /tmp/requirements.txt
RUN pip install -r /tmp/requirements.txt
COPY . /app
RUN useradd --create-home appuser \
&& chown -R appuser:appuser /app
USER appuser
CMD ["python", "-m", "conjurer_librarian.conjurer_librarian"]
+23
View File
@@ -0,0 +1,23 @@
FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements_bot.txt /tmp/requirements.txt
RUN pip install -r /tmp/requirements.txt
COPY . /app
RUN useradd --create-home appuser \
&& chown -R appuser:appuser /app
USER appuser
CMD ["python", "-m", "conjurer_musician.conjurer_musician"]
+24
View File
@@ -0,0 +1,24 @@
# Discord bot service configuration
DISCORD_TOKEN=HACKME!
OPENAI_API_KEY=HACKME!
CONJURER_API_KEY=HACKME!
# Internal service endpoints
CONJURER_FILE_SERVICE=http://conjurer-musician:5000
CONJURER_LIBRARIAN_SERVICE=http://conjurer-librarian:5001
# Runtime paths mounted via docker-compose volumes
CONJURER_BASE_DIR=/data/config
CONJURER_SETTINGS_FILE=/data/config/settings.json
CONJURER_MEMORY_FILE=/data/config/pamiec.json
CONJURER_MUSIC_MEMORY_FILE=/data/config/pamiec_muzyki.json
CONJURER_SYSTEM_GPT_SETTINGS=/data/config/system_gpt_settings.json
CONJURER_GRAPHICS_PATH=/data/assets/graphics
CONJURER_LOG_FILE=/data/logs/discord.log
CONJURER_LOGSTORE=/data/logs
# Optional external integrations
CONJURER_NETRC_FILE=/data/secrets/.netrc
YOUTUBE_USERNAME=HACKME!
YOUTUBE_PASSWORD=HACKME!
+14
View File
@@ -0,0 +1,14 @@
# Librarian service configuration
CONJURER_API_KEY=HACKME!
CONJURER_MAIN_BOT=http://conjurer-bot:5000
CONJURER_LIBRARIAN_HOST=0.0.0.0
CONJURER_LIBRARIAN_PORT=5001
CONJURER_LIBRARIAN_MAX_RESULTS=500
CONJURER_CROSSREF_MAILTO=HACKME!
CONJURER_LIBRARIAN_LOG=/data/logs/librarian.log
CONJURER_NETRC_FILE=/data/secrets/.netrc
+20
View File
@@ -0,0 +1,20 @@
# Musician service configuration
CONJURER_API_KEY=HACKME!
CONJURER_MAIN_BOT=http://conjurer-bot:5000
CONJURER_MUSIC_TRACKER_ENDPOINT=/prepped_tracks
CONJURER_MUSICIAN_HOST=0.0.0.0
CONJURER_MUSICIAN_PORT=5000
CONJURER_MUSIC_FOLDER=/data/music
CONJURER_PRIORITY_FOLDER=/data/priority
CONJURER_ALL_PLAYLIST=/data/playlists/all_playlist.playlist
CONJURER_HIT_PLAYLIST=/data/playlists/hit.playlist
CONJURER_REQUEST_PLAYLIST=/data/playlists/request.playlist
CONJURER_PRIORITY_PLAYLIST=/data/playlists/priority_queue.playlist
CONJURER_STREAM_TEMPLATE=/data/templates/stream.html
CONJURER_RADIO_LOG=/data/logs/radio.log
CONJURER_PERSISTENCE_LOG=/data/logs/persistence.log
+163
View File
@@ -0,0 +1,163 @@
# Conjurer Deployment Guide
This document walks through deploying the Conjurer stack (Discord bot, music
service, librarian service) on two Raspberry Pi 4s and one Windows host, first
with plain Docker Compose, then with a future Kubernetes setup.
## 1. Current Hardware Layout
- **Windows PC**: Stores persistent data (JSON memories, configs, music
catalogue). Shares folders over the network (SMB) for the Pis.
- **Raspberry Pi A** (“radio”): Runs the Liquidsoap/liq radio pipeline and the
musician service (Flask + file watcher).
- **Raspberry Pi B** (“bot”): Runs the Discord bot, communication bridge, and
librarian Flask service.
You can rebalance as follows:
| Service | Recommended Host | Notes |
|---------------------|------------------|-------|
| Discord bot + comms | Raspberry Pi B | Needs outbound internet, moderate CPU |
| Librarian service | Raspberry Pi B | CPU-heavy during Crossref queries; keep close to bot |
| Musician service | Raspberry Pi A | Has direct disk access to music, same box as Liquidsoap |
| Data storage | Windows | Expose via SMB; mount inside containers |
## 2. Prepare Shared Storage on Windows
1. Create directories, e.g. `C:\Conjurer\config`, `C:\Conjurer\logs`,
`C:\Conjurer\music`, `C:\Conjurer\playlists`, `C:\Conjurer\secrets`.
2. Copy your existing JSON settings (`settings.json`, `pamiec.json`,
`pamiec_muzyki.json`, `system_gpt_settings.json`, etc.) into `config`.
3. Create blank placeholder files if they do not exist yet.
4. Share the root folder (`C:\Conjurer`) over SMB with read/write access for the
Pi user (create credentials if necessary).
## 3. Configure Environment Files
1. On your workstation, copy the example env files:
```bash
cp docker/env/bot.env.example docker/env/bot.env
cp docker/env/musician.env.example docker/env/musician.env
cp docker/env/librarian.env.example docker/env/librarian.env
```
2. Edit each `docker/env/*.env` to replace `HACKME!` with real values:
- `DISCORD_TOKEN`, `OPENAI_API_KEY`, `CONJURER_API_KEY` (use the same value for
all services).
- 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.
## 4. Install Docker on Raspberry Pis and Windows
### Raspberry Pi
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
sudo reboot
# Install docker compose plugin
sudo apt-get install docker-compose-plugin
```
### Windows
- Install **Docker Desktop**.
- Enable WSL2 backend and expose the shared Windows folders to the containers
(Docker Desktop settings → Resources → File Sharing).
## 5. Deploy Musician Service (Pi A)
1. SSH into Raspberry Pi A.
2. Mount the Windows SMB share:
```bash
sudo mkdir -p /mnt/conjurer
sudo apt-get install cifs-utils
sudo mount -t cifs //WINDOWS_HOST/Conjurer /mnt/conjurer -o user=YOURUSER
```
Add an entry to `/etc/fstab` for persistence.
3. Copy the repo to the Pi or `git clone` it.
4. On Pi A, create override compose file (optional) pointing volumes to
`/mnt/conjurer`.
5. Start only the musician service:
```bash
docker compose --profile musician up --build -d musician
```
Alternatively, duplicate `docker-compose.yml`, strip other services, and run
`docker compose up -d`.
## 6. Deploy Bot + Librarian (Pi B)
1. Repeat SMB mount on Pi B (same mount path).
2. Copy repo / pull latest changes.
3. Create `.env` files with tokens (or copy from control machine).
4. Start bot and librarian:
```bash
docker compose up -d bot librarian
```
## 7. Optional: Run Supporting Liquidsoap Radio
- Keep Liquidsoap on Pi A as-is, using the same music directories. Ensure the
musician container has read access to those directories (bind mount).
## 8. Verifying
1. `docker ps` on each Pi to confirm containers running.
2. Inspect logs under the mounted logs directory (`/mnt/conjurer/logs`).
3. Join Discord server; issue commands to confirm functionality.
4. Hit health endpoints manually (e.g. `curl http://PIB:5000/conjurer`).
## Rebalancing Suggestions
- If librarian CPU spikes become an issue, move it to Pi A or another host.
- If you add a dedicated NAS, mount the network share read-only for the musician
container and read/write for other services.
## 9. Future Kubernetes Deployment (Outline)
### Hardware Considerations
- Minimum three nodes for HA: use the existing two Pis plus one additional Pi 4
(8 GB preferred). Use Windows PC as storage provider via NFS/SMB CSI driver or
as a data gateway.
- Consider Pi clusters with USB SSDs for better I/O.
### Cluster Setup Steps
1. Install a lightweight Kubernetes distribution (e.g., k3s) on each Pi:
```bash
curl -sfL https://get.k3s.io | sh -
# On additional nodes
curl -sfL https://get.k3s.io | K3S_URL=https://MASTER:6443 K3S_TOKEN=HACKME sh -
```
2. Install MetalLB for load balancer support on LAN.
3. Configure persistent volumes using:
- `nfs-subdir-external-provisioner` pointing to Windows share (ensure Windows
host supports NFS or run an NFS gateway on another machine).
- Alternatively, attach individual USB drives to each Pi and use
`local-path-provisioner` for node-local storage.
4. Create Kubernetes `Secret` objects for tokens (`DISCORD_TOKEN`, etc.).
5. Define `Deployment` manifests for each service (bot, musician, librarian) and
associated `Services`.
6. Expose Discord bot ports via `NodePort` or Ingress.
7. Use `StatefulSet` if you need stable identity for the musician service (due to
local storage).
### Optimisation Tips
- Keep CPU-heavy librarian pods optionally on a beefier node; use
`nodeSelector`/`affinity` to pin workloads.
- Consider splitting the persistent storage: music on Pi A (USB disk), logs and
configs on Pi B, backups on Windows.
- For improved reliability, add at least one extra Pi for quorum and to host the
communication bridge if the bot node fails.
## Summary Checklist
1. Prepare Windows shares & tokens.
2. Configure `docker/env/*.env` using `HACKME!` templates as reference.
3. Install Docker on Pis, mount network shares.
4. Launch musician on Pi A, bot + librarian on Pi B.
5. Verify Discord functionality and API endpoints.
6. Plan Kubernetes migration when ready (k3s + MetalLB + storage provisioner).
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# "apt dla fontów": Google Fonts ZIP → GitHub (ofl RAW) → fonts.gstatic.com → mirrory.
# System-wide install do /usr/local/share/fonts/fontget/<rodzina> + fc-cache.
# Usage: sudo ./fontget.sh "IM Fell English SC" "Cinzel Decorative" "EB Garamond"
set -Eeuo pipefail
SUDO=sudo; [ "$(id -u)" -eq 0 ] && SUDO=
log(){ echo -e "[+] $*"; }
warn(){ echo -e "[WARN] $*" >&2; }
die(){ echo -e "[FATAL] $*" >&2; exit 1; }
need(){ command -v "$1" >/dev/null 2>&1 || die "Missing: $1"; }
need fc-cache
command -v wget >/dev/null 2>&1 || command -v curl >/dev/null 2>&1 || die "Need wget/curl"
command -v unzip >/dev/null 2>&1 || warn "unzip missing — ZIP installs limited"
command -v file >/dev/null 2>&1 || warn "file missing — MIME checks limited"
command -v fc-list >/dev/null 2>&1 || die "Missing: fc-list"
fetch(){ local u="$1" o="$2"; if command -v wget >/dev/null 2>&1; then wget -O "$o" --https-only --no-verbose "$u"; else curl -L --fail --show-error --output "$o" "$u"; fi; }
slug(){ echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g'; }
present(){ fc-list -f '%{family}\n' | tr ',' '\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]' | sort -u | grep -Eiq "$1"; }
install_dir="/usr/local/share/fonts/fontget"; $SUDO mkdir -p "$install_dir"
is_zip(){ command -v file >/dev/null 2>&1 && file "$1" | grep -qi zip; }
gf_zip_try(){
local name="$1" outdir="$2" enc zip
enc="$(python3 - <<PY
import urllib.parse,sys
print(urllib.parse.quote(sys.argv[1]))
PY
"$name")" || enc="$(echo "$name" | sed 's/ /%20/g')"
zip="$(mktemp /tmp/fontget.XXXXXX.zip)"
if fetch "https://fonts.google.com/download?family=${enc}" "$zip"; then
if is_zip "$zip"; then $SUDO mkdir -p "$outdir"; $SUDO unzip -o -q "$zip" -d "$outdir" || true; rm -f "$zip"; return 0; fi
fi
rm -f "$zip" || true; return 1
}
gh_ofl_try(){
local family="$1" outdir="$2" html
html="$(mktemp /tmp/ofl.$(slug "$family").html)"
if fetch "https://github.com/google/fonts/tree/main/ofl/$(slug "$family")" "$html"; then
mapfile -t paths < <(grep -Eo '/google/fonts/blob/main/ofl/'"$(slug "$family")"'/[^"]+\.(ttf|otf)' "$html" | sed 's|/blob/|/raw/|g' | sort -u)
[ "${#paths[@]}" -gt 0 ] || { rm -f "$html"; return 1; }
$SUDO mkdir -p "$outdir"
for p in "${paths[@]}"; do fetch "https://github.com$p" "$outdir/$(basename "$p")" || true; done
rm -f "$html"; return 0
fi
rm -f "$html"; return 1
}
gstatic_try(){ local out="$2"; shift 2; for u in "$@"; do fetch "$u" "$out" && [ -s "$out" ] && return 0; done; return 1; }
report(){ local label="$1" re="$2"; if present "$re"; then printf "RESULT: %-24s : OK\n" "$label"; else printf "RESULT: %-24s : FAIL\n" "$label"; fi }
if [ $# -lt 1 ]; then echo "Usage: $0 <Font Family> [Another ...]"; exit 1; fi
for family in "$@"; do
echo; log "=== $family ==="
fam_slug="$(slug "$family")"
outdir="$install_dir/$fam_slug"
$SUDO mkdir -p "$outdir"
case "$fam_slug" in
cinzel-decorative|cinzel|cinzel-decorative-black)
fetch "https://github.com/google/fonts/raw/main/ofl/cinzeldecorative/CinzelDecorative-Black.ttf" "$outdir/CinzelDecorative-Black.ttf" || warn "Cinzel fetch failed"
;;
im-fell-english-sc)
gstatic_try "$fam_slug" "$outdir/IMFeENsc.ttf" \
"https://fonts.gstatic.com/s/imfellenglishsc/v7/h3Tn6yWfw4b5qaLD1RWvz5ATixNthKRRR1XVH3rJNiw.ttf" \
"https://fonts.gstatic.com/s/imfellenglishsc/v6/h3Tn6yWfw4b5qaLD1RWvz5ATixNthKRRR1XVH3rJNiw.ttf" \
|| { fetch "https://github.com/google/fonts/raw/main/ofl/imfellenglishsc/IMFeENsc28P.ttf" "$outdir/IMFeENsc.ttf" || true
[ -s "$outdir/IMFeENsc.ttf" ] || fetch "https://github.com/google/fonts/raw/main/ofl/imfellenglishsc/IMFellEnglishSC-Regular.ttf" "$outdir/IMFeENsc.ttf" || true
[ -s "$outdir/IMFeENsc.ttf" ] || fetch "https://www.wfonts.com/download/data/2016/06/14/im-fell-english-sc/IMFeENsc28P.ttf" "$outdir/IMFeENsc.ttf" || true
[ -s "$outdir/IMFeENsc.ttf" ] || fetch "https://www.1001freefonts.com/d/6800/IMFeENsc28P.ttf" "$outdir/IMFeENsc.ttf" || true; }
;;
eb-garamond|ebgaramond)
# Najpierw apt (jeśli chcesz: sudo apt install fonts-ebgaramond), tu tylko web:
gf_zip_try "$family" "$outdir" || gh_ofl_try "$family" "$outdir" || true
;;
*)
gf_zip_try "$family" "$outdir" || gh_ofl_try "$family" "$outdir" || true
;;
esac
$SUDO find "$outdir" -type f -size 0 -print -delete || true
$SUDO find "$outdir" -type f \( -name "*.ttf" -o -name "*.otf" \) -exec chmod 0644 {} \; || true
$SUDO find "$outdir" -type d -exec chmod 0755 {} \; || true
$SUDO fc-cache -f -v >/dev/null || true
case "$fam_slug" in
cinzel* ) report "Cinzel Decorative" '^cinzel decorative( |$)' ;;
im-fell-english-sc ) report "IM Fell English SC" '^im fell english sc( |$)' ;;
eb-garamond* ) report "EB Garamond" '^eb garamond( |$)' ;;
* ) report "$family" "$(echo "$family" | tr '[:upper:]' '[:lower:]' | sed 's/ /.* /g')" ;;
esac
done
echo; log "All done."
Binary file not shown.
View File
View File
+14
View File
@@ -0,0 +1,14 @@
from flask import Flask
from file_serv import bp as uploader_bp
from ingest import bp as ingest_bp
from waitress import serve
app = Flask(__name__)
HOST_ADDRESS = "127.0.0.1"
PORT_ADDRESS = 49151
if __name__ == "__main__":
app.register_blueprint(uploader_bp, url_prefix="/api")
app.register_blueprint(ingest_bp, url_prefix="/api")
serve(app, host=HOST_ADDRESS, port=PORT_ADDRESS)
+155
View File
@@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import os
from pathlib import Path
from typing import List, Dict, Any, Optional
from flask import Blueprint, request, jsonify, send_file, abort, current_app
from werkzeug.utils import secure_filename
# ===== Konfiguracja przez ENV =====
if API_KEY := os.getenv("API_KEY") is None:
with open("/home/pi/gpt_cont_api_key", "r") as f:
API_KEY = f.read().strip()
else:
API_KEY = os.getenv("API_KEY", "") # np. openssl rand -hex 32
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/home/pi/tmp_git/")) # katalog na dysku
MAX_CONTENT_MB = int(os.getenv("MAX_CONTENT_MB", "200"))
# Google Drive (opcjonalnie)
GDRIVE_ENABLE = os.getenv("GDRIVE_ENABLE", "0") == "1"
GDRIVE_SA_JSON = os.getenv("GDRIVE_SA_JSON", "") # ścieżka do pliku .json konta serwisowego
GDRIVE_FOLDER_ID = os.getenv("GDRIVE_FOLDER_ID", "") # ID folderu na Drive
bp = Blueprint("uploader", __name__)
# Inicjalizacja katalogu
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
def _check_auth() -> bool:
"""Proste Bearer auth. Jeśli API_KEY puste -> bez auth (niezalecane)."""
if not API_KEY:
return True
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:].strip()
return token == API_KEY
return False
def _safe_join(base: Path, *parts: str) -> Path:
"""Zapobiega ../ — wymusza pozostanie w katalogu bazowym."""
p = (base.joinpath(*parts)).resolve()
if not str(p).startswith(str(base.resolve())):
abort(400, description="Invalid path")
return p
# ===== Google Drive helper =====
_drive_client_cached = None
def _get_drive():
global _drive_client_cached
if _drive_client_cached is not None:
return _drive_client_cached
if not (GDRIVE_ENABLE and GDRIVE_SA_JSON and os.path.exists(GDRIVE_SA_JSON)):
return None
from google.oauth2 import service_account
from googleapiclient.discovery import build
scopes = ["https://www.googleapis.com/auth/drive.file"]
creds = service_account.Credentials.from_service_account_file(GDRIVE_SA_JSON, scopes=scopes)
_drive_client_cached = build("drive", "v3", credentials=creds, cache_discovery=False)
return _drive_client_cached
def upload_to_drive(local_path: Path, filename: str) -> Optional[Dict[str, Any]]:
drv = _get_drive()
if drv is None:
return None
from googleapiclient.http import MediaFileUpload
file_metadata = {"name": filename}
if GDRIVE_FOLDER_ID:
file_metadata["parents"] = [GDRIVE_FOLDER_ID]
media = MediaFileUpload(str(local_path), resumable=False)
created = drv.files().create(body=file_metadata, media_body=media, fields="id,webViewLink,webContentLink").execute()
file_id = created.get("id")
# Przydatne linki:
return {
"file_id": file_id,
"webViewLink": created.get("webViewLink"),
"webContentLink": created.get("webContentLink"),
"direct_view": f"https://drive.google.com/file/d/{file_id}/view",
"direct_download": f"https://drive.google.com/uc?export=download&id={file_id}",
}
@bp.get("/health")
def health():
return jsonify(ok=True, info="Okidokie")
@bp.post("/upload")
def upload():
"""Przyjmuje:
- multipart/form-data z jednym plikiem ('file') lub wieloma ('files')
- opcjonalnie: form 'subdir' (podkatalog), 'drive' (1/0) żeby wymusić wysyłkę na Drive
"""
if not _check_auth():
return jsonify(error="Unauthorized"), 401
# Limit ciała żądania po stronie Flaska:
request.max_content_length = MAX_CONTENT_MB * 1024 * 1024
files = []
if "file" in request.files:
files = [request.files["file"]]
elif "files" in request.files:
files = request.files.getlist("files")
else:
return jsonify(error="No file provided (use 'file' or 'files')"), 400
subdir = (request.form.get("subdir") or "").strip()
target_dir = _safe_join(UPLOAD_DIR, subdir) if subdir else UPLOAD_DIR
target_dir.mkdir(parents=True, exist_ok=True)
want_drive = (request.form.get("drive") == "1") or (request.args.get("drive") == "1")
saved: List[Dict[str, Any]] = []
for f in files:
if not f or not f.filename:
continue
fname = secure_filename(f.filename)
dest = _safe_join(target_dir, fname)
f.save(dest)
item = {
"filename": fname,
"size": dest.stat().st_size,
"path": str(dest.relative_to(UPLOAD_DIR)),
"url_hint": f"/api/files/{dest.relative_to(UPLOAD_DIR)}",
}
if want_drive and GDRIVE_ENABLE:
try:
gd = upload_to_drive(dest, fname)
if gd:
item["gdrive"] = gd
except Exception as e:
# log i idziemy dalej
current_app.logger.exception("Drive upload failed: %s", e)
item["gdrive_error"] = str(e)
saved.append(item)
if not saved:
return jsonify(error="No valid files"), 400
return jsonify(ok=True, saved=saved)
@bp.get("/files/<path:relpath>")
def get_file(relpath: str):
if not _check_auth():
return jsonify(error="Unauthorized"), 401
target = _safe_join(UPLOAD_DIR, relpath)
if not target.exists() or not target.is_file():
return jsonify(error="Not found"), 404
return send_file(target, as_attachment=False)
+52
View File
@@ -0,0 +1,52 @@
# ingest_text.py
import os
import urllib.parse
from pathlib import Path
from flask import Blueprint, abort, jsonify, request
bp = Blueprint("ingest_text", __name__)
BASE = Path(os.getenv("INGEST_DIR", "/home/pi/tmp_git")).resolve()
BASE.mkdir(parents=True, exist_ok=True)
TOKEN = None
with open("/home/pi/gpt_cont_api_key", "r") as f:
TOKEN = f.read().strip()
# prosty bufor kawałków w RAM (na 1 proces)
chunks = {}
@bp.get("/api/ingest-text")
def ingest_text():
if request.args.get("key") != TOKEN:
return jsonify(error="unauthorized"), 401
name = request.args.get("name", "").strip()
index = int(request.args.get("index", "1"))
total = int(request.args.get("total", "1"))
chunk = request.args.get("chunk", "")
if not name or "/" in name or ".." in name:
return jsonify(error="bad name"), 400
if not (1 <= index <= total <= 9999):
return jsonify(error="bad indexing"), 400
# gromadzimy w pamięci (możesz podmienić na Redis)
key = f"{name}:{total}"
entry = chunks.setdefault(key, {})
entry[index] = urllib.parse.unquote_plus(chunk)
if TOKEN is None:
exit("No API token set!")
if len(entry) == total:
# składamy i zapisujemy
data = "".join(entry[i] for i in range(1, total + 1))
out = (BASE / name).resolve()
if not str(out).startswith(str(BASE)):
return jsonify(error="bad path"), 400
out.write_text(data, encoding="utf-8")
del chunks[key]
return jsonify(ok=True, saved=str(out), bytes=len(data.encode("utf-8")))
else:
return jsonify(pending=True, got=len(entry), total=total)
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
cd ../conjurer/ && git pull && cp ./gpt_interface/* ../gpt_interf_serv/ && cd -
+196 -58
View File
@@ -1,22 +1,47 @@
# latex_commands.py # latex_commands.py
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
import io import io
from typing import Optional, List
import logging import logging
from typing import Optional
import discord import discord
from discord import app_commands from discord import app_commands
from discord.ext import commands from discord.ext import commands
from constants import ( from constants import (
LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, LATEX_MAX_ATTACH_MB, LATEX_MAX_ZIP_MB, ALLOWED_ROLES,
OPENAI_MODEL, ALLOWED_ROLES, GUILD_ID GUILD_ID,
LATEX_MAX_ATTACH_MB,
LATEX_MAX_COMPILE_SECONDS,
LATEX_MAX_ZIP_MB,
LATEX_TEX_ENGINE,
OPENAI_MODEL,
) )
from latex_functions import ( from latex_functions import (
is_tex_attachment, compile_single_tex_bytes, compile_zip_to_zip, ask_openai_diagnosis compile_single_tex_bytes,
compile_zip_to_zip,
is_safe_asset_name,
is_safe_tex_name,
) )
async def _collect_attachments_bytes(attachments, max_mb: int):
"""
Zwraca słownik {filename: bytes} dla wszystkich załączników, do limitu MB.
Nie podbija wyjątków z .read() — zostawiamy to wyżej; tu zwracamy, co się udało.
"""
out = {}
for a in attachments or []:
# limit rozmiaru
if (getattr(a, "size", 0) or 0) > max_mb * 1024 * 1024:
continue
data = await a.read()
out[a.filename] = data
return out
class LatexModule(commands.Cog): class LatexModule(commands.Cog):
def __init__(self, bot: commands.Bot, logger_name: str): def __init__(self, bot: commands.Bot, logger_name: str):
self.bot = bot self.bot = bot
@@ -31,47 +56,94 @@ class LatexModule(commands.Cog):
guild=None if GUILD_ID is None else discord.Object(id=GUILD_ID), guild=None if GUILD_ID is None else discord.Object(id=GUILD_ID),
) )
@commands.has_any_role(*ALLOWED_ROLES) @commands.has_any_role(*ALLOWED_ROLES)
async def tex(self, ctx: commands.Context, include_source_excerpt: Optional[bool] = True): async def tex(
self, ctx: commands.Context, include_source_excerpt: Optional[bool] = True
):
self.logger.info("LaTeX command invoked by %s", ctx.author) self.logger.info("LaTeX command invoked by %s", ctx.author)
ch = ctx.message.channel ch = ctx.message.channel
# ZBIERZ WSZYSTKIE ZAŁĄCZNIKI
async with ch.typing(): async with ch.typing():
atts = [a for a in (ctx.message.attachments or []) if is_tex_attachment(a, LATEX_MAX_ATTACH_MB)] all_bytes = await _collect_attachments_bytes(
if not atts: ctx.message.attachments, LATEX_MAX_ATTACH_MB
return await ctx.reply(f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False) )
self.logger.debug("LaTeX attachments: %s", ", ".join(a.filename for a in atts)) if not all_bytes:
files: List[discord.File] = [] return await ctx.reply(
parts: List[str] = [] f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
for att in atts: )
# PODZIEL NA GŁÓWNE .TEX i WSPIERAJĄCE
tex_names = [
n
for n in all_bytes.keys()
if n.lower().endswith(".tex") and is_safe_tex_name(n)
]
if not tex_names:
return await ctx.reply(
f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
)
support_names = [
n
for n in all_bytes.keys()
if n not in tex_names and is_safe_asset_name(n)
]
files_to_send = []
parts = []
for tex_name in tex_names:
try: try:
data = await att.read() main_bytes = all_bytes[tex_name]
self.logger.info("LaTeX read %s bytes from %s", len(data), att.filename) support = [
res = await compile_single_tex_bytes(data, att.filename, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name) (n, all_bytes[n])
self.logger.info("LaTeX result for %s: %s", att.filename, res) for n in support_names
self.logger.info("LaTeX log (%s)\n%s", att.filename, (res["log_text"] or "")[-2000:]) if n != tex_name and is_safe_asset_name(n)
]
# (ważne) do supportów dopuszczamy również **inne .tex** dla \input/\include
other_tex = [
(n, all_bytes[n])
for n in tex_names
if n != tex_name and is_safe_tex_name(n)
]
support.extend(other_tex)
res = await compile_single_tex_bytes(
main_bytes,
tex_name,
LATEX_TEX_ENGINE,
LATEX_MAX_COMPILE_SECONDS,
logger="discord",
support_files=support,
)
# Twoja dotychczasowa obsługa success/fail przykładowo:
if res["ok"] and res["pdf_bytes"]: if res["ok"] and res["pdf_bytes"]:
b = io.BytesIO(res["pdf_bytes"]) b = io.BytesIO(res["pdf_bytes"])
b.seek(0) b.seek(0)
b.name = res["pdf_name"] b.name = res["pdf_name"]
files.append(discord.File(b, filename=res["pdf_name"])) files_to_send.append(discord.File(b, filename=res["pdf_name"]))
parts.append(f"✅ `{att.filename}` → `{res['pdf_name']}`") parts.append(f"✅ `{tex_name}` → `{res['pdf_name']}`")
else: else:
self.logger.info("LaTeX compile failed for %s", att.filename) parts.append(
excerpt = "" f"❌ `{tex_name}` — błąd kompilacji.)"
if include_source_excerpt: )
try: # Tutaj wywołujesz swoją ścieżkę do OpenAI diagnozy, którą ogarnęliśmy wcześniej.
excerpt = data.decode("utf-8", errors="ignore")[:4000] except Exception:
except Exception: # let it crash — pełny stack w logu i re-raise (Discord.py to pokaże sensownie)
excerpt = "" logging.getLogger("discord").exception(
advice = await ask_openai_diagnosis(res["log_text"] or "", excerpt, OPENAI_MODEL, logger=self.logger_name) "Unhandled exception while compiling %s", tex_name
parts.append(f"❌ `{att.filename}` — błąd kompilacji.\nDiagnoza:\n{advice}") )
raise
except Exception as e:
self.logger.info("Exception %s: %s", att.filename, e)
parts.append(f"⚠️ `{att.filename}` — wyjątek (szczegóły w logu).")
raise raise
content = f"**LaTeX** — {len(atts)} plik(ów). Model: `{OPENAI_MODEL}`\n\n" + "\n\n".join(parts) content = (
await ctx.reply(content if len(content)<1900 else content[:1900]+"", files=files, mention_author=False) f"**LaTeX** — {len(tex_names)} plik(ów). Model: `{OPENAI_MODEL}`\n\n"
+ "\n\n".join(parts)
)
await ctx.reply(
content if len(content) < 1900 else content[:1900] + "",
files=files_to_send,
mention_author=False,
)
# -------- /latexclean (PDF only; log -> debug logger) -------- # -------- /latexclean (PDF only; log -> debug logger) --------
@commands.hybrid_command( @commands.hybrid_command(
@@ -84,57 +156,123 @@ class LatexModule(commands.Cog):
async def latexclean(self, ctx: commands.Context): async def latexclean(self, ctx: commands.Context):
ch = ctx.message.channel ch = ctx.message.channel
async with ch.typing(): async with ch.typing():
atts = [a for a in (ctx.message.attachments or []) if is_tex_attachment(a, LATEX_MAX_ATTACH_MB)] all_bytes = await _collect_attachments_bytes(
if not atts: ctx.message.attachments, LATEX_MAX_ATTACH_MB
return await ctx.reply(f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False) )
if not all_bytes:
return await ctx.reply(
f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
)
files: List[discord.File] = [] # PODZIEL NA GŁÓWNE .TEX i WSPIERAJĄCE
parts: List[str] = [] tex_names = [
for att in atts: n
for n in all_bytes.keys()
if n.lower().endswith(".tex") and is_safe_tex_name(n)
]
if not tex_names:
return await ctx.reply(
f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
)
support_names = [
n
for n in all_bytes.keys()
if n not in tex_names and is_safe_asset_name(n)
]
files_to_send = []
parts = []
for tex_name in tex_names:
try: try:
data = await att.read() main_bytes = all_bytes[tex_name]
res = await compile_single_tex_bytes(data, att.filename, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name) support = [
self.logger.debug("LaTeX CLEAN (%s)\n%s", att.filename, (res["log_text"] or "")[-2000:]) (n, all_bytes[n])
for n in support_names
if n != tex_name and is_safe_asset_name(n)
]
# (ważne) do supportów dopuszczamy również **inne .tex** dla \input/\include
other_tex = [
(n, all_bytes[n])
for n in tex_names
if n != tex_name and is_safe_tex_name(n)
]
support.extend(other_tex)
res = await compile_single_tex_bytes(
main_bytes,
tex_name,
LATEX_TEX_ENGINE,
LATEX_MAX_COMPILE_SECONDS,
logger="discord",
support_files=support,
)
# Twoja dotychczasowa obsługa success/fail przykładowo:
if res["ok"] and res["pdf_bytes"]: if res["ok"] and res["pdf_bytes"]:
b = io.BytesIO(res["pdf_bytes"]) b = io.BytesIO(res["pdf_bytes"])
b.seek(0) b.seek(0)
b.name = res["pdf_name"] b.name = res["pdf_name"]
files.append(discord.File(b, filename=res["pdf_name"])) files_to_send.append(discord.File(b, filename=res["pdf_name"]))
parts.append(f"✅ `{att.filename}`") parts.append(f"✅ `{tex_name}` → `{res['pdf_name']}`")
else: else:
parts.append(f"❌ `{att.filename}` — błąd (log w debug).") parts.append(
except Exception as e: f"❌ `{tex_name}` — błąd kompilacji.)"
self.logger.debug("CLEAN exception %s: %s", att.filename, e) )
parts.append(f"⚠️ `{att.filename}` — wyjątek (log w debug).") # Tutaj wywołujesz swoją ścieżkę do OpenAI diagnozy, którą ogarnęliśmy wcześniej.
except Exception:
# let it crash — pełny stack w logu i re-raise (Discord.py to pokaże sensownie)
logging.getLogger("discord").exception(
"Unhandled exception while compiling %s", tex_name
)
raise raise
await ctx.reply(
await ctx.reply("**LaTeX clean** — " + ", ".join(parts), files=files, mention_author=False) "**LaTeX clean** — " + ", ".join(parts),
files=files_to_send,
mention_author=False,
)
# -------- /texbatch (ZIP -> ZIP) -------- # -------- /texbatch (ZIP -> ZIP) --------
@app_commands.command( @app_commands.command(
name="texbatch", name="texbatch",
description="Wyślij ZIP z .tex → odeślę ZIP z PDF-ami (tylko udane)." description="Wyślij ZIP z .tex → odeślę ZIP z PDF-ami (tylko udane).",
) )
@app_commands.describe(archive=f"Archiwum .zip (max ~{LATEX_MAX_ZIP_MB} MiB)") @app_commands.describe(archive=f"Archiwum .zip (max ~{LATEX_MAX_ZIP_MB} MiB)")
@app_commands.checks.has_any_role(*ALLOWED_ROLES) @app_commands.checks.has_any_role(*ALLOWED_ROLES)
async def texbatch(self, interaction: discord.Interaction, archive: discord.Attachment): async def texbatch(
self, interaction: discord.Interaction, archive: discord.Attachment
):
await interaction.response.defer() await interaction.response.defer()
if not archive or not archive.filename.lower().endswith(".zip"): if not archive or not archive.filename.lower().endswith(".zip"):
return await interaction.followup.send("Dołącz ZIP (.zip) z plikami .tex.", ephemeral=True) return await interaction.followup.send(
"Dołącz ZIP (.zip) z plikami .tex.", ephemeral=True
)
if archive.size > LATEX_MAX_ZIP_MB * 1024 * 1024: if archive.size > LATEX_MAX_ZIP_MB * 1024 * 1024:
return await interaction.followup.send(f"ZIP > {LATEX_MAX_ZIP_MB} MiB — za duży.", ephemeral=True) return await interaction.followup.send(
f"ZIP > {LATEX_MAX_ZIP_MB} MiB — za duży.", ephemeral=True
)
data = await archive.read() data = await archive.read()
out_zip_bytes, failed = await compile_zip_to_zip(data, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name) out_zip_bytes, failed = await compile_zip_to_zip(
data, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name
)
if not out_zip_bytes and failed: if not out_zip_bytes and failed:
return await interaction.followup.send("Brak PDF-ów. " + "; ".join(failed[:10]), ephemeral=True) return await interaction.followup.send(
"Brak PDF-ów. " + "; ".join(failed[:10]), ephemeral=True
)
bio = io.BytesIO(out_zip_bytes) bio = io.BytesIO(out_zip_bytes)
bio.seek(0) bio.seek(0)
bio.name = "compiled_pdfs.zip" bio.name = "compiled_pdfs.zip"
if failed: if failed:
self.logger.debug("BATCH failed: %s", ", ".join(failed)) self.logger.debug("BATCH failed: %s", ", ".join(failed))
await interaction.followup.send("✅ Gotowe (ZIP w załączniku)." + (f" ❌ Błędy: {len(failed)}" if failed else ""), files=[discord.File(bio, filename=bio.name)]) await interaction.followup.send(
"✅ Gotowe (ZIP w załączniku)."
+ (f" ❌ Błędy: {len(failed)}" if failed else ""),
files=[discord.File(bio, filename=bio.name)],
)
async def setup(bot): async def setup(bot):
logger = logging.getLogger("discord") logger = logging.getLogger("discord")
+121 -175
View File
@@ -1,149 +1,121 @@
# latex_functions.py
# -*- coding: utf-8 -*-
from __future__ import annotations
import asyncio import asyncio
import logging import logging
import re import re
import shutil
import tempfile import tempfile
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple from typing import Dict, Iterable, List, Optional, Tuple
from ai_functions import handle_response
# ===== Bezpieczeństwo nazw/rozszerzeń =====
SAFE_TEX_NAME = re.compile(r"^[\w\-. ]+\.tex$", re.IGNORECASE) SAFE_TEX_NAME = re.compile(r"^[\w\-. ]+\.tex$", re.IGNORECASE)
SAFE_ASSET_NAME = re.compile(
r"^[\w\-. /]+?\.(tex|png|jpg|jpeg|pdf|svg|eps|bmp|gif|sty|cls|bib|bst|bbx|cbx|def|cfg)$",
re.IGNORECASE,
)
def is_safe_tex_name(name: str) -> bool: def is_safe_tex_name(name: str) -> bool:
return bool(SAFE_TEX_NAME.match(name or "")) and name.lower().endswith(".tex") return bool(SAFE_TEX_NAME.match(name or ""))
def is_safe_asset_name(name: str) -> bool:
return bool(SAFE_ASSET_NAME.match(name or ""))
def is_tex_attachment(att, max_mb: int) -> bool: # ===== Uruchamianie kompilatora =====
"""Filtruje discord.Attachment: tylko .tex + limit MB + nazwa bezpieczna."""
return bool( async def _run(cmd: List[str], cwd: Path, timeout_sec: int, logger_name: Optional[str]) -> Tuple[int, str]:
getattr(att, "filename", "") """Uruchamia proces kompilatora w cwd, zwraca (rc, sklejone_stdout_stderr)."""
and is_safe_tex_name(att.filename) logger = logging.getLogger(logger_name) if logger_name else logging.getLogger()
and getattr(att, "size", 0) <= max_mb * 1024 * 1024 logger.debug("RUN %s (cwd=%s, timeout=%s)", " ".join(cmd), cwd, timeout_sec)
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(cwd),
) )
try:
out = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
except asyncio.TimeoutError:
try:
proc.kill()
finally:
pass
return 124, "TIMEOUT: compiler took too long"
rc = proc.returncode
log = (out[0] or b"").decode("utf-8", errors="ignore")
logger.debug("RC=%s, LOG TAIL:\n%s", rc, log[-1500:])
return rc, log
def _build_cmd(tex_engine: str, main_tex: str) -> List[str]:
def _build_cmd(engine: str, tex_filename: str) -> list[str]: """Buduje komendę kompilatora; domyślnie tectonic/pdflatex/xelatex."""
base = (engine or "").strip().lower() if tex_engine.lower() == "tectonic":
if base in ("pdflatex", "xelatex", "lualatex"): # Tectonic sam robi wielofazowość, ale i tak uruchamiamy 2x (bezpiecznie dla bib/refs)
return [ return ["tectonic", "--keep-intermediates", "--synctex", main_tex]
base, elif tex_engine.lower() in ("pdflatex", "xelatex", "lualatex"):
"-interaction=nonstopmode", return [tex_engine, "-interaction=nonstopmode", "-halt-on-error", main_tex]
"-halt-on-error", else:
"-file-line-error", # fallback traktujemy jak pdflatex
"-no-shell-escape", return ["pdflatex", "-interaction=nonstopmode", "-halt-on-error", main_tex]
tex_filename,
]
if base == "latexmk":
return [
"latexmk",
"-pdf",
"-interaction=nonstopmode",
"-halt-on-error",
"-file-line-error",
tex_filename,
]
if base == "tectonic":
return [
"tectonic",
"-X",
"compile",
"--keep-logs",
"--outdir",
".",
tex_filename,
]
return [
"pdflatex",
"-interaction=nonstopmode",
"-halt-on-error",
"-file-line-error",
"-no-shell-escape",
tex_filename,
]
async def run_latex_two_passes( async def run_latex_two_passes(
tex_filename: str, tex_filename: str, workdir: Path, tex_engine: str, timeout_sec: int, logger: Optional[str] = None
workdir: Path, ) -> Tuple[bool, str, Path]:
tex_engine: str, """
timeout_sec: int, Uruchamia kompilację 2 razy (bezpieczeństwo referencji/bib).
logger: str = None, Zwraca: (ok, log_text, pdf_path).
): """
logger = logging.getLogger(logger) if logger else None logger_obj = logging.getLogger(logger) if logger else logging.getLogger()
logger_obj.info("Compiling %s with %s engine two passes", tex_filename, tex_engine)
async def run_once(cmd: list[str]) -> tuple[int, str]: logs: List[str] = []
proc = await asyncio.create_subprocess_exec( pdf_path = workdir / (Path(tex_filename).stem + ".pdf")
*cmd,
cwd=str(workdir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
try:
out_b, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
return (proc.returncode or 0), (out_b or b"").decode(
"utf-8", errors="replace"
)
except asyncio.TimeoutError:
try:
proc.kill()
except Exception:
raise
return 124, f"[Timeout] Compilation exceeded {timeout_sec}s.\n"
logs = []
cmd = _build_cmd(tex_engine, tex_filename)
if logger:
logger.debug("LaTeX cmd: %s (cwd=%s)", " ".join(cmd), workdir)
rc1, out1 = await run_once(cmd)
logs.append(out1)
second_pass = not ((tex_engine or "").strip().lower() == "tectonic")
# PASS 1
rc1, log1 = await _run(_build_cmd(tex_engine, tex_filename), workdir, timeout_sec, logger)
logs.append(log1)
if rc1 != 0: if rc1 != 0:
log_text = "".join(logs) return False, "".join(logs), pdf_path
pdf = workdir / (Path(tex_filename).stem + ".pdf")
return False, log_text, pdf
if second_pass: # PASS 2 (często już nic nie robi, ale nie szkodzi)
rc2, out2 = await run_once(cmd) rc2, log2 = await _run(_build_cmd(tex_engine, tex_filename), workdir, timeout_sec, logger)
logs.append(out2) logs.append(log2)
ok = rc2 == 0 ok = (rc2 == 0) and pdf_path.exists()
else: return ok, "".join(logs), pdf_path
ok = rc1 == 0
log_text = "".join(logs)
pdf = workdir / (Path(tex_filename).stem + ".pdf")
ok = ok and pdf.exists()
return ok, log_text, pdf
# ===== Kompilacje: pojedynczy TEX + ZIP =====
async def compile_single_tex_bytes( async def compile_single_tex_bytes(
tex_bytes: bytes, tex_bytes: bytes,
filename: str, filename: str,
tex_engine: str, tex_engine: str,
timeout_sec: int, timeout_sec: int,
logger: str = None, logger: Optional[str] = None,
support_files: Optional[Iterable[Tuple[str, bytes]]] = None,
) -> Dict[str, Optional[object]]: ) -> Dict[str, Optional[object]]:
"""
Kompiluje pojedynczy .tex + dowolne pliki pomocnicze (obrazy, sty/cls/bib, inne .tex).
Każdy plik zapisujemy w katalogu roboczym widocznym dla kompilatora.
"""
log = logging.getLogger(logger) if logger else logging.getLogger()
log.info("Compiling %s with %s engine single tex", filename, tex_engine)
if not is_safe_tex_name(filename): if not is_safe_tex_name(filename):
return { return {"ok": False, "pdf_bytes": None, "log_text": "Invalid .tex filename", "pdf_name": None}
"ok": False,
"pdf_bytes": None,
"log_text": "Invalid .tex filename",
"pdf_name": None,
}
with tempfile.TemporaryDirectory(prefix="latex_one_") as td: with tempfile.TemporaryDirectory(prefix="latex_one_") as td:
wd = Path(td) wd = Path(td)
tex_path = wd / filename (wd / Path(filename).name).write_bytes(tex_bytes)
tex_path.write_bytes(tex_bytes)
ok, log_text, pdf_path = await run_latex_two_passes( # Zapisz wszystkie supporty obok głównego pliku
filename, wd, tex_engine, timeout_sec, logger=logger if support_files:
) for fname, data in support_files:
if not fname or not is_safe_asset_name(fname):
continue
# spłaszczamy do nazwy bazowej (bez podkatalogów z zewn. źródeł)
(wd / Path(fname).name).write_bytes(data)
ok, log_text, pdf_path = await run_latex_two_passes(Path(filename).name, wd, tex_engine, timeout_sec, logger)
log.info("LaTeX compile finished: %s", "OK" if ok else "FAIL")
log.info("Log text:\n%s", log_text[-2000:]) # Log last 2000 chars
log.info("PDF path: %s", pdf_path)
if ok and pdf_path.exists(): if ok and pdf_path.exists():
return { return {
"ok": True, "ok": True,
@@ -151,83 +123,57 @@ async def compile_single_tex_bytes(
"log_text": log_text, "log_text": log_text,
"pdf_name": pdf_path.name, "pdf_name": pdf_path.name,
} }
return { return {"ok": False, "pdf_bytes": None, "log_text": log_text, "pdf_name": pdf_path.name}
"ok": False,
"pdf_bytes": None,
"log_text": log_text,
"pdf_name": pdf_path.name,
}
async def compile_zip_to_zip( async def compile_zip_to_zip(
zip_bytes: bytes, tex_engine: str, timeout_sec: int, logger: str = None zip_bytes: bytes, tex_engine: str, timeout_sec: int, logger: Optional[str] = None
) -> Tuple[bytes, List[str]]: ) -> Tuple[bytes, List[str]]:
"""
Rozpakowuje ZIP, znajduję wszystkie .tex i dla KAŻDEGO tworzy osobny katalog roboczy
ze skopiowanym CAŁYM drzewem, by zachować ścieżki względne obrazków/plików.
Zwraca (zip_pdf_bytes, lista_rel_sciezek_które_padły).
"""
log = logging.getLogger(logger) if logger else logging.getLogger()
failed: List[str] = [] failed: List[str] = []
out_pdf_paths: List[Path] = [] out_pdf_paths: List[Path] = []
with tempfile.TemporaryDirectory(prefix="latex_zip_") as td: with tempfile.TemporaryDirectory(prefix="latex_zip_") as td:
td_path = Path(td) td_path = Path(td)
in_zip = td_path / "in.zip" src_root = td_path / "src"
in_zip.write_bytes(zip_bytes) src_root.mkdir(parents=True, exist_ok=True)
extract_dir = td_path / "in"
extract_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(in_zip, "r") as zf:
zf.extractall(extract_dir)
tex_files = [p for p in extract_dir.rglob("*.tex") if is_safe_tex_name(p.name)] # 1) Rozpakuj
zpath = td_path / "in.zip"
zpath.write_bytes(zip_bytes)
with zipfile.ZipFile(zpath, "r") as zf:
zf.extractall(src_root)
# 2) Zbierz .tex
tex_files = [p for p in src_root.rglob("*.tex") if is_safe_tex_name(p.name)]
if not tex_files: if not tex_files:
return b"", ["No .tex files found in archive."] return b"", ["No .tex files found in archive."]
for tex in tex_files: # 3) Kompiluj każdy .tex w izolacji (kopiujemy całe drzewo do osobnego workdiru)
for tex_path in tex_files:
rel_tex = tex_path.relative_to(src_root).as_posix()
work = td_path / f"build_{tex_path.stem}"
try: try:
work = td_path / f"build_{tex.stem}" shutil.copytree(src_root, work / "src")
work.mkdir(parents=True, exist_ok=True) ok, log_text, pdf = await run_latex_two_passes(rel_tex, work / "src", tex_engine, timeout_sec, logger)
target_tex = work / tex.name if ok and pdf.exists():
target_tex.write_bytes(tex.read_bytes()) out_pdf_paths.append(pdf)
ok, log_text, pdf_path = await run_latex_two_passes(
target_tex.name, work, tex_engine, timeout_sec, logger=logger
)
if ok and pdf_path.exists():
out_pdf_paths.append(pdf_path)
else: else:
failed.append(tex.name) failed.append(rel_tex)
if logger: log.info("LaTeX FAIL %s\n%s", rel_tex, (log_text or "")[-2000:])
logger.debug("BATCH FAIL %s\n%s", tex.name, log_text[-1500:]) except Exception:
except Exception as e: # let it crash log + re-raise (to zobaczy wyższa warstwa/discord.py)
failed.append(f"{tex.name} (exception: {e})") log.exception("Exception while compiling %s", rel_tex)
if logger:
logger.debug("BATCH EXC %s: %s", tex.name, e)
raise raise
# 4) Spakuj wyjściowe PDF-y
out_zip = td_path / "compiled_pdfs.zip" out_zip = td_path / "compiled_pdfs.zip"
with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for pdf in out_pdf_paths: for pdf in out_pdf_paths:
zf.write(pdf, arcname=pdf.name) zf.write(pdf, arcname=pdf.name)
return out_zip.read_bytes(), failed return out_zip.read_bytes(), failed
async def ask_openai_diagnosis(
log_text: str, tex_excerpt: str, model: str, logger: str = None
) -> str:
logger = logging.getLogger(logger) if logger else None
sys = (
"You are a LaTeX build diagnostician. Analyze pdflatex log and return:\n"
"1) Root causes (bullets)\n2) Minimal working fix (code block)\n3) Alternative fix"
)
logger.debug("LaTeX DIAG (%s)\n%s", model, log_text[-9000:])
usr = f"---LOG---\n{log_text[-9000:]}\n---END LOG---"
if tex_excerpt:
usr += f"\n---TEX EXCERPT---\n{tex_excerpt[:4000]}\n---END EXCERPT---"
prompt = [{"role": sys, "content": "none"}, {"role": "user", "content": usr}]
response, _ = await handle_response(
prompt,
None,
None,
prompt,
"latex_diagnosis",
"NONE",
algorithm=model,
none_request=prompt,
)
return response
+10 -1
View File
@@ -15,7 +15,14 @@ from discord.ext import commands, tasks
from ai_functions import handle_response from ai_functions import handle_response
from communication_subroutine import IN_COMM_Q, OUT_COMM_Q, QueryControl from communication_subroutine import IN_COMM_Q, OUT_COMM_Q, QueryControl
from constants import DIR_PATH_SADOX, LIBRARIAN_SERVICE_ADDRESS, SEND_QUERY from constants import (
DIR_PATH_SADOX,
LIBRARIAN_SERVICE_ADDRESS,
SEND_QUERY,
service_headers,
)
SERVICE_HEADERS = service_headers()
class DataModule(commands.Cog): class DataModule(commands.Cog):
@@ -165,6 +172,7 @@ class DataModule(commands.Cog):
requests.post, requests.post,
f"{LIBRARIAN_SERVICE_ADDRESS}{SEND_QUERY}", f"{LIBRARIAN_SERVICE_ADDRESS}{SEND_QUERY}",
json=json_query, json=json_query,
headers=SERVICE_HEADERS,
timeout=360, timeout=360,
) )
await ctx.send( await ctx.send(
@@ -260,6 +268,7 @@ class DataModule(commands.Cog):
requests.post, requests.post,
f"{LIBRARIAN_SERVICE_ADDRESS}{SEND_QUERY}", f"{LIBRARIAN_SERVICE_ADDRESS}{SEND_QUERY}",
json=json_query, json=json_query,
headers=SERVICE_HEADERS,
timeout=360, timeout=360,
) )
await ctx.send( await ctx.send(
+20 -5
View File
@@ -17,11 +17,16 @@ from constants import (
SEND_MP3, SEND_MP3,
SPOTIFY_CTRL, SPOTIFY_CTRL,
YOUTUBE_AUTH, YOUTUBE_AUTH,
service_headers,
) )
from spotify_dl import spotify from spotify_dl import spotify
from spotify_dl import youtube as youtube_download from spotify_dl import youtube as youtube_download
SERVICE_HEADERS = service_headers()
MUSIC_ROOT = Path(MUSIC_FOLDER)
class MusicFileList(object): class MusicFileList(object):
""" """
@@ -43,11 +48,15 @@ class MusicFileList(object):
""" """
try: try:
self.logger.info("Attempt to connect to file service") self.logger.info("Attempt to connect to file service")
response = requests.get(f"{FILE_SERVICE_ADDRESS}{GET_MP3}", timeout=360) response = requests.get(
f"{FILE_SERVICE_ADDRESS}{GET_MP3}",
headers=SERVICE_HEADERS,
timeout=60,
)
self.music_file_list = response.json()["music_file_list"] self.music_file_list = response.json()["music_file_list"]
self.file_service_active = True self.file_service_active = True
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
for mp3_item in Path.glob(Path(MUSIC_FOLDER), "**/*.mp3"): for mp3_item in MUSIC_ROOT.glob("**/*.mp3"):
temp_music_file = mp3_item.as_posix() temp_music_file = mp3_item.as_posix()
if platform == "win32": if platform == "win32":
temp_music_file = temp_music_file.replace("/", "\\") temp_music_file = temp_music_file.replace("/", "\\")
@@ -98,7 +107,12 @@ class MusicFileList(object):
""" """
self.music_file_list.append(item) self.music_file_list.append(item)
post_data = {"item": str(item)} post_data = {"item": str(item)}
requests.post(f"{FILE_SERVICE_ADDRESS}{SEND_MP3}", json=post_data, timeout=360) requests.post(
f"{FILE_SERVICE_ADDRESS}{SEND_MP3}",
json=post_data,
headers=SERVICE_HEADERS,
timeout=60,
)
MUSIC_FILE_LIST = MusicFileList("discord") MUSIC_FILE_LIST = MusicFileList("discord")
@@ -142,7 +156,7 @@ async def get_file(ctx, source, link):
url_data = {"urls": []} url_data = {"urls": []}
url_dict = {} url_dict = {}
url_dict["save_path"] = Path( url_dict["save_path"] = Path(
PurePath.joinpath(Path(MUSIC_FOLDER), Path(directory_name)) PurePath.joinpath(MUSIC_ROOT, Path(directory_name))
) )
url_dict["save_path"].mkdir(parents=True, exist_ok=True) url_dict["save_path"].mkdir(parents=True, exist_ok=True)
url_dict["songs"] = file_list url_dict["songs"] = file_list
@@ -152,7 +166,7 @@ async def get_file(ctx, source, link):
coro = asyncio.to_thread( coro = asyncio.to_thread(
youtube_download.download_songs, youtube_download.download_songs,
songs=url_data, songs=url_data,
output_dir=MUSIC_FOLDER, output_dir=str(MUSIC_ROOT),
format_str="bestaudio/best", format_str="bestaudio/best",
skip_mp3=False, skip_mp3=False,
keep_playlist_order=False, keep_playlist_order=False,
@@ -308,6 +322,7 @@ async def search_music(ctx, how_many=0, slowa_kluczowe=None):
requests.post, requests.post,
f"{FILE_SERVICE_ADDRESS}{GET_PLAYLIST}", f"{FILE_SERVICE_ADDRESS}{GET_PLAYLIST}",
json=jrequest, json=jrequest,
headers=SERVICE_HEADERS,
timeout=360, timeout=360,
) )
return_data = await coroutine return_data = await coroutine
+16 -1
View File
@@ -8,7 +8,18 @@ import uuid
import asyncio import asyncio
from datetime import datetime from datetime import datetime
from constants import RADIO_HARBOR_ADDRESS, SKIP_TRACK, FILE_SERVICE_ADDRESS, ADD_TO_PRIO_PLAYLIST, REQUEST_MUSIC, CREATE_PRIO_PLAYLIST, CLEAR_PRIO 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()
class RadioModule(commands.Cog): class RadioModule(commands.Cog):
def __init__(self, bot, logger_name): def __init__(self, bot, logger_name):
@@ -95,6 +106,7 @@ class RadioModule(commands.Cog):
requests.post, requests.post,
f"{FILE_SERVICE_ADDRESS}{ADD_TO_PRIO_PLAYLIST}", f"{FILE_SERVICE_ADDRESS}{ADD_TO_PRIO_PLAYLIST}",
json=jrequest, json=jrequest,
headers=SERVICE_HEADERS,
timeout=360, timeout=360,
) )
result = await coroutine result = await coroutine
@@ -130,6 +142,7 @@ class RadioModule(commands.Cog):
requests.post, requests.post,
f"{FILE_SERVICE_ADDRESS}{REQUEST_MUSIC}", f"{FILE_SERVICE_ADDRESS}{REQUEST_MUSIC}",
json=jrequest, json=jrequest,
headers=SERVICE_HEADERS,
timeout=360, timeout=360,
) )
result = await coroutine result = await coroutine
@@ -167,6 +180,7 @@ class RadioModule(commands.Cog):
requests.post, requests.post,
f"{FILE_SERVICE_ADDRESS}{CREATE_PRIO_PLAYLIST}", f"{FILE_SERVICE_ADDRESS}{CREATE_PRIO_PLAYLIST}",
json=jrequest, json=jrequest,
headers=SERVICE_HEADERS,
timeout=360, timeout=360,
) )
result = await coroutine result = await coroutine
@@ -194,6 +208,7 @@ class RadioModule(commands.Cog):
coroutine = asyncio.to_thread( coroutine = asyncio.to_thread(
requests.get, requests.get,
f"{FILE_SERVICE_ADDRESS}{CLEAR_PRIO}", f"{FILE_SERVICE_ADDRESS}{CLEAR_PRIO}",
headers=SERVICE_HEADERS,
timeout=360, timeout=360,
) )
result = await coroutine result = await coroutine
+44 -24
View File
@@ -2,17 +2,19 @@
# trunk-ignore-all(bandit/B311) # trunk-ignore-all(bandit/B311)
# pylint: disable=line-too-long # pylint: disable=line-too-long
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
""" """Discord entrypoint for the Conjurer bot.
Module of a python bot named Conjurer - used to work on BDSM discord servers.
"""
import logging
# *=========================================== Standard Library Imports The legacy bootstrap used threads and synchronous calls that made clean
shutdowns difficult. We now run everything from a single asyncio event loop
with cooperative shutdown signals so the bot can stop gracefully.
"""
import asyncio
import logging
import random import random
import threading import threading
from logging import handlers from logging import handlers
# *==============Imported libraries
import discord import discord
from discord.ext import commands from discord.ext import commands
@@ -82,22 +84,40 @@ async def on_ready():
logger.info("All systems: operational") logger.info("All systems: operational")
# *================================== Run async def _run_comm_subroutine(stop_event: threading.Event) -> None:
await asyncio.to_thread(comm_subroutine, stop_event)
async def _run_bot(token: str, shutdown_event: asyncio.Event) -> None:
try:
await client.start(token, log_handler=None)
finally:
shutdown_event.set()
async def main() -> None:
if not TOKEN:
logger.error("Discord token missing set DISCORD_TOKEN or configure netrc")
return
shutdown_event = asyncio.Event()
comm_stop_event = threading.Event()
comm_task = asyncio.create_task(_run_comm_subroutine(comm_stop_event))
bot_task = asyncio.create_task(_run_bot(TOKEN, shutdown_event))
try:
await shutdown_event.wait()
except (KeyboardInterrupt, asyncio.CancelledError):
logger.info("Shutdown signal received")
comm_stop_event.set()
await client.close()
finally:
comm_stop_event.set()
if not client.is_closed():
await client.close()
await asyncio.gather(bot_task, comm_task, return_exceptions=True)
if __name__ == "__main__": if __name__ == "__main__":
logger.info("Starting discord bot") asyncio.run(main())
threads = []
logger.info("Starting discord bot: Creating threads")
threads.append(threading.Thread(target=client.run, args=(TOKEN,),kwargs={"log_handler":None}))
threads.append(threading.Thread(target=comm_subroutine))
logger.info("Starting discord bot: Starting threads")
WRK_CNT = 0
for worker in threads:
WRK_CNT += 1
logger.info("Starting discord bot: Starting thread %s", WRK_CNT)
worker.start()
logger.info("Starting discord bot: Joining threads")
WRK_CNT = 0
for worker in threads:
WRK_CNT += 1
logger.info("Starting discord bot: Joining thread %s", WRK_CNT)
worker.join()