Repository navigation
feat(plugin): support per-session plugin config overrides - #10612
mjy1113451 wants to merge 1 commit into
Conversation
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.
There was a problem hiding this comment.
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
| data = json.loads(file_path.read_text(encoding="utf-8-sig")) | ||
| except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: |
There was a problem hiding this comment.
🟡 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.| 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") |
There was a problem hiding this comment.
🟡 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.| 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 |
There was a problem hiding this comment.
🟡 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: |
There was a problem hiding this comment.
🟡 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 |
There was a problem hiding this comment.
🟡 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.
Motivation
Closes #10606 (phase 1 of the design discussed in the issue).
Plugins are still loaded once with a single global
AstrBotConfigshared 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.jsonnext to the plugin config storesscopes: {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 asUmopConfigRouter), recursively merges nested objects, replaces lists/scalars, and returns a deep copy so the global config is never mutated.astrbot/core/umop_config_router.pysplit_umo()/is_umo_pattern_match()as module-level helpers;UmopConfigRouterdelegates to them so both the core config router and the plugin override layer share one matching implementation.astrbot/core/star/star.py/star_manager.pyStarMetadata.config_overridesis loaded once per plugin at load/reload time (no per-message disk read).astrbot/core/star/context.pyContext.get_plugin_config(plugin_name, umo=None) -> dictreturns the merged snapshot;umo=Nonereturns the global config copy. Unknown plugin raisesValueError.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 } } }Verification
python -m py_compileon all touched Python files.ruff format --check/ruff checkclean.tests/test_plugin_config_overrides.pycovers 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, andContext.get_plugin_config().Scope / follow-ups
This is the phase-1 core API from the issue discussion. Not included yet:
self.configkeep their current semantics.Summary by Sourcery
Enable plugins to read isolated per-session configuration snapshots without changing their singleton configuration model.
New Features:
Enhancements:
Documentation:
Tests: