]> git.aero2k.de Git - urlbot-v3.git/commitdiff
Let room moderators toggle plugins, not just configured admins master
authorThorsten <mail@aero2k.de>
Fri, 11 Sep 2026 14:04:14 +0000 (16:04 +0200)
committerThorsten <mail@aero2k.de>
Fri, 11 Sep 2026 14:04:14 +0000 (16:04 +0200)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJp2rEnVtsgbH7FLj3jubx

src/distbot/bot/bot.py
src/distbot/common/config/local_config.ini.spec
src/distbot/plugins/plugin_help.py
tests/test_unit/test_muc_message.py
tests/test_unit/test_plugin_toggle.py

index 8f881edd6c8cf89788094ad58569ce3cf848051d..50263000919ccfdccada11c175e2151088558cfc 100644 (file)
@@ -280,15 +280,34 @@ class Bot(slixmpp.ClientXMPP):
 
             process_message(
                 routing_key=routing_key,
 
             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
     @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,
         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):
         })
 
     def echo(self, body: str, recipient: str = None):
index d13babf0479a1fb5eff2260d3b80e2db9ed0b306..d2174e3ac876d41ace2944c2f07dc5c31c584894 100644 (file)
@@ -6,7 +6,7 @@ src-url = string
 
 bot_nickname = string
 bot_owner = 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
 admins = string_list(default=list())
 bot_owner_email = string
 detectlanguage_api_key = string
index 1ab2be87af4dfcb5f9418194b38693db357f0949..a30b4405be69046db12bf4123566694ce6af4752 100644 (file)
@@ -54,14 +54,21 @@ class Plugins(Worker):
         self.build_reverse_lookup()
         return self.reverse_lookup.get(name)
 
         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 <str>" turning into a substring check
             admins = [admins]
         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 <str>" 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)
             return Action(msg="{}: you are not allowed to do that".format(nick))
 
         resolved = self.resolve_plugin_name(plugin_name)
index d26db8dfcce064afd862a643c746fbfd5515dc02..6108f8d9e0dc8710ab07fd3df039fb82f22ed027 100644 (file)
@@ -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()
     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"}
index b92a2d332864029d9a9328c5a1c8ccae8c006a6e..461d080329a3f060108ed617ff2ae2f8497c6ac2 100644 (file)
@@ -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")
         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 == {}