Skip to content

feat(plugin): support per-session plugin config overrides - #10612

Closed
mjy1113451 wants to merge 1 commit into
AstrBotDevs:masterfrom
mjy1113451:feat/plugin-config-overrides
Closed

mjy1113451 wants to merge 1 commit into
AstrBotDevs:masterfrom
mjy1113451:feat/plugin-config-overrides

Conversation

@mjy1113451

@mjy1113451 mjy1113451 commented Oct 18, 2026 •

Copy link
Copy Markdown

Motivation

Closes #10606 (phase 1 of the design discussed in the issue).

Plugins are still loaded once with a single global AstrBotConfig shared by every group and private chat, so two groups cannot use different plugin parameters today. This PR adds the sparse override layer described in the issue without changing the plugin singleton model or the behavior of existing plugins.

What changed

  • astrbot/core/star/plugin_config_overrides.py (new)
    • <plugin>_config.overrides.json next to the plugin config stores scopes: {UMO pattern: partial config}.
    • load_plugin_config_overrides() ignores missing/malformed files with a warning, so a broken override file never blocks plugin loading.
    • save_plugin_config_overrides() writes atomically (tmp file + os.replace, module lock).
    • resolve_plugin_config() picks the first matching scope in file order (same UMO matching semantics as UmopConfigRouter), recursively merges nested objects, replaces lists/scalars, and returns a deep copy so the global config is never mutated.
  • astrbot/core/umop_config_router.py
    • Extracted split_umo() / is_umo_pattern_match() as module-level helpers; UmopConfigRouter delegates to them so both the core config router and the plugin override layer share one matching implementation.
  • astrbot/core/star/star.py / star_manager.py
    • StarMetadata.config_overrides is loaded once per plugin at load/reload time (no per-message disk read).
  • astrbot/core/star/context.py
    • New Context.get_plugin_config(plugin_name, umo=None) -> dict returns the merged snapshot; umo=None returns the global config copy. Unknown plugin raises ValueError.
  • Docs (docs/zh, docs/en): documented the file format, matching rules, merge semantics and the plugin-facing API.

Example

{
  "version": 1,
  "scopes": {
    "aiocqhttp:group:123456": { "reply_style": "concise" },
    "aiocqhttp:group:*": { "enable_tts": false }
  }
}
@filter.command("style")
async def show_style(self, event: AstrMessageEvent):
    conf = self.context.get_plugin_config("astrbot_plugin_demo", event.unified_msg_origin)
    yield event.plain_result(conf.get("reply_style", "default"))

Verification

  • python -m py_compile on all touched Python files.
  • ruff format --check / ruff check clean.
  • tests/test_plugin_config_overrides.py covers path derivation, missing/malformed files, invalid scope entries, save/load round-trip, exact/wildcard/empty-segment matching, first-match precedence, recursive merge without mutating the global config, and Context.get_plugin_config().

Scope / follow-ups

This is the phase-1 core API from the issue discussion. Not included yet:

  • Dashboard scope selector and "restore inheritance" action (overrides are edited in the JSON file for now).
  • Schema validation of the merged result on save (will live in the dashboard service when the editor is added; the loader currently only validates structure).
  • Changing the override layer does not reload the plugin singleton; only the global config save path still reloads it, so initialization-time reads of self.config keep their current semantics.

Summary by Sourcery

Enable plugins to read isolated per-session configuration snapshots without changing their singleton configuration model.

New Features:

  • Add optional per-session plugin configuration overrides selected by UMO patterns and exposed through the plugin context API.

Enhancements:

  • Share UMO pattern matching between core configuration routing and plugin overrides while preserving the existing global plugin configuration behavior.

Documentation:

  • Document override file format, matching and merge rules, and plugin usage in English and Chinese configuration guides.

Tests:

  • Add coverage for override loading, persistence, matching, merging, and context access.

Add a sparse override layer on top of each plugin's global config. Overrides live in <plugin>_config.overrides.json and map UMO patterns to partial configs; Context.get_plugin_config() merges the first matching scope and returns a snapshot without mutating the plugin singleton.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 5 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="astrbot/core/star/plugin_config_overrides.py" line_range="90-91" />
<code_context>
+    if not file_path.is_file():
+        return {}
+    try:
+        data = json.loads(file_path.read_text(encoding="utf-8-sig"))
+    except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
+        logger.warning(
+            f"Failed to load plugin config overrides from {file_path}: {exc}"
</code_context>
<issue_to_address>
**Deep JSON prevents startup**

When the override file contains JSON nested deeply enough to exceed the decoder’s recursion limit, `json.loads()` raises `RecursionError`, which `load_plugin_config_overrides()` does not catch, so the error escapes plugin loading and prevents the plugin from starting.

Catch `RecursionError` when decoding the override file, log the failure, and return an empty mapping.
</issue_to_address>

### Comment 2
<location path="astrbot/core/star/context.py" line_range="668-676" />
<code_context>
+        Raises:
+            ValueError: 当插件不存在时抛出。
+        """
+        for metadata in star_registry:
+            if metadata.name != plugin_name:
+                continue
+            return resolve_plugin_config(
+                dict(metadata.config or {}),
+                metadata.config_overrides,
+                umo,
+            )
+        raise ValueError(f"Plugin {plugin_name} not found")
+
     async def send_message(
</code_context>
<issue_to_address>
**Duplicate names return wrong config**

When two installed plugins have the same metadata name but distinct plugin identities, `get_plugin_config()` returns the first `StarMetadata` whose `name` matches, even when another installed plugin has that name; callers for the later plugin receive the first plugin’s global config and overrides.

Make lookup unambiguous, for example by accepting plugin ID or the caller’s metadata identity and resolving by that key.
</issue_to_address>

### Comment 3
<location path="astrbot/core/star/plugin_config_overrides.py" line_range="107-114" />
<code_context>
+        return {}
+
+    scopes: dict[str, dict] = {}
+    for pattern, override in raw_scopes.items():
+        if not isinstance(pattern, str) or not isinstance(override, dict):
+            logger.warning(
+                f"Ignoring invalid scope {pattern!r} in plugin config overrides "
+                f"{file_path}: scope keys must be strings and values must be objects"
+            )
+            continue
+        scopes[pattern] = override
+    return scopes
+
</code_context>
<issue_to_address>
**Malformed scopes never match**

When a scope key is a string but is not a valid three-part UMO pattern, `load_plugin_config_overrides()` and `save_plugin_config_overrides()` accept the malformed key, but it never matches in `is_umo_pattern_match`; `get_plugin_config()` therefore returns global values without indicating that the override was ignored.

Validate UMO syntax when loading and saving scopes, and warn while skipping invalid keys.

Also at `astrbot/core/star/plugin_config_overrides.py:132-134`.
</issue_to_address>

### Comment 4
<location path="astrbot/core/star/plugin_config_overrides.py" line_range="99" />
<code_context>
+
+    raw_scopes = data.get(SCOPES_KEY) if isinstance(data, dict) else None
+    if not isinstance(raw_scopes, dict):
+        if isinstance(data, dict) and data:
+            logger.warning(
+                f"Ignoring plugin config overrides in {file_path}: missing "
</code_context>
<issue_to_address>
**Malformed files go unnoticed**

When the override file contains valid JSON with a non-object root, or an empty object without a `scopes` object, `load_plugin_config_overrides()` returns an empty mapping without warning, so the plugin silently uses global values and the operator gets no indication that overrides were ignored.

Log a warning for these malformed structures before returning an empty mapping.

Also at `astrbot/core/star/plugin_config_overrides.py:108`.
</issue_to_address>

### Comment 5
<location path="tests/test_plugin_config_overrides.py" line_range="100" />
<code_context>
+    scopes = {"aiocqhttp:group:1": {"enable": False}}
+    base = {"enable": True, "nested": {"a": 1}}
+
+    assert resolve_plugin_config(base, scopes, None) == base
+
+
</code_context>
<issue_to_address>
**Global config alias goes undetected**

When `resolve_plugin_config` is changed to return the original `base` when `umo` is `None`, `test_global_config_is_returned_without_umo` checks only value equality, so returning `base` itself passes; callers can then mutate the plugin’s global config through the supposed snapshot.

Assert that the result is not `base` and that mutating a nested value in it leaves `base` unchanged.
</issue_to_address>

Sourcery assessment

Approval pending. 5 findings to address first.

Blocking findings: astrbot/core/star/plugin_config_overrides.py:91, astrbot/core/star/context.py:676, astrbot/core/star/plugin_config_overrides.py:114, astrbot/core/star/plugin_config_overrides.py:99, tests/test_plugin_config_overrides.py:100


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment on lines +90 to +91
data = json.loads(file_path.read_text(encoding="utf-8-sig"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Deep JSON prevents startup

When the override file contains JSON nested deeply enough to exceed the decoder’s recursion limit, json.loads() raises RecursionError, which load_plugin_config_overrides() does not catch, so the error escapes plugin loading and prevents the plugin from starting.

Catch RecursionError when decoding the override file, log the failure, and return an empty mapping.

Prompt for AI agents
In `astrbot/core/star/plugin_config_overrides.py` at lines 90-91:

**Deep JSON prevents startup**

When the override file contains JSON nested deeply enough to exceed the decoder’s recursion limit, `json.loads()` raises `RecursionError`, which `load_plugin_config_overrides()` does not catch, so the error escapes plugin loading and prevents the plugin from starting.

Catch `RecursionError` when decoding the override file, log the failure, and return an empty mapping.

Comment on lines +668 to +676
for metadata in star_registry:
if metadata.name != plugin_name:
continue
return resolve_plugin_config(
dict(metadata.config or {}),
metadata.config_overrides,
umo,
)
raise ValueError(f"Plugin {plugin_name} not found")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Duplicate names return wrong config

When two installed plugins have the same metadata name but distinct plugin identities, get_plugin_config() returns the first StarMetadata whose name matches, even when another installed plugin has that name; callers for the later plugin receive the first plugin’s global config and overrides.

Make lookup unambiguous, for example by accepting plugin ID or the caller’s metadata identity and resolving by that key.

Prompt for AI agents
In `astrbot/core/star/context.py` at lines 668-676:

**Duplicate names return wrong config**

When two installed plugins have the same metadata name but distinct plugin identities, `get_plugin_config()` returns the first `StarMetadata` whose `name` matches, even when another installed plugin has that name; callers for the later plugin receive the first plugin’s global config and overrides.

Make lookup unambiguous, for example by accepting plugin ID or the caller’s metadata identity and resolving by that key.

Comment on lines +107 to +114
for pattern, override in raw_scopes.items():
if not isinstance(pattern, str) or not isinstance(override, dict):
logger.warning(
f"Ignoring invalid scope {pattern!r} in plugin config overrides "
f"{file_path}: scope keys must be strings and values must be objects"
)
continue
scopes[pattern] = override

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Malformed scopes never match

When a scope key is a string but is not a valid three-part UMO pattern, load_plugin_config_overrides() and save_plugin_config_overrides() accept the malformed key, but it never matches in is_umo_pattern_match; get_plugin_config() therefore returns global values without indicating that the override was ignored.

Validate UMO syntax when loading and saving scopes, and warn while skipping invalid keys.

Also at astrbot/core/star/plugin_config_overrides.py:132-134.

Prompt for AI agents
In `astrbot/core/star/plugin_config_overrides.py` at lines 107-114:

**Malformed scopes never match**

When a scope key is a string but is not a valid three-part UMO pattern, `load_plugin_config_overrides()` and `save_plugin_config_overrides()` accept the malformed key, but it never matches in `is_umo_pattern_match`; `get_plugin_config()` therefore returns global values without indicating that the override was ignored.

Validate UMO syntax when loading and saving scopes, and warn while skipping invalid keys.

Also at `astrbot/core/star/plugin_config_overrides.py:132-134`.


raw_scopes = data.get(SCOPES_KEY) if isinstance(data, dict) else None
if not isinstance(raw_scopes, dict):
if isinstance(data, dict) and data:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Malformed files go unnoticed

When the override file contains valid JSON with a non-object root, or an empty object without a scopes object, load_plugin_config_overrides() returns an empty mapping without warning, so the plugin silently uses global values and the operator gets no indication that overrides were ignored.

Log a warning for these malformed structures before returning an empty mapping.

Also at astrbot/core/star/plugin_config_overrides.py:108.

Prompt for AI agents
In `astrbot/core/star/plugin_config_overrides.py` at line 99:

**Malformed files go unnoticed**

When the override file contains valid JSON with a non-object root, or an empty object without a `scopes` object, `load_plugin_config_overrides()` returns an empty mapping without warning, so the plugin silently uses global values and the operator gets no indication that overrides were ignored.

Log a warning for these malformed structures before returning an empty mapping.

Also at `astrbot/core/star/plugin_config_overrides.py:108`.

scopes = {"aiocqhttp:group:1": {"enable": False}}
base = {"enable": True, "nested": {"a": 1}}

assert resolve_plugin_config(base, scopes, None) == base

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Global config alias goes undetected

When resolve_plugin_config is changed to return the original base when umo is None, test_global_config_is_returned_without_umo checks only value equality, so returning base itself passes; callers can then mutate the plugin’s global config through the supposed snapshot.

Assert that the result is not base and that mutating a nested value in it leaves base unchanged.

Prompt for AI agents
In `tests/test_plugin_config_overrides.py` at line 100:

**Global config alias goes undetected**

When `resolve_plugin_config` is changed to return the original `base` when `umo` is `None`, `test_global_config_is_returned_without_umo` checks only value equality, so returning `base` itself passes; callers can then mutate the plugin’s global config through the supposed snapshot.

Assert that the result is not `base` and that mutating a nested value in it leaves `base` unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 支持插件配置按群覆盖

1 participant