Scope
Note
This feature requires ErisPulse 2.8.0+.
Scope answers four questions: which modules are available, whether events from certain sources should be received, what text a module processes, and what actions a module can perform externally.
Control is entirely user-driven: at the upper level of module / adapter / processor / outbound call registration (configured via ErisPulse.scope or runtime sdk.scope), declarations are made, and the event pipeline automatically reads and executes them at entry, processor filtering, and outbound gate.
| Dimension | What is controlled | Rejection behavior | Configuration path |
|---|---|---|---|
| ① Module | Which modules are available (platform / Bot / session three levels) | Filtered modules do not trigger or reply (intercept broadcast scope.blocked event; matched commands are still claimed and blocked) |
scope.platforms / bots / sessions |
| ② Identity | Whether to receive events (adapter / Bot / session / user four levels) | Completely discard at entry (intercept broadcast scope.blocked event) |
scope.identity.* |
| ③ Outbound | Which outbound calls a module can initiate (messages / API / requests, method-level white/blacklists) | Fail response (retcode=34601) |
scope.actions |
Related systems: Commands are special message event processors, with their user allow/deny lists (ACL) and implementation parameter overrides handled by the command system itself (
ErisPulse.event.command).
See Event Handling Introduction and Configuration Guide.
{!--< tips >!--}
- Import the singleton via
from ErisPulse.Core import scope(same object assdk.scope) - Check permissions:
scope.is_allowed(...)/scope.is_identity_allowed(...)/scope.is_action_allowed(...)correspond to the three gates ①②③ - Read/write: dimension-specific parameter methods (IDE can auto-complete) —
scope.set_module(...)/scope.set_identity(...)/scope.set_action(...);
Additionally, there are dictionary-style fallback methodsscope.get(path)/scope.set(path, v)/scope.delete(path) - Event handler text condition overrides are covered in
Event Handling Introduction · Event Overriding;
command ACL / parameter overrides are covered in Event Handling Introduction {!--< /tips >!--}
Matching Entry Syntax (Unified Across the System)
All "name lists" in scope (module names, identity keys, outbound entries) share the same matching syntax (ErisPulse.Core.text_match):
| Syntax | Example | Description |
|---|---|---|
| Exact name | "Chat" |
Full value comparison, case-insensitive |
| Glob | "Tool*"、"spam_*" |
* for any string / ? for single character / [seq] for character set, case-insensitive |
| Regular expression | "re:^Danger.*" |
Declared with re: prefix, matches using regex search, default case-insensitive |
- Invalid regex silently degrades to "no match" (no error thrown, no crash)
- Decorator parameters (
pattern=/regex=) have fixed semantics:patternis glob,regexis raw regex (withoutre:prefix); regex entries in scope configuration must have there:prefix
Global Fallback: default_allow
default_allow is the single global fallback switch (default true), affecting two decision dimensions:
- Module dimension: If no binding is matched →
default_allowdecides allow/deny - Identity dimension: If no policy is matched →
default_allowdecides allow/deny
Setting it to false enables "implicit deny" strict mode: whitelisting management, any not explicitly allowed is denied.
Exception: The outbound dimension is not affected by
default_allow—it is an independent tightening switch, defaulting to full allow, restricting only with explicit rules (framework-level owner-empty calls are always allowed). This strict global mode won't accidentally cut off all module message replies. Command ACL has a separateErisPulse.event.command.default_allowfallback, independent of this.
Configuration File
[ErisPulse.scope]
default_allow = true # Global fallback (false = implicit deny strict mode)
cache_size = 1024 # LRU cache size
# ── ① Module dimension (priority: session > Bot > platform) ──
[ErisPulse.scope.platforms.onebot11]
modules = ["Chat", "Tool*"] # Whitelist: exact name / glob / re: regex
blocked = ["re:^Danger"]
[ErisPulse.scope.bots.onebot11."123456"]
modules = ["Chat"]
merge = true # Append on top of platform-level binding (default is overall override)
[ErisPulse.scope.sessions.onebot11."789012345"]
modules = ["Chat"]
# ── ② Identity dimension (priority: user > session > Bot > adapter) ──
[ErisPulse.scope.identity.adapters.onebot11]
deny = true # Discard all events from this adapter
[ErisPulse.scope.identity.bots.onebot11."123456"]
deny = true
[ErisPulse.scope.identity.sessions.onebot11."g_blocked"]
deny = true
[ErisPulse.scope.identity.users.onebot11]
allow = ["u_admin"] # User keys support glob / re: regex
deny = ["u_bad", "spam_*"]
# ── ③ Outbound dimension (default all allowed, only explicitly tighten) ──
[ErisPulse.scope.actions.MyModule]
send = { deny = true } # Disable all sending
api = { allow = ["get_*"] } # Only allow standard query APIs
request = { deny = true } # Disable request handling
① Module Level
Answer the question: "In a certain context, which modules are available?" By default, all modules are open; filtering starts only after configuration is bound. No changes are required for modules or adapters.
flowchart TD
A["Event arrives at a module's handler/command"] --> B{"scope.is_allowed<br/>(platform, bot, module, session)"}
B --> C{"Resolution chain: session level > bot level > platform level<br/> (when merge = true, merge each level)"}
C -->|"Matched"| D["blocked matched → deny<br/>modules non-empty → only whitelist allowed<br/>both empty → default_allow"]
C -->|"Not matched"| E["default_allow (default true = allow)"]
D -->|"Deny"| Z["No reply<br/> (blocks broadcast `scope.blocked` event; matched commands are still claimed and blocked)"]
- Resolution priority: session level > bot level > platform level. Higher-level bindings completely override lower-level ones; when sub-level bindings write
merge = true, they instead perform item-by-item union with lower levels (mergemodules/blockedseparately;mergeitself is a control key and does not count as an item). - Default semantics: Commands and handlers of filtered modules do not trigger or reply; TRACE-level logs are visible (
core.scope.denied). Blocking also broadcasts thescope.blockedlifecycle event (subscribers can observe who is blocked and why). Matched commands are still claimed and blocked—command text is no longer passed to lower-level message handlers, eliminating the ambiguity of "double response" where a command is rejected but then the message handler responds again. - Framework-level handlers (
scope_exempt=Trueor owner is empty) are unaffected; module names that are empty (framework-level resources) are always allowed. - Session-aware help and command query: Command query APIs (
command.help/get_command/get_commands/get_group_commands/get_visible_commands, andmodule.get_commands_overview) all support optionalevent=or explicitplatform=/bot_id=/session_id=keywords—commands from modules not available in the current session no longer appear in the results (get_commandreturns None, single-command help is treated as "not registered," consistent with default semantics); if no context is provided, the behavior remains full.
Binding Inheritance (merge)
By default, the semantics of complete override are clear and predictable; when you need to add to the parent binding, set merge = true in the sub-level:
[ErisPulse.scope.platforms.onebot11]
modules = ["Chat", "Tool"] # Platform level: allow Chat, Tool
[ErisPulse.scope.bots.onebot11."123456"]
modules = ["Music"]
merge = true # The actual effective modules for this Bot = ["Chat", "Tool", "Music"]
- Merge rules:
modulesandblockedeach take the union; within a binding,blockedstill takes precedence overmodules. - Chained merging: Platform → Bot → Session levels are merged step by step, with each level independently deciding whether to merge or override.
② Identity Dimension (Event Admission)
Answers "whose events are received or not." Events rejected at the distribution entry are completely discarded—they do not enter middleware or any processor (including framework-level), visible only in TRACE-level logs (core.scope.identity_denied).
- Parse priority: user > session > Bot > adapter, taking the most specific configured policy; deny takes precedence over allow
- Each level's binding is a binary policy:
{ allow = true }or{ deny = true } - User keys support glob / regex (e.g.,
"spam_*"to block a batch of spam users) - Typical usage—上级 deny, individual allow for "exceptional allowance":
[ErisPulse.scope.identity.adapters.onebot11]
deny = true
[ErisPulse.scope.identity.users.onebot11]
allow = ["u_admin"] # Even if adapter-level is denied, events from u_admin are still allowed
③ Outbound Dimension (Limiting Module Initiated Outbound Calls)
Restricts the outbound actions initiated by a module: message sending / standard API actions / request operations. The three action types correspond to the underlying DSL: Event.reply and Send (send), Api / call_api (api), Request's accept/reject (request). Outbound calls initiated by a module during event handler execution carry the module owner, and are uniformly judged by this dimension.
Rule Forms (Inline Table)
Each action's rule is an inline table: { allow = [...], deny = true|[...] }. Only one rule per action is allowed (TOML keys cannot be repeated, choose either full deny or fine-grained):
[ErisPulse.scope.actions.MyModule]
send = { deny = true } # Disable all sending (Event.reply / Send DSL)
# Or fine-grained method-level: send = { allow = ["Text", "Image*"], deny = ["File"] }
api = { allow = ["get_*"] } # Only allow standard query APIs
# Or action-level blacklist: api = { deny = ["set_*", "leave_*"] }
request = { deny = true } # Disable request handling accept/reject
sendentries match send method names (Text/Image/File...),apientries match standard action names (get_group_info/set_group_name...)- Entries support exact names / glob /
re:regex (consistent with the unified system syntax, case-insensitive) - Writing a single string in
allowis equivalent to a single-entry list:send = { allow = "Text" }
Decision Semantics
Default is full allow—unconfigured, or owner is empty (internal framework calls) are all allowed. After configuration, the following order is used for judgment:
deny = true→ denydenylist matches the call name → denyallowlist is non-empty and the call name is not matched (or the call has no name) → deny- Otherwise allow
Denied calls do not initiate any network requests, directly returning the standard failure response (retcode = 34601, see api-response §5.3).
# Runtime API
sdk.scope.set_action("MyModule", "send", deny=True) # Disable all message sending
sdk.scope.set_action("MyModule", "send", allow=["Text"]) # Allow only text sending
sdk.scope.is_action_allowed("MyModule", "send", name="Image") # False
sdk.scope.is_action_allowed("MyModule", "api", name="get_user_info") # Judged by rules
sdk.scope.delete_action("MyModule", "send") # Restore allow
sdk.scope.get_action("MyModule", "send") # Current rule for this action
Runtime API
The runtime API for scope is layered into three parts: decision (three questions), dimensional read/write (each dimension has set/get/delete parameterized methods, fully type-annotated, IDE-completable), and dictionary-style fallback (dot-path access to any section).
from ErisPulse import sdk
scope = sdk.scope
Decision (Three Questions)
scope.is_allowed("onebot11", "123456", "Chat") # ① Module dimension
scope.is_allowed("onebot11", "123456", "Chat", "789012345") # With session-level
scope.is_allowed("onebot11", "123456", None) # Framework-level resource -> True
scope.is_identity_allowed("onebot11", "123456", "group_9", "u1") # ② Identity dimension
scope.is_action_allowed("MyModule", "send") # ④ Outbound dimension
scope.is_action_allowed("MyModule", "send", name="Image") # Fine-grained method-level
① Module Dimension
# Binding (hierarchy determined by parameters: session_id > bot_id > platform-level)
scope.set_module("onebot11", bot_id="123456", modules=["Chat", "Tool*"])
scope.set_module("onebot11", blocked=["re:^Danger"]) # Platform-level
scope.set_module("onebot11", bot_id="123456", session_id="g9", modules=["Chat"]) # Session-level
scope.set_module("onebot11", bot_id="123456", modules=["Music"], merge=True) # Union with existing entries
scope.set_module("onebot11", bot_id="123456", modules=["Chat"], persist=False) # Runtime only
# Read / Delete
scope.get_module("onebot11", bot_id="123456") # {"modules": ["Chat"], "blocked": []}
scope.delete_module("onebot11", bot_id="123456")
merge=Trueis write-time union (merges entries with existing bindings at this level);merge = trueconfiguration at parse time across levels is described in the previous section on Binding Inheritance—these are independent mechanisms.
Runtime binding (persist=False) semantics: Runtime bindings are saved in a separate overlay layer, not overwritten by any subsequent configuration writes or hot reloads of the configuration file (the configuration tree is rebuilt and automatically reapplied in the order of writes, including runtime deletions). They are not persisted, lost on process restart; when a module is unloaded, runtime bindings written by that module are cleared by fallback. Subsequent
persist=Truewrites (user-persistent semantics) to the same path will replace runtime rules.
② Identity Dimension
# Binding policies (hierarchy determined by parameters: user > session > Bot > adapter; allow / deny is binary)
scope.set_identity("onebot11", user_id="u_bad", deny=True)
scope.set_identity("onebot11", user_id="spam_*", deny=True) # Key supports glob / re: regex
scope.set_identity("onebot11", bot_id="123456", session_id="g9", allow=True)
# Read / Delete
scope.get_identity("onebot11", user_id="u_bad") # {"deny": True}
scope.delete_identity("onebot11", user_id="u_bad")
③ Outbound Dimension
# Set restriction rules (allow: str|list; deny: bool|str|list; whole rule replacement semantics)
scope.set_action("MyModule", "send", deny=True) # Disable all sending
scope.set_action("MyModule", "send", allow=["Text"]) # Allow only text sending
scope.set_action("MyModule", "api", deny=["set_*", "leave_*"]) # Disable management APIs
# Read / Delete
scope.get_action("MyModule", "send") # {"allow": ["Text"]} original rule
scope.delete_action("MyModule", "send") # Remove single action
scope.delete_action("MyModule") # Remove all action restrictions for this module
General
scope.get("platforms") # Dictionary-style fallback: dot-path read any section
scope.topology() # Full configuration tree (for Dashboard)
scope.stats()
# {"module_calls": .., "module_filtered": .., "identity_checks": .., "identity_denied": ..,
# "action_checks": .., "action_denied": .., "cache_hits": .., "cache_misses": ..}
scope.reset_stats()
scope.clear() # Clear all configurations (memory-only)
Advanced: Dictionary-style Dot-Path Fallback
Dimensional methods cover daily scenarios; when you need direct access to any node (or future added dimensions), use the dictionary-style API—get / set / delete accepts dot-path (deep dict merge, immediate read after write), and provides scope[path] / scope[path] = v / del scope[path] / path in scope protocols:
scope.set("bots.onebot11.123456", {"modules": ["Chat"], "blocked": []})
scope.set("identity.users.onebot11.u_bad", {"deny": True})
scope.get("actions.MyModule.send")
scope["platforms.onebot11"] # Read (raises KeyError if not exists)
scope["platforms.onebot11"] = {...} # Write
del scope["platforms.onebot11"] # Delete
"actions.MyModule" in scope # Existence check
Interception Observability: scope.blocked Event
When scope interception (module filtering / identity rejection) occurs, the scope.blocked lifecycle event is broadcast, allowing subscription to and statistics on "who was blocked and at which layer" and presenting this information in the Dashboard. Interception does not reply by default, but it is no longer an opaque black box.
| Field | Description |
|---|---|
dimension |
"module" (module dimension filtering) / "identity" (identity admission rejection) |
module |
The name of the filtered module (only for module dimension) |
platform / bot_id / session_id / user_id |
The source context where the interception occurred |
from ErisPulse.Core.lifecycle import lifecycle
@lifecycle.on("scope.blocked")
def on_blocked(data):
print(f"Intercepted: {data['dimension']} {data.get('module') or data.get('user_id')}")
- The event is broadcast in the background via
fire(zero overhead if there are no listeners, does not slow down hot paths) - Repeated interceptions with cache hits are not broadcast repeatedly — the same combination is only broadcast once when the cache expires
- Distinct from
adapter.event.blocked(middleware rejection): the latter is event-level discarding, while this event is module / identity filtering at the scope admission gate
Cache and Hot Reload
is_allowed/is_identity_allowed/is_action_allowedresults include LRU cache (scope.cache_sizeis adjustable), andset/delete/ configuration hot reload (config.updated/config.set) automatically invalidate- All dimension configurations take effect immediately, no restart required
- Scope is "per-event" judgment, does not cross-event state: if configuration changes, the next event follows new rules
Configuration Format Validation
Configuration format is validated per section during loading / hot reload: sections with type errors (e.g., platforms written as a string), invalid outbound rules (e.g., allow written as a number), unknown action names, or unknown top-level keys (e.g., alow with a typo) will output a WARNING and ignore the corresponding section / entry, while other valid configurations remain effective—mistakes no longer silently fail.
Frequently Asked Questions and Precautions
1. Configuration Hierarchy and Overriding
- Module Level: Session > Bot > Platform, Overall Override (when
merge = trueat the sub-level, merge each item by union). If you want "Platform allows Chat, Bot adds Music", you can setmerge = trueat the Bot level, or list both. - Identity Level: User > Session > Bot > Adapter, take the most specific configured policy (exceptions can be allowed).
- Command User Whitelist/Blacklist: Exact command name takes precedence over glob key (see
event.command.acl).
2. Module/Command Not Responding
First suspect the scope rather than the module itself:
from ErisPulse import sdk
print(sdk.scope.is_allowed(event.get_platform(), bot_id, "MyModule", session_id))
print(sdk.scope.is_identity_allowed(event.get_platform(), bot_id, session_id, user_id))
print(sdk.scope.stats()) # module_filtered / identity_denied > 0 indicates there are blocking records
Filtered requests default to no reply (at both module and identity levels, to avoid exposing rules), but will broadcast the scope.blocked lifecycle event and continuously accumulate statistics;
ACL-rejected commands will explicitly reply with "Insufficient permissions".
3. Troubleshooting Outbound Actions Rejected
from ErisPulse import sdk
print(sdk.scope.get("actions.MyModule"))
print(sdk.scope.stats()) # action_denied > 0 indicates there are blocked calls
Blocking is explicit: rejected calls return a standard failure response with retcode = 34601 (no network request is initiated).
4. Session Identifier Isolation Across Platforms
The combination of (platform, session_id) is the unique identifier. scope.sessions.onebot11."789" only applies to onebot11 and does not affect the session with 789 on telegram. The same applies to user keys at the identity level.
Topology API
ModuleManager.get_topology() and AdapterManager.get_topology() provide data on module/adapter ownership relationships. sdk.get_topology() offers a one-click aggregation (including scope scope):
from ErisPulse import sdk
topology = sdk.get_topology()
# {
# "modules": { # Module → owned resources
# "Chat": {
# "loaded": True, "enabled": True,
# "commands": ["chat", "translate"],
# "handlers": {"message": 2, "notice": 1},
# "routes": {"http": ["/Chat/api"], "ws": [], "sse": []},
# "lifecycle_hooks": 3,
# }
# },
# "adapters": { # Adapter → Bot → scope
# "onebot11": {
# "status": "started", "enabled": True,
# "bots": {"123456": {"status": "online", "scope": {...}}},
# "scope": {"modules": [...], "blocked": [...]},
# }
# },
# "scope": { # Scope (module / identity / outbound action)
# "platforms": {...}, "bots": {...}, "sessions": {...},
# "identity": {"adapters": {...}, "bots": {...}, "sessions": {...}, "users": {...}},
# "actions": {...},
# },
# }
- The module topology aggregates commands, event handlers, HTTP/WS/SSE routes, and lifecycle hooks registered by the module, which is useful for drawing a module resource tree.
- The adapter topology aggregates the status of each adapter, the status of its subordinate Bots, and platform-level/Bot-level scope bindings (at the module level).
- JSON-safe output:
get_topology(json_safe=...)isTrueby default, and the returned structure can be directlyjson.dumps—the module'sinforetains only the pure data sub-tablemeta(discarding runtime objects likemodule_class/strategy), and other nodes (including arbitrary objects inserted by adapter authors into Botinfo) are sanitized by default (class objects use__name__, non-serializable objects are converted tostr()). Dashboard/WebUI can directly serialize the returned data; for raw objects, passjson_safe=False.