Workflow?

This commit is contained in:
Benjamin Morgan 2026-09-25 22:06:36 -06:00
parent d67006a008
commit 9235d6ec07
4 changed files with 113 additions and 33 deletions

View file

@ -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

View file

@ -1,6 +1,6 @@
services: services:
bot: bot:
image: registry.benrmorgan.com/minecraftstatus:latest image: registry.benrmorgan.com/minecraftstatus/mstbot:latest
env_file: env_file:
- ./secrets/.env - ./secrets/.env
volumes: volumes:

View file

@ -1,4 +1,4 @@
PRAGMA journal_mode = WAL; PRAGMA journal_mode = WAL;
CREATE TABLE names (id INTEGER NOT NULL, name TEXT NOT NULL, UNIQUE (id, name)); 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 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; 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;

View file

@ -5,6 +5,7 @@
# TODO: save invite link to README and show query port enable instructions # 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 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: 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 asyncio
import os import os
@ -78,21 +79,40 @@ class Admin(commands.Cog):
@commands.command(brief="Sets up new Minecraft server querier", @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.") 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) @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.""" """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: if not val_ip:
return 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: try:
status = mc.status()
query = mc.query() query = mc.query()
names = query.players.list names = query.players.list
except asyncio.exceptions.TimeoutError: 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: 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: else:
with sqlite_conn() as conn: with sqlite_conn() as conn:
conn.execute(""" conn.execute("""
@ -114,7 +134,7 @@ class Admin(commands.Cog):
bot.loop.create_task(status_task(ctx.guild.id)) bot.loop.create_task(status_task(ctx.guild.id))
servers.append(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", @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.") 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) 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""" """Validates IP and Port"""
split = ip.split(":") split = ip.split(":")
if len(split) > 1: if len(split) > 1:
@ -272,11 +292,15 @@ async def validate_ip_port(ctx, ip, port):
port = int(split[1]) port = int(split[1])
except ValueError: except ValueError:
await log(ctx, str(port), "is not a valid port number, please try again.") 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): if port not in range(65536):
await log(ctx, str(port), "is not a valid port number, please try again.") 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) domain = re.search(r"^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,6}$", ip)
addr = re.search( 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 domain is None or domain.group(0) != ip:
if addr is None or addr.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.") 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\.)", if re.search(r"(^127\.)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)",
ip) is not None: ip) is not None:
await log(ctx, ip, "is a private IP. I won't be able to query it.") 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): def get_server_by_id(sid: int):
for server in bot.guilds: for server in bot.guilds:
@ -357,14 +381,12 @@ def sqlite_conn():
def getMCIP(sid: int, conn: sqlite3.Connection): def getMCIP(sid: int, conn: sqlite3.Connection):
row = conn.execute( row = conn.execute(
"SELECT ip, port FROM servers WHERE id = ?", "SELECT ip, port, query_port FROM servers WHERE id = ?",
(sid,) (sid,)
).fetchone() ).fetchone()
ip = row[0] [ip, port, query_port] = row
port = row[1] return ip, port, query_port
return ip, port
def getMCNames(sid: int, conn: sqlite3.Connection): def getMCNames(sid: int, conn: sqlite3.Connection):
@ -525,8 +547,8 @@ async def status_task(sid: int):
return return
with sqlite_conn() as conn: with sqlite_conn() as conn:
ip, port = getMCIP(sid, conn) ip, port, query_port = getMCIP(sid, conn)
mc = JavaServer(ip, port) mc = JavaServer(ip, port, query_port)
currServ = get_server_by_id(sid) currServ = get_server_by_id(sid)
wait = 10 wait = 10
@ -560,7 +582,7 @@ async def status_task(sid: int):
for name in jNames: for name in jNames:
aChannels = find_channels(serv=currServ, chanid=int(annChanId)) aChannels = find_channels(serv=currServ, chanid=int(annChanId))
if len(aChannels) > 0: if len(aChannels):
if annRole is None: if annRole is None:
await safe_send(name + " joined the game!", chan=aChannels[0]) await safe_send(name + " joined the game!", chan=aChannels[0])
else: else:
@ -590,35 +612,36 @@ async def status_task(sid: int):
currServ.me: discord.PermissionOverwrite(connect=True, view_channel=True, manage_channels=True) 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]) lastIPName = await get_last_channel_name(iChannels[0])
ipStr = "IP: " + ip
if lastIPName != ipStr: if lastIPName != ipStr:
print(currServ.name, "Update: IP changed!") print(currServ.name, "Update: IP changed!")
await limiter.submit(iChannels[0].id, iChannels[0], ipStr) await limiter.submit(iChannels[0].id, iChannels[0], ipStr)
else: 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]) lastPName = await get_last_channel_name(pChannels[0])
if players == -1: if players == -1:
pStr = lastPName pStr = lastPName
else: else:
pStr = "Players: " + str(players) + "/" + str(max) pStr = p_str_temp
if lastPName != pStr: if lastPName != pStr:
print(currServ.name, "Update: Players changed!") print(currServ.name, "Update: Players changed!")
await limiter.submit(pChannels[0].id, pChannels[0], pStr) await limiter.submit(pChannels[0].id, pChannels[0], pStr)
else: else:
await currServ.create_voice_channel(f"Players: {str(players)}/{str(max)}", await currServ.create_voice_channel(p_str_temp,
overwrites=overwrites) overwrites=overwrites)
do_show_hours = getShowHours(sid, conn) do_show_hours = getShowHours(sid, conn)
if do_show_hours and not first_iter and lastSeconds != -1: if do_show_hours and not first_iter and lastSeconds != -1:
tStr = "Player Hrs: " + str(round(getPersonSeconds(sid, conn)/3600)) tStr = "Player Hrs: " + str(round(getPersonSeconds(sid, conn)/3600))
if len(tChannels) > 0: if len(tChannels):
lastTName = await get_last_channel_name(tChannels[0]) lastTName = await get_last_channel_name(tChannels[0])
if lastTName != tStr: if lastTName != tStr:
@ -628,12 +651,15 @@ async def status_task(sid: int):
await currServ.create_voice_channel(tStr, await currServ.create_voice_channel(tStr,
overwrites=overwrites) overwrites=overwrites)
elif not do_show_hours: elif not do_show_hours:
if len(tChannels) > 0: if len(tChannels):
for channel in tChannels: for channel in tChannels:
await channel.delete() await channel.delete()
print(currServ.name, "Query:", ip, str(players) + "/" + str(max), datetime.now(timezone.utc).strftime("%H:%M:%S"), ip_channel_str = f"IP: {str(await limiter.get_time_remaining(iChannels[0].id)) if len(iChannels) else 'N/A'}"
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))) 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) await asyncio.sleep(wait)
first_iter = False first_iter = False