From 4562a676444593a249b467299a53e8e8bd01921a Mon Sep 17 00:00:00 2001 From: Thorsten Date: Fri, 11 Sep 2026 16:04:14 +0200 Subject: [PATCH] Let room moderators toggle plugins, not just configured admins The chat daemon now attaches the sender's MUC role and affiliation (from the xep_0045 presence roster, no round-trip) to every groupchat message it forwards, so workers can make rank-based decisions. The plugin toggle accepts role == moderator in addition to the "admins" list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KJp2rEnVtsgbH7FLj3jubx --- src/distbot/bot/bot.py | 25 +++++++++-- .../common/config/local_config.ini.spec | 2 +- src/distbot/plugins/plugin_help.py | 13 ++++-- tests/test_unit/test_muc_message.py | 43 +++++++++++++++++++ tests/test_unit/test_plugin_toggle.py | 14 ++++++ 5 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/distbot/bot/bot.py b/src/distbot/bot/bot.py index 8f881ed..5026300 100644 --- a/src/distbot/bot/bot.py +++ b/src/distbot/bot/bot.py @@ -280,15 +280,34 @@ class Bot(slixmpp.ClientXMPP): process_message( routing_key=routing_key, - body=self.get_amqp_message_body(msg, nick_offset, recipient) + body=self.get_amqp_message_body(msg, nick_offset, recipient, self.get_muc_rank(msg)) ) + def get_muc_rank(self, msg) -> dict: + """MUC role/affiliation of the sender, from the xep_0045 presence roster (no round-trip). + + Workers never see XMPP, so this is the only way for a plugin to know whether a + sender is e.g. a moderator. Empty for non-groupchat messages or unknown occupants. + """ + if msg["type"] != "groupchat": + return {} + muc = self.plugin["xep_0045"] + try: + return { + "role": muc.get_jid_property(msg["mucroom"], msg["mucnick"], "role"), + "affiliation": muc.get_jid_property(msg["mucroom"], msg["mucnick"], "affiliation"), + } + except Exception as e: + logger.warning("could not look up MUC rank of %s: %s", msg["from"], e) + return {} + @staticmethod - def get_amqp_message_body(msg, nick_offset, recipient) -> str: + def get_amqp_message_body(msg, nick_offset, recipient, rank=None) -> str: return json.dumps({ "from": msg["from"].jid, "to": recipient, - "body": msg["body"][nick_offset:].strip() + "body": msg["body"][nick_offset:].strip(), + **(rank or {}), }) def echo(self, body: str, recipient: str = None): diff --git a/src/distbot/common/config/local_config.ini.spec b/src/distbot/common/config/local_config.ini.spec index d13babf..d2174e3 100644 --- a/src/distbot/common/config/local_config.ini.spec +++ b/src/distbot/common/config/local_config.ini.spec @@ -6,7 +6,7 @@ src-url = string bot_nickname = string bot_owner = string -# nicks allowed to activate/deactivate plugins at runtime +# nicks allowed to activate/deactivate plugins at runtime (room moderators always may) admins = string_list(default=list()) bot_owner_email = string detectlanguage_api_key = string diff --git a/src/distbot/plugins/plugin_help.py b/src/distbot/plugins/plugin_help.py index 1ab2be8..a30b440 100644 --- a/src/distbot/plugins/plugin_help.py +++ b/src/distbot/plugins/plugin_help.py @@ -54,14 +54,21 @@ class Plugins(Worker): self.build_reverse_lookup() return self.reverse_lookup.get(name) - def set_plugin_state(self, msg, plugin_name: str, enabled: bool) -> Action: - nick = get_nick_from_message(msg) + @staticmethod + def is_admin(msg) -> bool: + """Room moderators (MUC role, attached by the chat daemon) or nicks listed in `admins`.""" + if msg.get("role") == "moderator": + return True admins = conf_get("admins") or [] if isinstance(admins, str): # "admins = TRex" without the trailing comma fails string_list validation, but # Config() doesn't enforce it, so guard against "in " turning into a substring check admins = [admins] - if nick not in admins: + return get_nick_from_message(msg) in admins + + def set_plugin_state(self, msg, plugin_name: str, enabled: bool) -> Action: + nick = get_nick_from_message(msg) + if not self.is_admin(msg): return Action(msg="{}: you are not allowed to do that".format(nick)) resolved = self.resolve_plugin_name(plugin_name) diff --git a/tests/test_unit/test_muc_message.py b/tests/test_unit/test_muc_message.py index d26db8d..6108f8d 100644 --- a/tests/test_unit/test_muc_message.py +++ b/tests/test_unit/test_muc_message.py @@ -48,3 +48,46 @@ def test_ignores_non_groupchat(): msg = {"mucnick": "someRealUser", "type": "chat"} assert Bot.muc_message(fake, msg) is False fake.message.assert_not_called() + + +def _muc_msg(nick="Alice", room="room@conf", type_="groupchat"): + from_ = Mock() + from_.jid = f"{room}/{nick}" + return {"type": type_, "mucroom": room, "mucnick": nick, "from": from_, "body": "urlbug: plugin deactivate dice"} + + +def test_muc_rank_comes_from_xep_0045_roster(): + fake = _fake_bot() + fake.plugin = {"xep_0045": Mock()} + fake.plugin["xep_0045"].get_jid_property.side_effect = \ + lambda room, nick, prop: {"role": "moderator", "affiliation": "admin"}[prop] + assert Bot.get_muc_rank(fake, _muc_msg()) == {"role": "moderator", "affiliation": "admin"} + fake.plugin["xep_0045"].get_jid_property.assert_any_call("room@conf", "Alice", "role") + + +def test_muc_rank_empty_for_private_chat(): + fake = _fake_bot() + fake.plugin = {"xep_0045": Mock()} + assert Bot.get_muc_rank(fake, _muc_msg(type_="chat")) == {} + fake.plugin["xep_0045"].get_jid_property.assert_not_called() + + +def test_muc_rank_lookup_failure_is_not_fatal(): + fake = _fake_bot() + fake.plugin = {"xep_0045": Mock()} + fake.plugin["xep_0045"].get_jid_property.side_effect = KeyError("unknown occupant") + assert Bot.get_muc_rank(fake, _muc_msg()) == {} + + +def test_message_body_carries_rank(): + import json + body = json.loads(Bot.get_amqp_message_body(_muc_msg(), len("urlbug: "), "room@conf", + {"role": "moderator", "affiliation": "owner"})) + assert body == {"from": "room@conf/Alice", "to": "room@conf", "body": "plugin deactivate dice", + "role": "moderator", "affiliation": "owner"} + + +def test_message_body_without_rank_is_unchanged(): + import json + body = json.loads(Bot.get_amqp_message_body(_muc_msg(), len("urlbug: "), "room@conf")) + assert body == {"from": "room@conf/Alice", "to": "room@conf", "body": "plugin deactivate dice"} diff --git a/tests/test_unit/test_plugin_toggle.py b/tests/test_unit/test_plugin_toggle.py index b92a2d3..461d080 100644 --- a/tests/test_unit/test_plugin_toggle.py +++ b/tests/test_unit/test_plugin_toggle.py @@ -79,3 +79,17 @@ def test_single_admin_without_trailing_comma_is_not_a_substring_match(helper, st Action(msg="Rex: you are not allowed to do that") assert _run(helper, ["nick", "plugin", "deactivate", "dice"], sender="room@conf/TRex") == \ Action(msg="dice deactivated") + + +def test_moderator_role_is_admin_without_being_listed(helper, state): + helper.used_routing_key = ["nick", "plugin", "deactivate", "dice"] + msg = {"from": USER, "to": "room@conf", "body": "", "role": "moderator", "affiliation": "admin"} + assert helper.parse_body(msg) == Action(msg="dice deactivated") + assert state == {"dice": False} + + +def test_participant_role_is_not_admin(helper, state): + helper.used_routing_key = ["nick", "plugin", "deactivate", "dice"] + msg = {"from": USER, "to": "room@conf", "body": "", "role": "participant", "affiliation": "member"} + assert helper.parse_body(msg) == Action(msg="Someone: you are not allowed to do that") + assert state == {} -- 2.47.3