main
Peery 2 weeks ago
commit a670191b31
Signed by: pandro
SSH Key Fingerprint: SHA256:iBUZSuDxqYr4hYpe9U3BA9NJmXKpbGt4H0S8hUwIbrA

10
.gitignore vendored

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# contain secrets
*.env
application/mc-auth.config
# test-env data
test-env/data
test-env/config

@ -0,0 +1,23 @@
FROM python:3.13-slim
# User setup
RUN adduser -u 1001 python_user
RUN mkdir /home/python_user/app && chown python_user:python_user /home/python_user/app
WORKDIR /home/python_user/app/
# Copy application files
COPY ./application /home/python_user/app
RUN rm -rf /home/python_user/app/test-env \
/home/python_user/app/.venv \
/home/python_user/app/mc-auth.config
RUN chown -R python_user:python_user /home/python_user/app
USER python_user
# Install python requirements
#COPY ./requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "start.py"]

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
<excludeFolder url="file://$MODULE_DIR$/application/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 virtualenv at ~/Software_Projects/DiscordMinecraftAuthenticator/application/.venv" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -0,0 +1,24 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ourVersions">
<value>
<list size="6">
<item index="0" class="java.lang.String" itemvalue="3.12" />
<item index="1" class="java.lang.String" itemvalue="3.6" />
<item index="2" class="java.lang.String" itemvalue="3.13" />
<item index="3" class="java.lang.String" itemvalue="3.11" />
<item index="4" class="java.lang.String" itemvalue="3.10" />
<item index="5" class="java.lang.String" itemvalue="3.7" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<list />
</option>
</inspection_tool>
</profile>
</component>

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.13 virtualenv at ~/Software_Projects/DiscordMinecraftAuthenticator/.venv" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 virtualenv at ~/Software_Projects/DiscordMinecraftAuthenticator/application/.venv" project-jdk-type="Python SDK" />
<component name="PythonCompatibilityInspectionAdvertiser">
<option name="version" value="3" />
</component>
</project>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/DiscordMinecraftAuthenticator.iml" filepath="$PROJECT_DIR$/.idea/DiscordMinecraftAuthenticator.iml" />
</modules>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

@ -0,0 +1,189 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="14eb3365-c838-47ac-baf5-458c1d4e585a" name="Changes" comment="">
<change afterPath="$PROJECT_DIR$/config/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/config/config.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/database/DB.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/database/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/discord_bot/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/discord_bot/discord_bot.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/log/Log.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/log/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/mc-auth.config" afterDir="false" />
<change afterPath="$PROJECT_DIR$/minecraft_interface/MinecraftAPI.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/minecraft_interface/MinecraftServerAPI.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/minecraft_interface/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/start.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/version.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="FileTemplateManagerImpl">
<option name="RECENT_TEMPLATES">
<list>
<option value="Python Script" />
</list>
</option>
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$/.." />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 3
}</component>
<component name="ProjectId" id="339KQHi06fLjqrzJLXr3nWRWy96" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;Python.DB.executor&quot;: &quot;Run&quot;,
&quot;Python.MinecraftAPI.executor&quot;: &quot;Debug&quot;,
&quot;Python.MinecraftServerAPI.executor&quot;: &quot;Run&quot;,
&quot;Python.start.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;last_opened_file_path&quot;: &quot;/home/peery/Software_Projects/DiscordMinecraftAuthenticator/application&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
}
}</component>
<component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/application" />
<recent name="$PROJECT_DIR$" />
</key>
</component>
<component name="RunManager" selected="Python.start">
<configuration name="DB" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<module name="DiscordMinecraftAuthenticator" />
<option name="ENV_FILES" value="$PROJECT_DIR$/postgres-secrets.env" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/database/DB.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<configuration name="MinecraftAPI" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<module name="DiscordMinecraftAuthenticator" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/minecraft_interface/MinecraftAPI.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<configuration name="MinecraftServerAPI" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<module name="DiscordMinecraftAuthenticator" />
<option name="ENV_FILES" value="$PROJECT_DIR$/minecraft-secrets.env" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/minecraft_interface/MinecraftServerAPI.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<configuration name="start" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<module name="DiscordMinecraftAuthenticator" />
<option name="ENV_FILES" value="$PROJECT_DIR$/base.env:$PROJECT_DIR$/discord-secrets.env:$PROJECT_DIR$/minecraft-secrets.env:$PROJECT_DIR$/postgres-secrets.env" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/start.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<list>
<item itemvalue="Python.MinecraftServerAPI" />
<item itemvalue="Python.DB" />
<item itemvalue="Python.MinecraftAPI" />
<item itemvalue="Python.start" />
</list>
<recent_temporary>
<list>
<item itemvalue="Python.start" />
<item itemvalue="Python.MinecraftServerAPI" />
<item itemvalue="Python.DB" />
<item itemvalue="Python.MinecraftAPI" />
</list>
</recent_temporary>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="14eb3365-c838-47ac-baf5-458c1d4e585a" name="Changes" comment="" />
<created>1758723830290</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1758723830290</updated>
</task>
<servers />
</component>
<component name="UnknownFeatures">
<option featureType="com.intellij.fileTypeFactory" implementationName="*.env" />
<option featureType="com.intellij.fileTypeFactory" implementationName="*.config" />
</component>
<component name="VcsManagerConfiguration">
<ignored-roots>
<path value="$PROJECT_DIR$" />
</ignored-roots>
</component>
</project>

@ -0,0 +1,96 @@
import logging
import os.path
from typing import List, Dict
import discord
import yaml
class ConfigLoader:
"""
Loads the configuration data
"""
DEFAULT_CONFIG_LOCATION = "mc-auth.config"
_CONF_INST = None
def __init__(self, path=None):
ConfigLoader._CONF_INST = self
self.log = logging.getLogger("Conf")
try:
path = os.environ.get('MC_AUTH_CONFIG_LOCATION') if os.environ.get('MC_AUTH_CONFIG_LOCATION') is not None else path
except KeyError:
pass
self.conf_file_path = os.path.join(os.getcwd(), ConfigLoader.DEFAULT_CONFIG_LOCATION) if path is None else path
assert(os.path.isfile(self.conf_file_path))
self.config: dict = dict()
self.read_config()
self.__auth_guilds_per_domain: Dict[str, List[discord.Guild]] = None
self.__auth_guilds: List[discord.Guild] = None
self.__whitelist_locations_per_domain: Dict[str, str] = None
async def load_guilds(self, bot):
if self.__auth_guilds_per_domain is not None:
return
self.log.debug("Loading all auth_guild objects from discord API")
self.__auth_guilds_per_domain = dict()
guilds_bot_is_in: List[discord.Guild] = bot.fetch_guilds()
for domain in self.config["minecraft"]["domains"]:
gs = []
for gid in self.config["minecraft"]["domains"][domain]["auth_guilds"]:
async for guild in guilds_bot_is_in:
if guild.id == int(gid):
gs.append(guild)
self.__auth_guilds_per_domain[domain] = gs
@staticmethod
def get_config_loader(path=None):
if ConfigLoader._CONF_INST is None:
ConfigLoader(path)
return ConfigLoader._CONF_INST
def read_config(self):
self.log.debug(f"Reading config \"{self.conf_file_path}\"")
self.config = yaml.safe_load(open(self.conf_file_path, 'r'))
@property
def auth_guilds_per_domain(self) -> Dict[str, List[discord.Guild]]:
if self.__auth_guilds_per_domain is None:
self.log.critical("Looked up ConfigLoader.auth_guilds before it could be loaded by the DiscordBot!")
raise ValueError("Looked up ConfigLoader.auth_guilds before it could be loaded by the DiscordBot!")
return self.__auth_guilds_per_domain
@property
def auth_guilds(self) -> List[discord.Guild]:
if self.__auth_guilds is None:
self.__auth_guilds = list()
for domain in self.auth_guilds_per_domain:
self.__auth_guilds += self.auth_guilds_per_domain[domain]
self.__auth_guilds = list(set(self.__auth_guilds))
return self.__auth_guilds
@property
def whitelist_location_per_domain(self) -> Dict[str, str]:
if self.__whitelist_locations_per_domain is None:
self.__whitelist_locations_per_domain = dict()
for domain in self.config["minecraft"]["domains"]:
self.__whitelist_locations_per_domain[domain] = self.config["minecraft"]["domains"][domain]["whitelist_location"]
return self.__whitelist_locations_per_domain
@property
def default_minecraft_domain(self) -> str:
return self.config["minecraft"]["default_domain"]
@property
def database_host(self) -> str:
return self.config["database"]["host"]
@property
def database_port(self) -> str:
return self.config["database"]["port"]
def post_application_text(self, domain: str) -> str:
return self.config["minecraft"]["domains"][domain]["post_application_text"]
def roles_with_server_access(self, domain: str) -> List[str]:
return self.config["minecraft"]["domains"][domain]["roles_with_server_access"]

@ -0,0 +1,99 @@
import os
from typing import List
import sqlalchemy.engine
from sqlalchemy import String, create_engine, URL, Table, Column, ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, relationship, Session
from sqlalchemy.sql.expression import select
from sqlalchemy.testing.schema import mapped_column
from config.config import ConfigLoader
def get_engine() -> sqlalchemy.engine.Engine:
url_obj = URL.create(
"postgresql",
username=os.environ.get('POSTGRES_USER'),
password=os.environ.get('POSTGRES_PASSWORD'),
database=os.environ.get('POSTGRES_DB'),
host=ConfigLoader.get_config_loader().database_host,
port=ConfigLoader.get_config_loader().database_port
)
e = create_engine(url_obj)
return e
class Base(DeclarativeBase):
pass
mc_users_on_mc_server = Table(
"mc_users_on_mc_server",
Base.metadata,
Column("minecraft_user", ForeignKey("minecraft_users.uuid"), primary_key=True),
Column("minecraft_server", ForeignKey("minecraft_servers.domain"), primary_key=True)
)
class DiscordUser(Base):
__tablename__ = "discord_users"
snowflake_id: Mapped[str] = mapped_column(primary_key=True)
handle: Mapped[str] = mapped_column(String(32))
minecraft_user_uuid = mapped_column(ForeignKey("minecraft_users.uuid"))
def __repr__(self):
return f"User(snowflake_id={self.snowflake_id}, handle={self.handle})"
class MinecraftUser(Base):
__tablename__ = "minecraft_users"
uuid: Mapped[String] = mapped_column(String(32), primary_key=True)
user_name: Mapped[String] = mapped_column(String(16), primary_key=True)
#discord_user = mapped_column(ForeignKey("discord_users.snowflake_id"))
minecraft_servers: Mapped[List["MinecraftServer"]] = relationship(secondary=mc_users_on_mc_server, back_populates="minecraft_users")
def __repr__(self):
return f"MCUser(uuid={self.uuid}, user_name={self.user_name})"
class MinecraftServer(Base):
__tablename__ = "minecraft_servers"
domain: Mapped[str] = mapped_column(String(64), primary_key=True)
minecraft_users: Mapped[List["MinecraftUser"]] = relationship(secondary=mc_users_on_mc_server, back_populates="minecraft_servers")
def __repr__(self):
return f"MinecraftServer(domain={self.domain}, discord_user={self.discord_user})"
if __name__ == "__main__":
url_obj = URL.create(
"postgresql",
username=os.environ.get('POSTGRES_USER'),
password=os.environ.get('POSTGRES_PASSWORD'),
database=os.environ.get('POSTGRES_DB'),
host=ConfigLoader.get_config_loader().database_host,
port=ConfigLoader.get_config_loader().database_port
)
engine = create_engine(url_obj, echo=True)
with Session(engine) as session:
pass
#mc_server = MinecraftServer(domain="feather-mc.pandro.de")
#uuid = MinecraftAPI.get_uuid_from_user_name("Pandr0")
#mc_user = MinecraftUser(uuid=uuid,
# user_name="Pandr0"
# )
#disc_user = DiscordUser(snowflake_id="682326483921797146",
# handle="pandro_",
# minecraft_user_uuid=mc_user.uuid)
#stmt = update(mc_users_on_mc_server).where().values()
#session.execute(stmt)
#stmt = insert(mc_users_on_mc_server).values(minecraft_user=uuid, minecraft_server="feather-mc.pandro.de")
#session.execute(stmt)
#session.commit()
stmt = select(MinecraftUser).where(MinecraftUser.user_name == "Pandr0")
mc_user = session.execute(stmt).first()[0]
stmt = select(MinecraftServer).where(MinecraftServer.minecraft_users.any(MinecraftUser.user_name == "Pandr0")) # TODO figure out how to query minecraft servers with a given minecraft user
result = session.execute(stmt).all()
print(f"PG user: {os.environ.get('POSTGRES_USER')}")
#print(f"PG pw: {os.environ.get('POSTGRES_PASSWORD')}")
print(f"PG db: {os.environ.get('POSTGRES_DB')}")

@ -0,0 +1,130 @@
import logging
import discord
from discord import Intents, ui, Interaction, Activity, app_commands
from config.config import ConfigLoader
from minecraft_interface import MinecraftAPI, MinecraftServerAPI
class DiscordBot(discord.Client):
def __init__(self):
self.log = logging.getLogger("Discord")
self.conf: ConfigLoader = ConfigLoader.get_config_loader()
try:
mc_server_name = self.conf.default_minecraft_domain
except KeyError:
self.log.critical("Could not find configuration for minecraft server domain in config file!")
exit(1)
intents = Intents.default()
#intents.message_content = True
super().__init__(intents=intents, activity=Activity(name= "BotActivity", state=f"Guarding {mc_server_name}", type=discord.ActivityType.custom))
self.tree = app_commands.CommandTree(self)
def start_discord_bot(self, token: str, log_level):
logging.getLogger("Main").info("Starting the discord bot.")
self.run(token, log_level=logging.WARN) # blocks
async def setup_hook(self) -> None:
# Sync the application command with all Authentication Guilds
await self.register_commands()
for guild in self.conf.auth_guilds:
await self.tree.sync(guild=guild)
async def register_commands(self):
await self.conf.load_guilds(self)
self.tree.command(
name="signup_for_minecraft_server",
description="Enter your minecraft account name to sign up for the minecraft server",
guilds=self.conf.auth_guilds
)(self.signup_for_minecraft)
self.tree.command(
name="leave_minecraft_server",
description="Removes you from the minecraft server.",
guilds=self.conf.auth_guilds
)(self.leave_minecraft)
async def on_ready(self):
await self.conf.load_guilds(self)
self.log.info(f"We have logged in as {self.user}")
async def signup_for_minecraft(self, interaction: Interaction):
if interaction.guild in self.conf.auth_guilds:
user_has_correct_role = False
for user_role in interaction.user.roles:
if str(user_role.id) in self.conf.roles_with_server_access(self.conf.default_minecraft_domain):
user_has_correct_role = True
if not user_has_correct_role:
await interaction.response.send_message("Access denied!\n"
"You do not have the correct role to use this command.",
ephemeral=True)
return
self.log.info(
f"Command \"signup_for_minecraft\" was called from guild \"{interaction.guild}\" ({interaction.guild.id}) "
f"by \"{interaction.user.name}\" ({interaction.user.id})")
await interaction.response.send_modal(MinecraftModal(interaction))
else:
self.log.warning(f"Command \"signup_for_minecraft\" was called from outside an auth_guild! "
f"{interaction.guild} by {interaction.user}")
async def leave_minecraft(self, interaction: Interaction):
if interaction.guild in self.conf.auth_guilds:
user_has_correct_role = False
for user_role in interaction.user.roles:
if str(user_role.id) in self.conf.roles_with_server_access(self.conf.default_minecraft_domain):
user_has_correct_role = True
if not user_has_correct_role:
await interaction.response.send_message("Access denied!\n"
"You do not have the correct role to use this command.",
ephemeral=True)
return
self.log.info(
f"Command \"leave_minecraft_server\" was called from guild \"{interaction.guild}\" ({interaction.guild.id}) "
f"by \"{interaction.user.name}\" ({interaction.user.id})"
)
await MinecraftServerAPI.handle_minecraft_retirement(self.conf.default_minecraft_domain, interaction.user, interaction)
class MinecraftModal(ui.Modal, title="Minecraft server application"):
mc_name = ui.TextInput(label='Minecraft Username')
#reason = ui.TextInput(label='Why do you want to join?', style=discord.TextStyle.paragraph)
def __init__(self, interaction: discord.Interaction):
super().__init__()
self.log = logging.getLogger("Discord")
async def on_submit(self, interaction: Interaction) -> None:
config: ConfigLoader = ConfigLoader.get_config_loader()
if interaction.guild not in config.auth_guilds:
self.log.warning(f"Interaction from non-auth_guilds: {interaction} {interaction.guild} {interaction.user}")
await interaction.response.send_message(f"Interaction came from a guild not on the list. Not doing anything (except logging this)!")
return
# validating given minecraft username
mc_uuid = MinecraftAPI.get_uuid_from_user_name(str(self.mc_name))
if mc_uuid is not None:
self.log.info(f"Received server application with valid minecraft username \"{self.mc_name}\" from "
f"{interaction.user.name} ({interaction.user.id}) on "
f"{interaction.guild.name} ({interaction.guild.id})!")
else:
self.log.warning(f"Received server application with INVALID minecraft username \"{self.mc_name}\" from "
f"{interaction.user.name} ({interaction.user.id}) on "
f"{interaction.guild.name} ({interaction.guild.id})!")
await interaction.response.send_message(f'The minecraft username \"{self.mc_name}\" failed verification!\n\n'
f'Aborted!',
ephemeral=True)
return
# Got a valid application.
await MinecraftServerAPI.handle_minecraft_application(str(self.mc_name), mc_uuid, config.default_minecraft_domain,
interaction.user, interaction)
await interaction.response.send_message("Application received!\n"
"Give it a minute. If it hasn't worked by then please "
"contact an admin instead!\n\n"
"Remember that you will be responsible for any actions of that minecraft account!",
ephemeral=True)

@ -0,0 +1,25 @@
import logging
class Log:
@staticmethod
def setup(level="DEBUG"):
level = getattr(logging, level)
mlog = logging.getLogger("Main")
dlog = logging.getLogger("Discord")
mclog = logging.getLogger("MC-API")
console_handler = logging.StreamHandler()
formatter = logging.Formatter(
fmt="[%(asctime)s][%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
console_handler.setFormatter(formatter)
logs = [mlog, dlog, mclog]
for log in logs:
log.handlers.clear()
log.setLevel(level)
log.addHandler(console_handler)
dlog.addHandler(console_handler)
mlog.debug("Logging has been setup!")

@ -0,0 +1,13 @@
minecraft:
default_domain: "your-mc-server-domain"
domains:
your-mc-server-domain:
auth_guilds:
- "<SNOWFLAKE ID OF YOUR DISCORD GUILD>"
roles_with_server_access:
- "<ROLE ID OF ROLE THAT GETS ACCESS>"
whitelist_location: "./whitelist.json"
post_application_text: "<NOT IMPLEMENTED YET>"
database:
host: "127.0.0.1"
port: 5432

@ -0,0 +1,63 @@
import logging
import requests
from log.Log import Log
from version import VERSION
HEADERS = {'User-agent': f"DiscordMinecraft-Authenticator ({VERSION})(peery.coding@gmail.com)"}
def is_valid_user_name(user_name: str) -> bool:
"""
Checks if user_name is a valid minecraft player account by querying api.mojang.com if the profile exists
:param user_name:
:return:
"""
log = logging.getLogger("McAPI")
url = f"https://api.mojang.com/users/profiles/minecraft/{user_name}"
response = requests.get(url, headers=HEADERS)
if response.status_code == 404:
log.info(f"Looked up user name \"{user_name}\". Failed!")
return False
elif response.status_code == 200:
try:
uuid = response.json()['id']
except KeyError:
log.critical(f"Unexpected response from {url}! Response ({response.status_code}): {response.json()}")
return False
log.info(f"Looked up user name \"{user_name}\" (UUID: \"{uuid}\"). Success!")
return True
else:
log.error(f"Got unexpected status code from user_name lookup \"{user_name}\": {response.status_code}")
return False
def get_uuid_from_user_name(user_name: str) -> str:
"""
Checks if user_name is a valid minecraft player account by querying api.mojang.com if the profile exists
:param user_name:
:return:
"""
log = logging.getLogger("McAPI")
url = f"https://api.mojang.com/users/profiles/minecraft/{user_name}"
response = requests.get(url, headers=HEADERS)
if response.status_code == 404:
log.info(f"Looked up user name \"{user_name}\". Failed!")
return None
elif response.status_code == 200:
try:
uuid = response.json()['id']
log.info(f"Looked up user name \"{user_name}\" (UUID: \"{uuid}\"). Success!")
return uuid
except KeyError:
log.critical(f"Unexpected response from {url}! Response ({response.status_code}): {response.json()}")
return None
else:
log.error(f"Got unexpected status code from user_name lookup \"{user_name}\": {response.status_code}")
return None
if __name__ == "__main__":
Log.setup("DEBUG")
print(is_valid_user_name("MausKomant"))

@ -0,0 +1,224 @@
import json
import logging
import os
from time import sleep
from typing import List
import discord
from mcrcon import MCRcon
from sqlalchemy import select, and_
from sqlalchemy.orm import Session
from config.config import ConfigLoader
from database.DB import get_engine, MinecraftUser, MinecraftServer, DiscordUser
from log.Log import Log
def add_user_name_to_whitelist(domain: str, user_name: str):
"""
Adds a minecraft user_name to the whitelist
:param domain:
:param user_name:
:return:
"""
log = logging.getLogger("MC-API")
with MCRcon(os.environ.get('MCRCON_HOST'), os.environ.get('MCRCON_PW')) as mcr:
log.info(f"Adding minecraft user \"{user_name}\" to the whitelist of \"{domain}\"")
resp = mcr.command(f"/whitelist add {user_name}")
print(resp)
def get_all_users_on_whitelist(domain: str) -> dict:
"""
Gets a list of all users on the whitelist by reading the server's whitelist.json
:param domain:
:return:
"""
c: ConfigLoader = ConfigLoader.get_config_loader()
with open(c.whitelist_location_per_domain[domain], 'r') as whitelist_file:
whitelist = json.load(whitelist_file)
return whitelist
def get_list_of_users_on_whitelist(domain: str) -> List[str]:
on_whitelist = [name for name in
map(lambda x: x['name'], get_all_users_on_whitelist(domain))]
return on_whitelist
def remove_user_name_from_whitelist(domain: str, user_name: str):
"""
Removes a minecraft user_name from the whitelist
:param domain:
:param user_name:
:return:
"""
log = logging.getLogger("MC-API")
with MCRcon(os.environ.get('MCRCON_HOST'), os.environ.get('MCRCON_PW')) as mcr:
log.info(f"Removing minecraft user \"{user_name}\" from the whitelist of \"{domain}\"")
resp = mcr.command(f"/whitelist remove {user_name}")
print(resp)
async def handle_minecraft_application(mc_name: str, mc_uuid: str, domain: str, discord_user: discord.User,
interaction: discord.Interaction):
"""
Applies an application to the server whitelist or ignores it depending on if the minecraft user is already whitelisted.
Will alter the database to reflecte the current state.
:param mc_name:
:param mc_uuid:
:param domain:
:param discord_user:
:param interaction
:return:
"""
log = logging.getLogger("MC-API")
c: ConfigLoader = ConfigLoader.get_config_loader()
engine = get_engine()
with Session(engine) as session:
# query if the user is already related to our server
stmt = select(MinecraftUser).where(
MinecraftUser.minecraft_servers.any(and_(MinecraftServer.domain == domain, MinecraftUser.user_name == mc_name)))
mc_user_on_server: MinecraftUser = session.scalars(stmt).first()
# query for the server obj
stmt = select(MinecraftServer).where(MinecraftServer.domain == domain)
dc_user: DiscordUser = session.scalars(
select(DiscordUser).where(DiscordUser.snowflake_id == str(discord_user.id))
).first()
curr_server: MinecraftServer = session.scalars(stmt).first()
on_whitelist = get_list_of_users_on_whitelist(domain)
if mc_user_on_server is not None: # this user is already associated with the server (could be missing on whitelist?)
log.info(
f"Found minecraft username \"{mc_name}\" to be already associated with the server. Aborting ...")
if mc_name not in on_whitelist:
log.error(f"Minecraft username \"{mc_name}\" was found in the database but is "
f"not in \"whitelist.json\"! Did someone manually remove them? Removing the relationship ...")
mc_user_on_server.minecraft_servers.remove(curr_server)
session.add(mc_user_on_server)
session.commit()
if dc_user.minecraft_user_uuid == mc_uuid:
log.info(f"Discord user \"{dc_user.handle}\" ({dc_user.snowflake_id}) tried to add minecraft user "
f"\"{mc_name}\" ({mc_uuid}) twice! Aborting...")
#interaction.response.send_message(f"The minecraft user \"{mc_name}\" was already added by you!\n"
# f"Not doing anything because this seems unnecessary.\n\n"
# f"If this is an error inform an admin!",
# ephemeral=True)
return # do not inform user about this, otherwise leaks who is on the server by guessing their name with the bot
# minecraft user is not associated with server or was just dis-associated (could still be on whitelist)
log.info(
f"Did not find minecraft user \"{mc_name}\" in relation to server \"{curr_server.domain}\"")
if mc_name in on_whitelist:
log.warning(
f"Minecraft username \"{mc_name}\" was not found in the database but is "
f"in \"whitelist.json\"! Were they added manually? Ignoring that.")
return
# query for the minecraft user
mc_user = session.scalars(
select(MinecraftUser).where(MinecraftUser.user_name == str(mc_name))
).first()
if mc_user is None: # create mc_user
mc_user = MinecraftUser(user_name=str(mc_name), uuid=mc_uuid)
session.add(mc_user)
session.commit()
if dc_user is None: # got to create discord user
dc_user = DiscordUser(snowflake_id=str(discord_user.id),
handle=str(discord_user.name),
minecraft_user_uuid=mc_uuid)
session.add(dc_user)
else: # discord user is known
if dc_user.minecraft_user_uuid is not None and dc_user.minecraft_user_uuid != mc_uuid: # discord user already had another minecraft account
old_mc_user: MinecraftUser = session.scalars(select(MinecraftUser).where(
MinecraftUser.uuid == dc_user.minecraft_user_uuid
)).first()
log.info(f"Discord user \"{dc_user.handle}\" ({dc_user.snowflake_id}) already had a minecraft uuid "
f"associated: \"{old_mc_user.user_name}\" ({old_mc_user.uuid})\n"
f"Updating it to \"{mc_name}\" ({mc_uuid})!")
remove_user_name_from_whitelist(domain, str(old_mc_user.user_name))
dc_user.minecraft_user_uuid = mc_uuid
session.add(dc_user)
mc_user.minecraft_servers.append(curr_server)
session.commit()
add_user_name_to_whitelist(domain, mc_name)
#await interaction.response.send_message("Thanks for your response. You've been added to the server's whitelist!\n"
# "Enjoy your time playing!\n\n"
# "Also don't forget to inform yourself about available mods or "
# "which minecraft version to use! Some are: \n"
# f"{c.post_application_text(domain)}",
# ephemeral=True)
async def handle_minecraft_retirement(domain: str, discord_user: discord.User, interaction: discord.Interaction):
"""
Only removes any minecraft user associated with a discord user.
:param domain:
:param discord_user:
:param interaction:
:return:
"""
log = logging.getLogger("MC-API")
engine = get_engine()
with Session(engine) as session:
dc_user: DiscordUser = session.scalars(select(DiscordUser).where(
DiscordUser.snowflake_id == str(discord_user.id)
)).first()
if dc_user is None: # unknown discord user called this command
log.info(f"Can't find discord user \"{discord_user.name}\" (\"{discord_user.id}\") in the database!")
await interaction.response.send_message(f"Hey, {discord_user.name}!\nWould love to help but don't think we have met before!\n"
f"If you're still on the whitelist of \"{domain}\" "
"please tell an admin or moderator instead. "
"They'll figure this out for you!",
ephemeral=True)
return
else: # discord user has interacted with this bot before
if dc_user.minecraft_user_uuid is None: # no minecraft user known (used this command before or manual edit)?
log.info(f"Found discord user \"{dc_user.handle}\" ({dc_user.snowflake_id}) in the database "
f"but there was no minecraft UUID associated with it.")
await interaction.response.send_message("Couldn't find any minecraft user associated with your account.\n"
"But I've seen you before! Have you used this command already?",
ephemeral=True)
return
mc_user: MinecraftUser = session.scalars(select(MinecraftUser).where(
MinecraftUser.uuid == dc_user.minecraft_user_uuid
)).first()
if mc_user is None:
log.critical(f"Could not find minecraft user with UUID \"{dc_user.minecraft_user_uuid}\" but it was "
f"associated with discord account \"{dc_user.handle}\" ({dc_user.snowflake_id}). "
f"Something is broken in the database schema! Did a delete not cascade?")
exit(1)
curr_server: MinecraftServer = session.scalars(select(MinecraftServer).where(
MinecraftServer.domain == domain
)).first()
if curr_server is None: # domain is unknown to the database?!
log.critical(f"Tried to look up minecraft server \"{domain}\" but the database query was empty! \n"
f"Something is broken in the database!")
exit(1)
dc_user.minecraft_user_uuid = None
session.add(dc_user)
mc_user.minecraft_servers.remove(curr_server)
session.add(mc_user)
log.info(f"Discord user \"{dc_user.handle}\" ({dc_user.snowflake_id}) is leaving server \"{domain}\" with "
f"minecraft user \"{mc_user.user_name}\" ({mc_user.uuid})!")
remove_user_name_from_whitelist(curr_server.domain, str(mc_user.user_name))
session.commit()
await interaction.response.send_message(f"Removed your minecraft account \"{mc_user.user_name}\" "
f"from the server's whitelist.\n"
f"Take care!",
ephemeral=True)
if __name__ == "__main__":
Log.setup()
print(get_all_users_on_whitelist("feather-mc.pandro.de"))
sleep(2)
add_user_name_to_whitelist("feather-mc.pandro.de", "MausKomant")
sleep(2)
print(get_all_users_on_whitelist("feather-mc.pandro.de"))
remove_user_name_from_whitelist("feather-mc.pandro.de", "MausKomant")
sleep(2)
print(get_all_users_on_whitelist("feather-mc.pandro.de"))

@ -0,0 +1,6 @@
discord>=2.3.2
SQLAlchemy>=2.0.43
pyyaml>=6.0.3
requests>=2.32.5
psycopg2-binary>=2.9.10
mcrcon>=0.7.0

@ -0,0 +1,27 @@
import logging
import os
from database.DB import get_engine
from discord_bot.discord_bot import DiscordBot
from log.Log import Log
# TODO use docker secrets instead of env variables
# TODO rework MINECRAFT_SERVER_DOMAIN env
# TODO create command /lookup_minecraft_name to see which discord account is associated with a minecraft name
# TODO make /lookup_minecraft_name only available to a configured role #2
def main():
Log.setup(os.environ.get('LOG'))
if not (len(os.environ.get('DISCORD_BOT_TOKEN')) > 0):
logging.getLogger("Main").critical("No discord bot token was found! Please generate a token at "
"https://discord.com/developers/applications/<your application id>/bot "
"and make sure it is present as the "
"environment variable \"DISCORD_BOT_TOKEN\".")
exit(1)
get_engine()
disc = DiscordBot()
disc.start_discord_bot(token=os.environ.get('DISCORD_BOT_TOKEN'), log_level=os.environ.get('LOG'))
if __name__ == "__main__":
main()

@ -0,0 +1 @@
VERSION = "v0.1"

@ -0,0 +1 @@
test-env/data/mc-server/whitelist.json

@ -0,0 +1,59 @@
services:
db:
image: postgres:17-alpine
restart: unless-stopped
# ports:
# - 127.0.0.1:5432:5432 # only when testing from the outside
healthcheck:
test: ['CMD', 'pg_isready', '-U', "mc-authenticator", "minecraft_access"]
timeout: 5s
retries: 2
env_file:
- ./env/postgres-secrets.env
volumes:
- "./data/postgres_data:/var/lib/postgresql/data"
networks:
- internal
paper:
image: "docker.io/eclipse-temurin:21-jre"
restart: unless-stopped
ports:
- "127.0.0.1:25565:25565/tcp" # default mc
- "127.0.0.1:25565:25565/udp" # default mc
- "127.0.0.1:24454:24454/udp" # simple-voice-chat plugin
# - "127.0.0.1:25575:25575/tcp" # RCON, only when testing from the outside
volumes:
- ./data/mc-server:/app
working_dir: /app
command: "java -Xms8192M -Xmx8192M -jar paper.jar nogui"
stdin_open: true
tty: true
networks:
- internet
mc-authenticator:
image: "minecraft-authenticator:latest"
restart: unless-stopped
env_file:
- ./env/postgres-secrets.env
- ./env/discord-secrets.env
- ./env/minecraft-secrets.env
- ./env/base.env
volumes:
- ./data/mc-server/whitelist.json:/home/python_user/app/whitelist.json:ro
- ./config/mc-authenticator/mc-auth.config:/home/python_user/app/mc-auth.config:ro
depends_on:
- db
- paper
networks:
- internal
- internet
networks:
internet:
enable_ipv6: true
ipam:
config:
- subnet: fdf7:53f5:341e::/64
internal:
internal: true

@ -0,0 +1,26 @@
-- Creating the DB structure
-- Version 1.0 (2025-09-25)
CREATE TABLE IF NOT EXISTS minecraft_users (
uuid VARCHAR(32) PRIMARY KEY not null,
user_name VARCHAR(16) not null
);
CREATE TABLE IF NOT EXISTS discord_users (
snowflake_id VARCHAR(32) PRIMARY KEY not null,
handle VARCHAR(32) not null,
minecraft_user_uuid VARCHAR(32),
FOREIGN KEY (minecraft_user_uuid) REFERENCES minecraft_users(uuid) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS minecraft_servers (
domain VARCHAR(128) PRIMARY KEY not null
);
CREATE TABLE IF NOT EXISTS mc_users_on_mc_server (
minecraft_user VARCHAR(32) not null,
minecraft_server VARCHAR(64) not null,
PRIMARY KEY(minecraft_user, minecraft_server),
FOREIGN KEY(minecraft_user) REFERENCES minecraft_users(uuid) ON DELETE CASCADE,
FOREIGN KEY(minecraft_server) REFERENCES minecraft_servers(domain) ON DELETE CASCADE
);
Loading…
Cancel
Save