Compare commits

..

2 Commits

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 23:36:58 +02:00
35 changed files with 813 additions and 907 deletions
-21
View File
@@ -1,23 +1,2 @@
# 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.
+2 -2
View File
@@ -10,8 +10,8 @@ import discord
import numpy as np import numpy as np
from discord.ext import commands, tasks from discord.ext import commands, tasks
from conjurer.backup_old_docker.ai_functions import get_random_cyclic_message from ai_functions import get_random_cyclic_message
from conjurer.backup_old_docker.constants import ( from constants import (
ENCODING, ENCODING,
LAST_SPONTANEOUS_CALL, LAST_SPONTANEOUS_CALL,
LOGFILE, LOGFILE,
+3 -3
View File
@@ -10,11 +10,11 @@ import discord
import openai import openai
import requests import requests
from discord.ext import commands from discord.ext import commands
from conjurer.backup_old_docker.other_functions import discord_friendly_send, discord_friendly_reply from other_functions import discord_friendly_send, discord_friendly_reply
import conjurer.backup_old_docker.ai_functions as ai_functions import ai_functions
from conjurer.backup_old_docker.constants import ( from constants import (
ASSISTANTS, ASSISTANTS,
DATA, DATA,
GRAPHICS_PATH, GRAPHICS_PATH,
+2 -2
View File
@@ -6,8 +6,8 @@ import random
import openai import openai
import tiktoken import tiktoken
import time import time
from conjurer.backup_old_docker.other_functions import discord_friendly_send from other_functions import discord_friendly_send
from conjurer.backup_old_docker.constants import ( from constants import (
ASSISTANTS, ASSISTANTS,
CYCLIC_WORDS, CYCLIC_WORDS,
ENCODING, ENCODING,
+21 -9
View File
@@ -2,24 +2,23 @@
# 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.
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 asyncio
import logging import logging
# *=========================================== Standard Library Imports
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
from conjurer.backup_old_docker.communication_subroutine import comm_subroutine from communication_subroutine import comm_subroutine
from conjurer.backup_old_docker.constants import ENCODING, LOGFILE, TOKEN from constants import ENCODING, LOGFILE, TOKEN
logger = logging.getLogger("discord") logger = logging.getLogger("discord")
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
handler = handlers.RotatingFileHandler( handler = handlers.RotatingFileHandler(
@@ -72,6 +71,7 @@ async def on_ready():
await client.load_extension("voice_recognition_commands") await client.load_extension("voice_recognition_commands")
await client.load_extension("file_search_commands") await client.load_extension("file_search_commands")
await client.load_extension("latex_commands") await client.load_extension("latex_commands")
await client.load_extension("conanjurer_commands")
logger.info("Sensors: online") logger.info("Sensors: online")
logger.info(client.cogs) logger.info(client.cogs)
@@ -84,11 +84,21 @@ async def on_ready():
logger.info("All systems: operational") 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: 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) await asyncio.to_thread(comm_subroutine, stop_event)
async def _run_bot(token: str, shutdown_event: asyncio.Event) -> None: async def _run_bot(token: str, shutdown_event: asyncio.Event) -> None:
"""Start the Discord client and flag shutdown when it returns."""
try: try:
await client.start(token, log_handler=None) await client.start(token, log_handler=None)
finally: finally:
@@ -97,9 +107,10 @@ async def _run_bot(token: str, shutdown_event: asyncio.Event) -> None:
async def main() -> None: async def main() -> None:
if not TOKEN: 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 return
logger.info("Starting discord bot")
shutdown_event = asyncio.Event() shutdown_event = asyncio.Event()
comm_stop_event = threading.Event() comm_stop_event = threading.Event()
@@ -119,5 +130,6 @@ async def main() -> None:
await asyncio.gather(bot_task, comm_task, return_exceptions=True) await asyncio.gather(bot_task, comm_task, return_exceptions=True)
# *================================== Run
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())
-46
View File
@@ -1,46 +0,0 @@
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*"
)
+31 -27
View File
@@ -11,9 +11,10 @@ from urllib import request as urequest
from flask import Flask, abort, 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 = os.getenv("CONJURER_DISCORD_HOST", "192.168.1.31")
PORT_ADDRESS = 5000 PORT_ADDRESS = int(os.getenv("CONJURER_DISCORD_PORT", "5000"))
ICECAST_ADDRESS = "http://192.168.1.15:8000" ICECAST_ADDRESS = os.getenv("CONJURER_ICECAST", "http://192.168.1.15:8000")
API_KEY = os.getenv("CONJURER_API_KEY")
OUT_COMM_Q = Queue() OUT_COMM_Q = Queue()
IN_COMM_Q = Queue() IN_COMM_Q = Queue()
SRCHTITLE = re.compile(rb"StreamTitle=\\*(?P<title>[^;]*);").search SRCHTITLE = re.compile(rb"StreamTitle=\\*(?P<title>[^;]*);").search
@@ -21,6 +22,12 @@ SRCHTITLE = re.compile(rb"StreamTitle=\\*(?P<title>[^;]*);").search
awaiting_q = [] awaiting_q = []
incoming_q = Queue() incoming_q = Queue()
app = Flask(__name__) 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 = { PREPPED_TRACKS = {
"requests": "", "requests": "",
"hit": "", "hit": "",
@@ -33,13 +40,6 @@ 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:
""" """
@@ -89,13 +89,13 @@ 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.
:return: The function `answer_external_command()` is returning a JSON response with the message :return: The function `answer_external_command()` is returning a JSON response with the message
"SUCCESS". "SUCCESS".
""" """
_authorize_request()
logger = logging.getLogger("discord") logger = logging.getLogger("discord")
logger.info(request) logger.info(request)
record = json.loads(request.data) record = json.loads(request.data)
@@ -144,10 +144,11 @@ 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.
:param _logger: The `_logger` parameter is typically an instance of a logging object that is used to A bounded ``get(timeout=1)`` is used instead of a blocking ``get()`` so the
record and store log messages. It is commonly used to track the flow of the program, record errors, worker can observe ``stop_event`` and exit cleanly during shutdown.
and provide information for debugging purposes. In this code snippet, the `_logger` object is used
to log the :param stop_event: optional :class:`threading.Event`; when set the loop
stops at the next iteration.
""" """
logger = logging.getLogger("discord") logger = logging.getLogger("discord")
while True: while True:
@@ -167,10 +168,8 @@ 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.
:param _logger: The `_logger` parameter in the `scan_incoming` function is a logger object that is :param stop_event: optional :class:`threading.Event`; when set the loop
used to log messages or information during the execution of the function. It is typically used for stops at the next iteration.
debugging, monitoring, or tracking the flow of the program. In this case, the `_logger` is being
used
""" """
logger = logging.getLogger("discord") logger = logging.getLogger("discord")
while True: while True:
@@ -228,23 +227,28 @@ 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.
:param logger: The `logger` parameter in the `comm_subroutine` function is an instance of a logger Workers run as daemon threads and honour an optional ``stop_event`` so the
object that is used for logging messages at various levels (e.g., debug, info, warning, error). In bot can shut the communication layer down cleanly instead of blocking
the provided code snippet, the logger is used to log messages at the "info" level forever on ``join()``.
:param stop_event: optional :class:`threading.Event` shared with the caller
to coordinate a cooperative shutdown.
""" """
# logger.setLevel(logging.DEBUG) # logger.setLevel(logging.DEBUG)
logger = logging.getLogger("discord") logger = logging.getLogger("discord")
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, daemon=True))
threads.append( threads.append(
threading.Thread(target=waitress_run, daemon=True) threading.Thread(
target=scan_queue, kwargs={"stop_event": stop_event}, daemon=True
)
) )
threads.append( threads.append(
threading.Thread(target=scan_queue, kwargs={"stop_event": stop_event}, daemon=True) threading.Thread(
) target=scan_incoming, 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:
+217
View File
@@ -0,0 +1,217 @@
# 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
@@ -0,0 +1,266 @@
# 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
@@ -0,0 +1,7 @@
# Runtime-generated radio data — keep out of the repo
all_playlist.playlist
hit.playlist
request.playlist
priority_queue.playlist
prio_playlist.json
*.mp3
+179 -82
View File
@@ -1,19 +1,32 @@
"""Centralised configuration and runtime constants for Conjurer services. """Centralised configuration and runtime constants for Conjurer.
This module used to perform heavy filesystem and credential reads at import Historically this module performed heavy filesystem and credential reads at
time which made the project brittle on hosts that did not mirror the original import time which made the bot brittle on hosts that did not mirror the
paths. The current implementation defers that work, reads configuration from original paths. This version keeps the original platform defaults (so the
environment variables (with sensible fallbacks), and protects optional behaviour on the Raspberry Pi / WSL / Windows deployments is unchanged when no
dependencies so the main bot can start even when a secondary service is environment variables are set) but adds three robustness improvements ported
offline. from the dockerised experiment:
* every path/endpoint can be overridden via an environment variable,
* JSON state files are loaded defensively (a missing or corrupt file no longer
crashes the whole bot at import time),
* optional dependencies (openai, spotipy, netrc) and credentials are guarded so
the bot can still start when a secondary integration is offline, and
* a shared ``CONJURER_API_KEY`` plus ``service_headers()`` helper enables
authenticated internal HTTP calls.
All path constants intentionally remain plain ``str`` (with their original
trailing separators) to stay byte-for-byte compatible with the existing string
concatenation in the command modules.
""" """
import json import json
import logging import logging
import os import os
from datetime import datetime from datetime import datetime
from pathlib import Path from platform import uname
from typing import Dict, List, Optional, Tuple, TypedDict from sys import platform
from typing import List, Optional, TypedDict
try: try:
import netrc import netrc
@@ -32,6 +45,8 @@ except ImportError: # pragma: no cover - optional component
spotipy = None spotipy = None
SpotifyClientCredentials = None SpotifyClientCredentials = None
logger = logging.getLogger("discord")
Music_Config = TypedDict( Music_Config = TypedDict(
"Music_Config", "Music_Config",
{ {
@@ -47,44 +62,18 @@ 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": []}
logger = logging.getLogger("discord") LOGFILE = ""
NETRC_FILE = ""
MUSIC_FOLDER = ""
def _env_path(var_name: str, fallback: Path) -> Path: MEMORY_FIVE_SIARA = ""
value = os.getenv(var_name) MEMORY_FIVE_MUZYKA = ""
if value: SETTINGS_FILE = ""
return Path(value).expanduser().resolve() ENCODING = "utf-8"
return fallback GRAPHICS_PATH = ""
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 = _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" GET_MP3 = "/mp3"
SEND_MP3 = "/update_mp3" SEND_MP3 = "/update_mp3"
GET_PLAYLIST = "/get_music" GET_PLAYLIST = "/get_music"
@@ -93,42 +82,120 @@ 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 = _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 = _env("CONJURER_DISCORD_HOST", "0.0.0.0")
PORT_ADDRESS = int(_env("CONJURER_DISCORD_PORT", "5000"))
# *=========================================== Platform Specific Predefines # *=========================================== Platform Specific Defaults
# These blocks only establish *default* values. Every constant is overridable
# through the matching environment variable further below.
SEPARATOR_FILE_PATH = _env("CONJURER_PATH_SEPARATOR", os.sep) 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"
DIR_PATH_SADOX = _env_path( else:
"CONJURER_SADOX_DIR", BASE_DIR / "Fansadox" 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"
) )
HOST_ADDRESS = os.getenv("CONJURER_DISCORD_HOST", "192.168.1.191")
PORT_ADDRESS = int(os.getenv("CONJURER_DISCORD_PORT", "5000"))
SYSTEM_GPT_SETTINGS = _env_path( # Shared secret for authenticating internal service-to-service HTTP calls.
"CONJURER_SYSTEM_GPT_SETTINGS", BASE_DIR / "system_gpt_settings.json" API_SHARED_KEY = os.getenv("CONJURER_API_KEY", "")
)
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: # *=========================================== Defensive state loading
def _load_json(path: str, fallback):
"""Load JSON from *path*, falling back gracefully on missing/corrupt files."""
try: try:
with path.open("r", encoding=ENCODING) as handle: with open(path, "r", encoding=ENCODING) as handle:
return json.load(handle) return json.load(handle)
except FileNotFoundError: except FileNotFoundError:
logger.warning("Missing JSON file at %s using fallback", path) logger.warning("Missing JSON file at %s - using fallback", path)
return fallback return fallback
except json.JSONDecodeError: 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 return fallback
@@ -139,19 +206,21 @@ for key in WORD_REACTIONS:
if isinstance(WORD_REACTIONS[key], list) and len(WORD_REACTIONS[key]) >= 3: if isinstance(WORD_REACTIONS[key], list) and len(WORD_REACTIONS[key]) >= 3:
WORD_REACTIONS[key][2] = datetime.now() 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, {}) 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 {} SPECJALNE_ZIEMNIACZKI = GPT_SETTINGS[1] if isinstance(GPT_SETTINGS, list) else {}
ASSISTANTS: Dict[str, Tuple[str, str, int, object]] = {} ASSISTANTS = {}
def _load_netrc_credentials(host: str) -> Optional[Tuple[str, str, str]]: # *=========================================== Credentials
def _load_netrc_credentials(host: str):
"""Return the netrc authenticators tuple for *host* or ``None``."""
if netrc is None: if netrc is None:
return None return None
try: try:
parsed = netrc.netrc(str(NETRC_FILE)) parsed = netrc.netrc(NETRC_FILE)
except FileNotFoundError: except FileNotFoundError:
logger.warning("netrc file %s not found", NETRC_FILE) logger.warning("netrc file %s not found", NETRC_FILE)
return None return None
@@ -162,6 +231,7 @@ def _load_netrc_credentials(host: str) -> Optional[Tuple[str, str, str]]:
def _resolve_token(host: str, env_var: str) -> Optional[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) env_value = os.getenv(env_var)
if env_value: if env_value:
return env_value return env_value
@@ -182,12 +252,12 @@ else:
TOKEN = _resolve_token("discord", "DISCORD_TOKEN") TOKEN = _resolve_token("discord", "DISCORD_TOKEN")
if spotipy: if spotipy:
spotify_creds = _load_netrc_credentials("spotipy") _spotify_creds = _load_netrc_credentials("spotipy")
if spotify_creds and SpotifyClientCredentials: if _spotify_creds and SpotifyClientCredentials:
SPOTIFY_CTRL = spotipy.Spotify( SPOTIFY_CTRL = spotipy.Spotify(
client_credentials_manager=SpotifyClientCredentials( client_credentials_manager=SpotifyClientCredentials(
client_id=spotify_creds[0], client_id=_spotify_creds[0],
client_secret=spotify_creds[2], client_secret=_spotify_creds[2],
) )
) )
else: else:
@@ -195,18 +265,22 @@ if spotipy:
else: else:
SPOTIFY_CTRL = None SPOTIFY_CTRL = None
youtube_creds = _load_netrc_credentials("youtube") _youtube_creds = _load_netrc_credentials("youtube")
if youtube_creds: if _youtube_creds:
YOUTUBE_AUTH = [youtube_creds[0], youtube_creds[2]] YOUTUBE_AUTH = [_youtube_creds[0], _youtube_creds[2]]
else: else:
YOUTUBE_AUTH = [ YOUTUBE_AUTH = [
os.getenv("YOUTUBE_USERNAME", ""), os.getenv("YOUTUBE_USERNAME", ""),
os.getenv("YOUTUBE_PASSWORD", ""), os.getenv("YOUTUBE_PASSWORD", ""),
] ]
def service_headers() -> Dict[str, str]: def service_headers():
"""Shared header dict for internal HTTP calls.""" """Shared header dict for internal service-to-service HTTP calls.
Returns an empty dict when no key is configured, keeping calls backward
compatible with deployments that do not (yet) enforce authentication.
"""
if API_SHARED_KEY: if API_SHARED_KEY:
return {"X-Conjurer-Api-Key": API_SHARED_KEY} return {"X-Conjurer-Api-Key": API_SHARED_KEY}
return {} return {}
@@ -223,3 +297,26 @@ 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) 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) 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", "")
Executable → Regular
+9 -3
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
total_commands=29 total_commands=31
current_command=0 current_command=0
function print_progress { function print_progress {
@@ -95,8 +95,14 @@ print_progress "cp ./conjurer/latex_functions.py ./Conjurer/"
cp ./conjurer/librarian_functions.py ./Conjurer cp ./conjurer/librarian_functions.py ./Conjurer
print_progress "cp ./conjurer/librarian_functions.py ./Conjurer/" print_progress "cp ./conjurer/librarian_functions.py ./Conjurer/"
cp ./conjurer/thin_client.py ./Conjurer/bot.py cp ./conjurer/conanjurer_commands.py ./Conjurer/
print_progress "cp ./conjurer/thin_client.py ./Conjurer/bot.py" 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"
sudo systemctl restart conjurer.service sudo systemctl restart conjurer.service
print_progress "sudo systemctl restart conjurer.service" print_progress "sudo systemctl restart conjurer.service"
Executable → Regular
View File
Executable → Regular
View File
+1 -1
View File
@@ -1,7 +1,7 @@
import os import os
import discord import discord
from discord.ext import commands from discord.ext import commands
import conjurer.backup_old_docker.file_search_functions as file_search_functions import file_search_functions
class FileSelectView(discord.ui.View): class FileSelectView(discord.ui.View):
def __init__(self, files): def __init__(self, files):
-14
View File
@@ -1,14 +0,0 @@
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
@@ -1,155 +0,0 @@
# -*- 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
@@ -1,52 +0,0 @@
# 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
@@ -1,2 +0,0 @@
#!/bin/bash
cd ../conjurer/ && git pull && cp ./gpt_interface/* ../gpt_interf_serv/ && cd -
Executable → Regular
+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 portaudio19-dev python3-pyaudio
sudo apt-get install sudo apt-get install
cd /home/pi || exit cd /home/pi || exit
mkdir Conjurer mdkir Conjurer
cd Conjurer ||exit cd Conjurer ||exit
python3 -m venv /home/pi/Conjurer/.env python3 -m venv /home/pi/Conjurer/.env
cp /home/pi/conjurer/requirements_bot.txt /home/pi/Conjurer/ cp /home/pi/conjurer/requirements_bot.txt /home/pi/Conjurer/
Executable → Regular
View File
+2 -2
View File
@@ -10,7 +10,7 @@ import discord
from discord import app_commands from discord import app_commands
from discord.ext import commands from discord.ext import commands
from conjurer.backup_old_docker.constants import ( from constants import (
ALLOWED_ROLES, ALLOWED_ROLES,
GUILD_ID, GUILD_ID,
LATEX_MAX_ATTACH_MB, LATEX_MAX_ATTACH_MB,
@@ -19,7 +19,7 @@ from conjurer.backup_old_docker.constants import (
LATEX_TEX_ENGINE, LATEX_TEX_ENGINE,
OPENAI_MODEL, OPENAI_MODEL,
) )
from conjurer.backup_old_docker.latex_functions import ( from latex_functions import (
compile_single_tex_bytes, compile_single_tex_bytes,
compile_zip_to_zip, compile_zip_to_zip,
is_safe_asset_name, is_safe_asset_name,
+3 -8
View File
@@ -13,14 +13,9 @@ import PyPDF2
import requests import requests
from discord.ext import commands, tasks from discord.ext import commands, tasks
from conjurer.backup_old_docker.ai_functions import handle_response from ai_functions import handle_response
from conjurer.backup_old_docker.communication_subroutine import IN_COMM_Q, OUT_COMM_Q, QueryControl from communication_subroutine import IN_COMM_Q, OUT_COMM_Q, QueryControl
from conjurer.backup_old_docker.constants import ( from constants import DIR_PATH_SADOX, LIBRARIAN_SERVICE_ADDRESS, SEND_QUERY, service_headers
DIR_PATH_SADOX,
LIBRARIAN_SERVICE_ADDRESS,
SEND_QUERY,
service_headers,
)
SERVICE_HEADERS = service_headers() SERVICE_HEADERS = service_headers()
+6 -8
View File
@@ -9,7 +9,7 @@ import discord
import requests import requests
import yt_dlp import yt_dlp
from conjurer.backup_old_docker.constants import ( from constants import (
FILE_SERVICE_ADDRESS, FILE_SERVICE_ADDRESS,
GET_MP3, GET_MP3,
GET_PLAYLIST, GET_PLAYLIST,
@@ -22,9 +22,7 @@ from conjurer.backup_old_docker.constants import (
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() SERVICE_HEADERS = service_headers()
MUSIC_ROOT = Path(MUSIC_FOLDER)
@@ -51,12 +49,12 @@ class MusicFileList(object):
response = requests.get( response = requests.get(
f"{FILE_SERVICE_ADDRESS}{GET_MP3}", f"{FILE_SERVICE_ADDRESS}{GET_MP3}",
headers=SERVICE_HEADERS, headers=SERVICE_HEADERS,
timeout=60, timeout=360,
) )
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 MUSIC_ROOT.glob("**/*.mp3"): for mp3_item in Path.glob(Path(MUSIC_FOLDER), "**/*.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("/", "\\")
@@ -111,7 +109,7 @@ class MusicFileList(object):
f"{FILE_SERVICE_ADDRESS}{SEND_MP3}", f"{FILE_SERVICE_ADDRESS}{SEND_MP3}",
json=post_data, json=post_data,
headers=SERVICE_HEADERS, headers=SERVICE_HEADERS,
timeout=60, timeout=360,
) )
@@ -156,7 +154,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(MUSIC_ROOT, Path(directory_name)) PurePath.joinpath(Path(MUSIC_FOLDER), 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
@@ -166,7 +164,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=str(MUSIC_ROOT), output_dir=MUSIC_FOLDER,
format_str="bestaudio/best", format_str="bestaudio/best",
skip_mp3=False, skip_mp3=False,
keep_playlist_order=False, keep_playlist_order=False,
+1 -1
View File
@@ -7,7 +7,7 @@ from typing import Optional
import discord import discord
from discord.ext import commands from discord.ext import commands
from conjurer.backup_old_docker.constants import ACCIDENT_LOG, DATA, ENCODING from constants import ACCIDENT_LOG, DATA, ENCODING
historia_fabryczki = DATA["fabryczka"] historia_fabryczki = DATA["fabryczka"]
+16
View File
@@ -234,5 +234,21 @@
{ {
"role": "assistant", "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." "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", "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." "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."
} }
] ]
+2 -10
View File
@@ -8,16 +8,7 @@ import uuid
import asyncio import asyncio
from datetime import datetime from datetime import datetime
from conjurer.backup_old_docker.constants import ( from constants import RADIO_HARBOR_ADDRESS, SKIP_TRACK, FILE_SERVICE_ADDRESS, ADD_TO_PRIO_PLAYLIST, REQUEST_MUSIC, CREATE_PRIO_PLAYLIST, CLEAR_PRIO, service_headers
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() SERVICE_HEADERS = service_headers()
@@ -45,6 +36,7 @@ class RadioModule(commands.Cog):
coroutine = asyncio.to_thread( coroutine = asyncio.to_thread(
requests.get, requests.get,
f"{RADIO_HARBOR_ADDRESS}{SKIP_TRACK}", f"{RADIO_HARBOR_ADDRESS}{SKIP_TRACK}",
headers=SERVICE_HEADERS,
timeout=360, timeout=360,
) )
result = await coroutine result = await coroutine
-20
View File
@@ -1,20 +0,0 @@
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,4 +17,6 @@ PyMuPDF
waitress waitress
assemblyai[extras] assemblyai[extras]
SpeechRecognition SpeechRecognition
aiomcrcon
asyncssh
git+https://github.com/imayhaveborkedit/discord-ext-voice-recv git+https://github.com/imayhaveborkedit/discord-ext-voice-recv
Executable → Regular
View File
-67
View File
@@ -1,67 +0,0 @@
# 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
@@ -1,296 +0,0 @@
#!/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())
+41 -11
View File
@@ -1,13 +1,43 @@
[ { [
"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."
},
{ {
"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.", ""], "role": "system",
"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.", ""], "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."
"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.", ""] "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"
]
}
] ]
-63
View File
@@ -1,63 +0,0 @@
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()