value: "{{bot_owner}}"
- key: "bot_owner_email"
value: "{{bot_owner_email}}"
+ - key: "admins"
+ value: "{{ admins | default([]) | join(', ') }},"
- key: "amqp_uri"
value: "{{ amqp_uri }}"
- key: "broker_backend"
from distbot.common.action import Action, send_action
from distbot.common.broker import SyncBroker, get_sync_broker
-from distbot.common.config import conf_get
+from distbot.common.config import conf_get, is_plugin_enabled
logger = logging.getLogger(__name__)
self.history = self.total_history[body["to"]]
action = None
try:
- action = self.parse_body(body)
+ if self.is_enabled():
+ action = self.parse_body(body)
+ else:
+ logger.debug("%s is deactivated, ignoring message", self.get_subclass_name())
+ # keep recording history while suspended, so the plugin isn't blind once reactivated
if self.uses_history:
self.total_history[body["to"]].append(body)
assert (action is None) or isinstance(action, Action)
def get_subclass_name(self):
return self.__class__.__name__
+ def is_enabled(self) -> bool:
+ return is_plugin_enabled(self.get_subclass_name())
+
def die(self):
self.broker.stop()
import logging
import os
+import threading
from configobj import ConfigObj
from validate import Validator
Config().set(key, value)
+def plugin_enabled_key(plugin_name: str) -> str:
+ return "plugins.{}.enabled".format(plugin_name.lower())
+
+
+def is_plugin_enabled(plugin_name: str) -> bool:
+ # unknown/unset means enabled; only an explicit False suspends a plugin
+ return conf_get(plugin_enabled_key(plugin_name)) is not False
+
+
+def set_plugin_enabled(plugin_name: str, enabled: bool):
+ conf_set(plugin_enabled_key(plugin_name), bool(enabled))
+
+
CONFIG_SUFFIX = os.environ.get('BOTSUFFIX', '')
RESOURCE_PATH = "src/distbot/common/config"
+LOCAL_CONFIG_FILE = 'local_config{}.ini'.format(CONFIG_SUFFIX)
+PERSISTENT_CONFIG_FILE = 'persistent_config{}.ini'.format(CONFIG_SUFFIX)
+
+# Parsed+validated stores, shared by all Config() instances in this process and
+# reloaded only when one of the ini files changes on disk. conf_get() is called
+# from hot paths (e.g. once per delivered message per worker thread), and
+# parsing both files every time was the dominant cost.
+_cache_lock = threading.RLock()
+_cache_stamp = None
+_cache_stores = None
+
+
+def _files_stamp():
+ stamp = []
+ for filename in (LOCAL_CONFIG_FILE, PERSISTENT_CONFIG_FILE):
+ try:
+ st = os.stat(filename)
+ stamp.append((st.st_mtime_ns, st.st_size))
+ except FileNotFoundError:
+ stamp.append(None)
+ return tuple(stamp)
+
+
+def _invalidate_cache():
+ global _cache_stamp, _cache_stores
+ with _cache_lock:
+ _cache_stamp = None
+ _cache_stores = None
+
+
+def _load_stores():
+ config_store = ConfigObj(
+ LOCAL_CONFIG_FILE,
+ configspec='{}/local_config.ini.spec'.format(RESOURCE_PATH),
+ encoding='utf-8'
+ )
+ runtime_config_store = ConfigObj(
+ PERSISTENT_CONFIG_FILE,
+ configspec='{}/persistent_config.ini.spec'.format(RESOURCE_PATH),
+ encoding='utf-8'
+ )
+ if not all(c.validate(Validator(), True) for c in (config_store, runtime_config_store)):
+ logger.error("Config Validation failed")
+ raise SystemError()
+ return config_store, runtime_config_store
class Config:
def __init__(self):
- self.config_store = ConfigObj(
- 'local_config{}.ini'.format(CONFIG_SUFFIX),
- configspec='{}/local_config.ini.spec'.format(RESOURCE_PATH),
- encoding='utf-8'
- )
- self.runtime_config_store = ConfigObj(
- 'persistent_config{}.ini'.format(CONFIG_SUFFIX),
- configspec='{}/persistent_config.ini.spec'.format(RESOURCE_PATH),
- encoding='utf-8'
- )
- if not all(c.validate(Validator(), True) for c in (self.config_store, self.runtime_config_store)):
- logger.error("Config Validation failed")
- raise SystemError()
- else:
- # TODO: can't remember the reason for this one
- # disabled for now.
- # self.config_store.write()
- # self.runtime_config_store.write()
- pass
+ global _cache_stamp, _cache_stores
+ with _cache_lock:
+ stamp = _files_stamp()
+ if _cache_stores is None or stamp != _cache_stamp:
+ _cache_stores = _load_stores()
+ _cache_stamp = stamp
+ self.config_store, self.runtime_config_store = _cache_stores
def _set(self, cfg, path, value):
if len(path) == 1:
elif not path:
raise KeyError()
else:
+ # create intermediate sections on demand (configobj turns dicts into Sections)
+ if path[0] not in cfg:
+ cfg[path[0]] = {}
self._set(cfg=cfg[path[0]], path=path[1:], value=value)
def set(self, key, value):
- if '.' in key:
- path = key.split('.')
- self._set(self.runtime_config_store, path, value)
- else:
- self.runtime_config_store[key] = value
- self.runtime_config_store.write()
+ with _cache_lock:
+ if '.' in key:
+ path = key.split('.')
+ self._set(self.runtime_config_store, path, value)
+ else:
+ self.runtime_config_store[key] = value
+ self.runtime_config_store.write()
+ # mtime granularity may not catch a write within the same tick - force a reload
+ _invalidate_cache()
def get(self, key):
- if '.' in key:
- path = key.split('.')
- value = self.get(path.pop(0))
- for p in path:
+ path = key.split('.')
+ with _cache_lock:
+ # local_config.ini takes precedence over persistent_config.ini (top-level keys only,
+ # the two specs don't overlap). Deliberately no merge() into the shared runtime
+ # store: that would leak static config (credentials!) into persistent_config.ini
+ # on the next write.
+ if path[0] in self.config_store:
+ value = self.config_store.get(path[0])
+ else:
+ value = self.runtime_config_store.get(path[0])
+ for p in path[1:]:
value = value.get(p)
# die early, if run into a dead end
if value is None:
break
- else:
- self.runtime_config_store.merge(self.config_store)
- value = self.runtime_config_store.get(key)
return value
bot_nickname = string
bot_owner = string
+# nicks allowed to activate/deactivate plugins at runtime
+admins = string_list(default=list())
bot_owner_email = string
detectlanguage_api_key = string
start_time = integer(default=0)
[plugins]
+ # every plugin section (keyed by lowercased class name) can be suspended at
+ # runtime via "bot: plugin deactivate <name>", see Worker.callback()
+ [[__many__]]
+ enabled = boolean(default=true)
[[info]]
enabled = boolean(default=true)
[[dsa-watcher]]
+ enabled = boolean(default=true)
interval = integer(default=900)
active = boolean(default=False)
last_dsa = int()
last_dsa_date = float()
[[dice]]
+ enabled = boolean(default=true)
# the "dice" feature will use more efficient random data (0) for given users
enhanced-random-user = string_list(default=list())
[[moin]]
+ enabled = boolean(default=true)
# the "moin" feature will be "disabled" for given users
moin-modified-user = string_list(default=list())
moin-disabled-user = string_list(default=list())
[[teatimer]]
+ enabled = boolean(default=true)
steep_time = integer(default=220)
[[urlresolve]]
+ enabled = boolean(default=true)
[[[blacklist]]]
heise = string(default='^.*heise\.de/.*-[0-9]+\.html$')
from distbot.bot.worker import Worker
from distbot.common.action import Action
+from distbot.common.config import conf_get, is_plugin_enabled, set_plugin_enabled
+from distbot.common.message import get_nick_from_message
logger = logging.getLogger(__name__)
if key.startswith("nick"):
self.reverse_lookup[key.split('.')[1]] = plugin
+ def resolve_plugin_name(self, name: str) -> str | None:
+ """Map a class name or one of its command words to the registered plugin name (lowercased)."""
+ name = name.lower()
+ if name in self.plugin_storage:
+ return 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)
+ if nick not in (conf_get("admins") or []):
+ return Action(msg="{}: you are not allowed to do that".format(nick))
+
+ resolved = self.resolve_plugin_name(plugin_name)
+ if not resolved:
+ return Action(msg="unknown plugin {}, try help".format(plugin_name))
+ if resolved == self.get_subclass_name().lower() and not enabled:
+ return Action(msg="nice try, but then nobody could reactivate anything")
+
+ if is_plugin_enabled(resolved) == enabled:
+ return Action(msg="{} is already {}".format(resolved, "active" if enabled else "deactivated"))
+ set_plugin_enabled(resolved, enabled)
+ return Action(msg="{} {}".format(resolved, "activated" if enabled else "deactivated"))
+
def send_help(self, plugin_name=None):
if not plugin_name:
msg = "Known commands/reactions:\n" + ", ".join(self.plugin_storage.keys())
plugin_name = bind[2]
return self.send_help(plugin_name)
elif bind[1] == "plugin":
-
- # TODO find a good way to disable plugins (completely OR just suspended)
+ # suspends the plugin: its thread keeps running, Worker.callback() just skips parse_body()
if bind[2] == "activate":
- pass
+ return self.set_plugin_state(msg, bind[3], enabled=True)
elif bind[2] == "deactivate":
- pass
+ return self.set_plugin_state(msg, bind[3], enabled=False)
ALL = [Plugins]
--- /dev/null
+# -*- coding: utf-8 -*-
+from distbot.common.config import Config
+import os
+
+import pytest
+
+
+def test_config_read_write():
+ path = "plugins.dsa-watcher.interval"
+ c = Config()
+ original = c.get(path)
+ try:
+ c.set(path, original + 1)
+ assert c.get(path) == original + 1
+ finally:
+ c.set(path, original)
+
+
+def test_plugin_enabled_defaults_to_true_for_unknown_plugin():
+ from distbot.common.config import is_plugin_enabled
+ assert is_plugin_enabled("NoSuchPluginEver") is True
+
+
+def test_plugin_enabled_round_trip():
+ from distbot.common.config import is_plugin_enabled, set_plugin_enabled, plugin_enabled_key
+ name = "TestPluginRoundTrip"
+ c = Config()
+ try:
+ set_plugin_enabled(name, False)
+ # a fresh Config() re-reads the ini, so this is what another thread/process sees
+ assert is_plugin_enabled(name) is False
+ assert Config().get(plugin_enabled_key(name)) is False
+ set_plugin_enabled(name, True)
+ assert is_plugin_enabled(name) is True
+ finally:
+ section = c.runtime_config_store["plugins"]
+ section.pop(name.lower(), None)
+ c.runtime_config_store.write()
+
+
+@pytest.fixture()
+def tmp_config(tmp_path, monkeypatch):
+ """Point Config at private ini copies so cache tests can rewrite them freely."""
+ import distbot.common.config as cfg_module
+ local = tmp_path / "local_config.ini"
+ persistent = tmp_path / "persistent_config.ini"
+ local.write_text("jid = bot@example.org\npassword = hunter2\nbot_nickname = testbot\n")
+ persistent.write_text("request_counter = 1\n")
+ monkeypatch.setattr(cfg_module, "LOCAL_CONFIG_FILE", str(local))
+ monkeypatch.setattr(cfg_module, "PERSISTENT_CONFIG_FILE", str(persistent))
+ cfg_module._invalidate_cache()
+ yield local, persistent
+ cfg_module._invalidate_cache()
+
+
+def test_config_is_cached_between_instances(tmp_config):
+ import distbot.common.config as cfg_module
+ loads = []
+ real_load = cfg_module._load_stores
+ cfg_module._load_stores = lambda: loads.append(1) or real_load()
+ try:
+ assert Config().get("bot_nickname") == "testbot"
+ assert Config().get("request_counter") == 1
+ assert len(loads) == 1
+ finally:
+ cfg_module._load_stores = real_load
+
+
+def test_config_reloads_when_file_changes_on_disk(tmp_config):
+ local, persistent = tmp_config
+ assert Config().get("request_counter") == 1
+
+ # simulate another process writing the file, with an mtime guaranteed to differ
+ persistent.write_text("request_counter = 7\n")
+ st = os.stat(persistent)
+ os.utime(persistent, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000))
+
+ assert Config().get("request_counter") == 7
+
+
+def test_config_set_is_visible_to_next_instance(tmp_config):
+ Config().set("request_counter", 5)
+ assert Config().get("request_counter") == 5
+
+
+def test_get_does_not_leak_local_config_into_persistent_file(tmp_config):
+ local, persistent = tmp_config
+ c = Config()
+ assert c.get("password") == "hunter2"
+ c.set("request_counter", 2)
+
+ written = persistent.read_text()
+ assert "hunter2" not in written
+ assert "jid" not in written
+ assert "request_counter = 2" in written
+
+
+def test_local_config_takes_precedence_over_persistent(tmp_config):
+ local, persistent = tmp_config
+ persistent.write_text("request_counter = 1\nbot_nickname = wrong\n")
+ import distbot.common.config as cfg_module
+ cfg_module._invalidate_cache()
+ assert Config().get("bot_nickname") == "testbot"
--- /dev/null
+# -*- coding: utf-8 -*-
+import pytest
+
+import distbot.plugins.plugin_help as plugin_help
+from distbot.common.action import Action
+from distbot.plugins.plugin_help import Plugins
+
+ADMIN = "room@conf/Admin"
+USER = "room@conf/Someone"
+
+
+@pytest.fixture()
+def state(monkeypatch):
+ """In-memory replacement for the persistent config plumbing."""
+ store = {}
+ monkeypatch.setattr(plugin_help, "conf_get", lambda key: ["Admin"] if key == "admins" else None)
+ monkeypatch.setattr(plugin_help, "is_plugin_enabled", lambda name: store.get(name.lower(), True))
+ monkeypatch.setattr(plugin_help, "set_plugin_enabled", lambda name, enabled: store.__setitem__(name.lower(), enabled))
+ return store
+
+
+@pytest.fixture()
+def helper(state):
+ p = Plugins("_")
+ p.plugin_storage = {
+ "dice": {"name": "Dice", "bindings": ["nick.dice.#", "nick.würfle.#"], "description": "", "usage": ""},
+ "plugins": {"name": "Plugins", "bindings": ["nick.help", "nick.plugin.activate.*"], "description": "", "usage": ""},
+ }
+ return p
+
+
+def _run(p, words, sender=ADMIN):
+ p.used_routing_key = words
+ return p.parse_body({"from": sender, "to": "room@conf", "body": " ".join(words)})
+
+
+def test_deactivate_by_class_name(helper, state):
+ assert _run(helper, ["nick", "plugin", "deactivate", "dice"]) == Action(msg="dice deactivated")
+ assert state == {"dice": False}
+
+
+def test_deactivate_by_command_word(helper, state):
+ assert _run(helper, ["nick", "plugin", "deactivate", "würfle"]) == Action(msg="dice deactivated")
+ assert state == {"dice": False}
+
+
+def test_activate_again(helper, state):
+ state["dice"] = False
+ assert _run(helper, ["nick", "plugin", "activate", "dice"]) == Action(msg="dice activated")
+ assert state == {"dice": True}
+
+
+def test_already_in_requested_state(helper, state):
+ assert _run(helper, ["nick", "plugin", "activate", "dice"]) == Action(msg="dice is already active")
+ assert state == {}
+
+
+def test_unknown_plugin(helper, state):
+ assert _run(helper, ["nick", "plugin", "deactivate", "nope"]) == Action(msg="unknown plugin nope, try help")
+ assert state == {}
+
+
+def test_non_admin_is_rejected(helper, state):
+ assert _run(helper, ["nick", "plugin", "deactivate", "dice"], sender=USER) == \
+ Action(msg="Someone: you are not allowed to do that")
+ assert state == {}
+
+
+def test_cannot_deactivate_the_toggle_plugin_itself(helper, state):
+ result = _run(helper, ["nick", "plugin", "deactivate", "plugins"])
+ assert result == Action(msg="nice try, but then nobody could reactivate anything")
+ assert state == {}
w.channel.basic_publish.assert_not_called()
ch.basic_ack.assert_called_once_with(delivery_tag=42)
+
+
+@pytest.fixture()
+def disabled(monkeypatch):
+ monkeypatch.setattr(worker_module, 'is_plugin_enabled', lambda name: False)
+
+
+def test_callback_skips_parse_body_when_disabled(w, send_action, disabled):
+ w.parse_body = Mock(return_value=Action(msg='hello'))
+ ch, method, properties, body = _make_pika_args()
+
+ w.callback(ch, method, properties, body)
+
+ w.parse_body.assert_not_called()
+ send_action.assert_not_called()
+ ch.basic_ack.assert_called_once_with(delivery_tag=42)
+
+
+def test_callback_runs_parse_body_when_enabled(w, send_action, monkeypatch):
+ seen = []
+ monkeypatch.setattr(worker_module, 'is_plugin_enabled', lambda name: seen.append(name) or True)
+ w.returned_action = Action(msg='hello')
+ ch, method, properties, body = _make_pika_args()
+
+ w.callback(ch, method, properties, body)
+
+ assert seen == ['_TestWorker']
+ send_action.assert_called_once()
+
+
+def test_callback_keeps_recording_history_while_disabled(send_action, disabled):
+ class _HistoryWorker(_TestWorker):
+ uses_history = True
+
+ w = _HistoryWorker(actionqueue='test_actions')
+ ch, method, properties, body = _make_pika_args(body_to='room@conf', body_text='remember me')
+
+ w.callback(ch, method, properties, body)
+
+ assert [m['body'] for m in w.total_history['room@conf']] == ['remember me']