Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
cd7e0c1
docs: add configuration schema documentation
onerandomusername Oct 6, 2025
f65926f
feat: add first draft of configuration metadata
onerandomusername Oct 7, 2025
9d99be4
rework configuration embed
onerandomusername Oct 7, 2025
f31665a
overhaul metadata for configuration
onerandomusername Oct 7, 2025
906dc42
refactor: configuration schema should live at configuration/schema
onerandomusername Oct 7, 2025
5b16f37
refactor: config schema into multiple files
onerandomusername Oct 7, 2025
f7d2575
fix: regen autodoc
onerandomusername Oct 7, 2025
6bbecb8
Merge branch 'main' into qt/revamped-config
onerandomusername Oct 8, 2025
a5ac15c
Merge branch 'main' into qt/revamped-config
onerandomusername Oct 8, 2025
c0e0f99
feat: add modal-by-default for configuring options
onerandomusername Oct 8, 2025
9346b86
Merge branch 'main' into qt/revamped-config
onerandomusername Oct 8, 2025
5fd56b1
Merge branch 'main' into qt/revamped-config
onerandomusername Oct 8, 2025
1476ec8
feat: add modal listener for processing modal set commands
onerandomusername Oct 8, 2025
b69a11e
add final bit of config handling
onerandomusername Oct 8, 2025
4f303ab
fix: filter by features
onerandomusername Oct 8, 2025
bac95db
feat: add non-modalable component support
onerandomusername Oct 8, 2025
f89594c
fix pyright
onerandomusername Oct 8, 2025
3df39cd
Merge branch 'main' into qt/revamped-config
onerandomusername Oct 8, 2025
1f4ede5
fix: add support for app-only commands with no bot application
onerandomusername Oct 9, 2025
64da2c8
Merge branch 'main' into qt/revamped-config
onerandomusername Oct 9, 2025
3c581fe
Merge branch 'main' into qt/revamped-config
onerandomusername Oct 11, 2025
0c0235c
fix: remove unused pydantic
onerandomusername Oct 12, 2025
72b50b1
fix: use returned result from merged select
onerandomusername Oct 12, 2025
143f1e9
partial refactor of large methods within guild_config
onerandomusername Oct 12, 2025
e61aa0c
Merge branch 'main' into qt/revamped-config
onerandomusername Oct 12, 2025
d968e7a
Merge branch 'main' into qt/revamped-config
onerandomusername Oct 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 16 additions & 34 deletions docs/commands/app-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,40 +170,6 @@ RGB Format.

**Installable as:** `Guild`, `User`

## Config Manager

Configuration management for each guild.

### `config edit`

- `option` (`string`) (required) The configuration option to change.

[BETA] Edit the specified config option to the provided value.

**Usable in:** `Guilds`

**Installable as:** `Guild`

### `config reset`

- `option` (`string`) (required) The configuration option to act on.

[BETA] Reset the config for a config option to the default.

**Usable in:** `Guilds`

**Installable as:** `Guild`

### `config view`

- `option` (`string`) (required) The configuration option to act on.

[BETA] View the current config for a config option.

**Usable in:** `Guilds`

**Installable as:** `Guild`

## Discord

Useful discord api commands.
Expand Down Expand Up @@ -291,6 +257,22 @@ View information about an issue, pull, discussion, or comment on GitHub.

**Installable as:** `Guild`

## Guild Config

Configuration management for each guild.

### `config`

- `category` (`string`) Choose a configuration category to view the options in
that category. Choices: `⚙️ General bot configuration` (`General`),
`🐙 GitHub Configuration` (`GitHub`)

[BETA] Manage per-guild configuration for Monty.

**Usable in:** `Guilds`

**Installable as:** `Guild`

## Meta

Get meta information about the bot.
Expand Down
113 changes: 113 additions & 0 deletions docs/contributing/design/config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# The Configuration System

> [!CAUTION]
> This file describes the implementation for a module that may or may not exist.
> It is akin to an RFC.

End-user configuration is a complex topic, especially when the interface is
through Discord. While one can make a webpage for this, it is beyond the scope
of Monty, to have a web dashboard.

There are multiple sources for configuration, not necessarily in any specific
order.

- Default values
- Local environment configuration
- Guild configuration
- User configuration (not yet implemented)

There is also the matter of mixing in the Features system, which adds another
layer of complexity.

## Requirements

### Configuration Sources

> [!CAUTION]
> User configuration is not yet implemented.

- **Default Values**: These are the built-in defaults that Monty uses if no
other configuration is provided.
- **Local Environment Configuration**: This includes settings defined in
environment variables or local config files. These don't really override any
of the default values, but they may affect those settings and those options.
For example, GitHub settings and configuration won't be enabled if the token
is not set for GitHub.
- **Guild Configuration**: Guild specific configuration. These can override
default values and provide defaults for a guild and for user commands within
that guild.
- **User Configuration**: User specific configuration. These can override guild
configuration and default values for a specific user.

The last two have five options for each boolean setting: `always`, `never`,
`default`, `true`, and `false`. In the event that both guild and user
configuration are present, the user configuration takes precedence, with some
exceptions. This is best illustrated in the table below:

| Guild | User | Result |
| ------- | ------- | ----------- |
| default | default | bot default |
| always | never | never |
| always | true | always |
| always | false | always |
| true | never | never |
| false | never | never |
| false | true | true |

Always means the guild will always win, except if the user chose always or
never.

A better way to look at this is that "always" is a "force true", and "never" is
a "force false".

### Feature Integration

Monty has a feature system that allows for enabling or disabling specific
features by the bot owner. This adds another layer of complexity to the
configuration system.

Each feature can be enabled or disabled at the guild or user level, much like
the above. The important aspect about the feature system is that it can lock-out
configuration values and disable commands, options, and even entire extensions.
This is important for stability and ensuring that buggy or experimental features
do not affect the entire bot, while still being available for testing in
production.

### UI Options

The UI of the configuration is a major pain point for this system. The goal is
to have a user-friendly interface while still being entirely in Discord, and
being usable if the slash command system is turned off for whatever reason.

One possible way to implement this is with an app command that provides a slash
command interface, and a field for the configuration option.

However, if a user wants to change multiple options, this would require multiple
commands, which is not ideal. Another option is to use a modal, but this has the
downside of being limited in the number of fields that can be displayed. That
said, as some configuration options are free-response, a modal may be the only
option for those, if we don't want to lock ourselves to the rigid structure of
slash commands.

We are also able to make a sub command for each slash option, but that limits us
to only 25 configuration options.

Another concept idea is a slash command for each configuration section, which
then provides a list of options that can be changed. This would allow for a more
organized interface, but would still require multiple commands to change
multiple options. This can be used, for example, to open a modal with several
selects and a free response for updating a group of values in one go.

Another option, and perhaps the most user-friendly, is to use a message and
interaction based interface. An entry point with both a prefix and slash command
could be implemented to allow users to easily access the configuration options.
This launches a message with both buttons and selects in order to navigate
through the configuration option. Much like the features interface for admins,
this system would allow for easy navigation and configuration of options.

With a button to go-back, and buttons to access each specific section, this
would allow us 8 sections per page, before pagination is required.

## Implementation

See the source code in monty/configuration/schema.py
File renamed without changes.
4 changes: 3 additions & 1 deletion mkdocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ nav:
- Prefix Commands: commands/prefix-commands.md
- Contributing:
- Getting Started: contributing/README.md
- Design: contributing/design.md
- Design:
- contributing/design/index.md
- Configuration System: contributing/design/config.md
- Developer Commands: contributing/developer-commands.md
- Privacy: privacy.md
- Terms of Service: terms.md
Expand Down
11 changes: 6 additions & 5 deletions monty/bot.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import asyncio
import collections
import dataclasses
from typing import Any
from weakref import WeakValueDictionary

Expand Down Expand Up @@ -169,10 +168,10 @@ async def _create_features(self) -> None:
stmt = sa.select(Feature).options(selectinload(Feature.rollout))
result = await session.scalars(stmt)
existing_feature_names = {feature.name for feature in result.all()}
for feature_name in dataclasses.asdict(constants.Feature()).values():
if feature_name in existing_feature_names:
for feature_enum in constants.Feature:
if feature_enum.value in existing_feature_names:
continue
feature_instance = Feature(feature_name)
feature_instance = Feature(feature_enum.value)
session.add(feature_instance)
await session.commit() # this will error out if it cannot be made

Expand All @@ -193,7 +192,7 @@ async def refresh_features(self) -> None:
async def guild_has_feature(
self,
guild: int | disnake.abc.Snowflake | None,
feature: str,
feature: constants.Feature | str,
*,
include_feature_status: bool = True,
create_if_not_exists: bool = True,
Expand All @@ -204,6 +203,8 @@ async def guild_has_feature(
By default, this considers the feature's enabled status,
which can be disabled with `include_feature_status` set to False.
"""
if isinstance(feature, constants.Feature):
feature = feature.value
# first create the feature if we are told to create it
if feature in self.features:
feature_instance = self.features[feature]
Expand Down
21 changes: 21 additions & 0 deletions monty/config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from monty.config._validate_metadata import _check_config_metadata
from monty.config.components import get_category_choices
from monty.config.metadata import CATEGORY_TO_ATTR, GROUP_TO_ATTR, METADATA
from monty.config.models import Category, ConfigAttrMetadata, FreeResponseMetadata, SelectGroup, SelectOptionMetadata


__all__ = (
"METADATA",
"GROUP_TO_ATTR",
"CATEGORY_TO_ATTR",
"Category",
"ConfigAttrMetadata",
"SelectGroup",
"FreeResponseMetadata",
"SelectOptionMetadata",
"get_category_choices",
)

_check_config_metadata(METADATA)

del _check_config_metadata
22 changes: 22 additions & 0 deletions monty/config/_validate_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from monty import constants
from monty.config.models import Category, ConfigAttrMetadata, SelectOptionMetadata


__all__ = ()


def _check_config_metadata(metadata: dict[str, ConfigAttrMetadata]) -> None:
for m in metadata.values():
assert 0 < len(m.description) < 100
assert m.modal or m.select_option
if m.select_option:
assert isinstance(m.select_option, SelectOptionMetadata)
assert m.type is bool
if m.modal:
assert m.description and len(m.description) <= 45
if m.depends_on_features:
for feature in m.depends_on_features:
assert feature in constants.Feature
for c in Category:
if not any(c in m.categories for m in metadata.values()):
raise ValueError(f"Category {c} has no associated config attributes")
36 changes: 36 additions & 0 deletions monty/config/components.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from typing import cast

import disnake

from monty.config.models import Category, CategoryMetadata


__all__ = ("get_category_choices",)


def get_category_choices() -> list[disnake.OptionChoice]:
"""Get a list of category choices for use in slash command options."""
options = []
for cat in Category:
metadata: CategoryMetadata = cat.value
default_name = (
metadata.autocomplete_text
if isinstance(metadata.autocomplete_text, str)
else (metadata.autocomplete_text.get("_") or metadata.name)
)
assert isinstance(default_name, str)
localised: disnake.Localized | str
if isinstance(metadata.autocomplete_text, dict):
data = metadata.autocomplete_text.copy()
data.pop("_", default_name)
data = cast("dict[disnake.Locale, str]", data)
for opt, val in data.items():
data[opt] = str(metadata.emoji) + " " + val
localised = disnake.Localized(
string=default_name,
data=data,
)
else:
localised = str(metadata.emoji) + " " + default_name
options.append(disnake.OptionChoice(name=localised, value=cat.name))
return options
Loading