Skip to content

Commit 5e22809

Browse files
committed
feat: integrate NEM commands and Discord relay from Serenity
Add total, missmodid, and blinks subcommands to =nem: - total: mod count across all or a specific MC version - missmodid: find mods missing a modid for a given version - blinks: async broken link checker with progress updates Add nem_relay plugin for Discord webhook integration: - Watches ModBot update messages in #notenoughmods - Forwards mod updates as Discord embeds via configurable webhook - Reuses nem plugin's fetch_json for mod data lookup - No-ops gracefully when webhook URL is unconfigured Add config/nem_relay.yml.example for relay configuration.
1 parent ed3b596 commit 5e22809

3 files changed

Lines changed: 278 additions & 0 deletions

File tree

commands/nem.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,120 @@ async def help(router, name, params, channel, userdata, rank):
424424
await router.send_message(channel, name + ": Invalid command provided")
425425

426426

427+
async def total(router, name, params, channel, userdata, rank):
428+
if len(params) == 2:
429+
ver = params[1]
430+
if ver not in versions:
431+
await router.send_message(channel, f"{name}: MC version not found in NEM.")
432+
return
433+
jsonres = await fetch_json(f"https://bot.notenoughmods.com/{urlquote(ver)}.json", cache=True)
434+
if jsonres is None:
435+
await router.send_message(channel, f"{name}: Could not fetch the list.")
436+
return
437+
count_msg = f"{PURPLE}{len(jsonres)}{COLOUREND} mods in {BOLD}{BLUE}{ver}{COLOUREND}{BOLD}"
438+
await router.send_message(channel, count_msg)
439+
else:
440+
count = 0
441+
for ver in versions:
442+
jsonres = await fetch_json(f"https://bot.notenoughmods.com/{urlquote(ver)}.json", cache=True)
443+
if jsonres is not None:
444+
count += len(jsonres)
445+
await router.send_message(channel, f"{PURPLE}{count}{COLOUREND} mods total across {len(versions)} versions")
446+
447+
448+
async def missmodid(router, name, params, channel, userdata, rank):
449+
if len(params) != 2:
450+
await router.send_message(channel, f"{name}: Usage: =nem missmodid <version>")
451+
return
452+
453+
ver = params[1]
454+
if ver not in versions:
455+
await router.send_message(channel, f"{name}: MC version not found in NEM.")
456+
return
457+
458+
jsonres = await fetch_json(f"https://bot.notenoughmods.com/{urlquote(ver)}.json", cache=True)
459+
if jsonres is None:
460+
await router.send_message(channel, f"{name}: Could not fetch the list.")
461+
return
462+
463+
missing = [mod["name"] for mod in jsonres if mod.get("modid", "") == ""]
464+
465+
if not missing:
466+
await router.send_message(channel, f"{name}: All mods in {BOLD}{BLUE}{ver}{COLOUREND}{BOLD} have a modid set.")
467+
elif len(missing) <= 5:
468+
for mod_name in missing:
469+
await router.send_message(channel, f"[{BLUE}{ver}{COLOUREND}] {mod_name}")
470+
elif len(missing) <= 20:
471+
await router.send_message(channel, f"{len(missing)} mod(s) missing modid. Sending via notice...")
472+
for mod_name in missing:
473+
await router.send_notice(name, f"[{BLUE}{ver}{COLOUREND}] {mod_name}")
474+
else:
475+
msg = f"{len(missing)} mod(s) missing modid in {BOLD}{BLUE}{ver}{COLOUREND}{BOLD}."
476+
await router.send_message(channel, msg)
477+
478+
479+
async def blinks(router, name, params, channel, userdata, rank):
480+
if len(params) != 2:
481+
await router.send_message(channel, f"{name}: Usage: =nem blinks <version>")
482+
return
483+
484+
ver = params[1]
485+
if ver not in versions:
486+
await router.send_message(channel, f"{name}: MC version not found in NEM.")
487+
return
488+
489+
jsonres = await fetch_json(f"https://bot.notenoughmods.com/{urlquote(ver)}.json", cache=True)
490+
if jsonres is None:
491+
await router.send_message(channel, f"{name}: Could not fetch the list.")
492+
return
493+
494+
await router.send_message(channel, f"[{BLUE}{ver}{COLOUREND}] Checking {len(jsonres)} mod links...")
495+
496+
counts = {}
497+
badmods = []
498+
index = 0
499+
500+
for mod in jsonres:
501+
if mod.get("longurl", "") != "":
502+
try:
503+
async with session.head(
504+
mod["longurl"],
505+
timeout=aiohttp.ClientTimeout(total=10),
506+
allow_redirects=True,
507+
) as resp:
508+
code = resp.status
509+
counts[code] = counts.get(code, 0) + 1
510+
if code >= 400:
511+
badmods.append({"name": mod["name"], "reason": code})
512+
except Exception as e:
513+
reason = type(e).__name__
514+
counts[reason] = counts.get(reason, 0) + 1
515+
badmods.append({"name": mod["name"], "reason": reason})
516+
517+
index += 1
518+
if index % 50 == 0:
519+
await router.send_message(channel, f"[{BLUE}{ver}{COLOUREND}] {index} mods processed...")
520+
521+
if not badmods:
522+
await router.send_message(channel, f"[{BLUE}{ver}{COLOUREND}] No broken links found.")
523+
elif len(badmods) <= 5:
524+
await router.send_message(channel, f"{len(badmods)} broken link(s) found:")
525+
for mod in badmods:
526+
await router.send_message(channel, f"[{BLUE}{ver}{COLOUREND}] {mod['name']} ({mod['reason']})")
527+
elif len(badmods) <= 20:
528+
await router.send_message(channel, f"{len(badmods)} broken link(s) found. Sending via notice...")
529+
for mod in badmods:
530+
await router.send_notice(name, f"[{BLUE}{ver}{COLOUREND}] {mod['name']} ({mod['reason']})")
531+
else:
532+
msg = f"{len(badmods)} broken link(s) found in {BOLD}{BLUE}{ver}{COLOUREND}{BOLD}."
533+
await router.send_message(channel, msg)
534+
535+
await router.send_message(
536+
channel,
537+
f"[{BLUE}{ver}{COLOUREND}] Complete. {index} mods processed. Results: {counts}",
538+
)
539+
540+
427541
async def force_cache_redownload(router, name, params, channel, userdata, rank):
428542
if rank >= Permission.ADMIN:
429543
for ver in versions:
@@ -446,6 +560,9 @@ async def force_cache_redownload(router, name, params, channel, userdata, rank):
446560
"help": help,
447561
"setlist": setlist,
448562
"compare": compare,
563+
"total": total,
564+
"missmodid": missmodid,
565+
"blinks": blinks,
449566
"forceredownload": force_cache_redownload,
450567
}
451568

@@ -472,6 +589,18 @@ async def force_cache_redownload(router, name, params, channel, userdata, rank):
472589
"Compares the NEMP entries for two different MC versions and says how many mods "
473590
"haven't been updated to the new version.",
474591
],
592+
"total": [
593+
"=nem total [version]",
594+
"Returns the total number of mods, optionally for a specific MC version.",
595+
],
596+
"missmodid": [
597+
"=nem missmodid <version>",
598+
"Returns mods without a modid set for the given MC version.",
599+
],
600+
"blinks": [
601+
"=nem blinks <version>",
602+
"Checks each mod link for broken URLs (non-OK HTTP status codes).",
603+
],
475604
}
476605

477606
COMMANDS = {

commands/nem_relay.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import json
2+
import logging
3+
import re
4+
import shutil
5+
from pathlib import Path
6+
7+
import aiohttp
8+
import yaml
9+
10+
PLUGIN_ID = "nem_relay"
11+
12+
relay_logger = logging.getLogger("NEM_Relay")
13+
14+
CONFIG_FILE = Path("config/nem_relay.yml")
15+
CONFIG_EXAMPLE = Path("config/nem_relay.yml.example")
16+
17+
MODBOT_REGEX = re.compile(
18+
r"\x0312(?P<list>.+?)\x03\] \x0306(?P<mod>.+?)\x03(?P<dev> \(dev\))? "
19+
r"(?P<phrasing>added at|updated to) \x03(?:03|05)(?P<version>.+?)\x03"
20+
)
21+
22+
session = None
23+
webhook_url = None
24+
listen_channel = "#notenoughmods"
25+
listen_nick = "ModBot"
26+
27+
28+
def _load_config():
29+
if not CONFIG_FILE.exists():
30+
if CONFIG_EXAMPLE.exists():
31+
shutil.copy(CONFIG_EXAMPLE, CONFIG_FILE)
32+
relay_logger.warning("No config at %s — relay will be disabled.", CONFIG_FILE)
33+
return {}
34+
35+
with open(CONFIG_FILE) as f:
36+
return yaml.safe_load(f) or {}
37+
38+
39+
async def setup(router, startup):
40+
global session, webhook_url, listen_channel, listen_nick
41+
42+
config = _load_config()
43+
44+
webhook_url = config.get("discord", {}).get("webhook_url", "")
45+
listen_channel = config.get("nem", {}).get("listen_channel", "#notenoughmods")
46+
listen_nick = config.get("nem", {}).get("listen_nick", "ModBot")
47+
48+
if not webhook_url:
49+
relay_logger.info("Discord webhook URL not configured — relay disabled.")
50+
return
51+
52+
if session:
53+
await session.close()
54+
session = aiohttp.ClientSession(
55+
headers={"User-Agent": "NEM-Relay/1.0"},
56+
)
57+
58+
if not router.events["chat"].event_exists("NEM_Discord_Relay"):
59+
router.events["chat"].add_event("NEM_Discord_Relay", _on_chat, channel=[])
60+
61+
62+
async def teardown(router):
63+
global session
64+
if router.events["chat"].event_exists("NEM_Discord_Relay"):
65+
router.events["chat"].remove_event("NEM_Discord_Relay")
66+
if session:
67+
await session.close()
68+
session = None
69+
70+
71+
async def _on_chat(router, _channels, userdata, message, channel):
72+
if not webhook_url or not session:
73+
return
74+
75+
if not channel or channel.lower() != listen_channel.lower():
76+
return
77+
78+
if userdata["name"] != listen_nick:
79+
return
80+
81+
match = MODBOT_REGEX.search(message)
82+
if not match:
83+
return
84+
85+
mc_version = match.group("list")
86+
mod_name = match.group("mod")
87+
version = match.group("version")
88+
phrasing = match.group("phrasing")
89+
is_dev = bool(match.group("dev"))
90+
91+
# Import fetch_json from the nem plugin to look up mod details
92+
from commands import nem
93+
94+
mod_data = await _lookup_mod(nem, mc_version, mod_name)
95+
96+
long_url = mod_data.get("longurl", "") if mod_data else ""
97+
98+
embed = {
99+
"title": f"{mod_name} {phrasing} {version}",
100+
"url": long_url or None,
101+
"color": 0xA74F32 if is_dev else 0x77AF12,
102+
"fields": [
103+
{"name": "Minecraft", "value": mc_version, "inline": True},
104+
{"name": "Version", "value": version, "inline": True},
105+
{"name": "Version type", "value": "Dev" if is_dev else "Release", "inline": True},
106+
],
107+
}
108+
109+
if long_url:
110+
embed["fields"].append({"name": "URL", "value": long_url, "inline": True})
111+
112+
payload = json.dumps({"embeds": [embed]})
113+
114+
try:
115+
async with session.post(
116+
webhook_url,
117+
data=payload,
118+
headers={"Content-Type": "application/json"},
119+
timeout=aiohttp.ClientTimeout(total=10),
120+
) as resp:
121+
if resp.status >= 400:
122+
relay_logger.warning("Discord webhook returned %s", resp.status)
123+
except Exception:
124+
relay_logger.exception("Failed to send Discord webhook")
125+
126+
127+
async def _lookup_mod(nem_module, mc_version, mod_name):
128+
from urllib.parse import quote as urlquote
129+
130+
try:
131+
jsonres = await nem_module.fetch_json(
132+
f"https://bot.notenoughmods.com/{urlquote(mc_version)}.json",
133+
cache=True,
134+
)
135+
if jsonres:
136+
for mod in jsonres:
137+
if mod["name"] == mod_name:
138+
return mod
139+
except Exception:
140+
relay_logger.exception("Failed to look up mod %s in %s", mod_name, mc_version)
141+
return None
142+
143+
144+
COMMANDS = {}

config/nem_relay.yml.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
discord:
2+
webhook_url: ""
3+
nem:
4+
listen_channel: "#notenoughmods"
5+
listen_nick: "ModBot"

0 commit comments

Comments
 (0)