Skip to content

Commit d600107

Browse files
authored
Merge pull request zeusops#6 from zeusops/attachment
Modlist attachment for /zeus-upload
2 parents 1367365 + 076089f commit d600107

File tree

8 files changed

+143
-25
lines changed

8 files changed

+143
-25
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ The project uses semantic versioning (see [semver](https://semver.org)).
55

66
## [Unreleased]
77

8+
### Added
9+
10+
- `/zeus-upload` command now accepts an optional modlist (JSON snippet exported
11+
from Mod Presets menu in Reforger).
12+
- Command line flag `--debug` for enabling more verbose debug logging.
13+
814
### Fixed
915

1016
- Uploaded missions are now pretty-printed, to make it reviewable.

src/zeusops_bot/cli.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from zeusops_bot import reforger_config_gen as cmd
1010
from zeusops_bot.discord import ZeusopsBot
1111
from zeusops_bot.errors import ZeusopsBotConfigException
12+
from zeusops_bot.logging import setup_logging
1213
from zeusops_bot.models import ModDetail
1314
from zeusops_bot.settings import ZeusopsBotConfig, load
1415

@@ -35,6 +36,11 @@ def parse_arguments(args: list[str]) -> argparse.Namespace:
3536
"zeusops-bot",
3637
description="Multipurpose discord bot for the Zeusops community",
3738
)
39+
parser.add_argument(
40+
"--debug",
41+
help="Enable debug logging",
42+
action="store_true",
43+
)
3844
return parser.parse_args(args)
3945

4046

@@ -43,10 +49,10 @@ def cli(arguments: list[str] | None = None):
4349
if arguments is None:
4450
arguments = sys.argv[1:]
4551
_args = parse_arguments(arguments)
46-
return main()
52+
return main(_args.debug)
4753

4854

49-
def main():
55+
def main(debug: bool = False):
5056
"""Run the main bot"""
5157
try:
5258
config = load(ZeusopsBotConfig)
@@ -60,11 +66,9 @@ def main():
6066
print("Error while loading the bot's config from envvars", file=sys.stderr)
6167
raise
6268

63-
logging.basicConfig(level=logging.DEBUG)
64-
logging.getLogger("discord").setLevel(logging.INFO)
65-
logging.getLogger("discord.gateway").setLevel(logging.WARNING)
69+
setup_logging(debug)
6670

67-
bot = ZeusopsBot(config, logging.getLogger("discord"))
71+
bot = ZeusopsBot(config, logging.getLogger("zeusops.discord"))
6872
bot.run() # Token is already in config
6973

7074

src/zeusops_bot/cogs/zeus_upload.py

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,24 @@
22

33
import discord
44
from discord.ext import commands
5+
from pydantic import TypeAdapter, ValidationError
56

67
from zeusops_bot.errors import (
78
ConfigFileInvalidJson,
89
ConfigFileNotFound,
910
ConfigPatchingError,
1011
)
11-
from zeusops_bot.reforger_config_gen import ReforgerConfigGenerator
12+
from zeusops_bot.models import ModDetail
13+
from zeusops_bot.reforger_config_gen import ReforgerConfigGenerator, extract_mods
14+
from zeusops_bot.settings import ZeusopsBotConfig
15+
16+
modlist_typeadapter = TypeAdapter(list[ModDetail])
1217

1318

1419
class ZeusUpload(commands.Cog):
1520
"""ZeusUpload cog for handling mission uploads"""
1621

17-
def __init__(self, bot, config):
22+
def __init__(self, bot: discord.Bot, config: ZeusopsBotConfig):
1823
"""Initialise the cog"""
1924
self.bot = bot
2025
self.config = config
@@ -24,31 +29,66 @@ def __init__(self, bot, config):
2429
)
2530

2631
@commands.slash_command(name="zeus-upload")
32+
@discord.option(
33+
"modlist",
34+
description="Modlist JSON exported from Reforger",
35+
input_type=discord.SlashCommandOptionType.attachment,
36+
required=False,
37+
)
2738
async def zeus_upload(
28-
self, ctx: discord.ApplicationContext, scenario_id: str, filename: str
39+
self,
40+
ctx: discord.ApplicationContext,
41+
scenario_id: str,
42+
filename: str,
43+
modlist: discord.Attachment | None = None,
2944
):
3045
"""Upload a mission as a Zeus"""
31-
try: # TODO: How do we pass modlist != None
46+
extracted_mods = None
47+
try:
48+
if modlist is not None:
49+
data = await modlist.read()
50+
extracted_mods = extract_mods(data.decode())
51+
except ConfigFileInvalidJson as e:
52+
await ctx.respond(
53+
"Failed to understand the attached modlist as JSON. "
54+
"Check the file was exported from the workshop, "
55+
"try confirming with an online validator like "
56+
"<https://jsonlint.com/>.\n\n"
57+
f"Parse error was:\n```\n{e}\n```"
58+
)
59+
return
60+
except ValidationError as e:
61+
await ctx.respond(
62+
"Failed to understand the modlist given: valid JSON, "
63+
"but not a list of mod objects (name/ID/optional-version). "
64+
"Check the file was exported from the workshop.\n\n"
65+
f"Error was:\n```\n{e}\n```"
66+
)
67+
return
68+
try:
3269
path = self.reforger_confgen.zeus_upload(
33-
scenario_id, filename, modlist=None
70+
scenario_id, filename, modlist=extracted_mods
3471
)
35-
await ctx.respond(f"Mission uploaded successfully under {path=}")
3672
except ConfigFileNotFound:
3773
await ctx.respond(
3874
"Bot config error: the base config file could not be found"
39-
" Tell the Techmins! Path was: "
40-
+ str(self.reforger_confgen.base_config)
75+
f" Tell the Techmins! Path was: {self.reforger_confgen.base_config}"
4176
)
77+
return
4278
except ConfigFileInvalidJson as e:
4379
await ctx.respond(
4480
"Bot config error: the base config file is invalid JSON "
45-
"Tell the Techmins! Error was: " + str(e)
81+
"Tell the Techmins!\n\n"
82+
f"Error was:\n```\n{e}\n```"
4683
)
84+
return
4785
except ConfigPatchingError as e:
4886
await ctx.respond(
49-
"Failed to patch your requested change over base config.\n"
50-
f"Error was: {str(e)}"
87+
"Failed to patch your requested change over base config.\n\n"
88+
f"Error was:\n```\n{e}\n```"
5189
)
90+
return
91+
await ctx.respond(f"Mission uploaded successfully under {path=}")
5292

5393
@commands.slash_command(name="zeus-set-mission")
5494
async def zeus_set_mission(self, ctx: discord.ApplicationContext, filename: str):
@@ -77,11 +117,11 @@ async def current_mission(self, ctx: discord.ApplicationContext):
77117
f"Current mission: `{self.reforger_confgen.current_mission()}`"
78118
)
79119
except ConfigFileNotFound as e:
80-
await ctx.respond(f"Could not find configured mission: {str(e)}")
120+
await ctx.respond(f"Could not find configured mission: {e}")
81121

82122
@commands.Cog.listener()
83123
async def on_application_command_error(
84124
self, ctx: discord.ApplicationContext, error: discord.DiscordException
85125
):
86126
"""Handles application command errors that are not caught elsewhere"""
87-
await ctx.respond(f"Unhandled exception: {error}")
127+
await ctx.respond(f"Unhandled exception: \n\nError was: {error}")

src/zeusops_bot/discord.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Manage the Discord-side horrors"""
22

3+
from logging import Logger
4+
35
from discord import Bot
46

57
from zeusops_bot.cogs import ZeusUpload
@@ -9,7 +11,7 @@
911
class ZeusopsBot(Bot):
1012
"""A Discord Client for general purposes"""
1113

12-
def __init__(self, config: ZeusopsBotConfig, logger, *args, **kwargs):
14+
def __init__(self, config: ZeusopsBotConfig, logger: Logger, *args, **kwargs):
1315
"""Initialize the Client"""
1416
super().__init__(*args, **kwargs)
1517
self.config = config

src/zeusops_bot/logging.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Logging-related utilities"""
2+
3+
import logging
4+
5+
6+
def setup_logging(debug):
7+
"""Set up basic logger"""
8+
if debug:
9+
logging.basicConfig(level=logging.DEBUG)
10+
else:
11+
logging.basicConfig(level=logging.INFO)
12+
13+
# By default the Discord library is very verbose, so we're limiting it
14+
# a bit
15+
logging.getLogger("discord").setLevel(logging.INFO)
16+
logging.getLogger("discord.gateway").setLevel(logging.WARNING)

src/zeusops_bot/reforger_config_gen.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@
44
from pathlib import Path
55

66
import jsonpatch
7+
from pydantic import TypeAdapter, ValidationError
78

89
from zeusops_bot import errors
10+
from zeusops_bot.errors import ConfigFileInvalidJson
911
from zeusops_bot.models import ModDetail
1012

13+
modlist_typeadapter = TypeAdapter(list["ModDetail"])
14+
1115
SYMLINK_FILENAME = "current-config.json"
1216

1317

@@ -138,3 +142,24 @@ def patch_file(source: dict, modlist: list[ModDetail] | None, scenario_id: str)
138142
except jsonpatch.JsonPatchException as e:
139143
raise errors.ConfigPatchingError(e)
140144
return mod
145+
146+
147+
def extract_mods(modlist: str | None) -> list[ModDetail] | None:
148+
"""Extracts a list of ModDetail entries from a mod list exported from Reforger."""
149+
if modlist is None:
150+
return None
151+
modlist = f"[{modlist}]"
152+
try:
153+
return modlist_typeadapter.validate_json(modlist)
154+
except ValidationError as e:
155+
errors = e.errors()
156+
if len(errors) > 1:
157+
# Got more than 1 error, not our problem
158+
raise e
159+
error = errors[0]
160+
match error["type"]:
161+
case "json_invalid":
162+
raise ConfigFileInvalidJson(e)
163+
case _:
164+
# Unknown error
165+
raise e

tests/fixtures.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,34 @@
1010

1111
BASE_CONFIG: ConfigFile = {"game": {"scenarioId": "old-value", "mods": []}}
1212

13-
MODLIST: list[ModDetail] = [
13+
MODLIST_DICT: list[ModDetail] = [
1414
{"modId": "595F2BF2F44836FB", "name": "RHS - Status Quo", "version": "0.10.4075"},
1515
{"modId": "5EB744C5F42E0800", "name": "ACE Chopping", "version": "1.2.0"},
1616
{"modId": "60EAEA0389DB3CC2", "name": "ACE Trenches", "version": "1.2.0"},
1717
]
1818

19+
# NOTE: These two formats are not 100% equivalent: Reforger exports the mods in
20+
# a JSON list, but doesn't actually include the outer [] in the exported
21+
# string, because the data is meant to be pasted directly inside the
22+
# `"mods": [ ]` entry of the config file.
23+
MODLIST_JSON: str = """
24+
{
25+
"modId": "595F2BF2F44836FB",
26+
"name": "RHS - Status Quo",
27+
"version": "0.10.4075"
28+
},
29+
{
30+
"modId": "5EB744C5F42E0800",
31+
"name": "ACE Chopping",
32+
"version": "1.2.0"
33+
},
34+
{
35+
"modId": "60EAEA0389DB3CC2",
36+
"name": "ACE Trenches",
37+
"version": "1.2.0"
38+
}
39+
"""
40+
1941

2042
@pytest.fixture
2143
def mission_dir(tmp_path: Path) -> Path:

tests/test_upload_mission.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99
import json
1010

11-
from tests.fixtures import BASE_CONFIG, MODLIST
12-
from zeusops_bot.reforger_config_gen import ReforgerConfigGenerator
11+
from tests.fixtures import BASE_CONFIG, MODLIST_DICT, MODLIST_JSON
12+
from zeusops_bot.reforger_config_gen import ReforgerConfigGenerator, extract_mods
1313

1414

1515
def test_upload_edits_files(tmp_path):
@@ -23,13 +23,15 @@ def test_upload_edits_files(tmp_path):
2323
source_file.write_text(json.dumps(BASE_CONFIG))
2424
gen = ReforgerConfigGenerator(base_config_file=source_file, target_folder=dest)
2525
# When Zeus calls "/zeus-upload"
26-
out_path = gen.zeus_upload(scenario_id, filename, MODLIST)
26+
modlist = extract_mods(MODLIST_JSON)
27+
out_path = gen.zeus_upload(scenario_id, filename, modlist)
2728
# Then a new server config file is created
2829
assert out_path.is_file(), "Should have generated a file on disk"
2930
# And the config file is patched with <modlist.json> and <scenarioId>
3031
config = json.loads(out_path.read_text())
3132
assert config["game"]["scenarioId"] == scenario_id, "Should update scenarioId"
32-
assert config["game"]["mods"] == MODLIST, "Should update modlist"
33+
assert isinstance(config["game"]["mods"], list)
34+
assert config["game"]["mods"][0] == MODLIST_DICT[0]
3335

3436

3537
def test_upload_edits_files_without_modlist(tmp_path):
@@ -49,4 +51,5 @@ def test_upload_edits_files_without_modlist(tmp_path):
4951
# And the config file is patched with just <scenarioId>
5052
config = json.loads(out_path.read_text())
5153
assert config["game"]["scenarioId"] == scenario_id, "Should update scenarioId"
54+
assert isinstance(config["game"]["mods"], list)
5255
assert config["game"]["mods"] == BASE_CONFIG["game"]["mods"], "Should keep modlist"

0 commit comments

Comments
 (0)