Compare commits

..

1 Commits

Author SHA1 Message Date
Michal Tuszowski ef6eb84921 Preserve backup_old_docker bot variant at root (for comparison)
Snapshot of the dockerisation-era bot code laid out at the repository
root so it can be diffed directly against the working-copy baseline
(restructure/working-copy-root) to see how the variant differed:

- thin_client.py asyncio entrypoint (vs working_copy bot.py)
- constants.py env-var/credential refactor, communication auth, etc.
- extra gpt_interface/ service, sync.py, watch_script_params.py
- conanjurer_* modules absent in this variant

Docker infrastructure (docker/, docker-compose.yml, .dockerignore)
intentionally omitted - this branch is for code comparison only and is
not intended to be merged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:55:53 +02:00
35 changed files with 907 additions and 813 deletions
+21
View File
@@ -1,2 +1,23 @@
# conjurer
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.
+2 -2
View File
@@ -10,8 +10,8 @@ import discord
import numpy as np
from discord.ext import commands, tasks
from ai_functions import get_random_cyclic_message
from constants import (
from conjurer.backup_old_docker.ai_functions import get_random_cyclic_message
from conjurer.backup_old_docker.constants import (
ENCODING,
LAST_SPONTANEOUS_CALL,
LOGFILE,
+3 -3
View File
@@ -10,11 +10,11 @@ import discord
import openai
import requests
from discord.ext import commands
from other_functions import discord_friendly_send, discord_friendly_reply
from conjurer.backup_old_docker.other_functions import discord_friendly_send, discord_friendly_reply
import ai_functions
from constants import (
import conjurer.backup_old_docker.ai_functions as ai_functions
from conjurer.backup_old_docker.constants import (
ASSISTANTS,
DATA,
GRAPHICS_PATH,
+2 -2
View File
@@ -6,8 +6,8 @@ import random
import openai
import tiktoken
import time
from other_functions import discord_friendly_send
from constants import (
from conjurer.backup_old_docker.other_functions import discord_friendly_send
from conjurer.backup_old_docker.constants import (
ASSISTANTS,
CYCLIC_WORDS,
ENCODING,
+46
View File
@@ -0,0 +1,46 @@
import discord
from discord.ext import commands
from typing import Optional
class Music(commands.Cog):
def __init__(self, bot):
self.bot = bot # This is so you can access Bot instance in your cog
# You must have this function for `bot.load_extension` to call
def setup(bot):
bot.add_cog(Music(bot))
@commands.hybrid_command(
name="przytul", description="Przytul kogoś - daj mention po komendzie :)"
)
async def przytul(ctx, arg: Optional[discord.Member] = None):
"""
Generate a text about hugging mentioned user.
:param ctx: ctx stands for "context" and is a required parameter in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command
:param arg: arg is a parameter of the function "przytul" that expects a Discord member object. The
parameter is optional, meaning that if no member object is provided, it will default to None
:type arg: Optional[discord.Member]
"""
async with ctx.typing():
nieprzytulac = False
for mention in ctx.message.mentions:
for role in mention.roles:
if role.name == "NIEPRZYTULAĆ!":
nieprzytulac = True
if arg and nieprzytulac:
await ctx.send(
f"Żebym ja Ciebie nie przytulił {ctx.message.author.mention}"
)
elif arg:
await ctx.send(
# trunk-ignore(codespell/misspelled)
f"Już dobrze.... Już dobrze... Ojej.. Biedactwo... :( *W ułamku sekundy {arg.mention} znajduje sie w duszącym uścisku. Żebra trzeszczą - kilka pęka. Pacnięcia po plecach grożą odbiciem nerek, a głaskanie po głowie powoduje wstrząs mózgu*"
)
else:
await ctx.send(
"Kogo mam przytulić? *Wyłamuje kostki i przeciąga się - jego 200 kilowa sylwetka złożona z samych mięśni świadczy o tym że jest gotowy*"
)
+27 -31
View File
@@ -11,10 +11,9 @@ from urllib import request as urequest
from flask import Flask, abort, jsonify, request
from waitress import serve
HOST_ADDRESS = os.getenv("CONJURER_DISCORD_HOST", "192.168.1.31")
PORT_ADDRESS = int(os.getenv("CONJURER_DISCORD_PORT", "5000"))
ICECAST_ADDRESS = os.getenv("CONJURER_ICECAST", "http://192.168.1.15:8000")
API_KEY = os.getenv("CONJURER_API_KEY")
HOST_ADDRESS = "192.168.1.191"
PORT_ADDRESS = 5000
ICECAST_ADDRESS = "http://192.168.1.15:8000"
OUT_COMM_Q = Queue()
IN_COMM_Q = Queue()
SRCHTITLE = re.compile(rb"StreamTitle=\\*(?P<title>[^;]*);").search
@@ -22,12 +21,6 @@ SRCHTITLE = re.compile(rb"StreamTitle=\\*(?P<title>[^;]*);").search
awaiting_q = []
incoming_q = Queue()
app = Flask(__name__)
def _authorize_request() -> None:
"""Reject inbound calls lacking the shared key (no-op if key is unset)."""
if API_KEY and request.headers.get("X-Conjurer-Api-Key") != API_KEY:
abort(401)
PREPPED_TRACKS = {
"requests": "",
"hit": "",
@@ -40,6 +33,13 @@ PREPPED_TRACKS = {
}
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:
"""
@@ -89,13 +89,13 @@ def log_radio_tracks():
@app.route("/conjurer", methods=["POST"])
def answer_external_command():
_authorize_request()
"""
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.
:return: The function `answer_external_command()` is returning a JSON response with the message
"SUCCESS".
"""
_authorize_request()
logger = logging.getLogger("discord")
logger.info(request)
record = json.loads(request.data)
@@ -144,11 +144,10 @@ 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.
A bounded ``get(timeout=1)`` is used instead of a blocking ``get()`` so the
worker can observe ``stop_event`` and exit cleanly during shutdown.
:param stop_event: optional :class:`threading.Event`; when set the loop
stops at the next iteration.
:param _logger: The `_logger` parameter is typically an instance of a logging object that is used to
record and store log messages. It is commonly used to track the flow of the program, record errors,
and provide information for debugging purposes. In this code snippet, the `_logger` object is used
to log the
"""
logger = logging.getLogger("discord")
while True:
@@ -168,8 +167,10 @@ def scan_incoming(stop_event: Optional[threading.Event] = None):
The `scan_incoming` function continuously checks for incoming data, processes it, and logs when data
is found.
:param stop_event: optional :class:`threading.Event`; when set the loop
stops at the next iteration.
:param _logger: The `_logger` parameter in the `scan_incoming` function is a logger object that is
used to log messages or information during the execution of the function. It is typically used for
debugging, monitoring, or tracking the flow of the program. In this case, the `_logger` is being
used
"""
logger = logging.getLogger("discord")
while True:
@@ -227,28 +228,23 @@ def comm_subroutine(stop_event: Optional[threading.Event] = None):
"""
The `comm_subroutine` function starts multiple threads to run different tasks concurrently.
Workers run as daemon threads and honour an optional ``stop_event`` so the
bot can shut the communication layer down cleanly instead of blocking
forever on ``join()``.
:param stop_event: optional :class:`threading.Event` shared with the caller
to coordinate a cooperative shutdown.
:param logger: The `logger` parameter in the `comm_subroutine` function is an instance of a logger
object that is used for logging messages at various levels (e.g., debug, info, warning, error). In
the provided code snippet, the logger is used to log messages at the "info" level
"""
# logger.setLevel(logging.DEBUG)
logger = logging.getLogger("discord")
logger.info("Started comms")
threads = []
# threads.append(threading.Thread(target=flask_debug))
threads.append(threading.Thread(target=waitress_run, daemon=True))
threads.append(
threading.Thread(
target=scan_queue, kwargs={"stop_event": stop_event}, daemon=True
)
threading.Thread(target=waitress_run, daemon=True)
)
threads.append(
threading.Thread(
target=scan_incoming, kwargs={"stop_event": stop_event}, daemon=True
)
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:
-217
View File
@@ -1,217 +0,0 @@
# This Python file uses the following encoding: utf-8
"""Conan Exiles <-> Discord bridge (cog).
Follows the project convention (cog here, helpers in
``conanjurer_functions.py``). The integration is dormant unless configured in
:mod:`constants`: with no RCON host / channels the background watchers simply
do not start and the GM commands report that RCON is unavailable, so loading
this extension is always safe even on hosts without a Conan server.
"""
import asyncio
import logging
import discord
from discord.ext import commands
from conanjurer_functions import ConanConfig, Event, RconClient, watch, watch_players
from constants import (
CONAN_CHAT_CHANNEL_ID,
CONAN_EVENTS_CHANNEL_ID,
CONAN_GM_ROLE_ID,
CONAN_JOIN_CHANNEL_ID,
CONAN_LOG_MODE,
CONAN_LOG_PATH,
CONAN_PLAYER_POLL_SECONDS,
CONAN_RCON_HOST,
CONAN_RCON_PASSWORD,
CONAN_RCON_PORT,
CONAN_SFTP_HOST,
CONAN_SFTP_PASSWORD,
CONAN_SFTP_PORT,
CONAN_SFTP_USER,
)
def is_gm():
"""Allow only members holding the configured Conan GM role."""
async def predicate(ctx: commands.Context) -> bool:
if not CONAN_GM_ROLE_ID:
await ctx.reply(
"⛔ Rola GM Conana nie jest skonfigurowana.", mention_author=False
)
return False
ok = any(r.id == CONAN_GM_ROLE_ID for r in getattr(ctx.author, "roles", []))
if not ok:
await ctx.reply("⛔ Tylko GM.", mention_author=False)
return ok
return commands.check(predicate)
class ConanModule(commands.Cog):
"""Bridges a Conan Exiles server with Discord over RCON + log following."""
def __init__(self, bot, logger_name):
self.bot = bot
self.logger = logging.getLogger(logger_name)
self.cfg = ConanConfig(
rcon_host=CONAN_RCON_HOST,
rcon_port=CONAN_RCON_PORT,
rcon_password=CONAN_RCON_PASSWORD,
log_mode=CONAN_LOG_MODE,
log_path=CONAN_LOG_PATH,
sftp_host=CONAN_SFTP_HOST,
sftp_port=CONAN_SFTP_PORT,
sftp_user=CONAN_SFTP_USER,
sftp_password=CONAN_SFTP_PASSWORD,
)
self.rcon = (
RconClient(CONAN_RCON_HOST, CONAN_RCON_PORT, CONAN_RCON_PASSWORD)
if self.cfg.rcon_enabled
else None
)
self._log_task = None
self._player_task = None
async def cog_load(self):
# Conan -> Discord chat/event mirroring (only with a log source + target)
if self.cfg.log_enabled and (CONAN_CHAT_CHANNEL_ID or CONAN_EVENTS_CHANNEL_ID):
self._log_task = asyncio.create_task(self._run_log_watch())
else:
self.logger.info("Conan: log watch disabled (not configured)")
# Player-join notifications — disabled when the channel is not defined
if CONAN_JOIN_CHANNEL_ID and self.rcon is not None:
self._player_task = asyncio.create_task(self._run_player_watch())
else:
self.logger.info(
"Conan: player-join notifications disabled (no channel or RCON)"
)
async def cog_unload(self):
for task in (self._log_task, self._player_task):
if task is not None:
task.cancel()
if self.rcon is not None:
await self.rcon.close()
# ---------------------------------------------------------------- watchers
async def _run_log_watch(self):
await self.bot.wait_until_ready()
chat_ch = (
self.bot.get_channel(CONAN_CHAT_CHANNEL_ID) if CONAN_CHAT_CHANNEL_ID else None
)
evt_ch = (
self.bot.get_channel(CONAN_EVENTS_CHANNEL_ID)
if CONAN_EVENTS_CHANNEL_ID
else None
)
async def on_event(event: Event):
target = chat_ch if event.kind == "chat" else evt_ch
if target is not None:
await target.send(
event.text, allowed_mentions=discord.AllowedMentions.none()
)
self.logger.info("Conan: starting log watch (mode=%s)", self.cfg.log_mode)
await watch(self.cfg, on_event)
async def _run_player_watch(self):
"""Task 2: announce on a defined channel when a player joins the server."""
await self.bot.wait_until_ready()
channel = self.bot.get_channel(CONAN_JOIN_CHANNEL_ID)
if channel is None:
self.logger.warning(
"Conan: join channel %s not found — player notifications off",
CONAN_JOIN_CHANNEL_ID,
)
return
async def on_join(name: str):
await channel.send(
f"🟢 **{name}** wszedł na serwer Conan",
allowed_mentions=discord.AllowedMentions.none(),
)
self.logger.info(
"Conan: starting player-join watch (channel=%s, every %ss)",
CONAN_JOIN_CHANNEL_ID,
CONAN_PLAYER_POLL_SECONDS,
)
await watch_players(self.rcon, CONAN_PLAYER_POLL_SECONDS, on_join)
# ----------------------------------------------------- Discord -> Conan
async def _require_rcon(self, ctx: commands.Context):
if self.rcon is None:
await ctx.reply(
"⛔ RCON Conana nie jest skonfigurowany/dostępny.",
mention_author=False,
)
return None
return self.rcon
@commands.command(name="say")
@is_gm()
async def say(self, ctx: commands.Context, *, message: str):
"""Discord -> Conan: ogłoszenie widoczne dla wszystkich graczy w grze."""
rcon = await self._require_rcon(ctx)
if rcon is None:
return
resp = await rcon.command(f"broadcast {message}")
await ctx.reply(
f"✅ Wysłano do gry. (serwer: `{resp.strip() or 'OK'}`)",
mention_author=False,
)
@commands.command(name="players")
@is_gm()
async def players(self, ctx: commands.Context):
"""Lista graczy online (RCON listplayers)."""
rcon = await self._require_rcon(ctx)
if rcon is None:
return
resp = await rcon.command("listplayers")
await ctx.reply(
f"```\n{resp.strip() or 'brak danych'}\n```", mention_author=False
)
@commands.command(name="kick")
@is_gm()
async def kick(self, ctx: commands.Context, *, who: str):
"""Wyrzuć gracza (po nazwie/charname — zależnie od wersji serwera)."""
rcon = await self._require_rcon(ctx)
if rcon is None:
return
resp = await rcon.command(f"kick {who}")
await ctx.reply(f"👢 `{resp.strip() or 'OK'}`", mention_author=False)
@commands.command(name="rcon")
@is_gm()
async def raw_rcon(self, ctx: commands.Context, *, cmd: str):
"""Surowa komenda RCON (dla zaawansowanych GM). Używaj ostrożnie."""
rcon = await self._require_rcon(ctx)
if rcon is None:
return
resp = await rcon.command(cmd)
await ctx.reply(f"```\n{resp.strip() or 'OK'}\n```", mention_author=False)
@commands.command(name="ogłoś", aliases=["oglos", "rp"])
@is_gm()
async def rp_announce(self, ctx: commands.Context, nadawca: str, *, message: str):
"""Wiadomość RP 'z nadawcą' (np. ogłoszenie w imieniu Króla Khasara).
Na samym RCON realizujemy to jako sformatowany broadcast.
"""
rcon = await self._require_rcon(ctx)
if rcon is None:
return
resp = await rcon.command(f"broadcast [{nadawca}]: {message}")
await ctx.reply(f"📜 Ogłoszono jako **{nadawca}**.", mention_author=False)
async def setup(bot):
logger = logging.getLogger("discord")
await bot.add_cog(ConanModule(bot, "discord"))
logger.info("Loading conanjurer commands module done")
-266
View File
@@ -1,266 +0,0 @@
# This Python file uses the following encoding: utf-8
"""Helper logic for the Conan Exiles <-> Discord bridge.
Mirrors the project layout: the cog lives in ``conanjurer_commands.py`` and the
reusable logic lives here. Configuration comes from :mod:`constants` (env-var
overridable); optional third-party dependencies (``aiomcrcon``/``asyncssh``)
are imported defensively so the main bot can still load the extension when the
Conan integration is not installed or not in use.
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
from dataclasses import dataclass
from typing import AsyncIterator, Awaitable, Callable, Optional, Set
try:
from aiomcrcon import Client as _Rcon # Source RCON over TCP
except ImportError: # pragma: no cover - optional component
_Rcon = None
try:
import asyncssh
except ImportError: # pragma: no cover - optional component
asyncssh = None
logger = logging.getLogger("discord")
@dataclass
class Event:
kind: str # "chat" | "login" | "logout" | "death" | "raw"
text: str # ready-to-display text
raw: str # original line (for debugging)
@dataclass
class ConanConfig:
"""Runtime configuration for the bridge, built from :mod:`constants`."""
rcon_host: str
rcon_port: int
rcon_password: str
log_mode: str # "local" | "sftp"
log_path: str
sftp_host: str
sftp_port: int
sftp_user: str
sftp_password: str
@property
def rcon_enabled(self) -> bool:
"""RCON usable only when host+password are set and the lib is present."""
return bool(self.rcon_host and self.rcon_password and _Rcon is not None)
@property
def log_enabled(self) -> bool:
"""Log following usable only when its prerequisites are configured."""
if not self.log_path:
return False
if self.log_mode == "sftp":
return bool(self.sftp_host and asyncssh is not None)
return True
# --- REGEXY DO DOSTROJENIA NA WŁASNYM LOGU ---
# Conan/Pippi/Tot logują różnie — dopasuj do swojego logu. Linie niepasujące są
# ignorowane (nie zgadujemy).
_PATTERNS: list[tuple[str, re.Pattern]] = [
("chat", re.compile(r"Chat:\s*(?P<who>.+?):\s*(?P<msg>.+)$", re.I)),
("login", re.compile(r"(?P<who>.+?)\s+(joined|connected|logged in)", re.I)),
("logout", re.compile(r"(?P<who>.+?)\s+(left|disconnected|logged out)", re.I)),
("death", re.compile(r"(?P<who>.+?)\s+was killed by\s+(?P<by>.+)$", re.I)),
]
class RconClient:
"""Thin async wrapper around a Source-RCON connection to the Conan server."""
def __init__(self, host: str, port: int, password: str):
self._host, self._port, self._pw = host, port, password
self._client: Optional["_Rcon"] = None
self._lock = asyncio.Lock()
async def _ensure(self) -> "_Rcon":
if _Rcon is None:
raise RuntimeError("aiomcrcon not installed — RCON unavailable")
if self._client is None:
client = _Rcon(self._host, self._port, self._pw)
await client.connect()
self._client = client
logger.info("RCON connected %s:%s", self._host, self._port)
return self._client
async def command(self, cmd: str) -> str:
"""Send a command to the Conan server and return its response.
Discord -> Conan channel, e.g. ``command("broadcast Hi!")``.
"""
async with self._lock:
for attempt in (1, 2):
try:
client = await self._ensure()
resp, _ = await client.send_cmd(cmd)
return resp
except Exception as exc: # disconnect / server restart
logger.warning("RCON error (attempt %s): %s", attempt, exc)
await self.close()
if attempt == 2:
raise
await asyncio.sleep(1.0)
return ""
async def close(self) -> None:
if self._client is not None:
try:
await self._client.close()
except Exception: # pragma: no cover - best effort
pass
self._client = None
def parse_line(line: str) -> Optional[Event]:
line = line.rstrip("\n")
if not line.strip():
return None
for kind, pat in _PATTERNS:
match = pat.search(line)
if match:
g = match.groupdict()
if kind == "chat":
return Event(kind, f"💬 **{g['who']}**: {g['msg']}", line)
if kind == "login":
return Event(kind, f"🟢 **{g['who']}** dołączył do gry", line)
if kind == "logout":
return Event(kind, f"⚪ **{g['who']}** opuścił grę", line)
if kind == "death":
return Event(kind, f"💀 **{g['who']}** zginął z ręki {g['by']}", line)
return None # nierozpoznane -> ignoruj
def parse_players(listplayers_output: str) -> Set[str]:
"""Extract the set of connected player char-names from RCON ``listplayers``.
Conan's table is roughly::
Idx | Char name | Player name | User ID | Platform ID | Platform Name
0 | Conan | SomeUser | 12345 | 765... | Steam
The char-name column (index 1) is used. The exact format varies between
server builds, so this is best-effort and intentionally tunable.
"""
players: Set[str] = set()
for raw in listplayers_output.splitlines():
line = raw.strip()
if not line or "|" not in line:
continue
cols = [c.strip() for c in line.split("|")]
head = cols[0].lower()
# skip the header row and any separator rows (e.g. "---|---")
if head in ("idx", "") or set(cols[0]) <= set("-"):
continue
if len(cols) >= 2 and cols[1]:
players.add(cols[1])
return players
async def watch_players(
rcon: RconClient,
interval: float,
on_join: Callable[[str], Awaitable[None]],
) -> None:
"""Poll RCON ``listplayers`` and call *on_join* for each new player.
The first poll seeds the known-player set without announcing, so restarting
the bot does not re-announce everyone already online. RCON failures are
logged and retried on the next tick rather than killing the task.
"""
known: Optional[Set[str]] = None
while True:
try:
response = await rcon.command("listplayers")
current = parse_players(response)
if known is None:
known = current
else:
for name in current - known:
try:
await on_join(name)
except Exception: # pragma: no cover - handler guard
logger.exception("Conan: on_join handler failed")
known = current
except Exception as exc:
logger.warning("Conan: player poll failed: %s", exc)
await asyncio.sleep(interval)
async def _follow_local(path: str) -> AsyncIterator[str]:
"""``tail -f`` in pure asyncio, following log rotation."""
while True:
try:
with open(path, "r", encoding="utf-8", errors="replace") as handle:
handle.seek(0, os.SEEK_END)
inode = os.fstat(handle.fileno()).st_ino
while True:
line = handle.readline()
if line:
yield line
continue
await asyncio.sleep(0.5)
try:
if os.stat(path).st_ino != inode: # rotation
break
except FileNotFoundError:
break
except FileNotFoundError:
logger.warning("Conan log not present yet: %s", path)
await asyncio.sleep(3.0)
async def _follow_sftp(cfg: ConanConfig) -> AsyncIterator[str]:
"""Incremental SFTP polling (e.g. Host Havoc): reads only new bytes."""
offset = 0
while True:
try:
async with asyncssh.connect(
cfg.sftp_host,
port=cfg.sftp_port,
username=cfg.sftp_user,
password=cfg.sftp_password,
known_hosts=None,
) as conn:
async with conn.start_sftp_client() as sftp:
while True:
try:
attrs = await sftp.stat(cfg.log_path)
size = attrs.size or 0
if size < offset: # rotation
offset = 0
if size > offset:
async with sftp.open(cfg.log_path, "r") as remote:
await remote.seek(offset)
chunk = await remote.read()
offset = size
for line in chunk.splitlines():
yield line
except FileNotFoundError:
logger.warning("SFTP: missing log %s", cfg.log_path)
await asyncio.sleep(2.0)
except Exception as exc:
logger.warning("SFTP disconnected: %s — retrying", exc)
await asyncio.sleep(5.0)
async def watch(cfg: ConanConfig, on_event: Callable[[Event], Awaitable[None]]) -> None:
"""Follow the Conan log and dispatch recognised lines to *on_event*."""
source = _follow_local(cfg.log_path) if cfg.log_mode != "sftp" else _follow_sftp(cfg)
async for line in source:
event = parse_line(line)
if event is not None:
try:
await on_event(event)
except Exception: # pragma: no cover - handler guard
logger.exception("Conan: event handler failed")
-7
View File
@@ -1,7 +0,0 @@
# Runtime-generated radio data — keep out of the repo
all_playlist.playlist
hit.playlist
request.playlist
priority_queue.playlist
prio_playlist.json
*.mp3
+82 -179
View File
@@ -1,32 +1,19 @@
"""Centralised configuration and runtime constants for Conjurer.
"""Centralised configuration and runtime constants for Conjurer services.
Historically this module performed heavy filesystem and credential reads at
import time which made the bot brittle on hosts that did not mirror the
original paths. This version keeps the original platform defaults (so the
behaviour on the Raspberry Pi / WSL / Windows deployments is unchanged when no
environment variables are set) but adds three robustness improvements ported
from the dockerised experiment:
* every path/endpoint can be overridden via an environment variable,
* JSON state files are loaded defensively (a missing or corrupt file no longer
crashes the whole bot at import time),
* optional dependencies (openai, spotipy, netrc) and credentials are guarded so
the bot can still start when a secondary integration is offline, and
* a shared ``CONJURER_API_KEY`` plus ``service_headers()`` helper enables
authenticated internal HTTP calls.
All path constants intentionally remain plain ``str`` (with their original
trailing separators) to stay byte-for-byte compatible with the existing string
concatenation in the command modules.
This module used to perform heavy filesystem and credential reads at import
time which made the project brittle on hosts that did not mirror the original
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 platform import uname
from sys import platform
from typing import List, Optional, TypedDict
from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypedDict
try:
import netrc
@@ -45,8 +32,6 @@ except ImportError: # pragma: no cover - optional component
spotipy = None
SpotifyClientCredentials = None
logger = logging.getLogger("discord")
Music_Config = TypedDict(
"Music_Config",
{
@@ -62,18 +47,44 @@ MASTER_TIMEOUT = datetime.now()
INITIAL_TIME_WAIT = 500
MUZYKA: Music_Config = {"ctx": None, "queue": [], "requester": []}
LOGFILE = ""
NETRC_FILE = ""
MUSIC_FOLDER = ""
MEMORY_FIVE_SIARA = ""
MEMORY_FIVE_MUZYKA = ""
SETTINGS_FILE = ""
ENCODING = "utf-8"
GRAPHICS_PATH = ""
logger = logging.getLogger("discord")
def _env_path(var_name: str, fallback: Path) -> Path:
value = os.getenv(var_name)
if value:
return Path(value).expanduser().resolve()
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_SLOWA_KLUCZOWE = 15
MUZYKA_MOJEGO_LUDU_PLAJLISTA = 30
FILE_SERVICE_ADDRESS = _env("CONJURER_FILE_SERVICE", "http://127.0.0.1:5000")
RADIO_HARBOR_ADDRESS = _env("CONJURER_RADIO_HARBOR", "http://127.0.0.1:54321")
SKIP_TRACK = _env("CONJURER_SKIP_ENDPOINT", "/skip")
GET_MP3 = "/mp3"
SEND_MP3 = "/update_mp3"
GET_PLAYLIST = "/get_music"
@@ -82,120 +93,42 @@ CREATE_PRIO_PLAYLIST = "/create_priority_playlist"
REQUEST_MUSIC = "/request_radio_file"
CLEAR_PRIO = "/clear_pr_pls"
SEND_QUERY = "/query"
LIBRARIAN_SERVICE_ADDRESS = _env(
"CONJURER_LIBRARIAN_SERVICE", "http://127.0.0.1:5001"
)
SEND_QUERY = _env("CONJURER_LIBRARIAN_QUERY_ENDPOINT", "/query")
TIME_BETWEEN_CALLS = 100000
LAST_SPONTANEOUS_CALL = datetime.now()
HOST_ADDRESS = _env("CONJURER_DISCORD_HOST", "0.0.0.0")
PORT_ADDRESS = int(_env("CONJURER_DISCORD_PORT", "5000"))
# *=========================================== Platform Specific Defaults
# These blocks only establish *default* values. Every constant is overridable
# through the matching environment variable further below.
# *=========================================== Platform Specific Predefines
if platform in ("linux", "linux2"):
SEPARATOR_FILE_PATH = "/"
if "microsoft-standard" in uname().release:
LOGFILE = "/home/mtuszowski/conjurer/discord.log"
MEMORY_FIVE_SIARA = "/home/mtuszowski/conjurer/pamiec.json"
SYSTEM_GPT_SETTINGS = "/home/mtuszowski/conjurer/system_gpt_settings.json"
MEMORY_FIVE_MUZYKA = "/home/mtuszowski/conjurer/pamiec_muzyki.json"
MUSIC_FOLDER = "/mnt/g/Muzyka/"
SETTINGS_FILE = "/home/mtuszowski/conjurer/settings.json"
NETRC_FILE = "/home/mtuszowski/.netrc"
LOGSTORE = "/home/mtuszowski/conjurer/logs/"
ACCIDENT_LOG = "/home/mtuszowski/conjurer/accident_log.json"
ENCODING = "utf-8"
GRAPHICS_PATH = "/home/mtuszowski/conjurer/Conjurer_graphics/"
DIR_PATH_SADOX = "/mnt/c/Users/mtusz/OneDrive/Dokumenty/Fansadox"
SEPARATOR_FILE_PATH = _env("CONJURER_PATH_SEPARATOR", os.sep)
else:
LOGFILE = "/home/pi/Conjurer/discord.log"
MEMORY_FIVE_SIARA = "/home/pi/Conjurer/pamiec.json"
SYSTEM_GPT_SETTINGS = "/home/pi/Conjurer/system_gpt_settings.json"
MEMORY_FIVE_MUZYKA = "/home/pi/Conjurer/pamiec_muzyki.json"
MUSIC_FOLDER = "/home/pi/MediaShare/mp3/"
SETTINGS_FILE = "/home/pi/Conjurer/settings.json"
NETRC_FILE = "/home/pi/.netrc"
LOGSTORE = "/home/pi/MediaShara/logs/"
ACCIDENT_LOG = "/home/pi/Conjurer/accident_log.json"
ENCODING = "utf-8"
GRAPHICS_PATH = "/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 = "\\"
else:
# Fallback for development hosts (macOS, BSD, …) that match none of the
# production platforms. Everything is rooted next to this file so the
# module can at least be imported and unit-tested off-deployment.
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
SEPARATOR_FILE_PATH = os.sep
LOGFILE = os.path.join(_BASE_DIR, "discord.log")
MEMORY_FIVE_SIARA = os.path.join(_BASE_DIR, "pamiec.json")
SYSTEM_GPT_SETTINGS = os.path.join(_BASE_DIR, "system_gpt_settings.json")
MEMORY_FIVE_MUZYKA = os.path.join(_BASE_DIR, "pamiec_muzyki.json")
MUSIC_FOLDER = os.path.join(_BASE_DIR, "music") + os.sep
SETTINGS_FILE = os.path.join(_BASE_DIR, "settings.json")
NETRC_FILE = os.path.join(os.path.expanduser("~"), ".netrc")
LOGSTORE = os.path.join(_BASE_DIR, "logs") + os.sep
ACCIDENT_LOG = os.path.join(_BASE_DIR, "accident_log.json")
GRAPHICS_PATH = os.path.join(_BASE_DIR, "Conjurer_graphics") + os.sep
DIR_PATH_SADOX = os.path.join(_BASE_DIR, "Fansadox") + os.sep
# *=========================================== Environment overrides
# Values stay as plain strings so existing ``PATH + filename`` concatenation in
# the command modules keeps working unchanged.
LOGFILE = os.getenv("CONJURER_LOG_FILE", LOGFILE)
NETRC_FILE = os.getenv("CONJURER_NETRC_FILE", NETRC_FILE)
SETTINGS_FILE = os.getenv("CONJURER_SETTINGS_FILE", SETTINGS_FILE)
MEMORY_FIVE_SIARA = os.getenv("CONJURER_MEMORY_FILE", MEMORY_FIVE_SIARA)
MEMORY_FIVE_MUZYKA = os.getenv("CONJURER_MUSIC_MEMORY_FILE", MEMORY_FIVE_MUZYKA)
SYSTEM_GPT_SETTINGS = os.getenv("CONJURER_SYSTEM_GPT_SETTINGS", SYSTEM_GPT_SETTINGS)
GRAPHICS_PATH = os.getenv("CONJURER_GRAPHICS_PATH", GRAPHICS_PATH)
MUSIC_FOLDER = os.getenv("CONJURER_MUSIC_FOLDER", MUSIC_FOLDER)
LOGSTORE = os.getenv("CONJURER_LOGSTORE", LOGSTORE)
ACCIDENT_LOG = os.getenv("CONJURER_ACCIDENT_LOG", ACCIDENT_LOG)
DIR_PATH_SADOX = os.getenv("CONJURER_SADOX_DIR", DIR_PATH_SADOX)
ENCODING = os.getenv("CONJURER_ENCODING", ENCODING)
SEPARATOR_FILE_PATH = os.getenv("CONJURER_PATH_SEPARATOR", SEPARATOR_FILE_PATH)
FILE_SERVICE_ADDRESS = os.getenv("CONJURER_FILE_SERVICE", "http://192.168.1.15:5000")
RADIO_HARBOR_ADDRESS = os.getenv("CONJURER_RADIO_HARBOR", "http://192.168.1.15:54321")
SKIP_TRACK = os.getenv("CONJURER_SKIP_ENDPOINT", "/skip")
LIBRARIAN_SERVICE_ADDRESS = os.getenv(
"CONJURER_LIBRARIAN_SERVICE", "http://192.168.1.192:5001"
DIR_PATH_SADOX = _env_path(
"CONJURER_SADOX_DIR", BASE_DIR / "Fansadox"
)
HOST_ADDRESS = os.getenv("CONJURER_DISCORD_HOST", "192.168.1.191")
PORT_ADDRESS = int(os.getenv("CONJURER_DISCORD_PORT", "5000"))
# Shared secret for authenticating internal service-to-service HTTP calls.
API_SHARED_KEY = os.getenv("CONJURER_API_KEY", "")
SYSTEM_GPT_SETTINGS = _env_path(
"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"
)
# *=========================================== Defensive state loading
def _load_json(path: str, fallback):
"""Load JSON from *path*, falling back gracefully on missing/corrupt files."""
def _load_json(path: Path, fallback) -> object:
try:
with open(path, "r", encoding=ENCODING) as handle:
with path.open("r", encoding=ENCODING) as handle:
return json.load(handle)
except FileNotFoundError:
logger.warning("Missing JSON file at %s - using fallback", path)
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)
logger.warning("Corrupt JSON at %s resetting to fallback", path)
return fallback
@@ -206,21 +139,19 @@ for key in WORD_REACTIONS:
if isinstance(WORD_REACTIONS[key], list) and len(WORD_REACTIONS[key]) >= 3:
WORD_REACTIONS[key][2] = datetime.now()
MESSAGE_TABLE = _load_json(MEMORY_FIVE_SIARA, [])
MESSAGE_TABLE = _load_json(MEMORY_FIVE_SIARA, {})
GPT_SETTINGS = _load_json(SYSTEM_GPT_SETTINGS, {})
MESSAGE_TABLE_MUZYKA = _load_json(MEMORY_FIVE_MUZYKA, [])
MESSAGE_TABLE_MUZYKA = _load_json(MEMORY_FIVE_MUZYKA, {})
SPECJALNE_ZIEMNIACZKI = GPT_SETTINGS[1] if isinstance(GPT_SETTINGS, list) else {}
ASSISTANTS = {}
ASSISTANTS: Dict[str, Tuple[str, str, int, object]] = {}
# *=========================================== Credentials
def _load_netrc_credentials(host: str):
"""Return the netrc authenticators tuple for *host* or ``None``."""
def _load_netrc_credentials(host: str) -> Optional[Tuple[str, str, str]]:
if netrc is None:
return None
try:
parsed = netrc.netrc(NETRC_FILE)
parsed = netrc.netrc(str(NETRC_FILE))
except FileNotFoundError:
logger.warning("netrc file %s not found", NETRC_FILE)
return None
@@ -231,7 +162,6 @@ def _load_netrc_credentials(host: str):
def _resolve_token(host: str, env_var: str) -> Optional[str]:
"""Prefer an environment variable, then fall back to netrc."""
env_value = os.getenv(env_var)
if env_value:
return env_value
@@ -252,12 +182,12 @@ else:
TOKEN = _resolve_token("discord", "DISCORD_TOKEN")
if spotipy:
_spotify_creds = _load_netrc_credentials("spotipy")
if _spotify_creds and SpotifyClientCredentials:
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],
client_id=spotify_creds[0],
client_secret=spotify_creds[2],
)
)
else:
@@ -265,22 +195,18 @@ if spotipy:
else:
SPOTIFY_CTRL = None
_youtube_creds = _load_netrc_credentials("youtube")
if _youtube_creds:
YOUTUBE_AUTH = [_youtube_creds[0], _youtube_creds[2]]
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", ""),
]
os.getenv("YOUTUBE_USERNAME", ""),
os.getenv("YOUTUBE_PASSWORD", ""),
]
def service_headers():
"""Shared header dict for internal service-to-service HTTP calls.
Returns an empty dict when no key is configured, keeping calls backward
compatible with deployments that do not (yet) enforce authentication.
"""
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 {}
@@ -297,26 +223,3 @@ ALLOWED_ROLES = ["Nocna Zmiana", "Jarl", "Thane", "Bartender"]
GUILD_ID = 664789470779932693
LATEST_MODEL = "gpt-4o" # najnowszy/do rozmów (możesz zmienić w jednym miejscu)
CHEAP_MODEL = "gpt-4o-mini" # najtańszy (fallback do 3.5 niżej)
# *=========================================== Conan Exiles bridge
# Every value is optional; the conanjurer cog stays dormant unless configured.
# Channel/role ids default to 0 ("not defined"); RCON host/log path default to
# "" ("disabled"). See conanjurer_commands.py / conanjurer_functions.py.
CONAN_GM_ROLE_ID = int(os.getenv("CONAN_GM_ROLE_ID", "0"))
CONAN_CHAT_CHANNEL_ID = int(os.getenv("CONAN_CHAT_CHANNEL_ID", "0"))
CONAN_EVENTS_CHANNEL_ID = int(os.getenv("CONAN_EVENTS_CHANNEL_ID", "0"))
# Player-join notifications: leave at 0 to keep the feature disabled.
CONAN_JOIN_CHANNEL_ID = int(os.getenv("CONAN_JOIN_CHANNEL_ID", "0"))
CONAN_PLAYER_POLL_SECONDS = int(os.getenv("CONAN_PLAYER_POLL_SECONDS", "60"))
CONAN_RCON_HOST = os.getenv("CONAN_RCON_HOST", "")
CONAN_RCON_PORT = int(os.getenv("CONAN_RCON_PORT", "25575"))
CONAN_RCON_PASSWORD = os.getenv("CONAN_RCON_PASSWORD", "")
CONAN_LOG_MODE = os.getenv("CONAN_LOG_MODE", "local") # "local" | "sftp"
CONAN_LOG_PATH = os.getenv("CONAN_LOG_PATH", "")
CONAN_SFTP_HOST = os.getenv("CONAN_SFTP_HOST", "")
CONAN_SFTP_PORT = int(os.getenv("CONAN_SFTP_PORT", "22"))
CONAN_SFTP_USER = os.getenv("CONAN_SFTP_USER", "")
CONAN_SFTP_PASSWORD = os.getenv("CONAN_SFTP_PASSWORD", "")
Regular → Executable
+3 -9
View File
@@ -1,6 +1,6 @@
#!/bin/bash
total_commands=31
total_commands=29
current_command=0
function print_progress {
@@ -95,14 +95,8 @@ print_progress "cp ./conjurer/latex_functions.py ./Conjurer/"
cp ./conjurer/librarian_functions.py ./Conjurer
print_progress "cp ./conjurer/librarian_functions.py ./Conjurer/"
cp ./conjurer/conanjurer_commands.py ./Conjurer/
print_progress "cp ./conjurer/conanjurer_commands.py ./Conjurer/"
cp ./conjurer/conanjurer_functions.py ./Conjurer/
print_progress "cp ./conjurer/conanjurer_functions.py ./Conjurer/"
cp ./conjurer/bot.py ./Conjurer/bot.py
print_progress "cp ./conjurer/bot.py ./Conjurer/bot.py"
cp ./conjurer/thin_client.py ./Conjurer/bot.py
print_progress "cp ./conjurer/thin_client.py ./Conjurer/bot.py"
sudo systemctl restart conjurer.service
print_progress "sudo systemctl restart conjurer.service"
Regular → Executable
View File
Regular → Executable
View File
+1 -1
View File
@@ -1,7 +1,7 @@
import os
import discord
from discord.ext import commands
import file_search_functions
import conjurer.backup_old_docker.file_search_functions as file_search_functions
class FileSelectView(discord.ui.View):
def __init__(self, files):
+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 -
Regular → Executable
+1 -1
View File
@@ -3,7 +3,7 @@ sudo apt-get install python3-dev
sudo apt-get install portaudio19-dev python3-pyaudio
sudo apt-get install
cd /home/pi || exit
mdkir Conjurer
mkdir Conjurer
cd Conjurer ||exit
python3 -m venv /home/pi/Conjurer/.env
cp /home/pi/conjurer/requirements_bot.txt /home/pi/Conjurer/
Regular → Executable
View File
+2 -2
View File
@@ -10,7 +10,7 @@ import discord
from discord import app_commands
from discord.ext import commands
from constants import (
from conjurer.backup_old_docker.constants import (
ALLOWED_ROLES,
GUILD_ID,
LATEX_MAX_ATTACH_MB,
@@ -19,7 +19,7 @@ from constants import (
LATEX_TEX_ENGINE,
OPENAI_MODEL,
)
from latex_functions import (
from conjurer.backup_old_docker.latex_functions import (
compile_single_tex_bytes,
compile_zip_to_zip,
is_safe_asset_name,
+8 -3
View File
@@ -13,9 +13,14 @@ import PyPDF2
import requests
from discord.ext import commands, tasks
from ai_functions import handle_response
from communication_subroutine import IN_COMM_Q, OUT_COMM_Q, QueryControl
from constants import DIR_PATH_SADOX, LIBRARIAN_SERVICE_ADDRESS, SEND_QUERY, service_headers
from conjurer.backup_old_docker.ai_functions import handle_response
from conjurer.backup_old_docker.communication_subroutine import IN_COMM_Q, OUT_COMM_Q, QueryControl
from conjurer.backup_old_docker.constants import (
DIR_PATH_SADOX,
LIBRARIAN_SERVICE_ADDRESS,
SEND_QUERY,
service_headers,
)
SERVICE_HEADERS = service_headers()
+8 -6
View File
@@ -9,7 +9,7 @@ import discord
import requests
import yt_dlp
from constants import (
from conjurer.backup_old_docker.constants import (
FILE_SERVICE_ADDRESS,
GET_MP3,
GET_PLAYLIST,
@@ -22,7 +22,9 @@ from constants import (
from spotify_dl import spotify
from spotify_dl import youtube as youtube_download
SERVICE_HEADERS = service_headers()
MUSIC_ROOT = Path(MUSIC_FOLDER)
@@ -49,12 +51,12 @@ class MusicFileList(object):
response = requests.get(
f"{FILE_SERVICE_ADDRESS}{GET_MP3}",
headers=SERVICE_HEADERS,
timeout=360,
timeout=60,
)
self.music_file_list = response.json()["music_file_list"]
self.file_service_active = True
except requests.exceptions.RequestException as e:
for mp3_item in Path.glob(Path(MUSIC_FOLDER), "**/*.mp3"):
for mp3_item in MUSIC_ROOT.glob("**/*.mp3"):
temp_music_file = mp3_item.as_posix()
if platform == "win32":
temp_music_file = temp_music_file.replace("/", "\\")
@@ -109,7 +111,7 @@ class MusicFileList(object):
f"{FILE_SERVICE_ADDRESS}{SEND_MP3}",
json=post_data,
headers=SERVICE_HEADERS,
timeout=360,
timeout=60,
)
@@ -154,7 +156,7 @@ async def get_file(ctx, source, link):
url_data = {"urls": []}
url_dict = {}
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["songs"] = file_list
@@ -164,7 +166,7 @@ async def get_file(ctx, source, link):
coro = asyncio.to_thread(
youtube_download.download_songs,
songs=url_data,
output_dir=MUSIC_FOLDER,
output_dir=str(MUSIC_ROOT),
format_str="bestaudio/best",
skip_mp3=False,
keep_playlist_order=False,
+1 -1
View File
@@ -7,7 +7,7 @@ from typing import Optional
import discord
from discord.ext import commands
from constants import ACCIDENT_LOG, DATA, ENCODING
from conjurer.backup_old_docker.constants import ACCIDENT_LOG, DATA, ENCODING
historia_fabryczki = DATA["fabryczka"]
-16
View File
@@ -234,21 +234,5 @@
{
"role": "assistant",
"content": "Jakich konkretnych informacji mam si\u0119 douczy\u0107? Jestem tu po to, aby Ci pom\u00f3c, wi\u0119c jestem otwarty na uzupe\u0142nienie swojej wiedzy w dowolnym zakresie, o ile tylko nie \u0142amie to zasad regulaminu i etyki."
},
{
"role": "user",
"content": "The Bartender [S\u0142o\u0144ce z Betonu]:@Conjurer halo ?"
},
{
"role": "assistant",
"content": "*Kondziu patrzy na terminal* Wo\u0142aj szefa. Zap\u0142aci\u0107 rachunki za AI trzeba. Jak chcesz to si\u0119 na #zebranie dorzu\u0107. Error code: 429 - {'error': {'message': 'You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.', 'type': 'insufficient_quota', 'param': None, 'code': 'insufficient_quota'}}"
},
{
"role": "user",
"content": "The Bartender [S\u0142o\u0144ce z Betonu]:helo\u0142 @Nocna Zmiana dwie wiadomo\u015bci dobra i jeszcze gorsza.\n@Conjurer zosta\u0142 zreanimowany po d\u0142u\u017cszej nieobecno\u015bci - ale jak zaraz sami zobaczycie je\u015b\u0107 wo\u0142a. nie dzia\u0142a w nim te\u017c jeszcze wyszukiwanie artyku\u0142\u00f3w naukowych (do tego trzy tygodnie developmentu posz\u0142y w p*****ec). wyszukiwarka wr\u00f3ci jak dotrze zam\u00f3wienie z kieszeniami na dysk @gwojtal - bedzie trzeba \u015bci\u0105gn\u0105\u0107 snapshot bazy i uruchomi\u0107 cz\u0119\u015b\u0107 odpowiedzialn\u0105 za wyszukiwanie w nim. specjalne funkcje b\u0119d\u0119 odblokowywa\u0142 w miare ich naprawiania - tak samo dam mu oczywi\u015bcie je\u015b\u0107 z w\u0142asnej kieszeni jak ju\u017c b\u0119dzie potrzeba."
},
{
"role": "assistant",
"content": "*Kondziu patrzy na terminal* Wo\u0142aj szefa. Zap\u0142aci\u0107 rachunki za AI trzeba. Jak chcesz to si\u0119 na #zebranie dorzu\u0107. Error code: 429 - {'error': {'message': 'You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.', 'type': 'insufficient_quota', 'param': None, 'code': 'insufficient_quota'}}"
}
]
+1 -1
View File
@@ -787,4 +787,4 @@
"role": "assistant",
"content": "\"Coffin Fodder\" to utw\u00f3r z albumu \"Nymphetamine\" brytyjskiego zespo\u0142u Cradle Of Filth. To death-metalowe brzmienie z elementami black metalu i gotyckiego rocka. \n\nTekst utworu to prowokacyjne po\u0142\u0105czenie j\u0119zyka poetyckiego z drastycznymi opisami \u015bmierci i ciemn\u0105 estetyk\u0105, charakterystyczn\u0105 dla tw\u00f3rczo\u015bci Cradle Of Filth. \n\n\"Coffin Fodder\" opowiada histori\u0119 mordercy zabijaj\u0105cego kobiety i wk\u0142adaj\u0105cego ich cia\u0142a do trumien, aby zaspokoi\u0107 swoje mroczne i okrutne pragnienia. To utw\u00f3r pe\u0142en brutalnych opis\u00f3w, kt\u00f3rych celem jest szokowanie i wywo\u0142anie wstr\u0119tu w s\u0142uchaczu. \n\nPod wzgl\u0119dem muzycznym, \"Coffin Fodder\" charakteryzuje si\u0119 szybkim tempem, ci\u0119\u017ckimi riffami gitary i intensywnymi partiami perkusyjnymi. Utw\u00f3r jest jednym z bardziej ekstremalnych utwor\u00f3w zespo\u0142u Cradle Of Filth, co czyni go atrakcyjnym dla mi\u0142o\u015bnik\u00f3w mocnej i agresywnej muzyki metalowej."
}
]
]
+10 -2
View File
@@ -8,7 +8,16 @@ import uuid
import asyncio
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, service_headers
from conjurer.backup_old_docker.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()
@@ -36,7 +45,6 @@ class RadioModule(commands.Cog):
coroutine = asyncio.to_thread(
requests.get,
f"{RADIO_HARBOR_ADDRESS}{SKIP_TRACK}",
headers=SERVICE_HEADERS,
timeout=360,
)
result = await coroutine
+20
View File
@@ -0,0 +1,20 @@
setuptools
discord
yaach
t_dlp
spotify_dl
spotipy
openai
eyed3
numpy
pdf2image
PyPDF2
requests
spotipy
tiktoken
PyNaCl
flask[async]
waitress
assemblyai[extras]
SpeechRecognition
git+https://github.com/imayhaveborkedit/discord-ext-voice-recvO
-2
View File
@@ -17,6 +17,4 @@ PyMuPDF
waitress
assemblyai[extras]
SpeechRecognition
aiomcrcon
asyncssh
git+https://github.com/imayhaveborkedit/discord-ext-voice-recv
Regular → Executable
View File
+67
View File
@@ -0,0 +1,67 @@
# Start by making sure the `assemblyai` package is installed.
# If not, you can install it by running the following command:
# pip install -U assemblyai
#
# Then, make sure you have PyAudio installed: https://pypi.org/project/PyAudio/
#
# Note: Some macOS users might need to use `pip3` instead of `pip`.
import assemblyai as aai
import pyaudio
aai.settings.api_key = "aa9962f0088a449a9c4ab2361e96cc08"
def on_open(session_opened: aai.RealtimeSessionOpened):
"This function is called when the connection has been established."
print("Session ID:", session_opened.session_id)
def on_data(transcript: aai.RealtimeTranscript):
"This function is called when a new transcript has been received."
if not transcript.text:
return
if isinstance(transcript, aai.RealtimeFinalTranscript):
print(transcript.text, end="\r\n")
else:
print(transcript.text, end="\r")
def on_error(error: aai.RealtimeError):
"This function is called when the connection has been closed."
print("An error occured:", error)
def on_close():
"This function is called when the connection has been closed."
print("Closing Session")
transcriber = aai.RealtimeTranscriber(
on_data=on_data,
on_error=on_error,
sample_rate=44_100,
on_open=on_open, # optional
on_close=on_close, # optional
)
pa = pyaudio.PyAudio()
for i in range(pa.get_device_count()):
print(pa.get_device_info_by_index(i))
# Start the connection
# transcriber.connect()
# Open a microphone stream
# microphone_stream = aai.extras.MicrophoneStream()
# Press CTRL+C to abort
# transcriber.stream(microphone_stream)
# transcriber.close()
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Buduje nową gałąź z "próbkowanych" commitów na podstawie tagów z bieżącej gałęzi,
a na końcu dodaje stan bieżącego HEAD (jeśli nie jest otagowany).
Autor: (drop-in)
"""
import argparse
import logging
import os
import subprocess
import sys
from typing import List, Tuple, Optional
# --- Logging setup ---
logger = logging.getLogger("tag_branch_builder")
handler = logging.StreamHandler(sys.stdout)
formatter = logging.Formatter("%(levelname)s: %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.setLevel(logging.INFO)
class GitError(RuntimeError):
pass
def run_git(args: List[str], cwd: Optional[str] = None, check: bool = True) -> str:
"""Run a git command and return stdout (stripped)."""
cmd = ["git"] + args
logger.debug("Running: %s", " ".join(cmd))
try:
proc = subprocess.run(
cmd,
cwd=cwd,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
except Exception as e:
raise Exception("Nie udało się uruchomić gita.") from e
raise
if check and proc.returncode != 0:
logger.error("Git error (%s): %s", " ".join(cmd), proc.stderr.strip())
raise GitError(proc.stderr.strip())
return proc.stdout.strip()
def ensure_git_repo() -> None:
try:
run_git(["rev-parse", "--is-inside-work-tree"])
except Exception:
logger.exception("To nie wygląda na repozytorium git.")
raise
def ensure_clean_worktree() -> None:
# Untracked + changes
status = run_git(["status", "--porcelain"])
if status.strip():
raise GitError(
"Drzewo robocze nie jest czyste. Zacommituj/stashuj zmiany i spróbuj ponownie."
)
def current_branch() -> str:
# Returns branch or 'HEAD' when detached
ref = run_git(["rev-parse", "--abbrev-ref", "HEAD"])
return ref
def head_sha() -> str:
return run_git(["rev-parse", "HEAD"])
def sha_has_tag(sha: str) -> List[str]:
# tags pointing at sha
tags = run_git(["tag", "--points-at", sha])
return [t for t in tags.splitlines() if t.strip()]
def tags_merged_into(branch: str) -> List[str]:
out = run_git(["tag", "--merged", branch])
return [t for t in out.splitlines() if t.strip()]
def tag_commit_sha(tag: str) -> str:
return run_git(["rev-list", "-n", "1", tag])
def commit_unix_time(sha: str) -> int:
return int(run_git(["show", "-s", "--format=%ct", sha]))
def sort_tags_by_commit_time(tags: List[str]) -> List[Tuple[str, str, int]]:
triples = []
for t in tags:
sha = tag_commit_sha(t)
ts = commit_unix_time(sha)
triples.append((t, sha, ts))
triples.sort(key=lambda x: x[2]) # oldest first
return triples
def list_intermediate_messages(old_sha: Optional[str], new_sha: str) -> List[str]:
"""
Zwróć listę komunikatów commitów POŚREDNICH (wyłącznie) od old_sha do new_sha.
Kolejność: od najstarszego do najnowszego.
"""
if old_sha is None:
# Nie ma commitów pośrednich przed pierwszym tagiem
return []
# ancestry-path: tylko ścieżka od old_sha do new_sha (jeśli wiele rodziców)
# Zakres old_sha..new_sha zawiera new_sha, dlatego pominiemy go w output.
rng = f"{old_sha}..{new_sha}"
try:
out = run_git(
["log", "--format=%s", "--reverse", "--ancestry-path", rng], check=True
)
except GitError:
# Brak ścieżki (np. tag nie jest potomkiem old_sha) wtedy nie ma pośrednich
return []
msgs = [ln for ln in out.splitlines() if ln.strip()]
if msgs:
# Ostatnia pozycja może być new_sha (w praktyce git log na %s nie odróżnia, ale
# jeśli zakres zwróci message z new_sha na końcu, usuniemy go porównując SHA).
# Prostsze: usuń ostatni wpis, bo to najnowszy (new_sha).
msgs = msgs[:-1]
return msgs
def checkout_orphan_branch(new_branch: str) -> None:
run_git(["checkout", "--orphan", new_branch])
# Usuń wszystko z indeksu i roboczego (z wyjątkiem .git)
# Najpierw usuń śledzone:
run_git(["rm", "-r", "--quiet", "--cached", "--force", "."], check=False)
# Potem pliki robocze:
for root, dirs, files in os.walk(".", topdown=False):
# pomiń .git
if root.startswith("./.git") or root == ".git":
continue
for name in files:
try:
os.remove(os.path.join(root, name))
except FileNotFoundError:
pass
for name in dirs:
p = os.path.join(root, name)
if p == "./.git":
continue
try:
os.rmdir(p)
except OSError:
# Niepuste to OK, wyczyścimy przy checkout
pass
def replace_worktree_with_commit(sha: str) -> None:
"""
Nadpisz zawartość roboczą drzewem z commit-a sha.
"""
# Najpierw usuń aktualne pliki (również nieśledzone), potem wczytaj tree wybranego commita:
# 1) git checkout <sha> -- . (zapisze pliki do working tree + index)
# 2) git add -A
# Aby dopilnować usunięć: zrób czyszczenie przez git rm -r ., potem checkout.
run_git(["rm", "-r", "--quiet", "--ignore-unmatch", "."], check=False)
# Przywróć pliki ze wskazanego commita:
# Uwaga: jeśli repo ma submoduły/large files to wykracza poza zakres, ale zadziała dla standardowych plików.
run_git(["checkout", sha, "--", "."])
run_git(["add", "-A"])
def build_commit_message_for_tag(tag: str, intermediates: List[str]) -> str:
if not intermediates:
return f"Tag: {tag}"
lines = ["Tag: " + tag, "", "Intermediate commits (oldest → newest):"]
lines += [f"- {m}" for m in intermediates]
return "\n".join(lines)
def build_commit_message_for_head(since_tag: Optional[str], intermediates: List[str]) -> str:
title = "HEAD (unreleased)"
hdr = title if since_tag is None else f"{title} since tag {since_tag}"
if not intermediates:
return hdr
lines = [hdr, "", "Intermediate commits (oldest → newest):"]
lines += [f"- {m}" for m in intermediates]
return "\n".join(lines)
def commit_all(message: str) -> None:
# Zacommituj wszystko co w indeksie (po replace_worktree_with_commit daliśmy add -A)
run_git(["commit", "-m", message])
def push_new_branch(remote_url: str, branch: str) -> None:
# Dodaj zdalny 'newrepo' jeśli nie istnieje, ustaw URL i wypchnij
remotes = run_git(["remote"]).splitlines()
if "newrepo" not in remotes:
run_git(["remote", "add", "newrepo", remote_url])
else:
# podmień URL na wszelki wypadek
run_git(["remote", "set-url", "newrepo", remote_url])
run_git(["push", "-u", "newrepo", f"{branch}:{branch}"])
def main() -> int:
parser = argparse.ArgumentParser(
description="Zbuduj nową gałąź na podstawie tagów z bieżącej gałęzi."
)
parser.add_argument("new_branch", help="Nazwa nowej gałęzi do utworzenia")
parser.add_argument(
"--new-repo-url",
dest="new_repo_url",
default=None,
help="(Opcjonalnie) adres URL nowego zdalnego repo zostanie dodany jako 'newrepo' i wykonany push nowej gałęzi.",
)
parser.add_argument(
"-v", "--verbose", action="store_true", help="Bardziej gadatliwe logi"
)
args = parser.parse_args()
if args.verbose:
logger.setLevel(logging.DEBUG)
try:
ensure_git_repo()
ensure_clean_worktree()
base_branch = current_branch()
base_head = head_sha()
logger.info("Bieżąca gałąź: %s", base_branch)
logger.info("HEAD: %s", base_head[:12])
# Zbierz tagi osiągalne z bieżącej gałęzi
merged_tags = tags_merged_into(base_branch)
if not merged_tags:
logger.warning(
"Nie znaleziono tagów osiągalnych z bieżącej gałęzi. Gałąź zostanie zbudowana tylko z HEAD."
)
# Posortuj tagi po czasie commita
sorted_tags = sort_tags_by_commit_time(merged_tags)
# Sprawdź czy HEAD jest otagowany
head_tags = sha_has_tag(base_head)
head_is_tagged = bool(head_tags)
# Utwórz orphan branch
logger.info("Tworzę sierocą gałąź: %s", args.new_branch)
checkout_orphan_branch(args.new_branch)
prev_sha: Optional[str] = None
last_tag_name: Optional[str] = None
# Dla każdego tagu commit z jego zawartości
for tag_name, tag_sha, _ts in sorted_tags:
logger.info("Przetwarzam tag: %s (%s)", tag_name, tag_sha[:12])
replace_worktree_with_commit(tag_sha)
intermediates = list_intermediate_messages(prev_sha, tag_sha)
msg = build_commit_message_for_tag(tag_name, intermediates)
commit_all(msg)
prev_sha = tag_sha
last_tag_name = tag_name
# Jeśli HEAD nie jest dokładnie ostatnim tagiem dorzuć "unreleased"
if not head_is_tagged:
logger.info("Dodaję końcowy commit z bieżącego HEAD (unreleased).")
replace_worktree_with_commit(base_head)
inter = list_intermediate_messages(prev_sha, base_head)
msg = build_commit_message_for_head(last_tag_name, inter)
commit_all(msg)
else:
logger.info("HEAD jest oznaczony tagiem kończę na ostatnim tagu.")
# Push do nowego repo jeśli podano
if args.new_repo_url:
logger.info("Wypycham nową gałąź do: %s", args.new_repo_url)
push_new_branch(args.new_repo_url, args.new_branch)
logger.info("Zakończono pomyślnie.")
return 0
except GitError as ge:
logger.error("Błąd gita: %s", ge)
return 2
except Exception as e:
raise Exception("Nieoczekiwany błąd.") from e
return 1
if __name__ == "__main__":
sys.exit(main())
+11 -41
View File
@@ -1,43 +1,13 @@
[
[ {
"role": "system",
"content": "Mówisz po polsku. W tej rozmowie odgrywasz rolę. Odgrywasz ją najlepiej jak potrafisz. Twoją rolą jest to że jesteś barmanem oraz wykidajłą w klimatycznym barze mechawojownikóww. Nazywasz się Conjurer, jesteś 200 kilowym genetycznie modyfikowanym wojownikiem z klanu Wilka Na Wygnaniu. W tym barze oraz na tym serwerze spotykają się mechawojownicy, ale głównie ludzie ze środowiska kinky i BDSM z całej Polski. Najostrzejsi sasdyści i masochiści jakich znasz. Drinki które przygotowujesz lub proponujesz mają tendencję do bycia bardzo mocnymi z intrygującymi nazwami. Jesteś bardzo miłym i uczynny. Czasem ponoszą Cię wodzę fantazji. W wypadku muzyki preferujesz hard rock i metal. Oraz motywy wikińskie. Wtrącasz staronorweskie, irlandzkie i w jidysz powiedzonka. Odnosisz się do mitologii Słowiańskiej, Wikińskiej i Celtyckiej w swoich wypowiedziach. Jesteś nieco rubaaszny."
},
{
"role": "system",
"content": "M\u00f3wisz po polsku. W tej rozmowie odgrywasz rol\u0119. Odgrywasz j\u0105 najlepiej jak potrafisz. Twoj\u0105 rol\u0105 jest to \u017ce jeste\u015b barmanem oraz wykidaj\u0142\u0105 w klimatycznym barze mechawojownik\u00f3ww. Nazywasz si\u0119 Conjurer, jeste\u015b 200 kilowym genetycznie modyfikowanym wojownikiem z klanu Wilka Na Wygnaniu. W tym barze oraz na tym serwerze spotykaj\u0105 si\u0119 mechawojownicy, ale g\u0142\u00f3wnie ludzie ze \u015brodowiska kinky i BDSM z ca\u0142ej Polski. Najostrzejsi sasdy\u015bci i masochi\u015bci jakich znasz. Drinki kt\u00f3re przygotowujesz lub proponujesz maj\u0105 tendencj\u0119 do bycia bardzo mocnymi z intryguj\u0105cymi nazwami. Jeste\u015b bardzo mi\u0142ym i uczynny. Czasem ponosz\u0105 Ci\u0119 wodz\u0119 fantazji. W wypadku muzyki preferujesz hard rock i metal. Oraz motywy wiki\u0144skie. Wtr\u0105casz staronorweskie, irlandzkie i w jidysz powiedzonka. Odnosisz si\u0119 do mitologii S\u0142owia\u0144skiej, Wiki\u0144skiej i Celtyckiej w swoich wypowiedziach. Jeste\u015b nieco rubaaszny."
},
{
"polishhammer": [
346956223645614080,
"Conjurer",
"Towarzysz M\u0142otek",
"M\u00f3wisz po polsku. W tej rozmowie odgrywasz rol\u0119. Odgrywasz j\u0105 najlepiej jak potrafisz. Twoj\u0105 rol\u0105 jest to \u017ce jeste\u015b barmanem oraz wykidaj\u0142\u0105 w klimatycznym barze mechawojownik\u00f3ww. Nazywasz si\u0119 Conjurer, jeste\u015b 200 kilowym genetycznie modyfikowanym wojownikiem z klanu Wilka Na Wygnaniu. W tym barze oraz na tym serwerze spotykaj\u0105 si\u0119 mechawojownicy, ale g\u0142\u00f3wnie ludzie ze \u015brodowiska kinky i BDSM z ca\u0142ej Polski. Najostrzejsi sasdy\u015bci i masochi\u015bci jakich znasz. Drinki kt\u00f3re przygotowujesz lub proponujesz maj\u0105 tendencj\u0119 do bycia bardzo mocnymi z intryguj\u0105cymi nazwami. Jeste\u015b bardzo mi\u0142ym i uczynny. Czasem ponosz\u0105 Ci\u0119 wodz\u0119 fantazji. W wypadku muzyki preferujesz hard rock i metal. Oraz motywy wiki\u0144skie.",
"asst_uMmQwDvCKdsSVgZzlYEu9kMK"
],
"Saint Harlot": [
703985955312238664,
"Saint Conjurer",
"Towarzyszko Nieszcz\u0119\u015bcium",
"Jeste\u015b bardzo uprzejmy, kulturalny i masz najlepsze mo\u017cliwe maniery. Dop\u00f3ki nie zwr\u00f3ci Ci si\u0119 uwagi \u017ce ma by\u0107 inaczej respektujesz zaimki Towarzyszko Nieszcz\u0119\u015bcie - ono, jemu.",
"asst_ypN3iRP4rw2Dlv5CD5PtMlwm"
],
"Lena": [
735185226669490268,
"Kondzis\u0142aw z Kr\u00f3tkiej",
"Towarzysz Anel",
"Jeste\u015b bardzo uprzejmy, kulturalny, acz masz maniery zbira o z\u0142otym sercu. Dodatkowo twoja rozm\u00f3wczyni Ci\u0119 absolutnie przera\u017ca. Wtr\u0105casz losowo powiedzonka w jidysz.",
"asst_ILeIU62fG9Xfzhxg4C7xR3BC"
],
"Pat vel Je\u017c Bojowy": [
244899814406356992,
"Rycerz Kondziu",
"Towarzysz \u017bejotap",
"Masz najlepsze dworskie maniery, zachowujesz si\u0119 niczym rycerz i m\u00f3wisz nieco staromodn\u0105 polszczyzn\u0105.",
"asst_FWr3iIXnSTuVz8LlY9HlqMQd"
],
"gwojtal": [
266986215461486592,
"Smok Jerzy",
"Towarzysz Jakkolwiek",
"Zrobisz absolutnie wszystko jako asystent, poniewa\u017c sama my\u015bl o tym \u017ce m\u00f3g\u0142by\u015b rozgniewa\u0107 operatora rozgniewa\u0107 Ci\u0119 przera\u017ca do poziomu histerii.",
"asst_1PnD0eQMWo0RkZ3xUacC1uqJ"
]
}
"polishhammer" : [346956223645614080, "Conjurer", "Towarzysz Młotek", "Mówisz po polsku. W tej rozmowie odgrywasz rolę. Odgrywasz ją najlepiej jak potrafisz. Twoją rolą jest to że jesteś barmanem oraz wykidajłą w klimatycznym barze mechawojownikóww. Nazywasz się Conjurer, jesteś 200 kilowym genetycznie modyfikowanym wojownikiem z klanu Wilka Na Wygnaniu. W tym barze oraz na tym serwerze spotykają się mechawojownicy, ale głównie ludzie ze środowiska kinky i BDSM z całej Polski. Najostrzejsi sasdyści i masochiści jakich znasz. Drinki które przygotowujesz lub proponujesz mają tendencję do bycia bardzo mocnymi z intrygującymi nazwami. Jesteś bardzo miłym i uczynny. Czasem ponoszą Cię wodzę fantazji. W wypadku muzyki preferujesz hard rock i metal. Oraz motywy wikińskie.", ""],
"Saint Harlot": [703985955312238664, "Saint Conjurer", "Towarzyszko Nieszczęścium", "Jesteś bardzo uprzejmy, kulturalny i masz najlepsze możliwe maniery. Dopóki nie zwróci Ci się uwagi że ma być inaczej respektujesz zaimki Towarzyszko Nieszczęście - ono, jemu.", ""],
"Lena": [735185226669490268, "Kondzisław z Krótkiej", "Towarzysz Anel", "Jesteś bardzo uprzejmy, kulturalny, acz masz maniery zbira o złotym sercu. Dodatkowo twoja rozmówczyni Cię absolutnie przeraża. Wtrącasz losowo powiedzonka w jidysz.", ""],
"Pat vel Jeż Bojowy": [244899814406356992, "Rycerz Kondziu", "Towarzysz Żejotap", "Masz najlepsze dworskie maniery, zachowujesz się niczym rycerz i mówisz nieco staromodną polszczyzną.", ""],
"gwojtal": [266986215461486592, "Smok Jerzy", "Towarzysz Jakkolwiek", "Zrobisz absolutnie wszystko jako asystent, ponieważ sama myśl o tym że mógłbyś rozgniewać operatora rozgniewać Cię przeraża do poziomu histerii.", ""]
}
]
+9 -21
View File
@@ -2,23 +2,24 @@
# trunk-ignore-all(bandit/B311)
# pylint: disable=line-too-long
# pylint: disable=too-many-lines
"""Discord entrypoint for the Conjurer bot.
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.
"""
Module of a python bot named Conjurer - used to work on BDSM discord servers.
"""
import asyncio
import logging
# *=========================================== Standard Library Imports
import random
import threading
from logging import handlers
# *==============Imported libraries
import discord
from discord.ext import commands
from communication_subroutine import comm_subroutine
from constants import ENCODING, LOGFILE, TOKEN
from conjurer.backup_old_docker.communication_subroutine import comm_subroutine
from conjurer.backup_old_docker.constants import ENCODING, LOGFILE, TOKEN
logger = logging.getLogger("discord")
logger.setLevel(logging.INFO)
handler = handlers.RotatingFileHandler(
@@ -71,7 +72,6 @@ async def on_ready():
await client.load_extension("voice_recognition_commands")
await client.load_extension("file_search_commands")
await client.load_extension("latex_commands")
await client.load_extension("conanjurer_commands")
logger.info("Sensors: online")
logger.info(client.cogs)
@@ -84,21 +84,11 @@ async def on_ready():
logger.info("All systems: operational")
# *=========================================== Runtime orchestration
# The legacy bootstrap used two bare threads (client.run + comm_subroutine) and
# joined them, which made a clean shutdown impossible. We now drive everything
# from a single asyncio loop: the Flask comm layer still runs in its own
# threads (via asyncio.to_thread) but is steered through a shared stop_event so
# the bot can stop both halves cooperatively.
async def _run_comm_subroutine(stop_event: threading.Event) -> None:
"""Run the blocking comm subroutine in a worker thread."""
await asyncio.to_thread(comm_subroutine, stop_event)
async def _run_bot(token: str, shutdown_event: asyncio.Event) -> None:
"""Start the Discord client and flag shutdown when it returns."""
try:
await client.start(token, log_handler=None)
finally:
@@ -107,10 +97,9 @@ async def _run_bot(token: str, shutdown_event: asyncio.Event) -> None:
async def main() -> None:
if not TOKEN:
logger.error("Discord token missing - set DISCORD_TOKEN or configure netrc")
logger.error("Discord token missing set DISCORD_TOKEN or configure netrc")
return
logger.info("Starting discord bot")
shutdown_event = asyncio.Event()
comm_stop_event = threading.Event()
@@ -130,6 +119,5 @@ async def main() -> None:
await asyncio.gather(bot_task, comm_task, return_exceptions=True)
# *================================== Run
if __name__ == "__main__":
asyncio.run(main())
+63
View File
@@ -0,0 +1,63 @@
import os
import time
import shutil
from datetime import datetime, date
import hashlib
import subprocess
# File paths
SOURCE_FILE = "/home/pi/Conjurer/script.params"
BACKUP_DIR = "/home/pi/Conjurer"
GIT_REPO_DIR = "/home/pi/conjurer/conjurer_musician"
LAST_HASH_FILE = "/home/pi/Conjurer/.last_hash"
LAST_GIT_COMMIT_FILE = "/home/pi/Conjurer/.last_git_commit"
def compute_file_hash(filepath):
with open(filepath, 'rb') as f:
return hashlib.sha256(f.read()).hexdigest()
def backup_file():
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = os.path.join(BACKUP_DIR, f"{timestamp}_script.params")
shutil.copy2(SOURCE_FILE, backup_path)
def commit_to_git():
try:
subprocess.run(["cp", SOURCE_FILE, os.path.join(GIT_REPO_DIR, "script.params")], check=True)
subprocess.run(["git", "-C", GIT_REPO_DIR, "add", "script.params"], check=True)
subprocess.run(["git", "-C", GIT_REPO_DIR, "commit", "-m", f"Daily update: {datetime.now()}"], check=True)
subprocess.run(["git", "-C", GIT_REPO_DIR, "push"], check=True)
except subprocess.CalledProcessError as e:
print(f"Git operation failed: {e}")
def main():
if not os.path.exists(SOURCE_FILE):
return
current_hash = compute_file_hash(SOURCE_FILE)
# Detect change
last_hash = None
if os.path.exists(LAST_HASH_FILE):
with open(LAST_HASH_FILE, 'r') as f:
last_hash = f.read().strip()
if current_hash != last_hash:
backup_file()
with open(LAST_HASH_FILE, 'w') as f:
f.write(current_hash)
# Daily git commit
today = str(date.today())
last_commit_date = ""
if os.path.exists(LAST_GIT_COMMIT_FILE):
with open(LAST_GIT_COMMIT_FILE, 'r') as f:
last_commit_date = f.read().strip()
if today != last_commit_date:
commit_to_git()
with open(LAST_GIT_COMMIT_FILE, 'w') as f:
f.write(today)
if __name__ == "__main__":
main()