diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..1fec589 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,54 @@ +name: Build & Deploy MSTBot + +on: + push: + branches: + - main + tags: + - "v*.*.*" # only match version tags like v1.0.0 + +jobs: + build-and-deploy: + name: Build, push & deploy + runs-on: docker-cli-node24 + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Build & push image + run: | + IMAGE="${{ vars.REGISTRY_URL }}/minecraftstatus/mstbot" + + docker build --network=host -t $IMAGE:latest . + docker push $IMAGE:latest + + if [[ "${{ github.ref }}" =~ ^refs/tags ]]; then + VERSION_TAG="${{ github.ref_name }}" + docker tag $IMAGE:latest $IMAGE:$VERSION_TAG + docker push $IMAGE:$VERSION_TAG + fi + + - name: Deploy to LXC + uses: https://github.com/appleboy/ssh-action@v1 + with: + host: ${{ vars.MSTBOT_HOST }} + username: ${{ vars.MSTBOT_USER }} + key: ${{ secrets.MSTBOT_SSH_KEY }} + script: | + set -euo pipefail + cd /opt/mstbot + curl -f -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" -o docker-compose.yml "https://git.benrmorgan.com/api/v1/repos/bMorgan/MinecraftStatus/raw/main/docker-compose.yml" + docker compose down + docker compose pull + docker compose up -d + + - name: Create Release + if: startsWith(github.ref, 'refs/tags/') + uses: actions/forgejo-release@v2.7.3 + with: + direction: upload + title: ${{ github.ref_name }} + tag: ${{ github.ref_name }} + token: ${{ secrets.FORGEJO_TOKEN }} + release-dir: . + release-notes-assistant: true \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 126db91..5b9a2ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: bot: - image: registry.benrmorgan.com/minecraftstatus:latest + image: registry.benrmorgan.com/minecraftstatus/mstbot:latest env_file: - ./secrets/.env volumes: diff --git a/schema.sql b/schema.sql index 4fb9531..ee30e6f 100644 --- a/schema.sql +++ b/schema.sql @@ -1,4 +1,4 @@ PRAGMA journal_mode = WAL; CREATE TABLE names (id INTEGER NOT NULL, name TEXT NOT NULL, UNIQUE (id, name)); CREATE TABLE times (id INTEGER NOT NULL, name TEXT NOT NULL, seconds REAL NOT NULL DEFAULT 0, UNIQUE (id, name)); -CREATE TABLE servers (id INTEGER PRIMARY KEY, ip TEXT NOT NULL, port INTEGER NOT NULL DEFAULT 25565, last_query TIMESTAMP, show_hours BOOLEAN NOT NULL DEFAULT FALSE, announce_joins BOOLEAN NOT NULL DEFAULT FALSE, announce_joins_id INTEGER) WITHOUT ROWID; \ No newline at end of file +CREATE TABLE servers (id INTEGER PRIMARY KEY, ip TEXT NOT NULL, port INTEGER NOT NULL DEFAULT 25565, query_port INTEGER NOT NULL DEFAULT 25565, last_query TIMESTAMP, show_hours BOOLEAN NOT NULL DEFAULT FALSE, announce_joins BOOLEAN NOT NULL DEFAULT FALSE, announce_joins_id INTEGER) WITHOUT ROWID; \ No newline at end of file diff --git a/src/mstbot.py b/src/mstbot.py index dff85f4..c5d26e1 100644 --- a/src/mstbot.py +++ b/src/mstbot.py @@ -5,6 +5,7 @@ # TODO: save invite link to README and show query port enable instructions # TODO: Feature: allow discord users to associate their mc usernames, change server nickname? # TODO: Feature: allow other users to query playtime by username, default to their own. This and above require new columns in names table. +# TODO: If on startup the schema has changed, create new db and attempt to migrate old data into new schema import asyncio import os @@ -78,21 +79,40 @@ class Admin(commands.Cog): @commands.command(brief="Sets up new Minecraft server querier", description="Sets up new Minecraft server querier. Removes previous querier, if applicable. Use a valid domain name or IP address. The default port (25565) is used unless otherwise specified. Specify a channel ID to announce player joins there.") @commands.has_permissions(administrator=True) - async def setup(self, ctx: discord.ext.commands.context.Context, ip: str, port: int = 25565, annChanID: Union[int, str, None] = None): + async def setup(self, ctx: discord.ext.commands.context.Context, ip: str, port: int = 25565, query_port: int = 25565, annChanID: Union[int, str, None] = None): """Sets up a new Minecraft server querier.""" - val_ip, val_port = await validate_ip_port(ctx, ip, port) + val_ip, val_port, val_query_port = await validate_ip_port(ctx, ip, port, query_port) if not val_ip: return - mc = JavaServer.lookup(f"{val_ip}:{val_port}") + server_port_ip = f"{val_ip}:{val_port}" + await log(ctx, f"Attempting to establish connection to {server_port_ip} with query port {val_query_port}...") + mc = JavaServer(val_ip, val_port, query_port=val_query_port) + status = None + query = None try: + status = mc.status() query = mc.query() names = query.players.list except asyncio.exceptions.TimeoutError: - await log(ctx, "Setup query error, query port enabled?") + await log(ctx, ( + "The setup Query protocal ping failed, but the Minecraft server appears to be accessible.\n" + "Does the server.properties file contain `enable-query=true`? Changes to server.properties do not take effect until the Minecraft server is restarted.\n" + f"Is UDP port {val_query_port} forwarded and open on the firewall?" + )) + if status is None: + await log(ctx, ( + "A Status protocol ping also failed.\n" + "Does the server.properties file contain `enable-status=true`?\n" + f"Is TCP port {val_port} forwarded and open on the firewall?" + )) except ConnectionRefusedError: - await log(ctx, "Setup query failed, server down?") + await log(ctx, ( + f"Setup query failed. I could not reach a Minecraft server at {server_port_ip}.\n" + f"Is TCP port {val_port} forwarded and open on the firewall?\n" + f"Can you and a friend (on a different network) connect to and play on {server_port_ip}?" + )) else: with sqlite_conn() as conn: conn.execute(""" @@ -114,7 +134,7 @@ class Admin(commands.Cog): bot.loop.create_task(status_task(ctx.guild.id)) servers.append(ctx.guild.id) - await log(ctx, f"Setup new server query with IP: {val_ip}") + await log(ctx, f"Successfully set up a new server querier for {server_port_ip}!") @commands.command(brief="Removes querier and status channels", description="Removes querier and status channels. Deletes your guild's data from my server. Data deletion occurs automatically when I am removed from your guild.") @@ -262,7 +282,7 @@ async def log(ctx: commands.Context, *msg: str): await safe_send(" ".join(msg), ctx=ctx) -async def validate_ip_port(ctx, ip, port): +async def validate_ip_port(ctx, ip, port, query_port): """Validates IP and Port""" split = ip.split(":") if len(split) > 1: @@ -272,11 +292,15 @@ async def validate_ip_port(ctx, ip, port): port = int(split[1]) except ValueError: await log(ctx, str(port), "is not a valid port number, please try again.") - return None, None + return None, None, None if port not in range(65536): await log(ctx, str(port), "is not a valid port number, please try again.") - return None, None + return None, None, None + + if query_port not in range(65536): + await log(ctx, str(query_port), "is not a valid port number, please try again.") + return None, None, None domain = re.search(r"^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,6}$", ip) addr = re.search( @@ -285,14 +309,14 @@ async def validate_ip_port(ctx, ip, port): if domain is None or domain.group(0) != ip: if addr is None or addr.group(0) != ip: await log(ctx, ip, "is not a valid domain or IP address, please try again.") - return None, None + return None, None, None if re.search(r"(^127\.)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)", ip) is not None: await log(ctx, ip, "is a private IP. I won't be able to query it.") - return None, None + return None, None, None - return ip, port + return ip, port, query_port def get_server_by_id(sid: int): for server in bot.guilds: @@ -357,14 +381,12 @@ def sqlite_conn(): def getMCIP(sid: int, conn: sqlite3.Connection): row = conn.execute( - "SELECT ip, port FROM servers WHERE id = ?", + "SELECT ip, port, query_port FROM servers WHERE id = ?", (sid,) ).fetchone() - ip = row[0] - port = row[1] - - return ip, port + [ip, port, query_port] = row + return ip, port, query_port def getMCNames(sid: int, conn: sqlite3.Connection): @@ -525,8 +547,8 @@ async def status_task(sid: int): return with sqlite_conn() as conn: - ip, port = getMCIP(sid, conn) - mc = JavaServer(ip, port) + ip, port, query_port = getMCIP(sid, conn) + mc = JavaServer(ip, port, query_port) currServ = get_server_by_id(sid) wait = 10 @@ -560,7 +582,7 @@ async def status_task(sid: int): for name in jNames: aChannels = find_channels(serv=currServ, chanid=int(annChanId)) - if len(aChannels) > 0: + if len(aChannels): if annRole is None: await safe_send(name + " joined the game!", chan=aChannels[0]) else: @@ -590,35 +612,36 @@ async def status_task(sid: int): currServ.me: discord.PermissionOverwrite(connect=True, view_channel=True, manage_channels=True) } - if len(iChannels) > 0: + ipStr = f"IP: {ip}" + if len(iChannels): lastIPName = await get_last_channel_name(iChannels[0]) - ipStr = "IP: " + ip if lastIPName != ipStr: print(currServ.name, "Update: IP changed!") await limiter.submit(iChannels[0].id, iChannels[0], ipStr) else: - await currServ.create_voice_channel("IP: " + ip, overwrites=overwrites) + await currServ.create_voice_channel(ipStr, overwrites=overwrites) - if len(pChannels) > 0: + p_str_temp = f"Players: {str(players)}/{str(max)}" + if len(pChannels): lastPName = await get_last_channel_name(pChannels[0]) if players == -1: pStr = lastPName else: - pStr = "Players: " + str(players) + "/" + str(max) + pStr = p_str_temp if lastPName != pStr: print(currServ.name, "Update: Players changed!") await limiter.submit(pChannels[0].id, pChannels[0], pStr) else: - await currServ.create_voice_channel(f"Players: {str(players)}/{str(max)}", + await currServ.create_voice_channel(p_str_temp, overwrites=overwrites) do_show_hours = getShowHours(sid, conn) if do_show_hours and not first_iter and lastSeconds != -1: tStr = "Player Hrs: " + str(round(getPersonSeconds(sid, conn)/3600)) - if len(tChannels) > 0: + if len(tChannels): lastTName = await get_last_channel_name(tChannels[0]) if lastTName != tStr: @@ -628,12 +651,15 @@ async def status_task(sid: int): await currServ.create_voice_channel(tStr, overwrites=overwrites) elif not do_show_hours: - if len(tChannels) > 0: + if len(tChannels): for channel in tChannels: await channel.delete() - - print(currServ.name, "Query:", ip, str(players) + "/" + str(max), datetime.now(timezone.utc).strftime("%H:%M:%S"), - str(deltaSeconds), "IP:", str(await limiter.get_time_remaining(iChannels[0].id)), "P:", str(await limiter.get_time_remaining(pChannels[0].id)), "T:", str(await limiter.get_time_remaining(tChannels[0].id))) + + ip_channel_str = f"IP: {str(await limiter.get_time_remaining(iChannels[0].id)) if len(iChannels) else 'N/A'}" + p_channel_str = f"P: {str(await limiter.get_time_remaining(pChannels[0].id)) if len(pChannels) else 'N/A'}" + t_channel_str = f"T: {str(await limiter.get_time_remaining(tChannels[0].id)) if len(tChannels) else 'N/A'}" + print(currServ.name, "Query:", ip, p_str_temp, datetime.now(timezone.utc).strftime("%H:%M:%S"), + str(deltaSeconds), ip_channel_str, p_channel_str, t_channel_str) await asyncio.sleep(wait) first_iter = False