Skip to content

Features and Layered Configuration ​

The same feature often needs different settings on different platforms, accounts and groups: no RSS on one QQ account, a single item per push on one Telegram account, a higher repeat threshold in one group. The Console manages the four levels of its object tree, and every object page has a Features tab (ADR 0050, ADR 0055):

LevelKeyWhere it is stored
Global—The plugin's own block in the configuration file; global on / off is disable_plugins
AdapterAdapter instance alias (a top-level key such as qq or tg-main)The project database table feature_overrides (level = 'adapter')
Account<alias>/<endpointId>, e.g. qq/bot-1Same table (level = 'endpoint')
Conversation<alias>/<endpointId>/<kind>:<conversation id>, e.g. qq/bot-1/group:123 (kind is group / private / channel)Same table (level = 'conversation')

The global default lives in the configuration file and is reviewed with the code; the adapter, account and conversation levels are runtime state changed from the Console or in chat, so they live in the project database (ADR 0057, Chinese): backing up the database backs them up. A plugin block in the configuration file no longer accepts overrides; an existing project imports them on its first start, see Upgrading: feature overrides move to the database. In keys, conversation ids escape % as %25 and / as %2F; nothing else changes.

With a MongoDB, Redis or memory database these three levels cannot be saved: feature.get / feature.conversations answer persistent: false and writes are refused (HTTP 503, code: 'feature-overrides-unpersisted'; in chat, "Not saved: feature overrides need a relational project database …"), the same as chat roles.

Where to change it ​

There are no separate "adapter" or "account configuration" pages any more: feature settings live in the Features tab of every object page in the object tree:

PlaceRouteWhat it doesRead / write permission
Global Features tab/scope?tab=featuresOverview of every feature: settable levels, the global switch, how many levels below have their own settings; the Matrix view (&view=matrix) shows each feature's switch globally and per adapter. Click a feature name for its global settingsfeature.view / global switch plugin.manage
Adapter Features tab/scope/:adapter?tab=featuresApplies to every account and conversation of the adapterfeature.view / feature.configure, feature.toggle
Account Features tab/scope/:adapter/:endpoint?tab=featuresApplies only to the account and its conversationsSame
Conversation Features tab/scope/:adapter/:endpoint/:conversation?tab=featuresApplies only to this conversation; only fields the plugin declares as conversation-level can changeSame
Feature matrix/featuresA standalone entry to the same matrix as the global Features tabfeature.view / global column plugin.manage, adapter columns feature.toggle
Global settings/features/global/:pluginThe plugin's global configuration (schema form)feature.view / feature.configure
Where it is on in plugin details/plugins/:nameA summary only: the global and per-adapter switches and how many conversations override the plugin (account switches are in each account's Features tab), linking to the matching Features tabplugin.manage

The adapter, account and conversation Features tabs work the same way:

  • One row per plugin: effective state (on / off / locked), source level, how many fields are overridden here. Filter by All, Overridden here, Off or Locked, or search.
  • Expand a row to see each field's inherited / overridden here / effective value / source. Editing a field creates an override at this level; Remove override returns it to the inherited value, and Remove all deletes every override of that plugin at this level.
  • Switches, overrides and removals go through Review change and are confirmed in an action receipt; switching off warns that no scope below this level can switch it back on.
  • A plugin switched off above shows "Locked by …" with a link to the locking level; this level cannot switch it on or change its switch.
  • Features with global settings only are not listed below the global level; find them in the global Features tab. If the Host does not support per-level settings, the tab says a newer Host is needed and keeps the settings from above.

The account Overview lists conversations with overrides and recently active ones; the conversation Overview lists the features changed for that conversation only.

Feature matrix ​

Rows are features; columns are "Global" plus one per adapter instance. Each cell is a three-state switch:

  • enabled;
  • disabled;
  • disabled by a higher level and locked — hovering shows "disabled globally", "disabled at the adapter" or "disabled at the account" with a link.

Cells with overrides are marked; accounts with conversation overrides show their count (conversationOverrides of feature.matrix). Click a feature name for its global settings, or an adapter name for its Features tab. A global switch is plugin lifecycle: the management gateway runs it as a transaction (stop the instance → write disable_plugins → start the instance; if it does not start, the configuration is restored and the previous instance started again). It needs plugin.manage, and the confirmation says the instance restarts. The data comes from feature.matrix.

Inheritance ​

Enabled: the higher level wins, one way per level.

text
effectively enabled = not in disable_plugins
                    and the adapter override's enabled is not false
                    and the account override's enabled is not false
                    and the conversation override's enabled is not false
  • A level may only disable while every level above it is enabled.
  • Once an upper level (global / adapter / account) disables the plugin, every level below it is locked: it can neither enable nor change its own switch, and shows disabled by <level>. The RPC answers HTTP 409 { success: false, code: 'locked', lockedBy, error: '已被{全局|适配器|账号}层停用,下级开关已锁定' } (the message text is stable).
  • A lower level never stores "enabled": enabling there removes its switch. A lower "disabled" stored before the upper level was disabled is kept ("Remove override" keeps it too).

Other fields: the lower level wins. Effective configuration = global → adapter override → account override → conversation override, deep-merged in order: plain objects merge key by key, arrays and scalars replace, and null means "explicitly cleared", not "inherit".

Every field shows its source badge: "inherited from global", "inherited from adapter", "overridden here". Editing an inherited field creates an override; an overridden field has "Remove override" to return to inheritance.

  • A plugin that does not declare the adapter / account levels (omitting x-zhin-config.levels at the schema root means global only) does not appear on adapter and account pages. Every official plugin (adapters aside) declares all four levels, so its switch is available on every page; fields that only make sense globally narrow themselves with levels: ["global"] (enforced by tests/contracts/plugin-feature-levels.test.ts).
  • At the conversation level only the fields listed in the plugin's x-zhin-config.conversationFields can be changed; without them the conversation level is a switch only.
  • Global-only fields (database connections, API keys) are shown read-only on lower-level forms.
  • Plugins without a configuration schema get only the three-level on / off switch, no field overrides.
  • Secret values are returned masked.

When changes apply ​

What changedHow it applies
Adapter / account / conversation overrides and switches (database)In place: no restart, no plugin reload; the next message reads the new value. Other instances sharing the database see it within 5 seconds
Global fieldsPer the field's declared x-zhin-config.effect
Global on / off (disable_plugins)Gateway enable / disable transaction: stop the instance, write, start, roll back on failure (the Console stays connected)

When a feature is disabled for an account or a conversation, the Runtime skips it at dispatch for messages in that conversation: commands do not match (and do not answer "disabled"), middleware and handlers do not run, and Agent tools are neither visible nor callable there. Capabilities that do not go through messages, such as scheduled tasks, check isEnabledFor themselves.

RPC and permissions ​

RPCPermissionPurpose
feature.listfeature.viewAll features and their supported levels
feature.matrixfeature.viewMatrix data
feature.getfeature.viewSchema, values, effective values and sources for one level; revision is the configuration file's at the global level and the stored override's own at the adapter / account / conversation levels ('0' when none exists)
feature.set-overridefeature.configureWrite an override (merge patch)
feature.remove-overridefeature.configureRemove a whole level or specific fields
feature.set-enabledfeature.toggle; global level plugin.manageGlobal writes disable_plugins (intercepted by the gateway and run as an enable / disable transaction, see below); adapter / account / conversation write the database; 409 when locked
plugin:set-enabledplugin.manage{ instanceKey, enabled }: the same gateway transaction as the global feature.set-enabled
feature.conversationsfeature.view{ plugin?, adapter?, endpoint?, query?, cursor?, limit? }: conversations with overrides (paginated) plus conversations the instance saw recently (hasOverrides: false)
chat-roles.list / grant / revokeiam.manageChat roles (below)

The global switch (feature.set-enabled at the global level and plugin:set-enabled) is plugin lifecycle, not a feature switch: the management gateway intercepts it before forwarding to the instance, authorizes it with plugin.manage (an operator holding only feature.toggle is refused; non-owners can only toggle packages in the managed catalog, project-local plugins are owner-only), runs the same transaction as gateway.plugins.enable / disable, and answers in the RPC's existing reply shape (plus transaction; restartRequired: true means the instance was restarted for it). Already in the requested state answers unchanged and leaves the instance alone. A failed or rolled-back transaction answers { success: false, code: 'transaction_failed' | 'rollback_failed', error, transaction }. After a change the event stream carries plugin:lifecycle-updated. The instance no longer writes the global switch itself: without a gateway (zhin start --standalone) it is refused (feature.set-enabled answers 409 gateway-required); use zhin enable / zhin disable <plugin> or run under zhin start.

feature.configure, feature.toggle and iam.manage (for chat-roles.* only) are checked against scope: an operator bound to adapter qq can change overrides and roles on qq, its accounts and their conversations only, not global or other adapters; a conversation key counts for its account. Adapter / account / conversation writes carry that override's revision (expectedRevision), so concurrent edits are rejected (conflict) rather than overwriting each other; for older Console builds the adapter / account levels still accept the configuration revision returned by feature.list / feature.matrix (no row check while it is current, a conflict otherwise).

In-chat self-service ​

Off by default. Turn it on in the configuration:

yaml
features:
  selfService:
    enabled: true          # registers the `feature` / `role` chat commands

Feature switches are a zhin function: platform group owners / admins do not become admin of their group automatically and need a grant (see Chat roles).

CommandPurpose
feature list [page] / 功能 列表 [页码]State, source level and lock (including enabled: false in configuration and global disables) of the plugins that apply here (with commands / middlewares / handlers; adapters, service-only plugins and plugins for other platforms are left out), plus your roles; 15 per page
feature enable|disable <plugin> [level]Level: conversation (default) / endpoint / adapter / global
feature set <plugin> <field> <value> [level]Only the plugin's selfServiceFields (widened above the conversation by chatAdminFields); the value is the rest of the text after the field, quote values with spaces or ending in a level word ("…", “…”, 「…」, …), e.g. feature set group-suite welcomeMessage "Welcome {name}"
feature reset <plugin> [level]Removes that level's override (a locked switch is kept)
feature confirm <code>Global enable / disable restarts the instance and must be confirmed within 2 minutes
  • The sender needs the chat role admin or owner at the target scope (the current conversation's ancestor at the chosen level).
  • The conversation, account and adapter levels write the database (in place); the global switch is written by the instance to disable_plugins and requests a restart (unlike the Console, not yet a gateway transaction and not rolled back on failure).
  • Conversation overrides for private chats can only be changed in the Console.
  • Every change is audited by the instance (actor chat:<adapter alias>:<user id>, with level and scope).

Chat roles ​

Who may do what in chat follows the roles of ADR 0056: owner ⊃ admin ⊃ trusted, inherited downward along conversation → account → adapter → global, held by the identity (adapter alias, user id). See Chat roles for the full model. Sources:

SourceMeaning
Grant (chat_roles table, source: 'grant')The Roles tab of the object pages, or role grant|revoke <user> <role> [level] / 角色 授予|撤销 … in chat; the only source of zhin roles

Platform standing (the sender's group owner / admin / roles / permissions, and the bot's own standing in the group) is never converted into a zhin role; it is used only by the platform:* / bot:* permit conditions.

The configuration file no longer holds people lists: the old account master / trusted and ai.trigger.masters / trusted move into chat_roles on the first start; see Upgrading to chat roles.

In the Console, the object page at every level has a Roles tab (needs iam.manage; a scoped binding on that adapter / account counts): it lists the roles effective at this level, those granted here and those inherited from above, and Grant a role (enter the platform user ID, choose Owner / Administrator / Trusted). Revoking removes only roles granted directly at this level. A global role can register one identity per platform; below the global level an identity can only belong to that scope's own adapter.

When granting in chat, the grantor needs at least the granted role at the target scope; owner can only be granted or revoked by an owner at a higher level. role list shows the roles effective in this conversation and their sources.

For plugin authors — declaring levels, conversation fields and reading effective configuration (context.configFor / context.isEnabledFor) — see definePlugin, ADR 0050 and ADR 0055.